openvibers/openvibe.link Commits
v0.24.1: kept profiles carry session cookies too (found by the production proof) (#71)
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.24.0",-- "release": "v0.24.0, published 2026-10-27 as a GitHub release (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.24.1",++ "release": "v0.24.1, published 2026-10-27 as a GitHub release (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,@@@ -201,6 +201,9 @@ task's desk from the profile `agent`.- **Saved when the desk stops.** The browser is asked to quit first, so it writes its cookies down. The desk's profileis then saved back (`profile_saved` in the answer), and the desk's own copy is removed, so a desk per task leavesnothing behind. When two desks with the same profile stop, the last one wins.++- **Session sign-ins too** (Node v0.24.1). Many sites sign people in with a cookie that ends when the browser quits,++ and Chrome throws those away as it closes. Before the desk's browser quits, its session cookies are kept beside the++ profile (`ov-session-cookies.json`, 0600), and the next desk's browser gets them back as soon as it starts.- **Not kept:** caches, crash reports, locks and the last session's tabs. A profile larger than `desk_profile_max_mb`(256 MB) is not saved, and an event `desk_profile_too_big` says so. The kill switch saves nothing.- **Where.** `browser_profile` + `-kept-<name>`, 0700, on this machine only. Profiles that move between machines are@@@ -68,6 +68,7 @@ Command kinds and their answers (the ack's ``data``):import base64import contextlib++import jsonimport http.clientimport ipaddressimport os@@@ -117,6 +118,7 @@ BROWSER_READS = frozenset({"read", "screenshot", "close"})BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})MAX_URL = 2048PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")++SESSION_COOKIES = "ov-session-cookies.json" # in a kept profile: the cookies that end with the browser# What a kept profile leaves out: caches, crash reports, locks and the last session's tabs (a desk opens on a blank page).PROFILE_SKIP = shutil.ignore_patterns("Cache", "Code Cache", "GPUCache", "GrShaderCache", "GraphiteDawnCache", "ShaderCache","DawnCache", "DawnGraphiteCache", "DawnWebGPUCache", "CacheStorage", "ScriptCache",@@@ -175,7 +177,7 @@ def _int(v, name, lo, hi, default):class Computer(Plugin):driver = "computer"-- version = "0.12.0"++ version = "0.12.1"motion_kinds = frozenset()telemetry_interval_s = 2.0@@@ -534,6 +536,9 @@ class Computer(Plugin):profile = self._desk_profile_dir(desk)self._restore_profile(desk)b = self.desk_browsers[desk] = self.browser_factory(binary, profile, page, env=env)++ kept = self._take_session_cookies(desk)++ if kept:++ b.after_start = lambda: self._put_cookies(desk, b, kept)b.download_dir = self.desks.dir(desk, "downloads")if page:st = self.desks.status(desk)@@@ -586,11 +591,45 @@ class Computer(Plugin):shutil.copytree(kept, own, symlinks=True, ignore=PROFILE_SKIP)os.chmod(own, 0o700)++ def _take_session_cookies(self, desk):++ """The session cookies a desk's kept profile brought (read once, then the file goes: it is written again on save)."""++ if desk not in self.desk_profiles:++ return None++ path = os.path.join(self._desk_profile_dir(desk), SESSION_COOKIES)++ try:++ with open(path) as f:++ cookies = json.load(f)++ except (OSError, ValueError):++ return None++ finally:++ try:++ os.unlink(path)++ except OSError:++ pass++ return cookies if isinstance(cookies, list) else None++++ def _put_cookies(self, desk, b, cookies):++ try:++ b.restore_cookies(cookies)++ except BrowserError as e:++ self.log("desk %s: its kept session cookies did not go back in: %s", desk, e.message)++def _end_desk_browser(self, desk):"""Close a desk's browser so it writes everything down, save its profile back when it started from a kept one(the last desk to stop wins), and remove the desk's own copy. → True when saved, False when not (too large ora failed copy), None for a desk without a kept profile."""b = self.desk_browsers.pop(desk, None)++ profile = self.desk_profiles.get(desk)++ if b is not None and profile and b.alive():++ # Chrome drops cookies that end with the browser when it quits, and a sign-in is often one: keep them.++ try:++ cookies = b.session_cookies()++ path = os.path.join(self._desk_profile_dir(desk), SESSION_COOKIES)++ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)++ with os.fdopen(fd, "w") as f:++ json.dump(cookies, f)++ except (BrowserError, OSError) as e:++ self.log("desk %s: its session cookies were not kept: %s", desk, getattr(e, "message", e))if b is not None:b.close(graceful=True)profile = self.desk_profiles.pop(desk, None)@@@ -27,7 +27,11 @@ STARTUP_S = 45.0MAX_LINKS = 60MAX_ELEMENTS = 120MAX_APPEARED = 8--MAX_FOLLOWED = 5 # pages opened from pages (a sign-in popup, a new tab) followed at most this deep++MAX_FOLLOWED = 5++MAX_SESSION_COOKIES = 3000++# What Storage.setCookies takes back of what Storage.getCookies gives.++COOKIE_FIELDS = ("name", "value", "domain", "path", "secure", "httpOnly", "sameSite", "priority", "sourceScheme",++ "sourcePort", "partitionKey") # pages opened from pages (a sign-in popup, a new tab) followed at most this deepKEY_CODES = {"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),@@@ -203,6 +207,7 @@ class Browser:self.tab_events = [] # Target.targetCreated / targetDestroyed seen since the last _followself.tab_change = None # "opened" or "closed" since the last _mark: said in the action's receiptself.follow_mu = threading.Lock()++ self.after_start = None # called once the browser runs (a kept profile's session cookies go back in)# ---- the process and the pipe ------------------------------------------------------------------------------------def alive(self):@@@ -250,6 +255,9 @@ class Browser:self.call("Target.setDiscoverTargets", {"discover": True}, session=False)if self.download_dir:self.call("Browser.setDownloadBehavior", {"behavior": "allow", "downloadPath": self.download_dir}, session=False)++ if self.after_start:++ hook, self.after_start = self.after_start, None++ hook()def _attach(self, target):"""Drive that page: a session of its own, its events on, and a browser desk's exact size."""@@@ -610,6 +618,23 @@ class Browser:f.write(data)os.replace(path + ".part", path)++ # ---- cookies a kept profile carries (Node v0.24.1) ------------------------------------------------------------++ def session_cookies(self):++ """The cookies that end with the browser (no expiry): Chrome drops them when it quits, and a sign-in is often++ one. A kept profile keeps them alongside, for the next desk (restore_cookies)."""++ if not self.alive():++ return []++ got = self.call("Storage.getCookies", {}, session=False).get("cookies") or []++ return [{k: c[k] for k in COOKIE_FIELDS if k in c} for c in got if c.get("session")][:MAX_SESSION_COOKIES]++++ def restore_cookies(self, cookies):++ """Put session cookies back (from session_cookies, on another run of the browser)."""++ ok = [c for c in cookies if isinstance(c, dict) and isinstance(c.get("name"), str) and isinstance(c.get("value"), str)++ and isinstance(c.get("domain"), str)][:MAX_SESSION_COOKIES]++ if ok:++ self.call("Storage.setCookies", {"cookies": [{k: c[k] for k in COOKIE_FIELDS if k in c} for c in ok]}, session=False)++ return len(ok)++def close(self, graceful=False):"""End the browser. graceful: ask it to quit first (a few seconds), so it writes its cookies and storage to theprofile (a desk's profile is kept, Node v0.24.0); else, and when it does not quit, it is killed."""@@@ -235,7 +235,11 @@ class BrowserDesk:with self.mu:if not self.on:raise Fault("not_ready", "start the desk first (desk start)")-- return {k: v for k, v in self.base_env.items() if k not in OWNER_SESSION}++ env = {k: v for k, v in self.base_env.items() if k not in OWNER_SESSION}++ # No session bus at all, and none started: without an address Chrome tries to auto-launch one, which can++ # hang its start on a machine with no display (a CI runner, a desk host's service).++ env["DBUS_SESSION_BUS_ADDRESS"] = "disabled:"++ return env# A browser desk counts as half a desk against max_desks (a whole desk counts 2 of 2 * max_desks).@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"[project]name = "openvibe-computer"--version = "0.12.0"++version = "0.12.1"description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"requires-python = ">=3.9"license = { text = "AGPL-3.0-or-later" }@@@ -215,6 +215,7 @@ class Handler(http.server.SimpleHTTPRequestHandler):# Node v0.24.0: a page that opens another (a sign-in popup), and one that sets a cookie."/pop": b"<!doctype html><title>Pop</title><button id='open' onclick=\"window.open('/two', 'signin', 'popup')\">Sign in</button>","/cookie": b"<!doctype html><title>Cookie</title><script>if (!document.cookie.includes('kept=yes')) document.cookie = 'kept=yes; max-age=3600; path=/';</script>",++ "/session": b"<!doctype html><title>Session</title><script>if (!document.cookie.includes('sid=')) document.cookie = 'sid=s1; path=/';</script>",# Desks plan D3: a page whose button changes the page without going anywhere, and things that are not elements."/app": b"<!doctype html><title>App</title><p>Ready</p><button id='go' aria-label='Add a row'>+</button>"b"<input type='email' placeholder='Email'><input type='checkbox' checked><a href='/two'>Second page</a>"@@@ -298,6 +299,16 @@ class RealBrowserTest(unittest.TestCase):self.addCleanup(again.close)again.open(base + "/cookie")self.assertIn("kept=yes", again._eval("document.cookie"))++ # A session cookie (no expiry) ends with the browser; it is kept and put back by hand (Node v0.24.1).++ again.open(base + "/session")++ session = again.session_cookies()++ self.assertEqual([c["name"] for c in session], ["sid"])++ again.close(graceful=True)++ third = Browser(find_binary(shutil.which, sys.platform), profile, extra_args=extra)++ self.addCleanup(third.close)++ third.after_start = lambda: third.restore_cookies(session)++ third.open(base + "/two")++ self.assertIn("sid=s1", third._eval("document.cookie"), "the session sign-in is back")def test_a_browser_desk_its_page_is_the_screen(self):# Desks plan D2 in a real browser: no display; the page is the desk's screen, mouse and keyboard, and live view.@@@ -4,6 +4,7 @@ machine's level (refused at observe), reports it at every level, and sends screedesk: private (refused until it runs), never to the owner's screen. A real desk runs where Xvfb is installed."""import base64import io++import jsonimport osimport shutilimport sys@@@ -181,6 +182,7 @@ class ProfileBrowser:self.on, self.graceful = True, Nonepath = os.path.join(profile, "Default", "Cookies")self.found = open(path).read() if os.path.exists(path) else None # what the desk started with++ self.after_start, self.session, self.restored = None, [], NoneProfileBrowser.made.append(self)def alive(self):@@@ -194,8 +196,18 @@ class ProfileBrowser:f.write(b"x" * (cache_mb * 1024 * 1024 or 10))def open(self, url):++ if self.after_start:++ hook, self.after_start = self.after_start, None++ hook()return {"url": url}++ def session_cookies(self):++ return self.session++++ def restore_cookies(self, cookies):++ self.restored = cookies++ return len(cookies)++def close(self, graceful=False):self.on, self.graceful = False, graceful@@@ -378,6 +390,7 @@ class DeskPluginTest(unittest.TestCase):self.assertEqual((a.headless, a.window), (True, (1000, 500)))self.assertTrue(a.profile.endswith("-desk-a"))self.assertNotIn("DISPLAY", a.env)++ self.assertEqual(a.env["DBUS_SESSION_BUS_ADDRESS"], "disabled:", "never the owner's session bus, and none started")self.assertTrue(os.path.basename(a.env["TMPDIR"]).startswith("ov-desk-a-"))# The mouse and keyboard are the page's: a point in a 100×100 picture lands on the 1000×500 page.r = reply(self.out, self.cmd("input", {"action": "click", "x": 50, "y": 50, "width": 100, "height": 100, "desk": "desk-a"}))@@@ -460,7 +473,11 @@ class DeskPluginTest(unittest.TestCase):self.assertIsNone(a.found, "nothing kept yet")self.assertEqual(a.profile, os.path.join(home, "browser-desk-a"))a.sign_in("one")++ a.session = [{"name": "sid", "value": "s1", "domain": "example.org", "path": "/"}] # a sign-in that ends with the browserself.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)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")@@@ -472,6 +489,8 @@ class DeskPluginTest(unittest.TestCase):start("desk-c", profile="agent")b, c = use("desk-b"), use("desk-c")self.assertEqual((b.found, c.found), ("one", "one"), "signed in on both")++ self.assertEqual([x["name"] for x in b.restored], ["sid"], "the session cookie is back as the browser starts")++ self.assertFalse(os.path.exists(os.path.join(b.profile, "ov-session-cookies.json")), "read once, not left in the copy")c.sign_in("three")b.sign_in("two")stop("desk-c")@@@ -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.24.0"++__version__ = "0.24.1"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.24.0"++version = "0.24.1"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.24.0")++ self.assertEqual(openvibe_plugin.__version__, "0.24.1")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.24.0",
- "release": "v0.24.0, published 2026-10-27 as a GitHub release (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.24.1",
+ "release": "v0.24.1, published 2026-10-27 as a GitHub release (a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"database": "none",
"deployed": false,
"deployed": false,
@@ -201,6 +201,9 @@ task's desk from the profile `agent`.
@@ -201,6 +201,9 @@ task's desk from the profile `agent`.
- **Saved when the desk stops.** The browser is asked to quit first, so it writes its cookies down. The desk's profile
- **Saved when the desk stops.** The browser is asked to quit first, so it writes its cookies down. The desk's profile
is then saved back (`profile_saved` in the answer), and the desk's own copy is removed, so a desk per task leaves
is then saved back (`profile_saved` in the answer), and the desk's own copy is removed, so a desk per task leaves
nothing behind. When two desks with the same profile stop, the last one wins.
nothing behind. When two desks with the same profile stop, the last one wins.
+- **Session sign-ins too** (Node v0.24.1). Many sites sign people in with a cookie that ends when the browser quits,
+ and Chrome throws those away as it closes. Before the desk's browser quits, its session cookies are kept beside the
+ profile (`ov-session-cookies.json`, 0600), and the next desk's browser gets them back as soon as it starts.
- **Not kept:** caches, crash reports, locks and the last session's tabs. A profile larger than `desk_profile_max_mb`
- **Not kept:** caches, crash reports, locks and the last session's tabs. A profile larger than `desk_profile_max_mb`
(256 MB) is not saved, and an event `desk_profile_too_big` says so. The kill switch saves nothing.
(256 MB) is not saved, and an event `desk_profile_too_big` says so. The kill switch saves nothing.
- **Where.** `browser_profile` + `-kept-<name>`, 0700, on this machine only. Profiles that move between machines are
- **Where.** `browser_profile` + `-kept-<name>`, 0700, on this machine only. Profiles that move between machines are
@@ -68,6 +68,7 @@ Command kinds and their answers (the ack's ``data``):
@@ -68,6 +68,7 @@ Command kinds and their answers (the ack's ``data``):
import base64
import base64
import contextlib
import contextlib
+import json
import http.client
import http.client
import ipaddress
import ipaddress
import os
import os
@@ -117,6 +118,7 @@ BROWSER_READS = frozenset({"read", "screenshot", "close"})
@@ -117,6 +118,7 @@ BROWSER_READS = frozenset({"read", "screenshot", "close"})
BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
BROWSER_ACTS = frozenset({"open", "click", "fill", "press", "back"})
MAX_URL = 2048
MAX_URL = 2048
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
PROFILE_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,23}$")
+SESSION_COOKIES = "ov-session-cookies.json" # in a kept profile: the cookies that end with the browser
# What a kept profile leaves out: caches, crash reports, locks and the last session's tabs (a desk opens on a blank page).
# What a kept profile leaves out: caches, crash reports, locks and the last session's tabs (a desk opens on a blank page).
PROFILE_SKIP = shutil.ignore_patterns("Cache", "Code Cache", "GPUCache", "GrShaderCache", "GraphiteDawnCache", "ShaderCache",
PROFILE_SKIP = shutil.ignore_patterns("Cache", "Code Cache", "GPUCache", "GrShaderCache", "GraphiteDawnCache", "ShaderCache",
"DawnCache", "DawnGraphiteCache", "DawnWebGPUCache", "CacheStorage", "ScriptCache",
"DawnCache", "DawnGraphiteCache", "DawnWebGPUCache", "CacheStorage", "ScriptCache",
@@ -175,7 +177,7 @@ def _int(v, name, lo, hi, default):
@@ -175,7 +177,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
class Computer(Plugin):
driver = "computer"
driver = "computer"
- version = "0.12.0"
+ version = "0.12.1"
motion_kinds = frozenset()
motion_kinds = frozenset()
telemetry_interval_s = 2.0
telemetry_interval_s = 2.0
@@ -534,6 +536,9 @@ class Computer(Plugin):
@@ -534,6 +536,9 @@ class Computer(Plugin):
profile = self._desk_profile_dir(desk)
profile = self._desk_profile_dir(desk)
self._restore_profile(desk)
self._restore_profile(desk)
b = self.desk_browsers[desk] = self.browser_factory(binary, profile, page, env=env)
b = self.desk_browsers[desk] = self.browser_factory(binary, profile, page, env=env)
+ kept = self._take_session_cookies(desk)
+ if kept:
+ b.after_start = lambda: self._put_cookies(desk, b, kept)
b.download_dir = self.desks.dir(desk, "downloads")
b.download_dir = self.desks.dir(desk, "downloads")
if page:
if page:
st = self.desks.status(desk)
st = self.desks.status(desk)
@@ -586,11 +591,45 @@ class Computer(Plugin):
@@ -586,11 +591,45 @@ class Computer(Plugin):
shutil.copytree(kept, own, symlinks=True, ignore=PROFILE_SKIP)
shutil.copytree(kept, own, symlinks=True, ignore=PROFILE_SKIP)
os.chmod(own, 0o700)
os.chmod(own, 0o700)
+ def _take_session_cookies(self, desk):
+ """The session cookies a desk's kept profile brought (read once, then the file goes: it is written again on save)."""
+ if desk not in self.desk_profiles:
+ return None
+ path = os.path.join(self._desk_profile_dir(desk), SESSION_COOKIES)
+ try:
+ with open(path) as f:
+ cookies = json.load(f)
+ except (OSError, ValueError):
+ return None
+ finally:
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+ return cookies if isinstance(cookies, list) else None
+
+ def _put_cookies(self, desk, b, cookies):
+ try:
+ b.restore_cookies(cookies)
+ except BrowserError as e:
+ self.log("desk %s: its kept session cookies did not go back in: %s", desk, e.message)
+
def _end_desk_browser(self, desk):
def _end_desk_browser(self, desk):
"""Close a desk's browser so it writes everything down, save its profile back when it started from a kept one
"""Close a desk's browser so it writes everything down, save its profile back when it started from a kept one
(the last desk to stop wins), and remove the desk's own copy. → True when saved, False when not (too large or
(the last desk to stop wins), and remove the desk's own copy. → True when saved, False when not (too large or
a failed copy), None for a desk without a kept profile."""
a failed copy), None for a desk without a kept profile."""
b = self.desk_browsers.pop(desk, None)
b = self.desk_browsers.pop(desk, None)
+ profile = self.desk_profiles.get(desk)
+ if b is not None and profile and b.alive():
+ # Chrome drops cookies that end with the browser when it quits, and a sign-in is often one: keep them.
+ try:
+ cookies = b.session_cookies()
+ path = os.path.join(self._desk_profile_dir(desk), SESSION_COOKIES)
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w") as f:
+ json.dump(cookies, f)
+ except (BrowserError, OSError) as e:
+ self.log("desk %s: its session cookies were not kept: %s", desk, getattr(e, "message", e))
if b is not None:
if b is not None:
b.close(graceful=True)
b.close(graceful=True)
profile = self.desk_profiles.pop(desk, None)
profile = self.desk_profiles.pop(desk, None)
@@ -27,7 +27,11 @@ STARTUP_S = 45.0
@@ -27,7 +27,11 @@ STARTUP_S = 45.0
MAX_LINKS = 60
MAX_LINKS = 60
MAX_ELEMENTS = 120
MAX_ELEMENTS = 120
MAX_APPEARED = 8
MAX_APPEARED = 8
-MAX_FOLLOWED = 5 # pages opened from pages (a sign-in popup, a new tab) followed at most this deep
+MAX_FOLLOWED = 5
+MAX_SESSION_COOKIES = 3000
+# What Storage.setCookies takes back of what Storage.getCookies gives.
+COOKIE_FIELDS = ("name", "value", "domain", "path", "secure", "httpOnly", "sameSite", "priority", "sourceScheme",
+ "sourcePort", "partitionKey") # pages opened from pages (a sign-in popup, a new tab) followed at most this deep
KEY_CODES = {
KEY_CODES = {
"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),
"Enter": (13, "\r"), "Tab": (9, "\t"), "Escape": (27, ""), "Backspace": (8, ""), "Delete": (46, ""),
"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),
"ArrowUp": (38, ""), "ArrowDown": (40, ""), "ArrowLeft": (37, ""), "ArrowRight": (39, ""),
@@ -203,6 +207,7 @@ class Browser:
@@ -203,6 +207,7 @@ class Browser:
self.tab_events = [] # Target.targetCreated / targetDestroyed seen since the last _follow
self.tab_events = [] # Target.targetCreated / targetDestroyed seen since the last _follow
self.tab_change = None # "opened" or "closed" since the last _mark: said in the action's receipt
self.tab_change = None # "opened" or "closed" since the last _mark: said in the action's receipt
self.follow_mu = threading.Lock()
self.follow_mu = threading.Lock()
+ self.after_start = None # called once the browser runs (a kept profile's session cookies go back in)
# ---- the process and the pipe ------------------------------------------------------------------------------------
# ---- the process and the pipe ------------------------------------------------------------------------------------
def alive(self):
def alive(self):
@@ -250,6 +255,9 @@ class Browser:
@@ -250,6 +255,9 @@ class Browser:
self.call("Target.setDiscoverTargets", {"discover": True}, session=False)
self.call("Target.setDiscoverTargets", {"discover": True}, session=False)
if self.download_dir:
if self.download_dir:
self.call("Browser.setDownloadBehavior", {"behavior": "allow", "downloadPath": self.download_dir}, session=False)
self.call("Browser.setDownloadBehavior", {"behavior": "allow", "downloadPath": self.download_dir}, session=False)
+ if self.after_start:
+ hook, self.after_start = self.after_start, None
+ hook()
def _attach(self, target):
def _attach(self, target):
"""Drive that page: a session of its own, its events on, and a browser desk's exact size."""
"""Drive that page: a session of its own, its events on, and a browser desk's exact size."""
@@ -610,6 +618,23 @@ class Browser:
@@ -610,6 +618,23 @@ class Browser:
f.write(data)
f.write(data)
os.replace(path + ".part", path)
os.replace(path + ".part", path)
+ # ---- cookies a kept profile carries (Node v0.24.1) ------------------------------------------------------------
+ def session_cookies(self):
+ """The cookies that end with the browser (no expiry): Chrome drops them when it quits, and a sign-in is often
+ one. A kept profile keeps them alongside, for the next desk (restore_cookies)."""
+ if not self.alive():
+ return []
+ got = self.call("Storage.getCookies", {}, session=False).get("cookies") or []
+ return [{k: c[k] for k in COOKIE_FIELDS if k in c} for c in got if c.get("session")][:MAX_SESSION_COOKIES]
+
+ def restore_cookies(self, cookies):
+ """Put session cookies back (from session_cookies, on another run of the browser)."""
+ ok = [c for c in cookies if isinstance(c, dict) and isinstance(c.get("name"), str) and isinstance(c.get("value"), str)
+ and isinstance(c.get("domain"), str)][:MAX_SESSION_COOKIES]
+ if ok:
+ self.call("Storage.setCookies", {"cookies": [{k: c[k] for k in COOKIE_FIELDS if k in c} for c in ok]}, session=False)
+ return len(ok)
+
def close(self, graceful=False):
def close(self, graceful=False):
"""End the browser. graceful: ask it to quit first (a few seconds), so it writes its cookies and storage to the
"""End the browser. graceful: ask it to quit first (a few seconds), so it writes its cookies and storage to the
profile (a desk's profile is kept, Node v0.24.0); else, and when it does not quit, it is killed."""
profile (a desk's profile is kept, Node v0.24.0); else, and when it does not quit, it is killed."""
@@ -235,7 +235,11 @@ class BrowserDesk:
@@ -235,7 +235,11 @@ class BrowserDesk:
with self.mu:
with self.mu:
if not self.on:
if not self.on:
raise Fault("not_ready", "start the desk first (desk start)")
raise Fault("not_ready", "start the desk first (desk start)")
- return {k: v for k, v in self.base_env.items() if k not in OWNER_SESSION}
+ env = {k: v for k, v in self.base_env.items() if k not in OWNER_SESSION}
+ # No session bus at all, and none started: without an address Chrome tries to auto-launch one, which can
+ # hang its start on a machine with no display (a CI runner, a desk host's service).
+ env["DBUS_SESSION_BUS_ADDRESS"] = "disabled:"
+ return env
# A browser desk counts as half a desk against max_desks (a whole desk counts 2 of 2 * max_desks).
# A browser desk counts as half a desk against max_desks (a whole desk counts 2 of 2 * max_desks).
@@ -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.12.0"
+version = "0.12.1"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
requires-python = ">=3.9"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
license = { text = "AGPL-3.0-or-later" }
@@ -215,6 +215,7 @@ class Handler(http.server.SimpleHTTPRequestHandler):
@@ -215,6 +215,7 @@ class Handler(http.server.SimpleHTTPRequestHandler):
# Node v0.24.0: a page that opens another (a sign-in popup), and one that sets a cookie.
# Node v0.24.0: a page that opens another (a sign-in popup), and one that sets a cookie.
"/pop": b"<!doctype html><title>Pop</title><button id='open' onclick=\"window.open('/two', 'signin', 'popup')\">Sign in</button>",
"/pop": b"<!doctype html><title>Pop</title><button id='open' onclick=\"window.open('/two', 'signin', 'popup')\">Sign in</button>",
"/cookie": b"<!doctype html><title>Cookie</title><script>if (!document.cookie.includes('kept=yes')) document.cookie = 'kept=yes; max-age=3600; path=/';</script>",
"/cookie": b"<!doctype html><title>Cookie</title><script>if (!document.cookie.includes('kept=yes')) document.cookie = 'kept=yes; max-age=3600; path=/';</script>",
+ "/session": b"<!doctype html><title>Session</title><script>if (!document.cookie.includes('sid=')) document.cookie = 'sid=s1; path=/';</script>",
# Desks plan D3: a page whose button changes the page without going anywhere, and things that are not elements.
# Desks plan D3: a page whose button changes the page without going anywhere, and things that are not elements.
"/app": b"<!doctype html><title>App</title><p>Ready</p><button id='go' aria-label='Add a row'>+</button>"
"/app": b"<!doctype html><title>App</title><p>Ready</p><button id='go' aria-label='Add a row'>+</button>"
b"<input type='email' placeholder='Email'><input type='checkbox' checked><a href='/two'>Second page</a>"
b"<input type='email' placeholder='Email'><input type='checkbox' checked><a href='/two'>Second page</a>"
@@ -298,6 +299,16 @@ class RealBrowserTest(unittest.TestCase):
@@ -298,6 +299,16 @@ class RealBrowserTest(unittest.TestCase):
self.addCleanup(again.close)
self.addCleanup(again.close)
again.open(base + "/cookie")
again.open(base + "/cookie")
self.assertIn("kept=yes", again._eval("document.cookie"))
self.assertIn("kept=yes", again._eval("document.cookie"))
+ # A session cookie (no expiry) ends with the browser; it is kept and put back by hand (Node v0.24.1).
+ again.open(base + "/session")
+ session = again.session_cookies()
+ self.assertEqual([c["name"] for c in session], ["sid"])
+ again.close(graceful=True)
+ third = Browser(find_binary(shutil.which, sys.platform), profile, extra_args=extra)
+ self.addCleanup(third.close)
+ third.after_start = lambda: third.restore_cookies(session)
+ third.open(base + "/two")
+ self.assertIn("sid=s1", third._eval("document.cookie"), "the session sign-in is back")
def test_a_browser_desk_its_page_is_the_screen(self):
def test_a_browser_desk_its_page_is_the_screen(self):
# Desks plan D2 in a real browser: no display; the page is the desk's screen, mouse and keyboard, and live view.
# Desks plan D2 in a real browser: no display; the page is the desk's screen, mouse and keyboard, and live view.
@@ -4,6 +4,7 @@ machine's level (refused at observe), reports it at every level, and sends scree
@@ -4,6 +4,7 @@ machine's level (refused at observe), reports it at every level, and sends scree
desk: private (refused until it runs), never to the owner's screen. A real desk runs where Xvfb is installed."""
desk: private (refused until it runs), never to the owner's screen. A real desk runs where Xvfb is installed."""
import base64
import base64
import io
import io
+import json
import os
import os
import shutil
import shutil
import sys
import sys
@@ -181,6 +182,7 @@ class ProfileBrowser:
@@ -181,6 +182,7 @@ class ProfileBrowser:
self.on, self.graceful = True, None
self.on, self.graceful = True, None
path = os.path.join(profile, "Default", "Cookies")
path = os.path.join(profile, "Default", "Cookies")
self.found = open(path).read() if os.path.exists(path) else None # what the desk started with
self.found = open(path).read() if os.path.exists(path) else None # what the desk started with
+ self.after_start, self.session, self.restored = None, [], None
ProfileBrowser.made.append(self)
ProfileBrowser.made.append(self)
def alive(self):
def alive(self):
@@ -194,8 +196,18 @@ class ProfileBrowser:
@@ -194,8 +196,18 @@ class ProfileBrowser:
f.write(b"x" * (cache_mb * 1024 * 1024 or 10))
f.write(b"x" * (cache_mb * 1024 * 1024 or 10))
def open(self, url):
def open(self, url):
+ if self.after_start:
+ hook, self.after_start = self.after_start, None
+ hook()
return {"url": url}
return {"url": url}
+ def session_cookies(self):
+ return self.session
+
+ def restore_cookies(self, cookies):
+ self.restored = cookies
+ return len(cookies)
+
def close(self, graceful=False):
def close(self, graceful=False):
self.on, self.graceful = False, graceful
self.on, self.graceful = False, graceful
@@ -378,6 +390,7 @@ class DeskPluginTest(unittest.TestCase):
@@ -378,6 +390,7 @@ class DeskPluginTest(unittest.TestCase):
self.assertEqual((a.headless, a.window), (True, (1000, 500)))
self.assertEqual((a.headless, a.window), (True, (1000, 500)))
self.assertTrue(a.profile.endswith("-desk-a"))
self.assertTrue(a.profile.endswith("-desk-a"))
self.assertNotIn("DISPLAY", a.env)
self.assertNotIn("DISPLAY", a.env)
+ self.assertEqual(a.env["DBUS_SESSION_BUS_ADDRESS"], "disabled:", "never the owner's session bus, and none started")
self.assertTrue(os.path.basename(a.env["TMPDIR"]).startswith("ov-desk-a-"))
self.assertTrue(os.path.basename(a.env["TMPDIR"]).startswith("ov-desk-a-"))
# The mouse and keyboard are the page's: a point in a 100×100 picture lands on the 1000×500 page.
# The mouse and keyboard are the page's: a point in a 100×100 picture lands on the 1000×500 page.
r = reply(self.out, self.cmd("input", {"action": "click", "x": 50, "y": 50, "width": 100, "height": 100, "desk": "desk-a"}))
r = reply(self.out, self.cmd("input", {"action": "click", "x": 50, "y": 50, "width": 100, "height": 100, "desk": "desk-a"}))
@@ -460,7 +473,11 @@ class DeskPluginTest(unittest.TestCase):
@@ -460,7 +473,11 @@ class DeskPluginTest(unittest.TestCase):
self.assertIsNone(a.found, "nothing kept yet")
self.assertIsNone(a.found, "nothing kept yet")
self.assertEqual(a.profile, os.path.join(home, "browser-desk-a"))
self.assertEqual(a.profile, os.path.join(home, "browser-desk-a"))
a.sign_in("one")
a.sign_in("one")
+ a.session = [{"name": "sid", "value": "s1", "domain": "example.org", "path": "/"}] # a sign-in that ends with the browser
self.assertEqual(stop("desk-a")["profile_saved"], True)
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)
self.assertTrue(a.graceful, "closed so it writes its cookies down")
self.assertTrue(a.graceful, "closed so it writes its cookies down")
with open(os.path.join(kept, "Cookies")) as f:
with open(os.path.join(kept, "Cookies")) as f:
self.assertEqual(f.read(), "one")
self.assertEqual(f.read(), "one")
@@ -472,6 +489,8 @@ class DeskPluginTest(unittest.TestCase):
@@ -472,6 +489,8 @@ class DeskPluginTest(unittest.TestCase):
start("desk-c", profile="agent")
start("desk-c", profile="agent")
b, c = use("desk-b"), use("desk-c")
b, c = use("desk-b"), use("desk-c")
self.assertEqual((b.found, c.found), ("one", "one"), "signed in on both")
self.assertEqual((b.found, c.found), ("one", "one"), "signed in on both")
+ self.assertEqual([x["name"] for x in b.restored], ["sid"], "the session cookie is back as the browser starts")
+ self.assertFalse(os.path.exists(os.path.join(b.profile, "ov-session-cookies.json")), "read once, not left in the copy")
c.sign_in("three")
c.sign_in("three")
b.sign_in("two")
b.sign_in("two")
stop("desk-c")
stop("desk-c")
@@ -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.24.0"
+__version__ = "0.24.1"
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.24.0"
+version = "0.24.1"
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.24.0")
+ self.assertEqual(openvibe_plugin.__version__, "0.24.1")
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())