openvibers/openvibe.link Commits
v0.26.0: programs on a desk: exec takes desk (the desk's session, windows there) and detach (a program that keeps running on a whole desk and ends with it); desk templates: research, office, dev (#73)
Changes
@@@ -5,8 +5,8 @@"wave": "T15","plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)","kind": "client",-- "version": "0.25.0",-- "release": "v0.25.0, published 2026-10-27 as a GitHub release (the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",++ "version": "0.26.0",++ "release": "v0.26.0, published 2026-10-27 as a GitHub release (programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS","runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library","database": "none","deployed": false,@@@ -78,7 +78,7 @@ acting actions until `openvibe-node resume`. `halt` kills what runs.| kind | value | answer (`ack.data`) ||----------|-----------------------------------------------------------------------------------------------|---------------------|--| `exec` | `argv` (a program and its arguments: never a shell line), `cwd`, `timeout_s`, `stdin` (≤ 64 KB) | `exit_code`, `stdout`, `stderr` (each cut at `max_output_bytes`), `truncated`, `timed_out`, `duration_ms` |++| `exec` | `argv` (a program and its arguments: never a shell line), `cwd`, `timeout_s`, `stdin` (≤ 64 KB), `desk`, `detach` (Node v0.26.0: see "Programs on a desk") | `exit_code`, `stdout`, `stderr` (each cut at `max_output_bytes`), `truncated`, `timed_out`, `duration_ms`; detached: `started`, `pid`, `desk` || `fs` | `op` `list` / `stat` / `read` (`offset`, `length`) / `write` (`content_b64`, `mode` `create`, `overwrite` or `append`) / `mkdir`, and an absolute `path` | `entries` (≤ 1000), the stat, `content_b64` (≤ `max_read_bytes`), `written`, `created` || `probe` | `target`: `http(s)://…` (one GET, no redirect followed) or `tcp://host:port`; `timeout_s` | `status`, `content_type`, `etag`, `last_modified`, `body` (≤ 64 KB), `ms`; or `open`, `ms` || `screen` | `max_width` (default 1280) | `format` (`jpeg` with Pillow, else `png` ≤ 300 KB), `width`, `height`, `content_b64` |@@@ -201,6 +201,21 @@ window manager.size (1280×800 by default).- `private` is always a whole desk; a whole desk is `"kind": "desktop"`, the default.++### Programs on a desk (`exec` with `desk` and `detach`, Node v0.26.0)++++A whole desk is a whole computer, so an agent can work in real applications there, never on your screen.++- **`"desk": "desk-<name>"`** (or `private`) runs the program in that desk's session: its display, session bus and++ temp folder. A program with windows opens on the desk.++- **`"detach": true`** starts a program that keeps running and answers at once with its `pid`: an office suite, a++ terminal, an editor. It is ended when its desk stops: by its agent, by `desk_idle_s`, or by the kill switch. At most++ 16 run on one desk.++- **Never on your own session or a browser desk.** A browser desk has no screen for windows.++- **Desk templates** come from this: a *research desk* is a browser desk; an *office desk* is a whole desk with an++ office suite opened on it (`libreoffice --writer report.odt`); a *dev desk* is a whole desk with a terminal and an++ editor. On a desk host, install the applications its desks should have (`apt install libreoffice xterm`).++- `exec` keeps its rules: the folders you allow, the level (approved one by one at `ask`), and `allow_exec`.++ `describe` says `terminal.desks: true`.++### Kept profiles (`"profile": "<name>"`, Node v0.24.0)Each agent task gets a desk of its own, and with it a fresh browser. A **kept profile** carries the person's sign-ins@@@ -16,8 +16,12 @@ and deny what waits; the core refuses acting commands while either is latched toCommand kinds and their answers (the ack's ``data``):--* ``exec`` ``{"argv": [...], "cwd"?, "timeout_s"?, "stdin"?}`` → ``{exit_code, stdout, stderr, truncated, timed_out,-- duration_ms}``. A program and its arguments, never a shell line; output is cut at ``max_output_bytes``.++* ``exec`` ``{"argv": [...], "cwd"?, "timeout_s"?, "stdin"?, "desk"?, "detach"?}`` → ``{exit_code, stdout, stderr,++ truncated, timed_out, duration_ms}``. A program and its arguments, never a shell line; output is cut at++ ``max_output_bytes``. ``desk`` (Node v0.26) runs it in that desk's session (its display, bus and temp folder), so a++ program with windows opens on the desk. ``detach`` (a whole desk only) starts a program that keeps running (an office++ suite, a terminal) and answers at once ``{started, pid, desk}``; it is ended when its desk stops (at most++ MAX_DESK_PROGRAMS per desk).* ``fs`` ``{"op": "list"|"stat"|"read"|"write"|"mkdir", "path", …}``: ``read`` takes ``offset``/``length`` and answers``content_b64``; ``write`` takes ``content_b64`` and ``mode`` (``create``, ``overwrite`` or ``append``).* ``screen`` ``{"max_width"?}`` → ``{format, width, height, content_b64}`` (JPEG through Pillow when it is installed).@@@ -120,6 +124,7 @@ INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scroBROWSER_READS = frozenset({"read", "screenshot", "close"})BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})MAX_URL = 2048++MAX_DESK_PROGRAMS = 16PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")SESSION_COOKIES = "ov-session-cookies.json" # in a kept profile: the cookies that end with the browser# What a kept profile leaves out: caches, crash reports, locks and the last session's tabs (a desk opens on a blank page).@@@ -180,7 +185,7 @@ def _int(v, name, lo, hi, default):class Computer(Plugin):driver = "computer"-- version = "0.13.0"++ version = "0.14.0"motion_kinds = frozenset()telemetry_interval_s = 2.0@@@ -238,6 +243,7 @@ class Computer(Plugin):self.desk_restored = set() # desks whose browser started from their kept profile (so it is saved back on stop)self.profile_locks = {} # profile name -> one copy in or out at a timeself.desk_profile_max_mb = 256++ self.desk_programs = {} # desk name -> programs started there with detach, ended with the deskself.desk_idle_s = 900self.browser_locks = {} # "main" or a desk name -> one action at a time in that browserself.resolve = resolve or (lambda host: [ai[4][0] for ai in socket.getaddrinfo(host, None)])@@@ -285,7 +291,8 @@ class Computer(Plugin):return {}caps = {"files": {"level": self.level, "roots": self.roots, "max_read_bytes": self.max_read}}if self.allow_exec:-- caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output}++ caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output,++ "desks": bool(self._desk_kinds())} # exec takes desk and detach (Node v0.26.0)tool = self._screen_tool()if tool:caps["screen"] = {"level": self.level, "tool": tool[0],@@@ -343,6 +350,7 @@ class Computer(Plugin):self._close_browser()self._end_stream("stopped on the machine")self._close_desk_browsers()++ self._end_desk_programs()self.desks.stop_all()self._drop_desk_profiles()for cid in waiting:@@@ -369,6 +377,7 @@ class Computer(Plugin):for name in self.desks.running_names():if now - self.desk_used.get(name, now) > self.desk_idle_s:self._end_desk_browser(name)++ self._end_desk_programs(name)self.desks.stop(name)self.desk_used.pop(name, None)self.emit_event("desk_idle_stopped", desk=name, idle_s=self.desk_idle_s)@@@ -529,6 +538,7 @@ class Computer(Plugin):def stop():saved = self._end_desk_browser(name)++ self._end_desk_programs(name)out = self.desks.stop(name)return dict(out, profile_saved=saved) if saved is not None else outreturn self._act(cmd, level, stop, "stop %s (and close what runs on it)" % what, threaded=False)@@@ -905,20 +915,63 @@ class Computer(Plugin):raise Fault("bad_value", "stdin is a string of at most 64 KB")timeout = _int(v.get("timeout_s"), "timeout_s", 1, self.timeout_s, self.timeout_s)deadline = cmd.deadline++ desk = v.get("desk", "main")++ detach = v.get("detach", False)++ if not isinstance(detach, bool):++ raise Fault("bad_value", "detach is true or false")++ env = None++ if desk != "main":++ env = self._desk_env(v) # checks the name and that the desk runs++ self.desk_used[desk] = self.clock()++ if detach:++ if desk == "main":++ raise Fault("not_allowed", "detach is for a desk only: a program left running on your own session is never started")++ if self.desks.kind(desk) == "browser":++ raise Fault("unsupported", "a browser desk has no screen for a program's windows: start a whole desk (kind desktop)")++ if stdin is not None:++ raise Fault("bad_value", "a detached program takes no stdin")++ where = "the private desk" if desk == "private" else "desk %s" % desk++ return (lambda: self._start_on_desk(argv, cwd, env, desk),++ "start %s on %s (it keeps running until the desk stops)" % (shlex.join(argv)[:300], where))def run():left = deadline - self.clock() - 1.0if left < 1:raise Fault("expired", "no time was left to run it")-- return self._exec(argv, cwd, stdin, min(timeout, left))++ return self._exec(argv, cwd, stdin, min(timeout, left), env=env)-- return run, "run %s in %s" % (shlex.join(argv)[:300], cwd)++ where = "" if desk == "main" else (" on the private desk" if desk == "private" else " on desk %s" % desk)++ return run, "run %s in %s%s" % (shlex.join(argv)[:300], cwd, where)-- def _exec(self, argv, cwd, stdin, timeout):++ def _start_on_desk(self, argv, cwd, env, desk):++ """A program that keeps running on a desk (its windows there), ended when the desk stops."""++ running = [p for p in self.desk_programs.get(desk, []) if p.poll() is None]++ if len(running) >= MAX_DESK_PROGRAMS:++ raise Fault("not_ready", "%d programs already run on this desk: stop the desk, or close one there" % len(running))++ try:++ proc = self.popen(argv, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,++ stderr=subprocess.DEVNULL, start_new_session=True)++ except FileNotFoundError:++ raise Fault("bad_value", "no program %s on this machine" % argv[0])++ except PermissionError:++ raise Fault("not_allowed", "%s may not be run by the Node's account" % argv[0])++ self.desk_programs[desk] = running + [proc]++ return {"started": True, "pid": proc.pid, "desk": desk}++++ def _end_desk_programs(self, desk=None):++ """End what was started on a desk with detach (every desk's, without a name), each by its own handle."""++ names = [desk] if desk else list(self.desk_programs)++ for name in names:++ for proc in self.desk_programs.pop(name, []):++ if proc.poll() is None:++ self._kill(proc)++++ def _exec(self, argv, cwd, stdin, timeout, env=None):started = self.clock()try:proc = self.popen(argv, cwd=cwd, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,-- stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)++ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True,++ **({"env": env} if env else {}))except FileNotFoundError:raise Fault("bad_value", "no program %s on this machine" % argv[0])except PermissionError:@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-computer"--version = "0.13.0"++version = "0.14.0"description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -7,6 +7,7 @@ import ioimport jsonimport osimport shutil++import subprocessimport sysimport tempfileimport unittest@@@ -528,6 +529,54 @@ class DeskPluginTest(unittest.TestCase):caps = self.plugin.describe({"level": "trusted"})self.assertTrue(caps["desk"]["profiles"])++ def test_programs_on_a_desk(self):++ # Node v0.26.0: a program runs in a desk's session (its windows there); detach starts one that keeps running++ # and ends with its desk. Real processes stand in for the programs: env shows where it ran, sleep keeps running.++ self.boot("trusted", max_desks=2)++ fake = self.plugin.popen++ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-a", "width": 1000, "height": 500}))++ started = []++++ def real(argv, **kw):++ self.calls.append((argv, kw.get("env")))++ stand_in = ["env"] if kw.get("stdout") == subprocess.PIPE else ["sleep", "30"]++ proc = subprocess.Popen(stand_in, **{k: v for k, v in kw.items() if k != "cwd"})++ started.append(proc)++ return proc++ self.plugin.popen = real++ r = reply(self.out, self.cmd("exec", {"argv": ["xterm", "-version"], "desk": "desk-a"}))++ self.assertEqual(r["op"], "ack", r)++ self.assertIn("DISPLAY=:92", r["data"]["stdout"], "in the desk's session")++ r = reply(self.out, self.cmd("exec", {"argv": ["true"]}))++ self.assertNotIn("DISPLAY=:92", r["data"]["stdout"], "without desk: the person's own session, as before")++ # Detached on the desk: answered at once, still running, ended when the desk stops.++ r = reply(self.out, self.cmd("exec", {"argv": ["libreoffice", "--writer"], "desk": "desk-a", "detach": True}))++ self.assertEqual((r["data"]["started"], r["data"]["desk"]), (True, "desk-a"), r)++ office = started[-1]++ self.assertEqual(r["data"]["pid"], office.pid)++ self.assertEqual(self.calls[-1][1]["DISPLAY"], ":92")++ self.assertIsNone(office.poll(), "still running")++ self.plugin.popen = fake++ reply(self.out, self.cmd("desk", {"action": "stop", "desk": "desk-a"}))++ self.assertEqual(office.wait(timeout=5), -9, "ended with its desk")++ # Never detached on the person's own session or a browser desk; a desk that does not run is not_ready.++ self.plugin.popen = real++ before = len(started)++ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "detach": True}))["fault_code"], "not_allowed")++ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-b", "kind": "browser"}))++ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-b", "detach": True}))["fault_code"], "unsupported")++ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-c"}))["fault_code"], "not_ready")++ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-a", "detach": "yes"}))["fault_code"], "bad_value")++ self.assertEqual(len(started), before, "nothing refused was started")++ # The kill switch ends every desk's programs.++ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-d"}))++ reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-d", "detach": True}))++ term = started[-1]++ self.plugin.popen = fake++ self.plugin.stop()++ self.assertEqual(term.wait(timeout=5), -9)++ self.assertTrue(self.plugin.describe({"level": "trusted"})["terminal"]["desks"])++def test_the_kill_switch_stops_the_desk(self):self.boot("trusted")reply(self.out, self.cmd("desk", {"action": "start"}))@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]# The Node release this runtime ships with (pyproject.toml carries the same).--__version__ = "0.25.0"++__version__ = "0.26.0"PROTOCOL_VERSION = 1HEARTBEAT_TIMEOUT_S = 1.0DEFAULT_DEADLINE_MS = 300@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-plugin"--version = "0.25.0"++version = "0.26.0"description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)def test_the_package_reports_the_node_release(self):-- self.assertEqual(openvibe_plugin.__version__, "0.25.0")++ self.assertEqual(openvibe_plugin.__version__, "0.26.0")with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
@@ -5,8 +5,8 @@
@@ -5,8 +5,8 @@
"wave": "T15",
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
"kind": "client",
- "version": "0.25.0",
- "release": "v0.25.0, published 2026-10-27 as a GitHub release (the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
+ "version": "0.26.0",
+ "release": "v0.26.0, published 2026-10-27 as a GitHub release (programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -78,7 +78,7 @@ acting actions until `openvibe-node resume`. `halt` kills what runs.
@@ -78,7 +78,7 @@ acting actions until `openvibe-node resume`. `halt` kills what runs.
| kind | value | answer (`ack.data`) |
| kind | value | answer (`ack.data`) |
|----------|-----------------------------------------------------------------------------------------------|---------------------|
|----------|-----------------------------------------------------------------------------------------------|---------------------|
-| `exec` | `argv` (a program and its arguments: never a shell line), `cwd`, `timeout_s`, `stdin` (≤ 64 KB) | `exit_code`, `stdout`, `stderr` (each cut at `max_output_bytes`), `truncated`, `timed_out`, `duration_ms` |
+| `exec` | `argv` (a program and its arguments: never a shell line), `cwd`, `timeout_s`, `stdin` (≤ 64 KB), `desk`, `detach` (Node v0.26.0: see "Programs on a desk") | `exit_code`, `stdout`, `stderr` (each cut at `max_output_bytes`), `truncated`, `timed_out`, `duration_ms`; detached: `started`, `pid`, `desk` |
| `fs` | `op` `list` / `stat` / `read` (`offset`, `length`) / `write` (`content_b64`, `mode` `create`, `overwrite` or `append`) / `mkdir`, and an absolute `path` | `entries` (≤ 1000), the stat, `content_b64` (≤ `max_read_bytes`), `written`, `created` |
| `fs` | `op` `list` / `stat` / `read` (`offset`, `length`) / `write` (`content_b64`, `mode` `create`, `overwrite` or `append`) / `mkdir`, and an absolute `path` | `entries` (≤ 1000), the stat, `content_b64` (≤ `max_read_bytes`), `written`, `created` |
| `probe` | `target`: `http(s)://…` (one GET, no redirect followed) or `tcp://host:port`; `timeout_s` | `status`, `content_type`, `etag`, `last_modified`, `body` (≤ 64 KB), `ms`; or `open`, `ms` |
| `probe` | `target`: `http(s)://…` (one GET, no redirect followed) or `tcp://host:port`; `timeout_s` | `status`, `content_type`, `etag`, `last_modified`, `body` (≤ 64 KB), `ms`; or `open`, `ms` |
| `screen` | `max_width` (default 1280) | `format` (`jpeg` with Pillow, else `png` ≤ 300 KB), `width`, `height`, `content_b64` |
| `screen` | `max_width` (default 1280) | `format` (`jpeg` with Pillow, else `png` ≤ 300 KB), `width`, `height`, `content_b64` |
@@ -201,6 +201,21 @@ window manager.
@@ -201,6 +201,21 @@ window manager.
size (1280×800 by default).
size (1280×800 by default).
- `private` is always a whole desk; a whole desk is `"kind": "desktop"`, the default.
- `private` is always a whole desk; a whole desk is `"kind": "desktop"`, the default.
+### Programs on a desk (`exec` with `desk` and `detach`, Node v0.26.0)
+
+A whole desk is a whole computer, so an agent can work in real applications there, never on your screen.
+- **`"desk": "desk-<name>"`** (or `private`) runs the program in that desk's session: its display, session bus and
+ temp folder. A program with windows opens on the desk.
+- **`"detach": true`** starts a program that keeps running and answers at once with its `pid`: an office suite, a
+ terminal, an editor. It is ended when its desk stops: by its agent, by `desk_idle_s`, or by the kill switch. At most
+ 16 run on one desk.
+- **Never on your own session or a browser desk.** A browser desk has no screen for windows.
+- **Desk templates** come from this: a *research desk* is a browser desk; an *office desk* is a whole desk with an
+ office suite opened on it (`libreoffice --writer report.odt`); a *dev desk* is a whole desk with a terminal and an
+ editor. On a desk host, install the applications its desks should have (`apt install libreoffice xterm`).
+- `exec` keeps its rules: the folders you allow, the level (approved one by one at `ask`), and `allow_exec`.
+ `describe` says `terminal.desks: true`.
+
### Kept profiles (`"profile": "<name>"`, Node v0.24.0)
### Kept profiles (`"profile": "<name>"`, Node v0.24.0)
Each agent task gets a desk of its own, and with it a fresh browser. A **kept profile** carries the person's sign-ins
Each agent task gets a desk of its own, and with it a fresh browser. A **kept profile** carries the person's sign-ins
@@ -16,8 +16,12 @@ and deny what waits; the core refuses acting commands while either is latched to
@@ -16,8 +16,12 @@ and deny what waits; the core refuses acting commands while either is latched to
Command kinds and their answers (the ack's ``data``):
Command kinds and their answers (the ack's ``data``):
-* ``exec`` ``{"argv": [...], "cwd"?, "timeout_s"?, "stdin"?}`` → ``{exit_code, stdout, stderr, truncated, timed_out,
- duration_ms}``. A program and its arguments, never a shell line; output is cut at ``max_output_bytes``.
+* ``exec`` ``{"argv": [...], "cwd"?, "timeout_s"?, "stdin"?, "desk"?, "detach"?}`` → ``{exit_code, stdout, stderr,
+ truncated, timed_out, duration_ms}``. A program and its arguments, never a shell line; output is cut at
+ ``max_output_bytes``. ``desk`` (Node v0.26) runs it in that desk's session (its display, bus and temp folder), so a
+ program with windows opens on the desk. ``detach`` (a whole desk only) starts a program that keeps running (an office
+ suite, a terminal) and answers at once ``{started, pid, desk}``; it is ended when its desk stops (at most
+ MAX_DESK_PROGRAMS per desk).
* ``fs`` ``{"op": "list"|"stat"|"read"|"write"|"mkdir", "path", …}``: ``read`` takes ``offset``/``length`` and answers
* ``fs`` ``{"op": "list"|"stat"|"read"|"write"|"mkdir", "path", …}``: ``read`` takes ``offset``/``length`` and answers
``content_b64``; ``write`` takes ``content_b64`` and ``mode`` (``create``, ``overwrite`` or ``append``).
``content_b64``; ``write`` takes ``content_b64`` and ``mode`` (``create``, ``overwrite`` or ``append``).
* ``screen`` ``{"max_width"?}`` → ``{format, width, height, content_b64}`` (JPEG through Pillow when it is installed).
* ``screen`` ``{"max_width"?}`` → ``{format, width, height, content_b64}`` (JPEG through Pillow when it is installed).
@@ -120,6 +124,7 @@ INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scro
@@ -120,6 +124,7 @@ INPUT_ACTIONS = frozenset({"move", "click", "double_click", "right_click", "scro
BROWSER_READS = frozenset({"read", "screenshot", "close"})
BROWSER_READS = frozenset({"read", "screenshot", "close"})
BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
MAX_URL = 2048
MAX_URL = 2048
+MAX_DESK_PROGRAMS = 16
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
SESSION_COOKIES = "ov-session-cookies.json" # in a kept profile: the cookies that end with the browser
SESSION_COOKIES = "ov-session-cookies.json" # in a kept profile: the cookies that end with the browser
# What a kept profile leaves out: caches, crash reports, locks and the last session's tabs (a desk opens on a blank page).
# What a kept profile leaves out: caches, crash reports, locks and the last session's tabs (a desk opens on a blank page).
@@ -180,7 +185,7 @@ def _int(v, name, lo, hi, default):
@@ -180,7 +185,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.13.0"
+ version = "0.14.0"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
@@ -238,6 +243,7 @@ class Computer(Plugin):
@@ -238,6 +243,7 @@ class Computer(Plugin):
self.desk_restored = set() # desks whose browser started from their kept profile (so it is saved back on stop)
self.desk_restored = set() # desks whose browser started from their kept profile (so it is saved back on stop)
self.profile_locks = {} # profile name -> one copy in or out at a time
self.profile_locks = {} # profile name -> one copy in or out at a time
self.desk_profile_max_mb = 256
self.desk_profile_max_mb = 256
+ self.desk_programs = {} # desk name -> programs started there with detach, ended with the desk
self.desk_idle_s = 900
self.desk_idle_s = 900
self.browser_locks = {} # "main" or a desk name -> one action at a time in that browser
self.browser_locks = {} # "main" or a desk name -> one action at a time in that browser
self.resolve = resolve or (lambda host: [ai[4][0] for ai in socket.getaddrinfo(host, None)])
self.resolve = resolve or (lambda host: [ai[4][0] for ai in socket.getaddrinfo(host, None)])
@@ -285,7 +291,8 @@ class Computer(Plugin):
@@ -285,7 +291,8 @@ class Computer(Plugin):
return {}
return {}
caps = {"files": {"level": self.level, "roots": self.roots, "max_read_bytes": self.max_read}}
caps = {"files": {"level": self.level, "roots": self.roots, "max_read_bytes": self.max_read}}
if self.allow_exec:
if self.allow_exec:
- caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output}
+ caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output,
+ "desks": bool(self._desk_kinds())} # exec takes desk and detach (Node v0.26.0)
tool = self._screen_tool()
tool = self._screen_tool()
if tool:
if tool:
caps["screen"] = {"level": self.level, "tool": tool[0],
caps["screen"] = {"level": self.level, "tool": tool[0],
@@ -343,6 +350,7 @@ class Computer(Plugin):
@@ -343,6 +350,7 @@ class Computer(Plugin):
self._close_browser()
self._close_browser()
self._end_stream("stopped on the machine")
self._end_stream("stopped on the machine")
self._close_desk_browsers()
self._close_desk_browsers()
+ self._end_desk_programs()
self.desks.stop_all()
self.desks.stop_all()
self._drop_desk_profiles()
self._drop_desk_profiles()
for cid in waiting:
for cid in waiting:
@@ -369,6 +377,7 @@ class Computer(Plugin):
@@ -369,6 +377,7 @@ class Computer(Plugin):
for name in self.desks.running_names():
for name in self.desks.running_names():
if now - self.desk_used.get(name, now) > self.desk_idle_s:
if now - self.desk_used.get(name, now) > self.desk_idle_s:
self._end_desk_browser(name)
self._end_desk_browser(name)
+ self._end_desk_programs(name)
self.desks.stop(name)
self.desks.stop(name)
self.desk_used.pop(name, None)
self.desk_used.pop(name, None)
self.emit_event("desk_idle_stopped", desk=name, idle_s=self.desk_idle_s)
self.emit_event("desk_idle_stopped", desk=name, idle_s=self.desk_idle_s)
@@ -529,6 +538,7 @@ class Computer(Plugin):
@@ -529,6 +538,7 @@ class Computer(Plugin):
def stop():
def stop():
saved = self._end_desk_browser(name)
saved = self._end_desk_browser(name)
+ self._end_desk_programs(name)
out = self.desks.stop(name)
out = self.desks.stop(name)
return dict(out, profile_saved=saved) if saved is not None else out
return dict(out, profile_saved=saved) if saved is not None else out
return self._act(cmd, level, stop, "stop %s (and close what runs on it)" % what, threaded=False)
return self._act(cmd, level, stop, "stop %s (and close what runs on it)" % what, threaded=False)
@@ -905,20 +915,63 @@ class Computer(Plugin):
@@ -905,20 +915,63 @@ class Computer(Plugin):
raise Fault("bad_value", "stdin is a string of at most 64 KB")
raise Fault("bad_value", "stdin is a string of at most 64 KB")
timeout = _int(v.get("timeout_s"), "timeout_s", 1, self.timeout_s, self.timeout_s)
timeout = _int(v.get("timeout_s"), "timeout_s", 1, self.timeout_s, self.timeout_s)
deadline = cmd.deadline
deadline = cmd.deadline
+ desk = v.get("desk", "main")
+ detach = v.get("detach", False)
+ if not isinstance(detach, bool):
+ raise Fault("bad_value", "detach is true or false")
+ env = None
+ if desk != "main":
+ env = self._desk_env(v) # checks the name and that the desk runs
+ self.desk_used[desk] = self.clock()
+ if detach:
+ if desk == "main":
+ raise Fault("not_allowed", "detach is for a desk only: a program left running on your own session is never started")
+ if self.desks.kind(desk) == "browser":
+ raise Fault("unsupported", "a browser desk has no screen for a program's windows: start a whole desk (kind desktop)")
+ if stdin is not None:
+ raise Fault("bad_value", "a detached program takes no stdin")
+ where = "the private desk" if desk == "private" else "desk %s" % desk
+ return (lambda: self._start_on_desk(argv, cwd, env, desk),
+ "start %s on %s (it keeps running until the desk stops)" % (shlex.join(argv)[:300], where))
def run():
def run():
left = deadline - self.clock() - 1.0
left = deadline - self.clock() - 1.0
if left < 1:
if left < 1:
raise Fault("expired", "no time was left to run it")
raise Fault("expired", "no time was left to run it")
- return self._exec(argv, cwd, stdin, min(timeout, left))
+ return self._exec(argv, cwd, stdin, min(timeout, left), env=env)
- return run, "run %s in %s" % (shlex.join(argv)[:300], cwd)
+ where = "" if desk == "main" else (" on the private desk" if desk == "private" else " on desk %s" % desk)
+ return run, "run %s in %s%s" % (shlex.join(argv)[:300], cwd, where)
- def _exec(self, argv, cwd, stdin, timeout):
+ def _start_on_desk(self, argv, cwd, env, desk):
+ """A program that keeps running on a desk (its windows there), ended when the desk stops."""
+ running = [p for p in self.desk_programs.get(desk, []) if p.poll() is None]
+ if len(running) >= MAX_DESK_PROGRAMS:
+ raise Fault("not_ready", "%d programs already run on this desk: stop the desk, or close one there" % len(running))
+ try:
+ proc = self.popen(argv, cwd=cwd, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, start_new_session=True)
+ except FileNotFoundError:
+ raise Fault("bad_value", "no program %s on this machine" % argv[0])
+ except PermissionError:
+ raise Fault("not_allowed", "%s may not be run by the Node's account" % argv[0])
+ self.desk_programs[desk] = running + [proc]
+ return {"started": True, "pid": proc.pid, "desk": desk}
+
+ def _end_desk_programs(self, desk=None):
+ """End what was started on a desk with detach (every desk's, without a name), each by its own handle."""
+ names = [desk] if desk else list(self.desk_programs)
+ for name in names:
+ for proc in self.desk_programs.pop(name, []):
+ if proc.poll() is None:
+ self._kill(proc)
+
+ def _exec(self, argv, cwd, stdin, timeout, env=None):
started = self.clock()
started = self.clock()
try:
try:
proc = self.popen(argv, cwd=cwd, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
proc = self.popen(argv, cwd=cwd, stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True,
+ **({"env": env} if env else {}))
except FileNotFoundError:
except FileNotFoundError:
raise Fault("bad_value", "no program %s on this machine" % argv[0])
raise Fault("bad_value", "no program %s on this machine" % argv[0])
except PermissionError:
except PermissionError:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
[project]
name = "openvibe-computer"
name = "openvibe-computer"
-version = "0.13.0"
+version = "0.14.0"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -7,6 +7,7 @@ import io
@@ -7,6 +7,7 @@ import io
import json
import json
import os
import os
import shutil
import shutil
+import subprocess
import sys
import sys
import tempfile
import tempfile
import unittest
import unittest
@@ -528,6 +529,54 @@ class DeskPluginTest(unittest.TestCase):
@@ -528,6 +529,54 @@ class DeskPluginTest(unittest.TestCase):
caps = self.plugin.describe({"level": "trusted"})
caps = self.plugin.describe({"level": "trusted"})
self.assertTrue(caps["desk"]["profiles"])
self.assertTrue(caps["desk"]["profiles"])
+ def test_programs_on_a_desk(self):
+ # Node v0.26.0: a program runs in a desk's session (its windows there); detach starts one that keeps running
+ # and ends with its desk. Real processes stand in for the programs: env shows where it ran, sleep keeps running.
+ self.boot("trusted", max_desks=2)
+ fake = self.plugin.popen
+ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-a", "width": 1000, "height": 500}))
+ started = []
+
+ def real(argv, **kw):
+ self.calls.append((argv, kw.get("env")))
+ stand_in = ["env"] if kw.get("stdout") == subprocess.PIPE else ["sleep", "30"]
+ proc = subprocess.Popen(stand_in, **{k: v for k, v in kw.items() if k != "cwd"})
+ started.append(proc)
+ return proc
+ self.plugin.popen = real
+ r = reply(self.out, self.cmd("exec", {"argv": ["xterm", "-version"], "desk": "desk-a"}))
+ self.assertEqual(r["op"], "ack", r)
+ self.assertIn("DISPLAY=:92", r["data"]["stdout"], "in the desk's session")
+ r = reply(self.out, self.cmd("exec", {"argv": ["true"]}))
+ self.assertNotIn("DISPLAY=:92", r["data"]["stdout"], "without desk: the person's own session, as before")
+ # Detached on the desk: answered at once, still running, ended when the desk stops.
+ r = reply(self.out, self.cmd("exec", {"argv": ["libreoffice", "--writer"], "desk": "desk-a", "detach": True}))
+ self.assertEqual((r["data"]["started"], r["data"]["desk"]), (True, "desk-a"), r)
+ office = started[-1]
+ self.assertEqual(r["data"]["pid"], office.pid)
+ self.assertEqual(self.calls[-1][1]["DISPLAY"], ":92")
+ self.assertIsNone(office.poll(), "still running")
+ self.plugin.popen = fake
+ reply(self.out, self.cmd("desk", {"action": "stop", "desk": "desk-a"}))
+ self.assertEqual(office.wait(timeout=5), -9, "ended with its desk")
+ # Never detached on the person's own session or a browser desk; a desk that does not run is not_ready.
+ self.plugin.popen = real
+ before = len(started)
+ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "detach": True}))["fault_code"], "not_allowed")
+ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-b", "kind": "browser"}))
+ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-b", "detach": True}))["fault_code"], "unsupported")
+ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-c"}))["fault_code"], "not_ready")
+ self.assertEqual(reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-a", "detach": "yes"}))["fault_code"], "bad_value")
+ self.assertEqual(len(started), before, "nothing refused was started")
+ # The kill switch ends every desk's programs.
+ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-d"}))
+ reply(self.out, self.cmd("exec", {"argv": ["xterm"], "desk": "desk-d", "detach": True}))
+ term = started[-1]
+ self.plugin.popen = fake
+ self.plugin.stop()
+ self.assertEqual(term.wait(timeout=5), -9)
+ self.assertTrue(self.plugin.describe({"level": "trusted"})["terminal"]["desks"])
+
def test_the_kill_switch_stops_the_desk(self):
def test_the_kill_switch_stops_the_desk(self):
self.boot("trusted")
self.boot("trusted")
reply(self.out, self.cmd("desk", {"action": "start"}))
reply(self.out, self.cmd("desk", {"action": "start"}))
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
# The Node release this runtime ships with (pyproject.toml carries the same).
-__version__ = "0.25.0"
+__version__ = "0.26.0"
PROTOCOL_VERSION = 1
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
DEFAULT_DEADLINE_MS = 300
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
[project]
name = "openvibe-plugin"
name = "openvibe-plugin"
-version = "0.25.0"
+version = "0.26.0"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
def test_the_package_reports_the_node_release(self):
def test_the_package_reports_the_node_release(self):
- self.assertEqual(openvibe_plugin.__version__, "0.25.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.26.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())