openvibers/openvibe.link Commits

v0.19.0: several desks per machine (Desks plan D1) (#65)

OP OpenVibers committed 17 days ago · 29bb4c7fbe · 12 files, +403 −57

* v0.19.0: several desks per machine (Desks plan D1): named desks, each with its own display, bus, browser profile, downloads and temp folder, up to max_desks; desk list

* RealDeskBrowsersTest: Chrome's sandbox off on CI runners (their user-namespace policy crashes it), as RealBrowserTest does

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 4a8b80c..d33d395 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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.18.0",
-- "release": "v0.18.0, published 2026-10-27 as a GitHub release (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.19.0",
++ "release": "v0.19.0, published 2026-10-27 as a GitHub release (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,
ddiff --git a/docs/computer.md b/docs/computer.md
iindex 6cc6ab1..0131247 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -134,8 +134,21 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vib
the mouse and keyboard on the desk (points scale to the desk's own size), and the agent's browser shown on the desk
in a profile of its own (`browser_profile` + `-desk`). Without `desk` (or with `main`) they act on your own screen,
as before.
++- **Several desks at once** (Node v0.19.0, the Desks plan's step D1). Every `desk` command and every `screen`,
++ `screen_stream`, `input` and `browser` command may name a desk: `"desk": "desk-<name>"` (lower-case letters, digits
++ and dashes, up to 24 after `desk-`; `private` is the first desk, as before). Each desk is a separate computer for
++ its agent:
++ - its own X display and D-Bus;
++ - its own browser in its own profile (`browser_profile` + `-<name>`) and downloads folder;
++ - 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
++ 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`
++ (`~/.local/share/openvibe-node/desks`).
- **Levels.** Starting and stopping a desk are acting commands (refused at `observe`, approved one by one at `ask`);
-- `status` runs at every level. What runs on the desk keeps its own rules: input and the browser still act.
++ `status` and `list` run at every level. What runs on the desk keeps its own rules: input and the browser still act.
- **Watching and taking over.** Start the live screen with `"desk": "private"` (your desktop is not told, since nothing of
yours is shown) and drive it the same way.
- **It ends** on `stop`, and at once on `openvibe-node stop` or an e-stop: every process the desk started is ended by
@@@ -158,6 +171,8 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vib
| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) |
| `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_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) |
| `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) |
ddiff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
iindex 7662ac5..7f46f75 100644
---- a/internal/protocol/protocol.go
++++ b/internal/protocol/protocol.go
@@@ -179,7 +179,9 @@ const (
// machine's private OpenRestream stream); no operator sends them. The computer plugin captures the frames, the
// core publishes them; a latched stop ends it and refuses a start.
KindScreenStream = "screen_stream"
-- // KindDesk starts, stops or reports a private desk on the machine (contracts 0.214.0): {"action": "start"|"stop"|
++ // KindDesk starts, stops, reports or lists desks on the machine (contracts 0.214.0; named desks, Node v0.19.0):
++ // {"action": "start"|"stop"|"status"|"list", "desk"?: "private"|"desk-<name>", …}. Older form, kept:
++ // {"action": "start"|"stop"|
// "status", "width"?, "height"?}, an Xvfb display of its own where an agent works without touching the owner's
// screen; screen, screen_stream, input and browser take "desk": "private" to act there. Starting and stopping act.
KindDesk = "desk"
@@@ -225,7 +227,7 @@ func ComputerActs(kind string, value json.RawMessage) bool {
Action string `json:"action"`
}
_ = json.Unmarshal(value, &v)
-- return v.Action != "status"
++ return v.Action != "status" && v.Action != "list"
}
return false
}
ddiff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go
iindex 16f7ad3..7ef318a 100644
---- a/internal/protocol/protocol_test.go
++++ b/internal/protocol/protocol_test.go
@@@ -397,7 +397,7 @@ func TestJobUndecodableBody(t *testing.T) {
}
func TestComputerActsDesk(t *testing.T) {
-- cases := map[string]bool{"start": true, "stop": true, "status": false}
++ cases := map[string]bool{"start": true, "stop": true, "status": false, "list": false}
for action, acts := range cases {
if got := ComputerActs(KindDesk, json.RawMessage(`{"action":"`+action+`"}`)); got != acts {
t.Errorf("desk %s acts = %v, want %v", action, got, acts)
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex 0072658..49954c0 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -40,10 +40,14 @@ Command kinds and their answers (the ack's ``data``):
way; ``screenshot`` → ``{format, width, height, content_b64}``; ``close``. Reading, screenshots and closing run at every
level; the rest are acting commands. Below level full, ``open`` refuses an address on the machine's own network.
Config ``allow_browser: false`` turns it off; ``browser_path``, ``browser_headless`` (default true).
--* ``desk`` ``{"action": "start"|"stop"|"status", "width"?, "height"?}``: a private desk (``desk.py``): an Xvfb display with a
++* ``desk`` ``{"action": "start"|"stop"|"status"|"list", "desk"?, "width"?, "height"?}``: a private desk (``desk.py``): an Xvfb display with a
window manager and a D-Bus session of its own, where the agent works without touching the owner's screen. ``screen``,
``screen_stream``, ``input`` and ``browser`` take ``"desk": "private"`` to act there (the browser shown on the desk, in a
-- profile of its own, ``browser_profile`` + ``-desk``); ``main`` (the default) is the owner's screen. Starting and stopping
++ profile of its own, ``browser_profile`` + ``-desk``); ``main`` (the default) is the owner's screen. Several desks run at
++ 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
++ 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
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.
* ``probe`` ``{"target", "timeout_s"?}`` → for ``http(s)://…`` one GET, no redirect followed: ``{status, content_type,
@@@ -73,7 +77,7 @@ import urllib.parse
from openvibe_plugin import Deferred, Fault, Plugin
from .browser import KEY_CODES, Browser, BrowserError, find_binary
--from .desk import PrivateDesk
++from .desk import MAX_DESKS_CAP, Desks, PrivateDesk, default_max_desks
LEVELS = ("observe", "ask", "trusted", "full")
RANK = {name: i for i, name in enumerate(LEVELS)}
@@@ -139,12 +143,12 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
driver = "computer"
-- version = "0.8.0"
++ version = "0.9.0"
motion_kinds = frozenset()
telemetry_interval_s = 2.0
def __init__(self, which=shutil.which, popen=subprocess.Popen, clock=time.monotonic, env=None, platform=None, screen_size=None,
-- browser_factory=None, resolve=None, desk=None):
++ browser_factory=None, resolve=None, desk=None, desk_factory=None):
self.which = which
self.popen = popen
self.screen_size = screen_size or self._screen_size
@@@ -182,9 +186,12 @@ class Computer(Plugin):
self.browser_mu = threading.Lock()
self.browser_op_mu = threading.Lock()
self.browser_factory = browser_factory or (lambda binary, profile, headless, env=None: Browser(binary, profile, headless, env=env))
-- self.desk = desk or PrivateDesk(which=which, popen=popen)
++ self.desk_factory = desk_factory or (lambda: PrivateDesk(which=which, popen=popen))
++ self.desk = desk or self.desk_factory() # the desk named private (the first one, as before v0.9.0)
++ self.desks = Desks(self.desk_factory, private=self.desk, max_desks=default_max_desks())
self.allow_desk = True
-- self.desk_browser = None # the agent's browser on the private desk (its own profile, shown there, not headless)
++ self.desk_browsers = {} # desk name -> the agent's browser on that desk (its own profile, shown there)
++ 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)])
# ---- configuration -------------------------------------------------------------------------------------------
@@@ -210,6 +217,8 @@ class Computer(Plugin):
self.allow_browser = config.get("allow_browser", 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.desks.max_desks = _int(config.get("max_desks"), "max_desks", 1, MAX_DESKS_CAP, default_max_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_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
self.browser_headless = config.get("browser_headless", True) is not False
@@@ -235,9 +244,9 @@ class Computer(Plugin):
binary = self._browser_binary()
if binary:
caps["browser"] = {"level": self.level, "browser": os.path.basename(binary), "headless": self.browser_headless}
-- if self.allow_desk and self.platform.startswith("linux") and self.desk.available():
-- caps["desk"] = {"level": self.level, "window_manager": self.desk.window_manager(), "input": bool(self.which("xdotool")),
-- "screen": bool(self._x11_tool())}
++ if self.allow_desk and self.platform.startswith("linux") and self.desks.available():
++ caps["desk"] = {"level": self.level, "window_manager": self.desks.window_manager(), "input": bool(self.which("xdotool")),
++ "screen": bool(self._x11_tool()), "named": True, "max_desks": self.desks.max_desks}
return caps
def setup(self, config):
@@@ -262,7 +271,8 @@ class Computer(Plugin):
self._kill(p)
self._close_browser()
self._end_stream("stopped on the machine")
-- self.desk.stop()
++ self._close_desk_browsers()
++ self.desks.stop_all()
for cid in waiting:
self._close_prompt(cid)
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
@@@ -296,7 +306,8 @@ class Computer(Plugin):
# Sensors are what the core forwards to Bot: the machines page shows the level and what waits on the machine.
streaming = self.stream is not None
return {"sensors": {"computer_level": self.effective_level(), "computer_waiting": waiting, "computer_running": running,
-- "computer_screen_stream": "live" if streaming else "off"}}
++ "computer_screen_stream": "live" if streaming else "off",
++ "computer_desks": len(self.desks.running_names())}}
# ---- paths -----------------------------------------------------------------------------------------------------
def _inside(self, path):
@@@ -327,6 +338,7 @@ class Computer(Plugin):
if cmd.kind == "desk":
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_name = v.get("desk") if desk_env else None
if cmd.kind == "screen":
tool = self._x11_tool() if desk_env else self._screen_tool()
if not tool:
@@@ -334,7 +346,7 @@ class Computer(Plugin):
width = _int(v.get("max_width"), "max_width", 320, 3840, 1280)
return self._start(cmd, lambda: self._screen(tool, width, env=desk_env))
if cmd.kind == "screen_stream":
-- return self._screen_stream(v, env=desk_env)
++ return self._screen_stream(v, env=desk_env, desk=desk_name)
if cmd.kind == "fs":
op = v.get("op")
if op in READ_OPS:
@@@ -357,7 +369,7 @@ class Computer(Plugin):
run, summary = self._input_op(tool, v, env=desk_env)
return self._act(cmd, level, self._driving(run), summary, threaded=True)
if cmd.kind == "browser":
-- run, summary, acting = self._browser_op(v, level, env=desk_env)
++ run, summary, acting = self._browser_op(v, level, env=desk_env, desk=desk_name)
if acting:
return self._act(cmd, level, run, summary, threaded=True)
return self._start(cmd, run)
@@@ -370,41 +382,49 @@ class Computer(Plugin):
# ---- the private desk --------------------------------------------------------------------------------------------
def _desk_env(self, v):
-- """None for the owner's own screen (desk main, the default); the private desk's environment for desk private."""
++ """None for the owner's own screen (desk main, the default); a desk's environment for private or desk-<name>."""
where = v.get("desk", "main")
-- if where not in ("main", "private"):
-- raise Fault("bad_value", "desk is main or private")
if where == "main":
return None
++ Desks.check(where)
if not self.allow_desk:
raise Fault("unsupported", "this machine does not allow a private desk (allow_desk is off)")
-- return self.desk.env()
++ return self.desks.env(where)
def _desk_op(self, cmd, v, level):
action = v.get("action")
++ name = v.get("desk", "private")
++ if action == "list":
++ return self.desks.list()
++ Desks.check(name)
if action == "status":
-- return self.desk.status()
++ return self.desks.status(name)
if action not in ("start", "stop"):
-- raise Fault("bad_value", "action is start, stop or status")
++ raise Fault("bad_value", "action is start, stop, status or list")
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
if action == "start":
width = _int(v.get("width"), "width", 640, 3840, 1280)
height = _int(v.get("height"), "height", 480, 2160, 800)
-- return self._act(cmd, level, lambda: self.desk.start(width, height),
-- "start a private desk (%d×%d) where your agent works without touching your screen" % (width, height),
++ return self._act(cmd, level, lambda: self.desks.start(name, width, height),
++ "start %s (%d×%d) where your agent works without touching your screen" % (what, width, height),
threaded=True)
def stop():
-- self._close_desk_browser()
-- return self.desk.stop()
-- return self._act(cmd, level, stop, "stop the private desk (and close what runs on it)", threaded=False)
++ self._close_desk_browser(name)
++ return self.desks.stop(name)
++ return self._act(cmd, level, stop, "stop %s (and close what runs on it)" % what, threaded=False)
-- def _close_desk_browser(self):
-- b, self.desk_browser = self.desk_browser, None
++ def _close_desk_browser(self, name):
++ b = self.desk_browsers.pop(name, None)
if b is not None:
b.close()
++ def _close_desk_browsers(self):
++ for name in list(self.desk_browsers):
++ self._close_desk_browser(name)
++
def _x11_tool(self):
"""An X11 capture tool for the private desk (it is always X11, whatever the owner's session is)."""
if self.which("import"):
@@@ -891,7 +911,7 @@ class Computer(Plugin):
raise Fault("not_allowed", "%s is on this machine's own network: only at level full" % u.hostname)
return raw
-- def _browser_op(self, v, level, env=None):
++ def _browser_op(self, v, level, env=None, desk=None):
"""(run, summary, acting) for one browser action; nothing is opened or clicked before run()."""
binary = self._browser_binary()
if not binary:
@@@ -915,23 +935,32 @@ class Computer(Plugin):
raise Fault("bad_value", "key is one of %s" % ", ".join(sorted(KEY_CODES)))
width = _int(v.get("max_width"), "max_width", 320, 2560, 1280)
++ lock_key = desk if env else "main"
++
def browser():
with self.browser_mu:
-- if env: # on the private desk: shown there (never headless), in a profile of its own
-- if self.desk_browser is None or not self.desk_browser.alive():
-- self.desk_browser = self.browser_factory(binary, self.browser_profile + "-desk", False, env=env)
-- return self.desk_browser
++ if env: # on a desk: shown there (never headless), in a profile and a downloads folder of its own
++ b = self.desk_browsers.get(desk)
++ if b is None or not b.alive():
++ profile = self.browser_profile + ("-desk" if desk == "private" else "-" + desk)
++ b = self.desk_browsers[desk] = self.browser_factory(binary, profile, False, env=env)
++ b.download_dir = self.desks.dir(desk, "downloads")
++ return b
if self.browser is None or not self.browser.alive():
self.browser = self.browser_factory(binary, self.browser_profile, self.browser_headless)
return self.browser
++ def lock():
++ with self.browser_mu:
++ return self.browser_locks.setdefault(lock_key, threading.Lock())
++
def run():
if action == "close":
-- self._close_desk_browser() if env else self._close_browser()
++ self._close_desk_browser(desk) if env else self._close_browser()
return {"done": True}
b = browser()
try:
-- with self.browser_op_mu: # one action at a time in the one browser
++ with lock(): # one action at a time in each browser; two desks' browsers act in parallel
if action == "open":
return b.open(url)
if action == "read":
@@@ -1165,7 +1194,7 @@ class Computer(Plugin):
def _stream_capable(self, tool):
return self._have_pillow() or self._native_jpeg(tool, "x.jpg") is not None
-- def _screen_stream(self, v, env=None):
++ def _screen_stream(self, v, env=None, desk=None):
action = v.get("action")
if action == "stop":
return {"streaming": False, "stopped": self._end_stream("stopped by its owner")}
@@@ -1192,7 +1221,7 @@ class Computer(Plugin):
if not env: # the private desk is not the owner's screen: nothing of theirs goes live
self._notify_stream(seconds)
return {"streaming": True, "sid": stream["sid"], "seconds": seconds, "fps": fps, "tool": tool[0],
-- "desk": "private" if env else "main"}
++ "desk": (desk or "private") if env else "main"}
def _end_stream(self, reason):
"""End the live screen, if one runs → whether one did."""
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex 8ae04ab..3958d76 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -109,6 +109,7 @@ class Browser:
self.mu = threading.Lock()
self.session = None
self.used_at = 0.0
++ self.download_dir = None # a desk's own downloads folder (set before the first action), else the browser's default
# ---- the process and the pipe ------------------------------------------------------------------------------------
def alive(self):
@@@ -149,6 +150,8 @@ class Browser:
self.session = self.call("Target.attachToTarget", {"targetId": target, "flatten": True}, session=False)["sessionId"]
self.call("Page.enable")
self.call("Runtime.enable")
++ if self.download_dir:
++ self.call("Browser.setDownloadBehavior", {"behavior": "allow", "downloadPath": self.download_dir}, session=False)
def _stderr(self, stream):
"""Keep the browser's last lines of stderr: why it closed, when it does."""
ddiff --git a/plugins/computer/openvibe_computer/desk.py b/plugins/computer/openvibe_computer/desk.py
iindex a484bcd..2c2444e 100644
---- a/plugins/computer/openvibe_computer/desk.py
++++ b/plugins/computer/openvibe_computer/desk.py
@@@ -13,6 +13,7 @@ Brought in from the owner's desktop-mcp (``desks.py``), plan track K step 2 (Vib
from __future__ import annotations
import os
++import re
import shutil
import signal
import subprocess
@@@ -175,3 +176,125 @@ class PrivateDesk:
proc.kill()
except Exception: # noqa: BLE001
pass
++
++
++# A desk's name: `private` (the first desk, as before) or `desk-<name>` (Desks plan D1: several desks on one machine,
++# one per agent task). Lower case, digits and dashes, so a name is also a safe path part.
++DESK_NAME = re.compile(r"^(private|desk-[a-z0-9][a-z0-9-]{0,23})$")
++MAX_DESKS_CAP = len(DISPLAYS)
++
++
++def default_max_desks(meminfo="/proc/meminfo"):
++ """How many desks this machine runs at once by default: one per 2 GiB of memory, 1 to 8 (a desk with a browser
++ open uses about 0.5 to 1.5 GiB)."""
++ try:
++ with open(meminfo) as f:
++ for line in f:
++ if line.startswith("MemTotal:"):
++ return max(1, min(8, int(line.split()[1]) // (2 * 1024 * 1024)))
++ except (OSError, ValueError, IndexError):
++ pass
++ return 2
++
++
++class Desks:
++ """The machine's desks by name, each a PrivateDesk with a display, a bus and (in the plugin) a browser of its own.
++
++ Two desks never share a display, a bus, a browser profile or a temp folder, so two agents working at once cannot
++ see or drive each other's screen. At most `max_desks` run at once; starting one more is refused, never queued.
++ Starts are serialized, so two desks starting together cannot pick the same display number.
++ """
++
++ def __init__(self, factory, private=None, max_desks=2, base_dir=None):
++ self.factory = factory
++ self.max_desks = max_desks
++ self.base_dir = base_dir
++ self.mu = threading.Lock()
++ self.desks = {}
++ if private is not None:
++ self.desks["private"] = private
++
++ @staticmethod
++ def check(name):
++ if not isinstance(name, str) or not DESK_NAME.match(name):
++ raise Fault("bad_value", "desk is main, private or desk-<name> (lower-case letters, digits and dashes, at most 29 characters)")
++ return name
++
++ def _desk(self, name, create=False):
++ d = self.desks.get(name)
++ if d is None and create:
++ d = self.desks[name] = self.factory()
++ return d
++
++ def available(self):
++ d = self.desks.get("private") or self.factory()
++ return d.available()
++
++ def window_manager(self):
++ d = self.desks.get("private") or self.factory()
++ return d.window_manager()
++
++ def running_names(self):
++ with self.mu:
++ return [n for n, d in self.desks.items() if d.running()]
++
++ def start(self, name, width=1280, height=800):
++ self.check(name)
++ with self.mu:
++ d = self._desk(name, create=True)
++ if not d.running():
++ busy = [n for n, other in self.desks.items() if n != name and other.running()]
++ if len(busy) >= self.max_desks:
++ raise Fault("not_ready", "this machine already runs %d desks (%s), its limit (max_desks): stop one first"
++ % (len(busy), ", ".join(sorted(busy))))
++ st = d.start(width, height)
++ return dict(st, desk=name)
++
++ def stop(self, name):
++ self.check(name)
++ with self.mu:
++ d = self.desks.get(name)
++ if d is None:
++ return {"running": False, "stopped": False, "desk": name}
++ return dict(d.stop(), desk=name)
++
++ def stop_all(self):
++ with self.mu:
++ desks = list(self.desks.items())
++ return [n for n, d in desks if d.stop().get("stopped")]
++
++ def status(self, name):
++ self.check(name)
++ with self.mu:
++ d = self.desks.get(name)
++ if d is None:
++ return {"running": False, "available": self.available(), "desk": name}
++ return dict(d.status(), desk=name)
++
++ def list(self):
++ with self.mu:
++ desks = sorted(self.desks.items())
++ running = [dict(d.status(), desk=n) for n, d in desks if d.running()]
++ return {"desks": running, "max_desks": self.max_desks, "available": self.available()}
++
++ def env(self, name):
++ """The environment for a tool or app on that desk: its display and bus, and a temp folder of its own."""
++ self.check(name)
++ with self.mu:
++ d = self.desks.get(name)
++ 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
++ return env
++
++ 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:
++ return None
++ path = os.path.join(self.base_dir, self.check(name), part)
++ os.makedirs(path, mode=0o700, exist_ok=True)
++ return path
ddiff --git a/plugins/computer/pyproject.toml b/plugins/computer/pyproject.toml
iindex a03d3cc..0c67c6e 100644
---- a/plugins/computer/pyproject.toml
++++ b/plugins/computer/pyproject.toml
@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openvibe-computer"
--version = "0.1.0"
++version = "0.9.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" }
ddiff --git a/plugins/computer/tests/test_desk.py b/plugins/computer/tests/test_desk.py
iindex 51df8b6..2b9474d 100644
---- a/plugins/computer/tests/test_desk.py
++++ b/plugins/computer/tests/test_desk.py
@@@ -16,7 +16,7 @@ sys.path.insert(0, os.path.dirname(HERE))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(HERE)), "sdk"))
from openvibe_computer import Computer # noqa: E402
--from openvibe_computer.desk import PrivateDesk # noqa: E402
++from openvibe_computer.desk import Desks, PrivateDesk, default_max_desks # noqa: E402
from openvibe_plugin import Fault, Runtime # noqa: E402
from test_computer import Out, reply # noqa: E402
@@@ -90,8 +90,9 @@ class DeskModuleTest(unittest.TestCase):
class FakeDesk:
-- def __init__(self):
++ def __init__(self, display=91):
self.on = False
++ self.display = display
def available(self):
return True
@@@ -111,30 +112,39 @@ class FakeDesk:
return {"running": False, "stopped": was}
def status(self):
-- return {"running": True, "display": ":91", "width": 1000, "height": 500} if self.on else {"running": False, "available": True}
++ return {"running": True, "display": ":%d" % self.display, "width": 1000, "height": 500} if self.on else {"running": False, "available": True}
def env(self):
if not self.on:
raise Fault("not_ready", "start the private desk first (desk start)")
-- return {"DISPLAY": ":91"}
++ return {"DISPLAY": ":%d" % self.display}
class DeskPluginTest(unittest.TestCase):
-- def boot(self, level):
++ def boot(self, level, **config):
self.out = Out()
self.desk = FakeDesk()
++ self.made_desks = []
self.calls = []
++ def desk_factory():
++ d = FakeDesk(display=92 + len(self.made_desks))
++ self.made_desks.append(d)
++ return d
++ self.desk_dir = tempfile.mkdtemp(prefix="ov-desks-")
++ self.addCleanup(lambda: shutil.rmtree(self.desk_dir, ignore_errors=True))
++
def popen(argv, **kw):
self.calls.append((argv, kw.get("env")))
p = FakeProc(argv, kw)
p.returncode = 0
p.communicate = lambda timeout=None: (b"", b"")
return p
-- self.plugin = Computer(env={"DISPLAY": ":0"}, platform="linux", desk=self.desk, popen=popen,
++ self.plugin = Computer(env={"DISPLAY": ":0"}, platform="linux", desk=self.desk, desk_factory=desk_factory, popen=popen,
which=lambda n: "/usr/bin/" + n, screen_size=lambda tool: (2000, 1000))
self.rt = Runtime(self.plugin, infile=io.StringIO(""), outfile=self.out)
-- self.rt.dispatch({"op": "hello", "config": {"level": level, "roots": [tempfile.gettempdir()], "notify": False}})
++ self.rt.dispatch({"op": "hello", "config": {"level": level, "roots": [tempfile.gettempdir()], "notify": False,
++ "desk_dir": self.desk_dir, **config}})
self.n = 0
return self.out.msgs()
@@@ -146,11 +156,12 @@ class DeskPluginTest(unittest.TestCase):
def test_describe_offers_the_desk(self):
caps = self.boot("trusted")[0]["capabilities"]
-- self.assertEqual(caps["desk"], {"level": "trusted", "window_manager": "metacity", "input": True, "screen": True})
++ self.assertEqual(caps["desk"], {"level": "trusted", "window_manager": "metacity", "input": True, "screen": True,
++ "named": True, "max_desks": default_max_desks()})
def test_observe_reports_but_does_not_start(self):
self.boot("observe")
-- self.assertEqual(reply(self.out, self.cmd("desk", {"action": "status"}))["data"], {"running": False, "available": True})
++ self.assertEqual(reply(self.out, self.cmd("desk", {"action": "status"}))["data"], {"running": False, "available": True, "desk": "private"})
r = reply(self.out, self.cmd("desk", {"action": "start"}))
self.assertEqual((r["op"], r["fault_code"]), ("nack", "not_allowed"))
self.assertFalse(self.desk.on)
@@@ -173,7 +184,71 @@ class DeskPluginTest(unittest.TestCase):
r = reply(self.out, self.cmd("screen", {"desk": "neighbour"}))
self.assertEqual(r["fault_code"], "bad_value")
r = reply(self.out, self.cmd("desk", {"action": "stop"}))
-- self.assertEqual(r["data"], {"running": False, "stopped": True})
++ self.assertEqual(r["data"], {"running": False, "stopped": True, "desk": "private"})
++
++ def test_several_desks_each_with_its_own_display_temp_folder_and_browser(self):
++ # Desks plan D1: two agents at once on one machine, each on a desk of its own.
++ self.boot("trusted", max_desks=2)
++ for name in ("desk-a", "desk-b"):
++ r = reply(self.out, self.cmd("desk", {"action": "start", "desk": name, "width": 1000, "height": 500}))
++ self.assertEqual((r["op"], r["data"]["desk"], r["data"]["running"]), ("ack", name, True))
++ self.assertEqual([d.display for d in self.made_desks], [92, 93], "a display each")
++ # A third is refused while two run (max_desks 2), and the private desk counts like any other.
++ r = reply(self.out, self.cmd("desk", {"action": "start", "desk": "private"}))
++ self.assertEqual(r["fault_code"], "not_ready")
++ self.assertIn("max_desks", r["message"])
++ # Input goes to the named desk's own display.
++ reply(self.out, self.cmd("input", {"action": "click", "x": 1, "y": 1, "width": 100, "height": 100, "desk": "desk-a"}))
++ 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.
++ 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.assertEqual(os.stat(tmp_a).st_mode & 0o777, 0o700)
++ # 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))
++ self.assertEqual(reply(self.out, self.cmd("screen", {"desk": "desk-c"}))["fault_code"], "not_ready")
++ for bad in ("desk-", "Desk-A", "desk-a/../b", "desk-" + "x" * 30, "other"):
++ self.assertEqual(reply(self.out, self.cmd("screen", {"desk": bad}))["fault_code"], "bad_value", bad)
++ # 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.plugin.stop()
++ 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 = []
++
++ class FakeBrowser:
++ def __init__(self, binary, profile, headless, env=None):
++ self.profile, self.headless, self.env, self.download_dir = profile, headless, env, None
++ made.append(self)
++
++ def alive(self):
++ return True
++
++ def open(self, url):
++ return {"url": url}
++
++ def close(self):
++ pass
++ self.boot("trusted", max_desks=3)
++ self.plugin.browser_factory = FakeBrowser
++ for name in ("private", "desk-a"):
++ reply(self.out, self.cmd("desk", {"action": "start", "desk": name}))
++ r = reply(self.out, self.cmd("browser", {"action": "open", "url": "https://example.org/", "desk": name}))
++ self.assertEqual(r["op"], "ack", r)
++ self.assertEqual(len(made), 2)
++ self.assertTrue(made[0].profile.endswith("-desk"), "the private desk keeps its profile from before v0.9.0")
++ self.assertTrue(made[1].profile.endswith("-desk-a"))
++ self.assertEqual([b.headless for b in made], [False, False], "shown on the desk")
++ self.assertEqual(made[1].download_dir, os.path.join(self.desk_dir, "desk-a", "downloads"))
++ self.assertNotEqual(made[0].download_dir, made[1].download_dir)
++ self.assertNotEqual(made[0].env["DISPLAY"], made[1].env["DISPLAY"])
def test_the_kill_switch_stops_the_desk(self):
self.boot("trusted")
@@@ -183,7 +258,17 @@ class DeskPluginTest(unittest.TestCase):
self.assertFalse(self.desk.on)
-[email protected](all(shutil.which(t) for t in ("Xvfb", "import")) and sys.platform.startswith("linux"), "needs Xvfb and ImageMagick")
++def _pillow():
++ try:
++ import PIL # noqa: F401
++ return True
++ except ImportError:
++ return False
++
++
++# The screen's size comes from Pillow (without it a capture is a bare PNG): the real-desk tests need it like CI has it.
+[email protected](all(shutil.which(t) for t in ("Xvfb", "import")) and sys.platform.startswith("linux") and _pillow(),
++ "needs Xvfb, ImageMagick and Pillow")
class RealDeskTest(unittest.TestCase):
def test_a_real_desk_shows_its_own_screen(self):
out = Out()
@@@ -205,6 +290,95 @@ class RealDeskTest(unittest.TestCase):
with self.assertRaises(ProcessLookupError):
os.kill(pid, 0)
++ def test_two_real_desks_have_their_own_screens(self):
++ # Desks plan D1 for real: two desks at once, each its own X display at its own size; stopping one ends only its
++ # processes.
++ out = Out()
++ plugin = Computer(platform="linux")
++ rt = Runtime(plugin, infile=io.StringIO(""), outfile=out)
++ rt.dispatch({"op": "hello", "config": {"level": "trusted", "roots": [tempfile.gettempdir()], "notify": False, "max_desks": 2}})
++ self.addCleanup(plugin.desks.stop_all)
++ sizes = {"desk-one": (800, 600), "desk-two": (1024, 640)}
++ n = 0
++ for name, (w, h) in sizes.items():
++ n += 1
++ rt.dispatch({"op": "command", "id": "s%d" % n, "kind": "desk", "value": {"action": "start", "desk": name, "width": w, "height": h}, "deadline_ms": 30000})
++ self.assertTrue(reply(out, "s%d" % n)["data"]["running"])
++ displays = {d["desk"]: d["display"] for d in plugin.desks.list()["desks"]}
++ self.assertEqual(len(set(displays.values())), 2, displays)
++ for name, (w, h) in sizes.items():
++ n += 1
++ rt.dispatch({"op": "command", "id": "s%d" % n, "kind": "screen", "value": {"desk": name, "max_width": 1280}, "deadline_ms": 30000})
++ shot = reply(out, "s%d" % n)["data"]
++ self.assertEqual((shot["width"], shot["height"]), (w, h), name)
++ one = [p.pid for _, p in plugin.desks.desks["desk-one"].procs]
++ rt.dispatch({"op": "command", "id": "end", "kind": "desk", "value": {"action": "stop", "desk": "desk-one"}, "deadline_ms": 30000})
++ self.assertTrue(reply(out, "end")["data"]["stopped"])
++ for pid in one:
++ with self.assertRaises(ProcessLookupError):
++ os.kill(pid, 0)
++ self.assertEqual([d["desk"] for d in plugin.desks.list()["desks"]], ["desk-two"])
++
if __name__ == "__main__":
unittest.main()
++
++
++def _a_browser():
++ from openvibe_computer.browser import find_binary
++ return find_binary(shutil.which, sys.platform)
++
++
+[email protected](shutil.which("Xvfb") and sys.platform.startswith("linux") and _a_browser(), "needs Xvfb and a Chromium-family browser")
++class RealDeskBrowsersTest(unittest.TestCase):
++ def test_two_desks_never_share_cookies(self):
++ # Desks plan D1's proof: two agents signed in to the same site at once, on two desks of one machine, each with
++ # its own cookie; neither browser ever sends the other's.
++ import http.server
++ import threading
++
++ class Site(http.server.BaseHTTPRequestHandler):
++ def do_GET(self):
++ body = "cookie=[%s]" % (self.headers.get("Cookie") or "")
++ self.send_response(200)
++ if self.path.startswith("/set/"):
++ self.send_header("Set-Cookie", "who=%s; Path=/" % self.path[len("/set/"):])
++ self.send_header("Content-Type", "text/plain")
++ self.end_headers()
++ self.wfile.write(body.encode())
++
++ def log_message(self, *a):
++ pass
++ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Site)
++ threading.Thread(target=server.serve_forever, daemon=True).start()
++ self.addCleanup(server.shutdown)
++ base = "http://127.0.0.1:%d" % server.server_address[1]
++ profiles = tempfile.mkdtemp(prefix="ov-desk-browsers-")
++ self.addCleanup(lambda: shutil.rmtree(profiles, ignore_errors=True))
++ out = Out()
++ plugin = Computer(platform="linux")
++ # CI runners' Chrome crashes inside their user-namespace policy unless its sandbox is off (as RealBrowserTest).
++ from openvibe_computer.browser import Browser
++ extra = ["--no-sandbox"] if os.environ.get("CI") else []
++ plugin.browser_factory = lambda binary, profile, headless, env=None: Browser(binary, profile, headless, env=env, extra_args=extra)
++ rt = Runtime(plugin, infile=io.StringIO(""), outfile=out)
++ # Level full: the test site is on this machine (lower levels refuse the machine's own network).
++ rt.dispatch({"op": "hello", "config": {"level": "full", "roots": [tempfile.gettempdir()], "notify": False, "max_desks": 2,
++ "browser_profile": os.path.join(profiles, "browser"), "desk_dir": os.path.join(profiles, "desks")}})
++ self.addCleanup(plugin.stop)
++ n = [0]
++
++ def cmd(kind, value):
++ n[0] += 1
++ cid = "b%d" % n[0]
++ rt.dispatch({"op": "command", "id": cid, "kind": kind, "value": value, "deadline_ms": 60000})
++ r = out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"), timeout=60)
++ self.assertEqual(r["op"], "ack", r)
++ return r.get("data")
++ for name, who in (("desk-alpha", "alpha"), ("desk-beta", "beta")):
++ cmd("desk", {"action": "start", "desk": name})
++ cmd("browser", {"action": "open", "url": base + "/set/" + who, "desk": name})
++ for name, who, other in (("desk-alpha", "alpha", "beta"), ("desk-beta", "beta", "alpha")):
++ text = cmd("browser", {"action": "open", "url": base + "/show", "desk": name})["text"]
++ self.assertIn("who=" + who, text, name)
++ self.assertNotIn("who=" + other, text, name)
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 70af7ed..a73474f 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -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.18.0"
++__version__ = "0.19.0"
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
ddiff --git a/plugins/sdk/pyproject.toml b/plugins/sdk/pyproject.toml
iindex 7f93ec6..1fc2544 100644
---- a/plugins/sdk/pyproject.toml
++++ b/plugins/sdk/pyproject.toml
@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openvibe-plugin"
--version = "0.18.0"
++version = "0.19.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" }
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex 4e825f6..18a4eb6 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -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.18.0")
++ self.assertEqual(openvibe_plugin.__version__, "0.19.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())