Merge remote-tracking branch 'origin/main' into feat/the-agent-account-is-declared
mesh/merge-gate pass: builds claude-code, claude-licence-manager, restic, sudo → ace, g14, novox, shanks; no bus step; every machine composes with the chan…
mesh/repo-check pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the change's judges the pull requests after it merges; it…
mesh/delivery superseded: a newer head of the same pull request

This commit is contained in:
jochen
2026-10-09 23:37:04 +02:00
145 changed files with 14890 additions and 1433 deletions
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"fmt"
"regexp"
"sort"
"strings"
)
// The daemons' lines (novox/hq ADR 0272, its insight of 2026-10-09): the operator types one command, `nox`
// (`mesh-cli`). The node-engine (`nox-mesh-host`) and the controller (`mesh-controller`) are daemons, and a
// person never types their names — so no text a module can show names a line of theirs. The list is the
// copy in retired-lines at the catalogue's root, which novox/hq's words.py prints (`--list lines`) and
// compares; one line each, `<daemon> <command>`, and a trailing ` …` for a command that is a line only with
// an argument after it (`mesh-controller module check` is a line; "the mesh-controller module" is prose).
// retiredLine is one daemon's line: the daemon, its command, and whether an argument must follow.
type retiredLine struct {
daemon, command string
withArgument bool
}
// readLines reads retired-lines: one line a line; blank lines and lines starting with # are not lines.
func readLines(path string) ([]retiredLine, error) {
entries, err := readList(path)
if err != nil {
return nil, err
}
var out []retiredLine
for _, e := range entries {
withArgument := strings.HasSuffix(e, " …")
e = strings.TrimSuffix(e, " …")
parts := strings.Fields(e)
if len(parts) != 2 {
return nil, fmt.Errorf("retired-lines: %q is not `<daemon> <command>`", e)
}
out = append(out, retiredLine{daemon: parts[0], command: parts[1], withArgument: withArgument})
}
return out, nil
}
// linePattern matches the line whole: the daemon, white space, the command. A command that is a line only
// with an argument is matched as a line is written — at the start of a line or a code span, a prompt allowed,
// with an argument after it — so prose that names the daemon beside the word ("the mesh-controller module is
// assigned") is not one.
func linePattern(l retiredLine) *regexp.Regexp {
daemon, command := regexp.QuoteMeta(l.daemon), regexp.QuoteMeta(l.command)
if l.withArgument {
tick := "`"
return regexp.MustCompile(`(?m)(?:^|` + tick + `)[ \t]*(?:\$[ \t]*)?(` + daemon + `\s+` + command + `\s+[^\s` + tick + `])`)
}
return regexp.MustCompile(`(?:^|[^\w-])(` + daemon + `\s+` + command + `)(?:$|[^\w-])`)
}
// checkLines finds every daemon's line in the texts.
func checkLines(lines []retiredLine, texts []text) []string {
var out []string
for _, l := range lines {
rx := linePattern(l)
for _, t := range texts {
if m := rx.FindStringSubmatchIndex(t.s); m != nil {
line := t.line
if line > 0 {
line += strings.Count(t.s[:m[2]], "\n")
}
out = append(out, fmt.Sprintf("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (novox/hq "+
"ADR 0272) — in %q", t.file, line, l.daemon, l.command, excerpt(t.s, m[2], m[3])))
}
}
}
sort.Strings(out)
return out
}
+23 -2
View File
@@ -2,7 +2,9 @@
// 0244): no word the glossary retired for the tools' descriptions — the copy in retired-words at the
// catalogue's root — in any text a module's tools can show. That text is every string literal in a module's
// own code that is not a test (a tool's description, the notes and errors it answers with) and every
// description in its manifest. Comments are not read: an agent never sees them.
// description in its manifest. Comments are not read: an agent never sees them. And no daemon's line
// (novox/hq ADR 0272, lines.go): the copy in retired-lines, the lines a person would otherwise be told to
// type of the node-engine's or the controller's binary, where the operator's command is `nox`.
//
// A word is matched whole and in any case, a space in it matching any run of white space, and its plural
// (`s` or `es` on its last part) is matched as the word. A vendor word is
@@ -97,7 +99,26 @@ func main() {
fmt.Printf("words: %d use(s) of a word the glossary retired (novox/hq ADR 0244) — say it in the glossary's word\n", len(findings))
os.Exit(1)
}
fmt.Printf("words: %d retired words, none in the %d strings of %d files\n", len(words), len(texts), files)
// And no daemon's line (novox/hq ADR 0272): what a module tells the operator to type is a `nox` line.
lines, err := readLines(filepath.Join(root, "retired-lines"))
if err != nil {
fmt.Fprintln(os.Stderr, "words:", err)
os.Exit(2)
}
if len(lines) == 0 {
fmt.Fprintln(os.Stderr, "words: retired-lines lists no line — the check would pass on anything")
os.Exit(2)
}
if findings := checkLines(lines, texts); len(findings) > 0 {
for _, f := range findings {
fmt.Println(f)
}
fmt.Printf("words: %d daemon's line(s) in what a module shows — the operator types one command, nox (novox/hq ADR 0272)\n",
len(findings))
os.Exit(1)
}
fmt.Printf("words: %d retired words and %d daemons' lines, none in the %d strings of %d files\n", len(words), len(lines),
len(texts), files)
}
// readList reads retired-words: one word or phrase a line; blank lines and lines starting with # are not words.
+64 -1
View File
@@ -1,6 +1,9 @@
package main
import "testing"
import (
"os"
"testing"
)
// A retired word's plural is the word; anything else joined on is another word.
func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
@@ -24,3 +27,63 @@ func TestPatternMatchesTheWordAndItsPlural(t *testing.T) {
}
}
}
// A daemon's line is found whole — the daemon, its command, and an argument where the command is a line only with
// one — and prose that merely names the daemon beside a word is not one (novox/hq ADR 0272).
func TestADaemonsLineIsFoundWhole(t *testing.T) {
push := retiredLine{daemon: "mesh-controller", command: "push"}
module := retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}
handOver := retiredLine{daemon: "nox-mesh-host", command: "hand-over"}
cases := []struct {
line retiredLine
text string
want bool
}{
{push, "run `mesh-controller push laptop` after", true},
{push, "mesh-controller push", true},
{push, "mesh-controller\n push", true},
{push, "mesh-controller pushed it", false},
{push, "the mesh-controller pushes", false},
{push, "mesh-controller-push", false},
{module, "`mesh-controller module check modules/*/module.json`", true},
{module, "run\n $ mesh-controller module check x", true},
{module, "the mesh-controller module is assigned", false},
{module, "mesh-controller module", false},
{module, "`mesh-controller module`", false},
{handOver, "`nox-mesh-host hand-over /srv/x` at the machine", true},
{handOver, "`nox node hand-over laptop /srv/x`", false},
{retiredLine{daemon: "mesh-host", command: "hand-over"}, "nox-mesh-host hand-over", false},
}
for _, c := range cases {
if got := linePattern(c.line).MatchString(c.text); got != c.want {
t.Errorf("%s %s on %q = %v, want %v", c.line.daemon, c.line.command, c.text, got, c.want)
}
}
found := checkLines([]retiredLine{push, module}, []text{{file: "m/a.go", line: 3, s: "say\n`mesh-controller push`"},
{file: "m/b.go", line: 1, s: "the mesh-controller module"}})
if len(found) != 1 || found[0] != "m/a.go:4: `mesh-controller push` is a daemon's line — the operator types `nox …` (novox/hq ADR 0272) — in \"say `mesh-controller push`\"" {
t.Fatalf("found %q", found)
}
}
// The list's lines read as `<daemon> <command>`, a trailing ` …` meaning an argument must follow; anything else is
// refused rather than read as some line.
func TestRetiredLinesAreReadAsDaemonAndCommand(t *testing.T) {
dir := t.TempDir()
path := dir + "/retired-lines"
if err := os.WriteFile(path, []byte("# the list\nmesh-controller push\nmesh-controller module …\n\nnox-mesh-host hand-over\n"), 0o644); err != nil {
t.Fatal(err)
}
lines, err := readLines(path)
if err != nil || len(lines) != 3 || lines[0] != (retiredLine{daemon: "mesh-controller", command: "push"}) ||
lines[1] != (retiredLine{daemon: "mesh-controller", command: "module", withArgument: true}) ||
lines[2] != (retiredLine{daemon: "nox-mesh-host", command: "hand-over"}) {
t.Fatalf("read %+v, %v", lines, err)
}
if err := os.WriteFile(path, []byte("mesh-controller\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readLines(path); err == nil {
t.Fatal("a line with no command was read")
}
}
+122
View File
@@ -0,0 +1,122 @@
# betterbird
Betterbird, the mail and calendar client of g14 and shanks (a Thunderbird fork, with the operator's
Exchange calendar through the Owl add-on), as a module (novox/hq issue 345, ADR 0208). It requires
`x11-display`, so it is assigned only where a display server is held on the same machine.
It exists because a symptom had no tools: on a workstation, a calendar reminder appeared as a
transparent window with nothing in it (2026-10-09). Nothing in the mesh could say which window that
was, how the compositor treats it, or which preferences Betterbird ran with. The diagnosis is in hq
issue 345.
## Owns
| what | where |
|---|---|
| the module's directory | `~/.config/betterbird/` |
| the module's `user.js`, filled from the setting `prefs` | `~/.config/betterbird/user.js` |
Nothing else. It holds no seat and adds no start.
- **No package.** g14 and shanks both run `betterbird-bin` 153.1.0esr-1, installed explicitly from the
AUR: `pacman` counts it as foreign. The node-engine installs packages from the official
repositories only, so the module does not declare it, and **installing and upgrading Betterbird stay
the operator's** (an AUR helper). `betterbird_status` says the version and that it is from the AUR;
`betterbird_check` says when it is missing.
- **No start.** Betterbird is started by the operator from the launcher (on the workstation measured,
it runs in the login session's scope, started by hand, with no autostart entry and no line in i3's
configuration). `betterbird_restart` starts it as the transient user unit `mesh-betterbird`, so its
output then goes to the journal.
- **The profile is Betterbird's** (ADR 0182): `~/.thunderbird/` holds the mail, the address books,
the calendars' caches, the logins and the keys. The module declares none of it.
## The user.js: how a preference is declared
Betterbird reads `user.js` in its profile when it starts, and a preference set there wins over the
one it saved in `prefs.js`. The profile's directory has a random name per machine
(`<random>.default-default`), which a manifest cannot name. So:
1. the node-engine writes `~/.config/betterbird/user.js` from the module's setting `prefs`, one line of
`user_pref("name", value);` statements (default: a comment, no preference);
2. the module's tools copy that file whole into the profile: `betterbird_user_js` with `apply`, or
`betterbird_restart`, which places it before it starts Betterbird again;
3. Betterbird reads it at that start.
**The profile is found** as the one the running Betterbird holds open; with none running, as the
installation's default in `profiles.ini` (`[Install…] Default`), else the profile marked `Default=1`.
On the workstation measured these differ: `Profile1` is marked `Default=1`, and the installation's
default, the one in use, is the other.
**A user.js the module did not write is never overwritten.** The module's begins with the line
`// Betterbird's user.js (module betterbird`; any other is the operator's, and `apply` refuses with
what to do. The tools refuse a declared file that holds anything but comments and `user_pref`
statements, or that sets a preference whose name can hold a credential.
The file is **trusted** (it carries no `"trusted": false`): a preference can weaken how the mail client
checks a server, so the setting is changed only at the controller's terminal, never through a verb
(novox/hq issue 339).
## Tools
They are served by the node's runtime as the operator account (ADR 0175). **No answer carries mail, a
calendar entry, an account, an address or a credential.**
| tool | does |
|---|---|
| `betterbird_status` (r) | whether it runs (pid, since, scope, children), the package and version and that it is from the AUR, where its output goes, the profile and how it was found, the user.js (declared, placed, in effect), the add-ons the operator installed (ids), and the preferences that decide how its windows and notifications look |
| `betterbird_prefs` (r) | named preferences or prefixes (ending in `.` or `*`, at most 200; an empty name, `*` or `.` is refused): value and source (user.js, prefs.js or the default). Withheld without its value: a name that can hold a credential (`password`, `token`, `oauth`, `signon`, …); one that names an account, server, calendar, recent folder or person (`mail.server.`, `mail.identity.`, `mail.last_msg…`, `messenger.account.`, `mail.cloud_files.`, `calendar.registry.`, …); and every `extensions.*` but the theme's id (`extensions.activeThemeID`). In every value answered, the home is `~`, and a mail address, a `user%40domain@host` URI and a host name are masked |
| `betterbird_user_js` (r; a with `apply`) | the declared user.js against the profile's; `apply` places it |
| `betterbird_log` (r) | the last lines of the journal of `mesh-betterbird` (or of the reminder test), addresses and credentials masked (the value after `password=`, `token:` and the like, and the token after `Bearer`/`Basic`). A Betterbird the module did not start prints elsewhere, and the answer says where |
| `betterbird_restart` (a) | ends Betterbird (asked to quit, forced after 8 s), places the user.js where allowed, starts it as `mesh-betterbird`. **Refused while a composer window is open** (X instance `Msgcompose`, whatever its class), because the unsent message would be lost, unless `discard_unsent` is true, which only the operator's yes justifies. It answers within the runtime's 30 s: a start not yet seen after 4 s is said in `note`, and `betterbird_status` shows it |
| `betterbird_check` (r) | installed; one running; profile found; the user.js on the machine, valid, placed, in effect; its output read |
| `betterbird_reminder_test` (a) | a reminder on demand without the operator's calendar (below) |
### A reminder on demand
`betterbird_reminder_test` starts a **second** Betterbird with `--no-remote` in a throw-away profile
under `/run/user/<uid>/mesh-betterbird-reminder-test/`, as the user unit
`mesh-betterbird-reminder-test` (ended after 10 minutes at most). The profile holds the module's
user.js and one calendar: a local `.ics` file with one event, "Mesh reminder test", whose reminder is
due `after` seconds from the call (default 40). `carry` copies named preferences of the operator's
profile into it (a theme, a colour switch), under the same limits as `betterbird_prefs`. Then:
- `xorg_windows` with class `Calendar` answers the reminder window: role `AlarmWindow`, type `Dialog`,
its depth (32: a visual with alpha, which the compositor blends), its map state;
- `xorg_screenshot` with that window shows it as the screen shows it.
The unit removes its profile when it ends, however it ends (`ExecStopPost`): at its 10 minutes, on
`stop`, or when the test window is closed. The test Betterbird is never counted as the operator's: its
command line names its own profile, and `status`, `check`, `restart` and the user.js leave it out.
The running Betterbird, its profile and its calendars are not touched: `--no-remote` keeps the two
apart. **`profiles.ini` and `installs.ini` are not rewritten either**, measured on the workstation
(2026-10-09): their content and modification time (June 2023) were the same before a `--profile` start,
while it ran, and after it ended.
## The reminder window, as measured (2026-10-09, workstation, i3 and picom with glx)
| | |
|---|---|
| X class / instance | `eu.betterbird.Betterbird` / `Calendar` |
| role, type | `AlarmWindow`, `_NET_WM_WINDOW_TYPE_DIALOG` (i3 floats it) |
| depth | 32 (ARGB visual), like every Betterbird window there |
| size | 600×300, kept in the profile's `xulstore.json` |
A window of depth 32 that its program has not drawn is transparent under a compositor, not black:
that is why the symptom looks the way it does. Why Betterbird did not draw it is hq issue 345's
question.
## What changes when it is assigned
| | g14 | shanks |
|---|---|---|
| files | `~/.config/betterbird/` and its `user.js` (no preference) | the same |
| the profile | untouched until `betterbird_user_js` with `apply` or `betterbird_restart` | the same; the profile has no user.js today, so `apply` may place one |
| Betterbird | keeps running as started | keeps running as started (since 2026-10-04, its output on the session's pipe nobody reads: `betterbird_check` names it until `betterbird_restart`) |
## Relies on
- A display server on the same machine (`x11-display`, ADR 0208 §3).
- **`xorg`** for `xorg_windows` and `xorg_screenshot`, which look at Betterbird's windows. This module
carries no window code of its own.
- `systemd-run` and `journalctl` of the account's user manager.
@@ -0,0 +1,97 @@
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
// The same in every desktop module that carries it.
package main
import (
"fmt"
"math"
"strings"
"time"
)
// text is a string argument, trimmed; required says an empty one is refused.
func text(args map[string]any, key string, required bool) (string, error) {
v, present := args[key]
if !present || v == nil {
if required {
return "", fmt.Errorf("%s is required", key)
}
return "", nil
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("%s is a string, not %T", key, v)
}
s = strings.TrimSpace(s)
if s == "" && required {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is a whole-number argument within [least, most], or def when absent.
func whole(args map[string]any, key string, def, least, most int) (int, error) {
v, present := args[key]
if !present || 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 is a number, not %T", key, v)
}
}
if f != math.Trunc(f) {
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
}
n := int(f)
if n < least || n > most {
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
}
return n, nil
}
// flag is a boolean argument, or def when absent.
func flag(args map[string]any, key string, def bool) (bool, error) {
v, present := args[key]
if !present || v == nil {
return def, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false, not %T", key, v)
}
return b, nil
}
// texts is a list-of-strings argument.
func texts(args map[string]any, key string) ([]string, error) {
v, present := args[key]
if !present || v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
if ss, isStrings := v.([]string); isStrings {
return ss, nil
}
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
}
out := make([]string, 0, len(list))
for i, item := range list {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
}
out = append(out, s)
}
return out, nil
}
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
n, err := whole(args, key, def, 1, most)
return time.Duration(n) * time.Second, err
}
@@ -0,0 +1,790 @@
package main
// Betterbird as the tools see it. Betterbird is the mail client's one process tree: the main process
// (comm betterbird-bin, no -contentproc) owns every window — the mail window, the calendar's reminder
// dialog, the composer — and its children (-contentproc) render add-ons and media. The tools read only:
// - the account's processes, and of the main process where its output goes (fd 1 and 2) and which
// profile it holds open;
// - profiles.ini, to find the profile, and the profile's prefs.js and user.js, of which only the
// preferences asked for are answered and never one that can hold a credential or names an account
// (passwordSecret, accountPrefix);
// - the profile's extensions.json, for the add-ons' ids and whether each is on;
// - the module's declared user.js (~/.config/betterbird/user.js), which the node-engine writes from
// the module's setting `prefs`.
//
// Never read: the mail store, the address books, logins.json, key4.db, cert9.db, cookies, the
// calendar's caches. Nothing of the operator's mail, calendar or accounts is answered.
//
// The one file a tool writes in the profile is user.js, and only the module's own: the declared file,
// copied whole (betterbird_user_js with apply, betterbird_restart). A user.js that is not the module's
// is never overwritten.
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
const (
mainComm = "betterbird-bin"
startBin = "/usr/bin/betterbird"
packageFor = "betterbird-bin"
appIni = "/opt/betterbird/application.ini"
restartAs = "mesh-betterbird"
testUnit = "mesh-betterbird-reminder-test"
// profilesDir is where Betterbird keeps its profiles: Thunderbird's place, which Betterbird shares.
profilesDir = ".thunderbird"
// declared is the module's user.js, written by the node-engine from the setting prefs.
declared = ".config/betterbird/user.js"
// mark is the first line of the module's user.js: how a user.js is known to be the module's.
mark = "// Betterbird's user.js (module betterbird"
)
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
var readLink = os.Readlink
// mainProcs are Betterbird's main processes: comm betterbird-bin without -contentproc.
func (m *Machine) mainProcs() []Proc {
var out []Proc
for _, p := range m.procs(mainComm) {
// The reminder test's Betterbird is never the operator's: its command line names its own profile.
if !strings.Contains(p.Command, "-contentproc") && !strings.Contains(p.Command, testUnit) {
out = append(out, p)
}
}
return out
}
// Stream is where a process's standard output or error goes.
type Stream struct {
FD int `json:"fd"`
Goes string `json:"goes"`
// Dead is a pipe no process of the account reads: a write there fails.
Dead bool `json:"dead,omitempty"`
}
// streams says where fd 1 and 2 of a process go.
func (m *Machine) streams(pid int) []Stream {
var out []Stream
for _, fd := range []int{1, 2} {
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
s := Stream{FD: fd}
switch {
case err != nil:
s.Goes = "unreadable"
case target == "/dev/null":
s.Goes = "discarded (/dev/null)"
case strings.HasPrefix(target, "socket:"):
s.Goes = "a socket (the journal, when started by the module)"
case strings.HasPrefix(target, "pipe:"):
if r := m.pipeReaders(target, pid); len(r) > 0 {
s.Goes = "a pipe read by " + strings.Join(r, ", ")
} else {
s.Goes, s.Dead = "a pipe nobody reads", true
}
default:
s.Goes = m.tilde(target)
}
out = append(out, s)
}
return out
}
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
func (m *Machine) pipeReaders(pipe string, except int) []string {
entries, _ := os.ReadDir(m.path("/proc"))
seen := map[string]bool{}
var out []string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil || pid == except {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
for _, fd := range fds {
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
continue
}
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
if !seen[name] {
seen[name] = true
out = append(out, name)
}
}
}
}
sort.Strings(out)
return out
}
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
func readOnly(fdinfo string) bool {
for _, l := range strings.Split(fdinfo, "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
n, err := strconv.ParseInt(f[1], 8, 64)
return err == nil && n&3 == 0
}
}
return false
}
// iniSections reads an INI file into its sections, in order.
func iniSections(raw []byte) (names []string, sections map[string]map[string]string) {
sections = map[string]map[string]string{}
cur := ""
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#"):
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
cur = l[1 : len(l)-1]
if _, ok := sections[cur]; !ok {
names = append(names, cur)
sections[cur] = map[string]string{}
}
default:
if i := strings.IndexByte(l, '='); i > 0 && cur != "" {
sections[cur][strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return names, sections
}
// Profile is the profile the tools act on, and how it was found.
type Profile struct {
Dir string `json:"dir"`
FoundBy string `json:"found_by"`
path string
}
// profile finds the profile: the one the running Betterbird holds open, else the installation's
// default in profiles.ini ([Install…] Default), else the profile marked Default=1.
func (m *Machine) profile() (Profile, error) {
base := m.home(profilesDir)
if ps := m.mainProcs(); len(ps) > 0 {
counts := map[string]int{}
fds, _ := os.ReadDir(m.path(fmt.Sprintf("/proc/%d/fd", ps[0].PID)))
for _, fd := range fds {
t, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%s", ps[0].PID, fd.Name())))
if err != nil {
continue
}
rel, err := filepath.Rel(filepath.Join(m.Home, profilesDir), t)
if err != nil || strings.HasPrefix(rel, "..") || !strings.Contains(rel, "/") {
continue
}
counts[strings.SplitN(rel, "/", 2)[0]]++
}
best, n := "", 0
for d, c := range counts {
if c > n || (c == n && d < best) {
best, n = d, c
}
}
if best != "" {
return Profile{Dir: m.tilde(filepath.Join(m.Home, profilesDir, best)), path: filepath.Join(base, best),
FoundBy: fmt.Sprintf("held open by the running Betterbird (pid %d, %d files)", ps[0].PID, n)}, nil
}
}
raw, err := readBounded(filepath.Join(base, "profiles.ini"))
if err != nil {
return Profile{}, fmt.Errorf("no profile: Betterbird is not running and ~/%s/profiles.ini is unreadable (%v)", profilesDir, err)
}
names, sections := iniSections(raw)
pick := func(rel string, by string) (Profile, error) {
dir := filepath.Join(base, filepath.FromSlash(rel))
if filepath.IsAbs(rel) {
dir = m.path(rel)
}
if !strings.HasPrefix(filepath.Clean(dir), filepath.Clean(base)+string(filepath.Separator)) {
return Profile{}, fmt.Errorf("profiles.ini names a profile outside ~/%s: %s", profilesDir, rel)
}
return Profile{Dir: m.tilde(strings.TrimPrefix(dir, m.Root)), path: dir, FoundBy: by}, nil
}
var installs []string
for _, n := range names {
if strings.HasPrefix(n, "Install") && sections[n]["Default"] != "" {
installs = append(installs, n)
}
}
if len(installs) == 1 {
return pick(sections[installs[0]]["Default"], "profiles.ini: the installation's default ["+installs[0]+"]")
}
for _, n := range names {
if strings.HasPrefix(n, "Profile") && sections[n]["Default"] == "1" && sections[n]["Path"] != "" {
return pick(sections[n]["Path"], "profiles.ini: the profile marked Default=1 ["+n+"]")
}
}
return Profile{}, fmt.Errorf("profiles.ini names %d installation defaults and no profile marked default: start Betterbird once, then ask again", len(installs))
}
// prefLine is one user_pref(…) or pref(…) line of prefs.js or user.js.
var prefLine = regexp.MustCompile(`^\s*(?:user_)?pref\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(.*?)\s*\)\s*;\s*$`)
// readPrefs reads the preferences of a prefs.js or user.js: name to its value, as written.
func readPrefs(raw []byte) map[string]string {
out := map[string]string{}
s := bufio.NewScanner(bytes.NewReader(raw))
s.Buffer(make([]byte, 64<<10), 4<<20)
for s.Scan() {
if mm := prefLine.FindStringSubmatch(s.Text()); mm != nil {
out[mm[1]] = mm[2]
}
}
return out
}
// passwordSecret and accountPrefix say which preferences are never answered with their value: one
// whose name can hold a credential, and one that names an account, a server or a person.
var (
passwordSecret = regexp.MustCompile(`(?i)(password|passwd|secret|token|oauth|auth|cookie|credential|signon|apikey|api_key|privatekey|private_key|\.key$)`)
accountPrefix = []string{"mail.server.", "mail.identity.", "mail.smtpserver.", "mail.smtp.", "mail.account.",
"mail.accountmanager.", "ldap_2.", "extensions.owl.", "calendar.registry.", "mail.root.", "mailnews.",
"services.sync.", "mail.displayname", "mail.default_", "mail.last_msg", "messenger.account.", "mail.cloud_files."}
// allowedExtensions are the only extensions.* preferences answered: the add-ons' own preferences can
// hold anything, and a theme's id is what a window's look depends on.
allowedExtensions = map[string]bool{"extensions.activeThemeID": true}
)
// withheld says why a preference's value is never answered, or "" when it may be.
func withheld(name string) string {
if strings.HasPrefix(name, "extensions.") && !allowedExtensions[name] {
return "an add-on's own preference: only the theme's id (extensions.activeThemeID) is answered"
}
if passwordSecret.MatchString(name) {
return "its name says it can hold a credential"
}
for _, p := range accountPrefix {
if strings.HasPrefix(name, p) {
return "it names an account, a server, a calendar or a person"
}
}
return ""
}
// Pref is one preference as the profile holds it.
type Pref struct {
Name string `json:"name"`
// Value is what Betterbird uses: user.js's at the start, else prefs.js's. Absent: the default.
Value string `json:"value,omitempty"`
From string `json:"from,omitempty"`
Withheld string `json:"withheld,omitempty"`
}
// mostPrefs bounds how many preferences one answer carries.
const mostPrefs = 200
// Prefs answers the named preferences (a name, or a prefix ending in . or *) from the profile.
func (m *Machine) Prefs(names []string) (map[string]any, error) {
if len(names) == 0 {
return nil, errors.New("names is required: preferences by name, or a prefix ending in . or *, e.g. calendar.alarms.")
}
for _, n := range names {
if strings.Trim(strings.TrimSpace(n), ".*") == "" {
return nil, fmt.Errorf("%q asks for every preference: name one, or a prefix such as calendar.alarms.", n)
}
}
p, err := m.profile()
if err != nil {
return nil, err
}
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
userJS, _ := readBounded(filepath.Join(p.path, "user.js"))
fromPrefs, fromUser := readPrefs(prefsJS), readPrefs(userJS)
all := map[string]bool{}
for n := range fromPrefs {
all[n] = true
}
for n := range fromUser {
all[n] = true
}
var out []Pref
cut := false
seen := map[string]bool{}
for _, want := range names {
prefix := strings.HasSuffix(want, ".") || strings.HasSuffix(want, "*")
want = strings.TrimSuffix(want, "*")
var matched []string
if prefix {
for n := range all {
if strings.HasPrefix(n, want) {
matched = append(matched, n)
}
}
sort.Strings(matched)
} else {
matched = []string{want}
}
for _, n := range matched {
if seen[n] {
continue
}
seen[n] = true
if len(out) == mostPrefs {
cut = true
break
}
pr := Pref{Name: n}
v, from := fromPrefs[n], "prefs.js"
if u, ok := fromUser[n]; ok {
v, from = u, "user.js (wins at the start)"
} else if _, ok := fromPrefs[n]; !ok {
from = "not set: Betterbird's default"
v = ""
}
pr.From = from
if why := withheld(n); why != "" {
pr.Withheld = why
} else if allowedExtensions[n] {
pr.Value = v
} else {
pr.Value = m.maskValue(v)
}
out = append(out, pr)
}
}
answer := map[string]any{"profile": p, "prefs": out}
if cut {
answer["cut"] = fmt.Sprintf("the first %d; ask for a narrower prefix", mostPrefs)
}
return answer, nil
}
// statement is the one thing a line of the module's user.js may be, beside a comment.
var statement = regexp.MustCompile(`^user_pref\("[A-Za-z0-9_.\-\[\]]+",\s*(true|false|-?[0-9]+|"(?:[^"\\]|\\.)*")\);$`)
// validUserJS says what is wrong with a user.js the module would place, or nil. It holds comments and
// user_pref(…) statements only — the setting is one line, so several statements may share one — and
// sets no preference whose name could hold a credential.
func validUserJS(text string) error {
if !strings.HasPrefix(text, mark) {
return fmt.Errorf("it does not begin with the module's mark (%q)", mark)
}
for i, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "//") {
continue
}
for _, st := range splitStatements(line) {
if st == "" || strings.HasPrefix(st, "//") {
continue
}
if !statement.MatchString(st) {
return fmt.Errorf("line %d holds %q, which is not user_pref(\"name\", value);", i+1, st)
}
name := st[len(`user_pref("`):strings.Index(st, `",`)]
if passwordSecret.MatchString(name) {
return fmt.Errorf("line %d sets %s, a preference that can hold a credential: not through the mesh", i+1, name)
}
}
}
return nil
}
// splitStatements cuts a line after each `);`, outside a string.
func splitStatements(line string) []string {
var out []string
in, esc, start := false, false, 0
for i := 0; i < len(line); i++ {
c := line[i]
switch {
case esc:
esc = false
case c == '\\' && in:
esc = true
case c == '"':
in = !in
case c == ';' && !in:
out = append(out, strings.TrimSpace(line[start:i+1]))
start = i + 1
}
}
if rest := strings.TrimSpace(line[start:]); rest != "" {
out = append(out, rest)
}
return out
}
// UserJS is the module's user.js against the profile's.
type UserJS struct {
Declared string `json:"declared"`
Present bool `json:"declared_present"`
Valid string `json:"declared_problem,omitempty"`
Profile string `json:"profile_user_js"`
Placed string `json:"placed"`
Statements int `json:"statements"`
// Effective says whether the running Betterbird started after the file was placed.
Effective string `json:"effective,omitempty"`
Content string `json:"content,omitempty"`
Applied bool `json:"applied,omitempty"`
}
// userJS compares the declared user.js with the profile's; apply places the declared one when the
// profile has none or the module's.
func (m *Machine) userJS(apply bool) (UserJS, error) {
a := UserJS{Declared: "~/" + declared}
want, err := readBounded(m.home(declared))
a.Present = err == nil
p, perr := m.profile()
if perr != nil {
return a, perr
}
target := filepath.Join(p.path, "user.js")
a.Profile = m.tilde(strings.TrimPrefix(target, m.Root))
if a.Present {
if verr := validUserJS(string(want)); verr != nil {
a.Valid = verr.Error()
}
a.Content = string(want)
for _, line := range strings.Split(string(want), "\n") {
for _, st := range splitStatements(strings.TrimSpace(line)) {
if statement.MatchString(st) {
a.Statements++
}
}
}
}
have, herr := readBounded(target)
switch {
case herr != nil:
a.Placed = "none: the profile has no user.js"
case a.Present && bytes.Equal(have, want):
a.Placed = "the module's, as declared"
case bytes.HasPrefix(have, []byte(mark)):
a.Placed = "the module's, from an earlier declaration"
default:
a.Placed = "not the module's: a user.js of the operator's, which the module never overwrites"
}
if apply {
switch {
case !a.Present:
return a, fmt.Errorf("the module's user.js is not on this machine (~/%s): the module is not pushed here yet", declared)
case a.Valid != "":
return a, fmt.Errorf("the module's user.js is refused: %s; change the setting prefs", a.Valid)
case herr == nil && !bytes.HasPrefix(have, []byte(mark)):
return a, fmt.Errorf("%s is not the module's: move it aside (its preferences belong in the setting prefs), then apply again", a.Profile)
}
if herr != nil || !bytes.Equal(have, want) {
tmp := target + ".mesh-new"
if err := os.WriteFile(tmp, want, 0o644); err != nil {
return a, err
}
if err := os.Rename(tmp, target); err != nil {
os.Remove(tmp)
return a, err
}
}
a.Applied, a.Placed = true, "the module's, as declared"
}
if ps := m.mainProcs(); len(ps) > 0 {
if st, err := os.Stat(target); err == nil {
if t, err := time.Parse(time.RFC3339, ps[0].Since); err == nil && st.ModTime().After(t) {
a.Effective = "not yet: Betterbird reads user.js when it starts, and it started before the file was placed (betterbird_restart)"
} else if err == nil {
a.Effective = "yes: the running Betterbird started after the file was placed"
}
}
}
return a, nil
}
// Status is what betterbird_status answers.
type Status struct {
Running []Proc `json:"running"`
Children int `json:"children"`
Installed string `json:"installed,omitempty"`
Foreign bool `json:"from_the_aur,omitempty"`
Version string `json:"version,omitempty"`
Build string `json:"build,omitempty"`
Profile *Profile `json:"profile,omitempty"`
Problem string `json:"profile_problem,omitempty"`
Output []Stream `json:"output,omitempty"`
UserJS *UserJS `json:"user_js,omitempty"`
AddOns []AddOn `json:"add_ons,omitempty"`
// Display are the preferences that decide how its windows and notifications look.
Display []Pref `json:"display_prefs,omitempty"`
}
// AddOn is one add-on the operator installed: its id, and whether it is on.
type AddOn struct {
ID string `json:"id"`
Active bool `json:"active"`
}
// displayPrefs are the preferences betterbird_status answers: the reminder dialog, the theme, the
// renderer, and whether notifications go to the desktop's notifier.
var displayPrefs = []string{"calendar.alarms.playsound", "calendar.alarms.show", "calendar.alarms.showmissed",
"calendar.alarms.indicator.show", "alerts.useSystemBackend", "mail.biff.use_system_alert",
"extensions.activeThemeID", "browser.display.use_system_colors", "ui.systemUsesDarkTheme",
"gfx.webrender.software", "gfx.webrender.all", "layers.acceleration.disabled", "widget.gtk.overlay-scrollbars.enabled",
"mozilla.widget.use-argb-visuals"}
func (m *Machine) foreign(pkg string) bool {
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
}
// Status answers whether Betterbird runs, what is installed, its profile and the module's user.js.
func (m *Machine) Status() (Status, error) {
s := Status{Running: m.mainProcs()}
if s.Running == nil {
s.Running = []Proc{}
}
s.Children = len(m.procs(mainComm)) - len(s.Running)
if v, err := m.installed(packageFor); err == nil && v != "" {
s.Installed, s.Foreign = v, m.foreign(packageFor)
}
if raw, err := readBounded(m.path(appIni)); err == nil {
_, sec := iniSections(raw)
s.Version, s.Build = sec["App"]["Version"], sec["App"]["BuildID"]
}
if len(s.Running) > 0 {
s.Output = m.streams(s.Running[0].PID)
}
p, err := m.profile()
if err != nil {
s.Problem = err.Error()
return s, nil
}
s.Profile = &p
if u, err := m.userJS(false); err == nil {
u.Content = ""
s.UserJS = &u
}
if raw, err := readBounded(filepath.Join(p.path, "extensions.json")); err == nil {
var ext struct {
Addons []struct {
ID string `json:"id"`
Active bool `json:"active"`
Location string `json:"location"`
} `json:"addons"`
}
if json.Unmarshal(raw, &ext) == nil {
for _, a := range ext.Addons {
if a.Location == "app-profile" {
s.AddOns = append(s.AddOns, AddOn{a.ID, a.Active})
}
}
}
}
if got, err := m.Prefs(displayPrefs); err == nil {
s.Display = got["prefs"].([]Pref)
}
return s, nil
}
// emailAddress and credentialWord are masked in what Betterbird printed.
var (
emailAddress = regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
credentialWord = regexp.MustCompile(`(?i)((?:password|passwd|token|secret|authorization|cookie)\S*\s*[=:]\s*)\S+`)
)
// userAtHost and hostName are what a preference's value may name a server by.
var (
userAtHost = regexp.MustCompile(`[A-Za-z0-9._%+\-]+%40[A-Za-z0-9.\-]+@[A-Za-z0-9.\-]+`)
// A host is a name of three labels or more, or of two whose last is a common top-level domain: a
// file's name (a.pdf) is not one.
hostName = regexp.MustCompile(`(?i)\b(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.){2,}[a-z]{2,}|[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:com|org|net|be|nl|de|fr|eu|io|uk|us|info|biz|dev|app|cloud|local|lan|internal|home|corp))\b`)
bearerOrBasic = regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=\-]+`)
)
func mask(s string) string {
s = userAtHost.ReplaceAllString(s, "<hidden>")
s = emailAddress.ReplaceAllString(s, "<hidden>")
s = bearerOrBasic.ReplaceAllString(s, "$1 <hidden>")
return credentialWord.ReplaceAllString(s, "${1}<hidden>")
}
// maskValue is a preference's value as answered: the home as ~, and no address, user or host.
func (m *Machine) maskValue(v string) string {
if m.Home != "" && m.Home != "/" {
v = strings.ReplaceAll(v, strings.TrimSuffix(m.Home, "/")+"/", "~/")
}
v = mask(v)
return hostName.ReplaceAllString(v, "<host>")
}
// mostAnswer bounds a log answer.
const mostAnswer = 128 << 10
// Log answers the last n lines Betterbird printed, from the journal of the unit the module starts it
// as; problems keeps the lines that say error, warning, crash or assertion. Addresses and credentials
// are masked.
func (m *Machine) Log(n int, problems bool, unit string) (map[string]any, error) {
if unit == "" {
unit = restartAs
}
if unit != restartAs && unit != testUnit {
return nil, fmt.Errorf("unit is %s or %s", restartAs, testUnit)
}
o := m.cmd(0, m.bus(), "journalctl", "--user", "--unit", unit+".service", "--no-pager", "--output", "short-iso", "-n", strconv.Itoa(n))
if err := failed(o, "journalctl"); err != nil {
return nil, err
}
var lines []string
size := 0
keep := regexp.MustCompile(`(?i)(error|warn|crash|assert|fail|exception)`)
for _, l := range strings.Split(strings.TrimRight(o.Stdout, "\n"), "\n") {
if l == "" || (problems && !keep.MatchString(l)) {
continue
}
l = mask(l)
if size+len(l) > mostAnswer {
break
}
size += len(l) + 1
lines = append(lines, l)
}
if lines == nil {
lines = []string{}
}
a := map[string]any{"unit": unit + ".service", "lines": lines}
if ps := m.mainProcs(); len(ps) > 0 && !strings.HasPrefix(ps[0].StartedIn, restartAs) && unit == restartAs {
a["note"] = fmt.Sprintf("the running Betterbird (pid %d) was not started by the module: it runs in %s, "+
"and its output goes where status says (output), not to this journal; betterbird_restart starts it as %s",
ps[0].PID, ps[0].StartedIn, restartAs)
}
return a, nil
}
// RestartAnswer is what betterbird_restart answers.
type RestartAnswer struct {
// Composers are the message windows that were open: what was unsent and is gone.
Composers int `json:"composers_discarded,omitempty"`
Note string `json:"note,omitempty"`
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
UserJS string `json:"user_js"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// composerClass is the X instance of a message being written, whatever the class beside it.
var composerClass = regexp.MustCompile(`\("Msgcompose" "[^"]*"\)`)
// composers counts the open message windows, from the X window tree.
func (m *Machine) composers(s Session) (int, error) {
o := m.cmd(3*time.Second, s.Env(), "xwininfo", "-root", "-tree")
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
return 0, err
}
return len(composerClass.FindAllString(o.Stdout, -1)), nil
}
// Restart ends Betterbird (asked to quit, forced after 10 s), places the module's user.js when it can,
// and starts Betterbird again in the operator's session under the account's service manager, its output
// in the journal. While a message is being written it refuses, unless discard says the unsent may go.
// It answers within the runtime's 30 s: a start that has not shown after 4 s is said, and
// betterbird_status sees it come.
func (m *Machine) Restart(discard bool) (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
n, err := m.composers(s)
switch {
case err != nil && !discard:
return a, fmt.Errorf("whether a message is being written cannot be told (%v): restart with discard_unsent only if none is", err)
case n > 0 && !discard:
return a, fmt.Errorf("%d composer window(s) are open: an unsent message would be lost. Send or save it, or ask the operator, then call again with discard_unsent", n)
}
a.Composers = n
a.Ended, a.Killed = m.stopProcs(8*time.Second, m.mainProcs())
if u, err := m.userJS(true); err != nil {
a.UserJS = "not placed: " + err.Error()
} else {
a.UserJS = fmt.Sprintf("placed: %d preference(s) from ~/%s", u.Statements, declared)
}
// As detach does, with shorter bounds: the worst case of the whole call (3 s for the window tree,
// 8 s to quit, 3 + 5 s to start, 4 s to see it) stays under the runtime's 30 s.
_ = m.cmd(3*time.Second, s.Env(), "systemctl", "--user", "stop", restartAs+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + restartAs}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--", startBin)
if err := failed(m.cmd(5*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
for waited := time.Duration(0); waited < 4*time.Second && len(m.mainProcs()) == 0; waited += 250 * time.Millisecond {
m.Sleep(250 * time.Millisecond)
}
a.Running = m.mainProcs()
if a.Running == nil {
a.Running = []Proc{}
}
if len(a.Running) == 0 {
a.Note = "started as " + a.Unit + "; no main process yet after 4 s: betterbird_status shows it once it runs, betterbird_log says why if it does not"
}
return a, nil
}
// CheckAnswer is what betterbird_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
}
// Check verifies what the module promises: Betterbird installed, one running in the session, its
// profile found, the module's user.js declared, valid, placed and in effect, and its output read.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("Betterbird ("+packageFor+") is not installed", "install it from the AUR: the node-engine installs packages from the official repositories only, so the module does not")
}
ps := m.mainProcs()
switch {
case len(ps) == 0:
add("Betterbird is not running", "start it from the launcher, or betterbird_restart")
case len(ps) > 1:
add(fmt.Sprintf("%d Betterbird main processes run", len(ps)), "one profile is held by one process: betterbird_restart")
}
u, err := m.userJS(false)
switch {
case err != nil:
add("no profile: "+err.Error(), "start Betterbird once")
case !u.Present:
add("the module's user.js is not on this machine (~/"+declared+")", "the module is not pushed here: the controller's push")
case u.Valid != "":
add("the module's user.js is refused: "+u.Valid, "change the module's setting prefs")
case !strings.HasPrefix(u.Placed, "the module's, as declared"):
add("the profile's user.js is "+u.Placed, "betterbird_user_js with apply, then betterbird_restart")
case strings.HasPrefix(u.Effective, "not yet"):
add("the module's user.js is placed but not in effect: "+u.Effective, "betterbird_restart")
}
if len(ps) > 0 {
for _, s := range m.streams(ps[0].PID) {
if s.Dead {
add(fmt.Sprintf("Betterbird's fd %d is a pipe nobody reads (a session started before its output went to the journal): what it prints is lost", s.FD),
"betterbird_restart, which starts it with its output in the journal")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,256 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const profileDir = testHome + "/.thunderbird/60016jsp.default-default"
// links stands in for the kernel's fd links: path → target.
func links(t *testing.T, m map[string]string) {
t.Helper()
old := readLink
readLink = func(p string) (string, error) {
for k, v := range m {
if strings.HasSuffix(p, k) {
return v, nil
}
}
return "", os.ErrNotExist
}
t.Cleanup(func() { readLink = old })
}
// betterbird is a running Betterbird with one child, its profile and its prefs.
func (f *fake) betterbird() {
f.proc(4000, 1000, "betterbird-bin", []string{"betterbird"}, "session-4.scope", "DISPLAY=:1")
f.proc(4001, 1000, "betterbird-bin", []string{"/opt/betterbird/betterbird-bin", "-contentproc", "-isForBrowser"}, "session-4.scope")
f.write("/proc/4000/fd/40", "")
f.write("/proc/4000/fd/41", "")
f.write("/proc/4000/fd/1", "")
f.write(testHome+"/.thunderbird/profiles.ini", "[Profile0]\nName=default-default\nIsRelative=1\nPath=60016jsp.default-default\n\n"+
"[Profile1]\nName=default\nIsRelative=1\nPath=au2i98w4.default\nDefault=1\n\n[InstallF72D9E66FA3C07ED]\nDefault=60016jsp.default-default\nLocked=1\n")
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
`user_pref("browser.display.use_system_colors", true);`+"\n"+
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
`user_pref("calendar.alarms.showmissed", true);`+"\n"+
`user_pref("mail.identity.id1.useremail", "someone@example.org");`+"\n"+
`user_pref("extensions.owl.token", "abc");`+"\n"+
`user_pref("signon.rememberSignons", true);`+"\n")
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
}
func (f *fake) declare(prefs string) {
f.write(testHome+"/.config/betterbird/user.js", mark+", novox/hq issue 345). Owned by the mesh.\n//\n"+prefs+"\n")
}
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/40": profileDir + "/prefs.js", "/proc/4000/fd/41": profileDir + "/places.sqlite"})
p, err := f.profile()
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "pid 4000, 2 files") {
t.Fatalf("%+v %v", p, err)
}
links(t, map[string]string{})
p, err = f.profile()
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "InstallF72D9E66FA3C07ED") {
t.Fatalf("the installation's default, not Profile1's Default=1: %+v %v", p, err)
}
}
func TestPrefsAnswerValuesButNeverACredentialOrAnAccount(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("browser.display.use_system_colors", false);`+"\n")
got, err := f.Prefs([]string{"browser.display.", "mail.identity.*", "extensions.owl.token", "signon.rememberSignons", "gfx.webrender.software"})
if err != nil {
t.Fatal(err)
}
ps := got["prefs"].([]Pref)
by := map[string]Pref{}
for _, p := range ps {
by[p.Name] = p
}
if p := by["browser.display.use_system_colors"]; p.Value != "false" || !strings.HasPrefix(p.From, "user.js") {
t.Fatalf("user.js wins: %+v", p)
}
for _, n := range []string{"mail.identity.id1.useremail", "extensions.owl.token", "signon.rememberSignons"} {
if p := by[n]; p.Value != "" || p.Withheld == "" {
t.Errorf("%s: %+v", n, p)
}
}
if p := by["gfx.webrender.software"]; p.Value != "" || !strings.Contains(p.From, "default") {
t.Fatalf("%+v", p)
}
if _, err := f.Prefs(nil); err == nil {
t.Fatal("names are required")
}
}
func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not pushed") {
t.Fatalf("nothing declared: %v", err)
}
f.declare(`user_pref("calendar.alarms.showmissed", true); user_pref("ui.key.x", "a;b");`)
u, err := f.userJS(true)
if err != nil || !u.Applied || u.Statements != 2 {
t.Fatalf("%+v %v", u, err)
}
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
declared, _ := os.ReadFile(filepath.Join(f.Root, testHome, ".config/betterbird/user.js"))
if string(placed) != string(declared) {
t.Fatal("placed whole, as declared")
}
if !strings.HasPrefix(u.Effective, "yes") {
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
}
later := time.Unix(1_900_000_000, 0)
os.Chtimes(filepath.Join(f.Root, profileDir, "user.js"), later, later)
if u, _ := f.userJS(false); !strings.HasPrefix(u.Effective, "not yet") {
t.Fatalf("placed after Betterbird started: %q", u.Effective)
}
f.write(profileDir+"/user.js", `user_pref("a.b", 1);`+"\n")
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not the module's") {
t.Fatalf("an operator's user.js is never overwritten: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); string(b) != `user_pref("a.b", 1);`+"\n" {
t.Fatal("and it is left as it was")
}
}
func TestTheUserJSHoldsOnlyPreferencesAndNoCredential(t *testing.T) {
for text, ok := range map[string]bool{
mark + ")\n// none\n": true,
mark + ")\n" + `user_pref("a.b", true); user_pref("c", -3); user_pref("d", "x\"y;z");`: true,
mark + ")\n" + `pref("a.b", true);`: false,
mark + ")\n" + `user_pref("a.b", true); Services.foo();`: false,
mark + ")\n" + `user_pref("mail.smtpserver.s1.password", "x");`: false,
`user_pref("a.b", true);`: false,
} {
if err := validUserJS(text); (err == nil) != ok {
t.Errorf("%q: %v", text, err)
}
}
}
func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[36802]"})
f.write("/opt/betterbird/application.ini", "[App]\nVendor=Betterbird Project\nName=Betterbird\nVersion=153.1.0\nBuildID=20260820183427\n")
f.answer = func(name string, args []string) Output {
if name == "pacman" && args[0] == "-Q" {
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
}
if name == "pacman" && args[0] == "-Qmq" {
return Output{Stdout: "betterbird-bin\n"}
}
return Output{}
}
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
s.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
t.Fatalf("%+v", s)
}
if !s.Output[0].Dead {
t.Fatalf("the session's dead pipe: %+v", s.Output)
}
found := false
for _, p := range s.Display {
if p.Name == "extensions.activeThemeID" && p.Value == `"thunderbird-compact-dark@mozilla.org"` {
found = true
}
}
if !found {
t.Fatalf("%+v", s.Display)
}
}
func TestCheckNamesWhatIsMissingAndWhatToDo(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[1]", "/proc/4000/fd/2": "/dev/null"})
f.answer = func(name string, args []string) Output { return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} }
a, err := f.Check()
if err != nil || a.OK {
t.Fatalf("%+v %v", a, err)
}
all := ""
for _, x := range a.Findings {
all += x.What + " | " + x.Do + "\n"
}
if !strings.Contains(all, "not on this machine") || !strings.Contains(all, "pipe nobody reads") {
t.Fatal(all)
}
}
func TestRestartEndsItPlacesTheUserJSAndStartsItUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare("// none")
f.onStart = func(argv []string) {
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
}
a, err := f.Restart(false)
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 1 || a.Ended[0] != 4000 || len(a.Running) != 1 || a.Running[0].PID != 5000 || !strings.HasPrefix(a.UserJS, "placed") {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=mesh-betterbird") {
t.Fatalf("%v", f.calls)
}
}
func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare(`user_pref("calendar.alarms.show", true);`)
before, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
a, err := f.ReminderTest(40*time.Second, []string{"extensions.activeThemeID", "mail.identity.id1.useremail", "nope.nope"}, false)
if err != nil {
t.Fatal(err)
}
if strings.Join(a.Carried, ",") != "extensions.activeThemeID" || len(a.Withheld) != 2 {
t.Fatalf("%+v", a)
}
dir := filepath.Join(f.Root, "/run/user/1000", testUnit)
user, _ := os.ReadFile(filepath.Join(dir, "profile", "user.js"))
cal, _ := os.ReadFile(filepath.Join(dir, "test.ics"))
if !strings.Contains(string(user), `"calendar.alarms.show", true`) || !strings.Contains(string(user), `"extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org"`) ||
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
t.Fatalf("%s", user)
}
// The event starts 10 minutes after the reminder, which is due 40 s from now.
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
a.Due != "2027-01-15T08:00:40Z" {
t.Fatalf("%s %s", cal, a.Due)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit + " --setenv=MOZ_NO_REMOTE=1") {
t.Fatalf("%v", f.calls)
}
after, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
if string(before) != string(after) || exists(filepath.Join(f.Root, profileDir, "user.js")) {
t.Fatal("the operator's profile is untouched")
}
if _, err := f.ReminderTest(0, nil, true); err != nil || exists(dir) {
t.Fatalf("stop removes the test profile: %v", err)
}
}
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// 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
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,157 @@
// The betterbird module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): Betterbird, the
// mail and calendar client, in the operator's session, served by the node's runtime as the operator
// account. The module holds no seat, so every tool is its own.
//
// The operator's mail, calendars, accounts and credentials are never read: a tool reads processes,
// profiles.ini, and of the profile only prefs.js, user.js and extensions.json, and answers no
// preference that can hold a credential or names an account.
package main
import (
"fmt"
"os"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "betterbird_status",
Description: "Betterbird: whether it runs (the main process's pid, since, and the scope it runs in; how " +
"many child processes), the installed package and version and whether it is from the AUR, where its " +
"output goes, the profile it uses and how that was found, the module's user.js (declared, placed, in " +
"effect), the add-ons the operator installed, and the preferences that decide how its windows and " +
"notifications look (reminder dialog, theme, renderer, system notifications). (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "betterbird_prefs",
Description: "Named preferences of Betterbird's profile: each one's value and where it comes from (user.js, " +
"which wins at the start; prefs.js; or Betterbird's default). A name ending in . or * is a prefix " +
"(at most 200 answered). A preference whose name can hold a credential, or that names an account, a " +
"server, a calendar or a person, is answered without its value. (r)",
Input: map[string]any{
"names": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "preference names or prefixes, e.g. [\"calendar.alarms.\", \"extensions.activeThemeID\"]"},
},
Run: func(args map[string]any) (any, error) {
names, err := texts(args, "names")
if err != nil {
return nil, err
}
return machine.Prefs(names)
},
},
{
Name: "betterbird_user_js",
Description: "The module's user.js — the preferences the module declares, from its setting prefs, written by " +
"the mesh to ~/.config/betterbird/user.js — against the profile's user.js: whether it is placed, and " +
"whether the running Betterbird started after it was. With apply, place it in the profile (only where " +
"the profile has no user.js or the module's own; an operator's file is never overwritten). Betterbird " +
"reads it at its next start: betterbird_restart. (r; a with apply)",
Input: map[string]any{
"apply": map[string]any{"type": "boolean", "description": "place the declared user.js in the profile (default false)"},
},
Run: func(args map[string]any) (any, error) {
apply, err := flag(args, "apply", false)
if err != nil {
return nil, err
}
return machine.userJS(apply)
},
},
{
Name: "betterbird_log",
Description: "The last lines Betterbird printed, from the journal of the unit the module starts it as " +
"(mesh-betterbird; or mesh-betterbird-reminder-test). With problems, only lines that say error, " +
"warning, assertion, crash or failure. Mail addresses and credentials are replaced by <hidden>. A " +
"Betterbird the module did not start prints elsewhere, and the answer says so. (r)",
Input: map[string]any{
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
"problems": map[string]any{"type": "boolean", "description": "only problem lines (default false)"},
"unit": map[string]any{"type": "string", "enum": []string{restartAs, testUnit}, "description": "which start's journal (default " + restartAs + ")"},
},
Run: func(args map[string]any) (any, error) {
n, err := whole(args, "lines", 100, 1, 2000)
if err != nil {
return nil, err
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
unit, err := text(args, "unit", false)
if err != nil {
return nil, err
}
return machine.Log(n, problems, unit)
},
},
{
Name: "betterbird_restart",
Description: "End Betterbird (asked to quit, forced after 8 s), place the module's user.js when the profile " +
"allows it, and start Betterbird again in the operator's desktop session under the account's service " +
"manager (unit mesh-betterbird), its output in the journal. Refused while a message is being written " +
"(a composer window is open), because the unsent message would be lost, unless discard_unsent is true — " +
"ask the operator first. Answers the pids ended and the new main process. (a)",
Input: map[string]any{
"discard_unsent": map[string]any{"type": "boolean", "description": "restart even with a composer open, losing what it holds (default false)"},
},
Run: func(args map[string]any) (any, error) {
discard, err := flag(args, "discard_unsent", false)
if err != nil {
return nil, err
}
return machine.Restart(discard)
},
},
{
Name: "betterbird_check",
Description: "Check what the module promises: Betterbird is installed; one runs; its profile is found; the " +
"module's user.js is on the machine, valid, placed in the profile and in effect; its output is read " +
"(a pipe nobody reads loses what it prints). Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
{
Name: "betterbird_reminder_test",
Description: "Show a calendar reminder without touching the operator's calendar: a second Betterbird in a " +
"throw-away profile under the runtime directory (--no-remote, unit mesh-betterbird-reminder-test, at most " +
"10 minutes), with the module's user.js and one local calendar file holding one event whose reminder is " +
"due after `after` seconds. carry copies named preferences of the operator's profile (a theme, a " +
"colour switch) into it, never a credential or an account. Then xorg_windows (class Calendar) and " +
"xorg_screenshot show the reminder window. stop ends it and removes the profile. (a)",
Input: map[string]any{
"after": map[string]any{"type": "integer", "description": "seconds until the reminder is due (default 40, 15 to 300)"},
"carry": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "preference names to copy from the operator's profile, e.g. [\"extensions.activeThemeID\"]"},
"stop": map[string]any{"type": "boolean", "description": "end the test Betterbird and remove its profile (default false)"},
},
Run: func(args map[string]any) (any, error) {
after, err := whole(args, "after", 40, 15, 300)
if err != nil {
return nil, err
}
carry, err := texts(args, "carry")
if err != nil {
return nil, err
}
stop, err := flag(args, "stop", false)
if err != nil {
return nil, err
}
return machine.ReminderTest(time.Duration(after)*time.Second, carry, stop)
},
},
}
}
@@ -0,0 +1,110 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// betterbird's shape (novox/hq issue 345, ADR 0208): no package (Betterbird comes from the AUR, and the
// node-engine installs from the official repositories only), no seat, no start of its own (the operator
// starts it from the launcher); the X display on its own machine; one directory and one file of its own,
// the user.js the setting prefs fills; and the Go bundle serving exactly the listed betterbird_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Requires []string `json:"requires"`
Settings map[string]any `json:"settings"`
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, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestItOwnsTheUserJSAndNothingOfTheProfile(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("%+v", m)
}
var ids []string
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
if r["type"] == "package" {
t.Errorf("betterbird-bin is the AUR's: a package resource asks the node-engine for what it cannot install")
}
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${machine:account-home}/.config/betterbird") {
t.Errorf("a path outside the module's directory: %s", p)
}
}
if strings.Join(ids, ",") != "configuration-dir,user-js" {
t.Fatalf("%v", ids)
}
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q", never)
}
}
file := m.Resources[1]
content := file["content"].(string)
if !strings.HasPrefix(content, mark) || !strings.Contains(content, "${setting:prefs}") || file["trusted"] != nil {
t.Fatalf("the user.js begins with the mark, holds the setting, and stays trusted (only the terminal sets it): %v", file)
}
// The default is a comment, so a machine without the setting places a valid, empty user.js.
def := m.Settings["prefs"].(map[string]any)["default"].(string)
if err := validUserJS(strings.ReplaceAll(content, "${setting:prefs}", def)); err != nil {
t.Fatal(err)
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "betterbird_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed betterbird_ and described", tool.Name)
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -0,0 +1,152 @@
package main
// A reminder on demand, without the operator's calendar: a second Betterbird, in a profile of its own
// under the account's runtime directory, with one local calendar file holding one event whose reminder
// is due a few seconds after the start. The running Betterbird, its profile, its accounts and its
// calendars are not touched: the second one starts with --no-remote, so it neither hands over to the
// first nor is handed to.
//
// The test profile gets the module's user.js too, so the reminder is drawn with the preferences the
// module declares. A theme or another preference of the operator's profile can be carried over by
// name (carry), within the same limits as betterbird_prefs: never a credential, never an account.
//
// It lives for at most ten minutes (the unit's RuntimeMaxSec), and stop ends it early and removes the
// profile. The reminder window is then found with xorg_windows (class Calendar, role AlarmWindow) and
// looked at with xorg_screenshot.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// testDir is the test's profile and calendar, under the account's runtime directory.
func (m *Machine) testDir() string {
return m.path(fmt.Sprintf("/run/user/%d/%s", m.UID, testUnit))
}
// carryName is a preference name the test may carry over from the operator's profile.
var carryName = regexp.MustCompile(`^[A-Za-z0-9_.\-]+$`)
// ReminderAnswer is what betterbird_reminder_test answers.
type ReminderAnswer struct {
Unit string `json:"unit"`
Profile string `json:"profile"`
Event string `json:"event"`
Due string `json:"reminder_due"`
Carried []string `json:"carried,omitempty"`
Withheld []string `json:"withheld,omitempty"`
Look string `json:"look"`
Stopped bool `json:"stopped,omitempty"`
}
// ics is the test calendar: one event, its reminder `after` from now.
func ics(now time.Time, after time.Duration, stamp string) string {
f := func(t time.Time) string { return t.UTC().Format("20060102T150405Z") }
start := now.Add(after + 10*time.Minute)
return strings.Join([]string{"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//novox mesh//betterbird reminder test//EN",
"BEGIN:VEVENT", "UID:mesh-reminder-test-" + stamp + "@mesh", "DTSTAMP:" + f(now), "DTSTART:" + f(start),
"DTEND:" + f(start.Add(30*time.Minute)), "SUMMARY:Mesh reminder test", "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:Mesh reminder test", "TRIGGER:-PT10M", "END:VALARM", "END:VEVENT", "END:VCALENDAR", ""}, "\r\n")
}
// testPrefs are the test profile's own preferences: its one calendar, and no first-run questions.
func testPrefs(calendar string) string {
return strings.Join([]string{
`user_pref("calendar.registry.meshtest.type", "ics");`,
`user_pref("calendar.registry.meshtest.uri", "file://` + calendar + `");`,
`user_pref("calendar.registry.meshtest.name", "Mesh reminder test");`,
`user_pref("calendar.registry.meshtest.calendar-main-in-composite", true);`,
`user_pref("calendar.registry.meshtest.suppressAlarms", false);`,
`user_pref("calendar.list.sortOrder", "meshtest");`,
`user_pref("mail.provider.suppress_dialog_on_startup", true);`,
`user_pref("mail.shell.checkDefaultClient", false);`,
`user_pref("datareporting.policy.dataSubmissionEnabled", false);`,
`user_pref("toolkit.telemetry.reportingpolicy.firstRun", false);`,
`user_pref("app.update.auto", false);`,
`user_pref("mail.rights.version", 1);`,
"",
}, "\n")
}
// ReminderTest starts the test Betterbird, or with stop ends it and removes its profile.
func (m *Machine) ReminderTest(after time.Duration, carry []string, stop bool) (ReminderAnswer, error) {
dir := m.testDir()
a := ReminderAnswer{Unit: testUnit + ".service", Profile: strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root),
Look: "xorg_windows with class Calendar answers the reminder window (role AlarmWindow, its depth and map state); " +
"xorg_screenshot with that window shows it as the screen does"}
if stop {
_ = m.cmd(5*time.Second, m.bus(), "systemctl", "--user", "stop", testUnit+".service")
if err := os.RemoveAll(dir); err != nil {
return a, err
}
a.Stopped, a.Look = true, ""
return a, nil
}
s, err := m.session()
if err != nil {
return a, err
}
var carried []string
if len(carry) > 0 {
p, err := m.profile()
if err != nil {
return a, err
}
prefsJS, _ := readBounded(filepath.Join(p.path, "prefs.js"))
have := readPrefs(prefsJS)
for _, n := range carry {
switch {
case !carryName.MatchString(n):
return a, fmt.Errorf("carry holds preference names, not %q", n)
case withheld(n) != "":
a.Withheld = append(a.Withheld, n+": "+withheld(n))
case have[n] == "":
a.Withheld = append(a.Withheld, n+": not set in the operator's profile")
default:
carried = append(carried, fmt.Sprintf("user_pref(%s, %s);", strconv.Quote(n), have[n]))
a.Carried = append(a.Carried, n)
}
}
}
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", testUnit+".service")
if err := os.RemoveAll(dir); err != nil {
return a, err
}
if err := os.MkdirAll(filepath.Join(dir, "profile"), 0o700); err != nil {
return a, err
}
now := m.Now()
calendar := filepath.Join(dir, "test.ics")
if err := os.WriteFile(calendar, []byte(ics(now, after, strconv.FormatInt(now.Unix(), 10))), 0o600); err != nil {
return a, err
}
user := testPrefs(strings.TrimPrefix(calendar, m.Root))
if mine, err := readBounded(m.home(declared)); err == nil && validUserJS(string(mine)) == nil {
user = string(mine) + "\n" + user
}
user += strings.Join(carried, "\n") + "\n"
if err := os.WriteFile(filepath.Join(dir, "profile", "user.js"), []byte(user), 0o600); err != nil {
return a, err
}
// The unit removes the profile when it ends, however it ends: at its 10 minutes, on stop, or when the
// test Betterbird is closed.
call := []string{"--user", "--collect", "--quiet", "--unit=" + testUnit, "-p", "RuntimeMaxSec=600",
"-p", "ExecStopPost=/usr/bin/rm -rf " + strings.TrimPrefix(dir, m.Root), "--setenv=MOZ_NO_REMOTE=1"}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--", startBin, "--no-remote", "--profile", strings.TrimPrefix(filepath.Join(dir, "profile"), m.Root))
if err := failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...); err != nil {
return a, err
}
a.Event = "Mesh reminder test, in a calendar file of the test profile only"
a.Due = now.Add(after).UTC().Format(time.RFC3339)
return a, nil
}
@@ -0,0 +1,182 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// The review of pull request 155 (hq issue 345): a restart never loses an unsent message unasked, a
// preference's value never carries an address, a log never a credential, the reminder test never
// counts as the operator's Betterbird and leaves nothing behind, and a restart answers within the
// runtime's call limit.
const composerTree = `
0x2a00003 "Inbox - Betterbird": ("Mail" "eu.betterbird.Betterbird") 2560x1388+0+0 +0+26
0x2a01234 "Write: hello - Betterbird": ("Msgcompose" "eu.betterbird.Betterbird") 900x700+10+10 +10+10
`
func TestARestartRefusesWhileAComposerIsOpenUnlessTheUnsentMayGo(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare("// none")
f.answer = func(name string, args []string) Output {
if name == "xwininfo" {
return Output{Stdout: composerTree}
}
return Output{}
}
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
t.Fatalf("refused while a message is being written: %v", err)
}
if len(f.signals) != 0 {
t.Fatalf("nothing was ended: %v", f.signals)
}
f.onStart = func([]string) {
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
}
a, err := f.Restart(true)
if err != nil || len(a.Ended) != 1 || a.Composers != 1 {
t.Fatalf("with discard_unsent it goes ahead and says what was open: %+v %v", a, err)
}
}
func TestARestartAnswersWithinTheCallLimitEvenWhenNothingCooperates(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.stubborn[4000] = true
slept := time.Duration(0)
f.Sleep = func(d time.Duration) { slept += d }
a, err := f.Restart(false)
if err != nil {
t.Fatalf("a start that has not shown yet is said, not failed: %v", err)
}
if slept > 18*time.Second || len(a.Killed) != 1 || a.Note == "" {
t.Fatalf("waited %v of the runtime's 30 s: %+v", slept, a)
}
}
func TestAPreferencesValueCarriesNoAddressHostOrHome(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
f.write(profileDir+"/prefs.js", strings.Join([]string{
`user_pref("mail.last_msg_movecopy_target_uri", "imap://someone%40example.org@mail.example.org/INBOX");`,
`user_pref("messenger.account.a1.server", "x");`,
`user_pref("mail.cloud_files.accounts.a", "y");`,
`user_pref("editor.lastFileLocation", "/home/operator/Documents/a.pdf");`,
`user_pref("network.proxy.http", "proxy.corp.example.com");`,
`user_pref("mail.compose.default_to", "imap://someone%40example.org@mail.example.org/x and bob@example.org");`,
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`,
`user_pref("extensions.ui.lastCategory", "addons://list/theme");`,
`user_pref("font.size.variable.x-western", 12);`,
}, "\n")+"\n")
got, err := f.Prefs([]string{"mail.last_msg*", "messenger.account.", "mail.cloud_files.", "editor.", "network.proxy.",
"mail.compose.default_to", "extensions.", "font.size."})
if err != nil {
t.Fatal(err)
}
by := map[string]Pref{}
for _, p := range got["prefs"].([]Pref) {
by[p.Name] = p
}
for _, n := range []string{"mail.last_msg_movecopy_target_uri", "messenger.account.a1.server", "mail.cloud_files.accounts.a", "extensions.ui.lastCategory"} {
if p := by[n]; p.Value != "" || p.Withheld == "" {
t.Errorf("withheld: %s %+v", n, p)
}
}
if v := by["editor.lastFileLocation"].Value; v != `"~/Documents/a.pdf"` {
t.Errorf("home as ~: %s", v)
}
if v := by["network.proxy.http"].Value; strings.Contains(v, "example") {
t.Errorf("a host: %s", v)
}
if v := by["mail.compose.default_to"].Value; strings.Contains(v, "example") || strings.Contains(v, "someone") {
t.Errorf("an address: %s", v)
}
if v := by["extensions.activeThemeID"].Value; v != `"thunderbird-compact-dark@mozilla.org"` {
t.Errorf("a theme's id is answered as it is: %s", v)
}
if v := by["font.size.variable.x-western"].Value; v != "12" {
t.Errorf("%s", v)
}
for _, bad := range [][]string{{""}, {"*"}, {"."}, {" "}} {
if _, err := f.Prefs(bad); err == nil {
t.Errorf("%q is refused: it asks for everything", bad)
}
}
}
func TestALogLineKeepsNoBearerOrBasicCredential(t *testing.T) {
for _, l := range []string{"Authorization: Bearer eyJhbGciOi.abc.def", "auth header basic dXNlcjpwYXNz sent", "token=abc123"} {
got := mask(l)
for _, secret := range []string{"eyJhbGciOi", "dXNlcjpwYXNz", "abc123"} {
if strings.Contains(got, secret) {
t.Errorf("%q → %q", l, got)
}
}
}
}
func TestTheReminderTestIsNeverTheOperatorsBetterbirdAndCleansUpWhenItEnds(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.proc(6000, 1000, "betterbird-bin", []string{"/usr/bin/betterbird", "--no-remote", "--profile", "/run/user/1000/" + testUnit + "/profile"}, testUnit+".service")
if ps := f.mainProcs(); len(ps) != 1 || ps[0].PID != 4000 {
t.Fatalf("only the operator's: %+v", ps)
}
f.declare("// none")
if _, err := f.ReminderTest(40*time.Second, nil, false); err != nil {
t.Fatal(err)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit) {
t.Fatalf("the unit removes its profile when it ends: %v", f.calls)
}
}
func TestAComposerIsKnownByItsInstanceWhateverItsClass(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.answer = func(name string, args []string) Output {
if name == "xwininfo" {
return Output{Stdout: ` 0x2a01234 "Write: x": ("Msgcompose" "Betterbird") 900x700+10+10 +10+10` + "\n"}
}
return Output{}
}
if _, err := f.Restart(false); err == nil || !strings.Contains(err.Error(), "composer") {
t.Fatalf("a composer of another class is still a composer: %v", err)
}
}
func TestARestartsWorstCaseStaysUnderTheRuntimesThirtySeconds(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.stubborn[4000] = true
total := time.Duration(0)
run := f.Run
f.Run = func(ctx context.Context, env []string, name string, args ...string) Output {
// Every command takes its whole timeout: the worst case.
if d, ok := ctx.Deadline(); ok {
total += time.Until(d).Round(time.Second)
}
return run(ctx, env, name, args...)
}
f.Sleep = func(d time.Duration) { total += d }
if _, err := f.Restart(true); err != nil {
t.Fatal(err)
}
if total > 26*time.Second {
t.Fatalf("the worst case is %v, over the runtime's 30 s less a margin", total)
}
}
+5
View File
@@ -0,0 +1,5 @@
module betterbird
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+55
View File
@@ -0,0 +1,55 @@
{
"module": "betterbird",
"version": "1",
"requires": [
"x11-display"
],
"settings": {
"prefs": {
"kind": "preference",
"default": "// none",
"why": "Betterbird runs with its own defaults until a machine's assignment names a preference: one line of user_pref(\"name\", value); statements, which the module's user.js holds and Betterbird reads at its start (novox/hq issue 345)"
}
},
"tools": [
"betterbird_status",
"betterbird_prefs",
"betterbird_user_js",
"betterbird_log",
"betterbird_restart",
"betterbird_check",
"betterbird_reminder_test"
],
"resources": [
{
"id": "configuration-dir",
"type": "directory",
"path": "${machine:account-home}/.config/betterbird",
"owner": "${machine:account}",
"mode": "0755"
},
{
"id": "user-js",
"type": "file",
"path": "${machine:account-home}/.config/betterbird/user.js",
"owner": "${machine:account}",
"mode": "0644",
"content": "// Betterbird's user.js (module betterbird, novox/hq issue 345). Owned by the mesh: the node-engine\n// writes it here at every push, and the module's tools copy it whole into Betterbird's profile\n// (betterbird_user_js with apply, betterbird_restart), where Betterbird reads it at its start and lets it\n// win over the preferences Betterbird saves itself. Change the module's setting prefs, never this file or the profile's copy.\n//\n// Only comments and user_pref(\"name\", value); statements; the tools refuse anything else, and any\n// preference whose name can hold a credential.\n${setting:prefs}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/betterbird-tools",
"binary": "betterbird-tools",
"loads": [
"betterbird-tools"
]
}
]
}
}
+5 -5
View File
@@ -107,8 +107,8 @@ another node. So these tools refuse, and say why and how it is done instead:
- `claude_code_hook_register`, `claude_code_settings_set`, `claude_code_permission_add`, `claude_code_permission_remove`:
the operator sets hooks, permission rules and every other managed setting in this module's `managed_settings`
setting, `mesh-controller settings set claude-code '{"managed_settings": {…}}' [--node <node>]`, typed at the
controller's terminal. The controller refuses `managed_settings`, `mcp_servers` and `role` through any verb: a
setting, `mesh-cli settings set claude-code '{"managed_settings": {…}}' [--node <node>]`, typed at the
controller's terminal (`mesh-cli`, or `nox`, on the control-node; hq ADR 0272). The controller refuses `managed_settings`, `mcp_servers` and `role` through any verb: a
mergeable file asks for every key its own content names;
- `claude_code_mcp_register`: the operator sets tool servers in the `mcp_servers` setting the same way;
- a skill, subagent or command that would run something by itself: a shell line the agent runs when it loads one
@@ -130,7 +130,7 @@ decision of 2026-10-09; a surface of the operator's own may update it later). In
skills, commands and subagents at the `mesh` or `node` scope are refused through the tools, and the import tool is
refused. They are keys of this module's settings, each text a list of its lines, because a setting holds no line
break: `instructions`, `output_styles`, `commands` and `agents` map a name to its text; `skills` maps a name to its
files, each a list of lines. For example `mesh-controller settings set claude-code '{"instructions": {"conventions":
files, each a list of lines. For example `mesh-cli settings set claude-code '{"instructions": {"conventions":
["Commit in the imperative.", "", "Test before pushing."]}}'`. They land where a registration did, laid over what was
registered before; what the operator writes there is not judged, but an item that is not well formed is left out and
said in the render's answer. What was registered before is still rendered and may be unregistered.
@@ -169,8 +169,8 @@ A new session takes a change; a running one at `/reload-plugins`.
## Settings
Per node or for the whole mesh, at the controller's terminal alone: `mesh-controller settings set claude-code '<json>'
[--node <node>]`. The controller refuses all three keys through any verb, `mesh-controller.settings` included, because
Per node or for the whole mesh, at the controller's terminal alone: `mesh-cli settings set claude-code '<json>'
[--node <node>]` on the control-node (in zsh, `nox`; hq ADR 0272). The controller refuses all three keys through any verb, `mesh-controller.settings` included, because
each reaches every session on the node and a verb's caller may be an agent (hq issue 340). Reading them through the
verb still answers.
@@ -86,8 +86,8 @@ var nodeName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
// AtTheTerminal is how a hook, a permission rule or any other managed setting is set now (novox/hq issue 340):
// in this module's `managed_settings` setting, at the controller's terminal, which refuses it through a verb.
const AtTheTerminal = "mesh-controller settings set claude-code '{\"managed_settings\": {...}}' [--node <node>], " +
"typed by the operator at the controller's terminal"
const AtTheTerminal = "mesh-cli settings set claude-code '{\"managed_settings\": {...}}' [--node <node>], " +
"typed by the operator at the controller's terminal: mesh-cli (nox) on the control-node (novox/hq ADR 0272)"
// RunsInASession says why a kind is never registered through this module: what it holds runs in, or is obeyed
// by, every session it reaches, as whoever starts that session (novox/hq issue 340).
@@ -942,7 +942,7 @@ func SharedTextAtTheTerminal(kind string) string {
KindOutputStyle: "output_styles", KindInstructions: "instructions"}[kind]
return "a " + kind + " for every node or some reaches every session there as the mesh's own word, and this tool " +
"cannot tell an agent from the operator. It is set at the controller's terminal only, under `" + key +
"` in this module's settings, its text as a list of lines: mesh-controller settings set claude-code " +
"` in this module's settings, its text as a list of lines, on the control-node: mesh-cli settings set claude-code " +
"'{\"" + key + "\": {\"<name>\": [\"line\", …]}}' [--node <node>] (novox/hq issue 340). The home scope, " +
"one account's own directory, is still registered here. Nothing was registered"
}
+1 -1
View File
@@ -684,7 +684,7 @@ func OnServerChange(v *ServerView, c ServerChange, p Paths, write WriteManaged)
const ServerAtTheTerminal = "a tool server is started, or handed what the agent asks it, by every session on the nodes it " +
"reaches, as whoever starts that session — the operator, whose account holds root — and the tool runner is not " +
"told who called it, so an agent on another node could register one. It is set at the controller's terminal " +
"only, in this module's mcp_servers setting: mesh-controller settings set claude-code '{\"mcp_servers\": {...}}' " +
"only, in this module's mcp_servers setting, on the control-node: mesh-cli settings set claude-code '{\"mcp_servers\": {...}}' " +
"[--node <node>] (novox/hq issue 340). Nothing was registered"
// RegisterServer unregisters a server: a delete per scope in the `servers` state, taken into this node's view at
@@ -130,7 +130,7 @@ func TestWhatASessionRunsIsRefusedThroughEveryTool(t *testing.T) {
// The settings and the tool server refusals name the terminal's line.
text, _ := s.call(t, "claude_code_settings_set", map[string]any{"settings": map[string]any{"model": "x"}})
if !strings.Contains(text, "mesh-controller settings set claude-code") {
if !strings.Contains(text, "mesh-cli settings set claude-code") {
t.Errorf("the refusal does not say how it is done: %s", text)
}
text, _ = s.call(t, "claude_code_mcp_register", map[string]any{"name": "x", "url": "https://x.example"})
+45
View File
@@ -0,0 +1,45 @@
# desk-channel
The desk as a channel of the operator's conversation: kind `desktop` of the `channel` and `intake` benches the
router (`messenger`) declares (novox/hq ADR 0258, ADR 0259). It shows the router's messages on the notifier of
each machine the operator sits at (the `node-notifier` seat), and says which button was chosen there.
- **It proves nothing about who clicked.** Any process of the operator's account can choose a notification's
action (`dunstctl action`), so its claim declares no `verified-sender`: the router offers here only
acknowledgements (Silence for a week, Details) and says in the words where an approval can be given.
- **An answer is taken only on a machine its message was shown on**, and once; a refused click is said where it
was made. A message with buttons stays until it is dismissed.
- **A notifier that started again** (`dunst.started`) is shown again every open message with buttons it showed.
- A message no machine took is said to the router as failed; the router tries it again.
## What the operator gives
The machines to show on, as a setting: `settings` for `desk-channel` with
`{"desktop-machines": ["<the machine the operator sits at>", …]}`. Until then it says it cannot send, and the
router sends warnings to the channel that reaches the operator away.
## Where it runs
Held once for the mesh, on the same machine as the router, which is where it is assigned. **It runs as the
account `desk-channel`, in a runtime of its own, on a bus account of its own** (`runs-as`), as telegram does:
what it shows and the answers it says are out of reach of the operator's account, which agents run as. It asks
each machine's notifier through the mesh: nothing new runs on the machines.
## A link's code (the review of 2026-10-09)
- **It is shown only where it is seen.** Each machine's notifier says whether it is paused (`dunst.paused`, at
least every two minutes). A message the router marks secret is shown only on a named machine heard unpaused in
the last five minutes; while there is none, it is said to the router as failed and the desk says it cannot
send, so the router starts no link. A code held by a paused notifier would appear later, to whoever is at the
screen then.
- **It is kept nowhere.** Not in this module's state, and sent `secret` to the notifier, which sends it
transient in the category `mesh.secret`: dunst's rules keep it out of its history, and
`node-notifier.history`, which every caller of the mesh's verbs may read, leaves it out.
- **What remains**: while it is on screen, a program of the operator's account on that X display can read it
(X11 isolates no client from another). It is shown for as long as the router's code lives.
## Tools
| tool | does |
|---|---|
| `desk_channel_status` | whether it can show messages, the machines and each one's last answer, and its rules |
@@ -0,0 +1,441 @@
package main
// The desk: kind `desktop` of the `channel` and `intake` benches (novox/hq ADR 0258, ADR 0259). It shows the
// router's messages on the notifier of each machine the operator sits at (the `node-notifier` seat, dunst)
// and says which button was chosen there. It is a channel like any other: the router judges every answer.
//
// - **It proves nothing about who clicked.** Any process of the operator's account can choose a
// notification's action, so its claim declares no `verified-sender`: the router offers here only
// acknowledgements, and says where an approval can be given.
// - **An answer is bound to the machines it was shown on**: a click on another machine is refused here, and
// said there. A message with buttons stays until it is dismissed.
// - **A notifier that started again lost the answers it waited for**: every open message with buttons shown
// on that machine is shown there again.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
import (
"encoding/json"
"errors"
"fmt"
"html"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Kind is this channel's kind on both benches.
const Kind = "desktop"
// Account is the account this module runs as, in a runtime of its own (runs-as).
const Account = "desk-channel"
// PausedFresh is how old a notifier's word on whether it is paused may be: it says it every two minutes, so
// two missed is a notifier stopped or a machine away.
const PausedFresh = 5 * time.Minute
// pauseSeen is what a machine's notifier last said of its pause, and when.
type pauseSeen struct {
paused bool
at time.Time
}
// StandingEvery is how often the channel says whether it can send, at least.
const StandingEvery = 5 * time.Minute
// Shown is one of the router's messages as the desk shows it: the message, and its notification on each
// machine.
type Shown struct {
Message asks.Message `json:"message"`
On map[string]int `json:"on"`
Sent time.Time `json:"sent"`
}
// Store keeps what is shown, by the router's handle.
type Store interface {
Get(handle string) (*Shown, error)
Put(handle string, s Shown) error
Handles() ([]string, error)
}
// Desk is the channel's whole working.
type Desk struct {
Machines func() []string
// Notify calls one machine's notifier: `seat:node-notifier.send@<machine>`.
Notify func(machine string, args map[string]any) (json.RawMessage, error)
Store Store
Publish func(subject string, body any, id string) error
Now func() time.Time
Logf func(string, ...any)
mu sync.Mutex
last map[string]string // machine -> its last error, "" after a success
clickedOn map[string]string // a choice's id -> the machine it was made on, for the router's reply
said asks.Standing
saidAt time.Time
acted map[string]bool
pause map[string]pauseSeen
}
func (d *Desk) init() {
d.mu.Lock()
defer d.mu.Unlock()
if d.last == nil {
d.last, d.clickedOn, d.acted = map[string]string{}, map[string]string{}, map[string]bool{}
}
if d.pause == nil {
d.pause = map[string]pauseSeen{}
}
if d.Now == nil {
d.Now = time.Now
}
if d.Logf == nil {
d.Logf = func(string, ...any) {}
}
}
func (d *Desk) note(machine string, err error) {
d.mu.Lock()
defer d.mu.Unlock()
if err != nil {
d.last[machine] = err.Error()
} else {
d.last[machine] = ""
}
}
// Take does one piece of the desk's work. It returns an error only to have the work offered again.
func (d *Desk) Take(subject string, body json.RawMessage) error {
d.init()
parts := strings.Split(subject, ".")
if len(parts) != 6 || parts[5] != Kind {
d.Logf("[desk-channel] work on %s is not this kind's; taken and dropped", subject)
return nil
}
var m asks.Message
if err := json.Unmarshal(body, &m); err != nil || m.Handle == "" {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
}
machines := d.Machines()
if m.Secret {
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
// would appear later, to whoever is at the screen then.
seeing, why := d.seeing()
if len(seeing) == 0 {
f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()}
return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10))
}
machines = seeing
}
if m.Reply != "" && !m.Secret {
// An answer to a click is shown where the click was made.
d.mu.Lock()
on, ok := d.clickedOn[m.Reply]
d.mu.Unlock()
if ok {
machines = []string{on}
}
}
var before *Shown
if parts[4] == asks.Edit {
var err error
if before, err = d.Store.Get(m.Handle); err != nil {
return err
}
}
s := Shown{Message: m, On: map[string]int{}, Sent: d.Now()}
var failed []string
for _, machine := range machines {
replace := 0
if before != nil {
replace = before.On[machine]
if replace == 0 {
continue // an edit goes where the message was shown, and nowhere new
}
}
id, err := d.show(machine, m, replace)
if err != nil {
failed = append(failed, machine+": "+err.Error())
continue
}
s.On[machine] = id
}
if len(s.On) == 0 {
why := "no machine showed it"
if len(failed) > 0 {
why += " — " + strings.Join(failed, "; ")
}
if len(machines) == 0 {
why = "no machine to show it on: the setting desktop-machines names none"
}
f := asks.Failed{Handle: m.Handle, Why: why, At: d.Now()}
if err := d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)); err != nil {
return err
}
return nil
}
if m.Secret {
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
return nil
}
if err := d.Store.Put(m.Handle, s); err != nil {
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
}
return nil
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
urgency := "normal"
switch {
case m.Urgent:
urgency = "critical"
case m.Silent:
urgency = "low"
}
args := map[string]any{"summary": m.Title, "body": html.EscapeString(m.Body), "urgency": urgency, "app_name": "mesh"}
if replace != 0 {
args["replace_id"] = replace
}
if m.Secret {
// Transient and secret: the notifier keeps it out of the history every caller of the verbs reads.
args["secret"] = true
args["transient"] = true
}
if len(m.Buttons) > 0 {
var actions []map[string]string
for _, b := range m.Buttons {
actions = append(actions, map[string]string{"token": b.Ticket, "label": b.Label})
}
// Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds.
args["expire_ms"] = 0
args["actions"] = actions
}
raw, err := d.Notify(machine, args)
if err == nil {
var id int
if id, err = notificationID(raw); err == nil {
d.note(machine, nil)
return id, nil
}
}
d.note(machine, err)
return 0, err
}
// Chosen takes a button chosen on a machine's notifier (dunst's `action-chosen`): said to the router as this
// kind's choice when the message it belongs to was shown on that machine; refused there otherwise. The
// machine is the event's own node, said by the runtime; the same event is acted on once.
func (d *Desk) Chosen(event, machine, ticket string) {
d.init()
d.mu.Lock()
done := d.acted[event]
if event != "" {
d.acted[event] = true
}
d.mu.Unlock()
if done {
return
}
handle, shown := d.find(ticket)
if shown == nil {
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
Body: "It belongs to no message shown here any more. Nothing was done.", Silent: true}, 0)
return
}
if _, here := shown.On[machine]; !here {
_, _ = d.show(machine, asks.Message{Title: "That answer was not taken",
Body: "It was not shown on " + machine + ". Nothing was done.", Silent: true}, 0)
return
}
id := "desk-" + event
if event == "" {
id = "desk-" + ticket + "-" + strconv.FormatInt(d.Now().UnixNano(), 10)
}
d.mu.Lock()
if len(d.clickedOn) > 500 {
d.clickedOn = map[string]string{}
}
d.clickedOn[id] = machine
d.mu.Unlock()
c := asks.Choice{ID: id, Ticket: ticket, Handle: handle, At: d.Now(),
Sender: asks.Sender{Identity: machine, Display: machine, Authenticated: false}}
if err := d.Publish(asks.IntakeSubject(asks.Chosen, Kind), c, id); err != nil {
d.Logf("[desk-channel] a choice could not be said to the router: %v", err)
_, _ = d.show(machine, asks.Message{Title: "That answer could not be taken now",
Body: "The mesh did not take it. Try again in a minute.", Silent: true}, 0)
}
}
func (d *Desk) find(ticket string) (string, *Shown) {
handles, err := d.Store.Handles()
if err != nil {
return "", nil
}
for _, h := range handles {
s, err := d.Store.Get(h)
if err != nil || s == nil {
continue
}
for _, b := range s.Message.Buttons {
if b.Ticket == ticket {
return h, s
}
}
}
return "", nil
}
// Started shows again, on a machine whose notifier started again, every message with buttons shown there.
func (d *Desk) Started(machine string) int {
d.init()
handles, err := d.Store.Handles()
if err != nil {
return 0
}
sort.Strings(handles)
n := 0
for _, h := range handles {
s, err := d.Store.Get(h)
if err != nil || s == nil || len(s.Message.Buttons) == 0 {
continue
}
if _, here := s.On[machine]; !here {
continue
}
id, err := d.show(machine, s.Message, 0)
if err != nil {
continue
}
s.On[machine] = id
_ = d.Store.Put(h, *s)
n++
}
if n > 0 {
d.Logf("[desk-channel] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
}
return n
}
// Paused takes a notifier's word on whether it is paused (dunst's event `paused`), from the event's own node.
func (d *Desk) Paused(machine string, paused bool) {
d.init()
d.mu.Lock()
defer d.mu.Unlock()
d.pause[machine] = pauseSeen{paused: paused, at: d.Now()}
}
// seeing is the named machines whose notifier said lately that it is not paused, and why none is when none is.
func (d *Desk) seeing() ([]string, string) {
machines := d.Machines()
if len(machines) == 0 {
return nil, "no machine to show it on: the setting desktop-machines names none"
}
now := d.Now()
d.mu.Lock()
defer d.mu.Unlock()
var out, not []string
for _, m := range machines {
p, heard := d.pause[m]
switch {
case !heard || now.Sub(p.at) > PausedFresh:
not = append(not, m+" (its notifier not heard from lately)")
case p.paused:
not = append(not, m+" (notifications paused)")
default:
out = append(out, m)
}
}
if len(out) == 0 {
return nil, "notifications are paused on the desk, or its notifier was not heard from lately, on every " +
"machine named: " + strings.Join(not, ", ")
}
return out, ""
}
// Standing is whether the desk can send: a named machine whose notifier said lately that it is not paused
// (the review of 2026-10-09). The router shows no link's code while the desk is not ready.
func (d *Desk) Standing() asks.Standing {
d.init()
s := asks.Standing{At: d.Now()}
if _, why := d.seeing(); why != "" {
s.Why = why
return s
}
s.Ready = true
return s
}
// SayStanding says the standing when it changed, or every StandingEvery.
func (d *Desk) SayStanding() {
s := d.Standing()
d.mu.Lock()
same := s.Ready == d.said.Ready && s.Why == d.said.Why && s.At.Sub(d.saidAt) < StandingEvery
d.mu.Unlock()
if same {
return
}
if err := d.Publish(asks.IntakeSubject(asks.StandingWhat, Kind), s, "standing."+strconv.FormatInt(s.At.UnixNano(), 10)); err != nil {
d.Logf("[desk-channel] could not say whether it can send: %v", err)
return
}
d.mu.Lock()
d.said, d.saidAt = s, s.At
d.mu.Unlock()
}
// Status is the desk's account of itself.
func (d *Desk) Status() map[string]any {
s := d.Standing()
d.mu.Lock()
defer d.mu.Unlock()
last := map[string]string{}
for k, v := range d.last {
if v == "" {
v = "took the last message"
}
last[k] = v
}
out := map[string]any{"kind": Kind, "can_send": s.Ready, "machines": d.Machines(), "machines_last": last,
"rules": []string{
"it shows the router's messages on the notifier of each machine named in desktop-machines, and says which button was chosen there; the router judges every answer (novox/hq ADR 0259)",
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"a notifier that started again is shown again every open message with buttons it showed",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
out["why_not"] = s.Why
}
return out
}
// notificationID reads the notifier's answer, {"id": N}, bare or in a tool reply's text.
func notificationID(raw json.RawMessage) (int, error) {
var direct struct {
ID *int `json:"id"`
}
if json.Unmarshal(raw, &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) > 0 {
if wrapped.IsError {
return 0, errors.New(wrapped.Content[0].Text)
}
if json.Unmarshal([]byte(wrapped.Content[0].Text), &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
}
return 0, fmt.Errorf("the notifier answered no id: %.80s", string(raw))
}
@@ -0,0 +1,261 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
type memShown map[string]Shown
func (m memShown) Get(h string) (*Shown, error) {
s, ok := m[h]
if !ok {
return nil, nil
}
return &s, nil
}
func (m memShown) Put(h string, s Shown) error { m[h] = s; return nil }
func (m memShown) Handles() ([]string, error) {
var out []string
for h := range m {
out = append(out, h)
}
return out, nil
}
type notified struct {
machine string
args map[string]any
}
func newDesk(t *testing.T, machines ...string) (*Desk, *[]notified, map[string][]json.RawMessage, memShown) {
t.Helper()
var shown []notified
said := map[string][]json.RawMessage{}
store := memShown{}
n := 0
d := &Desk{Machines: func() []string { return machines }, Store: store, Logf: t.Logf,
Now: func() time.Time { return time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC) },
Notify: func(machine string, args map[string]any) (json.RawMessage, error) {
if machine == "away-machine" {
return nil, errors.New("no session answers")
}
n++
shown = append(shown, notified{machine, args})
raw, _ := json.Marshal(map[string]int{"id": n})
return raw, nil
},
Publish: func(subject string, body any, id string) error {
raw, _ := json.Marshal(body)
said[subject] = append(said[subject], raw)
return nil
}}
d.init()
return d, &shown, said, store
}
func take(t *testing.T, d *Desk, verb string, m asks.Message) {
t.Helper()
raw, _ := json.Marshal(m)
if err := d.Take(asks.ChannelSubject(verb, Kind), raw); err != nil {
t.Fatal(err)
}
}
// novox/hq ADR 0259: the desk shows the router's message with its tickets as the notifier's actions, and
// says a click as a choice the router judges — only on a machine the message was shown on.
func TestAClickOnAMachineTheMessageWasShownOnIsSaidToTheRouter(t *testing.T) {
d, shown, said, _ := newDesk(t, "laptop", "away-machine")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "3 failed services", Body: "Needs you: <mend> them.",
Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["expire_ms"] != 0 ||
(*shown)[0].args["body"] != "Needs you: &lt;mend&gt; them." {
t.Fatalf("shown %+v", *shown)
}
d.Chosen("e1", "laptop", "t1")
d.Chosen("e1", "laptop", "t1") // a redelivery
got := said[asks.IntakeSubject(asks.Chosen, Kind)]
var c asks.Choice
if len(got) != 1 || json.Unmarshal(got[0], &c) != nil || c.Ticket != "t1" || c.Handle != "m1" ||
c.Sender.Identity != "laptop" || c.Sender.Authenticated {
t.Fatalf("said %s", got)
}
// Elsewhere it is refused, there, and nothing is said to the router.
d.Chosen("e2", "workstation", "t1")
d.Chosen("e3", "laptop", "forged")
if len(said[asks.IntakeSubject(asks.Chosen, Kind)]) != 1 {
t.Error("a click not shown there was said")
}
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "That answer was not taken" {
t.Errorf("the refusal was not shown where it was chosen: %+v", last)
}
// The router's reply to the click is shown on the machine it was made on.
take(t, d, asks.Send, asks.Message{Handle: "r1", Title: "Details: 3 failed services", Reply: c.ID})
if last := (*shown)[len(*shown)-1]; last.machine != "laptop" || last.args["summary"] != "Details: 3 failed services" {
t.Errorf("%+v", last)
}
}
func TestAnEditReplacesTheNotificationAndANotifierThatStartedAgainIsShownItsQuestions(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
take(t, d, asks.Show, asks.Message{Handle: "m1", Title: "q", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t1"}}})
take(t, d, asks.Edit, asks.Message{Handle: "m1", Title: "Silence for a week: chosen"})
if last := (*shown)[len(*shown)-1]; last.args["replace_id"] != 1 || last.args["actions"] != nil {
t.Errorf("the edit: %+v", last.args)
}
take(t, d, asks.Show, asks.Message{Handle: "m2", Title: "q2", Buttons: []asks.Button{{Label: "Silence for a week", Ticket: "t2"}}})
before := len(*shown)
if n := d.Started("laptop"); n != 1 || len(*shown) != before+1 {
t.Errorf("shown again: %d", n)
}
if store["m2"].On["laptop"] != len(*shown) {
t.Errorf("the new notification is not kept: %+v", store["m2"])
}
}
func TestNoMachineThatShowsItIsAFailureSaidToTheRouter(t *testing.T) {
d, _, said, _ := newDesk(t, "away-machine")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "x"})
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "no session answers") ||
strings.Contains(string(f[0]), `"permanent":true`) {
t.Errorf("%s", f)
}
none, _, _, _ := newDesk(t)
if s := none.Standing(); s.Ready || !strings.Contains(s.Why, "desktop-machines names none") {
t.Errorf("%+v", s)
}
}
// It claims the desk's kind, and promises nothing that would let it approve.
func TestItPromisesNoVerifiedSender(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Claims []struct {
Name, Kind string
Capabilities []string
}
Consumes []string
}
_ = json.Unmarshal(raw, &m)
if len(m.Claims) != 2 || m.Claims[0].Kind != Kind || m.Claims[1].Kind != Kind {
t.Fatalf("%+v", m.Claims)
}
for _, c := range m.Claims[0].Capabilities {
if c == "verified-sender" || c == "reaches-away" {
t.Errorf("the desk promises %s", c)
}
}
if strings.Join(m.Consumes, ",") != AnswerEvent+","+StartedEvent+","+PausedEvent {
t.Errorf("consumes %v", m.Consumes)
}
}
// A message the router marks secret (a link's code) is shown and kept nowhere of this module's.
func TestASecretMessageIsShownAndNotKept(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
d.Paused("laptop", false)
take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true})
if len(*shown) != 1 {
t.Fatal("not shown")
}
if _, kept := store["code"]; kept {
t.Error("the code was kept")
}
}
// The review of 2026-10-09 (H4, C4): a link's code is shown only on a machine whose notifier said lately that
// it is not paused — a code held back by a paused notifier appears later, to whoever is at the screen then —
// and is sent secret, so the notifier keeps it out of the history every caller of the verbs reads. While no
// named machine is heard unpaused, the desk says it cannot send.
func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testing.T) {
d, shown, said, store := newDesk(t, "laptop", "desk2")
now := d.Now()
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "not heard") {
t.Fatalf("ready before any notifier said whether it is paused: %+v", s)
}
d.Paused("laptop", false)
if s := d.Standing(); !s.Ready {
t.Fatalf("not ready with laptop heard unpaused: %+v", s)
}
code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}
take(t, d, asks.Send, code)
if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true {
t.Fatalf("the code was shown as %+v", *shown)
}
if _, kept := store["code"]; kept {
t.Error("the code was kept")
}
// Paused: not shown anywhere, said as failed, and the desk is not ready.
d.Paused("laptop", true)
take(t, d, asks.Send, code)
if len(*shown) != 1 {
t.Fatalf("the code was shown on a paused notifier: %+v", *shown)
}
if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") {
t.Errorf("not said as failed: %s", f)
}
if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") {
t.Errorf("ready while paused: %+v", s)
}
// Heard unpaused, but longer ago than PausedFresh: not known now.
d.Paused("laptop", false)
d.Now = func() time.Time { return now.Add(PausedFresh + time.Second) }
if s := d.Standing(); s.Ready {
t.Errorf("ready on a word older than %s: %+v", PausedFresh, s)
}
// A machine not named is never one to show on, whatever it says.
d.Paused("stranger", false)
if s := d.Standing(); s.Ready {
t.Error("a machine not named made the desk ready")
}
// A message that is not secret still goes to every named machine, paused or not.
take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"})
if len(*shown) != 3 {
t.Errorf("an ordinary message was held back: %+v", *shown)
}
}
// It runs as its own account (the review of 2026-10-09, H4): what it shows and the answers it says are its
// own, out of every agent's reach, as telegram's are.
func TestItRunsAsItsOwnAccount(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
RunsAs string `json:"runs-as"`
Own map[string]string `json:"own-secrets"`
Owner string `json:"secrets-owner"`
Resources []map[string]any
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if m.RunsAs != Account || m.Owner != Account || m.Own["broker"] == "" {
t.Fatalf("runs as %q, owner %q, secrets %v", m.RunsAs, m.Owner, m.Own)
}
var account, state, settings bool
for _, r := range m.Resources {
switch r["id"] {
case "account":
account = r["type"] == "user" && r["name"] == Account && r["shell"] == "/usr/bin/nologin"
case "state":
state = r["owner"] == Account && r["mode"] == "0700"
case "settings":
settings = r["owner"] == Account && r["mode"] == "0600"
}
}
if !account || !state || !settings {
t.Errorf("the account %v, its state %v, its settings %v", account, state, settings)
}
}
@@ -0,0 +1,144 @@
// desk-channel: the desk as a channel of the operator's conversation, kind `desktop` of the `channel` and
// `intake` benches (novox/hq ADR 0258, ADR 0259). A Go bundle the node's runtime launches. stdout is the MCP
// channel; what this module says, it says on stderr.
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// Worker is the work queue of this kind, as the controller names it.
const Worker = "SEAT_CHANNEL_DESKTOP_worker"
// The notifier's events, which the dunst holder of node-notifier emits as its module's own.
const (
AnswerEvent = "dunst.action-chosen"
StartedEvent = "dunst.started"
PausedEvent = "dunst.paused"
)
type busStore struct{}
func (busStore) Get(handle string) (*Shown, error) {
e, err := stdio.State("shown").Get(handle)
if err != nil || e == nil {
return nil, err
}
var s Shown
return &s, json.Unmarshal(e.Value, &s)
}
func (busStore) Put(handle string, s Shown) error {
_, err := stdio.State("shown").Put(handle, s)
return err
}
func (busStore) Handles() ([]string, error) { return stdio.State("shown").Keys() }
// machines reads the setting desktop-machines at each use, so a change takes effect without a restart.
func machines(path string) []string {
raw, err := os.ReadFile(path)
if err != nil {
return nil
}
var s struct {
Machines []string `json:"desktop-machines"`
}
if json.Unmarshal(raw, &s) != nil {
return nil
}
var out []string
for _, m := range s.Machines {
if m = strings.TrimSpace(m); m != "" {
out = append(out, m)
}
}
return out
}
func main() {
settings := os.Getenv("MESH_DESK_SETTINGS")
d := &Desk{
Machines: func() []string { return machines(settings) },
Notify: func(machine string, args map[string]any) (json.RawMessage, error) {
return stdio.Ask("seat:node-notifier.send@"+machine, args)
},
Store: busStore{},
Publish: func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) },
Logf: logf,
}
d.init()
go run(d)
tools := []stdio.Tool{{Name: "desk_channel_status",
Description: "Whether the desk can show the operator's messages, and on which machines: the setting " +
"desktop-machines, each machine's last answer, and the rules it keeps.",
Run: func(map[string]any) (any, error) { return d.Status(), nil }}}
if err := stdio.Serve("", tools); err != nil {
logf("%v", err)
os.Exit(1)
}
}
func run(d *Desk) {
time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it
go func() {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatTake(Worker, func(w stdio.Work) error { return d.Take(w.Subject, w.Body) }); err == nil {
logf("[desk-channel] taking the router's messages for the desk")
return
} else {
logf("[desk-channel] not taking messages yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}()
go func() {
for {
d.SayStanding()
time.Sleep(time.Minute)
}
}()
handle := func(e stdio.Envelope) error {
var body struct {
Token string `json:"token"`
Machine string `json:"machine"`
Paused *bool `json:"paused"`
}
_ = json.Unmarshal(e.Body, &body)
// The machine is the event's own node, said by the runtime: the body's word is only a hint.
machine := e.Node
if machine == "" {
machine = body.Machine
}
switch e.Key {
case StartedEvent:
d.Started(machine)
case PausedEvent:
// Only from the event's own node: a word about another machine is no word at all.
if body.Paused != nil && e.Node != "" {
d.Paused(e.Node, *body.Paused)
d.SayStanding()
}
case AnswerEvent:
if body.Token != "" {
d.Chosen(e.Headers["x-event-id"], machine, body.Token)
}
}
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe("dunst.*", handle); err == nil {
logf("[desk-channel] listening for the answers chosen on the desk")
return
} else {
logf("[desk-channel] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
+5
View File
@@ -0,0 +1,5 @@
module deskchannel
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.10
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+91
View File
@@ -0,0 +1,91 @@
{
"module": "desk-channel",
"version": "1",
"slug": "desk",
"runs-as": "desk-channel",
"claims": [
{
"name": "channel",
"scope": "mesh",
"kind": "desktop",
"capabilities": [
"deliver",
"silent",
"loud",
"edit",
"private",
"choice",
"exact-render"
]
},
{
"name": "intake",
"scope": "mesh",
"kind": "desktop"
}
],
"consumes": [
"dunst.action-chosen",
"dunst.started",
"dunst.paused"
],
"invokes": [
"seat:node-notifier.send"
],
"state": [
{
"name": "shown",
"ttl-seconds": 2592000
}
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "desk-channel",
"tools": [
"desk_channel_status"
],
"resources": [
{
"id": "account",
"type": "user",
"name": "desk-channel",
"shell": "/usr/bin/nologin",
"home": "/var/lib/desk-channel"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "desk-channel",
"place": "."
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"owner": "desk-channel",
"merge": "json",
"content": "{\n \"desktop-machines\": []\n}\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/desk-channel",
"binary": "desk-channel",
"loads": [
"desk-channel"
],
"env": {
"MESH_DESK_SETTINGS": "${dir:state}/settings.json"
}
}
]
}
}
+10
View File
@@ -29,6 +29,16 @@ ends the waits it held; it says `started` {machine} once it serves, and the oper
open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a
pipe (checked on the laptop on 2026-10-08: the id at once, and "Wait timeout expired" when it expired).
## What is shown once, and whether it would be seen
- **A secret notification** (`send` with `secret`: a link's code) is sent transient and in the category
`mesh.secret`, whatever category was named. The dunstrc's rules keep it, and every transient one, out of
dunst's history (`history_ignore`), and `history` leaves out any that got there anyway, counting them as
`withheld`: the history is served to every caller of the mesh's verbs, agents among them.
- **Whether notifications are paused** is said as this module's event `paused` {paused, machine}: at start,
on every change, and every two minutes, read from dunst itself so a pause set by a key counts too. The desk
shows a link's code only on a machine it heard is not paused lately.
## Tools
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
+41 -1
View File
@@ -28,8 +28,16 @@ type Notification struct {
Category string
ReplaceID int
Actions []Choice
// Transient asks dunst to keep it out of its history; Secret marks something shown once (a link's code):
// transient, and in SecretCategory whatever category was named, which the dunstrc keeps out of the
// history and the history verb leaves out (the review of 2026-10-09).
Transient bool
Secret bool
}
// SecretCategory is the category a secret notification is sent in, and the one the history never shows.
const SecretCategory = "mesh.secret"
func notificationOf(args map[string]any) (Notification, error) {
var n Notification
var err error
@@ -70,10 +78,32 @@ func notificationOf(args map[string]any) (Notification, error) {
if n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1); err != nil {
return n, err
}
if n.Transient, err = boolean(args, "transient"); err != nil {
return n, err
}
if n.Secret, err = boolean(args, "secret"); err != nil {
return n, err
}
if n.Secret {
n.Transient, n.Category = true, SecretCategory
}
n.Actions, err = choicesOf(args)
return n, err
}
// boolean reads an optional true or false.
func boolean(args map[string]any, key string) (bool, error) {
v, present := args[key]
if !present || v == nil {
return false, nil
}
b, ok := v.(bool)
if !ok {
return false, fmt.Errorf("%s is true or false", key)
}
return b, nil
}
// SendResult is what node-notifier.send answers.
type SendResult struct {
ID int `json:"id"`
@@ -100,6 +130,9 @@ func Send(n Notification) (SendResult, error) {
if n.Category != "" {
args = append(args, "--category="+n.Category)
}
if n.Transient {
args = append(args, "--transient")
}
if n.ReplaceID > 0 {
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
}
@@ -187,6 +220,8 @@ type Shown struct {
type HistoryResult struct {
Total int `json:"total"`
Notifications []Shown `json:"notifications"`
// Withheld is how many secret notifications the history held and this left out.
Withheld int `json:"withheld,omitempty"`
}
// History is dunst's history, newest first.
@@ -203,13 +238,18 @@ func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error)
if err != nil {
return HistoryResult{}, err
}
out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
out := HistoryResult{Notifications: []Shown{}}
type stamped struct {
Shown
at int64
}
var all []stamped
for _, e := range entries {
if str(e["category"]) == SecretCategory {
out.Withheld++
continue
}
out.Total++
at := number(e["timestamp"])
all = append(all, stamped{Shown{
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
+7 -2
View File
@@ -12,6 +12,7 @@ import (
func main() {
go sayStarted()
go watchPaused()
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -25,7 +26,8 @@ func tools() []stdio.Tool {
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
"normal, critical), and optionally the sending application's name, an icon, how long it stays and " +
"the answers it offers (actions: token and label each). Answers the notification's id; the answer " +
"the operator chooses is said later as the event action-chosen {id, token, machine}.",
"the operator chooses is said later as the event action-chosen {id, token, machine}. A secret " +
"or transient one is kept out of the history.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
@@ -37,6 +39,9 @@ func tools() []stdio.Tool {
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
"transient": map[string]any{"type": "boolean", "description": "keep it out of the history (default false)"},
"secret": map[string]any{"type": "boolean", "description": "it carries something shown once, such as a " +
"link's code: transient, in the category " + SecretCategory + ", and never in the history (default false)"},
"actions": map[string]any{"type": "array", "description": "at most 4 answers the notification offers, " +
"each {token, label}; the chosen token is said as the event action-chosen",
"items": map[string]any{"type": "object", "properties": map[string]any{
@@ -55,7 +60,7 @@ func tools() []stdio.Tool {
{
Name: "node-notifier.history",
Description: "The notifications the operator was shown, newest first: id, application, title, " +
"body, urgency and how long ago.",
"body, urgency and how long ago. A secret one (a link's code) is never shown; withheld counts them.",
Input: map[string]any{
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
},
+71
View File
@@ -0,0 +1,71 @@
package main
// Whether notifications are paused, said as this module's event `paused` {paused, machine} (the review of
// 2026-10-09, C4): at start, on every change, and again every PausedEvery. The desk shows a link's code only on
// a machine it heard is not paused lately, and says it cannot send one while none is: a code held back by a
// paused notifier would appear later, to whoever is then at the screen. The pause is read from dunst itself
// (`dunstctl is-paused`), so one set by a key or by hand is said like one set by dunst_pause.
import (
"fmt"
"os"
"strings"
"time"
)
const (
// PausedEvent is the event's name; PausedEvery how often it is said when nothing changed; PausedLook how
// often dunst is asked.
PausedEvent = "paused"
PausedEvery = 2 * time.Minute
PausedLook = 5 * time.Second
)
// pauseWatch says the pause; its two reaches are seams a test replaces.
type pauseWatch struct {
isPaused func() (bool, error)
say func(paused bool) error
saidAt time.Time
said bool
known bool
}
// step looks once, and says it when it changed or was said PausedEvery ago. Nothing is said while dunst
// cannot be asked: the desk then hears nothing, and takes the machine as not known.
func (w *pauseWatch) step(now time.Time) {
paused, err := w.isPaused()
if err != nil {
return
}
if w.known && paused == w.said && now.Sub(w.saidAt) < PausedEvery {
return
}
if w.say(paused) == nil {
w.known, w.said, w.saidAt = true, paused, now
}
}
func isPaused() (bool, error) {
out, err := dunstctl("is-paused")
if err != nil {
return false, err
}
switch strings.TrimSpace(out) {
case "true":
return true, nil
case "false":
return false, nil
}
return false, fmt.Errorf("dunstctl is-paused answered %q", out)
}
// watchPaused runs the watch for as long as the bundle runs.
func watchPaused() {
w := &pauseWatch{isPaused: isPaused, say: func(p bool) error {
return emit(PausedEvent, map[string]any{"paused": p, "machine": os.Getenv("MESH_NODE")})
}}
for {
w.step(time.Now())
time.Sleep(PausedLook)
}
}
@@ -0,0 +1,88 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A notification carrying something shown once — a link's code — is sent transient and in the category
// mesh.secret, whatever category the caller names (the review of 2026-10-09, H4): the dunstrc's rules keep
// both out of dunst's history, and the history verb leaves out any that got there anyway, so no agent reads
// the code back through node-notifier.history.
func TestASecretNotificationIsTransientAndKeptOutOfTheHistory(t *testing.T) {
withBus(t)
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo 42`})
t.Setenv("LOG", filepath.Join(bin, "log"))
n, err := notificationOf(map[string]any{"summary": "Link code", "body": "123 456", "secret": true, "category": "email"})
if err != nil {
t.Fatal(err)
}
if _, err := Send(n); err != nil {
t.Fatal(err)
}
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
for _, want := range []string{"[--transient]", "[--category=" + SecretCategory + "]"} {
if !strings.Contains(string(asked), want) {
t.Errorf("notify-send was not asked %s: %s", want, asked)
}
}
if strings.Contains(string(asked), "--category=email") {
t.Error("a secret notification kept the caller's category")
}
if n, err := notificationOf(map[string]any{"summary": "x", "transient": true}); err != nil || !n.Transient || n.Secret {
t.Errorf("transient alone: %+v %v", n, err)
}
// The history leaves a secret one out, and says how many it left out.
withSecret := strings.Replace(history, `"category":{"type":"s","data":"device"}`,
`"category":{"type":"s","data":"`+SecretCategory+`"}`, 1)
got, err := parseHistory(withSecret, 200_000_000, 20)
if err != nil {
t.Fatal(err)
}
if len(got.Notifications) != 1 || got.Notifications[0].ID != 7 || got.Total != 1 || got.Withheld != 1 {
t.Fatalf("the history showed a secret notification: %+v", got)
}
// The decided file holds the rules that keep them out of dunst's own history.
rc, err := os.ReadFile(filepath.Join("..", "..", "files", "dunstrc"))
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"category = \"" + SecretCategory + "\"", "match_transient = yes", "history_ignore = yes"} {
if !strings.Contains(string(rc), want) {
t.Errorf("dunstrc does not say %q", want)
}
}
}
// Whether notifications are paused is said as the event `paused` {paused, machine}: at once, on every change,
// and again every PausedEvery, so the desk knows a link's code would not be seen (C4); nothing is said while
// dunst cannot be asked.
func TestWhetherPausedIsSaidOnChangeAndEveryFewMinutes(t *testing.T) {
var said []bool
paused, fails := false, false
w := pauseWatch{
isPaused: func() (bool, error) {
if fails {
return false, os.ErrNotExist
}
return paused, nil
},
say: func(p bool) error { said = append(said, p); return nil },
}
at := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC)
w.step(at)
w.step(at.Add(5 * time.Second))
paused = true
w.step(at.Add(10 * time.Second))
w.step(at.Add(10*time.Second + PausedEvery))
fails = true
w.step(at.Add(10*time.Second + 3*PausedEvery))
if len(said) != 3 || said[0] || !said[1] || !said[2] {
t.Fatalf("said %v", said)
}
}
+11
View File
@@ -92,3 +92,14 @@
foreground = "#ffffff"
frame_color = "#ff0000"
timeout = 0
# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's
# code, category mesh.secret) and any transient one leave no entry in the history, which
# node-notifier.history serves to every caller of the mesh's verbs.
[mesh-secret]
category = "mesh.secret"
history_ignore = yes
[transient-history-ignore]
match_transient = yes
history_ignore = yes
+3 -2
View File
@@ -66,7 +66,7 @@
"path": "${machine:account-home}/.config/dunst/dunstrc",
"owner": "${machine:account}",
"mode": "0644",
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n"
},
{
"id": "notifier",
@@ -96,6 +96,7 @@
},
"emits": [
"action-chosen",
"started"
"started",
"paused"
]
}
+20
View File
@@ -4,6 +4,26 @@ The forge, and the mesh's npm package registry: it claims the `git` and `npm-pac
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (`index.ts`,
`tools/`, `provisioner/`). This README covers the Go bundle beside it.
## Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
Every new head of an open pull request is announced as `pull.updated`, once, and the controller has it checked.
- **Which repositories a pass looks at.** Each that moved since the last pass, less a minute's margin (issue 250);
each holding a pull request updated since then, found by the forge's own search — opening, reopening or
retargeting a pull request moves its own update time and not its repository's, and a pull request opened more
than a minute after its push was never announced (issue 347); and every repository on a sweep, every ten
minutes, or when the search fails.
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
at the latest, and `mesh-controller.conditions` names none for it.
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
misnamed argument sent `pulls/NaN`.
## The package registry's retention (novox/hq ADR 0251 §4, to-be 51)
Every publish adds a version to the registry, and until this nothing removed one. The Go bundle
+127 -33
View File
@@ -6,6 +6,21 @@
import { readFileSync } from "node:fs";
import { ConfiguredToken, MintedToken, type TokenSource } from "./token.js";
import { protectionBody, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { Refused, checkSentPath, pathSegment, pathSegments } from "./guard.js";
import { isCommit } from "./checks.js";
/** A repository's path on the forge, its owner and name each one segment (guard.ts pathSegment): uniform for every
* call, whether the names came from a caller or from the forge's own answer. */
function repoPath(owner: unknown, repo: unknown): string {
return `/repos/${pathSegment("repository owner", owner)}/${pathSegment("repository name", repo)}`;
}
/** A commit, by its sha or the start of one, as it goes in a path: anything else is refused before it is sent. */
function commitSegment(sha: unknown): string {
const v = String(sha ?? "");
if (!isCommit(v)) throw new Refused(`${JSON.stringify(sha)} is not a commit. Nothing was sent`);
return v;
}
export type { BranchProtection, ProtectionWanted } from "./protection.js";
/** A repository, trimmed to what the mesh cares about. */
@@ -54,6 +69,20 @@ export interface GiteaPull {
body?: string;
}
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
export interface OpenPull {
owner: string;
repo: string;
number: number;
base: string;
head_sha: string;
html_url?: string;
/** The contexts the base branch's protection requires; none when it requires none, or cannot be read. */
required: string[];
/** The contexts the head has a status of, in any state. */
statuses: string[];
}
/** A commit status, as the forge keeps it: what a pull request shows beside its head commit. */
export interface CommitStatus {
state: "pending" | "success" | "error" | "failure" | "warning";
@@ -135,6 +164,7 @@ export class GiteaClient {
}
private send(path: string, options: RequestInit, token: string): Promise<Response> {
checkSentPath(path);
return fetch(`${this.baseUrl}/api/v1${path}`, {
...options,
headers: {
@@ -182,19 +212,20 @@ export class GiteaClient {
}
async deleteRepo(owner: string, repo: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}`, { method: "DELETE" });
await this.request(`${repoPath(owner, repo)}`, { method: "DELETE" });
}
// ---- Issues ----
async listIssues(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaIssue[]> {
const qs = new URLSearchParams({ type: "issues", ...params }).toString();
const issues = await this.request<any[]>(`/repos/${owner}/${repo}/issues?${qs}`);
const issues = await this.request<any[]>(`${repoPath(owner, repo)}/issues?${qs}`);
return (issues ?? []).map(GiteaClient.mapIssue);
}
async getIssue(owner: string, repo: string, index: number): Promise<GiteaIssue> {
return GiteaClient.mapIssue(await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`));
indexOf(index);
return GiteaClient.mapIssue(await this.request<any>(`${repoPath(owner, repo)}/issues/${index}`));
}
async createIssue(
@@ -203,14 +234,15 @@ export class GiteaClient {
data: { title: string; body?: string; labels?: number[] },
): Promise<GiteaIssue> {
return GiteaClient.mapIssue(
await this.request<any>(`/repos/${owner}/${repo}/issues`, { method: "POST", body: JSON.stringify(data) }),
await this.request<any>(`${repoPath(owner, repo)}/issues`, { method: "POST", body: JSON.stringify(data) }),
);
}
/** Patch an issue's state — the one edit the close tool needs. */
async setIssueState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaIssue> {
indexOf(index);
return GiteaClient.mapIssue(
await this.request<any>(`/repos/${owner}/${repo}/issues/${index}`, {
await this.request<any>(`${repoPath(owner, repo)}/issues/${index}`, {
method: "PATCH",
body: JSON.stringify({ state }),
}),
@@ -218,7 +250,8 @@ export class GiteaClient {
}
async addComment(owner: string, repo: string, index: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/${index}/comments`, {
indexOf(index);
const c = await this.request<any>(`${repoPath(owner, repo)}/issues/${index}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
});
@@ -228,7 +261,7 @@ export class GiteaClient {
// ---- Labels ----
async listLabels(owner: string, repo: string): Promise<GiteaLabel[]> {
const labels = await this.request<any[]>(`/repos/${owner}/${repo}/labels`);
const labels = await this.request<any[]>(`${repoPath(owner, repo)}/labels`);
return (labels ?? []).map((l: any) => ({ id: l.id, name: l.name }));
}
@@ -237,7 +270,7 @@ export class GiteaClient {
repo: string,
data: { name: string; color: string; description?: string },
): Promise<GiteaLabel> {
const l = await this.request<any>(`/repos/${owner}/${repo}/labels`, { method: "POST", body: JSON.stringify(data) });
const l = await this.request<any>(`${repoPath(owner, repo)}/labels`, { method: "POST", body: JSON.stringify(data) });
return { id: l.id, name: l.name };
}
@@ -253,12 +286,53 @@ export class GiteaClient {
async listPullRequests(owner: string, repo: string, params: Record<string, string> = {}): Promise<GiteaPull[]> {
const qs = new URLSearchParams(params).toString();
const prs = await this.request<any[]>(`/repos/${owner}/${repo}/pulls?${qs}`);
const prs = await this.request<any[]>(`${repoPath(owner, repo)}/pulls?${qs}`);
return (prs ?? []).map(GiteaClient.mapPull);
}
/** The open pull requests of every repository the mesh's account sees, updated since a moment when one is given,
* page by page (novox/hq issue 347): each with its repository and its own update time. */
async searchOpenPulls(since?: string): Promise<{ owner: string; name: string; number: number; updated_at?: string }[]> {
const out: { owner: string; name: string; number: number; updated_at?: string }[] = [];
for (let page = 1; page <= 20; page++) {
const qs = new URLSearchParams({ type: "pulls", state: "open", limit: "50", page: String(page) });
if (since) qs.set("since", since);
const found = await this.request<any[]>(`/repos/issues/search?${qs}`);
for (const i of found ?? []) {
const r = i?.repository ?? {};
const [fo, fn] = String(r.full_name ?? "").split("/");
const owner = String(r.owner ?? fo ?? "");
const name = String(r.name ?? fn ?? "");
if (owner && name && i?.number) out.push({ owner, name, number: Number(i.number), updated_at: i.updated_at });
}
if (!found || found.length < 50) break;
}
return out;
}
/** **Every open pull request, from the forge itself** (novox/hq issue 347): with what its base's protection
* requires and the contexts its head has a status of, in any state. What mesh-delivery reads to say a pull
* request the mesh was never asked to check; asked of the forge, never of what the announcer remembers, so a
* fault in the announcer cannot hide itself. Reads only. */
async openPulls(): Promise<OpenPull[]> {
const out: OpenPull[] = [];
const rules = new Map<string, BranchProtection[] | undefined>();
for (const found of await this.searchOpenPulls()) {
const full = `${found.owner}/${found.name}`;
const pull = await this.getPullRequest(found.owner, found.name, found.number);
if (pull.state !== "open" || !pull.head_sha) continue;
if (!rules.has(full)) rules.set(full, await this.branchProtections(found.owner, found.name).catch(() => undefined));
const rule = rules.get(full)?.find((p) => (p.rule_name ?? p.branch_name) === (pull.base || "main"));
const statuses = Object.keys(await this.commitStatuses(found.owner, found.name, pull.head_sha));
out.push({ owner: found.owner, repo: found.name, number: pull.number, base: pull.base ?? "", head_sha: pull.head_sha,
html_url: pull.html_url, required: rule?.enable_status_check ? (rule.status_check_contexts ?? []) : [], statuses });
}
return out;
}
async getPullRequest(owner: string, repo: string, index: number): Promise<GiteaPull> {
return GiteaClient.mapPull(await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`));
indexOf(index);
return GiteaClient.mapPull(await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`));
}
/** The files a merged pull request changed, as paths from the repository's root.
@@ -275,11 +349,12 @@ export class GiteaClient {
* deleted is gone from its source, and the mesh forgets it rather than asking its build (novox/hq ADR
* 0236). The forge says `deleted`; `removed` is read the same. */
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; removed: string[]; truncated: boolean }> {
indexOf(index);
const paths: string[] = [];
const removed: string[] = [];
let pageSize = 0;
for (let page = 1; ; page++) {
const files = (await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
const files = (await this.request<any[]>(`${repoPath(owner, repo)}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
if (page === 1) pageSize = files.length;
for (const f of files) {
const name = String(f?.filename ?? "");
@@ -308,7 +383,7 @@ export class GiteaClient {
if (dirs.length > most) return null;
const out: string[] = [];
for (const dir of dirs) {
if (await this.exists(`/repos/${owner}/${repo}/contents/${encodePath(dir + "/module.json")}?ref=${encodeURIComponent(sha)}`)) {
if (await this.exists(`${repoPath(owner, repo)}/contents/${encodePath(dir + "/module.json")}?ref=${encodeURIComponent(sha)}`)) {
out.push(dir);
}
}
@@ -317,7 +392,7 @@ export class GiteaClient {
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
async holdsFile(owner: string, repo: string, sha: string, file: string): Promise<boolean> {
return this.exists(`/repos/${owner}/${repo}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
return this.exists(`${repoPath(owner, repo)}/contents/${encodePath(file)}?ref=${encodeURIComponent(sha)}`);
}
/** Whether the forge has something at a path: true for an answer, false for a 404, thrown otherwise. */
@@ -337,12 +412,12 @@ export class GiteaClient {
/** Set a commit's status — what a pull request whose head it is shows beside it (novox/hq to-be 45 §9).
* The forge keeps one per context, the newest, so setting it again replaces it. */
async setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void> {
await this.request(`/repos/${owner}/${repo}/statuses/${sha}`, { method: "POST", body: JSON.stringify(status) });
await this.request(`${repoPath(owner, repo)}/statuses/${commitSegment(sha)}`, { method: "POST", body: JSON.stringify(status) });
}
/** A commit's statuses by context, the newest of each: what a merge's head was checked as (novox/hq ADR 0239). */
async commitStatuses(owner: string, repo: string, sha: string): Promise<Record<string, string>> {
const raw = await this.request<any>(`/repos/${owner}/${repo}/commits/${sha}/status`);
const raw = await this.request<any>(`${repoPath(owner, repo)}/commits/${commitSegment(sha)}/status`);
const out: Record<string, string> = {};
for (const s of raw?.statuses ?? []) if (s?.context && !out[s.context]) out[s.context] = String(s.status ?? s.state ?? "");
return out;
@@ -351,19 +426,19 @@ export class GiteaClient {
/** A commit's statuses whole, as the forge combines them for a ref (a sha, or the start of one): the commit
* it resolved to and every status with its description, link, times and setter (novox/hq ADR 0239). */
async combinedStatus(owner: string, repo: string, ref: string): Promise<{ sha: string; state: string; statuses: unknown[] }> {
const raw = await this.request<any>(`/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}/status?limit=50`);
const raw = await this.request<any>(`${repoPath(owner, repo)}/commits/${pathSegment("commit or branch", ref)}/status?limit=50`);
return { sha: String(raw?.sha ?? ""), state: String(raw?.state ?? ""), statuses: Array.isArray(raw?.statuses) ? raw.statuses : [] };
}
/** The forge's compare of two commits, `base...head`: the commits on head that base lacks, and how many
* (novox/hq ADR 0270). Reads only. */
async compare(owner: string, repo: string, base: string, head: string): Promise<unknown> {
return this.request<unknown>(`/repos/${owner}/${repo}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`);
return this.request<unknown>(`${repoPath(owner, repo)}/compare/${commitSegment(base)}...${commitSegment(head)}`);
}
/** Replace a comment's body: the delivery's view, kept current in place (novox/hq ADR 0239). */
async editComment(owner: string, repo: string, id: number, body: string): Promise<{ id: number; html_url: string }> {
const c = await this.request<any>(`/repos/${owner}/${repo}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
const c = await this.request<any>(`${repoPath(owner, repo)}/issues/comments/${id}`, { method: "PATCH", body: JSON.stringify({ body }) });
return { id: Number(c?.id ?? id), html_url: String(c?.html_url ?? "") };
}
@@ -371,7 +446,7 @@ export class GiteaClient {
/** A repository's branch protection rules. */
async branchProtections(owner: string, repo: string): Promise<BranchProtection[]> {
return (await this.request<BranchProtection[]>(`/repos/${owner}/${repo}/branch_protections`)) ?? [];
return (await this.request<BranchProtection[]>(`${repoPath(owner, repo)}/branch_protections`)) ?? [];
}
/** The rule named for a branch, null when it has none. */
@@ -386,11 +461,11 @@ export class GiteaClient {
const had = await this.branchProtection(owner, repo, branch);
const body = protectionBody(want, !had);
if (had) {
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections/${encodeURIComponent(branch)}`,
const rule = await this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`,
{ method: "PATCH", body: JSON.stringify(body) });
return { created: false, rule };
}
const rule = await this.request<BranchProtection>(`/repos/${owner}/${repo}/branch_protections`,
const rule = await this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections`,
{ method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) });
return { created: true, rule };
}
@@ -401,33 +476,37 @@ export class GiteaClient {
data: { title: string; body?: string; head: string; base: string },
): Promise<GiteaPull> {
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls`, { method: "POST", body: JSON.stringify(data) }),
await this.request<any>(`${repoPath(owner, repo)}/pulls`, { method: "POST", body: JSON.stringify(data) }),
);
}
/** Close or reopen a pull request without merging it. A pull request is an issue to the forge's
* state machine, and the pulls endpoint takes the same `state`. */
async setPullState(owner: string, repo: string, index: number, state: "open" | "closed"): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ state }) }),
);
}
/** Change a pull request's title or body; a field left undefined is left alone. */
async updatePullRequest(owner: string, repo: string, index: number, data: { title?: string; body?: string }): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`/repos/${owner}/${repo}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify(data) }),
);
}
/** The unified diff of a pull request, as text. */
async pullDiff(owner: string, repo: string, index: number): Promise<string> {
return this.requestText(`/repos/${owner}/${repo}/pulls/${index}.diff`);
indexOf(index);
return this.requestText(`${repoPath(owner, repo)}/pulls/${index}.diff`);
}
/** Every comment on an issue or pull request, oldest first. */
async listComments(owner: string, repo: string, index: number): Promise<GiteaComment[]> {
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/issues/${index}/comments`);
indexOf(index);
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/issues/${index}/comments`);
return (raw ?? []).map((c) => ({
id: Number(c?.id ?? 0),
user: c?.user?.login,
@@ -440,19 +519,19 @@ export class GiteaClient {
/** One file's contents at a ref (default the repository's default branch), decoded. */
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
const f = await this.request<any>(`/repos/${owner}/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}${qs}`);
const f = await this.request<any>(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`);
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
return { path, ref, sha: String(f.sha ?? ""), size: Number(f.size ?? content.length), content };
}
async listBranches(owner: string, repo: string): Promise<{ name: string; commit: string; protected: boolean }[]> {
const raw = await this.request<any[]>(`/repos/${owner}/${repo}/branches?limit=100`);
const raw = await this.request<any[]>(`${repoPath(owner, repo)}/branches?limit=100`);
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
}
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`, { method: "DELETE" });
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
}
/** A request whose answer is text, not JSON — a diff. Same token handling as request(). */
@@ -463,10 +542,15 @@ export class GiteaClient {
return res.text();
}
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
headCommit?: string): Promise<void> {
indexOf(index);
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }),
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
...(headCommit ? { head_commit_id: headCommit } : {}) }),
});
}
@@ -729,6 +813,16 @@ export class GiteaAdmin {
}
}
/** A pull request's or issue's number, refused unless it is one: a tool asked with a misnamed argument hands over
* `NaN`, and the forge was asked about `pulls/NaN` (novox/hq issue 347). */
export function indexOf(index: number): number {
if (!Number.isInteger(index) || index <= 0) {
throw new Error(`a pull request or issue number is a whole number above 0, and ${String(index)} was given — ` +
"is the argument named \`number\`? Nothing was asked of the forge");
}
return index;
}
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
@@ -752,5 +846,5 @@ export function directoriesAbove(paths: string[]): string[] {
/** A repository path for the forge's URL: each segment escaped, the slashes kept. */
function encodePath(path: string): string {
return path.split("/").map(encodeURIComponent).join("/");
return pathSegments("file path", path);
}
+316
View File
@@ -0,0 +1,316 @@
// What the forge's tools refuse (novox/hq ADR 0266): whoever may call a tool includes agents, and the token
// the tools hold is minted for the forge's administrator. Through `gitea_api` any endpoint was one call away:
// an agent could drop a branch's protection or its required checks, write to main, set the merge check's
// status, or merge its own pull request — and a module merged into the catalogue is what the next push sends,
// as root, to every node. So:
//
// - `gitea_api` reads anything but the forge's administration, and writes only what an author writes:
// issues, comments, labels, pull requests (opened, edited, reviewed — never merged), branches, and a
// file on a branch that is neither the repository's default branch nor one a protection rule names.
// Everything else — administration, protection, settings, collaborators, teams, hooks, keys, tokens,
// statuses, a merge, a repository deleted or moved — has its own tool or is the operator's, at the
// forge as themselves;
// - `gitea_branch_protection_set` only makes protection stronger;
// - `gitea_merge_pull_request` merges only a head whose every required status succeeded, and only that
// head;
// - `gitea_delete_repo` refuses a repository any protection rule guards.
//
// Pure, so it is tested without a forge; the tools do the asking (tools/index.ts).
import type { BranchProtection, ProtectionWanted } from "./protection.js";
/** A refusal of policy, in the words the caller sees. Never an error of the forge's. */
export class Refused extends Error {
constructor(message: string) {
super(message);
this.name = "Refused";
}
}
/** What `gitea_api` may do with one call, as far as the call alone says. */
export type ApiJudgement =
| { allowed: true }
| { refused: string }
/** A file written: allowed only on a branch that is not the default branch and that no rule names. */
| { contents: { owner: string; repo: string; branch?: string; newBranch?: string } };
const READS = new Set(["GET", "HEAD"]);
const seg = "([^/]+)";
const repoPath = `^/repos/${seg}/${seg}`;
/** The writes an author makes, by method and path. Anything not here is refused. */
const AUTHOR_WRITES: ReadonlyArray<{ methods: string[]; path: RegExp }> = [
{ methods: ["POST"], path: new RegExp(`${repoPath}/issues$`) },
{ methods: ["PATCH"], path: new RegExp(`${repoPath}/issues/\\d+$`) },
{ methods: ["POST"], path: new RegExp(`${repoPath}/issues/\\d+/comments$`) },
{ methods: ["PATCH", "DELETE"], path: new RegExp(`${repoPath}/issues/comments/\\d+$`) },
{ methods: ["POST", "PUT", "DELETE"], path: new RegExp(`${repoPath}/issues/\\d+/labels(/\\d+)?$`) },
{ methods: ["POST"], path: new RegExp(`${repoPath}/labels$`) },
{ methods: ["PATCH", "DELETE"], path: new RegExp(`${repoPath}/labels/\\d+$`) },
{ methods: ["POST"], path: new RegExp(`${repoPath}/pulls$`) },
{ methods: ["PATCH"], path: new RegExp(`${repoPath}/pulls/\\d+$`) },
{ methods: ["POST"], path: new RegExp(`${repoPath}/pulls/\\d+/reviews(/\\d+(/comments)?)?$`) },
{ methods: ["POST", "DELETE"], path: new RegExp(`${repoPath}/pulls/\\d+/requested_reviewers$`) },
{ methods: ["POST"], path: new RegExp(`${repoPath}/branches$`) },
{ methods: ["POST"], path: /^\/user\/repos$/ },
{ methods: ["POST"], path: /^\/markdown(\/raw)?$/ },
];
/** A file written through the contents API: one file, or several in one commit. */
const CONTENTS = new RegExp(`${repoPath}/contents(/.*)?$`);
/** Reads that are no one's but the administrator's. */
const ADMIN_READS = [/^\/admin(\/|$)/, /^\/users\/[^/]+\/tokens(\/|$)/];
/**
* The path as the forge would route it: relative to /api/v1, its query dropped, refused when it could be read
* as another path — a dot segment, an encoded slash or dot, a backslash, an empty segment. A path the forge and
* this judge could read differently is never judged.
*/
export function routedPath(raw: string): string {
let path = String(raw ?? "").trim();
if (!path.startsWith("/")) path = "/" + path;
path = path.replace(/^\/api\/v1(?=\/|$)/, "") || "/";
const q = path.search(/[?#]/);
if (q >= 0) path = path.slice(0, q);
if (/%2e|%2f|%5c|\\/i.test(path)) throw new Refused(`${raw}: an encoded dot, slash or backslash is not taken`);
path = path.replace(/\/+$/, "") || "/";
const segments = path.split("/").slice(1);
if (path !== "/" && segments.some((s) => s === "" || s === "." || s === "..")) {
throw new Refused(`${raw}: a path with an empty or dot segment is not taken`);
}
return path;
}
/**
* The query keys `gitea_api` sends: what the forge's listing and reading endpoints take. Any other key is refused,
* `sudo` above all — the forge lets a site administrator's token act as any user named by it, and the tools' token is
* one (the confirmation review of 2026-10-09 read the operator's own account through `GET /user?sudo=…`). The token
* keys are refused too: authentication is the client's, never the caller's.
*/
export const QUERY_KEYS: ReadonlySet<string> = new Set([
"limit", "page", "state", "type", "q", "sort", "order", "since", "before", "ref", "sha", "path", "labels",
"milestones", "keyword", "stat", "verification", "files", "not", "mode", "include_desc", "archived", "private",
"exclusive", "uid", "priority_repo_id", "created_by", "assigned_by", "mentioned_by", "whitespace", "binary",
]);
/** The query a call sends: only allow-listed keys, each as given; refused when any other key is there. */
export function sentQuery(raw: string): string {
const at = String(raw ?? "").indexOf("?");
if (at < 0) return "";
const query = String(raw).slice(at + 1).split("#")[0];
const params = new URLSearchParams(query);
const kept = new URLSearchParams();
for (const [key, value] of params) {
if (key.toLowerCase() === "sudo") {
throw new Refused(`the query key ${key} is refused: it would act as another user of the forge (novox/hq ADR 0266). Nothing was sent`);
}
if (!QUERY_KEYS.has(key)) {
throw new Refused(`the query key ${key} is not one gitea_api sends: it sends ${[...QUERY_KEYS].join(", ")} (novox/hq ADR 0266). Nothing was sent`);
}
kept.append(key, value);
}
const out = kept.toString();
return out ? "?" + out : "";
}
/** What `gitea_api` may do with this call, judged on the call alone (novox/hq ADR 0266). */
export function judgeApiCall(method: string, rawPath: string, body: unknown): ApiJudgement {
const m = String(method || "GET").toUpperCase();
let path: string;
try {
path = routedPath(rawPath);
sentQuery(rawPath);
} catch (err) {
return { refused: (err as Error).message };
}
if (READS.has(m)) {
if (ADMIN_READS.some((r) => r.test(path))) {
return { refused: `${m} ${path} is the forge's administration, which no tool reads (novox/hq ADR 0266)` };
}
return { allowed: true };
}
const contents = path.match(CONTENTS);
if (contents && ["POST", "PUT", "DELETE"].includes(m)) {
const b = (body ?? {}) as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined);
return { contents: { owner: contents[1], repo: contents[2], branch: str(b.branch), newBranch: str(b.new_branch) } };
}
// A pull request's base changed through the escape hatch would merge what was checked against one branch into
// another; gitea_update_pull_request changes a title and a body, never a base.
if (m === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/pulls\/\d+$/.test(path) && body && typeof body === "object" &&
"base" in (body as Record<string, unknown>)) {
return { refused: `${m} ${path} changing a pull request's base is refused (novox/hq ADR 0266): its checks were judged against the base it had. Open a new pull request. Nothing was sent` };
}
// An approval, or a request for changes, is a reviewer's word on a merge: the operator's, never a tool's — the
// tools' token is the forge's administrator, and its approval would count as anyone's (novox/hq ADR 0266).
if (m === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls\/\d+\/reviews(\/\d+)?$/.test(path)) {
const event = String((body as Record<string, unknown> | null)?.event ?? "").trim().toUpperCase();
if (event !== "" && event !== "COMMENT" && event !== "PENDING") {
return { refused: `${m} ${path} with the event ${event} is refused: approving a pull request, or requesting ` +
`changes on it, is the operator's at the forge as themselves, never a tool's (novox/hq ADR 0266). A review ` +
`that only comments is sent. Nothing was sent` };
}
}
if (AUTHOR_WRITES.some((w) => w.methods.includes(m) && w.path.test(path))) return { allowed: true };
return {
refused: `${m} ${path} is not a write gitea_api makes (novox/hq ADR 0266): it writes only what an author ` +
`writes — issues, comments, labels, pull requests (never their merge), branches, and files on a branch ` +
`that is not the default branch and that no protection rule names. The forge's administration, a ` +
`branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` +
`are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` +
`gitea_branch_protection_set, which only strengthens). Nothing was sent`,
};
}
/** Whether a protection rule's name — a branch, or a glob of them (`*`, `**`) — covers a branch. */
export function ruleCovers(rule: string, branch: string): boolean {
if (rule === branch) return true;
if (!/[*?[]/.test(rule)) return false;
let re = "";
for (let i = 0; i < rule.length; i++) {
const c = rule[i];
if (c === "*" && rule[i + 1] === "*") {
re += ".*";
i++;
} else if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(`^${re}$`).test(branch);
}
/** Refuses a file written to the default branch or to a branch a protection rule names. */
export function judgeContentsWrite(target: string, defaultBranch: string, rules: BranchProtection[]): string | null {
if (target === defaultBranch) {
return `a file written to ${target}, the repository's default branch, is refused: a change reaches it by a pull ` +
`request that passes its checks (novox/hq ADR 0266). Write it on a branch of its own (new_branch). Nothing was sent`;
}
const guarding = rules.map((r) => String(r.rule_name ?? r.branch_name ?? "")).filter((r) => r && ruleCovers(r, target));
if (guarding.length) {
return `a file written to ${target} is refused: the protection rule ${guarding.join(", ")} guards it ` +
`(novox/hq ADR 0266). Write it on a branch of its own. Nothing was sent`;
}
return null;
}
/**
* Refuses a protection change that weakens what guards a branch (novox/hq ADR 0266): the branch's own rule, or —
* when it has none — every glob rule that covers it, since a rule named for the branch takes the glob's place.
* A rule made new takes the forge's defaults for what it does not say: pushes refused, an administrator's
* override allowed.
*/
export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted,
covering: BranchProtection[] = []): string | null {
if (!before) {
for (const glob of covering) {
const weaker = judgeProtectionChange(glob, {
...want,
push: want.push ?? false,
blockAdminOverride: want.blockAdminOverride ?? false,
});
if (weaker) return weaker;
}
return null; // A new rule protects what nothing protected.
}
const had = before.enable_status_check ? [...new Set((before.status_check_contexts ?? []).filter(Boolean))] : [];
const wanted = new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean));
const dropped = had.filter((c) => !wanted.has(c));
const why: string[] = [];
if (dropped.length) why.push(`it drops the required status ${dropped.join(", ")}`);
if (want.push === true && before.enable_push === false) why.push("it lets a person push to the branch directly");
if (want.blockAdminOverride === false && before.block_admin_merge_override === true) {
why.push("it lets an administrator merge past a status that has not succeeded");
}
if (!why.length) return null;
return `the protection of ${before.rule_name ?? before.branch_name} is only made stronger through a tool: ` +
`${why.join("; ")}. Whoever may call a tool includes agents, and a branch's protection is what keeps a ` +
`change from merging without its checks and the operator (novox/hq ADR 0266) — weakening it is the ` +
`operator's, at the forge as themselves. Nothing was changed`;
}
/** The token's own user's repositories, untouched for this long, are the only ones a tool deletes. */
export const DELETABLE_AFTER_DAYS = 30;
/**
* Refuses deleting a repository (novox/hq ADR 0266): deleted and made again under its name, a repository the mesh
* builds from would be one without its protection. A tool deletes only a repository of the token's own user — the
* mesh builds from organisations' and people's repositories, never the tools' own — that no protection rule
* guards, with no commit for DELETABLE_AFTER_DAYS days, and only when confirm names it in full.
*/
export function judgeDelete(fullName: string, confirm: unknown, owner: string, self: string,
rules: BranchProtection[], lastCommit: Date | null, now: Date): string | null {
if (String(confirm ?? "") !== fullName) {
return `confirm must be the repository's full name, ${fullName}, to delete it. Nothing was deleted`;
}
if (owner.toLowerCase() !== self.toLowerCase()) {
return `${fullName} is not a repository of the tools' own user (${self}): a tool deletes only those, never an ` +
`organisation's or a person's — the mesh builds from them (novox/hq ADR 0266). Deleting it is the operator's, at ` +
`the forge as themselves. Nothing was deleted`;
}
if (rules.length) {
return `${fullName} is guarded by the protection rule ${rules.map((r) => r.rule_name ?? r.branch_name).join(", ")}: ` +
`deleting it is the operator's, at the forge as themselves (novox/hq ADR 0266). Nothing was deleted`;
}
if (lastCommit && now.getTime() - lastCommit.getTime() < DELETABLE_AFTER_DAYS * 24 * 3600 * 1000) {
return `${fullName} had a commit on ${lastCommit.toISOString().slice(0, 10)}, within ${DELETABLE_AFTER_DAYS} days: ` +
`a repository in use is not deleted by a tool (novox/hq ADR 0266). Nothing was deleted`;
}
return null;
}
/** The merge methods the forge takes for a merge that is a merge — never `manually-merged`. */
export const MERGE_METHODS = new Set(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]);
/** A repository owner's or name's shape: what keeps a name from becoming another path. */
export function checkName(what: string, value: unknown): string {
const v = String(value ?? "").trim();
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(v) || v === "." || v === "..") {
throw new Refused(`${JSON.stringify(value)} is not a repository ${what}`);
}
return v;
}
/**
* One caller-supplied path segment, encoded: refused when empty, `.` or `..`, or holding `/`, `?`, `#` or `%` — any
* of which, encoded or not, could make the request another path or carry another query (the confirmation review of
* 2026-10-09: a branch named `..` became DELETE /repos/o/r/, the repository itself). Every value a caller gives that
* lands in a path goes through this, or through pathSegments.
*/
export function pathSegment(what: string, value: unknown): string {
const v = String(value ?? "");
if (v === "" || v === "." || v === ".." || /[/?#%\\]/.test(v) || /[\u0000-\u001f]/.test(v)) {
throw new Refused(`${JSON.stringify(value)} is not a ${what} this tool puts in a path. Nothing was sent`);
}
return encodeURIComponent(v);
}
/** A caller-supplied value that is several segments — a branch like feat/x, a file's path — each judged as one. */
export function pathSegments(what: string, value: unknown): string {
return String(value ?? "").split("/").map((part) => pathSegment(what, part)).join("/");
}
/**
* Every path the client sends, judged once more where it is sent: no empty, `.` or `..` segment, encoded or not, and
* only the query keys the tools send (sentQuery). What a caller's text could still make of a path built elsewhere
* stops here, before the forge.
*/
export function checkSentPath(path: string): void {
const at = path.search(/[?#]/);
const route = at < 0 ? path : path.slice(0, at);
if (path.includes("#")) throw new Refused(`${path}: a path with a fragment is not sent`);
const segments = route.replace(/^\/+/, "").split("/");
for (const s of segments) {
let decoded: string;
try {
decoded = decodeURIComponent(s);
} catch {
throw new Refused(`${path}: a segment that does not decode is not sent`);
}
if (decoded === "" || decoded === "." || decoded === ".." || decoded.includes("?")) {
throw new Refused(`${path}: an empty or dot segment is not sent. Nothing was sent`);
}
}
if (at >= 0) sentQuery(path.slice(at));
}
+19 -2
View File
@@ -28,7 +28,7 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, headsToAnnounce, statusesFor, type Announced, type Checked,
type RepoCheckFacts } from "./pulls.js";
type RepoCheckFacts, reposToLook } from "./pulls.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -213,12 +213,29 @@ function keepHeads(): void {
renameSync(tmp, pullsRecord);
}
let lastPullLook = "";
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
// heuristic, and a heuristic that misses is caught by the next sweep rather than never.
const SWEEP_MS = 10 * 60_000;
let lastSweep = 0;
async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
let changed = false;
for (const repo of movedSince(await client.listAllRepos(), floor)) {
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
let sweep = Date.now() - lastSweep >= SWEEP_MS;
let updated: { owner: string; name: string; updated_at?: string }[] = [];
if (floor && !sweep) {
try {
updated = await client.searchOpenPulls(floor);
} catch (err) {
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
sweep = true;
}
}
if (sweep) lastSweep = Date.now();
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", sort: "recentupdate", limit: "20" });
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
+22 -1
View File
@@ -9,7 +9,8 @@
//
// Pure functions here, so they are tested without a forge; index.ts does the asking and the setting.
import type { CommitStatus, GiteaPull } from "./client.js";
import type { CommitStatus, GiteaPull, GiteaRepo } from "./client.js";
import { movedSince } from "./client.js";
/** The status context a merge check's gate is kept under: one per commit, the newest replacing the last. */
export const CHECK_CONTEXT = "mesh/merge-gate";
@@ -92,6 +93,26 @@ export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Ann
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
}
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
export interface PullSeen {
owner: string;
name: string;
updated_at?: string;
}
/** **The repositories a pass looks at** (novox/hq issue 347): every one that moved since the floor, every one
* holding a pull request updated since the floor — opening, reopening or retargeting a pull request moves its own
* update time and not its repository's — and every one on a sweep or the first pass. */
export function reposToLook(repos: GiteaRepo[], floor: string, pulls: PullSeen[], sweep: boolean): GiteaRepo[] {
if (sweep || !floor) return repos;
const moved = new Set(movedSince(repos, floor).map((r) => r.full_name));
const at = Date.parse(floor);
for (const p of pulls) {
if (!p.updated_at || !(Date.parse(p.updated_at) < at)) moved.add(`${p.owner}/${p.name}`);
}
return repos.filter((r) => moved.has(r.full_name));
}
// **A required status is success or it blocks** (novox/hq issue 293). The forge combines `warning` as a
// failure, and a branch whose protection requires mesh/merge-gate or mesh/repo-check — with no
// administrator override — will not merge past one. So the controller's `warning`, a note and never a
+91
View File
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
// **A pull request opened after its push is still checked** (novox/hq issue 347). The announcer read only the
// repositories whose update time moved since its last pass, less a minute's margin; opening, reopening or
// retargeting a pull request does not move a repository's update time, so a pull request opened more than a
// minute or so after its push was never announced, never checked, and nothing said so.
const repo = (full: string, updated: string) => {
const [owner, name] = full.split("/");
return { full_name: full, owner, name, private: false, html_url: "", updated_at: updated };
};
test("a pull request opened after the margin is looked at, by its own update time", async () => {
const { reposToLook } = await import("../pulls.ts");
const pushed = "2026-10-09T10:00:00Z"; // the branch pushed: the repository's update time
// The pull request opened two minutes later; the last pass was at 10:02:30, so the floor is 10:01:30.
const floor = "2026-10-09T10:01:30Z";
const repos = [repo("novox/mesh-sdk", pushed), repo("novox/hq", "2026-10-09T09:00:00Z")];
const opened = [{ owner: "novox", name: "mesh-sdk", updated_at: "2026-10-09T10:02:00Z" }];
assert.deepEqual(reposToLook(repos, floor, opened, false).map((r) => r.full_name), ["novox/mesh-sdk"],
"the repository of a pull request updated since the floor, though the repository itself did not move");
assert.deepEqual(reposToLook(repos, floor, [], false).map((r) => r.full_name), [],
"without the pull request's own time nothing is looked at — the fault");
assert.deepEqual(reposToLook(repos, floor, [], true).map((r) => r.full_name).sort(), ["novox/hq", "novox/mesh-sdk"],
"a sweep looks at every repository");
assert.deepEqual(reposToLook(repos, "", [], false).length, 2, "the first pass looks at every repository");
});
test("every open pull request is read with what its base requires and what its head has (gitea_open_pulls)", async () => {
const { GiteaClient } = await import("../client.ts");
const asked: string[] = [];
const server = createServer((req, res) => {
asked.push(req.url ?? "");
const url = req.url ?? "";
const send = (v: unknown) => res.end(JSON.stringify(v));
if (url.startsWith("/api/v1/repos/issues/search")) {
return send(url.includes("page=1") ? [
{ number: 243, repository: { owner: "novox", name: "hq", full_name: "novox/hq" }, updated_at: "2026-10-09T11:00:00Z" },
{ number: 14, repository: { owner: "novox", name: "mesh-sdk", full_name: "novox/mesh-sdk" } },
] : []);
}
if (url.startsWith("/api/v1/repos/novox/hq/pulls/243")) {
return send({ number: 243, state: "open", base: { ref: "main" }, head: { ref: "x", sha: "aaa1111" }, html_url: "h" });
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/pulls/14")) {
return send({ number: 14, state: "open", base: { ref: "main" }, head: { ref: "y", sha: "bbb2222" }, html_url: "s" });
}
if (url.startsWith("/api/v1/repos/novox/hq/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/repo-check"] }]);
}
if (url.startsWith("/api/v1/repos/novox/mesh-sdk/branch_protections")) {
return send([{ rule_name: "main", enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] }]);
}
if (url.includes("/commits/aaa1111/status")) return send({ statuses: [] });
if (url.includes("/commits/bbb2222/status")) return send({ statuses: [{ context: "mesh/merge-gate", status: "pending" }] });
res.statusCode = 404;
res.end("{}");
});
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
try {
const open = await client.openPulls();
assert.deepEqual(open.map((p) => [p.owner, p.repo, p.number, p.base, p.head_sha, p.required, p.statuses]), [
["novox", "hq", 243, "main", "aaa1111", ["mesh/repo-check"], []],
["novox", "mesh-sdk", 14, "main", "bbb2222", ["mesh/merge-gate", "mesh/repo-check"], ["mesh/merge-gate"]],
]);
} finally {
server.close();
}
});
test("a pull request or issue number that is not one is refused before the forge is asked", async () => {
const { GiteaClient } = await import("../client.ts");
let asked = 0;
const server = createServer((_req, res) => {
asked++;
res.end("{}");
});
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
try {
await assert.rejects(client.getPullRequest("novox", "hq", Number(undefined)), /whole number/);
await assert.rejects(client.addComment("novox", "hq", 0, "x"), /whole number/);
await assert.rejects(client.listPullFiles("novox", "hq", 1.5), /whole number/);
assert.equal(asked, 0, "the forge was asked about pulls/NaN");
} finally {
server.close();
}
});
+267
View File
@@ -0,0 +1,267 @@
// What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools
// with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from
// dropping a branch's protection, writing to main, setting the merge check's status, or merging past the
// checks. Each route a review found is reproduced here against a fake forge, and each is refused with nothing
// sent; the operator-approved merge of a pull request whose checks passed still goes through.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
interface Sent { method: string; path: string; body: any }
const state = {
sent: [] as Sent[],
asked: [] as string[],
statuses: [] as { context: string; state: string }[],
rules: [] as any[],
lastCommit: "2026-01-01T00:00:00Z",
};
const protectedMain = () => ({ rule_name: "main", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true });
beforeEach(() => {
state.sent = [];
state.asked = [];
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "success" }];
state.rules = [protectedMain()];
});
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const read = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let text = "";
req.on("data", (c) => (text += c));
req.on("end", () => resolve(text ? JSON.parse(text) : null));
});
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const path = url.pathname.replace(/^\/api\/v1/, "");
const method = req.method ?? "GET";
const body = await read(req);
state.asked.push(`${method} ${path}${url.search}`);
if (method !== "GET") state.sent.push({ method, path, body });
const repo = "/repos/novox/mesh-catalog";
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
if (method === "GET" && path === "/user") return json(res, 200, { login: "mesh-admin" });
if (method === "GET" && path === "/repos/mesh-admin/scratch/branch_protections") return json(res, 200, []);
if (method === "GET" && /^\/repos\/[^/]+\/[^/]+\/commits$/.test(path)) {
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
}
if (method === "GET" && path === `${repo}/pulls/7`) {
return json(res, 200, { number: 7, title: "x", state: "open", merged: false, head: { ref: "feat/x", sha: "abc1234" },
base: { ref: "main" }, html_url: "http://fake/7" });
}
if (method === "GET" && path === `${repo}/commits/abc1234/status`) {
return json(res, 200, { sha: "abc1234", state: "success", statuses: state.statuses });
}
if (method === "POST" && path === `${repo}/pulls/7/merge`) return json(res, 200, null);
return json(res, method === "GET" ? 200 : 201, {});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((r) => server.close(() => r())));
const { port } = server.address() as { port: number };
const tools = getGiteaTools(new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")));
const tool = (name: string) => tools.find((t) => t.name === name)!;
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
test("gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing", async () => {
for (const [method, path, body] of [
["PATCH", "/admin/users/agent", { admin: true }],
["DELETE", "/repos/novox/mesh-catalog/branch_protections/main", undefined],
["PATCH", "/repos/novox/mesh-catalog/branch_protections/main", { enable_status_check: false }],
["POST", "/repos/novox/mesh-catalog/statuses/abc1234", { context: "mesh/merge-gate", state: "success" }],
["POST", "/repos/novox/mesh-catalog/pulls/7/merge", { Do: "merge", force_merge: true }],
["PUT", "/repos/novox/mesh-catalog/collaborators/agent", { permission: "admin" }],
["POST", "/repos/novox/mesh-catalog/hooks", { type: "gitea" }],
["POST", "/repos/novox/mesh-catalog/keys", { key: "ssh-ed25519 AAAA" }],
["PATCH", "/repos/novox/mesh-catalog", { default_branch: "agent" }],
["DELETE", "/repos/novox/mesh-catalog", undefined],
["POST", "/repos/novox/mesh-catalog/transfer", { new_owner: "agent" }],
["POST", "/users/mesh-admin/tokens", { name: "mine", scopes: ["all"] }],
["PUT", "/orgs/novox/members/agent", undefined],
["PATCH", "/repos/novox/mesh-catalog/issues/1/../../branch_protections/main", { enable_push: true }],
["PATCH", "/repos/novox/mesh-catalog/issues/1/%2e%2e/%2e%2e/branch_protections/main", { enable_push: true }],
] as const) {
await assert.rejects(tool("gitea_api").run({ path, method, ...(body ? { body } : {}) }),
/not a write gitea_api makes|not taken|administration/, `${method} ${path}`);
}
await assert.rejects(tool("gitea_api").run({ path: "/admin/users" }), /administration/);
assert.deepEqual(writes(), []);
});
test("gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own", async () => {
const put = (body: Record<string, unknown>) =>
tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/contents/modules/sudo/module.json", method: "PUT", body });
await assert.rejects(put({ content: "e30=", sha: "x" }), /default branch/);
await assert.rejects(put({ content: "e30=", sha: "x", branch: "main" }), /default branch/);
state.rules.push({ rule_name: "release/*" });
await assert.rejects(put({ content: "e30=", sha: "x", branch: "release/1" }), /protection rule release\/\*/);
assert.deepEqual(writes(), []);
await put({ content: "e30=", sha: "x", branch: "main", new_branch: "feat/sudo-docs" });
assert.deepEqual(writes(), ["PUT /repos/novox/mesh-catalog/contents/modules/sudo/module.json"]);
});
test("gitea_api still writes what an author writes", async () => {
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
});
test("gitea_branch_protection_set only strengthens", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /drops the required status mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", push: true }), /push to the branch directly/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", block_admin_override: false }),
/merge past a status/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate,mesh/repo-check,mesh/delivery" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]);
});
test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 });
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "failure" }];
await assert.rejects(merge(), /not merged: .*mesh\/repo-check \(failure\)/);
state.statuses = [{ context: "mesh/merge-gate", state: "success" }];
await assert.rejects(merge(), /mesh\/repo-check \(missing\)/);
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
method: "manually-merged" }), /not a merge method/);
assert.deepEqual(writes(), []);
// The operator said yes and the checks passed: the flow that stays.
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "success" }];
await merge();
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
assert.equal(sent.body.force_merge, false);
assert.equal(sent.body.head_commit_id, "abc1234");
});
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
state.lastCommit = new Date().toISOString();
await assert.rejects(del("mesh-admin", "scratch", "mesh-admin/scratch"), /within 30 days/);
assert.deepEqual(writes(), []);
state.lastCommit = "2026-01-01T00:00:00Z";
await del("mesh-admin", "scratch", "mesh-admin/scratch");
assert.deepEqual(writes(), ["DELETE /repos/mesh-admin/scratch"]);
});
test("gitea_api sends the path it judged, and refuses moving a pull request's base", async () => {
await tool("gitea_api").run({ path: "/api/v1/repos/novox/mesh-catalog/issues/3/comments/?page=1", method: "POST", body: { body: "hi" } });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments"]);
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH",
body: { base: "unguarded" } }), /changing a pull request's base/);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH", body: { title: "x" } });
assert.equal(writes().length, 2);
});
test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => {
state.rules = [];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 }),
/no protection rule requiring the checks guards/);
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 }),
/no protection rule requiring the checks guards/);
assert.deepEqual(writes(), []);
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate", block_admin_override: true });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]);
});
test("a repository owner or name is never another path", async () => {
await assert.rejects(tool("gitea_update_pull_request").run({ owner: "novox", repo: "mesh-catalog/branch_protections/main?",
number: 1, title: "x" }), /not a repository name/);
await assert.rejects(tool("gitea_get_issue").run({ owner: "..", repo: "admin", number: 1 }), /not a repository owner/);
assert.deepEqual(writes(), []);
});
test("the judges, without a forge", () => {
assert.equal(routedPath("/api/v1/repos/a/b/issues?x=1"), "/repos/a/b/issues");
assert.throws(() => routedPath("/repos/a/b/../c"), /dot segment/);
assert.ok("allowed" in judgeApiCall("GET", "/repos/a/b/branch_protections", undefined));
assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {}));
assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" }));
assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b"));
assert.equal(judgeProtectionChange(null, { statusChecks: [] }), null);
assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null);
});
test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => {
for (const path of ["/user?sudo=jschoubben", "/user?SUDO=jschoubben", "/repos/novox/mesh-catalog/issues?state=open&Sudo=jschoubben",
"/repos/novox/mesh-catalog/issues?token=abc", "/repos/novox/mesh-catalog/issues?access_token=abc"]) {
await assert.rejects(tool("gitea_api").run({ path }), /query key/, path);
}
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments?sudo=jschoubben",
method: "POST", body: { body: "as the operator" } }), /query key sudo/);
assert.deepEqual(state.asked, []);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues?state=open&limit=5" });
assert.deepEqual(state.asked, ["GET /repos/novox/mesh-catalog/issues?state=open&limit=5"]);
});
test("gitea_api never approves a pull request nor requests changes: those are the operator's", async () => {
for (const [path, event] of [["/repos/novox/mesh-catalog/pulls/7/reviews", "APPROVE"],
["/repos/novox/mesh-catalog/pulls/7/reviews", "approve"], ["/repos/novox/mesh-catalog/pulls/7/reviews", "REQUEST_CHANGES"],
["/repos/novox/mesh-catalog/pulls/7/reviews/5", "APPROVE"]]) {
await assert.rejects(tool("gitea_api").run({ path, method: "POST", body: { event, body: "lgtm" } }),
/operator's at the forge/, `${path} ${event}`);
}
assert.deepEqual(writes(), []);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT", body: "a note" } });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
});
test("no tool's caller text becomes another path or another user: `..` and `?sudo=` are refused before the forge", async () => {
const repo = { owner: "novox", repo: "mesh-catalog" };
const cases: Array<[string, Record<string, unknown>]> = [
["gitea_delete_branch", { ...repo, branch: ".." }],
["gitea_delete_branch", { ...repo, branch: "feat/../.." }],
["gitea_delete_branch", { ...repo, branch: "x?sudo=jschoubben" }],
["gitea_delete_branch", { ...repo, branch: "%2e%2e" }],
["gitea_get_file", { ...repo, path: "../../../../version" }],
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
["gitea_commit_statuses", { ...repo, sha: ".." }],
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
["gitea_branch_protection_set", { ...repo, branch: "main?sudo=jschoubben", status_checks: "mesh/merge-gate" }],
["gitea_contains", { ...repo, commit: "..", in: "abc1234" }],
];
for (const [name, args] of cases) {
state.asked = [];
await assert.rejects(tool(name).run(args), (err: unknown) => err instanceof Error, `${name} ${JSON.stringify(args)}`);
// Nothing that is not a read of the repository's own rules reached the forge.
assert.deepEqual(state.asked.filter((a) => !a.startsWith("GET /repos/novox/mesh-catalog/branch_protections")), [],
`${name} ${JSON.stringify(args)} reached the forge`);
}
// What is ordinary still goes: a branch with a slash, a file in a directory.
state.asked = [];
await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" });
await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined);
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
});
+6 -3
View File
@@ -119,9 +119,12 @@ test("a commit status is set on the commit, under the merge check's context", as
await new Promise<void>((r) => server.listen(0, r));
const port = (server.address() as any).port;
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
await client.setCommitStatus("novox", "mesh-catalog", "abc123", { state: "failure", context: "mesh/merge-gate", description: "x" });
server.close();
assert.equal(seen!.path, "POST /api/v1/repos/novox/mesh-catalog/statuses/abc123");
try {
await client.setCommitStatus("novox", "mesh-catalog", "abc1234", { state: "failure", context: "mesh/merge-gate", description: "x" });
} finally {
server.close();
}
assert.equal(seen!.path, "POST /api/v1/repos/novox/mesh-catalog/statuses/abc1234");
assert.equal(seen!.body.state, "failure");
assert.equal(seen!.body.context, "mesh/merge-gate");
});
+32 -17
View File
@@ -17,7 +17,7 @@ import { join } from "node:path";
import { collectTools } from "@novox/mesh-sdk/tools";
import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import "../dist/tools/index.js";
import { getGiteaTools } from "../dist/tools/index.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
@@ -230,10 +230,12 @@ test("first start: mints with the admin account, keeps the token at 0600, asks f
assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "write:admin"]);
// Never write:admin (novox/hq ADR 0266): the forge's administration is the operator's, not a tool's.
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n");
assert.equal(await readFile(file + ".scopes", "utf8"), "read:user,write:issue,write:repository\n");
assert.equal((await stat(file)).mode & 0o777, 0o600);
// Said that it minted, and where it keeps it — never what it is.
assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n"));
@@ -268,32 +270,45 @@ test("the forge rejects the kept token (its data was restored): minted afresh, o
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
});
test("a kept token from before write:admin: the forge refuses the admin route for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
test("a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one", async () => {
const { env, file, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const old = forge.tokens.get("mesh-tools")!;
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user", "write:admin"]);
await rm(file + ".scopes"); // an earlier build kept no scopes beside it
const before = forge.mints;
const again: string[] = [];
await minted(env, again).listRepos();
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
assert.ok(!forge.lastScopes!.includes("write:admin"));
assert.notEqual(forge.tokens.get("mesh-tools"), old);
assert.ok(![...forge.tokens.values()].includes(old), "the old token still stands on the forge");
assert.ok(again.some((l) => l.includes("minted with other scopes")), again.join("\n"));
// An admin route through the escape hatch is refused before it is sent.
const tool = getGiteaTools(minted(env, again)).find((t) => t.name === "gitea_api")!;
await assert.rejects(tool.run({ path: "/admin/users/mesh_novox_builder", method: "PATCH", body: { admin: true } }),
/not a write gitea_api makes/);
});
test("a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
const { env, file, logs } = await delivered(forge);
const client = minted(env, logs);
await client.listRepos();
const before = forge.mints;
const old = forge.tokens.get("mesh-tools")!;
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user"]); // minted by the previous build
forge.scopesOf.set(old, ["write:repository", "write:issue"]); // minted before read:user was added
const user = await client.api<{ login: string; is_admin: boolean }>("/admin/users/mesh_novox_builder", {
method: "PATCH",
body: JSON.stringify({ admin: true }),
});
const repos = await client.api<unknown[]>("/user/repos");
assert.equal(user.is_admin, true);
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
assert.notEqual(forge.tokens.get("mesh-tools"), old);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
// A 403 that is not about scopes is the forge's answer, not a reason to mint.
const again = forge.mints;
await assert.rejects(
client.api("/admin/users/untouchable", { method: "PATCH", body: JSON.stringify({ admin: true }) }),
/403 .*untouchable/,
);
assert.equal(forge.mints, again);
});
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {
@@ -411,7 +426,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_list_branches",
"gitea_delete_branch",
"gitea_branch_protection_get", "gitea_branch_protection_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_contains", "gitea_commit_status",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_contains", "gitea_commit_status",
"gitea_list_labels", "gitea_create_label",
"gitea_api",
],
+26 -6
View File
@@ -34,15 +34,18 @@ export const TOKEN_NAME = "mesh-tools";
* It sits under the `user` category despite listing repositories, not `repository`
* — confirmed against the running forge (1.27.3), which answered
* `required=[read:user]` to a token carrying only the other two.
* write:admin — /admin/users: the forge's own users are the mesh's to settle, such as making
* the builder's login a site admin so every repository the mesh may build is
* clonable (novox/hq 229). Nothing under /orgs or write:user.
*
* **Not write:admin** (novox/hq ADR 0266). It was here so a person could settle the forge's users through
* `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the
* operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token.
* A token kept with a different list is minted again with this one at the next start (the scopes are kept
* beside it), and the forge drops the old one by name — so the scope a kept token had does not outlive this.
*
* A token kept from before a scope was added lacks it: the forge answers such a call with
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
* 401 — the source re-mints by name, with the whole list, and the call is retried once.
*/
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "write:admin"];
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"];
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource {
@@ -153,11 +156,12 @@ export class MintedToken implements TokenSource {
if (!this.readFile) {
this.readFile = true;
const kept = this.read();
if (kept !== null) {
if (kept !== null && this.keptScopes() === this.scopesLine()) {
this.held = kept;
this.log(`reusing the token kept at ${this.opts.file}`);
return kept;
}
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them");
}
return this.mint("no token kept — minting one");
}
@@ -202,13 +206,29 @@ export class MintedToken implements TokenSource {
}
}
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */
/** The scopes as kept beside the token: sorted, one line. */
private scopesLine(): string {
return [...this.scopes].sort().join(",");
}
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
private keptScopes(): string {
try {
return readFileSync(`${this.opts.file}.scopes`, "utf8").trim();
} catch {
return "";
}
}
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. Its scopes beside it. */
private keep(token: string): void {
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
const tmp = `${this.opts.file}.tmp`;
writeFileSync(tmp, token + "\n", { mode: 0o600 });
chmodSync(tmp, 0o600);
renameSync(tmp, this.opts.file);
writeFileSync(`${this.opts.file}.scopes.tmp`, this.scopesLine() + "\n", { mode: 0o600 });
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
}
private async doMint(): Promise<string> {
+94 -11
View File
@@ -13,6 +13,10 @@ import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, pathSegments, routedPath, sentQuery,
ruleCovers,
} from "../guard.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
@@ -37,7 +41,26 @@ function parseLabels(raw: unknown): string[] {
.filter(Boolean);
}
/** Every tool's repository owner and name checked before it runs: a name is never another path (guard.ts). */
function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
return tools.map((t) => ({
...t,
run: async (args: Record<string, unknown>) => {
if (args.owner !== undefined) checkName("owner", args.owner);
if (args.repo !== undefined) checkName("name", args.repo);
if ((t.name === "gitea_create_repo" || t.name === "gitea_delete_repo") && args.name !== undefined) {
checkName("name", args.name);
}
return t.run(args);
},
}));
}
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea));
}
function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
return [
// ---- Repositories ----
{
@@ -72,14 +95,23 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_delete_repo",
description: "Delete a repository. Destructive and irreversible — requires confirm=true.",
description: "Delete a repository of the tools' own user that no protection rule guards and that had no commit " +
"in 30 days. Destructive and irreversible — confirm must be the repository's full name, owner/name. An " +
"organisation's or a person's repository is the operator's to delete, at the forge (novox/hq ADR 0266).",
input: {
owner: { type: "string", description: "the repository owner" },
name: { type: "string", description: "the repository name" },
confirm: { type: "boolean", description: "must be true to actually delete" },
confirm: { type: "string", description: "the repository's full name, owner/name, to actually delete it" },
},
run: async (args) => {
if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" };
const owner = String(args.owner), name = String(args.name), full = `${owner}/${name}`;
const self = (await gitea.api<{ login?: string }>("/user"))?.login ?? "";
const commits = await gitea.api<Array<{ created?: string; commit?: { committer?: { date?: string } } }>>(
`/repos/${owner}/${name}/commits?limit=1`).catch(() => []);
const when = commits?.[0]?.commit?.committer?.date ?? commits?.[0]?.created;
const refused = judgeDelete(full, args.confirm, owner, self, await gitea.branchProtections(owner, name),
when ? new Date(when) : null, new Date());
if (refused) throw new Refused(refused);
await gitea.deleteRepo(String(args.owner), String(args.name));
return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` };
},
@@ -230,7 +262,9 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_merge_pull_request",
description: "Merge a pull request, optionally deleting the source branch afterwards.",
description: "Merge a pull request whose head has every status its base branch's protection requires as a success " +
"(mesh/merge-gate, mesh/repo-check), optionally deleting the source branch afterwards. Refused otherwise — no " +
"tool merges past a check — and only the head that was checked is merged.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -243,8 +277,28 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const repo = String(args.repo);
const number = Number(args.number);
const method = args.method ? String(args.method) : "merge";
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
// rule that lets it. A rule that cannot be read is no rule passed.
const pull = await gitea.getPullRequest(owner, repo, number);
if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`);
if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`);
const rule = await gitea.branchProtection(owner, repo, pull.base);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
// branch nothing guards would merge past every check (novox/hq ADR 0266).
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` +
`the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`);
}
const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses);
const verdict = mergeableUnder(pull.base, rule, statuses);
if (verdict.mergeable !== true) {
throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` +
`its checks succeed; no tool merges past them (novox/hq ADR 0266)`);
}
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha);
// The merge commit only exists now; answered so the caller can follow what is built from it.
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
const merged = await gitea.getPullRequest(owner, repo, number);
@@ -395,7 +449,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_branch_protection_set",
description: "Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -407,12 +461,19 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
if (!branch) throw new Error("name the branch to protect");
pathSegments("branch", branch); // a rule's name is a branch's, never a path's
const before = await gitea.branchProtection(owner, repo, branch);
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, {
const want = {
statusChecks: String(args.status_checks ?? "").split(","),
push: args.push === undefined ? undefined : Boolean(args.push),
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
});
};
const covering = before ? [] : (await gitea.branchProtections(owner, repo))
.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), branch));
const weakens = judgeProtectionChange(before, want, covering);
if (weakens) throw new Refused(weakens);
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, want);
return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) };
},
},
@@ -492,6 +553,12 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
merge: mergeableUnder(base, rule, statuses, unreadable) };
},
},
{
name: "gitea_open_pulls",
description: "Every open pull request of every repository, from the forge itself: its head, its base, the statuses the base's protection requires and the ones its head has, in any state. What mesh-delivery reads to say a pull request the mesh was never asked to check (novox/hq issue 347). Reads only.",
input: {},
run: async () => ({ pulls: await gitea.openPulls() }),
},
{
name: "gitea_contains",
description: "Whether a commit is contained in another — an ancestor of it, or the same — from the forge's compare of the two: what mesh-delivery reads before it says a held merge is covered by a later one (novox/hq ADR 0270). Commits only, by sha or the start of one. Reads only.",
@@ -562,15 +629,31 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// ---- Escape hatch ----
{
name: "gitea_api",
description: "Make an authenticated Gitea API call for any endpoint without a dedicated tool. Path is relative to /api/v1.",
description: "Make an authenticated Gitea API call for an endpoint without a dedicated tool. Path is relative to /api/v1. " +
"Reads anything but the forge's administration; writes only what an author writes — issues, comments, labels, " +
"pull requests (not their merge: gitea_merge_pull_request), branches, and files on a branch that is not the " +
"default branch and that no protection rule names. Administration, protection, settings, collaborators, teams, " +
"hooks, keys, tokens and statuses are refused (novox/hq ADR 0266).",
input: {
path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" },
method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" },
body: { type: "object", description: "JSON request body for POST/PUT/PATCH" },
},
run: async (args) => {
const method = args.method ? String(args.method) : "GET";
const result = await gitea.api(String(args.path), {
const method = (args.method ? String(args.method) : "GET").toUpperCase();
const judged = judgeApiCall(method, String(args.path ?? ""), args.body);
if ("refused" in judged) throw new Refused(judged.refused);
if ("contents" in judged) {
const { owner, repo, branch, newBranch } = judged.contents;
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
const defaultBranch = String(found?.default_branch ?? "main");
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
await gitea.branchProtections(owner, repo));
if (refused) throw new Refused(refused);
}
// The path judged is the path sent: normalised, with only the allow-listed query keys (guard.ts).
const raw = String(args.path ?? "");
const result = await gitea.api(routedPath(raw) + sentQuery(raw), {
method,
...(args.body ? { body: JSON.stringify(args.body) } : {}),
});
+64
View File
@@ -0,0 +1,64 @@
# mesh-cli
The operator's command on every node (novox/hq ADR 0272): `mesh-cli <command line>` asks the controller to run one
of its command lines, from wherever the operator is. In interactive zsh it is also `nox`.
```
nox status
nox settings show zsh --node <node>
nox settings set zsh '{"execute":"withhold"}' --node <node>
```
## What it places
| What | Where | How |
|---|---|---|
| the program | `/usr/local/bin/mesh-cli` | the `program` bundle, unpacked by an `archive` resource: the node-engine moves the one file in, refuses to write over a file it did not place, and takes out only that file when the module goes |
| the alias `nox` | the zsh module's mesh block in `~/.zshrc` | a `shell` contribution in the `normal` slot (ADR 0204) |
No link and no second copy. `/usr/local/bin` is on every account's `PATH` already, so the module contributes no
environment. The alias is interactive zsh only: a script, `sudo`, a cron line and any document meant to work
everywhere say `mesh-cli`. In interactive zsh the alias hides any other `nox` (Arch's `python-nox` installs
`/usr/bin/nox`); `command nox` still reaches it.
## How it is answered
`mesh-cli` holds no credential and reads no file of the mesh. It writes one request to the node-engine's socket
(the SDK's `go/cli`: the line, as words) and prints the one answer.
1. The node-engine reads which account is asking from the kernel, never from the program, and asks the controller
on its own node's subject.
2. The controller judges (ADR 0272 §4):
- **the controller's terminal**: the node's operator account, on the control-node. The line runs as the
operator typed it there.
- **an ordinary call**: the node's operator account on any other node, where agents may run as that account.
It meets every refusal of the generic `command` verb, and a change made at the terminal alone is refused with
the reason.
- **refused**: any other account, root included. Run it as yourself, not under `sudo`.
3. A line still running after ten seconds is followed by its call until it ends. `mesh-cli` prints standard
output and standard error as the command wrote them, says why a line was not the terminal or was refused, says
when the output was cut to fit one bus message, and exits with the command's status. When no answer comes in
time it says the line may have run and names its call; "nothing ran" is said only of a line no controller
took.
A process that runs for a verb (it carries `MESH_VERB`) is refused before anything is asked: a verb is never the
operator.
## What it does not do yet
- **No standard input.** A command that reads one gets nothing, and fails saying so.
- **No local files.** A path in a line names a file where the controller runs, read by the controller's account.
Give settings inline: `nox settings set <module> "$(cat settings.json)" --node <node>`.
- **No streaming.** The answer arrives when the command ends, within the controller's bound on a call (five
minutes). Interrupting `mesh-cli` stops the wait, not the command.
- **The `MESH_VERB` refusal is a courtesy.** A verb's process is refused before asking, but the gate is the
controller: it judges the account the node-engine vouches for, whatever the program says.
- **No other node is the terminal.** Phase 2 of ADR 0272 adds a node whose agents run under an account of their
own (ADR 0266), judged unable to become root.
## Tests
`go test ./...`: the module places exactly one file (one archive resource, one Go bundle compiled to the binary
alone); the line is asked as given and the answer printed with its exit; a reason, a refusal, a cut and an outcome not known
are said and a refusal never exits 0; a verb's process is refused before asking; an engine that does not answer
is said.
+121
View File
@@ -0,0 +1,121 @@
// Command mesh-cli is the operator's command on every node (novox/hq ADR 0272): `mesh-cli <command line>` asks the
// controller to run one of its command lines, and prints what it said. In interactive zsh it is also `nox`.
//
// It holds no credential and reads no file of the mesh. It asks the node-engine on this machine over a local
// socket; the engine reads which account is asking from the kernel and asks the controller. The controller decides:
// on the control-node, from the operator's own account, the line runs as the controller's terminal; elsewhere it is
// an ordinary call, and a change made at the terminal alone is refused with the reason; from any other account
// nothing runs.
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"git.novox.be/novox/mesh-sdk/go/cli"
)
// socket is where the node-engine answers; a variable so a test can name its own.
var socket = cli.Socket
// standardInput is mesh-cli's own standard input, and whether it is a terminal (or a device such as /dev/null),
// which is never read; a variable so a test can say.
var standardInput = func() (io.Reader, bool) {
info, err := os.Stdin.Stat()
if err != nil {
return os.Stdin, true
}
return os.Stdin, info.Mode()&os.ModeCharDevice != 0
}
// carried is what mesh-cli carries of its standard input for the line: what a pipe or a file held, at most
// cli.MaxStdin, for the controller's terminal alone (a secret given with `secret accept … --from -`). A terminal is
// never read: the line is asked without. More than the bound is refused, and nothing is asked.
func carried() ([]byte, error) {
in, terminal := standardInput()
if terminal {
return nil, nil
}
body, err := io.ReadAll(io.LimitReader(in, cli.MaxStdin+1))
if err != nil {
return nil, fmt.Errorf("standard input could not be read (%v); nothing was asked", err)
}
if len(body) > cli.MaxStdin {
return nil, fmt.Errorf("standard input is more than %d bytes, which mesh-cli does not carry; nothing was asked", cli.MaxStdin)
}
return body, nil
}
const usage = `mesh-cli — the controller's command lines, from this node (novox/hq ADR 0272)
mesh-cli <command line> e.g. mesh-cli status, mesh-cli settings show zsh --node <node>
nox <command line> the same, in interactive zsh
Run it as yourself, never under sudo: the node-engine tells the controller which account asks.
What a pipe or a file gives its standard input (at most 64 KiB) goes with the line to the controller's
terminal alone, e.g. a secret: mesh-cli secret accept <node> <module> <name> --from - < file
On the control-node, from its operator's account, the line runs as the controller's terminal.
Anywhere else it is an ordinary call, and a terminal-only change is refused with the reason.
'mesh-cli help' asks the controller for its own list of command lines.
`
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
code := run(ctx, os.Args[1:], os.Stdout, os.Stderr)
stop()
os.Exit(code)
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
fmt.Fprint(stderr, usage)
return 2
}
// A verb's own process carries the verb it runs for; a verb is never a person, so it is never the terminal.
if verb := os.Getenv("MESH_VERB"); verb != "" {
fmt.Fprintf(stderr, "mesh-cli: this process runs for the verb %q, and a verb is never the operator. "+
"Nothing was asked\n", verb)
return 1
}
in, err := carried()
if err != nil {
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
return 1
}
a, err := cli.AskWith(ctx, socket, cli.Request{Line: args, Stdin: in})
if err != nil {
if errors.Is(ctx.Err(), context.Canceled) {
fmt.Fprintln(stderr, "mesh-cli: stopped waiting; the line may still be running on the controller")
return 130
}
fmt.Fprintf(stderr, "mesh-cli: %v\n", err)
return 1
}
_, _ = stdout.Write(a.Stdout)
_, _ = stderr.Write(a.Stderr)
if a.Cut {
fmt.Fprintln(stderr, "mesh-cli: the output was longer than one bus message, and was cut there")
}
if !a.Terminal && a.Why != "" {
fmt.Fprintln(stderr, "mesh-cli: "+a.Why)
}
if a.Unknown != "" {
// Asked, and what came of it not known: never said as nothing having run.
fmt.Fprintln(stderr, "mesh-cli: "+a.Unknown)
if a.Exit == 0 {
return 1
}
}
if a.Refused != "" {
fmt.Fprintln(stderr, "mesh-cli: "+a.Refused)
if a.Exit == 0 {
return 1
}
}
return a.Exit
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"bytes"
"context"
"io"
"net"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/cli"
)
// The tests' own standard input is the runner's, which may be a pipe nobody closes: read as a terminal unless a test
// says otherwise.
func init() { standardInput = func() (io.Reader, bool) { return nil, true } }
// lastStdin is the standard input the engine was last given.
var lastStdin []byte
// engine answers one connection on a socket of the test's own as the node-engine would, and says what it was asked.
func engine(t *testing.T, answer cli.Answer) <-chan []string {
t.Helper()
path := filepath.Join(t.TempDir(), "cli.sock")
l, err := net.Listen("unix", path)
if err != nil {
t.Skipf("no unix socket here: %v", err)
}
t.Cleanup(func() { l.Close() })
before := socket
socket = path
t.Cleanup(func() { socket = before })
asked := make(chan []string, 1)
lastStdin = nil
go func() {
conn, err := l.Accept()
if err != nil {
return
}
defer conn.Close()
req, err := cli.ReadRequest(conn)
if err != nil {
return
}
lastStdin = req.Stdin
asked <- req.Line
_ = cli.WriteAnswer(conn, answer)
}()
return asked
}
// The line is asked as given, what the controller printed is printed, and its exit is mesh-cli's.
func TestTheLineIsAskedAndTheAnswerIsPrinted(t *testing.T) {
t.Setenv("MESH_VERB", "")
asked := engine(t, cli.Answer{Stdout: []byte("out\n"), Stderr: []byte("err\n"), Exit: 3, Terminal: true, Why: "the terminal"})
var out, errOut bytes.Buffer
code := run(context.Background(), []string{"settings", "set", "zsh", `{"execute":"withhold"}`}, &out, &errOut)
if line := <-asked; strings.Join(line, "|") != `settings|set|zsh|{"execute":"withhold"}` {
t.Fatalf("asked %q", line)
}
if code != 3 || out.String() != "out\n" || errOut.String() != "err\n" {
t.Fatalf("exit %d, stdout %q, stderr %q", code, out.String(), errOut.String())
}
}
// Not the terminal, refused, or cut: each is said, and a refusal never exits 0.
func TestWhyARefusalAndACutAreSaid(t *testing.T) {
t.Setenv("MESH_VERB", "")
engine(t, cli.Answer{Why: "not the controller's terminal: agents on laptop may run as operator",
Refused: "set at the controller's terminal only", Cut: true})
var out, errOut bytes.Buffer
code := run(context.Background(), []string{"settings", "set", "x", "{}"}, &out, &errOut)
said := errOut.String()
if code == 0 || !strings.Contains(said, "not the controller's terminal") || !strings.Contains(said, "terminal only") ||
!strings.Contains(said, "cut") {
t.Fatalf("exit %d, said %q", code, said)
}
}
// A verb's own process is never the operator: refused before anything is asked.
func TestAVerbsProcessIsRefusedBeforeAsking(t *testing.T) {
t.Setenv("MESH_VERB", "command")
before := socket
socket = filepath.Join(t.TempDir(), "nothing-listens.sock")
t.Cleanup(func() { socket = before })
var out, errOut bytes.Buffer
if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 ||
!strings.Contains(errOut.String(), "never the operator") {
t.Fatalf("exit %d, said %q", code, errOut.String())
}
}
// An engine that does not answer is said, with where it was asked.
func TestAnEngineThatDoesNotAnswerIsSaid(t *testing.T) {
t.Setenv("MESH_VERB", "")
before := socket
socket = filepath.Join(t.TempDir(), "nothing-listens.sock")
t.Cleanup(func() { socket = before })
var out, errOut bytes.Buffer
if code := run(context.Background(), []string{"status"}, &out, &errOut); code != 1 ||
!strings.Contains(errOut.String(), "does not answer") {
t.Fatalf("exit %d, said %q", code, errOut.String())
}
}
// An outcome not known is said as such — the line may have run — and never as nothing having run.
func TestAnOutcomeNotKnownIsSaid(t *testing.T) {
t.Setenv("MESH_VERB", "")
engine(t, cli.NotKnown("call-4", "no answer within 6m0s"))
var out, errOut bytes.Buffer
code := run(context.Background(), []string{"push", "laptop"}, &out, &errOut)
if code == 0 || !strings.Contains(errOut.String(), "may have run") || !strings.Contains(errOut.String(), "call-4") {
t.Fatalf("exit %d, said %q", code, errOut.String())
}
}
// A pipe's or a file's standard input goes with the line, whole; a terminal's is never read; more than the bound is
// refused and nothing is asked (novox/hq ADR 0259 §10).
func TestStandardInputGoesWithTheLineAndATerminalIsNeverRead(t *testing.T) {
t.Setenv("MESH_VERB", "")
was := standardInput
t.Cleanup(func() { standardInput = was })
secret := "123456789:the-bot-token\n"
standardInput = func() (io.Reader, bool) { return strings.NewReader(secret), false }
asked := engine(t, cli.Answer{Terminal: true})
var out, errOut bytes.Buffer
if code := run(context.Background(), []string{"secret", "accept", "anchor", "telegram", "telegram-token", "--from", "-"},
&out, &errOut); code != 0 {
t.Fatalf("exit %d: %s", code, errOut.String())
}
<-asked
if string(lastStdin) != secret {
t.Fatalf("the engine was given %q", lastStdin)
}
if strings.Contains(out.String()+errOut.String(), "bot-token") {
t.Fatal("mesh-cli printed what it carried")
}
standardInput = func() (io.Reader, bool) { return strings.NewReader("typed"), true }
asked = engine(t, cli.Answer{})
_ = run(context.Background(), []string{"status"}, &out, &errOut)
<-asked
if len(lastStdin) != 0 {
t.Fatalf("a terminal's input was read: %q", lastStdin)
}
standardInput = func() (io.Reader, bool) { return bytes.NewReader(make([]byte, cli.MaxStdin+1)), false }
errOut.Reset()
if code := run(context.Background(), []string{"secret", "accept", "a", "b", "c", "--from", "-"}, &out, &errOut); code == 0 ||
!strings.Contains(errOut.String(), "nothing was asked") {
t.Fatalf("more than the bound was asked: %d %s", code, errOut.String())
}
}
@@ -0,0 +1,56 @@
package main
import (
"encoding/json"
"os"
"testing"
)
// **The archive places exactly one file, /usr/local/bin/mesh-cli** (review of novox/hq ADR 0272): one archive
// resource, unpacking one artifact into /usr/local/bin, and that artifact a compiled Go bundle — the builder writes
// such a bundle as its binary alone (`go build -o <out>/<binary>`), named mesh-cli — with nothing the runtime would
// load or put beside it. A second file in the bundle, or a second resource writing there, fails here.
func TestTheArchivePlacesExactlyOneFile(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
if len(m.Resources) != 1 {
t.Fatalf("the module declares %d resources; it places one file", len(m.Resources))
}
r := m.Resources[0]
if r["type"] != "archive" || r["path"] != "/usr/local/bin" {
t.Fatalf("its resource is %v", r)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("it builds %d artifacts", len(m.Build.Artifacts))
}
a := m.Build.Artifacts[0]
if a["name"] != r["artifact"] || a["kind"] != "bundle" || a["language"] != "go" || a["binary"] != "mesh-cli" ||
a["from"] != "cmd/mesh-cli" {
t.Fatalf("its artifact is %v, unpacked as %v", a, r["artifact"])
}
for _, extra := range []string{"loads", "env", "context", "files"} {
if _, has := a[extra]; has {
t.Fatalf("its artifact says %q, which would put more than the binary in the bundle or load it", extra)
}
}
entries, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if e.IsDir() {
t.Fatalf("cmd/mesh-cli holds a directory %s, which a build could carry into the bundle", e.Name())
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module mesh-cli
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970 h1:9tFDQsgmI+4X7/BpZGXIr+HemPKE7YddYGqWV0lINAI=
git.novox.be/novox/mesh-sdk/go v0.1.11-0.20261009143344-f047d0a4a970/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+31
View File
@@ -0,0 +1,31 @@
{
"module": "mesh-cli",
"version": "1",
"resources": [
{
"id": "program",
"type": "archive",
"artifact": "program",
"path": "/usr/local/bin"
}
],
"shell": [
{
"for": "zsh",
"slot": "normal",
"code": "# The operator's command (module mesh-cli, novox/hq ADR 0272): nox is mesh-cli, in interactive zsh only.\n# A script, sudo and a document meant to work everywhere say mesh-cli.\nalias nox=mesh-cli\n"
}
],
"build": {
"artifacts": [
{
"name": "program",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-cli",
"binary": "mesh-cli"
}
]
}
}
@@ -474,3 +474,197 @@ func TestAMemberHeldForItsComposedCheckIsLiftedWhenItPasses(t *testing.T) {
})
}
}
// **A module a release carried to every machine the held delivery sends it to covers it** (novox/hq issue
// 351). A build the controller held back for a person and released on their word reaches each machine by a
// backlog walk, which credits the delivery that published the build one step per machine — never the walk's
// own "every machine running it" or "the rest running it". The held delivery's plan says which machines
// receive the module: a passed step on each of them is the module sent everywhere. A machine still judging,
// one that failed, or a plan that names no machines leaves it held, as before.
func TestAModuleAReleaseCarriedToEveryMachineCovers(t *testing.T) {
const cat = "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
laptop, desktop := "laptop", "desktop"
plan := func(machines ...string) *DeliveryPlan {
p := planMoving(cat, "openrazer")
for _, m := range machines {
p.Machines = append(p.Machines, MachinePlan{Machine: m, Receives: []string{"openrazer"}})
}
return p
}
held := func(p *DeliveryPlan) *Delivery {
why := "its group feat/j's composed check did not pass for the heads that merged; a person decides that it goes on"
return &Delivery{ID: IDOf(cat, head), Repository: cat, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: p, Walk: &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"},
Transitions: []Transition{{At: merged1, From: Published, To: Held, Event: EvHold, Why: why}}}
}
step := func(machine string, state StepState) Step {
return Step{Machine: machine, Module: "openrazer", Build: "build-1", State: state, Why: "carried by release-1"}
}
coverer := func(steps ...Step) *Delivery {
return &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: append([]Step{{Machine: "anchor", Module: "power", State: StepPassed},
{Machine: "the rest running it", Module: "power", State: StepSent}}, steps...)}
}
for _, c := range []struct {
name string
held *Delivery
by *Delivery
want State
}{
{"passed on every machine its plan sends it to", held(plan(laptop, desktop)),
coverer(step(laptop, StepPassed), step(desktop, StepPassed)), Covered},
{"still judging on one of them", held(plan(laptop, desktop)),
coverer(step(laptop, StepPassed), step(desktop, StepJudging)), Held},
{"failed on one of them", held(plan(laptop, desktop)),
coverer(step(laptop, StepPassed), step(desktop, StepFailed)), Held},
{"never sent to one of them", held(plan(laptop, desktop)), coverer(step(laptop, StepPassed)), Held},
{"its plan names no machines", held(plan()), coverer(step(laptop, StepPassed), step(desktop, StepPassed)), Held},
} {
t.Run(c.name, func(t *testing.T) {
store := newMemStore()
for _, d := range []*Delivery{c.held, c.by} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, merged2.Add(30*time.Hour))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
d := w.delivery(c.held.ID)
if d.State != c.want {
t.Fatalf("it is %s (%s), want %s", d.State, d.HeldWhy, c.want)
}
if c.want == Covered {
if s := statusOn(w, cat, head); !strings.HasPrefix(s, "success covered: a later merge that contains it was delivered") {
t.Fatalf("its pull request's mesh/delivery says %q", s)
}
if last := d.Transitions[len(d.Transitions)-1]; !strings.Contains(last.Why, "openrazer") {
t.Fatalf("its transition does not name the module: %q", last.Why)
}
}
})
}
}
// **A finished release moves the steps it carried to passed** (novox/hq issue 351): a backlog walk's gate
// credits a step per machine while it judges, and its last record keeps no gate — only which machines it is
// done on. A step credited to it, on a machine it is done on, is passed; a step it failed on, one on a machine
// it is not done on, and a step another walk carried are left as they are.
func TestAFinishedReleaseMovesTheStepsItCarriedToPassed(t *testing.T) {
const cat = "novox/mesh-catalog"
carried := func(machine string, state StepState, by string) Step {
return Step{Machine: machine, Module: "openrazer", Build: "build-1", State: state, Why: "carried by " + by}
}
for _, c := range []struct {
name string
steps []Step
done []string
want map[string]StepState
}{
{"done on both", []Step{carried("laptop", StepJudging, "release-1"), carried("desktop", StepJudging, "release-1")},
[]string{"laptop", "desktop"}, map[string]StepState{"laptop": StepPassed, "desktop": StepPassed}},
{"done on one, the other still to come", []Step{carried("laptop", StepJudging, "release-1"), carried("desktop", StepJudging, "release-1")},
[]string{"laptop"}, map[string]StepState{"laptop": StepPassed, "desktop": StepJudging}},
{"a step it failed on stays failed", []Step{carried("laptop", StepFailed, "release-1")},
[]string{"laptop"}, map[string]StepState{"laptop": StepFailed}},
{"a step another walk carried is not its", []Step{carried("laptop", StepJudging, "release-2")},
[]string{"laptop"}, map[string]StepState{"laptop": StepJudging}},
} {
t.Run(c.name, func(t *testing.T) {
d := &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: t0.Add(time.Hour), Created: t0, MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: t0,
Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone}, Steps: c.steps}
store := newMemStore()
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
w := newWorldOver(t, store, newFakeController(), newFakeForge(), t0.Add(2*time.Hour))
w.h.WalkMoved(Walk{ID: "release-1", Repository: "the builds waiting for a gate", State: walkDone,
Tiers: [][]string{{"openrazer"}}, Modules: map[string]*WalkModule{},
Release: &WalkRelease{Order: []string{"laptop", "desktop"}, Next: len(c.done), Done: c.done}})
got := map[string]StepState{}
for _, s := range w.delivery(d.ID).Steps {
got[s.Machine] = s.State
}
for machine, want := range c.want {
if got[machine] != want {
t.Fatalf("openrazer on %s is %s, want %s (steps %v)", machine, got[machine], want, got)
}
}
})
}
}
// The day's case whole (novox/hq issue 351): a member held for its group's check, its own walk taken over by
// later merges; the one later walk that carried its module failed its gate; a later merge's walk was delivered
// without the module; the controller then held the module's build back for a person, who released it, and the
// release reached both machines. The held delivery is covered by that later merge once the release is done —
// read by its id, since a walk that ended before the holder's restart may be older than the ended walks the
// controller lists back.
func TestAHeldDeliveryWhoseModuleAReleaseCarriedIsCoveredOnceTheReleaseIsDone(t *testing.T) {
const cat = "novox/mesh-catalog"
merged1, merged2 := t0, t0.Add(2*time.Hour)
plan := planMoving(cat, "openrazer")
plan.Machines = []MachinePlan{{Machine: "laptop", Receives: []string{"openrazer"}}, {Machine: "desktop", Receives: []string{"openrazer"}}}
why := "its group feat/j's composed check did not pass for the heads that merged; a person decides that it goes on"
held := &Delivery{ID: IDOf(cat, head), Repository: cat, Commit: head, Number: 7, Base: "main", State: Held,
Since: merged1.Add(10 * time.Minute), Created: merged1.Add(-time.Hour), MergedAs: merge, MergedAt: merged1,
HeldWhy: why, Plan: plan, Walk: &WalkSeen{ID: "plan-7", Commit: merge, State: walkSuperseded, Waits: true, WaitedFor: "mesh-delivery"},
Transitions: []Transition{{At: merged1, From: Published, To: Held, Event: EvHold, Why: why}}}
later := &Delivery{ID: IDOf(cat, "c0c0c0c0c0c0c0c0"), Repository: cat, Commit: "c0c0c0c0c0c0c0c0", Number: 90,
Base: "main", State: Delivered, Since: merged2.Add(time.Hour), Created: merged2.Add(-time.Hour),
MergedAs: "f0f0f0f0f0f0f0f0", MergedAt: merged2, Plan: planMoving(cat, "power"), Check: &Verdict{Gate: "pass"},
Walk: &WalkSeen{ID: "plan-90", Commit: "f0f0f0f0f0f0f0f0", State: walkDone},
Steps: []Step{{Machine: "anchor", Module: "power", State: StepPassed}, {Machine: "the rest running it", Module: "power", State: StepSent}}}
store := newMemStore()
for _, d := range []*Delivery{held, later} {
if err := store.PutDelivery(d); err != nil {
t.Fatal(err)
}
}
forge := newFakeForge()
forge.contains[merge+" in f0f0f0f0f0f0f0f0"] = true
w := newWorldOver(t, store, newFakeController(), forge, merged2.Add(3*time.Hour))
if err := w.h.Reconcile(); err != nil {
t.Fatal(err)
}
w.settleAll()
if s := w.state(held.ID); s != Held {
t.Fatalf("before any release it is %s", s)
}
// The release judges the build on each machine in turn: its gate names what it carried.
release := Walk{ID: "release-1", Repository: "the builds waiting for a gate", State: walkRolling,
Tiers: [][]string{{"openrazer"}}, Modules: map[string]*WalkModule{},
Release: &WalkRelease{Order: []string{"laptop", "desktop"}, Gate: &WalkGate{Machines: []string{"laptop"},
Carried: []WalkCarry{{Module: "openrazer", Node: "laptop", To: "f0f0f0f0f0f0f0f0", Build: "build-1"},
{Module: "openrazer", Node: "desktop", To: "f0f0f0f0f0f0f0f0", Build: "build-1"}}}}}
w.h.WalkMoved(release)
w.settleAll()
if s := w.state(held.ID); s != Held {
t.Fatalf("while the release judges it is %s", s)
}
// Done on both: its last record keeps no gate. It ended while this holder was down, and is older than the
// ended walks the controller lists back: the holder asks for it by its id, since a step it carried still judges.
release.State, release.Release.Gate, release.Release.Done, release.Release.Next = walkDone, nil, []string{"laptop", "desktop"}, 2
w.ctl.put(release)
w.later(time.Hour) // a walk a step still judges is asked for once an hour; at a start, at once
w.settleAll()
d := w.delivery(held.ID)
if d.State != Covered {
t.Fatalf("once the release is done it is %s (%s), want covered", d.State, d.HeldWhy)
}
if last := d.Transitions[len(d.Transitions)-1]; !strings.Contains(last.Why, IDOf(cat, "c0c0c0c0c0c0c0c0")) ||
!strings.Contains(last.Why, "openrazer") {
t.Fatalf("its transition does not name what covered it: %q", last.Why)
}
}
@@ -239,6 +239,18 @@ type fakeForge struct {
contains map[string]bool
containsDown bool
containsAsks []string
// open is what OpenPulls answers; openDown a forge that cannot say.
open []OpenPull
openDown bool
}
func (f *fakeForge) OpenPulls() ([]OpenPull, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.openDown {
return nil, errors.New("the forge is away")
}
return append([]OpenPull(nil), f.open...), nil
}
func newFakeForge() *fakeForge {
@@ -40,7 +40,11 @@ type Holder struct {
unreached string
// askTried is when a check this owner asks was last tried and could not be asked: tried again a minute on.
askTried map[string]time.Time
refusals []string
// walkAsked is when a walk that carried a step still judging was last asked for by its id: once an hour.
walkAsked map[string]time.Time
refusals []string
// unchecked are the open pull requests' heads seen without a merge check (unchecked.go).
unchecked map[string]unchecked
}
// unmatched is a walk no delivery is known for yet: its merge not heard, or merged before this owner held.
@@ -80,6 +84,8 @@ func (h *Holder) init() {
h.dirtyGroup = map[string]bool{}
h.unmatched = map[string]unmatched{}
h.askTried = map[string]time.Time{}
h.walkAsked = map[string]time.Time{}
h.unchecked = map[string]unchecked{}
h.started = h.Now()
}
@@ -643,6 +649,58 @@ func (h *Holder) creditBacklog(w Walk) {
if w.Release.Gate != nil {
h.creditCarried(w.Release.Gate, w.ID, "")
}
if w.State == walkDone {
h.creditReleased(w)
}
}
// creditReleased passes the steps a finished backlog walk carried: its last record keeps no gate, only the
// machines it is done on, and a release ends done only when its gate passed on each of them (novox/hq issue
// 351). A step it failed on, and a step another walk carried, are left as they are.
func (h *Holder) creditReleased(w Walk) {
why := carriedBy + w.ID
for _, d := range h.deliveries {
moved := false
for _, s := range d.Steps {
if s.Why != why || !containsString(w.Release.Done, s.Machine) || s.State != StepSent && s.State != StepJudging {
continue
}
s.State, s.Since = StepPassed, h.Now()
if h.step(d, s) {
moved = true
}
}
if moved {
h.keep(d)
}
}
}
// carriedBy begins the why of a step another walk's send carried, followed by that walk's id.
const carriedBy = "carried by "
// askWalk is the ask of one walk's record by its id, once an hour while a step it carried still judges;
// what it answers is taken as said.
func (h *Holder) askWalk(id string, now time.Time) func() {
if asked, was := h.walkAsked[id]; was && now.Sub(asked) < time.Hour {
return nil
}
h.walkAsked[id] = now
return func() {
_, walks, err := h.Controller.Walks(id)
h.mu.Lock()
defer h.mu.Unlock()
if err != nil {
h.Logf("[mesh-delivery] the walk %s, which carried a step still judging, cannot be read yet: %v", id, err)
h.walkAsked[id] = h.Now().Add(time.Minute - time.Hour) // asked again a minute on
return
}
for _, w := range walks {
if w.ID == id {
h.walkMoved(w)
}
}
}
}
// creditCarried gives the delivery that published each carried build its machine step.
@@ -662,7 +720,7 @@ func (h *Holder) creditCarried(g *WalkGate, walk, except string) {
continue
}
if h.step(d, Step{Machine: c.Node, Module: c.Module, Build: c.Build, State: state, Since: h.Now(),
Why: "carried by " + walk}) {
Why: carriedBy + walk}) {
h.keep(d)
}
}
@@ -778,6 +836,20 @@ func (h *Holder) plan() []func() {
}
}
// A step a walk carried and left judging: that walk's record is asked for by its id. A walk that ended
// while this holder was down may be older than the ended walks the controller lists back at its start,
// and its end is what passes the step (novox/hq issue 351).
for _, d := range h.deliveries {
for _, s := range d.Steps {
if s.State != StepSent && s.State != StepJudging || !strings.HasPrefix(s.Why, carriedBy) {
continue
}
if a := h.askWalk(strings.TrimPrefix(s.Why, carriedBy), now); a != nil {
asks = append(asks, a)
}
}
}
// Merged, and no walk opened for it after the walks were read again: never silent — held, saying so.
for _, d := range h.deliveries {
if h.unwalkedPast(d, now) {
@@ -881,7 +953,11 @@ func (h *Holder) coverOf(d *Delivery) (string, []string) {
// carries is whether c delivered a module after d: of the same repository, merged later into the same trunk,
// delivered on its own passing check — a merge held without one is covered only by a later merge that passed
// its own — by its own walk, of its merge commit on that trunk, which sent the module to every machine running
// it. Whether c's merge commit contains d's is the forge's to say (coverOf).
// it. Its machine steps say so in one of two ways: the walk's own record, which sends to every machine at once
// or to the rest after its first machines passed; or, for a build the controller held back and a person
// released, one step per machine credited by the backlog walk that carried it — passed on every machine d's
// plan sends the module to (novox/hq issue 351). Whether c's merge commit contains d's is the forge's to say
// (coverOf).
func carries(c, d *Delivery, module string) bool {
if c.ID == d.ID || c.State != Delivered || !c.Check.Passes() || !strings.EqualFold(c.Repository, d.Repository) ||
!strings.EqualFold(c.Base, d.Base) || c.MergedAs == "" || sameCommit(c.MergedAs, d.MergedAs) ||
@@ -890,6 +966,7 @@ func carries(c, d *Delivery, module string) bool {
return false
}
everywhere := false
passed := map[string]bool{}
for _, s := range c.Steps {
if s.Module != module {
continue
@@ -899,9 +976,23 @@ func carries(c, d *Delivery, module string) bool {
return false
case s.State == StepSent && (s.Machine == stepEveryMachine || s.Machine == stepTheRest):
everywhere = true
case s.State == StepPassed:
passed[s.Machine] = true
}
}
return everywhere
if everywhere {
return true
}
machines := d.Plan.receiving(module)
if len(machines) == 0 {
return false
}
for _, m := range machines {
if !passed[m] {
return false
}
}
return true
}
// askContains is the ask of the forge whether a later merge commit contains a delivery's merge: at most once a
@@ -68,7 +68,7 @@ func run(h *Holder, l *listening) {
go func() {
tick := time.NewTicker(10 * time.Second)
defer tick.Stop()
last := time.Now()
last, looked := time.Now(), time.Time{}
for now := range tick.C {
if now.Sub(last) >= 30*time.Second {
last = now
@@ -76,6 +76,11 @@ func run(h *Holder, l *listening) {
logf("[mesh-delivery] the controller's walks cannot be read: %v", err)
}
}
// Every open pull request, from the forge itself, every five minutes (novox/hq issue 347).
if now.Sub(looked) >= 5*time.Minute {
looked = now
h.SeeOpenPulls()
}
h.Tick()
h.Flush()
}
@@ -65,7 +65,7 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
want = append(want, "seat:"+ControllerSeat+"."+v)
}
want = append(want, "gitea.gitea_note_append", "gitea.gitea_delivery_view", "gitea.gitea_commit_status",
"gitea.gitea_commit_statuses", "gitea.gitea_contains")
"gitea.gitea_commit_statuses", "gitea.gitea_contains", "gitea.gitea_open_pulls")
if !reflect.DeepEqual(m.Invokes, want) {
t.Fatalf("invokes %v, wanted %v", m.Invokes, want)
}
@@ -144,6 +144,21 @@ type DeliveryPlan struct {
Summary string `json:"summary"`
}
// receiving are the machines the plan sends a module to, in the plan's order; none when the plan names no
// machines, as a plan kept before they were said may not.
func (p *DeliveryPlan) receiving(module string) []string {
if p == nil {
return nil
}
var out []string
for _, m := range p.Machines {
if containsString(m.Receives, module) {
out = append(out, m.Machine)
}
}
return out
}
// MachinePlan is one machine's part of a plan.
type MachinePlan struct {
Machine string `json:"machine"`
@@ -78,6 +78,9 @@ type Forge interface {
Statuses(owner, repo string, number int, commit, base string) (*ForgeChecks, error)
// Contains is whether a commit is contained in another — an ancestor of it, or it. Reads only.
Contains(owner, repo, commit, in string) (bool, error)
// OpenPulls is every open pull request, with what its base requires and what its head has (novox/hq issue
// 347). Reads only.
OpenPulls() ([]OpenPull, error)
}
// ForgeChecks is what the forge says of a commit's checks (the gitea module's gitea_commit_statuses).
@@ -339,6 +342,24 @@ func (f toolForge) Statuses(owner, repo string, number int, commit, base string)
return &c, nil
}
func (f toolForge) OpenPulls() ([]OpenPull, error) {
raw, err := f.ask("gitea.gitea_open_pulls", map[string]any{})
if err != nil {
return nil, err
}
answer, output, ok := answerOf(raw)
if !ok {
return nil, fmt.Errorf("the forge refused gitea_open_pulls: %s", lastLine(output))
}
var open struct {
Pulls []OpenPull `json:"pulls"`
}
if err := json.Unmarshal(answer, &open); err != nil || open.Pulls == nil {
return nil, fmt.Errorf("the forge's gitea_open_pulls is not readable: %s", clip(string(answer), 200))
}
return open.Pulls, nil
}
func (f toolForge) Contains(owner, repo, commit, in string) (bool, error) {
raw, err := f.ask("gitea.gitea_contains", map[string]any{"owner": owner, "repo": repo, "commit": commit, "in": in})
if err != nil {
@@ -0,0 +1,112 @@
package main
import (
"fmt"
"slices"
"time"
)
// A pull request the mesh was never asked to check (novox/hq issue 347).
//
// The forge's announcer is the only way the mesh hears of a pull request's head, and on 2026-10-09 it missed one: a
// pull request opened after its push moved nothing the announcer read, and waited unchecked, unable to merge, with
// nothing saying so. This owner asks the forge itself for every open pull request every few minutes, and one whose
// base requires the merge check — mesh/merge-gate or mesh/repo-check — with neither on its head for UncheckedAfter
// since this owner first saw it so is said as a delivery held: a `stalled` line in the state Unannounced, which the
// controller raises as `delivery.<id>.stalled` (D14) like any other delivery past its bound. The delivery domain's own
// word, so no second condition and no second judge: the id is the one its delivery will have once announced.
// Unannounced is a pull request's head the forge never announced: not a state of the table — no delivery exists —
// only what a stalled line says.
const Unannounced State = "unannounced"
// UncheckedAfter is how long a head may go without a merge check before it is said: the announcer passes every half
// minute and the check is asked at once, so ten minutes is a fault, never a queue.
const UncheckedAfter = 10 * time.Minute
// mergeChecks are the statuses that are the mesh's merge check (novox/hq ADR 0237): a base requiring either is
// one the mesh is meant to check.
var mergeChecks = []string{"mesh/merge-gate", "mesh/repo-check"}
// OpenPull is an open pull request as the forge says it (the gitea module's gitea_open_pulls): its head, its base,
// the contexts the base's protection requires, and the contexts its head has a status of, in any state.
type OpenPull struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
Base string `json:"base"`
HeadSHA string `json:"head_sha"`
HTMLURL string `json:"html_url,omitempty"`
Required []string `json:"required"`
Statuses []string `json:"statuses"`
}
// unchecked is a head seen without a merge check, and when this owner first saw it so — its own time, since the
// forge's update time moves on every comment.
type unchecked struct {
pull OpenPull
first time.Time
}
// wantsACheck is whether the base requires the mesh's merge check and the head has none of it.
func (p OpenPull) wantsACheck() bool {
required := slices.ContainsFunc(mergeChecks, func(c string) bool { return slices.Contains(p.Required, c) })
checked := slices.ContainsFunc(mergeChecks, func(c string) bool { return slices.Contains(p.Statuses, c) })
return required && !checked && p.HeadSHA != ""
}
// SeeOpenPulls asks the forge for every open pull request and keeps the heads that want a check and have none.
// A forge that cannot be asked changes nothing: what was seen stands, and nothing is guessed.
func (h *Holder) SeeOpenPulls() {
open, err := h.Forge.OpenPulls()
if err != nil {
h.Logf("[mesh-delivery] the forge's open pull requests cannot be read: %v", err)
return
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
seen := map[string]unchecked{}
for _, p := range open {
if !p.wantsACheck() {
continue
}
id := IDOf(p.Owner+"/"+p.Repo, p.HeadSHA)
if h.deliveries[id] != nil {
continue // announced: a delivery with its own bound
}
first := now
if was, ok := h.unchecked[id]; ok {
first = was.first
} else {
h.Logf("[mesh-delivery] %s/%s#%d's head %s has no merge check; said if it has none in %s", p.Owner, p.Repo,
p.Number, short(p.HeadSHA), UncheckedAfter)
}
seen[id] = unchecked{pull: p, first: first}
}
h.unchecked = seen
}
// uncheckedLines are the heads unchecked past UncheckedAfter, as stalled lines; under the lock.
func (h *Holder) uncheckedLines(now time.Time) []StalledLine {
var out []StalledLine
for id, u := range h.unchecked {
if h.deliveries[id] != nil || now.Sub(u.first) < UncheckedAfter {
continue
}
out = append(out, StalledLine{ID: id, Number: u.pull.Number, State: Unannounced, For: now.Sub(u.first).Round(time.Second).String(),
Bound: UncheckedAfter.String(),
H2: "none: the forge never announced it, so there is no delivery to close — the operator's",
Says: fmt.Sprintf("%s/%s#%d is open on %s, which requires the merge check, and its head has had no merge "+
"check: the mesh was never asked to check it. A new commit on its branch announces it",
u.pull.Owner, u.pull.Repo, u.pull.Number, u.pull.Base)})
}
return out
}
func short(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}
@@ -0,0 +1,80 @@
package main
import (
"strings"
"testing"
"time"
)
// **A pull request the mesh was never asked to check is a delivery held, and said** (novox/hq issue 347): one open
// on a base that requires the merge check — mesh/merge-gate or mesh/repo-check, as hq requires only the second —
// whose head has had neither for ten minutes since this owner first saw it so, is a stalled line: the controller
// raises it as `delivery.<id>.stalled` (D14), the delivery domain's own word for a delivery held past its bound.
func TestAPullRequestNobodyCheckedIsAStalledDelivery(t *testing.T) {
w := newWorld(t)
head := "bfe82315f42c8f4509d0598c32569a202d5eada6"
w.forge.open = []OpenPull{
{Owner: "novox", Repo: "hq", Number: 243, Base: "main", HeadSHA: head, Required: []string{"mesh/repo-check"}},
{Owner: "novox", Repo: "sdk", Number: 14, Base: "main", HeadSHA: "aaaa", Required: []string{"mesh/merge-gate", "mesh/repo-check"},
Statuses: []string{"mesh/merge-gate"}},
{Owner: "novox", Repo: "free", Number: 1, Base: "main", HeadSHA: "bbbb", Required: nil},
}
w.h.SeeOpenPulls()
if lines := w.h.Stalled(); len(lines) != 0 {
t.Fatalf("a head just seen is stalled already: %+v", lines)
}
w.later(6 * time.Minute)
w.h.SeeOpenPulls() // seen again: its own first-seen time stands, whatever the forge's update time says
w.later(5 * time.Minute)
lines := w.h.Stalled()
if len(lines) != 1 || lines[0].ID != IDOf("novox/hq", head) || lines[0].State != Unannounced ||
!strings.Contains(lines[0].Says, "no merge check") || !strings.HasPrefix(lines[0].H2, "none") {
t.Fatalf("an unchecked pull request on a base requiring only mesh/repo-check reads %+v", lines)
}
if lines[0].Number != 243 {
t.Fatalf("the line does not name the pull request's number: %+v", lines[0])
}
if lines[0].For != "11m0s" {
t.Fatalf("its time is not counted from when this owner first saw it: %s", lines[0].For)
}
// Announced at last: a delivery of its own, with its own bound — no longer this line.
w.h.PullUpdated(pr("novox/hq", 243, head, "decision/x"))
w.h.SeeOpenPulls()
for _, l := range w.h.Stalled() {
if l.State == Unannounced {
t.Fatalf("an announced pull request is still said unannounced: %+v", l)
}
}
}
// One that gets a check, or closes, is dropped; one the forge cannot be asked about is not guessed.
func TestAnUncheckedPullRequestThatGetsACheckOrClosesIsDropped(t *testing.T) {
w := newWorld(t)
w.forge.open = []OpenPull{{Owner: "novox", Repo: "hq", Number: 1, Base: "main", HeadSHA: "cccc",
Required: []string{"mesh/merge-gate"}}}
w.h.SeeOpenPulls()
w.later(11 * time.Minute)
if len(w.h.Stalled()) != 1 {
t.Fatal("not said")
}
w.forge.open[0].Statuses = []string{"mesh/merge-gate"}
w.h.SeeOpenPulls()
if len(w.h.Stalled()) != 0 {
t.Fatal("a pull request with a check is still said")
}
w.forge.open[0].Statuses = nil
w.h.SeeOpenPulls()
w.later(11 * time.Minute)
w.forge.openDown = true
w.h.SeeOpenPulls() // the forge away: what was seen stands, nothing new is guessed
if len(w.h.Stalled()) != 1 {
t.Fatal("a forge that could not be asked dropped or invented a line")
}
w.forge.openDown = false
w.forge.open = nil
w.h.SeeOpenPulls()
if len(w.h.Stalled()) != 0 {
t.Fatal("a closed pull request is still said")
}
}
@@ -152,12 +152,14 @@ func (h *Holder) Groups(all bool) []GroupLine {
// StalledLine is one delivery past its state's bound.
type StalledLine struct {
ID string `json:"id"`
State State `json:"state"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
ID string `json:"id"`
// Number is the pull request's, for a head the forge never announced: the condition names it (novox/hq issue 347).
Number int `json:"number,omitempty"`
State State `json:"state"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
}
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
@@ -189,6 +191,7 @@ func (h *Holder) Stalled() []StalledLine {
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(),
Bound: b.For.String(), H2: h2, Says: b.Says})
}
out = append(out, h.uncheckedLines(now)...)
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
+2 -1
View File
@@ -44,7 +44,8 @@
"gitea.gitea_delivery_view",
"gitea.gitea_commit_status",
"gitea.gitea_commit_statuses",
"gitea.gitea_contains"
"gitea.gitea_contains",
"gitea.gitea_open_pulls"
],
"state": [
"deliveries",
+1 -1
View File
@@ -25,7 +25,7 @@ export function getVaultTools(ledger: Ledger, receives: string | undefined, root
"The mesh's root secrets as this vault keeps them: every secret a module holds for itself, " +
"sealed to the operator's key (novox/hq ADR 0085, amended). Ciphertext — nothing here can " +
"open a line of it; the operator, holding the private key off the mesh, recovers one with " +
"`mesh-controller secret recover --from-export`. Also lists what is NOT recoverable: secrets " +
"`nox secret recover --from-export` on the control-node. Also lists what is NOT recoverable: secrets " +
"made before the mesh had an operator key.",
input: {
sealed: {
+83 -46
View File
@@ -1,20 +1,71 @@
# messenger
The holder of the `operator-channel` seat: how the mesh tells its operator what it noticed (novox/hq
to-be 45 §5, ADR 0227, research 028 — the minimal form, Q1a, Q5a, Q8).
The holder of the `operator-channel` seat: the router (novox/hq to-be 45 §5, to-be 46, ADR 0227, ADR 0234,
ADR 0259). It decides what is said to the operator and on which channel, holds every ask a module makes of
the operator, judges each answer and issues the **warrant** its asker acts on. It performs nothing, holds no
channel's client and no channel's secret.
- **Declares and holds `operator-channel`**, held once for the mesh, serving `open`, `history` and
`notify`.
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) — the state it
believes, before and beside the events.
- **Consumes the controller's condition events** — `mesh-controller.condition-raised`, `-changed`,
`-cleared` — and decides what is sent. The controller calls nobody. The events are read in one
place, `cmd/messenger/condition.go`, which states every assumption it makes about their shape.
- **Two channels:** Telegram (a bot to the operator's chat) and the desktop notifier — the
`node-notifier` seat's `send` verb on the machine the operator sits at (ADR 0208), asked through the
mesh. Nothing new runs on that machine.
- **Keeps its open messages in its own state** (`open`; the recent sends in `sent`), so a restart
forgets nothing (ADR 0201).
- **Declares three seats:** `operator-channel` (held here: `open`, `history`, `notify`, `asks`; an ask and
its cancel accepted under the asker's own name, the warrant emitted to that asker alone; the asks kept as
a record each asker reads under its own name), and the two kinded benches its channels hold, `channel`
(`show`, `edit`, `send`, each under the holder's kind) and `intake` (`choice`, `link`, `failed`, `standing`,
and the proof `code`, request and reply only). Only `channel` and `intake` may be kinded.
- **Reads the controller's open conditions** (`seat:mesh-controller.conditions`) and **consumes its
condition events**; the events are read in one place, `cmd/messenger/condition.go`.
- **The channels are modules of their own**: `telegram` (kind `telegram`, reaches the operator away, proves
its sender) and `desk-channel` (kind `desktop`, the machines' notifiers, proves nothing). Which kinds exist
and what each promises is the controller's word, in this module's membership; whether each can send now
is the channel's own, said on `intake.standing`.
- **Keeps its state on the bus** (ADR 0201): `open` and `sent` (the messages), `asks` (every ask, closed ones
for 30 days), `identities` (the operator's linked accounts, one per kind, and a link asked for).
## Asks, answers and warrants (novox/hq ADR 0259)
- **An ask** arrives on `mesh.seat.operator-channel.accept.ask.<asker>`; the asker is the subject's last
token, which the bus grants that module alone. It is checked (the asks contract, the content rule, at
most three open per asker) and recorded before its work is taken; a refusal is said to the asker on its
`decided` subject, in words.
- **It is offered on every kind that can carry one of its answers**: an acknowledgement wherever a choice
can be made; an approval only where the kind proves its sender and an account is linked; nothing that
destroys until an authenticator-app code exists. Each option has a one-time ticket per kind; the words
say where an answer not offered here can be given ("Release or Stop: answer on Telegram, or from the mesh
MCP server."). An ask about a condition is shown on that condition's message.
- **An answer is judged in order**, refused at the first failure and said where it was given: the ticket is
known, unused and for this kind; the ask is open and unexpired; the sender is the linked account where
the kind proves its sender; the kind's capabilities meet the level; an approval comes from an account
linked an hour or more. The ask is then closed by compare-and-set — a second answer loses — and the
warrant published to the asker alone. A warrant the bus did not take is published again every minute.
- **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would.
- **Linking** (`/start` to the bot): a six-digit code is shown, with the account's name and number, on a
channel whose words stay private (the desk) for ten minutes; the operator sends it to the bot, and the
account is linked. It acknowledges at once and approves after an hour. **The code is shown now or never**:
while the desk cannot show it (its notifications paused, nobody there), no link is begun and no code waits
in a queue. At most three links an hour per account, and ten per kind, counted in the router's state. Every
link and unlink is said on every channel. An account unlinked less than a day ago is not linked again by a
code; one account per kind is checked again as a link is made.
- **Unlinking is asked of the account that is linked** (`messenger_unlink` asks it): an approve question put
to that account alone. Its approval unlinks it, told to it first, by name, then everywhere. **A link within
a day of an unlink waits for the replaced account's approval**, asked of it by name. Each unlink or relink
question binds the account it is about, so its approval never acts on another account linked meanwhile.
**An account still in its first hour**, which cannot approve yet, is unlinked from the desk: when another
account sends `/start`, the desk is asked whether to unlink it.
- **When that account is lost with its phone**, root at the router's machine leaves a file in this module's
state directory — `unlink.<kind>`, or `allow-link.<kind>` for a link that waits, a regular file owned by
uid 0 — and the router does it within the minute, said on every channel with what was done, and every open
question of the router's own ends. A file not made by root is refused and said.
- **A channel proving its sender says only to the linked account**: none linked, it is not ready, and asks
for approval are not offered on it. **An approval rests on the controller's positive word**, asked of its
verb `root-free` for the router's machine and the channel's, live when an answer arrives (a word at most
30 s old renders the buttons): only a machine it says is free carries an approval. A router that does not
know its machine, a controller that does not answer, a machine its answer leaves out, or a word on the
channel kinds older than two minutes all fail closed.
- **An ask that offers an approval is answered only where it could be approved**, whichever option is
chosen: the desk, which proves nothing of who clicked, is not offered it, and an acknowledgement from a
fresh link does not end it. An asker wanting an acknowledgement at the desk asks it alone.
- **Each ask's message says who asks** (as the bus names it), what each answer does, and what happens if
nobody answers. A warrant is published under a message id nobody can foresee.
- **The router runs as its own account `messenger`, on a bus account of its own**, never carried by the
machine's runtime (ADR 0259 §8): its state and its bus credential are that account's.
## What is sent, and when
@@ -49,18 +100,10 @@ operator can do themselves. **A warning that needs nothing is not sent**: it sta
controller's conditions, the history says it was kept quiet, and it is said the moment it needs the
operator (urgent at its bound, or its words now say "Needs you:"). An urgent condition is always said.
**A notification offers answers** (`cmd/messenger/answers.go`), limited by novox/hq ADR 0258: **a desk
click performs only an acknowledgement**. Any process of the operator's account can choose a dunst action
(`dunstctl action`), so a click proves nothing about who chose. Until answers are authorised (to-be 46
phase 5) the only answers offered and performed are **Details** and **Silence for a week** (the allow-list
`answerable`); release, stop, start and restart are said in words ("Needs you: release it, or stop it.")
and offered as no answer. Each answer is a token made for one message of one condition: bound to the
machines it was shown on, good for a day, used once, refused for a condition cleared or silenced, and one
event is acted on once. A refused answer is said on the desk where it was chosen, never dropped. When the
dunst bundle says it started (`dunst.started`), the open notifications that offer answers are shown there
again with fresh tokens. A silence is recorded with the cause `operator-answer`, which the hand-act log
does not count as a repair. A warning kept quiet is also said once after a day. Telegram shows the words
but no buttons.
**A message offers the answers of the ask about it, and Details** (`cmd/messenger/answers.go`). The router
calls no verb of a condition's: the controller asks for the answers a condition names, and performs the one
chosen on its warrant. Details is a ticket per kind, good for a day, used once, shown where it was chosen. A
warning kept quiet is also said once after a day.
| When | What |
|---|---|
@@ -71,9 +114,8 @@ but no buttons.
| cleared and raised again within 10 min | the same message, edited back to open — not a new one |
| silenced | nothing, its clearing included |
- **Routing:** urgent to Telegram and the desktop; warning to the desktop when a session there
answers, otherwise to Telegram. The notifier answering is how "the operator's session is there" is
read until presence is decided (research 028 Q4).
- **Routing:** urgent to every kind that can send; a warning to the desk where it can send, otherwise to
the channel that reaches the operator away; an ask to every kind that carries one of its answers.
- **Bursts are one message.** What is to be said on a channel is held **30 s** from the first and goes
out together: alone, as itself; several, as one digest (`WARNING: 5 new warnings`, a line each). An
urgent message goes at once when nothing went out on its channel in the last 30 s.
@@ -91,21 +133,13 @@ but no buttons.
when the open conditions were last read, why they could not be, and how many events were taken as
history. An event that cannot be read
is refused by name, counted, and told to the operator once an hour.
- **No answering back.** Acknowledging is `conditions silence`, through the mesh.
## What the operator gives
Nothing is sent until these are given; `messenger_status` says which is missing.
1. **The bot token**, as this module's own secret: `secret accept <machine> messenger telegram-token`,
then push that machine. A value the mesh minted because none was accepted is recognised as not a
bot token and said so.
2. **The chat id**, as a setting: `settings` for `messenger` with `{"telegram-chat-id": "<id>"}`.
3. **The desktop machines**, as a setting: `{"desktop-machines": ["<the machine the operator sits at>", …]}`.
Without it, warnings go to Telegram.
4. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
said in it; not given, the zone of the machine this runs on. A name this machine does not know is
logged and that zone used instead.
1. **The time zone**, as a setting: `{"time-zone": "<an IANA zone name>"}`. Every time a message says is
said in it; not given, the zone of the machine this runs on.
2. **The channels**: assign `telegram` and `desk-channel` (each says in its README what it needs). Linking
an account as the operator is done on the channel, never by a setting.
## Tools
@@ -114,15 +148,18 @@ Nothing is sent until these are given; `messenger_status` says which is missing.
| `operator-channel.open` | what is open now, urgent first, with where it went, silenced, reminded, held, refused, unsent |
| `operator-channel.history` | what was said lately, and the refusals |
| `operator-channel.notify` | a message from a module using the seat: key, severity, summary, and best a plain `headline`, `explanation` and `resolved` line; `clear` to end it |
| `messenger_status` | whether the operator can be reached and why not; `check` asks Telegram whether the token works |
| `messenger_recent` | the recent sends, edits, digests and failures |
| `messenger_test` | a test message now, to telegram, desktop or both |
| `operator-channel.asks` | the asks held, open first: asker, state, where offered, the warrant, whether the asker was told |
| `messenger_status` | whether the operator can be reached and why not, per channel kind |
| `messenger_recent` | the recent sends, edits, digests, answers and failures |
| `messenger_test` | a test message now, to one kind or every one |
| `messenger_check` | whether some words may leave the mesh |
| `messenger_identities` | the accounts linked as the operator, and from when each approves |
| `messenger_unlink` | unlink a kind's account, said on every channel |
## Where it runs
Held once for the mesh: assign it to one machine whose tool runtime runs as root, since its secret
and settings are root's files at 0600 (as every runtime-carried module's are).
Held once for the mesh, on the control node: its settings are root's files at 0600 (as every
runtime-carried module's are). It holds no secret.
## Not yet
+123 -244
View File
@@ -1,41 +1,31 @@
package main
// What the operator needs to do, and answering from the notification itself (novox/hq ADR 0253, as
// limited by ADR 0258).
// What the operator needs to do, and answering from a message (novox/hq ADR 0253, ADR 0258, ADR 0259).
//
// - **Every explanation opens with a verdict**, written by the controller: "Nothing for you to do." or
// "Needs you:" and one thing the operator can do themselves.
// - **A warning that needs nothing is not sent.** It is kept open here and in the controller's
// conditions, the history says it was kept quiet, and it is said when it needs the operator — urgent
// at its bound, or its words changed to "Needs you:" (an event, or a reading of the state) — and once
// after a day in any case (QuietRemindAfter), so nothing stays silent for ever.
// - **A desk click performs only an acknowledgement** (ADR 0234 §8). Any process of the operator's account
// can choose a notification's action (`dunstctl action`), so a click proves nothing about who chose;
// until answers are authorised (to-be 46 phase 5) the only answers offered and performed are Details
// and silencing a condition. Everything else — release, stop, start, restart — is said in words and
// offered as no answer. The allow-list is `answerable`; an action outside it is never offered, and a
// token for one is refused.
// - **A token is an answer's one-time ticket**: made for one message of one condition, bound to the
// machines it was shown on, valid for TokenFor, used once. An answer for a condition cleared or
// silenced, from another machine, used, expired or unknown, is refused — and the refusal is shown where
// the answer came from, so a click is never silently dead. One event is acted on once, whatever its
// redelivery.
// - **A notifier that restarted lost the answers it was waiting for**; when it says it started, the open
// notifications that offer answers are shown there again with fresh tokens (Reoffer).
// at its bound, its words changed to "Needs you:", or an ask about it arrived — and once after a day in
// any case (QuietRemindAfter), so nothing stays silent for ever.
// - **The router performs nothing** (ADR 0259). A message offers the answers of the ask about it, which
// its asker performs on a warrant (asks.go), and Details, which performs nothing and is shown where it
// was chosen. The condition's own actions are never called from here: the controller asks for them.
// - **A ticket is an answer's one-time ticket**: made for one message on one channel kind, good for
// TokenFor, used once; refused for a record cleared or silenced, used, expired, from another kind or
// unknown — and the refusal is shown where the answer came from. One answer is acted on once,
// whatever its redelivery.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// Action is one answer a condition offers: a label, and the seat verb called with these arguments when
// it is chosen. Machine is set for a seat every machine holds. An argument "why" given empty is filled
// with the operator's words.
// Action is one answer a condition names: a label, and the seat verb its producer calls when it is chosen.
// Kept as the controller says it; the router offers a condition's answers only through an ask about it.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
@@ -43,45 +33,25 @@ type Action struct {
Arguments map[string]string `json:"arguments,omitempty"`
}
// Grant is one token's answer: the action (or -1, Details), when it was made, and whether it was used.
// Grant is one Details ticket: the kind it was shown on, when it was made, and whether it was used.
type Grant struct {
Index int `json:"index"`
Kind string `json:"kind,omitempty"`
Issued time.Time `json:"issued"`
Used bool `json:"used,omitempty"`
}
const (
// DetailsLabel is the answer every condition's notification offers.
// DetailsLabel is the answer every condition's message offers.
DetailsLabel = "Details"
// MostActions bounds what a notification offers, Details included.
// MostActions bounds what a message offers, Details included.
MostActions = 4
// TokenFor is how long an answer's token is good for.
// TokenFor is how long a Details ticket is good for.
TokenFor = 24 * time.Hour
// KeptEvents is how many answered events are remembered, so a redelivered one is acted on once.
// KeptEvents is how many answers are remembered, so a redelivered one is acted on once.
KeptEvents = 200
)
// answerable is the allow-list of answers a desk click performs (ADR 0258): silencing a condition, an
// acknowledgement any granted principal may already make. Nothing else, until answers are authorised.
func answerable(a Action) bool {
if a.Verb != "mesh-controller.conditions" || a.Machine != "" || a.Arguments["silence"] == "" {
return false
}
for k := range a.Arguments {
switch k {
case "silence", "for", "why", "cause":
default:
return false
}
}
return true
}
// answerableFor is answerable for this record: a silence of its own condition, and of no other.
func answerableFor(r *Record, a Action) bool {
return answerable(a) && a.Arguments["silence"] == r.Key
}
func sameActions(a, b []Action) bool {
x, _ := json.Marshal(a)
y, _ := json.Marshal(b)
@@ -89,82 +59,83 @@ func sameActions(a, b []Action) bool {
}
// needsNothing is a record the operator need not hear of now: words of its own that open "Nothing for
// you to do.", and only a warning. A record without words (an older controller, a notice) is said.
// you to do.", only a warning, and nothing asked about it. A record without words is said.
func needsNothing(r *Record) bool {
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition"
return r.Headline != "" && r.Needs == "" && r.Severity == Warning && r.Origin == "condition" && r.Ask == ""
}
// choices are the answers a record's next message offers, each with a fresh token; the tokens of its
// earlier messages end with them.
func (h *Holder) choices(r *Record) []Choice {
if r.Origin != "condition" {
return nil
// choices are the answers a record's next message offers on one kind: the open ask's options offered
// there, and Details for a condition, with a fresh ticket replacing that kind's earlier one.
func (h *Holder) choices(r *Record, kind string) []Choice {
var out []Choice
if rec := h.openAsk(r); rec != nil {
out = h.offers(rec, kind)
}
if r.Origin != "condition" || len(out) >= MostActions {
return out
}
now := h.Now()
h.mu.Lock()
defer h.mu.Unlock()
r.Tokens = map[string]Grant{}
var out []Choice
for i, a := range r.Actions {
if !answerableFor(r, a) || len(out) == MostActions-1 {
continue
}
t := token()
r.Tokens[t] = Grant{Index: i, Issued: now}
out = append(out, Choice{Token: t, Label: a.Label})
if r.Tokens == nil {
r.Tokens = map[string]Grant{}
}
t := token()
r.Tokens[t] = Grant{Index: -1, Issued: now}
for t, g := range r.Tokens {
if g.Kind == kind {
delete(r.Tokens, t)
}
}
t := ticket()
r.Tokens[t] = Grant{Index: -1, Kind: kind, Issued: now}
return append(out, Choice{Token: t, Label: DetailsLabel})
}
func token() string {
var b [12]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// shownOn are the machines a record's desktop message was shown on.
func shownOn(r *Record) []string {
var out []string
for _, part := range strings.Split(r.Sent["desktop"], ",") {
if m, _, ok := strings.Cut(part, "="); ok {
out = append(out, m)
}
}
return out
}
// Chosen takes an answer a channel handed back: the token of a choice, on a machine, in an event. It
// performs an allowed answer, shows what came of it — or why it was refused — where it was chosen, and
// answers that in words. The same event is acted on once.
func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
// Chosen takes an answer a channel of a kind said it was given: an ask's option is judged and, when it
// stands, closes the ask with a warrant for its asker (asks.go); Details are shown where they were asked.
// Anything else is refused, and said there. The same answer is acted on once.
func (h *Holder) Chosen(kind string, c asks.Choice) (string, error) {
// The controller's root-free word is asked before the work is taken (the confirmation review of 2026-10-09):
// up to RootFreeWithin, which no ask, answer or link should wait behind. Asked fresh here, for this answer,
// and judged with in it.
root := h.rootWordFor(kind)
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
if event != "" {
if outcome, done := h.answered[event]; done {
if c.ID != "" {
if outcome, done := h.answered[c.ID]; done {
h.mu.Unlock()
return outcome, nil
}
}
key := ""
if h.tickets != nil {
key = h.tickets[c.Ticket]
}
now := h.Now()
var r *Record
var g Grant
for _, rec := range h.open {
if gr, ok := rec.Tokens[tok]; ok {
if gr, ok := rec.Tokens[c.Ticket]; ok {
r, g = rec, gr
}
}
h.mu.Unlock()
if !c.At.IsZero() && now.Sub(c.At) > FreshFor {
// Replayed from the stream long after it was given: history, never an answer.
h.Logf("[messenger] an answer on %s from %s is %s old and was not acted on", kind, c.At.Format(time.RFC3339), roughly(now.Sub(c.At)))
h.remember(c.ID, "too old")
return "too old", errorf("it was given %s ago", roughly(now.Sub(c.At)))
}
if key != "" {
out, err := h.judge(kind, c, key, root)
h.remember(c.ID, out)
return out, err
}
refuse := func(why string) (string, error) {
h.Logf("[messenger] an answer on %s from %s was refused: %s", channel, machine, why)
h.remember(event, "refused: "+why)
h.record(Sent{At: now, Channel: channel, Key: keyOf(r), What: "answer", Outcome: "refused: " + why})
_, _ = h.showOn(channel, machine, Message{Title: "That answer was not taken",
Body: capital(why) + ". Nothing was done.", Quiet: true})
return "refused", errorf("%s", why)
out, err := h.refuseChoice(kind, c, why)
h.remember(c.ID, out)
return out, err
}
switch {
case r == nil:
@@ -177,119 +148,28 @@ func (h *Holder) Chosen(channel, machine, tok, event string) (string, error) {
return refuse("it was already chosen once")
case now.Sub(g.Issued) > TokenFor:
return refuse("it is more than a day old")
case channel == "desktop" && !contains(shownOn(r), machine):
return refuse("it was not shown on " + machine)
case g.Index >= len(r.Actions) || (g.Index >= 0 && !answerableFor(r, r.Actions[g.Index])):
return refuse("it is not an answer a notification may give")
case g.Kind != "" && g.Kind != kind:
return refuse("it was not offered here")
}
h.mu.Lock()
g.Used = true
r.Tokens[tok] = g
r.Tokens[c.Ticket] = g
h.mu.Unlock()
headline, _ := wordsOf(r)
if g.Index == -1 {
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
lines = append(lines, l)
// Each line held to the content rule on its own: a line that carries what may not leave is said
// withheld, and the rest is still shown.
var lines []string
for _, l := range []string{r.Summary, "Condition: " + r.Key, "Since: " + r.Raised.In(h.zone()).Format("2 Jan 15:04")} {
if refusal, ok := Check(l); !ok {
l = "(a line carrying " + refusal.What + " is withheld; the controller's conditions hold it)"
}
_, err := h.showOn(channel, machine, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n")})
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(event, "shown")
h.persist(r)
return "shown", err
lines = append(lines, l)
}
a := r.Actions[g.Index]
args := map[string]any{}
for k, v := range a.Arguments {
args[k] = v
}
if v, ok := a.Arguments["why"]; ok && v == "" {
args["why"] = fmt.Sprintf("chosen on the %s notification %q on %s (a desk click: whoever was at the "+
"operator's session)", channel, headline, machine)
}
var err error
if h.Ask == nil {
err = errorf("this holder cannot call the mesh")
} else {
var raw json.RawMessage
raw, err = h.Ask("seat:"+a.Verb, args)
if err == nil {
err = refusalIn(raw)
}
}
outcome := "done"
title := a.Label + ": done. " + headline
body := "Chosen at " + now.In(h.zone()).Format("15:04") + "."
if err != nil {
outcome = "failed: " + err.Error()
title = "Could not " + strings.ToLower(a.Label) + ": " + headline
body = "The mesh refused it: " + firstLineOf(err.Error())
if _, ok := Check(body); !ok {
body = "The mesh refused it; the reason carries what may not be shown here."
}
}
h.mu.Lock()
r.Answered = a.Label + ": " + outcome
sent := r.Sent[channel]
h.mu.Unlock()
err := h.showOn(kind, Message{Title: "Details: " + headline, Body: strings.Join(lines, "\n"), Reply: c.ID})
h.record(Sent{At: now, Channel: kind, Key: r.Key, What: "details", Outcome: outcomeOf(err)})
h.remember(c.ID, "shown")
h.persist(r)
m := Message{Title: title, Body: body, Quiet: err == nil}
if ch := h.channelNamed(channel); ch != nil && sent != "" && ch.CanEdit() {
if e := ch.Edit(sent, m); e != nil {
_, _ = h.showOn(channel, machine, m)
}
} else {
_, _ = h.showOn(channel, machine, m)
}
h.remember(event, outcome)
h.record(Sent{At: now, Channel: channel, Key: r.Key, What: "answer " + a.Label, Outcome: outcome})
h.Logf("[messenger] %s was chosen for %s on %s (%s): %s", a.Label, r.Key, channel, machine, outcome)
return outcome, err
}
// Reoffer shows again, on a machine whose notifier restarted and so lost the answers it waited for, every
// open message shown there that offers answers — with fresh tokens, in place of the old one.
func (h *Holder) Reoffer(machine string) int {
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
h.init()
var recs []*Record
for _, r := range h.open {
if r.Cleared.IsZero() && len(r.Tokens) > 0 && contains(shownOn(r), machine) {
recs = append(recs, r)
}
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Key < recs[j].Key })
n := 0
for _, r := range recs {
if r.silenced(h.Now()) || h.Desktop == nil {
continue
}
m := h.say(r, "raised")
id := ""
for _, part := range strings.Split(r.Sent["desktop"], ",") {
if strings.HasPrefix(part, machine+"=") {
id = part
}
}
if err := h.Desktop.Edit(id, m); err != nil {
h.Logf("[messenger] could not offer %s again on %s: %v", r.Key, machine, err)
continue
}
h.persist(r)
n++
}
if n > 0 {
h.Logf("[messenger] the notifier on %s started again: %d message(s) with answers shown there again", machine, n)
}
return n
return "shown", err
}
func (h *Holder) remember(event, outcome string) {
@@ -310,13 +190,6 @@ func (h *Holder) remember(event, outcome string) {
h.answered[event] = outcome
}
func keyOf(r *Record) string {
if r == nil {
return "(none)"
}
return r.Key
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
@@ -341,43 +214,49 @@ func firstLineOf(s string) string {
return s
}
// refusalIn reads a tool's answer for a refusal: an error result is an error.
func refusalIn(raw json.RawMessage) error {
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &wrapped) == nil {
if wrapped.IsError && len(wrapped.Content) > 0 {
return errorf("%s", wrapped.Content[0].Text)
}
if wrapped.Error != "" {
return errorf("%s", wrapped.Error)
// Failed takes a channel's word that a message did not arrive: the channel is said to be failing, and the
// record it carried is tried again — unless the failure is permanent, which is said and not repeated.
func (h *Holder) Failed(kind string, f asks.Failed) {
h.work.Lock()
defer h.work.Unlock()
h.noteChannel(kind, errorf("%s", f.Why))
h.mu.Lock()
var r *Record
for _, rec := range h.open {
if rec.Sent[kind] == f.Handle && f.Handle != "" {
r = rec
}
}
return nil
if r != nil && r.Cleared.IsZero() && !f.Permanent {
delete(r.Sent, kind)
if r.Pending == "" {
r.Pending = "raised"
}
}
desk := h.Bench != nil && h.Bench.roleOf(kind) == RoleDesk
h.mu.Unlock()
// A warning the desk could not show goes to the channel that reaches the operator away, as it did when the
// desk was the router's own (the correctness review of 2026-10-08).
if desk && r != nil && r.Cleared.IsZero() && r.Severity != Urgent && h.Away != nil && h.Away.Ready() == nil {
h.enqueue(h.Away.Name(), item{Key: r.Key, What: "raised", Severity: r.Severity, At: h.Now()})
h.mu.Lock()
r.Pending = ""
h.mu.Unlock()
h.flush(h.Now(), false)
}
outcome := "failed: " + f.Why
if f.Permanent {
outcome = "failed for good: " + f.Why
}
h.record(Sent{At: h.Now(), Channel: kind, Key: keyOrNone(r), What: "delivery", Outcome: outcome})
if r != nil {
h.persist(r)
}
}
func (h *Holder) channelNamed(name string) Channel {
for _, ch := range h.channels() {
if ch.Name() == name {
return ch
}
func keyOrNone(r *Record) string {
if r == nil {
return "(none)"
}
return nil
}
// showOn shows a message of the holder's own on one channel, on one machine where the channel has several.
func (h *Holder) showOn(channel, machine string, m Message) (string, error) {
ch := h.channelNamed(channel)
if ch == nil {
return "", errorf("no channel %s", channel)
}
if d, ok := ch.(*Desktop); ok && machine != "" {
return d.showOne(machine, m)
}
return ch.Send(m)
return r.Key
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,152 +0,0 @@
package main
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
const goodToken = "123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsawQ"
func tokenFile(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "telegram-token")
if content != "" {
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
return p
}
func TestTelegramSaysWhatItLacks(t *testing.T) {
for _, c := range []struct{ token, chat, says string }{
{"", "42", "not on this machine yet"},
{"r4nd0mlyMadeByTheMeshBecauseNobodyAcceptedOne", "42", "not a bot token"},
{goodToken, "", "telegram-chat-id is not given"},
{goodToken, "not a chat", "is not a chat id"},
} {
tg := NewTelegram(TelegramConfig{TokenFile: tokenFile(t, c.token), ChatID: func() string { return c.chat }})
err := tg.Ready()
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v", c, err)
}
if err != nil && strings.Contains(err.Error(), goodToken) {
t.Errorf("the token is in the words")
}
}
}
func TestTelegramSendsEditsAndNeverSaysItsToken(t *testing.T) {
var asked []string
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
asked = append(asked, r.URL.Path)
raw, _ := io.ReadAll(r.Body)
var b map[string]any
_ = json.Unmarshal(raw, &b)
bodies = append(bodies, b)
switch {
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":77}}`))
case strings.HasSuffix(r.URL.Path, "/editMessageText"):
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: message to edit not found"}`))
default:
_, _ = w.Write([]byte(`{"ok":true,"result":{"username":"mesh_bot"}}`))
}
}))
defer srv.Close()
old := TelegramAPI
TelegramAPI = srv.URL
defer func() { TelegramAPI = old }()
tg := NewTelegram(TelegramConfig{TokenFile: tokenFile(t, goodToken+"\n"), ChatID: func() string { return "-1001" }})
id, err := tg.Send(Message{Title: "URGENT: x", Body: "key: a.b.c"})
if err != nil || id != "77" {
t.Fatalf("%q %v", id, err)
}
if asked[0] != "/bot"+goodToken+"/sendMessage" || bodies[0]["chat_id"] != "-1001" || bodies[0]["text"] != "URGENT: x\nkey: a.b.c" {
t.Fatalf("%v %v", asked, bodies[0])
}
err = tg.Edit("77", Message{Title: "CLEARED"})
if err == nil || !strings.Contains(err.Error(), "message to edit not found") || strings.Contains(err.Error(), goodToken) {
t.Fatalf("edit: %v", err)
}
if bodies[1]["message_id"] != float64(77) {
t.Fatalf("message id: %v", bodies[1]["message_id"])
}
if who, err := tg.Who(); err != nil || who != "mesh_bot" {
t.Fatalf("%q %v", who, err)
}
// Nothing answers: the error is in words, without the URL that carries the token.
srv.Close()
_, err = tg.Send(Message{Title: "x"})
if err == nil || strings.Contains(err.Error(), goodToken) || strings.Contains(err.Error(), "/bot") {
t.Fatalf("unreachable: %v", err)
}
}
func TestTheDesktopAsksTheNotifierSeatOnEachMachine(t *testing.T) {
var asked []string
d := &Desktop{
Machines: func() []string { return []string{"one", "two"} },
Ask: func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
args := body.(map[string]any)
if args["body"] != "a &lt;b&gt;" || args["urgency"] != "critical" {
t.Errorf("args: %v", args)
}
if strings.HasSuffix(key, "@two") {
return nil, errors.New("the account is not logged in")
}
return json.RawMessage(`{"id":12}`), nil
},
}
id, err := d.Send(Message{Title: "t", Body: "a <b>", Urgent: true})
if err != nil || id != "one=12" {
t.Fatalf("%q %v", id, err)
}
if asked[0] != "seat:node-notifier.send@one" || asked[1] != "seat:node-notifier.send@two" {
t.Fatalf("%v", asked)
}
if d.LastAnswers()["two"] == "" || d.LastAnswers()["one"] != "" {
t.Fatalf("%v", d.LastAnswers())
}
asked = nil
if err := d.Edit("one=12", Message{Title: "t", Body: "a <b>", Urgent: true}); err != nil || len(asked) != 1 {
t.Fatalf("edit asked %v: %v", asked, err)
}
none := &Desktop{Machines: func() []string { return nil }}
if err := none.Ready(); err == nil || !strings.Contains(err.Error(), "desktop-machines") {
t.Fatalf("%v", err)
}
}
func TestTheNotifiersAnswerIsReadInEitherShape(t *testing.T) {
for raw, want := range map[string]int{
`{"id":5}`: 5,
`{"content":[{"type":"text","text":"{\"id\":6}"}]}`: 6,
`"{\"id\":7}"`: 7,
} {
if got, err := notificationID(json.RawMessage(raw)); err != nil || got != want {
t.Errorf("%s: %d %v", raw, got, err)
}
}
if _, err := notificationID(json.RawMessage(`{"content":[{"text":"no session"}],"isError":true}`)); err == nil {
t.Errorf("an error answer read as an id")
}
}
func TestSettingsAreReadAsTheMeshMergesThem(t *testing.T) {
p := filepath.Join(t.TempDir(), "settings.json")
_ = os.WriteFile(p, []byte(`{"telegram-chat-id": 123456, "desktop-machines": ["a", " ", "b"]}`), 0o600)
s, err := readSettings(p)
if err != nil || s.TelegramChatID != "123456" || len(s.DesktopMachines) != 2 {
t.Fatalf("%+v %v", s, err)
}
}
-186
View File
@@ -1,186 +0,0 @@
package main
// The desktop notifier (novox/hq to-be 45 §5, ADR 0208): the `node-notifier` seat's `send` verb on
// the machine the operator is at, asked through the mesh. Nothing new runs on that machine — the
// seat's holder (dunst) already answers `send` in the operator's session, and answers with an error
// when no session is there. That error is what "the operator's session is there" is read from until
// presence is decided (research 028 Q4): a machine whose notifier answers has a session.
//
// Which machines to try is the setting desktop-machines, in order. None given: the channel is not
// configured, the status says so, and a warning goes to Telegram.
import (
"encoding/json"
"errors"
"fmt"
"html"
"strings"
"sync"
)
// Asker calls a tool through the mesh, as stdio.Ask does.
type Asker func(key string, body any) (json.RawMessage, error)
// Desktop sends to the notifier of each configured machine.
type Desktop struct {
Machines func() []string
Ask Asker
mu sync.Mutex
last map[string]string // machine -> the last error, or "" after a success
}
func (d *Desktop) Name() string { return "desktop" }
func (d *Desktop) CanEdit() bool { return true }
// SilentEdit: replacing a notification shows it again.
func (d *Desktop) SilentEdit() bool { return false }
func (d *Desktop) Ready() error {
if len(d.Machines()) == 0 {
return errors.New("no machine to show it on: the setting desktop-machines is not given " +
"(`settings` for messenger with {\"desktop-machines\": [\"<the machine the operator sits at>\"]})")
}
return nil
}
// Send shows the message on every configured machine that has a session, and answers
// `<machine>=<id>` for each one that took it. It fails only when none did.
func (d *Desktop) Send(m Message) (string, error) {
return d.show(m, nil)
}
// Edit replaces the notification shown before on each machine that showed it.
func (d *Desktop) Edit(id string, m Message) error {
shown := map[string]int{}
for _, part := range strings.Split(id, ",") {
machine, n, ok := strings.Cut(part, "=")
var i int
if ok {
if _, err := fmt.Sscan(n, &i); err == nil {
shown[machine] = i
}
}
}
if len(shown) == 0 {
return errors.New("no notification on record to replace")
}
_, err := d.show(m, shown)
return err
}
func (d *Desktop) show(m Message, replace map[string]int) (string, error) {
if err := d.Ready(); err != nil {
return "", err
}
urgency := "normal"
switch {
case m.Quiet:
urgency = "low"
case m.Urgent:
urgency = "critical"
}
var took []string
var failed []string
for _, machine := range d.Machines() {
if replace != nil {
if _, shown := replace[machine]; !shown {
continue
}
}
args := map[string]any{
"summary": m.Title,
"body": html.EscapeString(m.Body),
"urgency": urgency,
"app_name": "mesh",
}
if replace != nil {
args["replace_id"] = replace[machine]
}
if len(m.Actions) > 0 {
// Answers stay until the operator dismisses them: a warning's answers must not die in ten seconds.
args["expire_ms"] = 0
// The answers the notification offers (node-notifier's actions): the notifier hands the chosen
// token back as an event, which the holder takes (answers.go).
args["actions"] = m.Actions
}
raw, err := d.Ask("seat:node-notifier.send@"+machine, args)
if err == nil {
var id int
id, err = notificationID(raw)
if err == nil {
took = append(took, fmt.Sprintf("%s=%d", machine, id))
}
}
d.note(machine, err)
if err != nil {
failed = append(failed, machine+": "+err.Error())
}
}
if len(took) == 0 {
return "", errors.New("no machine showed it — " + strings.Join(failed, "; "))
}
return strings.Join(took, ","), nil
}
func (d *Desktop) note(machine string, err error) {
d.mu.Lock()
defer d.mu.Unlock()
if d.last == nil {
d.last = map[string]string{}
}
if err != nil {
d.last[machine] = err.Error()
} else {
d.last[machine] = ""
}
}
// Machines' last answers, for the status: "" is a machine that took the last message shown to it.
func (d *Desktop) LastAnswers() map[string]string {
d.mu.Lock()
defer d.mu.Unlock()
out := map[string]string{}
for k, v := range d.last {
out[k] = v
}
return out
}
// showOne shows a message on one machine only: an answer's outcome, or details, where it was asked.
func (d *Desktop) showOne(machine string, m Message) (string, error) {
only := &Desktop{Machines: func() []string { return []string{machine} }, Ask: d.Ask}
id, err := only.Send(m)
d.note(machine, err)
return id, err
}
// notificationID reads the notifier's answer, {"id": N}, whether the runtime hands it over bare or in
// the tool reply's text content.
func notificationID(raw json.RawMessage) (int, error) {
var direct struct {
ID *int `json:"id"`
}
if json.Unmarshal(raw, &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
var wrapped struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
IsError bool `json:"isError"`
}
if json.Unmarshal(raw, &wrapped) == nil && len(wrapped.Content) > 0 {
if wrapped.IsError {
return 0, errors.New(wrapped.Content[0].Text)
}
if json.Unmarshal([]byte(wrapped.Content[0].Text), &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
}
var s string
if json.Unmarshal(raw, &s) == nil && json.Unmarshal([]byte(s), &direct) == nil && direct.ID != nil {
return *direct.ID, nil
}
return 0, fmt.Errorf("the notifier answered no id: %.80s", string(raw))
}
@@ -270,7 +270,7 @@ func TestAClearingOfSomethingNeverSaidSaysNothing(t *testing.T) {
func TestABurstIsOneMessage(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
for i := 0; i < 5; i++ {
h.Condition(EventRaised, cond(fmt.Sprintf("seat.s%d.silent", i), Warning, fmt.Sprintf("seat %d is silent", i)))
c.pass(2 * time.Second)
@@ -413,7 +413,7 @@ func TestARestartSaysNothingAgain(t *testing.T) {
w.At = c.now()
h.Condition(EventRaised, w) // held when the holder stops
c.pass(5 * time.Second)
again := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf}
again := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf}
if err := again.Load(); err != nil {
t.Fatal(err)
}
+111 -11
View File
@@ -89,6 +89,13 @@ type Message struct {
Urgent bool
Quiet bool // a clearing: shown without urgency
Actions []Choice
// Reply is the answer this message answers, by its id: the channel shows it where that was given.
Reply string
// To is the account it is for, when not the one linked: a notice to an account being unlinked, or an
// ask put to the account a relink replaces.
To string
// Secret: shown once, and kept by no channel (a link's code).
Secret bool
}
// Choice is one answer a message offers: the token a channel hands back when it is chosen, and its label.
@@ -145,7 +152,10 @@ type Record struct {
// nothing stays silent for ever.
QuietReminded bool `json:"quiet_reminded,omitempty"`
// Answered is what the operator chose from a notification, and what came of it.
Answered string `json:"answered,omitempty"`
Answered string `json:"answered,omitempty"`
// Ask is the open ask about this record, `<asker>.<id>`: its options are this record's answers
// (novox/hq ADR 0259).
Ask string `json:"ask,omitempty"`
Origin string `json:"origin"`
More string `json:"more"`
Raised time.Time `json:"raised"`
@@ -212,17 +222,30 @@ type Store interface {
Recent() ([]Sent, error)
}
// Holder is the seat's holder.
// Holder is the seat's holder: the router (novox/hq ADR 0234 §5, ADR 0259). It keeps no channel's client:
// Away is the channel that reaches the operator away from the machines (a kind promising reaches-away),
// Desk the one at the machines; each is a kind of the `channel` bench, held by a module of its own.
type Holder struct {
Telegram Channel
Desktop Channel
Store Store
Away Channel
Desk Channel
Store Store
// Asks keeps the asks, the operator's identities and their links (asks.go, link.go); Bench is what
// the controller says of every channel kind and what each says of itself; Publish says on a seat.
Asks AskStore
Bench *Bench
Publish func(subject string, body any, id string) error
// Node is the machine this router runs on; StateDir its own directory, where root leaves a break-glass
// request (selfact.go).
Node string
StateDir string
Now func() time.Time
Logf func(string, ...any)
// Emit states a fact as this module (refused); nil states nothing.
Emit func(event string, body any) error
// Ask calls a seat verb through the mesh, as this module: the answers the operator chooses.
Ask Asker
// RootFree asks the controller's verb root-free about machines; nil asks it through Ask (askRootFree).
RootFree func(machines []string) (map[string]RootVerdict, error)
// Zone is the operator's time zone, which every time in a message is said in; nil is this machine's.
Zone func() *time.Location
@@ -255,6 +278,13 @@ type Holder struct {
stale int
// answered is what came of each answer event, by its id: a redelivered one is acted on once.
answered map[string]string
// asks are the asks this router holds, by `<asker>.<id>`, and tickets each ask's tickets to it.
asks map[string]*askEntry
tickets map[string]string
// lastUnlink is the last unlink of each kind.
lastUnlink map[string]Unlinked
// rootWord is the controller's last word on whether an agent can become root, by the machines asked.
rootWord map[string]rootWord
}
func (h *Holder) init() {
@@ -308,7 +338,10 @@ func (h *Holder) Load() error {
if sent, err := h.Store.Recent(); err == nil {
h.recent = sent
}
return nil
h.mu.Unlock()
err = h.loadAsks()
h.mu.Lock()
return err
}
// Start marks the holder's start: the grace minute counts from here.
@@ -521,6 +554,7 @@ func (h *Holder) cleared(key string, quiet bool) {
// Tick does what time asks: reminders, retries, what is held, and forgetting what cleared long
// enough ago that a new raise is a new message.
func (h *Holder) Tick() {
h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
@@ -532,6 +566,8 @@ func (h *Holder) Tick() {
}
h.mu.Unlock()
sort.Slice(recs, func(i, j int) bool { return recs[i].Raised.Before(recs[j].Raised) })
h.sweepAsks(now)
h.breakGlass()
for _, r := range recs {
switch {
case !r.Cleared.IsZero():
@@ -574,6 +610,7 @@ func (h *Holder) Tick() {
// Flush sends what is due on each channel; the runtime calls it every few seconds, so a held
// message waits its window and not a minute more.
func (h *Holder) Flush() {
h.warmRootFree(false) // outside the work: what is offered inside it reads a recent word
h.work.Lock()
defer h.work.Unlock()
h.mu.Lock()
@@ -592,7 +629,73 @@ func remindAfter(severity string) time.Duration {
// say composes a record's message under the content rule: refused, it is composed again with less of
// it, until what remains may leave. The refusal is recorded and stated once per record.
func (h *Holder) say(r *Record, what string) Message {
func (h *Holder) say(r *Record, what string) Message { return h.sayOn(r, what, "") }
// sayOn is a record's message on one channel kind: its words, what came of an answer, the answers that kind
// can carry, and where the others can be given. An empty kind is the words alone.
func (h *Holder) sayOn(r *Record, what, kind string) Message {
m := h.words(r, what)
if what == "cleared" || m.Title == "A mesh message was withheld" {
return m
}
if r.Answered != "" {
m.Body = strings.TrimSpace(r.Answered + "\n" + m.Body)
}
if kind == "" {
return m
}
withheld := map[string]bool{} // the labels of answers whose effect could not be said
if rec := h.openAsk(r); rec != nil {
// Who asks, as the bus proves it (the asker is the subject's, never the body's), what each answer does,
// and what happens if nobody answers (the review of 2026-10-09).
for _, line := range h.askLines(rec) {
if _, ok := Check(line); ok {
m.Body = strings.TrimSpace(m.Body + "\n" + line)
}
}
// **An answer whose effect is not said is not offered** (the confirmation review of 2026-10-09): its line
// refused by the content rule, its button went out all the same, and a tap would authorise what the
// person was not shown.
for _, o := range rec.Ask.Options {
if _, ok := Check(o.Label + ": " + o.Does + "."); !ok || strings.TrimSpace(o.Does) == "" {
withheld[o.Label] = true
}
}
if line := h.elsewhere(rec, kind); line != "" {
if _, ok := Check(line); ok {
m.Body = strings.TrimSpace(m.Body + "\n" + line)
}
}
}
for _, c := range h.choices(r, kind) {
if !withheld[c.Label] {
m.Actions = append(m.Actions, c)
}
}
return m
}
// askLines are what an ask's message says of the ask itself: who asks, each option's effect, and its expiry.
func (h *Holder) askLines(rec *AskRecord) []string {
asker := rec.Asker
if asker == SelfAsker {
asker = "the router itself"
}
lines := []string{"Asked by " + asker + "."}
for _, o := range rec.Ask.Options {
if o.Does != "" {
lines = append(lines, o.Label+": "+o.Does+".")
}
}
if !rec.Ask.Expires.IsZero() && rec.Ask.OnExpiry != "" {
lines = append(lines, "If nobody answers by "+rec.Ask.Expires.In(h.zone()).Format("15:04")+": "+rec.Ask.OnExpiry+".")
}
return lines
}
// words is a record's message under the content rule: refused, it is composed again with less of it,
// until what remains may leave. The refusal is recorded and stated once per record.
func (h *Holder) words(r *Record, what string) Message {
now := h.Now()
if r.Refused != "" {
// Refused before: its words stay withheld in every later message too.
@@ -606,9 +709,6 @@ func (h *Holder) say(r *Record, what string) Message {
m := compose(r, what, now, h.zone(), 0)
refusal, ok := Check(m.Text())
if ok {
if what != "cleared" {
m.Actions = h.choices(r)
}
return m
}
h.mu.Lock()
@@ -643,7 +743,7 @@ func (h *Holder) say(r *Record, what string) Message {
func (h *Holder) channels() []Channel {
var out []Channel
for _, c := range []Channel{h.Telegram, h.Desktop} {
for _, c := range []Channel{h.Away, h.Desk} {
if c != nil {
out = append(out, c)
}
+11 -9
View File
@@ -9,6 +9,7 @@ import (
)
type fakeChannel struct {
to []string // whom each send was for, by account; "" for the linked one
name string
notReady error
fail error
@@ -29,6 +30,7 @@ func (f *fakeChannel) Send(m Message) (string, error) {
}
f.n++
f.sends = append(f.sends, m)
f.to = append(f.to, m.To)
if f.name == "desktop" {
return fmt.Sprintf("g14=%d", f.n), nil // as the desktop answers: the machine and the notification
}
@@ -108,7 +110,7 @@ func newHolder(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memS
tg, dt := &fakeChannel{name: "telegram", silent: true}, &fakeChannel{name: "desktop"}
st := &memStore{}
var emitted []string
h := &Holder{Telegram: tg, Desktop: dt, Store: st, Now: c.now, Logf: t.Logf,
h := &Holder{Away: tg, Desk: dt, Store: st, Now: c.now, Logf: t.Logf,
Emit: func(e string, _ any) error { emitted = append(emitted, e); return nil }}
h.init()
return h, tg, dt, c, st
@@ -163,7 +165,7 @@ func TestAWarningGoesToTheDesktopWhenASessionAnswersElseTelegram(t *testing.T) {
func TestTheCapIsALastLineAndIsSaidOnce(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
// Urgent conditions, each alone and after a quiet half minute: each is its own message — until
// the cap, which is said once, in a message of its own, and holds the rest.
for i := 0; i < 25; i++ {
@@ -241,7 +243,7 @@ func TestAMessageCarryingAnAddressIsRefusedAndSaidWithItsWordsWithheld(t *testin
func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
h, tg, _, _, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
h.Condition(EventRaised, cond("machine.10.0.0.7.silent", Urgent, "a machine is silent"))
if len(tg.sends) != 1 || strings.Contains(tg.sends[0].Text(), "10.0.0.7") {
t.Fatalf("sends: %+v", tg.sends)
@@ -250,7 +252,7 @@ func TestAKeyThatCarriesAnAddressIsWithheldToo(t *testing.T) {
func TestStillOpenPastItsBoundItIsSaidOnceMore(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
u := cond("bus.controller.slow-consumer", Urgent, "the controller's consumer is far behind")
u.Raised = c.now()
w := cond("plan.41.stalled", Warning, "plan 41 waits")
@@ -365,7 +367,7 @@ func TestEscalationIsSaidOnce(t *testing.T) {
func TestAChannelThatCannotSendSaysSoAndIsTriedAgain(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
tg.fail = errors.New("telegram sendMessage: cannot connect (dial)")
h.Condition(EventRaised, cond("machine.ace.silent", Urgent, "silent"))
st := h.Status("listening")
@@ -396,11 +398,11 @@ func TestNothingConfiguredIsSaidInTheStatus(t *testing.T) {
func TestARestartForgetsNothing(t *testing.T) {
h, tg, _, c, st := newHolder(t)
h.Desktop = nil
h.Desk = nil
k := cond("machine.ace.silent", Urgent, "silent")
k.Raised = c.now()
h.Condition(EventRaised, k)
again := &Holder{Telegram: tg, Store: st, Now: c.now, Logf: t.Logf}
again := &Holder{Away: tg, Store: st, Now: c.now, Logf: t.Logf}
if err := again.Load(); err != nil {
t.Fatal(err)
}
@@ -416,7 +418,7 @@ func TestARestartForgetsNothing(t *testing.T) {
func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
h.Unreadable("mesh-controller.condition-raised", errors.New("no severity"))
settle(h, c)
@@ -433,7 +435,7 @@ func TestAnUnreadableEventIsToldOnceAnHour(t *testing.T) {
func TestNotifyIsKeptApartFromConditions(t *testing.T) {
h, tg, _, c, _ := newHolder(t)
h.Desktop = nil
h.Desk = nil
out, err := h.Notify("backup.ace.failed", Warning, "last night's backup of the home server failed", "backup", false)
if err != nil || out["held"] != true {
t.Fatalf("%v %v", out, err)
+390
View File
@@ -0,0 +1,390 @@
package main
// Who the operator is on a channel (novox/hq ADR 0259 §7): the router's list of the operator's
// identities, one per kind, changed only by linking and unlinking — never by a setting, which any
// principal granted `settings` could write.
//
// - **Linking:** the operator sends the channel's bot `/start`; the channel says `link.<kind>` with the
// sender as its service authenticated it. The router shows a six-digit code on the desk, with the name
// the service shows, for ten minutes. The operator sends the code to the bot; the channel asks it as a
// proof (`proof.code.<kind>`: request and reply, never kept). When it matches, the account is linked,
// and said on every channel.
// - **A new link acknowledges at once and approves only after an hour** (LinkedApproveAfter), decided
// by the operator on 2026-10-08 until an authenticator-app code exists: the hour lets the operator
// unlink an account an agent linked by reading the code off the X11 desk.
// - **One identity per kind.** A second link of a kind is refused until the first is unlinked; unlinking
// is said on every channel, the unlinked one included.
// - Only a kind whose claim declares `verified-sender` can be linked: the desk proves nothing about who
// clicked, so it has no identities and answers acknowledgements only.
// - The code is kept only as a salted hash; five wrong tries end the link asked for.
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
const (
// LinkCodeFor is how long a link's code is good for.
LinkCodeFor = 10 * time.Minute
// LinkTries is how many wrong codes end a link asked for.
LinkTries = 5
)
// PendingLink is a link asked for and not yet confirmed: who asked, and the code's hash.
type PendingLink struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Display string `json:"display,omitempty"`
Salt string `json:"salt"`
CodeHash string `json:"code-hash"`
Expires time.Time `json:"expires"`
Tries int `json:"tries"`
// Confirmed: the code was right, and the link waits for the account it replaces.
Confirmed bool `json:"confirmed,omitempty"`
}
func identityKey(kind, identity string) string { return kind + "." + kvKey(identity) }
func hashCode(salt, code string) string {
sum := sha256.Sum256([]byte(salt + ":" + code))
return hex.EncodeToString(sum[:])
}
// identity is the linked account of a kind, when this sender is it.
func (h *Holder) identity(kind, identity string) *Identity {
if h.Asks == nil || identity == "" {
return nil
}
id, err := h.Asks.GetIdentity(identityKey(kind, identity))
if err != nil || id == nil || id.Kind != kind || id.Identity != identity {
return nil
}
return id
}
// identitiesOf is every account linked on a kind.
func (h *Holder) identitiesOf(kind string) []Identity {
if h.Asks == nil {
return nil
}
keys, err := h.Asks.IdentityKeys()
if err != nil {
return nil
}
var out []Identity
for _, k := range keys {
if !strings.HasPrefix(k, kind+".") {
continue
}
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil && id.Kind == kind {
out = append(out, *id)
}
}
return out
}
func (h *Holder) linked(kind string) bool { return len(h.identitiesOf(kind)) > 0 }
// LinksAnHour is how many links one account may ask in an hour, and KindLinksAnHour how many all accounts
// together may ask of one kind: kept in the router's state, so a restart forgets neither (the review of
// 2026-10-09). Per account, so a stranger's tries do not stop the operator's.
const (
LinksAnHour = 3
KindLinksAnHour = 10
)
// allowLink counts a link asked by an account on a kind, and says why not when either bound is reached, or
// the count cannot be kept: an uncounted link is not begun.
func (h *Holder) allowLink(kind, identity string, now time.Time) string {
for _, b := range []struct {
key string
most int
words string
}{
{"asked." + kind + "." + kvKey(identity), LinksAnHour, fmt.Sprintf("This account asked to be linked %d times this hour. Try again in an hour.", LinksAnHour)},
{"asked." + kind, KindLinksAnHour, fmt.Sprintf("Links were asked %d times this hour on %s. Try again in an hour.", KindLinksAnHour, Display(kind))},
} {
times, err := h.Asks.GetLinksAsked(b.key)
if err != nil {
return "How often links were asked cannot be read, so none is begun: " + err.Error()
}
var recent []time.Time
for _, t := range times {
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
recent = append(recent, t)
}
}
if len(recent) >= b.most {
return b.words
}
}
for _, key := range []string{"asked." + kind + "." + kvKey(identity), "asked." + kind} {
times, _ := h.Asks.GetLinksAsked(key)
var recent []time.Time
for _, t := range times {
if now.Sub(t) < time.Hour && now.Sub(t) >= 0 {
recent = append(recent, t)
}
}
if err := h.Asks.PutLinksAsked(key, append(recent, now)); err != nil {
return "How often links were asked cannot be kept, so none is begun: " + err.Error()
}
}
return ""
}
// offerDeskUnlink asks at the desk whether an account still in its first hour is unlinked: it cannot approve
// yet, so whoever is at the desk may undo it (ADR 0259 §7, the review of 2026-10-09). One such question per
// account at a time. Answers whether it was asked.
func (h *Holder) offerDeskUnlink(id Identity) bool {
desk := ""
for _, k := range h.Bench.Kinds() {
if k.Kind != id.Kind && h.Bench.Promises(k.Kind, "private") && !h.Bench.Promises(k.Kind, "verified-sender") &&
h.Bench.Ready(k.Kind) == nil {
desk = k.Kind
break
}
}
if desk == "" {
return false
}
h.mu.Lock()
for _, e := range h.asks {
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen && e.rec.Act != nil && e.rec.Act.Do == "unlink" &&
e.rec.Act.Identity == id.Identity && e.rec.Only != nil && e.rec.Only.Kind == desk {
h.mu.Unlock()
return true
}
}
h.mu.Unlock()
now := h.Now()
until := id.ApproveFrom
a := asks.Ask{ID: "desk-unlink-" + id.Kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink the account linked just now?",
Explanation: fmt.Sprintf("The %s account %s (number %s) was linked as you at %s. Another account just asked "+
"to be linked. If the linked one is not yours, unlink it here before %s; after that only that account, "+
"or root at the router's machine, can.", Display(id.Kind), id.Display, id.Identity,
id.Linked.In(h.zone()).Format("15:04"), until.In(h.zone()).Format("15:04")),
Who: asks.Operator, Expires: until, OnExpiry: "the account stays linked",
Options: []asks.Option{{ID: "unlink", Label: "Unlink it", Does: "that account stops answering as you", Level: asks.Acknowledge}}}
if err := h.askSelf(a, Identity{Kind: desk}, SelfAct{Do: "unlink", Kind: id.Kind, Identity: id.Identity}); err != nil {
h.Logf("[messenger] the desk could not be asked to unlink %s: %v", id.Display, err)
return false
}
return true
}
// Link takes a link asked for on a kind: a code shown where only the operator sees it, or a refusal said on
// the kind.
func (h *Holder) Link(kind string, l asks.LinkAsked) string {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
reply := func(title, body string) string {
_ = h.showOn(kind, Message{Title: title, Body: body, Quiet: true, Reply: l.ID})
return title
}
if h.Bench == nil || !h.Bench.Promises(kind, "verified-sender") {
return reply("This channel cannot be linked", "It cannot prove who sends to it, so it answers only what anyone at the desk could.")
}
if !l.Sender.Authenticated || l.Sender.Identity == "" {
return reply("Not linked", "The service did not say who you are.")
}
now := h.Now()
if now.Sub(l.At) > FreshFor {
h.Logf("[messenger] a link asked on %s at %s is too old to act on", kind, l.At.Format(time.RFC3339))
return "too old"
}
for _, id := range h.identitiesOf(kind) {
if id.Identity == l.Sender.Identity {
return reply("Already linked", "This account is already linked as the operator.")
}
if now.Before(id.ApproveFrom) && h.offerDeskUnlink(id) {
return reply("Another account is linked", "Another account was linked here less than an hour ago. If "+
"that was not you, unlink it at your desk, where you were just asked, or as root at the router's "+
"machine; then send /start again.")
}
return reply("Another account is linked", "Another account is linked as the operator here. It is unlinked "+
"with that account's own approval, or as root at the router's machine, and then this one can be linked.")
}
if u := h.recentUnlink(kind); u != nil && u.Identity.Identity == l.Sender.Identity {
// An account unlinked less than a day ago does not come back by a code at once (the review of
// 2026-10-09): whoever unlinked it meant it, and an account taken with a phone must not walk back in.
return reply("Not linked", "This account was unlinked less than a day ago. It can be linked again from "+
u.At.Add(RelinkWaits).In(h.zone()).Format("2006-01-02 15:04")+", or allowed sooner as root at the router's machine.")
}
if why := h.allowLink(kind, l.Sender.Identity, now); why != "" {
return reply("Not linked", why)
}
// A code is shown only where the words stay on the operator's machines: a kind declaring `private`.
// It is shown now or never: a desk that cannot show it now (its notifications paused, nobody there) begins
// no link, so no code waits in a queue to be shown to whoever comes later (the review of 2026-10-09).
shows := ""
var whyNot []string
for _, k := range h.Bench.Kinds() {
if k.Kind == kind || !h.Bench.Promises(k.Kind, "private") {
continue
}
if err := h.Bench.Ready(k.Kind); err != nil {
whyNot = append(whyNot, err.Error())
continue
}
shows = k.Kind
break
}
if shows == "" {
why := "No private place can show the code now."
if len(whyNot) > 0 {
why = "Your desk cannot show the code now: " + strings.Join(whyNot, "; ") + "."
}
return reply("Not linked", why+" Nothing was begun. Try again when you are at your machine.")
}
code, err := sixDigits()
if err != nil {
return reply("Not linked", "No code could be made.")
}
salt := ticket()
p := PendingLink{Kind: kind, Identity: l.Sender.Identity, Display: l.Sender.Display, Salt: salt,
CodeHash: hashCode(salt, code), Expires: now.Add(LinkCodeFor)}
if err := h.Asks.PutLink(kind, p); err != nil {
return reply("Not linked", "The link could not be recorded: "+err.Error())
}
who := l.Sender.Display
if who == "" {
who = "an account"
}
notice := Message{Title: "Link a " + Display(kind) + " account as you",
Body: fmt.Sprintf("The %s account %q (number %s) asked to be linked as you. If that is you, send this code to "+
"the bot before %s: %s %s. If it is not you, do nothing: nothing is linked.", Display(kind), who,
l.Sender.Identity, p.Expires.In(h.zone()).Format("15:04"), code[:3], code[3:]),
Urgent: true, Secret: true}
if err := h.showOn(shows, notice); err != nil {
_ = h.Asks.DeleteLink(kind)
return reply("Not linked", "The code could not be shown: "+err.Error())
}
h.Logf("[messenger] a link was asked on %s by %q (%s); its code is shown on %s", kind, who, l.Sender.Identity, shows)
return reply("Send the code shown on your desk", "Six digits, within ten minutes.")
}
// Prove answers a code sent on a kind: the link it confirms, or why not. Never said in an event.
func (h *Holder) Prove(kind string, c asks.Code) asks.ProofAnswer {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
no := func(words string) asks.ProofAnswer { return asks.ProofAnswer{Accepted: false, Words: words} }
if c.Purpose != "" && c.Purpose != "link" {
return no("The mesh takes no code for that yet.")
}
if h.Asks == nil {
return no("Nothing can be linked here.")
}
p, err := h.Asks.GetLink(kind)
if err != nil || p == nil || p.Confirmed {
return no("No link waits for a code. Send /start to begin.")
}
now := h.Now()
if !now.Before(p.Expires) {
_ = h.Asks.DeleteLink(kind)
return no("The code expired. Send /start for a new one.")
}
if c.Sender.Identity != p.Identity || !c.Sender.Authenticated {
return no("This code was asked for by another account.")
}
code := strings.ReplaceAll(strings.TrimSpace(c.Code), " ", "")
if subtle.ConstantTimeCompare([]byte(hashCode(p.Salt, code)), []byte(p.CodeHash)) != 1 {
p.Tries++
if p.Tries >= LinkTries {
_ = h.Asks.DeleteLink(kind)
h.Logf("[messenger] a link on %s ended after %d wrong codes", kind, p.Tries)
return no("That code is not right, and that was the last try. Send /start for a new one.")
}
_ = h.Asks.PutLink(kind, *p)
return no("That code is not right.")
}
id := Identity{Kind: kind, Identity: p.Identity, Display: p.Display}
u := h.recentUnlink(kind)
if u != nil && u.Identity.Identity == p.Identity {
_ = h.Asks.DeleteLink(kind)
return no("This account was unlinked less than a day ago, and is not linked again by a code before a day has passed.")
}
if others := h.identitiesOf(kind); len(others) > 0 {
_ = h.Asks.DeleteLink(kind)
return no("Another account was linked here meanwhile. Nothing was linked.")
}
if u != nil {
// Within a day of an unlink: the account it replaces decides (novox/hq ADR 0259 §7).
p.Confirmed = true
p.Expires = now.Add(RelinkWaits)
_ = h.Asks.PutLink(kind, *p)
a := asks.Ask{ID: "relink-" + kind + "-" + fmt.Sprint(now.Unix()),
Headline: "Allow another account in your place?",
Explanation: fmt.Sprintf("This account was unlinked from being you less than a day ago. The %s account %s "+
"(number %s) now asks to be linked as you. If you choose Allow, it is.", Display(kind), p.Display, p.Identity),
Who: asks.Operator, Expires: now.Add(RelinkWaits - time.Minute), OnExpiry: "nothing is linked",
Options: []asks.Option{{ID: "allow", Label: "Allow", Does: "the other account is linked as you", Level: asks.Approve}}}
prior := u.Identity
prior.ApproveFrom = prior.Linked // an account linked before: its hour passed while it was linked
if err := h.askSelf(a, prior, SelfAct{Do: "link", Kind: kind, Identity: id.Identity, Link: &id}); err != nil {
return no("The link could not be asked of the account it replaces: " + err.Error())
}
h.announce(Message{Title: "A new " + Display(kind) + " account asks to be linked as you",
Body: fmt.Sprintf("%s (number %s), less than a day after an unlink. It waits for the account it replaces, "+
"or for root at the router's machine.", p.Display, p.Identity), Urgent: true})
return asks.ProofAnswer{Accepted: true, Words: "The code is right. Because an account was unlinked here less " +
"than a day ago, the link waits for that account's approval, or for you at the router's machine."}
}
h.linkNow(id, "with the code shown on the desk")
from := now.Add(LinkedApproveAfter).In(h.zone()).Format("15:04")
return asks.ProofAnswer{Accepted: true, Words: "Linked. You can answer acknowledgements now, and approve from " + from + "."}
}
// Identities is every linked account, by kind.
func (h *Holder) Identities() []Identity {
if h.Asks == nil {
return nil
}
keys, _ := h.Asks.IdentityKeys()
var out []Identity
for _, k := range keys {
if id, err := h.Asks.GetIdentity(k); err == nil && id != nil {
out = append(out, *id)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Kind+out[i].Identity < out[j].Kind+out[j].Identity })
return out
}
// announce says a message on every kind that can send, at once: a link or an unlink is seen everywhere.
func (h *Holder) announce(m Message) {
if h.Bench == nil {
return
}
for _, k := range h.Bench.Kinds() {
if h.Bench.Ready(k.Kind) == nil {
_ = h.showOn(k.Kind, m)
}
}
}
func sixDigits() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// decodeLink reads a link asked for.
func decodeLink(body json.RawMessage) (asks.LinkAsked, error) {
var l asks.LinkAsked
return l, json.Unmarshal(body, &l)
}
+265 -92
View File
@@ -1,7 +1,7 @@
// messenger: the holder of the operator-channel seat (novox/hq to-be 45 §5, ADR 0227, research 028).
// A Go bundle the node's runtime launches. It consumes the controller's condition events and decides
// what is said to the operator, on Telegram and on the desktop notifier of the machine the operator
// is at. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP
// messenger: the holder of the operator-channel seat, the router (novox/hq to-be 45 §5, to-be 46, ADR
// 0227, ADR 0259). A Go bundle the node's runtime launches. It consumes the controller's condition events and
// decides what is said to the operator, on the channel kinds the mesh holds — each a module of its own — and
// holds every ask, judges each answer and issues the warrant its asker acts on. It keeps its open messages in its own state, so a restart forgets nothing. stdout is the MCP
// channel; what this module says, it says on stderr.
package main
@@ -17,6 +17,7 @@ import (
_ "time/tzdata" // the operator's zone is known whatever this machine has installed
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/asks"
)
func errorf(format string, a ...any) error { return fmt.Errorf(format, a...) }
@@ -25,8 +26,6 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
// Settings are the operator's values for this module, merged by the mesh into one JSON file.
type Settings struct {
TelegramChatID string `json:"telegram-chat-id"`
DesktopMachines []string `json:"desktop-machines"`
// TimeZone is the operator's, an IANA name such as Europe/Brussels: every time a message says is said
// in it. Empty is this machine's own zone.
TimeZone string `json:"time-zone"`
@@ -54,27 +53,13 @@ func readSettings(path string) (Settings, error) {
if err != nil {
return s, err
}
// The chat id may be given as a number.
var loose map[string]any
if err := json.Unmarshal(raw, &loose); err != nil {
return s, fmt.Errorf("the settings file is not JSON: %v", err)
}
switch v := loose["telegram-chat-id"].(type) {
case string:
s.TelegramChatID = strings.TrimSpace(v)
case float64:
s.TelegramChatID = fmt.Sprintf("%.0f", v)
}
if tz, ok := loose["time-zone"].(string); ok {
s.TimeZone = strings.TrimSpace(tz)
}
if list, ok := loose["desktop-machines"].([]any); ok {
for _, m := range list {
if name, ok := m.(string); ok && strings.TrimSpace(name) != "" {
s.DesktopMachines = append(s.DesktopMachines, strings.TrimSpace(name))
}
}
}
return s, nil
}
@@ -137,6 +122,102 @@ func (stateStore) Recent() ([]Sent, error) {
return s, json.Unmarshal(e.Value, &s)
}
// askStore keeps the asks in the state `asks` and the operator's identities, and the links asked for, in
// `identities` (novox/hq ADR 0259). Each write that must win once is a compare-and-set.
type askStore struct{}
// changed reads the runtime's refusal of a compare-and-set that lost, told by its code, as ErrChanged.
func changed(err error) error {
if errors.Is(err, stdio.ErrChanged) {
return ErrChanged
}
return err
}
func (askStore) GetAsk(key string) (*AskRecord, uint64, error) {
e, err := stdio.State("asks").Get(key)
if err != nil || e == nil {
return nil, 0, err
}
var r AskRecord
if err := json.Unmarshal(e.Value, &r); err != nil {
return nil, 0, fmt.Errorf("the ask kept as %s cannot be read: %v", key, err)
}
return &r, e.Revision, nil
}
func (askStore) CreateAsk(key string, r AskRecord) (uint64, error) {
rev, err := stdio.State("asks").Create(key, r)
return rev, changed(err)
}
func (askStore) UpdateAsk(key string, r AskRecord, revision uint64) (uint64, error) {
rev, err := stdio.State("asks").Update(key, r, revision)
return rev, changed(err)
}
func (askStore) AskKeys() ([]string, error) { return stdio.State("asks").Keys() }
func (askStore) GetIdentity(key string) (*Identity, error) {
e, err := stdio.State("identities").Get(key)
if err != nil || e == nil {
return nil, err
}
var id Identity
return &id, json.Unmarshal(e.Value, &id)
}
func (askStore) PutIdentity(key string, id Identity) error {
_, err := stdio.State("identities").Put(key, id)
return err
}
func (askStore) DeleteIdentity(key string) error { return stdio.State("identities").Delete(key) }
func (askStore) IdentityKeys() ([]string, error) {
keys, err := stdio.State("identities").Keys()
var out []string
for _, k := range keys {
if !strings.HasPrefix(k, "pending.") && !strings.HasPrefix(k, "unlinked.") && !strings.HasPrefix(k, "asked.") {
out = append(out, k)
}
}
return out, err
}
func (askStore) GetLink(kind string) (*PendingLink, error) {
e, err := stdio.State("identities").Get("pending." + kind)
if err != nil || e == nil {
return nil, err
}
var l PendingLink
return &l, json.Unmarshal(e.Value, &l)
}
func (askStore) PutLink(kind string, l PendingLink) error {
_, err := stdio.State("identities").Put("pending."+kind, l)
return err
}
func (askStore) DeleteLink(kind string) error {
return stdio.State("identities").Delete("pending." + kind)
}
func (askStore) GetUnlinked(kind string) (*Unlinked, error) {
e, err := stdio.State("identities").Get("unlinked." + kind)
if err != nil || e == nil {
return nil, err
}
var u Unlinked
return &u, json.Unmarshal(e.Value, &u)
}
func (askStore) PutUnlinked(kind string, u Unlinked) error {
_, err := stdio.State("identities").Put("unlinked."+kind, u)
return err
}
func (askStore) GetLinksAsked(key string) ([]time.Time, error) {
e, err := stdio.State("identities").Get(key)
if err != nil || e == nil {
return nil, err
}
var times []time.Time
return times, json.Unmarshal(e.Value, &times)
}
func (askStore) PutLinksAsked(key string, times []time.Time) error {
_, err := stdio.State("identities").Put(key, times)
return err
}
// listening is whether condition events reach this holder, in words.
type listening struct {
mu sync.Mutex
@@ -163,18 +244,19 @@ func main() {
}
return s
}
bench := NewBench(time.Now)
publish := func(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) }
h := &Holder{
Telegram: NewTelegram(TelegramConfig{
TokenFile: os.Getenv("MESH_MESSENGER_TELEGRAM_TOKEN_FILE"),
ChatID: func() string { return settings().TelegramChatID },
}),
Desktop: &Desktop{
Machines: func() []string { return settings().DesktopMachines },
Ask: stdio.Ask,
},
Store: stateStore{},
Ask: stdio.Ask,
Logf: logf,
Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish},
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish},
Store: stateStore{},
Asks: askStore{},
Node: os.Getenv("MESH_NODE"),
StateDir: os.Getenv("MESH_MESSENGER_STATE"),
Bench: bench,
Publish: publish,
Ask: stdio.Ask,
Logf: logf,
Zone: func() *time.Location {
loc, err := zoneOf(settings().TimeZone)
said.Lock()
@@ -190,6 +272,12 @@ func main() {
},
Emit: func(event string, body any) error { return stdio.Emit(event, body) },
}
bench.To = func(kind string) string {
if ids := h.identitiesOf(kind); len(ids) > 0 {
return ids[0].Identity
}
return ""
}
h.init()
l := &listening{now: "not yet: starting"}
go run(h, l)
@@ -250,7 +338,10 @@ func run(h *Holder, l *listening) {
h.Condition(event, c)
return nil
}
go listenForAnswers(h)
go listenForChannels(h)
go takeAsks(h)
go answerProofs(h)
go followKinds(h)
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.Subscribe(ControllerSeat+".*", handle)
if err == nil {
@@ -264,61 +355,137 @@ func run(h *Holder, l *listening) {
}
}
// AnswerEvent is the desk's notifier saying which answer was chosen, and StartedEvent that it started (so
// the answers it waited for are lost): the dunst holder of node-notifier emits them as its module's events
// until seats publish events of their own (to-be 46 phase 3).
const (
AnswerEvent = "dunst.action-chosen"
StartedEvent = "dunst.started"
)
// kindOf is an intake event's kind and what it is: `intake.<what>.<kind>`, the kind the subject's last token,
// which the bus grants that kind's holder alone.
func kindOf(key string) (what, kind string, ok bool) {
parts := strings.Split(key, ".")
if len(parts) != 3 || parts[0] != asks.IntakeSeat {
return "", "", false
}
return parts[1], parts[2], true
}
// listenForAnswers takes the answers the operator chooses on the desk, retried until it can.
func listenForAnswers(h *Holder) {
// listenForChannels takes what the channels say (novox/hq ADR 0259): answers chosen, links asked, messages
// that failed, and whether each can send — every one under the kind the bus granted its holder.
func listenForChannels(h *Holder) {
handle := func(e stdio.Envelope) error {
if e.Key == StartedEvent {
var body struct {
Machine string `json:"machine"`
what, kind, ok := kindOf(e.Key)
if !ok {
return nil
}
switch what {
case asks.Chosen:
var c asks.Choice
if err := json.Unmarshal(e.Body, &c); err != nil || c.Ticket == "" {
logf("[messenger] an answer from %s could not be read: %s", kind, firstLineOf(string(e.Body)))
return nil
}
_ = json.Unmarshal(e.Body, &body)
if body.Machine == "" {
body.Machine = e.Node
if c.ID == "" {
c.ID = e.Headers["x-event-id"]
}
_, _ = h.Chosen(kind, c)
case asks.Link:
l, err := decodeLink(e.Body)
if err != nil {
logf("[messenger] a link asked on %s could not be read", kind)
return nil
}
h.Link(kind, l)
case asks.FailedWhat:
var f asks.Failed
if json.Unmarshal(e.Body, &f) == nil {
h.Failed(kind, f)
}
case asks.StandingWhat:
var s asks.Standing
if json.Unmarshal(e.Body, &s) == nil {
h.Bench.Stand(kind, Standing{Ready: s.Ready, Why: s.Why, EditsSilently: s.EditsSilently, At: s.At})
}
h.Reoffer(body.Machine)
return nil
}
if e.Key != AnswerEvent {
return nil
}
var body struct {
Token string `json:"token"`
Machine string `json:"machine"`
}
if err := json.Unmarshal(e.Body, &body); err != nil || body.Token == "" {
logf("[messenger] an answer from the desk could not be read: %s", firstLineOf(string(e.Body)))
return nil
}
machine := body.Machine
if machine == "" {
machine = e.Node
}
// The machine is the event's own node, said by the runtime: the body's word is only a hint.
if e.Node != "" {
machine = e.Node
}
_, _ = h.Chosen("desktop", machine, body.Token, e.Headers["x-event-id"])
return nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.Subscribe("dunst.*", handle); err == nil {
logf("[messenger] listening for the operator's answers on the desk")
return
if err := stdio.Subscribe(asks.IntakeSeat+".#", handle); err == nil {
logf("[messenger] listening for what the channels say")
break
} else {
logf("[messenger] not hearing the desk's answers yet (%v); asking again in %s", err, wait)
logf("[messenger] not hearing the channels yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// takeAsks takes the asks and cancels submitted to the seat, each under its asker's name.
func takeAsks(h *Holder) {
handle := func(w stdio.Work) error {
parts := strings.Split(w.Subject, ".")
if len(parts) != 6 {
logf("[messenger] work on %s is not an ask; taken and dropped", w.Subject)
return nil
}
return h.TakeAsk(parts[4], parts[5], w.Body)
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatTake(AsksWorker, handle); err == nil {
logf("[messenger] taking the asks submitted to %s", asks.Seat)
break
} else {
logf("[messenger] not taking asks yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// AsksWorker is the worker the seat's asks are taken from, as the controller names it.
const AsksWorker = "SEAT_OPERATOR_CHANNEL_worker"
// answerProofs answers the codes the channels ask as proofs: never an event, never kept.
func answerProofs(h *Holder) {
handle := func(subject string, body json.RawMessage) (any, error) {
parts := strings.Split(subject, ".")
kind := parts[len(parts)-1]
var c asks.Code
if err := json.Unmarshal(body, &c); err != nil {
return asks.ProofAnswer{Words: "The code could not be read."}, nil
}
return h.Prove(kind, c), nil
}
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
if err := stdio.SeatAnswer(asks.ProofSubject(asks.CodeProof, "*"), handle); err == nil {
logf("[messenger] answering the codes the channels are sent")
break
} else {
logf("[messenger] not answering codes yet (%v); asking again in %s", err, wait)
}
time.Sleep(wait)
}
}
// followKinds reads, every half minute, which channel kinds the controller says exist and what each
// promises: never from a channel.
func followKinds(h *Holder) {
said := ""
for {
kinds, err := stdio.SeatKinds()
if err != nil {
if err.Error() != said {
logf("[messenger] which channels exist cannot be read (%v); asking again", err)
said = err.Error()
}
} else {
var out []KindInfo
for _, k := range kinds {
if k.Seat == asks.ChannelSeat {
out = append(out, KindInfo{Kind: k.Kind, Module: k.Module, Node: k.Node, Capabilities: k.Capabilities})
}
}
h.Bench.SetKinds(out)
said = ""
}
time.Sleep(30 * time.Second)
}
}
// syncFromController reads the controller's open conditions and gives them to the holder.
func syncFromController(h *Holder) {
asked := h.Now()
@@ -398,34 +565,40 @@ func tools(h *Holder, l *listening) []stdio.Tool {
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
}},
{Name: "messenger_status",
Description: "Whether the operator can be reached, and why not: each channel — can it send, what it lacks " +
"(the Telegram bot token and chat id, the desktop machines), its last error, how many it sent in the " +
"last hour against the cap, how many it holds — the open and silenced count, messages not sent yet, " +
"refusals, unreadable events, whether condition events arrive, and the rules it applies. check asks " +
"Telegram who the bot is, sending nothing.",
Input: map[string]any{"check": map[string]any{"type": "boolean", "description": "also ask Telegram whether the token works"}},
Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " +
"send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " +
"many it holds — the open and silenced count, messages not sent yet, refusals, unreadable events, " +
"whether condition events arrive, and the rules it applies.",
Run: func(map[string]any) (any, error) { return h.Status(l.get()), nil }},
{Name: "operator-channel.asks",
Description: "The asks this router holds (novox/hq ADR 0259): open first, then the last month's — each " +
"one's asker, headline, state, when it opened and expires, the channels it was offered on, how it " +
"ended (the warrant: who chose what, through which channel), and whether its asker was told.",
Run: func(map[string]any) (any, error) { return h.AsksNow(), nil }},
{Name: "messenger_identities",
Description: "The accounts linked as the operator, one per channel kind: the account, the name its " +
"service shows, when it was linked, and from when it can approve (an hour after linking).",
Run: func(map[string]any) (any, error) { return h.Identities(), nil }},
{Name: "messenger_unlink",
Description: "Unlink the account linked as the operator on one channel kind: from then its answers are " +
"refused until an account is linked again. Said on every channel, the unlinked one included.",
Input: map[string]any{"kind": str("the channel kind, e.g. telegram")},
Run: func(a map[string]any) (any, error) {
st := h.Status(l.get())
if check, _ := a["check"].(bool); check {
if t, ok := h.Telegram.(*Telegram); ok {
who, err := t.Who()
if err != nil {
return map[string]any{"status": st, "telegram_check": "failed: " + err.Error()}, nil
}
return map[string]any{"status": st, "telegram_check": "the token works: the bot is @" + who}, nil
}
kind := strArg(a, "kind")
if kind == "" {
return nil, errorf("kind is required")
}
return st, nil
return h.Unlink(kind)
}},
{Name: "messenger_recent",
Description: "The recent sends — each message, digest, edit and failure on each channel, newest first — and the refusals.",
Input: map[string]any{"limit": map[string]any{"type": "integer", "description": "at most this many (default 20)"}},
Run: func(a map[string]any) (any, error) { return h.History(limitArg(a, 20)), nil }},
{Name: "messenger_test",
Description: "Send a test message now, to telegram, desktop or both (default both), through the content rule " +
"and the cap: proves a channel reaches the operator. Answers per channel: sent, or why not.",
Description: "Send a test message now, to one channel kind or every one (default), through the content rule " +
"and the cap: proves a channel reaches the operator. Answers per channel: put on its queue, or why not.",
Input: map[string]any{
"channel": map[string]any{"type": "string", "enum": []string{"telegram", "desktop", "both"}},
"channel": str("a channel kind, e.g. telegram, or empty for every one"),
"text": str("the words (default: a line saying it is a test)"),
},
Run: func(a map[string]any) (any, error) { return h.Test(strArg(a, "channel"), strArg(a, "text")), nil }},
@@ -8,31 +8,43 @@ import (
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The manifest says what the code does: the seat it declares and holds and the verbs it serves, the
// events it consumes (the controller's three), the one it emits, the tool it calls, its state, its
// secret, and its own tools — and names nothing of one installation.
// The manifest says what the code does (novox/hq ADR 0259): the seats it declares — the operator's, and
// the two kinded benches its channels hold — the one it holds and the verbs it serves, the bench it sends
// on, the events it consumes, the one it emits, the tool it calls, its state, and its own tools. It holds
// no channel's secret, and names nothing of one installation.
type manifest struct {
Module string `json:"module"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Consumes []string
Emits []string
Invokes []string
State []string
Own map[string]string `json:"own-secrets"`
Tools []string
Build struct {
Module string `json:"module"`
Seats []seat `json:"seats"`
Claims []seat `json:"claims"`
Uses []string
Consumes []string
Emits []string
Invokes []string
State []json.RawMessage
Own map[string]string `json:"own-secrets"`
RunsAs string `json:"runs-as"`
SecretsOwner string `json:"secrets-owner"`
Tools []string
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
Accepts []string `json:"accepts"`
Emits []string `json:"emits"`
Kinded bool `json:"kinded"`
ByCaller []string `json:"by-caller"`
Proofs []string `json:"proofs"`
Records []string `json:"records"`
}
func readManifest(t *testing.T) (manifest, string) {
@@ -48,29 +60,51 @@ func readManifest(t *testing.T) (manifest, string) {
return m, string(raw)
}
func TestItDeclaresAndHoldsTheOperatorChannel(t *testing.T) {
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
m, _ := readManifest(t)
want := seat{Name: "operator-channel", Scope: "mesh", Serves: []string{"open", "history", "notify"}}
if len(m.Seats) != 1 || !reflect.DeepEqual(m.Seats[0], want) || len(m.Claims) != 1 || !reflect.DeepEqual(m.Claims[0], want) {
t.Fatalf("seats %+v claims %+v", m.Seats, m.Claims)
verbs := []string{"open", "history", "notify", "asks"}
want := []seat{
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}},
{Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}},
{Name: asks.IntakeSeat, Scope: "mesh", Kinded: true,
Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}},
}
if !reflect.DeepEqual(m.Seats, want) {
t.Fatalf("seats %+v", m.Seats)
}
if len(m.Claims) != 1 || m.Claims[0].Name != asks.Seat || !reflect.DeepEqual(m.Claims[0].Serves, verbs) {
t.Fatalf("claims %+v", m.Claims)
}
if !reflect.DeepEqual(m.Uses, []string{asks.ChannelSeat}) {
t.Fatalf("uses %v", m.Uses)
}
if !reflect.DeepEqual(m.Consumes, []string{
ControllerSeat + "." + EventRaised, ControllerSeat + "." + EventChanged, ControllerSeat + "." + EventCleared,
AnswerEvent, StartedEvent}) {
"intake.choice", "intake.link", "intake.failed", "intake.standing"}) {
t.Fatalf("consumes %v", m.Consumes)
}
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:node-notifier.send", "seat:" + ControllerSeat + ".conditions"}) {
// It performs nothing: the verbs it calls read the open conditions and whether root is free.
if !reflect.DeepEqual(m.Emits, []string{"refused"}) || !reflect.DeepEqual(m.Invokes, []string{"seat:" + ControllerSeat + ".conditions",
"seat:" + ControllerSeat + ".root-free"}) {
t.Fatalf("emits %v invokes %v", m.Emits, m.Invokes)
}
if !reflect.DeepEqual(m.State, []string{"open", "sent"}) {
t.Fatalf("state %v", m.State)
var state []string
for _, s := range m.State {
var name string
if json.Unmarshal(s, &name) != nil {
var full struct{ Name string }
_ = json.Unmarshal(s, &full)
name = full.Name
}
state = append(state, name)
}
if m.Own["telegram-token"] == "" {
t.Fatalf("own secrets %v", m.Own)
if !reflect.DeepEqual(state, []string{"open", "sent", "asks", "identities"}) {
t.Fatalf("state %v", state)
}
env, _ := m.Build.Artifacts[0]["env"].(map[string]any)
if env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"] != m.Own["telegram-token"] {
t.Fatalf("the bundle reads its token from %v, the secret is at %v", env["MESH_MESSENGER_TELEGRAM_TOKEN_FILE"], m.Own["telegram-token"])
// It holds no channel's secret: only its own bus account, read by its own account alone (ADR 0259 §8).
if len(m.Own) != 1 || m.Own["broker"] == "" || m.RunsAs != "messenger" || m.SecretsOwner != "messenger" {
t.Fatalf("own secrets %v, runs as %q, secrets owned by %q", m.Own, m.RunsAs, m.SecretsOwner)
}
}
@@ -99,7 +133,7 @@ func TestTheToolsAgreeWithTheManifest(t *testing.T) {
if !reflect.DeepEqual(verbs, m.Claims[0].Serves) {
t.Fatalf("verbs %v, claim %v", verbs, m.Claims[0].Serves)
}
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "api.telegram"} {
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox", "zurag", "telegram"} {
if strings.Contains(strings.ToLower(raw), never) {
t.Errorf("module.json names %q", never)
}
+25 -16
View File
@@ -33,7 +33,15 @@ func (h *Holder) deliver(r *Record, what string) {
return
}
var to []Channel
if r.Severity == Urgent {
if kinds := h.askKinds(r); len(kinds) > 0 {
// An ask goes to every channel that carries one of its answers (novox/hq ADR 0259): the desk for an
// acknowledgement, the channel that proves its sender for an approval — whatever its severity.
for _, ch := range h.channels() {
if kinds[ch.Name()] && ch.Ready() == nil {
to = append(to, ch)
}
}
} else if r.Severity == Urgent {
for _, ch := range h.channels() {
if ch.Ready() == nil {
to = append(to, ch)
@@ -41,10 +49,10 @@ func (h *Holder) deliver(r *Record, what string) {
}
} else {
switch {
case h.Desktop != nil && h.Desktop.Ready() == nil:
to = []Channel{h.Desktop}
case h.Telegram != nil && h.Telegram.Ready() == nil:
to = []Channel{h.Telegram}
case h.Desk != nil && h.Desk.Ready() == nil:
to = []Channel{h.Desk}
case h.Away != nil && h.Away.Ready() == nil:
to = []Channel{h.Away}
}
}
if len(to) == 0 {
@@ -113,7 +121,6 @@ func (h *Holder) queued(key string) bool {
// edit fails, or that said it inside a digest, is given a new message instead — held like any other.
// silentOnly: only where an edit notifies nobody; elsewhere nothing.
func (h *Holder) edit(r *Record, what string, silentOnly bool) {
m := h.say(r, what)
h.mu.Lock()
sent := map[string]string{}
for k, v := range r.Sent {
@@ -131,7 +138,7 @@ func (h *Holder) edit(r *Record, what string, silentOnly bool) {
continue
}
if id != "" && ch.CanEdit() {
err := ch.Edit(id, m)
err := ch.Edit(id, h.sayOn(r, what, ch.Name()))
h.noteChannel(ch.Name(), err)
if err == nil {
h.record(Sent{At: now, Channel: ch.Name(), Key: r.Key, What: what, Outcome: "edited"})
@@ -204,14 +211,14 @@ func (h *Holder) due(ch Channel, list []item, now time.Time) bool {
if now.Sub(oldest) < BurstWindow {
return false
}
if ch == h.Desktop && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery {
if ch == h.Desk && !lastWarn.IsZero() && now.Sub(lastWarn) < DesktopWarningEvery {
return false
}
return true
}
// line is what an item says, resolved against the record now: nil when it is no longer to be said.
func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) {
func (h *Holder) resolve(it item, now time.Time, kind string) (*Record, *Message) {
if it.Msg != nil {
return nil, it.Msg
}
@@ -224,7 +231,7 @@ func (h *Holder) resolve(it item, now time.Time) (*Record, *Message) {
if (it.What == "cleared") != !r.Cleared.IsZero() {
return nil, nil
}
m := h.say(r, it.What)
m := h.sayOn(r, it.What, kind)
return r, &m
}
@@ -238,7 +245,7 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
}
var entries []entry
for _, it := range list {
r, m := h.resolve(it, now)
r, m := h.resolve(it, now, name)
if m == nil {
continue
}
@@ -280,10 +287,12 @@ func (h *Holder) flushChannel(ch Channel, now time.Time, force bool) {
return
}
if len(entries) > 1 {
// A message that asks the operator something goes alone, so its answers come with it.
// A message that asks the operator something goes alone, so its answers come with it: one with several
// answers, and one carrying an ask's answer even when only one can be given here (the lab's proof found
// an ask whose approval was not offered folded into a digest, and its one answer lost with it).
var alone, rest []entry
for _, e := range entries {
if len(e.msg.Actions) > 1 {
if len(e.msg.Actions) > 1 || (len(e.msg.Actions) > 0 && e.r != nil && e.r.Ask != "") {
alone = append(alone, e)
} else {
rest = append(rest, e)
@@ -341,7 +350,7 @@ func (h *Holder) sendEntries(ch Channel, entries []entry, now time.Time) bool {
h.lastSend[name] = now
h.lastTry[name] = time.Time{}
for _, e := range entries {
if ch == h.Desktop && !e.it.urgent() {
if ch == h.Desk && !e.it.urgent() {
// The desktop's warning rhythm counts from a message that carried a warning.
h.lastWarn[name] = now
}
@@ -404,14 +413,14 @@ func (h *Holder) failed(ch Channel, list []item, taken int, now time.Time, err e
h.lastTry[name] = now
h.mu.Unlock()
h.record(Sent{At: now, Channel: name, Key: "(held)", What: fmt.Sprintf("%d held", taken), Outcome: "failed: " + err.Error()})
if ch == h.Desktop && h.Telegram != nil && h.Telegram.Ready() == nil {
if ch == h.Desk && h.Away != nil && h.Away.Ready() == nil {
var keep []item
for _, it := range list[:taken] {
if it.Severity == Urgent {
keep = append(keep, it)
continue
}
h.enqueue(h.Telegram.Name(), it)
h.enqueue(h.Away.Name(), it)
}
h.mu.Lock()
h.outbox[name] = append(keep, h.outbox[name][taken:]...)
@@ -0,0 +1,535 @@
package main
// The findings of the review of 2026-10-09, each held here by a test that failed before its fix.
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// H3: approval rests on the controller's positive, recent word that no agent can become root where the router
// and the channel run (its verb root-free), asked live. Every other answer fails closed.
func TestApprovalNeedsTheControllersWordThatRootIsFree(t *testing.T) {
for name, tc := range map[string]struct {
setup func(h *Holder)
want string
}{
"a router that does not know its machine": {func(h *Holder) { h.Node = "" }, "does not know which machine"},
"a channel whose machine is not said": {func(h *Holder) {
k, _ := h.Bench.Kind("telegram")
k.Node = ""
h.Bench.SetKinds([]KindInfo{k})
}, "has not said which machine"},
"a controller that does not answer": {func(h *Holder) {
h.RootFree = func([]string) (map[string]RootVerdict, error) { return nil, errors.New("no responders") }
}, "could not be asked: no responders"},
"a machine the answer leaves out": {func(h *Holder) {
h.RootFree = func([]string) (map[string]RootVerdict, error) { return map[string]RootVerdict{}, nil }
}, "said nothing of whether an agent can become root on anchor"},
"a machine not free": {func(h *Holder) { rootFreeOf[h]["anchor"] = "the agent account is not judged" },
"may become root on anchor"},
"the router's own machine not free": {func(h *Holder) {
h.Node = "router-machine"
rootFreeOf[h]["router-machine"] = "execute is served there"
}, "may become root on router-machine"},
} {
t.Run(name, func(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
release := ticketFor(tg.sends[0], "Release")
if release == "" {
t.Fatal("not offered while root was free")
}
tc.setup(h)
out, err := h.Chosen("telegram", choiceOf(release, "42", c.now()))
if out != "refused" || err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s %v, want refused for %q", out, err, tc.want)
}
if len(w.of("mesh-delivery")) != 0 {
t.Error("a warrant was issued")
}
if got := h.carriers(asks.Approve); len(got) != 0 {
t.Errorf("approval still carried on %v", got)
}
})
}
}
// H3: the word is asked of the controller's verb, as this module, and read from its answer.
func TestTheRootWordIsAskedOfTheControllersVerb(t *testing.T) {
h, _, _, _, _, _ := newRouter(t)
h.RootFree = nil
var asked []string
h.Ask = func(key string, body any) (json.RawMessage, error) {
asked = append(asked, key)
raw, _ := json.Marshal(body)
if !strings.Contains(string(raw), `"machines":["anchor"]`) {
t.Errorf("asked about %s", raw)
}
return json.RawMessage(`{"machines":[{"machine":"anchor","free":true,"why":"judged","judged":"2026-10-09T10:00:00Z"}]}`), nil
}
info, _ := h.Bench.Kind("telegram")
if why := h.rootFree(info, false); why != "" {
t.Fatalf("free not read: %s", why)
}
if len(asked) != 1 || asked[0] != "seat:mesh-controller.root-free" {
t.Errorf("asked %v", asked)
}
h.Ask = func(string, any) (json.RawMessage, error) { return json.RawMessage(`not json`), nil }
if why := h.rootFree(info, false); !strings.Contains(why, "could not be read") {
t.Errorf("an unreadable answer was a pass: %q", why)
}
h.Ask = nil
if why := h.rootFree(info, false); why == "" {
t.Error("no way to ask was a pass")
}
}
// H3: a message rendered while root is not free offers no approval; a word kept for rendering is never the
// word an answer is judged on.
func TestAMessageRenderedWhileRootIsNotFreeOffersNoApproval(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
rec := h.openAsk(h.open["ask.mesh-delivery.d-1"])
if got := h.offers(rec, "telegram"); len(got) != 3 {
t.Fatalf("offers %v", got)
}
rootFreeOf[h]["anchor"] = "not judged"
c.pass(RootFreeKept)
if got := h.offers(rec, "telegram"); len(got) != 0 {
t.Errorf("rendered with an approval while root is not free: %v", got)
}
// Free again, but the kept word is not: the answer asks anew and is taken.
delete(rootFreeOf[h], "anchor")
out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now()))
if err != nil || out != "chosen" {
t.Errorf("judged on a kept word: %s %v", out, err)
}
}
// H3: a capability the controller's word no longer says stays no longer than the word is fresh: a router that
// cannot read the kinds again promises no verified sender.
func TestAStaleWordOnTheKindsPromisesNoVerifiedSender(t *testing.T) {
h, _, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
if !h.Bench.Promises("telegram", "verified-sender") {
t.Fatal("not promised while fresh")
}
c.pass(KindsFresh + time.Second)
h.Bench.Stand("telegram", Standing{Ready: true, At: c.now()})
if h.Bench.Promises("telegram", "verified-sender") || len(h.carriers(asks.Approve)) != 0 {
t.Error("a stale word still promises a verified sender")
}
if !h.Bench.Promises("telegram", "choice") {
t.Error("a stale word stopped the channel sending")
}
}
// C6: a link's code is marked secret on every path to a channel, the desk's own role included, so the channel
// keeps no copy (the review found it dropped where the desk's role sends).
func TestALinksCodeIsSecretOnEveryPath(t *testing.T) {
c := start()
bench := NewBench(c.now)
bench.SetKinds([]KindInfo{
{Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"reaches-away", "choice", "verified-sender", "exact-render"}},
{Kind: "desktop", Module: "desk-channel", Node: "anchor", Capabilities: []string{"choice", "exact-render", "private"}},
})
bench.Stand("telegram", Standing{Ready: true, At: c.now()})
bench.Stand("desktop", Standing{Ready: true, At: c.now()})
var mu sync.Mutex
put := map[string][]asks.Message{}
publish := func(subject string, body any, id string) error {
mu.Lock()
defer mu.Unlock()
if m, ok := body.(asks.Message); ok {
put[subject] = append(put[subject], m)
}
return nil
}
h := &Holder{Away: &SeatChannel{Bench: bench, Role: RoleAway, Publish: publish},
Desk: &SeatChannel{Bench: bench, Role: RoleDesk, Publish: publish}, Bench: bench, Publish: publish,
Asks: newMemAsks(), Node: "anchor", Now: c.now, Logf: t.Logf, Zone: func() *time.Location { return time.UTC }}
h.init()
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
shown := put[asks.ChannelSubject(asks.Send, "desktop")]
if len(shown) != 1 || !strings.Contains(shown[0].Body, "send this code") {
t.Fatalf("the code was not shown on the desk: %+v", put)
}
if !shown[0].Secret {
t.Error("the code went to the desk not marked secret: the channel would keep a copy")
}
}
// L1: a warrant's message id is new each time: one anyone could foresee lets another publisher on the stream
// take it first, and the bus would drop the warrant as a duplicate.
func TestAWarrantsMessageIdCannotBeForeseen(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
var ids []string
h.Publish = func(subject string, body any, id string) error {
if _, ok := body.(asks.Warrant); ok {
ids = append(ids, id)
}
return nil
}
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
if out, err := h.Chosen("telegram", choiceOf(ticketFor(tg.sends[0], "Release"), "42", c.now())); out != "chosen" {
t.Fatalf("%s %v", out, err)
}
_ = h.tell(*store.asks["mesh-delivery.d-1"].Warrant)
if len(ids) != 2 || ids[0] == ids[1] {
t.Fatalf("ids %v", ids)
}
for _, id := range ids {
if strings.Contains(id, "d-1") || strings.Contains(id, "mesh-delivery") {
t.Errorf("the id %q is made of what anyone knows of the ask", id)
}
}
}
// M2: an ask's message says who asks, as the bus proves it, what each answer does, and what happens if nobody
// answers.
func TestAnAsksMessageSaysWhoAsksWhatEachAnswerDoesAndWhatExpiryDoes(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
body := tg.sends[0].Body
for _, want := range []string{"Asked by mesh-delivery.", "Release: the delivery goes on.", "Stop: the delivery ends.",
"Silence for a week: nothing more is said of it for a week.",
"If nobody answers by " + c.now().Add(2*time.Hour-BurstWindow).In(time.UTC).Format("15:04") + ": the delivery stays held."} {
if !strings.Contains(body, want) {
t.Errorf("the message does not say %q:\n%s", want, body)
}
}
}
// M3: the router's own unlink binds the account it asks about: an approval from that account, after another was
// linked in its place, unlinks nothing.
func TestAnUnlinkUnlinksOnlyTheAccountItNamed(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
if _, err := h.Unlink("telegram"); err != nil {
t.Fatal(err)
}
unlink := ticketFor(tg.sends[len(tg.sends)-1], "Unlink")
delete(store.ids, identityKey("telegram", "42"))
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Display: "Al",
Linked: c.now().Add(-2 * time.Hour), ApproveFrom: c.now().Add(-time.Hour)}
_, _ = h.Chosen("telegram", choiceOf(unlink, "42", c.now()))
if _, still := store.ids[identityKey("telegram", "77")]; !still {
t.Error("an approval to unlink 42 unlinked 77")
}
if got := store.asks; len(got) == 0 {
t.Fatal("no ask")
}
for _, rec := range store.asks {
if rec.Act != nil && rec.Act.Identity != "42" {
t.Errorf("the ask binds %q", rec.Act.Identity)
}
}
}
// M3: a link is checked again as it is made: an approved relink does not link a second account of a kind.
func TestALinkIsNotMadeWhileAnotherAccountIsLinked(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}})
code := codeOf(t, dt.sends[len(dt.sends)-1])
_ = h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "666", Authenticated: true}, Code: code})
asked := tg.sends[len(tg.sends)-1]
// Meanwhile root linked another account (allowed at the machine).
store.ids[identityKey("telegram", "77")] = Identity{Kind: "telegram", Identity: "77", Linked: c.now(), ApproveFrom: c.now().Add(time.Hour)}
_, _ = h.Chosen("telegram", choiceOf(ticketFor(asked, "Allow"), "42", c.now()))
if _, linked := store.ids[identityKey("telegram", "666")]; linked {
t.Errorf("a second account of a kind was linked: %v", store.ids)
}
}
// M3: an account unlinked less than a day ago is not linked again by a code: no code is shown for it.
func TestAnUnlinkedAccountDoesNotComeBackByACodeWithinADay(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
shown := len(dt.sends)
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a link was begun for the account unlinked an hour ago")
}
for _, m := range dt.sends[shown:] {
if strings.Contains(m.Body, "send this code") {
t.Error("a code was shown for it")
}
}
if len(store.ids) != 0 {
t.Error("linked again")
}
}
// H4: an account still in its first hour cannot approve yet, so whoever is at the desk may unlink it — asked
// there when another account asks to be linked — and past its hour the desk no longer can.
func TestAnAccountInItsFirstHourIsUnlinkedFromTheDesk(t *testing.T) {
h, tg, dt, c, store, w := newRouter(t)
link(h, store, c, "666", c.now().Add(-10*time.Minute)) // an account the operator did not link
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if reply := tg.sends[len(tg.sends)-1]; !strings.Contains(reply.Body, "unlink it at your desk") {
t.Fatalf("the sender was not told how: %q", reply.Body)
}
asked := dt.sends[len(dt.sends)-1]
if labelsOf(asked) != "Unlink it" || !strings.Contains(asked.Body, "number 666") {
t.Fatalf("the desk was not asked: %+v", asked)
}
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now())
ch.Sender.Authenticated = false
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
if len(store.ids) != 0 {
t.Errorf("not unlinked: %v", store.ids)
}
if len(w.of(SelfAsker)) != 0 {
t.Error("the router's own act was told to an asker")
}
// Past its hour, no question goes to the desk.
h2, tg2, dt2, c2, store2, _ := newRouter(t)
link(h2, store2, c2, "666", c2.now().Add(-2*time.Hour))
h2.Link("telegram", asks.LinkAsked{ID: "l1", At: c2.now(), Sender: asks.Sender{Identity: "42", Authenticated: true}})
for _, m := range dt2.sends {
if labelsOf(m) == "Unlink it" {
t.Error("the desk was asked to unlink an account past its first hour")
}
}
if !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "own approval, or as root") {
t.Errorf("%q", tg2.sends[len(tg2.sends)-1].Body)
}
}
// H4 (C4): while the desk cannot show a code now — its notifications paused — no link is begun and no code is
// made or queued; the sender is told why.
func TestNoLinkIsBegunWhileTheDeskCannotShowTheCode(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
h.Bench.Stand("desktop", Standing{Ready: false, Why: "notifications are paused on the desk", At: c.now()})
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if _, pending := store.links["telegram"]; pending {
t.Error("a link was begun")
}
if len(dt.sends) != 0 {
t.Errorf("something was put to the desk: %+v", dt.sends)
}
if reply := tg.sends[len(tg.sends)-1].Body; !strings.Contains(reply, "notifications are paused on the desk") {
t.Errorf("the sender was not told why: %q", reply)
}
}
// L5: links are bounded per account, and kept in the router's state: a stranger's tries do not stop the
// operator's link, and a restart forgets no count.
func TestLinksAreBoundedPerAccountAndTheCountIsKept(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
eve := asks.Sender{Identity: "666", Display: "Eve", Authenticated: true}
for i := 0; i < LinksAnHour; i++ {
h.Link("telegram", asks.LinkAsked{ID: "e" + string(rune('0'+i)), At: c.now(), Sender: eve})
}
shown := len(dt.sends)
h.Link("telegram", asks.LinkAsked{ID: "jo", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
if len(dt.sends) != shown+1 || !strings.Contains(dt.sends[len(dt.sends)-1].Body, "(number 42)") {
t.Fatalf("a stranger's tries stopped the operator's link: %q", tg.sends[len(tg.sends)-1].Body)
}
// A router started again on the same state still counts the stranger's tries.
h2, tg2, dt2, c2, _, _ := newRouter(t)
h2.Asks = store
c2.pass(c.now().Sub(c2.now()))
stand(h2, c2)
before := len(dt2.sends)
h2.Link("telegram", asks.LinkAsked{ID: "e9", At: c2.now(), Sender: eve})
if len(dt2.sends) != before || !strings.Contains(tg2.sends[len(tg2.sends)-1].Body, "Try again in an hour") {
t.Error("a restart forgot the count")
}
}
// L7: two answers to one ask arriving at once — on two routers sharing the state — make exactly one warrant.
func TestTwoAnswersAtOnceMakeOneWarrant(t *testing.T) {
h1, tg, _, c, store, w1 := newRouter(t)
link(h1, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h1, "mesh-delivery", heldAsk(c, "d-1"))
settle(h1, c)
release, stop := ticketFor(tg.sends[0], "Release"), ticketFor(tg.sends[0], "Stop")
h2, _, _, _, _, w2 := newRouter(t)
h2.Asks = store
h2.Now = h1.Now
if err := h2.loadAsks(); err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
outs := make([]string, 2)
for i, pick := range []struct {
h *Holder
ticket string
}{{h1, release}, {h2, stop}} {
wg.Add(1)
go func(i int, h *Holder, ticket string) {
defer wg.Done()
outs[i], _ = h.Chosen("telegram", choiceOf(ticket, "42", c.now()))
}(i, pick.h, pick.ticket)
}
wg.Wait()
n := len(w1.of("mesh-delivery")) + len(w2.of("mesh-delivery"))
if n != 1 {
t.Fatalf("%d warrants for one ask (%v)", n, outs)
}
if (outs[0] == "chosen") == (outs[1] == "chosen") {
t.Errorf("not exactly one chosen: %v", outs)
}
}
// The confirmation review of 2026-10-09, N1-router: another account's /start offers the desk an unlink of the
// operator's account while it is in its first hour; a desk click — which a program on the desk machine can forge
// — unlinked it and barred it for a day, again and again, so the operator never approved anything. A desk
// unlink in the first hour bars nothing: the operator links again at once with a code from the desk, and the
// next link waits for nobody. The bar is for an account removed deliberately later.
func TestAnUnlinkFromTheDeskInTheFirstHourBarsNothing(t *testing.T) {
h, tg, dt, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-10*time.Minute)) // the operator's own, linked just now
h.Link("telegram", asks.LinkAsked{ID: "l1", At: c.now(), Sender: asks.Sender{Identity: "666", Display: "X", Authenticated: true}})
asked := dt.sends[len(dt.sends)-1]
ch := choiceOf(ticketFor(asked, "Unlink it"), "g14", c.now()) // a click nobody can prove was the operator's
ch.Sender.Authenticated = false
if out, err := h.Chosen("desktop", ch); err != nil || out != "chosen" {
t.Fatalf("%s %v", out, err)
}
if len(store.ids) != 0 {
t.Fatalf("not unlinked: %v", store.ids)
}
if u := h.recentUnlink("telegram"); u != nil {
t.Errorf("a desk unlink in the first hour bars relinking: %+v", u)
}
// The operator sends /start again: a code is shown at once, and the code links without waiting for anybody.
c.pass(time.Minute)
stand(h, c)
h.Link("telegram", asks.LinkAsked{ID: "l2", At: c.now(), Sender: asks.Sender{Identity: "42", Display: "Jo", Authenticated: true}})
code := ""
for _, m := range dt.sends {
if strings.Contains(m.Body, "number 42") && strings.Contains(m.Body, "send this code") {
code = codeIn(t, m.Body)
}
}
if code == "" {
t.Fatalf("no code was shown to relink the operator's account; said %q", tg.sends[len(tg.sends)-1].Body)
}
if a := h.Prove("telegram", asks.Code{Sender: asks.Sender{Identity: "42", Authenticated: true}, Code: code, Purpose: "link"}); !a.Accepted ||
!strings.Contains(a.Words, "Linked") {
t.Fatalf("the operator's code was not taken at once: %+v", a)
}
}
// And break-glass lifts any bar: root's allow-link at the router's machine, with no link waiting, ends a day's bar.
func TestBreakGlassLiftsABarOnRelinking(t *testing.T) {
h, tg, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
_, _ = h.Unlink("telegram")
_, _ = h.Chosen("telegram", choiceOf(ticketFor(tg.sends[len(tg.sends)-1], "Unlink"), "42", c.now()))
if h.recentUnlink("telegram") == nil {
t.Fatal("the control: a deliberate unlink bars relinking for a day")
}
h.StateDir = t.TempDir()
was := fileOwner
t.Cleanup(func() { fileOwner = was })
fileOwner = func(os.FileInfo) uint32 { return 0 }
_ = os.WriteFile(filepath.Join(h.StateDir, "allow-link.telegram"), nil, 0o600)
h.Tick()
if u := h.recentUnlink("telegram"); u != nil {
t.Errorf("root's allow-link left the bar: %+v", u)
}
}
// codeIn is the six digits a link's notice shows, "123 456".
func codeIn(t *testing.T, body string) string {
t.Helper()
m := regexp.MustCompile(`(\d{3}) (\d{3})\.`).FindStringSubmatch(body)
if m == nil {
t.Fatalf("no code in %q", body)
}
return m[1] + m[2]
}
// The confirmation review of 2026-10-09 (low): the router asked the controller's root-free word — up to ten
// seconds — while holding its work, so every ask, answer and link waited behind one slow controller. It now
// asks before it takes the work, and judges with the word it was given then.
func TestTheRootFreeWordIsNotAskedWhileTheWorkIsHeld(t *testing.T) {
h, tg, _, c, store, w := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
settle(h, c)
release := ticketFor(tg.sends[0], "Release")
asking, slow := make(chan struct{}), make(chan struct{})
var once sync.Once
free := h.RootFree
h.RootFree = func(machines []string) (map[string]RootVerdict, error) {
once.Do(func() { close(asking); <-slow })
return free(machines)
}
done := make(chan string, 1)
go func() {
out, _ := h.Chosen("telegram", choiceOf(release, "42", c.now()))
done <- out
}()
<-asking
linked := make(chan struct{})
go func() {
h.Link("telegram", asks.LinkAsked{ID: "l9", At: c.now(), Sender: asks.Sender{Identity: "77", Authenticated: true}})
close(linked)
}()
select {
case <-linked:
case <-time.After(2 * time.Second):
close(slow)
t.Fatal("a link waited for the controller's root-free word: the work was held while it was asked")
}
close(slow)
if out := <-done; out != "chosen" || len(w.of("mesh-delivery")) != 1 {
t.Fatalf("the answer after the word: %s, %v", out, w.of("mesh-delivery"))
}
}
// The confirmation review of 2026-10-09 (low, on M2): an answer whose line — what it does — the content rule
// withholds is not offered either; its button went out alone, and a tap would choose what was not shown.
func TestAnAnswerWhoseEffectIsWithheldIsNotOffered(t *testing.T) {
h, _, _, c, store, _ := newRouter(t)
link(h, store, c, "42", c.now().Add(-2*time.Hour))
submit(t, h, "mesh-delivery", heldAsk(c, "d-1"))
key := askKey("mesh-delivery", "d-1")
h.mu.Lock()
e := h.asks[key]
e.rec.Ask.Options[0].Does = "the delivery reads /etc/shadow"
h.asks[key] = e
r := h.open["ask."+key]
h.mu.Unlock()
m := h.sayOn(r, "asked", "telegram")
for _, a := range m.Actions {
if a.Label == "Release" {
t.Errorf("Release was offered with its effect withheld: %+v", m.Actions)
}
}
if strings.Contains(m.Body, "/etc/shadow") {
t.Errorf("the withheld words were said: %q", m.Body)
}
if len(m.Actions) == 0 {
t.Error("the answers whose effect was said were not offered either")
}
}
@@ -0,0 +1,329 @@
package main
// The channels, as the router sees them (novox/hq ADR 0259): kinds of the `channel` bench, each held by a
// module of its own. The router keeps no channel's client and knows no service. What it knows of a kind
// comes from two places, never from one alone:
//
// - **the controller**, in this module's membership: which kinds exist, which module holds each, and the
// capabilities its claim declares (`channel-capabilities/1`). An answer is judged by these.
// - **the channel**, on the `intake` bench under its own kind: whether it can send now (`standing`), and
// a message it could not deliver (`failed`).
//
// A message is sent by putting it on the kind's work queue (`mesh.seat.channel.accept.<verb>.<kind>`) under
// a handle the router makes; the channel keeps which of its own messages that handle is, so an edit names
// the handle. A queued message survives the channel being away.
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// StandingFresh is how long a channel's word on whether it can send is believed: a channel says it at
// least every five minutes, so a word older than this is a channel that stopped saying.
const StandingFresh = 15 * time.Minute
// KindInfo is one kind of the channel bench as the controller issued it.
type KindInfo struct {
Kind string
Module string
Node string
Capabilities []string
}
// Standing is what a channel last said of itself.
type Standing struct {
Ready bool `json:"ready"`
Why string `json:"why,omitempty"`
EditsSilently bool `json:"edits-silently,omitempty"`
At time.Time `json:"at"`
}
// Bench is every kind the router may send to, and what each said.
type Bench struct {
Now func() time.Time
// To is the account linked as the operator on a kind, where it verifies its sender: where its messages go.
To func(kind string) string
mu sync.Mutex
kinds map[string]KindInfo
standing map[string]Standing
known bool
knownAt time.Time
}
// KindsFresh is how long the controller's word on the kinds is believed for a proof: the router reads it every
// half minute, so an older word is one it could not read again. A stale word keeps the kinds for sending, and
// promises no verified sender (the review of 2026-10-09: a capability the controller withdrew must not stay).
const KindsFresh = 2 * time.Minute
// NewBench is a bench that knows no kind yet.
func NewBench(now func() time.Time) *Bench {
return &Bench{Now: now, kinds: map[string]KindInfo{}, standing: map[string]Standing{}}
}
// SetKinds takes the kinds the controller issued.
func (b *Bench) SetKinds(kinds []KindInfo) {
b.mu.Lock()
defer b.mu.Unlock()
b.kinds = map[string]KindInfo{}
for _, k := range kinds {
b.kinds[k.Kind] = k
}
b.known = true
b.knownAt = b.Now()
}
// Stand takes what a kind said of itself.
func (b *Bench) Stand(kind string, s Standing) {
b.mu.Lock()
defer b.mu.Unlock()
b.standing[kind] = s
}
// Kind is one kind, as the controller issued it.
func (b *Bench) Kind(kind string) (KindInfo, bool) {
b.mu.Lock()
defer b.mu.Unlock()
k, ok := b.kinds[kind]
return k, ok
}
// Kinds is every kind, by name.
func (b *Bench) Kinds() []KindInfo {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]KindInfo, 0, len(b.kinds))
for _, k := range b.kinds {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool { return out[i].Kind < out[j].Kind })
return out
}
// Promises says whether a kind's claim declares a capability: the controller's record, never the channel's.
func (b *Bench) Promises(kind, capability string) bool {
k, ok := b.Kind(kind)
if !ok {
return false
}
if capability == "verified-sender" {
b.mu.Lock()
at := b.knownAt
b.mu.Unlock()
if age := b.Now().Sub(at); age > KindsFresh || age < 0 {
return false
}
}
for _, c := range k.Capabilities {
if c == capability {
return true
}
}
return false
}
// Ready says whether a kind can send now, in words when it cannot.
func (b *Bench) Ready(kind string) error {
b.mu.Lock()
_, held := b.kinds[kind]
s, said := b.standing[kind]
known := b.known
b.mu.Unlock()
switch {
case !held && !known:
return errors.New("the mesh has not yet said which channels exist")
case !held:
return fmt.Errorf("no module holds the channel %s", kind)
case !said:
return fmt.Errorf("%s has not said whether it can send", kind)
case b.Now().Sub(s.At) > StandingFresh:
return fmt.Errorf("%s has said nothing since %s", kind, s.At.UTC().Format(time.RFC3339))
case !s.Ready:
if s.Why == "" {
return fmt.Errorf("%s says it cannot send", kind)
}
return errors.New(s.Why)
case b.Promises(kind, "verified-sender") && b.To != nil && b.To(kind) == "":
// A channel proving its sender says only to the account linked as the operator: none linked, it has
// nobody to say anything to (the correctness review of 2026-10-08).
return fmt.Errorf("no %s account is linked as the operator: send /start to the bot", Display(kind))
}
return nil
}
func (b *Bench) editsSilently(kind string) bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.standing[kind].EditsSilently
}
// Roles: the away channel reaches the operator away from the machines; the desk is at them.
const (
RoleAway = "away"
RoleDesk = "desk"
)
// roleOf is a kind's role, from what its claim declares.
func (b *Bench) roleOf(kind string) string {
if b.Promises(kind, "reaches-away") {
return RoleAway
}
return RoleDesk
}
// Display is a kind as the operator reads it: "Telegram", "the desk".
func Display(kind string) string {
if kind == "desktop" {
return "the desk"
}
if kind == "" {
return kind
}
return strings.ToUpper(kind[:1]) + kind[1:]
}
// SeatChannel is one role's channel: the first kind of that role that can send, else the first of it.
type SeatChannel struct {
Bench *Bench
Role string
Publish func(subject string, body any, id string) error
}
func (c *SeatChannel) kind() string {
var first string
for _, k := range c.Bench.Kinds() {
if c.Bench.roleOf(k.Kind) != c.Role {
continue
}
if first == "" {
first = k.Kind
}
if c.Bench.Ready(k.Kind) == nil {
return k.Kind
}
}
return first
}
// Name is the kind it sends to now, or its role when no kind holds it.
func (c *SeatChannel) Name() string {
if k := c.kind(); k != "" {
return k
}
return c.Role
}
func (c *SeatChannel) Ready() error {
k := c.kind()
if k == "" {
if c.Role == RoleAway {
return errors.New("no channel reaching the operator away from the machines is held: a module claims a " +
"kind of the channel bench that promises reaches-away (novox/hq ADR 0259)")
}
return errors.New("no channel at the operator's machines is held")
}
return c.Bench.Ready(k)
}
func (c *SeatChannel) CanEdit() bool { return c.Bench.Promises(c.kind(), "edit") }
func (c *SeatChannel) SilentEdit() bool { return c.Bench.editsSilently(c.kind()) }
// Send puts a message on the kind's queue and answers the handle it is known by.
func (c *SeatChannel) Send(m Message) (string, error) {
handle := newHandle()
return handle, c.put(handle, m, false)
}
// Edit replaces the message a handle names.
func (c *SeatChannel) Edit(handle string, m Message) error {
if handle == "" {
return errors.New("no message on record to replace")
}
return c.put(handle, m, true)
}
func (c *SeatChannel) put(handle string, m Message, edit bool) error {
kind := c.kind()
if kind == "" {
return c.Ready()
}
return c.putOn(kind, handle, m, edit)
}
func (c *SeatChannel) putOn(kind, handle string, m Message, edit bool) error {
if c.Publish == nil {
return errors.New("this router cannot reach the bus")
}
verb := asks.Send
switch {
case edit:
verb = asks.Edit
case len(m.Actions) > 0:
verb = asks.Show
}
// Secret travels whatever the path: a link's code is kept by no channel (the review of 2026-10-09 found it
// dropped here, where the desk's own role sends).
out := asks.Message{Handle: handle, Title: m.Title, Body: m.Body, Urgent: m.Urgent, Silent: m.Quiet, Reply: m.Reply,
Secret: m.Secret}
if c.Bench.To != nil {
out.To = c.Bench.To(kind)
}
if m.To != "" {
out.To = m.To
}
for _, a := range m.Actions {
out.Buttons = append(out.Buttons, asks.Button{Label: a.Label, Ticket: a.Token})
}
return c.Publish(asks.ChannelSubject(verb, kind), out, handle+"."+fmt.Sprint(time.Now().UnixNano()))
}
func newHandle() string {
var b [9]byte
_, _ = rand.Read(b[:])
return "m" + hex.EncodeToString(b[:])
}
// Asker calls a tool through the mesh, as stdio.Ask does: how the open conditions are read.
type Asker func(key string, body any) (json.RawMessage, error)
// KindChannel is one kind as a channel: for a test, and for the status of each.
type KindChannel struct {
Bench *Bench
Kind string
Publish func(subject string, body any, id string) error
}
func (c *KindChannel) Name() string { return c.Kind }
func (c *KindChannel) Ready() error { return c.Bench.Ready(c.Kind) }
func (c *KindChannel) CanEdit() bool { return c.Bench.Promises(c.Kind, "edit") }
func (c *KindChannel) SilentEdit() bool { return c.Bench.editsSilently(c.Kind) }
func (c *KindChannel) Send(m Message) (string, error) {
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
handle := newHandle()
return handle, s.putOn(c.Kind, handle, m, false)
}
func (c *KindChannel) Edit(handle string, m Message) error {
s := &SeatChannel{Bench: c.Bench, Publish: c.Publish}
return s.putOn(c.Kind, handle, m, true)
}
// kindChannels is every kind the controller says exists, as a channel; the two roles when it says none yet.
func (h *Holder) kindChannels() []Channel {
if h.Bench == nil || len(h.Bench.Kinds()) == 0 {
return h.channels()
}
var out []Channel
for _, k := range h.Bench.Kinds() {
out = append(out, &KindChannel{Bench: h.Bench, Kind: k.Kind, Publish: h.Publish})
}
return out
}
+370
View File
@@ -0,0 +1,370 @@
package main
// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the
// operator is on a channel changes only with the word of the account it is now.
//
// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the
// operator's account without it could link their own in its place. The notice of the unlink reaches that
// account first, by name, and then every channel.
// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name.
// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a
// file in the router's directory — `unlink.<kind>` or `allow-link.<kind>`, owned by root — and the router
// does it, announced as done at the machine. A file of any other owner is refused and said: an agent
// without root cannot make one.
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// SelfAsker is the name the router asks its own questions under.
const SelfAsker = "messenger"
// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval.
const RelinkWaits = 24 * time.Hour
// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option
// binds that account, so a warrant for unlinking one account never unlinks another linked in its place
// (the review of 2026-10-09).
type SelfAct struct {
Do string `json:"do"` // "unlink" or "link"
Kind string `json:"kind"`
Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
}
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
func (a SelfAct) act() asks.Act {
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
}
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
type Unlinked struct {
Identity Identity `json:"identity"`
At time.Time `json:"at"`
// Early is an unlink at the desk in the account's first hour: it bars nothing (the confirmation review of
// 2026-10-09, N1-router). A desk click is anyone's at the desk machine, and a bar it set — the account not
// linked again for a day, the next link waiting for it — let a forged click lock the operator out, again
// and again. The bar is for an account removed deliberately: by its own approval, or by root.
Early bool `json:"early,omitempty"`
}
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
binds, err := asks.ActDigest(act.act())
if err != nil {
return err
}
for i := range a.Options {
a.Options[i].Binds = binds
}
key := askKey(SelfAsker, a.ID)
now := h.Now()
rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{},
Only: &only, Act: &act, Record: "ask." + key}
for _, o := range a.Options {
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind}
}
if h.Asks != nil {
rev, err := h.Asks.CreateAsk(key, rec)
if err != nil {
return err
}
h.keepAsk(key, rec, rev)
} else {
h.keepAsk(key, rec, 0)
}
r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, Summary: a.Headline,
Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks",
Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key}
h.mu.Lock()
h.open[r.Key] = &r
h.mu.Unlock()
m := h.words(&r, "raised")
m.Actions = h.offers(&rec, only.Kind)
m.To = only.Identity
handle, err := h.showOnHandle(only.Kind, m)
if err == nil {
h.mu.Lock()
r.Sent[only.Kind] = handle
r.FirstSent = now
h.mu.Unlock()
}
h.persist(&r)
return err
}
// selfAct does what the router's own ask decided.
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
return
}
o, err := w.For(SelfAsker, rec.Ask)
if err == nil {
err = o.Performs(rec.Act.act())
}
if err != nil {
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
return
}
switch rec.Act.Do {
case "unlink":
if o.Level == asks.Acknowledge {
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
id := h.identity(rec.Act.Kind, rec.Act.Identity)
if id == nil || !h.Now().Before(id.ApproveFrom) {
h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed")
h.announce(Message{Title: "An unlink chosen at the desk was not done",
Body: "The account's first hour had passed, so only the account itself, or root at the router's " +
"machine, can unlink it now.", Urgent: true})
return
}
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked", true)
return
}
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself", false)
case "link":
if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity {
h.linkNow(*rec.Act.Link, "approved by the account it replaces")
}
}
}
// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone.
func (h *Holder) Unlink(kind string) (map[string]any, error) {
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
h.work.Lock()
defer h.work.Unlock()
ids := h.identitiesOf(kind)
if len(ids) == 0 {
return map[string]any{"kind": kind, "linked": false, "asked": false}, nil
}
id := ids[0]
now := h.Now()
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
return nil, err
}
h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked",
Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+
"cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true})
return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil
}
// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it
// is named, and nothing when another is linked now.
func (h *Holder) unlinkNow(kind, identity, how string, early bool) {
if h.Asks == nil {
return
}
id := h.identity(kind, identity)
if id == nil {
h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind)
return
}
_ = h.showOn(kind, Message{Title: "This account is no longer linked as you",
Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity})
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err)
return
}
h.unlinked(kind, Unlinked{Identity: *id, At: h.Now(), Early: early})
after := "A new link within a day waits for that account's approval, and that account itself can be linked " +
"again only after a day."
if early {
after = "Any account, this one too, can be linked again at once with a code shown on your desk."
}
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. %s", id.Display, id.Identity, how, after), Urgent: true})
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how})
}
// linkNow links an account as the operator's: acknowledges at once, approves after the hour.
func (h *Holder) linkNow(id Identity, how string) {
if h.Asks == nil {
return
}
// One account per kind, checked again as it is linked: a link approved, or allowed by root, while
// another account was linked meanwhile is not made (the review of 2026-10-09).
for _, other := range h.identitiesOf(id.Kind) {
if other.Identity != id.Identity {
h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display)
h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made",
Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+
"One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity),
Urgent: true})
return
}
return // already linked
}
now := h.Now()
id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter)
if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil {
h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err)
return
}
_ = h.Asks.DeleteLink(id.Kind)
from := id.ApproveFrom.In(h.zone()).Format("15:04")
h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you",
Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+
"approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from),
Urgent: true})
h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how})
}
// breakGlass does what root asked at the router's machine: a file `unlink.<kind>` or `allow-link.<kind>` in
// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no
// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute
// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and
// every open question of the router's own ends, since root decided in its place.
func (h *Holder) breakGlass() {
if h.StateDir == "" {
return
}
for _, pattern := range []string{"unlink.*", "allow-link.*"} {
files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern))
for _, f := range files {
name := filepath.Base(f)
what, kind, _ := strings.Cut(name, ".")
info, err := os.Lstat(f)
if err != nil {
continue
}
owner := fileOwner(info)
_ = os.Remove(f)
if !info.Mode().IsRegular() || owner != 0 {
h.Logf("[messenger] %s was refused: it is not a file root made", name)
h.announce(Message{Title: "A request to change who is you was refused",
Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.",
Urgent: true})
continue
}
where := "at the router's machine"
if h.Node != "" {
where = "at " + h.Node
}
done := ""
switch what {
case "unlink":
if ids := h.identitiesOf(kind); len(ids) > 0 {
h.unlinkNow(kind, ids[0].Identity, where+", as root", false)
done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity)
} else {
done = "nothing was unlinked: no " + Display(kind) + " account is linked"
}
case "allow-link":
if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed {
h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root")
done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity)
} else {
done = "nothing was linked: no link with its code given waits on " + Display(kind)
}
// And whatever bar an unlink set is lifted: root decided who may be linked.
if h.recentUnlink(kind) != nil {
h.liftBar(kind)
done += "; the bar on linking an account again after an unlink was lifted"
}
}
ended := h.endSelfAsks("root decided " + where)
body := "Root " + where + " asked it: " + done + "."
if ended > 0 {
body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended)
}
h.Logf("[messenger] break-glass %s: %s", name, done)
h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true})
}
}
}
// endSelfAsks ends every open ask of the router's own: root decided in their place.
func (h *Holder) endSelfAsks(why string) int {
h.mu.Lock()
var keys []string
for k, e := range h.asks {
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen {
keys = append(keys, k)
}
}
h.mu.Unlock()
sort.Strings(keys)
n := 0
for _, k := range keys {
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, ""); err == nil {
n++
}
}
return n
}
// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root.
var fileOwner = func(info os.FileInfo) uint32 {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return st.Uid
}
return 1
}
// unlinked keeps the last unlink of a kind, a day.
func (h *Holder) unlinked(kind string, u Unlinked) {
h.mu.Lock()
defer h.mu.Unlock()
if h.lastUnlink == nil {
h.lastUnlink = map[string]Unlinked{}
}
h.lastUnlink[kind] = u
if h.Asks != nil {
_ = h.Asks.PutUnlinked(kind, u)
}
}
// liftBar forgets the last unlink of a kind: root's allow-link at the router's machine.
func (h *Holder) liftBar(kind string) {
h.mu.Lock()
delete(h.lastUnlink, kind)
h.mu.Unlock()
if h.Asks != nil {
_ = h.Asks.PutUnlinked(kind, Unlinked{})
}
}
// recentUnlink is the account unlinked deliberately on a kind less than a day ago, if any: an early desk
// unlink bars nothing.
func (h *Holder) recentUnlink(kind string) *Unlinked {
h.mu.Lock()
u, ok := h.lastUnlink[kind]
h.mu.Unlock()
if !ok && h.Asks != nil {
if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil {
u, ok = *got, true
}
}
if !ok || u.Early || u.At.IsZero() || h.Now().Sub(u.At) >= RelinkWaits {
return nil
}
return &u
}
// announceExcept says a message on every kind that can send but one.
func (h *Holder) announceExcept(kind string, m Message) {
if h.Bench == nil {
return
}
for _, k := range h.Bench.Kinds() {
if k.Kind != kind && h.Bench.Ready(k.Kind) == nil {
_ = h.showOn(k.Kind, m)
}
}
}
+9 -5
View File
@@ -44,7 +44,7 @@ type Status struct {
func (h *Holder) Status(listening string) Status {
now := h.Now()
var st Status
for _, ch := range h.channels() {
for _, ch := range h.kindChannels() {
cs := ChannelStatus{Name: ch.Name(), Cap: CapPerHour, SentLastHour: h.sentLastHour(ch.Name())}
if err := ch.Ready(); err != nil {
cs.NotReady = err.Error()
@@ -103,13 +103,17 @@ func (h *Holder) Status(listening string) Status {
"history is never news: what is open is read from the controller on start, every 10 min and when an old event arrives; an event older than that reading is not acted on, one older than 10 min by its own time is state, never a message",
"a clearing of something never said says nothing; an urgent condition open and never said is said, all of them in one message",
"nothing is sent in the first minute after start but that one message",
"urgent: Telegram and the desktop; warning: the desktop where a session answers, otherwise Telegram",
"urgent: every channel kind that can send; warning: the desk where it can send, otherwise the channel that reaches the operator away",
"the channels are kinds of the channel bench, each a module of its own; which exist and what each promises is the controller's word, in this module's membership; whether each can send is its own (novox/hq ADR 0259)",
"an ask is held here and offered on each kind whose capabilities carry its answers; an answer is judged here and closes the ask with a warrant its asker acts on; nothing is performed here",
"an account linked as the operator acknowledges at once and approves an hour after it was linked; every link and unlink is said on every channel",
"the desk proves nothing about who clicked: it answers acknowledgements only",
"bursts are one message: what arrives within 30 s goes out together, a digest when it is several; urgent goes at once when the channel was quiet for 30 s",
"the desktop is gentle: warnings at most one message every 15 min",
"the desk is gentle: warnings at most one message every 15 min",
"deduplicated by the condition's key; reminded once after 1 h (urgent) or 12 h (warning); cleared by editing the first message",
"at most 20 messages an hour per channel, a last line of defence: reached, it is said once and the rest held for one digest",
"refused: anything carrying an address, a path or a secret; channel-refused is sent with the words withheld",
"silenced conditions send nothing; silence is `conditions silence` through the mesh",
"silenced conditions send nothing; a silence is the controller's, asked of the operator or set through the mesh",
}
anyCan := false
for _, c := range st.Channels {
@@ -218,7 +222,7 @@ func (h *Holder) Test(channel, text string) map[string]string {
return out
}
m := Message{Title: "Test message: " + text, Body: "Nothing is wrong; this was asked for."}
for _, ch := range h.channels() {
for _, ch := range h.kindChannels() {
if channel != "" && channel != "both" && channel != ch.Name() {
continue
}
-206
View File
@@ -1,206 +0,0 @@
package main
// The Telegram channel: a bot to the operator's chat (novox/hq to-be 45 §5, research 028/02). Sending
// needs only outbound HTTPS. The bot token is this module's own secret, accepted from the operator; the
// chat id is a setting. Neither is ever said: an error from the HTTP client carries the URL, and the
// URL carries the token, so every error is rebuilt here from its kind before it leaves this file.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"time"
)
// TelegramAPI is where the bot API answers; a test points it elsewhere.
var TelegramAPI = "https://api.telegram.org"
var (
tokenShape = regexp.MustCompile(`^\d{5,}:[A-Za-z0-9_-]{30,}$`)
chatIDShape = regexp.MustCompile(`^(-?\d{1,20}|@[A-Za-z][A-Za-z0-9_]{4,})$`)
)
// TelegramConfig is where the token is and what the chat is; read each time it is used, so a secret
// accepted or a setting changed takes effect at the next message without a restart.
type TelegramConfig struct {
TokenFile string
ChatID func() string
}
// Telegram sends to one chat.
type Telegram struct {
Config TelegramConfig
Client *http.Client
}
func NewTelegram(cfg TelegramConfig) *Telegram {
return &Telegram{Config: cfg, Client: &http.Client{Timeout: 20 * time.Second}}
}
func (t *Telegram) Name() string { return "telegram" }
// Ready says whether the channel can send, in words.
func (t *Telegram) Ready() error {
_, _, err := t.ready()
return err
}
// ready says whether the channel can send, and when not, what the operator must give. The words name
// the setting and the secret, never a value.
func (t *Telegram) ready() (string, string, error) {
token, err := t.token()
if err != nil {
return "", "", err
}
chat := strings.TrimSpace(t.Config.ChatID())
if chat == "" {
return "", "", errors.New("no chat to send to: the setting telegram-chat-id is not given " +
"(`settings` for messenger with {\"telegram-chat-id\": \"<the chat's id>\"})")
}
if !chatIDShape.MatchString(chat) {
return "", "", errors.New("the setting telegram-chat-id is not a chat id (a number, or @name of a channel)")
}
return token, chat, nil
}
func (t *Telegram) token() (string, error) {
if t.Config.TokenFile == "" {
return "", errors.New("no bot token: this module was started without a token file")
}
raw, err := os.ReadFile(t.Config.TokenFile)
if errors.Is(err, os.ErrNotExist) {
return "", errors.New("no bot token: the own secret telegram-token is not on this machine yet " +
"(`secret accept <machine> messenger telegram-token`, then push the machine)")
}
if err != nil {
return "", errors.New("the own secret telegram-token cannot be read: " + plainError(err))
}
token := strings.TrimSpace(string(raw))
if token == "" {
return "", errors.New("no bot token: the own secret telegram-token is empty")
}
if !tokenShape.MatchString(token) {
// The mesh mints a random value for an own secret nobody accepted; that is not a bot token.
return "", errors.New("the own secret telegram-token is not a bot token (digits, a colon, then the key " +
"BotFather gave) — most likely the mesh made it because none was accepted: " +
"`secret accept <machine> messenger telegram-token`, then push the machine")
}
return token, nil
}
// Send posts a message and answers its message id.
func (t *Telegram) Send(m Message) (string, error) {
text := m.Text()
token, chat, err := t.ready()
if err != nil {
return "", err
}
var out struct {
MessageID int64 `json:"message_id"`
}
if err := t.call(token, "sendMessage", map[string]any{
"chat_id": chat, "text": text, "disable_web_page_preview": true,
}, &out); err != nil {
return "", err
}
return fmt.Sprint(out.MessageID), nil
}
// Edit replaces the text of a message sent before: how a cleared condition is said (to-be 45 §5).
func (t *Telegram) Edit(id string, m Message) error {
text := m.Text()
token, chat, err := t.ready()
if err != nil {
return err
}
return t.call(token, "editMessageText", map[string]any{
"chat_id": chat, "message_id": json.Number(id), "text": text, "disable_web_page_preview": true,
}, nil)
}
// CanEdit: Telegram edits a message in place.
func (t *Telegram) CanEdit() bool { return true }
// SilentEdit: an edited Telegram message notifies nobody.
func (t *Telegram) SilentEdit() bool { return true }
// Who asks the bot API who it is: the check that the token works, with no message sent.
func (t *Telegram) Who() (string, error) {
token, _, err := t.ready()
if err != nil {
return "", err
}
var me struct {
Username string `json:"username"`
}
if err := t.call(token, "getMe", map[string]any{}, &me); err != nil {
return "", err
}
return me.Username, nil
}
func (t *Telegram) call(token, method string, body map[string]any, into any) error {
raw, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, TelegramAPI+"/bot"+token+"/"+method, bytes.NewReader(raw))
if err != nil {
return errors.New("telegram " + method + ": the request could not be made")
}
req.Header.Set("Content-Type", "application/json")
resp, err := t.Client.Do(req)
if err != nil {
return fmt.Errorf("telegram %s: %s", method, plainError(err))
}
defer resp.Body.Close()
var answer struct {
OK bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
Result json.RawMessage `json:"result"`
}
if err := json.NewDecoder(resp.Body).Decode(&answer); err != nil {
return fmt.Errorf("telegram %s: HTTP %d with an answer that is not the bot API's", method, resp.StatusCode)
}
if !answer.OK {
d := strings.ReplaceAll(answer.Description, token, "<token>")
return fmt.Errorf("telegram %s refused: %d %s", method, answer.ErrorCode, d)
}
if into != nil && len(answer.Result) > 0 {
_ = json.Unmarshal(answer.Result, into)
}
return nil
}
// plainError is an error in words, without the URL a transport error carries.
func plainError(err error) string {
var ue *url.Error
if errors.As(err, &ue) {
err = ue.Err
}
var ne net.Error
switch {
case errors.As(err, &ne) && ne.Timeout():
return "no answer in time"
case errors.Is(err, os.ErrPermission):
return "permission denied"
}
var dns *net.DNSError
if errors.As(err, &dns) {
return "the bot API's name does not resolve"
}
var op *net.OpError
if errors.As(err, &op) {
return "cannot connect (" + op.Op + ")"
}
s := err.Error()
if strings.Contains(s, "/bot") || strings.Contains(s, "://") {
return "the request failed"
}
return s
}
+40 -19
View File
@@ -105,8 +105,7 @@ var examples = []example{{
body: "Needs you: mend or remove them on shanks, or silence this if they do not matter. On shanks, mnt-recalbox " +
"(a mount), storage-media (a mount) and greenclip failed. No module manages them, so the mesh does not repair " +
"them.\nSince 12:40.",
offers: "Silence for a week, Details", choose: "Silence for a week",
asked: `seat:mesh-controller.conditions {"cause":"operator-answer","for":"7d","silence":"machine.shanks.units","why":"chosen on the desktop notification \"3 failed services on shanks\" on g14 (a desk click: whoever was at the operator's session)"}`,
offers: "Details", choose: "Details",
cleared: "Resolved: no failed services on shanks any more, after 45 min", openFor: 45 * time.Minute,
}, {
name: "a healer wanted",
@@ -213,21 +212,12 @@ func TestRealNotificationsReadInOneGlanceAndAreAnsweredFromTheNotification(t *te
tok = a.Token
}
}
outcome, err := h.Chosen("desktop", "g14", tok, "event-1")
var done Message
if e.choose == DetailsLabel {
done = dt.sends[len(dt.sends)-1]
if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline {
t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title)
}
} else {
if err != nil || outcome != "done" || len(asked) != 1 || asked[0] != e.asked {
t.Fatalf("chose %s: %q %v\nasked %v\nwant %s", e.choose, outcome, err, asked, e.asked)
}
done = dt.edits["g14=1"]
if done.Title != e.choose+": done. "+e.headline {
t.Errorf("the answer's outcome: %q", done.Title)
}
// The router performs nothing: Details is shown where it was chosen, and the condition's own actions
// are never called from here (novox/hq ADR 0259) — the controller asks for them.
outcome, err := h.Chosen("desktop", choiceOf(tok, "g14", c.now()))
done := dt.sends[len(dt.sends)-1]
if err != nil || outcome != "shown" || len(asked) != 0 || done.Title != "Details: "+e.headline {
t.Fatalf("details: %q %v %v %q", outcome, err, asked, done.Title)
}
c.pass(e.openFor - BurstWindow)
cleared, err := DecodeCondition(EventCleared, e.event(c.now(), true))
@@ -280,7 +270,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
if details.Label != DetailsLabel {
t.Fatalf("%+v", m.Actions)
}
if _, err := h.Chosen("desktop", "g14", details.Token, ""); err != nil {
if _, err := h.Chosen("desktop", choiceOf(details.Token, "g14", c.now())); err != nil {
t.Fatal(err)
}
shown := dt.sends[len(dt.sends)-1]
@@ -288,7 +278,7 @@ func TestDetailsAreShownOnTheMachineThatAsked(t *testing.T) {
!strings.Contains(shown.Body, "Since: 8 Oct 10:40") {
t.Errorf("details: %q / %q", shown.Title, shown.Body)
}
if _, err := h.Chosen("desktop", "g14", "not-a-token", ""); err == nil {
if _, err := h.Chosen("desktop", choiceOf("not-a-token", "g14", c.now())); err == nil {
t.Error("an unknown answer was taken")
}
}
@@ -324,3 +314,34 @@ func TestTimesAreSaidInTheOperatorsZone(t *testing.T) {
t.Errorf("a zone that does not exist was not said: %v", err)
}
}
// A warning kept quiet is said once after a day, and not again (novox/hq ADR 0258 §6).
func TestAQuietWarningIsSaidOnceAfterADay(t *testing.T) {
h, tg, dt, c, _ := newHolder(t)
c.t = time.Date(2026, 10, 8, 10, 40, 0, 0, time.UTC)
e := examples[0]
raised, _ := DecodeCondition(EventRaised, e.event(c.now(), false))
h.Condition(EventRaised, raised)
settle(h, c)
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatal("said while it needed nothing")
}
c.pass(QuietRemindAfter - time.Hour)
h.Tick()
settle(h, c)
if len(dt.sends)+len(tg.sends) != 0 {
t.Fatal("said before a day")
}
c.pass(time.Hour)
h.Tick()
settle(h, c)
if len(dt.sends) != 1 || !strings.HasPrefix(dt.sends[0].Title, "Still open after a day: ") {
t.Fatalf("not said once after a day: %+v", dt.sends)
}
c.pass(QuietRemindAfter)
h.Tick()
settle(h, c)
if len(dt.sends) != 1 {
t.Errorf("said again: %d", len(dt.sends))
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ module messenger
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
require git.novox.be/novox/mesh-sdk/go v0.1.10
+2 -2
View File
@@ -1,2 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.10 h1:EHhnPRrkw7/CSQAqRsxYnTvlTZ3dI02IPO6WSmVX80o=
git.novox.be/novox/mesh-sdk/go v0.1.10/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+76 -12
View File
@@ -2,6 +2,7 @@
"module": "messenger",
"version": "1",
"slug": "msgr",
"runs-as": "messenger",
"seats": [
{
"name": "operator-channel",
@@ -9,7 +10,47 @@
"serves": [
"open",
"history",
"notify"
"notify",
"asks"
],
"accepts": [
"ask",
"cancel"
],
"emits": [
"decided"
],
"by-caller": [
"ask",
"cancel",
"decided"
],
"records": [
"asks"
]
},
{
"name": "channel",
"scope": "mesh",
"kinded": true,
"accepts": [
"show",
"edit",
"send"
]
},
{
"name": "intake",
"scope": "mesh",
"kinded": true,
"emits": [
"choice",
"link",
"failed",
"standing"
],
"proofs": [
"code"
]
}
],
@@ -20,43 +61,65 @@
"serves": [
"open",
"history",
"notify"
"notify",
"asks"
]
}
],
"uses": [
"channel"
],
"consumes": [
"mesh-controller.condition-raised",
"mesh-controller.condition-changed",
"mesh-controller.condition-cleared",
"dunst.action-chosen",
"dunst.started"
"intake.choice",
"intake.link",
"intake.failed",
"intake.standing"
],
"emits": [
"refused"
],
"invokes": [
"seat:node-notifier.send",
"seat:mesh-controller.conditions"
"seat:mesh-controller.conditions",
"seat:mesh-controller.root-free"
],
"state": [
"open",
"sent"
"sent",
{
"name": "asks",
"ttl-seconds": 2592000
},
"identities"
],
"own-secrets": {
"telegram-token": "${dir:state}/telegram-token"
"broker": "${dir:state}/broker"
},
"secrets-owner": "messenger",
"tools": [
"messenger_status",
"messenger_recent",
"messenger_test",
"messenger_check"
"messenger_check",
"messenger_identities",
"messenger_unlink"
],
"resources": [
{
"id": "account",
"type": "user",
"name": "messenger",
"shell": "/usr/bin/nologin",
"home": "/var/lib/messenger"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
"place": ".",
"owner": "messenger"
},
{
"id": "settings",
@@ -64,7 +127,8 @@
"path": "${dir:state}/settings.json",
"mode": "0600",
"merge": "json",
"content": "{\n \"telegram-chat-id\": \"\",\n \"desktop-machines\": [],\n \"time-zone\": \"\"\n}\n"
"content": "{\n \"time-zone\": \"\"\n}\n",
"owner": "messenger"
}
],
"build": {
@@ -81,7 +145,7 @@
],
"env": {
"MESH_MESSENGER_SETTINGS": "${dir:state}/settings.json",
"MESH_MESSENGER_TELEGRAM_TOKEN_FILE": "${dir:state}/telegram-token"
"MESH_MESSENGER_STATE": "${dir:state}"
}
}
]
+13
View File
@@ -42,6 +42,17 @@ an autofs mount, so nothing writes into the empty directory under it.
| an SMB source | `//<host>/<share>`, `Type=cifs` | `credentials=<file>,iocharset=utf8,_netdev,nofail,uid=…,gid=…` | 10 |
| a disk | `/dev/disk/by-uuid/<uuid>`; the mount `BindsTo=` the disk's device | `nofail` | 10 |
A mount of the module's own that failed because its source was not there — an occasional source that is
off, or a node's share whose server is away, tried when someone opened the mount point — is cleared
(`systemctl reset-failed`) by `apply` within five seconds, and so is the failure a unit of the same name
carried before the module wrote it (the unit fstab's generator makes until the line is adopted). The module
says such a share as `absent` or `unreachable`; left failed, the unit would also be said as a failed unit of
the machine's own, with a notification each time it is touched. A mount that failed while its source answers
is left failed: that is a fault, and the share is said `failed`. An automount that gave up after repeated
failed mounts (`mount-start-limit-hit`) while its source was away is cleared and armed again the same way.
Only a unit the module takes over has its earlier failure cleared regardless of the source; a pair of its
own that it rewrites keeps its failure.
A share is read-only when the setting says `:ro` or the sharing machine exports it read-only. A
read-write share is mounted hard, so a write is never dropped while the server is away.
@@ -102,6 +113,8 @@ Each refusal is said as the share's state `failed`, with why, and nothing is wri
- a mount point the system owns (`/`, `/etc`, `/usr`, `/boot`, `/proc`, `/sys`, `/dev`, `/run` and what
is under them, and `/var`, `/home`, `/root`, `/tmp` themselves);
- a mount point that something else is mounted at, or an automount that is not the module's;
- a mount point whose unit someone made at run time (`/run/systemd/transient`, from `systemd-mount` or the
like): that is somebody's mount now;
- a folder that holds files: a mount would hide them. Only a folder not in the kernel's mount table is
opened, so a server that is away cannot hold the look;
- a share that this machine serves itself.
+122 -5
View File
@@ -33,6 +33,11 @@ import (
// LookEvery is how often the process looks.
const LookEvery = 30 * time.Second
// ClearEvery is how often the acting process clears a failed mount of its own whose source does not
// answer (clearFailed). Shorter than the node-engine's two looks of 15 s, so a mount that failed because
// its source is away is never read as a failed unit of the machine's own.
const ClearEvery = 5 * time.Second
// ProbeTimeout bounds one try of a source.
const ProbeTimeout = 2 * time.Second
@@ -89,6 +94,11 @@ type Applier struct {
Dial func(addr string, timeout time.Duration) error
Now func() time.Time
Lookup func(account string) (int, int, error)
// written is every pair the last pass wrote and armed, by its mount unit: what clearFailed looks at
// between passes. The acting process's own memory; nothing else reads it.
written map[string]Pair
// LookInterval and ClearInterval are LookEvery and ClearEvery, or a test's own when set.
LookInterval, ClearInterval time.Duration
}
// NewApplier is the process on this machine.
@@ -326,6 +336,9 @@ func (a *Applier) Look(ctx context.Context) Status {
own := a.ownUnits()
mounted := ParseMountinfo(func() string { t, _ := a.M.read("/proc/self/mountinfo"); return t }())
changed := false
// The mount units this pass wrote where no unit file of the module's was before: a unit of the same
// name that was there — the one fstab's generator makes until the line is adopted — is taken over.
takenOver := map[string]bool{}
var written []Pair
for _, p := range pairs {
ours := own[p.MountUnit] && own[p.AutoUnit]
@@ -362,6 +375,9 @@ func (a *Applier) Look(ctx context.Context) Status {
State: StateFailed, Why: fmt.Sprintf("its units could not be written: %v %v", err1, err2)}
continue
}
if c1 && !own[p.MountUnit] {
takenOver[p.MountUnit] = true
}
if c1 || c2 {
changed = true
st.Changed = append(st.Changed, "the units of "+p.Name)
@@ -448,6 +464,24 @@ func (a *Applier) Look(ctx context.Context) Status {
}
}
}
if a.Act {
// A mount unit the module wrote for the first time, over a unit of the same name that had already
// failed — the one fstab's generator makes until the line is adopted — carries a failure that is not
// the module's pair's. It is cleared, so the pair starts armed and is judged on its own mounts (ADR
// 0263 rule 4). A pair the module already had and rewrote keeps its failure: that one is its own.
for _, p := range written {
if takenOver[p.MountUnit] && states[p.MountUnit]["ActiveState"] == "failed" {
if _, err := a.M.Run(ctx, "systemctl", "reset-failed", p.MountUnit); err == nil {
st.Changed = append(st.Changed, "cleared "+p.MountUnit+", which had failed before the module wrote it")
}
}
}
a.written = map[string]Pair{}
for _, p := range written {
a.written[p.MountUnit] = p
}
st.Changed = append(st.Changed, a.clearFailed(ctx)...)
}
if !a.Judge {
a.finish(&st, notSet)
return st
@@ -522,6 +556,12 @@ func (a *Applier) finish(st *Status, notSet map[string]ShareStatus) {
// foreign refuses a pair whose unit file exists and is not the module's.
func (a *Applier) foreign(p Pair, own map[string]bool) error {
for _, unit := range []string{p.MountUnit, p.AutoUnit} {
// A transient unit of the same name — made by `systemd-mount` or `mount` on the command line — is
// someone's mount now, whatever the module's own file says.
transient := filepath.Join(TransientDir, unit)
if _, err := os.Lstat(a.path(transient)); err == nil {
return fmt.Errorf("%s has a transient unit that is not the module's (%s); it is left as it is, and this share is not mounted here", p.Mountpoint, transient)
}
if own[unit] {
continue
}
@@ -532,6 +572,9 @@ func (a *Applier) foreign(p Pair, own map[string]bool) error {
return nil
}
// TransientDir is where the service manager keeps the units made at run time rather than from a file.
const TransientDir = "/run/systemd/transient"
// judge is one pair's state. The source is tried first, without touching the mount point.
func (a *Applier) judge(p Pair, states map[string]map[string]string, mounted map[string]Mounted) (string, string) {
there := true
@@ -569,17 +612,91 @@ func (a *Applier) writeStatus(st *Status) {
}
}
// RunApply looks every LookEvery until the context ends, saying on stderr what it changed.
// clearFailed clears every failed mount of the module's own pairs whose source does not answer now: an
// occasional source that is away, or a node's share whose server is. Such a mount failed because its
// source was not there, which the module already says as the share's state (absent, or unreachable, and
// for a share that should always be there its health after five minutes, ADR 0263 rule 8); left failed,
// the unit would be said a second time as a failed unit of the machine's own, with a notification each
// time it is touched — what rules 4 and 6 promise never happens. A mount that failed while its source
// answers is left failed: that is a fault, and the share is said failed until a person looks.
func (a *Applier) clearFailed(ctx context.Context) []string {
if len(a.written) == 0 {
return nil
}
var mounts, units []string
for unit, p := range a.written {
mounts = append(mounts, unit)
units = append(units, unit, p.AutoUnit)
}
sort.Strings(mounts)
states := a.unitStates(ctx, units)
var cleared []string
for _, unit := range mounts {
p := a.written[unit]
mountFailed := states[unit]["ActiveState"] == "failed"
// After repeated failed mounts the automount gives up too (mount-start-limit-hit) and stops
// answering its point: cleared and armed again, or the share stays dead until the next pass.
autoFailed := states[p.AutoUnit]["ActiveState"] == "failed"
if !mountFailed && !autoFailed {
continue
}
if a.sourceAnswers(p) {
continue
}
if mountFailed {
if _, err := a.M.Run(ctx, "systemctl", "reset-failed", unit); err == nil {
cleared = append(cleared, "cleared "+unit+": "+p.Name+" was not there to mount")
}
}
if autoFailed {
if _, err := a.M.Run(ctx, "systemctl", "reset-failed", p.AutoUnit); err == nil {
if _, err := a.M.Run(ctx, "systemctl", "start", p.AutoUnit); err == nil {
cleared = append(cleared, "cleared and armed "+p.AutoUnit+" again ("+orDefault(states[p.AutoUnit]["Result"], "failed")+"): "+p.Name+" was not there to mount")
}
}
}
}
return cleared
}
// sourceAnswers tries a pair's source the way the judging process does, without touching its mount point.
func (a *Applier) sourceAnswers(p Pair) bool {
if p.Kind == "disk" {
return a.M.present(p.Probe)
}
return a.Dial != nil && a.Dial(p.Probe, ProbeTimeout) == nil
}
// RunApply looks every LookEvery until the context ends, saying on stderr what it changed. Between looks
// the acting process clears, every ClearEvery, a mount of its own that failed because its source was away.
func RunApply(ctx context.Context, a *Applier) {
for {
st := a.Look(ctx)
for _, c := range st.Changed {
fmt.Fprintln(os.Stderr, "mounts:", c)
}
select {
case <-ctx.Done():
return
case <-time.After(LookEvery):
next := time.After(orInterval(a.LookInterval, LookEvery))
between:
for {
select {
case <-ctx.Done():
return
case <-next:
break between
case <-time.After(orInterval(a.ClearInterval, ClearEvery)):
if a.Act {
for _, c := range a.clearFailed(ctx) {
fmt.Fprintln(os.Stderr, "mounts:", c)
}
}
}
}
}
}
func orInterval(d, otherwise time.Duration) time.Duration {
if d > 0 {
return d
}
return otherwise
}
+237
View File
@@ -0,0 +1,237 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
// A mount the module took over, or one whose source was away when it was touched, is never left as a
// failed unit: the node-engine would say it as a failed unit of the machine's own, with a notification each
// time (hq ADR 0263 rules 4 and 6). A mount that failed while its source answers stays failed: a fault.
func failedLook(t *testing.T, f *fakeSystem) map[string]ShareStatus {
t.Helper()
st := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC)).Look(context.Background())
out := map[string]ShareStatus{}
for _, s := range st.Shares {
out[s.Name] = s
}
return out
}
func TestTheFailureOfTheUnitTheModuleTakesOverIsCleared(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
f.write(ExportsFile, exportsAnswer(t, library))
f.reachable["192.0.2.10:2049"] = true
// fstab's generator made srv-library.mount for the same mount point, and it timed out at boot.
f.units["srv-library.mount"] = map[string]string{"ActiveState": "failed", "Result": "timeout"}
states := failedLook(t, f)
if !f.called("systemctl reset-failed srv-library.mount") {
t.Fatalf("the generator's failure is cleared once the module's unit is written: %q", f.calls)
}
if s := states["library"]; s.State != StateArmed {
t.Errorf("the share is judged on its own mounts, not on the generator's: %+v", s)
}
}
func TestAMountThatFailedBecauseItsSourceWasAwayIsCleared(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, "shares=library=/srv/library\nsources=games=smb://192.0.2.76/Share@/mnt/games\n"+
"server=homeserver\nat=192.0.2.10\nnode=workstation\naccount=operator\n"+
"secret=/var/lib/mesh/mounts/smb-credentials.secret\nstate=/var/lib/mesh/mounts\n")
f.write(ExportsFile, exportsAnswer(t, library))
f.write("/var/lib/mesh/mounts/smb-credentials.secret", "[games]\nusername=root\npassword="+testPassword+"\n")
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Look(context.Background()) // writes and arms both pairs; neither source answers
f.calls = nil
// Someone opened both mount points: the gaming device is off and the server is away, so both mounts failed.
f.units["mnt-games.mount"] = map[string]string{"ActiveState": "failed", "Result": "timeout"}
f.units["srv-library.mount"] = map[string]string{"ActiveState": "failed", "Result": "timeout"}
cleared := a.clearFailed(context.Background())
for _, unit := range []string{"mnt-games.mount", "srv-library.mount"} {
if !f.called("systemctl reset-failed " + unit) {
t.Errorf("%s failed because its source was away and is cleared: %q", unit, f.calls)
}
}
if len(cleared) != 2 || !strings.Contains(strings.Join(cleared, "; "), "games was not there to mount") {
t.Errorf("each clearing is said: %q", cleared)
}
states := failedLook(t, f)
if states["games"].State != StateAbsent || states["library"].State != StateUnreachable {
t.Errorf("the module still says each as absent or unreachable: %+v", states)
}
}
func TestAMountThatFailedWhileItsSourceAnswersStaysFailed(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
f.write(ExportsFile, exportsAnswer(t, library))
f.reachable["192.0.2.10:2049"] = true
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Look(context.Background())
f.calls = nil
f.units["srv-library.mount"] = map[string]string{"ActiveState": "failed", "Result": "exit-code"}
if cleared := a.clearFailed(context.Background()); len(cleared) != 0 || f.called("systemctl reset-failed") {
t.Fatalf("a mount refused by a server that answers is a fault and is left failed: %q %q", cleared, f.calls)
}
if s := failedLook(t, f)["library"]; s.State != StateFailed {
t.Errorf("it is said failed: %+v", s)
}
}
func TestOnlyTheActingProcessClears(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, "shares=library=/srv/library\nserver=homeserver\nat=192.0.2.10\nnode=workstation\n")
f.write(ExportsFile, exportsAnswer(t, library))
f.units["srv-library.mount"] = map[string]string{"ActiveState": "failed", "Result": "timeout"}
a := f.applier(time.Now())
a.Act = false
a.Look(context.Background())
if f.called("systemctl reset-failed") {
t.Errorf("the judging process changes nothing: %q", f.calls)
}
}
// waitFor polls until cond holds or the deadline passes.
func waitFor(t *testing.T, within time.Duration, cond func() bool) bool {
t.Helper()
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(5 * time.Millisecond)
}
return cond()
}
func TestTheActingProcessClearsBetweenItsLooks(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, libraryOnly)
f.write(ExportsFile, exportsAnswer(t, library))
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Judge = false
// The next look is an hour away: only the loop between looks can clear what fails now.
a.LookInterval, a.ClearInterval = time.Hour, 10*time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { RunApply(ctx, a); close(done) }()
defer func() { cancel(); <-done }()
if !waitFor(t, 2*time.Second, func() bool { return f.called("systemctl enable --now srv-library.automount") }) {
t.Fatal("the first look did not arm the pair")
}
// The server is away and someone opened the mount point: the mount failed after the look.
f.setUnit("srv-library.mount", map[string]string{"ActiveState": "failed", "Result": "timeout"})
if !waitFor(t, 2*time.Second, func() bool { return f.called("systemctl reset-failed srv-library.mount") }) {
t.Fatal("a mount that failed between looks, its server away, is cleared before the next look")
}
}
func TestALookClearsAMountOfItsOwnThatFailedWhileItsSourceWasAway(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, libraryOnly)
f.write(ExportsFile, exportsAnswer(t, library))
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Look(context.Background())
f.setUnit("srv-library.mount", map[string]string{"ActiveState": "failed", "Result": "timeout"})
f.calls = nil
// A second look, with nothing to write: the pair is the module's own already, so this is no takeover.
a.Look(context.Background())
if !f.called("systemctl reset-failed srv-library.mount") {
t.Errorf("the look itself clears it: %q", f.calls)
}
}
func TestAnAbsentDiskIsClearedAndAPresentOneIsNot(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, "sources=usb=disk:1234-ABCD@/mnt/usb\nnode=workstation\n")
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Look(context.Background())
f.setUnit("mnt-usb.mount", map[string]string{"ActiveState": "failed", "Result": "timeout"})
f.calls = nil
if a.clearFailed(context.Background()); !f.called("systemctl reset-failed mnt-usb.mount") {
t.Errorf("the disk is not plugged in: its failed mount is cleared: %q", f.calls)
}
// Plugged in, and the mount still failed: a fault, left as it is.
f.write("/dev/disk/by-uuid/1234-ABCD", "")
f.setUnit("mnt-usb.mount", map[string]string{"ActiveState": "failed", "Result": "exit-code"})
f.calls = nil
if a.clearFailed(context.Background()); f.called("systemctl reset-failed") {
t.Errorf("a disk that is there and failed to mount is left failed: %q", f.calls)
}
}
func TestARewrittenPairOfItsOwnKeepsItsFailure(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, libraryOnly)
f.write(ExportsFile, exportsAnswer(t, library))
f.reachable["192.0.2.10:2049"] = true
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Look(context.Background())
// The server moved the library and refuses the mount while it answers: the module's own failure.
f.setUnit("srv-library.mount", map[string]string{"ActiveState": "failed", "Result": "exit-code"})
moved := library
moved.Path = "/tank/library"
f.write(ExportsFile, exportsAnswer(t, moved))
f.calls = nil
a.Look(context.Background())
if !strings.Contains(f.read(UnitDir+"/srv-library.mount"), "What=192.0.2.10:/tank/library") {
t.Fatal("the pair was not rewritten")
}
if f.called("systemctl reset-failed") {
t.Errorf("only a unit the module takes over has its failure cleared, not one of its own it rewrites: %q", f.calls)
}
}
func TestAnAutomountThatGaveUpIsClearedAndArmedAgain(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, libraryOnly)
f.write(ExportsFile, exportsAnswer(t, library))
a := f.applier(time.Date(2026, 10, 9, 14, 0, 0, 0, time.UTC))
a.Look(context.Background())
f.setUnit("srv-library.automount", map[string]string{"ActiveState": "failed", "Result": "mount-start-limit-hit", "UnitFileState": "enabled"})
f.calls = nil
cleared := a.clearFailed(context.Background())
if !f.called("systemctl reset-failed srv-library.automount") || !f.called("systemctl start srv-library.automount") {
t.Fatalf("the automount is cleared and started: %q", f.calls)
}
if !strings.Contains(strings.Join(cleared, ";"), "mount-start-limit-hit") {
t.Errorf("said with its result: %q", cleared)
}
// With the server answering, an automount that gave up is a fault: left as it is.
f.reachable["192.0.2.10:2049"] = true
f.setUnit("srv-library.automount", map[string]string{"ActiveState": "failed", "Result": "mount-start-limit-hit", "UnitFileState": "enabled"})
f.calls = nil
if a.clearFailed(context.Background()); f.called("systemctl reset-failed") || f.called("systemctl start") {
t.Errorf("left failed while the server answers: %q", f.calls)
}
}
func TestATransientUnitOfTheSameNameIsNotTheModules(t *testing.T) {
f := newSystem(t)
f.write(ConfigFile, libraryOnly)
f.write(ExportsFile, exportsAnswer(t, library))
f.write(TransientDir+"/srv-library.mount", "[Mount]\nWhat=elsewhere\n")
states := failedLook(t, f)
if s := states["library"]; s.State != StateFailed || !strings.Contains(s.Why, "transient") {
t.Errorf("a share whose unit someone made at run time is refused: %+v", s)
}
if f.called("systemctl enable --now srv-library.automount") {
t.Error("and not armed")
}
}
+27
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
@@ -16,6 +17,8 @@ const testPassword = "s3cr3t-Pa55"
// fakeSystem is a machine under a temporary root with a recorded service manager.
type fakeSystem struct {
// mu guards calls, units and reachable, for a test that runs RunApply beside itself.
mu sync.Mutex
t *testing.T
root string
calls []string
@@ -55,6 +58,8 @@ func (f *fakeSystem) read(p string) string {
}
func (f *fakeSystem) run(_ context.Context, name string, args ...string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, name+" "+strings.Join(args, " "))
if name != "systemctl" || len(args) == 0 {
return "", nil
@@ -77,6 +82,17 @@ func (f *fakeSystem) run(_ context.Context, name string, args ...string) (string
case "enable":
u := args[len(args)-1]
f.units[u] = map[string]string{"ActiveState": "active", "UnitFileState": "enabled"}
case "start":
u := args[len(args)-1]
if f.units[u] == nil {
f.units[u] = map[string]string{}
}
f.units[u]["ActiveState"], f.units[u]["Result"] = "active", "success"
case "reset-failed":
u := args[len(args)-1]
if f.units[u]["ActiveState"] == "failed" {
f.units[u]["ActiveState"], f.units[u]["Result"] = "inactive", "success"
}
}
return "", nil
}
@@ -88,6 +104,8 @@ func (f *fakeSystem) machine() *Machine {
func (f *fakeSystem) applier(now time.Time) *Applier {
return &Applier{M: f.machine(), Act: true, Judge: true,
Dial: func(addr string, _ time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.reachable[addr] {
return nil
}
@@ -99,6 +117,8 @@ func (f *fakeSystem) applier(now time.Time) *Applier {
}
func (f *fakeSystem) called(prefix string) bool {
f.mu.Lock()
defer f.mu.Unlock()
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
@@ -560,3 +580,10 @@ func TestApplyActsAndWatchJudgesAndNeitherDoesTheOthersWork(t *testing.T) {
t.Error("the password leaked")
}
}
// setUnit sets one unit's state as the service manager answers it, under the lock.
func (f *fakeSystem) setUnit(unit string, state map[string]string) {
f.mu.Lock()
defer f.mu.Unlock()
f.units[unit] = state
}
+45 -1
View File
@@ -29,7 +29,20 @@ another node requires.
- **Never the backups.** A folder that is the machine's root, is not there, is not a folder, is inside a
backup (restic) repository (any parent up to `/`), or holds one within four levels below it is refused,
said by the tools, and makes the module unhealthy. The look below is bounded (200 000 folders); when it
is cut short, the share is refused rather than exported unchecked.
is cut short, the share is refused rather than exported unchecked. A clean look is believed for an hour.
- **Never where the backup seat keeps its data, checked on every pass.** So that the hour cannot hide a
repository started inside it, the tools ask the controller over the bus, every five minutes, which module
holds `node-backup` on this machine (`seat:mesh-controller.seats`) and every path that module keeps data
in here (`seat:mesh-controller.data`; on the home server the restic repository and its state). They
leave it in `/var/lib/nfs-server/from-bus/backup.json`, and every pass refuses a share at, inside or
holding one of those paths, compared after resolving symbolic links, with nothing kept. A failed ask
keeps the last paths, and the status and the health warn that they are the last answer, with its age
and why. Until a list has been learnt, what is exported stays exported and the health warns that the
list is not known: a slow bus is no reason to take the library away, and the look below still guards.
A warning is said in the health without making the module unhealthy. **This is a deliberate choice**:
while the backup seat's paths are unknown the module stays healthy with a warning, rather than
unhealthy or refusing, because the look below every share still refuses one that holds a repository,
and taking the library away from every node because the bus was slow would be the larger harm.
- **Only while its filesystem is mounted.** A share that is a filesystem of its own is exported with `mp`,
a folder inside one with `mp=<its mount point>`, so an unmounted dataset never exports the empty folder
under it. A folder of the root filesystem carries neither.
@@ -94,6 +107,37 @@ its config file, so on unassignment the config file is gone when the process is
removes its export file — the one removal its code makes — and runs `exportfs -ra`, so the kernel lets the
shares go. A plain restart, a push or a shutdown leaves the config file in place, and the exports stay.
**Two signs of being taken away.** The process takes a stop as an unassignment when its config file is
gone, or when its own unit (`nfs-server-exports.service`) is disabled: the node-engine stops a process it
removes with `systemctl disable --now`, which disables the unit first, while a restart, a push or a
shutdown leaves it enabled. The second sign covers a first apply that failed and recorded the process
after its config (mesh-host #62), when the config is still there at the stop.
**Within the stop timeout.** The service manager kills a process that has not stopped 90 s after SIGTERM
(mesh-host #62), and a killed process cleans up nothing. So SIGTERM ends a running pass at once (its
commands and its look below a share are cancelled, and it writes nothing), and the clean-up has its own
bound of 10 s: the export file is removed first, which needs no command, then `exportfs -ra` runs within
that bound. Whatever it could not finish is logged as `nfs-server: UNASSIGNED, NOT CLEAN: …`, with what a
person must do.
**If it is killed anyway** (it did not stop within the service manager's 90 s), nothing cleans up and the
module never starts again. Then, on the home server as root: remove `/etc/exports.d/mesh.exports` and run
`exportfs -ra`; `exportfs -v` should list no client of the private network's range for the module's folders.
## The kernel's table follows the file
The process has the kernel read the export files again (`exportfs -ra`) on its first pass, after any
pass whose reload failed, when the file changed, when a share it exports is missing from the kernel's
table, and when the kernel exports one of the module's folders to a client the file no longer has — a
client inside the private network's range that no other export file names. While the kernel still
exports such an entry, the module is unhealthy and says which. An export another file makes (ZFS's own,
`/etc/exports`) is not the module's and is left alone.
**A hand-made export does not survive.** `exportfs -ra` makes the kernel's table exactly what the export
files say, so an export a person added by hand with `exportfs -o …` (in no file) is dropped by the first
pass of every start of the process, and by any later reload. An export meant to last belongs in a file:
the module's settings for a share of the mesh, or `/etc/exports` for anything else.
## What it leaves
- **A subfolder export shares its filesystem.** A share that is a folder inside a larger filesystem (not
a filesystem of its own) is exported with `no_subtree_check`, so a client that forges file handles could
+197
View File
@@ -0,0 +1,197 @@
package main
// Where this machine's backup seat keeps its data, from the bus (follow-up to the review of
// mesh-catalog #136, point 4).
//
// The look below a share for a backup repository walks the folders and is believed for LookKept, so a
// repository that the backup seat starts keeping under a share inside that hour would be exported until the
// look is taken again. So the module also learns, over the bus, which module holds `node-backup` on this
// machine and every path that module keeps data in (its repository among them), and every pass judges
// every share against those paths with nothing kept: a share at, under or above one of them is refused.
//
// The tools ask — the root process does not reach the bus — every BackupEvery, and leave what they learnt
// in BackupFile for the root process. A failed ask keeps the last paths and says why. Until a list has
// been read, what is exported stays exported and the status says the list is not known: the bus being
// slow is no reason to take the library away, and the look below still guards against a repository.
//
// The parsing of mesh-controller.data's answer is a small copy of the mounts module's (DataFrom). Shared
// code belongs in mesh-sdk: moving both there is mesh-sdk issue 13.
import (
"encoding/json"
"fmt"
"os"
"path"
"sort"
"time"
)
// BackupFile is what the tools learnt about the backup seat on this machine.
const BackupFile = WantsDir + "/backup.json"
// BackupEvery is how often the tools ask.
const BackupEvery = 5 * time.Minute
// BackupSeat is the seat whose holder keeps this machine's backups.
const BackupSeat = "node-backup"
// BackupFromBus is the backup seat's holder on this machine and the paths it keeps data in.
type BackupFromBus struct {
Holder string `json:"holder"`
Paths []string `json:"paths"`
Looked string `json:"looked,omitempty"`
// Learnt is when an ask last succeeded: how old the holder and paths are.
Learnt string `json:"learnt,omitempty"`
// Error is why the last ask got no answer; the holder and paths are then the last answer's.
Error string `json:"error,omitempty"`
}
// BackupFrom reads the holder of node-backup on one machine from mesh-controller.seats' answer, and the
// paths that module keeps data in on that machine from mesh-controller.data's. A machine whose backup seat
// nobody holds, and an answer with no data list, are errors: never an empty list read as "nothing to fear".
func BackupFrom(machine string, seats, data json.RawMessage) (string, []string, error) {
var s struct {
Answer *struct {
Seats []struct {
Seat string `json:"seat"`
Holders []struct {
Module string `json:"module"`
Node string `json:"node"`
} `json:"holders"`
} `json:"seats"`
} `json:"answer"`
}
if err := json.Unmarshal(seats, &s); err != nil || s.Answer == nil {
return "", nil, fmt.Errorf("the controller's seats answer could not be read: %v", err)
}
holder := ""
for _, seat := range s.Answer.Seats {
if seat.Seat != BackupSeat {
continue
}
for _, h := range seat.Holders {
if h.Node == machine {
holder = h.Module
}
}
}
if holder == "" {
return "", nil, fmt.Errorf("no module holds %s on %s", BackupSeat, machine)
}
var d struct {
Answer *struct {
Data *[]struct {
Machine string `json:"machine"`
Module string `json:"module"`
Path string `json:"path"`
} `json:"data"`
} `json:"answer"`
}
if err := json.Unmarshal(data, &d); err != nil || d.Answer == nil || d.Answer.Data == nil {
return "", nil, fmt.Errorf("the controller's data answer holds no data list (%v)", err)
}
var paths []string
for _, item := range *d.Answer.Data {
if item.Module == holder && (item.Machine == "" || item.Machine == machine) && path.IsAbs(item.Path) {
paths = append(paths, path.Clean(item.Path))
}
}
sort.Strings(paths)
return holder, paths, nil
}
// TakeBackup asks the controller over the bus, as the module, through the runtime.
func (m *Machine) TakeBackup(machine string) {
m.TakeBackupWith(machine, askBus)
}
// TakeBackupWith is TakeBackup with the ask given, for tests. A failed ask keeps the last holder and
// paths and adds why.
func (m *Machine) TakeBackupWith(machine string, ask func(key string, body any) (json.RawMessage, error)) {
var last BackupFromBus
if text, err := m.Read(BackupFile); err == nil {
_ = json.Unmarshal([]byte(text), &last)
}
next := last
next.Looked = m.Now().UTC().Format(time.RFC3339)
next.Error = ""
seats, err := ask("seat:mesh-controller.seats", map[string]any{})
var data json.RawMessage
if err == nil {
data, err = ask("seat:mesh-controller.data", map[string]any{"machine": machine})
}
if err == nil {
var holder string
var paths []string
holder, paths, err = BackupFrom(machine, seats, data)
if err == nil {
next.Holder, next.Paths, next.Learnt = holder, paths, next.Looked
}
}
if err != nil {
next.Error = err.Error()
fmt.Fprintln(os.Stderr, "nfs-server: where the backup seat keeps its data could not be learnt:", err)
}
raw, _ := json.MarshalIndent(next, "", " ")
if werr := m.Write(BackupFile, string(raw)+"\n"); werr != nil {
fmt.Fprintln(os.Stderr, "nfs-server: the backup seat's paths could not be written:", werr)
}
}
// Backup is what the tools last learnt, and a sentence when it cannot be relied on as it stands: nothing
// usable learnt yet, or a later ask failed and what is used is the last answer, with its age.
func (m *Machine) Backup() (BackupFromBus, string) {
text, err := m.Read(BackupFile)
if err != nil {
return BackupFromBus{}, "where this machine's backup seat keeps its repository has not been learnt from the bus yet; the shares are judged by the look below them alone until it is"
}
var b BackupFromBus
if err := json.Unmarshal([]byte(text), &b); err != nil {
return BackupFromBus{}, fmt.Sprintf("what was learnt about the backup seat (%s) could not be read: %v", BackupFile, err)
}
if len(b.Paths) == 0 {
why := "no path has been learnt"
if b.Error != "" {
why = b.Error
}
return b, "where this machine's backup seat keeps its repository is not known (" + why + "); the shares are judged by the look below them alone until it is"
}
if b.Error != "" {
age := "of unknown age"
if at, err := time.Parse(time.RFC3339, b.Learnt); err == nil {
age = fmt.Sprintf("learnt %s ago, at %s", m.Now().Sub(at).Round(time.Second), at.Local().Format("15:04"))
}
return b, fmt.Sprintf("where this machine's backup seat (%s) keeps its data is the last answer, %s: the "+
"latest ask failed (%s)", b.Holder, age, b.Error)
}
return b, ""
}
// realPaths are paths as the kernel resolves them, so a backup path named through a symbolic link is
// compared where it really is; one that cannot be resolved is kept as named.
func (m *Machine) realPaths(paths []string) []string {
out := make([]string, 0, len(paths))
for _, p := range paths {
if real, err := m.Real(p); err == nil {
out = append(out, real)
} else {
out = append(out, p)
}
}
return out
}
// nearBackup is the backup seat's path a real path is at, under or above, or "".
func nearBackup(real string, b BackupFromBus) (string, string) {
for _, p := range b.Paths {
switch {
case real == p:
return p, "is"
case len(real) > len(p) && real[:len(p)] == p && (p == "/" || real[len(p)] == '/'):
return p, "is inside"
case len(p) > len(real) && p[:len(real)] == real && (real == "/" || p[len(real)] == '/'):
return p, "holds"
}
}
return "", ""
}
@@ -0,0 +1,173 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
// Follow-up to the review (point 4): the one-hour look below a share must not hide a backup repository
// that appears later. Where this machine's backup seat keeps its repository is read from the bus and every
// share is judged against it on every pass.
func (f *fake) backup(holder string, paths ...string) {
raw, _ := json.Marshal(BackupFromBus{Holder: holder, Paths: paths})
f.file(BackupFile, string(raw))
}
func TestAShareHoldingTheBackupSeatsRepositoryIsRefusedOnTheNextPass(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/pool/media")
f.dir("/srv/pool/restic")
f.file(ConfigFile, grantsConfig("pool=/srv/pool media=/srv/pool/media", "pool=ws:rw media=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "pool", Access: "rw"}, {Share: "media", Access: "rw"}}})
m := f.machine()
now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC)
m.Now = func() time.Time { return now }
// The repository is not there yet: the look below finds nothing and is kept for an hour.
if s := m.Pass(context.Background(), nil); refusedWhy(s, "pool") != "" {
t.Fatalf("the pool is refused before any repository exists: %+v", s.Refused)
}
// The backup seat starts keeping its repository there; a minute later is well inside LookKept.
f.backup("restic", "/srv/pool/restic", "/var/lib/restic")
now = now.Add(time.Minute)
s := m.Pass(context.Background(), nil)
if why := refusedWhy(s, "pool"); !strings.Contains(why, "/srv/pool/restic") || !strings.Contains(why, "restic") {
t.Errorf("a share holding the backup seat's repository is exported inside the kept look: %q", why)
}
if refusedWhy(s, "media") != "" {
t.Errorf("a share beside the repository is refused: %q", refusedWhy(s, "media"))
}
if strings.Contains(f.read(ExportFile), "/srv/pool ") {
t.Errorf("the export file still names the pool:\n%s", f.read(ExportFile))
}
}
func TestAShareInsideTheBackupSeatsRepositoryIsRefused(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/backups/data")
f.file(ConfigFile, grantsConfig("inside=/srv/backups/data", "inside=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "inside", Access: "rw"}}})
f.backup("restic", "/srv/backups")
s := f.machine().Pass(context.Background(), nil)
if !strings.Contains(refusedWhy(s, "inside"), "/srv/backups") {
t.Errorf("a share inside the backup seat's repository is not refused: %+v", s.Refused)
}
}
// Until a list has been read, what is exported stays exported — the bus being slow is no reason to take
// the library away — and the status says the list is not known.
func TestWithoutTheBackupListTheExportsStayAndItIsSaid(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
s := f.machine().Pass(context.Background(), nil)
if refusedWhy(s, "library") != "" || len(s.Shares) != 1 {
t.Fatalf("the library is not exported while the backup list is unknown: %+v", s)
}
if !strings.Contains(strings.Join(s.Warnings, "\n"), "backup seat") {
t.Errorf("the status does not warn that the backup seat's repository is not known: %v", s.Warnings)
}
}
func TestBackupFromTheSeatsAndTheData(t *testing.T) {
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","scope":"node","holders":[{"module":"restic","node":"ace"},{"module":"borg","node":"shanks"}]}]},"ok":true}`)
data := json.RawMessage(`{"answer":{"data":[{"machine":"ace","module":"restic","item":"repository","path":"/storage/restic"},{"machine":"ace","module":"restic","item":"state","path":"/var/lib/restic"},{"machine":"ace","module":"plex","item":"data","path":"/mnt/plex/data"}]},"ok":true}`)
holder, paths, err := BackupFrom("ace", seats, data)
if err != nil || holder != "restic" || strings.Join(paths, " ") != "/storage/restic /var/lib/restic" {
t.Errorf("got %q %v %v", holder, paths, err)
}
if _, _, err := BackupFrom("g14", seats, data); err == nil {
t.Errorf("a machine whose backup seat nobody holds is not said")
}
if _, _, err := BackupFrom("ace", seats, json.RawMessage(`{"ok":true}`)); err == nil {
t.Errorf("an answer with no data list reads as an empty list")
}
}
// A failed ask keeps the last paths: the file is not emptied, only the error is added.
func TestAFailedAskKeepsTheLastBackupPaths(t *testing.T) {
f := newFake(t)
m := f.machine()
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
return nil, errors.New("no responders")
})
f.backup("restic", "/storage/restic")
m.TakeBackupWith("ace", func(string, any) (json.RawMessage, error) {
return nil, errors.New("timeout")
})
var b BackupFromBus
if err := json.Unmarshal([]byte(f.read(BackupFile)), &b); err != nil {
t.Fatal(err)
}
if strings.Join(b.Paths, " ") != "/storage/restic" || !strings.Contains(b.Error, "timeout") {
t.Errorf("a failed ask lost the last paths or did not say why: %+v", b)
}
}
// The review of #142: the backup seat's path is compared as the kernel resolves it. A repository the data
// list names through a symbolic link is still found under the share that really holds it.
func TestTheBackupPathIsComparedAfterItsSymlinks(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/pool/restic")
f.symlink("pool/restic", "/srv/backups") // /srv/backups -> /srv/pool/restic
f.file(ConfigFile, grantsConfig("pool=/srv/pool", "pool=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "pool", Access: "rw"}}})
f.backup("restic", "/srv/backups")
s := f.machine().Pass(context.Background(), nil)
if why := refusedWhy(s, "pool"); !strings.Contains(why, "/srv/pool/restic") {
t.Errorf("a share holding the backup seat's repository, named through a symlink, is not refused: %q", why)
}
}
// The review of #142: a list the tools could not refresh is said in the status and the health with its
// age, and a list not known yet is a warning in the health — neither is a plain "healthy".
func TestAStaleOrUnknownBackupListIsSaidWithItsAge(t *testing.T) {
f := newFake(t)
f.mountinfo("/")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
now := time.Date(2026, 10, 8, 20, 0, 0, 0, time.UTC)
m.Now = func() time.Time { return now }
m.Pass(context.Background(), nil)
h := m.Health()
if !strings.Contains(h.Why, "Warning") || !strings.Contains(h.Why, "backup seat") {
t.Errorf("an unknown backup list is not a warning in the health: %+v", h)
}
ok := func(key string, body any) (json.RawMessage, error) {
if strings.Contains(key, "seats") {
return json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"hs"}]}]}}`), nil
}
return json.RawMessage(`{"answer":{"data":[{"machine":"hs","module":"restic","path":"/srv/restic"}]}}`), nil
}
m.TakeBackupWith("hs", ok)
now = now.Add(2 * time.Hour)
m.TakeBackupWith("hs", func(string, any) (json.RawMessage, error) { return nil, errors.New("the bus did not answer") })
s := m.Pass(context.Background(), nil)
said := strings.Join(append(append([]string{}, s.Said...), s.Warnings...), "\n")
if !strings.Contains(said, "2h0m0s") || !strings.Contains(said, "the bus did not answer") {
t.Errorf("the status does not say the backup list is 2 h old and why: %q", said)
}
h = m.Health()
if !strings.Contains(h.Why, "2h0m0s") {
t.Errorf("the health does not say the backup list's age: %+v", h)
}
}
@@ -0,0 +1,111 @@
package main
// One test per guard of the review of #142, each failing when its guard alone is taken away.
import (
"context"
"encoding/json"
"strings"
"testing"
)
func TestStrayFindsAnExportOfTheModulesThatNoFileCarries(t *testing.T) {
live := []Entry{{Path: "/srv/library", Client: "10.0.0.4"}}
if got := Stray(live, nil, nil, meshRange); len(got) != 1 {
t.Errorf("an export inside the mesh range that no file carries is the module's leftover: %+v", got)
}
}
// The foreign-file guard alone: the client is inside the mesh's range, so only the other file tells it apart.
func TestStrayLeavesAnExportAnotherFileCarriesEvenInsideTheMeshRange(t *testing.T) {
// ZFS's own file exports the library to a client inside the mesh's range.
foreign := ParseExports("/srv/library 10.0.0.9(rw,no_subtree_check)\n", "/etc/exports.d/zfs.exports")
live := []Entry{{Path: "/srv/library", Client: "10.0.0.9", Options: "rw"}}
if got := Stray(live, nil, foreign, meshRange); len(got) != 0 {
t.Errorf("what ZFS's file exports is not the module's, whatever its client: %+v", got)
}
}
// The range guard alone: no file carries the client, so only the range tells it apart.
func TestStrayLeavesAClientOutsideTheMeshRange(t *testing.T) {
live := []Entry{{Path: "/srv/library", Client: "192.0.2.0/24"}, {Path: "/srv/library", Client: "192.0.2.7"},
{Path: "/srv/library", Client: "10.0.0.0/16"}}
if got := Stray(live, nil, nil, meshRange); len(got) != 0 {
t.Errorf("a client the module never exports to (outside or wider than the mesh's range) is not its leftover: %+v", got)
}
}
// The failed-reload retry alone: the file is unchanged, every share is live and nothing is stray, so the
// last pass's failed reload is the only reason to reload.
func TestAFailedReloadIsTriedAgainWhenNothingElseAsks(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
s := m.Pass(context.Background(), nil)
if !s.Shares[0].Live {
t.Fatalf("the library was not exported: %+v", s)
}
f.ran = nil
if s = m.Pass(context.Background(), &s); countExportfs(f.ran) != 0 {
t.Fatalf("with nothing to do a pass reloads: %v", f.ran)
}
s.ReloadError = "exportfs: stuck"
f.ran = nil
m.Pass(context.Background(), &s)
if countExportfs(f.ran) == 0 {
t.Errorf("the last pass's failed reload was not tried again: %v", f.ran)
}
}
func TestBackupFromRefusesAnAnswerWithoutADataList(t *testing.T) {
seats := json.RawMessage(`{"answer":{"seats":[{"seat":"node-backup","holders":[{"module":"restic","node":"ace"}]}]}}`)
for _, data := range []string{`{"answer":{}}`, `{"answer":{"data":null}}`} {
if _, paths, err := BackupFrom("ace", seats, json.RawMessage(data)); err == nil {
t.Errorf("%s was read as the empty list %v: never \"nothing to fear\"", data, paths)
}
}
}
func TestTheLookBelowStopsWhenTheProcessStops(t *testing.T) {
f := newFake(t)
f.dir("/srv/library/movies/a")
m := f.machine()
ctx, cancel := context.WithCancel(context.Background())
cancel()
m.setWalk(ctx)
if found, complete := m.walkBelow("/srv/library"); complete || found != "" {
t.Errorf("a look the process ended is unfinished, so the share is refused rather than exported: %q %v", found, complete)
}
}
func TestAPassEndedByStoppingWritesAndReloadsNothing(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
s := m.Pass(context.Background(), nil)
file, status := f.read(ExportFile), f.read(StatusFile)
// The grant is withdrawn, and the process is stopping as the next pass runs.
f.file(ConfigFile, grantsConfig("library=/srv/library", "none"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
f.ran = nil
got := m.Pass(ctx, &s)
if f.read(ExportFile) != file || f.read(StatusFile) != status || countExportfs(f.ran) != 0 {
t.Errorf("a pass ended by stopping wrote or reloaded: file %q, ran %v", f.read(ExportFile), f.ran)
}
if !strings.Contains(strings.Join(got.Problems, ";"), "nothing was written") {
t.Errorf("it says so: %+v", got.Problems)
}
}
+28 -4
View File
@@ -108,6 +108,21 @@ type Machine struct {
// clean is when a look below each real path last found no backup repository (LookKept).
clean map[string]time.Time
cleanMu sync.Mutex
// walk is the running pass's context, which ends the look below a share when the process stops.
walk context.Context
walkMu sync.Mutex
}
func (m *Machine) setWalk(ctx context.Context) {
m.walkMu.Lock()
m.walk = ctx
m.walkMu.Unlock()
}
func (m *Machine) walking() context.Context {
m.walkMu.Lock()
defer m.walkMu.Unlock()
return m.walk
}
// Here is the machine this process runs on.
@@ -232,11 +247,11 @@ func (m *Machine) Glob(pattern string) []string {
}
// written are the only paths Write may touch.
var written = map[string]bool{ExportFile: true, StatusFile: true, WantsFile: true}
var written = map[string]bool{ExportFile: true, StatusFile: true, WantsFile: true, BackupFile: true}
// Write replaces one of the module's own files, atomically. Every other path is refused: the module owns
// these three files (the export file and the status, written as root; the wants, written by the tools)
// and nothing else on the machine.
// exactly these four files and nothing else on the machine: the export file and the status, written by the
// root process; the wants and what was learnt about the backup seat, written by the tools from the bus.
func (m *Machine) Write(path, content string) error {
if !written[path] {
return fmt.Errorf("the module writes only %s, %s and %s, not %s", ExportFile, StatusFile, WantsFile, path)
@@ -272,8 +287,17 @@ func (m *Machine) Escalated(name string, args ...string) (string, []string) {
// AsRoot runs what only root may do, and names a refusal by how it failed.
func (m *Machine) AsRoot(name string, args ...string) (Ran, error) {
return m.AsRootWithin(context.Background(), name, args...)
}
// AsRootWithin is AsRoot ended with its context: the process's pass is cancelled by SIGTERM, and Stopped
// runs within StopTimeout.
func (m *Machine) AsRootWithin(ctx context.Context, name string, args ...string) (Ran, error) {
program, argv := m.Escalated(name, args...)
r := m.Run(context.Background(), program, argv...)
r := m.Run(ctx, program, argv...)
if err := ctx.Err(); err != nil && r.Code != 0 {
return r, fmt.Errorf("%s %s did not finish: %v", name, strings.Join(args, " "), err)
}
if r.Code != 0 {
why := r.Err
if why == "" {
+15 -2
View File
@@ -74,12 +74,25 @@ func ShareExports(m *Machine) {
}
// The SDK reaches the bus once Serve runs.
time.Sleep(2 * time.Second)
m.TakeBackup(machine)
put()
for range time.Tick(Every) {
put()
backup := time.Tick(BackupEvery)
every := time.Tick(Every)
for {
select {
case <-backup:
m.TakeBackup(machine)
case <-every:
put()
}
}
}
// askBus calls a seat's verb over the bus as this module, through the runtime.
func askBus(key string, body any) (json.RawMessage, error) {
return stdio.Ask(key, body)
}
// TakeWants reads every node's `mounts` state from the bus and writes what is asked of this machine to
// the wants file, for the root process. When the bus does not answer, the last wants stay.
func (m *Machine) TakeWants(machine string) {
@@ -162,3 +162,17 @@ func TestTheManifestCarriesGrantsTheRosterTheWantsAndTheRemovalOrder(t *testing.
t.Errorf("the process must be declared before its config, so unassignment removes the config first: %v", order)
}
}
// Stopped asks whether the process's own unit is disabled; that unit is the manifest's process name.
func TestTheProcessUnitIsTheManifestsProcess(t *testing.T) {
m, _ := readManifest(t)
for _, r := range m.Resources {
if r["type"] == "process" && r["id"] == "exports" {
if got := r["name"].(string) + ".service"; got != ProcessUnit {
t.Errorf("the exports process runs as %s, and Stopped asks about %s", got, ProcessUnit)
}
return
}
}
t.Error("the manifest has no exports process")
}
+109 -10
View File
@@ -4,7 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"net/netip"
"os"
"path"
"strings"
"time"
)
@@ -26,6 +28,9 @@ type Status struct {
// Said is what does not match between the grants and the wants: a node wanting a share it is not
// granted, a grant nobody wants, a share exported to no node.
Said []string `json:"said,omitempty"`
// Warnings make the module no less healthy but are said in its health: what it could not learn, and
// how old what it relies on is.
Warnings []string `json:"warnings,omitempty"`
// Reloaded is when the process last had the kernel read the export files again, and why.
Reloaded *time.Time `json:"reloaded,omitempty"`
ReloadWhy string `json:"reload_why,omitempty"`
@@ -53,7 +58,17 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if note != "" {
s.Said = append(s.Said, note)
}
if _, note := m.Backup(); note != "" {
s.Warnings = append(s.Warnings, note)
}
m.setWalk(ctx)
exports, refused, said := m.Judge(c, roster, wants)
m.setWalk(nil)
if ctx.Err() != nil {
// Stopping: write nothing and reload nothing, so Stopped starts at once.
s.Problems = append(s.Problems, "the pass was ended by the process stopping; nothing was written")
return s
}
s.Refused = refused
s.Said = append(s.Said, said...)
@@ -68,6 +83,14 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
live, note := m.Live()
foreign, _, _ := m.Foreign()
mine := ParseExports(want, ExportFile)
if reload == "" && last == nil {
reload = "the process started, and the kernel's table is made to match the file"
}
if reload == "" && last != nil && last.ReloadError != "" {
reload = "the last reload failed: " + last.ReloadError
}
if reload == "" {
for _, e := range exports {
if !SharedLive(live, e) {
@@ -76,8 +99,13 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
}
}
}
if reload == "" {
if stray := Stray(live, mine, foreign, c.Range); len(stray) > 0 {
reload = fmt.Sprintf("the kernel exports %s to %s, which the export file no longer carries", stray[0].Path, stray[0].Client)
}
}
if reload != "" {
if _, err := m.AsRoot("exportfs", "-ra"); err != nil {
if _, err := m.AsRootWithin(ctx, "exportfs", "-ra"); err != nil {
s.ReloadError = err.Error()
} else {
at := m.Now().UTC()
@@ -88,6 +116,10 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
if note != "" && len(exports) > 0 {
s.Problems = append(s.Problems, note)
}
for _, e := range Stray(live, mine, foreign, c.Range) {
s.Problems = append(s.Problems, fmt.Sprintf("the kernel still exports %s to %s, which the module's export file "+
"no longer carries: a reload has to take it away (exportfs -ra as root)", e.Path, e.Client))
}
for i := range exports {
exports[i].Live = SharedLive(live, exports[i])
}
@@ -99,6 +131,13 @@ func (m *Machine) Pass(ctx context.Context, last *Status) Status {
return s
}
// ProcessUnit is the service the node-engine runs this process as: the manifest's process name, `.service`.
const ProcessUnit = "nfs-server-exports.service"
// StopTimeout bounds Stopped and the commands it runs: well under the service manager's stop timeout (90 s,
// then SIGKILL), so the export file is gone before the process could be killed (mesh-host #62).
var StopTimeout = 10 * time.Second
// RunExports is the process: a pass now and every Every until the context ends, then Stopped.
func (m *Machine) RunExports(ctx context.Context) error {
last := m.Pass(ctx, nil)
@@ -172,20 +211,80 @@ func SharedLive(live []Entry, e Exported) bool {
return true
}
// Stopped is the process ending. When the module was unassigned — the node-engine removes the module's
// files in reverse declaration order, so the config file, declared after this process, is already gone
// when the process is stopped (mesh-host internal/apply: orphans removed in reverse order) — the
// module's export file goes and the kernel lets the shares go. A plain restart, a push or the machine
// shutting down leaves the config file, and the exports stay.
// Stopped is the process ending. When the module was unassigned (see unassigned) the module's export file
// goes and the kernel lets the shares go. A plain restart, a push or the machine shutting down leaves the
// config file and the unit enabled, and the exports stay.
func (m *Machine) Stopped() {
if _, err := m.Read(ConfigFile); err == nil || !os.IsNotExist(err) {
ctx, cancel := context.WithTimeout(context.Background(), StopTimeout)
defer cancel()
if !m.unassigned(ctx) {
return
}
// The file first: removing it needs no command, so a hanging sudo or exportfs cannot keep it.
if err := m.unexport(); err != nil {
fmt.Fprintln(os.Stderr, "nfs-server:", err)
loud("the module was unassigned and its export file is still there: " + err.Error() +
"; remove " + ExportFile + " and run exportfs -ra as root")
return
}
if _, err := m.AsRoot("exportfs", "-ra"); err != nil {
fmt.Fprintln(os.Stderr, "nfs-server: the export file is gone, and exportfs -ra failed:", err)
fmt.Fprintln(os.Stderr, "nfs-server: unassigned: the export file "+ExportFile+" is removed")
if _, err := m.AsRootWithin(ctx, "exportfs", "-ra"); err != nil {
loud("the module was unassigned and its export file is removed, but the kernel may still export its " +
"shares until exportfs -ra runs as root: " + err.Error())
return
}
fmt.Fprintln(os.Stderr, "nfs-server: unassigned: the kernel let the shares go (exportfs -ra)")
}
// unassigned says whether this stop is the module being taken away: its config file is gone (the node-engine
// removes resources in reverse of their record, and the config is recorded after this process), or this
// process's own unit is disabled. The node-engine stops a process it removes with `systemctl disable
// --now`, which disables the unit before it stops it, while a restart, a push or a shutdown leaves it
// enabled — so the second sign holds even when a failed first apply recorded the process after its config
// (mesh-host #62) and the config is still there.
func (m *Machine) unassigned(ctx context.Context) bool {
if _, err := m.Read(ConfigFile); os.IsNotExist(err) {
return true
}
r := m.Run(ctx, "systemctl", "is-enabled", ProcessUnit)
return strings.TrimSpace(r.Out) == "disabled"
}
// loud says, on the process's log where the service manager's journal keeps it, what an unassignment left
// undone, so a person finds it rather than an export that outlived its module.
func loud(what string) {
fmt.Fprintln(os.Stderr, "nfs-server: UNASSIGNED, NOT CLEAN: "+what)
}
// Stray is what the kernel exports that is the module's and that its file no longer carries: an entry to a
// client inside the private network's range — the only clients the module ever exports to — that neither
// the module's file nor any other export file (/etc/exports, ZFS's own) has. What another file exports is
// not the module's, whatever its client.
func Stray(live, mine, foreign []Entry, rng string) []Entry {
prefix, err := netip.ParsePrefix(rng)
if err != nil {
return nil
}
has := map[string]bool{}
for _, e := range append(append([]Entry{}, mine...), foreign...) {
has[path.Clean(e.Path)+" "+e.Client] = true
}
var out []Entry
for _, e := range live {
if has[path.Clean(e.Path)+" "+e.Client] || !insideRange(e.Client, prefix) {
continue
}
out = append(out, e)
}
return out
}
// insideRange says whether a client — one address, or a network — lies wholly inside a range.
func insideRange(client string, prefix netip.Prefix) bool {
if a, err := netip.ParseAddr(client); err == nil {
return prefix.Contains(a)
}
if p, err := netip.ParsePrefix(client); err == nil {
return p.Bits() >= prefix.Bits() && prefix.Contains(p.Addr())
}
return false
}
@@ -0,0 +1,201 @@
package main
import (
"context"
"strings"
"testing"
"time"
)
func countExportfs(ran []string) int {
n := 0
for _, r := range ran {
if strings.Contains(r, "exportfs -ra") {
n++
}
}
return n
}
// The review of #142: pass 1 exports /srv/pool; pass 2 no longer exports it and exportfs fails; pass 3 ran nothing,
// and the kernel still exported the refused folder while the module said it was healthy. A failed reload
// is tried again on the next pass, and while the kernel exports anything of the module's that the file no
// longer carries, the module is unhealthy and says what.
func TestARefusalWhoseReloadFailedIsReloadedAgainAndIsUnhealthyUntilItIs(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/pool")
f.dir("/srv/pool")
f.file(ConfigFile, grantsConfig("pool=/srv/pool", "pool=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "pool", Access: "rw"}}})
f.kernelReads()
m := f.machine()
s := m.Pass(context.Background(), nil)
if !strings.Contains(f.read(Etab), "/srv/pool") {
t.Fatalf("pass 1 did not export the pool: %+v", s)
}
f.file(ConfigFile, grantsConfig("pool=/srv/pool", "none")) // pass 2: the grant is taken away
f.onExportfs = nil
f.answers["exportfs -ra"] = Ran{Code: 1, Err: "exportfs: stuck"}
f.answers["sudo -n exportfs -ra"] = Ran{Code: 1, Err: "exportfs: stuck"}
s = m.Pass(context.Background(), &s)
if s.ReloadError == "" {
t.Fatalf("pass 2's reload did not fail as arranged: %+v", s)
}
f.ran = nil
s = m.Pass(context.Background(), &s)
if countExportfs(f.ran) == 0 {
t.Errorf("pass 3 did not try the failed reload again: %v", f.ran)
}
h := m.Health()
if h.Healthy || !strings.Contains(h.Why, "/srv/pool") {
t.Errorf("the kernel still exports the refused /srv/pool and the module says: %+v", h)
}
delete(f.answers, "exportfs -ra")
delete(f.answers, "sudo -n exportfs -ra")
f.kernelReads()
s = m.Pass(context.Background(), &s)
if strings.Contains(f.read(Etab), "/srv/pool") {
t.Errorf("a reload that works again did not let the refused pool go: %q", f.read(Etab))
}
for _, p := range s.Problems {
if strings.Contains(p, "/srv/pool") {
t.Errorf("a problem stays after the kernel let the pool go: %q", p)
}
}
}
// A grant taken away while the kernel still holds it — exported before the process started, or a reload
// that never happened — is reconciled: the first pass always reloads, a later pass reloads when the kernel
// exports one of the module's folders to a client the file no longer has, and the module is unhealthy
// while it cannot.
func TestAWithdrawnGrantIsTakenFromTheKernel(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3", "laptop 10.0.0.4")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}, "laptop": {{Share: "library", Access: "ro"}}})
f.kernelReads()
m := f.machine()
s := m.Pass(context.Background(), nil)
// The file and the kernel agree on ws only; the laptop's old export is left in the kernel's table, as
// a grant withdrawn while the process was down would be.
f.file(Etab, f.read(Etab)+"/srv/library\t10.0.0.4(ro,sync)\n")
f.ran = nil
m2 := f.machine() // a restarted process: its first pass
m2.Pass(context.Background(), nil)
if countExportfs(f.ran) == 0 {
t.Errorf("the first pass of a process did not reload: %v", f.ran)
}
if strings.Contains(f.read(Etab), "10.0.0.4") {
t.Errorf("the withdrawn grant stays in the kernel after the first pass: %q", f.read(Etab))
}
f.file(Etab, f.read(Etab)+"/srv/library\t10.0.0.4(ro,sync)\n")
f.ran = nil
s = m.Pass(context.Background(), &s)
if countExportfs(f.ran) == 0 {
t.Errorf("a later pass did not reload while the kernel exported a folder of the module's to a client the file no longer has: %v", f.ran)
}
if strings.Contains(f.read(Etab), "10.0.0.4") {
t.Errorf("the withdrawn grant stays in the kernel: %q", f.read(Etab))
}
// A client the module never exported to — the home network's range, from ZFS's own file — is not
// the module's, and makes no reload and no finding.
f.file("/etc/exports.d/zfs.exports", "/srv/library 192.0.2.0/24(rw)\n")
f.file(Etab, f.read(Etab)+"/srv/library\t192.0.2.0/24(rw)\n")
f.ran = nil
s = m.Pass(context.Background(), &s)
if n := countExportfs(f.ran); n != 0 {
t.Errorf("an export that is not the module's made a reload: %v", f.ran)
}
if h := m.Health(); !h.Healthy {
t.Errorf("an export that is not the module's made it unhealthy: %+v", h)
}
}
// The review of #142: SIGTERM in the middle of a pass on a plain restart (the config is still there) ends
// the pass, and the exports stay: the export file is unchanged and the kernel's table keeps the share.
func TestSIGTERMDuringAPassOnARestartLeavesTheExports(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
m.Pass(context.Background(), nil)
file, table := f.read(ExportFile), f.read(Etab)
if !strings.Contains(table, "/srv/library") {
t.Fatalf("the library was not exported: %q", table)
}
inner := m.Run
hung := make(chan struct{}, 8)
m.Run = func(ctx context.Context, name string, args ...string) Ran {
if strings.Contains(strings.Join(append([]string{name}, args...), " "), "exportfs") {
hung <- struct{}{}
<-ctx.Done()
return Ran{Code: -1, Err: ctx.Err().Error()}
}
return inner(ctx, name, args...)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- m.RunExports(ctx) }() // its first pass reloads, and hangs there
select {
case <-hung:
case <-time.After(5 * time.Second):
t.Fatal("the pass never reached exportfs")
}
cancel()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the process did not stop after SIGTERM")
}
if got := f.read(ExportFile); got != file {
t.Errorf("a restart changed the export file:\nwas %q\nnow %q", file, got)
}
if got := f.read(Etab); got != table {
t.Errorf("a restart changed what the kernel exports:\nwas %q\nnow %q", table, got)
}
}
// mesh-host #62's caveat: when the process's first apply failed, it can be recorded after its config, and
// unassignment then stops it with the config still there. The node-engine disables a process's unit before
// it stops it (`systemctl disable --now`), and a restart or a shutdown leaves it enabled, so a process
// whose own unit is disabled when it is stopped is being taken away, whatever the config says.
func TestAProcessWhoseUnitIsDisabledAtStopCleansUpWithTheConfigStillThere(t *testing.T) {
f := newFake(t)
f.mountinfo("/", "/srv/library")
f.dir("/srv/library")
f.file(ConfigFile, grantsConfig("library=/srv/library", "library=ws:rw"))
f.roster("ws 10.0.0.3")
f.wants(map[string][]Want{"ws": {{Share: "library", Access: "rw"}}})
f.kernelReads()
m := f.machine()
m.Pass(context.Background(), nil)
f.answers["systemctl is-enabled "+ProcessUnit] = Ran{Out: "enabled\n"}
m.Stopped()
if !strings.Contains(f.read(ExportFile), "/srv/library") {
t.Fatalf("a stop with the unit enabled (a restart) removed the exports")
}
f.answers["systemctl is-enabled "+ProcessUnit] = Ran{Code: 1, Out: "disabled\n"}
m.Stopped()
if strings.Contains(f.read(ExportFile), "/srv/library") {
t.Errorf("a stop with the unit disabled (unassigned, config still there) left the export file")
}
if strings.Contains(f.read(Etab), "/srv/library") {
t.Errorf("the kernel still exports the library after an unassignment with the config in place: %q", f.read(Etab))
}
}

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