openvibers/openvibe.link Commits

v0.25.0: the egress guard: every connection every agent browser makes (a page's own scripts included) goes through a proxy 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; a task's desk leaves no empty folders (#72)

OP OpenVibers committed 17 days ago · c043f89d7c · 11 files, +423 −11

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex ed5788d..b67eef8 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.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",
++ "version": "0.25.0",
++ "release": "v0.25.0, published 2026-10-27 as a GitHub release (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 2f477f7..428c1f7 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -121,6 +121,16 @@ text and keys are passed to the tool as arguments, never through a shell, and `k
- **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).
++- **The egress guard** (Node v0.25.0). A page the agent opened could still reach your network with its own scripts: a
++ `fetch`, an image, a WebSocket or a redirect to your router, a printer or another computer at home. So every
++ connection every agent browser makes (on your screen and on every desk) goes through a small proxy the plugin runs on
++ `127.0.0.1`.
++ - **Checked.** Each address is resolved and checked. A name with any address that is not on the public internet is
++ refused below level `full`.
++ - **Connected to what was checked.** The connection goes to the exact address that was checked, so a name cannot
++ pass the check with one address and connect to another.
++ - **Nothing around it.** Loopback goes through the guard too, WebRTC sends no UDP around it, and QUIC is off.
++ - `browser_egress: "open"` turns the guard off, for a machine whose agent must reach a local service.
- **Elements and receipts** (Node v0.21.0, the Desks plan's step D3). `open` and `read` list the page's interactive
elements, one compact line each: `e7 button "Sign in"`, `e8 textbox "Email" (email)`, `e9 checkbox "Remember me"
checked`. That is at most 120 visible links, buttons, fields, boxes and menus, named by their label, text or
@@@ -254,6 +264,7 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_
| `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_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 2cf430c..f979cdb 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -33,7 +33,9 @@ Command kinds and their answers (the ack's ``data``):
``height`` (the ``screen`` answer) and are scaled to the real screen. An acting command: refused at observe, approved
one by one at ask. xdotool on X11, cliclick on macOS (no scroll there); none on Wayland yet. Config
``allow_input: false`` turns it off.
--* ``browser`` ``{"action", "url"?, "selector"?, "text"?, "key"?, "max_width"?}``: a Chromium the agent drives, in a profile
++* ``browser`` ``{"action", "url"?, "selector"?, "text"?, "key"?, "max_width"?}`` (every connection it makes, a page's own
++ scripts included, goes through the egress guard, ``egress.py``: never the machine's own network below level full): a
++ Chromium the agent drives, in a profile
of its own (``browser_profile``), over a pipe (no port is opened). ``open`` a ``http(s)`` URL, ``click`` a CSS
``selector`` or the button or link whose ``text`` it shows, ``fill`` a field (``selector`` and ``text``), ``press`` a
``key`` (Enter, Tab, Escape, arrows …), ``back`` → ``{url, title, text, truncated, links}``; ``read`` the page the same
@@@ -90,6 +92,7 @@ from openvibe_plugin import Deferred, Fault, Plugin
from .browser import KEY_CODES, Browser, BrowserError, find_binary
from .desk import MAX_DESKS_CAP, BrowserDesk, Desks, PrivateDesk, default_max_desks
++from .egress import EgressProxy, not_public
LEVELS = ("observe", "ask", "trusted", "full")
RANK = {name: i for i, name in enumerate(LEVELS)}
@@@ -177,7 +180,7 @@ def _int(v, name, lo, hi, default):
class Computer(Plugin):
driver = "computer"
-- version = "0.12.1"
++ version = "0.13.0"
motion_kinds = frozenset()
telemetry_interval_s = 2.0
@@@ -219,7 +222,11 @@ class Computer(Plugin):
self.stream_mu = threading.Lock()
self.browser_mu = threading.Lock()
self.browser_op_mu = threading.Lock()
-- self.browser_factory = browser_factory or (lambda binary, profile, headless, env=None: Browser(binary, profile, headless, env=env))
++ self.browser_factory = browser_factory or (lambda binary, profile, headless, env=None: Browser(binary, profile, headless, env=env, extra_args=self._browser_args()))
++ # Every agent browser's connections go through the egress guard (Node v0.25.0): a page's own scripts never reach
++ # the machine's own network below level full (config browser_egress: "open" turns it off).
++ self.browser_egress = "public"
++ self.egress = EgressProxy(blocked=self._egress_blocked, log=lambda *a: self.log(*a))
self.desk_factory = desk_factory or (lambda: PrivateDesk(which=which, popen=popen))
self.desk = desk or self.desk_factory() # the desk named private (the first one, as before v0.9.0)
self.desks = Desks(self.desk_factory, private=self.desk, max_desks=default_max_desks(),
@@@ -266,6 +273,10 @@ class Computer(Plugin):
self.browser_profile = os.path.expanduser(config.get("browser_profile") or "~/.local/share/openvibe-node/browser")
self.browser_headless = config.get("browser_headless", True) is not False
self.browser_idle_s = _int(config.get("browser_idle_s"), "browser_idle_s", 60, 86400, 600)
++ egress = str(config.get("browser_egress") or "public").strip().lower()
++ if egress not in ("public", "open"):
++ raise Fault("bad_value", "browser_egress is public (the default: never the machine's own network below level full) or open")
++ self.browser_egress = egress
def describe(self, config):
try:
@@@ -286,7 +297,8 @@ class Computer(Plugin):
caps["input"] = {"level": self.level, "tool": itool, "scroll": itool == "xdotool"}
binary = self._browser_binary()
if binary:
-- caps["browser"] = {"level": self.level, "browser": os.path.basename(binary), "headless": self.browser_headless}
++ caps["browser"] = {"level": self.level, "browser": os.path.basename(binary), "headless": self.browser_headless,
++ "egress": self.browser_egress}
kinds = self._desk_kinds()
if kinds:
# A browser desk's screen and input are its page's own (the browser's pipe); a whole desk's need X tools.
@@@ -661,6 +673,18 @@ class Computer(Plugin):
return False
finally:
shutil.rmtree(own, ignore_errors=True) # a copy for one run of the desk
++ self._drop_empty_desk_dir(desk)
++
++ def _drop_empty_desk_dir(self, desk):
++ """A desk for one task leaves no empty folders behind (its downloads folder, then its own); a download stays."""
++ if not self.desks.base_dir:
++ return
++ base = os.path.join(self.desks.base_dir, desk)
++ for path in (os.path.join(base, "downloads"), base):
++ try:
++ os.rmdir(path) # only when empty
++ except OSError:
++ return
def _drop_desk_profiles(self):
"""The kill switch: nothing is saved, and the desks' own copies go."""
@@@ -1132,6 +1156,16 @@ class Computer(Plugin):
return None
return find_binary(self.which, self.platform, self.browser_path)
++ def _egress_blocked(self, ip, port):
++ """Whether the egress guard refuses a connection: an address that is not on the public internet (the machine's
++ own network, loopback, link-local, ...), below level full; nothing when browser_egress is open."""
++ if self.browser_egress == "open" or self.effective_level() == "full":
++ return False
++ return not_public(ip)
++
++ def _browser_args(self):
++ return [] if self.browser_egress == "open" else self.egress.args()
++
def _close_browser(self):
# Never waits for an action in flight: closing kills the process, and the action fails at once.
b, self.browser = self.browser, None
ddiff --git a/plugins/computer/openvibe_computer/egress.py b/plugins/computer/openvibe_computer/egress.py
nnew file mode 100644
iindex 0000000..cd3751e
---- /dev/null
++++ b/plugins/computer/openvibe_computer/egress.py
@@@ -0,0 +1,191 @@
++"""The agent browser's egress guard (Node v0.25.0): a forward proxy on 127.0.0.1 that every agent browser goes through.
++
++The browser's ``open`` already refuses an address on the machine's own network below level full, but a page it opened
++could still reach the network with its own scripts (fetch, an image, a WebSocket, a redirect): the owner's router, a
++printer, another computer at home. Every connection the browser makes now comes here first. Its address is resolved,
++checked (``blocked(ip, port)``: private, loopback, link-local and other non-public addresses, below level full), and
++then connected to the exact address that was checked, so a name cannot resolve to a public address for the check and a
++private one for the connection.
++
++ proxy = EgressProxy(blocked=lambda ip, port: ...) ; port = proxy.start() ; ... ; proxy.stop()
++
++HTTPS and WebSockets arrive as CONNECT and are tunnelled; plain HTTP arrives in absolute form and is sent on in origin
++form with ``Connection: close`` (one request per connection, which the browser handles). The browser is started with
++``--proxy-server``, ``--proxy-bypass-list=<-loopback>`` (so loopback goes through the guard too) and WebRTC limited to
++proxied traffic (no UDP around it). QUIC is off whenever a proxy is set.
++"""
++from __future__ import annotations
++
++import ipaddress
++import select
++import socket
++import threading
++
++MAX_HEAD = 64 * 1024
++IDLE_S = 300.0
++CONNECT_TIMEOUT_S = 15.0
++
++
++def not_public(ip):
++ """Whether an address is not on the public internet: private, loopback, link-local, multicast, reserved, ..."""
++ try:
++ a = ipaddress.ip_address(ip.split("%", 1)[0])
++ except ValueError:
++ return True
++ if isinstance(a, ipaddress.IPv6Address) and a.ipv4_mapped:
++ a = a.ipv4_mapped
++ return not a.is_global or a.is_multicast
++
++
++class EgressProxy:
++ def __init__(self, blocked, resolve=None, log=None):
++ self.blocked = blocked # (ip, port) -> True to refuse
++ self.resolve = resolve or (lambda host, port: [ai[4][0] for ai in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)])
++ self.log = log or (lambda *a: None)
++ self.sock = None
++ self.port = None
++ self.mu = threading.Lock()
++ self.refused = 0
++
++ def start(self):
++ with self.mu:
++ if self.sock is not None:
++ return self.port
++ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
++ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
++ s.bind(("127.0.0.1", 0))
++ s.listen(128)
++ self.sock, self.port = s, s.getsockname()[1]
++ threading.Thread(target=self._accept, args=(s,), name="computer-egress", daemon=True).start()
++ return self.port
++
++ def stop(self):
++ with self.mu:
++ s, self.sock, self.port = self.sock, None, None
++ if s is not None:
++ try:
++ s.close()
++ except OSError:
++ pass
++
++ def args(self):
++ """The browser's arguments to go through this guard."""
++ port = self.start()
++ return ["--proxy-server=http://127.0.0.1:%d" % port, "--proxy-bypass-list=<-loopback>",
++ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"]
++
++ # ---- one connection ---------------------------------------------------------------------------------------------
++ def _accept(self, s):
++ while True:
++ try:
++ conn, _ = s.accept()
++ except OSError:
++ return
++ threading.Thread(target=self._serve, args=(conn,), name="computer-egress-conn", daemon=True).start()
++
++ def _serve(self, client):
++ upstream = None
++ try:
++ client.settimeout(CONNECT_TIMEOUT_S)
++ head = b""
++ while b"\r\n\r\n" not in head:
++ chunk = client.recv(8192)
++ if not chunk:
++ return
++ head += chunk
++ if len(head) > MAX_HEAD:
++ return self._answer(client, 431, "Request Header Fields Too Large")
++ raw_head, rest = head.split(b"\r\n\r\n", 1)
++ lines = raw_head.decode("latin-1").split("\r\n")
++ parts = lines[0].split(" ")
++ if len(parts) != 3:
++ return self._answer(client, 400, "Bad Request")
++ method, target, version = parts
++ if method == "CONNECT":
++ host, port = self._split(target, 443)
++ else:
++ if not target.lower().startswith("http://"):
++ return self._answer(client, 400, "Bad Request")
++ hostport, _, path = target[7:].partition("/")
++ host, port = self._split(hostport, 80)
++ if host is None:
++ return self._answer(client, 400, "Bad Request")
++ upstream = self._connect(host, port)
++ if upstream is None:
++ return self._answer(client, 403, "Forbidden by the agent's network guard")
++ if method == "CONNECT":
++ client.sendall(b"HTTP/1.1 200 Connection Established\r\n\r\n")
++ if rest:
++ upstream.sendall(rest)
++ else:
++ headers = [h for h in lines[1:] if h and not h.lower().startswith(("proxy-connection:", "connection:", "keep-alive:"))]
++ out = "%s /%s %s\r\n%s\r\nConnection: close\r\n\r\n" % (method, path, version, "\r\n".join(headers))
++ upstream.sendall(out.encode("latin-1") + rest)
++ self._relay(client, upstream)
++ except (OSError, ValueError, UnicodeError):
++ pass
++ finally:
++ for x in (client, upstream):
++ if x is not None:
++ try:
++ x.close()
++ except OSError:
++ pass
++
++ @staticmethod
++ def _split(hostport, default):
++ hostport = hostport.strip()
++ if hostport.startswith("["):
++ host, _, tail = hostport[1:].partition("]")
++ port = tail[1:] if tail.startswith(":") else ""
++ elif hostport.count(":") == 1:
++ host, port = hostport.split(":")
++ else:
++ host, port = hostport, ""
++ if not host:
++ return None, None
++ try:
++ port = int(port) if port else default
++ except ValueError:
++ return None, None
++ return (host, port) if 0 < port < 65536 else (None, None)
++
++ def _connect(self, host, port):
++ """A connection to the first address of host that is allowed, or None. The address checked is the one used."""
++ try:
++ addrs = self.resolve(host, port)
++ except (OSError, UnicodeError):
++ return None
++ for ip in addrs:
++ if self.blocked(ip, port):
++ with self.mu:
++ self.refused += 1
++ self.log("egress refused %s:%d (%s)", host, port, ip)
++ return None # one non-public address is enough to refuse the name
++ for ip in addrs:
++ try:
++ return socket.create_connection((ip, port), timeout=CONNECT_TIMEOUT_S)
++ except OSError:
++ continue
++ return None
++
++ @staticmethod
++ def _answer(client, code, text):
++ body = text.encode()
++ client.sendall(b"HTTP/1.1 %d %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s"
++ % (code, text.encode(), len(body), body))
++
++ @staticmethod
++ def _relay(a, b):
++ a.settimeout(None)
++ b.settimeout(None)
++ socks = [a, b]
++ while True:
++ r, _, _ = select.select(socks, [], [], IDLE_S)
++ if not r:
++ return
++ for s in r:
++ data = s.recv(65536)
++ if not data:
++ return
++ (b if s is a else a).sendall(data)
ddiff --git a/plugins/computer/pyproject.toml b/plugins/computer/pyproject.toml
iindex de5937b..1a83ba6 100644
---- a/plugins/computer/pyproject.toml
++++ b/plugins/computer/pyproject.toml
@@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openvibe-computer"
--version = "0.12.1"
++version = "0.13.0"
description = "OpenVibe Node computer plugin: the machine's terminal, files and screen under the owner's control level"
requires-python = ">=3.9"
license = { text = "AGPL-3.0-or-later" }
ddiff --git a/plugins/computer/tests/test_browser.py b/plugins/computer/tests/test_browser.py
iindex 07e4567..e6311f3 100644
---- a/plugins/computer/tests/test_browser.py
++++ b/plugins/computer/tests/test_browser.py
@@@ -95,7 +95,7 @@ class BrowserKindTest(unittest.TestCase):
def test_described_with_its_level(self):
caps = self.boot("ask")[0]["capabilities"]
-- self.assertEqual(caps["browser"], {"level": "ask", "browser": "chromium", "headless": True})
++ self.assertEqual(caps["browser"], {"level": "ask", "browser": "chromium", "headless": True, "egress": "public"})
caps = self.boot("ask", allow_browser=False)[0]["capabilities"]
self.assertNotIn("browser", caps)
ddiff --git a/plugins/computer/tests/test_desk.py b/plugins/computer/tests/test_desk.py
iindex 50de155..682bc92 100644
---- a/plugins/computer/tests/test_desk.py
++++ b/plugins/computer/tests/test_desk.py
@@@ -483,6 +483,7 @@ class DeskPluginTest(unittest.TestCase):
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)
# Two desks at once start from the same kept profile; the last to stop wins.
start("desk-b", profile="agent")
ddiff --git a/plugins/computer/tests/test_egress.py b/plugins/computer/tests/test_egress.py
nnew file mode 100644
iindex 0000000..efa0a31
---- /dev/null
++++ b/plugins/computer/tests/test_egress.py
@@@ -0,0 +1,175 @@
++"""The agent browser's egress guard (openvibe_computer/egress.py, Node v0.25.0): every connection is resolved, checked and
++made to the address checked; a refused one gets 403. In a real browser, a page's own script cannot reach a refused
++address. The plugin refuses the machine's own network below level full, and browser_egress: open turns the guard off."""
++import http.server
++import io
++import os
++import shutil
++import socket
++import socketserver
++import sys
++import tempfile
++import threading
++import unittest
++
++HERE = os.path.dirname(os.path.abspath(__file__))
++sys.path.insert(0, HERE)
++sys.path.insert(0, os.path.dirname(HERE))
++sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(HERE)), "sdk"))
++
++from openvibe_computer import Computer # noqa: E402
++from openvibe_computer.browser import Browser, find_binary # noqa: E402
++from openvibe_computer.egress import EgressProxy, not_public # noqa: E402
++from openvibe_plugin import Fault, Runtime # noqa: E402
++from test_computer import Out # noqa: E402
++
++
++class Page(http.server.BaseHTTPRequestHandler):
++ seen = []
++
++ def do_GET(self):
++ Page.seen.append((self.server.server_address[1], self.path, self.headers.get("Connection")))
++ body = b"<!doctype html><title>Here</title><p>hello</p>"
++ self.send_response(200)
++ self.send_header("Content-Type", "text/html")
++ self.send_header("Content-Length", str(len(body)))
++ self.send_header("Access-Control-Allow-Origin", "*")
++ self.end_headers()
++ self.wfile.write(body)
++
++ def log_message(self, *a):
++ pass
++
++
++def serve():
++ srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Page)
++ srv.daemon_threads = True
++ threading.Thread(target=srv.serve_forever, daemon=True).start()
++ return srv
++
++
++def ask(port, raw):
++ s = socket.create_connection(("127.0.0.1", port), timeout=5)
++ s.sendall(raw)
++ data = b""
++ while True:
++ chunk = s.recv(65536)
++ if not chunk:
++ break
++ data += chunk
++ if b"\r\n\r\n" in data and raw.startswith(b"CONNECT"):
++ break
++ return s, data
++
++
++class NotPublicTest(unittest.TestCase):
++ def test_addresses(self):
++ for ip in ("10.0.0.1", "172.16.5.4", "192.168.1.1", "127.0.0.1", "169.254.1.1", "100.64.0.1", "0.0.0.0",
++ "::1", "fe80::1", "fd00::1", "::ffff:192.168.1.1", "224.0.0.1", "not an address"):
++ self.assertTrue(not_public(ip), ip)
++ for ip in ("8.8.8.8", "93.184.215.14", "2606:4700::1111", "::ffff:8.8.8.8"):
++ self.assertFalse(not_public(ip), ip)
++
++
++class EgressProxyTest(unittest.TestCase):
++ def setUp(self):
++ self.ok, self.no = serve(), serve()
++ self.addCleanup(self.ok.shutdown)
++ self.addCleanup(self.no.shutdown)
++ self.no_port = self.no.server_address[1]
++ self.proxy = EgressProxy(blocked=lambda ip, port: port == self.no_port)
++ self.port = self.proxy.start()
++ self.addCleanup(self.proxy.stop)
++
++ def test_connect_is_tunnelled_or_refused(self):
++ s, head = ask(self.port, b"CONNECT 127.0.0.1:%d HTTP/1.1\r\nHost: x\r\n\r\n" % self.ok.server_address[1])
++ self.assertTrue(head.startswith(b"HTTP/1.1 200"), head)
++ s.sendall(b"GET /through HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n")
++ body = b""
++ while True:
++ chunk = s.recv(65536)
++ if not chunk:
++ break
++ body += chunk
++ self.assertIn(b"hello", body)
++ s.close()
++ s, head = ask(self.port, b"CONNECT 127.0.0.1:%d HTTP/1.1\r\nHost: x\r\n\r\n" % self.no_port)
++ self.assertTrue(head.startswith(b"HTTP/1.1 403"), head)
++ s.close()
++ self.assertEqual(self.proxy.refused, 1)
++
++ def test_plain_http_goes_on_in_origin_form(self):
++ Page.seen = []
++ s, _ = ask(self.port, b"GET http://127.0.0.1:%d/plain?x=1 HTTP/1.1\r\nHost: 127.0.0.1\r\nProxy-Connection: keep-alive\r\n\r\n" % self.ok.server_address[1])
++ s.close()
++ self.assertEqual(Page.seen[-1][1:], ("/plain?x=1", "close"), "origin form, one request per connection")
++ s, data = ask(self.port, b"GET http://127.0.0.1:%d/ HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" % self.no_port)
++ s.close()
++ self.assertTrue(data.startswith(b"HTTP/1.1 403"), data)
++
++ def test_a_name_with_any_refused_address_is_refused(self):
++ # One public and one private address: the name is refused, so no answer can pick the private one later.
++ proxy = EgressProxy(blocked=lambda ip, port: not_public(ip), resolve=lambda host, port: ["93.184.215.14", "192.168.1.1"])
++ port = proxy.start()
++ self.addCleanup(proxy.stop)
++ s, head = ask(port, b"CONNECT sneaky.example:443 HTTP/1.1\r\nHost: x\r\n\r\n")
++ s.close()
++ self.assertTrue(head.startswith(b"HTTP/1.1 403"), head)
++ for bad in (b"CONNECT :443 HTTP/1.1\r\n\r\n", b"GET /relative HTTP/1.1\r\n\r\n", b"CONNECT a:99999 HTTP/1.1\r\n\r\n"):
++ s, data = ask(port, bad)
++ s.close()
++ self.assertTrue(data.startswith(b"HTTP/1.1 400"), (bad, data))
++
++
++class PluginEgressTest(unittest.TestCase):
++ def boot(self, **config):
++ out = Out()
++ plugin = Computer(env={}, platform="linux", which=lambda n: "/usr/bin/" + n)
++ rt = Runtime(plugin, infile=io.StringIO(""), outfile=out)
++ rt.dispatch({"op": "hello", "config": {"level": "trusted", "roots": [tempfile.gettempdir()], "notify": False, **config}})
++ self.addCleanup(plugin.egress.stop)
++ return plugin, out
++
++ def test_the_machines_own_network_is_refused_below_full(self):
++ plugin, out = self.boot()
++ self.assertEqual(out.msgs()[0]["capabilities"]["browser"]["egress"], "public")
++ self.assertTrue(plugin._egress_blocked("192.168.1.1", 80))
++ self.assertTrue(plugin._egress_blocked("127.0.0.1", 631))
++ self.assertFalse(plugin._egress_blocked("93.184.215.14", 443))
++ args = plugin._browser_args()
++ self.assertTrue(args[0].startswith("--proxy-server=http://127.0.0.1:"))
++ self.assertIn("--proxy-bypass-list=<-loopback>", args)
++ plugin.level = "full"
++ self.assertFalse(plugin._egress_blocked("192.168.1.1", 80), "full reaches the local network, as open does")
++
++ def test_open_turns_it_off_and_a_bad_value_is_refused(self):
++ plugin, _ = self.boot(browser_egress="open")
++ self.assertFalse(plugin._egress_blocked("192.168.1.1", 80))
++ self.assertEqual(plugin._browser_args(), [])
++ with self.assertRaises(Fault):
++ plugin._configure({"level": "trusted", "browser_egress": "sometimes"})
++
++
+[email protected](find_binary(shutil.which, sys.platform), "no Chromium-family browser on this machine")
++class RealEgressTest(unittest.TestCase):
++ def test_a_pages_script_cannot_reach_a_refused_address(self):
++ ok, no = serve(), serve()
++ self.addCleanup(ok.shutdown)
++ self.addCleanup(no.shutdown)
++ no_port = no.server_address[1]
++ proxy = EgressProxy(blocked=lambda ip, port: port == no_port)
++ self.addCleanup(proxy.stop)
++ profile = tempfile.mkdtemp(prefix="ov-egress-")
++ self.addCleanup(lambda: shutil.rmtree(profile, ignore_errors=True))
++ extra = (["--no-sandbox"] if os.environ.get("CI") else []) + proxy.args()
++ b = Browser(find_binary(shutil.which, sys.platform), profile, extra_args=extra)
++ self.addCleanup(b.close)
++ self.assertEqual(b.open("http://127.0.0.1:%d/" % ok.server_address[1])["title"], "Here", "an allowed address loads")
++ got = b._eval("fetch('http://127.0.0.1:%d/probe').then((r) => 'reached ' + r.status).catch(() => 'refused')" % no_port)
++ self.assertEqual(got, "refused", "the page's own script never reaches it")
++ got = b._eval("fetch('http://127.0.0.1:%d/again').then((r) => 'reached ' + r.status).catch(() => 'refused')" % ok.server_address[1])
++ self.assertEqual(got, "reached 200")
++
++
++if __name__ == "__main__":
++ unittest.main()
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex bc437f9..11e8634 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
--__version__ = "0.24.1"
++__version__ = "0.25.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 c40d6d4..e4a8598 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.24.1"
++version = "0.25.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 1165da3..cbf5b62 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.24.1")
++ self.assertEqual(openvibe_plugin.__version__, "0.25.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())