openvibers/openvibe.link Commits

the computer-control review, core side: a local resume, a local approval id, an honest stop (#102)

OP OpenVibers committed 7 days ago · d29211a78d · 24 files, +881 −147

* the computer-control review, core side: a local resume, a local approval id, an honest stop

The review of the computer boundary (plan-state-2026-10-22/NODE-COMPUTER-REVIEW-2026-11-05.md)
found five things in this core. Each is fixed here with the test the review names.

F1: an e-stop on a computer waits for a local resume. The latch keeps a third bit, Computer:
a remote e-stop sets it with the remote latch, and the owner's remote clear (estop
latched:false, or a config without estop_latched) releases the robot's remote latch and
nothing else. The computer plugin stays in observe and the core refuses its acting kinds
until openvibe-node resume on the machine. A robot's remote clearing is unchanged.

F6: an answer cannot be attached to another request. The dedup cache retires only
completed entries, so a flood cannot evict a request that still waits. A second command
with an id that is already pending is refused instead of replacing the first. Every
approval carries a nonce the core mints (32 random bytes, bound to the plugin process,
the payload digest and the expiry); the local socket's approve and deny name that nonce
and never the cloud command id, and the tray binds every rendered Approve and Deny to
the nonce it showed instead of a slot index.

F8: a stop whose latch could not be saved says so. The control socket answers OK:false
with the error, localctl.Call treats any error as a failure, and openvibe-node stop
prints it instead of claiming the stop is durable.

