openvibers/openvibe.link Commits

Node 0.32.0: desk hosts for other people (every browser desk in the worker's sandbox; desk-sandbox; --desk-host --shared) (#80)

OP OpenVibers committed 17 days ago · dd556bc530 · 26 files, +1192 −35

* Desk sandbox: a desk's browser in the jobs' sandbox, for desk hosts that serve other people (Desks D5, second part)

openvibe-node desk-sandbox [--memory-mb --cpu-millis --disk-mb --pids --bind --setenv --egress public] -- PROGRAM
runs one desk's program as worker.run_as in its own user, mount, network, PID and IPC namespaces and cgroup
(memory.max with swap off, cpu.max, pids.max, io.max), seeing only the system paths, its own /tmp (the profile) and
read-only binds under /opt, /usr/local or /etc/fonts, reaching only the public internet when asked. Its fds 3 and 4
pass through as a browser's --remote-debugging-pipe: the init moves its status pipe out of fd 4's way. No RLIMIT_AS
(a browser maps tens of GB it never uses) and no RLIMIT_CPU (a desk lives for hours): the cgroup bounds both.

* desk-sandbox takes fds 3 and 4 only when they are pipes: in a process started without them they are the Go runtime's poller, and the test closed it (CI crash on every OS)

* Shared desk hosts: the computer plugin's desk_isolation sandbox runs every browser desk through desk-sandbox and offers nothing of the machine; install.sh --desk-host --shared; v0.32.0

* Shared desk tests: a browser path that is not a link elsewhere; the real desk's failure says what the sandbox said; a desk-sandbox smoke first in CI

* A desk's private root under /tmp whatever TMPDIR says (the plugin's desk temp folder is closed to the sandbox's user: found by the root e2e); the heartbeat test's margins fit a slow macOS runner

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 1d9ae4b..5a7993a 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -47,6 +47,22 @@ jobs:
(cd internal/$1 && sudo systemd-run --scope --quiet -p Delegate=yes \
env OPENVIBE_WORKER_TESTS=require "$d/$1/$1.test" -test.count=1 -test.v -test.run "$2")
done
++ # A desk host for other people (computer plugin desk_isolation sandbox): the plugin, as root, drives a real
++ # browser desk through `openvibe-node desk-sandbox`. The shell stands in for the Node, which moves itself into its
++ # openvibe-node leaf and enables the controllers (its worker's probe) before any plugin starts.
++ - name: shared desk host (root)
++ run: |
++ d=$(mktemp -d) && chmod 755 "$d"
++ go build -o "$d/openvibe-node" ./cmd/openvibe-node
++ mkdir -m 700 "$d/config" "$d/state"
++ echo '{"worker":{"enabled":true,"run_as":{"uid":65534,"gid":65534}}}' > "$d/config/config.json"
++ sudo systemd-run --scope --quiet -p Delegate=yes env OPENVIBE_DESK_SANDBOX_TESTS=require \
++ OPENVIBE_NODE_EXE="$d/openvibe-node" OPENVIBE_NODE_CONFIG="$d/config" OPENVIBE_NODE_STATE="$d/state" sh -c '
++ cg=/sys/fs/cgroup$(sed -n "s/^0:://p" /proc/self/cgroup)
++ mkdir "$cg/openvibe-node" && echo $$ > "$cg/openvibe-node/cgroup.procs"
++ echo "+memory +pids +cpu +io" > "$cg/cgroup.subtree_control"
++ : | "$OPENVIBE_NODE_EXE" desk-sandbox --memory-mb 256 -- /bin/sh -c "echo desk-sandbox runs >&4" 3<&0 4>&1 | cat
++ exec python3 plugins/computer/tests/test_shared_desks.py -v RealSharedDeskTest'
build:
name: cross-compile
ddiff --git a/STATUS.json b/STATUS.json
iindex fe46028..e20cf6c 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.31.0",
-- "release": "v0.31.0, published 2026-10-27 as a GitHub release (every bundled plugin is tested on Windows and macOS too, and the ai plugin at all; what that found is fixed: on a Mac a browser desk's ctrl or cmd editing shortcuts (select all, copy, paste, cut, undo, redo) work, sent with the command Chrome needs there, and a desk's temp folder moves under /tmp when Chrome's SingletonSocket would not fit macOS's 104-byte socket path, so a Mac desk's browser starts; on Windows the computer plugin keeps its data in %LOCALAPPDATA%\\openvibe-node (an existing ~/.local/share folder is kept) with normal paths; v0.30.0: Windows keeps the original deadline across the pipe too: the core measures every deadline on one precise clock shared with its plugins, CLOCK_MONOTONIC on Linux and macOS and the raw QueryPerformanceCounter on Windows, named in hello as shared_clock, and passes the instant a command expires on it; the Python runtime reads the same raw counter with the same integer conversion; strict timing (Plugin.strict_timing, OPENVIBE_STRICT_TIMING=1, or strict_timing in the Node's plugin config) refuses a motion command whose original expiry cannot be checked with timing_unavailable instead of running it on deadline_ms; the deadline tests run a real Python plugin over real pipes on Linux, macOS and Windows; looi runs as python -m openvibe_looi without a command; v0.29.0: the ai plugin says how fast each model answers here: ai_tokens_per_s for the lastthe ai plugin says how fast each model answers here: ai_tokens_per_s for the last model, averaged with the earlier answers, and ai_busy while one answers, shown beside the machine's models on openvibe.bot; v0.28.0: install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
++ "version": "0.32.0",
++ "release": "v0.32.0, published 2026-10-28 as a GitHub release (desk hosts for other people, the Desks plan's step D5 completed: install.sh --desk-host --shared makes a host whose browser desks each run in the job worker's sandbox, through the new openvibe-node desk-sandbox: its own namespaces and cgroup as the unprivileged openvibe-job, its own memory, CPU, process and disk limits (desk_memory_mb, desk_cpu_millis, desk_pids, desk_disk_mb), its profile and downloads in its private /tmp, the public internet only, enforced by the kernel; the computer plugin's desk_isolation sandbox offers browser desks only and nothing of the machine (no programs, files, probes, its own screen or browser, whole desks, kept profiles or live view); proven in CI as root with a real Chrome driven over its pipe inside the sandbox; the core gives plugins OPENVIBE_NODE_CONFIG and OPENVIBE_NODE_EXE; v0.31.0: every bundled plugin is tested on Windows and macOS too, and the ai plugin at all; what that found is fixed: on a Mac a browser desk's ctrl or cmd editing shortcuts (select all, copy, paste, cut, undo, redo) work, sent with the command Chrome needs there, and a desk's temp folder moves under /tmp when Chrome's SingletonSocket would not fit macOS's 104-byte socket path, so a Mac desk's browser starts; on Windows the computer plugin keeps its data in %LOCALAPPDATA%\\openvibe-node (an existing ~/.local/share folder is kept) with normal paths; v0.30.0: Windows keeps the original deadline across the pipe too: the core measures every deadline on one precise clock shared with its plugins, CLOCK_MONOTONIC on Linux and macOS and the raw QueryPerformanceCounter on Windows, named in hello as shared_clock, and passes the instant a command expires on it; the Python runtime reads the same raw counter with the same integer conversion; strict timing (Plugin.strict_timing, OPENVIBE_STRICT_TIMING=1, or strict_timing in the Node's plugin config) refuses a motion command whose original expiry cannot be checked with timing_unavailable instead of running it on deadline_ms; the deadline tests run a real Python plugin over real pipes on Linux, macOS and Windows; looi runs as python -m openvibe_looi without a command; v0.29.0: the ai plugin says how fast each model answers here: ai_tokens_per_s for the lastthe ai plugin says how fast each model answers here: ai_tokens_per_s for the last model, averaged with the earlier answers, and ai_busy while one answers, shown beside the machine's models on openvibe.bot; v0.28.0: install.sh --ai sets up your own models: it adds the ai plugin, which a driver chosen later keeps, says which model server answers (Ollama, LM Studio, llama.cpp) or how to get one, and --ai-endpoint names another port; v0.27.0: your own models for your OpenVibe work: the ai plugin relays kind ai (contracts 0.219.0), one chat completion, to a local OpenAI-compatible model server (Ollama, LM Studio, llama.cpp), lists its models, runs one at a time, and reaches nothing else; the core routes ai to it like a computer command that never acts; v0.26.0: programs on a desk: exec takes desk, running in that desk's session so a program's windows open there, and detach, starting a program that keeps running on a whole desk (an office suite, a terminal) and ends with its desk; desk templates follow: research, office and dev desks; v0.25.0: the egress guard: every connection every agent browser makes, a page's own scripts included, goes through a proxy the plugin runs on 127.0.0.1 that resolves, checks and connects to the address it checked, so a page never reaches the machine's own network below level full; browser_egress: open turns it off; v0.24.1: a kept profile carries session cookies too: Chrome drops cookies that end with the browser as it quits, and a sign-in is often one, so a desk keeps them beside the profile and the next desk's browser gets them back as it starts, found by the production proof; v0.24.0: kept desk profiles: desk start takes profile, so a desk's browser starts from the person's kept profile (sign in once, on any desk) and saves it back when it stops, closing the browser gracefully so its cookies are written, the last desk to stop wins, caches never kept, and each desk's own copy removed; the browser follows a page its page opens (a sign-in popup, a new tab) and comes back when it closes; the agent's clicks count as a person's, so sites open their sign-in popups; v0.23.0: browser desks, the Desks plan's step D2: desk start takes kind browser, a headless browser alone with no display at about half the cost of a whole desk, on Linux or macOS; its page is its screen, mouse and keyboard and live view over the browser's pipe, exactly the desk's size; a desk host, which has no screen or input of its own, gets screen, screen_stream and input on its desks; a click on a named desk scales to that desk's size; v0.22.0: desks on a desk host, found by the first production proof: a desk's temp folder is short (Chrome's SingletonSocket must fit a Unix socket path, and the desks folder on a desk host was too deep, so Chrome closed as it started), and a desk no command used for desk_idle_s (15 minutes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
ddiff --git a/cmd/openvibe-node/desk_pipe_linux.go b/cmd/openvibe-node/desk_pipe_linux.go
nnew file mode 100644
iindex 0000000..391e68d
---- /dev/null
++++ b/cmd/openvibe-node/desk_pipe_linux.go
@@@ -0,0 +1,25 @@
++//go:build linux
++
++package main
++
++import (
++ "fmt"
++ "os"
++ "syscall"
++)
++
++// inheritedDeskPipe answers fds 3 and 4 as the caller (the computer plugin) passed them, the browser's
++// --remote-debugging-pipe. Only a pipe or a socket is taken: in a process started without them those numbers are the
++// Go runtime's own (its poller), and an *os.File around one would close it when collected.
++func inheritedDeskPipe() (in, out *os.File, err error) {
++ for _, fd := range []int{3, 4} {
++ var st syscall.Stat_t
++ if err := syscall.Fstat(fd, &st); err != nil {
++ return nil, nil, fmt.Errorf("fd %d: %w (fds 3 and 4 are the browser's pipe)", fd, err)
++ }
++ if t := st.Mode & syscall.S_IFMT; t != syscall.S_IFIFO && t != syscall.S_IFSOCK {
++ return nil, nil, fmt.Errorf("fd %d is not a pipe (fds 3 and 4 are the browser's pipe)", fd)
++ }
++ }
++ return os.NewFile(3, "desk in"), os.NewFile(4, "desk out"), nil
++}
ddiff --git a/cmd/openvibe-node/desk_pipe_other.go b/cmd/openvibe-node/desk_pipe_other.go
nnew file mode 100644
iindex 0000000..59f121a
---- /dev/null
++++ b/cmd/openvibe-node/desk_pipe_other.go
@@@ -0,0 +1,13 @@
++//go:build !linux
++
++package main
++
++import (
++ "errors"
++ "os"
++)
++
++// inheritedDeskPipe: desk sandboxes are Linux's (namespaces, cgroup v2); fds 3 and 4 are never touched elsewhere.
++func inheritedDeskPipe() (in, out *os.File, err error) {
++ return nil, nil, errors.New("desk sandboxes need Linux")
++}
ddiff --git a/cmd/openvibe-node/desk_sandbox.go b/cmd/openvibe-node/desk_sandbox.go
nnew file mode 100644
iindex 0000000..d6789bf
---- /dev/null
++++ b/cmd/openvibe-node/desk_sandbox.go
@@@ -0,0 +1,86 @@
++package main
++
++import (
++ "context"
++ "flag"
++ "fmt"
++ "io"
++ "os"
++ "os/signal"
++ "strings"
++ "syscall"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/worker"
++)
++
++// listFlag gathers a repeatable flag.
++type listFlag []string
++
++func (l *listFlag) String() string { return strings.Join(*l, ",") }
++func (l *listFlag) Set(v string) error { *l = append(*l, v); return nil }
++
++const deskSandboxUsage = `usage: openvibe-node desk-sandbox [--home DIR] [--memory-mb N] [--cpu-millis N] [--disk-mb N] [--pids N]
++ [--bind PATH]... [--setenv NAME=VALUE]... [--egress public] -- PROGRAM [ARG]...
++
++Runs one desk's program (its browser) confined, for a desk host that serves people other than its owner: in the
++jobs' sandbox (worker.run_as, its own namespaces and cgroup, only the system paths, its /tmp and the read-only binds
++under /opt, /usr/local or /etc/fonts), with fds 3 and 4 passed through as its --remote-debugging-pipe. Needs what
++jobs need (root with worker.run_as, cgroup v2 delegated); --egress public needs worker.egress public. Exits with the
++program's code; 125 when the sandbox could not be set up.
++`
++
++// deskPipe answers the desk's two pipe ends (its fds 3 and 4); tests replace it.
++var deskPipe = inheritedDeskPipe
++
++// cmdDeskSandbox is `openvibe-node desk-sandbox`; it parses its own flags, up to "--", so the program's flags (a
++// browser's) are never read as the Node's.
++func cmdDeskSandbox(args []string, stderr io.Writer) int {
++ fs := flag.NewFlagSet("desk-sandbox", flag.ContinueOnError)
++ fs.SetOutput(stderr)
++ fs.Usage = func() { fmt.Fprint(stderr, deskSandboxUsage) }
++ home := fs.String("home", "", "config and state directory")
++ memMB := fs.Int64("memory-mb", 1024, "memory.max of the desk, in MiB (swap off)")
++ cpu := fs.Int64("cpu-millis", 1000, "CPU of the desk, in thousandths of a core (1000 = one core)")
++ diskMB := fs.Int64("disk-mb", 512, "the size of the desk's /tmp, where its profile lives, in MiB")
++ pids := fs.Int64("pids", 256, "the most processes and threads of the desk")
++ egress := fs.String("egress", "", `"public" to reach the public internet (never the host's networks); none by default`)
++ var binds, env listFlag
++ fs.Var(&binds, "bind", "a read-only path the program needs (repeatable): its install directory, /etc/fonts")
++ fs.Var(&env, "setenv", "NAME=VALUE in the program's environment (repeatable); it gets nothing else")
++ if err := fs.Parse(args); err != nil {
++ return 2
++ }
++ argv := fs.Args()
++ if len(argv) == 0 {
++ fs.Usage()
++ return 2
++ }
++ // Started by a plugin, it reads the config of the Node that started it (which names the plugin's environment).
++ paths := config.DefaultPaths(*home)
++ if c, st := os.Getenv(config.EnvConfigDir), os.Getenv(config.EnvStateDir); *home == "" && c != "" && st != "" {
++ paths = config.Paths{ConfigDir: c, StateDir: st}
++ }
++ cfg, err := config.Load(paths.ConfigFile())
++ if err != nil {
++ fmt.Fprintln(stderr, "desk-sandbox:", err)
++ return 125
++ }
++ wcfg := cfg.Worker
++ wcfg.NodeDirs = []string{paths.ConfigDir, paths.StateDir} // as the Node's own jobs: no desk sees them
++ in, out, err := deskPipe()
++ if err != nil {
++ fmt.Fprintln(stderr, "desk-sandbox:", err)
++ return 125
++ }
++ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP)
++ defer stop()
++ code, err := worker.RunDesk(ctx, wcfg, worker.DeskSpec{Argv: argv, Env: env, Binds: binds,
++ MemBytes: *memMB << 20, CPUMillis: *cpu, DiskBytes: *diskMB << 20, Pids: *pids, Egress: *egress,
++ In: in, Out: out, Stderr: os.Stderr})
++ if err != nil && ctx.Err() == nil {
++ fmt.Fprintln(stderr, "desk-sandbox:", err)
++ return 125
++ }
++ return code
++}
ddiff --git a/cmd/openvibe-node/desk_sandbox_test.go b/cmd/openvibe-node/desk_sandbox_test.go
nnew file mode 100644
iindex 0000000..26f7f64
---- /dev/null
++++ b/cmd/openvibe-node/desk_sandbox_test.go
@@@ -0,0 +1,37 @@
++package main
++
++import (
++ "bytes"
++ "os"
++ "runtime"
++ "strings"
++ "testing"
++)
++
++// desk-sandbox reads its own flags up to "--" (a browser's flags after it are never the Node's), shows its usage
++// without a program, and refuses (125, never running the program unconfined) where desks cannot be sandboxed: here a
++// config with no worker.run_as.
++func TestDeskSandboxCommand(t *testing.T) {
++ var out, errb bytes.Buffer
++ if code := run([]string{"desk-sandbox"}, &out, &errb); code != 2 || !strings.Contains(errb.String(), "usage: openvibe-node desk-sandbox") {
++ t.Fatalf("no program: code %d, stderr %q", code, errb.String())
++ }
++ errb.Reset()
++ // Never the test process's own fds 3 and 4: they are the Go runtime's poller here.
++ in, out2, err := os.Pipe()
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer in.Close()
++ defer out2.Close()
++ deskPipe = func() (*os.File, *os.File, error) { return in, out2, nil }
++ defer func() { deskPipe = inheritedDeskPipe }()
++ home := t.TempDir()
++ code := run([]string{"desk-sandbox", "--home", home, "--memory-mb", "64", "--", "/bin/true", "--headless=new", "--home", "x"}, &out, &errb)
++ if code != 125 || !strings.Contains(errb.String(), "desk-sandbox:") {
++ t.Fatalf("unconfinable: code %d, stderr %q", code, errb.String())
++ }
++ if runtime.GOOS == "linux" && !strings.Contains(errb.String(), "worker.run_as") {
++ t.Fatalf("the refusal should say what is missing: %q", errb.String())
++ }
++}
ddiff --git a/cmd/openvibe-node/main.go b/cmd/openvibe-node/main.go
iindex 01bb0fa..5398530 100644
---- a/cmd/openvibe-node/main.go
++++ b/cmd/openvibe-node/main.go
@@@ -69,6 +69,9 @@ Usage:
resume; --autostart starts it when you sign in
openvibe-node profile verify community.<name> [--server URL] [--key-id ID]
check a community robot profile against the signature Bot made when it was reviewed
++ openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--bind PATH]... [--egress public] -- PROGRAM [ARG]...
++ run one desk's browser confined (a desk host that serves other people; the computer plugin
++ calls it, with --remote-debugging-pipe on fds 3 and 4)
openvibe-node version
Global flags (before or after the command):
@@@ -98,6 +101,9 @@ func runWithStdin(args []string, stdin io.Reader, stdout, stderr io.Writer) int
return 0
}
cmd, rest := args[0], args[1:]
++ if cmd == "desk-sandbox" { // its own flags, up to "--": a browser's flags after it are never the Node's
++ return cmdDeskSandbox(rest, stderr)
++ }
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
fs.SetOutput(stderr)
g := &globals{}
ddiff --git a/docs/computer.md b/docs/computer.md
iindex aa71430..d7eab84 100644
---- a/docs/computer.md
++++ b/docs/computer.md
@@@ -256,6 +256,33 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_
- **Then** turn on "Let my agent use this computer" for the machine on openvibe.bot/machines. Watch any desk live from
there, and take it over the same way.
++### Desk hosts for other people (`--desk-host --shared`, Node v0.32.0)
++
++A desk host OpenVibe or a community member runs for **other people's** agents (the Desks plan's step D5, second part)
++must keep each person's desk away from the machine and from every other desk. Such a host runs browser desks only, and
++each desk's browser runs in the job worker's sandbox (docs/worker.md):
++```
++curl -fsSL https://openvibe.bot/install | sudo sh -s -- --network … --pairing pair_… --code … --desk-host --shared
++```
++- **Each desk sandboxed.** The plugin starts a desk's browser as `openvibe-node desk-sandbox -- <browser> …`: its own
++ user, mount, network, PID and IPC namespaces, as the unprivileged `openvibe-job` (`worker.run_as`), in a cgroup of its
++ own with `desk_memory_mb` of memory (swap off: running out ends that desk alone), `desk_cpu_millis` of CPU,
++ `desk_pids` processes and a `desk_disk_mb` private `/tmp`, where its profile and downloads live. It sees only the
++ system's own files read-only, the browser's install directory and the fonts; never the Node's config or credential,
++ another desk, or a home. Its network is the public internet only (`desk_egress: public`), enforced by the kernel
++ (the worker's veth and nftables rules), never the machine's own network or the host itself. Chrome's own sandbox is
++ off inside it (ours replaces it; it cannot nest), and the browser gets nothing of the plugin's environment.
++- **Nothing of the machine.** `exec`, `fs`, `probe`, the machine's own screen, mouse, keyboard and browser, whole desks,
++ kept profiles and `screen_stream` (the machine's one live stream is its operator's) are refused; `screen`, `input` and
++ `browser` act on a browser desk. `describe` reports only `desk`, with `isolation: "sandbox"` and the limits.
++- **What it runs as.** The Node runs as root, which the sandbox needs (namespaces, a cgroup per desk, its network), with
++ its worker on (no functions: no jobs offered) so it sets up its cgroup before any desk starts; the computer plugin
++ runs as root only here, at level `trusted` (nobody is at the machine to approve), and starts nothing unconfined.
++- **What it needs.** Linux 5.14+ with cgroup v2 and the Node's service delegated (`Delegate=yes`), a browser installed
++ under `/usr` or `/opt` (Google Chrome's .deb on amd64, Debian's Chromium on ARM; a snap cannot run sandboxed),
++ nftables, iproute2 and IPv4 forwarding. The installer adds them, sizes `max_desks` to the machine (a desk may use
++ 1 GiB; a browser desk counts half) and keeps your own sizes on a second run.
++
## Config
| key | default | meaning |
@@@ -275,6 +302,12 @@ curl -fsSL https://openvibe.bot/install | sh -s -- --network … --pairing pair_
| `desk_idle_s` | `900` | a desk no command used this long is stopped (60 to 86400) |
| `desk_dir` | `~/.local/share/openvibe-node/desks` (Windows: `%LOCALAPPDATA%\openvibe-node\desks`) | each desk's own temp and downloads folders |
| `desk_profile_max_mb` | `256` | a kept profile larger than this is not saved (16 to 4096) |
++| `desk_isolation` | `none` | `sandbox`: a desk host for other people: browser desks only, each in its own sandbox, nothing of the machine offered (Linux, the Node as root with its worker on) |
++| `desk_memory_mb` | `1024` | with `sandbox`: each desk's memory (256 to 16384; swap off) |
++| `desk_cpu_millis` | `1000` | with `sandbox`: each desk's CPU in thousandths of a core (100 to 16000) |
++| `desk_disk_mb` | `512` | with `sandbox`: each desk's private `/tmp`, its profile and downloads (64 to 16384) |
++| `desk_pids` | `512` | with `sandbox`: each desk's processes and threads (64 to 8192) |
++| `desk_egress` | `public` | with `sandbox`: `public` (the public internet only) or `none` |
| `allow_browser` | `true` | `false`: no browser (no `browser` capability) |
| `allow_screen_stream` | `true` | `false`: never show the screen live (`screen.stream` is false) |
| `browser_path` | found on PATH | the browser to drive (Chromium, Chrome, Edge or Brave) |
ddiff --git a/docs/plugins.md b/docs/plugins.md
iindex 21cee55..127d90b 100644
---- a/docs/plugins.md
++++ b/docs/plugins.md
@@@ -22,7 +22,9 @@ hardware.
A bundled plugin needs only its `name`; the core runs `<state>/venv/bin/python -m openvibe_<name>`. Anything else needs
`command`. `config` is sent to the plugin in `hello`. The core adds `frame_dir` (where to write camera frames) unless
--set, and the environment variable `OPENVIBE_NODE_STATE`.
++set, and the environment variables `OPENVIBE_NODE_STATE` and `OPENVIBE_NODE_CONFIG` (its state and config directories)
++and `OPENVIBE_NODE_EXE` (its own executable), so a command a plugin runs with it (`openvibe-node desk-sandbox`, the
++computer plugin's shared desks) reads the same config as the Node that started it.
## Lifecycle
ddiff --git a/docs/worker.md b/docs/worker.md
iindex dc30591..7940e51 100644
---- a/docs/worker.md
++++ b/docs/worker.md
@@@ -315,6 +315,25 @@ How `public` and `openvibe-only` are enforced (`internal/worker/egress_linux.go`
- **`openvibe-only` is configured, not guessed.** No authoritative list of the OpenVibe network's ranges exists in
this repository or OpenVibe.Contracts, so `egress_allow` must name them; without it the config does not load.
++### Desks in the sandbox (`openvibe-node desk-sandbox`, Node v0.32.0)
++
++A desk host for other people (docs/computer.md, "Desk hosts for other people") runs each desk's browser in this same
++sandbox: `openvibe-node desk-sandbox [--memory-mb N] [--cpu-millis N] [--disk-mb N] [--pids N] [--egress public]
++[--bind PATH]... [--setenv NAME=VALUE]... -- PROGRAM [ARG]...`, which the computer plugin runs. What differs from a job:
++
++- **It lives for hours and talks both ways.** Fds 3 and 4 pass through to the program (a browser's
++ `--remote-debugging-pipe`; desk-sandbox takes them only when they are pipes or sockets), and there is no wall-clock or
++ CPU-time limit: `cpu.max`, `memory.max` (swap off), `pids.max` and the size of its private `/tmp` are. No `RLIMIT_AS`
++ either: a browser maps far more address space than it uses.
++- **Its binds.** Besides the system paths, only read-only `--bind`s under `/opt`, `/usr/local` or `/etc/fonts` (its
++ install directory, the fonts' configuration), never one that shows a Node directory or a hidden host path.
++- **Its config.** It reads the worker config of the Node that started its plugin (`OPENVIBE_NODE_CONFIG`,
++ `OPENVIBE_NODE_STATE`): `run_as`, `egress` (`--egress public` needs `public` there) and `dns`. Its cgroup is made
++ beside the Node's leaf, which the Node sets up when its worker is on, so a desk host keeps `worker.enabled` (with no
++ functions, no jobs are offered).
++- **Its end.** SIGTERM (the plugin's close) kills everything in its cgroup and removes the cgroup, its network and its
++ root; it exits with the program's code, or 125 when the sandbox could not be set up (the program never ran).
++
## Refusals and exit reasons
Refused with `nack` (keyed by the job id; the first matching row wins, after the checks every Node does, in
ddiff --git a/install/install.sh b/install/install.sh
iindex 3a7074f..ef346af 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -42,6 +42,12 @@
# window manager, D-Bus, xdotool, ImageMagick, Chromium) and sizes them to the machine's memory
# (max_desks: one per 2 GiB); each agent task gets a desk of its own. Implies --computer trusted
# (for that account's own files only); give another level with --computer.
++# --shared with --desk-host: the desks serve people other than the machine's owner (a desk host OpenVibe or
++# a community member runs for others; Desks plan D5). Browser desks only, each one's browser in the
++# worker's sandbox (its own namespaces and cgroup, as the unprivileged user openvibe-job, 1 GiB and
++# one core at most, the public internet only), and nothing of the machine itself offered. The
++# service runs as root, which the sandbox needs; the installer adds Google Chrome's .deb (amd64) or
++# Debian's Chromium, nftables and iproute2. Linux only.
# --computer LEVEL this machine's terminal, files and screen for its owner's agents (the computer plugin,
# docs/computer.md), at LEVEL observe, ask, trusted or full. The service then runs as YOUR account
# (the one that ran sudo), never root, and its folders are your home. Linux only; not with --worker.
@@@ -85,6 +91,7 @@ TAG="latest"
WORKER=0
COMPUTER=""
DESK_HOST=0
++SHARED=0
AI=0
AI_ENDPOINT=""
CLAIM_OK=1
@@@ -97,7 +104,7 @@ CODES_DIR=/opt/openvibe-codes
usage() {
echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
echo " [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none] [--worker]"
-- echo " [--computer observe|ask|trusted|full] [--desk-host] [--ai] [--ai-endpoint URL]"
++ echo " [--computer observe|ask|trusted|full] [--desk-host [--shared]] [--ai] [--ai-endpoint URL]"
echo " [--no-service] [--no-claim] [--setup osk_…]"
echo " [--local DIR] [--version TAG]"
echo " curl -fsSL https://openvibe.bot/install | sh (finds the kit, then shows a code to approve on openvibe.bot/link)"
@@@ -130,6 +137,7 @@ while [ $# -gt 0 ]; do
--pairing=*) PAIRING="${1#*=}"; shift ;;
--worker) WORKER=1; shift ;;
--desk-host) DESK_HOST=1; shift ;;
++ --shared) SHARED=1; shift ;;
--ai) AI=1; shift ;;
--ai-endpoint) AI=1; AI_ENDPOINT="${2:-}"; shift 2 ;;
--ai-endpoint=*) AI=1; AI_ENDPOINT="${1#*=}"; shift ;;
@@@ -150,6 +158,12 @@ done
case "$DRIVER" in adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none) ;; *) die "--driver must be adeept, adeept-mecanum, cozmo, looi, ros2, ros2-holonomic or none" ;; esac
case "$ROBOT_ID" in ""|rob_*) ;; *) die "--robot must be a rob_… id" ;; esac
case "$AI_ENDPOINT" in ""|http://*|https://*) ;; *) die "--ai-endpoint is the model server's http(s) base, for example http://127.0.0.1:11434" ;; esac
++if [ "$SHARED" = 1 ]; then
++ [ "$DESK_HOST" = 1 ] || die "--shared goes with --desk-host (a desk host that serves other people)"
++ # Nobody sits at a shared desk host to approve, and its desks act on other people's behalf only.
++ case "$COMPUTER" in ""|trusted|observe) ;; *) die "a shared desk host runs at level trusted (or observe): nobody is at it to approve" ;; esac
++ DESK_HOST=shared
++fi
# --pairing takes the pair_… id; with no --code, anything else there is the code itself.
case "$PAIRING" in ""|pair_*) ;; *) [ -z "$CODE" ] || die "--pairing must be a pair_… id"; CODE="$PAIRING"; PAIRING="" ;; esac
if [ -n "$PAIRING" ] && [ -z "$NETWORK" ]; then die "--pairing needs --network, the OpenVibe.Network URL"; fi
@@@ -159,7 +173,7 @@ case "$NETWORK" in ""|https://*|http://localhost*|http://127.0.0.1*) ;; *) die "
if [ -n "$SETUP" ]; then
printf '%s' "$SETUP" | grep -Eq '^osk_[A-Za-z0-9_-]{43}$' || die "--setup takes the osk_… key from your card files (openvibe.bot/setup/sd-card)"
[ -z "$CODE$ROBOT_ID$NETWORK$PAIRING" ] || die "--setup is used on its own (no --robot, --code, --network or --pairing)"
-- if [ "$CLAIM_OK" = 0 ] || [ "$WORKER" = 1 ] || [ -n "$COMPUTER" ] || [ "$DESK_HOST" = 1 ] || [ "$AI" = 1 ]; then die "--setup cannot be used with --no-claim, --worker, --computer, --desk-host or --ai"; fi
++ if [ "$CLAIM_OK" = 0 ] || [ "$WORKER" = 1 ] || [ -n "$COMPUTER" ] || [ "$DESK_HOST" != 0 ] || [ "$AI" = 1 ]; then die "--setup cannot be used with --no-claim, --worker, --computer, --desk-host or --ai"; fi
fi
# No robot, no code, no Network pairing, not a worker, a computer or a model machine: the one command (openvibe.bot/link,
# see the top).
@@@ -319,12 +333,14 @@ fi
# and, with a Node.js and a Codes install, the `[email protected]` job runner. Keys stay empty for the owner to add.
# write_computer_config <config.json> <level> <home> [desk-host]: add the computer plugin at that level (replacing an
# earlier entry), its folder the owner's home; the rest of the config is kept. A desk host (4th argument 1) keeps its
--# desks under <home>/desks, shows no notifications (nobody sits at it) and turns the camera off.
++# desks under <home>/desks, shows no notifications (nobody sits at it) and turns the camera off; a shared one (4th
++# argument shared) sandboxes every desk (desk_isolation sandbox) and offers nothing else.
write_computer_config() {
${SUDO:-} python3 - "$@" <<'PY'
import json, os, sys
path, level, home = sys.argv[1], sys.argv[2], sys.argv[3]
--desk_host = len(sys.argv) > 4 and sys.argv[4] == '1'
++desk_host = len(sys.argv) > 4 and sys.argv[4] in ('1', 'shared')
++shared = len(sys.argv) > 4 and sys.argv[4] == 'shared'
with open(path) as f:
conf = json.load(f)
plugins = [p for p in conf.get('plugins') or [] if p.get('name') not in ('computer', 'dryrun')]
@@@ -333,6 +349,14 @@ if desk_host:
computer.update({'notify': False, 'allow_desk': True, 'desk_dir': os.path.join(home, 'desks'),
'browser_profile': os.path.join(home, 'browser')})
conf['video'] = {'source': 'off'}
++if shared:
++ previous = next((p.get('config') or {} for p in conf.get('plugins') or [] if p.get('name') == 'computer'), {})
++ computer.update({'desk_isolation': 'sandbox', 'desk_egress': 'public', 'allow_exec': False, 'probe': False,
++ 'allow_screen_stream': False})
++ for k, v in {'desk_memory_mb': 1024, 'desk_cpu_millis': 1000, 'desk_disk_mb': 512, 'desk_pids': 512}.items():
++ computer[k] = previous.get(k, v) # an operator's own sizes are kept on a second run
++ if previous.get('max_desks'):
++ computer['max_desks'] = previous['max_desks']
plugins.append({'name': 'computer', 'config': computer})
conf['plugins'] = plugins
old = os.umask(0o077)
@@@ -456,6 +480,7 @@ if [ -n "${OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY:-}" ]; then
[ "$DESK_HOST" = 0 ] || [ -n "$COMPUTER" ] || COMPUTER=trusted
case "$COMPUTER" in observe|ask|trusted|full) ;; *) die "--computer is observe, ask, trusted or full" ;; esac
write_computer_config "$OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY" "$COMPUTER" "${OPENVIBE_COMPUTER_HOME:?}" "$DESK_HOST"
++ [ "$DESK_HOST" != shared ] || write_worker_config "$OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY" "${OPENVIBE_JOB_UID:-999}" "${OPENVIBE_JOB_GID:-999}"
exit 0
fi
@@@ -711,7 +736,17 @@ say "installed $BIN_DIR/openvibe-node ($("$BIN_DIR/openvibe-node" version))"
SVC_USER=""
if [ "$WORKER" = 1 ] && [ "$OS" != linux ]; then die "--worker is for Linux"; fi
COMPUTER_HOME=""
--if [ "$DESK_HOST" = 1 ]; then
++if [ "$DESK_HOST" = shared ]; then
++ # A desk host for other people: the service runs as root, which the desk sandbox needs (namespaces, a cgroup per
++ # desk, its network), and every desk's browser runs in it as the unprivileged openvibe-job, as a worker's jobs do.
++ [ "$OS" = linux ] || die "--desk-host --shared is for Linux (namespaces and cgroup v2)"
++ [ "$WORKER" = 0 ] || die "--desk-host --shared already runs the worker's sandbox for its desks; leave out --worker"
++ [ -n "$COMPUTER" ] || COMPUTER=trusted
++ COMPUTER_HOME=/var/lib/openvibe-desk
++ $SUDO mkdir -p "$COMPUTER_HOME/desks"
++ $SUDO chown -R root:root "$COMPUTER_HOME"
++ $SUDO chmod 700 "$COMPUTER_HOME"
++elif [ "$DESK_HOST" = 1 ]; then
# A desk host: the desks run as an account of their own, never the person's, so an agent there never reaches the
# owner's files, browser or sessions. --computer still sets the level (trusted by default).
[ "$OS" = linux ] || die "--desk-host is for Linux (desks are Xvfb displays)"
@@@ -736,7 +771,7 @@ elif [ -n "$COMPUTER" ]; then
if [ -z "$COMPUTER_HOME" ] || [ ! -d "$COMPUTER_HOME" ]; then die "no home folder for $SVC_USER"; fi
fi
case "$COMPUTER" in ""|observe|ask|trusted|full) ;; *) die "--computer is observe, ask, trusted or full" ;; esac
--if [ "$OS" = linux ] && [ "$SERVICE" = 1 ] && [ "$WORKER" = 0 ] && [ -z "$COMPUTER" ]; then
++if [ "$OS" = linux ] && [ "$SERVICE" = 1 ] && [ "$WORKER" = 0 ] && [ -z "$COMPUTER" ] && [ "$DESK_HOST" != shared ]; then
SVC_USER=openvibe-node
if ! id "$SVC_USER" >/dev/null 2>&1; then
$SUDO useradd --system --home-dir "$STATE" --no-create-home --shell /usr/sbin/nologin "$SVC_USER" 2>/dev/null ||
@@@ -808,7 +843,33 @@ if [ -n "$COMPUTER" ]; then
$SUDO "$PIP" install --quiet "$STATE/plugins/computer[screen]" 2>/dev/null || $SUDO "$PIP" install --quiet "$STATE/plugins/computer"
[ "$DRIVER" = none ] && VIDEO='"source": "off"'
fi
--if [ "$DESK_HOST" = 1 ]; then
++if [ "$DESK_HOST" = shared ]; then
++ # What a sandboxed browser desk needs: a browser installed under /usr or /opt (never a snap, which cannot run in
++ # the sandbox), fonts, and the sandbox's network tools (its egress is a veth the Node NATs).
++ if command -v apt-get >/dev/null 2>&1; then
++ say "installing what shared desks need (a browser, fonts, nftables, iproute2)"
++ $SUDO apt-get install -y nftables iproute2 util-linux fonts-dejavu-core fonts-noto-color-emoji fonts-liberation >/dev/null 2>&1 ||
++ say "warning: apt-get could not install nftables iproute2 util-linux and fonts; install them yourself"
++ if [ ! -x /opt/google/chrome/chrome ] && [ "$(dpkg --print-architecture 2>/dev/null)" = amd64 ]; then
++ say "installing Google Chrome (its .deb from dl.google.com)"
++ if ! curl -fsSL -o "$TMP/chrome.deb" https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb ||
++ ! $SUDO apt-get install -y "$TMP/chrome.deb" >/dev/null 2>&1; then
++ say "warning: could not install Google Chrome"
++ fi
++ elif [ ! -x /opt/google/chrome/chrome ]; then
++ # No Chrome for Linux on ARM: Debian's Chromium is a .deb (Ubuntu's is a snap, which cannot run sandboxed).
++ $SUDO apt-get install -y chromium >/dev/null 2>&1 ||
++ say "warning: no Chromium .deb here (Ubuntu's is a snap): use Debian on an ARM desk host, or a Chromium build under /opt"
++ fi
++ else
++ say "install Google Chrome (or a Chromium that is not a snap), fonts, nftables and iproute2: shared desks need them"
++ fi
++ if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0)" != 1 ]; then
++ say "turning on IPv4 forwarding (the sandbox NATs each desk's own network; /etc/sysctl.d/90-openvibe-node-worker.conf)"
++ $SUDO sh -c "printf 'net.ipv4.ip_forward = 1\n' > /etc/sysctl.d/90-openvibe-node-worker.conf"
++ $SUDO sysctl -q -p /etc/sysctl.d/90-openvibe-node-worker.conf || say "warning: could not turn IPv4 forwarding on"
++ fi
++elif [ "$DESK_HOST" = 1 ]; then
# What a desk needs: a display (Xvfb), a window manager, its own D-Bus, input (xdotool), screenshots (ImageMagick),
# fonts, and a Chromium-family browser.
if command -v apt-get >/dev/null 2>&1; then
@@@ -837,7 +898,36 @@ else
# The one command sets up the robot it found: the driver's plugins, kind and video replace the old ones.
[ "$CLAIM" = 0 ] || set_driver_config "$CONF/config.json"
fi
--if [ "$DESK_HOST" = 1 ]; then
++if [ "$DESK_HOST" = shared ]; then
++ if ! id openvibe-job >/dev/null 2>&1; then
++ $SUDO useradd --system --no-create-home --shell /usr/sbin/nologin --user-group openvibe-job 2>/dev/null ||
++ $SUDO adduser --system --no-create-home --shell /usr/sbin/nologin --group openvibe-job
++ fi
++ # The worker is on (no functions: no jobs are offered) so the Node sets up its cgroup before any desk starts, and
++ # its sandbox, run_as and egress are the desks'.
++ write_worker_config "$CONF/config.json" "$(id -u openvibe-job)" "$(id -g openvibe-job)"
++ write_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME" shared
++ mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
++ # A browser desk counts as half a desk against max_desks and may use 1 GiB: keep 1 GiB for the system.
++ desks=$(( (mem_kb / 1048576 - 1) / 2 )); [ "$desks" -ge 1 ] || desks=1; [ "$desks" -le 10 ] || desks=10
++ $SUDO python3 - "$CONF/config.json" "$desks" <<'PY'
++import json, os, sys
++path, desks = sys.argv[1], int(sys.argv[2])
++conf = json.load(open(path))
++for p in conf.get('plugins') or []:
++ if p.get('name') == 'computer':
++ p.setdefault('config', {}).setdefault('max_desks', desks)
++old = os.umask(0o077)
++with open(path + '.tmp', 'w') as f:
++ json.dump(conf, f, indent=2)
++ f.write('\n')
++os.umask(old)
++os.replace(path + '.tmp', path)
++PY
++ say "this machine is a shared desk host: up to $((desks * 2)) browser desks at once for other people's agents, each"
++ say "in its own sandbox (1 GiB, one core, the public internet only); nothing of this machine is offered to them"
++ say "(desk_memory_mb, desk_cpu_millis and max_desks in $CONF/config.json change it)."
++elif [ "$DESK_HOST" = 1 ]; then
write_computer_config "$CONF/config.json" "$COMPUTER" "$COMPUTER_HOME" 1
mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo 0)
desks=$(( mem_kb / 2097152 )); [ "$desks" -ge 1 ] || desks=1; [ "$desks" -le 8 ] || desks=8
ddiff --git a/internal/config/config.go b/internal/config/config.go
iindex 16ffa03..71a6d4b 100644
---- a/internal/config/config.go
++++ b/internal/config/config.go
@@@ -28,6 +28,10 @@ import (
const (
DefaultServer = "https://openvibe.bot"
EnvHome = "OPENVIBE_NODE_HOME"
++ // EnvConfigDir and EnvStateDir are the Node's directories as it gives them to its plugins, so a command a plugin
++ // runs (`openvibe-node desk-sandbox`) reads the same config as the Node that started it.
++ EnvConfigDir = "OPENVIBE_NODE_CONFIG"
++ EnvStateDir = "OPENVIBE_NODE_STATE"
)
// Paths are the Node's directories.
ddiff --git a/internal/link/link_test.go b/internal/link/link_test.go
iindex c058e10..650cc2f 100644
---- a/internal/link/link_test.go
++++ b/internal/link/link_test.go
@@@ -109,7 +109,9 @@ func waitFrame(t *testing.T, r *recorder, typ string) protocol.Frame {
}
func TestConnectHelloConfigHeartbeat(t *testing.T) {
-- srv, l, rec, _, _ := setup(t, 50*time.Millisecond)
++ // 100 ms heartbeats (a 200 ms deadman): a macOS runner can stall a goroutine past a 100 ms deadman, which this
++ // test is not about.
++ srv, l, rec, _, _ := setup(t, 100*time.Millisecond)
c, err := srv.NextConn(3 * time.Second)
if err != nil {
t.Fatal(err)
@@@ -126,8 +128,8 @@ func TestConnectHelloConfigHeartbeat(t *testing.T) {
if srv.CredentialSeenInURL() {
t.Fatal("credential in URL")
}
-- // Heartbeats keep the link up well past the deadman.
-- time.Sleep(300 * time.Millisecond)
++ // Heartbeats keep the link up well past the deadman (three of them).
++ time.Sleep(600 * time.Millisecond)
if !l.Connected() {
t.Fatal("dropped while heartbeats were acked")
}
ddiff --git a/internal/node/node.go b/internal/node/node.go
iindex 800dc63..6c80baf 100644
---- a/internal/node/node.go
++++ b/internal/node/node.go
@@@ -156,6 +156,7 @@ func New(opt Options) (*Node, error) {
n.limits = safety.Merge(protocol.Limits{}, opt.Config.Limits.MaxSpeed, opt.Config.Limits.MaxTurn, opt.Config.Limits.MaxCommandMS)
var specs []plugins.Spec
++ exe, _ := os.Executable()
for _, p := range opt.Config.Plugins {
if p.Disabled {
continue
@@@ -165,7 +166,10 @@ func New(opt Options) (*Node, error) {
env = append(env, k+"="+v)
}
sort.Strings(env)
-- env = append(env, "OPENVIBE_NODE_STATE="+opt.Paths.StateDir)
++ env = append(env, config.EnvStateDir+"="+opt.Paths.StateDir, config.EnvConfigDir+"="+opt.Paths.ConfigDir)
++ if exe != "" {
++ env = append(env, "OPENVIBE_NODE_EXE="+exe) // `openvibe-node desk-sandbox` for the computer plugin's shared desks
++ }
cfg := map[string]any{}
for k, v := range p.Config {
cfg[k] = v
ddiff --git a/internal/worker/desk_linux.go b/internal/worker/desk_linux.go
nnew file mode 100644
iindex 0000000..4f0de54
---- /dev/null
++++ b/internal/worker/desk_linux.go
@@@ -0,0 +1,193 @@
++//go:build linux
++
++package worker
++
++import (
++ "context"
++ "errors"
++ "fmt"
++ "os"
++ "os/exec"
++ "path/filepath"
++ "strings"
++ "syscall"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++ "github.com/OpenVibers/OpenVibe.Node/internal/protocol"
++)
++
++// DeskSpec is one desk's program (its browser) as RunDesk runs it in the jobs' sandbox, on a desk host that serves
++// people other than its owner (Desks plan D5, second part): each desk in its own user, mount, network, PID and IPC
++// namespaces, as worker.run_as, inside its own cgroup with its own memory, CPU and process limits, seeing only the
++// system paths, its own /tmp (its profile) and the read-only Binds it names, and reaching only the public internet.
++type DeskSpec struct {
++ Argv []string
++ Env []string
++ // Binds are host paths the program needs beyond the system paths: the browser's install directory, /etc/fonts.
++ // Read-only, and only under deskBindRoots (CheckDeskBinds).
++ Binds []string
++ MemBytes int64 // memory.max (swap off): an OOM ends the whole desk
++ CPUMillis int64 // cpu.max: thousandths of a core per second, 1000 = one core
++ DiskBytes int64 // the size of its /tmp, where the profile lives
++ Pids int64 // pids.max
++ // Egress is "" (no network) or config.EgressPublic, which needs the host's worker.egress public too.
++ Egress string
++ // In and Out are the program's fd 3 and fd 4: a browser's --remote-debugging-pipe reads commands on 3 and writes
++ // its answers on 4.
++ In, Out *os.File
++ // Stderr receives the program's stderr (nil: discarded).
++ Stderr *os.File
++}
++
++// deskBindRoots are where a desk's extra read-only binds may come from: an install directory (a browser in /opt, a
++// /usr/local build) and the fonts' configuration. Never /home, /root, /var, /run, the rest of /etc, nor anything that
++// is, holds or lies in a Node directory or hiddenPaths (rootPlan's rule).
++var deskBindRoots = []string{"/opt", "/usr/local", "/etc/fonts"}
++
++// CheckDeskBinds refuses a bind outside deskBindRoots, a missing one, and one that would show a Node directory or a
++// hidden host path; it answers each bind cleaned.
++func CheckDeskBinds(nodeDirs, binds []string) ([]string, error) {
++ out := make([]string, 0, len(binds))
++ for _, b := range binds {
++ p := filepath.Clean(b)
++ if !filepath.IsAbs(p) {
++ return nil, fmt.Errorf("desk bind %s: not an absolute path", b)
++ }
++ if !under(p, deskBindRoots) {
++ return nil, fmt.Errorf("desk bind %s: only %s may be bound", b, strings.Join(deskBindRoots, ", "))
++ }
++ if _, err := os.Stat(p); err != nil {
++ return nil, fmt.Errorf("desk bind %s: %w", b, err)
++ }
++ for _, r := range resolved(p) {
++ if d, ok := overlaps(r, nodeDirs); ok {
++ return nil, fmt.Errorf("desk bind %s would expose the Node's directory %s", b, d)
++ }
++ if d, ok := overlaps(r, hiddenPaths); ok {
++ return nil, fmt.Errorf("desk bind %s would expose the host's %s", b, d)
++ }
++ }
++ out = append(out, p)
++ }
++ return out, nil
++}
++
++// RunDesk runs d's program confined and waits for it: its exit code, or an error when the sandbox could not be set up
++// or checked (the program then never ran). Cancelling ctx ends the desk: everything in its cgroup is killed.
++func RunDesk(ctx context.Context, cfg config.WorkerConfig, d DeskSpec) (int, error) {
++ if len(d.Argv) == 0 || d.In == nil || d.Out == nil {
++ return 0, errors.New("desk: a program and both pipe ends are needed")
++ }
++ if d.MemBytes <= 0 || d.CPUMillis <= 0 || d.DiskBytes <= 0 || d.Pids <= 0 {
++ return 0, errors.New("desk: memory, CPU, disk and process limits must all be set")
++ }
++ if d.Egress != "" && (d.Egress != config.EgressPublic || cfg.Egress != config.EgressPublic) {
++ return 0, fmt.Errorf("desk: egress %q needs egress public here (worker.egress is %q)", d.Egress, cfg.Egress)
++ }
++ if err := policy(cfg); err != nil {
++ return 0, err
++ }
++ attr, err := jobAttr(cfg)
++ if err != nil {
++ return 0, err
++ }
++ extra, err := CheckDeskBinds(cfg.NodeDirs, d.Binds)
++ if err != nil {
++ return 0, err
++ }
++ binds, links, err := rootPlan(cfg.NodeDirs)
++ if err != nil {
++ return 0, err
++ }
++ // Under /tmp whatever TMPDIR says: a plugin starts desk-sandbox with a desk's own temp folder there, closed to
++ // everyone else, and the sandbox mounts its root as worker.run_as, who must be able to reach it.
++ root, err := os.MkdirTemp("/tmp", "openvibe-desk-")
++ if err != nil {
++ return 0, err
++ }
++ defer os.RemoveAll(root)
++
++ caps := cfg.Caps.WithDefaults()
++ caps.MaxPids, caps.MaxCPUMillis, caps.MaxDiskBytes = d.Pids, d.CPUMillis, d.DiskBytes
++ sb := &sandbox{}
++ defer sb.close()
++ sr, sw, err := os.Pipe()
++ if err != nil {
++ return 0, err
++ }
++ sb.status, sb.child = sr, append(sb.child, sw)
++ gr, gw, err := os.Pipe()
++ if err != nil {
++ return 0, err
++ }
++ sb.goW, sb.child = gw, append(sb.child, gr)
++ // A browser keeps hundreds of files open and maps tens of GB of address space it never uses: no RLIMIT_AS (the
++ // cgroup's memory.max is the limit) and no RLIMIT_CPU (cpu.max is: a desk lives for hours).
++ spec := sandboxSpec{Root: root, Work: "/tmp/desk", Disk: d.DiskBytes, Links: links, Argv: d.Argv, Env: d.Env, Pipe: true,
++ Limits: []rlimit{{rlimitCORE, 0, 0}, {rlimitNOFILE, 8192, 8192}, {rlimitNPROC, uint64(d.Pids), uint64(d.Pids)},
++ {rlimitFSIZE, uint64(d.DiskBytes), uint64(d.DiskBytes)}}}
++ if d.Egress != "" {
++ spec.Resolvers = cfg.DNS
++ }
++ for _, b := range binds {
++ spec.Binds = append(spec.Binds, bindSpec{Path: b.path, Dev: b.dev})
++ }
++ for _, p := range extra {
++ spec.Binds = append(spec.Binds, bindSpec{Path: p})
++ }
++ sf, err := specFile(spec)
++ if err != nil {
++ return 0, err
++ }
++ sb.child = append(sb.child, sf)
++ if d.Egress != "" {
++ if sb.egress, err = newEgress(cfg); err != nil {
++ return 0, fmt.Errorf("desk egress: %w", err)
++ }
++ }
++ if sb.cg, err = newJobCgroup(caps, protocol.JobLimits{MemBytes: d.MemBytes}); err != nil {
++ // Its cgroup is made beside the Node's own leaf, which the Node sets up when its worker is on (the probe);
++ // a desk started by a plugin cannot rearrange the Node's cgroup itself.
++ return 0, fmt.Errorf("desk cgroup: %w (a desk host runs its Node with worker.enabled, so the cgroup is set up first)", err)
++ }
++ attr.UseCgroupFD, attr.CgroupFD = true, sb.cg.fd
++ cmd := &exec.Cmd{Path: "/proc/self/exe", Args: []string{sandboxArg0}, Env: []string{}, Dir: "/", Stderr: d.Stderr,
++ ExtraFiles: []*os.File{d.In, sw, gr, sf, d.Out}, SysProcAttr: attr} // fds 3, 4 status, 5 go, 6 spec, 7 → 4
++ if err := cmd.Start(); err != nil {
++ return 0, fmt.Errorf("starting the desk's sandbox: %w", err)
++ }
++ sb.started()
++ pid := cmd.Process.Pid
++ waited := make(chan error, 1)
++ go func() { waited <- cmd.Wait() }()
++ fail := func(err error) (int, error) {
++ _ = cmd.Process.Kill()
++ <-waited
++ return 0, err
++ }
++ if err := sb.ready(pid); err != nil {
++ return fail(err)
++ }
++ if err := sb.run(); err != nil {
++ return fail(err)
++ }
++ select {
++ case err := <-waited:
++ var ee *exec.ExitError
++ switch {
++ case err == nil:
++ return 0, nil
++ case errors.As(err, &ee):
++ if ws, ok := ee.Sys().(syscall.WaitStatus); ok && ws.Signaled() {
++ return 128 + int(ws.Signal()), nil
++ }
++ return ee.ExitCode(), nil
++ default:
++ return 0, err
++ }
++ case <-ctx.Done():
++ _ = cmd.Process.Kill() // the init; close() then kills what is left in the cgroup
++ <-waited
++ return 128 + int(syscall.SIGKILL), ctx.Err()
++ }
++}
ddiff --git a/internal/worker/desk_linux_test.go b/internal/worker/desk_linux_test.go
nnew file mode 100644
iindex 0000000..f47b54e
---- /dev/null
++++ b/internal/worker/desk_linux_test.go
@@@ -0,0 +1,216 @@
++//go:build linux
++
++package worker
++
++import (
++ "bufio"
++ "bytes"
++ "context"
++ "encoding/json"
++ "io"
++ "os"
++ "strings"
++ "testing"
++ "time"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++)
++
++// deskConfig is a worker config a desk can run under here, or the test skips (fails under OPENVIBE_WORKER_TESTS=require,
++// as the jobs' tests do).
++func deskConfig(t *testing.T) config.WorkerConfig {
++ t.Helper()
++ cfg := helperConfig(t, config.WorkerCaps{})
++ newRunWorkerConfig(t, cfg) // probes, and skips or fails as the jobs' tests do
++ return cfg
++}
++
++type deskPipes struct {
++ in, out *os.File // the desk's ends: its fd 3 and fd 4
++ write, answer *os.File // ours
++}
++
++func newDeskPipes(t *testing.T) deskPipes {
++ t.Helper()
++ inR, inW, err := os.Pipe()
++ if err != nil {
++ t.Fatal(err)
++ }
++ outR, outW, err := os.Pipe()
++ if err != nil {
++ t.Fatal(err)
++ }
++ t.Cleanup(func() {
++ for _, f := range []*os.File{inR, inW, outR, outW} {
++ f.Close()
++ }
++ })
++ return deskPipes{in: inR, out: outW, write: inW, answer: outR}
++}
++
++// TestDeskBindsOnlyInstallDirsAndFonts: a desk may bind an install directory or the fonts' configuration, read-only,
++// never a home, a secret, the host's runtime or a relative path.
++func TestDeskBindsOnlyInstallDirsAndFonts(t *testing.T) {
++ for _, bad := range []string{"/home", "/root", "/etc/shadow", "/var/lib", "/run", "/etc", "opt/x", "/opt/../home"} {
++ if _, err := CheckDeskBinds(nil, []string{bad}); err == nil {
++ t.Errorf("%s was allowed", bad)
++ }
++ }
++ dir := t.TempDir()
++ if _, err := CheckDeskBinds([]string{"/opt"}, []string{"/opt"}); err == nil {
++ t.Error("a bind holding a Node directory was allowed")
++ }
++ if _, err := os.Stat("/etc/fonts"); err == nil {
++ if got, err := CheckDeskBinds([]string{dir}, []string{"/etc/fonts/"}); err != nil || len(got) != 1 || got[0] != "/etc/fonts" {
++ t.Errorf("/etc/fonts: %v %v", got, err)
++ }
++ }
++}
++
++// TestDeskPipesBothWaysConfined: the desk's program reads on fd 3 and answers on fd 4 (as a browser's
++// --remote-debugging-pipe does), runs as worker.run_as (nobody, as root), sees no /home, and gets its exit code back.
++func TestDeskPipesBothWaysConfined(t *testing.T) {
++ cfg := deskConfig(t)
++ p := newDeskPipes(t)
++ script := `read -r line <&3; printf 'uid=%s\n' "$(id -u)" >&4; printf 'got=%s\n' "$line" >&4; ` +
++ `if [ -e /home ]; then echo home=yes >&4; else echo home=no >&4; fi; ` +
++ `if [ -w /tmp/desk ]; then echo work=writable >&4; fi; exit 7`
++ done := make(chan struct{})
++ var code int
++ var runErr error
++ go func() {
++ defer close(done)
++ code, runErr = RunDesk(context.Background(), cfg, DeskSpec{Argv: []string{"/bin/sh", "-c", script}, Env: []string{"PATH=/usr/bin:/bin"},
++ MemBytes: 64 << 20, CPUMillis: 500, DiskBytes: 16 << 20, Pids: 32, In: p.in, Out: p.out})
++ }()
++ if _, err := p.write.WriteString("hello desk\n"); err != nil {
++ t.Fatal(err)
++ }
++ select {
++ case <-done:
++ case <-time.After(30 * time.Second):
++ t.Fatal("the desk did not end")
++ }
++ if runErr != nil {
++ t.Fatal(runErr)
++ }
++ p.out.Close() // ours: the desk's copy closed when it ended
++ b, _ := io.ReadAll(p.answer)
++ got := string(b)
++ want := []string{"got=hello desk", "home=no", "work=writable"}
++ if os.Geteuid() == 0 {
++ want = append(want, "uid=65534")
++ }
++ for _, w := range want {
++ if !strings.Contains(got, w) {
++ t.Errorf("missing %q in %q", w, got)
++ }
++ }
++ if code != 7 {
++ t.Fatalf("exit code %d, want 7", code)
++ }
++}
++
++// TestDeskEndsWithItsContext: cancelling a running desk kills it, and RunDesk returns.
++func TestDeskEndsWithItsContext(t *testing.T) {
++ cfg := deskConfig(t)
++ p := newDeskPipes(t)
++ ctx, cancel := context.WithCancel(context.Background())
++ done := make(chan error, 1)
++ go func() {
++ _, err := RunDesk(ctx, cfg, DeskSpec{Argv: []string{"/bin/sh", "-c", "echo up >&4; exec sleep 600"}, Env: []string{"PATH=/usr/bin:/bin"},
++ MemBytes: 64 << 20, CPUMillis: 500, DiskBytes: 16 << 20, Pids: 32, In: p.in, Out: p.out})
++ done <- err
++ }()
++ line, err := bufio.NewReader(p.answer).ReadString('\n')
++ if err != nil || line != "up\n" {
++ t.Fatalf("the desk did not start: %q %v", line, err)
++ }
++ cancel()
++ select {
++ case err := <-done:
++ if err == nil {
++ t.Fatal("no error for a cancelled desk")
++ }
++ case <-time.After(15 * time.Second):
++ t.Fatal("a cancelled desk kept running")
++ }
++}
++
++// TestDeskRunsARealBrowser: headless Chrome in the desk sandbox answers DevTools over its pipe (Browser.getVersion,
++// a page made and read), with no network: a desk host's browser works confined. Skipped where no Chrome is installed.
++func TestDeskRunsARealBrowser(t *testing.T) {
++ bin, dir := "", ""
++ for _, c := range []struct{ bin, dir string }{{"/opt/google/chrome/chrome", "/opt/google/chrome"}, {"/opt/chromium/chrome", "/opt/chromium"}} {
++ if _, err := os.Stat(c.bin); err == nil {
++ bin, dir = c.bin, c.dir
++ break
++ }
++ }
++ if bin == "" {
++ t.Skip("no Chrome in /opt here")
++ }
++ cfg := deskConfig(t)
++ p := newDeskPipes(t)
++ binds := []string{dir}
++ if _, err := os.Stat("/etc/fonts"); err == nil {
++ binds = append(binds, "/etc/fonts")
++ }
++ ctx, cancel := context.WithCancel(context.Background())
++ defer cancel()
++ var stderr bytes.Buffer
++ ef, ew, err := os.Pipe()
++ if err != nil {
++ t.Fatal(err)
++ }
++ go func() { _, _ = io.Copy(&stderr, ef) }()
++ done := make(chan error, 1)
++ go func() {
++ _, err := RunDesk(ctx, cfg, DeskSpec{Argv: []string{bin, "--headless=new", "--no-sandbox", "--no-zygote", "--disable-gpu",
++ "--disable-dev-shm-usage", "--no-first-run", "--no-default-browser-check", "--remote-debugging-pipe",
++ "--user-data-dir=/tmp/desk/profile", "about:blank"}, Env: []string{"HOME=/tmp/desk", "PATH=/usr/bin:/bin"},
++ Binds: binds, MemBytes: 1 << 30, CPUMillis: 1000, DiskBytes: 256 << 20, Pids: 256, In: p.in, Out: p.out, Stderr: ew})
++ done <- err
++ }()
++ ew.Close()
++ answers := bufio.NewReader(p.answer)
++ call := func(id int, method string, params any) map[string]any {
++ t.Helper()
++ msg, _ := json.Marshal(map[string]any{"id": id, "method": method, "params": params})
++ if _, err := p.write.Write(append(msg, 0)); err != nil {
++ t.Fatalf("%s: %v", method, err)
++ }
++ deadline := time.Now().Add(45 * time.Second)
++ for time.Now().Before(deadline) {
++ raw, err := answers.ReadBytes(0)
++ if err != nil {
++ t.Fatalf("%s: the browser closed its pipe: %v\nstderr: %s", method, err, stderr.String())
++ }
++ var m map[string]any
++ if json.Unmarshal(raw[:len(raw)-1], &m) == nil && m["id"] == float64(id) {
++ return m
++ }
++ }
++ t.Fatalf("%s: no answer", method)
++ return nil
++ }
++ v := call(1, "Browser.getVersion", map[string]any{})
++ res, _ := v["result"].(map[string]any)
++ if res == nil || !strings.Contains(res["product"].(string), "Chrome") {
++ t.Fatalf("Browser.getVersion: %v\nstderr: %s", v, stderr.String())
++ }
++ tg := call(2, "Target.createTarget", map[string]any{"url": "data:text/html,<title>desk</title><p>confined"})
++ if r, _ := tg["result"].(map[string]any); r == nil || r["targetId"] == "" {
++ t.Fatalf("Target.createTarget: %v", tg)
++ }
++ call(3, "Browser.close", map[string]any{})
++ select {
++ case err := <-done:
++ if err != nil {
++ t.Fatal(err)
++ }
++ case <-time.After(30 * time.Second):
++ t.Fatal("the browser did not end after Browser.close")
++ }
++ t.Logf("Chrome %s ran confined", res["product"])
++}
ddiff --git a/internal/worker/desk_other.go b/internal/worker/desk_other.go
nnew file mode 100644
iindex 0000000..a50a664
---- /dev/null
++++ b/internal/worker/desk_other.go
@@@ -0,0 +1,26 @@
++//go:build !linux
++
++package worker
++
++import (
++ "context"
++ "os"
++
++ "github.com/OpenVibers/OpenVibe.Node/internal/config"
++)
++
++// DeskSpec is one desk's program as RunDesk runs it; off Linux a desk never runs sandboxed (desk_linux.go).
++type DeskSpec struct {
++ Argv []string
++ Env []string
++ Binds []string
++ MemBytes, CPUMillis, DiskBytes, Pids int64
++ Egress string
++ In, Out, Stderr *os.File
++}
++
++// CheckDeskBinds refuses every bind off Linux: there is no sandbox to bind into.
++func CheckDeskBinds(nodeDirs, binds []string) ([]string, error) { return nil, errNotLinux }
++
++// RunDesk: the namespaces a desk is confined with exist on Linux only.
++func RunDesk(context.Context, config.WorkerConfig, DeskSpec) (int, error) { return 0, errNotLinux }
ddiff --git a/internal/worker/sandbox_linux.go b/internal/worker/sandbox_linux.go
iindex 87ac4ed..a4f279f 100644
---- a/internal/worker/sandbox_linux.go
++++ b/internal/worker/sandbox_linux.go
@@@ -54,8 +54,14 @@ type sandboxSpec struct {
Env []string `json:"env"`
// Resolvers are the nameservers of the job's /etc/resolv.conf (worker.dns, for a job with a network); none, none.
Resolvers []string `json:"resolvers,omitempty"`
++ // Pipe: a desk's program (RunDesk) talks both ways, as a browser's --remote-debugging-pipe does: it reads on fd 3
++ // and writes on fd 4, which arrives on artifactFD (a desk has no artifact) and takes fd 4 just before execve.
++ Pipe bool `json:"pipe,omitempty"`
}
++// statusOut is where the init reports: statusFD, until a desk's pipe takes fd 4 (enterSandbox).
++var statusOut = statusFD
++
// bindSpec bind-mounts the host's path at the same path in the private root, read-only unless it is a device node.
// The bind is made by path in the job's own mount namespace: a mount cannot be bound from another namespace.
type bindSpec struct {
@@@ -95,7 +101,7 @@ func sandboxMain() {
// no_new_privs, the seccomp filter and the capabilities belong to this thread, the one that calls execve.
runtime.LockOSThread()
err := enterSandbox()
-- status := os.NewFile(statusFD, "status")
++ status := os.NewFile(uintptr(statusOut), "status")
_, _ = status.WriteString(err.Error())
os.Exit(126)
}
@@@ -209,13 +215,28 @@ func enterSandbox() error {
if err := syscall.Chdir(s.Work); err != nil {
return err
}
-- // Only fds 0-3 reach the function; statusFD closes when execve succeeds.
++ // Only fds 0-3 reach the function (0-4 a desk's); statusFD closes when execve succeeds.
++ keep := 3
++ if s.Pipe {
++ // The status pipe moves out of fd 4's way, close-on-exec as before, so execve still closes it; fd 4 then
++ // becomes the desk's answer pipe.
++ fd, _, e := syscall.RawSyscall(syscall.SYS_FCNTL, statusFD, syscall.F_DUPFD_CLOEXEC, 10)
++ if e != 0 {
++ return fmt.Errorf("moving the status pipe: %w", e)
++ }
++ statusOut = int(fd)
++ if err := syscall.Dup3(artifactFD, 4, 0); err != nil {
++ return fmt.Errorf("placing the desk's pipe on fd 4: %w", err)
++ }
++ syscall.Close(artifactFD)
++ keep = 4
++ }
ents, err := os.ReadDir("/proc/self/fd")
if err != nil {
return err
}
for _, e := range ents {
-- if fd, err := strconv.Atoi(e.Name()); err == nil && fd > 3 {
++ if fd, err := strconv.Atoi(e.Name()); err == nil && fd > keep {
syscall.CloseOnExec(fd)
}
}
@@@ -257,7 +278,7 @@ func enterSandbox() error {
}
runtime.KeepAlive(filter)
var b [1]byte
-- if _, err := syscall.Write(statusFD, b[:]); err != nil {
++ if _, err := syscall.Write(statusOut, b[:]); err != nil {
return fmt.Errorf("reporting ready: %w", err)
}
if n, err := syscall.Read(goFD, b[:]); n != 1 {
ddiff --git a/plugins/computer/openvibe_computer/__init__.py b/plugins/computer/openvibe_computer/__init__.py
iindex 4b6e2c5..6829264 100644
---- a/plugins/computer/openvibe_computer/__init__.py
++++ b/plugins/computer/openvibe_computer/__init__.py
@@@ -66,6 +66,13 @@ Command kinds and their answers (the ack's ``data``):
against ``max_desks``. ``describe`` lists the kinds this machine runs (``desk.kinds``). Starting and stopping act
(refused at observe, approved at ask), ``status`` runs at every level, and the kill switch stops the desk. Whole desks
need Linux with Xvfb, browser desks a Chromium-family browser; config ``allow_desk: false`` turns both off.
++* **A desk host for other people** (``desk_isolation: "sandbox"``, Node v0.32, Desks plan D5): every desk is a browser
++ desk whose browser runs under ``openvibe-node desk-sandbox`` (the jobs' sandbox: its own namespaces and cgroup as
++ ``worker.run_as``, ``desk_memory_mb``, ``desk_cpu_millis``, ``desk_disk_mb`` and ``desk_pids`` each, only the public
++ internet with ``desk_egress: public``), its profile and downloads inside the sandbox and gone with the desk. Nothing
++ of the machine itself is offered: ``exec``, ``fs``, ``probe``, the main screen and browser, whole desks and kept
++ profiles are refused, and so is ``screen_stream`` (the machine's one stream is its operator's; ``screen`` shows a desk). It runs on Linux with the Node as root (the one case the plugin runs as root) and the worker
++ on, at level ``trusted`` (or ``observe``): nobody is at the machine to approve.
* ``probe`` ``{"target", "timeout_s"?}`` → for ``http(s)://…`` one GET, no redirect followed: ``{status, content_type,
etag, last_modified, body, truncated, ms}``; for ``tcp://host:port``: ``{open, ms, error?}``. Read-only, so it runs at
every level, and only towards the machine's own network: every address the name resolves to must be private,
@@@ -143,6 +150,17 @@ CLICLICK_KEYS = {"return": "return", "enter": "enter", "escape": "esc", "esc": "
"delete": "fwd-delete", "space": "space", "up": "arrow-up", "down": "arrow-down", "left": "arrow-left",
"right": "arrow-right", "home": "home", "end": "end", "page_up": "page-up", "prior": "page-up",
"page_down": "page-down", "next": "page-down"}
++# A desk host serving other people (desk_isolation sandbox, Desks plan D5): each desk's browser runs under
++# `openvibe-node desk-sandbox`, which replaces Chrome's own sandbox (it cannot nest inside ours) and has no /dev/shm;
++# its profile and downloads live in the sandbox's /tmp, gone with the desk; its environment is only this.
++SANDBOX_CHROME_ARGS = ("--no-sandbox", "--no-zygote", "--disable-dev-shm-usage", "--disable-gpu")
++SANDBOX_PROFILE = "/tmp/desk/profile"
++SANDBOX_DOWNLOADS = "/tmp/desk/downloads"
++SANDBOX_ENV = ("HOME=/tmp/desk", "TMPDIR=/tmp/desk", "PATH=/usr/bin:/bin", "LANG=C.UTF-8", "DBUS_SESSION_BUS_ADDRESS=disabled:")
++# What a shared desk host's own commands may be: its browser desks and what acts on them, never the machine itself.
++# No screen_stream: the live view goes to the machine owner's one stream, which is the host's operator's, not the
++# person's whose desk it is (a desk is seen there through screen, a picture answered to the command).
++SHARED_DESK_KINDS = frozenset({"screen", "input", "browser"})
@@@ -196,7 +214,7 @@ def node_data_dir():
class Computer(Plugin):
driver = "computer"
-- version = "0.14.0"
++ version = "0.15.0"
motion_kinds = frozenset()
telemetry_interval_s = 2.0
@@@ -238,10 +256,13 @@ 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, extra_args=self._browser_args()))
++ self.browser_factory = browser_factory or self._new_browser
# 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.desk_isolation = "none" # or "sandbox": a desk host for other people (SANDBOX_*)
++ self.desk_limits = {"memory_mb": 1024, "cpu_millis": 1000, "disk_mb": 512, "pids": 512}
++ self.desk_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)
@@@ -294,12 +315,39 @@ class Computer(Plugin):
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
++ isolation = str(config.get("desk_isolation") or "none").strip().lower()
++ if isolation not in ("none", "sandbox"):
++ raise Fault("bad_value", "desk_isolation is none (the default: desks for the machine's owner) or sandbox (a desk host for other people)")
++ if isolation == "sandbox":
++ if not self.platform.startswith("linux"):
++ raise Fault("unsupported", "desk_isolation sandbox needs Linux (namespaces and cgroup v2)")
++ if level not in ("observe", "trusted"):
++ raise Fault("bad_value", "a desk host for other people runs at level trusted (or observe): nobody is at the machine to approve")
++ self.desk_isolation = isolation
++ self.desk_limits = {"memory_mb": _int(config.get("desk_memory_mb"), "desk_memory_mb", 256, 16384, 1024),
++ "cpu_millis": _int(config.get("desk_cpu_millis"), "desk_cpu_millis", 100, 16000, 1000),
++ "disk_mb": _int(config.get("desk_disk_mb"), "desk_disk_mb", 64, 16384, 512),
++ "pids": _int(config.get("desk_pids"), "desk_pids", 64, 8192, 512)}
++ desk_egress = str(config.get("desk_egress") or "public").strip().lower()
++ if desk_egress not in ("public", "none"):
++ raise Fault("bad_value", "desk_egress is public (the default: the public internet only) or none")
++ self.desk_egress = desk_egress
++
++ def _shared(self):
++ return self.desk_isolation == "sandbox"
def describe(self, config):
try:
self._configure(config)
except Fault:
return {}
++ if self._shared():
++ # A desk host for other people offers its browser desks, sandboxed, and nothing of the machine itself.
++ if "browser" not in self._desk_kinds():
++ return {}
++ return {"desk": {"level": self.level, "window_manager": None, "input": True, "screen": True, "named": True,
++ "max_desks": self.desks.max_desks, "kinds": ["browser"], "profiles": False,
++ "isolation": "sandbox", "limits": dict(self.desk_limits), "egress": self.desk_egress}}
caps = {"files": {"level": self.level, "roots": self.roots, "max_read_bytes": self.max_read}}
if self.allow_exec:
caps["terminal"] = {"level": self.level, "timeout_s": self.timeout_s, "max_output_bytes": self.max_output,
@@@ -340,7 +388,9 @@ class Computer(Plugin):
def setup(self, config):
self._configure(config)
-- if hasattr(os, "geteuid") and os.geteuid() == 0:
++ # As root only on a desk host for other people, where nothing it starts runs unconfined: every browser goes
++ # through desk-sandbox (as worker.run_as) and no command reaches the machine itself (_shared_check).
++ if hasattr(os, "geteuid") and os.geteuid() == 0 and not self._shared():
raise Fault("not_allowed", "the computer plugin never runs as root: run the Node as your own account")
# ---- safety ----------------------------------------------------------------------------------------------------
@@@ -438,6 +488,8 @@ class Computer(Plugin):
if not isinstance(v, dict):
raise Fault("bad_value", "value must be an object")
level = self.effective_level()
++ if self._shared():
++ self._shared_check(cmd.kind, v)
if cmd.kind == "desk":
return self._desk_op(cmd, v, level)
desk_env = self._desk_env(v) if cmd.kind in ("screen", "screen_stream", "input", "browser") else None
@@@ -492,6 +544,56 @@ class Computer(Plugin):
return self._act(cmd, level, run, summary, threaded=True)
raise Fault("unsupported", "kind %s not supported" % cmd.kind)
++ def _shared_check(self, kind, v):
++ """On a desk host for other people: browser desks (started without a kept profile) and the commands that act on
++ one; never the machine's own screen, browser, files, programs or network."""
++ if kind == "desk":
++ if v.get("action") == "start" and v.get("kind", "desktop") != "browser":
++ raise Fault("unsupported", "this desk host runs browser desks only (kind browser)")
++ if v.get("action") == "start" and v.get("profile") is not None:
++ raise Fault("unsupported", "this desk host keeps no browser profiles: a desk's sign-ins end with it")
++ return
++ if kind in SHARED_DESK_KINDS and v.get("desk", "main") != "main":
++ return
++ if kind == "screen_stream":
++ raise Fault("not_allowed", "this desk host shows a desk as pictures (screen), not live: its one stream is its operator's")
++ raise Fault("not_allowed", "this machine is a desk host for other people: only its browser desks take commands")
++
++ def _sandbox_argv(self, binary):
++ """The `openvibe-node desk-sandbox … --` a desk's browser runs under on a desk host for other people, else None."""
++ if not self._shared():
++ return None
++ node = self.env.get("OPENVIBE_NODE_EXE") or self.which("openvibe-node")
++ if not node:
++ raise Fault("unsupported", "openvibe-node was not found to sandbox the desk (the Node sets OPENVIBE_NODE_EXE)")
++ real = os.path.realpath(binary)
++ binds = []
++ if binary.startswith("/snap/") or os.path.basename(real) == "snap":
++ raise Fault("unsupported", "a sandboxed desk's browser cannot be a snap: install Google Chrome's .deb (or Chromium from a .deb)")
++ if real.startswith(("/opt/", "/usr/local/")):
++ binds.append(os.path.dirname(real)) # the install directory, read-only; the rest of /usr is the system's
++ elif not real.startswith("/usr/"):
++ raise Fault("unsupported", "a sandboxed desk's browser is installed under /usr, /opt or /usr/local (%s is not: "
++ "a snap cannot run there)" % real)
++ if os.path.isdir("/etc/fonts"):
++ binds.append("/etc/fonts")
++ lim = self.desk_limits
++ argv = [node, "desk-sandbox", "--memory-mb", str(lim["memory_mb"]), "--cpu-millis", str(lim["cpu_millis"]),
++ "--disk-mb", str(lim["disk_mb"]), "--pids", str(lim["pids"])]
++ if self.desk_egress == "public":
++ argv += ["--egress", "public"]
++ for b in binds:
++ argv += ["--bind", b]
++ for kv in SANDBOX_ENV:
++ argv += ["--setenv", kv]
++ return argv + ["--"]
++
++ def _new_browser(self, binary, profile, headless, env=None):
++ sandbox = self._sandbox_argv(binary)
++ if sandbox:
++ binary = os.path.realpath(binary) # the sandbox shows the install directory, not the links that lead to it
++ return Browser(binary, profile, headless, env=env, extra_args=self._browser_args(), sandbox=sandbox)
++
# ---- the private desk --------------------------------------------------------------------------------------------
def _desk_env(self, v):
"""None for the owner's own screen (desk main, the default); a desk's environment for private or desk-<name>."""
@@@ -566,13 +668,17 @@ class Computer(Plugin):
raise Fault("unsupported", "no browser on this machine (Chromium, Chrome, Edge or Brave), or this machine does not allow it")
env = env or self.desks.env(desk)
page = self.desks.kind(desk) == "browser"
-- 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 self._shared(): # its profile and downloads inside its sandbox, gone with the desk
++ b = self.desk_browsers[desk] = self.browser_factory(binary, SANDBOX_PROFILE, page, env=env)
++ b.download_dir = SANDBOX_DOWNLOADS
++ else:
++ 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)
b.window = b.page_size = (st.get("width") or 1280, st.get("height") or 800)
@@@ -1228,6 +1334,8 @@ class Computer(Plugin):
return not_public(ip)
def _browser_args(self):
++ if self._shared(): # the sandbox's own network rules, not a proxy on 127.0.0.1 (another namespace there)
++ return list(SANDBOX_CHROME_ARGS)
return [] if self.browser_egress == "open" else self.egress.args()
def _close_browser(self):
ddiff --git a/plugins/computer/openvibe_computer/browser.py b/plugins/computer/openvibe_computer/browser.py
iindex 3f53aaa..e30dd07 100644
---- a/plugins/computer/openvibe_computer/browser.py
++++ b/plugins/computer/openvibe_computer/browser.py
@@@ -194,9 +194,13 @@ def find_binary(which, platform, configured=None):
class Browser:
-- def __init__(self, binary, profile, headless=True, timeout_s=15.0, popen=subprocess.Popen, extra_args=(), env=None):
++ def __init__(self, binary, profile, headless=True, timeout_s=15.0, popen=subprocess.Popen, extra_args=(), env=None,
++ sandbox=None):
self.binary = binary
self.env = env # the private desk's display and bus, or None for this process's own
++ # On a desk host serving other people (desk_isolation sandbox): the `openvibe-node desk-sandbox … --` command the
++ # browser runs under, its profile inside the sandbox (profile is then a path in there, never made here).
++ self.sandbox = list(sandbox) if sandbox else None
self.extra_args = list(extra_args)
self.stderr_tail = []
self.profile = profile
@@@ -235,7 +239,8 @@ class Browser:
self._start()
def _start(self):
-- os.makedirs(self.profile, mode=0o700, exist_ok=True)
++ if not self.sandbox:
++ os.makedirs(self.profile, mode=0o700, exist_ok=True)
to_chrome_r, to_chrome_w = os.pipe()
from_chrome_r, from_chrome_w = os.pipe()
argv = [self.binary, "--remote-debugging-pipe", "--user-data-dir=%s" % self.profile, "--no-first-run",
@@@ -244,6 +249,8 @@ class Browser:
if self.headless:
argv.insert(1, "--headless=new")
argv[1:1] = self.extra_args
++ if self.sandbox:
++ argv[:0] = self.sandbox # fds 3 and 4 pass through the sandbox to the browser
def fds():
# In the child: Chromium reads commands on 3 and writes on 4. Both ends move above 10 first, so placing one
@@@ -683,6 +690,13 @@ class Browser:
except OSError:
pass
self.out = self.inp = None
++ if proc is not None and self.sandbox and proc.poll() is None:
++ # The sandbox ends its desk and removes its cgroup, network and root on SIGTERM; killed, it would leave them.
++ try:
++ proc.terminate()
++ proc.wait(timeout=5)
++ except Exception:
++ pass
if proc is not None and proc.poll() is None:
try:
if hasattr(os, "killpg"):
ddiff --git a/plugins/computer/pyproject.toml b/plugins/computer/pyproject.toml
iindex 61d25ff..e90e890 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.14.0"
++version = "0.15.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_shared_desks.py b/plugins/computer/tests/test_shared_desks.py
nnew file mode 100644
iindex 0000000..9d5af4d
---- /dev/null
++++ b/plugins/computer/tests/test_shared_desks.py
@@@ -0,0 +1,212 @@
++"""A desk host for other people (desk_isolation: sandbox, Node v0.32, Desks plan D5): browser desks only, each one's
++browser under `openvibe-node desk-sandbox`, its profile and downloads inside the sandbox, and nothing of the machine
++itself offered. The last test runs a real desk through the sandbox; it needs root, a delegated cgroup and the Node
++(OPENVIBE_DESK_SANDBOX_TESTS=require with OPENVIBE_NODE_EXE, as CI's root step runs it) and is skipped otherwise."""
++import io
++import os
++import shutil
++import sys
++import tempfile
++import unittest
++from unittest import mock
++
++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 SANDBOX_DOWNLOADS, SANDBOX_PROFILE, Computer # noqa: E402
++from openvibe_computer.browser import Browser # noqa: E402
++from openvibe_plugin import Fault, Runtime # noqa: E402
++from test_computer import Out, reply # noqa: E402
++from test_desk import FakePageBrowser # noqa: E402
++
++NODE = "/usr/local/bin/openvibe-node"
++
++
++class SharedDeskHostTest(unittest.TestCase):
++ def boot(self, level="trusted", env=None, **config):
++ self.out = Out()
++ self.desk_dir = tempfile.mkdtemp(prefix="ov-desks-")
++ self.addCleanup(lambda: shutil.rmtree(self.desk_dir, ignore_errors=True))
++ self.made = []
++
++ def factory(binary, profile, headless, env=None):
++ b = FakePageBrowser(binary, profile, headless, env)
++ self.made.append(b)
++ return b
++ self.plugin = Computer(env={"OPENVIBE_NODE_EXE": NODE} if env is None else env, platform="linux",
++ which=lambda n: "/usr/bin/" + n, browser_factory=factory)
++ self.rt = Runtime(self.plugin, infile=io.StringIO(""), outfile=self.out)
++ self.rt.dispatch({"op": "hello", "config": {"level": level, "notify": False, "desk_dir": self.desk_dir,
++ "desk_isolation": "sandbox", **config}})
++ self.n = 0
++ return self.out.msgs()
++
++ def cmd(self, kind, value):
++ self.n += 1
++ cid = "s%d" % self.n
++ self.rt.dispatch({"op": "command", "id": cid, "kind": kind, "value": value, "deadline_ms": 30000})
++ return reply(self.out, cid)
++
++ def test_it_offers_browser_desks_and_nothing_of_the_machine(self):
++ msgs = self.boot(desk_memory_mb=768, desk_pids=99999)
++ caps = msgs[0]["capabilities"]
++ self.assertEqual(list(caps), ["desk"], "no files, terminal, screen, input, probe or main browser")
++ self.assertEqual((caps["desk"]["kinds"], caps["desk"]["isolation"], caps["desk"]["profiles"], caps["desk"]["egress"]),
++ (["browser"], "sandbox", False, "public"))
++ self.assertEqual(caps["desk"]["limits"], {"memory_mb": 768, "cpu_millis": 1000, "disk_mb": 512, "pids": 8192})
++ self.assertEqual(msgs[-1]["op"], "ready")
++ for kind, value in (("exec", {"argv": ["id"]}), ("fs", {"op": "list", "path": "/"}),
++ ("probe", {"target": "http://192.168.1.1/"}), ("screen", {}), ("input", {"action": "click", "x": 1, "y": 1}),
++ ("browser", {"action": "open", "url": "https://example.org/"}),
++ ("screen_stream", {"action": "start", "desk": "desk-a"})):
++ r = self.cmd(kind, value)
++ self.assertEqual((r["op"], r["fault_code"]), ("nack", "not_allowed"), (kind, r))
++ self.assertIn("pictures", self.cmd("screen_stream", {"action": "start", "desk": "desk-a"})["message"])
++ r = self.cmd("desk", {"action": "start", "desk": "desk-a"})
++ self.assertEqual((r["fault_code"], "browser desks only" in r["message"]), ("unsupported", True), r)
++ r = self.cmd("desk", {"action": "start", "desk": "desk-a", "kind": "browser", "profile": "agent"})
++ self.assertEqual(r["fault_code"], "unsupported", "no kept profiles: a desk's sign-ins end with it")
++ self.assertEqual(self.made, [])
++
++ def test_a_desk_keeps_its_profile_and_downloads_inside_its_sandbox(self):
++ self.boot()
++ r = self.cmd("desk", {"action": "start", "desk": "desk-a", "kind": "browser", "width": 1000, "height": 600})
++ self.assertEqual(r["op"], "ack", r)
++ r = self.cmd("browser", {"action": "open", "url": "https://example.org/", "desk": "desk-a"})
++ self.assertEqual(r["op"], "ack", r)
++ self.assertEqual(self.cmd("screen", {"desk": "desk-a"})["data"]["format"], "jpeg")
++ self.assertEqual(self.cmd("input", {"action": "type", "text": "hi", "desk": "desk-a"})["op"], "ack")
++ b = self.made[0]
++ self.assertEqual((b.profile, b.download_dir, b.headless), (SANDBOX_PROFILE, SANDBOX_DOWNLOADS, True))
++ self.assertEqual(os.listdir(self.desk_dir), [], "nothing of the desk's on the host's disk")
++ self.cmd("desk", {"action": "stop", "desk": "desk-a"})
++ self.assertFalse(b.alive())
++
++ def test_what_a_shared_host_must_be(self):
++ for config, why in (({"level": "ask"}, "approve"), ({"level": "full"}, "approve"), ({"desk_egress": "lan"}, "desk_egress"),
++ ({"desk_isolation": "vm"}, "desk_isolation")):
++ out = Out()
++ rt = Runtime(Computer(env={}, platform="linux"), infile=io.StringIO(""), outfile=out)
++ rt.dispatch({"op": "hello", "config": {"level": "trusted", "desk_isolation": "sandbox", **config}})
++ self.assertEqual(out.msgs()[-1]["op"], "fault", config)
++ self.assertIn(why, out.msgs()[-1]["message"])
++ out = Out()
++ Runtime(Computer(env={}, platform="darwin"), infile=io.StringIO(""), outfile=out).dispatch(
++ {"op": "hello", "config": {"level": "trusted", "desk_isolation": "sandbox"}})
++ self.assertIn("needs Linux", out.msgs()[-1]["message"])
++
++ @unittest.skipUnless(hasattr(os, "geteuid"), "no root on this system")
++ def test_root_only_on_a_shared_host(self):
++ with mock.patch("os.geteuid", return_value=0):
++ plugin = Computer(env={}, platform="linux")
++ plugin.setup({"level": "trusted", "desk_isolation": "sandbox"})
++ with self.assertRaises(Fault) as e:
++ Computer(env={}, platform="linux").setup({"level": "trusted"})
++ self.assertIn("never runs as root", e.exception.message)
++
++ @unittest.skipIf(os.name == "nt", "paths of a Linux desk host")
++ def test_the_sandbox_command(self):
++ self.boot(desk_memory_mb=1536, desk_cpu_millis=500, desk_disk_mb=256, desk_pids=300)
++ argv = self.plugin._sandbox_argv("/opt/google/chrome/google-chrome")
++ self.assertEqual(argv[:10], [NODE, "desk-sandbox", "--memory-mb", "1536", "--cpu-millis", "500", "--disk-mb", "256", "--pids", "300"])
++ self.assertEqual(argv[-1], "--")
++ pairs = list(zip(argv[10:-1:2], argv[11:-1:2]))
++ self.assertIn(("--egress", "public"), pairs)
++ self.assertIn(("--bind", "/opt/google/chrome"), pairs)
++ self.assertIn(("--setenv", "HOME=/tmp/desk"), pairs)
++ under_usr = self.plugin._sandbox_argv("/usr/lib/ov-test-browser/chrome") # (no such file: not a link elsewhere)
++ self.assertEqual([b for f, b in zip(under_usr, under_usr[1:]) if f == "--bind" and b != "/etc/fonts"], [],
++ "a browser under /usr binds nothing: /usr is the system's, bound already")
++ self.assertEqual(self.plugin._browser_args(), ["--no-sandbox", "--no-zygote", "--disable-dev-shm-usage", "--disable-gpu"],
++ "no egress proxy: 127.0.0.1 is the sandbox's own there")
++ for bad in ("/snap/bin/chromium", "/home/someone/chrome/chrome"):
++ with self.assertRaises(Fault) as e:
++ self.plugin._sandbox_argv(bad)
++ self.assertEqual(e.exception.code, "unsupported", bad)
++ self.boot(desk_egress="none")
++ self.assertNotIn("--egress", self.plugin._sandbox_argv("/usr/bin/chromium"))
++ self.boot(env={})
++ self.assertEqual(self.plugin._sandbox_argv("/usr/bin/chromium")[0], "/usr/bin/openvibe-node", "on the PATH")
++ self.plugin.which = lambda n: None
++ with self.assertRaises(Fault):
++ self.plugin._sandbox_argv("/usr/bin/chromium")
++
++ @unittest.skipIf(os.name == "nt", "a Linux desk host")
++ def test_the_browser_runs_inside_it_and_ends_it_gently(self):
++ started = []
++
++ class Proc:
++ pid, stderr = 4321, None
++
++ def __init__(self):
++ self.signals = []
++
++ def poll(self):
++ return None if not self.signals else 0
++
++ def terminate(self):
++ self.signals.append("TERM")
++
++ def wait(self, timeout=None):
++ return 0
++
++ def popen(argv, **kw):
++ started.append(argv)
++ raise OSError("not starting a browser here")
++ prefix = [NODE, "desk-sandbox", "--memory-mb", "512", "--"]
++ b = Browser("/opt/google/chrome/chrome", SANDBOX_PROFILE, True, popen=popen, extra_args=["--no-sandbox"], sandbox=prefix)
++ with self.assertRaises(OSError):
++ b.start()
++ argv = started[0]
++ self.assertEqual(argv[:5], prefix)
++ self.assertEqual(argv[5], "/opt/google/chrome/chrome")
++ self.assertIn("--user-data-dir=" + SANDBOX_PROFILE, argv)
++ self.assertIn("--no-sandbox", argv)
++ self.assertFalse(os.path.exists(SANDBOX_PROFILE), "the profile is made inside the sandbox, never on the host")
++ p = b.proc = Proc()
++ b.close()
++ self.assertEqual(p.signals, ["TERM"], "SIGTERM first: the sandbox removes its cgroup, network and root")
++
++
+[email protected](os.environ.get("OPENVIBE_DESK_SANDBOX_TESTS"), "a real sandboxed desk needs root, a delegated cgroup and the Node")
++class RealSharedDeskTest(unittest.TestCase):
++ def test_a_real_desk_in_the_sandbox(self):
++ from openvibe_computer.browser import find_binary
++ # Google Chrome's .deb, as a desk host installs it (a runner's chromium-browser may be a snap's launcher).
++ binary = "/opt/google/chrome/chrome" if os.path.exists("/opt/google/chrome/chrome") else find_binary()
++ self.assertTrue(binary and os.environ.get("OPENVIBE_NODE_EXE"), "CI's root step has Chrome and builds the Node")
++ out = Out()
++ plugin = Computer(platform="linux")
++ rt = Runtime(plugin, infile=io.StringIO(""), outfile=out)
++ rt.dispatch({"op": "hello", "config": {"level": "trusted", "notify": False, "desk_isolation": "sandbox", "desk_egress": "none",
++ "browser_path": binary, "desk_dir": tempfile.mkdtemp(prefix="ov-desks-")}})
++ self.assertEqual(out.msgs()[-1]["op"], "ready", out.msgs())
++ n = [0]
++
++ def cmd(kind, value):
++ n[0] += 1
++ cid = "r%d" % n[0]
++ rt.dispatch({"op": "command", "id": cid, "kind": kind, "value": value, "deadline_ms": 60000})
++ return out.wait_for(lambda m: m.get("id") == cid and m.get("op") in ("ack", "nack"), timeout=60)
++ self.assertEqual(cmd("desk", {"action": "start", "desk": "desk-a", "kind": "browser", "width": 800, "height": 600})["op"], "ack")
++ try:
++ shot = cmd("screen", {"desk": "desk-a", "max_width": 400})
++ b = plugin.desk_browsers.get("desk-a")
++ self.assertEqual(shot["op"], "ack", "%r\nthe sandbox said: %s" % (shot, "\n".join(b.stderr_tail) if b else "(no browser)"))
++ self.assertEqual(shot["data"]["format"], "jpeg")
++ self.assertEqual(b.sandbox[1], "desk-sandbox")
++ page = cmd("browser", {"action": "read", "desk": "desk-a"})
++ self.assertEqual(page["op"], "ack", page)
++ proc = b.proc
++ finally:
++ cmd("desk", {"action": "stop", "desk": "desk-a"})
++ self.assertIsNotNone(proc.poll(), "the desk's sandbox ended with it")
++ self.assertEqual([d for d in os.listdir(tempfile.gettempdir()) if d.startswith("openvibe-desk-")], [],
++ "the sandbox removed its root (SIGTERM, not killed)")
++ plugin.stop()
++
++
++if __name__ == "__main__":
++ unittest.main()
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 2c1aecc..d52125b 100644
---- a/plugins/sdk/openvibe_plugin/__init__.py
++++ b/plugins/sdk/openvibe_plugin/__init__.py
@@@ -24,7 +24,7 @@ __all__ = ["Plugin", "Fault", "Deferred", "Command", "StopInfo", "Runtime", "run
"RUNTIME_CAUSES", "SHARED_CLOCK", "SHARED_CLOCK_NAME", "TIMING_UNAVAILABLE", "__version__"]
# The Node release this runtime ships with (pyproject.toml carries the same).
--__version__ = "0.31.0"
++__version__ = "0.32.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 78a04cd..8c05b54 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.31.0"
++version = "0.32.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 8991873..17d83e8 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.31.0")
++ self.assertEqual(openvibe_plugin.__version__, "0.32.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())
ddiff --git a/scripts/test-install-computer-config.sh b/scripts/test-install-computer-config.sh
iindex 3cdb529..5be735f 100644
---- a/scripts/test-install-computer-config.sh
++++ b/scripts/test-install-computer-config.sh
@@@ -41,6 +41,36 @@ import json, sys
c = json.load(open(sys.argv[1]))
assert [p['config']['level'] for p in c['plugins']] == ['ask'], c['plugins']
PY
++# A shared desk host (--desk-host --shared, Desks plan D5): every desk sandboxed, at trusted, nothing of the machine
++# offered (no programs, probes or live view), the worker on with the job user (its sandbox is the desks'), and an
++# operator's own sizes kept on a second run; it refuses a level that waits for someone at the machine.
++shared="$tmp/shared.json"
++printf '{"server": "https://openvibe.bot", "plugins": [{"name": "dryrun"}]}\n' > "$shared"
++OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY="$shared" OPENVIBE_COMPUTER_HOME=/var/lib/openvibe-desk OPENVIBE_JOB_UID=990 OPENVIBE_JOB_GID=991 \
++ sh "$installer" --desk-host --shared
++python3 - "$shared" <<'PY' || fail "shared desk host config"
++import json, sys
++c = json.load(open(sys.argv[1]))
++[p] = c['plugins']
++cfg = p['config']
++assert (p['name'], cfg['level'], cfg['desk_isolation'], cfg['desk_egress']) == ('computer', 'trusted', 'sandbox', 'public'), cfg
++assert (cfg['allow_exec'], cfg['probe'], cfg['allow_screen_stream'], cfg['notify']) == (False, False, False, False), cfg
++assert (cfg['desk_memory_mb'], cfg['desk_cpu_millis'], cfg['desk_disk_mb'], cfg['desk_pids']) == (1024, 1000, 512, 512), cfg
++w = c['worker']
++assert (w['enabled'], w['run_as'], w['egress'], w['functions']) == (True, {'uid': 990, 'gid': 991}, 'public', []), w
++assert c['video'] == {'source': 'off'}
++cfg['desk_memory_mb'] = 768
++json.dump(c, open(sys.argv[1], 'w'))
++PY
++OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY="$shared" OPENVIBE_COMPUTER_HOME=/var/lib/openvibe-desk sh "$installer" --desk-host --shared
++python3 -c 'import json, sys; c = json.load(open(sys.argv[1])); assert c["plugins"][0]["config"]["desk_memory_mb"] == 768, c' "$shared" ||
++ fail "an operator's desk size was not kept"
++if OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY="$shared" OPENVIBE_COMPUTER_HOME=/x sh "$installer" --desk-host --shared --computer ask 2>/dev/null; then
++ fail "a shared desk host at level ask was accepted"
++fi
++if OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY="$shared" OPENVIBE_COMPUTER_HOME=/x sh "$installer" --shared 2>/dev/null; then
++ fail "--shared without --desk-host was accepted"
++fi
# --ai (AI on your own machines): the ai plugin, in place of the dry-run plugin and of an earlier ai entry, the rest kept;
# --ai-endpoint gives the server's base, and a base that is not http(s) is refused. A driver rewrite keeps it.
ai="$tmp/ai.json"