openvibers/openvibe.link Commits

worker's plan logins: opt-in, read-only, for one job (#83)

OP OpenVibers committed 14 days ago · 684988cdc9 · 15 files, +415 −14

* worker: the owner's plan logins, opt-in, read-only, for one job

A coding CLI signs in with a file in the owner's home (~/.claude/.credentials.json, ~/.codex/auth.json,
~/.local/share/opencode/auth.json and the like). A job that runs on the owner's machine wants the plan the owner
signed in for, but a job is code from a task: it may not read the owner's files, and the Node may not send a
credential anywhere.

So worker.plan_logins (off by default) names the files, and the Node copies each one into that one job's private
home, read-only (mode 0400), at the same path it has under the owner's home. The copy is made in the job's own
directory and goes with it; its content is never logged, never uploaded and never part of a result.

The installer moves to the OpenVibe.Codes v0.5.0 job runner, which runs every harness the private portal runs.

* worker: gofmt

* worker: the plan logins build on every platform

The copy into the job's home opens the staged file relative to the job's root, without following a symlink out of
it: syscall.Openat is Linux's, so that part belongs with the Linux sandbox. The portable half (staging the owner's
files, the copy that writes them read-only) stays where every platform can build it.

* worker: the plan-login tests set the home the way each platform reads it

os.UserHomeDir reads HOME on Linux and macOS and USERPROFILE on Windows, so a test that set only HOME left the
Windows runner pointing at its own profile and every login outside it.

* worker: the plan-login test compares the name the way the platform writes it

A path under the home is written with the platform's separator, so the test joins it rather than spelling it out.

* worker: the read-only check holds where the mode is what keeps the job out

Root may write a read-only file, so on a host that runs the tests as root the write is not the check: the mode is.

* 0.33.0: the owner's plan logins, opt-in and read-only, and the OpenVibe.Codes v0.5.0 job runner

