From f3813a279e23dc22229b3891145a0f3ce820ce3e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 12:09:56 +0100 Subject: [PATCH] H-6: the seat verb process-builds reads the build each process was started with; the engine keeps no callers table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §262, §245, §440, §447 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- cmd/mesh-host/main.go | 14 ++ internal/link/handacts.go | 26 ++-- internal/link/handover_test.go | 16 ++- internal/processbuilds/processbuilds.go | 140 +++++++++++++++++++ internal/processbuilds/processbuilds_test.go | 70 ++++++++++ 5 files changed, 245 insertions(+), 21 deletions(-) create mode 100644 internal/processbuilds/processbuilds.go create mode 100644 internal/processbuilds/processbuilds_test.go diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 1edcaad9..cf5c9e84 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -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) { diff --git a/internal/link/handacts.go b/internal/link/handacts.go index 186c7cab..28ad5861 100644 --- a/internal/link/handacts.go +++ b/internal/link/handacts.go @@ -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-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") } diff --git a/internal/link/handover_test.go b/internal/link/handover_test.go index 1fc5de3b..3fb5491d 100644 --- a/internal/link/handover_test.go +++ b/internal/link/handover_test.go @@ -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, " ") diff --git a/internal/processbuilds/processbuilds.go b/internal/processbuilds/processbuilds.go new file mode 100644 index 00000000..9b39cf97 --- /dev/null +++ b/internal/processbuilds/processbuilds.go @@ -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//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" +} diff --git a/internal/processbuilds/processbuilds_test.go b/internal/processbuilds/processbuilds_test.go new file mode 100644 index 00000000..fc31889b --- /dev/null +++ b/internal/processbuilds/processbuilds_test.go @@ -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) + } +}