openvibers/openvibe.link Commits
v0.19.0: several desks per machine (Desks plan D1) (#65)
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.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,@@@ -134,8 +134,21 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vibthe mouse and keyboard on the desk (points scale to the desk's own size), and the agent's browser shown on the deskin 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 ofyours 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) |@@@ -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}@@@ -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)@@@ -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 everylevel; 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 awindow 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 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,@@@ -73,7 +77,7 @@ import urllib.parsefrom openvibe_plugin import Deferred, Fault, Pluginfrom .browser import KEY_CODES, Browser, BrowserError, find_binary--from .desk import PrivateDesk++from .desk import MAX_DESKS_CAP, Desks, PrivateDesk, default_max_desksLEVELS = ("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.0def __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 = whichself.popen = popenself.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 browserself.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 Falseself.allow_screen_stream = config.get("allow_screen_stream", True) is not Falseself.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 Noneself.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 capsdef 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 Nonereturn {"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 Noneif 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" % nameif 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 bif 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 parallelif 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 liveself._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."""@@@ -109,6 +109,7 @@ class Browser:self.mu = threading.Lock()self.session = Noneself.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."""@@@ -13,6 +13,7 @@ Brought in from the owner's desktop-mcp (``desks.py``), plan track K step 2 (Vibfrom __future__ import annotationsimport os++import reimport shutilimport signalimport subprocess@@@ -175,3 +176,125 @@ class PrivateDesk:proc.kill()except Exception: # noqa: BLE001pass++++++# 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@@@ -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" }@@@ -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: E402from openvibe_plugin import Fault, Runtime # noqa: E402from 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 = displaydef 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 = 0p.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 = 0return 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)@@@ -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 = 1HEARTBEAT_TIMEOUT_S = 1.0DEFAULT_DEADLINE_MS = 300@@@ -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" }@@@ -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())
@@ -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.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",
"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,
@@ -134,8 +134,21 @@ An agent can work on a desk of its own instead of your screen (plan track K: Vib
@@ -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
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,
in a profile of its own (`browser_profile` + `-desk`). Without `desk` (or with `main`) they act on your own screen,
as before.
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`);
- **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
- **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.
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
- **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
@@ -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) |
| `probe` | `true` | `false`: no probes (OpenVibe.Watch's `node` source) |
| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) |
| `allow_input` | `true` | `false`: never use the mouse or keyboard (no `input` capability) |
| `allow_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) |
+| `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) |
| `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) |
| `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) |
@@ -179,7 +179,9 @@ const (
@@ -179,7 +179,9 @@ const (
// machine's private OpenRestream stream); no operator sends them. The computer plugin captures the frames, the
// 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.
// core publishes them; a latched stop ends it and refuses a start.
KindScreenStream = "screen_stream"
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
// "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.
// screen; screen, screen_stream, input and browser take "desk": "private" to act there. Starting and stopping act.
KindDesk = "desk"
KindDesk = "desk"
@@ -225,7 +227,7 @@ func ComputerActs(kind string, value json.RawMessage) bool {
@@ -225,7 +227,7 @@ func ComputerActs(kind string, value json.RawMessage) bool {
Action string `json:"action"`
Action string `json:"action"`
}
}
_ = json.Unmarshal(value, &v)
_ = json.Unmarshal(value, &v)
- return v.Action != "status"
+ return v.Action != "status" && v.Action != "list"
}
}
return false
return false
}
}
@@ -397,7 +397,7 @@ func TestJobUndecodableBody(t *testing.T) {
@@ -397,7 +397,7 @@ func TestJobUndecodableBody(t *testing.T) {
}
}
func TestComputerActsDesk(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 {
for action, acts := range cases {
if got := ComputerActs(KindDesk, json.RawMessage(`{"action":"`+action+`"}`)); got != acts {
if got := ComputerActs(KindDesk, json.RawMessage(`{"action":"`+action+`"}`)); got != acts {
t.Errorf("desk %s acts = %v, want %v", action, got, acts)
t.Errorf("desk %s acts = %v, want %v", action, got, acts)
@@ -40,10 +40,14 @@ Command kinds and their answers (the ack's ``data``):
@@ -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
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.
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).
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``,
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
``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
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,
@@ -73,7 +77,7 @@ import urllib.parse
@@ -73,7 +77,7 @@ import urllib.parse
from openvibe_plugin import Deferred, Fault, Plugin
from openvibe_plugin import Deferred, Fault, Plugin
from .browser import KEY_CODES, Browser, BrowserError, find_binary
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")
LEVELS = ("observe", "ask", "trusted", "full")
RANK = {name: i for i, name in enumerate(LEVELS)}
RANK = {name: i for i, name in enumerate(LEVELS)}
@@ -139,12 +143,12 @@ def _int(v, name, lo, hi, default):
@@ -139,12 +143,12 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.8.0"
+ version = "0.9.0"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
def __init__(self, which=shutil.which, popen=subprocess.Popen, clock=time.monotonic, env=None, platform=None, screen_size=None,
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.which = which
self.popen = popen
self.popen = popen
self.screen_size = screen_size or self._screen_size
self.screen_size = screen_size or self._screen_size
@@ -182,9 +186,12 @@ class Computer(Plugin):
@@ -182,9 +186,12 @@ class Computer(Plugin):
self.browser_mu = threading.Lock()
self.browser_mu = threading.Lock()
self.browser_op_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.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.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)])
self.resolve = resolve or (lambda host: [ai[4][0] for ai in socket.getaddrinfo(host, None)])
# ---- configuration -------------------------------------------------------------------------------------------
# ---- configuration -------------------------------------------------------------------------------------------
@@ -210,6 +217,8 @@ class Computer(Plugin):
@@ -210,6 +217,8 @@ class Computer(Plugin):
self.allow_browser = config.get("allow_browser", True) is not False
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_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.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")
self.browser_headless = config.get("browser_headless", True) is not False
self.browser_headless = config.get("browser_headless", True) is not False
@@ -235,9 +244,9 @@ class Computer(Plugin):
@@ -235,9 +244,9 @@ class Computer(Plugin):
binary = self._browser_binary()
binary = self._browser_binary()
if binary:
if binary:
caps["browser"] = {"level": self.level, "browser": os.path.basename(binary), "headless": self.browser_headless}
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
return caps
def setup(self, config):
def setup(self, config):
@@ -262,7 +271,8 @@ class Computer(Plugin):
@@ -262,7 +271,8 @@ class Computer(Plugin):
self._kill(p)
self._kill(p)
self._close_browser()
self._close_browser()
self._end_stream("stopped on the machine")
self._end_stream("stopped on the machine")
- self.desk.stop()
+ self._close_desk_browsers()
+ self.desks.stop_all()
for cid in waiting:
for cid in waiting:
self._close_prompt(cid)
self._close_prompt(cid)
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
self.emit_event("approval_done", id=cid, approved=False, reason="stopped")
@@ -296,7 +306,8 @@ class Computer(Plugin):
@@ -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.
# 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
streaming = self.stream is not None
return {"sensors": {"computer_level": self.effective_level(), "computer_waiting": waiting, "computer_running": running,
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 -----------------------------------------------------------------------------------------------------
# ---- paths -----------------------------------------------------------------------------------------------------
def _inside(self, path):
def _inside(self, path):
@@ -327,6 +338,7 @@ class Computer(Plugin):
@@ -327,6 +338,7 @@ class Computer(Plugin):
if cmd.kind == "desk":
if cmd.kind == "desk":
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
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:
@@ -334,7 +346,7 @@ class Computer(Plugin):
@@ -334,7 +346,7 @@ class Computer(Plugin):
width = _int(v.get("max_width"), "max_width", 320, 3840, 1280)
width = _int(v.get("max_width"), "max_width", 320, 3840, 1280)
return self._start(cmd, lambda: self._screen(tool, width, env=desk_env))
return self._start(cmd, lambda: self._screen(tool, width, env=desk_env))
if cmd.kind == "screen_stream":
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":
if cmd.kind == "fs":
op = v.get("op")
op = v.get("op")
if op in READ_OPS:
if op in READ_OPS:
@@ -357,7 +369,7 @@ class Computer(Plugin):
@@ -357,7 +369,7 @@ class Computer(Plugin):
run, summary = self._input_op(tool, v, env=desk_env)
run, summary = self._input_op(tool, v, env=desk_env)
return self._act(cmd, level, self._driving(run), summary, threaded=True)
return self._act(cmd, level, self._driving(run), summary, threaded=True)
if cmd.kind == "browser":
if cmd.kind == "browser":
- run, summary, acting = self._browser_op(v, level, env=desk_env)
+ run, summary, acting = self._browser_op(v, level, env=desk_env, desk=desk_name)
if acting:
if acting:
return self._act(cmd, level, run, summary, threaded=True)
return self._act(cmd, level, run, summary, threaded=True)
return self._start(cmd, run)
return self._start(cmd, run)
@@ -370,41 +382,49 @@ class Computer(Plugin):
@@ -370,41 +382,49 @@ class Computer(Plugin):
# ---- the private desk --------------------------------------------------------------------------------------------
# ---- the private desk --------------------------------------------------------------------------------------------
def _desk_env(self, v):
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")
where = v.get("desk", "main")
- if where not in ("main", "private"):
- raise Fault("bad_value", "desk is main or private")
if where == "main":
if where == "main":
return None
return None
+ Desks.check(where)
if not self.allow_desk:
if not self.allow_desk:
raise Fault("unsupported", "this machine does not allow a private desk (allow_desk is off)")
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):
def _desk_op(self, cmd, v, level):
action = v.get("action")
action = v.get("action")
+ name = v.get("desk", "private")
+ if action == "list":
+ return self.desks.list()
+ Desks.check(name)
if action == "status":
if action == "status":
- return self.desk.status()
+ 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 or status")
+ raise Fault("bad_value", "action is start, stop, status or list")
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
if action == "start":
if action == "start":
width = _int(v.get("width"), "width", 640, 3840, 1280)
width = _int(v.get("width"), "width", 640, 3840, 1280)
height = _int(v.get("height"), "height", 480, 2160, 800)
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)
threaded=True)
def stop():
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:
if b is not None:
b.close()
b.close()
+ def _close_desk_browsers(self):
+ for name in list(self.desk_browsers):
+ self._close_desk_browser(name)
+
def _x11_tool(self):
def _x11_tool(self):
"""An X11 capture tool for the private desk (it is always X11, whatever the owner's session is)."""
"""An X11 capture tool for the private desk (it is always X11, whatever the owner's session is)."""
if self.which("import"):
if self.which("import"):
@@ -891,7 +911,7 @@ class Computer(Plugin):
@@ -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)
raise Fault("not_allowed", "%s is on this machine's own network: only at level full" % u.hostname)
return raw
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()."""
"""(run, summary, acting) for one browser action; nothing is opened or clicked before run()."""
binary = self._browser_binary()
binary = self._browser_binary()
if not binary:
if not binary:
@@ -915,23 +935,32 @@ class Computer(Plugin):
@@ -915,23 +935,32 @@ class Computer(Plugin):
raise Fault("bad_value", "key is one of %s" % ", ".join(sorted(KEY_CODES)))
raise Fault("bad_value", "key is one of %s" % ", ".join(sorted(KEY_CODES)))
width = _int(v.get("max_width"), "max_width", 320, 2560, 1280)
width = _int(v.get("max_width"), "max_width", 320, 2560, 1280)
+ lock_key = desk if env else "main"
+
def browser():
def browser():
with self.browser_mu:
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():
if self.browser is None or not self.browser.alive():
self.browser = self.browser_factory(binary, self.browser_profile, self.browser_headless)
self.browser = self.browser_factory(binary, self.browser_profile, self.browser_headless)
return self.browser
return self.browser
+ def lock():
+ with self.browser_mu:
+ return self.browser_locks.setdefault(lock_key, threading.Lock())
+
def run():
def run():
if action == "close":
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}
return {"done": True}
b = browser()
b = browser()
try:
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":
if action == "open":
return b.open(url)
return b.open(url)
if action == "read":
if action == "read":
@@ -1165,7 +1194,7 @@ class Computer(Plugin):
@@ -1165,7 +1194,7 @@ class Computer(Plugin):
def _stream_capable(self, tool):
def _stream_capable(self, tool):
return self._have_pillow() or self._native_jpeg(tool, "x.jpg") is not None
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")
action = v.get("action")
if action == "stop":
if action == "stop":
return {"streaming": False, "stopped": self._end_stream("stopped by its owner")}
return {"streaming": False, "stopped": self._end_stream("stopped by its owner")}
@@ -1192,7 +1221,7 @@ class Computer(Plugin):
@@ -1192,7 +1221,7 @@ class Computer(Plugin):
if not env: # the private desk is not the owner's screen: nothing of theirs goes live
if not env: # the private desk is not the owner's screen: nothing of theirs goes live
self._notify_stream(seconds)
self._notify_stream(seconds)
return {"streaming": True, "sid": stream["sid"], "seconds": seconds, "fps": fps, "tool": tool[0],
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):
def _end_stream(self, reason):
"""End the live screen, if one runs → whether one did."""
"""End the live screen, if one runs → whether one did."""
@@ -109,6 +109,7 @@ class Browser:
@@ -109,6 +109,7 @@ class Browser:
self.mu = threading.Lock()
self.mu = threading.Lock()
self.session = None
self.session = None
self.used_at = 0.0
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 ------------------------------------------------------------------------------------
# ---- the process and the pipe ------------------------------------------------------------------------------------
def alive(self):
def alive(self):
@@ -149,6 +150,8 @@ class Browser:
@@ -149,6 +150,8 @@ class Browser:
self.session = self.call("Target.attachToTarget", {"targetId": target, "flatten": True}, session=False)["sessionId"]
self.session = self.call("Target.attachToTarget", {"targetId": target, "flatten": True}, session=False)["sessionId"]
self.call("Page.enable")
self.call("Page.enable")
self.call("Runtime.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):
def _stderr(self, stream):
"""Keep the browser's last lines of stderr: why it closed, when it does."""
"""Keep the browser's last lines of stderr: why it closed, when it does."""
@@ -13,6 +13,7 @@ Brought in from the owner's desktop-mcp (``desks.py``), plan track K step 2 (Vib
@@ -13,6 +13,7 @@ Brought in from the owner's desktop-mcp (``desks.py``), plan track K step 2 (Vib
from __future__ import annotations
from __future__ import annotations
import os
import os
+import re
import shutil
import shutil
import signal
import signal
import subprocess
import subprocess
@@ -175,3 +176,125 @@ class PrivateDesk:
@@ -175,3 +176,125 @@ class PrivateDesk:
proc.kill()
proc.kill()
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001
pass
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
@@ -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.1.0"
+version = "0.9.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" }
@@ -16,7 +16,7 @@ sys.path.insert(0, os.path.dirname(HERE))
@@ -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"))
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 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 openvibe_plugin import Fault, Runtime # noqa: E402
from test_computer import Out, reply # noqa: E402
from test_computer import Out, reply # noqa: E402
@@ -90,8 +90,9 @@ class DeskModuleTest(unittest.TestCase):
@@ -90,8 +90,9 @@ class DeskModuleTest(unittest.TestCase):
class FakeDesk:
class FakeDesk:
- def __init__(self):
+ def __init__(self, display=91):
self.on = False
self.on = False
+ self.display = display
def available(self):
def available(self):
return True
return True
@@ -111,30 +112,39 @@ class FakeDesk:
@@ -111,30 +112,39 @@ class FakeDesk:
return {"running": False, "stopped": was}
return {"running": False, "stopped": was}
def status(self):
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):
def env(self):
if not self.on:
if not self.on:
raise Fault("not_ready", "start the private desk first (desk start)")
raise Fault("not_ready", "start the private desk first (desk start)")
- return {"DISPLAY": ":91"}
+ return {"DISPLAY": ":%d" % self.display}
class DeskPluginTest(unittest.TestCase):
class DeskPluginTest(unittest.TestCase):
- def boot(self, level):
+ def boot(self, level, **config):
self.out = Out()
self.out = Out()
self.desk = FakeDesk()
self.desk = FakeDesk()
+ self.made_desks = []
self.calls = []
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):
def popen(argv, **kw):
self.calls.append((argv, kw.get("env")))
self.calls.append((argv, kw.get("env")))
p = FakeProc(argv, kw)
p = FakeProc(argv, kw)
p.returncode = 0
p.returncode = 0
p.communicate = lambda timeout=None: (b"", b"")
p.communicate = lambda timeout=None: (b"", b"")
return p
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))
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 = 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
self.n = 0
return self.out.msgs()
return self.out.msgs()
@@ -146,11 +156,12 @@ class DeskPluginTest(unittest.TestCase):
@@ -146,11 +156,12 @@ class DeskPluginTest(unittest.TestCase):
def test_describe_offers_the_desk(self):
def test_describe_offers_the_desk(self):
caps = self.boot("trusted")[0]["capabilities"]
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):
def test_observe_reports_but_does_not_start(self):
self.boot("observe")
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"}))
r = reply(self.out, self.cmd("desk", {"action": "start"}))
self.assertEqual((r["op"], r["fault_code"]), ("nack", "not_allowed"))
self.assertEqual((r["op"], r["fault_code"]), ("nack", "not_allowed"))
self.assertFalse(self.desk.on)
self.assertFalse(self.desk.on)
@@ -173,7 +184,71 @@ class DeskPluginTest(unittest.TestCase):
@@ -173,7 +184,71 @@ class DeskPluginTest(unittest.TestCase):
r = reply(self.out, self.cmd("screen", {"desk": "neighbour"}))
r = reply(self.out, self.cmd("screen", {"desk": "neighbour"}))
self.assertEqual(r["fault_code"], "bad_value")
self.assertEqual(r["fault_code"], "bad_value")
r = reply(self.out, self.cmd("desk", {"action": "stop"}))
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):
def test_the_kill_switch_stops_the_desk(self):
self.boot("trusted")
self.boot("trusted")
@@ -183,7 +258,17 @@ class DeskPluginTest(unittest.TestCase):
@@ -183,7 +258,17 @@ class DeskPluginTest(unittest.TestCase):
self.assertFalse(self.desk.on)
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):
class RealDeskTest(unittest.TestCase):
def test_a_real_desk_shows_its_own_screen(self):
def test_a_real_desk_shows_its_own_screen(self):
out = Out()
out = Out()
@@ -205,6 +290,95 @@ class RealDeskTest(unittest.TestCase):
@@ -205,6 +290,95 @@ class RealDeskTest(unittest.TestCase):
with self.assertRaises(ProcessLookupError):
with self.assertRaises(ProcessLookupError):
os.kill(pid, 0)
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__":
if __name__ == "__main__":
unittest.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)
@@ -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.18.0"
+__version__ = "0.19.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.18.0"
+version = "0.19.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.18.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.19.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())