F9: a computer Node's service does not run privileged. The per-user unit sets
NoNewPrivileges=yes, and openvibe-node install refuses the privileged system service
when the config enables the computer plugin outside the documented shared desk host
(Linux, desks in the worker's sandbox, the worker on, level trusted or observe).

F5: the approvals list and the tray show text, not control characters. Control, ANSI
and bidi characters are escaped, and the ids in hints are quoted as one shell argument.

Docs: docs/computer.md and docs/protocol.md say that a computer's e-stop waits for the
local resume.

* plugins: say it plainly when the plugin's input queue is full

The queue push in begin reused the error variable from the marshal above it, which read as if the
marshal had failed. A named flag says what happened: the plugin is not reading its input, so it is
ended, and the command is refused.

* tray: stop at the first waiting request with that id

Changes

ddiff --git a/cmd/openvibe-node/core_review_test.go b/cmd/openvibe-node/core_review_test.go
nnew file mode 100644
iindex 0000000..58e930e
---- /dev/null
++++ b/cmd/openvibe-node/core_review_test.go
@@@ -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)
++ }
++}
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex ed46bd5..b3a7183 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -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
++}
ddiff --git a/cmd/openvibe-node/tray.go b/cmd/openvibe-node/tray.go
iindex 668cc27..71f1a22 100644
---- a/cmd/openvibe-node/tray.go
++++ b/cmd/openvibe-node/tray.go
@@@ -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.Computer
link := "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 hidden
SetPanel(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.Status
var list []node.Approval
resp, 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:
++ }
++ }
++ }()
++}
ddiff --git a/cmd/openvibe-node/tray_notify_linux.go b/cmd/openvibe-node/tray_notify_linux.go
iindex dd1884f..ce631fd 100644
---- a/cmd/openvibe-node/tray_notify_linux.go
++++ b/cmd/openvibe-node/tray_notify_linux.go
@@@ -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]
ddiff --git a/cmd/openvibe-node/tray_notify_linux_test.go b/cmd/openvibe-node/tray_notify_linux_test.go
iindex 2a9576c..d8886d9 100644
---- a/cmd/openvibe-node/tray_notify_linux_test.go
++++ b/cmd/openvibe-node/tray_notify_linux_test.go
@@@ -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:
ddiff --git a/cmd/openvibe-node/tray_systray.go b/cmd/openvibe-node/tray_systray.go
iindex e5302e4..09fc5d6 100644
---- a/cmd/openvibe-node/tray_systray.go
++++ b/cmd/openvibe-node/tray_systray.go
@@@ -26,11 +26,12 @@ type systrayUI struct {
header, level *systray.MenuItem
stop, resume, panel *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
levelTx string
-- labels []string
++ bindings []traySlot
stopped, 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 *global
select {
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)
ddiff --git a/cmd/openvibe-node/tray_test.go b/cmd/openvibe-node/tray_test.go
iindex fc0b48e..6eaebca 100644
---- a/cmd/openvibe-node/tray_test.go
++++ b/cmd/openvibe-node/tray_test.go
@@@ -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 string
stopped, panel bool
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 (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")
++ }
++}
ddiff --git a/docs/computer.md b/docs/computer.md
iindex d7eab84..353af5c 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -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
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
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex d7b8547..3f8e7ee 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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
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.
--`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
ddiff --git a/internal/localctl/localctl.go b/internal/localctl/localctl.go
iindex be2f072..78081f6 100644
---- a/internal/localctl/localctl.go
++++ b/internal/localctl/localctl.go
@@@ -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
}
ddiff --git a/internal/localctl/localctl_test.go b/internal/localctl/localctl_test.go
iindex 07c61d6..550b9fe 100644
---- a/internal/localctl/localctl_test.go
++++ b/internal/localctl/localctl_test.go
@@@ -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")
ddiff --git a/internal/node/computer_test.go b/internal/node/computer_test.go
iindex dd0076c..610bea6 100644
---- a/internal/node/computer_test.go
++++ b/internal/node/computer_test.go
@@@ -5,6 +5,7 @@ package node
import (
"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 ran
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") {
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 running
e.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")
++ }
++}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex e3e225d..08d8b53 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -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, r
nack(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 persist
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}
case localctl.CmdQuit:
ddiff --git a/internal/node/node_test.go b/internal/node/node_test.go
iindex 4a5e4b3..9dc4e89 100644
---- a/internal/node/node_test.go
++++ b/internal/node/node_test.go
@@@ -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)
ddiff --git a/internal/node/screen.go b/internal/node/screen.go
iindex 334ae08..3a0a2f7 100644
---- a/internal/node/screen.go
++++ b/internal/node/screen.go
@@@ -93,8 +93,8 @@ func (n *Node) screenCommand(c protocol.Command, entry *safety.Entry, received m
return
}
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 int
case 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")
return
case now.After(until.Add(3 * time.Second)):
ddiff --git a/internal/plugins/approval_test.go b/internal/plugins/approval_test.go
nnew file mode 100644
iindex 0000000..853ded0
---- /dev/null
++++ b/internal/plugins/approval_test.go
@@@ -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))
++ }
++}
ddiff --git a/internal/plugins/plugin.go b/internal/plugins/plugin.go
iindex 7f4b288..25b4a8f 100644
---- a/internal/plugins/plugin.go
++++ b/internal/plugins/plugin.go
@@@ -8,6 +8,9 @@ package plugins
import (
"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 int64
pending map[string]chan Reply
++ requests map[string]*ApprovalRequest
queue chan line // to the current process's stdin; nil while down
kill 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] = ch
p.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.RawMessa
if 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 event
p.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))
ddiff --git a/internal/plugins/plugins_test.go b/internal/plugins/plugins_test.go
iindex a686e88..1af2f7f 100644
---- a/internal/plugins/plugins_test.go
++++ b/internal/plugins/plugins_test.go
@@@ -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)
++ }
++}
ddiff --git a/internal/safety/dedup.go b/internal/safety/dedup.go
iindex a0b0585..d460adb 100644
---- a/internal/safety/dedup.go
++++ b/internal/safety/dedup.go
@@@ -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, false
ddiff --git a/internal/safety/display.go b/internal/safety/display.go
nnew file mode 100644
iindex 0000000..cfcc762
---- /dev/null
++++ b/internal/safety/display.go
@@@ -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), "'", "'\"'\"'") + "'"
++}
ddiff --git a/internal/safety/latch.go b/internal/safety/latch.go
iindex 3e78087..163cf05 100644
---- a/internal/safety/latch.go
++++ b/internal/safety/latch.go
@@@ -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 || computer
if !s.Remote {
s.RemoteAt = time.Now().UTC()
}
ddiff --git a/internal/safety/safety_test.go b/internal/safety/safety_test.go
iindex 93d359f..1af6536 100644
---- a/internal/safety/safety_test.go
++++ b/internal/safety/safety_test.go
@@@ -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")
++ }
++}
ddiff --git a/internal/service/service.go b/internal/service/service.go
iindex 0c371ee..c2c724f 100644
---- a/internal/service/service.go
++++ b/internal/service/service.go
@@@ -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
ddiff --git a/internal/service/service_test.go b/internal/service/service_test.go
iindex 1de312e..727dd5c 100644
---- a/internal/service/service_test.go
++++ b/internal/service/service_test.go
@@@ -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)
++ }
++}