openvibers/openvibe.link Commits
the computer-control review, core side: a local resume, a local approval id, an honest stop (#102)
Changes
nnew file mode 100644@@@ -0,0 +1,126 @@++package main++++import (++ "bytes"++ "context"++ "encoding/json"++ "os"++ "os/user"++ "runtime"++ "strings"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/config"++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/node"++)++++func reviewSocket(t *testing.T, h localctl.Handler) *globals {++ t.Helper()++ dir := shortHome(t)++ g := &globals{paths: config.HomePaths(dir)}++ ctx, cancel := context.WithCancel(context.Background())++ t.Cleanup(cancel)++ go localctl.Serve(ctx, g.paths.SocketPath(), h)++ for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {++ if _, err := os.Stat(g.paths.SocketPath()); err == nil {++ return g++ }++ time.Sleep(time.Millisecond * 10)++ }++ t.Fatal("socket did not start")++ return nil++}++++func TestStopReportsPersistenceFailure(t *testing.T) {++ g := reviewSocket(t, func(localctl.Request) localctl.Response {++ return localctl.Response{Error: "stopped, but the latch could not be saved: fixture"}++ })++ var out bytes.Buffer++ err := cmdStop(g, &out)++ if err == nil || !strings.Contains(err.Error(), "could not be saved") || out.Len() != 0 {++ t.Fatalf("failure hidden: %v %q", err, out.String())++ }++ if _, err := os.Stat(g.paths.LatchFile()); !os.IsNotExist(err) {++ t.Fatal("server failure treated as offline fallback")++ }++}++++func TestApprovalDisplaysEscapeControls(t *testing.T) {++ hostile := "x\n\r\x1b[2J\u202ey\u2066z\u0085"++ a := node.Approval{ID: hostile, Kind: hostile, Summary: hostile}++ g := reviewSocket(t, func(localctl.Request) localctl.Response {++ b, _ := json.Marshal([]node.Approval{a})++ return localctl.Response{OK: true, Data: b}++ })++ var out bytes.Buffer++ if err := cmdApprovals(g, false, &out); err != nil {++ t.Fatal(err)++ }++ for _, s := range []string{out.String(), slotLabel(a)} {++ if strings.ContainsAny(s, "\x1b\r\u202e\u2066\u0085") || strings.Contains(s, "x\n") {++ t.Fatalf("unescaped approval: %q", s)++ }++ if !strings.Contains(s, `\u202e`) {++ t.Fatalf("bidi not visibly escaped: %q", s)++ }++ }++}++++func TestInstallRefusesPrivilegedComputer(t *testing.T) {++ dir := shortHome(t)++ paths := config.HomePaths(dir)++ cfg := config.Default()++ cfg.Plugins = []config.PluginConfig{{Name: "computer", Config: map[string]any{"level": "trusted"}}}++ if err := config.Save(paths.ConfigFile(), cfg); err != nil {++ t.Fatal(err)++ }++ // Refusal must precede service manager calls and even directory ownership changes.++ g := &globals{paths: paths}++ var out bytes.Buffer++ if err := cmdInstall(g, "", &out); err == nil || !strings.Contains(err.Error(), "computer") {++ t.Fatalf("unsafe install not refused: %v", err)++ }++}++++func TestComputerServiceAccountsAndSharedHost(t *testing.T) {++ cfg := config.Default()++ computer := config.PluginConfig{Name: "computer", Config: map[string]any{"level": "trusted"}}++ cfg.Plugins = []config.PluginConfig{computer}++ if err := validateComputerService(cfg, ""); err == nil {++ t.Fatal("default privileged account allowed")++ }++ if runtime.GOOS != "windows" {++ if err := validateComputerService(cfg, "root"); err == nil {++ t.Fatal("UID 0 allowed")++ }++ }++ if current, err := user.Current(); err == nil && current.Uid != "0" {++ if err := validateComputerService(cfg, current.Username); err != nil {++ t.Fatal("ordinary account refused", err)++ }++ }++ computer.Config["desk_isolation"] = "sandbox"++ if err := validateComputerService(cfg, ""); err == nil {++ t.Fatal("shared host without worker allowed")++ }++ cfg.Worker.Enabled = true++ if err := validateComputerService(cfg, ""); (err == nil) != (runtime.GOOS == "linux") {++ t.Fatalf("shared host: %v", err)++ }++ // The installer gives a shared desk host level trusted or observe; nothing else is that host.++ computer.Config["level"] = "observe"++ if err := validateComputerService(cfg, ""); (err == nil) != (runtime.GOOS == "linux") {++ t.Fatalf("shared host at observe: %v", err)++ }++ computer.Config["level"] = "full"++ if err := validateComputerService(cfg, ""); err == nil {++ t.Fatal("a shared desk host at full allowed a privileged service")++ }++ computer.Config["level"] = "trusted"++ cfg.Plugins[0].Disabled = true++ if err := validateComputerService(cfg, ""); err != nil {++ t.Fatal("disabled computer blocks robot install", err)++ }++}@@@ -26,6 +26,7 @@ import ("log/slog""net""os"++ "os/user""path/filepath""runtime""sort"@@@ -627,6 +628,14 @@ func serviceOptions(g *globals, user string) service.Options {}func cmdInstall(g *globals, user string, stdout io.Writer) error {++ cfg, err := loadConfig(g)++ if err != nil {++ return err++ }++ if err := validateComputerService(cfg, user); err != nil {++ return err++ }++if err := g.paths.Ensure(); err != nil {return fmt.Errorf("cannot create %s (run with sudo): %w", g.paths.ConfigDir, err)}@@@ -650,11 +659,18 @@ func cmdInstall(g *globals, user string, stdout io.Writer) error {}func cmdStop(g *globals, stdout io.Writer) error {-- _, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second)++ resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second)if err == nil {fmt.Fprintln(stdout, "Stopped. Every actuator is held stopped until `openvibe-node resume`.")return nil}++ if resp.Error != "" {++ return fmt.Errorf("stop failed: %s", resp.Error)++ }++ var op *net.OpError++ if !errors.As(err, &op) || op.Op != "dial" {++ return fmt.Errorf("stop failed: %w", err)++ }// No running Node answered: latch it in the file, which a running Node polls and a starting Node obeys.if ferr := safety.WriteLocalFile(g.paths.LatchFile(), true); ferr != nil {return fmt.Errorf("could not reach the node (%v) nor write %s (%v); run with sudo", err, g.paths.LatchFile(), ferr)@@@ -722,7 +738,9 @@ func cmdApprovals(g *globals, asJSON bool, stdout io.Writer) error {}for _, a := range list {age := time.Since(time.UnixMilli(a.At)).Round(time.Second)-- fmt.Fprintf(stdout, "%s %s, %s ago: %s\n", a.ID, a.Kind, age, a.Summary)++ // The id is quoted so it can be copied into a command; kind and summary are untrusted text and are shown++ // with their control, ANSI and bidi characters made visible.++ fmt.Fprintf(stdout, "%q %s, %s ago: %s\n", a.ID, safety.DisplayText(a.Kind), age, safety.DisplayText(a.Summary))}fmt.Fprintln(stdout, "\nopenvibe-node approve <ID> lets one run; openvibe-node deny <ID> refuses it.")return nil@@@ -829,6 +847,9 @@ func deviceLine(device, principal, server string) string {}func latchText(l safety.LatchState) string {++ if l.Computer {++ return "STOPPED for computer control (an e-stop on this computer); `openvibe-node resume` on this machine is required"++ }switch {case l.Local && l.Remote:return "STOPPED locally and by the server (" + l.RemoteReason + "); `openvibe-node resume` releases both"@@@ -964,3 +985,41 @@ func reportCommunityProfile(ctx context.Context, server string, profile json.Raw}fmt.Fprintf(stdout, "The robot's community profile %s (version %s) is the one Bot reviewed and signed (key %s).\n", res.ID, res.Version, res.KeyID)}++++// validateComputerService refuses a privileged service for a computer plugin that is not the documented++// shared desk host: the computer plugin must not run as root on an ordinary machine.++func validateComputerService(cfg *config.Config, account string) error {++ for _, p := range cfg.Plugins {++ if p.Disabled || p.Name != "computer" {++ continue++ }++ if sharedDeskHost(cfg, p) {++ continue++ }++ if account == "" {++ return errors.New("computer control requires an unprivileged service user; only a configured shared desk host may install a privileged service")++ }++ u, err := user.Lookup(account)++ if err != nil {++ return fmt.Errorf("computer service user: %w", err)++ }++ if u.Uid == "0" {++ return errors.New("computer service user must not have UID 0")++ }++ }++ return nil++}++++// sharedDeskHost is the one documented mode that runs as root (docs/computer.md, "Desk hosts for other++// people"): Linux, every desk in the worker's sandbox, the worker on, and the computer plugin at the level++// the installer gives such a host (trusted, or observe: nobody sits at it to approve).++func sharedDeskHost(cfg *config.Config, p config.PluginConfig) bool {++ if runtime.GOOS != "linux" || !cfg.Worker.Enabled || p.Config["desk_isolation"] != "sandbox" {++ return false++ }++ switch p.Config["level"] {++ case "trusted", "observe":++ return true++ }++ return false++}@@@ -33,6 +33,7 @@ import ("github.com/OpenVibers/OpenVibe.Node/internal/localctl""github.com/OpenVibers/OpenVibe.Node/internal/node"++ "github.com/OpenVibers/OpenVibe.Node/internal/safety")// trayPoll is how often the tray asks the Node what changed.@@@ -69,7 +70,7 @@ func viewOf(st *node.Status, list []node.Approval, err error) trayView {if lv, ok := st.Telemetry.Sensors["computer_level"].(string); ok {v.Level = lv}-- v.Stopped = st.Latch.Local || st.Latch.Remote++ v.Stopped = st.Latch.Local || st.Latch.Remote || st.Latch.Computerlink := "offline"if st.Link.Connected {link = "online"@@@ -121,16 +122,22 @@ func panelURL(server string) string {// slotLabel is one waiting request in the menu: its kind and what it would do, cut to fit a menu.func slotLabel(a node.Approval) string {-- s := strings.Join(strings.Fields(a.Summary), " ")++ s := safety.DisplayText(a.Summary)if len([]rune(s)) > 70 {s = string([]rune(s)[:69]) + "…"}if a.Kind != "" {-- return a.Kind + ": " + s++ return safety.DisplayText(a.Kind) + ": " + s}return s}++// traySlot binds the rendered label and both buttons to one immutable approval.++type traySlot struct {++ Label string++ Approval node.Approval++}++// trayUI is the notification-area side (systray on Linux and Windows; a fake in tests).type trayUI interface {SetIcon(state string)@@@ -138,7 +145,7 @@ type trayUI interface {SetHeader(string)SetLevel(string)SetStopped(bool)-- SetSlots([]string) // one label per waiting request, at most traySlots; the rest hidden++ SetSlots([]traySlot) // one label per waiting request, at most traySlots; the rest hiddenSetPanel(bool)}@@@ -168,12 +175,13 @@ func (nopNotifier) Shut() {}// trayCore polls the Node and drives the UI and the notifications; the platform files only wire it up.type trayCore struct {-- sock string-- ui trayUI-- note notifier-- mu sync.Mutex-- view trayView-- shown map[string]bool // approval ids with a notification up++ sock string++ ui trayUI++ note notifier++ pollMu sync.Mutex // serialize concurrent UI refreshes++ mu sync.Mutex++ view trayView++ shown map[string]bool // approval ids with a notification up}func newTrayCore(sock string, ui trayUI, note notifier) *trayCore {@@@ -189,6 +197,8 @@ func (t *trayCore) call(req localctl.Request) (localctl.Response, error) {// tick asks the Node for its status and approvals, says the tray is there, and updates everything.func (t *trayCore) tick() trayView {++ t.pollMu.Lock()++ defer t.pollMu.Unlock()var st *node.Statusvar list []node.Approvalresp, err := t.call(localctl.Request{Cmd: localctl.CmdStatus})@@@ -229,33 +239,23 @@ func (t *trayCore) tick() trayView {for _, a := range fresh {t.note.Ask(a, "")}-- labels := make([]string, 0, traySlots)++ slots := make([]traySlot, 0, traySlots)for i, a := range v.Waiting {if i == traySlots {break}-- labels = append(labels, slotLabel(a))++ slots = append(slots, traySlot{Label: slotLabel(a), Approval: a})}t.ui.SetIcon(v.State)t.ui.SetTooltip(v.Tooltip)t.ui.SetHeader(v.Header)t.ui.SetLevel(v.Level)t.ui.SetStopped(v.Stopped)-- t.ui.SetSlots(labels)++ t.ui.SetSlots(slots)t.ui.SetPanel(v.Panel != "")return v}--// slotID is the approval id the menu's slot i shows now ("" if none).--func (t *trayCore) slotID(i int) (node.Approval, bool) {-- t.mu.Lock()-- defer t.mu.Unlock()-- if i < 0 || i >= len(t.view.Waiting) {-- return node.Approval{}, false-- }-- return t.view.Waiting[i], true--}--// answer approves or denies one request; a refusal (the agent just used the mouse or keyboard) asks again.func (t *trayCore) answer(a node.Approval, approved bool) error {cmd := localctl.CmdDeny@@@ -286,6 +286,7 @@ func (t *trayCore) answerByID(id string, approved bool) error {if t.view.Waiting[i].ID == id {a := t.view.Waiting[i]found = &a++ break}}t.mu.Unlock()@@@ -458,3 +459,16 @@ func trayICO(state string) []byte {out.Write(dib.Bytes())return out.Bytes()}++++// forwardTrayClicks captures the nonce when the button is created, not at click++// handling time. Removing the menu closes its channel and ends this goroutine.++func forwardTrayClicks(clicks <-chan struct{}, answers chan<- trayAnswer, nonce string, approved bool) {++ go func() {++ for range clicks {++ select {++ case answers <- trayAnswer{ID: nonce, Approved: approved}:++ default:++ }++ }++ }()++}@@@ -10,6 +10,7 @@ import ("github.com/godbus/dbus/v5""github.com/OpenVibers/OpenVibe.Node/internal/node"++ "github.com/OpenVibers/OpenVibe.Node/internal/safety")// Desktop notifications over org.freedesktop.Notifications (every Linux desktop): a waiting request with Approve and@@@ -118,7 +119,7 @@ func (n *dbusNotifier) text(s string) string {}func (n *dbusNotifier) Ask(a node.Approval, again string) {-- body := a.Summary++ body := safety.DisplayText(a.Summary)if again != "" {body = again + "\n" + body}@@@ -126,7 +127,7 @@ func (n *dbusNotifier) Ask(a node.Approval, again string) {if n.actions {actions = []string{"approve", "Approve", "deny", "Deny"}} else {-- body += "\nopenvibe-node approve " + a.ID + " (or deny " + a.ID + ")"++ body += "\nopenvibe-node approve " + safety.QuoteHint(a.ID) + " (or deny " + safety.QuoteHint(a.ID) + ")"}n.mu.Lock()replaces := n.byID[a.ID]@@@ -6,6 +6,7 @@ import ("io""log/slog""os"++ "strings""testing""time"@@@ -100,6 +101,15 @@ func TestDesktopNotifierAsksAndHearsTheButtons(t *testing.T) {if again := <-f.notify; again[0] != nid {t.Fatalf("asking again replaces the same notification: %v", again)}++ // The no-actions hint must quote its identifier and escape display controls.++ desktop := n.(*dbusNotifier)++ desktop.actions = false++ desktop.Ask(node.Approval{ID: "x';$(bad)", Summary: "unsafe\n\x1b[2J\u202e"}, "")++ hint := <-f.notify++ body := hint[2].(string)++ if strings.ContainsAny(body, "\x1b\u202e") || !strings.Contains(body, `\u202e`) || !strings.Contains(body, `approve 'x'"'"';$(bad)'`) {++ t.Fatalf("unsafe hint: %q", body)++ }n.Close("c1")select {case id := <-f.closed:@@@ -26,11 +26,12 @@ type systrayUI struct {header, level *systray.MenuItemstop, resume, panel *systray.MenuItemquit *systray.MenuItem-- slots [traySlots]*systray.MenuItem-- approve, deny [traySlots]*systray.MenuItem++ requests *systray.MenuItem++ slots []*systray.MenuItem++ answers chan trayAnswerstate, tooltip, headTx stringlevelTx string-- labels []string++ bindings []traySlotstopped, hasPanel *bool}@@@ -41,12 +42,10 @@ func (u *systrayUI) build() {u.level.Disable()u.level.Hide()systray.AddSeparator()-- for i := 0; i < traySlots; i++ {-- u.slots[i] = systray.AddMenuItem("", "Approve or deny what your agent asks")-- u.approve[i] = u.slots[i].AddSubMenuItem("Approve", "Let it run")-- u.deny[i] = u.slots[i].AddSubMenuItem("Deny", "Refuse it")-- u.slots[i].Hide()-- }++ u.requests = systray.AddMenuItem("Waiting requests", "Approve or deny what your agent asks")++ u.requests.Hide()++ u.answers = make(chan trayAnswer, 16)++systray.AddSeparator()u.stop = systray.AddMenuItem("Stop everything", "The kill switch: stop every actuator, observe only, until you resume")u.resume = systray.AddMenuItem("Resume", "Release the stop")@@@ -119,20 +118,40 @@ func (u *systrayUI) SetStopped(stopped bool) {}}--func (u *systrayUI) SetSlots(labels []string) {++func (u *systrayUI) SetSlots(slots []traySlot) {u.mu.Lock()defer u.mu.Unlock()-- if strings.Join(labels, "\x00") == strings.Join(u.labels, "\x00") && u.labels != nil {++ same := len(slots) == len(u.bindings)++ if same {++ for i, slot := range slots {++ if slot.Approval.ID != u.bindings[i].Approval.ID || slot.Label != u.bindings[i].Label {++ same = false++ break++ }++ }++ }++ if same {return}-- u.labels = append([]string{}, labels...)-- for i := 0; i < traySlots; i++ {-- if i < len(labels) {-- u.slots[i].SetTitle(labels[i])-- u.slots[i].Show()-- } else {-- u.slots[i].Hide()-- }++ // Menu objects (and their click channels) are never reassigned to another++ // nonce. An already queued click can therefore only answer the old request.++ for _, item := range u.slots {++ item.Remove()++ }++ u.slots = nil++ u.bindings = append([]traySlot(nil), slots...)++ for _, slot := range slots {++ item := u.requests.AddSubMenuItem(slot.Label, "")++ approve := item.AddSubMenuItem("Approve", "Let it run")++ deny := item.AddSubMenuItem("Deny", "Refuse it")++ u.slots = append(u.slots, item)++ forwardTrayClicks(approve.ClickedCh, u.answers, slot.Approval.ID, true)++ forwardTrayClicks(deny.ClickedCh, u.answers, slot.Approval.ID, false)++ }++ if len(slots) == 0 {++ u.requests.Hide()++ } else {++ u.requests.Show()}}@@@ -168,25 +187,7 @@ func runTray(g *globals) error {}func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *globals) {-- for i := 0; i < traySlots; i++ {-- i := i-- go func() {-- for range ui.approve[i].ClickedCh {-- if a, ok := core.slotID(i); ok {-- _ = core.answer(a, true)-- core.tick()-- }-- }-- }()-- go func() {-- for range ui.deny[i].ClickedCh {-- if a, ok := core.slotID(i); ok {-- _ = core.answer(a, false)-- core.tick()-- }-- }-- }()-- }++t := time.NewTicker(trayPoll)defer t.Stop()answers := core.note.Answers()@@@ -194,6 +195,9 @@ func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *globalselect {case <-t.C:core.tick()++ case a := <-ui.answers:++ _ = core.answerByID(a.ID, a.Approved)++ core.tick()case a := <-answers:if err := core.answerByID(a.ID, a.Approved); err != nil {g.log.Info("tray answer", "id", a.ID, "err", err)@@@ -60,7 +60,7 @@ func TestTrayPanelURLOnlyForTheBot(t *testing.T) {}func TestTraySlotLabel(t *testing.T) {-- if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != "exec: run echo hi" {++ if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != `exec: run echo\u000ahi` {t.Fatalf("label: %q", got)}long := slotLabel(node.Approval{Kind: "input", Summary: strings.Repeat("é", 100)})@@@ -166,14 +166,21 @@ type fakeUI struct {state, tooltip, header, level stringstopped, panel boolslots []string++ bindings []traySlot}--func (u *fakeUI) SetIcon(s string) { u.state = s }--func (u *fakeUI) SetTooltip(s string) { u.tooltip = s }--func (u *fakeUI) SetHeader(s string) { u.header = s }--func (u *fakeUI) SetLevel(s string) { u.level = s }--func (u *fakeUI) SetStopped(b bool) { u.stopped = b }--func (u *fakeUI) SetSlots(l []string) { u.slots = l }++func (u *fakeUI) SetIcon(s string) { u.state = s }++func (u *fakeUI) SetTooltip(s string) { u.tooltip = s }++func (u *fakeUI) SetHeader(s string) { u.header = s }++func (u *fakeUI) SetLevel(s string) { u.level = s }++func (u *fakeUI) SetStopped(b bool) { u.stopped = b }++func (u *fakeUI) SetSlots(l []traySlot) {++ u.bindings = l++ u.slots = nil++ for _, s := range l {++ u.slots = append(u.slots, s.Label)++ }++}func (u *fakeUI) SetPanel(b bool) { u.panel = b }func (n *fakeNotes) Answers() <-chan trayAnswer { return nil }func (n *fakeNotes) Shut() { n.shut = true }@@@ -228,9 +235,9 @@ func TestTrayCoreAgainstANode(t *testing.T) {t.Fatalf("asked again without a change: %v", notes.asked)}-- a, ok := core.slotID(0)-- if !ok || a.ID != "c1" {-- t.Fatalf("slot 0: %+v %v", a, ok)++ a := ui.bindings[0].Approval++ if a.ID != "c1" {++ t.Fatalf("slot 0: %+v", a)}if err := core.answer(a, true); err == nil {t.Fatal("the Node refused the approval; the tray should say so")@@@ -291,3 +298,25 @@ func TestTrayCoreAgainstANode(t *testing.T) {t.Fatalf("the Node gone: %+v", ui)}}++++func TestTrayClickRetainsRenderedNonce(t *testing.T) {++ clicks := make(chan struct{}, 1)++ answers := make(chan trayAnswer, 1)++ forwardTrayClicks(clicks, answers, "old-nonce", true)++ core := newTrayCore("", &fakeUI{}, nil)++ core.view = trayView{Waiting: []node.Approval{{ID: "replacement-nonce"}}}++ // A click queued while the old label was on screen is delivered after reorder.++ clicks <- struct{}{}++ close(clicks)++ select {++ case a := <-answers:++ if a.ID != "old-nonce" {++ t.Fatal("click rebound", a)++ }++ if err := core.answerByID(a.ID, a.Approved); err == nil {++ t.Fatal("stale click accepted")++ }++ case <-time.After(time.Second):++ t.Fatal("click lost")++ }++}@@@ -72,7 +72,9 @@ that a request is waiting on the machine.**Kill switch:** `openvibe-node stop` (and an owner's e-stop) drops the plugin to `observe`: what runs is killed, whatwaits is denied, the agent's browser is closed, and the core also refuses `exec`, file writes, `input` and the browser's--acting actions until `openvibe-node resume`. `halt` kills what runs.++acting actions until `openvibe-node resume`. `halt` kills what runs. An e-stop on this computer always needs that local++resume: the owner's remote clear (`estop` `latched:false`, or a config without `estop_latched`) releases the robot's remote++latch, never this computer.## What it does@@@ -170,7 +170,9 @@ applied the Node runs nothing but `halt` (`nack not_ready`), so that latch is in`desks.host`, `ai.host`, `bot.robot`): what the machine does for the network. The Node keeps them and applies the onesthis machine was installed for; the local configuration always wins (a role never installs anything and never turns ona feature the owner turned off here), and a role the Node does not know is ignored.--`estop` `latched:false` is the owner's clear: it clears the remote latch only, never the local kill switch.++`estop` `latched:false` is the owner's clear: it clears the remote latch only, never the local kill switch. A computer++plugin is the one exception to the remote latch's reach: an e-stop on a computer waits for `openvibe-node resume` on the++machine, whatever the server clears (docs/computer.md).### Device → server@@@ -32,7 +32,7 @@ const (type Request struct {Cmd string `json:"cmd"`-- ID string `json:"id,omitempty"` // approve and deny: the command id the plugin asked about++ ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)On bool `json:"on,omitempty"` // tray: the tray is running}@@@ -104,8 +104,11 @@ func Call(path string, req Request, timeout time.Duration) (Response, error) {if err := json.NewDecoder(c).Decode(&resp); err != nil {return resp, err}-- if !resp.OK && resp.Error != "" {++ if resp.Error != "" {return resp, errors.New(resp.Error)}++ if !resp.OK {++ return resp, errors.New("local control request failed")++ }return resp, nil}@@@ -20,6 +20,12 @@ func TestServeCall(t *testing.T) {done := make(chan error, 1)go func() {done <- Serve(ctx, path, func(r Request) Response {++ if r.Cmd == "partial" {++ return Response{OK: true, Error: "could not save latch"}++ }++ if r.Cmd == "failed" {++ return Response{}++ }if r.Cmd == CmdStop {return Response{OK: true, Data: []byte(`"stopped"`)}}@@@ -47,6 +53,11 @@ func TestServeCall(t *testing.T) {if _, err := Call(path, Request{Cmd: "nope"}, time.Second); err == nil {t.Fatal("unknown accepted")}++ for _, cmd := range []string{"partial", "failed"} {++ if _, err := Call(path, Request{Cmd: cmd}, time.Second); err == nil {++ t.Fatalf("%s failure ignored", cmd)++ }++ }// A second server refuses to start.if err := Serve(context.Background(), path, nil); err == nil {t.Fatal("second server started")@@@ -5,6 +5,7 @@ package nodeimport ("context""encoding/json"++ "fmt""io""log/slog""os"@@@ -21,6 +22,7 @@ import ("github.com/OpenVibers/OpenVibe.Node/internal/localctl""github.com/OpenVibers/OpenVibe.Node/internal/plugins""github.com/OpenVibers/OpenVibe.Node/internal/protocol"++ "github.com/OpenVibers/OpenVibe.Node/internal/safety")// startComputer runs a Node with only the real Python computer plugin at the given control level, its roots the one@@@ -180,13 +182,13 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {time.Sleep(50 * time.Millisecond)}}-- if len(list) != 1 || list[0].ID != "a1" || list[0].Kind != protocol.KindExec || !strings.Contains(list[0].Summary, "echo approved") {++ if len(list) != 1 || list[0].CommandID != "a1" || list[0].ID == "a1" || list[0].Kind != protocol.KindExec || !strings.Contains(list[0].Summary, "echo approved") {t.Fatalf("approvals: %+v", list)}if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "nope"}, 2*time.Second); r.OK {t.Fatal("an unknown id was approved")}-- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "a1"}, 2*time.Second); err != nil || !r.OK {++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: list[0].ID}, 2*time.Second); err != nil || !r.OK {t.Fatal(err, r.Error)}r, err := e.conn.Reply("a1", 10*time.Second)@@@ -205,7 +207,7 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {for time.Now().Before(deadline.Add(5*time.Second)) && len(e.node.Approvals()) == 0 {time.Sleep(50 * time.Millisecond)}-- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: "a2"}, 2*time.Second); err != nil || !r.OK {++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: e.node.Approvals()[0].ID}, 2*time.Second); err != nil || !r.OK {t.Fatal(err, r.Error)}r, err = e.conn.Reply("a2", 10*time.Second)@@@ -232,7 +234,7 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {e.node.mu.Lock()e.node.inputEnded = time.Now() // a click just rane.node.mu.Unlock()-- r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second)++ r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: e.node.Approvals()[0].ID}, 2*time.Second)if err == nil || r.OK || !strings.Contains(r.Error, "mouse or keyboard") {t.Fatalf("approve right after a click: %+v %v", r, err)}@@@ -243,13 +245,13 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {e.node.inputs["i1"] = true // an input command in flight, not waiting for approval: it is runninge.node.inputEnded = time.Now().Add(-InputGuard - time.Second)e.node.mu.Unlock()-- if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second); r.OK {++ if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: e.node.Approvals()[0].ID}, 2*time.Second); r.OK {t.Fatal("approved while an input command runs")}e.node.mu.Lock()delete(e.node.inputs, "i1")e.node.mu.Unlock()-- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second); err != nil || !r.OK {++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: e.node.Approvals()[0].ID}, 2*time.Second); err != nil || !r.OK {t.Fatal(err, r.Error)}if d := dataOf(t, func() protocol.Message {@@@ -270,7 +272,7 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {e.node.mu.Lock()e.node.inputEnded = time.Now()e.node.mu.Unlock()-- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: "g2"}, 2*time.Second); err != nil || !r.OK {++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: e.node.Approvals()[0].ID}, 2*time.Second); err != nil || !r.OK {t.Fatal("deny is never held back:", err, r.Error)}}@@@ -345,3 +347,138 @@ func TestComputerKindsNeedTheComputerPlugin(t *testing.T) {t.Fatalf("exec without the computer plugin: %+v", nk)}}++++func TestComputerRemoteStopRequiresLocalResume(t *testing.T) {++ for _, level := range []string{"ask", "trusted", "full"} {++ t.Run(level, func(t *testing.T) {++ e, work := startComputer(t, level)++ e.node.Frame(protocol.Frame{Msg: protocol.Estop{Latched: true, By: "server"}})++ e.node.Frame(protocol.Frame{Msg: protocol.Estop{Latched: false, By: "server"}})++ e.node.applyConfig(protocol.Config{}) // absent estop_latched++ e.node.applyConfig(protocol.Config{EstopLatched: false})++ if st := e.node.latch.State(); !st.Computer || st.Remote {++ t.Fatalf("remote clear released computer: %+v", st)++ }++ e.conn.Close()++ e.conn = e.nextConn()++ e.stop()++ // Reopen exactly the same local config and persisted latch after a Node restart.++ n, err := New(e.node.opt)++ if err != nil {++ t.Fatal(err)++ }++ e.node = n++ ctx, cancel := context.WithCancel(context.Background())++ e.cancel = cancel++ e.done = make(chan struct{})++ go func() { n.Run(ctx); close(e.done) }()++ e.conn = e.nextConn()++ if _, err := e.conn.Expect(protocol.TypeStatus, 20*time.Second, func(m protocol.Message) bool {++ s := m.(protocol.Status)++ return len(s.Drivers) > 0 && s.Drivers[0].State == plugins.StateReady++ }); err != nil {++ t.Fatal(err)++ }++ for i, c := range []struct {++ kind string++ value any++ }{++ {"exec", map[string]any{"argv": []string{"echo", "bad"}}},++ {"fs", map[string]any{"op": "write", "path": filepath.Join(work, "bad"), "content_b64": "YmFk"}},++ {"input", map[string]any{"op": "key", "key": "a"}},++ {"browser", map[string]any{"op": "navigate", "url": "https://example.com"}},++ {"desk", map[string]any{"op": "create"}},++ {"screen_stream", map[string]any{"action": "start"}},++ } {++ r := e.computerCmd(fmt.Sprintf("stopped-%d", i), c.kind, c.value, 5*time.Second)++ if nk, ok := r.(protocol.Nack); !ok || (nk.FaultCode != protocol.FaultLocalStop && nk.FaultCode != protocol.FaultEstopped) {++ t.Fatalf("%s released: %#v", c.kind, r)++ }++ }++ if r := n.control(localctl.Request{Cmd: localctl.CmdResume}); !r.OK {++ t.Fatal(r)++ }++ if level == "ask" {++ b, _ := json.Marshal(map[string]any{"op": "write", "path": filepath.Join(work, "after"), "content_b64": "b2s="})++ if err := e.conn.Command(protocol.Command{ID: "after-resume", Kind: "fs", Value: b}, 20000); err != nil {++ t.Fatal(err)++ }++ deadline := time.Now().Add(5 * time.Second)++ for len(n.Approvals()) == 0 && time.Now().Before(deadline) {++ time.Sleep(10 * time.Millisecond)++ }++ if len(n.Approvals()) != 1 {++ t.Fatal("local resume did not restore ask")++ }++ if r := n.control(localctl.Request{Cmd: localctl.CmdApprove, ID: n.Approvals()[0].ID}); !r.OK {++ t.Fatal(r)++ }++ r, err := e.conn.Reply("after-resume", 5*time.Second)++ if err != nil {++ t.Fatal(err)++ }++ dataOf(t, r)++ return++ }++ r := e.computerCmd("resumed", "fs", map[string]any{"op": "write", "path": filepath.Join(work, "after"), "content_b64": "b2s="}, 5*time.Second)++ dataOf(t, r)++ })++ }++}++++func TestStopSaveFailureIsNotOK(t *testing.T) {++ e := start(t)++ if err := os.Mkdir(e.paths.LatchFile()+".tmp", 0700); err != nil {++ t.Fatal(err)++ }++ r := e.node.control(localctl.Request{Cmd: localctl.CmdStop})++ if r.OK || !strings.Contains(r.Error, "could not be saved") {++ t.Fatalf("false durability acknowledgement: %+v", r)++ }++ if !e.node.latch.State().Local {++ t.Fatal("in-memory stop lost")++ }++ l, _ := safety.OpenLatch(e.paths.LatchFile())++ if l.State().Local {++ t.Fatal("fixture did not fail persistence")++ }++}++++func TestComputerApprovalNonceSurvivesFloodReplay(t *testing.T) {++ e, _ := startComputer(t, "ask")++ e.node.dedup = safety.NewDedup(2, time.Minute)++ send := func(id, text string) {++ b, _ := json.Marshal(map[string]any{"argv": []string{"echo", text}})++ if err := e.conn.Command(protocol.Command{ID: id, Kind: "exec", Value: b}, 20000); err != nil {++ t.Fatal(err)++ }++ }++ send("original", "first")++ for deadline := time.Now().Add(5 * time.Second); len(e.node.Approvals()) == 0 && time.Now().Before(deadline); {++ time.Sleep(10 * time.Millisecond)++ }++ if len(e.node.Approvals()) != 1 {++ t.Fatal("no approval")++ }++ a := e.node.Approvals()[0]++ for _, id := range []string{"b", "c", "d", "e"} {++ e.computerCmd(id, "fs", map[string]any{"op": "list", "path": e.node.opt.Config.Plugins[0].Config["roots"].([]string)[0]}, 5*time.Second)++ }++ send("original", "replacement")++ if r := e.node.control(localctl.Request{Cmd: localctl.CmdApprove, ID: "original"}); r.OK {++ t.Fatal("cloud ID accepted as local approval")++ }++ if r := e.node.control(localctl.Request{Cmd: localctl.CmdApprove, ID: a.ID}); !r.OK {++ t.Fatal(r)++ }++ reply, err := e.conn.Reply("original", 5*time.Second)++ if err != nil {++ t.Fatal(err)++ }++ if d := dataOf(t, reply); d["stdout"] != "first\n" {++ t.Fatalf("replacement ran: %v", d)++ }++ if r := e.node.control(localctl.Request{Cmd: localctl.CmdApprove, ID: a.ID}); r.OK {++ t.Fatal("old nonce reused")++ }++}@@@ -138,12 +138,14 @@ const (// Approval is one computer command waiting for its owner's answer on the machine.type Approval struct {-- ID string `json:"id"`-- Plugin string `json:"plugin"`-- Kind string `json:"kind,omitempty"`-- Summary string `json:"summary,omitempty"`-- At int64 `json:"at"`-- ExpiresS int64 `json:"expires_s,omitempty"`++ ID string `json:"id"` // local approval nonce++ CommandID string `json:"command_id"`++ binding *plugins.ApprovalRequest++ Plugin string `json:"plugin"`++ Kind string `json:"kind,omitempty"`++ Summary string `json:"summary,omitempty"`++ At int64 `json:"at"`++ ExpiresS int64 `json:"expires_s,omitempty"`}// New builds a Node. Run starts it.@@@ -187,6 +189,12 @@ func New(opt Options) (*Node, error) {specs = append(specs, plugins.Spec{Name: p.Name, Argv: opt.Config.PluginCommand(p, opt.Paths), Env: env, Dir: p.Dir, Config: cfg, StrictTiming: p.StrictTiming})}n.mgr = plugins.NewManager(specs, opt.Log)++ // Upgrade a pre-existing remote stop to the computer's local-resume policy.++ if st := latch.State(); st.Remote && n.hasComputer() && !st.Computer {++ if _, err := latch.SetRemoteWithComputer(st.RemoteReason, true); err != nil {++ n.log.Error("could not persist computer stop", "err", err)++ }++ }// Codes C9c: the same Codes runner the worker runs, asked for its logins instead of a job. The probe runs as the// Node's user, the home worker.plan_logins names are under, and only its answer (labels, numbers, times) leaves.@@@ -263,14 +271,11 @@ func (n *Node) Run(parent context.Context) error {pctx, cancelPlugins := context.WithCancel(context.Background())// A stop latched on disk is set on every plugin before any starts: each process's first lines are then hello and// the e-stop replayed (cause latched, first_cause what set it), before it can be routed any command.-- if st := n.latch.State(); st.Stopped() {-- n.log.Warn("starting with the stop latched", "remote", st.Remote, "local", st.Local)-- first := plugins.EstopLocal-- if st.Remote {-- first = plugins.EstopRemote-- }-- n.mgr.EstopAll(first, latchReason(st))++ if st := n.latch.State(); st.Stopped() || st.Computer {++ n.log.Warn("starting with the stop latched", "remote", st.Remote, "local", st.Local, "computer", st.Computer)}++ n.applyPluginLatch(n.latch.State(), false)++n.mgr.Start(pctx)var wg sync.WaitGroup@@@ -364,7 +369,7 @@ func (n *Node) Frame(f protocol.Frame) {func (n *Node) remoteEstop(reason, by string) {n.log.Warn("e-stop from the server", "by", by)n.mgr.EstopAll(plugins.EstopRemote, safety.CleanReason(reason)) // at once, before the disk write-- if _, err := n.latch.SetRemote(reason); err != nil {++ if _, err := n.latch.SetRemoteWithComputer(reason, n.hasComputer()); err != nil {n.log.Error("could not persist the e-stop (it is held in memory)", "err", err)}}@@@ -747,6 +752,10 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, rnack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`: observe only")return}++ if st.Computer {++ nack(protocol.FaultLocalStop, "the emergency stop on this computer needs a local `openvibe-node resume`: observe only")++ return++ }if st.Remote {nack(protocol.FaultEstopped, st.RemoteReason)return@@@ -846,10 +855,14 @@ func ceilMS(d time.Duration) int64 {// latchReason is the reason a latched stop gives a driver: the remote one when the platform set it, else the local one.func latchReason(st safety.LatchState) string {-- if st.Remote {++ switch {++ case st.Remote:return st.RemoteReason++ case st.Local:++ return st.LocalReason++ default:++ return "the computer's emergency stop: `openvibe-node resume` on this machine releases it"}-- return st.LocalReason}// serverNow is the server's clock (epoch ns) when a frame stamped sentMS was received: the link's estimate, else the@@@ -877,21 +890,44 @@ func (n *Node) send(m protocol.Message) {// ---- latch ----++// Computer stop is independent of the robot protocol's remotely cleared latch.++func (n *Node) hasComputer() bool {++ if n.opt.Config == nil {++ return false++ }++ for _, p := range n.opt.Config.Plugins {++ if p.Name == "computer" && !p.Disabled {++ return true++ }++ }++ return false++}++++func (n *Node) applyPluginLatch(st safety.LatchState, resume bool) {++ for _, p := range n.mgr.Plugins() {++ if st.Stopped() || (st.Computer && p.Name() == "computer") {++ cause := plugins.EstopRemote++ if st.Local || st.Computer && p.Name() == "computer" {++ cause = plugins.EstopLocal++ }++ p.Estop(cause, latchReason(st))++ } else if resume {++ p.Resume()++ }++ }++}++func (n *Node) onLatch(st safety.LatchState) {if n.worker != nil {n.worker.SetStopped(stopFault(st)) // kills every job at once while stopped}-- if st.Stopped() {-- n.log.Warn("stop latched", "remote", st.Remote, "local", st.Local)-- cause := plugins.EstopRemote-- if st.Local {-- cause = plugins.EstopLocal-- }-- n.mgr.EstopAll(cause, latchReason(st))++ if st.Stopped() || st.Computer {++ n.log.Warn("stop latched", "remote", st.Remote, "local", st.Local, "computer", st.Computer)} else {n.log.Warn("stop cleared; motion allowed again")-- n.mgr.ResumeAll()}++ n.applyPluginLatch(st, true)++n.sendEstopState()n.sendStatus()}@@@ -986,7 +1022,7 @@ func (n *Node) output(o plugins.Output) {sid, _ := o.Data["sid"].(string)n.screenEnded(o.Plugin, sid)}-- n.trackApproval(o.Plugin, name, o.Data, o.At)++ n.trackApproval(o.Plugin, name, o.Data, o.At, o.Approval)n.mu.Lock()n.events = append(n.events, protocol.Event{Name: name, Driver: o.Plugin, TS: o.At.UnixMilli(), Fields: fields})n.mu.Unlock()@@@ -1305,21 +1341,30 @@ func (n *Node) sendStatus() {}// trackApproval keeps the list `openvibe-node approvals` shows from the computer plugin's approval events.--func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.Time) {++func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.Time, binding *plugins.ApprovalRequest) {id, _ := data["id"].(string)-- if id == "" || (name != "approval" && name != "approval_done") {++ if id == "" || binding == nil || (name != "approval" && name != "approval_done") {return}n.mu.Lock()defer n.mu.Unlock()if name == "approval_done" {-- delete(n.approvals, id)++ if a, ok := n.approvals[id]; ok && a.binding == binding {++ delete(n.approvals, id)++ }++ return++ }++ p := n.mgr.Get(plugin)++ if p == nil || !p.ApprovalValid(binding) {++ return++ }++ if _, exists := n.approvals[id]; exists {return}if len(n.approvals) >= 64 {return}-- a := Approval{ID: id, Plugin: plugin, At: at.UnixMilli()}++ a := Approval{ID: binding.Nonce, CommandID: id, binding: binding, Plugin: plugin, At: at.UnixMilli()}a.Kind, _ = data["kind"].(string)a.Summary, _ = data["summary"].(string)if e, ok := data["expires_s"].(float64); ok {@@@ -1333,7 +1378,12 @@ func (n *Node) Approvals() []Approval {n.mu.Lock()defer n.mu.Unlock()out := make([]Approval, 0, len(n.approvals))-- for _, a := range n.approvals {++ for id, a := range n.approvals {++ p := n.mgr.Get(a.Plugin)++ if p == nil || !p.ApprovalValid(a.binding) {++ delete(n.approvals, id)++ continue++ }out = append(out, a)}sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At || (out[i].At == out[j].At && out[i].ID < out[j].ID) })@@@ -1386,13 +1436,20 @@ func (n *Node) control(r localctl.Request) localctl.Response {return localctl.Response{OK: true, Data: b}case localctl.CmdApprove, localctl.CmdDeny:n.mu.Lock()-- a, ok := n.approvals[r.ID]++ var a Approval++ ok := false++ for _, candidate := range n.approvals {++ if candidate.ID == r.ID {++ a, ok = candidate, true++ break++ }++ }if ok && r.Cmd == localctl.CmdApprove && n.drivingLocked() {n.mu.Unlock()return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment, so that its click cannot approve this"}}if ok {-- delete(n.approvals, r.ID)++ delete(n.approvals, a.CommandID)}n.mu.Unlock()if !ok {@@@ -1402,7 +1459,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {if p == nil {return localctl.Response{Error: "the plugin that asked is gone"}}-- if err := p.Answer(a.ID, r.Cmd == localctl.CmdApprove); err != nil {++ if err := p.Answer(a.binding, r.Cmd == localctl.CmdApprove); err != nil {return localctl.Response{Error: err.Error()}}return localctl.Response{OK: true}@@@ -1412,7 +1469,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {case localctl.CmdStop:n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persistif _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {-- return localctl.Response{OK: true, Error: "stopped, but the latch could not be saved: " + err.Error()}++ return localctl.Response{Error: "stopped, but the latch could not be saved: " + err.Error()}}return localctl.Response{OK: true}case localctl.CmdQuit:@@@ -378,7 +378,10 @@ func TestMissedHeartbeatsStop(t *testing.T) {}}--func TestEstopLatchedUntilOwnerClears(t *testing.T) {++// TestRobotEstopLatchedUntilOwnerClears: the remote latch is the server's to clear, and the owner clears it++// with `estop latched:false`. The computer plugin is the one exception: an e-stop on a computer waits for a++// local `openvibe-node resume` (TestComputerRemoteStopRequiresLocalResume).++func TestRobotEstopLatchedUntilOwnerClears(t *testing.T) {e := start(t)n := len(e.records())e.cmd("drive", `{"throttle":0.3}`, 1000)@@@ -93,8 +93,8 @@ func (n *Node) screenCommand(c protocol.Command, entry *safety.Entry, received mreturn}st := n.latch.State()-- if st.Local {-- nack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`")++ if st.Local || st.Computer {++ nack(protocol.FaultLocalStop, "stopped on the machine: `openvibe-node resume` on this machine is required")return}if st.Remote {@@@ -186,7 +186,7 @@ func (n *Node) startScreen(whip string, key credentials.Secret, seconds, fps intcase now := <-t.C:st := n.latch.State()switch {-- case st.Local || st.Remote:++ case st.Local || st.Remote || st.Computer:n.endScreen(s, "stopped on the machine")returncase now.After(until.Add(3 * time.Second)):nnew file mode 100644@@@ -0,0 +1,72 @@++package plugins++++import (++ "context"++ "encoding/json"++ "log/slog"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/mono"++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"++)++++func TestApprovalBindsPayloadProcessAndExpiry(t *testing.T) {++ p := newPlugin(Spec{Name: "computer"}, slog.Default(), make(chan Output, 8))++ p.state, p.queue = StateReady, make(chan line, 16)++ payload := json.RawMessage(`{"argv":["original"],"number":9007199254740993}`)++ wait := p.Begin(context.Background(), "cloud", "exec", payload, 10000)++ a := p.approval("cloud", 10)++ if a == nil || len(a.Nonce) != 64 || a.Nonce == "cloud" {++ t.Fatal("no independent nonce", a)++ }++ queued := <-p.queue++ payload[10] = 'X'++ var sent map[string]json.RawMessage++ if err := json.Unmarshal(queued.b, &sent); err != nil {++ t.Fatal(err)++ }++ if string(sent["value"]) != `{"argv":["original"],"number":9007199254740993}` {++ t.Fatal("payload changed", string(sent["value"]))++ }++ // A restart invalidates even a delayed event whose cloud ID is reused.++ p.failPending(protocol.FaultPluginDown)++ p.queue = make(chan line, 16)++ p.Begin(context.Background(), "cloud", "exec", json.RawMessage(`{"argv":["replacement"]}`), 10000)++ replacement := p.approval("cloud", 10)++ if replacement.Nonce == a.Nonce || replacement.Digest == a.Digest {++ t.Fatal("replacement reused approval binding")++ }++ if p.ApprovalValid(a) || p.Answer(a, true) == nil {++ t.Fatal("stale process approval accepted")++ }++ if r := wait(); r.FaultCode != protocol.FaultPluginDown {++ t.Fatal(r)++ }++ if !p.ApprovalValid(replacement) {++ t.Fatal("old waiter deleted replacement")++ }++ replacement.expires = mono.Now().Add(-time.Millisecond)++ if p.Answer(replacement, true) == nil {++ t.Fatal("expired approval accepted")++ }++ if len(p.queue) != 1 {++ t.Fatal("stale answer reached plugin")++ }++}++++func TestApprovalAnswerIsSingleUse(t *testing.T) {++ p := newPlugin(Spec{Name: "computer"}, slog.Default(), make(chan Output, 8))++ p.state, p.queue = StateReady, make(chan line, 8)++ p.Begin(context.Background(), "cloud", "exec", json.RawMessage(`{}`), 10000)++ a := p.approval("cloud", 10)++ if err := p.Answer(a, false); err != nil {++ t.Fatal(err)++ }++ if err := p.Answer(a, true); err == nil {++ t.Fatal("answer replayed")++ }++ if len(p.queue) != 2 {++ t.Fatal("unexpected messages", len(p.queue))++ }++}@@@ -8,6 +8,9 @@ package pluginsimport ("bufio""context"++ "crypto/rand"++ "crypto/sha256"++ "encoding/hex""encoding/json""errors""fmt"@@@ -89,10 +92,11 @@ const (// Output is something a plugin said that the core forwards: telemetry, an event, a video frame, a fault or a state// change. Data is the plugin's message without "op".type Output struct {-- Plugin string-- Kind string-- Data map[string]any-- At time.Time++ Plugin string++ Kind string++ Data map[string]any++ At time.Time++ Approval *ApprovalRequest // local only: never forwarded in Data}// Plugin is one supervised driver process.@@@ -111,6 +115,7 @@ type Plugin struct {// The newest turn_epoch a halt carried: hello tells a restarted process, so a stale turn stays refused there too.haltEpoch int64pending map[string]chan Reply++ requests map[string]*ApprovalRequestqueue chan line // to the current process's stdin; nil while downkill func()restarts int@@@ -119,7 +124,7 @@ type Plugin struct {func newPlugin(spec Spec, log *slog.Logger, out chan<- Output) *Plugin {return &Plugin{spec: spec, log: log.With("plugin", spec.Name), out: out, state: StateStarting,-- pending: map[string]chan Reply{}}++ pending: map[string]chan Reply{}, requests: map[string]*ApprovalRequest{}}}// Name is the configured name.@@@ -329,9 +334,63 @@ func (p *Plugin) Resume() {_ = p.send(map[string]any{"op": "resume", "cause": "cleared"})}--// Answer gives the plugin its owner's answer to an approval it asked for (`openvibe-node approve|deny <id>`).--func (p *Plugin) Answer(id string, approved bool) error {-- return p.send(map[string]any{"op": "approval", "id": id, "approved": approved})++// ApprovalRequest is an immutable local identity for one pending payload and process instance: the++// random nonce the owner's answer names, the digest of the payload it was minted for, the process it++// belongs to (its queue) and the instant it expires at. Only the core's local approval listing exposes++// the nonce; the plugin never sees it.++type ApprovalRequest struct {++ Nonce string++ Digest [32]byte++ id string++ expires mono.Instant++ queue chan line++ reply chan Reply++ answered bool++}++++// approval captures the pending request at event receipt, before Node's event queue.++func (p *Plugin) approval(id string, seconds float64) *ApprovalRequest {++ p.mu.Lock()++ defer p.mu.Unlock()++ a := p.requests[id]++ if a == nil || a.answered || p.pending[id] != a.reply {++ return nil++ }++ if seconds > 0 && seconds < mono.Until(a.expires).Seconds() {++ a.expires = mono.Now().Add(time.Duration(seconds * float64(time.Second)))++ }++ return a++}++++// ApprovalValid rejects stale events, replaced processes, completed work and expiry.++func (p *Plugin) ApprovalValid(a *ApprovalRequest) bool {++ p.mu.Lock()++ defer p.mu.Unlock()++ return p.approvalValidLocked(a)++}++++func (p *Plugin) approvalValidLocked(a *ApprovalRequest) bool {++ return a != nil && !a.answered && p.requests[a.id] == a && p.pending[a.id] == a.reply && p.queue == a.queue && mono.Until(a.expires) > 0++}++++// Answer binds validation and enqueue to the same process queue under the lock.++func (p *Plugin) Answer(a *ApprovalRequest, approved bool) error {++ p.mu.Lock()++ defer p.mu.Unlock()++ if !p.approvalValidLocked(a) {++ return errors.New("approval expired or its request is no longer pending")++ }++ b, err := json.Marshal(map[string]any{"op": "approval", "id": a.id, "approved": approved})++ if err != nil {++ return err++ }++ select {++ case p.queue <- line{b: append(b, '\n')}:++ a.answered = true++ return nil++ default:++ return errors.New("plugin input queue is full")++ }}// Voice tells a voice plugin whether its owner lets Vibe answer their voice here, and the call word to listen for.@@@ -422,12 +481,24 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa}return now(Reply{FaultCode: protocol.FaultNotReady})}++ if _, exists := p.pending[id]; exists {++ p.mu.Unlock()++ return now(Reply{FaultCode: protocol.FaultNotAllowed, Message: "command ID is already pending"})++ }++ var nonce [32]byte++ if _, err := rand.Read(nonce[:]); err != nil {++ p.mu.Unlock()++ return now(Reply{FaultCode: protocol.FaultNotAllowed, Message: "cannot mint local approval identity"})++ }ch := make(chan Reply, 1)p.pending[id] = chp.mu.Unlock()done := func() {p.mu.Lock()-- delete(p.pending, id)++ if p.pending[id] == ch {++ delete(p.pending, id)++ delete(p.requests, id)++ }p.mu.Unlock()}msg := map[string]any{"op": "command", "id": id, "kind": kind, "deadline_ms": deadlineMS}@@@ -447,13 +518,51 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessaif prov != nil {msg["provenance"] = prov}-- var err error-- if expires == 0 {-- err = p.send(msg)-- } else {-- err = p.enqueue(line{cmd: &timed{id: id, msg: msg, expires: expires, queued: mono.Now(), motion: p.motionKind(kind)}})-- }++ // Hash the encoded command, not a caller-owned RawMessage that could change.++ raw, err := json.Marshal(msg)if err != nil {++ done()++ return now(Reply{FaultCode: protocol.FaultBadValue})++ }++ var fields map[string]json.RawMessage++ if err := json.Unmarshal(raw, &fields); err != nil {++ done()++ return now(Reply{FaultCode: protocol.FaultBadValue})++ }++ immutable := make(map[string]any, len(fields))++ for key, value := range fields {++ immutable[key] = value++ }++ expiry := expires++ if expiry == 0 {++ expiry = mono.Now().Add(time.Duration(deadlineMS) * time.Millisecond)++ }++ queued := line{b: append(raw, '\n')}++ if expires != 0 {++ queued = line{cmd: &timed{id: id, msg: immutable, expires: expires, queued: mono.Now(), motion: p.motionKind(kind)}}++ }++ p.mu.Lock()++ binding := &ApprovalRequest{Nonce: hex.EncodeToString(nonce[:]), Digest: sha256.Sum256(raw), id: id, expires: expiry, queue: p.queue, reply: ch}++ if p.pending[id] != ch || p.queue == nil {++ p.mu.Unlock()++ done()++ return now(Reply{FaultCode: protocol.FaultPluginDown})++ }++ p.requests[id] = binding++ pushed := true++ select {++ case p.queue <- queued:++ default:++ pushed = false++ }++ kill := p.kill++ p.mu.Unlock()++ if !pushed {++ // The plugin is not reading its input: it can no longer be told to stop, so we end it.++ p.log.Error("plugin is not reading its input; killing it")++ if kill != nil {++ kill()++ }done()return now(Reply{FaultCode: protocol.FaultPluginDown})}@@@ -490,6 +599,7 @@ func (p *Plugin) failPending(code string) {default:}delete(p.pending, id)++ delete(p.requests, id)}}@@@ -761,6 +871,7 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {}p.mu.Lock()ch := p.pending[id]++ delete(p.requests, id) // completion invalidates any queued approval eventp.mu.Unlock()if ch != nil {select {@@@ -786,7 +897,13 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {}p.emit(Output{Kind: OutTelemetry, Data: m}, false)case "event":-- p.emit(Output{Kind: OutEvent, Data: m}, true)++ var approval *ApprovalRequest++ if name, _ := m["name"].(string); name == "approval" || name == "approval_done" {++ id, _ := m["id"].(string)++ seconds, _ := m["expires_s"].(float64)++ approval = p.approval(id, seconds)++ }++ p.emit(Output{Kind: OutEvent, Data: m, Approval: approval}, true)case "local_stop":reason, _ := m["reason"].(string)p.log.Warn("plugin latched the local stop", "reason", truncate(reason, 120))@@@ -555,3 +555,17 @@ exit 1`}}}++++func TestDuplicatePendingCannotReplaceRequest(t *testing.T) {++ p := newPlugin(Spec{Name: "computer"}, slog.Default(), make(chan Output, 8))++ p.state, p.queue = StateReady, make(chan line, 8)++ p.Begin(context.Background(), "same", "exec", json.RawMessage(`{"argv":["first"]}`), 10000)++ first := p.pending["same"]++ wait := p.Begin(context.Background(), "same", "exec", json.RawMessage(`{"argv":["replacement"]}`), 10000)++ if p.pending["same"] != first || len(p.queue) != 1 {++ t.Fatal("duplicate replaced the in-flight request")++ }++ if r := wait(); r.OK || r.FaultCode == "" {++ t.Fatal("duplicate was not refused", r)++ }++}@@@ -44,13 +44,20 @@ func (d *Dedup) Begin(id string) (e *Entry, first bool) {d.mu.Lock()defer d.mu.Unlock()now := d.now()-- for f := d.order.Front(); f != nil; f = d.order.Front() {++ // Capacity and TTL only retire completed requests. Pending work must keep++ // its identity even when a peer floods the cache or its deadline passes.++ for f := d.order.Front(); f != nil; {++ next := f.Next()old := f.Value.(*Entry)-- if len(d.items) <= d.max && now.Sub(old.at) < d.ttl {-- break++ select {++ case <-old.done:++ if len(d.items) >= d.max || now.Sub(old.at) >= d.ttl {++ d.order.Remove(f)++ delete(d.items, old.id)++ }++ default:}-- d.order.Remove(f)-- delete(d.items, old.id)++ f = next}if e, ok := d.items[id]; ok {return e, falsennew file mode 100644@@@ -0,0 +1,30 @@++package safety++++import (++ "fmt"++ "strings"++ "unicode"++)++++// DisplayText makes untrusted approval text literal on terminals and desktops.++// Format characters include bidi overrides, isolates and invisible separators.++func DisplayText(s string) string {++ var b strings.Builder++ for _, r := range s {++ if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' {++ if r <= 0xffff {++ fmt.Fprintf(&b, "\\u%04x", r)++ } else {++ fmt.Fprintf(&b, "\\U%08x", r)++ }++ } else {++ b.WriteRune(r)++ }++ }++ return b.String()++}++++// QuoteHint quotes a displayed identifier as one POSIX shell argument.++func QuoteHint(s string) string {++ return "'" + strings.ReplaceAll(DisplayText(s), "'", "'\"'\"'") + "'"++}@@@ -22,6 +22,8 @@ import (//// Anything that moves is refused while either is set.type LatchState struct {++ // Computer requires a local resume even after the robot remote latch clears.++ Computer bool `json:"computer,omitempty"`Remote bool `json:"remote"`RemoteReason string `json:"remote_reason,omitempty"`RemoteAt time.Time `json:"remote_at,omitempty"`@@@ -30,7 +32,8 @@ type LatchState struct {LocalAt time.Time `json:"local_at,omitempty"`}--// Stopped reports whether anything is latched.++// Stopped reports a global stop. Computer is checked separately for computer commands++// so a remote clear can still release robot plugins.func (s LatchState) Stopped() bool { return s.Remote || s.Local }// Latch is the persisted latch. It survives restarts: a Node that was e-stopped comes back e-stopped.@@@ -133,7 +136,13 @@ func (l *Latch) persistLocked() error {// SetRemote latches the server e-stop.func (l *Latch) SetRemote(reason string) (LatchState, error) {++ return l.SetRemoteWithComputer(reason, false)++}++++// SetRemoteWithComputer persists both requirements atomically.++func (l *Latch) SetRemoteWithComputer(reason string, computer bool) (LatchState, error) {return l.update(func(s *LatchState) {++ s.Computer = s.Computer || computerif !s.Remote {s.RemoteAt = time.Now().UTC()}@@@ -4,6 +4,7 @@ import ("encoding/json""os""path/filepath"++ "strconv""strings""testing""time"@@@ -181,7 +182,8 @@ func TestDedup(t *testing.T) {}d.Begin("b")d.Begin("c")-- d.Begin("d")++ last, _ := d.Begin("d")++ d.Complete(last, protocol.Ack{ID: "d"})if _, first := d.Begin("a"); !first {t.Fatal("capacity not enforced")}@@@ -213,3 +215,17 @@ func TestReloadNeverUndoesConcurrentStop(t *testing.T) {}}}++++func TestDedupPendingSurvivesFloodAndTTL(t *testing.T) {++ d := NewDedup(4096, time.Millisecond)++ original, _ := d.Begin("waiting")++ for i := 0; i < 4200; i++ {++ id := strconv.Itoa(i)++ e, _ := d.Begin(id)++ d.Complete(e, protocol.Ack{ID: id})++ }++ d.now = func() time.Time { return time.Now().Add(time.Hour) }++ if got, first := d.Begin("waiting"); first || got != original {++ t.Fatal("unfinished entry was evicted")++ }++}@@@ -127,6 +127,7 @@ ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}{{if ChRoot}}RootDirectory={{ChRoot | cmd}}{{end}}{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}}{{if UserName}}User={{UserName}}++NoNewPrivileges=yes{{end}}{{if ReloadSignal}}ExecReload=/bin/kill -{{ReloadSignal}} "$MAINPID"{{end}}{{if PIDFile}}PIDFile={{PIDFile | cmd}}{{end}}{{if OutputFileSupport}}StandardOutput=file:{{LogDirectory}}/{{Name}}.out@@@ -42,3 +42,13 @@ func TestConfig(t *testing.T) {t.Fatalf("the systemd unit must not restart a Node that has to pair again: %q", u)}}++++// A Node that runs as a person must not gain privileges through a setuid child, so its unit refuses++// new privileges. The privileged system unit does not: the shared desk host runs as root on purpose.++func TestUserServicePreventsPrivilegeGain(t *testing.T) {++ unit := config(Options{UserName: "person"}).Option["SystemdScript"].(string)++ // The directive sits inside the {{if UserName}} guard: only the per-user unit gets it.++ if !strings.Contains(unit, "{{if UserName}}User={{UserName}}\nNoNewPrivileges=yes\n{{end}}") {++ t.Fatalf("the per-user unit must set NoNewPrivileges=yes: %q", unit)++ }++}
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "os"
+ "os/user"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/config"
+ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/node"
+)
+
+func reviewSocket(t *testing.T, h localctl.Handler) *globals {
+ t.Helper()
+ dir := shortHome(t)
+ g := &globals{paths: config.HomePaths(dir)}
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ go localctl.Serve(ctx, g.paths.SocketPath(), h)
+ for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
+ if _, err := os.Stat(g.paths.SocketPath()); err == nil {
+ return g
+ }
+ time.Sleep(time.Millisecond * 10)
+ }
+ t.Fatal("socket did not start")
+ return nil
+}
+
+func TestStopReportsPersistenceFailure(t *testing.T) {
+ g := reviewSocket(t, func(localctl.Request) localctl.Response {
+ return localctl.Response{Error: "stopped, but the latch could not be saved: fixture"}
+ })
+ var out bytes.Buffer
+ err := cmdStop(g, &out)
+ if err == nil || !strings.Contains(err.Error(), "could not be saved") || out.Len() != 0 {
+ t.Fatalf("failure hidden: %v %q", err, out.String())
+ }
+ if _, err := os.Stat(g.paths.LatchFile()); !os.IsNotExist(err) {
+ t.Fatal("server failure treated as offline fallback")
+ }
+}
+
+func TestApprovalDisplaysEscapeControls(t *testing.T) {
+ hostile := "x\n\r\x1b[2J\u202ey\u2066z\u0085"
+ a := node.Approval{ID: hostile, Kind: hostile, Summary: hostile}
+ g := reviewSocket(t, func(localctl.Request) localctl.Response {
+ b, _ := json.Marshal([]node.Approval{a})
+ return localctl.Response{OK: true, Data: b}
+ })
+ var out bytes.Buffer
+ if err := cmdApprovals(g, false, &out); err != nil {
+ t.Fatal(err)
+ }
+ for _, s := range []string{out.String(), slotLabel(a)} {
+ if strings.ContainsAny(s, "\x1b\r\u202e\u2066\u0085") || strings.Contains(s, "x\n") {
+ t.Fatalf("unescaped approval: %q", s)
+ }
+ if !strings.Contains(s, `\u202e`) {
+ t.Fatalf("bidi not visibly escaped: %q", s)
+ }
+ }
+}
+
+func TestInstallRefusesPrivilegedComputer(t *testing.T) {
+ dir := shortHome(t)
+ paths := config.HomePaths(dir)
+ cfg := config.Default()
+ cfg.Plugins = []config.PluginConfig{{Name: "computer", Config: map[string]any{"level": "trusted"}}}
+ if err := config.Save(paths.ConfigFile(), cfg); err != nil {
+ t.Fatal(err)
+ }
+ // Refusal must precede service manager calls and even directory ownership changes.
+ g := &globals{paths: paths}
+ var out bytes.Buffer
+ if err := cmdInstall(g, "", &out); err == nil || !strings.Contains(err.Error(), "computer") {
+ t.Fatalf("unsafe install not refused: %v", err)
+ }
+}
+
+func TestComputerServiceAccountsAndSharedHost(t *testing.T) {
+ cfg := config.Default()
+ computer := config.PluginConfig{Name: "computer", Config: map[string]any{"level": "trusted"}}
+ cfg.Plugins = []config.PluginConfig{computer}
+ if err := validateComputerService(cfg, ""); err == nil {
+ t.Fatal("default privileged account allowed")
+ }
+ if runtime.GOOS != "windows" {
+ if err := validateComputerService(cfg, "root"); err == nil {
+ t.Fatal("UID 0 allowed")
+ }
+ }
+ if current, err := user.Current(); err == nil && current.Uid != "0" {
+ if err := validateComputerService(cfg, current.Username); err != nil {
+ t.Fatal("ordinary account refused", err)
+ }
+ }
+ computer.Config["desk_isolation"] = "sandbox"
+ if err := validateComputerService(cfg, ""); err == nil {
+ t.Fatal("shared host without worker allowed")
+ }
+ cfg.Worker.Enabled = true
+ if err := validateComputerService(cfg, ""); (err == nil) != (runtime.GOOS == "linux") {
+ t.Fatalf("shared host: %v", err)
+ }
+ // The installer gives a shared desk host level trusted or observe; nothing else is that host.
+ computer.Config["level"] = "observe"
+ if err := validateComputerService(cfg, ""); (err == nil) != (runtime.GOOS == "linux") {
+ t.Fatalf("shared host at observe: %v", err)
+ }
+ computer.Config["level"] = "full"
+ if err := validateComputerService(cfg, ""); err == nil {
+ t.Fatal("a shared desk host at full allowed a privileged service")
+ }
+ computer.Config["level"] = "trusted"
+ cfg.Plugins[0].Disabled = true
+ if err := validateComputerService(cfg, ""); err != nil {
+ t.Fatal("disabled computer blocks robot install", err)
+ }
+}
@@ -26,6 +26,7 @@ import (
@@ -26,6 +26,7 @@ import (
"log/slog"
"log/slog"
"net"
"net"
"os"
"os"
+ "os/user"
"path/filepath"
"path/filepath"
"runtime"
"runtime"
"sort"
"sort"
@@ -627,6 +628,14 @@ func serviceOptions(g *globals, user string) service.Options {
@@ -627,6 +628,14 @@ func serviceOptions(g *globals, user string) service.Options {
}
}
func cmdInstall(g *globals, user string, stdout io.Writer) error {
func cmdInstall(g *globals, user string, stdout io.Writer) error {
+ cfg, err := loadConfig(g)
+ if err != nil {
+ return err
+ }
+ if err := validateComputerService(cfg, user); err != nil {
+ return err
+ }
+
if err := g.paths.Ensure(); err != nil {
if err := g.paths.Ensure(); err != nil {
return fmt.Errorf("cannot create %s (run with sudo): %w", g.paths.ConfigDir, err)
return fmt.Errorf("cannot create %s (run with sudo): %w", g.paths.ConfigDir, err)
}
}
@@ -650,11 +659,18 @@ func cmdInstall(g *globals, user string, stdout io.Writer) error {
@@ -650,11 +659,18 @@ func cmdInstall(g *globals, user string, stdout io.Writer) error {
}
}
func cmdStop(g *globals, stdout io.Writer) error {
func cmdStop(g *globals, stdout io.Writer) error {
- _, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second)
+ resp, err := localctl.Call(g.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second)
if err == nil {
if err == nil {
fmt.Fprintln(stdout, "Stopped. Every actuator is held stopped until `openvibe-node resume`.")
fmt.Fprintln(stdout, "Stopped. Every actuator is held stopped until `openvibe-node resume`.")
return nil
return nil
}
}
+ if resp.Error != "" {
+ return fmt.Errorf("stop failed: %s", resp.Error)
+ }
+ var op *net.OpError
+ if !errors.As(err, &op) || op.Op != "dial" {
+ return fmt.Errorf("stop failed: %w", err)
+ }
// No running Node answered: latch it in the file, which a running Node polls and a starting Node obeys.
// No running Node answered: latch it in the file, which a running Node polls and a starting Node obeys.
if ferr := safety.WriteLocalFile(g.paths.LatchFile(), true); ferr != nil {
if ferr := safety.WriteLocalFile(g.paths.LatchFile(), true); ferr != nil {
return fmt.Errorf("could not reach the node (%v) nor write %s (%v); run with sudo", err, g.paths.LatchFile(), ferr)
return fmt.Errorf("could not reach the node (%v) nor write %s (%v); run with sudo", err, g.paths.LatchFile(), ferr)
@@ -722,7 +738,9 @@ func cmdApprovals(g *globals, asJSON bool, stdout io.Writer) error {
@@ -722,7 +738,9 @@ func cmdApprovals(g *globals, asJSON bool, stdout io.Writer) error {
}
}
for _, a := range list {
for _, a := range list {
age := time.Since(time.UnixMilli(a.At)).Round(time.Second)
age := time.Since(time.UnixMilli(a.At)).Round(time.Second)
- fmt.Fprintf(stdout, "%s %s, %s ago: %s\n", a.ID, a.Kind, age, a.Summary)
+ // The id is quoted so it can be copied into a command; kind and summary are untrusted text and are shown
+ // with their control, ANSI and bidi characters made visible.
+ fmt.Fprintf(stdout, "%q %s, %s ago: %s\n", a.ID, safety.DisplayText(a.Kind), age, safety.DisplayText(a.Summary))
}
}
fmt.Fprintln(stdout, "\nopenvibe-node approve <ID> lets one run; openvibe-node deny <ID> refuses it.")
fmt.Fprintln(stdout, "\nopenvibe-node approve <ID> lets one run; openvibe-node deny <ID> refuses it.")
return nil
return nil
@@ -829,6 +847,9 @@ func deviceLine(device, principal, server string) string {
@@ -829,6 +847,9 @@ func deviceLine(device, principal, server string) string {
}
}
func latchText(l safety.LatchState) string {
func latchText(l safety.LatchState) string {
+ if l.Computer {
+ return "STOPPED for computer control (an e-stop on this computer); `openvibe-node resume` on this machine is required"
+ }
switch {
switch {
case l.Local && l.Remote:
case l.Local && l.Remote:
return "STOPPED locally and by the server (" + l.RemoteReason + "); `openvibe-node resume` releases both"
return "STOPPED locally and by the server (" + l.RemoteReason + "); `openvibe-node resume` releases both"
@@ -964,3 +985,41 @@ func reportCommunityProfile(ctx context.Context, server string, profile json.Raw
@@ -964,3 +985,41 @@ func reportCommunityProfile(ctx context.Context, server string, profile json.Raw
}
}
fmt.Fprintf(stdout, "The robot's community profile %s (version %s) is the one Bot reviewed and signed (key %s).\n", res.ID, res.Version, res.KeyID)
fmt.Fprintf(stdout, "The robot's community profile %s (version %s) is the one Bot reviewed and signed (key %s).\n", res.ID, res.Version, res.KeyID)
}
}
+
+// validateComputerService refuses a privileged service for a computer plugin that is not the documented
+// shared desk host: the computer plugin must not run as root on an ordinary machine.
+func validateComputerService(cfg *config.Config, account string) error {
+ for _, p := range cfg.Plugins {
+ if p.Disabled || p.Name != "computer" {
+ continue
+ }
+ if sharedDeskHost(cfg, p) {
+ continue
+ }
+ if account == "" {
+ return errors.New("computer control requires an unprivileged service user; only a configured shared desk host may install a privileged service")
+ }
+ u, err := user.Lookup(account)
+ if err != nil {
+ return fmt.Errorf("computer service user: %w", err)
+ }
+ if u.Uid == "0" {
+ return errors.New("computer service user must not have UID 0")
+ }
+ }
+ return nil
+}
+
+// sharedDeskHost is the one documented mode that runs as root (docs/computer.md, "Desk hosts for other
+// people"): Linux, every desk in the worker's sandbox, the worker on, and the computer plugin at the level
+// the installer gives such a host (trusted, or observe: nobody sits at it to approve).
+func sharedDeskHost(cfg *config.Config, p config.PluginConfig) bool {
+ if runtime.GOOS != "linux" || !cfg.Worker.Enabled || p.Config["desk_isolation"] != "sandbox" {
+ return false
+ }
+ switch p.Config["level"] {
+ case "trusted", "observe":
+ return true
+ }
+ return false
+}
@@ -33,6 +33,7 @@ import (
@@ -33,6 +33,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
+ "github.com/OpenVibers/OpenVibe.Node/internal/safety"
)
)
// trayPoll is how often the tray asks the Node what changed.
// trayPoll is how often the tray asks the Node what changed.
@@ -69,7 +70,7 @@ func viewOf(st *node.Status, list []node.Approval, err error) trayView {
@@ -69,7 +70,7 @@ func viewOf(st *node.Status, list []node.Approval, err error) trayView {
if lv, ok := st.Telemetry.Sensors["computer_level"].(string); ok {
if lv, ok := st.Telemetry.Sensors["computer_level"].(string); ok {
v.Level = lv
v.Level = lv
}
}
- v.Stopped = st.Latch.Local || st.Latch.Remote
+ v.Stopped = st.Latch.Local || st.Latch.Remote || st.Latch.Computer
link := "offline"
link := "offline"
if st.Link.Connected {
if st.Link.Connected {
link = "online"
link = "online"
@@ -121,16 +122,22 @@ func panelURL(server string) string {
@@ -121,16 +122,22 @@ func panelURL(server string) string {
// slotLabel is one waiting request in the menu: its kind and what it would do, cut to fit a menu.
// slotLabel is one waiting request in the menu: its kind and what it would do, cut to fit a menu.
func slotLabel(a node.Approval) string {
func slotLabel(a node.Approval) string {
- s := strings.Join(strings.Fields(a.Summary), " ")
+ s := safety.DisplayText(a.Summary)
if len([]rune(s)) > 70 {
if len([]rune(s)) > 70 {
s = string([]rune(s)[:69]) + "…"
s = string([]rune(s)[:69]) + "…"
}
}
if a.Kind != "" {
if a.Kind != "" {
- return a.Kind + ": " + s
+ return safety.DisplayText(a.Kind) + ": " + s
}
}
return s
return s
}
}
+// traySlot binds the rendered label and both buttons to one immutable approval.
+type traySlot struct {
+ Label string
+ Approval node.Approval
+}
+
// trayUI is the notification-area side (systray on Linux and Windows; a fake in tests).
// trayUI is the notification-area side (systray on Linux and Windows; a fake in tests).
type trayUI interface {
type trayUI interface {
SetIcon(state string)
SetIcon(state string)
@@ -138,7 +145,7 @@ type trayUI interface {
@@ -138,7 +145,7 @@ type trayUI interface {
SetHeader(string)
SetHeader(string)
SetLevel(string)
SetLevel(string)
SetStopped(bool)
SetStopped(bool)
- SetSlots([]string) // one label per waiting request, at most traySlots; the rest hidden
+ SetSlots([]traySlot) // one label per waiting request, at most traySlots; the rest hidden
SetPanel(bool)
SetPanel(bool)
}
}
@@ -168,12 +175,13 @@ func (nopNotifier) Shut() {}
@@ -168,12 +175,13 @@ func (nopNotifier) Shut() {}
// trayCore polls the Node and drives the UI and the notifications; the platform files only wire it up.
// trayCore polls the Node and drives the UI and the notifications; the platform files only wire it up.
type trayCore struct {
type trayCore struct {
- sock string
- ui trayUI
- note notifier
- mu sync.Mutex
- view trayView
- shown map[string]bool // approval ids with a notification up
+ sock string
+ ui trayUI
+ note notifier
+ pollMu sync.Mutex // serialize concurrent UI refreshes
+ mu sync.Mutex
+ view trayView
+ shown map[string]bool // approval ids with a notification up
}
}
func newTrayCore(sock string, ui trayUI, note notifier) *trayCore {
func newTrayCore(sock string, ui trayUI, note notifier) *trayCore {
@@ -189,6 +197,8 @@ func (t *trayCore) call(req localctl.Request) (localctl.Response, error) {
@@ -189,6 +197,8 @@ func (t *trayCore) call(req localctl.Request) (localctl.Response, error) {
// tick asks the Node for its status and approvals, says the tray is there, and updates everything.
// tick asks the Node for its status and approvals, says the tray is there, and updates everything.
func (t *trayCore) tick() trayView {
func (t *trayCore) tick() trayView {
+ t.pollMu.Lock()
+ defer t.pollMu.Unlock()
var st *node.Status
var st *node.Status
var list []node.Approval
var list []node.Approval
resp, err := t.call(localctl.Request{Cmd: localctl.CmdStatus})
resp, err := t.call(localctl.Request{Cmd: localctl.CmdStatus})
@@ -229,33 +239,23 @@ func (t *trayCore) tick() trayView {
@@ -229,33 +239,23 @@ func (t *trayCore) tick() trayView {
for _, a := range fresh {
for _, a := range fresh {
t.note.Ask(a, "")
t.note.Ask(a, "")
}
}
- labels := make([]string, 0, traySlots)
+ slots := make([]traySlot, 0, traySlots)
for i, a := range v.Waiting {
for i, a := range v.Waiting {
if i == traySlots {
if i == traySlots {
break
break
}
}
- labels = append(labels, slotLabel(a))
+ slots = append(slots, traySlot{Label: slotLabel(a), Approval: a})
}
}
t.ui.SetIcon(v.State)
t.ui.SetIcon(v.State)
t.ui.SetTooltip(v.Tooltip)
t.ui.SetTooltip(v.Tooltip)
t.ui.SetHeader(v.Header)
t.ui.SetHeader(v.Header)
t.ui.SetLevel(v.Level)
t.ui.SetLevel(v.Level)
t.ui.SetStopped(v.Stopped)
t.ui.SetStopped(v.Stopped)
- t.ui.SetSlots(labels)
+ t.ui.SetSlots(slots)
t.ui.SetPanel(v.Panel != "")
t.ui.SetPanel(v.Panel != "")
return v
return v
}
}
-// slotID is the approval id the menu's slot i shows now ("" if none).
-func (t *trayCore) slotID(i int) (node.Approval, bool) {
- t.mu.Lock()
- defer t.mu.Unlock()
- if i < 0 || i >= len(t.view.Waiting) {
- return node.Approval{}, false
- }
- return t.view.Waiting[i], true
-}
-
// answer approves or denies one request; a refusal (the agent just used the mouse or keyboard) asks again.
// answer approves or denies one request; a refusal (the agent just used the mouse or keyboard) asks again.
func (t *trayCore) answer(a node.Approval, approved bool) error {
func (t *trayCore) answer(a node.Approval, approved bool) error {
cmd := localctl.CmdDeny
cmd := localctl.CmdDeny
@@ -286,6 +286,7 @@ func (t *trayCore) answerByID(id string, approved bool) error {
@@ -286,6 +286,7 @@ func (t *trayCore) answerByID(id string, approved bool) error {
if t.view.Waiting[i].ID == id {
if t.view.Waiting[i].ID == id {
a := t.view.Waiting[i]
a := t.view.Waiting[i]
found = &a
found = &a
+ break
}
}
}
}
t.mu.Unlock()
t.mu.Unlock()
@@ -458,3 +459,16 @@ func trayICO(state string) []byte {
@@ -458,3 +459,16 @@ func trayICO(state string) []byte {
out.Write(dib.Bytes())
out.Write(dib.Bytes())
return out.Bytes()
return out.Bytes()
}
}
+
+// forwardTrayClicks captures the nonce when the button is created, not at click
+// handling time. Removing the menu closes its channel and ends this goroutine.
+func forwardTrayClicks(clicks <-chan struct{}, answers chan<- trayAnswer, nonce string, approved bool) {
+ go func() {
+ for range clicks {
+ select {
+ case answers <- trayAnswer{ID: nonce, Approved: approved}:
+ default:
+ }
+ }
+ }()
+}
@@ -10,6 +10,7 @@ import (
@@ -10,6 +10,7 @@ import (
"github.com/godbus/dbus/v5"
"github.com/godbus/dbus/v5"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
"github.com/OpenVibers/OpenVibe.Node/internal/node"
+ "github.com/OpenVibers/OpenVibe.Node/internal/safety"
)
)
// Desktop notifications over org.freedesktop.Notifications (every Linux desktop): a waiting request with Approve and
// Desktop notifications over org.freedesktop.Notifications (every Linux desktop): a waiting request with Approve and
@@ -118,7 +119,7 @@ func (n *dbusNotifier) text(s string) string {
@@ -118,7 +119,7 @@ func (n *dbusNotifier) text(s string) string {
}
}
func (n *dbusNotifier) Ask(a node.Approval, again string) {
func (n *dbusNotifier) Ask(a node.Approval, again string) {
- body := a.Summary
+ body := safety.DisplayText(a.Summary)
if again != "" {
if again != "" {
body = again + "\n" + body
body = again + "\n" + body
}
}
@@ -126,7 +127,7 @@ func (n *dbusNotifier) Ask(a node.Approval, again string) {
@@ -126,7 +127,7 @@ func (n *dbusNotifier) Ask(a node.Approval, again string) {
if n.actions {
if n.actions {
actions = []string{"approve", "Approve", "deny", "Deny"}
actions = []string{"approve", "Approve", "deny", "Deny"}
} else {
} else {
- body += "\nopenvibe-node approve " + a.ID + " (or deny " + a.ID + ")"
+ body += "\nopenvibe-node approve " + safety.QuoteHint(a.ID) + " (or deny " + safety.QuoteHint(a.ID) + ")"
}
}
n.mu.Lock()
n.mu.Lock()
replaces := n.byID[a.ID]
replaces := n.byID[a.ID]
@@ -6,6 +6,7 @@ import (
@@ -6,6 +6,7 @@ import (
"io"
"io"
"log/slog"
"log/slog"
"os"
"os"
+ "strings"
"testing"
"testing"
"time"
"time"
@@ -100,6 +101,15 @@ func TestDesktopNotifierAsksAndHearsTheButtons(t *testing.T) {
@@ -100,6 +101,15 @@ func TestDesktopNotifierAsksAndHearsTheButtons(t *testing.T) {
if again := <-f.notify; again[0] != nid {
if again := <-f.notify; again[0] != nid {
t.Fatalf("asking again replaces the same notification: %v", again)
t.Fatalf("asking again replaces the same notification: %v", again)
}
}
+ // The no-actions hint must quote its identifier and escape display controls.
+ desktop := n.(*dbusNotifier)
+ desktop.actions = false
+ desktop.Ask(node.Approval{ID: "x';$(bad)", Summary: "unsafe\n\x1b[2J\u202e"}, "")
+ hint := <-f.notify
+ body := hint[2].(string)
+ if strings.ContainsAny(body, "\x1b\u202e") || !strings.Contains(body, `\u202e`) || !strings.Contains(body, `approve 'x'"'"';$(bad)'`) {
+ t.Fatalf("unsafe hint: %q", body)
+ }
n.Close("c1")
n.Close("c1")
select {
select {
case id := <-f.closed:
case id := <-f.closed:
@@ -26,11 +26,12 @@ type systrayUI struct {
@@ -26,11 +26,12 @@ type systrayUI struct {
header, level *systray.MenuItem
header, level *systray.MenuItem
stop, resume, panel *systray.MenuItem
stop, resume, panel *systray.MenuItem
quit *systray.MenuItem
quit *systray.MenuItem
- slots [traySlots]*systray.MenuItem
- approve, deny [traySlots]*systray.MenuItem
+ requests *systray.MenuItem
+ slots []*systray.MenuItem
+ answers chan trayAnswer
state, tooltip, headTx string
state, tooltip, headTx string
levelTx string
levelTx string
- labels []string
+ bindings []traySlot
stopped, hasPanel *bool
stopped, hasPanel *bool
}
}
@@ -41,12 +42,10 @@ func (u *systrayUI) build() {
@@ -41,12 +42,10 @@ func (u *systrayUI) build() {
u.level.Disable()
u.level.Disable()
u.level.Hide()
u.level.Hide()
systray.AddSeparator()
systray.AddSeparator()
- for i := 0; i < traySlots; i++ {
- u.slots[i] = systray.AddMenuItem("", "Approve or deny what your agent asks")
- u.approve[i] = u.slots[i].AddSubMenuItem("Approve", "Let it run")
- u.deny[i] = u.slots[i].AddSubMenuItem("Deny", "Refuse it")
- u.slots[i].Hide()
- }
+ u.requests = systray.AddMenuItem("Waiting requests", "Approve or deny what your agent asks")
+ u.requests.Hide()
+ u.answers = make(chan trayAnswer, 16)
+
systray.AddSeparator()
systray.AddSeparator()
u.stop = systray.AddMenuItem("Stop everything", "The kill switch: stop every actuator, observe only, until you resume")
u.stop = systray.AddMenuItem("Stop everything", "The kill switch: stop every actuator, observe only, until you resume")
u.resume = systray.AddMenuItem("Resume", "Release the stop")
u.resume = systray.AddMenuItem("Resume", "Release the stop")
@@ -119,20 +118,40 @@ func (u *systrayUI) SetStopped(stopped bool) {
@@ -119,20 +118,40 @@ func (u *systrayUI) SetStopped(stopped bool) {
}
}
}
}
-func (u *systrayUI) SetSlots(labels []string) {
+func (u *systrayUI) SetSlots(slots []traySlot) {
u.mu.Lock()
u.mu.Lock()
defer u.mu.Unlock()
defer u.mu.Unlock()
- if strings.Join(labels, "\x00") == strings.Join(u.labels, "\x00") && u.labels != nil {
+ same := len(slots) == len(u.bindings)
+ if same {
+ for i, slot := range slots {
+ if slot.Approval.ID != u.bindings[i].Approval.ID || slot.Label != u.bindings[i].Label {
+ same = false
+ break
+ }
+ }
+ }
+ if same {
return
return
}
}
- u.labels = append([]string{}, labels...)
- for i := 0; i < traySlots; i++ {
- if i < len(labels) {
- u.slots[i].SetTitle(labels[i])
- u.slots[i].Show()
- } else {
- u.slots[i].Hide()
- }
+ // Menu objects (and their click channels) are never reassigned to another
+ // nonce. An already queued click can therefore only answer the old request.
+ for _, item := range u.slots {
+ item.Remove()
+ }
+ u.slots = nil
+ u.bindings = append([]traySlot(nil), slots...)
+ for _, slot := range slots {
+ item := u.requests.AddSubMenuItem(slot.Label, "")
+ approve := item.AddSubMenuItem("Approve", "Let it run")
+ deny := item.AddSubMenuItem("Deny", "Refuse it")
+ u.slots = append(u.slots, item)
+ forwardTrayClicks(approve.ClickedCh, u.answers, slot.Approval.ID, true)
+ forwardTrayClicks(deny.ClickedCh, u.answers, slot.Approval.ID, false)
+ }
+ if len(slots) == 0 {
+ u.requests.Hide()
+ } else {
+ u.requests.Show()
}
}
}
}
@@ -168,25 +187,7 @@ func runTray(g *globals) error {
@@ -168,25 +187,7 @@ func runTray(g *globals) error {
}
}
func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *globals) {
func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *globals) {
- for i := 0; i < traySlots; i++ {
- i := i
- go func() {
- for range ui.approve[i].ClickedCh {
- if a, ok := core.slotID(i); ok {
- _ = core.answer(a, true)
- core.tick()
- }
- }
- }()
- go func() {
- for range ui.deny[i].ClickedCh {
- if a, ok := core.slotID(i); ok {
- _ = core.answer(a, false)
- core.tick()
- }
- }
- }()
- }
+
t := time.NewTicker(trayPoll)
t := time.NewTicker(trayPoll)
defer t.Stop()
defer t.Stop()
answers := core.note.Answers()
answers := core.note.Answers()
@@ -194,6 +195,9 @@ func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *global
@@ -194,6 +195,9 @@ func trayLoop(core *trayCore, ui *systrayUI, stopSig <-chan os.Signal, g *global
select {
select {
case <-t.C:
case <-t.C:
core.tick()
core.tick()
+ case a := <-ui.answers:
+ _ = core.answerByID(a.ID, a.Approved)
+ core.tick()
case a := <-answers:
case a := <-answers:
if err := core.answerByID(a.ID, a.Approved); err != nil {
if err := core.answerByID(a.ID, a.Approved); err != nil {
g.log.Info("tray answer", "id", a.ID, "err", err)
g.log.Info("tray answer", "id", a.ID, "err", err)
@@ -60,7 +60,7 @@ func TestTrayPanelURLOnlyForTheBot(t *testing.T) {
@@ -60,7 +60,7 @@ func TestTrayPanelURLOnlyForTheBot(t *testing.T) {
}
}
func TestTraySlotLabel(t *testing.T) {
func TestTraySlotLabel(t *testing.T) {
- if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != "exec: run echo hi" {
+ if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != `exec: run echo\u000ahi` {
t.Fatalf("label: %q", got)
t.Fatalf("label: %q", got)
}
}
long := slotLabel(node.Approval{Kind: "input", Summary: strings.Repeat("é", 100)})
long := slotLabel(node.Approval{Kind: "input", Summary: strings.Repeat("é", 100)})
@@ -166,14 +166,21 @@ type fakeUI struct {
@@ -166,14 +166,21 @@ type fakeUI struct {
state, tooltip, header, level string
state, tooltip, header, level string
stopped, panel bool
stopped, panel bool
slots []string
slots []string
+ bindings []traySlot
}
}
-func (u *fakeUI) SetIcon(s string) { u.state = s }
-func (u *fakeUI) SetTooltip(s string) { u.tooltip = s }
-func (u *fakeUI) SetHeader(s string) { u.header = s }
-func (u *fakeUI) SetLevel(s string) { u.level = s }
-func (u *fakeUI) SetStopped(b bool) { u.stopped = b }
-func (u *fakeUI) SetSlots(l []string) { u.slots = l }
+func (u *fakeUI) SetIcon(s string) { u.state = s }
+func (u *fakeUI) SetTooltip(s string) { u.tooltip = s }
+func (u *fakeUI) SetHeader(s string) { u.header = s }
+func (u *fakeUI) SetLevel(s string) { u.level = s }
+func (u *fakeUI) SetStopped(b bool) { u.stopped = b }
+func (u *fakeUI) SetSlots(l []traySlot) {
+ u.bindings = l
+ u.slots = nil
+ for _, s := range l {
+ u.slots = append(u.slots, s.Label)
+ }
+}
func (u *fakeUI) SetPanel(b bool) { u.panel = b }
func (u *fakeUI) SetPanel(b bool) { u.panel = b }
func (n *fakeNotes) Answers() <-chan trayAnswer { return nil }
func (n *fakeNotes) Answers() <-chan trayAnswer { return nil }
func (n *fakeNotes) Shut() { n.shut = true }
func (n *fakeNotes) Shut() { n.shut = true }
@@ -228,9 +235,9 @@ func TestTrayCoreAgainstANode(t *testing.T) {
@@ -228,9 +235,9 @@ func TestTrayCoreAgainstANode(t *testing.T) {
t.Fatalf("asked again without a change: %v", notes.asked)
t.Fatalf("asked again without a change: %v", notes.asked)
}
}
- a, ok := core.slotID(0)
- if !ok || a.ID != "c1" {
- t.Fatalf("slot 0: %+v %v", a, ok)
+ a := ui.bindings[0].Approval
+ if a.ID != "c1" {
+ t.Fatalf("slot 0: %+v", a)
}
}
if err := core.answer(a, true); err == nil {
if err := core.answer(a, true); err == nil {
t.Fatal("the Node refused the approval; the tray should say so")
t.Fatal("the Node refused the approval; the tray should say so")
@@ -291,3 +298,25 @@ func TestTrayCoreAgainstANode(t *testing.T) {
@@ -291,3 +298,25 @@ func TestTrayCoreAgainstANode(t *testing.T) {
t.Fatalf("the Node gone: %+v", ui)
t.Fatalf("the Node gone: %+v", ui)
}
}
}
}
+
+func TestTrayClickRetainsRenderedNonce(t *testing.T) {
+ clicks := make(chan struct{}, 1)
+ answers := make(chan trayAnswer, 1)
+ forwardTrayClicks(clicks, answers, "old-nonce", true)
+ core := newTrayCore("", &fakeUI{}, nil)
+ core.view = trayView{Waiting: []node.Approval{{ID: "replacement-nonce"}}}
+ // A click queued while the old label was on screen is delivered after reorder.
+ clicks <- struct{}{}
+ close(clicks)
+ select {
+ case a := <-answers:
+ if a.ID != "old-nonce" {
+ t.Fatal("click rebound", a)
+ }
+ if err := core.answerByID(a.ID, a.Approved); err == nil {
+ t.Fatal("stale click accepted")
+ }
+ case <-time.After(time.Second):
+ t.Fatal("click lost")
+ }
+}
@@ -72,7 +72,9 @@ that a request is waiting on the machine.
@@ -72,7 +72,9 @@ that a request is waiting on the machine.
**Kill switch:** `openvibe-node stop` (and an owner's e-stop) drops the plugin to `observe`: what runs is killed, what
**Kill switch:** `openvibe-node stop` (and an owner's e-stop) drops the plugin to `observe`: what runs is killed, what
waits is denied, the agent's browser is closed, and the core also refuses `exec`, file writes, `input` and the browser's
waits is denied, the agent's browser is closed, and the core also refuses `exec`, file writes, `input` and the browser's
-acting actions until `openvibe-node resume`. `halt` kills what runs.
+acting actions until `openvibe-node resume`. `halt` kills what runs. An e-stop on this computer always needs that local
+resume: the owner's remote clear (`estop` `latched:false`, or a config without `estop_latched`) releases the robot's remote
+latch, never this computer.
## What it does
## What it does
@@ -170,7 +170,9 @@ applied the Node runs nothing but `halt` (`nack not_ready`), so that latch is in
@@ -170,7 +170,9 @@ applied the Node runs nothing but `halt` (`nack not_ready`), so that latch is in
`desks.host`, `ai.host`, `bot.robot`): what the machine does for the network. The Node keeps them and applies the ones
`desks.host`, `ai.host`, `bot.robot`): what the machine does for the network. The Node keeps them and applies the ones
this machine was installed for; the local configuration always wins (a role never installs anything and never turns on
this machine was installed for; the local configuration always wins (a role never installs anything and never turns on
a feature the owner turned off here), and a role the Node does not know is ignored.
a feature the owner turned off here), and a role the Node does not know is ignored.
-`estop` `latched:false` is the owner's clear: it clears the remote latch only, never the local kill switch.
+`estop` `latched:false` is the owner's clear: it clears the remote latch only, never the local kill switch. A computer
+plugin is the one exception to the remote latch's reach: an e-stop on a computer waits for `openvibe-node resume` on the
+machine, whatever the server clears (docs/computer.md).
### Device → server
### Device → server
@@ -32,7 +32,7 @@ const (
@@ -32,7 +32,7 @@ const (
type Request struct {
type Request struct {
Cmd string `json:"cmd"`
Cmd string `json:"cmd"`
- ID string `json:"id,omitempty"` // approve and deny: the command id the plugin asked about
+ ID string `json:"id,omitempty"` // approve and deny: the locally minted approval nonce (never the cloud command ID)
On bool `json:"on,omitempty"` // tray: the tray is running
On bool `json:"on,omitempty"` // tray: the tray is running
}
}
@@ -104,8 +104,11 @@ func Call(path string, req Request, timeout time.Duration) (Response, error) {
@@ -104,8 +104,11 @@ func Call(path string, req Request, timeout time.Duration) (Response, error) {
if err := json.NewDecoder(c).Decode(&resp); err != nil {
if err := json.NewDecoder(c).Decode(&resp); err != nil {
return resp, err
return resp, err
}
}
- if !resp.OK && resp.Error != "" {
+ if resp.Error != "" {
return resp, errors.New(resp.Error)
return resp, errors.New(resp.Error)
}
}
+ if !resp.OK {
+ return resp, errors.New("local control request failed")
+ }
return resp, nil
return resp, nil
}
}
@@ -20,6 +20,12 @@ func TestServeCall(t *testing.T) {
@@ -20,6 +20,12 @@ func TestServeCall(t *testing.T) {
done := make(chan error, 1)
done := make(chan error, 1)
go func() {
go func() {
done <- Serve(ctx, path, func(r Request) Response {
done <- Serve(ctx, path, func(r Request) Response {
+ if r.Cmd == "partial" {
+ return Response{OK: true, Error: "could not save latch"}
+ }
+ if r.Cmd == "failed" {
+ return Response{}
+ }
if r.Cmd == CmdStop {
if r.Cmd == CmdStop {
return Response{OK: true, Data: []byte(`"stopped"`)}
return Response{OK: true, Data: []byte(`"stopped"`)}
}
}
@@ -47,6 +53,11 @@ func TestServeCall(t *testing.T) {
@@ -47,6 +53,11 @@ func TestServeCall(t *testing.T) {
if _, err := Call(path, Request{Cmd: "nope"}, time.Second); err == nil {
if _, err := Call(path, Request{Cmd: "nope"}, time.Second); err == nil {
t.Fatal("unknown accepted")
t.Fatal("unknown accepted")
}
}
+ for _, cmd := range []string{"partial", "failed"} {
+ if _, err := Call(path, Request{Cmd: cmd}, time.Second); err == nil {
+ t.Fatalf("%s failure ignored", cmd)
+ }
+ }
// A second server refuses to start.
// A second server refuses to start.
if err := Serve(context.Background(), path, nil); err == nil {
if err := Serve(context.Background(), path, nil); err == nil {
t.Fatal("second server started")
t.Fatal("second server started")
@@ -5,6 +5,7 @@ package node
@@ -5,6 +5,7 @@ package node
import (
import (
"context"
"context"
"encoding/json"
"encoding/json"
+ "fmt"
"io"
"io"
"log/slog"
"log/slog"
"os"
"os"
@@ -21,6 +22,7 @@ import (
@@ -21,6 +22,7 @@ import (
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/localctl"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/plugins"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
"github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+ "github.com/OpenVibers/OpenVibe.Node/internal/safety"
)
)
// startComputer runs a Node with only the real Python computer plugin at the given control level, its roots the one
// startComputer runs a Node with only the real Python computer plugin at the given control level, its roots the one
@@ -180,13 +182,13 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
@@ -180,13 +182,13 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
time.Sleep(50 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
}
}
}
}
- if len(list) != 1 || list[0].ID != "a1" || list[0].Kind != protocol.KindExec || !strings.Contains(list[0].Summary, "echo approved") {
+ if len(list) != 1 || list[0].CommandID != "a1" || list[0].ID == "a1" || list[0].Kind != protocol.KindExec || !strings.Contains(list[0].Summary, "echo approved") {
t.Fatalf("approvals: %+v", list)
t.Fatalf("approvals: %+v", list)
}
}
if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "nope"}, 2*time.Second); r.OK {
if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "nope"}, 2*time.Second); r.OK {
t.Fatal("an unknown id was approved")
t.Fatal("an unknown id was approved")
}
}
- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "a1"}, 2*time.Second); err != nil || !r.OK {
+ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: list[0].ID}, 2*time.Second); err != nil || !r.OK {
t.Fatal(err, r.Error)
t.Fatal(err, r.Error)
}
}
r, err := e.conn.Reply("a1", 10*time.Second)
r, err := e.conn.Reply("a1", 10*time.Second)
@@ -205,7 +207,7 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
@@ -205,7 +207,7 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
for time.Now().Before(deadline.Add(5*time.Second)) && len(e.node.Approvals()) == 0 {
for time.Now().Before(deadline.Add(5*time.Second)) && len(e.node.Approvals()) == 0 {
time.Sleep(50 * time.Millisecond)
time.Sleep(50 * time.Millisecond)
}
}
- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: "a2"}, 2*time.Second); err != nil || !r.OK {
+ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: e.node.Approvals()[0].ID}, 2*time.Second); err != nil || !r.OK {
t.Fatal(err, r.Error)
t.Fatal(err, r.Error)
}
}
r, err = e.conn.Reply("a2", 10*time.Second)
r, err = e.conn.Reply("a2", 10*time.Second)
@@ -232,7 +234,7 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
@@ -232,7 +234,7 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
e.node.mu.Lock()
e.node.mu.Lock()
e.node.inputEnded = time.Now() // a click just ran
e.node.inputEnded = time.Now() // a click just ran
e.node.mu.Unlock()
e.node.mu.Unlock()
- r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second)
+ r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: e.node.Approvals()[0].ID}, 2*time.Second)
if err == nil || r.OK || !strings.Contains(r.Error, "mouse or keyboard") {
if err == nil || r.OK || !strings.Contains(r.Error, "mouse or keyboard") {
t.Fatalf("approve right after a click: %+v %v", r, err)
t.Fatalf("approve right after a click: %+v %v", r, err)
}
}
@@ -243,13 +245,13 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
@@ -243,13 +245,13 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
e.node.inputs["i1"] = true // an input command in flight, not waiting for approval: it is running
e.node.inputs["i1"] = true // an input command in flight, not waiting for approval: it is running
e.node.inputEnded = time.Now().Add(-InputGuard - time.Second)
e.node.inputEnded = time.Now().Add(-InputGuard - time.Second)
e.node.mu.Unlock()
e.node.mu.Unlock()
- if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second); r.OK {
+ if r, _ := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: e.node.Approvals()[0].ID}, 2*time.Second); r.OK {
t.Fatal("approved while an input command runs")
t.Fatal("approved while an input command runs")
}
}
e.node.mu.Lock()
e.node.mu.Lock()
delete(e.node.inputs, "i1")
delete(e.node.inputs, "i1")
e.node.mu.Unlock()
e.node.mu.Unlock()
- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: "g1"}, 2*time.Second); err != nil || !r.OK {
+ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdApprove, ID: e.node.Approvals()[0].ID}, 2*time.Second); err != nil || !r.OK {
t.Fatal(err, r.Error)
t.Fatal(err, r.Error)
}
}
if d := dataOf(t, func() protocol.Message {
if d := dataOf(t, func() protocol.Message {
@@ -270,7 +272,7 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
@@ -270,7 +272,7 @@ func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
e.node.mu.Lock()
e.node.mu.Lock()
e.node.inputEnded = time.Now()
e.node.inputEnded = time.Now()
e.node.mu.Unlock()
e.node.mu.Unlock()
- if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: "g2"}, 2*time.Second); err != nil || !r.OK {
+ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdDeny, ID: e.node.Approvals()[0].ID}, 2*time.Second); err != nil || !r.OK {
t.Fatal("deny is never held back:", err, r.Error)
t.Fatal("deny is never held back:", err, r.Error)
}
}
}
}
@@ -345,3 +347,138 @@ func TestComputerKindsNeedTheComputerPlugin(t *testing.T) {
@@ -345,3 +347,138 @@ func TestComputerKindsNeedTheComputerPlugin(t *testing.T) {
t.Fatalf("exec without the computer plugin: %+v", nk)
t.Fatalf("exec without the computer plugin: %+v", nk)
}
}
}
}
+
+func TestComputerRemoteStopRequiresLocalResume(t *testing.T) {
+ for _, level := range []string{"ask", "trusted", "full"} {
+ t.Run(level, func(t *testing.T) {
+ e, work := startComputer(t, level)
+ e.node.Frame(protocol.Frame{Msg: protocol.Estop{Latched: true, By: "server"}})
+ e.node.Frame(protocol.Frame{Msg: protocol.Estop{Latched: false, By: "server"}})
+ e.node.applyConfig(protocol.Config{}) // absent estop_latched
+ e.node.applyConfig(protocol.Config{EstopLatched: false})
+ if st := e.node.latch.State(); !st.Computer || st.Remote {
+ t.Fatalf("remote clear released computer: %+v", st)
+ }
+ e.conn.Close()
+ e.conn = e.nextConn()
+ e.stop()
+ // Reopen exactly the same local config and persisted latch after a Node restart.
+ n, err := New(e.node.opt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ e.node = n
+ ctx, cancel := context.WithCancel(context.Background())
+ e.cancel = cancel
+ e.done = make(chan struct{})
+ go func() { n.Run(ctx); close(e.done) }()
+ e.conn = e.nextConn()
+ if _, err := e.conn.Expect(protocol.TypeStatus, 20*time.Second, func(m protocol.Message) bool {
+ s := m.(protocol.Status)
+ return len(s.Drivers) > 0 && s.Drivers[0].State == plugins.StateReady
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for i, c := range []struct {
+ kind string
+ value any
+ }{
+ {"exec", map[string]any{"argv": []string{"echo", "bad"}}},
+ {"fs", map[string]any{"op": "write", "path": filepath.Join(work, "bad"), "content_b64": "YmFk"}},
+ {"input", map[string]any{"op": "key", "key": "a"}},
+ {"browser", map[string]any{"op": "navigate", "url": "https://example.com"}},
+ {"desk", map[string]any{"op": "create"}},
+ {"screen_stream", map[string]any{"action": "start"}},
+ } {
+ r := e.computerCmd(fmt.Sprintf("stopped-%d", i), c.kind, c.value, 5*time.Second)
+ if nk, ok := r.(protocol.Nack); !ok || (nk.FaultCode != protocol.FaultLocalStop && nk.FaultCode != protocol.FaultEstopped) {
+ t.Fatalf("%s released: %#v", c.kind, r)
+ }
+ }
+ if r := n.control(localctl.Request{Cmd: localctl.CmdResume}); !r.OK {
+ t.Fatal(r)
+ }
+ if level == "ask" {
+ b, _ := json.Marshal(map[string]any{"op": "write", "path": filepath.Join(work, "after"), "content_b64": "b2s="})
+ if err := e.conn.Command(protocol.Command{ID: "after-resume", Kind: "fs", Value: b}, 20000); err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(5 * time.Second)
+ for len(n.Approvals()) == 0 && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if len(n.Approvals()) != 1 {
+ t.Fatal("local resume did not restore ask")
+ }
+ if r := n.control(localctl.Request{Cmd: localctl.CmdApprove, ID: n.Approvals()[0].ID}); !r.OK {
+ t.Fatal(r)
+ }
+ r, err := e.conn.Reply("after-resume", 5*time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dataOf(t, r)
+ return
+ }
+ r := e.computerCmd("resumed", "fs", map[string]any{"op": "write", "path": filepath.Join(work, "after"), "content_b64": "b2s="}, 5*time.Second)
+ dataOf(t, r)
+ })
+ }
+}
+
+func TestStopSaveFailureIsNotOK(t *testing.T) {
+ e := start(t)
+ if err := os.Mkdir(e.paths.LatchFile()+".tmp", 0700); err != nil {
+ t.Fatal(err)
+ }
+ r := e.node.control(localctl.Request{Cmd: localctl.CmdStop})
+ if r.OK || !strings.Contains(r.Error, "could not be saved") {
+ t.Fatalf("false durability acknowledgement: %+v", r)
+ }
+ if !e.node.latch.State().Local {
+ t.Fatal("in-memory stop lost")
+ }
+ l, _ := safety.OpenLatch(e.paths.LatchFile())
+ if l.State().Local {
+ t.Fatal("fixture did not fail persistence")
+ }
+}
+
+func TestComputerApprovalNonceSurvivesFloodReplay(t *testing.T) {
+ e, _ := startComputer(t, "ask")
+ e.node.dedup = safety.NewDedup(2, time.Minute)
+ send := func(id, text string) {
+ b, _ := json.Marshal(map[string]any{"argv": []string{"echo", text}})
+ if err := e.conn.Command(protocol.Command{ID: id, Kind: "exec", Value: b}, 20000); err != nil {
+ t.Fatal(err)
+ }
+ }
+ send("original", "first")
+ for deadline := time.Now().Add(5 * time.Second); len(e.node.Approvals()) == 0 && time.Now().Before(deadline); {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if len(e.node.Approvals()) != 1 {
+ t.Fatal("no approval")
+ }
+ a := e.node.Approvals()[0]
+ for _, id := range []string{"b", "c", "d", "e"} {
+ e.computerCmd(id, "fs", map[string]any{"op": "list", "path": e.node.opt.Config.Plugins[0].Config["roots"].([]string)[0]}, 5*time.Second)
+ }
+ send("original", "replacement")
+ if r := e.node.control(localctl.Request{Cmd: localctl.CmdApprove, ID: "original"}); r.OK {
+ t.Fatal("cloud ID accepted as local approval")
+ }
+ if r := e.node.control(localctl.Request{Cmd: localctl.CmdApprove, ID: a.ID}); !r.OK {
+ t.Fatal(r)
+ }
+ reply, err := e.conn.Reply("original", 5*time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d := dataOf(t, reply); d["stdout"] != "first\n" {
+ t.Fatalf("replacement ran: %v", d)
+ }
+ if r := e.node.control(localctl.Request{Cmd: localctl.CmdApprove, ID: a.ID}); r.OK {
+ t.Fatal("old nonce reused")
+ }
+}
@@ -138,12 +138,14 @@ const (
@@ -138,12 +138,14 @@ const (
// Approval is one computer command waiting for its owner's answer on the machine.
// Approval is one computer command waiting for its owner's answer on the machine.
type Approval struct {
type Approval struct {
- ID string `json:"id"`
- Plugin string `json:"plugin"`
- Kind string `json:"kind,omitempty"`
- Summary string `json:"summary,omitempty"`
- At int64 `json:"at"`
- ExpiresS int64 `json:"expires_s,omitempty"`
+ ID string `json:"id"` // local approval nonce
+ CommandID string `json:"command_id"`
+ binding *plugins.ApprovalRequest
+ Plugin string `json:"plugin"`
+ Kind string `json:"kind,omitempty"`
+ Summary string `json:"summary,omitempty"`
+ At int64 `json:"at"`
+ ExpiresS int64 `json:"expires_s,omitempty"`
}
}
// New builds a Node. Run starts it.
// New builds a Node. Run starts it.
@@ -187,6 +189,12 @@ func New(opt Options) (*Node, error) {
@@ -187,6 +189,12 @@ func New(opt Options) (*Node, error) {
specs = append(specs, plugins.Spec{Name: p.Name, Argv: opt.Config.PluginCommand(p, opt.Paths), Env: env, Dir: p.Dir, Config: cfg, StrictTiming: p.StrictTiming})
specs = append(specs, plugins.Spec{Name: p.Name, Argv: opt.Config.PluginCommand(p, opt.Paths), Env: env, Dir: p.Dir, Config: cfg, StrictTiming: p.StrictTiming})
}
}
n.mgr = plugins.NewManager(specs, opt.Log)
n.mgr = plugins.NewManager(specs, opt.Log)
+ // Upgrade a pre-existing remote stop to the computer's local-resume policy.
+ if st := latch.State(); st.Remote && n.hasComputer() && !st.Computer {
+ if _, err := latch.SetRemoteWithComputer(st.RemoteReason, true); err != nil {
+ n.log.Error("could not persist computer stop", "err", err)
+ }
+ }
// Codes C9c: the same Codes runner the worker runs, asked for its logins instead of a job. The probe runs as the
// Codes C9c: the same Codes runner the worker runs, asked for its logins instead of a job. The probe runs as the
// Node's user, the home worker.plan_logins names are under, and only its answer (labels, numbers, times) leaves.
// Node's user, the home worker.plan_logins names are under, and only its answer (labels, numbers, times) leaves.
@@ -263,14 +271,11 @@ func (n *Node) Run(parent context.Context) error {
@@ -263,14 +271,11 @@ func (n *Node) Run(parent context.Context) error {
pctx, cancelPlugins := context.WithCancel(context.Background())
pctx, cancelPlugins := context.WithCancel(context.Background())
// A stop latched on disk is set on every plugin before any starts: each process's first lines are then hello and
// A stop latched on disk is set on every plugin before any starts: each process's first lines are then hello and
// the e-stop replayed (cause latched, first_cause what set it), before it can be routed any command.
// the e-stop replayed (cause latched, first_cause what set it), before it can be routed any command.
- if st := n.latch.State(); st.Stopped() {
- n.log.Warn("starting with the stop latched", "remote", st.Remote, "local", st.Local)
- first := plugins.EstopLocal
- if st.Remote {
- first = plugins.EstopRemote
- }
- n.mgr.EstopAll(first, latchReason(st))
+ if st := n.latch.State(); st.Stopped() || st.Computer {
+ n.log.Warn("starting with the stop latched", "remote", st.Remote, "local", st.Local, "computer", st.Computer)
}
}
+ n.applyPluginLatch(n.latch.State(), false)
+
n.mgr.Start(pctx)
n.mgr.Start(pctx)
var wg sync.WaitGroup
var wg sync.WaitGroup
@@ -364,7 +369,7 @@ func (n *Node) Frame(f protocol.Frame) {
@@ -364,7 +369,7 @@ func (n *Node) Frame(f protocol.Frame) {
func (n *Node) remoteEstop(reason, by string) {
func (n *Node) remoteEstop(reason, by string) {
n.log.Warn("e-stop from the server", "by", by)
n.log.Warn("e-stop from the server", "by", by)
n.mgr.EstopAll(plugins.EstopRemote, safety.CleanReason(reason)) // at once, before the disk write
n.mgr.EstopAll(plugins.EstopRemote, safety.CleanReason(reason)) // at once, before the disk write
- if _, err := n.latch.SetRemote(reason); err != nil {
+ if _, err := n.latch.SetRemoteWithComputer(reason, n.hasComputer()); err != nil {
n.log.Error("could not persist the e-stop (it is held in memory)", "err", err)
n.log.Error("could not persist the e-stop (it is held in memory)", "err", err)
}
}
}
}
@@ -747,6 +752,10 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
@@ -747,6 +752,10 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
nack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`: observe only")
nack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`: observe only")
return
return
}
}
+ if st.Computer {
+ nack(protocol.FaultLocalStop, "the emergency stop on this computer needs a local `openvibe-node resume`: observe only")
+ return
+ }
if st.Remote {
if st.Remote {
nack(protocol.FaultEstopped, st.RemoteReason)
nack(protocol.FaultEstopped, st.RemoteReason)
return
return
@@ -846,10 +855,14 @@ func ceilMS(d time.Duration) int64 {
@@ -846,10 +855,14 @@ func ceilMS(d time.Duration) int64 {
// latchReason is the reason a latched stop gives a driver: the remote one when the platform set it, else the local one.
// latchReason is the reason a latched stop gives a driver: the remote one when the platform set it, else the local one.
func latchReason(st safety.LatchState) string {
func latchReason(st safety.LatchState) string {
- if st.Remote {
+ switch {
+ case st.Remote:
return st.RemoteReason
return st.RemoteReason
+ case st.Local:
+ return st.LocalReason
+ default:
+ return "the computer's emergency stop: `openvibe-node resume` on this machine releases it"
}
}
- return st.LocalReason
}
}
// serverNow is the server's clock (epoch ns) when a frame stamped sentMS was received: the link's estimate, else the
// serverNow is the server's clock (epoch ns) when a frame stamped sentMS was received: the link's estimate, else the
@@ -877,21 +890,44 @@ func (n *Node) send(m protocol.Message) {
@@ -877,21 +890,44 @@ func (n *Node) send(m protocol.Message) {
// ---- latch ----
// ---- latch ----
+// Computer stop is independent of the robot protocol's remotely cleared latch.
+func (n *Node) hasComputer() bool {
+ if n.opt.Config == nil {
+ return false
+ }
+ for _, p := range n.opt.Config.Plugins {
+ if p.Name == "computer" && !p.Disabled {
+ return true
+ }
+ }
+ return false
+}
+
+func (n *Node) applyPluginLatch(st safety.LatchState, resume bool) {
+ for _, p := range n.mgr.Plugins() {
+ if st.Stopped() || (st.Computer && p.Name() == "computer") {
+ cause := plugins.EstopRemote
+ if st.Local || st.Computer && p.Name() == "computer" {
+ cause = plugins.EstopLocal
+ }
+ p.Estop(cause, latchReason(st))
+ } else if resume {
+ p.Resume()
+ }
+ }
+}
+
func (n *Node) onLatch(st safety.LatchState) {
func (n *Node) onLatch(st safety.LatchState) {
if n.worker != nil {
if n.worker != nil {
n.worker.SetStopped(stopFault(st)) // kills every job at once while stopped
n.worker.SetStopped(stopFault(st)) // kills every job at once while stopped
}
}
- if st.Stopped() {
- n.log.Warn("stop latched", "remote", st.Remote, "local", st.Local)
- cause := plugins.EstopRemote
- if st.Local {
- cause = plugins.EstopLocal
- }
- n.mgr.EstopAll(cause, latchReason(st))
+ if st.Stopped() || st.Computer {
+ n.log.Warn("stop latched", "remote", st.Remote, "local", st.Local, "computer", st.Computer)
} else {
} else {
n.log.Warn("stop cleared; motion allowed again")
n.log.Warn("stop cleared; motion allowed again")
- n.mgr.ResumeAll()
}
}
+ n.applyPluginLatch(st, true)
+
n.sendEstopState()
n.sendEstopState()
n.sendStatus()
n.sendStatus()
}
}
@@ -986,7 +1022,7 @@ func (n *Node) output(o plugins.Output) {
@@ -986,7 +1022,7 @@ func (n *Node) output(o plugins.Output) {
sid, _ := o.Data["sid"].(string)
sid, _ := o.Data["sid"].(string)
n.screenEnded(o.Plugin, sid)
n.screenEnded(o.Plugin, sid)
}
}
- n.trackApproval(o.Plugin, name, o.Data, o.At)
+ n.trackApproval(o.Plugin, name, o.Data, o.At, o.Approval)
n.mu.Lock()
n.mu.Lock()
n.events = append(n.events, protocol.Event{Name: name, Driver: o.Plugin, TS: o.At.UnixMilli(), Fields: fields})
n.events = append(n.events, protocol.Event{Name: name, Driver: o.Plugin, TS: o.At.UnixMilli(), Fields: fields})
n.mu.Unlock()
n.mu.Unlock()
@@ -1305,21 +1341,30 @@ func (n *Node) sendStatus() {
@@ -1305,21 +1341,30 @@ func (n *Node) sendStatus() {
}
}
// trackApproval keeps the list `openvibe-node approvals` shows from the computer plugin's approval events.
// trackApproval keeps the list `openvibe-node approvals` shows from the computer plugin's approval events.
-func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.Time) {
+func (n *Node) trackApproval(plugin, name string, data map[string]any, at time.Time, binding *plugins.ApprovalRequest) {
id, _ := data["id"].(string)
id, _ := data["id"].(string)
- if id == "" || (name != "approval" && name != "approval_done") {
+ if id == "" || binding == nil || (name != "approval" && name != "approval_done") {
return
return
}
}
n.mu.Lock()
n.mu.Lock()
defer n.mu.Unlock()
defer n.mu.Unlock()
if name == "approval_done" {
if name == "approval_done" {
- delete(n.approvals, id)
+ if a, ok := n.approvals[id]; ok && a.binding == binding {
+ delete(n.approvals, id)
+ }
+ return
+ }
+ p := n.mgr.Get(plugin)
+ if p == nil || !p.ApprovalValid(binding) {
+ return
+ }
+ if _, exists := n.approvals[id]; exists {
return
return
}
}
if len(n.approvals) >= 64 {
if len(n.approvals) >= 64 {
return
return
}
}
- a := Approval{ID: id, Plugin: plugin, At: at.UnixMilli()}
+ a := Approval{ID: binding.Nonce, CommandID: id, binding: binding, Plugin: plugin, At: at.UnixMilli()}
a.Kind, _ = data["kind"].(string)
a.Kind, _ = data["kind"].(string)
a.Summary, _ = data["summary"].(string)
a.Summary, _ = data["summary"].(string)
if e, ok := data["expires_s"].(float64); ok {
if e, ok := data["expires_s"].(float64); ok {
@@ -1333,7 +1378,12 @@ func (n *Node) Approvals() []Approval {
@@ -1333,7 +1378,12 @@ func (n *Node) Approvals() []Approval {
n.mu.Lock()
n.mu.Lock()
defer n.mu.Unlock()
defer n.mu.Unlock()
out := make([]Approval, 0, len(n.approvals))
out := make([]Approval, 0, len(n.approvals))
- for _, a := range n.approvals {
+ for id, a := range n.approvals {
+ p := n.mgr.Get(a.Plugin)
+ if p == nil || !p.ApprovalValid(a.binding) {
+ delete(n.approvals, id)
+ continue
+ }
out = append(out, a)
out = append(out, a)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At || (out[i].At == out[j].At && out[i].ID < out[j].ID) })
sort.Slice(out, func(i, j int) bool { return out[i].At < out[j].At || (out[i].At == out[j].At && out[i].ID < out[j].ID) })
@@ -1386,13 +1436,20 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1386,13 +1436,20 @@ func (n *Node) control(r localctl.Request) localctl.Response {
return localctl.Response{OK: true, Data: b}
return localctl.Response{OK: true, Data: b}
case localctl.CmdApprove, localctl.CmdDeny:
case localctl.CmdApprove, localctl.CmdDeny:
n.mu.Lock()
n.mu.Lock()
- a, ok := n.approvals[r.ID]
+ var a Approval
+ ok := false
+ for _, candidate := range n.approvals {
+ if candidate.ID == r.ID {
+ a, ok = candidate, true
+ break
+ }
+ }
if ok && r.Cmd == localctl.CmdApprove && n.drivingLocked() {
if ok && r.Cmd == localctl.CmdApprove && n.drivingLocked() {
n.mu.Unlock()
n.mu.Unlock()
return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment, so that its click cannot approve this"}
return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment, so that its click cannot approve this"}
}
}
if ok {
if ok {
- delete(n.approvals, r.ID)
+ delete(n.approvals, a.CommandID)
}
}
n.mu.Unlock()
n.mu.Unlock()
if !ok {
if !ok {
@@ -1402,7 +1459,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1402,7 +1459,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
if p == nil {
if p == nil {
return localctl.Response{Error: "the plugin that asked is gone"}
return localctl.Response{Error: "the plugin that asked is gone"}
}
}
- if err := p.Answer(a.ID, r.Cmd == localctl.CmdApprove); err != nil {
+ if err := p.Answer(a.binding, r.Cmd == localctl.CmdApprove); err != nil {
return localctl.Response{Error: err.Error()}
return localctl.Response{Error: err.Error()}
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
@@ -1412,7 +1469,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1412,7 +1469,7 @@ func (n *Node) control(r localctl.Request) localctl.Response {
case localctl.CmdStop:
case localctl.CmdStop:
n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persist
n.mgr.EstopAll(plugins.EstopLocal, "openvibe-node stop") // at once, then persist
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
- return localctl.Response{OK: true, Error: "stopped, but the latch could not be saved: " + err.Error()}
+ return localctl.Response{Error: "stopped, but the latch could not be saved: " + err.Error()}
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
case localctl.CmdQuit:
case localctl.CmdQuit:
@@ -378,7 +378,10 @@ func TestMissedHeartbeatsStop(t *testing.T) {
@@ -378,7 +378,10 @@ func TestMissedHeartbeatsStop(t *testing.T) {
}
}
}
}
-func TestEstopLatchedUntilOwnerClears(t *testing.T) {
+// TestRobotEstopLatchedUntilOwnerClears: the remote latch is the server's to clear, and the owner clears it
+// with `estop latched:false`. The computer plugin is the one exception: an e-stop on a computer waits for a
+// local `openvibe-node resume` (TestComputerRemoteStopRequiresLocalResume).
+func TestRobotEstopLatchedUntilOwnerClears(t *testing.T) {
e := start(t)
e := start(t)
n := len(e.records())
n := len(e.records())
e.cmd("drive", `{"throttle":0.3}`, 1000)
e.cmd("drive", `{"throttle":0.3}`, 1000)
@@ -93,8 +93,8 @@ func (n *Node) screenCommand(c protocol.Command, entry *safety.Entry, received m
@@ -93,8 +93,8 @@ func (n *Node) screenCommand(c protocol.Command, entry *safety.Entry, received m
return
return
}
}
st := n.latch.State()
st := n.latch.State()
- if st.Local {
- nack(protocol.FaultLocalStop, "stopped on the machine with `openvibe-node stop`")
+ if st.Local || st.Computer {
+ nack(protocol.FaultLocalStop, "stopped on the machine: `openvibe-node resume` on this machine is required")
return
return
}
}
if st.Remote {
if st.Remote {
@@ -186,7 +186,7 @@ func (n *Node) startScreen(whip string, key credentials.Secret, seconds, fps int
@@ -186,7 +186,7 @@ func (n *Node) startScreen(whip string, key credentials.Secret, seconds, fps int
case now := <-t.C:
case now := <-t.C:
st := n.latch.State()
st := n.latch.State()
switch {
switch {
- case st.Local || st.Remote:
+ case st.Local || st.Remote || st.Computer:
n.endScreen(s, "stopped on the machine")
n.endScreen(s, "stopped on the machine")
return
return
case now.After(until.Add(3 * time.Second)):
case now.After(until.Add(3 * time.Second)):
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+package plugins
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/mono"
+ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
+)
+
+func TestApprovalBindsPayloadProcessAndExpiry(t *testing.T) {
+ p := newPlugin(Spec{Name: "computer"}, slog.Default(), make(chan Output, 8))
+ p.state, p.queue = StateReady, make(chan line, 16)
+ payload := json.RawMessage(`{"argv":["original"],"number":9007199254740993}`)
+ wait := p.Begin(context.Background(), "cloud", "exec", payload, 10000)
+ a := p.approval("cloud", 10)
+ if a == nil || len(a.Nonce) != 64 || a.Nonce == "cloud" {
+ t.Fatal("no independent nonce", a)
+ }
+ queued := <-p.queue
+ payload[10] = 'X'
+ var sent map[string]json.RawMessage
+ if err := json.Unmarshal(queued.b, &sent); err != nil {
+ t.Fatal(err)
+ }
+ if string(sent["value"]) != `{"argv":["original"],"number":9007199254740993}` {
+ t.Fatal("payload changed", string(sent["value"]))
+ }
+ // A restart invalidates even a delayed event whose cloud ID is reused.
+ p.failPending(protocol.FaultPluginDown)
+ p.queue = make(chan line, 16)
+ p.Begin(context.Background(), "cloud", "exec", json.RawMessage(`{"argv":["replacement"]}`), 10000)
+ replacement := p.approval("cloud", 10)
+ if replacement.Nonce == a.Nonce || replacement.Digest == a.Digest {
+ t.Fatal("replacement reused approval binding")
+ }
+ if p.ApprovalValid(a) || p.Answer(a, true) == nil {
+ t.Fatal("stale process approval accepted")
+ }
+ if r := wait(); r.FaultCode != protocol.FaultPluginDown {
+ t.Fatal(r)
+ }
+ if !p.ApprovalValid(replacement) {
+ t.Fatal("old waiter deleted replacement")
+ }
+ replacement.expires = mono.Now().Add(-time.Millisecond)
+ if p.Answer(replacement, true) == nil {
+ t.Fatal("expired approval accepted")
+ }
+ if len(p.queue) != 1 {
+ t.Fatal("stale answer reached plugin")
+ }
+}
+
+func TestApprovalAnswerIsSingleUse(t *testing.T) {
+ p := newPlugin(Spec{Name: "computer"}, slog.Default(), make(chan Output, 8))
+ p.state, p.queue = StateReady, make(chan line, 8)
+ p.Begin(context.Background(), "cloud", "exec", json.RawMessage(`{}`), 10000)
+ a := p.approval("cloud", 10)
+ if err := p.Answer(a, false); err != nil {
+ t.Fatal(err)
+ }
+ if err := p.Answer(a, true); err == nil {
+ t.Fatal("answer replayed")
+ }
+ if len(p.queue) != 2 {
+ t.Fatal("unexpected messages", len(p.queue))
+ }
+}
@@ -8,6 +8,9 @@ package plugins
@@ -8,6 +8,9 @@ package plugins
import (
import (
"bufio"
"bufio"
"context"
"context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
"encoding/json"
"encoding/json"
"errors"
"errors"
"fmt"
"fmt"
@@ -89,10 +92,11 @@ const (
@@ -89,10 +92,11 @@ const (
// Output is something a plugin said that the core forwards: telemetry, an event, a video frame, a fault or a state
// Output is something a plugin said that the core forwards: telemetry, an event, a video frame, a fault or a state
// change. Data is the plugin's message without "op".
// change. Data is the plugin's message without "op".
type Output struct {
type Output struct {
- Plugin string
- Kind string
- Data map[string]any
- At time.Time
+ Plugin string
+ Kind string
+ Data map[string]any
+ At time.Time
+ Approval *ApprovalRequest // local only: never forwarded in Data
}
}
// Plugin is one supervised driver process.
// Plugin is one supervised driver process.
@@ -111,6 +115,7 @@ type Plugin struct {
@@ -111,6 +115,7 @@ type Plugin struct {
// The newest turn_epoch a halt carried: hello tells a restarted process, so a stale turn stays refused there too.
// The newest turn_epoch a halt carried: hello tells a restarted process, so a stale turn stays refused there too.
haltEpoch int64
haltEpoch int64
pending map[string]chan Reply
pending map[string]chan Reply
+ requests map[string]*ApprovalRequest
queue chan line // to the current process's stdin; nil while down
queue chan line // to the current process's stdin; nil while down
kill func()
kill func()
restarts int
restarts int
@@ -119,7 +124,7 @@ type Plugin struct {
@@ -119,7 +124,7 @@ type Plugin struct {
func newPlugin(spec Spec, log *slog.Logger, out chan<- Output) *Plugin {
func newPlugin(spec Spec, log *slog.Logger, out chan<- Output) *Plugin {
return &Plugin{spec: spec, log: log.With("plugin", spec.Name), out: out, state: StateStarting,
return &Plugin{spec: spec, log: log.With("plugin", spec.Name), out: out, state: StateStarting,
- pending: map[string]chan Reply{}}
+ pending: map[string]chan Reply{}, requests: map[string]*ApprovalRequest{}}
}
}
// Name is the configured name.
// Name is the configured name.
@@ -329,9 +334,63 @@ func (p *Plugin) Resume() {
@@ -329,9 +334,63 @@ func (p *Plugin) Resume() {
_ = p.send(map[string]any{"op": "resume", "cause": "cleared"})
_ = p.send(map[string]any{"op": "resume", "cause": "cleared"})
}
}
-// Answer gives the plugin its owner's answer to an approval it asked for (`openvibe-node approve|deny <id>`).
-func (p *Plugin) Answer(id string, approved bool) error {
- return p.send(map[string]any{"op": "approval", "id": id, "approved": approved})
+// ApprovalRequest is an immutable local identity for one pending payload and process instance: the
+// random nonce the owner's answer names, the digest of the payload it was minted for, the process it
+// belongs to (its queue) and the instant it expires at. Only the core's local approval listing exposes
+// the nonce; the plugin never sees it.
+type ApprovalRequest struct {
+ Nonce string
+ Digest [32]byte
+ id string
+ expires mono.Instant
+ queue chan line
+ reply chan Reply
+ answered bool
+}
+
+// approval captures the pending request at event receipt, before Node's event queue.
+func (p *Plugin) approval(id string, seconds float64) *ApprovalRequest {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ a := p.requests[id]
+ if a == nil || a.answered || p.pending[id] != a.reply {
+ return nil
+ }
+ if seconds > 0 && seconds < mono.Until(a.expires).Seconds() {
+ a.expires = mono.Now().Add(time.Duration(seconds * float64(time.Second)))
+ }
+ return a
+}
+
+// ApprovalValid rejects stale events, replaced processes, completed work and expiry.
+func (p *Plugin) ApprovalValid(a *ApprovalRequest) bool {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.approvalValidLocked(a)
+}
+
+func (p *Plugin) approvalValidLocked(a *ApprovalRequest) bool {
+ return a != nil && !a.answered && p.requests[a.id] == a && p.pending[a.id] == a.reply && p.queue == a.queue && mono.Until(a.expires) > 0
+}
+
+// Answer binds validation and enqueue to the same process queue under the lock.
+func (p *Plugin) Answer(a *ApprovalRequest, approved bool) error {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if !p.approvalValidLocked(a) {
+ return errors.New("approval expired or its request is no longer pending")
+ }
+ b, err := json.Marshal(map[string]any{"op": "approval", "id": a.id, "approved": approved})
+ if err != nil {
+ return err
+ }
+ select {
+ case p.queue <- line{b: append(b, '\n')}:
+ a.answered = true
+ return nil
+ default:
+ return errors.New("plugin input queue is full")
+ }
}
}
// Voice tells a voice plugin whether its owner lets Vibe answer their voice here, and the call word to listen for.
// Voice tells a voice plugin whether its owner lets Vibe answer their voice here, and the call word to listen for.
@@ -422,12 +481,24 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa
@@ -422,12 +481,24 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa
}
}
return now(Reply{FaultCode: protocol.FaultNotReady})
return now(Reply{FaultCode: protocol.FaultNotReady})
}
}
+ if _, exists := p.pending[id]; exists {
+ p.mu.Unlock()
+ return now(Reply{FaultCode: protocol.FaultNotAllowed, Message: "command ID is already pending"})
+ }
+ var nonce [32]byte
+ if _, err := rand.Read(nonce[:]); err != nil {
+ p.mu.Unlock()
+ return now(Reply{FaultCode: protocol.FaultNotAllowed, Message: "cannot mint local approval identity"})
+ }
ch := make(chan Reply, 1)
ch := make(chan Reply, 1)
p.pending[id] = ch
p.pending[id] = ch
p.mu.Unlock()
p.mu.Unlock()
done := func() {
done := func() {
p.mu.Lock()
p.mu.Lock()
- delete(p.pending, id)
+ if p.pending[id] == ch {
+ delete(p.pending, id)
+ delete(p.requests, id)
+ }
p.mu.Unlock()
p.mu.Unlock()
}
}
msg := map[string]any{"op": "command", "id": id, "kind": kind, "deadline_ms": deadlineMS}
msg := map[string]any{"op": "command", "id": id, "kind": kind, "deadline_ms": deadlineMS}
@@ -447,13 +518,51 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa
@@ -447,13 +518,51 @@ func (p *Plugin) begin(ctx context.Context, id, kind string, value json.RawMessa
if prov != nil {
if prov != nil {
msg["provenance"] = prov
msg["provenance"] = prov
}
}
- var err error
- if expires == 0 {
- err = p.send(msg)
- } else {
- err = p.enqueue(line{cmd: &timed{id: id, msg: msg, expires: expires, queued: mono.Now(), motion: p.motionKind(kind)}})
- }
+ // Hash the encoded command, not a caller-owned RawMessage that could change.
+ raw, err := json.Marshal(msg)
if err != nil {
if err != nil {
+ done()
+ return now(Reply{FaultCode: protocol.FaultBadValue})
+ }
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ done()
+ return now(Reply{FaultCode: protocol.FaultBadValue})
+ }
+ immutable := make(map[string]any, len(fields))
+ for key, value := range fields {
+ immutable[key] = value
+ }
+ expiry := expires
+ if expiry == 0 {
+ expiry = mono.Now().Add(time.Duration(deadlineMS) * time.Millisecond)
+ }
+ queued := line{b: append(raw, '\n')}
+ if expires != 0 {
+ queued = line{cmd: &timed{id: id, msg: immutable, expires: expires, queued: mono.Now(), motion: p.motionKind(kind)}}
+ }
+ p.mu.Lock()
+ binding := &ApprovalRequest{Nonce: hex.EncodeToString(nonce[:]), Digest: sha256.Sum256(raw), id: id, expires: expiry, queue: p.queue, reply: ch}
+ if p.pending[id] != ch || p.queue == nil {
+ p.mu.Unlock()
+ done()
+ return now(Reply{FaultCode: protocol.FaultPluginDown})
+ }
+ p.requests[id] = binding
+ pushed := true
+ select {
+ case p.queue <- queued:
+ default:
+ pushed = false
+ }
+ kill := p.kill
+ p.mu.Unlock()
+ if !pushed {
+ // The plugin is not reading its input: it can no longer be told to stop, so we end it.
+ p.log.Error("plugin is not reading its input; killing it")
+ if kill != nil {
+ kill()
+ }
done()
done()
return now(Reply{FaultCode: protocol.FaultPluginDown})
return now(Reply{FaultCode: protocol.FaultPluginDown})
}
}
@@ -490,6 +599,7 @@ func (p *Plugin) failPending(code string) {
@@ -490,6 +599,7 @@ func (p *Plugin) failPending(code string) {
default:
default:
}
}
delete(p.pending, id)
delete(p.pending, id)
+ delete(p.requests, id)
}
}
}
}
@@ -761,6 +871,7 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
@@ -761,6 +871,7 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
}
}
p.mu.Lock()
p.mu.Lock()
ch := p.pending[id]
ch := p.pending[id]
+ delete(p.requests, id) // completion invalidates any queued approval event
p.mu.Unlock()
p.mu.Unlock()
if ch != nil {
if ch != nil {
select {
select {
@@ -786,7 +897,13 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
@@ -786,7 +897,13 @@ func (p *Plugin) readStdout(r io.Reader, described chan struct{}) {
}
}
p.emit(Output{Kind: OutTelemetry, Data: m}, false)
p.emit(Output{Kind: OutTelemetry, Data: m}, false)
case "event":
case "event":
- p.emit(Output{Kind: OutEvent, Data: m}, true)
+ var approval *ApprovalRequest
+ if name, _ := m["name"].(string); name == "approval" || name == "approval_done" {
+ id, _ := m["id"].(string)
+ seconds, _ := m["expires_s"].(float64)
+ approval = p.approval(id, seconds)
+ }
+ p.emit(Output{Kind: OutEvent, Data: m, Approval: approval}, true)
case "local_stop":
case "local_stop":
reason, _ := m["reason"].(string)
reason, _ := m["reason"].(string)
p.log.Warn("plugin latched the local stop", "reason", truncate(reason, 120))
p.log.Warn("plugin latched the local stop", "reason", truncate(reason, 120))
@@ -555,3 +555,17 @@ exit 1`
@@ -555,3 +555,17 @@ exit 1`
}
}
}
}
}
}
+
+func TestDuplicatePendingCannotReplaceRequest(t *testing.T) {
+ p := newPlugin(Spec{Name: "computer"}, slog.Default(), make(chan Output, 8))
+ p.state, p.queue = StateReady, make(chan line, 8)
+ p.Begin(context.Background(), "same", "exec", json.RawMessage(`{"argv":["first"]}`), 10000)
+ first := p.pending["same"]
+ wait := p.Begin(context.Background(), "same", "exec", json.RawMessage(`{"argv":["replacement"]}`), 10000)
+ if p.pending["same"] != first || len(p.queue) != 1 {
+ t.Fatal("duplicate replaced the in-flight request")
+ }
+ if r := wait(); r.OK || r.FaultCode == "" {
+ t.Fatal("duplicate was not refused", r)
+ }
+}
@@ -44,13 +44,20 @@ func (d *Dedup) Begin(id string) (e *Entry, first bool) {
@@ -44,13 +44,20 @@ func (d *Dedup) Begin(id string) (e *Entry, first bool) {
d.mu.Lock()
d.mu.Lock()
defer d.mu.Unlock()
defer d.mu.Unlock()
now := d.now()
now := d.now()
- for f := d.order.Front(); f != nil; f = d.order.Front() {
+ // Capacity and TTL only retire completed requests. Pending work must keep
+ // its identity even when a peer floods the cache or its deadline passes.
+ for f := d.order.Front(); f != nil; {
+ next := f.Next()
old := f.Value.(*Entry)
old := f.Value.(*Entry)
- if len(d.items) <= d.max && now.Sub(old.at) < d.ttl {
- break
+ select {
+ case <-old.done:
+ if len(d.items) >= d.max || now.Sub(old.at) >= d.ttl {
+ d.order.Remove(f)
+ delete(d.items, old.id)
+ }
+ default:
}
}
- d.order.Remove(f)
- delete(d.items, old.id)
+ f = next
}
}
if e, ok := d.items[id]; ok {
if e, ok := d.items[id]; ok {
return e, false
return e, false
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+package safety
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+)
+
+// DisplayText makes untrusted approval text literal on terminals and desktops.
+// Format characters include bidi overrides, isolates and invisible separators.
+func DisplayText(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || r == '\u2028' || r == '\u2029' {
+ if r <= 0xffff {
+ fmt.Fprintf(&b, "\\u%04x", r)
+ } else {
+ fmt.Fprintf(&b, "\\U%08x", r)
+ }
+ } else {
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// QuoteHint quotes a displayed identifier as one POSIX shell argument.
+func QuoteHint(s string) string {
+ return "'" + strings.ReplaceAll(DisplayText(s), "'", "'\"'\"'") + "'"
+}
@@ -22,6 +22,8 @@ import (
@@ -22,6 +22,8 @@ import (
//
//
// Anything that moves is refused while either is set.
// Anything that moves is refused while either is set.
type LatchState struct {
type LatchState struct {
+ // Computer requires a local resume even after the robot remote latch clears.
+ Computer bool `json:"computer,omitempty"`
Remote bool `json:"remote"`
Remote bool `json:"remote"`
RemoteReason string `json:"remote_reason,omitempty"`
RemoteReason string `json:"remote_reason,omitempty"`
RemoteAt time.Time `json:"remote_at,omitempty"`
RemoteAt time.Time `json:"remote_at,omitempty"`
@@ -30,7 +32,8 @@ type LatchState struct {
@@ -30,7 +32,8 @@ type LatchState struct {
LocalAt time.Time `json:"local_at,omitempty"`
LocalAt time.Time `json:"local_at,omitempty"`
}
}
-// Stopped reports whether anything is latched.
+// Stopped reports a global stop. Computer is checked separately for computer commands
+// so a remote clear can still release robot plugins.
func (s LatchState) Stopped() bool { return s.Remote || s.Local }
func (s LatchState) Stopped() bool { return s.Remote || s.Local }
// Latch is the persisted latch. It survives restarts: a Node that was e-stopped comes back e-stopped.
// Latch is the persisted latch. It survives restarts: a Node that was e-stopped comes back e-stopped.
@@ -133,7 +136,13 @@ func (l *Latch) persistLocked() error {
@@ -133,7 +136,13 @@ func (l *Latch) persistLocked() error {
// SetRemote latches the server e-stop.
// SetRemote latches the server e-stop.
func (l *Latch) SetRemote(reason string) (LatchState, error) {
func (l *Latch) SetRemote(reason string) (LatchState, error) {
+ return l.SetRemoteWithComputer(reason, false)
+}
+
+// SetRemoteWithComputer persists both requirements atomically.
+func (l *Latch) SetRemoteWithComputer(reason string, computer bool) (LatchState, error) {
return l.update(func(s *LatchState) {
return l.update(func(s *LatchState) {
+ s.Computer = s.Computer || computer
if !s.Remote {
if !s.Remote {
s.RemoteAt = time.Now().UTC()
s.RemoteAt = time.Now().UTC()
}
}
@@ -4,6 +4,7 @@ import (
@@ -4,6 +4,7 @@ import (
"encoding/json"
"encoding/json"
"os"
"os"
"path/filepath"
"path/filepath"
+ "strconv"
"strings"
"strings"
"testing"
"testing"
"time"
"time"
@@ -181,7 +182,8 @@ func TestDedup(t *testing.T) {
@@ -181,7 +182,8 @@ func TestDedup(t *testing.T) {
}
}
d.Begin("b")
d.Begin("b")
d.Begin("c")
d.Begin("c")
- d.Begin("d")
+ last, _ := d.Begin("d")
+ d.Complete(last, protocol.Ack{ID: "d"})
if _, first := d.Begin("a"); !first {
if _, first := d.Begin("a"); !first {
t.Fatal("capacity not enforced")
t.Fatal("capacity not enforced")
}
}
@@ -213,3 +215,17 @@ func TestReloadNeverUndoesConcurrentStop(t *testing.T) {
@@ -213,3 +215,17 @@ func TestReloadNeverUndoesConcurrentStop(t *testing.T) {
}
}
}
}
}
}
+
+func TestDedupPendingSurvivesFloodAndTTL(t *testing.T) {
+ d := NewDedup(4096, time.Millisecond)
+ original, _ := d.Begin("waiting")
+ for i := 0; i < 4200; i++ {
+ id := strconv.Itoa(i)
+ e, _ := d.Begin(id)
+ d.Complete(e, protocol.Ack{ID: id})
+ }
+ d.now = func() time.Time { return time.Now().Add(time.Hour) }
+ if got, first := d.Begin("waiting"); first || got != original {
+ t.Fatal("unfinished entry was evicted")
+ }
+}
@@ -127,6 +127,7 @@ ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
@@ -127,6 +127,7 @@ ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
{{if ChRoot}}RootDirectory={{ChRoot | cmd}}
{{if ChRoot}}RootDirectory={{ChRoot | cmd}}
{{end}}{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}
{{end}}{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}
{{end}}{{if UserName}}User={{UserName}}
{{end}}{{if UserName}}User={{UserName}}
+NoNewPrivileges=yes
{{end}}{{if ReloadSignal}}ExecReload=/bin/kill -{{ReloadSignal}} "$MAINPID"
{{end}}{{if ReloadSignal}}ExecReload=/bin/kill -{{ReloadSignal}} "$MAINPID"
{{end}}{{if PIDFile}}PIDFile={{PIDFile | cmd}}
{{end}}{{if PIDFile}}PIDFile={{PIDFile | cmd}}
{{end}}{{if OutputFileSupport}}StandardOutput=file:{{LogDirectory}}/{{Name}}.out
{{end}}{{if OutputFileSupport}}StandardOutput=file:{{LogDirectory}}/{{Name}}.out
@@ -42,3 +42,13 @@ func TestConfig(t *testing.T) {
@@ -42,3 +42,13 @@ func TestConfig(t *testing.T) {
t.Fatalf("the systemd unit must not restart a Node that has to pair again: %q", u)
t.Fatalf("the systemd unit must not restart a Node that has to pair again: %q", u)
}
}
}
}
+
+// A Node that runs as a person must not gain privileges through a setuid child, so its unit refuses
+// new privileges. The privileged system unit does not: the shared desk host runs as root on purpose.
+func TestUserServicePreventsPrivilegeGain(t *testing.T) {
+ unit := config(Options{UserName: "person"}).Option["SystemdScript"].(string)
+ // The directive sits inside the {{if UserName}} guard: only the per-user unit gets it.
+ if !strings.Contains(unit, "{{if UserName}}User={{UserName}}\nNoNewPrivileges=yes\n{{end}}") {
+ t.Fatalf("the per-user unit must set NoNewPrivileges=yes: %q", unit)
+ }
+}