diff --git a/cmd/mesh-controller/desk_secret.go b/cmd/mesh-controller/desk_secret.go index 67f242cb..96524993 100644 --- a/cmd/mesh-controller/desk_secret.go +++ b/cmd/mesh-controller/desk_secret.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "strings" "time" @@ -253,8 +254,9 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error { return err } defer open.Close() + askedBy := link.Caller() d := deskGive{ - askedBy: link.Caller(), + askedBy: askedBy, open: func(node, module, name, desk string) (int64, error) { return open.inventory.OpenSecretAsk(ctx, node, module, name, askedByName(link.Caller()), desk) }, @@ -291,7 +293,9 @@ func askAtDesk(ctx context.Context, node, module, name, desk string) error { return err }) }, - announce: func(node, module, name, how string) error { return announceSecretGiven(ctx, node, module, name, how) }, + announce: func(node, module, name, how string) error { + return announceSecretGiven(ctx, node, module, name, how, askedBy) + }, } words, err := d.give(node, module, name, desk) if err != nil { @@ -342,10 +346,84 @@ func secretNameWords(name string) string { return strings.Join(strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == '.' }), " ") } -// announceSecretGiven raises it on this controller's keeper. -func announceSecretGiven(ctx context.Context, node, module, name, how string) error { - return withKeeper(ctx, func(k *conditions.Keeper) error { - _, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, time.Now())) - return err +// tellWithin is how long the operator channel's `tell` is given to answer: well under a human's patience, +// and short because nothing in the call waits on the operator — it asks nothing (novox/hq ADR 0380). +const tellWithin = 10 * time.Second + +// askOperatorChannel asks the operator channel's seat (the holder issue 797 gives it) its `tell` verb, +// through the controller's grant (novox/hq ADR 0380 §8), over this controller's own bus connection; a seam +// a test replaces whole. The holder's own refusal is an error naming it; an absent holder or a refused +// grant answer as link.ErrNothingServes or link.ErrGrantRefused (checked with errors.Is), so the caller can +// tell the two apart from any other failure. +var askOperatorChannel = func(ctx context.Context, args map[string]any) (json.RawMessage, error) { + var result json.RawMessage + err := onTheBus(func(conn *nats.Conn) error { + answer, err := link.AskMeshSeatTool(ctx, conn, "operator-channel", "tell", args, tellWithin) + if err != nil { + return err + } + if answer.Error != "" { + return fmt.Errorf("operator-channel.tell refused: %s", answer.Error) + } + result = answer.Result + return nil }) + return result, err +} + +// announceSecretGiven tells the operator, once, that a module's own secret was given (novox/hq ADR 0380): +// one plain message through the operator channel's `tell`, naming the secret, the module, the machine, +// where and when it was given and who asked — asking nothing, and opening no condition. **The controller's +// change waits for that verb at run time, not at merge** (clause 9): while nothing on the bus yet serves it, +// or the controller's own grant is refused, the secret-given condition is raised exactly as before ADR 0380 +// — any other failure (the holder itself refusing, or answering something unreadable) is the announcement's +// own failure, said to the caller as it always was. +func announceSecretGiven(ctx context.Context, node, module, name, how, askedBy string) error { + at := time.Now() + body := map[string]any{ + "headline": fmt.Sprintf("A password was entered for %s's %s", module, secretNameWords(name)), + "lines": []string{ + fmt.Sprintf("%s at %s, asked by %s.", how, at.Local().Format("15:04"), askedByName(askedBy)), + "If that was not you, give it again yourself at the controller's terminal.", + }, + } + _, told := askOperatorChannel(ctx, body) + switch { + case told == nil: + clearSecretGivenConditions(ctx) + return nil + case errors.Is(told, link.ErrNothingServes), errors.Is(told, link.ErrGrantRefused): + log.Printf("secret-given: %s's %s on %s could not be told through the operator channel (%v); the "+ + "secret-given condition is raised as it was before novox/hq ADR 0380 (its clause 9)", module, name, + node, told) + return withKeeper(ctx, func(k *conditions.Keeper) error { + _, err := k.Observe(ctx, secretGivenObservation(node, module, name, how, at)) + return err + }) + default: + return told + } +} + +// clearSecretGivenConditions retires every open secret-given condition once a plain message has replaced +// it (novox/hq ADR 0380 §10): called after the first time a secret-given message is told successfully. No +// resolved line is sent — the condition is simply gone — and why is kept in the keeper's own history. +func clearSecretGivenConditions(ctx context.Context) { + if err := withKeeper(ctx, func(k *conditions.Keeper) error { + open, err := k.Open(ctx) + if err != nil { + return err + } + for _, c := range open { + if c.Kind != kindSecretGiven { + continue + } + if _, err := k.ClearSaying(ctx, c.Key, "retired by novox/hq ADR 0380", ""); err != nil { + return err + } + } + return nil + }); err != nil { + log.Printf("secret-given: the open secret-given conditions, retired by ADR 0380, could not be cleared: %v", err) + } } diff --git a/cmd/mesh-controller/desk_secret_test.go b/cmd/mesh-controller/desk_secret_test.go index d2cc26a9..00e41f81 100644 --- a/cmd/mesh-controller/desk_secret_test.go +++ b/cmd/mesh-controller/desk_secret_test.go @@ -556,3 +556,125 @@ func TestASecretAskIsNeverASecretRead(t *testing.T) { } } } + +// askOperatorChannelFake replaces askOperatorChannel for the test and records every call it was given. +func askOperatorChannelFake(t *testing.T, answer func(args map[string]any) (json.RawMessage, error)) *[]map[string]any { + t.Helper() + var calls []map[string]any + was := askOperatorChannel + askOperatorChannel = func(_ context.Context, args map[string]any) (json.RawMessage, error) { + calls = append(calls, args) + return answer(args) + } + t.Cleanup(func() { askOperatorChannel = was }) + return &calls +} + +// announceWithRealTell wires a deskGive's announce to the real announceSecretGiven, as askAtDesk does, +// rather than the fakes the tests above use to look at what `give` does around it. +func announceWithRealTell(ctx context.Context, askedBy string) func(node, module, name, how string) error { + return func(node, module, name, how string) error { + return announceSecretGiven(ctx, node, module, name, how, askedBy) + } +} + +// novox/hq ADR 0380 clauses 1-2: a secret given at the desk is told to the operator once, in a plain +// message through the operator channel's `tell`, naming the secret, the module, where and when and who +// asked, and opens no condition — the kind the review of 2026-10-09 (M4) raised is retired. +func TestASecretGivenAtTheDeskIsToldOnceAndOpensNoCondition(t *testing.T) { + ctx := context.Background() + k, _ := withConditionsInMemory(t) + calls := askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, nil }) + d, _, _, _ := aDesk(t, sealedTo(t, typed)) + d.announce = announceWithRealTell(ctx, "mail-seats-835@novox") + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 { + t.Fatalf("told the operator %d times, want exactly 1: %v", len(*calls), *calls) + } + call := (*calls)[0] + if call["headline"] != "A password was entered for telegram's telegram token" { + t.Errorf("headline %v", call["headline"]) + } + lines, _ := call["lines"].([]string) + if len(lines) != 2 || !strings.Contains(lines[0], "at the desk on laptop at") || + !strings.Contains(lines[0], "asked by mail-seats-835@novox") || + lines[1] != "If that was not you, give it again yourself at the controller's terminal." { + t.Errorf("lines %v", lines) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 0 { + t.Errorf("a condition was raised though the operator was told: %+v", open) + } +} + +// novox/hq ADR 0380 §9: until the operator channel's `tell` is delivered — nothing yet serves it, or the +// controller's own grant is refused — the secret-given condition is raised exactly as before. +func TestASecretGivenAtTheDeskRaisesTheConditionWhileNothingServesTell(t *testing.T) { + ctx := context.Background() + for _, failure := range []error{link.ErrNothingServes, link.ErrGrantRefused} { + k, _ := withConditionsInMemory(t) + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, failure }) + d, _, _, _ := aDesk(t, sealedTo(t, typed)) + d.announce = announceWithRealTell(ctx, "mesh-mail-835, novox") + if _, err := d.give("anchor", "telegram", "telegram-token", "laptop"); err != nil { + t.Fatal(err) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 1 || open[0].Kind != kindSecretGiven { + t.Fatalf("%v: the condition was not raised as before: %+v", failure, open) + } + } +} + +// novox/hq ADR 0380 §10: once the operator channel is told successfully, every secret-given condition +// still open from before this change is retired, without a resolved line of its own. +func TestASuccessfulTellRetiresEveryOpenSecretGivenCondition(t *testing.T) { + ctx := context.Background() + k, _ := withConditionsInMemory(t) + for _, module := range []string{"telegram", "mesh-mail"} { + if _, err := k.Observe(ctx, secretGivenObservation("anchor", module, "a-secret", "at the desk on laptop", + time.Now())); err != nil { + t.Fatal(err) + } + } + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, nil }) + if err := announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the desk on laptop", "me"); err != nil { + t.Fatal(err) + } + open, err := k.Open(ctx) + if err != nil { + t.Fatal(err) + } + if len(open) != 0 { + t.Fatalf("the open secret-given conditions were not retired: %+v", open) + } +} + +// novox/hq ADR 0380 clause 7: a trusted party's secret given at the controller's terminal is announced +// before it is kept, and not kept when the announcement fails. It still fails when telling the operator +// AND raising the fallback condition both fail — nothing silently keeps a trusted party's value unheard. +func TestATrustedPartysSecretAtTheTerminalIsNotKeptWhenTellAndTheConditionBothFail(t *testing.T) { + ctx := context.Background() + askOperatorChannelFake(t, func(map[string]any) (json.RawMessage, error) { return nil, link.ErrNothingServes }) + // conditionsFrom is left nil and there is no bus to dial here, so the fallback condition cannot be + // raised either: both of ADR 0380's ways to tell the operator fail. + kept := false + if _, _, err := keepGiven(true, + func() error { + return announceSecretGiven(ctx, "anchor", "telegram", "telegram-token", "at the controller's terminal", "me") + }, + func() (bool, error) { kept = true; return false, nil }); err == nil { + t.Error("a trusted party's secret was kept though telling the operator and raising the condition both failed") + } + if kept { + t.Error("the value was kept despite the failure") + } +} diff --git a/cmd/mesh-controller/secret.go b/cmd/mesh-controller/secret.go index 373dbd7b..09e69c12 100644 --- a/cmd/mesh-controller/secret.go +++ b/cmd/mesh-controller/secret.go @@ -12,8 +12,11 @@ import ( "strings" "time" + "github.com/nats-io/nats.go" + "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/secrets" ) @@ -151,8 +154,11 @@ func secretCommand(ctx context.Context, args []string) error { if err != nil { return err } + askedBy := link.Caller() untilStart, unannounced, err := keepGiven(trusted, - func() error { return announceSecretGiven(ctx, node, module, name, "at the controller's terminal") }, + func() error { + return announceSecretGiven(ctx, node, module, name, "at the controller's terminal", askedBy) + }, func() (bool, error) { return open.inventory.AcceptGivenSecret(ctx, node, module, name, value) }) if err != nil { if trusted && unannounced != nil { @@ -164,6 +170,17 @@ func secretCommand(ctx context.Context, args []string) error { if unannounced != nil { fmt.Printf(" this change could NOT be announced on the operator's channels: %v\n", unannounced) } + // The giving is recorded, never the value (novox/hq ADR 0380 §6): the desk already logs its own act; the + // terminal's own secret accept did not until now. + act := link.HandAct{Verb: "secret accept", Args: []string{node, module, name}, + Why: fmt.Sprintf("the operator gave %s for %s on %s at the controller's terminal", name, module, node), + Cause: "given-at-the-terminal"} + if err := onTheBus(func(conn *nats.Conn) error { + _, err := link.RecordHandAct(ctx, conn, act) + return err + }); err != nil { + fmt.Printf(" this act could NOT be recorded in the hand-act log, and is done anyway: %v\n", err) + } // Not printed back, and there is nowhere it could be printed from: it is sealed to that // machine and the mesh cannot read it again. fmt.Printf("%s on %s now holds %q, sealed to that machine.\n", module, node, name) diff --git a/internal/broker/nats.go b/internal/broker/nats.go index b1f0b831..79994946 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -279,6 +279,11 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery", var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"}, {Seat: ControllerSeat, Verb: "plans"}} +// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq +// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named, +// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject. +var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}} + // perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the // holder's machine (novox/hq ADR 0219): `paused.`, the build agent saying whether it takes work. var perMachineEvents = map[string]bool{"paused.*": true} @@ -500,6 +505,10 @@ func PermissionsFor(p Principal) (Permissions, error) { for _, v := range VerbsTheControllerAsksForASecret { pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*") } + // And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380). + for _, v := range VerbsTheControllerAsksTheOperatorChannel { + pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) + } // And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239). for _, v := range VerbsTheControllerAsksTheDeliveryOwner { pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb) diff --git a/internal/broker/testdata/composed.conf b/internal/broker/testdata/composed.conf index 8270aaf3..971c1491 100644 --- a/internal/broker/testdata/composed.conf +++ b/internal/broker/testdata/composed.conf @@ -24,7 +24,7 @@ accounts { jetstream: enabled users = [ { user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: { - publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] } + publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] } subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] } allow_responses: { max: 1, ttl: "1m" } } } diff --git a/internal/link/queue.go b/internal/link/queue.go index 6b6f5fb1..b30d570b 100644 --- a/internal/link/queue.go +++ b/internal/link/queue.go @@ -641,8 +641,8 @@ func AskMeshSeatTool(ctx context.Context, conn *nats.Conn, seat, verb string, ar reply, err = r.msg, r.err case why := <-refused: cancel() - return Answer{}, fmt.Errorf("the bus refused the controller asking %s.%s — its grants do not name %s: %v", - seat, verb, subject, why) + return Answer{}, fmt.Errorf("%w: the bus refused the controller asking %s.%s — its grants do not name %s: %v", + ErrGrantRefused, seat, verb, subject, why) } switch { case errors.Is(err, nats.ErrNoResponders): diff --git a/internal/link/retirement.go b/internal/link/retirement.go index c0bd8330..201b4836 100644 --- a/internal/link/retirement.go +++ b/internal/link/retirement.go @@ -167,6 +167,11 @@ const ( // is older than the tool. var ErrNothingServes = errors.New("nothing serves it") +// ErrGrantRefused is the bus refusing this principal's own call: its grants do not name the subject +// asked. Distinct from ErrNothingServes (nobody there to answer) — this is the asker's own grant, not +// the holder's absence (novox/hq ADR 0380 §9). +var ErrGrantRefused = errors.New("the bus refused the ask") + // ModuleToolOn is where one machine's instance of a module answers a tool: the module's tool subject // with the machine as its last token — the subject the node tools bind beside the shared one. func ModuleToolOn(module, tool, node string) string { return ToolSubject(module, tool) + "." + node }