The worker copies the owner's coding-CLI sign-in files (worker.plan_logins, off by default) read-only into a job's
private home, so a job may use the plan the owner signed in for without a job ever reading the owner's files and
without the Node ever sending a credential anywhere.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 4818680..fdc0cef 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.32.2",
-- "release": "v0.32.2, published 2026-10-28 as a GitHub release (the installer adds the OpenVibe.Codes v0.4.0 job runner: each step's input, live output and result reach the task page on openvibe.codes) 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.33.0",
++ "release": "v0.33.0: the worker copies the owner's plan logins (the coding CLIs' own sign-in files) read-only into a job's private home, opt-in (worker.plan_logins, off by default), so a job may use the plan the owner signed in for without a job ever reading the owner's files and without the Node ever sending a credential anywhere; the installer adds the OpenVibe.Codes v0.5.0 job runner, which runs every harness the private portal runs. v0.32.2, published 2026-10-28 as a GitHub release (the installer adds the OpenVibe.Codes v0.4.0 job runner: each step's input, live output and result reach the task page on openvibe.codes) stops by itself, so a desk an agent left behind when it crashed or restarted ends; v0.21.0: element refs and receipts, the Desks plan's step D3: the agent's browser lists a page's interactive elements as compact refs (e7 button \"Sign in\"), kept on the element while it stays, and click, press and back answer with what changed (url, lines that appeared, lines gone), so agents act on the page's structure before screenshots; v0.20.0: desk hosts, the Desks plan's step D5: install.sh --desk-host turns an old laptop, a mini PC or a spare workstation into a host of desks for its owner's agents, run as its own openvibe-desk account with Xvfb, openbox, D-Bus, xdotool, ImageMagick and Chromium installed and max_desks sized to its memory; v0.19.0: several desks per machine, the Desks plan's step D1: the computer plugin runs named desks (desk-<name>, private kept), each with its own X display, D-Bus, browser profile, downloads and temp folder, up to max_desks (one per 2 GiB of memory by default), so two agents work at once without seeing or driving each other's screen; proven with two real Chrome desks that never share a cookie; v0.18.0: a motion command's validity also counts the time it waits in the queue to its plugin and in the pipe: the core fixes the instant it expires at receipt, writes what is left at the moment it writes the line with expires_at_mono_ns on the clock it shares with the plugin, and never writes one that has run out, and the Python runtime refuses one that ran out in the pipe before handle(); the clock bound is kept in nanoseconds and counts Bot's whole-millisecond stamps, rounded up from nanoseconds; a Node booting latched sets the e-stop on its plugins before starting them; the Python SDK package reports the Node release; v0.17.0: deadlines are checked against the latest the server's clock can be: the estimate plus its bound, the connection's fastest round trip, and motion waits for that bound on every new connection; every stop and estop names its cause, hello carries the newest halt epoch, and the Python SDK gives drivers on_stop(StopInfo) and on_resume to tell an ordinary stop from an emergency without re-arming anything; v0.16.1: the Python runtime refuses a motion command whose deadline passed while it waited behind a slow handler, so a driver never moves on a stale command; plugins.md answers where deadlines come from, re-stamping, order across a change of driver, stop versus estop, and that nothing is held past its validity; v0.16.0: openvibe-node quit ends a running node cleanly with nothing latched, for launchers; each driver command carries its provenance (link session, seq, Bot send time and deadline) and a halt's stop its turn_epoch, and the Python runtime refuses a replay on a session and a command from an ended turn; the no-redelivery contract written down in plugins.md): binaries for linux amd64/arm64/armv7, darwin amd64/arm64 and windows amd64, the plugins tarball, install.sh and SHA256SUMS",
"runtime": "go 1.24 (one static binary per platform); plugins in Python 3; an ESP32 Arduino library",
"database": "none",
"deployed": false,
ddiff --git a/docs/worker.md b/docs/worker.md
iindex 7940e51..c3a4b31 100644
---- a/docs/worker.md
++++ b/docs/worker.md
@@@ -46,6 +46,7 @@ summary, the changed files and the patch; it never pushes to the branch it start
"allow_same_user": false,
"egress": "none",
"dns": [],
++ "plan_logins": [],
"media": {
"enabled": false,
"endpoint": "",
@@@ -315,6 +316,24 @@ 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.
++### Plan logins (`worker.plan_logins`)
++
++A coding CLI signs in with a file in the owner's home: `~/.claude/.credentials.json`, `~/.codex/auth.json`,
++`~/.local/share/opencode/auth.json` and the like. A job that runs on the owner's machine wants the plan the owner
++signed in for, but a job is code from a task, so it may not read the owner's files, and the Node may not send a
++credential anywhere.
++
++So the owner names the files (`worker.plan_logins`, **off by default**: a job sees no credential of any kind until they
++do), and the Node copies each one into that one job's private home, **read-only** (mode `0400`: the job may read it,
++never change it), at the same path it has under the owner's home. The copy goes with the job: it is made in the job's
++own directory and disappears when the job ends. Its content is never logged, never uploaded and never part of a result.
++
++- **What may be named.** An absolute path under the Node user's home, and a plain file. A path outside the home, a
++ directory or a symlink is refused when the config loads.
++- **A file that is not there is skipped**, not an error: the CLI it belongs to is simply not signed in. A file that is
++ there but cannot be read, or is not a plain file, ends the job: a half-copied credential is worse than none.
++- **What the job sees.** `openvibe-codes logins` (the Codes job runner) reports each CLI as installed, signed in, and
++ with the limit windows its login file carries, and nothing else: no key, no token, no account name.
### 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
ddiff --git a/install/install.sh b/install/install.sh
iindex c33ab9e..a665f2b 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -97,9 +97,10 @@ AI_ENDPOINT=""
CLAIM_OK=1
DRIVER_GIVEN=0
SETUP="${OPENVIBE_SETUP_KEY:-}"
--# The OpenVibe.Codes release whose `openvibe-codes job` the worker runs (protocol [email protected]; v0.4.0 prints job
--# lines 1.1: each step's input, output while it runs and result, so the task page on openvibe.codes shows the session).
--CODES_VERSION="${OPENVIBE_CODES_VERSION:-v0.4.0}"
++# The OpenVibe.Codes release whose `openvibe-codes job` the worker runs (protocol [email protected]; v0.5.0 prints job
++# lines 1.1: each step's input, output while it runs and result, so the task page on openvibe.codes shows the session;
++# and it runs every harness the private portal runs, with the argv and the environment the portal learned).
++CODES_VERSION="${OPENVIBE_CODES_VERSION:-v0.5.0}"
CODES_DIR=/opt/openvibe-codes
usage() {
ddiff --git a/internal/config/config.go b/internal/config/config.go
iindex 71a6d4b..539b4a9 100644
---- a/internal/config/config.go
++++ b/internal/config/config.go
@@@ -151,6 +151,12 @@ type WorkerConfig struct {
// Media is where a job's inputs come from and a result over 256 KiB is uploaded to. Off (the default), a job
// naming inputs is refused and a result over 256 KiB ends the job limit.
Media MediaConfig `json:"media,omitzero"`
++ // PlanLogins are the owner's own coding-CLI login files (for example ~/.claude/.credentials.json,
++ // ~/.codex/auth.json, ~/.local/share/opencode/auth.json), copied read-only into a job's private home so a job may
++ // use the plan the owner signed in for. Off (the default): a job sees no credential of any kind until the owner
++ // names them here. Each is a path under the Node user's home; the copies live in that one job's home and go with
++ // it, and their content is never sent anywhere.
++ PlanLogins []string `json:"plan_logins,omitempty"`
// NodeDirs are the Node's own directories (config, credential, state, control socket), which no job may see; the
// Node sets them, the config file does not.
NodeDirs []string `json:"-"`
@@@ -482,6 +488,9 @@ func (w WorkerConfig) validate() error {
default:
return fmt.Errorf("config: worker.egress %q is not none, public or openvibe-only", w.Egress)
}
++ if err := w.validatePlanLogins(); err != nil {
++ return err
++ }
for key, cidrs := range map[string][]string{"egress_allow": w.EgressAllow, "egress_deny": w.EgressDeny} {
for _, c := range cidrs {
if p, err := netip.ParsePrefix(c); err != nil || !p.Addr().Is4() || p != p.Masked() {
@@@ -537,6 +546,32 @@ func (c *Config) PythonPath(paths Paths) string {
// validateDNS checks worker.dns: at most three IPv4 resolvers (the most a resolv.conf is read for), only with a policy
// that gives jobs a network, each public, and under openvibe-only inside egress_allow.
++// validatePlanLogins refuses a plan login that is not a plain path under the Node user's home: a job's copy is made
++// from it, so it must be a file the owner meant to share with their own jobs and nothing else.
++func (w WorkerConfig) validatePlanLogins() error {
++ home, err := os.UserHomeDir()
++ if err != nil {
++ if len(w.PlanLogins) > 0 {
++ return fmt.Errorf("config: worker.plan_logins: the Node user's home is unknown: %w", err)
++ }
++ return nil
++ }
++ for _, p := range w.PlanLogins {
++ clean := filepath.Clean(p)
++ if !filepath.IsAbs(clean) {
++ return fmt.Errorf("config: worker.plan_logins %q must be an absolute path", p)
++ }
++ rel, err := filepath.Rel(home, clean)
++ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
++ return fmt.Errorf("config: worker.plan_logins %q must be under the Node user's home (%s)", p, home)
++ }
++ if rel == "." {
++ return fmt.Errorf("config: worker.plan_logins %q must be a file, not the home itself", p)
++ }
++ }
++ return nil
++}
++
func (w WorkerConfig) validateDNS() error {
if len(w.DNS) == 0 {
return nil
ddiff --git a/internal/config/planlogins_test.go b/internal/config/planlogins_test.go
nnew file mode 100644
iindex 0000000..185d971
---- /dev/null
++++ b/internal/config/planlogins_test.go
@@@ -0,0 +1,37 @@
++package config
++
++import (
++ "os"
++ "path/filepath"
++ "testing"
++)
++
++// TestWorkerPlanLogins: worker.plan_logins is off unless the owner names the files, and each named file must be a
++// path under the Node user's home: the copy a job gets is made from it.
++func TestWorkerPlanLogins(t *testing.T) {
++ home, err := os.UserHomeDir()
++ if err != nil {
++ t.Skip("no home directory")
++ }
++ t.Setenv("HOME", home)
++ t.Setenv("USERPROFILE", home)
++ ok := []WorkerConfig{{}, {PlanLogins: []string{filepath.Join(home, ".claude", ".credentials.json")}},
++ {PlanLogins: []string{filepath.Join(home, ".codex", "auth.json"), filepath.Join(home, ".local", "share", "opencode", "auth.json")}}}
++ for _, w := range ok {
++ c := Default()
++ c.Worker = w
++ if err := c.Validate(); err != nil {
++ t.Fatalf("%+v: %v", w, err)
++ }
++ }
++ bad := []WorkerConfig{{PlanLogins: []string{"relative/path.json"}},
++ {PlanLogins: []string{filepath.Join(home, "..", "elsewhere", "auth.json")}},
++ {PlanLogins: []string{home}}}
++ for _, w := range bad {
++ c := Default()
++ c.Worker = w
++ if err := c.Validate(); err == nil {
++ t.Fatalf("%+v was accepted", w)
++ }
++ }
++}
ddiff --git a/internal/worker/planlogins.go b/internal/worker/planlogins.go
nnew file mode 100644
iindex 0000000..946ab20
---- /dev/null
++++ b/internal/worker/planlogins.go
@@@ -0,0 +1,95 @@
++package worker
++
++import (
++ "fmt"
++ "io"
++ "os"
++ "path/filepath"
++ "strings"
++)
++
++// The plan logins: the owner's own coding-CLI sign-in files, copied read-only into a job's private home.
++//
++// A CLI signs in with a file in the owner's home (~/.claude/.credentials.json, ~/.codex/auth.json,
++// ~/.local/share/opencode/auth.json and the like). A job that runs on the owner's machine wants the plan the owner
++// signed in for, but a job is code from a task: it must not be able to read the owner's files, and the Node must not
++// send a credential anywhere. So the owner names the files (worker.plan_logins, off by default), the Node copies each
++// one into that one job's home, read-only, and the copy goes with the job.
++//
++// Nothing about the content leaves the machine: the copy is made on the Node, it is never logged, never uploaded and
++// never part of a result.
++
++// planLoginName is the path of a plan login under the job's home: the path it has under the owner's home.
++func planLoginName(home, path string) (string, error) {
++ rel, err := filepath.Rel(home, filepath.Clean(path))
++ if err != nil {
++ return "", err
++ }
++ if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
++ return "", fmt.Errorf("plan login %q is not under the home", path)
++ }
++ return rel, nil
++}
++
++// stagePlanLogins copies each named plan login into root, and returns the names (the paths under the job's home).
++//
++// A file that is not there is skipped: the CLI it belongs to is simply not signed in, which is not an error. A file
++// that is there but cannot be read, or is not a plain file, fails the job: a half-copied credential is worse than none.
++func stagePlanLogins(logins []string, root string) ([]string, error) {
++ if len(logins) == 0 {
++ return nil, nil
++ }
++ home, err := os.UserHomeDir()
++ if err != nil {
++ return nil, fmt.Errorf("the Node user's home is unknown: %w", err)
++ }
++ names := make([]string, 0, len(logins))
++ for _, p := range logins {
++ name, err := planLoginName(home, p)
++ if err != nil {
++ return nil, fmt.Errorf("plan login %q: %w", p, err)
++ }
++ st, err := os.Lstat(p)
++ if err != nil {
++ if os.IsNotExist(err) {
++ continue // not signed in for this CLI
++ }
++ return nil, fmt.Errorf("plan login %q: %w", p, err)
++ }
++ if !st.Mode().IsRegular() {
++ return nil, fmt.Errorf("plan login %q is not a plain file", p)
++ }
++ src, err := os.Open(p)
++ if err != nil {
++ return nil, fmt.Errorf("plan login %q: %w", p, err)
++ }
++ dst := filepath.Join(root, name)
++ if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
++ src.Close()
++ return nil, fmt.Errorf("plan login %q: %w", p, err)
++ }
++ err = copyFile(dst, src)
++ src.Close()
++ if err != nil {
++ return nil, fmt.Errorf("plan login %q: %w", p, err)
++ }
++ names = append(names, name)
++ }
++ return names, nil
++}
++
++// copyFile writes src into dst, read-only for its owner: a job may read the credential, never change it.
++func copyFile(dst string, src *os.File) error {
++ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o400)
++ if err != nil {
++ return err
++ }
++ if _, err := io.Copy(out, src); err != nil {
++ out.Close()
++ return err
++ }
++ if err := out.Close(); err != nil {
++ return err
++ }
++ return os.Chmod(dst, 0o400)
++}
ddiff --git a/internal/worker/planlogins_linux.go b/internal/worker/planlogins_linux.go
nnew file mode 100644
iindex 0000000..8dc4247
---- /dev/null
++++ b/internal/worker/planlogins_linux.go
@@@ -0,0 +1,45 @@
++//go:build linux
++
++package worker
++
++import (
++ "fmt"
++ "os"
++ "path/filepath"
++ "strings"
++ "syscall"
++)
++
++// copyPlanLogins copies the staged plan logins from the job's root into its working directory (its home), read-only.
++//
++// The names are paths under the home: they may hold a directory (~/.claude/.credentials.json), so they are not the flat
++// names the job's inputs use. A name that would leave the home is refused, and the file is opened without following a
++// symlink out of the root.
++func copyPlanLogins(from *os.File, work string, names []string) error {
++ for _, name := range names {
++ clean := filepath.Clean(name)
++ if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
++ return fmt.Errorf("plan login name %q", name)
++ }
++ fd, err := syscall.Openat(int(from.Fd()), clean, syscall.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
++ if err != nil {
++ return fmt.Errorf("%s: %w", clean, err)
++ }
++ src := os.NewFile(uintptr(fd), clean)
++ err = func() error {
++ defer src.Close()
++ if st, err := src.Stat(); err != nil || !st.Mode().IsRegular() {
++ return fmt.Errorf("%s is not a plain file (%v)", clean, err)
++ }
++ dst := filepath.Join(work, clean)
++ if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
++ return err
++ }
++ return copyFile(dst, src)
++ }()
++ if err != nil {
++ return err
++ }
++ }
++ return nil
++}
ddiff --git a/internal/worker/planlogins_linux_test.go b/internal/worker/planlogins_linux_test.go
nnew file mode 100644
iindex 0000000..800b645
---- /dev/null
++++ b/internal/worker/planlogins_linux_test.go
@@@ -0,0 +1,59 @@
++//go:build linux
++
++package worker
++
++import (
++ "os"
++ "path/filepath"
++ "testing"
++)
++
++// The plan logins as the job sees them: copied into the job's home, read-only, and never able to leave it.
++
++func TestPlanLoginsAreCopiedIntoTheJobHome(t *testing.T) {
++ home := t.TempDir()
++ setHome(t, home)
++ src := filepath.Join(home, ".codex", "auth.json")
++ if err := os.MkdirAll(filepath.Dir(src), 0o700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(src, []byte(`{"tokens":{}}`), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ root := t.TempDir()
++ names, err := stagePlanLogins([]string{src}, root)
++ if err != nil {
++ t.Fatalf("staging: %v", err)
++ }
++ work := t.TempDir()
++ dir, err := os.Open(root)
++ if err != nil {
++ t.Fatal(err)
++ }
++ defer dir.Close()
++ if err := copyPlanLogins(dir, work, names); err != nil {
++ t.Fatalf("copying: %v", err)
++ }
++ got := filepath.Join(work, ".codex", "auth.json")
++ b, err := os.ReadFile(got)
++ if err != nil {
++ t.Fatalf("reading the copy: %v", err)
++ }
++ if string(b) != `{"tokens":{}}` {
++ t.Fatalf("the copy is not the file: %q", b)
++ }
++ st, err := os.Stat(got)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if st.Mode().Perm()&0o222 != 0 {
++ t.Fatalf("the copy is writable (%v)", st.Mode().Perm())
++ }
++}
++
++func TestPlanLoginNameMayNotLeaveTheHome(t *testing.T) {
++ dir := t.TempDir()
++ if err := copyPlanLogins(nil, dir, []string{"../escape"}); err == nil {
++ t.Fatal("a name that leaves the home was copied")
++ }
++}
ddiff --git a/internal/worker/planlogins_test.go b/internal/worker/planlogins_test.go
nnew file mode 100644
iindex 0000000..1befd58
---- /dev/null
++++ b/internal/worker/planlogins_test.go
@@@ -0,0 +1,96 @@
++package worker
++
++import (
++ "os"
++ "path/filepath"
++ "testing"
++)
++
++// The plan logins: the copy, the read-only mode, the cleanup and the opt-in.
++
++// setHome points the Node user's home at dir: os.UserHomeDir reads HOME on Linux and macOS, USERPROFILE on Windows.
++func setHome(t *testing.T, dir string) {
++ t.Setenv("HOME", dir)
++ t.Setenv("USERPROFILE", dir)
++}
++
++func TestPlanLoginsAreOptIn(t *testing.T) {
++ // Off by default: nothing is staged, so a job sees no credential of any kind.
++ names, err := stagePlanLogins(nil, t.TempDir())
++ if err != nil {
++ t.Fatalf("staging nothing: %v", err)
++ }
++ if len(names) != 0 {
++ t.Fatalf("staged %v with no plan logins named", names)
++ }
++}
++
++func TestPlanLoginsAreCopiedReadOnly(t *testing.T) {
++ home := t.TempDir()
++ setHome(t, home)
++ src := filepath.Join(home, ".claude", ".credentials.json")
++ if err := os.MkdirAll(filepath.Dir(src), 0o700); err != nil {
++ t.Fatal(err)
++ }
++ if err := os.WriteFile(src, []byte(`{"access_token":"a-token"}`), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ root := t.TempDir()
++ names, err := stagePlanLogins([]string{src}, root)
++ if err != nil {
++ t.Fatalf("staging: %v", err)
++ }
++ // The name is the path under the home, so it is written the way this platform writes paths.
++ if len(names) != 1 || names[0] != filepath.Join(".claude", ".credentials.json") {
++ t.Fatalf("staged %v", names)
++ }
++ staged := filepath.Join(root, names[0])
++ b, err := os.ReadFile(staged)
++ if err != nil {
++ t.Fatalf("reading the staged copy: %v", err)
++ }
++ if string(b) != `{"access_token":"a-token"}` {
++ t.Fatalf("the copy is not the file: %q", b)
++ }
++ // Read-only: the job may read it, never change it.
++ st, err := os.Stat(staged)
++ if err != nil {
++ t.Fatal(err)
++ }
++ if st.Mode().Perm()&0o222 != 0 {
++ t.Fatalf("the staged copy is writable (%v)", st.Mode().Perm())
++ }
++ // Root may write a read-only file, so the write is only a check where the mode is what keeps the job out.
++ if os.Geteuid() != 0 {
++ if err := os.WriteFile(staged, []byte("x"), 0o600); err == nil {
++ t.Fatal("the staged copy could be written")
++ }
++ }
++}
++
++func TestPlanLoginMissingIsSkipped(t *testing.T) {
++ home := t.TempDir()
++ setHome(t, home)
++ root := t.TempDir()
++ // A CLI that is not signed in has no file: that is not an error, the job simply is not signed in.
++ names, err := stagePlanLogins([]string{filepath.Join(home, ".codex", "auth.json")}, root)
++ if err != nil {
++ t.Fatalf("staging: %v", err)
++ }
++ if len(names) != 0 {
++ t.Fatalf("staged %v for a missing file", names)
++ }
++}
++
++func TestPlanLoginOutsideHomeIsRefused(t *testing.T) {
++ home := t.TempDir()
++ setHome(t, home)
++ root := t.TempDir()
++ outside := filepath.Join(t.TempDir(), "auth.json")
++ if err := os.WriteFile(outside, []byte("{}"), 0o600); err != nil {
++ t.Fatal(err)
++ }
++ if _, err := stagePlanLogins([]string{outside}, root); err == nil {
++ t.Fatal("a login outside the home was staged")
++ }
++}
ddiff --git a/internal/worker/sandbox_linux.go b/internal/worker/sandbox_linux.go
iindex a4f279f..6fed28b 100644
---- a/internal/worker/sandbox_linux.go
++++ b/internal/worker/sandbox_linux.go
@@@ -44,8 +44,10 @@ type sandboxSpec struct {
Work string `json:"work"` // the working directory, inside the private root
Disk int64 `json:"disk"` // the size of the job's /tmp, and the most its artifact's copy may take
// Inputs are the files the Node fetched into Root, copied into Work before the private root covers them.
-- Inputs []string `json:"inputs,omitempty"`
-- Binds []bindSpec `json:"binds"`
++ Inputs []string `json:"inputs,omitempty"`
++ // PlanLogins are the owner's plan logins staged in Root (paths under the home), copied read-only into Work.
++ PlanLogins []string `json:"plan_logins,omitempty"`
++ Binds []bindSpec `json:"binds"`
// Artifact is where the copy of the artifact directory open on artifactFD goes (its host path); "" for none.
Artifact string `json:"artifact,omitempty"`
Links []linkSpec `json:"links"`
@@@ -133,7 +135,7 @@ func enterSandbox() error {
r := s.Root
// Root itself, before the private root is mounted on it: the job's inputs lie there.
var inputs *os.File
-- if len(s.Inputs) > 0 {
++ if len(s.Inputs) > 0 || len(s.PlanLogins) > 0 {
if inputs, err = os.Open(r); err != nil {
return step("opening the job's inputs", err)
}
@@@ -159,6 +161,9 @@ func enterSandbox() error {
if err := step("copying the job's inputs", copyInputs(inputs, r+s.Work, s.Inputs)); err != nil {
return err
}
++ if err := step("copying the plan logins", copyPlanLogins(inputs, r+s.Work, s.PlanLogins)); err != nil {
++ return err
++ }
for _, b := range s.Binds {
if err := step("bind-mounting "+b.Path, bind(r, b)); err != nil {
return err
ddiff --git a/internal/worker/worker.go b/internal/worker/worker.go
iindex 4e65abb..9e5a41e 100644
---- a/internal/worker/worker.go
++++ b/internal/worker/worker.go
@@@ -69,6 +69,9 @@ type plan struct {
// inputs are the names of the files fetched into root, which the sandbox copies into the job's working directory
// before covering root with the private root.
inputs []string
++ // planLogins are the names (paths under the home) of the owner's plan logins staged in root, which the sandbox
++ // copies read-only into the job's working directory. Empty unless the owner named them (worker.plan_logins).
++ planLogins []string
}
// egressFor is the network policy a job runs under: the stricter of the net the job names and the host's worker.egress,
@@@ -426,8 +429,14 @@ func (w *Worker) execute(jb *job) protocol.JobExit {
}
return never(protocol.ExitFailed, err)
}
++ // The owner's plan logins, staged for this job only: the copy goes with the job's directory and its content
++ // never leaves the machine.
++ planLogins, err := stagePlanLogins(w.cfg.PlanLogins, dir)
++ if err != nil {
++ return never(protocol.ExitFailed, err)
++ }
if r := w.cfg.RunAs; r != nil {
-- for _, p := range append([]string{"."}, inputs...) { // the job's user reads its inputs to copy them
++ for _, p := range append(append([]string{"."}, inputs...), planLogins...) { // the job's user reads its inputs to copy them
if err := os.Chown(filepath.Join(dir, p), int(r.UID), int(r.GID)); err != nil {
return never(protocol.ExitFailed, err)
}
@@@ -452,7 +461,7 @@ func (w *Worker) execute(jb *job) protocol.JobExit {
cmd.Stdin = bytes.NewReader(append(append([]byte(nil), jb.req.Args...), '\n'))
cmd.Stdout, cmd.ExtraFiles = outW, []*os.File{resW}
egress, _ := egressFor(w.cfg.Egress, jb.req.Net) // Admit refused what the host cannot enforce
-- sb, err := w.isolate(cmd, w.cfg, plan{root: dir, fn: jb.fn, limits: jb.limits, egress: egress, inputs: inputs})
++ sb, err := w.isolate(cmd, w.cfg, plan{root: dir, fn: jb.fn, limits: jb.limits, egress: egress, inputs: inputs, planLogins: planLogins})
if err != nil {
outW.Close()
resW.Close()
ddiff --git a/internal/worker/worker_linux.go b/internal/worker/worker_linux.go
iindex a8515a6..e71c7ab 100644
---- a/internal/worker/worker_linux.go
++++ b/internal/worker/worker_linux.go
@@@ -73,7 +73,7 @@ func isolate(cmd *exec.Cmd, cfg config.WorkerConfig, p plan) (*sandbox, error) {
sb.goW, sb.child = gw, append(sb.child, gr)
files = append(files, sw, gr) // statusFD, goFD
spec := sandboxSpec{Root: p.root, Work: cmd.Dir, Disk: caps.MaxDiskBytes, Links: links, Inputs: p.inputs,
-- Argv: append([]string{cmd.Path}, cmd.Args[1:]...), Env: cmd.Env}
++ PlanLogins: p.planLogins, Argv: append([]string{cmd.Path}, cmd.Args[1:]...), Env: cmd.Env}
if art != nil {
spec.Artifact = p.fn.Artifact()
}
ddiff --git a/plugins/sdk/openvibe_plugin/__init__.py b/plugins/sdk/openvibe_plugin/__init__.py
iindex 77cc33c..7beae54 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.32.2"
++__version__ = "0.33.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 3c0eb5c..32881a0 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.32.2"
++version = "0.33.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 1e94300..4f75528 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.32.2")
++ self.assertEqual(openvibe_plugin.__version__, "0.33.0")
with open(os.path.join(os.path.dirname(HERE), "pyproject.toml")) as f:
self.assertIn('version = "%s"' % openvibe_plugin.__version__, f.read())