diff --git a/merge-check.sh b/merge-check.sh index d04b8a5..fa55750 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -33,6 +33,9 @@ if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; t # What of the console launches no bundle runs anyway: how an address resolves (novox/hq issue 287), # and how a search ranks (novox/hq ADR 0245). bundleless='^(TestASeatAndAModuleOfOneNameAreEachReached|TestSearch.*|TestASearchByCommandSaysWhatItReplaces)$' + # And what of the runtime launches no bundle: how a bundle's seat traffic reaches the bus, what its + # environment holds, and what a runtime on a module's own account serves (novox/hq ADR 0259). + runtimeless='^(TestARuntimeOnAModulesOwnAccountServesThatModuleAlone|TestNoBusWordReachesABundle|TestAToolCallNeverReachesASeatsEventAcceptOrProof|TestABundlesSeatTrafficGoesThroughItsModulesBus|TestALostCompareAndSetCarriesItsCode)$' fi if command -v gcc >/dev/null 2>&1; then CGO_ENABLED=1 go test -race -count=1 $packages @@ -43,4 +46,7 @@ fi if [ -n "$bundleless" ]; then CGO_ENABLED=0 go test -count=1 -run "$bundleless" ./internal/console fi +if [ -n "${runtimeless:-}" ]; then + CGO_ENABLED=0 go test -count=1 -run "$runtimeless" ./internal/runtime +fi echo "NOT TESTED HERE: node-tools/src (TypeScript) needs @novox/mesh-sdk from the package registry" diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index dd39bcd..5e0a0a8 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -64,6 +64,9 @@ type Membership struct { Tools string `json:"tools"` // State is every bucket the module's code may reach, by the name it uses (novox/hq ADR 0201). State []StateIssued `json:"state,omitempty"` + // SeatTraffic is what the module's code may submit, say, take, ask, answer and read on seats that name + // their caller or their kind (novox/hq ADR 0259): the runtime does for it only what is listed here. + SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"` } // Served is an address a tool is answered on; `{tool}` stands for the tool's name. diff --git a/node-tools/internal/bus/seat.go b/node-tools/internal/bus/seat.go new file mode 100644 index 0000000..dc5713d --- /dev/null +++ b/node-tools/internal/bus/seat.go @@ -0,0 +1,439 @@ +package bus + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// A bundle's traffic on seats that name their caller or their kind (novox/hq ADR 0259 §3): submitting an +// accept and saying an event under its own name or kind, taking a held seat's work from its worker, asking a +// proof and answering one, and reading a record kept for it. +// +// **The runtime keeps each module to its own name and kind.** One account per machine carries every module +// on it, so the bus enforces only the union; what one module may reach is its membership's `seat-traffic`, +// and anything else is refused here, with the reason, before it is sent. A bundle reaches this only through +// its own standard input and output: no tool call does. + +// SeatTraffic is what a membership lists of a module's seat traffic (the controller's broker.SeatTraffic). +type SeatTraffic struct { + Publish []string `json:"publish,omitempty"` + Subscribe []string `json:"subscribe,omitempty"` + Answers []string `json:"answers,omitempty"` + Workers []SeatWorker `json:"workers,omitempty"` + Records []string `json:"records,omitempty"` + // Kinds are the holders of every kinded bench the module uses or watches, with what each promises: the + // controller's record of their claims (novox/hq ADR 0259 §5). + Kinds []KindHeld `json:"kinds,omitempty"` +} + +// KindHeld is one kind of a kinded bench and who holds it. +type KindHeld struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Module string `json:"module"` + Node string `json:"node"` + Capabilities []string `json:"capabilities,omitempty"` +} + +// SeatKinds is the kinds the module's membership names, as the controller issued them. +func (c *Conn) SeatKinds(module string) ([]KindHeld, error) { + t, err := c.trafficOf(module) + if err != nil { + return nil, err + } + return append([]KindHeld{}, t.Kinds...), nil +} + +// SeatWorker is one work queue a holder takes from. +type SeatWorker struct { + Stream string `json:"Stream"` + Consumer string `json:"Consumer"` + Filter string `json:"Filter"` +} + +// ProofTimeout bounds how long a proof waits for its answer. +var ProofTimeout = 15 * time.Second + +// WorkHeartbeat is how often work a bundle is still doing is said to be in progress, well inside the +// worker's ack wait. +var WorkHeartbeat = 15 * time.Second + +// WorkBackoff is how long a piece of work the bundle did not take waits before it is offered again: from +// five seconds, doubling, to ten minutes. +func WorkBackoff(delivered uint64) time.Duration { + wait := 5 * time.Second + for i := uint64(1); i < delivered && wait < 10*time.Minute; i++ { + wait *= 2 + } + return min(wait, 10*time.Minute) +} + +// SubjectMatches is a subject against a permission pattern, in the server's wildcards. +func SubjectMatches(pattern, subject string) bool { + p, s := strings.Split(pattern, "."), strings.Split(subject, ".") + for i, part := range p { + if part == ">" { + return len(s) > i + } + if i >= len(s) || (part != "*" && part != s[i]) { + return false + } + } + return len(p) == len(s) +} + +func (c *Conn) trafficOf(module string) (*SeatTraffic, error) { + m := c.Membership(module) + if m == nil { + return nil, fmt.Errorf("%s has no membership issued on %s yet, so it reaches no seat (novox/hq ADR 0259)", + module, c.node) + } + if m.SeatTraffic == nil { + return nil, fmt.Errorf("%s holds, uses and watches no seat that names its caller or its kind (novox/hq "+ + "ADR 0259): its manifest says so under `uses`, `claims` and `consumes`", module) + } + return m.SeatTraffic, nil +} + +func listed(patterns []string, subject string) bool { + for _, p := range patterns { + if SubjectMatches(p, subject) { + return true + } + } + return false +} + +// mayPublish refuses a subject the module's seat traffic does not list. A wildcard is never published. +func (c *Conn) mayPublish(module, subject string) error { + t, err := c.trafficOf(module) + if err != nil { + return err + } + if strings.ContainsAny(subject, "*>") || !listed(t.Publish, subject) { + return fmt.Errorf("%s may not publish %s: a module submits and says only under its own name or kind, "+ + "and its membership lists %s (novox/hq ADR 0259)", module, subject, strings.Join(t.Publish, ", ")) + } + return nil +} + +func newID() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +// SeatPublish submits an accept or says an event on a seat, as the module, into the stream that keeps it, +// awaited and de-duplicated by its id: the same id twice is one message. A proof is never published here. +func (c *Conn) SeatPublish(module, subject string, body json.RawMessage, id string) (uint64, error) { + seq, _, err := c.SeatPublishSaid(module, subject, body, id) + return seq, err +} + +// SeatPublishSaid is SeatPublish, also saying whether the bus took it as a duplicate of one published before +// under the same id: the same message, kept once. +func (c *Conn) SeatPublishSaid(module, subject string, body json.RawMessage, id string) (uint64, bool, error) { + if strings.Contains(subject, ".proof.") { + return 0, false, fmt.Errorf("%s is a proof, asked and answered, never kept: ask it as one", subject) + } + if err := c.mayPublish(module, subject); err != nil { + return 0, false, err + } + if len(body) == 0 || !json.Valid(body) { + return 0, false, fmt.Errorf("what is said on %s is JSON", subject) + } + if id == "" { + id = newID() + } + // **The publisher's name is part of the id the bus de-duplicates by** (security review 2026-10-08): ids + // are the publisher's own, and one module could otherwise suppress another's warrant by publishing first + // under the same id. + id = module + "." + id + msg := nats.NewMsg(subject) + msg.Header.Set("x-event-id", id) + msg.Header.Set("x-source", module) + msg.Header.Set("x-node", c.node) + msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano)) + msg.Header.Set("content-type", "application/json") + msg.Data = body + ack, err := c.js.PublishMsg(msg, nats.MsgId(id)) + if err != nil { + return 0, false, err + } + return ack.Sequence, ack.Duplicate, nil +} + +// SeatProve asks a proof and answers its reply: core request and reply, on no stream. +func (c *Conn) SeatProve(module, subject string, body json.RawMessage) (json.RawMessage, error) { + if !strings.Contains(subject, ".proof.") { + return nil, fmt.Errorf("%s is not a proof", subject) + } + if err := c.mayPublish(module, subject); err != nil { + return nil, err + } + reply, err := c.nc.Request(subject, body, ProofTimeout) + if errors.Is(err, nats.ErrNoResponders) { + return nil, fmt.Errorf("nothing answers %s: the module watching the seat is not running", subject) + } + if err != nil { + return nil, err + } + if !json.Valid(reply.Data) { + return nil, fmt.Errorf("the answer to %s is not JSON", subject) + } + return json.RawMessage(reply.Data), nil +} + +// SeatAnswer answers every proof on a subject the module's seat traffic lists among its answers, with what +// answer returns. A returned error is answered as `{"error": ""}`, never as silence. +func (c *Conn) SeatAnswer(module, subject string, answer func(subject string, body json.RawMessage) (json.RawMessage, error)) (func(), error) { + t, err := c.trafficOf(module) + if err != nil { + return nil, err + } + allowed := false + for _, a := range t.Answers { + allowed = allowed || a == subject + } + if !allowed { + return nil, fmt.Errorf("%s does not answer %s: its membership lists %s (novox/hq ADR 0259)", module, + subject, strings.Join(t.Answers, ", ")) + } + sub, err := c.nc.Subscribe(subject, func(msg *nats.Msg) { + if msg.Reply == "" { + return + } + body := json.RawMessage(msg.Data) + if !json.Valid(body) { + _ = msg.Respond([]byte(`{"error":"a proof is JSON"}`)) + return + } + out, err := answer(msg.Subject, body) + if err != nil { + b, _ := json.Marshal(map[string]string{"error": err.Error()}) + _ = msg.Respond(b) + return + } + if len(out) == 0 { + out = json.RawMessage(`{}`) + } + if err := msg.Respond(out); err != nil { + c.Logf("[mesh-tools] %s could not answer the proof on %s: %v", module, msg.Subject, err) + } + }) + if err != nil { + return nil, err + } + c.track(sub) + var once sync.Once + return func() { once.Do(func() { _ = sub.Unsubscribe() }) }, nil +} + +// Work is one message a holder took from a seat's work queue. +type Work struct { + Worker string `json:"worker"` + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + Headers map[string]string `json:"headers,omitempty"` +} + +// SeatTake reads a worker the module's seat traffic lists and hands each message to deliver: acknowledged +// when deliver returns nil, offered again after NakDelay when it returns an error, ended when it is not +// JSON. The worker is the controller's to create; this binds to it by name and never makes one. +func (c *Conn) SeatTake(module, consumer string, deliver func(Work) error) (func(), error) { + t, err := c.trafficOf(module) + if err != nil { + return nil, err + } + var w *SeatWorker + for i := range t.Workers { + if t.Workers[i].Consumer == consumer { + w = &t.Workers[i] + } + } + if w == nil { + var names []string + for _, x := range t.Workers { + names = append(names, x.Consumer) + } + return nil, fmt.Errorf("%s takes no work from %s: its membership lists %s (novox/hq ADR 0259)", module, + consumer, strings.Join(names, ", ")) + } + js, err := jetstream.New(c.nc) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + bound, err := js.Consumer(ctx, w.Stream, w.Consumer) + if err != nil { + return nil, fmt.Errorf("binding %s's worker %s on %s: %w", module, w.Consumer, w.Stream, err) + } + reading, err := bound.Consume(func(msg jetstream.Msg) { + if !json.Valid(msg.Data()) { + _ = msg.Term() + return + } + headers := map[string]string{} + for k := range msg.Headers() { + headers[k] = msg.Headers().Get(k) + } + // Kept alive while the bundle works on it, however long that takes: the ack wait is for a holder + // that died, not one that is slow. + done := make(chan struct{}) + go func() { + tick := time.NewTicker(WorkHeartbeat) + defer tick.Stop() + for { + select { + case <-done: + return + case <-tick.C: + _ = msg.InProgress() + } + } + }() + err := deliver(Work{Worker: w.Consumer, Subject: msg.Subject(), Body: json.RawMessage(msg.Data()), Headers: headers}) + close(done) + if err != nil { + // Offered again later and later, never given up on: a channel that is away keeps its work + // (security and correctness reviews of 2026-10-08). + delivered := uint64(1) + if meta, merr := msg.Metadata(); merr == nil { + delivered = meta.NumDelivered + } + _ = msg.NakWithDelay(WorkBackoff(delivered)) + return + } + _ = msg.Ack() + }, jetstream.ConsumeErrHandler(func(_ jetstream.ConsumeContext, err error) { + // A worker deleted, or the bus refusing it, is said — never a holder that silently takes nothing. + c.Logf("[mesh-tools] %s's worker %s: %v", module, w.Consumer, err) + })) + if err != nil { + return nil, fmt.Errorf("reading %s's worker %s: %w", module, w.Consumer, err) + } + var once sync.Once + return func() { once.Do(reading.Stop) }, nil +} + +// SeatRecord reads one key of a record a seat keeps for the module, under its own name: the bucket's +// current value of `.`, or nil when there is none. +func (c *Conn) SeatRecord(module, bucket, key string) (*StateEntry, error) { + t, err := c.trafficOf(module) + if err != nil { + return nil, err + } + if err := checkKey(key); err != nil { + return nil, err + } + if bucket == "" { + // The one record its membership lists, when it lists one: an asker reads the router's asks without + // knowing the router's name. + var named []string + for _, r := range t.Records { + if b, _, ok := strings.Cut(strings.TrimPrefix(r, "$JS.API.DIRECT.GET.KV_"), "."); ok { + named = append(named, b) + } + } + if len(named) != 1 { + return nil, fmt.Errorf("%s reads %d records under its name; name the one meant", module, len(named)) + } + bucket = named[0] + } + subject := "$JS.API.DIRECT.GET.KV_" + bucket + ".$KV." + bucket + "." + module + "." + key + if !listed(t.Records, subject) { + return nil, fmt.Errorf("%s reads no record of %s under its name: its membership lists %s (novox/hq ADR 0259)", + module, bucket, strings.Join(t.Records, ", ")) + } + reply, err := c.nc.Request(subject, nil, StateTimeout) + if err != nil { + return nil, err + } + if status := reply.Header.Get("Status"); status == "404" { + return nil, nil + } else if status != "" { + return nil, fmt.Errorf("the record %s.%s could not be read: %s %s", module, key, status, reply.Header.Get("Description")) + } + if op := reply.Header.Get("KV-Operation"); op == "DEL" || op == "PURGE" { + return nil, nil + } + var revision uint64 + fmt.Sscan(reply.Header.Get("Nats-Sequence"), &revision) + return &StateEntry{Key: module + "." + key, Value: valueOf(reply.Data), Revision: revision}, nil +} + +// StateCreate writes one key only when it has no value, and answers its revision; a key that has one is +// refused, naming it, so two writers racing to make it are one winner and one refusal. +func (c *Conn) StateCreate(module, name, key string, value json.RawMessage) (uint64, error) { + return c.stateWrite(module, name, key, value, func(ctx context.Context, kv jetstream.KeyValue) (uint64, error) { + return kv.Create(ctx, key, value) + }) +} + +// StateUpdate writes one key only when its revision is still the one given, and answers the new revision; +// a key changed meanwhile is refused, so of two writers that read one value only the first writes. +func (c *Conn) StateUpdate(module, name, key string, value json.RawMessage, revision uint64) (uint64, error) { + if revision == 0 { + // Revision zero would be read by the server as "the key is new": an update names the value it read. + return 0, fmt.Errorf("an update names the revision it read; zero is none — create the key instead") + } + return c.stateWrite(module, name, key, value, func(ctx context.Context, kv jetstream.KeyValue) (uint64, error) { + return kv.Update(ctx, key, value, revision) + }) +} + +// ErrStateChanged is a compare-and-set that lost: the key was made or changed by another writer first. +var ErrStateChanged = errors.New("the key was written by another writer first") + +// changed is ErrStateChanged naming the key, with the code a bundle is answered it by. +type changed struct{ key string } + +func (e changed) Error() string { return e.key + ": " + ErrStateChanged.Error() } +func (e changed) Is(target error) bool { return target == ErrStateChanged } +func (e changed) ErrorCode() int { return -32010 } + +func (c *Conn) stateWrite(module, name, key string, value json.RawMessage, + write func(context.Context, jetstream.KeyValue) (uint64, error)) (uint64, error) { + s, err := c.writable(module, name) + if err != nil { + return 0, err + } + if err := checkKey(key); err != nil { + return 0, err + } + if len(value) == 0 || !json.Valid(value) { + return 0, fmt.Errorf("a state value is JSON") + } + if field := credentialField(value); field != "" { + return 0, fmt.Errorf("%s's %s.%s carries a field %q, which names a credential: no secret is kept in state "+ + "(novox/hq ADR 0201)", module, name, key, field) + } + ctx, cancel := context.WithTimeout(context.Background(), StateTimeout) + defer cancel() + kv, err := c.bucket(ctx, s) + if err != nil { + return 0, err + } + rev, err := write(ctx, kv) + if errors.Is(err, jetstream.ErrKeyExists) || isWrongSequence(err) { + return 0, changed{name + "." + key} + } + return rev, err +} + +// isWrongSequence is the server's refusal of an update whose revision is not the key's last. +func isWrongSequence(err error) bool { + var api *jetstream.APIError + return errors.As(err, &api) && api.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence +} diff --git a/node-tools/internal/bus/seat_test.go b/node-tools/internal/bus/seat_test.go new file mode 100644 index 0000000..6da91d3 --- /dev/null +++ b/node-tools/internal/bus/seat_test.go @@ -0,0 +1,239 @@ +package bus_test + +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" +) + +// The seats of novox/hq ADR 0259 §3 as the controller issues them: an asker, the router, and a channel of +// kind telegram, each with the seat traffic its membership lists. +func seatMesh(t *testing.T) (*mt.Mesh, *bus.Conn, nats.JetStreamContext) { + t.Helper() + mesh := mt.New(t) + nc, err := nats.Connect(mt.URL(t)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(nc.Close) + js, _ := nc.JetStream() + for _, s := range []*nats.StreamConfig{ + {Name: "SEAT_OPERATOR_CHANNEL", Subjects: []string{"mesh.seat.operator-channel.accept.>"}, Retention: nats.WorkQueuePolicy}, + {Name: "SEAT_CHANNEL", Subjects: []string{"mesh.seat.channel.accept.>"}, Retention: nats.WorkQueuePolicy}, + {Name: "SEAT_EVENTS", Subjects: []string{"mesh.seat.*.event.>"}}, + } { + if _, err := js.AddStream(s); err != nil { + t.Fatal(err) + } + } + for stream, c := range map[string]*nats.ConsumerConfig{ + "SEAT_OPERATOR_CHANNEL": {Durable: "SEAT_OPERATOR_CHANNEL_worker", AckPolicy: nats.AckExplicitPolicy, + FilterSubject: "mesh.seat.operator-channel.accept.>", AckWait: 2 * time.Second}, + "SEAT_CHANNEL": {Durable: "SEAT_CHANNEL_TELEGRAM_worker", AckPolicy: nats.AckExplicitPolicy, + FilterSubject: "mesh.seat.channel.accept.*.telegram", AckWait: 2 * time.Second}, + } { + if _, err := js.AddConsumer(stream, c); err != nil { + t.Fatal(err) + } + } + if _, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: "messenger_asks"}); err != nil { + t.Fatal(err) + } + + asker := mt.MembershipOf("mesh-delivery", "anchor", false, nil) + asker.SeatTraffic = &bus.SeatTraffic{ + Publish: []string{"mesh.seat.operator-channel.accept.ask.mesh-delivery"}, + Subscribe: []string{"mesh.seat.operator-channel.event.decided.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{ + Publish: []string{"mesh.seat.operator-channel.event.decided.*", "mesh.seat.channel.accept.show.*"}, + 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.>"}}, + } + router.State = []bus.StateIssued{{Name: "asks", Bucket: "messenger_asks", Writes: true}} + telegram := mt.MembershipOf("telegram", "anchor", false, nil) + telegram.SeatTraffic = &bus.SeatTraffic{ + Publish: []string{"mesh.seat.intake.event.choice.telegram", "mesh.seat.intake.proof.code.telegram"}, + Workers: []bus.SeatWorker{{Stream: "SEAT_CHANNEL", Consumer: "SEAT_CHANNEL_TELEGRAM_worker", + Filter: "mesh.seat.channel.accept.*.telegram"}}, + } + for _, m := range []bus.Membership{asker, router, telegram} { + mesh.Issue(t, m) + } + conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"}) + if err != nil { + t.Fatal(err) + } + conn.Logf = func(string, ...any) {} + t.Cleanup(conn.Close) + for _, m := range []string{"mesh-delivery", "messenger", "telegram"} { + conn.Follow(m) + } + mt.Until(t, func() error { + for _, m := range []string{"mesh-delivery", "messenger", "telegram"} { + if conn.Membership(m) == nil { + return errors.New(m + " not issued yet") + } + } + return nil + }) + return mesh, conn, js +} + +func TestAModuleSubmitsAndSaysOnlyUnderItsOwnNameAndKind(t *testing.T) { + _, conn, _ := seatMesh(t) + body := json.RawMessage(`{"id":"a1"}`) + if _, err := conn.SeatPublish("mesh-delivery", "mesh.seat.operator-channel.accept.ask.mesh-delivery", body, "a1"); err != nil { + t.Fatalf("an asker could not ask under its own name: %v", err) + } + for module, subject := range map[string]string{ + "mesh-delivery": "mesh.seat.operator-channel.accept.ask.mesh-controller", + "telegram": "mesh.seat.intake.event.choice.desktop", + "messenger": "mesh.seat.intake.event.choice.telegram", + } { + if _, err := conn.SeatPublish(module, subject, body, ""); err == nil || !strings.Contains(err.Error(), "may not publish") { + t.Errorf("%s published %s: %v", module, subject, err) + } + } + if _, err := conn.SeatPublish("messenger", "mesh.seat.operator-channel.event.decided.*", body, ""); err == nil { + t.Error("a wildcard was published") + } + if _, err := conn.SeatPublish("telegram", "mesh.seat.intake.proof.code.telegram", body, ""); err == nil || + !strings.Contains(err.Error(), "never kept") { + t.Errorf("a proof was published onto a stream: %v", err) + } +} + +func TestTheHolderTakesItsWorkOnceAndAFailureComesBack(t *testing.T) { + _, conn, _ := seatMesh(t) + if _, err := conn.SeatPublish("mesh-delivery", "mesh.seat.operator-channel.accept.ask.mesh-delivery", + json.RawMessage(`{"id":"a1"}`), "a1"); err != nil { + t.Fatal(err) + } + // The same id again is one ask: a retry after a lost answer does not ask twice. + if _, err := conn.SeatPublish("mesh-delivery", "mesh.seat.operator-channel.accept.ask.mesh-delivery", + json.RawMessage(`{"id":"a1"}`), "a1"); err != nil { + t.Fatal(err) + } + var mu sync.Mutex + var got []string + fail := true + stop, err := conn.SeatTake("messenger", "SEAT_OPERATOR_CHANNEL_worker", func(w bus.Work) error { + mu.Lock() + defer mu.Unlock() + got = append(got, w.Subject+" from "+w.Headers["x-source"]) + if fail { + fail = false + return errors.New("not recorded") + } + return nil + }) + if err != nil { + t.Fatal(err) + } + defer stop() + deadline := time.Now().Add(15 * time.Second) + for { + mu.Lock() + n := len(got) + mu.Unlock() + if n >= 2 || time.Now().After(deadline) { + break + } + time.Sleep(50 * time.Millisecond) + } + time.Sleep(500 * time.Millisecond) + mu.Lock() + defer mu.Unlock() + if len(got) != 2 || got[0] != "mesh.seat.operator-channel.accept.ask.mesh-delivery from mesh-delivery" || got[0] != got[1] { + t.Fatalf("want the one ask, offered again after the failure: %v", got) + } + if _, err := conn.SeatTake("telegram", "SEAT_OPERATOR_CHANNEL_worker", func(bus.Work) error { return nil }); err == nil { + t.Error("a channel took the router's work") + } +} + +func TestAProofIsAskedAndAnsweredAndNeverKept(t *testing.T) { + _, conn, js := seatMesh(t) + stop, err := conn.SeatAnswer("messenger", "mesh.seat.intake.proof.code.*", func(subject string, body json.RawMessage) (json.RawMessage, error) { + if !strings.HasSuffix(subject, ".telegram") { + return nil, errors.New("asked by another kind") + } + return json.RawMessage(`{"linked":true}`), nil + }) + if err != nil { + t.Fatal(err) + } + defer stop() + var answer json.RawMessage + mt.Until(t, func() error { + answer, err = conn.SeatProve("telegram", "mesh.seat.intake.proof.code.telegram", json.RawMessage(`{"code":"123456"}`)) + return err + }) + if string(answer) != `{"linked":true}` { + t.Fatalf("the proof was answered %s", answer) + } + if _, err := conn.SeatProve("telegram", "mesh.seat.intake.proof.code.desktop", json.RawMessage(`{}`)); err == nil { + t.Error("a channel asked a proof as another kind") + } + if _, err := conn.SeatAnswer("telegram", "mesh.seat.intake.proof.code.*", nil); err == nil { + t.Error("a channel answers proofs") + } + for name := range js.StreamNames() { + info, _ := js.StreamInfo(name) + for _, s := range info.Config.Subjects { + if bus.SubjectMatches(s, "mesh.seat.intake.proof.code.telegram") { + t.Errorf("%s keeps proofs", name) + } + } + } +} + +func TestAnAskerReadsItsOwnRecordAndNoOther(t *testing.T) { + _, conn, js := seatMesh(t) + kv, _ := js.KeyValue("messenger_asks") + if _, err := kv.Put("mesh-delivery.a1", []byte(`{"state":"open"}`)); err != nil { + t.Fatal(err) + } + if _, err := kv.Put("mesh-controller.a1", []byte(`{"state":"open"}`)); err != nil { + t.Fatal(err) + } + got, err := conn.SeatRecord("mesh-delivery", "messenger_asks", "a1") + if err != nil || got == nil || string(got.Value) != `{"state":"open"}` { + t.Fatalf("the asker could not read its own ask: %v %v", got, err) + } + if missing, err := conn.SeatRecord("mesh-delivery", "messenger_asks", "a2"); err != nil || missing != nil { + t.Fatalf("a missing ask read as %v, %v", missing, err) + } + if _, err := conn.SeatRecord("telegram", "messenger_asks", "a1"); err == nil { + t.Error("a module read records it is not given") + } +} + +func TestOfTwoAnswersToOneAskOnlyTheFirstIsWritten(t *testing.T) { + _, conn, _ := seatMesh(t) + rev, err := conn.StateCreate("messenger", "asks", "mesh-delivery.a1", json.RawMessage(`{"state":"open"}`)) + if err != nil { + t.Fatal(err) + } + if _, err := conn.StateCreate("messenger", "asks", "mesh-delivery.a1", json.RawMessage(`{"state":"open"}`)); !errors.Is(err, bus.ErrStateChanged) { + t.Errorf("a second create stood: %v", err) + } + if _, err := conn.StateUpdate("messenger", "asks", "mesh-delivery.a1", json.RawMessage(`{"state":"answered"}`), rev); err != nil { + t.Fatal(err) + } + if _, err := conn.StateUpdate("messenger", "asks", "mesh-delivery.a1", json.RawMessage(`{"state":"answered"}`), rev); !errors.Is(err, bus.ErrStateChanged) { + t.Errorf("a second answer from the same revision stood: %v", err) + } +} diff --git a/node-tools/internal/launch/event_answer_test.go b/node-tools/internal/launch/event_answer_test.go index a102b0f..2046ec0 100644 --- a/node-tools/internal/launch/event_answer_test.go +++ b/node-tools/internal/launch/event_answer_test.go @@ -40,6 +40,9 @@ func (b *subscribing) State(string, json.RawMessage) (json.RawMessage, error) { func (b *subscribing) Watch(json.RawMessage, func(json.RawMessage) error) (func(), error) { return func() {}, nil } +func (b *subscribing) Seat(string, json.RawMessage, func(string, any) (json.RawMessage, error)) (json.RawMessage, error) { + return nil, nil +} func (b *subscribing) Subscribe(d func(json.RawMessage) error) error { b.mu.Lock() defer b.mu.Unlock() diff --git a/node-tools/internal/launch/launch.go b/node-tools/internal/launch/launch.go index a691111..dc68ae5 100644 --- a/node-tools/internal/launch/launch.go +++ b/node-tools/internal/launch/launch.go @@ -60,6 +60,10 @@ type Bus interface { Subscribe(deliver func(envelope json.RawMessage) error) error State(verb string, params json.RawMessage) (json.RawMessage, error) Watch(params json.RawMessage, deliver func(change json.RawMessage) error) (stop func(), err error) + // Seat answers a bundle's seat traffic (novox/hq ADR 0259 §3): `publish`, `prove` and `record` at once; + // `take` and `answer` bind once and hand each message or proof on through handOn, to whichever child + // of the module asked last — a restarted child asks again as its code runs again. + Seat(verb string, params json.RawMessage, handOn func(method string, params any) (json.RawMessage, error)) (json.RawMessage, error) } // EventTimeout bounds how long a bundle has to handle one event before it is offered again. @@ -147,6 +151,17 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an // The child that last subscribed is the one events are handed to: a restarted child subscribes // again as its code is imported, and from then on the module's events are its. var subscriber *child + // The child that last asked to take a seat's work or answer its proofs is the one they are handed to. + var seatTaker *child + handOn := func(method string, params any) (json.RawMessage, error) { + mu.Lock() + c := seatTaker + mu.Unlock() + if c == nil { + return nil, errors.New(module + "'s bundle is not running to take it") + } + return c.ask(module, method, params, EventTimeout) + } stopped := false deliver := func(envelope json.RawMessage) error { mu.Lock() @@ -237,7 +252,21 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an subscriber = c mu.Unlock() err = mesh.Subscribe(deliver) - case "mesh/state.get", "mesh/state.put", "mesh/state.delete", "mesh/state.keys": + case "mesh/seat.publish", "mesh/seat.prove", "mesh/seat.record", "mesh/seat.kinds": + var answered json.RawMessage + if answered, err = mesh.Seat(strings.TrimPrefix(m.Method, "mesh/seat."), m.Params, nil); err == nil { + result = answered + } + case "mesh/seat.take", "mesh/seat.answer": + mu.Lock() + seatTaker = c + mu.Unlock() + var answered json.RawMessage + if answered, err = mesh.Seat(strings.TrimPrefix(m.Method, "mesh/seat."), m.Params, handOn); err == nil { + result = answered + } + case "mesh/state.get", "mesh/state.put", "mesh/state.delete", "mesh/state.keys", + "mesh/state.create", "mesh/state.update": var answered json.RawMessage if answered, err = mesh.State(strings.TrimPrefix(m.Method, "mesh/state."), m.Params); err == nil { result = answered @@ -275,7 +304,7 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an } if err != nil { _ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID, - "error": map[string]any{"code": -32000, "message": err.Error()}}) + "error": map[string]any{"code": errorCode(err), "message": err.Error()}}) return } _ = c.write(map[string]any{"jsonrpc": "2.0", "id": m.ID, "result": result}) @@ -329,6 +358,9 @@ func Start(module, entry string, env []string, mesh Bus, logf func(string, ...an if subscriber == c { subscriber = nil } + if seatTaker == c { + seatTaker = nil + } wasStopped := stopped // Started again at once if it ran a while; after a growing pause while it keeps exiting. wait := RestartFirst @@ -561,3 +593,17 @@ func (c *child) deathReason() error { } return errors.New("the bundle exited") } + +// CodeChanged is the error code a bundle is answered a lost compare-and-set with (mesh-sdk's +// stdio.CodeChanged): told by its code, never by its words (novox/hq ADR 0259). +const CodeChanged = -32010 + +// errorCode is the code an error is answered to a bundle with: a lost compare-and-set's own, else the +// general one. +func errorCode(err error) int { + var coded interface{ ErrorCode() int } + if errors.As(err, &coded) { + return coded.ErrorCode() + } + return -32000 +} diff --git a/node-tools/internal/launch/seat_test.go b/node-tools/internal/launch/seat_test.go new file mode 100644 index 0000000..acd45a9 --- /dev/null +++ b/node-tools/internal/launch/seat_test.go @@ -0,0 +1,71 @@ +package launch + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +// A bundle that asks to take a worker's work and answers each piece it is handed. +const takingBundle = `#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\)[,}].*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;; + *'"method":"tools/list"'*) + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[]}}\n' "$id" + printf '{"jsonrpc":"2.0","id":"s1","method":"mesh/seat.take","params":{"worker":"SEAT_CHANNEL_TELEGRAM_worker"}}\n' ;; + *'"method":"mesh/work"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"taken":true}}\n' "$id" ;; + esac +done +` + +type seating struct { + subscribing + mu sync.Mutex + verb string + params string + handOn func(string, any) (json.RawMessage, error) +} + +func (b *seating) Seat(verb string, params json.RawMessage, handOn func(string, any) (json.RawMessage, error)) (json.RawMessage, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.verb, b.params, b.handOn = verb, string(params), handOn + return json.RawMessage(`{}`), nil +} + +// novox/hq ADR 0259 §3: a bundle's seat traffic reaches the runtime's bus from its own channel, and the work +// it takes is handed back to it. +func TestABundleTakesASeatsWorkThroughItsOwnChannel(t *testing.T) { + entry := filepath.Join(t.TempDir(), "bundle") + if err := os.WriteFile(entry, []byte(takingBundle), 0o755); err != nil { + t.Fatal(err) + } + b := &seating{} + l, err := Start("telegram", entry, os.Environ(), b, t.Logf) + if err != nil { + t.Fatal(err) + } + t.Cleanup(l.Stop) + var handOn func(string, any) (json.RawMessage, error) + for i := 0; handOn == nil; i++ { + if i > 100 { + t.Fatal("the bundle never asked to take its work") + } + time.Sleep(20 * time.Millisecond) + b.mu.Lock() + handOn = b.handOn + b.mu.Unlock() + } + if b.verb != "take" || b.params != `{"worker":"SEAT_CHANNEL_TELEGRAM_worker"}` { + t.Fatalf("asked %s %s", b.verb, b.params) + } + got, err := handOn("mesh/work", map[string]any{"work": map[string]any{"subject": "mesh.seat.channel.accept.show.telegram"}}) + if err != nil || string(got) != `{"taken":true}` { + t.Fatalf("the work was not handed to the bundle: %s %v", got, err) + } +} diff --git a/node-tools/internal/runtime/own_account_test.go b/node-tools/internal/runtime/own_account_test.go new file mode 100644 index 0000000..eaf433d --- /dev/null +++ b/node-tools/internal/runtime/own_account_test.go @@ -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) + } + } +} diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index d778471..edc1498 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -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: `=` 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 = 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 =; 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. @@ -458,6 +455,8 @@ type consumers struct { logf func(string, ...any) mu sync.Mutex of map[string]*moduleEvents + // seatBound is each module's seat traffic bound once: a worker taken, a proof subject answered. + seatBound map[string]func() } type moduleEvents struct { @@ -475,6 +474,10 @@ func (c *consumers) stopAll() { } } c.of = map[string]*moduleEvents{} + for _, stop := range c.seatBound { + stop() + } + c.seatBound = map[string]func(){} } // forModule is the bus one launched bundle of a module reaches the mesh through. @@ -604,9 +607,10 @@ func eventRef(env bus.Envelope) string { // stateAsked is what a bundle names when it reaches its state (ADR 0201): the state by the name its // module uses, a key, and for a put the value. type stateAsked struct { - State string `json:"state"` - Key string `json:"key"` - Value json.RawMessage `json:"value"` + State string `json:"state"` + Key string `json:"key"` + Value json.RawMessage `json:"value"` + Revision uint64 `json:"revision"` } // State answers a bundle's `get`, `put`, `delete` and `keys` on its module's state (ADR 0201). The @@ -645,6 +649,20 @@ func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage, return nil, err } answer = keys + case "create": + // Only when the key has no value: of two writers making it, one wins (novox/hq ADR 0259). + revision, err := conn.StateCreate(b.module, asked.State, asked.Key, asked.Value) + if err != nil { + return nil, err + } + answer = map[string]any{"revision": revision} + case "update": + // Only when the key is still at the revision read: a second answer to one ask loses (ADR 0259). + revision, err := conn.StateUpdate(b.module, asked.State, asked.Key, asked.Value, asked.Revision) + if err != nil { + return nil, err + } + answer = map[string]any{"revision": revision} default: return nil, fmt.Errorf("the runtime answers no mesh/state.%s", verb) } @@ -666,3 +684,124 @@ func (b *moduleBus) Watch(params json.RawMessage, deliver func(json.RawMessage) return deliver(raw) }) } + +// seatAsked is what a bundle names for its seat traffic (novox/hq ADR 0259 §3). +type seatAsked struct { + Subject string `json:"subject"` + Body json.RawMessage `json:"body"` + ID string `json:"id"` + Worker string `json:"worker"` + Bucket string `json:"bucket"` + Key string `json:"key"` +} + +// Seat answers a bundle's seat traffic, each as the module and only as far as its membership lists: +// `publish` an accept or an event, `prove` (ask a proof), `record` (read a record kept for it), `take` a +// worker's work and `answer` a proof subject — the last two bound once per module and subject, each +// message or proof handed to the module's child that asked last (novox/hq ADR 0259 §3). +func (b *moduleBus) Seat(verb string, params json.RawMessage, handOn func(string, any) (json.RawMessage, error)) (json.RawMessage, error) { + var asked seatAsked + if err := json.Unmarshal(params, &asked); err != nil { + return nil, fmt.Errorf("mesh/seat.%s: %w", verb, err) + } + conn := b.all.conn + switch verb { + case "publish": + 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, "duplicate": duplicate}) + case "prove": + return conn.SeatProve(b.module, asked.Subject, asked.Body) + case "kinds": + kinds, err := conn.SeatKinds(b.module) + if err != nil { + return nil, err + } + return json.Marshal(kinds) + case "record": + entry, err := conn.SeatRecord(b.module, asked.Bucket, asked.Key) + if err != nil || entry == nil { + return json.RawMessage("null"), err + } + return json.Marshal(entry) + case "take": + key := "take " + asked.Worker + if b.bound(key) { + return json.RawMessage(`{}`), nil + } + stop, err := conn.SeatTake(b.module, asked.Worker, func(w bus.Work) error { + _, err := handOn("mesh/work", map[string]any{"work": w}) + return err + }) + if err != nil { + return nil, err + } + b.bind(key, stop) + b.all.logf("[mesh-tools] %s takes its work from %s", b.module, asked.Worker) + return json.RawMessage(`{}`), nil + case "answer": + key := "answer " + asked.Subject + if b.bound(key) { + return json.RawMessage(`{}`), nil + } + stop, err := conn.SeatAnswer(b.module, asked.Subject, func(subject string, body json.RawMessage) (json.RawMessage, error) { + return handOn("mesh/proof", map[string]any{"subject": subject, "body": body}) + }) + if err != nil { + return nil, err + } + b.bind(key, stop) + b.all.logf("[mesh-tools] %s answers the proofs on %s", b.module, asked.Subject) + return json.RawMessage(`{}`), nil + } + return nil, fmt.Errorf("the runtime answers no mesh/seat.%s", verb) +} + +// bound and bind keep, per module, what its seat traffic is bound to, so a child that starts again and +// asks again is handed what is already bound instead of a second reader of it. +func (b *moduleBus) bound(key string) bool { + b.all.mu.Lock() + defer b.all.mu.Unlock() + _, ok := b.all.seatBound[b.module+" "+key] + return ok +} + +func (b *moduleBus) bind(key string, stop func()) { + b.all.mu.Lock() + defer b.all.mu.Unlock() + if b.all.seatBound == nil { + b.all.seatBound = map[string]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 +} diff --git a/node-tools/internal/runtime/seat_traffic_test.go b/node-tools/internal/runtime/seat_traffic_test.go new file mode 100644 index 0000000..3986cc5 --- /dev/null +++ b/node-tools/internal/runtime/seat_traffic_test.go @@ -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") + } +}