mesh/merge-gate pass: builds messenger, telegram → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
At 13:30 on 2026-10-10 the operator approved two questions on Telegram and saw nothing happen: the channel acknowledged each tap with no words, before the router had judged it, and the edit that removed the buttons kept the question's title, adding only a line under it that rings nothing. The router now answers a chosen option in reply to the tap, once the ask's end is recorded and before the question is edited: "You chose Approve: recorded, and claude-code was told." (or, when the bus did not take the warrant, that it is told when it does). A refusal already replied with its reason. Telegram holds a tap's acknowledgement for the router's word and shows it as the tap's own toast; a word with a body (a refusal's reason, Details) also stays in the chat. Without a word in 8 seconds the toast claims nothing, and the late word goes to the chat. A secret is never a toast. An ask's own message, once answered, carries its outcome as its title: "You chose Approve at 13:30 on Telegram · <headline>", then whether the asker was told. The asks protocol carries no result back from the asker, so nothing more is claimed of what it did.
371 lines
15 KiB
Go
371 lines
15 KiB
Go
package main
|
|
|
|
// The router's own asks (novox/hq ADR 0259 §7, as revised after the security review of 2026-10-08): who the
|
|
// operator is on a channel changes only with the word of the account it is now.
|
|
//
|
|
// - **Unlinking is an approve ask, put to the account that is linked.** Whoever could unlink the
|
|
// operator's account without it could link their own in its place. The notice of the unlink reaches that
|
|
// account first, by name, and then every channel.
|
|
// - **A link within a day of an unlink waits for the replaced account's approval**, asked of it by name.
|
|
// - **When that account is lost with its phone**, the operator, as root at the router's machine, leaves a
|
|
// file in the router's directory — `unlink.<kind>` or `allow-link.<kind>`, owned by root — and the router
|
|
// does it, announced as done at the machine. A file of any other owner is refused and said: an agent
|
|
// without root cannot make one.
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// SelfAsker is the name the router asks its own questions under.
|
|
const SelfAsker = "messenger"
|
|
|
|
// RelinkWaits is how long after an unlink a new link waits for the replaced account's approval.
|
|
const RelinkWaits = 24 * time.Hour
|
|
|
|
// SelfAct is what the router does on its own ask's warrant. It names the account it concerns: an option
|
|
// binds that account, so a warrant for unlinking one account never unlinks another linked in its place
|
|
// (the review of 2026-10-09).
|
|
type SelfAct struct {
|
|
Do string `json:"do"` // "unlink" or "link"
|
|
Kind string `json:"kind"`
|
|
Identity string `json:"identity"` // the account unlinked, or the account a link makes the operator's
|
|
Link *Identity `json:"link,omitempty"` // the account a link makes the operator's
|
|
}
|
|
|
|
// act is what an option of the router's own ask binds: its named fields (asks.ActDigest).
|
|
func (a SelfAct) act() asks.Act {
|
|
return asks.Act{"do": a.Do, "kind": a.Kind, "identity": a.Identity}
|
|
}
|
|
|
|
// Unlinked is the last account unlinked on a kind, kept a day so a relink is asked of it.
|
|
type Unlinked struct {
|
|
Identity Identity `json:"identity"`
|
|
At time.Time `json:"at"`
|
|
// Early is an unlink at the desk in the account's first hour: it bars nothing (the confirmation review of
|
|
// 2026-10-09, N1-router). A desk click is anyone's at the desk machine, and a bar it set — the account not
|
|
// linked again for a day, the next link waiting for it — let a forged click lock the operator out, again
|
|
// and again. The bar is for an account removed deliberately: by its own approval, or by root.
|
|
Early bool `json:"early,omitempty"`
|
|
}
|
|
|
|
// askSelf puts a question of the router's own to one account, on its kind, and keeps it like any ask.
|
|
func (h *Holder) askSelf(a asks.Ask, only Identity, act SelfAct) error {
|
|
// Its options bind the act, as any asker's do (ADR 0259 §6): the warrant then performs this act alone.
|
|
binds, err := asks.ActDigest(act.act())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range a.Options {
|
|
a.Options[i].Binds = binds
|
|
}
|
|
key := askKey(SelfAsker, a.ID)
|
|
now := h.Now()
|
|
rec := AskRecord{Asker: SelfAsker, Ask: a, State: StateOpen, Opened: now, Tickets: map[string]Ticket{},
|
|
Only: &only, Act: &act, Record: "ask." + key}
|
|
for _, o := range a.Options {
|
|
rec.Tickets[ticket()] = Ticket{Option: o.ID, Kind: only.Kind}
|
|
}
|
|
if h.Asks != nil {
|
|
rev, err := h.Asks.CreateAsk(key, rec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
h.keepAsk(key, rec, rev)
|
|
} else {
|
|
h.keepAsk(key, rec, 0)
|
|
}
|
|
r := Record{Key: rec.Record, Kind: "ask", Subject: SelfAsker, Severity: Urgent, Summary: a.Headline,
|
|
Headline: a.Headline, Explanation: a.Explanation, Origin: "ask", More: "operator-channel.asks",
|
|
Raised: now, Sent: map[string]string{}, Seen: now, Count: 1, Ask: key}
|
|
h.mu.Lock()
|
|
h.open[r.Key] = &r
|
|
h.mu.Unlock()
|
|
m := h.words(&r, "raised")
|
|
m.Actions = h.offers(&rec, only.Kind)
|
|
m.To = only.Identity
|
|
handle, err := h.showOnHandle(only.Kind, m)
|
|
if err == nil {
|
|
h.mu.Lock()
|
|
r.Sent[only.Kind] = handle
|
|
r.FirstSent = now
|
|
h.mu.Unlock()
|
|
}
|
|
h.persist(&r)
|
|
return err
|
|
}
|
|
|
|
// selfAct does what the router's own ask decided.
|
|
func (h *Holder) selfAct(rec AskRecord, w asks.Warrant) {
|
|
if rec.Act == nil || w.Outcome != asks.OutcomeChosen {
|
|
return
|
|
}
|
|
o, err := w.For(SelfAsker, rec.Ask)
|
|
if err == nil {
|
|
err = o.Performs(rec.Act.act())
|
|
}
|
|
if err != nil {
|
|
h.Logf("[messenger] REFUSED a warrant for its own ask %s: %v", rec.Ask.ID, err)
|
|
return
|
|
}
|
|
switch rec.Act.Do {
|
|
case "unlink":
|
|
if o.Level == asks.Acknowledge {
|
|
// Asked at the desk: only an account still in its first hour, which cannot approve yet, is unlinked
|
|
// this way (ADR 0259 §7, the review of 2026-10-09). Past its hour it decides for itself.
|
|
id := h.identity(rec.Act.Kind, rec.Act.Identity)
|
|
if id == nil || !h.Now().Before(id.ApproveFrom) {
|
|
h.Logf("[messenger] an unlink chosen at the desk was not done: the account's first hour has passed")
|
|
h.announce(Message{Title: "An unlink chosen at the desk was not done",
|
|
Body: "The account's first hour had passed, so only the account itself, or root at the router's " +
|
|
"machine, can unlink it now.", Urgent: true})
|
|
return
|
|
}
|
|
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "at the desk, within the hour after it was linked", true)
|
|
return
|
|
}
|
|
h.unlinkNow(rec.Act.Kind, rec.Act.Identity, "approved by the account itself", false)
|
|
case "link":
|
|
if rec.Act.Link != nil && rec.Act.Link.Identity == rec.Act.Identity {
|
|
h.linkNow(*rec.Act.Link, "approved by the account it replaces")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unlink asks the account linked on a kind whether it is to be unlinked: an approve ask put to it alone.
|
|
func (h *Holder) Unlink(kind string) (map[string]any, error) {
|
|
h.warmRootFree(false) // outside the work: a question it asks reads a recent word
|
|
h.work.Lock()
|
|
defer h.work.Unlock()
|
|
ids := h.identitiesOf(kind)
|
|
if len(ids) == 0 {
|
|
return map[string]any{"kind": kind, "linked": false, "asked": false}, nil
|
|
}
|
|
id := ids[0]
|
|
now := h.Now()
|
|
a := asks.Ask{ID: "unlink-" + kind + "-" + fmt.Sprint(now.Unix()), Headline: "Unlink this account?",
|
|
Explanation: fmt.Sprintf("Unlinking this %s account (%s, number %s) was asked. If you choose Unlink, answers "+
|
|
"from here stop counting as yours until an account is linked again.", Display(kind), id.Display, id.Identity),
|
|
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the account stays linked",
|
|
Options: []asks.Option{{ID: "unlink", Label: "Unlink", Does: "this account stops answering as you", Level: asks.Approve}}}
|
|
if err := h.askSelf(a, id, SelfAct{Do: "unlink", Kind: kind, Identity: id.Identity}); err != nil {
|
|
return nil, err
|
|
}
|
|
h.announceExcept(kind, Message{Title: "An unlink of your " + Display(kind) + " account was asked",
|
|
Body: fmt.Sprintf("It waits for the account itself (%s, number %s) to approve it within the hour. If you "+
|
|
"cannot answer there, you unlink it as root at the router's machine.", id.Display, id.Identity), Urgent: true})
|
|
return map[string]any{"kind": kind, "asked": true, "of": id.Display}, nil
|
|
}
|
|
|
|
// unlinkNow unlinks one account of a kind: told first, by name, then every channel. It unlinks the account it
|
|
// is named, and nothing when another is linked now.
|
|
func (h *Holder) unlinkNow(kind, identity, how string, early bool) {
|
|
if h.Asks == nil {
|
|
return
|
|
}
|
|
id := h.identity(kind, identity)
|
|
if id == nil {
|
|
h.Logf("[messenger] the unlink of %s on %s was not done: that account is not linked", identity, kind)
|
|
return
|
|
}
|
|
_ = h.showOn(kind, Message{Title: "This account is no longer linked as you",
|
|
Body: "Unlinked, " + how + ". Answers from here no longer count as yours.", Urgent: true, To: id.Identity})
|
|
if err := h.Asks.DeleteIdentity(identityKey(kind, id.Identity)); err != nil {
|
|
h.Logf("[messenger] the unlink of %s on %s could not be recorded: %v", id.Display, kind, err)
|
|
return
|
|
}
|
|
h.unlinked(kind, Unlinked{Identity: *id, At: h.Now(), Early: early})
|
|
after := "A new link within a day waits for that account's approval, and that account itself can be linked " +
|
|
"again only after a day."
|
|
if early {
|
|
after = "Any account, this one too, can be linked again at once with a code shown on your desk."
|
|
}
|
|
h.announce(Message{Title: "No " + Display(kind) + " account is linked as you any more",
|
|
Body: fmt.Sprintf("Unlinked: %s (number %s), %s. %s", id.Display, id.Identity, how, after), Urgent: true})
|
|
h.record(Sent{At: h.Now(), Channel: kind, Key: "(link)", What: "unlinked", Outcome: id.Display + ", " + how})
|
|
}
|
|
|
|
// linkNow links an account as the operator's: acknowledges at once, approves after the hour.
|
|
func (h *Holder) linkNow(id Identity, how string) {
|
|
if h.Asks == nil {
|
|
return
|
|
}
|
|
// One account per kind, checked again as it is linked: a link approved, or allowed by root, while
|
|
// another account was linked meanwhile is not made (the review of 2026-10-09).
|
|
for _, other := range h.identitiesOf(id.Kind) {
|
|
if other.Identity != id.Identity {
|
|
h.Logf("[messenger] the link of %s on %s was not made: %s is linked there", id.Display, id.Kind, other.Display)
|
|
h.announce(Message{Title: "A " + Display(id.Kind) + " link was not made",
|
|
Body: fmt.Sprintf("%s (number %s) was not linked, %s: the account %s (number %s) is linked there now. "+
|
|
"One account is linked per channel.", id.Display, id.Identity, how, other.Display, other.Identity),
|
|
Urgent: true})
|
|
return
|
|
}
|
|
return // already linked
|
|
}
|
|
now := h.Now()
|
|
id.Linked, id.ApproveFrom = now, now.Add(LinkedApproveAfter)
|
|
if err := h.Asks.PutIdentity(identityKey(id.Kind, id.Identity), id); err != nil {
|
|
h.Logf("[messenger] the link of %s on %s could not be recorded: %v", id.Display, id.Kind, err)
|
|
return
|
|
}
|
|
_ = h.Asks.DeleteLink(id.Kind)
|
|
from := id.ApproveFrom.In(h.zone()).Format("15:04")
|
|
h.announce(Message{Title: "A " + Display(id.Kind) + " account is now linked as you",
|
|
Body: fmt.Sprintf("The %s account %s (number %s) is linked, %s. It can answer acknowledgements now, and "+
|
|
"approve from %s. If this was not you, unlink it before then.", Display(id.Kind), id.Display, id.Identity, how, from),
|
|
Urgent: true})
|
|
h.record(Sent{At: now, Channel: id.Kind, Key: "(link)", What: "linked " + id.Display, Outcome: how})
|
|
}
|
|
|
|
// breakGlass does what root asked at the router's machine: a file `unlink.<kind>` or `allow-link.<kind>` in
|
|
// the router's directory, owned by root. Called every minute from Tick, which holds the work: it takes no
|
|
// lock of its own (the review of 2026-10-09 found it took the work again, and so hung the router's minute
|
|
// the first time root used it). Loud: what root did, or why nothing was done, is said on every channel, and
|
|
// every open question of the router's own ends, since root decided in its place.
|
|
func (h *Holder) breakGlass() {
|
|
if h.StateDir == "" {
|
|
return
|
|
}
|
|
for _, pattern := range []string{"unlink.*", "allow-link.*"} {
|
|
files, _ := filepath.Glob(filepath.Join(h.StateDir, pattern))
|
|
for _, f := range files {
|
|
name := filepath.Base(f)
|
|
what, kind, _ := strings.Cut(name, ".")
|
|
info, err := os.Lstat(f)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
owner := fileOwner(info)
|
|
_ = os.Remove(f)
|
|
if !info.Mode().IsRegular() || owner != 0 {
|
|
h.Logf("[messenger] %s was refused: it is not a file root made", name)
|
|
h.announce(Message{Title: "A request to change who is you was refused",
|
|
Body: "A file asking it was left at the router's machine by someone other than root. Nothing changed.",
|
|
Urgent: true})
|
|
continue
|
|
}
|
|
where := "at the router's machine"
|
|
if h.Node != "" {
|
|
where = "at " + h.Node
|
|
}
|
|
done := ""
|
|
switch what {
|
|
case "unlink":
|
|
if ids := h.identitiesOf(kind); len(ids) > 0 {
|
|
h.unlinkNow(kind, ids[0].Identity, where+", as root", false)
|
|
done = fmt.Sprintf("the %s account %s (number %s) was unlinked", Display(kind), ids[0].Display, ids[0].Identity)
|
|
} else {
|
|
done = "nothing was unlinked: no " + Display(kind) + " account is linked"
|
|
}
|
|
case "allow-link":
|
|
if p, err := h.Asks.GetLink(kind); err == nil && p != nil && p.Confirmed {
|
|
h.linkNow(Identity{Kind: kind, Identity: p.Identity, Display: p.Display}, "allowed "+where+", as root")
|
|
done = fmt.Sprintf("the waiting link of %s (number %s) was allowed", p.Display, p.Identity)
|
|
} else {
|
|
done = "nothing was linked: no link with its code given waits on " + Display(kind)
|
|
}
|
|
// And whatever bar an unlink set is lifted: root decided who may be linked.
|
|
if h.recentUnlink(kind) != nil {
|
|
h.liftBar(kind)
|
|
done += "; the bar on linking an account again after an unlink was lifted"
|
|
}
|
|
}
|
|
ended := h.endSelfAsks("root decided " + where)
|
|
body := "Root " + where + " asked it: " + done + "."
|
|
if ended > 0 {
|
|
body += fmt.Sprintf(" %d open question(s) of the router's own were ended.", ended)
|
|
}
|
|
h.Logf("[messenger] break-glass %s: %s", name, done)
|
|
h.announce(Message{Title: "Root changed who is you", Body: body, Urgent: true})
|
|
}
|
|
}
|
|
}
|
|
|
|
// endSelfAsks ends every open ask of the router's own: root decided in their place.
|
|
func (h *Holder) endSelfAsks(why string) int {
|
|
h.mu.Lock()
|
|
var keys []string
|
|
for k, e := range h.asks {
|
|
if e.rec.Asker == SelfAsker && e.rec.State == StateOpen {
|
|
keys = append(keys, k)
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
sort.Strings(keys)
|
|
n := 0
|
|
for _, k := range keys {
|
|
if err := h.closeAsk(k, asks.OutcomeCancelled, why, nil, "", nil); err == nil {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// fileOwner is the uid owning a file; a variable so a test, which is not root, can stand for root.
|
|
var fileOwner = func(info os.FileInfo) uint32 {
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
return st.Uid
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// unlinked keeps the last unlink of a kind, a day.
|
|
func (h *Holder) unlinked(kind string, u Unlinked) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
if h.lastUnlink == nil {
|
|
h.lastUnlink = map[string]Unlinked{}
|
|
}
|
|
h.lastUnlink[kind] = u
|
|
if h.Asks != nil {
|
|
_ = h.Asks.PutUnlinked(kind, u)
|
|
}
|
|
}
|
|
|
|
// liftBar forgets the last unlink of a kind: root's allow-link at the router's machine.
|
|
func (h *Holder) liftBar(kind string) {
|
|
h.mu.Lock()
|
|
delete(h.lastUnlink, kind)
|
|
h.mu.Unlock()
|
|
if h.Asks != nil {
|
|
_ = h.Asks.PutUnlinked(kind, Unlinked{})
|
|
}
|
|
}
|
|
|
|
// recentUnlink is the account unlinked deliberately on a kind less than a day ago, if any: an early desk
|
|
// unlink bars nothing.
|
|
func (h *Holder) recentUnlink(kind string) *Unlinked {
|
|
h.mu.Lock()
|
|
u, ok := h.lastUnlink[kind]
|
|
h.mu.Unlock()
|
|
if !ok && h.Asks != nil {
|
|
if got, err := h.Asks.GetUnlinked(kind); err == nil && got != nil {
|
|
u, ok = *got, true
|
|
}
|
|
}
|
|
if !ok || u.Early || u.At.IsZero() || h.Now().Sub(u.At) >= RelinkWaits {
|
|
return nil
|
|
}
|
|
return &u
|
|
}
|
|
|
|
// announceExcept says a message on every kind that can send but one.
|
|
func (h *Holder) announceExcept(kind string, m Message) {
|
|
if h.Bench == nil {
|
|
return
|
|
}
|
|
for _, k := range h.Bench.Kinds() {
|
|
if k.Kind != kind && h.Bench.Ready(k.Kind) == nil {
|
|
_ = h.showOn(k.Kind, m)
|
|
}
|
|
}
|
|
}
|