Serve a module from a runtime on its own account instead of switching users, keep bus words from bundles, and never give up a channel's work (hq ADR 0259 revision)
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
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 delivered
mesh/delivery-group group feat/asks-answered-on-any-channel stopped: a member was stopped
mesh/merge-gate pass: builds mesh-tools, node-tools → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of …
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 delivered
mesh/delivery-group group feat/asks-answered-on-any-channel stopped: a member was stopped
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0259 §8: a runtime on a module's own account serves that module alone, and the machine's
|
||||
// runtime never serves itself.
|
||||
func TestARuntimeOnAModulesOwnAccountServesThatModuleAlone(t *testing.T) {
|
||||
if got, err := ServedModulesFrom("telegram=/b/telegram/tools/telegram", "telegram"); err != nil || len(got) != 1 {
|
||||
t.Fatalf("a module's own runtime could not serve it: %v", err)
|
||||
}
|
||||
if _, err := ServedModulesFrom("telegram=/b/t,dunst=/b/d", "telegram"); err == nil || !strings.Contains(err.Error(), "serves telegram alone") {
|
||||
t.Errorf("a module's own runtime served another: %v", err)
|
||||
}
|
||||
if _, err := ServedModulesFrom("node-tools=/b/n", "node-tools"); err == nil {
|
||||
t.Error("the machine's runtime served itself")
|
||||
}
|
||||
}
|
||||
|
||||
// No bus word reaches a bundle (security review of 2026-10-08), not even one its module's words name.
|
||||
func TestNoBusWordReachesABundle(t *testing.T) {
|
||||
base := []string{"MESH_BROKER_FILE=/etc/mesh/broker", "MESH_BROKER_URL=nats://x", "MESH_TOOL_ENV={}",
|
||||
"MESH_TOOL_MODULES=a=/b", "MESH_CONSOLE_LISTEN=127.0.0.1:1", "PATH=/usr/bin"}
|
||||
env := strings.Join(BundleEnv(base, map[string]string{"MESH_BROKER_FILE": "/again", "OWN": "1"}, "telegram", "anchor"), "\n")
|
||||
for _, never := range []string{"MESH_BROKER_FILE", "MESH_BROKER_URL", "MESH_TOOL_ENV", "MESH_TOOL_MODULES", "MESH_CONSOLE_LISTEN"} {
|
||||
if strings.Contains(env, never+"=") {
|
||||
t.Errorf("%s reached the bundle", never)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"PATH=/usr/bin", "OWN=1", "MESH_MODULE=telegram", "MESH_NODE=anchor"} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Errorf("%s did not reach the bundle", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tool call reaches only a tool's subject: whatever key a caller names, it is never a seat's event, accept
|
||||
// or proof — so no tool call says a choice, a link or a code, or submits an ask, in anybody's name.
|
||||
func TestAToolCallNeverReachesASeatsEventAcceptOrProof(t *testing.T) {
|
||||
for _, key := range []string{"seat:intake.event.choice.telegram", "seat:intake.proof.code.telegram",
|
||||
"seat:operator-channel.accept.ask.mesh-delivery", "intake.event", "seat:operator-channel.event.decided.x@anchor",
|
||||
"telegram.anything", "x"} {
|
||||
subject, err := bus.ToolSubject(key, "node-tools")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tokens := strings.Split(subject, ".")
|
||||
if len(tokens) < 4 || tokens[3] != "tool" {
|
||||
t.Errorf("%q reaches %s, which is not a tool's subject", key, subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,13 @@ type ListedTool struct {
|
||||
Subjects []string `json:"subjects,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRuntimeModule is the module that is a machine's runtime, serving every module on it.
|
||||
const NodeRuntimeModule = "node-tools"
|
||||
|
||||
// runtimeOnly are the words of the runtime's own environment no bundle is given.
|
||||
var runtimeOnly = map[string]bool{ToolEnv: true, ToolModules: true, "MESH_BROKER_FILE": true,
|
||||
"MESH_BROKER_URL": true, "MESH_CONSOLE_LISTEN": true}
|
||||
|
||||
// ServedModulesFrom reads MESH_TOOL_MODULES: `<module>=<entrypoint>` entries, comma-separated, several
|
||||
// per module. The one-module form — a bare path, or the runtime's own module — is the per-module
|
||||
// containers' (to-be 38 WP4c) and refused here: the node's runtime imports nothing.
|
||||
@@ -66,9 +73,20 @@ func ServedModulesFrom(spec, own string) ([]Served, error) {
|
||||
}
|
||||
module, path, ok := strings.Cut(entry, "=")
|
||||
module, path = strings.TrimSpace(module), strings.TrimSpace(path)
|
||||
if !ok || module == "" || path == "" || module == own {
|
||||
return nil, fmt.Errorf("%s: %q is not <module>=<entrypoint> of another module; the node's "+
|
||||
"runtime launches the bundles it is given and imports nothing (novox/hq ADR 0193)", ToolModules, entry)
|
||||
if !ok || module == "" || path == "" {
|
||||
return nil, fmt.Errorf("%s: %q is not <module>=<entrypoint>; the runtime launches the bundles it "+
|
||||
"is given and imports nothing (novox/hq ADR 0193)", ToolModules, entry)
|
||||
}
|
||||
// The node's runtime serves the machine's modules and never itself. **A runtime on a module's own
|
||||
// account serves that module and nothing else** (novox/hq ADR 0259 §8): the router and a channel that
|
||||
// proves its sender reach the bus on an account of their own, never the machine's runtime.
|
||||
switch {
|
||||
case own == NodeRuntimeModule && module == own:
|
||||
return nil, fmt.Errorf("%s: %q is the runtime itself; it launches the bundles it is given and "+
|
||||
"imports nothing (novox/hq ADR 0193)", ToolModules, entry)
|
||||
case own != NodeRuntimeModule && module != own:
|
||||
return nil, fmt.Errorf("%s: %q is another module's; a runtime on %s's own account serves %s alone "+
|
||||
"(novox/hq ADR 0259)", ToolModules, entry, own, own)
|
||||
}
|
||||
if _, seen := by[module]; !seen {
|
||||
order = append(order, module)
|
||||
@@ -135,28 +153,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log
|
||||
node := conn.Node()
|
||||
|
||||
base := os.Environ()
|
||||
envFor := func(module string) []string {
|
||||
words := map[string]string{}
|
||||
for _, kv := range base {
|
||||
if k, v, ok := strings.Cut(kv, "="); ok && k != ToolEnv {
|
||||
words[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range envs[module] {
|
||||
words[k] = v
|
||||
}
|
||||
words["MESH_SERVED_MODULE"] = module
|
||||
words["MESH_MODULE"] = module
|
||||
if node != "" {
|
||||
words["MESH_NODE"] = node
|
||||
}
|
||||
out := make([]string, 0, len(words))
|
||||
for k, v := range words {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
envFor := func(module string) []string { return BundleEnv(base, envs[module], module, node) }
|
||||
|
||||
// The module's events, for every child of it that subscribes (ADR 0198): one consumer per module,
|
||||
// bound the first time any of its children subscribes, each event handed to every child that did.
|
||||
@@ -710,11 +707,11 @@ func (b *moduleBus) Seat(verb string, params json.RawMessage, handOn func(string
|
||||
conn := b.all.conn
|
||||
switch verb {
|
||||
case "publish":
|
||||
seq, err := conn.SeatPublish(b.module, asked.Subject, asked.Body, asked.ID)
|
||||
seq, duplicate, err := conn.SeatPublishSaid(b.module, asked.Subject, asked.Body, asked.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"sequence": seq})
|
||||
return json.Marshal(map[string]any{"sequence": seq, "duplicate": duplicate})
|
||||
case "prove":
|
||||
return conn.SeatProve(b.module, asked.Subject, asked.Body)
|
||||
case "kinds":
|
||||
@@ -779,3 +776,32 @@ func (b *moduleBus) bind(key string, stop func()) {
|
||||
}
|
||||
b.all.seatBound[b.module+" "+key] = stop
|
||||
}
|
||||
|
||||
// BundleEnv is what one module's bundle is started with: the runtime's environment without its own words,
|
||||
// the module's composed words over it, and the module's and machine's names. **No bus word reaches a
|
||||
// bundle** (security review of 2026-10-08): the credential and the bus's address are the runtime's, and a
|
||||
// bundle reaches the bus only through the runtime.
|
||||
func BundleEnv(base []string, given map[string]string, module, node string) []string {
|
||||
words := map[string]string{}
|
||||
for _, kv := range base {
|
||||
if k, v, ok := strings.Cut(kv, "="); ok && !runtimeOnly[k] {
|
||||
words[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range given {
|
||||
if !runtimeOnly[k] {
|
||||
words[k] = v
|
||||
}
|
||||
}
|
||||
words["MESH_SERVED_MODULE"] = module
|
||||
words["MESH_MODULE"] = module
|
||||
if node != "" {
|
||||
words["MESH_NODE"] = node
|
||||
}
|
||||
out := make([]string, 0, len(words))
|
||||
for k, v := range words {
|
||||
out = append(out, k+"="+v)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
||||
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
||||
)
|
||||
|
||||
// A bundle's seat traffic through moduleBus.Seat (novox/hq ADR 0259 §3), against a real bus: publish under
|
||||
// its own name with the id prefixed and a duplicate said; the record read without naming the bucket; a
|
||||
// worker and a proof subject bound once however often a restarted child asks; and nothing beyond what the
|
||||
// membership lists.
|
||||
func TestABundlesSeatTrafficGoesThroughItsModulesBus(t *testing.T) {
|
||||
mesh := mt.New(t)
|
||||
nc, err := nats.Connect(mt.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, _ := nc.JetStream()
|
||||
if _, err := js.AddStream(&nats.StreamConfig{Name: "SEAT_OPERATOR_CHANNEL", Retention: nats.WorkQueuePolicy,
|
||||
Subjects: []string{"mesh.seat.operator-channel.accept.>"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.AddConsumer("SEAT_OPERATOR_CHANNEL", &nats.ConsumerConfig{Durable: "SEAT_OPERATOR_CHANNEL_worker",
|
||||
AckPolicy: nats.AckExplicitPolicy, FilterSubject: "mesh.seat.operator-channel.accept.>"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kv, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "messenger_asks"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = kv.Put("mesh-delivery.d-1", []byte(`{"state":"open"}`))
|
||||
asker := mt.MembershipOf("mesh-delivery", "anchor", false, nil)
|
||||
asker.SeatTraffic = &bus.SeatTraffic{Publish: []string{"mesh.seat.operator-channel.accept.ask.mesh-delivery"},
|
||||
Records: []string{"$JS.API.DIRECT.GET.KV_messenger_asks.$KV.messenger_asks.mesh-delivery.>"}}
|
||||
router := mt.MembershipOf("messenger", "anchor", false, nil)
|
||||
router.SeatTraffic = &bus.SeatTraffic{Answers: []string{"mesh.seat.intake.proof.code.*"},
|
||||
Workers: []bus.SeatWorker{{Stream: "SEAT_OPERATOR_CHANNEL", Consumer: "SEAT_OPERATOR_CHANNEL_worker",
|
||||
Filter: "mesh.seat.operator-channel.accept.>"}}}
|
||||
mesh.Issue(t, asker)
|
||||
mesh.Issue(t, router)
|
||||
conn := connect(t, "node-tools", "anchor")
|
||||
conn.Follow("mesh-delivery")
|
||||
conn.Follow("messenger")
|
||||
mt.Until(t, func() error {
|
||||
if conn.Membership("mesh-delivery") == nil || conn.Membership("messenger") == nil {
|
||||
return errors.New("not issued yet")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
all := &consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}}
|
||||
asking := all.forModule("mesh-delivery").(*moduleBus)
|
||||
routing := all.forModule("messenger").(*moduleBus)
|
||||
|
||||
publish := json.RawMessage(`{"subject":"mesh.seat.operator-channel.accept.ask.mesh-delivery","body":{"id":"d-1"},"id":"d-1"}`)
|
||||
first, err := asking.Seat("publish", publish, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _ := asking.Seat("publish", publish, nil)
|
||||
if !strings.Contains(string(first), `"duplicate":false`) || !strings.Contains(string(again), `"duplicate":true`) {
|
||||
t.Errorf("the same id twice: %s, then %s", first, again)
|
||||
}
|
||||
if _, err := asking.Seat("publish", json.RawMessage(`{"subject":"mesh.seat.operator-channel.accept.ask.mesh-controller","body":{}}`), nil); err == nil {
|
||||
t.Error("a module submitted under another's name")
|
||||
}
|
||||
got, err := asking.Seat("record", json.RawMessage(`{"key":"d-1"}`), nil)
|
||||
if err != nil || !strings.Contains(string(got), `"state":"open"`) {
|
||||
t.Errorf("the record read without its bucket: %s %v", got, err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var taken []string
|
||||
handOn := func(method string, params any) (json.RawMessage, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
raw, _ := json.Marshal(params)
|
||||
taken = append(taken, method+" "+string(raw))
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
for i := 0; i < 2; i++ { // a child that started again asks again
|
||||
if _, err := routing.Seat("take", json.RawMessage(`{"worker":"SEAT_OPERATOR_CHANNEL_worker"}`), handOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
mu.Lock()
|
||||
n := len(taken)
|
||||
mu.Unlock()
|
||||
if n >= 1 || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
mu.Lock()
|
||||
if len(taken) != 1 || !strings.Contains(taken[0], "mesh/work") || !strings.Contains(taken[0], `"x-event-id":"mesh-delivery.d-1"`) {
|
||||
t.Errorf("taken %v", taken)
|
||||
}
|
||||
mu.Unlock()
|
||||
if _, err := asking.Seat("take", json.RawMessage(`{"worker":"SEAT_OPERATOR_CHANNEL_worker"}`), handOn); err == nil {
|
||||
t.Error("an asker took the router's work")
|
||||
}
|
||||
if _, err := asking.Seat("answer", json.RawMessage(`{"subject":"mesh.seat.intake.proof.code.*"}`), handOn); err == nil {
|
||||
t.Error("an asker answered proofs")
|
||||
}
|
||||
if _, err := asking.Seat("anything", json.RawMessage(`{}`), nil); err == nil {
|
||||
t.Error("an unknown verb was answered")
|
||||
}
|
||||
}
|
||||
|
||||
// A lost compare-and-set is answered with its code, so the SDK knows it without reading words.
|
||||
func TestALostCompareAndSetCarriesItsCode(t *testing.T) {
|
||||
mesh := mt.New(t)
|
||||
mesh.Bucket(t, "messenger_asks")
|
||||
m := mt.MembershipOf("messenger", "anchor", false, nil)
|
||||
m.State = []bus.StateIssued{{Name: "asks", Bucket: "messenger_asks", Writes: true}}
|
||||
mesh.Issue(t, m)
|
||||
conn := connect(t, "node-tools", "anchor")
|
||||
conn.Follow("messenger")
|
||||
mt.Until(t, func() error {
|
||||
if conn.Membership("messenger") == nil {
|
||||
return errors.New("not issued yet")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
b := (&consumers{conn: conn, logf: t.Logf, of: map[string]*moduleEvents{}}).forModule("messenger")
|
||||
if _, err := b.State("create", json.RawMessage(`{"state":"asks","key":"a","value":{}}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := b.State("create", json.RawMessage(`{"state":"asks","key":"a","value":{}}`))
|
||||
var coded interface{ ErrorCode() int }
|
||||
if !errors.As(err, &coded) || coded.ErrorCode() != -32010 || !errors.Is(err, bus.ErrStateChanged) {
|
||||
t.Errorf("a second create: %v", err)
|
||||
}
|
||||
if _, err := b.State("update", json.RawMessage(`{"state":"asks","key":"a","value":{},"revision":0}`)); err == nil {
|
||||
t.Error("an update of revision zero was taken")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user