openvibers/openvibe.link Commits

Node 0.31.0: every bundled plugin tested on Windows and macOS (and ai at all); the computer plugin fixed for both (#79)

OP OpenVibers committed 17 days ago · 35387fa91b · 12 files, +174 −22

* CI: every bundled plugin's tests and packages on Windows and macOS too, and the ai plugin's at all

A LOOI or Cozmo bridge is often a Windows PC and a desk a Mac (owner, 2026-10-27: "work well across multi-platform"),
but the Python plugins were tested on Linux only, and plugins/ai not in CI.

* Node 0.31.0: the computer plugin on macOS and Windows, as the new CI jobs found it

- macOS: a browser desk's editing shortcuts (ctrl or cmd + a, c, v, x, z, shift+z, y) carry the command Chrome on a
  Mac needs (ctrl+a then BackSpace deleted one letter there); a desk's temp folder goes under /tmp when Chrome's
  SingletonSocket would not fit macOS's 104-byte socket path (/var/folders/... made it 109), so a Mac desk's browser
  starts.
- Windows: the plugin keeps its data in %LOCALAPPDATA%\openvibe-node (it was the Linux path with mixed separators);
  an existing ~/.local/share/openvibe-node is kept, so a kept sign-in is not lost; paths are normalized.
- Tests: POSIX permission bits, Unix socket lengths and SIGKILL's -9 are checked where they exist; the fixture file
  is written with \n everywhere.

* computer tests on Windows: the last POSIX-only checks (a folder's 0700, a terminal's SIGKILL) and the socket-length case, which is about Unix

* computer: a call waiting on a page that closed fails at once, and the read goes to the page in front

A popup that closes itself after a sign-in: on macOS Chrome never answers a call still waiting on its session, so the
next read hung for the whole 15 s timeout and failed (the macOS CI job, twice). Target.detachedFromTarget now answers
those calls at once with a session error, which _eval already turns into one more try on the page in front.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 0f6d644..1d9ae4b 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -103,8 +103,38 @@ jobs:
- run: python -m pytest -q plugins/ros2/tests
- run: python -m pytest -q plugins/computer/tests
- run: python -m pytest -q plugins/voice/tests
++ - run: python -m pytest -q plugins/ai/tests
- name: packages install
-- run: pip install ./plugins/sdk ./plugins/dryrun ./plugins/adeept_adr036 ./plugins/cozmo ./plugins/relay ./plugins/ros2 ./plugins/computer ./plugins/voice
++ run: pip install ./plugins/sdk ./plugins/dryrun ./plugins/adeept_adr036 ./plugins/cozmo ./plugins/relay ./plugins/ros2 ./plugins/computer ./plugins/voice ./plugins/ai
++
++ # The bundled drivers where people run them: a LOOI or Cozmo bridge is often a Windows PC, a desk a Mac (owner,
++ # 2026-10-27: "work well across multi-platform"). Every plugin's tests and packages, on Windows and macOS.
++ python-other-os:
++ name: plugins (${{ matrix.os }}, python 3.11)
++ runs-on: ${{ matrix.os }}
++ strategy:
++ fail-fast: false
++ matrix:
++ os: [windows-latest, macos-latest]
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-python@v5
++ with:
++ python-version: "3.11"
++ - run: pip install pytest pillow numpy
++ # One run per plugin (their test files share names), every one reported before the job fails.
++ - name: each plugin's tests
++ shell: bash
++ run: |
++ failed=""
++ for d in sdk dryrun adeept_adr036 cozmo relay ros2 computer voice ai; do
++ echo "::group::$d"
++ python -m pytest -q "plugins/$d/tests" || failed="$failed $d"
++ echo "::endgroup::"
++ done
++ if [ -n "$failed" ]; then echo "failed on ${{ matrix.os }}:$failed"; exit 1; fi
++ - name: packages install
++ run: pip install ./plugins/sdk ./plugins/dryrun ./plugins/adeept_adr036 ./plugins/cozmo ./plugins/relay ./plugins/ros2 ./plugins/computer ./plugins/voice ./plugins/ai
shell:
name: shellcheck
ddiff --git a/STATUS.json b/STATUS.json
iindex 12e84f6..fe46028 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,8 +5,8 @@
"wave": "T15",
"plan": "Plan track T15 (Bot complete) and ADR-043 (Bot: devices, pairing, control and safety); the computer plugin and the job worker serve OpenVibe.Actor and the Codes job runner (tracks T14/T17)",
"kind": "client",
-- "version": "0.30.0",
-- "release": "v0.30.0, published 2026-10-27 as a GitHub release (Windows keeps the original deadline across the pipe too: the core measures every deadline on one precise clock shared with its plugins, CLOCK_MONOTONIC on Linux and macOS and the raw QueryPerformanceCounter on Windows, named in hello as shared_clock, and passes the instant a command expires on it; the Python runtime reads the same raw counter with the same integer conversion; strict timing (Plugin.strict_timing, OPENVIBE_STRICT_TIMING=1, or strict_timing in the Node's plugin config) refuses a motion command whose original expiry cannot be checked with timing_unavailable instead of running it on deadline_ms; the deadline tests run a real Python plugin over real pipes on Linux, macOS and Windows; looi runs as python -m openvibe_looi without a command; v0.29.0: the ai plugin says how fast each model answers here: ai_tokens_per_s for the lastthe ai plugin says how fast each model answers here: ai_tokens_per_s for the last model, averaged with the earlier answers, and ai_busy while one answers, shown beside the machine's models on openvibe.bot; v0.28.0: install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
++ "version": "0.31.0",
++ "release": "v0.31.0, published 2026-10-27 as a GitHub release (every bundled plugin is tested on Windows and macOS too, and the ai plugin at all; what that found is fixed: on a Mac a browser desk's ctrl or cmd editing shortcuts (select all, copy, paste, cut, undo, redo) work, sent with the command Chrome needs there, and a desk's temp folder moves under /tmp when Chrome's SingletonSocket would not fit macOS's 104-byte socket path, so a Mac desk's browser starts; on Windows the computer plugin keeps its data in %LOCALAPPDATA%\\openvibe-node (an existing ~/.local/share folder is kept) with normal paths; v0.30.0: Windows keeps the original deadline across the pipe too: the core measures every deadline on one precise clock shared with its plugins, CLOCK_MONOTONIC on Linux and macOS and the raw QueryPerformanceCounter on Windows, named in hello as shared_clock, and passes the instant a command expires on it; the Python runtime reads the same raw counter with the same integer conversion; strict timing (Plugin.strict_timing, OPENVIBE_STRICT_TIMING=1, or strict_timing in the Node's plugin config) refuses a motion command whose original expiry cannot be checked with timing_unavailable instead of running it on deadline_ms; the deadline tests run a real Python plugin over real pipes on Linux, macOS and Windows; looi runs as python -m openvibe_looi without a command; v0.29.0: the ai plugin says how fast each model answers here: ai_tokens_per_s for the lastthe ai plugin says how fast each model answers here: ai_tokens_per_s for the last model, averaged with the earlier answers, and ai_busy while one answers, shown beside the machine's models on openvibe.bot; v0.28.0: install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
ddiff --git a/docs/computer.md b/docs/computer.md
iindex 502b30d..aa71430 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -117,7 +117,7 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
`-` and `_`.
**The browser** (`browser`) is a Chromium-family browser (Chromium, Chrome, Edge or Brave) the agent drives:
--- **Its own profile.** It runs in a folder of its own (`browser_profile`, by default `~/.local/share/openvibe-node/browser`), so the agent never sees the sessions of your own browser unless you point it at one. Sign it in to a site yourself (`browser_headless: false` shows its window) if you want the agent to work there as you.
++- **Its own profile.** It runs in a folder of its own (`browser_profile`, by default `~/.local/share/openvibe-node/browser`; on Windows `%LOCALAPPDATA%\openvibe-node\browser`), so the agent never sees the sessions of your own browser unless you point it at one. Sign it in to a site yourself (`browser_headless: false` shows its window) if you want the agent to work there as you.
- **No port.** The Node starts it on the first command with `--remote-debugging-pipe`: it speaks the DevTools protocol over two file descriptors, so nothing else on the machine can drive it.
- **Levels.** `read`, `screenshot` and `close` run at every level. `open`, `click`, `fill`, `press` and `back` are acting commands: refused at `observe`, approved one by one at `ask`. The approval says exactly what would happen: "open https://… in the browser", "type 5 characters into #q in the browser: 'hello'".
- **Your own network.** Below level `full`, `open` refuses an address on the machine's own network (private, loopback or link-local, checked for every address the name resolves to).
@@@ -273,12 +273,12 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_
| `allow_desk` | `true` | `false`: never start a private desk (no `desk` capability) |
| `max_desks` | memory / 2 GiB, 1 to 8 | how many whole desks run at once (1 to 10); a browser desk counts as half |
| `desk_idle_s` | `900` | a desk no command used this long is stopped (60 to 86400) |
--| `desk_dir` | `~/.local/share/openvibe-node/desks` | each desk's own temp and downloads folders |
++| `desk_dir` | `~/.local/share/openvibe-node/desks` (Windows: `%LOCALAPPDATA%\openvibe-node\desks`) | each desk's own temp and downloads folders |
| `desk_profile_max_mb` | `256` | a kept profile larger than this is not saved (16 to 4096) |
| `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) |
--| `browser_profile` | `~/.local/share/openvibe-node/browser` | its profile folder: the agent's own, unless you choose another |
++| `browser_profile` | `~/.local/share/openvibe-node/browser` (Windows: `%LOCALAPPDATA%\openvibe-node\browser`) | its profile folder: the agent's own, unless you choose another |
| `browser_egress` | `public` | `public`: every agent browser goes through the egress guard (never your own network below level `full`); `open`: no guard |
| `browser_headless` | `true` | `false`: show its window |
| `browser_idle_s` | 600 | close it after this long without a command |
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex ed773b2..4b6e2c5 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -183,6 +183,17 @@ def _int(v, name, lo, hi, default):
return int(max(lo, min(hi, v)))
++def node_data_dir():
++ """Where the plugin keeps what it keeps (the agent's browser profile, the desks' folders). ~/.local/share/openvibe-node
++ on Linux and macOS; on Windows %LOCALAPPDATA%\\openvibe-node, where a Windows program keeps its data (it was the
++ Linux path with mixed separators before 0.31, found by running the tests on Windows). A Windows machine that already
++ has the old folder keeps using it, so a kept sign-in is not lost."""
++ old = os.path.normpath(os.path.expanduser("~/.local/share/openvibe-node"))
++ if os.name != "nt":
++ return old
++ new = os.path.join(os.environ.get("LOCALAPPDATA") or os.path.expanduser(os.path.join("~", "AppData", "Local")), "openvibe-node")
++ return old if os.path.isdir(old) and not os.path.isdir(new) else new
++
class Computer(Plugin):
driver = "computer"
version = "0.14.0"
@@@ -274,9 +285,9 @@ class Computer(Plugin):
self.desks.max_desks = _int(config.get("max_desks"), "max_desks", 1, MAX_DESKS_CAP, default_max_desks())
self.desk_idle_s = _int(config.get("desk_idle_s"), "desk_idle_s", 60, 86400, 900)
self.desk_profile_max_mb = _int(config.get("desk_profile_max_mb"), "desk_profile_max_mb", 16, 4096, 256)
-- self.desks.base_dir = os.path.expanduser(config.get("desk_dir") or "~/.local/share/openvibe-node/desks")
++ self.desks.base_dir = os.path.normpath(os.path.expanduser(config.get("desk_dir") or os.path.join(node_data_dir(), "desks")))
self.browser_path = config.get("browser_path") or None
-- self.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
++ self.browser_profile = os.path.normpath(os.path.expanduser(config.get("browser_profile") or os.path.join(node_data_dir(), "browser")))
self.browser_headless = config.get("browser_headless", True) is not False
self.browser_idle_s = _int(config.get("browser_idle_s"), "browser_idle_s", 60, 86400, 600)
egress = str(config.get("browser_egress") or "public").strip().lower()
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex 4c1cca1..3f53aaa 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -19,6 +19,7 @@ import base64
import json
import os
import subprocess
++import sys
import threading
import time
@@@ -57,6 +58,20 @@ PAGE_MODIFIERS = {
}
++def mac_edit_command(bits, name, platform=None):
++ """The editing command a chord means in a text field on macOS, or None. Chrome on a Mac runs select all, copy,
++ paste, cut, undo and redo from the system's key bindings, which synthetic key events never reach: the event has to
++ name the command (CDP's `commands`, as Puppeteer does). An agent sends the chord it knows (ctrl+a on Linux and
++ Windows, cmd+a on a Mac), so on a Mac both work: a browser desk behaves the same on every OS (2026-10-27, found by
++ running the plugin's tests on macOS: ctrl+a then BackSpace deleted one letter)."""
++ if (platform or sys.platform) != "darwin" or not bits & 6: # ctrl or meta held
++ return None
++ k = name.lower()
++ if k == "z":
++ return "redo" if bits & 8 else "undo"
++ return {"a": "selectAll", "c": "copy", "x": "cut", "v": "paste", "y": "redo"}.get(k)
++
++
def page_key(name, shift=False):
"""(key, code, Windows key code, text) for one xdotool key name on a page, or None."""
low = name.lower()
@@@ -352,6 +367,14 @@ class Browser:
slot[0].set()
elif "method" in msg:
with self.mu:
++ if msg["method"] == "Target.detachedFromTarget":
++ # Its page closed (a popup that closes itself): a call still waiting on it is never answered
++ # (Chrome on macOS stays silent), so it fails at once and _eval reads the page in front again.
++ gone = (msg.get("params") or {}).get("sessionId")
++ for waiting in self.waiting.values():
++ if gone and waiting[2] == gone and waiting[1] is None:
++ waiting[1] = {"error": {"message": "Session closed: its page closed"}}
++ waiting[0].set()
self.events.append(msg["method"])
if msg["method"] in ("Target.targetCreated", "Target.targetDestroyed"):
self.tab_events = (self.tab_events + [(msg["method"], msg.get("params") or {})])[-50:]
@@@ -362,14 +385,15 @@ class Browser:
slot[0].set()
def call(self, method, params=None, session=True, timeout_s=None):
++ sid = self.session if session else None
with self.mu:
self.next_id += 1
mid = self.next_id
-- slot = [threading.Event(), None]
++ slot = [threading.Event(), None, sid] # the page's session it went to: answered at once if that page closes
self.waiting[mid] = slot
msg = {"id": mid, "method": method, "params": params or {}}
-- if session and self.session:
-- msg["sessionId"] = self.session
++ if sid:
++ msg["sessionId"] = sid
try:
with self.write_mu:
self.out.write(json.dumps(msg).encode("utf-8") + b"\0")
@@@ -585,6 +609,9 @@ class Browser:
down = {"type": "keyDown" if typed else "rawKeyDown", "key": k, "code": code, "windowsVirtualKeyCode": vk, "modifiers": bits}
if typed:
down["text"] = down["unmodifiedText"] = typed
++ command = mac_edit_command(bits, parts[-1], getattr(self, "platform", None))
++ if command:
++ down["commands"] = [command]
self.call("Input.dispatchKeyEvent", down)
self.call("Input.dispatchKeyEvent", {"type": "keyUp", "key": k, "code": code, "windowsVirtualKeyCode": vk, "modifiers": bits})
for bit, (mk, mcode, mvk) in reversed(mods):
ddiff --git a/plugins/computer/openvibe_computer/desk.py b/plugins/computer/openvibe_computer/desk.py
iindex b6bc32d..13cc919 100644
---- a/plugins/computer/openvibe_computer/desk.py
++++ b/plugins/computer/openvibe_computer/desk.py
@@@ -18,6 +18,7 @@ import shutil
import tempfile
import signal
import subprocess
++import sys
import threading
import time
@@@ -28,6 +29,23 @@ DISPLAYS = range(90, 100)
START_TIMEOUT_S = 8.0
++# A Unix socket's path: at most 108 bytes on Linux, 104 on macOS (sockaddr_un.sun_path, with its NUL).
++SOCKET_PATH_MAX = 104 if sys.platform == "darwin" else 108
++# What Chrome adds under TMPDIR for its SingletonSocket.
++CHROME_SOCKET_TAIL = "/.com.google.Chrome.XXXXXX/SingletonSocket"
++
++
++def short_tmp_root(name, base=None):
++ """Where a desk's temp folder goes: the system's temp folder, unless Chrome's SingletonSocket under it would not fit
++ a Unix socket path (macOS's TMPDIR is /var/folders/…/T/, long enough that it did not, 2026-10-27); then /tmp, which
++ every Unix has. The folder itself is made 0700 by mkdtemp either way."""
++ base = base or tempfile.gettempdir()
++ longest = len(os.path.join(base, "ov-%s-XXXXXXXX" % name)) + len(CHROME_SOCKET_TAIL)
++ if longest < SOCKET_PATH_MAX or not os.path.isdir("/tmp"):
++ return base
++ return "/tmp"
++
++
class PrivateDesk:
kind = "desktop"
@@@ -382,7 +400,7 @@ class Desks:
with self.mu:
tmp = self.tmpdirs.get(name)
if tmp is None or not os.path.isdir(tmp):
-- tmp = self.tmpdirs[name] = tempfile.mkdtemp(prefix="ov-%s-" % name)
++ tmp = self.tmpdirs[name] = tempfile.mkdtemp(prefix="ov-%s-" % name, dir=short_tmp_root(name))
env["TMPDIR"] = tmp
return env
ddiff --git a/plugins/computer/tests/test_browser.py b/plugins/computer/tests/test_browser.py
iindex e6311f3..de1205e 100644
---- a/plugins/computer/tests/test_browser.py
++++ b/plugins/computer/tests/test_browser.py
@@@ -1,10 +1,12 @@
import http.server
import io
++import json
import os
import shutil
import socketserver
import sys
import threading
++import time
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
@@@ -206,6 +208,52 @@ class PageKeysTest(unittest.TestCase):
with self.assertRaises(BrowserError):
b.keys(bad)
++ def test_a_call_to_a_page_that_closed_fails_at_once(self):
++ # A popup that closes itself: Chrome on macOS never answers a call still waiting on its session, which hung the
++ # next read for the whole timeout (2026-10-27, the first macOS CI runs). The detach answers it at once, and
++ # _eval then reads the page in front again.
++ b = Browser("chromium", "/nonexistent", timeout_s=30)
++ b.session = "S-popup"
++ b.out = io.BytesIO()
++ events = [{"method": "Target.detachedFromTarget", "params": {"sessionId": "S-popup", "targetId": "T2"}}]
++ b.inp = io.BytesIO(b"".join(json.dumps(m).encode() + b"\0" for m in events))
++ started = time.monotonic()
++ errors = []
++ caller = threading.Thread(target=lambda: errors.append(self._call_error(b)))
++ caller.start()
++ time.sleep(0.2) # the call is waiting on the popup's session
++ b._reader() # then the browser says that page is gone
++ caller.join(5)
++ self.assertLess(time.monotonic() - started, 5, "not the 30 s timeout")
++ self.assertIn("session closed", errors[0].lower())
++
++ @staticmethod
++ def _call_error(b):
++ try:
++ b.call("Runtime.evaluate", {"expression": "1"})
++ except BrowserError as e:
++ return e.message
++ return "answered"
++
++ def test_editing_shortcuts_on_a_mac(self):
++ # Chrome on a Mac runs select all, copy, paste… only when the key event names the command: ctrl+a then
++ # BackSpace deleted one letter there (2026-10-27). An agent's ctrl or cmd chord does the same on every OS.
++ b = self.browser()
++ b.platform = "darwin"
++ for chord, command in (("ctrl+a", "selectAll"), ("cmd+a", "selectAll"), ("ctrl+c", "copy"), ("ctrl+v", "paste"),
++ ("ctrl+x", "cut"), ("ctrl+z", "undo"), ("ctrl+shift+z", "redo"), ("cmd+y", "redo")):
++ self.sent.clear()
++ b.keys(chord)
++ down = [p for p in self.sent if p["type"] in ("rawKeyDown", "keyDown") and p["key"].lower() in "acvxzy"][0]
++ self.assertEqual(down.get("commands"), [command], chord)
++ self.sent.clear()
++ b.keys("a")
++ self.assertNotIn("commands", self.sent[0], "a plain key types")
++ b.platform = "linux"
++ self.sent.clear()
++ b.keys("ctrl+a")
++ self.assertFalse(any("commands" in p for p in self.sent), "Linux and Windows run the shortcut themselves")
++
class Handler(http.server.SimpleHTTPRequestHandler):
PAGES = {
ddiff --git a/plugins/computer/tests/test_computer.py b/plugins/computer/tests/test_computer.py
iindex 589d164..820a29c 100644
---- a/plugins/computer/tests/test_computer.py
++++ b/plugins/computer/tests/test_computer.py
@@@ -79,7 +79,7 @@ class ComputerTest(unittest.TestCase):
def boot(self, level="observe", **config):
self.dir = tempfile.mkdtemp(prefix="ov-computer-")
self.addCleanup(lambda: __import__("shutil").rmtree(self.dir, ignore_errors=True))
-- with open(os.path.join(self.dir, "notes.txt"), "w") as f:
++ with open(os.path.join(self.dir, "notes.txt"), "w", newline="\n") as f: # the same bytes on Windows
f.write("hello computer\n")
os.mkdir(os.path.join(self.dir, "sub"))
self.out = Out()
ddiff --git a/plugins/computer/tests/test_desk.py b/plugins/computer/tests/test_desk.py
iindex d6f65ec..ee8be6b 100644
---- a/plugins/computer/tests/test_desk.py
++++ b/plugins/computer/tests/test_desk.py
@@@ -279,6 +279,18 @@ class DeskPluginTest(unittest.TestCase):
r = reply(self.out, self.cmd("desk", {"action": "stop"}))
self.assertEqual(r["data"], {"running": False, "stopped": True, "desk": "private"})
++ @unittest.skipIf(os.name == "nt", "Unix socket paths: desks run on Linux and macOS")
++ def test_a_desk_temp_folder_fits_chromes_socket(self):
++ # macOS's TMPDIR (/var/folders/…/T/) left Chrome's SingletonSocket at 109 bytes, over the 104 a Unix socket
++ # path takes there (2026-10-27): a temp folder whose socket would not fit goes under /tmp instead.
++ from openvibe_computer.desk import CHROME_SOCKET_TAIL, SOCKET_PATH_MAX, short_tmp_root
++ mac = "/var/folders/8x/kq4w0qz12n59p3v2fdwm5hzr0000gn/T"
++ root = short_tmp_root("desk-a", base=mac)
++ fits = len(os.path.join(mac, "ov-desk-a-XXXXXXXX")) + len(CHROME_SOCKET_TAIL) < SOCKET_PATH_MAX
++ self.assertEqual(root, mac if fits else "/tmp")
++ self.assertEqual(short_tmp_root("desk-a", base="/tmp"), "/tmp")
++ self.assertEqual(short_tmp_root("desk-a", base="/var/tmp"), "/var/tmp", "a short one stays")
++
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)
@@@ -300,8 +312,11 @@ class DeskPluginTest(unittest.TestCase):
tmp_b = self.calls[-1][1]["TMPDIR"]
self.assertNotEqual(tmp_a, tmp_b)
self.assertTrue(os.path.isdir(tmp_a) and os.path.basename(tmp_a).startswith("ov-desk-a-"))
-- self.assertEqual(os.stat(tmp_a).st_mode & 0o777, 0o700)
-- self.assertLess(len(os.path.join(tmp_a, ".com.google.Chrome.XXXXXX", "SingletonSocket")), 108)
++ if os.name != "nt": # POSIX permissions and Unix socket paths (Windows keeps them private by the profile's ACL)
++ self.assertEqual(os.stat(tmp_a).st_mode & 0o777, 0o700)
++ # Chrome's SingletonSocket fits a Unix socket path: 104 bytes on macOS, 108 on Linux.
++ from openvibe_computer.desk import SOCKET_PATH_MAX
++ self.assertLess(len(os.path.join(tmp_a, ".com.google.Chrome.XXXXXX", "SingletonSocket")), SOCKET_PATH_MAX)
# 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))
@@@ -478,14 +493,16 @@ class DeskPluginTest(unittest.TestCase):
self.assertEqual(stop("desk-a")["profile_saved"], True)
with open(os.path.join(os.path.dirname(kept), "ov-session-cookies.json")) as f:
self.assertEqual(json.load(f)[0]["name"], "sid", "session cookies are kept beside the profile")
-- self.assertEqual(os.stat(os.path.join(os.path.dirname(kept), "ov-session-cookies.json")).st_mode & 0o777, 0o600)
++ if os.name != "nt":
++ self.assertEqual(os.stat(os.path.join(os.path.dirname(kept), "ov-session-cookies.json")).st_mode & 0o777, 0o600)
self.assertTrue(a.graceful, "closed so it writes its cookies down")
with open(os.path.join(kept, "Cookies")) as f:
self.assertEqual(f.read(), "one")
self.assertFalse(os.path.exists(os.path.join(kept, "Cache")), "caches are never kept")
self.assertFalse(os.path.exists(a.profile), "the desk's own copy is removed")
self.assertFalse(os.path.exists(os.path.join(self.desk_dir, "desk-a")), "and its empty folders")
-- self.assertEqual(os.stat(os.path.dirname(kept)).st_mode & 0o777, 0o700)
++ if os.name != "nt":
++ self.assertEqual(os.stat(os.path.dirname(kept)).st_mode & 0o777, 0o700)
# Two desks at once start from the same kept profile; the last to stop wins.
start("desk-b", profile="agent")
start("desk-c", profile="agent")
@@@ -558,7 +575,8 @@ class DeskPluginTest(unittest.TestCase):
self.assertIsNone(office.poll(), "still running")
self.plugin.popen = fake
reply(self.out, self.cmd("desk", {"action": "stop", "desk": "desk-a"}))
-- self.assertEqual(office.wait(timeout=5), -9, "ended with its desk")
++ # Killed with its desk: SIGKILL on Linux and macOS; on Windows TerminateProcess, exit code 1.
++ self.assertEqual(office.wait(timeout=5), 1 if os.name == "nt" else -9, "ended with its desk")
# Never detached on the person's own session or a browser desk; a desk that does not run is not_ready.
self.plugin.popen = real
before = len(started)
@@@ -574,7 +592,7 @@ class DeskPluginTest(unittest.TestCase):
term = started[-1]
self.plugin.popen = fake
self.plugin.stop()
-- self.assertEqual(term.wait(timeout=5), -9)
++ self.assertEqual(term.wait(timeout=5), 1 if os.name == "nt" else -9)
self.assertTrue(self.plugin.describe({"level": "trusted"})["terminal"]["desks"])
def test_the_kill_switch_stops_the_desk(self):
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 2f01059..2c1aecc 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "SHARED_CLOCK_NAME", "TIMING_UNAVAILABLE", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
--__version__ = "0.30.0"
++__version__ = "0.31.0"
PROTOCOL_VERSION = 1
HEARTBEAT_TIMEOUT_S = 1.0
DEFAULT_DEADLINE_MS = 300
ddiff --git a/plugins/sdk/pyproject.toml b/plugins/sdk/pyproject.toml
iindex 93934e0..78a04cd 100644
---- a/plugins/sdk/pyproject.toml
++++ b/plugins/sdk/pyproject.toml
@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openvibe-plugin"
--version = "0.30.0"
++version = "0.31.0"
description = "Runtime for OpenVibe Node plugins (JSON lines over stdin/stdout, deadlines, heartbeat, e-stop)"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
ddiff --git a/plugins/sdk/tests/test_runtime.py b/plugins/sdk/tests/test_runtime.py
iindex 5efc016..8991873 100644
---- a/plugins/sdk/tests/test_runtime.py
++++ b/plugins/sdk/tests/test_runtime.py
@@@ -439,7 +439,7 @@ class RuntimeTest(unittest.TestCase):
self.assertEqual(rt.stop_infos[-1].halt_epoch, 1792900000500)
def test_the_package_reports_the_node_release(self):
-- self.assertEqual(openvibe_plugin.__version__, "0.30.0")
++ self.assertEqual(openvibe_plugin.__version__, "0.31.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())