openvibers/openvibe.link Commits
Node: a request waiting for approval on the machine shows a desktop notification (#42)
Changes
@@@ -44,7 +44,7 @@ openvibe-node approve <ID> # let it runopenvibe-node deny <ID> # refuse it```--At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say++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 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@@@ -74,6 +74,7 @@ your graphical session for `screen`; without one the plugin simply does not offe| `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) |`describe` reports `terminal`, `files` and `screen` with the level and folders, so Bot shows what the machine allows;telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting` and@@@ -60,7 +60,7 @@ def _int(v, name, lo, hi, default):class Computer(Plugin):driver = "computer"-- version = "0.1.0"++ version = "0.2.0"motion_kinds = frozenset()telemetry_interval_s = 2.0@@@ -76,6 +76,7 @@ class Computer(Plugin):self.max_output = 64 * 1024self.max_read = 256 * 1024self.allow_exec = True++ self.notify = Trueself.approval_timeout_s = 120self.pending = {} # command id -> (run, expires_at, summary)self.procs = set()@@@ -99,6 +100,7 @@ class Computer(Plugin):self.max_read = _int(config.get("max_read_bytes"), "max_read_bytes", 1024, MAX_READ_CAP, 256 * 1024)self.approval_timeout_s = _int(config.get("approval_timeout_s"), "approval_timeout_s", 10, 600, 120)self.allow_exec = config.get("allow_exec", True) is not False++ self.notify = config.get("notify", True) is not Falsedef describe(self, config):try:@@@ -220,11 +222,31 @@ class Computer(Plugin):raise Fault("not_ready", "%d requests already wait for approval on the machine" % MAX_WAITING)self.pending[cmd.id] = ((run, threaded), self.clock() + self.approval_timeout_s, summary)self.emit_event("approval", id=cmd.id, kind=cmd.kind, summary=summary, expires_s=self.approval_timeout_s)++ self._notify(cmd.id, summary)self.log("waiting for approval %s: %s", cmd.id, summary)raise Deferred()self.log("%s %s: %s", cmd.kind, cmd.id, summary)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:++ return++ title = "OpenVibe: your agent asks"++ body = "%s\nopenvibe-node approve %s (or deny %s)" % (summary[:200], cid, cid)++ 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]++ 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))]++ else:++ return++ try:++ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)++ except OSError:++ pass++def on_approval(self, cid, approved):with self.mu:item = self.pending.pop(cid, None)@@@ -166,6 +166,25 @@ class ComputerTest(unittest.TestCase):self.assertEqual((r["fault_code"], r["message"]), ("not_allowed", "the owner denied it on the machine"))self.assertFalse(os.path.exists(os.path.join(self.dir, "denied.txt")))++ def test_a_waiting_request_notifies_the_desktop(self):++ self.boot("ask")++ calls = []++ real = self.plugin.popen++ 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.env = {"DISPLAY": ":0"}++ cid = 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"])++ self.assertIn("openvibe-node approve %s" % cid, calls[0][3])++ self.plugin.env = {}++ self.cmd("exec", {"argv": ["echo", "again"]})++ self.assertEqual(len(calls), 1, "no graphical session: no notification")++ self.plugin.notify = False++ self.plugin.env = {"DISPLAY": ":0"}++ self.cmd("exec", {"argv": ["echo", "quiet"]})++ self.assertEqual(len(calls), 1, "notify off")++def test_unanswered_requests_expire(self):self.boot("ask", approval_timeout_s=10)t = [1000.0]
@@ -44,7 +44,7 @@ openvibe-node approve <ID> # let it run
@@ -44,7 +44,7 @@ openvibe-node approve <ID> # let it run
openvibe-node deny <ID> # refuse it
openvibe-node deny <ID> # refuse it
```
```
-At most 8 requests wait at once. The panel on openvibe.bot sees `approval` and `approval_done` events, so it can say
+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
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
@@ -74,6 +74,7 @@ your graphical session for `screen`; without one the plugin simply does not offe
@@ -74,6 +74,7 @@ your graphical session for `screen`; without one the plugin simply does not offe
| `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) |
`describe` reports `terminal`, `files` and `screen` with the level and folders, so Bot shows what the machine allows;
`describe` reports `terminal`, `files` and `screen` with the level and folders, so Bot shows what the machine allows;
telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting` and
telemetry carries the sensors `computer_level` (observe while a stop is latched), `computer_waiting` and
@@ -60,7 +60,7 @@ def _int(v, name, lo, hi, default):
@@ -60,7 +60,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.1.0"
+ version = "0.2.0"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
@@ -76,6 +76,7 @@ class Computer(Plugin):
@@ -76,6 +76,7 @@ class Computer(Plugin):
self.max_output = 64 * 1024
self.max_output = 64 * 1024
self.max_read = 256 * 1024
self.max_read = 256 * 1024
self.allow_exec = True
self.allow_exec = True
+ self.notify = True
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()
@@ -99,6 +100,7 @@ class Computer(Plugin):
@@ -99,6 +100,7 @@ class Computer(Plugin):
self.max_read = _int(config.get("max_read_bytes"), "max_read_bytes", 1024, MAX_READ_CAP, 256 * 1024)
self.max_read = _int(config.get("max_read_bytes"), "max_read_bytes", 1024, MAX_READ_CAP, 256 * 1024)
self.approval_timeout_s = _int(config.get("approval_timeout_s"), "approval_timeout_s", 10, 600, 120)
self.approval_timeout_s = _int(config.get("approval_timeout_s"), "approval_timeout_s", 10, 600, 120)
self.allow_exec = config.get("allow_exec", True) is not False
self.allow_exec = config.get("allow_exec", True) is not False
+ self.notify = config.get("notify", True) is not False
def describe(self, config):
def describe(self, config):
try:
try:
@@ -220,11 +222,31 @@ class Computer(Plugin):
@@ -220,11 +222,31 @@ class Computer(Plugin):
raise Fault("not_ready", "%d requests already wait for approval on the machine" % MAX_WAITING)
raise Fault("not_ready", "%d requests already wait for approval on the machine" % MAX_WAITING)
self.pending[cmd.id] = ((run, threaded), self.clock() + self.approval_timeout_s, summary)
self.pending[cmd.id] = ((run, threaded), self.clock() + self.approval_timeout_s, summary)
self.emit_event("approval", id=cmd.id, kind=cmd.kind, summary=summary, expires_s=self.approval_timeout_s)
self.emit_event("approval", id=cmd.id, kind=cmd.kind, summary=summary, expires_s=self.approval_timeout_s)
+ self._notify(cmd.id, summary)
self.log("waiting for approval %s: %s", cmd.id, summary)
self.log("waiting for approval %s: %s", cmd.id, summary)
raise Deferred()
raise Deferred()
self.log("%s %s: %s", cmd.kind, cmd.id, summary)
self.log("%s %s: %s", cmd.kind, cmd.id, summary)
return self._start(cmd, run) if threaded else run()
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:
+ return
+ title = "OpenVibe: your agent asks"
+ body = "%s\nopenvibe-node approve %s (or deny %s)" % (summary[:200], cid, cid)
+ 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]
+ 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))]
+ else:
+ return
+ try:
+ self.popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+ except OSError:
+ pass
+
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)
@@ -166,6 +166,25 @@ class ComputerTest(unittest.TestCase):
@@ -166,6 +166,25 @@ class ComputerTest(unittest.TestCase):
self.assertEqual((r["fault_code"], r["message"]), ("not_allowed", "the owner denied it on the machine"))
self.assertEqual((r["fault_code"], r["message"]), ("not_allowed", "the owner denied it on the machine"))
self.assertFalse(os.path.exists(os.path.join(self.dir, "denied.txt")))
self.assertFalse(os.path.exists(os.path.join(self.dir, "denied.txt")))
+ def test_a_waiting_request_notifies_the_desktop(self):
+ self.boot("ask")
+ calls = []
+ real = self.plugin.popen
+ 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.env = {"DISPLAY": ":0"}
+ cid = 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"])
+ self.assertIn("openvibe-node approve %s" % cid, calls[0][3])
+ self.plugin.env = {}
+ self.cmd("exec", {"argv": ["echo", "again"]})
+ self.assertEqual(len(calls), 1, "no graphical session: no notification")
+ self.plugin.notify = False
+ self.plugin.env = {"DISPLAY": ":0"}
+ self.cmd("exec", {"argv": ["echo", "quiet"]})
+ self.assertEqual(len(calls), 1, "notify off")
+
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]