openvibers/openvibe.link Commits
Node: approvals from the desktop — openvibe-node tray (Linux, Windows), Approve/Deny notifications and a macOS dialog; approvals wait out the agent's own clicks (#47)
Changes
@@@ -28,6 +28,12 @@ jobs:# (and Ubuntu's AppArmor restricts unprivileged user namespaces).- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true- run: go test -race -count=1 ./...++ # The tray's desktop notifications, against a fake notification server on a private session bus.++ - name: tray notifications (private D-Bus)++ run: |++ command -v dbus-run-session >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq dbus; }++ dbus-run-session -- go test -race -count=1 -v -run DesktopNotifier ./cmd/openvibe-node | tee /tmp/notify.log++ grep -q -- '--- PASS: TestDesktopNotifierAsksAndHearsTheButtons' /tmp/notify.log# So they run again as root (jobs as nobody), in a systemd scope with its cgroup delegated, where# OPENVIBE_WORKER_TESTS=require makes them fail instead of skipping. Each test binary lies alone in a directory# nobody can read, as a function's executable must (a job gets a copy of that directory).@@@ -18,6 +18,7 @@ openvibe-node status # link, plugins, video, battery, stop latchopenvibe-node stop # kill switch: stop every motor now and keep it stoppedopenvibe-node resumeopenvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>++openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)```## What is in here@@@ -43,7 +44,7 @@ on a community profile runs the same check and warns when it fails. Install a co**Your computer for your agents:** `--computer observe|ask|trusted|full` adds the `computer` plugin (terminal, files,screen, mouse and keyboard, and a browser of its own for OpenVibe.Actor), running as your own account, at the level you set on the machine; `ask` waits for--`openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).++your answer in the tray, a notification's Approve button or `openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: thejob worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding@@@ -60,6 +60,9 @@ Usage:openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")openvibe-node approve <ID> let it runopenvibe-node deny <ID> refuse it++ openvibe-node tray [--autostart on|off]++ the Node in your notification area (Linux, Windows): what waits with Approve and Deny, stop,++ resume; --autostart starts it when you sign inopenvibe-node profile verify community.<name> [--server URL] [--key-id ID]check a community robot profile against the signature Bot made when it was reviewedopenvibe-node version@@@ -110,6 +113,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intsvcUser = fs.String("user", "", "run the service as this account (install)")session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")++ autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)"))// Accept flags anywhere: split positionals out.var positional []string@@@ -189,6 +193,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) intreturn 2}err = cmdAnswer(g, cmd, positional[0], stdout)++ case "tray":++ err = cmdTray(g, *autoArg, stdout)case "profile":if len(positional) != 2 || positional[0] != "verify" {fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")nnew file mode 100644@@@ -0,0 +1,460 @@++package main++++// `openvibe-node tray`: the Node in its owner's notification area. It runs in the graphical session (not in the++// service), talks to the running Node over the local control socket like the other commands, and shows:++//++// - the state in the icon: blue when all is well, amber while a computer request waits for an answer, red while a++// stop is latched, grey when the Node does not answer;++// - what waits for approval (the computer plugin at level "ask"), each with Approve and Deny;++// - Stop everything (the local kill switch), Resume, and the machines page on openvibe.bot.++//++// A new request also raises a desktop notification with Approve and Deny buttons where the desktop offers them++// (Linux, org.freedesktop.Notifications). While the tray runs, the computer plugin's own prompts stay quiet. The core++// refuses an Approve while the agent drives the mouse or keyboard and just after (node.InputGuard), so an approved++// click cannot press Approve here; the tray then asks again.++++import (++ "bytes"++ "encoding/binary"++ "encoding/json"++ "errors"++ "fmt"++ "image"++ "image/color"++ "image/png"++ "io"++ "math"++ "net/url"++ "os"++ "path/filepath"++ "strings"++ "sync"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/node"++)++++// trayPoll is how often the tray asks the Node what changed.++const trayPoll = 1500 * time.Millisecond++++// traySlots is how many waiting requests the menu lists (the plugin lets 8 wait at once).++const traySlots = 8++++// Tray states, which pick the icon.++const (++ trayOK = "ok"++ trayWaiting = "waiting"++ trayStopped = "stopped"++ trayDown = "down"++)++++// trayView is what the tray shows for one poll.++type trayView struct {++ State string++ Tooltip string++ Header string // the first, disabled menu line++ Level string // the computer plugin's level, "" without it++ Stopped bool++ Waiting []node.Approval++ Panel string // the machines page on the Bot this Node is paired with, "" if unknown++}++++// viewOf builds the view from the Node's status and approvals; err is the socket error (the Node is not running).++func viewOf(st *node.Status, list []node.Approval, err error) trayView {++ if err != nil || st == nil {++ return trayView{State: trayDown, Tooltip: "OpenVibe Node: not running", Header: "The Node is not running"}++ }++ v := trayView{Waiting: list, Panel: panelURL(st.Server)}++ if lv, ok := st.Telemetry.Sensors["computer_level"].(string); ok {++ v.Level = lv++ }++ v.Stopped = st.Latch.Local || st.Latch.Remote++ link := "offline"++ if st.Link.Connected {++ link = "online"++ }++ switch {++ case v.Stopped:++ v.State = trayStopped++ v.Header = "Stopped: observe only"++ case len(list) > 0:++ v.State = trayWaiting++ v.Header = fmt.Sprintf("%d %s your answer", len(list), plural(len(list), "request waits for", "requests wait for"))++ default:++ v.State = trayOK++ v.Header = "OpenVibe Node: " + link++ }++ tip := []string{"OpenVibe Node: " + link}++ if v.Level != "" {++ tip = append(tip, "computer level "+v.Level)++ }++ if v.Stopped {++ tip = append(tip, "stopped")++ }++ if len(list) > 0 {++ tip = append(tip, fmt.Sprintf("%d waiting", len(list)))++ }++ v.Tooltip = strings.Join(tip, ", ")++ return v++}++++func plural(n int, one, many string) string {++ if n == 1 {++ return one++ }++ return many++}++++// panelURL is the machines page of the Bot the Node is paired with: an https origin, or a loopback http one.++func panelURL(server string) string {++ u, err := url.Parse(strings.TrimSpace(server))++ if err != nil || u.Host == "" {++ return ""++ }++ host := u.Hostname()++ if u.Scheme != "https" && !(u.Scheme == "http" && (host == "127.0.0.1" || host == "localhost" || host == "::1")) {++ return ""++ }++ return u.Scheme + "://" + u.Host + "/machines"++}++++// 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), " ")++ if len([]rune(s)) > 70 {++ s = string([]rune(s)[:69]) + "…"++ }++ if a.Kind != "" {++ return a.Kind + ": " + s++ }++ return s++}++++// trayUI is the notification-area side (systray on Linux and Windows; a fake in tests).++type trayUI interface {++ SetIcon(state string)++ SetTooltip(string)++ SetHeader(string)++ SetLevel(string)++ SetStopped(bool)++ SetSlots([]string) // one label per waiting request, at most traySlots; the rest hidden++ SetPanel(bool)++}++++// notifier raises and takes down desktop notifications (D-Bus on Linux; nothing elsewhere).++type notifier interface {++ // Ask shows a request with Approve and Deny; again marks a second asking after a refused answer.++ Ask(a node.Approval, again string)++ Close(id string)++ Info(title, body string)++ // Answers carries {id, approved} when a notification's button is pressed; nil if there are none.++ Answers() <-chan trayAnswer++ Shut()++}++++type trayAnswer struct {++ ID string++ Approved bool++}++++type nopNotifier struct{}++++func (nopNotifier) Ask(node.Approval, string) {}++func (nopNotifier) Close(string) {}++func (nopNotifier) Info(string, string) {}++func (nopNotifier) Answers() <-chan trayAnswer { return nil }++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++}++++func newTrayCore(sock string, ui trayUI, note notifier) *trayCore {++ if note == nil {++ note = nopNotifier{}++ }++ return &trayCore{sock: sock, ui: ui, note: note, shown: map[string]bool{}}++}++++func (t *trayCore) call(req localctl.Request) (localctl.Response, error) {++ return localctl.Call(t.sock, req, 2*time.Second)++}++++// tick asks the Node for its status and approvals, says the tray is there, and updates everything.++func (t *trayCore) tick() trayView {++ var st *node.Status++ var list []node.Approval++ resp, err := t.call(localctl.Request{Cmd: localctl.CmdStatus})++ if err == nil {++ var s node.Status++ if err = json.Unmarshal(resp.Data, &s); err == nil {++ st = &s++ }++ }++ if err == nil {++ if resp, aerr := t.call(localctl.Request{Cmd: localctl.CmdApprovals}); aerr == nil {++ _ = json.Unmarshal(resp.Data, &list)++ }++ _, _ = t.call(localctl.Request{Cmd: localctl.CmdTray, On: true})++ }++ v := viewOf(st, list, err)++ t.mu.Lock()++ t.view = v++ fresh, gone := []node.Approval{}, []string{}++ now := map[string]bool{}++ for _, a := range list {++ now[a.ID] = true++ if !t.shown[a.ID] {++ fresh = append(fresh, a)++ t.shown[a.ID] = true++ }++ }++ for id := range t.shown {++ if !now[id] {++ gone = append(gone, id)++ delete(t.shown, id)++ }++ }++ t.mu.Unlock()++ for _, id := range gone {++ t.note.Close(id)++ }++ for _, a := range fresh {++ t.note.Ask(a, "")++ }++ labels := make([]string, 0, traySlots)++ for i, a := range v.Waiting {++ if i == traySlots {++ break++ }++ labels = append(labels, slotLabel(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.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++ if approved {++ cmd = localctl.CmdApprove++ }++ _, err := t.call(localctl.Request{Cmd: cmd, ID: a.ID})++ if err != nil {++ if approved && strings.Contains(err.Error(), "mouse or keyboard") {++ t.note.Ask(a, "Not taken: the agent was using the mouse or keyboard. Answer again if it was you.")++ } else {++ t.note.Info("OpenVibe Node", err.Error())++ }++ return err++ }++ t.mu.Lock()++ delete(t.shown, a.ID)++ t.mu.Unlock()++ t.note.Close(a.ID)++ return nil++}++++// answerByID is a notification's button: the request must still wait.++func (t *trayCore) answerByID(id string, approved bool) error {++ t.mu.Lock()++ var found *node.Approval++ for i := range t.view.Waiting {++ if t.view.Waiting[i].ID == id {++ a := t.view.Waiting[i]++ found = &a++ }++ }++ t.mu.Unlock()++ if found == nil {++ return errors.New("it no longer waits")++ }++ return t.answer(*found, approved)++}++++func (t *trayCore) stop() error {++ _, err := t.call(localctl.Request{Cmd: localctl.CmdStop})++ return err++}++++func (t *trayCore) resume() error {++ _, err := t.call(localctl.Request{Cmd: localctl.CmdResume})++ return err++}++++// quit tells the Node the tray is gone (the plugin prompts again) and takes the notifications down.++func (t *trayCore) quit() {++ _, _ = t.call(localctl.Request{Cmd: localctl.CmdTray, On: false})++ t.mu.Lock()++ ids := make([]string, 0, len(t.shown))++ for id := range t.shown {++ ids = append(ids, id)++ }++ t.shown = map[string]bool{}++ t.mu.Unlock()++ for _, id := range ids {++ t.note.Close(id)++ }++ t.note.Shut()++}++++func (t *trayCore) panel() string {++ t.mu.Lock()++ defer t.mu.Unlock()++ return t.view.Panel++}++++// cmdTray runs the tray, or with --autostart on|off adds it to (or removes it from) the session's start.++func cmdTray(g *globals, autostart string, stdout io.Writer) error {++ switch autostart {++ case "":++ return runTray(g)++ case "on", "off":++ exe, err := os.Executable()++ if err != nil {++ return err++ }++ return trayAutostart(autostart == "on", exe, g.home, stdout)++ }++ return fmt.Errorf("--autostart is on or off")++}++++// trayDesktopEntry is the XDG autostart entry that starts the tray when its owner signs in (Linux).++func trayDesktopEntry(exe, home string) string {++ line := shellQuote(exe) + " tray"++ if home != "" {++ line += " --home " + shellQuote(home)++ }++ return "[Desktop Entry]\nType=Application\nName=OpenVibe Node\nComment=Approve what your agents ask to do on this computer\n" +++ "Exec=" + line + "\nIcon=dialog-question\nTerminal=false\nX-GNOME-Autostart-enabled=true\nNoDisplay=false\n"++}++++// shellQuote quotes one Exec argument the way the desktop entry spec reads them.++func shellQuote(s string) string {++ if s != "" && !strings.ContainsAny(s, " \t\n\"'\\$`") {++ return s++ }++ r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)++ return `"` + r.Replace(s) + `"`++}++++// trayAutostartPath is where the Linux autostart entry goes: $XDG_CONFIG_HOME/autostart, else ~/.config/autostart.++func trayAutostartPath() (string, error) {++ dir := os.Getenv("XDG_CONFIG_HOME")++ if dir == "" {++ home, err := os.UserHomeDir()++ if err != nil {++ return "", err++ }++ dir = filepath.Join(home, ".config")++ }++ return filepath.Join(dir, "autostart", "openvibe-node-tray.desktop"), nil++}++++// ---- icons ---------------------------------------------------------------------------------------------------------++++var trayColors = map[string]color.NRGBA{++ trayOK: {0x3b, 0x82, 0xf6, 0xff}, // the OpenVibe blue++ trayWaiting: {0xf5, 0x9e, 0x0b, 0xff},++ trayStopped: {0xdc, 0x26, 0x26, 0xff},++ trayDown: {0x9c, 0xa3, 0xaf, 0xff},++}++++// trayImage draws the tray icon: a filled disc in the state's colour with a white ring-and-dot (the Node's mark);++// while something waits the dot becomes a bar, an exclamation.++func trayImage(state string, size int) *image.NRGBA {++ img := image.NewNRGBA(image.Rect(0, 0, size, size))++ c, ok := trayColors[state]++ if !ok {++ c = trayColors[trayDown]++ }++ white := color.NRGBA{0xff, 0xff, 0xff, 0xff}++ r := float64(size) / 2++ for y := 0; y < size; y++ {++ for x := 0; x < size; x++ {++ dx, dy := float64(x)+0.5-r, float64(y)+0.5-r++ d := math.Hypot(dx, dy)++ a := clamp01(r - d) // anti-aliased edge of the disc++ if a <= 0 {++ continue++ }++ px := c++ ring := math.Abs(d - r*0.55)++ if state == trayWaiting {++ // an exclamation mark: a bar and a dot++ if (math.Abs(dx) < r*0.13 && dy > -r*0.55 && dy < r*0.15) || math.Hypot(dx, dy-r*0.42) < r*0.14 {++ px = white++ }++ } else if ring < r*0.1 || d < r*0.18 {++ px = white++ }++ px.A = uint8(float64(px.A) * a)++ img.SetNRGBA(x, y, px)++ }++ }++ return img++}++++func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }++++// trayPNG is the icon for the StatusNotifierItem (Linux), which takes PNG bytes.++func trayPNG(state string) []byte {++ var b bytes.Buffer++ _ = png.Encode(&b, trayImage(state, 64))++ return b.Bytes()++}++++// trayICO is the icon for Windows: a 32×32 32-bit ICO (BMP image with its AND mask), which LoadImage reads.++func trayICO(state string) []byte {++ const size = 32++ img := trayImage(state, size)++ var dib bytes.Buffer++ hdr := struct {++ Size uint32++ Width, Height int32++ Planes, BitCount uint16++ Compression, SizeImage uint32++ XPels, YPels, Used, Imported int32++ }{40, size, size * 2, 1, 32, 0, size * size * 4, 0, 0, 0, 0}++ _ = binary.Write(&dib, binary.LittleEndian, hdr)++ for y := size - 1; y >= 0; y-- { // bottom-up rows, BGRA++ for x := 0; x < size; x++ {++ p := img.NRGBAAt(x, y)++ dib.Write([]byte{p.B, p.G, p.R, p.A})++ }++ }++ dib.Write(make([]byte, size*4)) // the AND mask: 32 bits a row, all zero (alpha decides)++ var out bytes.Buffer++ _ = binary.Write(&out, binary.LittleEndian, [3]uint16{0, 1, 1}) // ICONDIR: reserved, type icon, one image++ entry := struct {++ W, H, Colors, Reserved uint8++ Planes, BitCount uint16++ Bytes, Offset uint32++ }{size, size, 0, 0, 1, 32, uint32(dib.Len()), 6 + 16}++ _ = binary.Write(&out, binary.LittleEndian, entry)++ out.Write(dib.Bytes())++ return out.Bytes()++}nnew file mode 100644@@@ -0,0 +1,172 @@++//go:build linux++++package main++++import (++ "log/slog"++ "strings"++ "sync"++++ "github.com/godbus/dbus/v5"++++ "github.com/OpenVibers/OpenVibe.Node/internal/node"++)++++// Desktop notifications over org.freedesktop.Notifications (every Linux desktop): a waiting request with Approve and++// Deny buttons when the server has "actions", else the command to answer with.++++const (++ notifyName = "org.freedesktop.Notifications"++ notifyPath = dbus.ObjectPath("/org/freedesktop/Notifications")++ notifyIface = "org.freedesktop.Notifications"++)++++type dbusNotifier struct {++ conn *dbus.Conn++ obj dbus.BusObject++ actions bool++ markup bool++ log *slog.Logger++ mu sync.Mutex++ byID map[string]uint32 // approval id → notification id++ byNote map[uint32]string++ owner string // the notification server's unique bus name: only its signals count++ answers chan trayAnswer++}++++func newDesktopNotifier(log *slog.Logger) notifier {++ conn, err := dbus.ConnectSessionBus()++ if err != nil {++ log.Warn("tray: no desktop notifications (no session bus)", "err", err)++ return nopNotifier{}++ }++ n := &dbusNotifier{conn: conn, obj: conn.Object(notifyName, notifyPath), log: log,++ byID: map[string]uint32{}, byNote: map[uint32]string{}, answers: make(chan trayAnswer, 8)}++ var caps []string++ if err := n.obj.Call(notifyIface+".GetCapabilities", 0).Store(&caps); err == nil {++ for _, c := range caps {++ n.actions = n.actions || c == "actions"++ n.markup = n.markup || c == "body-markup"++ }++ }++ _ = conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, notifyName).Store(&n.owner)++ var host bool++ if conn.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, "org.kde.StatusNotifierWatcher").Store(&host) == nil && !host {++ log.Warn("tray: this desktop shows no tray icons (on GNOME, the AppIndicator extension adds them); the notifications still ask")++ }++ if err := conn.AddMatchSignal(dbus.WithMatchInterface(notifyIface), dbus.WithMatchObjectPath(notifyPath)); err != nil {++ log.Warn("tray: notification buttons will not answer", "err", err)++ }++ ch := make(chan *dbus.Signal, 16)++ conn.Signal(ch)++ go n.listen(ch)++ return n++}++++func (n *dbusNotifier) listen(ch <-chan *dbus.Signal) {++ for sig := range ch {++ if sig == nil || sig.Path != notifyPath {++ continue++ }++ if n.owner != "" && sig.Sender != n.owner {++ // The server may have restarted: ask who owns the name now before trusting it.++ var owner string++ if err := n.conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, notifyName).Store(&owner); err != nil || owner != sig.Sender {++ continue++ }++ n.owner = owner++ }++ switch sig.Name {++ case notifyIface + ".ActionInvoked":++ if len(sig.Body) != 2 {++ continue++ }++ nid, _ := sig.Body[0].(uint32)++ key, _ := sig.Body[1].(string)++ n.mu.Lock()++ id := n.byNote[nid]++ n.mu.Unlock()++ if id == "" || (key != "approve" && key != "deny") {++ continue++ }++ select {++ case n.answers <- trayAnswer{ID: id, Approved: key == "approve"}:++ default:++ }++ case notifyIface + ".NotificationClosed":++ if len(sig.Body) < 1 {++ continue++ }++ nid, _ := sig.Body[0].(uint32)++ n.mu.Lock()++ if id, ok := n.byNote[nid]; ok {++ delete(n.byNote, nid)++ if n.byID[id] == nid {++ delete(n.byID, id)++ }++ }++ n.mu.Unlock()++ }++ }++}++++func (n *dbusNotifier) text(s string) string {++ if !n.markup {++ return s++ }++ return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s)++}++++func (n *dbusNotifier) Ask(a node.Approval, again string) {++ body := a.Summary++ if again != "" {++ body = again + "\n" + body++ }++ var actions []string++ if n.actions {++ actions = []string{"approve", "Approve", "deny", "Deny"}++ } else {++ body += "\nopenvibe-node approve " + a.ID + " (or deny " + a.ID + ")"++ }++ n.mu.Lock()++ replaces := n.byID[a.ID]++ n.mu.Unlock()++ expire := int32(-1)++ if a.ExpiresS > 0 {++ expire = int32(a.ExpiresS * 1000)++ }++ hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2)), "desktop-entry": dbus.MakeVariant("openvibe-node-tray")}++ var nid uint32++ err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Node", replaces, "dialog-question", "OpenVibe: your agent asks",++ n.text(body), actions, hints, expire).Store(&nid)++ if err != nil {++ n.log.Warn("tray: notification failed", "err", err)++ return++ }++ n.mu.Lock()++ n.byID[a.ID] = nid++ n.byNote[nid] = a.ID++ n.mu.Unlock()++}++++func (n *dbusNotifier) Close(id string) {++ n.mu.Lock()++ nid, ok := n.byID[id]++ delete(n.byID, id)++ if ok {++ delete(n.byNote, nid)++ }++ n.mu.Unlock()++ if ok {++ n.obj.Call(notifyIface+".CloseNotification", 0, nid)++ }++}++++func (n *dbusNotifier) Info(title, body string) {++ hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}++ n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Node", uint32(0), "dialog-information", title, n.text(body), []string{}, hints, int32(6000))++}++++func (n *dbusNotifier) Answers() <-chan trayAnswer { return n.answers }++++func (n *dbusNotifier) Shut() { _ = n.conn.Close() }nnew file mode 100644@@@ -0,0 +1,112 @@++//go:build linux++++package main++++import (++ "io"++ "log/slog"++ "os"++ "testing"++ "time"++++ "github.com/godbus/dbus/v5"++++ "github.com/OpenVibers/OpenVibe.Node/internal/node"++)++++// fakeNotifications is an org.freedesktop.Notifications server on a private session bus.++type fakeNotifications struct {++ conn *dbus.Conn++ notify chan []any++ closed chan uint32++ next uint32++}++++func (f *fakeNotifications) GetCapabilities() ([]string, *dbus.Error) {++ return []string{"actions", "body", "body-markup"}, nil++}++++func (f *fakeNotifications) Notify(app string, replaces uint32, icon, summary, body string, actions []string, hints map[string]dbus.Variant, expire int32) (uint32, *dbus.Error) {++ f.next++++ id := f.next++ if replaces != 0 {++ id = replaces++ }++ f.notify <- []any{id, summary, body, actions, expire}++ return id, nil++}++++func (f *fakeNotifications) CloseNotification(id uint32) *dbus.Error {++ f.closed <- id++ return nil++}++++// Run under a private bus: dbus-run-session -- go test ./cmd/openvibe-node -run DesktopNotifier++func TestDesktopNotifierAsksAndHearsTheButtons(t *testing.T) {++ if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {++ t.Skip("no session bus (run it under dbus-run-session)")++ }++ conn, err := dbus.ConnectSessionBus()++ if err != nil {++ t.Skip("session bus:", err)++ }++ defer conn.Close()++ reply, err := conn.RequestName(notifyName, dbus.NameFlagDoNotQueue)++ if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {++ t.Skip("a real notification server owns the name: run it under dbus-run-session")++ }++ f := &fakeNotifications{conn: conn, notify: make(chan []any, 4), closed: make(chan uint32, 4)}++ if err := conn.Export(f, notifyPath, notifyIface); err != nil {++ t.Fatal(err)++ }++ n := newDesktopNotifier(slog.New(slog.NewTextHandler(io.Discard, nil)))++ defer n.Shut()++ n.Ask(node.Approval{ID: "c1", Kind: "exec", Summary: "run <b>rm</b> & co", ExpiresS: 120}, "")++ var got []any++ select {++ case got = <-f.notify:++ case <-time.After(5 * time.Second):++ t.Fatal("no Notify call")++ }++ if got[1] != "OpenVibe: your agent asks" || got[2] != "run <b>rm</b> & co" || got[4] != int32(120000) {++ t.Fatalf("notify: %v", got)++ }++ if a := got[3].([]string); len(a) != 4 || a[0] != "approve" || a[2] != "deny" {++ t.Fatalf("actions: %v", a)++ }++ nid := got[0].(uint32)++ // Somebody else on the bus faking the button: ignored. The server's own signal: an answer.++ other, err := dbus.ConnectSessionBus(dbus.WithSignalHandler(dbus.NewSequentialSignalHandler()))++ if err == nil {++ _ = other.Emit(notifyPath, notifyIface+".ActionInvoked", nid, "approve")++ }++ select {++ case a := <-n.Answers():++ t.Fatalf("an impostor's signal answered: %+v", a)++ case <-time.After(300 * time.Millisecond):++ }++ if err := conn.Emit(notifyPath, notifyIface+".ActionInvoked", nid, "approve"); err != nil {++ t.Fatal(err)++ }++ select {++ case a := <-n.Answers():++ if a.ID != "c1" || !a.Approved {++ t.Fatalf("answer: %+v", a)++ }++ case <-time.After(5 * time.Second):++ t.Fatal("the Approve button was not heard")++ }++ n.Ask(node.Approval{ID: "c1", Summary: "again"}, "Not taken")++ if again := <-f.notify; again[0] != nid {++ t.Fatalf("asking again replaces the same notification: %v", again)++ }++ n.Close("c1")++ select {++ case id := <-f.closed:++ if id != nid {++ t.Fatalf("closed %d, want %d", id, nid)++ }++ case <-time.After(5 * time.Second):++ t.Fatal("not closed")++ }++}nnew file mode 100644@@@ -0,0 +1,9 @@++//go:build !linux++++package main++++import "log/slog"++++// Desktop notifications with buttons are Linux's (org.freedesktop.Notifications); on Windows the icon turns amber and++// the menu lists what waits.++func newDesktopNotifier(*slog.Logger) notifier { return nopNotifier{} }nnew file mode 100644@@@ -0,0 +1,15 @@++//go:build !linux && !windows++++package main++++import (++ "errors"++ "io"++)++++// The tray library needs cgo on macOS and the release builds without it: there the computer plugin asks with a++// dialog for each request instead (docs/computer.md).++var errTrayPlatform = errors.New("the tray is for Linux and Windows; on macOS the computer plugin asks with a dialog for each request (Approve or Deny)")++++func runTray(*globals) error { return errTrayPlatform }++func trayAutostart(bool, string, string, io.Writer) error { return errTrayPlatform }nnew file mode 100644@@@ -0,0 +1,283 @@++//go:build linux || windows++++package main++++import (++ "fmt"++ "io"++ "os"++ "os/exec"++ "os/signal"++ "path/filepath"++ "runtime"++ "strings"++ "sync"++ "syscall"++ "time"++++ "fyne.io/systray"++)++++// The notification area: a StatusNotifierItem over D-Bus on Linux (KDE, XFCE, Cinnamon, MATE, GNOME with the++// AppIndicator extension) and the notification icon on Windows; both without cgo.++++type systrayUI struct {++ mu sync.Mutex++ header, level *systray.MenuItem++ stop, resume, panel *systray.MenuItem++ quit *systray.MenuItem++ slots [traySlots]*systray.MenuItem++ approve, deny [traySlots]*systray.MenuItem++ state, tooltip, headTx string++ levelTx string++ labels []string++ stopped, hasPanel *bool++}++++func (u *systrayUI) build() {++ u.header = systray.AddMenuItem("OpenVibe Node", "")++ u.header.Disable()++ u.level = systray.AddMenuItem("", "")++ 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()++ }++ 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")++ u.panel = systray.AddMenuItem("Open my machines page", "This machine on openvibe.bot")++ u.panel.Hide()++ systray.AddSeparator()++ u.quit = systray.AddMenuItem("Quit the tray", "The Node keeps running; requests wait for `openvibe-node approve`")++}++++func (u *systrayUI) SetIcon(state string) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if state == u.state {++ return++ }++ u.state = state++ if runtime.GOOS == "windows" {++ systray.SetIcon(trayICO(state))++ } else {++ systray.SetIcon(trayPNG(state))++ }++}++++func (u *systrayUI) SetTooltip(s string) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if s != u.tooltip {++ u.tooltip = s++ systray.SetTooltip(s)++ }++}++++func (u *systrayUI) SetHeader(s string) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if s != u.headTx {++ u.headTx = s++ u.header.SetTitle(s)++ }++}++++func (u *systrayUI) SetLevel(lv string) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if lv == u.levelTx {++ return++ }++ u.levelTx = lv++ if lv == "" {++ u.level.Hide()++ return++ }++ u.level.SetTitle("Computer level: " + lv + " (set on this machine)")++ u.level.Show()++}++++func (u *systrayUI) SetStopped(stopped bool) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if u.stopped != nil && *u.stopped == stopped {++ return++ }++ u.stopped = &stopped++ if stopped {++ u.stop.Disable()++ u.resume.Enable()++ } else {++ u.stop.Enable()++ u.resume.Disable()++ }++}++++func (u *systrayUI) SetSlots(labels []string) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if strings.Join(labels, "\x00") == strings.Join(u.labels, "\x00") && u.labels != nil {++ 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()++ }++ }++}++++func (u *systrayUI) SetPanel(has bool) {++ u.mu.Lock()++ defer u.mu.Unlock()++ if u.hasPanel != nil && *u.hasPanel == has {++ return++ }++ u.hasPanel = &has++ if has {++ u.panel.Show()++ } else {++ u.panel.Hide()++ }++}++++func runTray(g *globals) error {++ if runtime.GOOS == "linux" && os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" && os.Getenv("XDG_RUNTIME_DIR") == "" {++ return fmt.Errorf("the tray runs in your desktop session: start it there (openvibe-node tray --autostart on starts it when you sign in)")++ }++ ui := &systrayUI{}++ core := newTrayCore(g.paths.SocketPath(), ui, newDesktopNotifier(g.log))++ stopSig := make(chan os.Signal, 1)++ signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)++ systray.Run(func() {++ systray.SetTitle("OpenVibe Node")++ ui.build()++ core.tick()++ go trayLoop(core, ui, stopSig, g)++ }, core.quit)++ return nil++}++++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()++ for {++ select {++ case <-t.C:++ 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)++ }++ core.tick()++ case <-ui.stop.ClickedCh:++ if err := core.stop(); err != nil {++ core.note.Info("OpenVibe Node", "Could not stop: "+err.Error())++ }++ core.tick()++ case <-ui.resume.ClickedCh:++ if err := core.resume(); err != nil {++ core.note.Info("OpenVibe Node", "Could not resume: "+err.Error())++ }++ core.tick()++ case <-ui.panel.ClickedCh:++ if u := core.panel(); u != "" {++ openURL(u)++ }++ case <-ui.quit.ClickedCh:++ systray.Quit()++ return++ case <-stopSig:++ systray.Quit()++ return++ }++ }++}++++// openURL opens the machines page in the default browser.++func openURL(u string) {++ var c *exec.Cmd++ if runtime.GOOS == "windows" {++ c = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)++ } else {++ c = exec.Command("xdg-open", u)++ }++ _ = c.Start()++}++++// trayAutostart starts the tray when its owner signs in: an XDG autostart entry on Linux, a Run value on Windows.++func trayAutostart(on bool, exe, home string, stdout io.Writer) error {++ if runtime.GOOS == "windows" {++ line := quoteArg(exe) + " tray"++ if home != "" {++ line += " --home " + quoteArg(home)++ }++ var out []byte++ var err error++ if on {++ out, err = exec.Command("reg", "add", runKey, "/v", "OpenVibe Node tray", "/t", "REG_SZ", "/d", line, "/f").CombinedOutput()++ } else {++ out, err = exec.Command("reg", "delete", runKey, "/v", "OpenVibe Node tray", "/f").CombinedOutput()++ if err != nil && strings.Contains(strings.ToLower(string(out)), "unable to find") {++ err = nil++ }++ }++ if err != nil {++ return fmt.Errorf("reg: %v: %s", err, strings.TrimSpace(string(out)))++ }++ if on {++ fmt.Fprintln(stdout, "The tray now starts when you sign in:", line)++ } else {++ fmt.Fprintln(stdout, "The tray no longer starts when you sign in.")++ }++ return nil++ }++ path, err := trayAutostartPath()++ if err != nil {++ return err++ }++ if !on {++ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {++ return err++ }++ fmt.Fprintln(stdout, "The tray no longer starts when you sign in (removed", path+")")++ return nil++ }++ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {++ return err++ }++ if err := os.WriteFile(path, []byte(trayDesktopEntry(exe, home)), 0o644); err != nil {++ return err++ }++ fmt.Fprintln(stdout, "The tray now starts when you sign in:", path)++ return nil++}nnew file mode 100644@@@ -0,0 +1,293 @@++package main++++import (++ "bytes"++ "context"++ "encoding/binary"++ "encoding/json"++ "errors"++ "image/png"++ "os"++ "path/filepath"++ "strings"++ "sync"++ "testing"++ "time"++++ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"++ "github.com/OpenVibers/OpenVibe.Node/internal/node"++ "github.com/OpenVibers/OpenVibe.Node/internal/safety"++)++++func TestTrayViewStates(t *testing.T) {++ if v := viewOf(nil, nil, errors.New("dial unix: no such file")); v.State != trayDown || !strings.Contains(v.Tooltip, "not running") {++ t.Fatalf("no Node: %+v", v)++ }++ st := &node.Status{Server: "https://openvibe.bot", Link: node.LinkStatus{Connected: true}}++ st.Telemetry.Sensors = map[string]any{"computer_level": "ask"}++ v := viewOf(st, nil, nil)++ if v.State != trayOK || v.Level != "ask" || v.Panel != "https://openvibe.bot/machines" || v.Tooltip != "OpenVibe Node: online, computer level ask" {++ t.Fatalf("all well: %+v", v)++ }++ v = viewOf(st, []node.Approval{{ID: "a"}, {ID: "b"}}, nil)++ if v.State != trayWaiting || v.Header != "2 requests wait for your answer" || !strings.HasSuffix(v.Tooltip, "2 waiting") {++ t.Fatalf("waiting: %+v", v)++ }++ if v = viewOf(st, []node.Approval{{ID: "a"}}, nil); v.Header != "1 request waits for your answer" {++ t.Fatalf("one waiting: %q", v.Header)++ }++ st.Latch = safety.LatchState{Local: true}++ if v = viewOf(st, []node.Approval{{ID: "a"}}, nil); v.State != trayStopped || !v.Stopped {++ t.Fatalf("a latched stop wins over waiting: %+v", v)++ }++}++++func TestTrayPanelURLOnlyForTheBot(t *testing.T) {++ for in, want := range map[string]string{++ "https://openvibe.bot": "https://openvibe.bot/machines",++ "https://openvibe.bot/api/x": "https://openvibe.bot/machines",++ "http://127.0.0.1:4100": "http://127.0.0.1:4100/machines",++ "http://example.com": "",++ "javascript:alert(1)": "",++ "": "",++ "file:///etc/passwd": "",++ "https://[email protected]:44": "https://openvibe.bot:44/machines", // no user info++ } {++ if got := panelURL(in); got != want {++ t.Errorf("panelURL(%q) = %q, want %q", in, got, want)++ }++ }++}++++func TestTraySlotLabel(t *testing.T) {++ if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != "exec: run echo hi" {++ t.Fatalf("label: %q", got)++ }++ long := slotLabel(node.Approval{Kind: "input", Summary: strings.Repeat("é", 100)})++ if !strings.HasSuffix(long, "…") || len([]rune(long)) != len("input: ")+70 {++ t.Fatalf("long label: %q (%d runes)", long, len([]rune(long)))++ }++}++++func TestTrayIcons(t *testing.T) {++ for _, state := range []string{trayOK, trayWaiting, trayStopped, trayDown} {++ img, err := png.Decode(bytes.NewReader(trayPNG(state)))++ if err != nil || img.Bounds().Dx() != 64 {++ t.Fatalf("%s png: %v %v", state, err, img.Bounds())++ }++ _, _, _, a := img.At(0, 0).RGBA()++ if a != 0 {++ t.Fatalf("%s: the corner is transparent", state)++ }++ ico := trayICO(state)++ if !bytes.Equal(ico[:6], []byte{0, 0, 1, 0, 1, 0}) || ico[6] != 32 || ico[7] != 32 {++ t.Fatalf("%s ico header: % x", state, ico[:8])++ }++ if size := binary.LittleEndian.Uint32(ico[14:18]); int(size) != len(ico)-22 || size != 40+32*32*4+32*4 {++ t.Fatalf("%s ico image size %d of %d", state, size, len(ico))++ }++ if binary.LittleEndian.Uint32(ico[22:26]) != 40 {++ t.Fatalf("%s: BITMAPINFOHEADER", state)++ }++ }++ ok, _ := png.Decode(bytes.NewReader(trayPNG(trayOK)))++ waiting, _ := png.Decode(bytes.NewReader(trayPNG(trayWaiting)))++ r1, g1, b1, _ := ok.At(10, 32).RGBA()++ r2, g2, b2, _ := waiting.At(10, 32).RGBA()++ if r1 == r2 && g1 == g2 && b1 == b2 {++ t.Fatal("waiting and all-well icons look the same")++ }++}++++func TestTrayDesktopEntry(t *testing.T) {++ e := trayDesktopEntry("/opt/Open Vibe/openvibe-node", "/home/a/node home")++ if !strings.Contains(e, `Exec="/opt/Open Vibe/openvibe-node" tray --home "/home/a/node home"`+"\n") || !strings.HasPrefix(e, "[Desktop Entry]\n") {++ t.Fatalf("entry:\n%s", e)++ }++ if e := trayDesktopEntry("/usr/local/bin/openvibe-node", ""); !strings.Contains(e, "Exec=/usr/local/bin/openvibe-node tray\n") {++ t.Fatalf("entry:\n%s", e)++ }++ dir := t.TempDir()++ t.Setenv("XDG_CONFIG_HOME", dir)++ if p, err := trayAutostartPath(); err != nil || p != filepath.Join(dir, "autostart", "openvibe-node-tray.desktop") {++ t.Fatalf("path %q %v", p, err)++ }++}++++// fakeNode answers the control socket like a running Node with the computer plugin at level ask.++type fakeNode struct {++ mu sync.Mutex++ list []node.Approval++ refuse int // approvals to refuse as "the agent is using the mouse or keyboard"++ answered []string++ tray []bool++ stopped bool++}++++func (f *fakeNode) handle(r localctl.Request) localctl.Response {++ f.mu.Lock()++ defer f.mu.Unlock()++ switch r.Cmd {++ case localctl.CmdStatus:++ st := node.Status{Server: "https://openvibe.bot", Link: node.LinkStatus{Connected: true}, Latch: safety.LatchState{Local: f.stopped}}++ st.Telemetry.Sensors = map[string]any{"computer_level": "ask"}++ b, _ := json.Marshal(st)++ return localctl.Response{OK: true, Data: b}++ case localctl.CmdApprovals:++ b, _ := json.Marshal(f.list)++ return localctl.Response{OK: true, Data: b}++ case localctl.CmdTray:++ f.tray = append(f.tray, r.On)++ return localctl.Response{OK: true}++ case localctl.CmdApprove, localctl.CmdDeny:++ if r.Cmd == localctl.CmdApprove && f.refuse > 0 {++ f.refuse--++ return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment"}++ }++ for i, a := range f.list {++ if a.ID == r.ID {++ f.list = append(f.list[:i], f.list[i+1:]...)++ f.answered = append(f.answered, r.Cmd+" "+r.ID)++ return localctl.Response{OK: true}++ }++ }++ return localctl.Response{Error: "nothing with the id waits"}++ case localctl.CmdStop:++ f.stopped = true++ return localctl.Response{OK: true}++ case localctl.CmdResume:++ f.stopped = false++ return localctl.Response{OK: true}++ }++ return localctl.Response{Error: "unknown"}++}++++type fakeUI struct {++ state, tooltip, header, level string++ stopped, panel bool++ slots []string++}++++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) SetPanel(b bool) { u.panel = b }++func (n *fakeNotes) Answers() <-chan trayAnswer { return nil }++func (n *fakeNotes) Shut() { n.shut = true }++++type fakeNotes struct {++ asked []string // id, or "id again" for a second asking++ closed []string++ infos []string++ shut bool++}++++func (n *fakeNotes) Ask(a node.Approval, again string) {++ if again != "" {++ n.asked = append(n.asked, a.ID+" again")++ return++ }++ n.asked = append(n.asked, a.ID)++}++func (n *fakeNotes) Close(id string) { n.closed = append(n.closed, id) }++func (n *fakeNotes) Info(_, body string) { n.infos = append(n.infos, body) }++++func TestTrayCoreAgainstANode(t *testing.T) {++ dir, err := os.MkdirTemp("", "ovt") // short: unix socket paths are limited to ~100 bytes++ if err != nil {++ t.Fatal(err)++ }++ defer os.RemoveAll(dir)++ sock := filepath.Join(dir, "n.sock")++ f := &fakeNode{list: []node.Approval{{ID: "c1", Kind: "exec", Summary: "run echo hi", ExpiresS: 120}}, refuse: 1}++ ctx, cancel := context.WithCancel(context.Background())++ defer cancel()++ go func() { _ = localctl.Serve(ctx, sock, f.handle) }()++ deadline := time.Now().Add(5 * time.Second)++ for time.Now().Before(deadline) {++ if _, err := os.Stat(sock); err == nil {++ break++ }++ time.Sleep(10 * time.Millisecond)++ }++ ui, notes := &fakeUI{}, &fakeNotes{}++ core := newTrayCore(sock, ui, notes)++++ core.tick()++ if ui.state != trayWaiting || ui.level != "ask" || !ui.panel || len(ui.slots) != 1 || ui.slots[0] != "exec: run echo hi" {++ t.Fatalf("ui after the first poll: %+v", ui)++ }++ if strings.Join(notes.asked, ",") != "c1" || len(f.tray) != 1 || !f.tray[0] {++ t.Fatalf("asked %v, tray %v", notes.asked, f.tray)++ }++ core.tick()++ if len(notes.asked) != 1 {++ 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)++ }++ if err := core.answer(a, true); err == nil {++ t.Fatal("the Node refused the approval; the tray should say so")++ }++ if strings.Join(notes.asked, ",") != "c1,c1 again" {++ t.Fatalf("a refused approval asks again: %v", notes.asked)++ }++ if err := core.answerByID("c1", true); err != nil {++ t.Fatal(err)++ }++ if strings.Join(f.answered, ",") != "approve c1" || strings.Join(notes.closed, ",") != "c1" {++ t.Fatalf("answered %v closed %v", f.answered, notes.closed)++ }++ core.tick()++ if ui.state != trayOK || len(ui.slots) != 0 || len(notes.closed) != 1 {++ t.Fatalf("after the answer: ui %+v closed %v", ui, notes.closed)++ }++++ // A request answered elsewhere (the CLI, or it expired) takes its notification down at the next poll.++ f.mu.Lock()++ f.list = append(f.list, node.Approval{ID: "c2", Kind: "input", Summary: "click at 1, 2"})++ f.mu.Unlock()++ core.tick()++ f.mu.Lock()++ f.list = nil++ f.mu.Unlock()++ core.tick()++ if strings.Join(notes.asked, ",") != "c1,c1 again,c2" || strings.Join(notes.closed, ",") != "c1,c2" {++ t.Fatalf("asked %v closed %v", notes.asked, notes.closed)++ }++ if err := core.answerByID("c2", false); err == nil {++ t.Fatal("answered a request that no longer waits")++ }++++ if err := core.stop(); err != nil {++ t.Fatal(err)++ }++ core.tick()++ if ui.state != trayStopped || !ui.stopped {++ t.Fatalf("stopped: %+v", ui)++ }++ if err := core.resume(); err != nil {++ t.Fatal(err)++ }++ core.tick()++ if ui.state != trayOK || ui.stopped {++ t.Fatalf("resumed: %+v", ui)++ }++++ core.quit()++ if last := f.tray[len(f.tray)-1]; last || !notes.shut {++ t.Fatalf("quit tells the Node and shuts the notifier: tray %v shut %v", f.tray, notes.shut)++ }++ cancel()++ time.Sleep(50 * time.Millisecond)++ core.tick()++ if ui.state != trayDown {++ t.Fatalf("the Node gone: %+v", ui)++ }++}@@@ -44,7 +44,30 @@ openvibe-node approve <ID> # let it runopenvibe-node deny <ID> # refuse it```--When the Node runs in your graphical session, a waiting request also shows a desktop notification (`notify-send` on Linux, `osascript` on macOS; config `notify: false` turns it off). At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say++**The tray** (Linux and Windows): `openvibe-node tray` puts the Node in your notification area. The installer's++`--computer` starts it when you sign in (`openvibe-node tray --autostart on|off` does it by hand). It runs in your++desktop session, not in the service, and talks to the Node over the same local socket as the commands above:++++- the icon is blue when all is well, amber while a request waits, red while a stop is latched, grey when the Node does++ not answer;++- the menu lists what waits (kind and what it would do), each with **Approve** and **Deny**, then **Stop everything**++ (the kill switch), **Resume** and your machines page on openvibe.bot;++- a new request raises a notification with **Approve** and **Deny** buttons (Linux, any desktop with++ org.freedesktop.Notifications; without buttons it names the command to answer with).++++KDE, XFCE, Cinnamon and MATE show tray icons; GNOME needs the AppIndicator extension, and its notifications still ask.++While the tray runs, the plugin's own prompts below stay quiet.++++**Without the tray**, when the Node runs in your graphical session, the plugin asks itself (config `notify: false` turns++it off): on Linux a notification with Approve and Deny buttons (`notify-send` from libnotify 0.7.10 on; older ones show++the command to answer with); on macOS a dialog with **Deny** as its default button, which gives up when the request++expires. A prompt goes away once the request is answered anywhere else, expires or is stopped.++++**An approval is not taken while the agent drives the mouse or keyboard**, nor for 2 seconds after: an approved click++must not be able to press another request's Approve button. `openvibe-node approve` then answers "answer again in a++moment", and the tray and the plugin ask again. Deny always goes through.++++At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can saythat 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@@@ -94,7 +117,7 @@ text and keys are passed to the tool as arguments, never through a shell, and `k| `max_output_bytes` | 65536 (≤ 192 KB) | stdout and stderr are each cut there || `max_read_bytes` | 262144 (≤ 256 KB) | the most one read or write moves || `approval_timeout_s` | 120 (≤ 600) | how long a request waits for you |--| `notify` | `true` | a desktop notification when a request waits (graphical session only) |++| `notify` | `true` | the plugin's own prompt when a request waits and no tray shows it (graphical session only) || `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) || `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) || `allow_browser` | `true` | `false`: no browser (no `browser` capability) |@@@ -53,6 +53,8 @@ describes itself and waits for EOF without opening any hardware.| `stop` | | stop every actuator || `estop` | | stop every actuator and refuse motion until `resume` || `resume` | | accept motion again |++| `approval` | `id`, `approved` bool | the owner's answer to an `approval` event it raised |++| `tray` | `attached` bool, `ttl_s` | (computer plugin only) the tray shows its approvals for `ttl_s` s unless told again: its own desktop prompts may stay quiet |`value` has already been validated and clamped to the owner's limits by the core ([protocol.md](protocol.md#commands)).`deadline_ms` is relative to receipt and already capped.@@@ -242,8 +244,10 @@ with the owner's control level (`observe`, `ask`, `trusted`, `full`) and folderswhile a stop is latched). Its `ack` carries the answer in `data`. The level is enforced in the plugin from its localconfig, never from a command; `ask` holds a command (`Deferred` in the SDK), raises an `approval` event and answersonce the owner runs `openvibe-node approve|deny <id>`, which the core forwards to the plugin as `{"op": "approval", "id",--"approved"}`. The local kill switch drops it to `observe`. Keyboard and mouse input, a browser and the machine's own--coding agents are later kinds on the same plugin.++"approved"}`. The local kill switch drops it to `observe`. `input` (mouse and keyboard) and `browser` are kinds on the++same plugin; the machine's own coding agents come later. While `openvibe-node tray` runs, the core sends the plugin++`{"op": "tray", "attached": true, "ttl_s": 15}` every few seconds (and `attached: false` when it quits), and the plugin++leaves the desktop prompts to the tray.**SDK additions it uses:** `handle()` may return a dict (the ack's `data`) or raise `Deferred` and answer later with`complete(id, data)` / `fail(id, code, message)` from any thread; `on_approval(id, approved)`; kinds that move nothing@@@ -3,6 +3,8 @@ module github.com/OpenVibers/OpenVibe.Nodego 1.24.7require (++ fyne.io/systray v1.12.2++ github.com/godbus/dbus/v5 v5.1.0github.com/gorilla/websocket v1.5.3github.com/kardianos/service v1.3.0github.com/pion/ice/v4 v4.4.4@@@ -1,3 +1,7 @@++fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=++fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=++github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=++github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=@@@ -480,6 +480,16 @@ fiif [ -n "$COMPUTER" ]; thenwrite_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME"say "the computer plugin is on at level $COMPUTER for $SVC_USER (folder $COMPUTER_HOME); change it in $CONF/config.json"++ # The tray starts when they sign in: what waits for approval with Approve and Deny, the stop and resume.++ had_config=0++ [ -d "$COMPUTER_HOME/.config" ] && had_config=1++ if XDG_CONFIG_HOME="$COMPUTER_HOME/.config" "$BIN_DIR/openvibe-node" tray --autostart on >/dev/null 2>&1; then++ if [ "$(id -u)" = 0 ]; then++ [ "$had_config" = 1 ] || chown "$SVC_USER": "$COMPUTER_HOME/.config" 2>/dev/null || true++ chown "$SVC_USER": "$COMPUTER_HOME/.config/autostart" "$COMPUTER_HOME/.config/autostart/openvibe-node-tray.desktop" 2>/dev/null || true++ fi++ say "the tray starts when $SVC_USER signs in (openvibe-node tray; openvibe-node tray --autostart off removes it)"++ fifi# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----@@@ -1,5 +1,5 @@// Package localctl is the running Node's local control socket: `openvibe-node stop`, `resume`, `status`, `plugins`,--// `approvals`, `approve` and `deny` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode++// `approvals`, `approve`, `deny` and `tray` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode// 0600, so only the account running the Node and root can use it. One JSON request per connection, one JSON answer.package localctl@@@ -23,11 +23,15 @@ const (CmdApprovals = "approvals"CmdApprove = "approve"CmdDeny = "deny"++ // CmdTray: `openvibe-node tray` shows the approvals (On) or has quit; the computer plugin's own desktop prompts++ // stay quiet while it does.++ CmdTray = "tray")type Request struct {Cmd string `json:"cmd"`ID string `json:"id,omitempty"` // approve and deny: the command id the plugin asked about++ On bool `json:"on,omitempty"` // tray: the tray is running}type Response struct {@@@ -207,6 +207,93 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {}}++// An approval is refused while the agent drives the mouse or keyboard and just after: an approved click must not be++// able to press another request's Approve button (in the tray or a notification). Deny always goes through.++func TestComputerApproveWaitsOutTheMouse(t *testing.T) {++ e, _ := startComputer(t, "ask")++ b, _ := json.Marshal(map[string]any{"argv": []string{"echo", "later"}})++ if err := e.conn.Command(protocol.Command{ID: "g1", Kind: protocol.KindExec, Value: b}, 20000); err != nil {++ t.Fatal(err)++ }++ deadline := time.Now().Add(10 * time.Second)++ for time.Now().Before(deadline) && len(e.node.Approvals()) == 0 {++ time.Sleep(50 * time.Millisecond)++ }++ 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)++ if err == nil || r.OK || !strings.Contains(r.Error, "mouse or keyboard") {++ t.Fatalf("approve right after a click: %+v %v", r, err)++ }++ if len(e.node.Approvals()) != 1 {++ t.Fatal("the refused approval no longer waits")++ }++ e.node.mu.Lock()++ 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 {++ 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 {++ t.Fatal(err, r.Error)++ }++ if d := dataOf(t, func() protocol.Message {++ m, err := e.conn.Reply("g1", 10*time.Second)++ if err != nil {++ t.Fatal(err)++ }++ return m++ }()); d["stdout"] != "later\n" {++ t.Fatalf("approved exec: %v", d)++ }++ if err := e.conn.Command(protocol.Command{ID: "g2", Kind: protocol.KindExec, Value: b}, 20000); err != nil {++ t.Fatal(err)++ }++ for time.Now().Before(deadline.Add(5*time.Second)) && len(e.node.Approvals()) == 0 {++ time.Sleep(50 * time.Millisecond)++ }++ 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 {++ t.Fatal("deny is never held back:", err, r.Error)++ }++}++++// The tray says it is there on every poll; the core tells the computer plugin at most every trayRetell, and at once++// when the tray quits.++func TestComputerTrayPresenceReachesThePlugin(t *testing.T) {++ e, _ := startComputer(t, "ask")++ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray, On: true}, 2*time.Second); err != nil || !r.OK {++ t.Fatal(err, r.Error)++ }++ e.node.mu.Lock()++ first := e.node.trayToldAt++ e.node.mu.Unlock()++ if first.IsZero() {++ t.Fatal("the tray's presence was not passed on")++ }++ _, _ = localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray, On: true}, 2*time.Second)++ e.node.mu.Lock()++ again := e.node.trayToldAt++ e.node.mu.Unlock()++ if !again.Equal(first) {++ t.Fatal("told again within trayRetell")++ }++ _, _ = localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray}, 2*time.Second)++ e.node.mu.Lock()++ off := e.node.trayToldAt++ e.node.mu.Unlock()++ if !off.IsZero() {++ t.Fatal("the tray quitting was not passed on")++ }++}++func TestComputerLocalStopLeavesObserveOnly(t *testing.T) {e, work := startComputer(t, "trusted")if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second); err != nil || !r.OK {@@@ -90,8 +90,23 @@ type Node struct {// approvals waiting on the machine (the computer plugin at level "ask"), by command id: what// `openvibe-node approvals` lists and `approve`/`deny` answer.approvals map[string]Approval++ // input commands in flight, by id, and when the last one finished: `approve` is refused while the agent drives++ // the mouse or keyboard and for InputGuard after, so an approved click cannot press another request's Approve.++ inputs map[string]bool++ inputEnded time.Time++ trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals}++// InputGuard is how long after an input command `approve` stays refused.++const InputGuard = 2 * time.Second++++// trayTTL is how long the computer plugin trusts one "the tray shows your approvals"; the tray says it again++// every poll and the core passes it on at most every trayRetell.++const (++ trayTTL = 15++ trayRetell = 5 * time.Second++)++// Approval is one computer command waiting for its owner's answer on the machine.type Approval struct {ID string `json:"id"`@@@ -115,7 +130,7 @@ func New(opt Options) (*Node, error) {opt.Log.Error("latch", "err", err)}n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),-- jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}}++ jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{}}n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)var specs []plugins.Spec@@@ -506,9 +521,22 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, rnack(protocol.FaultUnsupported, "no driver on this device handles "+c.Kind+" (the computer plugin is off)")return}++ if c.Kind == protocol.KindInput {++ n.mu.Lock()++ n.inputs[c.ID] = true++ n.mu.Unlock()++ }wait := p.Begin(n.ctx, c.ID, c.Kind, c.Value, int(window))go func() {r := wait()++ if c.Kind == protocol.KindInput {++ n.mu.Lock()++ delete(n.inputs, c.ID)++ if r.OK || (r.FaultCode != protocol.FaultNotAllowed && r.FaultCode != protocol.FaultLocalStop) {++ n.inputEnded = time.Now() // it ran (or may have run part way)++ }++ n.mu.Unlock()++ }var msg protocol.Messageswitch {case r.OK && len(r.Data) > protocol.MaxAckData:@@@ -986,6 +1014,42 @@ func (n *Node) Approvals() []Approval {return out}++// drivingLocked: an input command runs (it is in flight and not waiting for approval) or finished under InputGuard ago.++func (n *Node) drivingLocked() bool {++ for id := range n.inputs {++ if _, waiting := n.approvals[id]; !waiting {++ return true++ }++ }++ return !n.inputEnded.IsZero() && time.Since(n.inputEnded) < InputGuard++}++++// tellTray passes the tray's presence to the plugins that handle computer kinds: at once when it quits, at most every++// trayRetell while it runs.++func (n *Node) tellTray(on bool) {++ n.mu.Lock()++ if on && !n.trayToldAt.IsZero() && time.Since(n.trayToldAt) < trayRetell {++ n.mu.Unlock()++ return++ }++ if on {++ n.trayToldAt = time.Now()++ } else {++ n.trayToldAt = time.Time{}++ }++ n.mu.Unlock()++ if n.mgr == nil {++ return++ }++ seen := map[*plugins.Plugin]bool{}++ for kind := range protocol.ComputerKinds {++ if p := n.mgr.Route(kind, "", ""); p != nil && !seen[p] {++ seen[p] = true++ _ = p.Tray(on, trayTTL)++ }++ }++}++func (n *Node) control(r localctl.Request) localctl.Response {switch r.Cmd {case localctl.CmdApprovals:@@@ -997,6 +1061,10 @@ func (n *Node) control(r localctl.Request) localctl.Response {case localctl.CmdApprove, localctl.CmdDeny:n.mu.Lock()a, ok := n.approvals[r.ID]++ 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)}@@@ -1012,6 +1080,9 @@ func (n *Node) control(r localctl.Request) localctl.Response {return localctl.Response{Error: err.Error()}}return localctl.Response{OK: true}++ case localctl.CmdTray:++ n.tellTray(r.On)++ return localctl.Response{OK: true}case localctl.CmdStop:n.mgr.EstopAll() // at once, then persistif _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {@@@ -206,6 +206,11 @@ func (p *Plugin) Answer(id string, approved bool) error {return p.send(map[string]any{"op": "approval", "id": id, "approved": approved})}++// Tray tells the plugin whether the Node's tray shows its approvals, for the next ttlS seconds unless told again.++func (p *Plugin) Tray(on bool, ttlS int) error {++ return p.send(map[string]any{"op": "tray", "attached": on, "ttl_s": ttlS})++}++// Command sends a command and waits for the plugin's ack or nack.func (p *Plugin) Command(ctx context.Context, id, kind string, value json.RawMessage, deadlineMS int) Reply {return p.Begin(ctx, id, kind, value, deadlineMS)()@@@ -6,7 +6,8 @@ command can never raise it:* ``observe`` (the default): capture the screen; list, stat and read files inside the roots.* ``ask``: also run programs, write files inside the roots and use the mouse and keyboard, each only after the ownerapproves it on the machine-- (``openvibe-node approvals``, ``approve <id>``, ``deny <id>``); unanswered requests expire.++ (the tray, a notification's buttons, or ``openvibe-node approvals``, ``approve <id>``, ``deny <id>``); unanswered++ requests expire.* ``trusted``: run programs, write files inside the roots and use the mouse and keyboard without asking.* ``full``: as trusted, anywhere the account running the Node can reach.@@@ -40,6 +41,7 @@ Command kinds and their answers (the ack's ``data``):"""import base64++import contextlibimport http.clientimport ipaddressimport os@@@ -47,6 +49,7 @@ import socketimport sslimport shleximport shutil++import signalimport statimport subprocessimport sys@@@ -69,6 +72,9 @@ MAX_STDIN = 64 * 1024MAX_LIST = 1000MAX_RUNNING = 2MAX_WAITING = 8++# An approval from the desktop (the notification's button or the macOS dialog) is not taken while the agent drives the++# mouse or keyboard, nor this many seconds after: an approved click must not land on another request's Approve button.++INPUT_GUARD_S = 2.0# The core caps an ack's data at 512 KB of JSON; base64 grows by a third.MAX_OUTPUT_CAP = 192 * 1024MAX_READ_CAP = 256 * 1024@@@ -90,6 +96,21 @@ CLICLICK_KEYS = {"return": "return", "enter": "enter", "escape": "esc", "esc": ""page_down": "page-down", "next": "page-down"}++++def _parse_notify_send(out):++ """notify-send --wait prints the name of the button pressed; nothing when the notification just closed."""++ lines = (out or "").strip().splitlines()++ last = lines[-1].strip() if lines else ""++ return True if last == "approve" else False if last == "deny" else None++++++def _parse_dialog(out):++ """osascript's display dialog: "button returned:Approve, gave up:false"; giving up answers nothing."""++ out = out or ""++ if "gave up:true" in out:++ return None++ return True if "button returned:Approve" in out else False if "button returned:Deny" in out else None++def _int(v, name, lo, hi, default):if v is None:return default@@@ -121,6 +142,11 @@ class Computer(Plugin):self.allow_probe = Trueself.allow_input = Trueself.notify = True++ self.notify_actions = None # whether notify-send shows buttons (libnotify 0.7.10+); found out on first use++ self.prompts = {} # command id -> the notification or dialog process asking about it++ self.tray_until = 0.0 # the Node's tray shows the approvals until then (op "tray")++ self.input_busy = 0++ self.input_ended = Noneself.approval_timeout_s = 120self.pending = {} # command id -> (run, expires_at, summary)self.procs = set()@@@ -206,6 +232,7 @@ class Computer(Plugin):self._kill(p)self._close_browser()for cid in waiting:++ self._close_prompt(cid)self.emit_event("approval_done", id=cid, approved=False, reason="stopped")self.fail(cid, "local_stop", "stopped on the machine before it ran")@@@ -231,6 +258,7 @@ class Computer(Plugin):del self.pending[cid]waiting, running = len(self.pending), self.runningfor cid in expired:++ self._close_prompt(cid)self.emit_event("approval_done", id=cid, approved=False, reason="expired")self.fail(cid, "not_allowed", "nobody answered on the machine in time")# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.@@@ -288,7 +316,7 @@ class Computer(Plugin):if not tool:raise Fault("unsupported", "no input tool on this machine (xdotool on X11, cliclick on macOS)")run, summary = self._input_op(tool, v)-- return self._act(cmd, level, run, summary, threaded=True)++ return self._act(cmd, level, self._driving(run), summary, threaded=True)if cmd.kind == "browser":run, summary, acting = self._browser_op(v, level)if acting:@@@ -320,29 +348,124 @@ class Computer(Plugin):return self._start(cmd, run) if threaded else run()def _notify(self, cid, summary):-- """A desktop notification that a request waits (config `notify`, on by default), when the Node runs in a-- graphical session: notify-send on Linux, osascript on macOS. Best effort: it never blocks or fails a command."""-- if not self.notify:++ """Tell the desktop that a request waits (config `notify`, on by default) when the Node runs in a graphical++ session and the Node's tray does not show it already (`openvibe-node tray`). On Linux the notification has++ Approve and Deny buttons when notify-send can show them (libnotify 0.7.10+); on macOS a dialog asks, with Deny++ as its default button. Best effort: it never blocks or fails a command."""++ if not self.notify or self.clock() < self.tray_until:returntitle = "OpenVibe: your agent asks"-- body = "%s\nopenvibe-node approve %s (or deny %s)" % (summary[:200], cid, cid)++ hint = "openvibe-node approve %s (or deny %s)" % (cid, cid)++ parse = Noneif self.platform.startswith("linux") and (self.env.get("DISPLAY") or self.env.get("WAYLAND_DISPLAY")) and self.which("notify-send"):-- argv = ["notify-send", "--app-name=OpenVibe Node", title, body]++ if self._notify_can_act():++ argv = ["notify-send", "--app-name=OpenVibe Node", "--urgency=critical", "--wait",++ "--expire-time=%d" % (self.approval_timeout_s * 1000), "--action=approve=Approve",++ "--action=deny=Deny", "--", title, summary[:200]]++ parse = _parse_notify_send++ else:++ argv = ["notify-send", "--app-name=OpenVibe Node", title, "%s\n%s" % (summary[:200], hint)]elif self.platform == "darwin" and self.which("osascript"):quote = lambda s: '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"')-- argv = ["osascript", "-e", "display notification %s with title %s" % (quote(body), quote(title))]++ argv = ["osascript", "-e", 'display dialog %s with title %s buttons {"Deny", "Approve"} default button "Deny" '++ "giving up after %d with icon caution" % (quote(summary[:400]), quote(title), self.approval_timeout_s)]++ parse = _parse_dialogelse:returntry:-- self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)++ if parse is None:++ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)++ return++ proc = self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,++ start_new_session=True, text=True)except OSError:++ return++ with self.mu:++ old = self.prompts.pop(cid, None)++ self.prompts[cid] = proc++ if old is not None:++ self._end_prompt(old)++ threading.Thread(target=self._await_prompt, args=(cid, proc, parse), name="computer-ask-%s" % cid, daemon=True).start()++++ def _notify_can_act(self):++ """Whether this notify-send has --action and --wait (libnotify 0.7.10+), asked once."""++ if self.notify_actions is None:++ try:++ p = self.popen(["notify-send", "--help"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,++ stderr=subprocess.STDOUT, text=True)++ out = p.communicate(timeout=3)[0] or ""++ self.notify_actions = "--action" in out and "--wait" in out++ except (OSError, subprocess.SubprocessError, AttributeError):++ self.notify_actions = False++ return self.notify_actions++++ def _await_prompt(self, cid, proc, parse):++ try:++ out = proc.communicate()[0] or ""++ except (OSError, ValueError, AttributeError):++ out = ""++ with self.mu:++ if self.prompts.get(cid) is proc:++ del self.prompts[cid]++ answer = parse(out)++ if answer is not None:++ self._desktop_answer(cid, answer)++++ def _desktop_answer(self, cid, approved):++ """The owner pressed Approve or Deny on the desktop. An approval that arrives while the agent drives the mouse or++ keyboard (or just after) may be the agent's own click: it is not taken, and the request is shown again."""++ with self.mu:++ item = self.pending.get(cid)++ driving = self.input_busy > 0 or (self.input_ended is not None and self.clock() - self.input_ended < INPUT_GUARD_S)++ if item is None:++ return++ if approved and driving:++ self.log("an approval of %s came from the desktop while the agent drove the mouse or keyboard: asking again", cid)++ self._notify(cid, item[2])++ return++ with (self.runtime.lock if self.runtime is not None else contextlib.nullcontext()):++ self.on_approval(cid, approved)++++ def _close_prompt(self, cid):++ """Take down the notification or dialog of a request answered elsewhere, expired or stopped."""++ with self.mu:++ proc = self.prompts.pop(cid, None)++ if proc is not None:++ self._end_prompt(proc)++++ def _end_prompt(self, proc):++ try:++ if proc.poll() is None:++ proc.send_signal(signal.SIGINT) # notify-send --wait closes its notification on SIGINT++ except (OSError, AttributeError):pass++ def _driving(self, run):++ """An input command, counted while it runs so desktop approvals wait it out (INPUT_GUARD_S)."""++ def go():++ with self.mu:++ self.input_busy += 1++ try:++ return run()++ finally:++ with self.mu:++ self.input_busy -= 1++ self.input_ended = self.clock()++ return go++++ def on_tray(self, attached, ttl_s):++ try:++ ttl = max(1.0, min(float(ttl_s or 15), 60.0))++ except (TypeError, ValueError):++ ttl = 15.0++ self.tray_until = self.clock() + ttl if attached else 0.0++def on_approval(self, cid, approved):with self.mu:item = self.pending.pop(cid, None)if item is None:return++ self._close_prompt(cid)(run, threaded), _, summary = itemself.emit_event("approval_done", id=cid, approved=bool(approved))if not approved:@@@ -46,6 +46,31 @@ class Out(io.StringIO):self.cond.wait(left)++class FakePrompt:++ """A notify-send --wait or osascript dialog process: answer() is the owner pressing a button."""++++ def __init__(self, argv):++ self.argv = argv++ self.out = ""++ self.done = threading.Event()++ self.closed = threading.Event()++++ def answer(self, out):++ self.out = out++ self.done.set()++++ def communicate(self, timeout=None):++ self.done.wait(10)++ return (self.out, None)++++ def poll(self):++ return 0 if self.done.is_set() else None++++ def send_signal(self, sig):++ self.closed.set()++ self.done.set()++++def reply(out, cid):return out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"))@@@ -173,6 +198,7 @@ class ComputerTest(unittest.TestCase):self.plugin.popen = lambda argv, **kw: calls.append(argv) if argv[0] in ("notify-send", "osascript") else real(argv, **kw)self.plugin.which = lambda name: "/usr/bin/" + nameself.plugin.env = {"DISPLAY": ":0"}++ self.plugin.notify_actions = False # an older notify-send: no buttons, the command to answer withcid = self.cmd("exec", {"argv": ["echo", "hi"]})self.out.wait_for(lambda m: m.get("name") == "approval")self.assertEqual(calls[0][:3], ["notify-send", "--app-name=OpenVibe Node", "OpenVibe: your agent asks"])@@@ -185,6 +211,94 @@ class ComputerTest(unittest.TestCase):self.cmd("exec", {"argv": ["echo", "quiet"]})self.assertEqual(len(calls), 1, "notify off")++ def desktop(self, platform="linux"):++ """A graphical session whose notification or dialog the test answers: returns the prompts shown."""++ prompts = []++ real = self.plugin.popen++++ def popen(argv, **kw):++ if argv[0] in ("notify-send", "osascript"):++ p = FakePrompt(argv)++ prompts.append(p)++ return p++ return real(argv, **kw)++ self.plugin.popen = popen++ self.plugin.which = lambda name: "/usr/bin/" + name++ self.plugin.platform = platform++ self.plugin.env = {"DISPLAY": ":0"}++ self.plugin.notify_actions = True++ return prompts++++ def test_the_notification_approves_and_denies(self):++ self.boot("ask")++ prompts = self.desktop()++ cid = self.cmd("exec", {"argv": ["echo", "from the desktop"]})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ argv = prompts[0].argv++ self.assertIn("--action=approve=Approve", argv)++ self.assertIn("--wait", argv)++ self.assertEqual(argv[-3:-1], ["--", "OpenVibe: your agent asks"])++ prompts[0].answer("approve\n")++ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "from the desktop\n")++ cid = self.cmd("fs", {"op": "write", "path": os.path.join(self.dir, "no.txt"), "content_b64": "eA=="})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ prompts[1].answer("deny\n")++ self.assertEqual(reply(self.out, cid)["fault_code"], "not_allowed")++ self.assertFalse(os.path.exists(os.path.join(self.dir, "no.txt")))++++ def test_an_answer_elsewhere_takes_the_notification_down(self):++ self.boot("ask")++ prompts = self.desktop()++ cid = self.cmd("exec", {"argv": ["echo", "cli"]})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ self.rt.dispatch({"op": "approval", "id": cid, "approved": True})++ reply(self.out, cid)++ self.assertTrue(prompts[0].closed.wait(5), "the notification is closed once the CLI answered")++++ def test_a_desktop_approval_while_the_agent_drives_the_mouse_is_not_taken(self):++ self.boot("ask")++ prompts = self.desktop()++ cid = self.cmd("exec", {"argv": ["echo", "maybe"]})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ t = [time.monotonic()] # the real clock's value: the command's own deadline counts on it++ self.plugin.clock = lambda: t[0]++ self.plugin.input_ended = t[0] - 0.5 # a click ran half a second ago++ prompts[0].answer("approve\n")++ deadline = time.monotonic() + 5++ while len(prompts) < 2 and time.monotonic() < deadline:++ time.sleep(0.01)++ self.assertEqual(len(prompts), 2, "asked again")++ self.assertIn(cid, self.plugin.pending, "still waiting")++ t[0] += 3++ prompts[1].answer("approve\n")++ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "maybe\n")++++ def test_the_tray_shows_approvals_instead(self):++ self.boot("ask")++ prompts = self.desktop()++ self.rt.dispatch({"op": "tray", "attached": True, "ttl_s": 15})++ cid = self.cmd("exec", {"argv": ["echo", "tray"]})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ self.assertEqual(prompts, [], "the tray notifies; the plugin stays quiet")++ self.rt.dispatch({"op": "tray", "attached": False})++ self.cmd("exec", {"argv": ["echo", "no tray"]})++ self.assertEqual(len(prompts), 1)++++ def test_macos_asks_with_a_dialog(self):++ self.boot("ask")++ prompts = self.desktop("darwin")++ cid = self.cmd("exec", {"argv": ["echo", "mac"]})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ script = prompts[0].argv[2]++ self.assertIn('buttons {"Deny", "Approve"} default button "Deny"', script)++ prompts[0].answer("button returned:Approve, gave up:false\n")++ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "mac\n")++ cid = self.cmd("exec", {"argv": ["echo", "ignored"]})++ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)++ prompts[1].answer("button returned:, gave up:true\n")++ time.sleep(0.05)++ self.assertIn(cid, self.plugin.pending, "giving up answers nothing: the request waits until it expires")++def test_unanswered_requests_expire(self):self.boot("ask", approval_timeout_s=10)t = [1000.0]@@@ -130,6 +130,11 @@ class Plugin:"""The owner answered, on the machine (`openvibe-node approve|deny <id>`), an approval this plugin asked forwith emit_event("approval", id=…). Runs on the runtime's main thread."""++ def on_tray(self, attached, ttl_s):++ """The Node's tray (`openvibe-node tray`, in the owner's graphical session) is showing this plugin's approvals,++ for the next ttl_s seconds unless told again. A plugin that notifies the desktop itself can stay quiet then.++ Sent only to the plugin that handles computer kinds."""++# Helpers for drivers.def complete(self, cid, data=None):"""Answer a deferred command with ack (and its data). Safe from any thread."""@@@ -276,6 +281,11 @@ class Runtime:self.plugin.on_approval(msg.get("id"), msg.get("approved") is True)except Exception as e:self.plugin.log("approval handler failed: %r", e)++ elif op == "tray":++ try:++ self.plugin.on_tray(msg.get("attached") is True, msg.get("ttl_s"))++ except Exception as e:++ self.plugin.log("tray handler failed: %r", e)elif op == "resume":if self.local_stop_pending:self.plugin.log("resume ignored: the local stop has not reached the core yet")@@@ -284,6 +284,9 @@ class Later(Plugin):else:self.fail(cid, "not_allowed", "denied")++ def on_tray(self, attached, ttl_s):++ self.answers.append(("tray", attached, ttl_s))++class DeferredTest(unittest.TestCase):def test_data_deferred_and_approval(self):@@@ -303,6 +306,16 @@ class DeferredTest(unittest.TestCase):self.assertEqual(p.answers[-1], ("c", False), "only a JSON true approves")self.assertEqual(lines(out)[-1], {"op": "nack", "id": "c", "fault_code": "not_allowed", "message": "denied"})++ def test_the_tray_op_reaches_the_plugin(self):++ rt, p, out, clock = make(Later())++ rt.dispatch({"op": "hello", "config": {}})++ rt.dispatch({"op": "tray", "attached": True, "ttl_s": 15})++ rt.dispatch({"op": "tray", "attached": "yes"})++ self.assertEqual(p.answers, [("tray", True, 15), ("tray", False, None)], "only a JSON true attaches")++ rt2, p2, out2, _ = make()++ rt2.dispatch({"op": "hello", "config": {}})++ rt2.dispatch({"op": "tray", "attached": True, "ttl_s": 15}) # a plugin without on_tray: the base no-op++def test_motion_kinds_keep_the_short_cap(self):rt, p, out, clock = make()rt.dispatch({"op": "hello", "config": {}})
@@ -28,6 +28,12 @@ jobs:
@@ -28,6 +28,12 @@ jobs:
# (and Ubuntu's AppArmor restricts unprivileged user namespaces).
# (and Ubuntu's AppArmor restricts unprivileged user namespaces).
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
- run: go test -race -count=1 ./...
- run: go test -race -count=1 ./...
+ # The tray's desktop notifications, against a fake notification server on a private session bus.
+ - name: tray notifications (private D-Bus)
+ run: |
+ command -v dbus-run-session >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq dbus; }
+ dbus-run-session -- go test -race -count=1 -v -run DesktopNotifier ./cmd/openvibe-node | tee /tmp/notify.log
+ grep -q -- '--- PASS: TestDesktopNotifierAsksAndHearsTheButtons' /tmp/notify.log
# So they run again as root (jobs as nobody), in a systemd scope with its cgroup delegated, where
# So they run again as root (jobs as nobody), in a systemd scope with its cgroup delegated, where
# OPENVIBE_WORKER_TESTS=require makes them fail instead of skipping. Each test binary lies alone in a directory
# OPENVIBE_WORKER_TESTS=require makes them fail instead of skipping. Each test binary lies alone in a directory
# nobody can read, as a function's executable must (a job gets a copy of that directory).
# nobody can read, as a function's executable must (a job gets a copy of that directory).
@@ -18,6 +18,7 @@ openvibe-node status # link, plugins, video, battery, stop latch
@@ -18,6 +18,7 @@ openvibe-node status # link, plugins, video, battery, stop latch
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node stop # kill switch: stop every motor now and keep it stopped
openvibe-node resume
openvibe-node resume
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
openvibe-node approvals # the computer plugin at level ask: what waits; approve <id> / deny <id>
+openvibe-node tray # the same in your notification area, with Approve/Deny notifications (Linux, Windows)
```
```
## What is in here
## What is in here
@@ -43,7 +44,7 @@ on a community profile runs the same check and warns when it fails. Install a co
@@ -43,7 +44,7 @@ on a community profile runs the same check and warns when it fails. Install a co
**Your computer for your agents:** `--computer observe|ask|trusted|full` adds the `computer` plugin (terminal, files,
**Your computer for your agents:** `--computer observe|ask|trusted|full` adds the `computer` plugin (terminal, files,
screen, mouse and keyboard, and a browser of its own for OpenVibe.Actor), running as your own account, at the level you set on the machine; `ask` waits for
screen, mouse and keyboard, and a browser of its own for OpenVibe.Actor), running as your own account, at the level you set on the machine; `ask` waits for
-`openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).
+your answer in the tray, a notification's Approve button or `openvibe-node approve <id>` ([docs/computer.md](docs/computer.md)).
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
@@ -60,6 +60,9 @@ Usage:
@@ -60,6 +60,9 @@ Usage:
openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-node approvals [--json] what the computer plugin waits to be approved (control level "ask")
openvibe-node approve <ID> let it run
openvibe-node approve <ID> let it run
openvibe-node deny <ID> refuse it
openvibe-node deny <ID> refuse it
+ openvibe-node tray [--autostart on|off]
+ the Node in your notification area (Linux, Windows): what waits with Approve and Deny, stop,
+ resume; --autostart starts it when you sign in
openvibe-node profile verify community.<name> [--server URL] [--key-id ID]
openvibe-node profile verify community.<name> [--server URL] [--key-id ID]
check a community robot profile against the signature Bot made when it was reviewed
check a community robot profile against the signature Bot made when it was reviewed
openvibe-node version
openvibe-node version
@@ -110,6 +113,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -110,6 +113,7 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
svcUser = fs.String("user", "", "run the service as this account (install)")
svcUser = fs.String("user", "", "run the service as this account (install)")
session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
session = fs.Bool("user-session", false, "Windows: start at sign-in in your own session instead of as a service (install, uninstall)")
keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")
keyID = fs.String("key-id", "", "accept only this Bot signing key id (profile verify)")
+ autoArg = fs.String("autostart", "", "on or off: start the tray when you sign in (tray)")
)
)
// Accept flags anywhere: split positionals out.
// Accept flags anywhere: split positionals out.
var positional []string
var positional []string
@@ -189,6 +193,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
@@ -189,6 +193,8 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 2
return 2
}
}
err = cmdAnswer(g, cmd, positional[0], stdout)
err = cmdAnswer(g, cmd, positional[0], stdout)
+ case "tray":
+ err = cmdTray(g, *autoArg, stdout)
case "profile":
case "profile":
if len(positional) != 2 || positional[0] != "verify" {
if len(positional) != 2 || positional[0] != "verify" {
fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")
fmt.Fprintln(stderr, "usage: openvibe-node profile verify community.<name> [--server URL] [--key-id ID]")
new file mode 100644
new file mode 100644
@@ -0,0 +1,460 @@
@@ -0,0 +1,460 @@
+package main
+
+// `openvibe-node tray`: the Node in its owner's notification area. It runs in the graphical session (not in the
+// service), talks to the running Node over the local control socket like the other commands, and shows:
+//
+// - the state in the icon: blue when all is well, amber while a computer request waits for an answer, red while a
+// stop is latched, grey when the Node does not answer;
+// - what waits for approval (the computer plugin at level "ask"), each with Approve and Deny;
+// - Stop everything (the local kill switch), Resume, and the machines page on openvibe.bot.
+//
+// A new request also raises a desktop notification with Approve and Deny buttons where the desktop offers them
+// (Linux, org.freedesktop.Notifications). While the tray runs, the computer plugin's own prompts stay quiet. The core
+// refuses an Approve while the agent drives the mouse or keyboard and just after (node.InputGuard), so an approved
+// click cannot press Approve here; the tray then asks again.
+
+import (
+ "bytes"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+ "io"
+ "math"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/node"
+)
+
+// trayPoll is how often the tray asks the Node what changed.
+const trayPoll = 1500 * time.Millisecond
+
+// traySlots is how many waiting requests the menu lists (the plugin lets 8 wait at once).
+const traySlots = 8
+
+// Tray states, which pick the icon.
+const (
+ trayOK = "ok"
+ trayWaiting = "waiting"
+ trayStopped = "stopped"
+ trayDown = "down"
+)
+
+// trayView is what the tray shows for one poll.
+type trayView struct {
+ State string
+ Tooltip string
+ Header string // the first, disabled menu line
+ Level string // the computer plugin's level, "" without it
+ Stopped bool
+ Waiting []node.Approval
+ Panel string // the machines page on the Bot this Node is paired with, "" if unknown
+}
+
+// viewOf builds the view from the Node's status and approvals; err is the socket error (the Node is not running).
+func viewOf(st *node.Status, list []node.Approval, err error) trayView {
+ if err != nil || st == nil {
+ return trayView{State: trayDown, Tooltip: "OpenVibe Node: not running", Header: "The Node is not running"}
+ }
+ v := trayView{Waiting: list, Panel: panelURL(st.Server)}
+ if lv, ok := st.Telemetry.Sensors["computer_level"].(string); ok {
+ v.Level = lv
+ }
+ v.Stopped = st.Latch.Local || st.Latch.Remote
+ link := "offline"
+ if st.Link.Connected {
+ link = "online"
+ }
+ switch {
+ case v.Stopped:
+ v.State = trayStopped
+ v.Header = "Stopped: observe only"
+ case len(list) > 0:
+ v.State = trayWaiting
+ v.Header = fmt.Sprintf("%d %s your answer", len(list), plural(len(list), "request waits for", "requests wait for"))
+ default:
+ v.State = trayOK
+ v.Header = "OpenVibe Node: " + link
+ }
+ tip := []string{"OpenVibe Node: " + link}
+ if v.Level != "" {
+ tip = append(tip, "computer level "+v.Level)
+ }
+ if v.Stopped {
+ tip = append(tip, "stopped")
+ }
+ if len(list) > 0 {
+ tip = append(tip, fmt.Sprintf("%d waiting", len(list)))
+ }
+ v.Tooltip = strings.Join(tip, ", ")
+ return v
+}
+
+func plural(n int, one, many string) string {
+ if n == 1 {
+ return one
+ }
+ return many
+}
+
+// panelURL is the machines page of the Bot the Node is paired with: an https origin, or a loopback http one.
+func panelURL(server string) string {
+ u, err := url.Parse(strings.TrimSpace(server))
+ if err != nil || u.Host == "" {
+ return ""
+ }
+ host := u.Hostname()
+ if u.Scheme != "https" && !(u.Scheme == "http" && (host == "127.0.0.1" || host == "localhost" || host == "::1")) {
+ return ""
+ }
+ return u.Scheme + "://" + u.Host + "/machines"
+}
+
+// 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), " ")
+ if len([]rune(s)) > 70 {
+ s = string([]rune(s)[:69]) + "…"
+ }
+ if a.Kind != "" {
+ return a.Kind + ": " + s
+ }
+ return s
+}
+
+// trayUI is the notification-area side (systray on Linux and Windows; a fake in tests).
+type trayUI interface {
+ SetIcon(state string)
+ SetTooltip(string)
+ SetHeader(string)
+ SetLevel(string)
+ SetStopped(bool)
+ SetSlots([]string) // one label per waiting request, at most traySlots; the rest hidden
+ SetPanel(bool)
+}
+
+// notifier raises and takes down desktop notifications (D-Bus on Linux; nothing elsewhere).
+type notifier interface {
+ // Ask shows a request with Approve and Deny; again marks a second asking after a refused answer.
+ Ask(a node.Approval, again string)
+ Close(id string)
+ Info(title, body string)
+ // Answers carries {id, approved} when a notification's button is pressed; nil if there are none.
+ Answers() <-chan trayAnswer
+ Shut()
+}
+
+type trayAnswer struct {
+ ID string
+ Approved bool
+}
+
+type nopNotifier struct{}
+
+func (nopNotifier) Ask(node.Approval, string) {}
+func (nopNotifier) Close(string) {}
+func (nopNotifier) Info(string, string) {}
+func (nopNotifier) Answers() <-chan trayAnswer { return nil }
+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
+}
+
+func newTrayCore(sock string, ui trayUI, note notifier) *trayCore {
+ if note == nil {
+ note = nopNotifier{}
+ }
+ return &trayCore{sock: sock, ui: ui, note: note, shown: map[string]bool{}}
+}
+
+func (t *trayCore) call(req localctl.Request) (localctl.Response, error) {
+ return localctl.Call(t.sock, req, 2*time.Second)
+}
+
+// tick asks the Node for its status and approvals, says the tray is there, and updates everything.
+func (t *trayCore) tick() trayView {
+ var st *node.Status
+ var list []node.Approval
+ resp, err := t.call(localctl.Request{Cmd: localctl.CmdStatus})
+ if err == nil {
+ var s node.Status
+ if err = json.Unmarshal(resp.Data, &s); err == nil {
+ st = &s
+ }
+ }
+ if err == nil {
+ if resp, aerr := t.call(localctl.Request{Cmd: localctl.CmdApprovals}); aerr == nil {
+ _ = json.Unmarshal(resp.Data, &list)
+ }
+ _, _ = t.call(localctl.Request{Cmd: localctl.CmdTray, On: true})
+ }
+ v := viewOf(st, list, err)
+ t.mu.Lock()
+ t.view = v
+ fresh, gone := []node.Approval{}, []string{}
+ now := map[string]bool{}
+ for _, a := range list {
+ now[a.ID] = true
+ if !t.shown[a.ID] {
+ fresh = append(fresh, a)
+ t.shown[a.ID] = true
+ }
+ }
+ for id := range t.shown {
+ if !now[id] {
+ gone = append(gone, id)
+ delete(t.shown, id)
+ }
+ }
+ t.mu.Unlock()
+ for _, id := range gone {
+ t.note.Close(id)
+ }
+ for _, a := range fresh {
+ t.note.Ask(a, "")
+ }
+ labels := make([]string, 0, traySlots)
+ for i, a := range v.Waiting {
+ if i == traySlots {
+ break
+ }
+ labels = append(labels, slotLabel(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.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
+ if approved {
+ cmd = localctl.CmdApprove
+ }
+ _, err := t.call(localctl.Request{Cmd: cmd, ID: a.ID})
+ if err != nil {
+ if approved && strings.Contains(err.Error(), "mouse or keyboard") {
+ t.note.Ask(a, "Not taken: the agent was using the mouse or keyboard. Answer again if it was you.")
+ } else {
+ t.note.Info("OpenVibe Node", err.Error())
+ }
+ return err
+ }
+ t.mu.Lock()
+ delete(t.shown, a.ID)
+ t.mu.Unlock()
+ t.note.Close(a.ID)
+ return nil
+}
+
+// answerByID is a notification's button: the request must still wait.
+func (t *trayCore) answerByID(id string, approved bool) error {
+ t.mu.Lock()
+ var found *node.Approval
+ for i := range t.view.Waiting {
+ if t.view.Waiting[i].ID == id {
+ a := t.view.Waiting[i]
+ found = &a
+ }
+ }
+ t.mu.Unlock()
+ if found == nil {
+ return errors.New("it no longer waits")
+ }
+ return t.answer(*found, approved)
+}
+
+func (t *trayCore) stop() error {
+ _, err := t.call(localctl.Request{Cmd: localctl.CmdStop})
+ return err
+}
+
+func (t *trayCore) resume() error {
+ _, err := t.call(localctl.Request{Cmd: localctl.CmdResume})
+ return err
+}
+
+// quit tells the Node the tray is gone (the plugin prompts again) and takes the notifications down.
+func (t *trayCore) quit() {
+ _, _ = t.call(localctl.Request{Cmd: localctl.CmdTray, On: false})
+ t.mu.Lock()
+ ids := make([]string, 0, len(t.shown))
+ for id := range t.shown {
+ ids = append(ids, id)
+ }
+ t.shown = map[string]bool{}
+ t.mu.Unlock()
+ for _, id := range ids {
+ t.note.Close(id)
+ }
+ t.note.Shut()
+}
+
+func (t *trayCore) panel() string {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ return t.view.Panel
+}
+
+// cmdTray runs the tray, or with --autostart on|off adds it to (or removes it from) the session's start.
+func cmdTray(g *globals, autostart string, stdout io.Writer) error {
+ switch autostart {
+ case "":
+ return runTray(g)
+ case "on", "off":
+ exe, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ return trayAutostart(autostart == "on", exe, g.home, stdout)
+ }
+ return fmt.Errorf("--autostart is on or off")
+}
+
+// trayDesktopEntry is the XDG autostart entry that starts the tray when its owner signs in (Linux).
+func trayDesktopEntry(exe, home string) string {
+ line := shellQuote(exe) + " tray"
+ if home != "" {
+ line += " --home " + shellQuote(home)
+ }
+ return "[Desktop Entry]\nType=Application\nName=OpenVibe Node\nComment=Approve what your agents ask to do on this computer\n" +
+ "Exec=" + line + "\nIcon=dialog-question\nTerminal=false\nX-GNOME-Autostart-enabled=true\nNoDisplay=false\n"
+}
+
+// shellQuote quotes one Exec argument the way the desktop entry spec reads them.
+func shellQuote(s string) string {
+ if s != "" && !strings.ContainsAny(s, " \t\n\"'\\$`") {
+ return s
+ }
+ r := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", `$`, `\$`)
+ return `"` + r.Replace(s) + `"`
+}
+
+// trayAutostartPath is where the Linux autostart entry goes: $XDG_CONFIG_HOME/autostart, else ~/.config/autostart.
+func trayAutostartPath() (string, error) {
+ dir := os.Getenv("XDG_CONFIG_HOME")
+ if dir == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", err
+ }
+ dir = filepath.Join(home, ".config")
+ }
+ return filepath.Join(dir, "autostart", "openvibe-node-tray.desktop"), nil
+}
+
+// ---- icons ---------------------------------------------------------------------------------------------------------
+
+var trayColors = map[string]color.NRGBA{
+ trayOK: {0x3b, 0x82, 0xf6, 0xff}, // the OpenVibe blue
+ trayWaiting: {0xf5, 0x9e, 0x0b, 0xff},
+ trayStopped: {0xdc, 0x26, 0x26, 0xff},
+ trayDown: {0x9c, 0xa3, 0xaf, 0xff},
+}
+
+// trayImage draws the tray icon: a filled disc in the state's colour with a white ring-and-dot (the Node's mark);
+// while something waits the dot becomes a bar, an exclamation.
+func trayImage(state string, size int) *image.NRGBA {
+ img := image.NewNRGBA(image.Rect(0, 0, size, size))
+ c, ok := trayColors[state]
+ if !ok {
+ c = trayColors[trayDown]
+ }
+ white := color.NRGBA{0xff, 0xff, 0xff, 0xff}
+ r := float64(size) / 2
+ for y := 0; y < size; y++ {
+ for x := 0; x < size; x++ {
+ dx, dy := float64(x)+0.5-r, float64(y)+0.5-r
+ d := math.Hypot(dx, dy)
+ a := clamp01(r - d) // anti-aliased edge of the disc
+ if a <= 0 {
+ continue
+ }
+ px := c
+ ring := math.Abs(d - r*0.55)
+ if state == trayWaiting {
+ // an exclamation mark: a bar and a dot
+ if (math.Abs(dx) < r*0.13 && dy > -r*0.55 && dy < r*0.15) || math.Hypot(dx, dy-r*0.42) < r*0.14 {
+ px = white
+ }
+ } else if ring < r*0.1 || d < r*0.18 {
+ px = white
+ }
+ px.A = uint8(float64(px.A) * a)
+ img.SetNRGBA(x, y, px)
+ }
+ }
+ return img
+}
+
+func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
+
+// trayPNG is the icon for the StatusNotifierItem (Linux), which takes PNG bytes.
+func trayPNG(state string) []byte {
+ var b bytes.Buffer
+ _ = png.Encode(&b, trayImage(state, 64))
+ return b.Bytes()
+}
+
+// trayICO is the icon for Windows: a 32×32 32-bit ICO (BMP image with its AND mask), which LoadImage reads.
+func trayICO(state string) []byte {
+ const size = 32
+ img := trayImage(state, size)
+ var dib bytes.Buffer
+ hdr := struct {
+ Size uint32
+ Width, Height int32
+ Planes, BitCount uint16
+ Compression, SizeImage uint32
+ XPels, YPels, Used, Imported int32
+ }{40, size, size * 2, 1, 32, 0, size * size * 4, 0, 0, 0, 0}
+ _ = binary.Write(&dib, binary.LittleEndian, hdr)
+ for y := size - 1; y >= 0; y-- { // bottom-up rows, BGRA
+ for x := 0; x < size; x++ {
+ p := img.NRGBAAt(x, y)
+ dib.Write([]byte{p.B, p.G, p.R, p.A})
+ }
+ }
+ dib.Write(make([]byte, size*4)) // the AND mask: 32 bits a row, all zero (alpha decides)
+ var out bytes.Buffer
+ _ = binary.Write(&out, binary.LittleEndian, [3]uint16{0, 1, 1}) // ICONDIR: reserved, type icon, one image
+ entry := struct {
+ W, H, Colors, Reserved uint8
+ Planes, BitCount uint16
+ Bytes, Offset uint32
+ }{size, size, 0, 0, 1, 32, uint32(dib.Len()), 6 + 16}
+ _ = binary.Write(&out, binary.LittleEndian, entry)
+ out.Write(dib.Bytes())
+ return out.Bytes()
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+//go:build linux
+
+package main
+
+import (
+ "log/slog"
+ "strings"
+ "sync"
+
+ "github.com/godbus/dbus/v5"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/node"
+)
+
+// Desktop notifications over org.freedesktop.Notifications (every Linux desktop): a waiting request with Approve and
+// Deny buttons when the server has "actions", else the command to answer with.
+
+const (
+ notifyName = "org.freedesktop.Notifications"
+ notifyPath = dbus.ObjectPath("/org/freedesktop/Notifications")
+ notifyIface = "org.freedesktop.Notifications"
+)
+
+type dbusNotifier struct {
+ conn *dbus.Conn
+ obj dbus.BusObject
+ actions bool
+ markup bool
+ log *slog.Logger
+ mu sync.Mutex
+ byID map[string]uint32 // approval id → notification id
+ byNote map[uint32]string
+ owner string // the notification server's unique bus name: only its signals count
+ answers chan trayAnswer
+}
+
+func newDesktopNotifier(log *slog.Logger) notifier {
+ conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ log.Warn("tray: no desktop notifications (no session bus)", "err", err)
+ return nopNotifier{}
+ }
+ n := &dbusNotifier{conn: conn, obj: conn.Object(notifyName, notifyPath), log: log,
+ byID: map[string]uint32{}, byNote: map[uint32]string{}, answers: make(chan trayAnswer, 8)}
+ var caps []string
+ if err := n.obj.Call(notifyIface+".GetCapabilities", 0).Store(&caps); err == nil {
+ for _, c := range caps {
+ n.actions = n.actions || c == "actions"
+ n.markup = n.markup || c == "body-markup"
+ }
+ }
+ _ = conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, notifyName).Store(&n.owner)
+ var host bool
+ if conn.BusObject().Call("org.freedesktop.DBus.NameHasOwner", 0, "org.kde.StatusNotifierWatcher").Store(&host) == nil && !host {
+ log.Warn("tray: this desktop shows no tray icons (on GNOME, the AppIndicator extension adds them); the notifications still ask")
+ }
+ if err := conn.AddMatchSignal(dbus.WithMatchInterface(notifyIface), dbus.WithMatchObjectPath(notifyPath)); err != nil {
+ log.Warn("tray: notification buttons will not answer", "err", err)
+ }
+ ch := make(chan *dbus.Signal, 16)
+ conn.Signal(ch)
+ go n.listen(ch)
+ return n
+}
+
+func (n *dbusNotifier) listen(ch <-chan *dbus.Signal) {
+ for sig := range ch {
+ if sig == nil || sig.Path != notifyPath {
+ continue
+ }
+ if n.owner != "" && sig.Sender != n.owner {
+ // The server may have restarted: ask who owns the name now before trusting it.
+ var owner string
+ if err := n.conn.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, notifyName).Store(&owner); err != nil || owner != sig.Sender {
+ continue
+ }
+ n.owner = owner
+ }
+ switch sig.Name {
+ case notifyIface + ".ActionInvoked":
+ if len(sig.Body) != 2 {
+ continue
+ }
+ nid, _ := sig.Body[0].(uint32)
+ key, _ := sig.Body[1].(string)
+ n.mu.Lock()
+ id := n.byNote[nid]
+ n.mu.Unlock()
+ if id == "" || (key != "approve" && key != "deny") {
+ continue
+ }
+ select {
+ case n.answers <- trayAnswer{ID: id, Approved: key == "approve"}:
+ default:
+ }
+ case notifyIface + ".NotificationClosed":
+ if len(sig.Body) < 1 {
+ continue
+ }
+ nid, _ := sig.Body[0].(uint32)
+ n.mu.Lock()
+ if id, ok := n.byNote[nid]; ok {
+ delete(n.byNote, nid)
+ if n.byID[id] == nid {
+ delete(n.byID, id)
+ }
+ }
+ n.mu.Unlock()
+ }
+ }
+}
+
+func (n *dbusNotifier) text(s string) string {
+ if !n.markup {
+ return s
+ }
+ return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s)
+}
+
+func (n *dbusNotifier) Ask(a node.Approval, again string) {
+ body := a.Summary
+ if again != "" {
+ body = again + "\n" + body
+ }
+ var actions []string
+ if n.actions {
+ actions = []string{"approve", "Approve", "deny", "Deny"}
+ } else {
+ body += "\nopenvibe-node approve " + a.ID + " (or deny " + a.ID + ")"
+ }
+ n.mu.Lock()
+ replaces := n.byID[a.ID]
+ n.mu.Unlock()
+ expire := int32(-1)
+ if a.ExpiresS > 0 {
+ expire = int32(a.ExpiresS * 1000)
+ }
+ hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(2)), "desktop-entry": dbus.MakeVariant("openvibe-node-tray")}
+ var nid uint32
+ err := n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Node", replaces, "dialog-question", "OpenVibe: your agent asks",
+ n.text(body), actions, hints, expire).Store(&nid)
+ if err != nil {
+ n.log.Warn("tray: notification failed", "err", err)
+ return
+ }
+ n.mu.Lock()
+ n.byID[a.ID] = nid
+ n.byNote[nid] = a.ID
+ n.mu.Unlock()
+}
+
+func (n *dbusNotifier) Close(id string) {
+ n.mu.Lock()
+ nid, ok := n.byID[id]
+ delete(n.byID, id)
+ if ok {
+ delete(n.byNote, nid)
+ }
+ n.mu.Unlock()
+ if ok {
+ n.obj.Call(notifyIface+".CloseNotification", 0, nid)
+ }
+}
+
+func (n *dbusNotifier) Info(title, body string) {
+ hints := map[string]dbus.Variant{"urgency": dbus.MakeVariant(byte(1))}
+ n.obj.Call(notifyIface+".Notify", 0, "OpenVibe Node", uint32(0), "dialog-information", title, n.text(body), []string{}, hints, int32(6000))
+}
+
+func (n *dbusNotifier) Answers() <-chan trayAnswer { return n.answers }
+
+func (n *dbusNotifier) Shut() { _ = n.conn.Close() }
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+//go:build linux
+
+package main
+
+import (
+ "io"
+ "log/slog"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/node"
+)
+
+// fakeNotifications is an org.freedesktop.Notifications server on a private session bus.
+type fakeNotifications struct {
+ conn *dbus.Conn
+ notify chan []any
+ closed chan uint32
+ next uint32
+}
+
+func (f *fakeNotifications) GetCapabilities() ([]string, *dbus.Error) {
+ return []string{"actions", "body", "body-markup"}, nil
+}
+
+func (f *fakeNotifications) Notify(app string, replaces uint32, icon, summary, body string, actions []string, hints map[string]dbus.Variant, expire int32) (uint32, *dbus.Error) {
+ f.next++
+ id := f.next
+ if replaces != 0 {
+ id = replaces
+ }
+ f.notify <- []any{id, summary, body, actions, expire}
+ return id, nil
+}
+
+func (f *fakeNotifications) CloseNotification(id uint32) *dbus.Error {
+ f.closed <- id
+ return nil
+}
+
+// Run under a private bus: dbus-run-session -- go test ./cmd/openvibe-node -run DesktopNotifier
+func TestDesktopNotifierAsksAndHearsTheButtons(t *testing.T) {
+ if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
+ t.Skip("no session bus (run it under dbus-run-session)")
+ }
+ conn, err := dbus.ConnectSessionBus()
+ if err != nil {
+ t.Skip("session bus:", err)
+ }
+ defer conn.Close()
+ reply, err := conn.RequestName(notifyName, dbus.NameFlagDoNotQueue)
+ if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
+ t.Skip("a real notification server owns the name: run it under dbus-run-session")
+ }
+ f := &fakeNotifications{conn: conn, notify: make(chan []any, 4), closed: make(chan uint32, 4)}
+ if err := conn.Export(f, notifyPath, notifyIface); err != nil {
+ t.Fatal(err)
+ }
+ n := newDesktopNotifier(slog.New(slog.NewTextHandler(io.Discard, nil)))
+ defer n.Shut()
+ n.Ask(node.Approval{ID: "c1", Kind: "exec", Summary: "run <b>rm</b> & co", ExpiresS: 120}, "")
+ var got []any
+ select {
+ case got = <-f.notify:
+ case <-time.After(5 * time.Second):
+ t.Fatal("no Notify call")
+ }
+ if got[1] != "OpenVibe: your agent asks" || got[2] != "run <b>rm</b> & co" || got[4] != int32(120000) {
+ t.Fatalf("notify: %v", got)
+ }
+ if a := got[3].([]string); len(a) != 4 || a[0] != "approve" || a[2] != "deny" {
+ t.Fatalf("actions: %v", a)
+ }
+ nid := got[0].(uint32)
+ // Somebody else on the bus faking the button: ignored. The server's own signal: an answer.
+ other, err := dbus.ConnectSessionBus(dbus.WithSignalHandler(dbus.NewSequentialSignalHandler()))
+ if err == nil {
+ _ = other.Emit(notifyPath, notifyIface+".ActionInvoked", nid, "approve")
+ }
+ select {
+ case a := <-n.Answers():
+ t.Fatalf("an impostor's signal answered: %+v", a)
+ case <-time.After(300 * time.Millisecond):
+ }
+ if err := conn.Emit(notifyPath, notifyIface+".ActionInvoked", nid, "approve"); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case a := <-n.Answers():
+ if a.ID != "c1" || !a.Approved {
+ t.Fatalf("answer: %+v", a)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("the Approve button was not heard")
+ }
+ n.Ask(node.Approval{ID: "c1", Summary: "again"}, "Not taken")
+ if again := <-f.notify; again[0] != nid {
+ t.Fatalf("asking again replaces the same notification: %v", again)
+ }
+ n.Close("c1")
+ select {
+ case id := <-f.closed:
+ if id != nid {
+ t.Fatalf("closed %d, want %d", id, nid)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("not closed")
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+//go:build !linux
+
+package main
+
+import "log/slog"
+
+// Desktop notifications with buttons are Linux's (org.freedesktop.Notifications); on Windows the icon turns amber and
+// the menu lists what waits.
+func newDesktopNotifier(*slog.Logger) notifier { return nopNotifier{} }
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+//go:build !linux && !windows
+
+package main
+
+import (
+ "errors"
+ "io"
+)
+
+// The tray library needs cgo on macOS and the release builds without it: there the computer plugin asks with a
+// dialog for each request instead (docs/computer.md).
+var errTrayPlatform = errors.New("the tray is for Linux and Windows; on macOS the computer plugin asks with a dialog for each request (Approve or Deny)")
+
+func runTray(*globals) error { return errTrayPlatform }
+func trayAutostart(bool, string, string, io.Writer) error { return errTrayPlatform }
new file mode 100644
new file mode 100644
@@ -0,0 +1,283 @@
@@ -0,0 +1,283 @@
+//go:build linux || windows
+
+package main
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "os/signal"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "fyne.io/systray"
+)
+
+// The notification area: a StatusNotifierItem over D-Bus on Linux (KDE, XFCE, Cinnamon, MATE, GNOME with the
+// AppIndicator extension) and the notification icon on Windows; both without cgo.
+
+type systrayUI struct {
+ mu sync.Mutex
+ header, level *systray.MenuItem
+ stop, resume, panel *systray.MenuItem
+ quit *systray.MenuItem
+ slots [traySlots]*systray.MenuItem
+ approve, deny [traySlots]*systray.MenuItem
+ state, tooltip, headTx string
+ levelTx string
+ labels []string
+ stopped, hasPanel *bool
+}
+
+func (u *systrayUI) build() {
+ u.header = systray.AddMenuItem("OpenVibe Node", "")
+ u.header.Disable()
+ u.level = systray.AddMenuItem("", "")
+ 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()
+ }
+ 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")
+ u.panel = systray.AddMenuItem("Open my machines page", "This machine on openvibe.bot")
+ u.panel.Hide()
+ systray.AddSeparator()
+ u.quit = systray.AddMenuItem("Quit the tray", "The Node keeps running; requests wait for `openvibe-node approve`")
+}
+
+func (u *systrayUI) SetIcon(state string) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if state == u.state {
+ return
+ }
+ u.state = state
+ if runtime.GOOS == "windows" {
+ systray.SetIcon(trayICO(state))
+ } else {
+ systray.SetIcon(trayPNG(state))
+ }
+}
+
+func (u *systrayUI) SetTooltip(s string) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if s != u.tooltip {
+ u.tooltip = s
+ systray.SetTooltip(s)
+ }
+}
+
+func (u *systrayUI) SetHeader(s string) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if s != u.headTx {
+ u.headTx = s
+ u.header.SetTitle(s)
+ }
+}
+
+func (u *systrayUI) SetLevel(lv string) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if lv == u.levelTx {
+ return
+ }
+ u.levelTx = lv
+ if lv == "" {
+ u.level.Hide()
+ return
+ }
+ u.level.SetTitle("Computer level: " + lv + " (set on this machine)")
+ u.level.Show()
+}
+
+func (u *systrayUI) SetStopped(stopped bool) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if u.stopped != nil && *u.stopped == stopped {
+ return
+ }
+ u.stopped = &stopped
+ if stopped {
+ u.stop.Disable()
+ u.resume.Enable()
+ } else {
+ u.stop.Enable()
+ u.resume.Disable()
+ }
+}
+
+func (u *systrayUI) SetSlots(labels []string) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if strings.Join(labels, "\x00") == strings.Join(u.labels, "\x00") && u.labels != nil {
+ 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()
+ }
+ }
+}
+
+func (u *systrayUI) SetPanel(has bool) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ if u.hasPanel != nil && *u.hasPanel == has {
+ return
+ }
+ u.hasPanel = &has
+ if has {
+ u.panel.Show()
+ } else {
+ u.panel.Hide()
+ }
+}
+
+func runTray(g *globals) error {
+ if runtime.GOOS == "linux" && os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" && os.Getenv("XDG_RUNTIME_DIR") == "" {
+ return fmt.Errorf("the tray runs in your desktop session: start it there (openvibe-node tray --autostart on starts it when you sign in)")
+ }
+ ui := &systrayUI{}
+ core := newTrayCore(g.paths.SocketPath(), ui, newDesktopNotifier(g.log))
+ stopSig := make(chan os.Signal, 1)
+ signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM)
+ systray.Run(func() {
+ systray.SetTitle("OpenVibe Node")
+ ui.build()
+ core.tick()
+ go trayLoop(core, ui, stopSig, g)
+ }, core.quit)
+ return nil
+}
+
+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()
+ for {
+ select {
+ case <-t.C:
+ 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)
+ }
+ core.tick()
+ case <-ui.stop.ClickedCh:
+ if err := core.stop(); err != nil {
+ core.note.Info("OpenVibe Node", "Could not stop: "+err.Error())
+ }
+ core.tick()
+ case <-ui.resume.ClickedCh:
+ if err := core.resume(); err != nil {
+ core.note.Info("OpenVibe Node", "Could not resume: "+err.Error())
+ }
+ core.tick()
+ case <-ui.panel.ClickedCh:
+ if u := core.panel(); u != "" {
+ openURL(u)
+ }
+ case <-ui.quit.ClickedCh:
+ systray.Quit()
+ return
+ case <-stopSig:
+ systray.Quit()
+ return
+ }
+ }
+}
+
+// openURL opens the machines page in the default browser.
+func openURL(u string) {
+ var c *exec.Cmd
+ if runtime.GOOS == "windows" {
+ c = exec.Command("rundll32", "url.dll,FileProtocolHandler", u)
+ } else {
+ c = exec.Command("xdg-open", u)
+ }
+ _ = c.Start()
+}
+
+// trayAutostart starts the tray when its owner signs in: an XDG autostart entry on Linux, a Run value on Windows.
+func trayAutostart(on bool, exe, home string, stdout io.Writer) error {
+ if runtime.GOOS == "windows" {
+ line := quoteArg(exe) + " tray"
+ if home != "" {
+ line += " --home " + quoteArg(home)
+ }
+ var out []byte
+ var err error
+ if on {
+ out, err = exec.Command("reg", "add", runKey, "/v", "OpenVibe Node tray", "/t", "REG_SZ", "/d", line, "/f").CombinedOutput()
+ } else {
+ out, err = exec.Command("reg", "delete", runKey, "/v", "OpenVibe Node tray", "/f").CombinedOutput()
+ if err != nil && strings.Contains(strings.ToLower(string(out)), "unable to find") {
+ err = nil
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("reg: %v: %s", err, strings.TrimSpace(string(out)))
+ }
+ if on {
+ fmt.Fprintln(stdout, "The tray now starts when you sign in:", line)
+ } else {
+ fmt.Fprintln(stdout, "The tray no longer starts when you sign in.")
+ }
+ return nil
+ }
+ path, err := trayAutostartPath()
+ if err != nil {
+ return err
+ }
+ if !on {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ fmt.Fprintln(stdout, "The tray no longer starts when you sign in (removed", path+")")
+ return nil
+ }
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ if err := os.WriteFile(path, []byte(trayDesktopEntry(exe, home)), 0o644); err != nil {
+ return err
+ }
+ fmt.Fprintln(stdout, "The tray now starts when you sign in:", path)
+ return nil
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,293 @@
@@ -0,0 +1,293 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "encoding/json"
+ "errors"
+ "image/png"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/OpenVibers/OpenVibe.Node/internal/localctl"
+ "github.com/OpenVibers/OpenVibe.Node/internal/node"
+ "github.com/OpenVibers/OpenVibe.Node/internal/safety"
+)
+
+func TestTrayViewStates(t *testing.T) {
+ if v := viewOf(nil, nil, errors.New("dial unix: no such file")); v.State != trayDown || !strings.Contains(v.Tooltip, "not running") {
+ t.Fatalf("no Node: %+v", v)
+ }
+ st := &node.Status{Server: "https://openvibe.bot", Link: node.LinkStatus{Connected: true}}
+ st.Telemetry.Sensors = map[string]any{"computer_level": "ask"}
+ v := viewOf(st, nil, nil)
+ if v.State != trayOK || v.Level != "ask" || v.Panel != "https://openvibe.bot/machines" || v.Tooltip != "OpenVibe Node: online, computer level ask" {
+ t.Fatalf("all well: %+v", v)
+ }
+ v = viewOf(st, []node.Approval{{ID: "a"}, {ID: "b"}}, nil)
+ if v.State != trayWaiting || v.Header != "2 requests wait for your answer" || !strings.HasSuffix(v.Tooltip, "2 waiting") {
+ t.Fatalf("waiting: %+v", v)
+ }
+ if v = viewOf(st, []node.Approval{{ID: "a"}}, nil); v.Header != "1 request waits for your answer" {
+ t.Fatalf("one waiting: %q", v.Header)
+ }
+ st.Latch = safety.LatchState{Local: true}
+ if v = viewOf(st, []node.Approval{{ID: "a"}}, nil); v.State != trayStopped || !v.Stopped {
+ t.Fatalf("a latched stop wins over waiting: %+v", v)
+ }
+}
+
+func TestTrayPanelURLOnlyForTheBot(t *testing.T) {
+ for in, want := range map[string]string{
+ "https://openvibe.bot": "https://openvibe.bot/machines",
+ "https://openvibe.bot/api/x": "https://openvibe.bot/machines",
+ "http://127.0.0.1:4100": "http://127.0.0.1:4100/machines",
+ "http://example.com": "",
+ "javascript:alert(1)": "",
+ "": "",
+ "file:///etc/passwd": "",
+ "https://[email protected]:44": "https://openvibe.bot:44/machines", // no user info
+ } {
+ if got := panelURL(in); got != want {
+ t.Errorf("panelURL(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestTraySlotLabel(t *testing.T) {
+ if got := slotLabel(node.Approval{Kind: "exec", Summary: "run echo\nhi"}); got != "exec: run echo hi" {
+ t.Fatalf("label: %q", got)
+ }
+ long := slotLabel(node.Approval{Kind: "input", Summary: strings.Repeat("é", 100)})
+ if !strings.HasSuffix(long, "…") || len([]rune(long)) != len("input: ")+70 {
+ t.Fatalf("long label: %q (%d runes)", long, len([]rune(long)))
+ }
+}
+
+func TestTrayIcons(t *testing.T) {
+ for _, state := range []string{trayOK, trayWaiting, trayStopped, trayDown} {
+ img, err := png.Decode(bytes.NewReader(trayPNG(state)))
+ if err != nil || img.Bounds().Dx() != 64 {
+ t.Fatalf("%s png: %v %v", state, err, img.Bounds())
+ }
+ _, _, _, a := img.At(0, 0).RGBA()
+ if a != 0 {
+ t.Fatalf("%s: the corner is transparent", state)
+ }
+ ico := trayICO(state)
+ if !bytes.Equal(ico[:6], []byte{0, 0, 1, 0, 1, 0}) || ico[6] != 32 || ico[7] != 32 {
+ t.Fatalf("%s ico header: % x", state, ico[:8])
+ }
+ if size := binary.LittleEndian.Uint32(ico[14:18]); int(size) != len(ico)-22 || size != 40+32*32*4+32*4 {
+ t.Fatalf("%s ico image size %d of %d", state, size, len(ico))
+ }
+ if binary.LittleEndian.Uint32(ico[22:26]) != 40 {
+ t.Fatalf("%s: BITMAPINFOHEADER", state)
+ }
+ }
+ ok, _ := png.Decode(bytes.NewReader(trayPNG(trayOK)))
+ waiting, _ := png.Decode(bytes.NewReader(trayPNG(trayWaiting)))
+ r1, g1, b1, _ := ok.At(10, 32).RGBA()
+ r2, g2, b2, _ := waiting.At(10, 32).RGBA()
+ if r1 == r2 && g1 == g2 && b1 == b2 {
+ t.Fatal("waiting and all-well icons look the same")
+ }
+}
+
+func TestTrayDesktopEntry(t *testing.T) {
+ e := trayDesktopEntry("/opt/Open Vibe/openvibe-node", "/home/a/node home")
+ if !strings.Contains(e, `Exec="/opt/Open Vibe/openvibe-node" tray --home "/home/a/node home"`+"\n") || !strings.HasPrefix(e, "[Desktop Entry]\n") {
+ t.Fatalf("entry:\n%s", e)
+ }
+ if e := trayDesktopEntry("/usr/local/bin/openvibe-node", ""); !strings.Contains(e, "Exec=/usr/local/bin/openvibe-node tray\n") {
+ t.Fatalf("entry:\n%s", e)
+ }
+ dir := t.TempDir()
+ t.Setenv("XDG_CONFIG_HOME", dir)
+ if p, err := trayAutostartPath(); err != nil || p != filepath.Join(dir, "autostart", "openvibe-node-tray.desktop") {
+ t.Fatalf("path %q %v", p, err)
+ }
+}
+
+// fakeNode answers the control socket like a running Node with the computer plugin at level ask.
+type fakeNode struct {
+ mu sync.Mutex
+ list []node.Approval
+ refuse int // approvals to refuse as "the agent is using the mouse or keyboard"
+ answered []string
+ tray []bool
+ stopped bool
+}
+
+func (f *fakeNode) handle(r localctl.Request) localctl.Response {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ switch r.Cmd {
+ case localctl.CmdStatus:
+ st := node.Status{Server: "https://openvibe.bot", Link: node.LinkStatus{Connected: true}, Latch: safety.LatchState{Local: f.stopped}}
+ st.Telemetry.Sensors = map[string]any{"computer_level": "ask"}
+ b, _ := json.Marshal(st)
+ return localctl.Response{OK: true, Data: b}
+ case localctl.CmdApprovals:
+ b, _ := json.Marshal(f.list)
+ return localctl.Response{OK: true, Data: b}
+ case localctl.CmdTray:
+ f.tray = append(f.tray, r.On)
+ return localctl.Response{OK: true}
+ case localctl.CmdApprove, localctl.CmdDeny:
+ if r.Cmd == localctl.CmdApprove && f.refuse > 0 {
+ f.refuse--
+ return localctl.Response{Error: "the agent is using the mouse or keyboard (or just did): answer again in a moment"}
+ }
+ for i, a := range f.list {
+ if a.ID == r.ID {
+ f.list = append(f.list[:i], f.list[i+1:]...)
+ f.answered = append(f.answered, r.Cmd+" "+r.ID)
+ return localctl.Response{OK: true}
+ }
+ }
+ return localctl.Response{Error: "nothing with the id waits"}
+ case localctl.CmdStop:
+ f.stopped = true
+ return localctl.Response{OK: true}
+ case localctl.CmdResume:
+ f.stopped = false
+ return localctl.Response{OK: true}
+ }
+ return localctl.Response{Error: "unknown"}
+}
+
+type fakeUI struct {
+ state, tooltip, header, level string
+ stopped, panel bool
+ slots []string
+}
+
+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) SetPanel(b bool) { u.panel = b }
+func (n *fakeNotes) Answers() <-chan trayAnswer { return nil }
+func (n *fakeNotes) Shut() { n.shut = true }
+
+type fakeNotes struct {
+ asked []string // id, or "id again" for a second asking
+ closed []string
+ infos []string
+ shut bool
+}
+
+func (n *fakeNotes) Ask(a node.Approval, again string) {
+ if again != "" {
+ n.asked = append(n.asked, a.ID+" again")
+ return
+ }
+ n.asked = append(n.asked, a.ID)
+}
+func (n *fakeNotes) Close(id string) { n.closed = append(n.closed, id) }
+func (n *fakeNotes) Info(_, body string) { n.infos = append(n.infos, body) }
+
+func TestTrayCoreAgainstANode(t *testing.T) {
+ dir, err := os.MkdirTemp("", "ovt") // short: unix socket paths are limited to ~100 bytes
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(dir)
+ sock := filepath.Join(dir, "n.sock")
+ f := &fakeNode{list: []node.Approval{{ID: "c1", Kind: "exec", Summary: "run echo hi", ExpiresS: 120}}, refuse: 1}
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ go func() { _ = localctl.Serve(ctx, sock, f.handle) }()
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if _, err := os.Stat(sock); err == nil {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ ui, notes := &fakeUI{}, &fakeNotes{}
+ core := newTrayCore(sock, ui, notes)
+
+ core.tick()
+ if ui.state != trayWaiting || ui.level != "ask" || !ui.panel || len(ui.slots) != 1 || ui.slots[0] != "exec: run echo hi" {
+ t.Fatalf("ui after the first poll: %+v", ui)
+ }
+ if strings.Join(notes.asked, ",") != "c1" || len(f.tray) != 1 || !f.tray[0] {
+ t.Fatalf("asked %v, tray %v", notes.asked, f.tray)
+ }
+ core.tick()
+ if len(notes.asked) != 1 {
+ 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)
+ }
+ if err := core.answer(a, true); err == nil {
+ t.Fatal("the Node refused the approval; the tray should say so")
+ }
+ if strings.Join(notes.asked, ",") != "c1,c1 again" {
+ t.Fatalf("a refused approval asks again: %v", notes.asked)
+ }
+ if err := core.answerByID("c1", true); err != nil {
+ t.Fatal(err)
+ }
+ if strings.Join(f.answered, ",") != "approve c1" || strings.Join(notes.closed, ",") != "c1" {
+ t.Fatalf("answered %v closed %v", f.answered, notes.closed)
+ }
+ core.tick()
+ if ui.state != trayOK || len(ui.slots) != 0 || len(notes.closed) != 1 {
+ t.Fatalf("after the answer: ui %+v closed %v", ui, notes.closed)
+ }
+
+ // A request answered elsewhere (the CLI, or it expired) takes its notification down at the next poll.
+ f.mu.Lock()
+ f.list = append(f.list, node.Approval{ID: "c2", Kind: "input", Summary: "click at 1, 2"})
+ f.mu.Unlock()
+ core.tick()
+ f.mu.Lock()
+ f.list = nil
+ f.mu.Unlock()
+ core.tick()
+ if strings.Join(notes.asked, ",") != "c1,c1 again,c2" || strings.Join(notes.closed, ",") != "c1,c2" {
+ t.Fatalf("asked %v closed %v", notes.asked, notes.closed)
+ }
+ if err := core.answerByID("c2", false); err == nil {
+ t.Fatal("answered a request that no longer waits")
+ }
+
+ if err := core.stop(); err != nil {
+ t.Fatal(err)
+ }
+ core.tick()
+ if ui.state != trayStopped || !ui.stopped {
+ t.Fatalf("stopped: %+v", ui)
+ }
+ if err := core.resume(); err != nil {
+ t.Fatal(err)
+ }
+ core.tick()
+ if ui.state != trayOK || ui.stopped {
+ t.Fatalf("resumed: %+v", ui)
+ }
+
+ core.quit()
+ if last := f.tray[len(f.tray)-1]; last || !notes.shut {
+ t.Fatalf("quit tells the Node and shuts the notifier: tray %v shut %v", f.tray, notes.shut)
+ }
+ cancel()
+ time.Sleep(50 * time.Millisecond)
+ core.tick()
+ if ui.state != trayDown {
+ t.Fatalf("the Node gone: %+v", ui)
+ }
+}
@@ -44,7 +44,30 @@ openvibe-node approve <ID> # let it run
@@ -44,7 +44,30 @@ openvibe-node approve <ID> # let it run
openvibe-node deny <ID> # refuse it
openvibe-node deny <ID> # refuse it
```
```
-When the Node runs in your graphical session, a waiting request also shows a desktop notification (`notify-send` on Linux, `osascript` on macOS; config `notify: false` turns it off). At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
+**The tray** (Linux and Windows): `openvibe-node tray` puts the Node in your notification area. The installer's
+`--computer` starts it when you sign in (`openvibe-node tray --autostart on|off` does it by hand). It runs in your
+desktop session, not in the service, and talks to the Node over the same local socket as the commands above:
+
+- the icon is blue when all is well, amber while a request waits, red while a stop is latched, grey when the Node does
+ not answer;
+- the menu lists what waits (kind and what it would do), each with **Approve** and **Deny**, then **Stop everything**
+ (the kill switch), **Resume** and your machines page on openvibe.bot;
+- a new request raises a notification with **Approve** and **Deny** buttons (Linux, any desktop with
+ org.freedesktop.Notifications; without buttons it names the command to answer with).
+
+KDE, XFCE, Cinnamon and MATE show tray icons; GNOME needs the AppIndicator extension, and its notifications still ask.
+While the tray runs, the plugin's own prompts below stay quiet.
+
+**Without the tray**, when the Node runs in your graphical session, the plugin asks itself (config `notify: false` turns
+it off): on Linux a notification with Approve and Deny buttons (`notify-send` from libnotify 0.7.10 on; older ones show
+the command to answer with); on macOS a dialog with **Deny** as its default button, which gives up when the request
+expires. A prompt goes away once the request is answered anywhere else, expires or is stopped.
+
+**An approval is not taken while the agent drives the mouse or keyboard**, nor for 2 seconds after: an approved click
+must not be able to press another request's Approve button. `openvibe-node approve` then answers "answer again in a
+moment", and the tray and the plugin ask again. Deny always goes through.
+
+At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
that a request is waiting on the machine.
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
@@ -94,7 +117,7 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
@@ -94,7 +117,7 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
| `max_output_bytes` | 65536 (≤ 192 KB) | stdout and stderr are each cut there |
| `max_output_bytes` | 65536 (≤ 192 KB) | stdout and stderr are each cut there |
| `max_read_bytes` | 262144 (≤ 256 KB) | the most one read or write moves |
| `max_read_bytes` | 262144 (≤ 256 KB) | the most one read or write moves |
| `approval_timeout_s` | 120 (≤ 600) | how long a request waits for you |
| `approval_timeout_s` | 120 (≤ 600) | how long a request waits for you |
-| `notify` | `true` | a desktop notification when a request waits (graphical session only) |
+| `notify` | `true` | the plugin's own prompt when a request waits and no tray shows it (graphical session only) |
| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) |
| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) |
| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) |
| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
@@ -53,6 +53,8 @@ describes itself and waits for EOF without opening any hardware.
@@ -53,6 +53,8 @@ describes itself and waits for EOF without opening any hardware.
| `stop` | | stop every actuator |
| `stop` | | stop every actuator |
| `estop` | | stop every actuator and refuse motion until `resume` |
| `estop` | | stop every actuator and refuse motion until `resume` |
| `resume` | | accept motion again |
| `resume` | | accept motion again |
+| `approval` | `id`, `approved` bool | the owner's answer to an `approval` event it raised |
+| `tray` | `attached` bool, `ttl_s` | (computer plugin only) the tray shows its approvals for `ttl_s` s unless told again: its own desktop prompts may stay quiet |
`value` has already been validated and clamped to the owner's limits by the core ([protocol.md](protocol.md#commands)).
`value` has already been validated and clamped to the owner's limits by the core ([protocol.md](protocol.md#commands)).
`deadline_ms` is relative to receipt and already capped.
`deadline_ms` is relative to receipt and already capped.
@@ -242,8 +244,10 @@ with the owner's control level (`observe`, `ask`, `trusted`, `full`) and folders
@@ -242,8 +244,10 @@ with the owner's control level (`observe`, `ask`, `trusted`, `full`) and folders
while a stop is latched). Its `ack` carries the answer in `data`. The level is enforced in the plugin from its local
while a stop is latched). Its `ack` carries the answer in `data`. The level is enforced in the plugin from its local
config, never from a command; `ask` holds a command (`Deferred` in the SDK), raises an `approval` event and answers
config, never from a command; `ask` holds a command (`Deferred` in the SDK), raises an `approval` event and answers
once the owner runs `openvibe-node approve|deny <id>`, which the core forwards to the plugin as `{"op": "approval", "id",
once the owner runs `openvibe-node approve|deny <id>`, which the core forwards to the plugin as `{"op": "approval", "id",
-"approved"}`. The local kill switch drops it to `observe`. Keyboard and mouse input, a browser and the machine's own
-coding agents are later kinds on the same plugin.
+"approved"}`. The local kill switch drops it to `observe`. `input` (mouse and keyboard) and `browser` are kinds on the
+same plugin; the machine's own coding agents come later. While `openvibe-node tray` runs, the core sends the plugin
+`{"op": "tray", "attached": true, "ttl_s": 15}` every few seconds (and `attached: false` when it quits), and the plugin
+leaves the desktop prompts to the tray.
**SDK additions it uses:** `handle()` may return a dict (the ack's `data`) or raise `Deferred` and answer later with
**SDK additions it uses:** `handle()` may return a dict (the ack's `data`) or raise `Deferred` and answer later with
`complete(id, data)` / `fail(id, code, message)` from any thread; `on_approval(id, approved)`; kinds that move nothing
`complete(id, data)` / `fail(id, code, message)` from any thread; `on_approval(id, approved)`; kinds that move nothing
@@ -3,6 +3,8 @@ module github.com/OpenVibers/OpenVibe.Node
@@ -3,6 +3,8 @@ module github.com/OpenVibers/OpenVibe.Node
go 1.24.7
go 1.24.7
require (
require (
+ fyne.io/systray v1.12.2
+ github.com/godbus/dbus/v5 v5.1.0
github.com/gorilla/websocket v1.5.3
github.com/gorilla/websocket v1.5.3
github.com/kardianos/service v1.3.0
github.com/kardianos/service v1.3.0
github.com/pion/ice/v4 v4.4.4
github.com/pion/ice/v4 v4.4.4
@@ -1,3 +1,7 @@
@@ -1,3 +1,7 @@
+fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
+fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -480,6 +480,16 @@ fi
@@ -480,6 +480,16 @@ fi
if [ -n "$COMPUTER" ]; then
if [ -n "$COMPUTER" ]; then
write_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME"
write_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME"
say "the computer plugin is on at level $COMPUTER for $SVC_USER (folder $COMPUTER_HOME); change it in $CONF/config.json"
say "the computer plugin is on at level $COMPUTER for $SVC_USER (folder $COMPUTER_HOME); change it in $CONF/config.json"
+ # The tray starts when they sign in: what waits for approval with Approve and Deny, the stop and resume.
+ had_config=0
+ [ -d "$COMPUTER_HOME/.config" ] && had_config=1
+ if XDG_CONFIG_HOME="$COMPUTER_HOME/.config" "$BIN_DIR/openvibe-node" tray --autostart on >/dev/null 2>&1; then
+ if [ "$(id -u)" = 0 ]; then
+ [ "$had_config" = 1 ] || chown "$SVC_USER": "$COMPUTER_HOME/.config" 2>/dev/null || true
+ chown "$SVC_USER": "$COMPUTER_HOME/.config/autostart" "$COMPUTER_HOME/.config/autostart/openvibe-node-tray.desktop" 2>/dev/null || true
+ fi
+ say "the tray starts when $SVC_USER signs in (openvibe-node tray; openvibe-node tray --autostart off removes it)"
+ fi
fi
fi
# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----
# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
// Package localctl is the running Node's local control socket: `openvibe-node stop`, `resume`, `status`, `plugins`,
// Package localctl is the running Node's local control socket: `openvibe-node stop`, `resume`, `status`, `plugins`,
-// `approvals`, `approve` and `deny` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode
+// `approvals`, `approve`, `deny` and `tray` talk to it. It is a Unix domain socket (Windows 10+ supports them too) in the state directory with mode
// 0600, so only the account running the Node and root can use it. One JSON request per connection, one JSON answer.
// 0600, so only the account running the Node and root can use it. One JSON request per connection, one JSON answer.
package localctl
package localctl
@@ -23,11 +23,15 @@ const (
@@ -23,11 +23,15 @@ const (
CmdApprovals = "approvals"
CmdApprovals = "approvals"
CmdApprove = "approve"
CmdApprove = "approve"
CmdDeny = "deny"
CmdDeny = "deny"
+ // CmdTray: `openvibe-node tray` shows the approvals (On) or has quit; the computer plugin's own desktop prompts
+ // stay quiet while it does.
+ CmdTray = "tray"
)
)
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 command id the plugin asked about
+ On bool `json:"on,omitempty"` // tray: the tray is running
}
}
type Response struct {
type Response struct {
@@ -207,6 +207,93 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
@@ -207,6 +207,93 @@ func TestComputerAskWaitsForTheOwnerOnTheMachine(t *testing.T) {
}
}
}
}
+// An approval is refused while the agent drives the mouse or keyboard and just after: an approved click must not be
+// able to press another request's Approve button (in the tray or a notification). Deny always goes through.
+func TestComputerApproveWaitsOutTheMouse(t *testing.T) {
+ e, _ := startComputer(t, "ask")
+ b, _ := json.Marshal(map[string]any{"argv": []string{"echo", "later"}})
+ if err := e.conn.Command(protocol.Command{ID: "g1", Kind: protocol.KindExec, Value: b}, 20000); err != nil {
+ t.Fatal(err)
+ }
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) && len(e.node.Approvals()) == 0 {
+ time.Sleep(50 * time.Millisecond)
+ }
+ 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)
+ if err == nil || r.OK || !strings.Contains(r.Error, "mouse or keyboard") {
+ t.Fatalf("approve right after a click: %+v %v", r, err)
+ }
+ if len(e.node.Approvals()) != 1 {
+ t.Fatal("the refused approval no longer waits")
+ }
+ e.node.mu.Lock()
+ 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 {
+ 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 {
+ t.Fatal(err, r.Error)
+ }
+ if d := dataOf(t, func() protocol.Message {
+ m, err := e.conn.Reply("g1", 10*time.Second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m
+ }()); d["stdout"] != "later\n" {
+ t.Fatalf("approved exec: %v", d)
+ }
+ if err := e.conn.Command(protocol.Command{ID: "g2", Kind: protocol.KindExec, Value: b}, 20000); err != nil {
+ t.Fatal(err)
+ }
+ for time.Now().Before(deadline.Add(5*time.Second)) && len(e.node.Approvals()) == 0 {
+ time.Sleep(50 * time.Millisecond)
+ }
+ 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 {
+ t.Fatal("deny is never held back:", err, r.Error)
+ }
+}
+
+// The tray says it is there on every poll; the core tells the computer plugin at most every trayRetell, and at once
+// when the tray quits.
+func TestComputerTrayPresenceReachesThePlugin(t *testing.T) {
+ e, _ := startComputer(t, "ask")
+ if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray, On: true}, 2*time.Second); err != nil || !r.OK {
+ t.Fatal(err, r.Error)
+ }
+ e.node.mu.Lock()
+ first := e.node.trayToldAt
+ e.node.mu.Unlock()
+ if first.IsZero() {
+ t.Fatal("the tray's presence was not passed on")
+ }
+ _, _ = localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray, On: true}, 2*time.Second)
+ e.node.mu.Lock()
+ again := e.node.trayToldAt
+ e.node.mu.Unlock()
+ if !again.Equal(first) {
+ t.Fatal("told again within trayRetell")
+ }
+ _, _ = localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdTray}, 2*time.Second)
+ e.node.mu.Lock()
+ off := e.node.trayToldAt
+ e.node.mu.Unlock()
+ if !off.IsZero() {
+ t.Fatal("the tray quitting was not passed on")
+ }
+}
+
func TestComputerLocalStopLeavesObserveOnly(t *testing.T) {
func TestComputerLocalStopLeavesObserveOnly(t *testing.T) {
e, work := startComputer(t, "trusted")
e, work := startComputer(t, "trusted")
if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second); err != nil || !r.OK {
if r, err := localctl.Call(e.paths.SocketPath(), localctl.Request{Cmd: localctl.CmdStop}, 2*time.Second); err != nil || !r.OK {
@@ -90,8 +90,23 @@ type Node struct {
@@ -90,8 +90,23 @@ type Node struct {
// approvals waiting on the machine (the computer plugin at level "ask"), by command id: what
// approvals waiting on the machine (the computer plugin at level "ask"), by command id: what
// `openvibe-node approvals` lists and `approve`/`deny` answer.
// `openvibe-node approvals` lists and `approve`/`deny` answer.
approvals map[string]Approval
approvals map[string]Approval
+ // input commands in flight, by id, and when the last one finished: `approve` is refused while the agent drives
+ // the mouse or keyboard and for InputGuard after, so an approved click cannot press another request's Approve.
+ inputs map[string]bool
+ inputEnded time.Time
+ trayToldAt time.Time // when the computer plugin was last told the tray shows its approvals
}
}
+// InputGuard is how long after an input command `approve` stays refused.
+const InputGuard = 2 * time.Second
+
+// trayTTL is how long the computer plugin trusts one "the tray shows your approvals"; the tray says it again
+// every poll and the core passes it on at most every trayRetell.
+const (
+ trayTTL = 15
+ trayRetell = 5 * time.Second
+)
+
// 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"`
ID string `json:"id"`
@@ -115,7 +130,7 @@ func New(opt Options) (*Node, error) {
@@ -115,7 +130,7 @@ func New(opt Options) (*Node, error) {
opt.Log.Error("latch", "err", err)
opt.Log.Error("latch", "err", err)
}
}
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
n := &Node{opt: opt, log: opt.Log, latch: latch, dedup: safety.NewDedup(4096, 10*time.Minute),
- jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}}
+ jobs: newJobs(nil), telemetry: map[string]map[string]any{}, telemMS: 500, approvals: map[string]Approval{}, inputs: map[string]bool{}}
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
var specs []plugins.Spec
@@ -506,9 +521,22 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
@@ -506,9 +521,22 @@ func (n *Node) computer(c protocol.Command, entry *safety.Entry, sentMS int64, r
nack(protocol.FaultUnsupported, "no driver on this device handles "+c.Kind+" (the computer plugin is off)")
nack(protocol.FaultUnsupported, "no driver on this device handles "+c.Kind+" (the computer plugin is off)")
return
return
}
}
+ if c.Kind == protocol.KindInput {
+ n.mu.Lock()
+ n.inputs[c.ID] = true
+ n.mu.Unlock()
+ }
wait := p.Begin(n.ctx, c.ID, c.Kind, c.Value, int(window))
wait := p.Begin(n.ctx, c.ID, c.Kind, c.Value, int(window))
go func() {
go func() {
r := wait()
r := wait()
+ if c.Kind == protocol.KindInput {
+ n.mu.Lock()
+ delete(n.inputs, c.ID)
+ if r.OK || (r.FaultCode != protocol.FaultNotAllowed && r.FaultCode != protocol.FaultLocalStop) {
+ n.inputEnded = time.Now() // it ran (or may have run part way)
+ }
+ n.mu.Unlock()
+ }
var msg protocol.Message
var msg protocol.Message
switch {
switch {
case r.OK && len(r.Data) > protocol.MaxAckData:
case r.OK && len(r.Data) > protocol.MaxAckData:
@@ -986,6 +1014,42 @@ func (n *Node) Approvals() []Approval {
@@ -986,6 +1014,42 @@ func (n *Node) Approvals() []Approval {
return out
return out
}
}
+// drivingLocked: an input command runs (it is in flight and not waiting for approval) or finished under InputGuard ago.
+func (n *Node) drivingLocked() bool {
+ for id := range n.inputs {
+ if _, waiting := n.approvals[id]; !waiting {
+ return true
+ }
+ }
+ return !n.inputEnded.IsZero() && time.Since(n.inputEnded) < InputGuard
+}
+
+// tellTray passes the tray's presence to the plugins that handle computer kinds: at once when it quits, at most every
+// trayRetell while it runs.
+func (n *Node) tellTray(on bool) {
+ n.mu.Lock()
+ if on && !n.trayToldAt.IsZero() && time.Since(n.trayToldAt) < trayRetell {
+ n.mu.Unlock()
+ return
+ }
+ if on {
+ n.trayToldAt = time.Now()
+ } else {
+ n.trayToldAt = time.Time{}
+ }
+ n.mu.Unlock()
+ if n.mgr == nil {
+ return
+ }
+ seen := map[*plugins.Plugin]bool{}
+ for kind := range protocol.ComputerKinds {
+ if p := n.mgr.Route(kind, "", ""); p != nil && !seen[p] {
+ seen[p] = true
+ _ = p.Tray(on, trayTTL)
+ }
+ }
+}
+
func (n *Node) control(r localctl.Request) localctl.Response {
func (n *Node) control(r localctl.Request) localctl.Response {
switch r.Cmd {
switch r.Cmd {
case localctl.CmdApprovals:
case localctl.CmdApprovals:
@@ -997,6 +1061,10 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -997,6 +1061,10 @@ func (n *Node) control(r localctl.Request) localctl.Response {
case localctl.CmdApprove, localctl.CmdDeny:
case localctl.CmdApprove, localctl.CmdDeny:
n.mu.Lock()
n.mu.Lock()
a, ok := n.approvals[r.ID]
a, ok := n.approvals[r.ID]
+ 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 {
if ok {
delete(n.approvals, r.ID)
delete(n.approvals, r.ID)
}
}
@@ -1012,6 +1080,9 @@ func (n *Node) control(r localctl.Request) localctl.Response {
@@ -1012,6 +1080,9 @@ func (n *Node) control(r localctl.Request) localctl.Response {
return localctl.Response{Error: err.Error()}
return localctl.Response{Error: err.Error()}
}
}
return localctl.Response{OK: true}
return localctl.Response{OK: true}
+ case localctl.CmdTray:
+ n.tellTray(r.On)
+ return localctl.Response{OK: true}
case localctl.CmdStop:
case localctl.CmdStop:
n.mgr.EstopAll() // at once, then persist
n.mgr.EstopAll() // at once, then persist
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
if _, err := n.latch.SetLocalWith("openvibe-node stop"); err != nil {
@@ -206,6 +206,11 @@ func (p *Plugin) Answer(id string, approved bool) error {
@@ -206,6 +206,11 @@ func (p *Plugin) Answer(id string, approved bool) error {
return p.send(map[string]any{"op": "approval", "id": id, "approved": approved})
return p.send(map[string]any{"op": "approval", "id": id, "approved": approved})
}
}
+// Tray tells the plugin whether the Node's tray shows its approvals, for the next ttlS seconds unless told again.
+func (p *Plugin) Tray(on bool, ttlS int) error {
+ return p.send(map[string]any{"op": "tray", "attached": on, "ttl_s": ttlS})
+}
+
// Command sends a command and waits for the plugin's ack or nack.
// Command sends a command and waits for the plugin's ack or nack.
func (p *Plugin) Command(ctx context.Context, id, kind string, value json.RawMessage, deadlineMS int) Reply {
func (p *Plugin) Command(ctx context.Context, id, kind string, value json.RawMessage, deadlineMS int) Reply {
return p.Begin(ctx, id, kind, value, deadlineMS)()
return p.Begin(ctx, id, kind, value, deadlineMS)()
@@ -6,7 +6,8 @@ command can never raise it:
@@ -6,7 +6,8 @@ command can never raise it:
* ``observe`` (the default): capture the screen; list, stat and read files inside the roots.
* ``observe`` (the default): capture the screen; list, stat and read files inside the roots.
* ``ask``: also run programs, write files inside the roots and use the mouse and keyboard, each only after the owner
* ``ask``: also run programs, write files inside the roots and use the mouse and keyboard, each only after the owner
approves it on the machine
approves it on the machine
- (``openvibe-node approvals``, ``approve <id>``, ``deny <id>``); unanswered requests expire.
+ (the tray, a notification's buttons, or ``openvibe-node approvals``, ``approve <id>``, ``deny <id>``); unanswered
+ requests expire.
* ``trusted``: run programs, write files inside the roots and use the mouse and keyboard without asking.
* ``trusted``: run programs, write files inside the roots and use the mouse and keyboard without asking.
* ``full``: as trusted, anywhere the account running the Node can reach.
* ``full``: as trusted, anywhere the account running the Node can reach.
@@ -40,6 +41,7 @@ Command kinds and their answers (the ack's ``data``):
@@ -40,6 +41,7 @@ Command kinds and their answers (the ack's ``data``):
"""
"""
import base64
import base64
+import contextlib
import http.client
import http.client
import ipaddress
import ipaddress
import os
import os
@@ -47,6 +49,7 @@ import socket
@@ -47,6 +49,7 @@ import socket
import ssl
import ssl
import shlex
import shlex
import shutil
import shutil
+import signal
import stat
import stat
import subprocess
import subprocess
import sys
import sys
@@ -69,6 +72,9 @@ MAX_STDIN = 64 * 1024
@@ -69,6 +72,9 @@ MAX_STDIN = 64 * 1024
MAX_LIST = 1000
MAX_LIST = 1000
MAX_RUNNING = 2
MAX_RUNNING = 2
MAX_WAITING = 8
MAX_WAITING = 8
+# An approval from the desktop (the notification's button or the macOS dialog) is not taken while the agent drives the
+# mouse or keyboard, nor this many seconds after: an approved click must not land on another request's Approve button.
+INPUT_GUARD_S = 2.0
# The core caps an ack's data at 512 KB of JSON; base64 grows by a third.
# The core caps an ack's data at 512 KB of JSON; base64 grows by a third.
MAX_OUTPUT_CAP = 192 * 1024
MAX_OUTPUT_CAP = 192 * 1024
MAX_READ_CAP = 256 * 1024
MAX_READ_CAP = 256 * 1024
@@ -90,6 +96,21 @@ CLICLICK_KEYS = {"return": "return", "enter": "enter", "escape": "esc", "esc": "
@@ -90,6 +96,21 @@ CLICLICK_KEYS = {"return": "return", "enter": "enter", "escape": "esc", "esc": "
"page_down": "page-down", "next": "page-down"}
"page_down": "page-down", "next": "page-down"}
+
+def _parse_notify_send(out):
+ """notify-send --wait prints the name of the button pressed; nothing when the notification just closed."""
+ lines = (out or "").strip().splitlines()
+ last = lines[-1].strip() if lines else ""
+ return True if last == "approve" else False if last == "deny" else None
+
+
+def _parse_dialog(out):
+ """osascript's display dialog: "button returned:Approve, gave up:false"; giving up answers nothing."""
+ out = out or ""
+ if "gave up:true" in out:
+ return None
+ return True if "button returned:Approve" in out else False if "button returned:Deny" in out else None
+
def _int(v, name, lo, hi, default):
def _int(v, name, lo, hi, default):
if v is None:
if v is None:
return default
return default
@@ -121,6 +142,11 @@ class Computer(Plugin):
@@ -121,6 +142,11 @@ class Computer(Plugin):
self.allow_probe = True
self.allow_probe = True
self.allow_input = True
self.allow_input = True
self.notify = True
self.notify = True
+ self.notify_actions = None # whether notify-send shows buttons (libnotify 0.7.10+); found out on first use
+ self.prompts = {} # command id -> the notification or dialog process asking about it
+ self.tray_until = 0.0 # the Node's tray shows the approvals until then (op "tray")
+ self.input_busy = 0
+ self.input_ended = None
self.approval_timeout_s = 120
self.approval_timeout_s = 120
self.pending = {} # command id -> (run, expires_at, summary)
self.pending = {} # command id -> (run, expires_at, summary)
self.procs = set()
self.procs = set()
@@ -206,6 +232,7 @@ class Computer(Plugin):
@@ -206,6 +232,7 @@ class Computer(Plugin):
self._kill(p)
self._kill(p)
self._close_browser()
self._close_browser()
for cid in waiting:
for cid in waiting:
+ self._close_prompt(cid)
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
self.fail(cid, "local_stop", "stopped on the machine before it ran")
self.fail(cid, "local_stop", "stopped on the machine before it ran")
@@ -231,6 +258,7 @@ class Computer(Plugin):
@@ -231,6 +258,7 @@ class Computer(Plugin):
del self.pending[cid]
del self.pending[cid]
waiting, running = len(self.pending), self.running
waiting, running = len(self.pending), self.running
for cid in expired:
for cid in expired:
+ self._close_prompt(cid)
self.emit_event("approval_done", id=cid, approved=False, reason="expired")
self.emit_event("approval_done", id=cid, approved=False, reason="expired")
self.fail(cid, "not_allowed", "nobody answered on the machine in time")
self.fail(cid, "not_allowed", "nobody answered on the machine in time")
# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.
# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.
@@ -288,7 +316,7 @@ class Computer(Plugin):
@@ -288,7 +316,7 @@ class Computer(Plugin):
if not tool:
if not tool:
raise Fault("unsupported", "no input tool on this machine (xdotool on X11, cliclick on macOS)")
raise Fault("unsupported", "no input tool on this machine (xdotool on X11, cliclick on macOS)")
run, summary = self._input_op(tool, v)
run, summary = self._input_op(tool, v)
- return self._act(cmd, level, run, summary, threaded=True)
+ return self._act(cmd, level, self._driving(run), summary, threaded=True)
if cmd.kind == "browser":
if cmd.kind == "browser":
run, summary, acting = self._browser_op(v, level)
run, summary, acting = self._browser_op(v, level)
if acting:
if acting:
@@ -320,29 +348,124 @@ class Computer(Plugin):
@@ -320,29 +348,124 @@ class Computer(Plugin):
return self._start(cmd, run) if threaded else run()
return self._start(cmd, run) if threaded else run()
def _notify(self, cid, summary):
def _notify(self, cid, summary):
- """A desktop notification that a request waits (config `notify`, on by default), when the Node runs in a
- graphical session: notify-send on Linux, osascript on macOS. Best effort: it never blocks or fails a command."""
- if not self.notify:
+ """Tell the desktop that a request waits (config `notify`, on by default) when the Node runs in a graphical
+ session and the Node's tray does not show it already (`openvibe-node tray`). On Linux the notification has
+ Approve and Deny buttons when notify-send can show them (libnotify 0.7.10+); on macOS a dialog asks, with Deny
+ as its default button. Best effort: it never blocks or fails a command."""
+ if not self.notify or self.clock() < self.tray_until:
return
return
title = "OpenVibe: your agent asks"
title = "OpenVibe: your agent asks"
- body = "%s\nopenvibe-node approve %s (or deny %s)" % (summary[:200], cid, cid)
+ hint = "openvibe-node approve %s (or deny %s)" % (cid, cid)
+ parse = None
if self.platform.startswith("linux") and (self.env.get("DISPLAY") or self.env.get("WAYLAND_DISPLAY")) and self.which("notify-send"):
if self.platform.startswith("linux") and (self.env.get("DISPLAY") or self.env.get("WAYLAND_DISPLAY")) and self.which("notify-send"):
- argv = ["notify-send", "--app-name=OpenVibe Node", title, body]
+ if self._notify_can_act():
+ argv = ["notify-send", "--app-name=OpenVibe Node", "--urgency=critical", "--wait",
+ "--expire-time=%d" % (self.approval_timeout_s * 1000), "--action=approve=Approve",
+ "--action=deny=Deny", "--", title, summary[:200]]
+ parse = _parse_notify_send
+ else:
+ argv = ["notify-send", "--app-name=OpenVibe Node", title, "%s\n%s" % (summary[:200], hint)]
elif self.platform == "darwin" and self.which("osascript"):
elif self.platform == "darwin" and self.which("osascript"):
quote = lambda s: '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"')
quote = lambda s: '"%s"' % s.replace("\\", "\\\\").replace('"', '\\"')
- argv = ["osascript", "-e", "display notification %s with title %s" % (quote(body), quote(title))]
+ argv = ["osascript", "-e", 'display dialog %s with title %s buttons {"Deny", "Approve"} default button "Deny" '
+ "giving up after %d with icon caution" % (quote(summary[:400]), quote(title), self.approval_timeout_s)]
+ parse = _parse_dialog
else:
else:
return
return
try:
try:
- self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+ if parse is None:
+ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+ return
+ proc = self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
+ start_new_session=True, text=True)
except OSError:
except OSError:
+ return
+ with self.mu:
+ old = self.prompts.pop(cid, None)
+ self.prompts[cid] = proc
+ if old is not None:
+ self._end_prompt(old)
+ threading.Thread(target=self._await_prompt, args=(cid, proc, parse), name="computer-ask-%s" % cid, daemon=True).start()
+
+ def _notify_can_act(self):
+ """Whether this notify-send has --action and --wait (libnotify 0.7.10+), asked once."""
+ if self.notify_actions is None:
+ try:
+ p = self.popen(["notify-send", "--help"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT, text=True)
+ out = p.communicate(timeout=3)[0] or ""
+ self.notify_actions = "--action" in out and "--wait" in out
+ except (OSError, subprocess.SubprocessError, AttributeError):
+ self.notify_actions = False
+ return self.notify_actions
+
+ def _await_prompt(self, cid, proc, parse):
+ try:
+ out = proc.communicate()[0] or ""
+ except (OSError, ValueError, AttributeError):
+ out = ""
+ with self.mu:
+ if self.prompts.get(cid) is proc:
+ del self.prompts[cid]
+ answer = parse(out)
+ if answer is not None:
+ self._desktop_answer(cid, answer)
+
+ def _desktop_answer(self, cid, approved):
+ """The owner pressed Approve or Deny on the desktop. An approval that arrives while the agent drives the mouse or
+ keyboard (or just after) may be the agent's own click: it is not taken, and the request is shown again."""
+ with self.mu:
+ item = self.pending.get(cid)
+ driving = self.input_busy > 0 or (self.input_ended is not None and self.clock() - self.input_ended < INPUT_GUARD_S)
+ if item is None:
+ return
+ if approved and driving:
+ self.log("an approval of %s came from the desktop while the agent drove the mouse or keyboard: asking again", cid)
+ self._notify(cid, item[2])
+ return
+ with (self.runtime.lock if self.runtime is not None else contextlib.nullcontext()):
+ self.on_approval(cid, approved)
+
+ def _close_prompt(self, cid):
+ """Take down the notification or dialog of a request answered elsewhere, expired or stopped."""
+ with self.mu:
+ proc = self.prompts.pop(cid, None)
+ if proc is not None:
+ self._end_prompt(proc)
+
+ def _end_prompt(self, proc):
+ try:
+ if proc.poll() is None:
+ proc.send_signal(signal.SIGINT) # notify-send --wait closes its notification on SIGINT
+ except (OSError, AttributeError):
pass
pass
+ def _driving(self, run):
+ """An input command, counted while it runs so desktop approvals wait it out (INPUT_GUARD_S)."""
+ def go():
+ with self.mu:
+ self.input_busy += 1
+ try:
+ return run()
+ finally:
+ with self.mu:
+ self.input_busy -= 1
+ self.input_ended = self.clock()
+ return go
+
+ def on_tray(self, attached, ttl_s):
+ try:
+ ttl = max(1.0, min(float(ttl_s or 15), 60.0))
+ except (TypeError, ValueError):
+ ttl = 15.0
+ self.tray_until = self.clock() + ttl if attached else 0.0
+
def on_approval(self, cid, approved):
def on_approval(self, cid, approved):
with self.mu:
with self.mu:
item = self.pending.pop(cid, None)
item = self.pending.pop(cid, None)
if item is None:
if item is None:
return
return
+ self._close_prompt(cid)
(run, threaded), _, summary = item
(run, threaded), _, summary = item
self.emit_event("approval_done", id=cid, approved=bool(approved))
self.emit_event("approval_done", id=cid, approved=bool(approved))
if not approved:
if not approved:
@@ -46,6 +46,31 @@ class Out(io.StringIO):
@@ -46,6 +46,31 @@ class Out(io.StringIO):
self.cond.wait(left)
self.cond.wait(left)
+class FakePrompt:
+ """A notify-send --wait or osascript dialog process: answer() is the owner pressing a button."""
+
+ def __init__(self, argv):
+ self.argv = argv
+ self.out = ""
+ self.done = threading.Event()
+ self.closed = threading.Event()
+
+ def answer(self, out):
+ self.out = out
+ self.done.set()
+
+ def communicate(self, timeout=None):
+ self.done.wait(10)
+ return (self.out, None)
+
+ def poll(self):
+ return 0 if self.done.is_set() else None
+
+ def send_signal(self, sig):
+ self.closed.set()
+ self.done.set()
+
+
def reply(out, cid):
def reply(out, cid):
return out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"))
return out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"))
@@ -173,6 +198,7 @@ class ComputerTest(unittest.TestCase):
@@ -173,6 +198,7 @@ class ComputerTest(unittest.TestCase):
self.plugin.popen = lambda argv, **kw: calls.append(argv) if argv[0] in ("notify-send", "osascript") else real(argv, **kw)
self.plugin.popen = lambda argv, **kw: calls.append(argv) if argv[0] in ("notify-send", "osascript") else real(argv, **kw)
self.plugin.which = lambda name: "/usr/bin/" + name
self.plugin.which = lambda name: "/usr/bin/" + name
self.plugin.env = {"DISPLAY": ":0"}
self.plugin.env = {"DISPLAY": ":0"}
+ self.plugin.notify_actions = False # an older notify-send: no buttons, the command to answer with
cid = self.cmd("exec", {"argv": ["echo", "hi"]})
cid = self.cmd("exec", {"argv": ["echo", "hi"]})
self.out.wait_for(lambda m: m.get("name") == "approval")
self.out.wait_for(lambda m: m.get("name") == "approval")
self.assertEqual(calls[0][:3], ["notify-send", "--app-name=OpenVibe Node", "OpenVibe: your agent asks"])
self.assertEqual(calls[0][:3], ["notify-send", "--app-name=OpenVibe Node", "OpenVibe: your agent asks"])
@@ -185,6 +211,94 @@ class ComputerTest(unittest.TestCase):
@@ -185,6 +211,94 @@ class ComputerTest(unittest.TestCase):
self.cmd("exec", {"argv": ["echo", "quiet"]})
self.cmd("exec", {"argv": ["echo", "quiet"]})
self.assertEqual(len(calls), 1, "notify off")
self.assertEqual(len(calls), 1, "notify off")
+ def desktop(self, platform="linux"):
+ """A graphical session whose notification or dialog the test answers: returns the prompts shown."""
+ prompts = []
+ real = self.plugin.popen
+
+ def popen(argv, **kw):
+ if argv[0] in ("notify-send", "osascript"):
+ p = FakePrompt(argv)
+ prompts.append(p)
+ return p
+ return real(argv, **kw)
+ self.plugin.popen = popen
+ self.plugin.which = lambda name: "/usr/bin/" + name
+ self.plugin.platform = platform
+ self.plugin.env = {"DISPLAY": ":0"}
+ self.plugin.notify_actions = True
+ return prompts
+
+ def test_the_notification_approves_and_denies(self):
+ self.boot("ask")
+ prompts = self.desktop()
+ cid = self.cmd("exec", {"argv": ["echo", "from the desktop"]})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ argv = prompts[0].argv
+ self.assertIn("--action=approve=Approve", argv)
+ self.assertIn("--wait", argv)
+ self.assertEqual(argv[-3:-1], ["--", "OpenVibe: your agent asks"])
+ prompts[0].answer("approve\n")
+ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "from the desktop\n")
+ cid = self.cmd("fs", {"op": "write", "path": os.path.join(self.dir, "no.txt"), "content_b64": "eA=="})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ prompts[1].answer("deny\n")
+ self.assertEqual(reply(self.out, cid)["fault_code"], "not_allowed")
+ self.assertFalse(os.path.exists(os.path.join(self.dir, "no.txt")))
+
+ def test_an_answer_elsewhere_takes_the_notification_down(self):
+ self.boot("ask")
+ prompts = self.desktop()
+ cid = self.cmd("exec", {"argv": ["echo", "cli"]})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ self.rt.dispatch({"op": "approval", "id": cid, "approved": True})
+ reply(self.out, cid)
+ self.assertTrue(prompts[0].closed.wait(5), "the notification is closed once the CLI answered")
+
+ def test_a_desktop_approval_while_the_agent_drives_the_mouse_is_not_taken(self):
+ self.boot("ask")
+ prompts = self.desktop()
+ cid = self.cmd("exec", {"argv": ["echo", "maybe"]})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ t = [time.monotonic()] # the real clock's value: the command's own deadline counts on it
+ self.plugin.clock = lambda: t[0]
+ self.plugin.input_ended = t[0] - 0.5 # a click ran half a second ago
+ prompts[0].answer("approve\n")
+ deadline = time.monotonic() + 5
+ while len(prompts) < 2 and time.monotonic() < deadline:
+ time.sleep(0.01)
+ self.assertEqual(len(prompts), 2, "asked again")
+ self.assertIn(cid, self.plugin.pending, "still waiting")
+ t[0] += 3
+ prompts[1].answer("approve\n")
+ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "maybe\n")
+
+ def test_the_tray_shows_approvals_instead(self):
+ self.boot("ask")
+ prompts = self.desktop()
+ self.rt.dispatch({"op": "tray", "attached": True, "ttl_s": 15})
+ cid = self.cmd("exec", {"argv": ["echo", "tray"]})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ self.assertEqual(prompts, [], "the tray notifies; the plugin stays quiet")
+ self.rt.dispatch({"op": "tray", "attached": False})
+ self.cmd("exec", {"argv": ["echo", "no tray"]})
+ self.assertEqual(len(prompts), 1)
+
+ def test_macos_asks_with_a_dialog(self):
+ self.boot("ask")
+ prompts = self.desktop("darwin")
+ cid = self.cmd("exec", {"argv": ["echo", "mac"]})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ script = prompts[0].argv[2]
+ self.assertIn('buttons {"Deny", "Approve"} default button "Deny"', script)
+ prompts[0].answer("button returned:Approve, gave up:false\n")
+ self.assertEqual(reply(self.out, cid)["data"]["stdout"], "mac\n")
+ cid = self.cmd("exec", {"argv": ["echo", "ignored"]})
+ self.out.wait_for(lambda m: m.get("name") == "approval" and m.get("id") == cid)
+ prompts[1].answer("button returned:, gave up:true\n")
+ time.sleep(0.05)
+ self.assertIn(cid, self.plugin.pending, "giving up answers nothing: the request waits until it expires")
+
def test_unanswered_requests_expire(self):
def test_unanswered_requests_expire(self):
self.boot("ask", approval_timeout_s=10)
self.boot("ask", approval_timeout_s=10)
t = [1000.0]
t = [1000.0]
@@ -130,6 +130,11 @@ class Plugin:
@@ -130,6 +130,11 @@ class Plugin:
"""The owner answered, on the machine (`openvibe-node approve|deny <id>`), an approval this plugin asked for
"""The owner answered, on the machine (`openvibe-node approve|deny <id>`), an approval this plugin asked for
with emit_event("approval", id=…). Runs on the runtime's main thread."""
with emit_event("approval", id=…). Runs on the runtime's main thread."""
+ def on_tray(self, attached, ttl_s):
+ """The Node's tray (`openvibe-node tray`, in the owner's graphical session) is showing this plugin's approvals,
+ for the next ttl_s seconds unless told again. A plugin that notifies the desktop itself can stay quiet then.
+ Sent only to the plugin that handles computer kinds."""
+
# Helpers for drivers.
# Helpers for drivers.
def complete(self, cid, data=None):
def complete(self, cid, data=None):
"""Answer a deferred command with ack (and its data). Safe from any thread."""
"""Answer a deferred command with ack (and its data). Safe from any thread."""
@@ -276,6 +281,11 @@ class Runtime:
@@ -276,6 +281,11 @@ class Runtime:
self.plugin.on_approval(msg.get("id"), msg.get("approved") is True)
self.plugin.on_approval(msg.get("id"), msg.get("approved") is True)
except Exception as e:
except Exception as e:
self.plugin.log("approval handler failed: %r", e)
self.plugin.log("approval handler failed: %r", e)
+ elif op == "tray":
+ try:
+ self.plugin.on_tray(msg.get("attached") is True, msg.get("ttl_s"))
+ except Exception as e:
+ self.plugin.log("tray handler failed: %r", e)
elif op == "resume":
elif op == "resume":
if self.local_stop_pending:
if self.local_stop_pending:
self.plugin.log("resume ignored: the local stop has not reached the core yet")
self.plugin.log("resume ignored: the local stop has not reached the core yet")
@@ -284,6 +284,9 @@ class Later(Plugin):
@@ -284,6 +284,9 @@ class Later(Plugin):
else:
else:
self.fail(cid, "not_allowed", "denied")
self.fail(cid, "not_allowed", "denied")
+ def on_tray(self, attached, ttl_s):
+ self.answers.append(("tray", attached, ttl_s))
+
class DeferredTest(unittest.TestCase):
class DeferredTest(unittest.TestCase):
def test_data_deferred_and_approval(self):
def test_data_deferred_and_approval(self):
@@ -303,6 +306,16 @@ class DeferredTest(unittest.TestCase):
@@ -303,6 +306,16 @@ class DeferredTest(unittest.TestCase):
self.assertEqual(p.answers[-1], ("c", False), "only a JSON true approves")
self.assertEqual(p.answers[-1], ("c", False), "only a JSON true approves")
self.assertEqual(lines(out)[-1], {"op": "nack", "id": "c", "fault_code": "not_allowed", "message": "denied"})
self.assertEqual(lines(out)[-1], {"op": "nack", "id": "c", "fault_code": "not_allowed", "message": "denied"})
+ def test_the_tray_op_reaches_the_plugin(self):
+ rt, p, out, clock = make(Later())
+ rt.dispatch({"op": "hello", "config": {}})
+ rt.dispatch({"op": "tray", "attached": True, "ttl_s": 15})
+ rt.dispatch({"op": "tray", "attached": "yes"})
+ self.assertEqual(p.answers, [("tray", True, 15), ("tray", False, None)], "only a JSON true attaches")
+ rt2, p2, out2, _ = make()
+ rt2.dispatch({"op": "hello", "config": {}})
+ rt2.dispatch({"op": "tray", "attached": True, "ttl_s": 15}) # a plugin without on_tray: the base no-op
+
def test_motion_kinds_keep_the_short_cap(self):
def test_motion_kinds_keep_the_short_cap(self):
rt, p, out, clock = make()
rt, p, out, clock = make()
rt.dispatch({"op": "hello", "config": {}})
rt.dispatch({"op": "hello", "config": {}})