H-6: the seat verb process-builds reads the build each process was started with; the engine keeps no callers table

Builds: design 47 G §262, §245, §440, §447

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-11 12:09:56 +01:00
parent 420087ab14
commit f3813a279e
5 changed files with 245 additions and 21 deletions
+14
View File
@@ -16,6 +16,7 @@ import (
"errors"
"flag"
"fmt"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
"io"
"net"
@@ -42,6 +43,7 @@ import (
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/network"
"github.com/novox/mesh-host/internal/outward"
"github.com/novox/mesh-host/internal/processbuilds"
"github.com/novox/mesh-host/internal/profile"
"github.com/novox/mesh-host/internal/reachable"
"github.com/novox/mesh-host/internal/store"
@@ -1018,6 +1020,18 @@ func runLink(ctx context.Context, opts options) error {
queue.Acts.Compose = composeFrom(current, &composerNow)
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
queue.Acts.SeatList, queue.Acts.SeatListAgain = seatListRead, seatListAgain
queue.Acts.ProcessBuilds = func(module string) (any, error) {
d, err := declaredNow(opts.state, nil)
if errors.Is(err, store.ErrNothingDeclared) {
d, err = nil, nil
}
if err != nil {
return nodeengine.ProcessBuilds{Node: mine.Node, Error: "what runs on this node does not read: " + err.Error()}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), lookBound)
defer cancel()
return processbuilds.Reader{Node: mine.Node, Run: processbuilds.Runner(opts.run())}.Read(ctx, d, module), nil
}
queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
setRunsHere(runsFromAgent(current))
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
+10 -16
View File
@@ -55,18 +55,11 @@ const (
// (novox/hq ADR 0636 §7). The node-engine seat's definition carries both verbs.
ActSeatList = nodeengine.VerbSeatList
ActSeatListAgain = nodeengine.VerbSeatListAgain
// ActProcessBuilds reads the build each process on this node was started with (design 47, *The fingerprint handover*);
// it reads only; who may call it is the seat definition's (design 47 G §440, §447).
ActProcessBuilds = nodeengine.VerbProcessBuilds
)
// SeatListCallers are who may call the engine's two seat-list verbs (novox/hq ADR 0636 §7; the node-engine seat's
// definition: the operator and the healers): every operator's session and the healers, both verbs alike. A call through
// the mesh MCP server, and nox, is proved only as its machine's tool runner (<node>.node-tools, issue 627), so node-tools
// stands for an operator's session until a session is proved; without it the verbs answered nobody but the healers, and
// no healer calls them (issue 1966).
var SeatListCallers = stdio.VerbCallers{
ActSeatList: {stdio.AnyPerson, "node-tools", "mesh-healers"},
ActSeatListAgain: {stdio.AnyPerson, "node-tools", "mesh-healers"},
}
// The states of an act: asked, done and refused are also the seat's events.
const (
HandAsked = nodeengine.EventAsked
@@ -166,6 +159,8 @@ type HandActs struct {
// SeatList answers the seat list the engine holds; SeatListAgain makes it read the list now and answers what it holds.
SeatList func() (any, error)
SeatListAgain func() (any, error)
// ProcessBuilds answers the build each process of the node was started with, narrowed to a module when one is given.
ProcessBuilds func(module string) (any, error)
// ComposeCandidate composes a check's candidate (the indexer's candidate key) on this node in shadow and answers per
// module, applying and writing nothing; with input, also the composition input it composed from (the verb
// compose-candidate, ADR 0593 §7).
@@ -237,17 +232,16 @@ func (h *HandActs) Call(c EngineCall) (any, error) {
}
return h.Compose(arg("module"))
case ActSeatList:
if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil {
return nil, err
}
if h.SeatList == nil {
return nil, errors.New("this engine reads no seat list yet: its node agent has not started")
}
return h.SeatList()
case ActSeatListAgain:
if err := SeatListCallers.Allowed(c.Verb, c.Caller); err != nil {
return nil, err
case ActProcessBuilds:
if h.ProcessBuilds == nil {
return nil, errors.New("this engine reads no process yet: its node agent has not started")
}
return h.ProcessBuilds(arg("module"))
case ActSeatListAgain:
if h.SeatListAgain == nil {
return nil, errors.New("this engine reads no seat list yet: its node agent has not started")
}
+11 -5
View File
@@ -271,6 +271,10 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
ManifestReads: func() ([]string, time.Time) {
return []string{"form:a", "seat:b"}, time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC)
},
// Design 47: the build each process was started with, narrowed by module.
ProcessBuilds: func(module string) (any, error) {
return map[string]any{"processes": []any{}, "module": module}, nil
},
// ADR 0565 §6, §15: the node's key of the engine's state packages.
Packages: func(write bool) (any, error) {
key := []byte(`{"node":"n1","packages":[{"package":"curl","modules":["backup","web"],"absent":false}]}`)
@@ -301,16 +305,18 @@ func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
{"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`},
{"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`},
{"manifest-reads", EngineToolSubject("manifest-reads", "n1"), `"manifest-reads":["form:a","seat:b"],"node":"n1","stated-at":"2026-11-04T10:00:00Z","v":1`},
// ADR 0636 §7: both seat-list verbs answer every operator's session, proved as a person or, through the mesh MCP
// server, as its machine's tool runner (issue 627, issue 1966), and the healers, no other module.
// Design 47 G §447: the engine keeps no callers table; who may call a verb is the seat definition's, which the
// grants part makes the bus enforce, so a call that reaches the engine is answered whoever the caller is.
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.person~jochen", `"held":true`},
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.shanks~node-tools", `"held":true`},
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `not on the holder's caller list for seat-list`},
{"seat-list", "mesh.seat.node-engine.call.seat-list.n1.ace~messenger", `"held":true`},
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.shanks~node-tools", `"read":true`},
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.person~jochen", `"read":true`},
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~mesh-healers", `"read":true`},
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `not on the holder's caller list for seat-list-again`},
{"seat-list-again", EngineToolSubject("seat-list-again", "n1"), `no caller the tool runner proved`},
{"seat-list-again", "mesh.seat.node-engine.call.seat-list-again.n1.ace~messenger", `"read":true`},
{"process-builds", "mesh.seat.node-engine.call.process-builds.n1.person~jochen " + `{"module":"web"}`, `"module":"web","processes":[]`},
{"process-builds", "mesh.seat.node-engine.call.process-builds.n1.ace~messenger", `"processes":[]`},
{"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`},
} {
subject, body, ok := strings.Cut(row.subject, " ")
+140
View File
@@ -0,0 +1,140 @@
// Package processbuilds reads the build each process of this node was started with (design 47, *The fingerprint handover*):
// the value MESH_FINGERPRINT the node-engine handed a daemon or a container at its start, read from the running process
// itself, never from what the declaration would hand it now. A process started before the node-engine handed the value,
// or one whose value cannot be read, says why its build is unknown instead of a fingerprint.
package processbuilds
import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// Var is the environment value the build is handed in.
const Var = "MESH_FINGERPRINT"
// Runner runs a command and answers its output.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// Reader reads the running processes of a node.
type Reader struct {
Node string
Run Runner
// Environ reads a process's environment by its pid; nil reads /proc/<pid>/environ.
Environ func(pid int) ([]byte, error)
}
func (r Reader) environ(pid int) ([]byte, error) {
if r.Environ != nil {
return r.Environ(pid)
}
return os.ReadFile(fmt.Sprintf("/proc/%d/environ", pid))
}
// Read answers the build of every daemon and container the declaration holds, narrowed to module when it is not empty,
// ordered by module and process.
func (r Reader) Read(ctx context.Context, d *declaration.Declaration, module string) nodeengine.ProcessBuilds {
out := nodeengine.ProcessBuilds{Node: r.Node, Processes: []nodeengine.ProcessBuild{}}
if d == nil {
out.Error = "this node keeps no declaration of what it runs"
return out
}
for _, res := range d.Resources {
owner := res.DeclaredModule()
if owner == "" {
owner, _ = declaration.ModuleGuess(res.Identity())
}
if module != "" && owner != module {
continue
}
switch p := res.(type) {
case *declaration.Process:
out.Processes = append(out.Processes, r.daemon(ctx, owner, p.Name))
case *declaration.Container:
out.Processes = append(out.Processes, r.container(ctx, owner, p.Name))
}
}
sort.SliceStable(out.Processes, func(i, j int) bool {
a, b := out.Processes[i], out.Processes[j]
if a.Module != b.Module {
return a.Module < b.Module
}
return a.Process < b.Process
})
return out
}
func (r Reader) daemon(ctx context.Context, module, name string) nodeengine.ProcessBuild {
unit := name + ".service"
b := nodeengine.ProcessBuild{Module: module, Process: unit, Kind: nodeengine.ProcessBuildKindService}
said, err := r.Run(ctx, "systemctl", "show", "--property=MainPID,ActiveEnterTimestamp", "--", unit)
if err != nil {
b.Unknown = "the unit cannot be read: " + err.Error()
return b
}
props := map[string]string{}
for _, line := range strings.Split(said, "\n") {
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
props[k] = v
}
}
pid, _ := strconv.Atoi(props["MainPID"])
if pid <= 0 {
b.Unknown = "the unit runs no process now"
return b
}
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", props["ActiveEnterTimestamp"]); err == nil {
b.Started = &t
}
raw, err := r.environ(pid)
if err != nil {
b.Unknown = "its environment cannot be read: " + err.Error()
return b
}
b.Fingerprint, b.Unknown = fingerprintIn(strings.Split(string(raw), "\x00"))
return b
}
func (r Reader) container(ctx context.Context, module, name string) nodeengine.ProcessBuild {
b := nodeengine.ProcessBuild{Module: module, Process: name, Kind: nodeengine.ProcessBuildKindContainer}
var said string
var err error
for _, cri := range []string{"docker", "podman"} {
if said, err = r.Run(ctx, cri, "container", "inspect", "--format", "{{json .Config.Env}}\t{{.State.StartedAt}}", name); err == nil {
break
}
}
if err != nil {
b.Unknown = "the container cannot be read: " + err.Error()
return b
}
env, started, _ := strings.Cut(strings.TrimSpace(said), "\t")
var vars []string
if err := json.Unmarshal([]byte(env), &vars); err != nil {
b.Unknown = "its environment cannot be read: " + err.Error()
return b
}
if t, err := time.Parse(time.RFC3339Nano, started); err == nil {
b.Started = &t
}
b.Fingerprint, b.Unknown = fingerprintIn(vars)
return b
}
// fingerprintIn is the value of the handed variable in a process's environment, or why there is none.
func fingerprintIn(vars []string) (fingerprint, unknown string) {
for _, v := range vars {
if value, ok := strings.CutPrefix(v, Var+"="); ok && value != "" {
return value, ""
}
}
return "", "started before the node-engine handed " + Var + ", or without it"
}
@@ -0,0 +1,70 @@
package processbuilds
import (
"context"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// Design 47, *The fingerprint handover*: the build of a daemon is read from its running process's environment and of a
// container from its configuration, narrowed by module; one started without the value, one not running and one the
// runtime cannot read each say why their build is unknown, never a guessed fingerprint.
func TestProcessBuildsAreReadFromTheRunningProcesses(t *testing.T) {
fp := "src2:" + strings.Repeat("a", 64)
d := &declaration.Declaration{Resources: []declaration.Resource{
&declaration.Process{ID: "web.d", Type: declaration.TypeProcess, Module: "web", Name: "web-d"},
&declaration.Process{ID: "old.d", Type: declaration.TypeProcess, Module: "old", Name: "old-d"},
&declaration.Process{ID: "down.d", Type: declaration.TypeProcess, Module: "down", Name: "down-d"},
&declaration.Container{ID: "db.c", Type: declaration.TypeContainer, Module: "db", Name: "db-c"},
&declaration.Container{ID: "gone.c", Type: declaration.TypeContainer, Module: "gone", Name: "gone-c"},
&declaration.Directory{ID: "web.dir", Type: declaration.TypeDirectory, Module: "web"},
}}
run := func(_ context.Context, name string, args ...string) (string, error) {
switch {
case name == "systemctl" && args[len(args)-1] == "web-d.service":
return "MainPID=11\nActiveEnterTimestamp=Mon 2026-11-09 10:00:00 UTC\n", nil
case name == "systemctl" && args[len(args)-1] == "old-d.service":
return "MainPID=12\nActiveEnterTimestamp=\n", nil
case name == "systemctl":
return "MainPID=0\nActiveEnterTimestamp=\n", nil
case name == "docker" && args[len(args)-1] == "db-c":
return `["A=1","MESH_FINGERPRINT=` + fp + `"]` + "\t2026-11-09T10:00:00.5Z\n", nil
}
return "", errors.New("no such container")
}
environ := func(pid int) ([]byte, error) {
if pid == 11 {
return []byte("A=1\x00MESH_FINGERPRINT=" + fp + "\x00"), nil
}
return []byte("A=1\x00"), nil
}
r := Reader{Node: "n1", Run: run, Environ: environ}
got := r.Read(context.Background(), d, "")
by := map[string]string{}
for _, p := range got.Processes {
by[p.Module] = p.Fingerprint + "|" + p.Unknown
}
want := map[string]string{"web": fp + "|", "db": fp + "|",
"old": "|started before the node-engine handed MESH_FINGERPRINT, or without it",
"down": "|the unit runs no process now", "gone": "|the container cannot be read: no such container"}
if len(by) != len(want) || got.Node != "n1" {
t.Fatalf("read %v, want %v", by, want)
}
for m, w := range want {
if by[m] != w {
t.Errorf("%s: %q, want %q", m, by[m], w)
}
}
if got.Processes[0].Module != "db" || got.Processes[len(got.Processes)-1].Module != "web" || got.Processes[len(got.Processes)-1].Started == nil {
t.Errorf("order or start time wrong: %+v", got.Processes)
}
if one := r.Read(context.Background(), d, "web"); len(one.Processes) != 1 || one.Processes[0].Process != "web-d.service" {
t.Errorf("narrowed by module: %+v", one.Processes)
}
if none := r.Read(context.Background(), nil, ""); none.Error == "" || none.Processes == nil {
t.Errorf("no declaration: %+v", none)
}
}