openvibers/openvibe.link Commits
v0.22.0: a desk's temp folder is short (Chrome closed as it started on a desk host: its SingletonSocket path was too long), and an unused desk stops after desk_idle_s (#68)
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.21.0",-- "release": "v0.21.0, published 2026-10-27 as a GitHub release (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.22.0",++ "release": "v0.22.0, published 2026-10-27 as a GitHub release (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,@@@ -152,7 +152,9 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vib- its own temp folder (`TMPDIR`), so its clipboard, cookies, files and windows never meet another desk's.Two agents can work at once without either seeing or driving the other's screen, and two desks' browsers act in-- parallel. `{"action": "list"}` reports the desks that run. `max_desks` caps how many run at once (by default one per++ parallel. `{"action": "list"}` reports the desks that run. A desk no command used for `desk_idle_s` (15 minutes by++ default) is stopped, with an event `desk_idle_stopped`: a desk an agent left behind when it crashed or restarted++ mid-task ends by itself. `max_desks` caps how many run at once (by default one per2 GiB of memory, 1 to 8); one more is refused (`not_ready`), never queued. Stopping a desk ends only its processes,and the kill switch stops them all. The desks' own folders are under `desk_dir`(`~/.local/share/openvibe-node/desks`).@@@ -199,6 +201,7 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) || `allow_desk` | `true` | `false`: never start a private desk (no `desk` capability) || `max_desks` | memory / 2 GiB, 1 to 8 | how many desks run at once (1 to 10) |++| `desk_idle_s` | `900` | a desk no command used this long is stopped (60 to 86400) || `desk_dir` | `~/.local/share/openvibe-node/desks` | each desk's own temp and downloads folders || `allow_browser` | `true` | `false`: no browser (no `browser` capability) || `allow_screen_stream` | `true` | `false`: never show the screen live (`screen.stream` is false) |@@@ -47,7 +47,8 @@ Command kinds and their answers (the ack's ``data``):once (Node v0.19, Desks plan D1): ``"desk": "desk-<name>"`` names one (start it with ``desk`` and that name), each withits own display, bus, browser profile (``browser_profile`` + ``-<name>``), temp folder and downloads folder, so twoagents working at once never see or drive each other's screen. ``max_desks`` (default: one per 2 GiB of memory, 1 to-- 8) caps how many run at once; ``list`` reports the running ones. Starting and stopping++ 8) caps how many run at once; ``list`` reports the running ones. A desk no command used for ``desk_idle_s`` (15++ minutes by default) is stopped, so a desk an agent left behind (it crashed or restarted mid-task) ends by itself. Starting and stoppingact (refused at observe, approved at ask), ``status`` runs at every level, and the kill switch stops the desk. Linuxwith Xvfb; config ``allow_desk: false`` turns it off.* ``probe`` ``{"target", "timeout_s"?}`` → for ``http(s)://…`` one GET, no redirect followed: ``{status, content_type,@@@ -143,7 +144,7 @@ def _int(v, name, lo, hi, default):class Computer(Plugin):driver = "computer"-- version = "0.10.0"++ version = "0.10.1"motion_kinds = frozenset()telemetry_interval_s = 2.0@@@ -191,6 +192,8 @@ class Computer(Plugin):self.desks = Desks(self.desk_factory, private=self.desk, max_desks=default_max_desks())self.allow_desk = Trueself.desk_browsers = {} # desk name -> the agent's browser on that desk (its own profile, shown there)++ self.desk_used = {} # desk name -> when a command last used it (clock), for desk_idle_s++ self.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)])@@@ -218,6 +221,7 @@ class Computer(Plugin):self.allow_screen_stream = config.get("allow_screen_stream", True) is not Falseself.allow_desk = config.get("allow_desk", True) is not Falseself.desks.max_desks = _int(config.get("max_desks"), "max_desks", 1, MAX_DESKS_CAP, default_max_desks())++ self.desk_idle_s = _int(config.get("desk_idle_s"), "desk_idle_s", 60, 86400, 900)self.desks.base_dir = os.path.expanduser(config.get("desk_dir") or "~/.local/share/openvibe-node/desks")self.browser_path = config.get("browser_path") or Noneself.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")@@@ -292,6 +296,17 @@ class Computer(Plugin):b = self.browserif b is not None and b.alive() and b.used_at and time.monotonic() - b.used_at > self.browser_idle_s:self._close_browser()++ # A desk no command used for desk_idle_s is stopped: an agent that crashed or restarted mid-task never ran its++ # own stop, and a desk left running would hold its share of the machine and its browser's sessions.++ for name in self.desks.running_names():++ if now - self.desk_used.get(name, now) > self.desk_idle_s:++ self._close_desk_browser(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)++ self.log("desk %s stopped after %d s without a command", name, self.desk_idle_s)++ elif name not in self.desk_used:++ self.desk_used[name] = nowexpired = []with self.mu:for cid, (_, at, _) in list(self.pending.items()):@@@ -339,6 +354,8 @@ class Computer(Plugin):return self._desk_op(cmd, v, level)desk_env = self._desk_env(v) if cmd.kind in ("screen", "screen_stream", "input", "browser") else Nonedesk_name = v.get("desk") if desk_env else None++ if desk_name:++ self.desk_used[desk_name] = self.clock()if cmd.kind == "screen":tool = self._x11_tool() if desk_env else self._screen_tool()if not tool:@@@ -401,6 +418,7 @@ class Computer(Plugin):return self.desks.status(name)if action not in ("start", "stop"):raise Fault("bad_value", "action is start, stop, status or list")++ self.desk_used[name] = self.clock()if not self.allow_desk or not self.platform.startswith("linux"):raise Fault("unsupported", "this machine does not offer a private desk (Linux with Xvfb, allow_desk on)")what = "a private desk" if name == "private" else "desk %s" % name@@@ -15,6 +15,7 @@ from __future__ import annotationsimport osimport reimport shutil++import tempfileimport signalimport subprocessimport threading@@@ -211,6 +212,7 @@ class Desks:self.base_dir = base_dirself.mu = threading.Lock()self.desks = {}++ self.tmpdirs = {} # desk name -> its own temp folder (short: under the system's temp folder)if private is not None:self.desks["private"] = private@@@ -256,12 +258,17 @@ class Desks:d = self.desks.get(name)if d is None:return {"running": False, "stopped": False, "desk": name}-- return dict(d.stop(), desk=name)++ out = dict(d.stop(), desk=name)++ self._drop_tmp(name)++ return outdef stop_all(self):with self.mu:desks = list(self.desks.items())-- return [n for n, d in desks if d.stop().get("stopped")]++ stopped = [n for n, d in desks if d.stop().get("stopped")]++ for n, _ in desks:++ self._drop_tmp(n)++ return stoppeddef status(self, name):self.check(name)@@@ -285,12 +292,23 @@ class Desks:if d is None:raise Fault("not_ready", "start the desk first (desk start, desk %s)" % name)env = d.env()-- if self.base_dir:-- tmp = os.path.join(self.base_dir, name, "tmp")-- os.makedirs(tmp, mode=0o700, exist_ok=True)-- env["TMPDIR"] = tmp++ # A temp folder of the desk's own, made once per run of the desk (0700) and removed when it stops. It sits in++ # the system's temp folder, not under desk_dir: Chrome puts its SingletonSocket in TMPDIR, and a Unix socket's++ # path must fit 108 bytes (a desk_dir path on a desk host, /var/lib/…/desks/<name>/tmp/.com.google.Chrome.…,++ # did not, and Chrome closed as it started).++ with self.mu:++ tmp = self.tmpdirs.get(name)++ if tmp is None or not os.path.isdir(tmp):++ tmp = self.tmpdirs[name] = tempfile.mkdtemp(prefix="ov-%s-" % name)++ env["TMPDIR"] = tmpreturn env++ def _drop_tmp(self, name):++ with self.mu:++ tmp = self.tmpdirs.pop(name, None)++ if tmp:++ shutil.rmtree(tmp, ignore_errors=True)++def dir(self, name, part):"""A folder of the desk's own (downloads, …), 0700, under base_dir; None without one."""if not self.base_dir:@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-computer"--version = "0.10.0"++version = "0.10.1"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" }@@@ -202,12 +202,13 @@ class DeskPluginTest(unittest.TestCase):self.assertEqual(self.calls[-1][1]["DISPLAY"], ":92")reply(self.out, self.cmd("input", {"action": "click", "x": 1, "y": 1, "width": 100, "height": 100, "desk": "desk-b"}))self.assertEqual(self.calls[-1][1]["DISPLAY"], ":93")-- # A temp folder each, inside the desks folder, 0700.++ # A temp folder each, 0700, short enough for a Unix socket in it (Chrome's SingletonSocket: 108 bytes).tmp_a = self.calls[-2][1]["TMPDIR"]tmp_b = self.calls[-1][1]["TMPDIR"]self.assertNotEqual(tmp_a, tmp_b)-- self.assertTrue(tmp_a.startswith(self.desk_dir) and os.path.isdir(tmp_a))++ self.assertTrue(os.path.isdir(tmp_a) and os.path.basename(tmp_a).startswith("ov-desk-a-"))self.assertEqual(os.stat(tmp_a).st_mode & 0o777, 0o700)++ self.assertLess(len(os.path.join(tmp_a, ".com.google.Chrome.XXXXXX", "SingletonSocket")), 108)# list reports both; a desk that never started is not_ready; names are checked.listed = reply(self.out, self.cmd("desk", {"action": "list"}))["data"]self.assertEqual(([d["desk"] for d in listed["desks"]], listed["max_desks"]), (["desk-a", "desk-b"], 2))@@@ -217,9 +218,27 @@ class DeskPluginTest(unittest.TestCase):# Stopping one leaves the other running; the kill switch stops every desk.self.assertEqual(reply(self.out, self.cmd("desk", {"action": "stop", "desk": "desk-a"}))["data"]["stopped"], True)self.assertEqual([d.on for d in self.made_desks], [False, True])++ self.assertFalse(os.path.exists(tmp_a), "a stopped desk's temp folder is removed")self.plugin.stop()self.assertEqual([d.on for d in self.made_desks], [False, False])++ def test_a_desk_nobody_uses_stops_by_itself(self):++ # An agent that crashed or restarted mid-task never stops its desk: desk_idle_s does.++ self.boot("trusted", max_desks=2, desk_idle_s=60)++ now = [1000.0]++ self.plugin.clock = lambda: now[0]++ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-a"}))++ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-b"}))++ now[0] += 40++ reply(self.out, self.cmd("input", {"action": "move", "x": 1, "y": 1, "width": 10, "height": 10, "desk": "desk-b"}))++ now[0] += 30++ self.plugin.poll()++ self.assertEqual([d.on for d in self.made_desks], [False, True], "a, 70 s without a command, stopped; b was used 30 s ago")++ self.assertTrue(any(m.get("op") == "event" and m.get("name") == "desk_idle_stopped" and m.get("desk") == "desk-a" for m in self.out.msgs()))++ now[0] += 61++ self.plugin.poll()++ self.assertEqual([d.on for d in self.made_desks], [False, False])++def test_each_desk_has_a_browser_profile_and_downloads_of_its_own(self):made = []@@@ -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.21.0"++__version__ = "0.22.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.21.0"++version = "0.22.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.21.0")++ self.assertEqual(openvibe_plugin.__version__, "0.22.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.21.0",
- "release": "v0.21.0, published 2026-10-27 as a GitHub release (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.22.0",
+ "release": "v0.22.0, published 2026-10-27 as a GitHub release (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,
@@ -152,7 +152,9 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vib
@@ -152,7 +152,9 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vib
- its own temp folder (`TMPDIR`), so its clipboard, cookies, files and windows never meet another desk's.
- its own temp folder (`TMPDIR`), so its clipboard, cookies, files and windows never meet another desk's.
Two agents can work at once without either seeing or driving the other's screen, and two desks' browsers act in
Two agents can work at once without either seeing or driving the other's screen, and two desks' browsers act in
- parallel. `{"action": "list"}` reports the desks that run. `max_desks` caps how many run at once (by default one per
+ parallel. `{"action": "list"}` reports the desks that run. A desk no command used for `desk_idle_s` (15 minutes by
+ default) is stopped, with an event `desk_idle_stopped`: a desk an agent left behind when it crashed or restarted
+ mid-task ends by itself. `max_desks` caps how many run at once (by default one per
2 GiB of memory, 1 to 8); one more is refused (`not_ready`), never queued. Stopping a desk ends only its processes,
2 GiB of memory, 1 to 8); one more is refused (`not_ready`), never queued. Stopping a desk ends only its processes,
and the kill switch stops them all. The desks' own folders are under `desk_dir`
and the kill switch stops them all. The desks' own folders are under `desk_dir`
(`~/.local/share/openvibe-node/desks`).
(`~/.local/share/openvibe-node/desks`).
@@ -199,6 +201,7 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_
@@ -199,6 +201,7 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_
| `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_desk` | `true` | `false`: never start a private desk (no `desk` capability) |
| `allow_desk` | `true` | `false`: never start a private desk (no `desk` capability) |
| `max_desks` | memory / 2 GiB, 1 to 8 | how many desks run at once (1 to 10) |
| `max_desks` | memory / 2 GiB, 1 to 8 | how many desks run at once (1 to 10) |
+| `desk_idle_s` | `900` | a desk no command used this long is stopped (60 to 86400) |
| `desk_dir` | `~/.local/share/openvibe-node/desks` | each desk's own temp and downloads folders |
| `desk_dir` | `~/.local/share/openvibe-node/desks` | each desk's own temp and downloads folders |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
| `allow_screen_stream` | `true` | `false`: never show the screen live (`screen.stream` is false) |
| `allow_screen_stream` | `true` | `false`: never show the screen live (`screen.stream` is false) |
@@ -47,7 +47,8 @@ Command kinds and their answers (the ack's ``data``):
@@ -47,7 +47,8 @@ Command kinds and their answers (the ack's ``data``):
once (Node v0.19, Desks plan D1): ``"desk": "desk-<name>"`` names one (start it with ``desk`` and that name), each with
once (Node v0.19, Desks plan D1): ``"desk": "desk-<name>"`` names one (start it with ``desk`` and that name), each with
its own display, bus, browser profile (``browser_profile`` + ``-<name>``), temp folder and downloads folder, so two
its own display, bus, browser profile (``browser_profile`` + ``-<name>``), temp folder and downloads folder, so two
agents working at once never see or drive each other's screen. ``max_desks`` (default: one per 2 GiB of memory, 1 to
agents working at once never see or drive each other's screen. ``max_desks`` (default: one per 2 GiB of memory, 1 to
- 8) caps how many run at once; ``list`` reports the running ones. Starting and stopping
+ 8) caps how many run at once; ``list`` reports the running ones. A desk no command used for ``desk_idle_s`` (15
+ minutes by default) is stopped, so a desk an agent left behind (it crashed or restarted mid-task) ends by itself. Starting and stopping
act (refused at observe, approved at ask), ``status`` runs at every level, and the kill switch stops the desk. Linux
act (refused at observe, approved at ask), ``status`` runs at every level, and the kill switch stops the desk. Linux
with Xvfb; config ``allow_desk: false`` turns it off.
with Xvfb; config ``allow_desk: false`` turns it off.
* ``probe`` ``{"target", "timeout_s"?}`` → for ``http(s)://…`` one GET, no redirect followed: ``{status, content_type,
* ``probe`` ``{"target", "timeout_s"?}`` → for ``http(s)://…`` one GET, no redirect followed: ``{status, content_type,
@@ -143,7 +144,7 @@ def _int(v, name, lo, hi, default):
@@ -143,7 +144,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.10.0"
+ version = "0.10.1"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
@@ -191,6 +192,8 @@ class Computer(Plugin):
@@ -191,6 +192,8 @@ class Computer(Plugin):
self.desks = Desks(self.desk_factory, private=self.desk, max_desks=default_max_desks())
self.desks = Desks(self.desk_factory, private=self.desk, max_desks=default_max_desks())
self.allow_desk = True
self.allow_desk = True
self.desk_browsers = {} # desk name -> the agent's browser on that desk (its own profile, shown there)
self.desk_browsers = {} # desk name -> the agent's browser on that desk (its own profile, shown there)
+ self.desk_used = {} # desk name -> when a command last used it (clock), for desk_idle_s
+ 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)])
@@ -218,6 +221,7 @@ class Computer(Plugin):
@@ -218,6 +221,7 @@ class Computer(Plugin):
self.allow_screen_stream = config.get("allow_screen_stream", True) is not False
self.allow_screen_stream = config.get("allow_screen_stream", True) is not False
self.allow_desk = config.get("allow_desk", True) is not False
self.allow_desk = config.get("allow_desk", True) is not False
self.desks.max_desks = _int(config.get("max_desks"), "max_desks", 1, MAX_DESKS_CAP, default_max_desks())
self.desks.max_desks = _int(config.get("max_desks"), "max_desks", 1, MAX_DESKS_CAP, default_max_desks())
+ self.desk_idle_s = _int(config.get("desk_idle_s"), "desk_idle_s", 60, 86400, 900)
self.desks.base_dir = os.path.expanduser(config.get("desk_dir") or "~/.local/share/openvibe-node/desks")
self.desks.base_dir = os.path.expanduser(config.get("desk_dir") or "~/.local/share/openvibe-node/desks")
self.browser_path = config.get("browser_path") or None
self.browser_path = config.get("browser_path") or None
self.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
self.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
@@ -292,6 +296,17 @@ class Computer(Plugin):
@@ -292,6 +296,17 @@ class Computer(Plugin):
b = self.browser
b = self.browser
if b is not None and b.alive() and b.used_at and time.monotonic() - b.used_at > self.browser_idle_s:
if b is not None and b.alive() and b.used_at and time.monotonic() - b.used_at > self.browser_idle_s:
self._close_browser()
self._close_browser()
+ # A desk no command used for desk_idle_s is stopped: an agent that crashed or restarted mid-task never ran its
+ # own stop, and a desk left running would hold its share of the machine and its browser's sessions.
+ for name in self.desks.running_names():
+ if now - self.desk_used.get(name, now) > self.desk_idle_s:
+ self._close_desk_browser(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)
+ self.log("desk %s stopped after %d s without a command", name, self.desk_idle_s)
+ elif name not in self.desk_used:
+ self.desk_used[name] = now
expired = []
expired = []
with self.mu:
with self.mu:
for cid, (_, at, _) in list(self.pending.items()):
for cid, (_, at, _) in list(self.pending.items()):
@@ -339,6 +354,8 @@ class Computer(Plugin):
@@ -339,6 +354,8 @@ class Computer(Plugin):
return self._desk_op(cmd, v, level)
return self._desk_op(cmd, v, level)
desk_env = self._desk_env(v) if cmd.kind in ("screen", "screen_stream", "input", "browser") else None
desk_env = self._desk_env(v) if cmd.kind in ("screen", "screen_stream", "input", "browser") else None
desk_name = v.get("desk") if desk_env else None
desk_name = v.get("desk") if desk_env else None
+ if desk_name:
+ self.desk_used[desk_name] = self.clock()
if cmd.kind == "screen":
if cmd.kind == "screen":
tool = self._x11_tool() if desk_env else self._screen_tool()
tool = self._x11_tool() if desk_env else self._screen_tool()
if not tool:
if not tool:
@@ -401,6 +418,7 @@ class Computer(Plugin):
@@ -401,6 +418,7 @@ class Computer(Plugin):
return self.desks.status(name)
return self.desks.status(name)
if action not in ("start", "stop"):
if action not in ("start", "stop"):
raise Fault("bad_value", "action is start, stop, status or list")
raise Fault("bad_value", "action is start, stop, status or list")
+ self.desk_used[name] = self.clock()
if not self.allow_desk or not self.platform.startswith("linux"):
if not self.allow_desk or not self.platform.startswith("linux"):
raise Fault("unsupported", "this machine does not offer a private desk (Linux with Xvfb, allow_desk on)")
raise Fault("unsupported", "this machine does not offer a private desk (Linux with Xvfb, allow_desk on)")
what = "a private desk" if name == "private" else "desk %s" % name
what = "a private desk" if name == "private" else "desk %s" % name
@@ -15,6 +15,7 @@ from __future__ import annotations
@@ -15,6 +15,7 @@ from __future__ import annotations
import os
import os
import re
import re
import shutil
import shutil
+import tempfile
import signal
import signal
import subprocess
import subprocess
import threading
import threading
@@ -211,6 +212,7 @@ class Desks:
@@ -211,6 +212,7 @@ class Desks:
self.base_dir = base_dir
self.base_dir = base_dir
self.mu = threading.Lock()
self.mu = threading.Lock()
self.desks = {}
self.desks = {}
+ self.tmpdirs = {} # desk name -> its own temp folder (short: under the system's temp folder)
if private is not None:
if private is not None:
self.desks["private"] = private
self.desks["private"] = private
@@ -256,12 +258,17 @@ class Desks:
@@ -256,12 +258,17 @@ class Desks:
d = self.desks.get(name)
d = self.desks.get(name)
if d is None:
if d is None:
return {"running": False, "stopped": False, "desk": name}
return {"running": False, "stopped": False, "desk": name}
- return dict(d.stop(), desk=name)
+ out = dict(d.stop(), desk=name)
+ self._drop_tmp(name)
+ return out
def stop_all(self):
def stop_all(self):
with self.mu:
with self.mu:
desks = list(self.desks.items())
desks = list(self.desks.items())
- return [n for n, d in desks if d.stop().get("stopped")]
+ stopped = [n for n, d in desks if d.stop().get("stopped")]
+ for n, _ in desks:
+ self._drop_tmp(n)
+ return stopped
def status(self, name):
def status(self, name):
self.check(name)
self.check(name)
@@ -285,12 +292,23 @@ class Desks:
@@ -285,12 +292,23 @@ class Desks:
if d is None:
if d is None:
raise Fault("not_ready", "start the desk first (desk start, desk %s)" % name)
raise Fault("not_ready", "start the desk first (desk start, desk %s)" % name)
env = d.env()
env = d.env()
- if self.base_dir:
- tmp = os.path.join(self.base_dir, name, "tmp")
- os.makedirs(tmp, mode=0o700, exist_ok=True)
- env["TMPDIR"] = tmp
+ # A temp folder of the desk's own, made once per run of the desk (0700) and removed when it stops. It sits in
+ # the system's temp folder, not under desk_dir: Chrome puts its SingletonSocket in TMPDIR, and a Unix socket's
+ # path must fit 108 bytes (a desk_dir path on a desk host, /var/lib/…/desks/<name>/tmp/.com.google.Chrome.…,
+ # did not, and Chrome closed as it started).
+ with self.mu:
+ tmp = self.tmpdirs.get(name)
+ if tmp is None or not os.path.isdir(tmp):
+ tmp = self.tmpdirs[name] = tempfile.mkdtemp(prefix="ov-%s-" % name)
+ env["TMPDIR"] = tmp
return env
return env
+ def _drop_tmp(self, name):
+ with self.mu:
+ tmp = self.tmpdirs.pop(name, None)
+ if tmp:
+ shutil.rmtree(tmp, ignore_errors=True)
+
def dir(self, name, part):
def dir(self, name, part):
"""A folder of the desk's own (downloads, …), 0700, under base_dir; None without one."""
"""A folder of the desk's own (downloads, …), 0700, under base_dir; None without one."""
if not self.base_dir:
if not self.base_dir:
@@ -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.10.0"
+version = "0.10.1"
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" }
@@ -202,12 +202,13 @@ class DeskPluginTest(unittest.TestCase):
@@ -202,12 +202,13 @@ class DeskPluginTest(unittest.TestCase):
self.assertEqual(self.calls[-1][1]["DISPLAY"], ":92")
self.assertEqual(self.calls[-1][1]["DISPLAY"], ":92")
reply(self.out, self.cmd("input", {"action": "click", "x": 1, "y": 1, "width": 100, "height": 100, "desk": "desk-b"}))
reply(self.out, self.cmd("input", {"action": "click", "x": 1, "y": 1, "width": 100, "height": 100, "desk": "desk-b"}))
self.assertEqual(self.calls[-1][1]["DISPLAY"], ":93")
self.assertEqual(self.calls[-1][1]["DISPLAY"], ":93")
- # A temp folder each, inside the desks folder, 0700.
+ # A temp folder each, 0700, short enough for a Unix socket in it (Chrome's SingletonSocket: 108 bytes).
tmp_a = self.calls[-2][1]["TMPDIR"]
tmp_a = self.calls[-2][1]["TMPDIR"]
tmp_b = self.calls[-1][1]["TMPDIR"]
tmp_b = self.calls[-1][1]["TMPDIR"]
self.assertNotEqual(tmp_a, tmp_b)
self.assertNotEqual(tmp_a, tmp_b)
- self.assertTrue(tmp_a.startswith(self.desk_dir) and os.path.isdir(tmp_a))
+ self.assertTrue(os.path.isdir(tmp_a) and os.path.basename(tmp_a).startswith("ov-desk-a-"))
self.assertEqual(os.stat(tmp_a).st_mode & 0o777, 0o700)
self.assertEqual(os.stat(tmp_a).st_mode & 0o777, 0o700)
+ self.assertLess(len(os.path.join(tmp_a, ".com.google.Chrome.XXXXXX", "SingletonSocket")), 108)
# list reports both; a desk that never started is not_ready; names are checked.
# list reports both; a desk that never started is not_ready; names are checked.
listed = reply(self.out, self.cmd("desk", {"action": "list"}))["data"]
listed = reply(self.out, self.cmd("desk", {"action": "list"}))["data"]
self.assertEqual(([d["desk"] for d in listed["desks"]], listed["max_desks"]), (["desk-a", "desk-b"], 2))
self.assertEqual(([d["desk"] for d in listed["desks"]], listed["max_desks"]), (["desk-a", "desk-b"], 2))
@@ -217,9 +218,27 @@ class DeskPluginTest(unittest.TestCase):
@@ -217,9 +218,27 @@ class DeskPluginTest(unittest.TestCase):
# Stopping one leaves the other running; the kill switch stops every desk.
# Stopping one leaves the other running; the kill switch stops every desk.
self.assertEqual(reply(self.out, self.cmd("desk", {"action": "stop", "desk": "desk-a"}))["data"]["stopped"], True)
self.assertEqual(reply(self.out, self.cmd("desk", {"action": "stop", "desk": "desk-a"}))["data"]["stopped"], True)
self.assertEqual([d.on for d in self.made_desks], [False, True])
self.assertEqual([d.on for d in self.made_desks], [False, True])
+ self.assertFalse(os.path.exists(tmp_a), "a stopped desk's temp folder is removed")
self.plugin.stop()
self.plugin.stop()
self.assertEqual([d.on for d in self.made_desks], [False, False])
self.assertEqual([d.on for d in self.made_desks], [False, False])
+ def test_a_desk_nobody_uses_stops_by_itself(self):
+ # An agent that crashed or restarted mid-task never stops its desk: desk_idle_s does.
+ self.boot("trusted", max_desks=2, desk_idle_s=60)
+ now = [1000.0]
+ self.plugin.clock = lambda: now[0]
+ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-a"}))
+ reply(self.out, self.cmd("desk", {"action": "start", "desk": "desk-b"}))
+ now[0] += 40
+ reply(self.out, self.cmd("input", {"action": "move", "x": 1, "y": 1, "width": 10, "height": 10, "desk": "desk-b"}))
+ now[0] += 30
+ self.plugin.poll()
+ self.assertEqual([d.on for d in self.made_desks], [False, True], "a, 70 s without a command, stopped; b was used 30 s ago")
+ self.assertTrue(any(m.get("op") == "event" and m.get("name") == "desk_idle_stopped" and m.get("desk") == "desk-a" for m in self.out.msgs()))
+ now[0] += 61
+ self.plugin.poll()
+ self.assertEqual([d.on for d in self.made_desks], [False, False])
+
def test_each_desk_has_a_browser_profile_and_downloads_of_its_own(self):
def test_each_desk_has_a_browser_profile_and_downloads_of_its_own(self):
made = []
made = []
@@ -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.21.0"
+__version__ = "0.22.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.21.0"
+version = "0.22.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.21.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.22.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())