diff --git a/modules/desk-channel/README.md b/modules/desk-channel/README.md index 2d759f40..d92f5139 100644 --- a/modules/desk-channel/README.md +++ b/modules/desk-channel/README.md @@ -20,10 +20,23 @@ router sends warnings to the channel that reaches the operator away. ## Where it runs -Held once for the mesh, on the same machine as the router, which is where it is assigned. A message the -router marks secret (a link's code) is shown and kept nowhere of this module's; the desktop's own notifier -history may still hold it for its length (20 notifications). It asks each machine's notifier through the -mesh: nothing new runs on the machines. +Held once for the mesh, on the same machine as the router, which is where it is assigned. **It runs as the +account `desk-channel`, in a runtime of its own, on a bus account of its own** (`runs-as`), as telegram does: +what it shows and the answers it says are out of reach of the operator's account, which agents run as. It asks +each machine's notifier through the mesh: nothing new runs on the machines. + +## A link's code (the review of 2026-10-09) + +- **It is shown only where it is seen.** Each machine's notifier says whether it is paused (`dunst.paused`, at + least every two minutes). A message the router marks secret is shown only on a named machine heard unpaused in + the last five minutes; while there is none, it is said to the router as failed and the desk says it cannot + send, so the router starts no link. A code held by a paused notifier would appear later, to whoever is at the + screen then. +- **It is kept nowhere.** Not in this module's state, and sent `secret` to the notifier, which sends it + transient in the category `mesh.secret`: dunst's rules keep it out of its history, and + `node-notifier.history`, which every caller of the mesh's verbs may read, leaves it out. +- **What remains**: while it is on screen, a program of the operator's account on that X display can read it + (X11 isolates no client from another). It is shown for as long as the router's code lives. ## Tools diff --git a/modules/desk-channel/cmd/desk-channel/desk.go b/modules/desk-channel/cmd/desk-channel/desk.go index 4d70a43a..9c90ece1 100644 --- a/modules/desk-channel/cmd/desk-channel/desk.go +++ b/modules/desk-channel/cmd/desk-channel/desk.go @@ -31,6 +31,19 @@ import ( // Kind is this channel's kind on both benches. const Kind = "desktop" +// Account is the account this module runs as, in a runtime of its own (runs-as). +const Account = "desk-channel" + +// PausedFresh is how old a notifier's word on whether it is paused may be: it says it every two minutes, so +// two missed is a notifier stopped or a machine away. +const PausedFresh = 5 * time.Minute + +// pauseSeen is what a machine's notifier last said of its pause, and when. +type pauseSeen struct { + paused bool + at time.Time +} + // StandingEvery is how often the channel says whether it can send, at least. const StandingEvery = 5 * time.Minute @@ -65,6 +78,7 @@ type Desk struct { said asks.Standing saidAt time.Time acted map[string]bool + pause map[string]pauseSeen } func (d *Desk) init() { @@ -73,6 +87,9 @@ func (d *Desk) init() { if d.last == nil { d.last, d.clickedOn, d.acted = map[string]string{}, map[string]string{}, map[string]bool{} } + if d.pause == nil { + d.pause = map[string]pauseSeen{} + } if d.Now == nil { d.Now = time.Now } @@ -105,7 +122,17 @@ func (d *Desk) Take(subject string, body json.RawMessage) error { return nil } machines := d.Machines() - if m.Reply != "" { + if m.Secret { + // Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back + // would appear later, to whoever is at the screen then. + seeing, why := d.seeing() + if len(seeing) == 0 { + f := asks.Failed{Handle: m.Handle, Why: "not shown: " + why, At: d.Now()} + return d.Publish(asks.IntakeSubject(asks.FailedWhat, Kind), f, "failed."+m.Handle+"."+strconv.FormatInt(d.Now().UnixNano(), 10)) + } + machines = seeing + } + if m.Reply != "" && !m.Secret { // An answer to a click is shown where the click was made. d.mu.Lock() on, ok := d.clickedOn[m.Reply] @@ -175,6 +202,11 @@ func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) { if replace != 0 { args["replace_id"] = replace } + if m.Secret { + // Transient and secret: the notifier keeps it out of the history every caller of the verbs reads. + args["secret"] = true + args["transient"] = true + } if len(m.Buttons) > 0 { var actions []map[string]string for _, b := range m.Buttons { @@ -290,12 +322,49 @@ func (d *Desk) Started(machine string) int { return n } -// Standing is whether the desk can send: machines are named to show on. +// Paused takes a notifier's word on whether it is paused (dunst's event `paused`), from the event's own node. +func (d *Desk) Paused(machine string, paused bool) { + d.init() + d.mu.Lock() + defer d.mu.Unlock() + d.pause[machine] = pauseSeen{paused: paused, at: d.Now()} +} + +// seeing is the named machines whose notifier said lately that it is not paused, and why none is when none is. +func (d *Desk) seeing() ([]string, string) { + machines := d.Machines() + if len(machines) == 0 { + return nil, "no machine to show it on: the setting desktop-machines names none" + } + now := d.Now() + d.mu.Lock() + defer d.mu.Unlock() + var out, not []string + for _, m := range machines { + p, heard := d.pause[m] + switch { + case !heard || now.Sub(p.at) > PausedFresh: + not = append(not, m+" (its notifier not heard from lately)") + case p.paused: + not = append(not, m+" (notifications paused)") + default: + out = append(out, m) + } + } + if len(out) == 0 { + return nil, "notifications are paused on the desk, or its notifier was not heard from lately, on every " + + "machine named: " + strings.Join(not, ", ") + } + return out, "" +} + +// Standing is whether the desk can send: a named machine whose notifier said lately that it is not paused +// (the review of 2026-10-09). The router shows no link's code while the desk is not ready. func (d *Desk) Standing() asks.Standing { d.init() s := asks.Standing{At: d.Now()} - if len(d.Machines()) == 0 { - s.Why = "no machine to show it on: the setting desktop-machines names none" + if _, why := d.seeing(); why != "" { + s.Why = why return s } s.Ready = true @@ -338,6 +407,7 @@ func (d *Desk) Status() map[string]any { "it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only", "an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed", "a notifier that started again is shown again every open message with buttons it showed", + "it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history", }} if s.Why != "" { out["why_not"] = s.Why diff --git a/modules/desk-channel/cmd/desk-channel/desk_test.go b/modules/desk-channel/cmd/desk-channel/desk_test.go index da887ea2..6a8caa91 100644 --- a/modules/desk-channel/cmd/desk-channel/desk_test.go +++ b/modules/desk-channel/cmd/desk-channel/desk_test.go @@ -155,7 +155,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) { t.Errorf("the desk promises %s", c) } } - if strings.Join(m.Consumes, ",") != AnswerEvent+","+StartedEvent { + if strings.Join(m.Consumes, ",") != AnswerEvent+","+StartedEvent+","+PausedEvent { t.Errorf("consumes %v", m.Consumes) } } @@ -163,6 +163,7 @@ func TestItPromisesNoVerifiedSender(t *testing.T) { // A message the router marks secret (a link's code) is shown and kept nowhere of this module's. func TestASecretMessageIsShownAndNotKept(t *testing.T) { d, shown, _, store := newDesk(t, "laptop") + d.Paused("laptop", false) take(t, d, asks.Send, asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true}) if len(*shown) != 1 { t.Fatal("not shown") @@ -171,3 +172,90 @@ func TestASecretMessageIsShownAndNotKept(t *testing.T) { t.Error("the code was kept") } } + +// The review of 2026-10-09 (H4, C4): a link's code is shown only on a machine whose notifier said lately that +// it is not paused — a code held back by a paused notifier appears later, to whoever is at the screen then — +// and is sent secret, so the notifier keeps it out of the history every caller of the verbs reads. While no +// named machine is heard unpaused, the desk says it cannot send. +func TestASecretIsShownOnlyWhereTheNotifierIsHeardUnpausedAndNeverKept(t *testing.T) { + d, shown, said, store := newDesk(t, "laptop", "desk2") + now := d.Now() + if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "not heard") { + t.Fatalf("ready before any notifier said whether it is paused: %+v", s) + } + d.Paused("laptop", false) + if s := d.Standing(); !s.Ready { + t.Fatalf("not ready with laptop heard unpaused: %+v", s) + } + code := asks.Message{Handle: "code", Title: "Link a Telegram account as you", Body: "123 456", Secret: true} + take(t, d, asks.Send, code) + if len(*shown) != 1 || (*shown)[0].machine != "laptop" || (*shown)[0].args["secret"] != true { + t.Fatalf("the code was shown as %+v", *shown) + } + if _, kept := store["code"]; kept { + t.Error("the code was kept") + } + // Paused: not shown anywhere, said as failed, and the desk is not ready. + d.Paused("laptop", true) + take(t, d, asks.Send, code) + if len(*shown) != 1 { + t.Fatalf("the code was shown on a paused notifier: %+v", *shown) + } + if f := said[asks.IntakeSubject(asks.FailedWhat, Kind)]; len(f) != 1 || !strings.Contains(string(f[0]), "paused") { + t.Errorf("not said as failed: %s", f) + } + if s := d.Standing(); s.Ready || !strings.Contains(s.Why, "paused") { + t.Errorf("ready while paused: %+v", s) + } + // Heard unpaused, but longer ago than PausedFresh: not known now. + d.Paused("laptop", false) + d.Now = func() time.Time { return now.Add(PausedFresh + time.Second) } + if s := d.Standing(); s.Ready { + t.Errorf("ready on a word older than %s: %+v", PausedFresh, s) + } + // A machine not named is never one to show on, whatever it says. + d.Paused("stranger", false) + if s := d.Standing(); s.Ready { + t.Error("a machine not named made the desk ready") + } + // A message that is not secret still goes to every named machine, paused or not. + take(t, d, asks.Send, asks.Message{Handle: "m9", Title: "x"}) + if len(*shown) != 3 { + t.Errorf("an ordinary message was held back: %+v", *shown) + } +} + +// It runs as its own account (the review of 2026-10-09, H4): what it shows and the answers it says are its +// own, out of every agent's reach, as telegram's are. +func TestItRunsAsItsOwnAccount(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m struct { + RunsAs string `json:"runs-as"` + Own map[string]string `json:"own-secrets"` + Owner string `json:"secrets-owner"` + Resources []map[string]any + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + if m.RunsAs != Account || m.Owner != Account || m.Own["broker"] == "" { + t.Fatalf("runs as %q, owner %q, secrets %v", m.RunsAs, m.Owner, m.Own) + } + var account, state, settings bool + for _, r := range m.Resources { + switch r["id"] { + case "account": + account = r["type"] == "user" && r["name"] == Account && r["shell"] == "/usr/bin/nologin" + case "state": + state = r["owner"] == Account && r["mode"] == "0700" + case "settings": + settings = r["owner"] == Account && r["mode"] == "0600" + } + } + if !account || !state || !settings { + t.Errorf("the account %v, its state %v, its settings %v", account, state, settings) + } +} diff --git a/modules/desk-channel/cmd/desk-channel/main.go b/modules/desk-channel/cmd/desk-channel/main.go index bba17dbf..6e9519b0 100644 --- a/modules/desk-channel/cmd/desk-channel/main.go +++ b/modules/desk-channel/cmd/desk-channel/main.go @@ -22,6 +22,7 @@ const Worker = "SEAT_CHANNEL_DESKTOP_worker" const ( AnswerEvent = "dunst.action-chosen" StartedEvent = "dunst.started" + PausedEvent = "dunst.paused" ) type busStore struct{} @@ -107,6 +108,7 @@ func run(d *Desk) { var body struct { Token string `json:"token"` Machine string `json:"machine"` + Paused *bool `json:"paused"` } _ = json.Unmarshal(e.Body, &body) // The machine is the event's own node, said by the runtime: the body's word is only a hint. @@ -117,6 +119,12 @@ func run(d *Desk) { switch e.Key { case StartedEvent: d.Started(machine) + case PausedEvent: + // Only from the event's own node: a word about another machine is no word at all. + if body.Paused != nil && e.Node != "" { + d.Paused(e.Node, *body.Paused) + d.SayStanding() + } case AnswerEvent: if body.Token != "" { d.Chosen(e.Headers["x-event-id"], machine, body.Token) diff --git a/modules/desk-channel/module.json b/modules/desk-channel/module.json index 04e1e21f..309efbb4 100644 --- a/modules/desk-channel/module.json +++ b/modules/desk-channel/module.json @@ -2,6 +2,7 @@ "module": "desk-channel", "version": "1", "slug": "desk", + "runs-as": "desk-channel", "claims": [ { "name": "channel", @@ -25,7 +26,8 @@ ], "consumes": [ "dunst.action-chosen", - "dunst.started" + "dunst.started", + "dunst.paused" ], "invokes": [ "seat:node-notifier.send" @@ -36,14 +38,26 @@ "ttl-seconds": 2592000 } ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "desk-channel", "tools": [ "desk_channel_status" ], "resources": [ + { + "id": "account", + "type": "user", + "name": "desk-channel", + "shell": "/usr/bin/nologin", + "home": "/var/lib/desk-channel" + }, { "id": "state", "type": "directory", "mode": "0700", + "owner": "desk-channel", "place": "." }, { @@ -51,6 +65,7 @@ "type": "file", "path": "${dir:state}/settings.json", "mode": "0600", + "owner": "desk-channel", "merge": "json", "content": "{\n \"desktop-machines\": []\n}\n" }