diff --git a/asks/faketelegram_test.go b/asks/faketelegram_test.go index 9f31fe8..1ad1a1d 100644 --- a/asks/faketelegram_test.go +++ b/asks/faketelegram_test.go @@ -143,11 +143,32 @@ func (f *fakeTelegram) typed(from int64, chat int64, chatType, text string) { "chat": map[string]any{"id": chat, "type": chatType}}}) } -// tapped is a button tapped on a message the bot sent, carrying the button's data as the service gives it. +// tapped is a button tapped on a message the bot sent, carrying the button's data and the message's words as +// the service gives them: the words the message shows now (its last send or edit), as on the phone. func (f *fakeTelegram) tapped(from int64, chat int64, chatType string, onMessage int64, data string) { + f.tappedOn(from, chat, chatType, onMessage, data, f.textOf(onMessage)) +} + +// tappedOn is a tap on a message showing the words given: what a message changed under the operator's thumb, +// or forwarded and edited elsewhere, carries. +func (f *fakeTelegram) tappedOn(from int64, chat int64, chatType string, onMessage int64, data, text string) { f.push(map[string]any{"callback_query": map[string]any{"id": fmt.Sprintf("cb-%d", f.nextID()), "data": data, - "from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)}, - "message": map[string]any{"message_id": onMessage, "chat": map[string]any{"id": chat, "type": chatType}}}}) + "from": map[string]any{"id": from, "is_bot": false, "first_name": fmt.Sprintf("Account %d", from)}, + "message": map[string]any{"message_id": onMessage, "text": text, + "chat": map[string]any{"id": chat, "type": chatType}}}}) +} + +// textOf is what a message the bot sent shows now. +func (f *fakeTelegram) textOf(id int64) string { + f.mu.Lock() + defer f.mu.Unlock() + text := "" + for _, m := range f.sent { + if m.ID == id { + text = m.Text + } + } + return text } func (f *fakeTelegram) nextID() int64 { diff --git a/asks/harness_test.go b/asks/harness_test.go index 8101b6a..4c4b464 100644 --- a/asks/harness_test.go +++ b/asks/harness_test.go @@ -48,9 +48,12 @@ type lab struct { mu sync.Mutex conditions []map[string]any - desk []string // what the desk was shown - procs []*exec.Cmd - logs map[string]*bytes.Buffer + // notFree are the machines the controller's root-free verb answers as not root-free. + notFree map[string]string + rootAsked int + desk []string // what the desk was shown + procs []*exec.Cmd + logs map[string]*bytes.Buffer } // clones is where each core repository the proof builds is checked out: side by side in MESH_LAB_REPOS (beside @@ -116,7 +119,9 @@ func (l *lab) run(dir string, env []string, name string, args ...string) string // compose asks the controller at its commit for the bus it would compose, then (bus set) to raise it. func (l *lab) compose(bus string) { - env := []string{"GOFLAGS=-mod=vendor", "GOPROXY=off", "MESH_LAB_ASKS_OUT=" + l.dir, + // The anchor composed as a machine the controller measured root-free: verified-sender reaches the router + // only then (ADR 0259 §8). The not-free case is played live, through the root-free verb, in the proof. + env := []string{"GOFLAGS=-mod=vendor", "GOPROXY=off", "MESH_LAB_ASKS_OUT=" + l.dir, "MESH_LAB_ASKS_ROOT_FREE=true", "MESH_LAB_ASKS_CATALOGUE=" + filepath.Join(l.repos.catalogue, "modules"), "MESH_LAB_ASKS_RUNTIME=" + filepath.Join(l.repos.tools, "node-tools", "module.json")} if bus != "" { @@ -343,9 +348,36 @@ func (w *lockedWriter) Write(p []byte) (int, error) { return w.w.Write(p) } -// playController answers the controller's `conditions` verb from the lab's list, as the router asks it. +// playController answers the controller's `conditions` verb from the lab's list, and its `root-free` verb from +// the lab's word on each machine (free unless notFree names it), as the router asks them. That a machine is +// root-free is the controller's own judgement, proven in its repository (probe_agent_root_test.go); here the +// lab says it, so the proof can show what the router does with each answer. func (l *lab) playController() { l.t.Helper() + free, err := l.observer.Subscribe("mesh.seat.mesh-controller.tool.root-free", func(m *nats.Msg) { + var args struct { + Machines []string `json:"machines"` + } + _ = json.Unmarshal(m.Data, &args) + var out []map[string]any + l.mu.Lock() + for _, name := range args.Machines { + why, not := l.notFree[name] + if !not { + why = "the lab says no agent can become root here" + } + out = append(out, map[string]any{"machine": name, "free": !not, "why": why, + "judged": time.Now().UTC().Format(time.RFC3339Nano)}) + } + l.rootAsked++ + l.mu.Unlock() + raw, _ := json.Marshal(map[string]any{"result": map[string]any{"machines": out}, "node": machine}) + _ = m.Respond(raw) + }) + if err != nil { + l.t.Fatal(err) + } + l.t.Cleanup(func() { _ = free.Unsubscribe() }) sub, err := l.observer.Subscribe("mesh.seat.mesh-controller.tool.conditions", func(m *nats.Msg) { l.mu.Lock() list := append([]map[string]any{}, l.conditions...) @@ -409,7 +441,8 @@ func (l *lab) sayCondition(event string, c map[string]any) { // carries the desk channel: it says it is ready, and keeps what the router shows it — the link's code. func (l *lab) playDesk(ctx context.Context) { l.t.Helper() - nc, _ := l.as(machine + ".node-tools") + // The desk channel runs as an account of its own (it carries a link's code), on its own credential. + nc, _ := l.as(machine + ".desk-channel") stand := func() { raw, _ := json.Marshal(map[string]any{"ready": true, "at": time.Now().UTC()}) js, _ := nc.JetStream() diff --git a/asks/live-acceptance.sh b/asks/live-acceptance.sh index 3fe7ff8..710688e 100644 --- a/asks/live-acceptance.sh +++ b/asks/live-acceptance.sh @@ -3,9 +3,9 @@ # terminal on the control node. The lab's proof (TestTheOperatorsAnswerEndToEnd) ran the same flow with a fake # Telegram; this runs it once for real, with a question that changes nothing. # -# sh live-acceptance.sh check, ask the drill, wait for the answer, read the record +# sh live-acceptance.sh check, ask the rehearsal, wait for the answer, read the record # -# It reads and asks only: no setting, assignment or push. The one thing it starts is the drill, a question the +# It reads and asks only: no setting, assignment or push. The one thing it starts is a rehearsal, a question the # operator answers on the phone; approving it performs nothing and is recorded as the operator's decision. # # It stops at the first check that fails and says what to do. Exit 0 means: the question reached the phone @@ -31,11 +31,11 @@ ids=$($mc ask messenger messenger_identities) || stop "the router does not answe echo "$ids" echo "$ids" | grep -q '"telegram"' || stop "no Telegram account is linked: send /start to the bot, then the code from the desk" -echo "4. The drill: a question on your phone. Approve it there within 15 minutes." -said=$($mc drill --for 15m) || stop "the drill could not be asked" +echo "4. The rehearsal: a question on your phone. Approve it there within 15 minutes." +said=$($mc rehearse --for 15m) || stop "the rehearsal could not be asked" echo "$said" -id=$(echo "$said" | sed -n 's/^drill \([A-Za-z0-9_-]*\) asked.*/\1/p') -[ -n "$id" ] || stop "the drill did not say its id" +id=$(echo "$said" | sed -n 's/^rehearsal \([A-Za-z0-9_-]*\) asked.*/\1/p') +[ -n "$id" ] || stop "the rehearsal did not say its id" echo "5. Waiting for your answer in the hand-act log (every 10 s, at most 16 minutes)." i=0 @@ -44,12 +44,12 @@ while [ $i -lt 96 ]; do mine=$(echo "$acts" | tr -d '\n' | grep -o "{[^{}]*\"ask\": *\"$id\"[^{}]*}" || true) if [ -n "$mine" ]; then echo "$mine" - echo "$mine" | grep -q 'drill=decline' && stop "the drill was declined: run it again and choose Approve to see an approval recorded" - echo "$mine" | grep -q 'drill=approve' || stop "the drill's record names no approval" + echo "$mine" | grep -q 'rehearsal=decline' && stop "the rehearsal was declined: run it again and choose Approve to see an approval recorded" + echo "$mine" | grep -q 'rehearsal=approve' || stop "the rehearsal's record names no approval" echo "$mine" | grep -q '"via": *"telegram (telegram), user id verified"' || stop "the record does not say the answer came through Telegram from a verified account" echo "$mine" | grep -q '"P1"' || stop "the record does not carry the proof" - echo "$mine" | grep -q '"outcome": *"done"' || stop "the drill's act did not end done" - echo "ACCEPTED: the drill was approved on Telegram, acted on once, and recorded as your decision." + echo "$mine" | grep -q '"outcome": *"done"' || stop "the rehearsal's act did not end done" + echo "ACCEPTED: the rehearsal was approved on Telegram, acted on once, and recorded as your decision." exit 0 fi i=$((i + 1)) diff --git a/asks/proof_test.go b/asks/proof_test.go index c90260d..19feac0 100644 --- a/asks/proof_test.go +++ b/asks/proof_test.go @@ -109,13 +109,19 @@ func TestTheOperatorsAnswerEndToEnd(t *testing.T) { first = a.ask("a1", "Flip the lab's switch?", time.Hour) firstMessage = l.tg.waitFor("the first question with its buttons", operatorAccount, since, 90*time.Second, func(m tgMessage) bool { return !m.Edited && button(m, "Approve") != "" && button(m, "Decline") != "" }) - if !strings.Contains(firstMessage.Text, "Flip the lab's switch?") || !strings.Contains(firstMessage.Text, "approve or decline") { - t.Errorf("the question does not say what is asked and why: %q", firstMessage.Text) + for _, says := range []string{"Flip the lab's switch?", "approve or decline", "lab-asker", + "the switch is flipped, once", "nothing is flipped"} { + if !strings.Contains(firstMessage.Text, says) { + t.Errorf("the question does not say %q (what is asked, why, by whom, what each answer does, what "+ + "no answer does): %q", says, firstMessage.Text) + } } tapped := time.Now() l.tg.tapped(operatorAccount, operatorAccount, "private", firstMessage.ID, button(firstMessage, "Approve")) l.tg.waitFor("the refusal of an approval inside the hour", operatorAccount, tapped, 90*time.Second, - func(m tgMessage) bool { return strings.Contains(m.Text, "can approve from") }) + func(m tgMessage) bool { + return strings.Contains(m.Text, "That answer was not taken") && strings.Contains(m.Text, "linked at") + }) if !a.noWordOn("a1", 2*time.Second) || len(a.performed()) != 0 { t.Fatalf("an approval inside the hour was a warrant: %v", a.performed()) } @@ -152,7 +158,7 @@ func TestTheOperatorsAnswerEndToEnd(t *testing.T) { if w.AskDigest != first.Digest() { t.Errorf("the warrant names the digest %s, not the ask's %s", w.AskDigest, first.Digest()) } - if got := a.performed(); len(got) != 1 || got[0]["switch"] != "approve" { + if got := a.performed(); len(got) != 1 || got[0]["arg.switch"] != "approve" { t.Fatalf("performed %v, want the one bound act once", got) } // Every copy says how it ended, its buttons gone. @@ -197,20 +203,19 @@ func TestTheOperatorsAnswerEndToEnd(t *testing.T) { m := l.tg.waitFor("the second question", operatorAccount, since, 90*time.Second, func(m tgMessage) bool { return !m.Edited && button(m, "Approve") != "" }) // The buttons' data reached a stranger (a forwarded question), who taps in their own chat with the bot: - // refused, and the operator is told which account tried. The stranger is told nothing. - tapped := time.Now() + // the channel never showed that message there, so the tap goes nowhere, and the stranger is told + // nothing. (A stranger's answer that does reach the router is refused there and told to the operator by + // name: the router's own test.) l.tg.tapped(strangerAccount, strangerAccount, "private", m.ID, button(m, "Approve")) - l.tg.waitFor("the operator told another account tried", operatorAccount, tapped, 90*time.Second, - func(m tgMessage) bool { - return strings.Contains(m.Text, "Another account tried to answer") && strings.Contains(m.Text, fmt.Sprint(strangerAccount)) - }) - if got := l.tg.since(strangerAccount, since); len(got) != 0 { - t.Errorf("the bot answered the stranger: %+v", got) - } + // The operator taps a message whose words are not the ones the channel put there. + l.tg.tappedOn(operatorAccount, operatorAccount, "private", m.ID, button(m, "Approve"), "Flip ALL the switches?") // The operator taps in a group the bot is in: not their own chat, dropped by the channel. l.tg.tapped(operatorAccount, groupChat, "supergroup", m.ID, button(m, "Approve")) if !a.noWordOn("a2", 3*time.Second) { - t.Fatal("a tap from another account, or from a group, was a warrant") + t.Fatal("a tap from another account, on changed words, or from a group, was a warrant") + } + if got := l.tg.since(strangerAccount, since); len(got) != 0 { + t.Errorf("the bot answered the stranger: %+v", got) } if got := l.tg.since(groupChat, since); len(got) != 0 { t.Errorf("the bot answered in a group: %+v", got) @@ -278,23 +283,42 @@ func TestTheOperatorsAnswerEndToEnd(t *testing.T) { }) // --- 7. While an agent can become root where the router runs, nothing proven there approves. - t.Run("while an agent can become root on the router's machine, approve is not offered", func(t *testing.T) { - key := "machine." + machine + ".agent-root" - l.raise(map[string]any{"key": key, "kind": "agent-root", "severity": "urgent", - "summary": "an agent can become root on anchor without a person", - "headline": "Root without you on anchor", "explanation": "A program working for you can become root.", - "subject": map[string]any{"scope": "machine", "id": machine, "machine": machine}, - "raised": time.Now().UTC().Format(time.RFC3339Nano)}) - time.Sleep(2 * time.Second) + t.Run("while the controller cannot say root is free where the router runs, nothing approves", func(t *testing.T) { + // An approval the operator already sees on the phone, then root stops being free. since := time.Now() - a.ask("a3", "Flip it while root is open?", time.Hour) - m := l.tg.waitFor("the third question", operatorAccount, since, 90*time.Second, - func(m tgMessage) bool { return !m.Edited && button(m, "Decline") != "" }) - if button(m, "Approve") != "" { - t.Errorf("approve was offered on Telegram while an agent can become root there: %v", labels(m)) + a.ask("a5", "Flip it while root is checked?", time.Hour) + m := l.tg.waitFor("the question asked while root was free", operatorAccount, since, 90*time.Second, + func(m tgMessage) bool { return !m.Edited && button(m, "Approve") != "" }) + l.mu.Lock() + l.notFree = map[string]string{machine: "an agent can become root without a person"} + asked := l.rootAsked + l.mu.Unlock() + tapped := time.Now() + l.tg.tapped(operatorAccount, operatorAccount, "private", m.ID, button(m, "Approve")) + l.tg.waitFor("the refusal of an approval while root is not free", operatorAccount, tapped, 90*time.Second, + func(m tgMessage) bool { return strings.Contains(m.Text, "That answer was not taken") }) + l.mu.Lock() + askedAgain := l.rootAsked > asked + l.mu.Unlock() + if !askedAgain { + t.Error("the router judged an approval without asking the controller whether root is free") } - l.clear(key) - if err := a.client.Cancel("a3"); err != nil { + if !a.noWordOn("a5", 2*time.Second) { + t.Fatal("an approval was a warrant while root was not free") + } + // A new ask that needs an approval is refused to its asker, in words: no channel can carry it now. + a.ask("a3", "Flip it while root is not free?", time.Hour) + w := a.warrantFor("a3", 30*time.Second) + if w.Outcome != asks.OutcomeRefused || w.By != nil { + t.Errorf("an approving ask while root is not free: %+v", w) + } + if got := a.performed(); len(got) != 1 { + t.Fatalf("performed %v while root was not free", got) + } + l.mu.Lock() + l.notFree = nil + l.mu.Unlock() + if err := a.client.Cancel("a5"); err != nil { t.Errorf("taking the ask back: %v", err) } })