openvibers/openvibe.link Commits
install: --plan-logins names the owner's coding-tool logins (#118)
Changes
@@@ -164,11 +164,12 @@ jobs:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4-- - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-claim.sh++ - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-plan-logins.sh scripts/test-install-claim.sh- run: sh scripts/test-install-stock-server.sh- run: sh scripts/test-install-pair-args.sh- run: sh scripts/test-install-worker-config.sh- run: sh scripts/test-install-computer-config.sh++ - run: sh scripts/test-install-plan-logins.sh- run: sh scripts/test-install-claim.shesp32-conformance:@@@ -79,7 +79,10 @@ this computer's models" on openvibe.bot/machines ([docs/ai.md](docs/ai.md)). The**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: thejob worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding--task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).++task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)). Add++`--plan-logins` to use the coding plans you already signed in for here: it names those login files for your jobs, sets++the service's HOME to your home, and says how to sign in a tool that is not ready, without ever reading or printing a++login's content.**What the machine does for the network:** the owner sets the machine's roles on openvibe.network/machines(`codes.worker`, `desks.host`, `ai.host`, `bot.robot`). The Node reads them at its check-in, keeps them, and applies@@@ -79,7 +79,12 @@ llama.cpp's server on the machine, and the installer says which one answers. `--**A machine that runs your jobs, not a robot:** openvibe.bot/machines prints the Network form with `--worker` (and no`--driver`). It turns the job worker on with a dedicated `openvibe-job` user and the OpenVibe.Codes job runner, so a--coding task you start on openvibe.codes/run runs on this machine. [worker.md](worker.md#a-machine-that-runs-your-coding-tasks---worker)++coding task you start on openvibe.codes/run runs on this machine. Add `--plan-logins` (Linux) to use the plans you++already signed in for: the installer names the coding tools' own login files that are ready (Claude Code, Codex,++OpenCode and Command Code) as the worker's plan logins, sets the Node service's HOME to your home, and prints the login++command for each tool whose login is missing or not signed in (a login whose CLI is not on the runner's PATH is named++with a line to install it system-wide). It never reads, copies or prints a login file's content.++[worker.md](worker.md#a-machine-that-runs-your-coding-tasks---worker)has the details.**Two pairing paths.** Which one the command uses is up to openvibe.bot; the installer takes both.@@@ -21,6 +21,11 @@ one-time code: `curl -fsSL https://node.openvibe.network/install | sh -s -- --ne(it installs `nftables`, `iproute2` and `util-linux` with apt when they are missing and turns IPv4 forwarding on);- installs the OpenVibe.Codes job runner (`openvibe-codes job`, artifact `[email protected]`) into`/opt/openvibe-codes` when Node.js 20 or newer is installed system-wide, and declares it as a `code` entry;++- with `--plan-logins` (which turns the worker on too), names the coding tools' own login files that are already signed++ in on this computer as the worker's `plan_logins`, and sets the service's HOME to your home so they sit under the++ Node user's home. A tool whose login file is missing or empty is skipped with a line giving its own login command; a++ login file whose CLI is not on this PATH is still named, with a line to install the CLI system-wide. The installer++ only looks at whether a login file is there; it never reads, copies or prints its content;- sizes the caps for a coding task (an hour, 4 GiB of memory, 3 GiB of scratch, one job at a time). Your own values winon a second run, and so do the keys you added.@@@ -335,8 +340,18 @@ do), and the Node copies each one into that one job's private home, **read-only*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'sown directory and disappears when the job ends. Its content is never logged, never uploaded and never part of a result.++The installer's `--plan-logins` does this on Linux: it names the files that are already there++(`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.local/share/opencode/auth.json` and++`~/.commandcode/auth.json`), skips a tool whose login file is missing or empty with a line giving that tool's own login++command, and sets the service's `HOME` to your home. It only looks at whether each file is there: its content is++never read, copied or printed. Sign a tool in with its own login, then run the installer again with `--plan-logins` to++name the new file.++- **What may be named.** An absolute path under the Node user's home, and a plain file. A path outside the home, adirectory or a symlink is refused when the config loads.++- **Which home.** The service's `HOME`, which `install.sh --plan-logins` sets to the owner's home. A++ `sudo openvibe-node …` command uses the sudo user's home for the same check, so the CLI and the service see the same++ folder rather than root's.- **A file that is not there is skipped**, not an error: the CLI it belongs to is simply not signed in. A file that isthere 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@@@ -5,6 +5,7 @@# curl -fsSL https://node.openvibe.network/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none]# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> [--driver …]# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> --worker++# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> --plan-logins# curl -fsSL https://node.openvibe.network/install | sh -s -- --setup <osk_…>## (the same script is also at https://openvibe.bot/install, which keeps working)@@@ -38,6 +39,12 @@# as the unprivileged user openvibe-job in the worker's sandbox with public egress (the service# runs as root, which run_as and the egress need), and the OpenVibe.Codes job runner is added when# Node.js 20+ is installed system-wide. Linux only. docs/worker.md has the details.++# --plan-logins with --worker (implied): name the coding tools' own login files that are already signed in on this++# computer (Claude Code, Codex, OpenCode, Command Code) as the worker's plan logins, so a Codes job++# uses the plan you signed in for. A tool that is not installed or not signed in is skipped with a++# line saying how to sign it in. The installer never reads, copies or prints a login's content, and++# sets the Node service's HOME to your home (the logins must sit under it). Linux only.++# docs/worker.md has the details.# --desk-host this machine hosts desks for its owner's agents (docs/computer.md "Desk hosts"; the Desks plan's# step D5): an old laptop, a mini PC or a spare workstation, no screen or login needed. The# service runs as its own account (openvibe-desk), the installer adds what desks need (Xvfb, a@@@ -78,6 +85,9 @@# OPENVIBE_INSTALL_AI_CONFIG_ONLY=<config.json> adds the --ai plugin entry to that file and exits.# OPENVIBE_INSTALL_WORKER_CONFIG_ONLY=<config.json> writes the --worker section into that file (OPENVIBE_JOB_UID,# OPENVIBE_JOB_GID, OPENVIBE_NODEJS and OPENVIBE_CODES_DIR stand in for the job user and the runner) and exits.++# OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 with --plan-logins prints the steps it would take and what it found, and exits.++# OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY=<config.json> with --plan-logins writes the found login files into that++# config's worker.plan_logins and exits (no root, no service).set -euREPO_URL="https://github.com/OpenVibers/OpenVibe.Node"@@@ -91,6 +101,7 @@ SERVICE=1LOCAL=""TAG="latest"WORKER=0++PLAN_LOGINS=0COMPUTER=""DESK_HOST=0SHARED=0@@@ -111,13 +122,14 @@ CODES_DIR=/opt/openvibe-codesusage() {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 " [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none] [--worker [--plan-logins]]"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://node.openvibe.network/install | sh (finds the kit, then shows a code to approve on openvibe.bot/link)"echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept"echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234"++ echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234 --plan-logins"}say() { printf '%s\n' "openvibe-node: $*"; }die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }@@@ -144,6 +156,7 @@ while [ $# -gt 0 ]; do--pairing) PAIRING="${2:-}"; shift 2 ;;--pairing=*) PAIRING="${1#*=}"; shift ;;--worker) WORKER=1; shift ;;++ --plan-logins) PLAN_LOGINS=1; WORKER=1; shift ;;--desk-host) DESK_HOST=1; shift ;;--shared) SHARED=1; shift ;;--ai) AI=1; shift ;;@@@ -181,7 +194,7 @@ case "$NETWORK" in ""|https://*|http://localhost*|http://127.0.0.1*) ;; *) die "if [ -n "$SETUP" ]; thenprintf '%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" != 0 ] || [ "$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/--plan-logins, --computer, --desk-host or --ai"; fifi# 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).@@@ -201,6 +214,133 @@ pair_with() {"$@"}++# plan_logins_home: set PLAN_HOME to the home whose coding-tool login files --plan-logins names. That is the person's++# own home, never root's: run the installer as yourself (it asks for sudo itself), or with sudo from that account.++# The login files must sit under the Node user's home; --plan-logins sets the service's HOME to this one.++plan_logins_home() {++ if [ "$(id -u)" -eq 0 ]; then++ _user="${SUDO_USER:-}"++ case "$_user" in ""|root) die "--plan-logins must be run as the person whose logins they are: run it as yourself (it asks for sudo itself), or with sudo from that account" ;; esac++ _home=""++ if command -v getent >/dev/null 2>&1; then _home="$(getent passwd "$_user" | cut -d: -f6)"; fi++ [ -n "$_home" ] || _home="$(awk -F: -v u="$_user" '$1 == u { print $6 }' /etc/passwd 2>/dev/null)"++ else++ _home="${HOME:-}"++ if [ -z "$_home" ] && command -v getent >/dev/null 2>&1; then _home="$(getent passwd "$(id -un)" | cut -d: -f6)"; fi++ fi++ case "$_home" in */) _home="${_home%/}" ;; esac++ if [ -z "$_home" ] || [ ! -d "$_home" ] || [ "$_home" = / ]; then++ die "no home folder to look for the coding tools' logins (--plan-logins)"++ fi++ case "$_home" in /*) ;; *) die "the home folder must be an absolute path (--plan-logins)" ;; esac++ PLAN_HOME="$_home"++}++++# plan_logins_scan <home> <paths-file>: look at each coding tool's own login file (its presence and kind only, never its++# content), say what a job would get, and write each file that is ready to <paths-file>. A tool that is not signed in,++# and a symlink or empty file, are skipped with a line saying how to sign in with the tool itself; a login file whose++# CLI is not on this PATH is still named, with a line to install the CLI system-wide.++plan_logins_scan() {++ _home="$1" _paths="$2"++ : > "$_paths"++ while IFS='|' read -r _id _bin _rel _hint; do++ _file="$_home/$_rel"++ _installed=0++ command -v "$_bin" >/dev/null 2>&1 && _installed=1++ if [ ! -f "$_file" ] || [ -L "$_file" ] || [ ! -s "$_file" ]; then++ if [ "$_installed" = 0 ]; then++ say " $_id: not installed: skipped (install it and sign in with \`$_hint\`, then run this installer again with --plan-logins)"++ else++ say " $_id: installed but not signed in: skipped (run \`$_hint\`, then run this installer again with --plan-logins)"++ fi++ elif [ "$_installed" = 0 ]; then++ printf '%s\n' "$_file" >> "$_paths"++ say " $_id: signed in (~/${_rel}) but $_bin is not on the job runner's PATH: named; install it system-wide so a job can run it"++ else++ printf '%s\n' "$_file" >> "$_paths"++ say " $_id: signed in (~/${_rel}): named for your jobs"++ fi++ done <<'EOF'++claude-code|claude|.claude/.credentials.json|claude++codex|codex|.codex/auth.json|codex login++opencode|opencode|.local/share/opencode/auth.json|opencode auth login++command-code|cmd|.commandcode/auth.json|cmd login++EOF++}++++# plan_logins_steps <home> <paths-file>: the dry run. Lists every step --plan-logins takes and what it finds, without++# writing a config, touching the service or reading a login's content.++plan_logins_steps() {++ _home="$1" _paths="$2"++ say "plan logins (dry run): what --plan-logins will do"++ say " 1. pair this computer for its owner's OpenVibe.Codes jobs (the code from openvibe.codes → Machines)"++ say " 2. set the openvibe-node service's HOME to $_home (the login files must sit under the Node user's home)"++ say " 3. name the coding tools' own login files for the job runner (their content is never read, copied or printed):"++ plan_logins_scan "$_home" "$_paths"++ say " 4. write the names into config.json's worker.plan_logins and restart openvibe-node"++}++++# write_plan_logins <config.json> <paths-file>: add the ready login files to worker.plan_logins, keeping the ones the++# owner named before (a tool that is signed out today is skipped at job time, not dropped from the config).++write_plan_logins() {++ ${SUDO:-} python3 - "$@" <<'PY'++import json, os, sys++path, names_file = sys.argv[1], sys.argv[2]++try:++ with open(path) as f:++ conf = json.load(f)++except FileNotFoundError:++ conf = {}++worker = conf.get('worker') or {}++named = set(worker.get('plan_logins') or [])++with open(names_file) as f:++ named.update(line.rstrip('\n') for line in f if line.strip())++worker['plan_logins'] = sorted(named)++conf['worker'] = worker++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++}++++# plan_logins_service_home <home>: make the Node service start with the owner's HOME. The worker runs as root, and the++# plan logins must sit under the Node user's home; without this the service's home is root's and the config is refused.++plan_logins_service_home() {++ _home="$1"++ if [ "$SERVICE" = 0 ]; then++ say "plan logins: no service to set HOME on; run the Node with HOME=$_home so it finds the login files"++ return 0++ fi++ if ! command -v systemctl >/dev/null 2>&1; then++ say "warning: systemctl is not here: start openvibe-node with HOME=$_home so it finds the plan logins"++ return 0++ fi++ $SUDO mkdir -p /etc/systemd/system/openvibe-node.service.d++ $SUDO sh -c "cat > /etc/systemd/system/openvibe-node.service.d/home.conf" <<EOF++# Plan logins are files under the Node user's home (install.sh --plan-logins).++[Service]++Environment="HOME=$_home"++EOF++ $SUDO systemctl daemon-reload || say "warning: could not reload systemd; the service keeps root's HOME until it restarts"++}++++if [ "$PLAN_LOGINS" = 1 ]; then++ [ "$(uname -s)" = Linux ] || die "--plan-logins is for Linux (the worker's sandbox is Linux only)"++ plan_logins_home++fi++++# Test hook: list what --plan-logins would do, without writing a config, touching the service or reading a login file.++if [ "${OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN:-0}" = 1 ]; then++ [ "$PLAN_LOGINS" = 1 ] || die "OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN needs --plan-logins"++ _tmp="$(mktemp -d)"++ trap 'rm -rf "$_tmp"' EXIT++ plan_logins_steps "$PLAN_HOME" "$_tmp/paths"++ exit 0++fi++if [ "${OPENVIBE_INSTALL_PAIR_ARGS_ONLY:-0}" = 1 ]; thenpair_with printf '%s\n'exit 0@@@ -491,6 +631,16 @@ if [ -n "${OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY:-}" ]; then[ "$DESK_HOST" != shared ] || write_worker_config "$OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY" "${OPENVIBE_JOB_UID:-999}" "${OPENVIBE_JOB_GID:-999}"exit 0fi++# OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY=<config.json> with --plan-logins writes the login files it finds into that++# config's worker.plan_logins and exits (no root, no service; scripts/test-install-plan-logins.sh).++if [ -n "${OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY:-}" ]; then++ [ "$PLAN_LOGINS" = 1 ] || die "OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY needs --plan-logins"++ _tmp="$(mktemp -d)"++ trap 'rm -rf "$_tmp"' EXIT++ plan_logins_scan "$PLAN_HOME" "$_tmp/paths"++ write_plan_logins "$OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY" "$_tmp/paths"++ exit 0++fi# Root for the service, /usr/local/bin and /etc.SUDO=""@@@ -973,6 +1123,12 @@ fi# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----if [ "$WORKER" = 1 ]; then++ PLAN_PATHS_FILE=""++ if [ "$PLAN_LOGINS" = 1 ]; then++ PLAN_PATHS_FILE="$TMP/plan-logins"++ say "plan logins: the coding tools' own login files (their content is never read, copied or printed):"++ plan_logins_scan "$PLAN_HOME" "$PLAN_PATHS_FILE"++ fiif ! 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@@@ -1011,6 +1167,20 @@ if [ "$WORKER" = 1 ]; then$SUDO touch "$CONF/config.json"write_worker_config "$CONF/config.json" "$JOB_UID" "$JOB_GID"fi++ if [ "$PLAN_LOGINS" = 1 ]; then++ write_plan_logins "$CONF/config.json" "$PLAN_PATHS_FILE"++ plan_logins_service_home "$PLAN_HOME"++ _named="$(wc -l < "$PLAN_PATHS_FILE" | tr -d ' ')"++ if [ "$_named" -gt 0 ]; then++ say "plan logins: $_named named for your jobs; each job sees them read-only, only while it runs"++ else++ say "plan logins: none named yet: sign in to the coding tools you use with their own login, then run this installer"++ say " again with --plan-logins (Claude Code: claude; Codex: codex login; OpenCode: opencode auth login; Command Code: cmd login)"++ fi++ if [ "$_named" -lt 4 ]; then++ say " the line above each skipped tool has its own login command; run it, then run this installer again with --plan-logins"++ fi++ fisay "the job worker is on: jobs run as openvibe-job in the worker's sandbox, with public egress"say "add your agent keys (DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, and GITHUB_TOKEN to push branches) to"say " worker.functions[openvibe-codes].env in $CONF/config.json, and install agent CLIs system-wide"@@@ -18,6 +18,7 @@ import ("net/netip""net/url""os"++ "os/user""path/filepath""regexp""runtime"@@@ -583,12 +584,24 @@ func (c *Config) PythonPath(paths Paths) string {return "python3"}++// planLoginHome is the home worker.plan_logins must be under. Under sudo it is the sudo user's home: install.sh++// --plan-logins sets the service's HOME to it, and a `sudo openvibe-node ...` must see the same folder, not root's.++// The service itself has HOME set, so os.UserHomeDir is right there.++func planLoginHome() (string, error) {++ if u := os.Getenv("SUDO_USER"); u != "" && u != "root" {++ if usr, err := user.Lookup(u); err == nil && usr.HomeDir != "" {++ return usr.HomeDir, nil++ }++ }++ return os.UserHomeDir()++}++// 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()++ home, err := planLoginHome()if err != nil {if len(w.PlanLogins) > 0 {return fmt.Errorf("config: worker.plan_logins: the Node user's home is unknown: %w", err)@@@ -2,7 +2,9 @@ package configimport ("os"++ "os/user""path/filepath"++ "runtime""testing")@@@ -15,6 +17,7 @@ func TestWorkerPlanLogins(t *testing.T) {}t.Setenv("HOME", home)t.Setenv("USERPROFILE", home)++ t.Setenv("SUDO_USER", "")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 {@@@ -35,3 +38,28 @@ func TestWorkerPlanLogins(t *testing.T) {}}}++++// TestPlanLoginHomeUnderSudo: a `sudo openvibe-node ...` reads the plan logins under the sudo user's home (the home++// install.sh --plan-logins gives the service), not root's; without sudo it is the Node user's own home.++func TestPlanLoginHomeUnderSudo(t *testing.T) {++ if runtime.GOOS != "linux" {++ t.Skip("the worker and its plan logins are Linux-only")++ }++ who, err := user.Current()++ if err != nil || who.Username == "" || who.Username == "root" || who.HomeDir == "" {++ t.Skip("no non-root user to stand in for the sudo user")++ }++ tmp := t.TempDir()++ t.Setenv("HOME", tmp)++ t.Setenv("USERPROFILE", tmp)++ t.Setenv("SUDO_USER", who.Username)++ got, err := planLoginHome()++ if err != nil || got != who.HomeDir {++ t.Fatalf("under sudo: got %q, %v; want %q", got, err, who.HomeDir)++ }++ t.Setenv("SUDO_USER", "")++ got, err = planLoginHome()++ if err != nil || got != tmp {++ t.Fatalf("without sudo: got %q, %v; want %q", got, err, tmp)++ }++}nnew file mode 100755@@@ -0,0 +1,84 @@++#!/bin/sh++# Tests install.sh --plan-logins: the dry run lists the steps and what it found, a missing or signed-out tool is++# skipped with a clear line, only the signed-in tools' login files are named, and a login file's content is never read++# or printed. OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN and OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY run without root,++# without a service and without touching the real home or config.++set -eu++++here="$(cd "$(dirname "$0")" && pwd)"++installer="$here/../install/install.sh"++tmp="$(mktemp -d)"++trap 'rm -rf "$tmp"' EXIT++fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }++++home="$tmp/home"++bin="$tmp/bin"++# A clean PATH: the fake tools, then the system's own. The invoking user's PATH may have the real Command Code on it.++path="$bin:/usr/bin:/bin"++mkdir -p "$home/.claude" "$home/.codex" "$home/.local/share/opencode" "$bin"++# The installer needs python3; a clean PATH still has to find it (it may live in /usr/local/bin).++ln -s "$(command -v python3)" "$bin/python3"++for tool in claude codex opencode; do++ printf '#!/bin/sh\nexit 0\n' > "$bin/$tool"++ chmod +x "$bin/$tool"++done++# Claude Code and OpenCode are signed in; Codex is installed but its login file is empty; Command Code is not installed.++printf '%s\n' '{"access_token":"CLAUDE-LOGIN-SECRET"}' > "$home/.claude/.credentials.json"++: > "$home/.codex/auth.json"++printf '%s\n' '{"token":"OPENCODE-LOGIN-SECRET"}' > "$home/.local/share/opencode/auth.json"++++dry="$(HOME="$home" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 sh "$installer" --plan-logins 2>&1)" ||++ fail "the dry run exited non-zero"++check() { printf '%s\n' "$dry" | grep -F -- "$1" >/dev/null || fail "the dry run is missing: $1"; }++check '1. pair this computer'++check '2. set the openvibe-node service'++check '3. name the coding tools'++check '4. write the names'++check 'claude-code: signed in (~/.claude/.credentials.json): named for your jobs'++check 'opencode: signed in (~/.local/share/opencode/auth.json): named for your jobs'++check "codex: installed but not signed in: skipped (run \`codex login\`"++check "command-code: not installed: skipped (install it and sign in with \`cmd login\`"++for secret in CLAUDE-LOGIN-SECRET OPENCODE-LOGIN-SECRET; do++ printf '%s\n' "$dry" | grep -F -- "$secret" >/dev/null && fail "the dry run printed a login's content"++done++++# A login file that is there is named even when its CLI is not on this PATH (sudo's secure_path, or a user-local CLI):++# the line says to install that CLI system-wide so a job can run it.++home2="$tmp/home2"++mkdir -p "$home2/.commandcode"++printf '{}\n' > "$home2/.commandcode/auth.json"++dry2="$(HOME="$home2" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 sh "$installer" --plan-logins 2>&1)" ||++ fail "the second dry run exited non-zero"++printf '%s\n' "$dry2" | grep -F 'command-code: signed in (~/.commandcode/auth.json) but cmd is not on the job runner' >/dev/null ||++ fail "a login without its CLI on PATH was not named with a warning"++++conf="$tmp/config.json"++existing="$home/.codex/old.json"++printf '{"server": "https://openvibe.bot", "plugins": [{"name": "dryrun"}], "worker": {"enabled": true, "plan_logins": ["%s"]}}\n' "$existing" > "$conf"++written="$(HOME="$home" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY="$conf" sh "$installer" --plan-logins 2>&1)" ||++ fail "writing worker.plan_logins exited non-zero"++for secret in CLAUDE-LOGIN-SECRET OPENCODE-LOGIN-SECRET; do++ printf '%s\n' "$written" | grep -F -- "$secret" >/dev/null && fail "the config run printed a login's content"++ grep -F -- "$secret" "$conf" >/dev/null && fail "a login's content reached config.json"++done++python3 - "$conf" "$home" <<'PY' || fail "worker.plan_logins was not written as expected"++import json, os, stat, sys++path, home = sys.argv[1], sys.argv[2]++c = json.load(open(path))++w = c['worker']++assert c['server'] == 'https://openvibe.bot' and c['plugins'] == [{'name': 'dryrun'}], 'the rest of the config stays'++assert w['enabled'] is True, 'the worker section stays'++want = sorted({os.path.join(home, '.codex', 'old.json'),++ os.path.join(home, '.claude', '.credentials.json'),++ os.path.join(home, '.local', 'share', 'opencode', 'auth.json')})++assert w['plan_logins'] == want, w['plan_logins']++assert stat.S_IMODE(os.stat(path).st_mode) == 0o600, 'the config stays private'++PY++++# --setup is the card path: it cannot carry --plan-logins (which turns the worker on).++if HOME="$home" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 \++ sh "$installer" --plan-logins --setup "osk_$(printf '%043d' 0)" >/dev/null 2>&1; then++ fail "--plan-logins was accepted with --setup"++fi++++echo "install.sh --plan-logins: ok"
@@ -164,11 +164,12 @@ jobs:
@@ -164,11 +164,12 @@ jobs:
runs-on: ubuntu-latest
runs-on: ubuntu-latest
steps:
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v4
- - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-claim.sh
+ - run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh scripts/test-install-worker-config.sh scripts/test-install-computer-config.sh scripts/test-install-plan-logins.sh scripts/test-install-claim.sh
- run: sh scripts/test-install-stock-server.sh
- run: sh scripts/test-install-stock-server.sh
- run: sh scripts/test-install-pair-args.sh
- run: sh scripts/test-install-pair-args.sh
- run: sh scripts/test-install-worker-config.sh
- run: sh scripts/test-install-worker-config.sh
- run: sh scripts/test-install-computer-config.sh
- run: sh scripts/test-install-computer-config.sh
+ - run: sh scripts/test-install-plan-logins.sh
- run: sh scripts/test-install-claim.sh
- run: sh scripts/test-install-claim.sh
esp32-conformance:
esp32-conformance:
@@ -79,7 +79,10 @@ this computer's models" on openvibe.bot/machines ([docs/ai.md](docs/ai.md)). The
@@ -79,7 +79,10 @@ this computer's models" on openvibe.bot/machines ([docs/ai.md](docs/ai.md)). The
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
**A machine that runs your jobs:** add it on openvibe.bot/machines, which prints the same installer with `--worker`: the
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
job worker runs each job as an unprivileged user in a sandbox, and the OpenVibe.Codes job runner lets you start a coding
-task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)).
+task on openvibe.codes/run from any browser and have this machine do it ([docs/worker.md](docs/worker.md)). Add
+`--plan-logins` to use the coding plans you already signed in for here: it names those login files for your jobs, sets
+the service's HOME to your home, and says how to sign in a tool that is not ready, without ever reading or printing a
+login's content.
**What the machine does for the network:** the owner sets the machine's roles on openvibe.network/machines
**What the machine does for the network:** the owner sets the machine's roles on openvibe.network/machines
(`codes.worker`, `desks.host`, `ai.host`, `bot.robot`). The Node reads them at its check-in, keeps them, and applies
(`codes.worker`, `desks.host`, `ai.host`, `bot.robot`). The Node reads them at its check-in, keeps them, and applies
@@ -79,7 +79,12 @@ llama.cpp's server on the machine, and the installer says which one answers. `--
@@ -79,7 +79,12 @@ llama.cpp's server on the machine, and the installer says which one answers. `--
**A machine that runs your jobs, not a robot:** openvibe.bot/machines prints the Network form with `--worker` (and no
**A machine that runs your jobs, not a robot:** openvibe.bot/machines prints the Network form with `--worker` (and no
`--driver`). It turns the job worker on with a dedicated `openvibe-job` user and the OpenVibe.Codes job runner, so a
`--driver`). It turns the job worker on with a dedicated `openvibe-job` user and the OpenVibe.Codes job runner, so a
-coding task you start on openvibe.codes/run runs on this machine. [worker.md](worker.md#a-machine-that-runs-your-coding-tasks---worker)
+coding task you start on openvibe.codes/run runs on this machine. Add `--plan-logins` (Linux) to use the plans you
+already signed in for: the installer names the coding tools' own login files that are ready (Claude Code, Codex,
+OpenCode and Command Code) as the worker's plan logins, sets the Node service's HOME to your home, and prints the login
+command for each tool whose login is missing or not signed in (a login whose CLI is not on the runner's PATH is named
+with a line to install it system-wide). It never reads, copies or prints a login file's content.
+[worker.md](worker.md#a-machine-that-runs-your-coding-tasks---worker)
has the details.
has the details.
**Two pairing paths.** Which one the command uses is up to openvibe.bot; the installer takes both.
**Two pairing paths.** Which one the command uses is up to openvibe.bot; the installer takes both.
@@ -21,6 +21,11 @@ one-time code: `curl -fsSL https://node.openvibe.network/install | sh -s -- --ne
@@ -21,6 +21,11 @@ one-time code: `curl -fsSL https://node.openvibe.network/install | sh -s -- --ne
(it installs `nftables`, `iproute2` and `util-linux` with apt when they are missing and turns IPv4 forwarding on);
(it installs `nftables`, `iproute2` and `util-linux` with apt when they are missing and turns IPv4 forwarding on);
- installs the OpenVibe.Codes job runner (`openvibe-codes job`, artifact `[email protected]`) into
- installs the OpenVibe.Codes job runner (`openvibe-codes job`, artifact `[email protected]`) into
`/opt/openvibe-codes` when Node.js 20 or newer is installed system-wide, and declares it as a `code` entry;
`/opt/openvibe-codes` when Node.js 20 or newer is installed system-wide, and declares it as a `code` entry;
+- with `--plan-logins` (which turns the worker on too), names the coding tools' own login files that are already signed
+ in on this computer as the worker's `plan_logins`, and sets the service's HOME to your home so they sit under the
+ Node user's home. A tool whose login file is missing or empty is skipped with a line giving its own login command; a
+ login file whose CLI is not on this PATH is still named, with a line to install the CLI system-wide. The installer
+ only looks at whether a login file is there; it never reads, copies or prints its content;
- sizes the caps for a coding task (an hour, 4 GiB of memory, 3 GiB of scratch, one job at a time). Your own values win
- sizes the caps for a coding task (an hour, 4 GiB of memory, 3 GiB of scratch, one job at a time). Your own values win
on a second run, and so do the keys you added.
on a second run, and so do the keys you added.
@@ -335,8 +340,18 @@ do), and the Node copies each one into that one job's private home, **read-only*
@@ -335,8 +340,18 @@ do), and the Node copies each one into that one job's private home, **read-only*
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
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.
own directory and disappears when the job ends. Its content is never logged, never uploaded and never part of a result.
+The installer's `--plan-logins` does this on Linux: it names the files that are already there
+(`~/.claude/.credentials.json`, `~/.codex/auth.json`, `~/.local/share/opencode/auth.json` and
+`~/.commandcode/auth.json`), skips a tool whose login file is missing or empty with a line giving that tool's own login
+command, and sets the service's `HOME` to your home. It only looks at whether each file is there: its content is
+never read, copied or printed. Sign a tool in with its own login, then run the installer again with `--plan-logins` to
+name the new file.
+
- **What may be named.** An absolute path under the Node user's home, and a plain file. A path outside the home, a
- **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.
directory or a symlink is refused when the config loads.
+- **Which home.** The service's `HOME`, which `install.sh --plan-logins` sets to the owner's home. A
+ `sudo openvibe-node …` command uses the sudo user's home for the same check, so the CLI and the service see the same
+ folder rather than root's.
- **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
- **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.
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
- **What the job sees.** `openvibe-codes logins` (the Codes job runner) reports each CLI as installed, signed in, and
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
# curl -fsSL https://node.openvibe.network/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none]
# curl -fsSL https://node.openvibe.network/install | sh -s -- --robot <rob_…> --code <CODE> [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none]
# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> [--driver …]
# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> [--driver …]
# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> --worker
# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> --worker
+# curl -fsSL https://node.openvibe.network/install | sh -s -- --network <URL> --pairing <pair_…> --code <CODE> --plan-logins
# curl -fsSL https://node.openvibe.network/install | sh -s -- --setup <osk_…>
# curl -fsSL https://node.openvibe.network/install | sh -s -- --setup <osk_…>
#
#
# (the same script is also at https://openvibe.bot/install, which keeps working)
# (the same script is also at https://openvibe.bot/install, which keeps working)
@@ -38,6 +39,12 @@
@@ -38,6 +39,12 @@
# as the unprivileged user openvibe-job in the worker's sandbox with public egress (the service
# as the unprivileged user openvibe-job in the worker's sandbox with public egress (the service
# runs as root, which run_as and the egress need), and the OpenVibe.Codes job runner is added when
# runs as root, which run_as and the egress need), and the OpenVibe.Codes job runner is added when
# Node.js 20+ is installed system-wide. Linux only. docs/worker.md has the details.
# Node.js 20+ is installed system-wide. Linux only. docs/worker.md has the details.
+# --plan-logins with --worker (implied): name the coding tools' own login files that are already signed in on this
+# computer (Claude Code, Codex, OpenCode, Command Code) as the worker's plan logins, so a Codes job
+# uses the plan you signed in for. A tool that is not installed or not signed in is skipped with a
+# line saying how to sign it in. The installer never reads, copies or prints a login's content, and
+# sets the Node service's HOME to your home (the logins must sit under it). Linux only.
+# docs/worker.md has the details.
# --desk-host this machine hosts desks for its owner's agents (docs/computer.md "Desk hosts"; the Desks plan's
# --desk-host this machine hosts desks for its owner's agents (docs/computer.md "Desk hosts"; the Desks plan's
# step D5): an old laptop, a mini PC or a spare workstation, no screen or login needed. The
# step D5): an old laptop, a mini PC or a spare workstation, no screen or login needed. The
# service runs as its own account (openvibe-desk), the installer adds what desks need (Xvfb, a
# service runs as its own account (openvibe-desk), the installer adds what desks need (Xvfb, a
@@ -78,6 +85,9 @@
@@ -78,6 +85,9 @@
# OPENVIBE_INSTALL_AI_CONFIG_ONLY=<config.json> adds the --ai plugin entry to that file and exits.
# OPENVIBE_INSTALL_AI_CONFIG_ONLY=<config.json> adds the --ai plugin entry to that file and exits.
# OPENVIBE_INSTALL_WORKER_CONFIG_ONLY=<config.json> writes the --worker section into that file (OPENVIBE_JOB_UID,
# OPENVIBE_INSTALL_WORKER_CONFIG_ONLY=<config.json> writes the --worker section into that file (OPENVIBE_JOB_UID,
# OPENVIBE_JOB_GID, OPENVIBE_NODEJS and OPENVIBE_CODES_DIR stand in for the job user and the runner) and exits.
# OPENVIBE_JOB_GID, OPENVIBE_NODEJS and OPENVIBE_CODES_DIR stand in for the job user and the runner) and exits.
+# OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 with --plan-logins prints the steps it would take and what it found, and exits.
+# OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY=<config.json> with --plan-logins writes the found login files into that
+# config's worker.plan_logins and exits (no root, no service).
set -eu
set -eu
REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
@@ -91,6 +101,7 @@ SERVICE=1
@@ -91,6 +101,7 @@ SERVICE=1
LOCAL=""
LOCAL=""
TAG="latest"
TAG="latest"
WORKER=0
WORKER=0
+PLAN_LOGINS=0
COMPUTER=""
COMPUTER=""
DESK_HOST=0
DESK_HOST=0
SHARED=0
SHARED=0
@@ -111,13 +122,14 @@ CODES_DIR=/opt/openvibe-codes
@@ -111,13 +122,14 @@ CODES_DIR=/opt/openvibe-codes
usage() {
usage() {
echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
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 " [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none] [--worker [--plan-logins]]"
echo " [--computer observe|ask|trusted|full] [--desk-host [--shared]] [--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 " [--no-service] [--no-claim] [--setup osk_…]"
echo " [--local DIR] [--version TAG]"
echo " [--local DIR] [--version TAG]"
echo " curl -fsSL https://node.openvibe.network/install | sh (finds the kit, then shows a code to approve on openvibe.bot/link)"
echo " curl -fsSL https://node.openvibe.network/install | sh (finds the kit, then shows a code to approve on openvibe.bot/link)"
echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept"
echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --robot rob_… --code ABCD-1234 --driver adeept"
echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234"
echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234"
+ echo " curl -fsSL https://node.openvibe.network/install | sh -s -- --network https://openvibe.network --pairing pair_… --code ABCD-1234 --plan-logins"
}
}
say() { printf '%s\n' "openvibe-node: $*"; }
say() { printf '%s\n' "openvibe-node: $*"; }
die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }
die() { printf '%s\n' "openvibe-node: error: $*" >&2; exit 1; }
@@ -144,6 +156,7 @@ while [ $# -gt 0 ]; do
@@ -144,6 +156,7 @@ while [ $# -gt 0 ]; do
--pairing) PAIRING="${2:-}"; shift 2 ;;
--pairing) PAIRING="${2:-}"; shift 2 ;;
--pairing=*) PAIRING="${1#*=}"; shift ;;
--pairing=*) PAIRING="${1#*=}"; shift ;;
--worker) WORKER=1; shift ;;
--worker) WORKER=1; shift ;;
+ --plan-logins) PLAN_LOGINS=1; WORKER=1; shift ;;
--desk-host) DESK_HOST=1; shift ;;
--desk-host) DESK_HOST=1; shift ;;
--shared) SHARED=1; shift ;;
--shared) SHARED=1; shift ;;
--ai) AI=1; shift ;;
--ai) AI=1; shift ;;
@@ -181,7 +194,7 @@ case "$NETWORK" in ""|https://*|http://localhost*|http://127.0.0.1*) ;; *) die "
@@ -181,7 +194,7 @@ case "$NETWORK" in ""|https://*|http://localhost*|http://127.0.0.1*) ;; *) die "
if [ -n "$SETUP" ]; then
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)"
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)"
[ -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" != 0 ] || [ "$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/--plan-logins, --computer, --desk-host or --ai"; fi
fi
fi
# No robot, no code, no Network pairing, not a worker, a computer or a model machine: the one command (openvibe.bot/link,
# 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).
# see the top).
@@ -201,6 +214,133 @@ pair_with() {
@@ -201,6 +214,133 @@ pair_with() {
"$@"
"$@"
}
}
+# plan_logins_home: set PLAN_HOME to the home whose coding-tool login files --plan-logins names. That is the person's
+# own home, never root's: run the installer as yourself (it asks for sudo itself), or with sudo from that account.
+# The login files must sit under the Node user's home; --plan-logins sets the service's HOME to this one.
+plan_logins_home() {
+ if [ "$(id -u)" -eq 0 ]; then
+ _user="${SUDO_USER:-}"
+ case "$_user" in ""|root) die "--plan-logins must be run as the person whose logins they are: run it as yourself (it asks for sudo itself), or with sudo from that account" ;; esac
+ _home=""
+ if command -v getent >/dev/null 2>&1; then _home="$(getent passwd "$_user" | cut -d: -f6)"; fi
+ [ -n "$_home" ] || _home="$(awk -F: -v u="$_user" '$1 == u { print $6 }' /etc/passwd 2>/dev/null)"
+ else
+ _home="${HOME:-}"
+ if [ -z "$_home" ] && command -v getent >/dev/null 2>&1; then _home="$(getent passwd "$(id -un)" | cut -d: -f6)"; fi
+ fi
+ case "$_home" in */) _home="${_home%/}" ;; esac
+ if [ -z "$_home" ] || [ ! -d "$_home" ] || [ "$_home" = / ]; then
+ die "no home folder to look for the coding tools' logins (--plan-logins)"
+ fi
+ case "$_home" in /*) ;; *) die "the home folder must be an absolute path (--plan-logins)" ;; esac
+ PLAN_HOME="$_home"
+}
+
+# plan_logins_scan <home> <paths-file>: look at each coding tool's own login file (its presence and kind only, never its
+# content), say what a job would get, and write each file that is ready to <paths-file>. A tool that is not signed in,
+# and a symlink or empty file, are skipped with a line saying how to sign in with the tool itself; a login file whose
+# CLI is not on this PATH is still named, with a line to install the CLI system-wide.
+plan_logins_scan() {
+ _home="$1" _paths="$2"
+ : > "$_paths"
+ while IFS='|' read -r _id _bin _rel _hint; do
+ _file="$_home/$_rel"
+ _installed=0
+ command -v "$_bin" >/dev/null 2>&1 && _installed=1
+ if [ ! -f "$_file" ] || [ -L "$_file" ] || [ ! -s "$_file" ]; then
+ if [ "$_installed" = 0 ]; then
+ say " $_id: not installed: skipped (install it and sign in with \`$_hint\`, then run this installer again with --plan-logins)"
+ else
+ say " $_id: installed but not signed in: skipped (run \`$_hint\`, then run this installer again with --plan-logins)"
+ fi
+ elif [ "$_installed" = 0 ]; then
+ printf '%s\n' "$_file" >> "$_paths"
+ say " $_id: signed in (~/${_rel}) but $_bin is not on the job runner's PATH: named; install it system-wide so a job can run it"
+ else
+ printf '%s\n' "$_file" >> "$_paths"
+ say " $_id: signed in (~/${_rel}): named for your jobs"
+ fi
+ done <<'EOF'
+claude-code|claude|.claude/.credentials.json|claude
+codex|codex|.codex/auth.json|codex login
+opencode|opencode|.local/share/opencode/auth.json|opencode auth login
+command-code|cmd|.commandcode/auth.json|cmd login
+EOF
+}
+
+# plan_logins_steps <home> <paths-file>: the dry run. Lists every step --plan-logins takes and what it finds, without
+# writing a config, touching the service or reading a login's content.
+plan_logins_steps() {
+ _home="$1" _paths="$2"
+ say "plan logins (dry run): what --plan-logins will do"
+ say " 1. pair this computer for its owner's OpenVibe.Codes jobs (the code from openvibe.codes → Machines)"
+ say " 2. set the openvibe-node service's HOME to $_home (the login files must sit under the Node user's home)"
+ say " 3. name the coding tools' own login files for the job runner (their content is never read, copied or printed):"
+ plan_logins_scan "$_home" "$_paths"
+ say " 4. write the names into config.json's worker.plan_logins and restart openvibe-node"
+}
+
+# write_plan_logins <config.json> <paths-file>: add the ready login files to worker.plan_logins, keeping the ones the
+# owner named before (a tool that is signed out today is skipped at job time, not dropped from the config).
+write_plan_logins() {
+ ${SUDO:-} python3 - "$@" <<'PY'
+import json, os, sys
+path, names_file = sys.argv[1], sys.argv[2]
+try:
+ with open(path) as f:
+ conf = json.load(f)
+except FileNotFoundError:
+ conf = {}
+worker = conf.get('worker') or {}
+named = set(worker.get('plan_logins') or [])
+with open(names_file) as f:
+ named.update(line.rstrip('\n') for line in f if line.strip())
+worker['plan_logins'] = sorted(named)
+conf['worker'] = worker
+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
+}
+
+# plan_logins_service_home <home>: make the Node service start with the owner's HOME. The worker runs as root, and the
+# plan logins must sit under the Node user's home; without this the service's home is root's and the config is refused.
+plan_logins_service_home() {
+ _home="$1"
+ if [ "$SERVICE" = 0 ]; then
+ say "plan logins: no service to set HOME on; run the Node with HOME=$_home so it finds the login files"
+ return 0
+ fi
+ if ! command -v systemctl >/dev/null 2>&1; then
+ say "warning: systemctl is not here: start openvibe-node with HOME=$_home so it finds the plan logins"
+ return 0
+ fi
+ $SUDO mkdir -p /etc/systemd/system/openvibe-node.service.d
+ $SUDO sh -c "cat > /etc/systemd/system/openvibe-node.service.d/home.conf" <<EOF
+# Plan logins are files under the Node user's home (install.sh --plan-logins).
+[Service]
+Environment="HOME=$_home"
+EOF
+ $SUDO systemctl daemon-reload || say "warning: could not reload systemd; the service keeps root's HOME until it restarts"
+}
+
+if [ "$PLAN_LOGINS" = 1 ]; then
+ [ "$(uname -s)" = Linux ] || die "--plan-logins is for Linux (the worker's sandbox is Linux only)"
+ plan_logins_home
+fi
+
+# Test hook: list what --plan-logins would do, without writing a config, touching the service or reading a login file.
+if [ "${OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN:-0}" = 1 ]; then
+ [ "$PLAN_LOGINS" = 1 ] || die "OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN needs --plan-logins"
+ _tmp="$(mktemp -d)"
+ trap 'rm -rf "$_tmp"' EXIT
+ plan_logins_steps "$PLAN_HOME" "$_tmp/paths"
+ exit 0
+fi
+
if [ "${OPENVIBE_INSTALL_PAIR_ARGS_ONLY:-0}" = 1 ]; then
if [ "${OPENVIBE_INSTALL_PAIR_ARGS_ONLY:-0}" = 1 ]; then
pair_with printf '%s\n'
pair_with printf '%s\n'
exit 0
exit 0
@@ -491,6 +631,16 @@ if [ -n "${OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY:-}" ]; then
@@ -491,6 +631,16 @@ if [ -n "${OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY:-}" ]; then
[ "$DESK_HOST" != shared ] || write_worker_config "$OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY" "${OPENVIBE_JOB_UID:-999}" "${OPENVIBE_JOB_GID:-999}"
[ "$DESK_HOST" != shared ] || write_worker_config "$OPENVIBE_INSTALL_COMPUTER_CONFIG_ONLY" "${OPENVIBE_JOB_UID:-999}" "${OPENVIBE_JOB_GID:-999}"
exit 0
exit 0
fi
fi
+# OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY=<config.json> with --plan-logins writes the login files it finds into that
+# config's worker.plan_logins and exits (no root, no service; scripts/test-install-plan-logins.sh).
+if [ -n "${OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY:-}" ]; then
+ [ "$PLAN_LOGINS" = 1 ] || die "OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY needs --plan-logins"
+ _tmp="$(mktemp -d)"
+ trap 'rm -rf "$_tmp"' EXIT
+ plan_logins_scan "$PLAN_HOME" "$_tmp/paths"
+ write_plan_logins "$OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY" "$_tmp/paths"
+ exit 0
+fi
# Root for the service, /usr/local/bin and /etc.
# Root for the service, /usr/local/bin and /etc.
SUDO=""
SUDO=""
@@ -973,6 +1123,12 @@ fi
@@ -973,6 +1123,12 @@ fi
# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----
# ---- the job worker (--worker): the job user, the egress tools, the Codes job runner, the worker config ----
if [ "$WORKER" = 1 ]; then
if [ "$WORKER" = 1 ]; then
+ PLAN_PATHS_FILE=""
+ if [ "$PLAN_LOGINS" = 1 ]; then
+ PLAN_PATHS_FILE="$TMP/plan-logins"
+ say "plan logins: the coding tools' own login files (their content is never read, copied or printed):"
+ plan_logins_scan "$PLAN_HOME" "$PLAN_PATHS_FILE"
+ fi
if ! id openvibe-job >/dev/null 2>&1; 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 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
$SUDO adduser --system --no-create-home --shell /usr/sbin/nologin --group openvibe-job
@@ -1011,6 +1167,20 @@ if [ "$WORKER" = 1 ]; then
@@ -1011,6 +1167,20 @@ if [ "$WORKER" = 1 ]; then
$SUDO touch "$CONF/config.json"
$SUDO touch "$CONF/config.json"
write_worker_config "$CONF/config.json" "$JOB_UID" "$JOB_GID"
write_worker_config "$CONF/config.json" "$JOB_UID" "$JOB_GID"
fi
fi
+ if [ "$PLAN_LOGINS" = 1 ]; then
+ write_plan_logins "$CONF/config.json" "$PLAN_PATHS_FILE"
+ plan_logins_service_home "$PLAN_HOME"
+ _named="$(wc -l < "$PLAN_PATHS_FILE" | tr -d ' ')"
+ if [ "$_named" -gt 0 ]; then
+ say "plan logins: $_named named for your jobs; each job sees them read-only, only while it runs"
+ else
+ say "plan logins: none named yet: sign in to the coding tools you use with their own login, then run this installer"
+ say " again with --plan-logins (Claude Code: claude; Codex: codex login; OpenCode: opencode auth login; Command Code: cmd login)"
+ fi
+ if [ "$_named" -lt 4 ]; then
+ say " the line above each skipped tool has its own login command; run it, then run this installer again with --plan-logins"
+ fi
+ fi
say "the job worker is on: jobs run as openvibe-job in the worker's sandbox, with public egress"
say "the job worker is on: jobs run as openvibe-job in the worker's sandbox, with public egress"
say "add your agent keys (DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, and GITHUB_TOKEN to push branches) to"
say "add your agent keys (DEEPSEEK_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, and GITHUB_TOKEN to push branches) to"
say " worker.functions[openvibe-codes].env in $CONF/config.json, and install agent CLIs system-wide"
say " worker.functions[openvibe-codes].env in $CONF/config.json, and install agent CLIs system-wide"
@@ -18,6 +18,7 @@ import (
@@ -18,6 +18,7 @@ import (
"net/netip"
"net/netip"
"net/url"
"net/url"
"os"
"os"
+ "os/user"
"path/filepath"
"path/filepath"
"regexp"
"regexp"
"runtime"
"runtime"
@@ -583,12 +584,24 @@ func (c *Config) PythonPath(paths Paths) string {
@@ -583,12 +584,24 @@ func (c *Config) PythonPath(paths Paths) string {
return "python3"
return "python3"
}
}
+// planLoginHome is the home worker.plan_logins must be under. Under sudo it is the sudo user's home: install.sh
+// --plan-logins sets the service's HOME to it, and a `sudo openvibe-node ...` must see the same folder, not root's.
+// The service itself has HOME set, so os.UserHomeDir is right there.
+func planLoginHome() (string, error) {
+ if u := os.Getenv("SUDO_USER"); u != "" && u != "root" {
+ if usr, err := user.Lookup(u); err == nil && usr.HomeDir != "" {
+ return usr.HomeDir, nil
+ }
+ }
+ return os.UserHomeDir()
+}
+
// validateDNS checks worker.dns: at most three IPv4 resolvers (the most a resolv.conf is read for), only with a policy
// 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.
// 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
// 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.
// 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 {
func (w WorkerConfig) validatePlanLogins() error {
- home, err := os.UserHomeDir()
+ home, err := planLoginHome()
if err != nil {
if err != nil {
if len(w.PlanLogins) > 0 {
if len(w.PlanLogins) > 0 {
return fmt.Errorf("config: worker.plan_logins: the Node user's home is unknown: %w", err)
return fmt.Errorf("config: worker.plan_logins: the Node user's home is unknown: %w", err)
@@ -2,7 +2,9 @@ package config
@@ -2,7 +2,9 @@ package config
import (
import (
"os"
"os"
+ "os/user"
"path/filepath"
"path/filepath"
+ "runtime"
"testing"
"testing"
)
)
@@ -15,6 +17,7 @@ func TestWorkerPlanLogins(t *testing.T) {
@@ -15,6 +17,7 @@ func TestWorkerPlanLogins(t *testing.T) {
}
}
t.Setenv("HOME", home)
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("USERPROFILE", home)
+ t.Setenv("SUDO_USER", "")
ok := []WorkerConfig{{}, {PlanLogins: []string{filepath.Join(home, ".claude", ".credentials.json")}},
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")}}}
{PlanLogins: []string{filepath.Join(home, ".codex", "auth.json"), filepath.Join(home, ".local", "share", "opencode", "auth.json")}}}
for _, w := range ok {
for _, w := range ok {
@@ -35,3 +38,28 @@ func TestWorkerPlanLogins(t *testing.T) {
@@ -35,3 +38,28 @@ func TestWorkerPlanLogins(t *testing.T) {
}
}
}
}
}
}
+
+// TestPlanLoginHomeUnderSudo: a `sudo openvibe-node ...` reads the plan logins under the sudo user's home (the home
+// install.sh --plan-logins gives the service), not root's; without sudo it is the Node user's own home.
+func TestPlanLoginHomeUnderSudo(t *testing.T) {
+ if runtime.GOOS != "linux" {
+ t.Skip("the worker and its plan logins are Linux-only")
+ }
+ who, err := user.Current()
+ if err != nil || who.Username == "" || who.Username == "root" || who.HomeDir == "" {
+ t.Skip("no non-root user to stand in for the sudo user")
+ }
+ tmp := t.TempDir()
+ t.Setenv("HOME", tmp)
+ t.Setenv("USERPROFILE", tmp)
+ t.Setenv("SUDO_USER", who.Username)
+ got, err := planLoginHome()
+ if err != nil || got != who.HomeDir {
+ t.Fatalf("under sudo: got %q, %v; want %q", got, err, who.HomeDir)
+ }
+ t.Setenv("SUDO_USER", "")
+ got, err = planLoginHome()
+ if err != nil || got != tmp {
+ t.Fatalf("without sudo: got %q, %v; want %q", got, err, tmp)
+ }
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+#!/bin/sh
+# Tests install.sh --plan-logins: the dry run lists the steps and what it found, a missing or signed-out tool is
+# skipped with a clear line, only the signed-in tools' login files are named, and a login file's content is never read
+# or printed. OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN and OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY run without root,
+# without a service and without touching the real home or config.
+set -eu
+
+here="$(cd "$(dirname "$0")" && pwd)"
+installer="$here/../install/install.sh"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
+
+home="$tmp/home"
+bin="$tmp/bin"
+# A clean PATH: the fake tools, then the system's own. The invoking user's PATH may have the real Command Code on it.
+path="$bin:/usr/bin:/bin"
+mkdir -p "$home/.claude" "$home/.codex" "$home/.local/share/opencode" "$bin"
+# The installer needs python3; a clean PATH still has to find it (it may live in /usr/local/bin).
+ln -s "$(command -v python3)" "$bin/python3"
+for tool in claude codex opencode; do
+ printf '#!/bin/sh\nexit 0\n' > "$bin/$tool"
+ chmod +x "$bin/$tool"
+done
+# Claude Code and OpenCode are signed in; Codex is installed but its login file is empty; Command Code is not installed.
+printf '%s\n' '{"access_token":"CLAUDE-LOGIN-SECRET"}' > "$home/.claude/.credentials.json"
+: > "$home/.codex/auth.json"
+printf '%s\n' '{"token":"OPENCODE-LOGIN-SECRET"}' > "$home/.local/share/opencode/auth.json"
+
+dry="$(HOME="$home" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 sh "$installer" --plan-logins 2>&1)" ||
+ fail "the dry run exited non-zero"
+check() { printf '%s\n' "$dry" | grep -F -- "$1" >/dev/null || fail "the dry run is missing: $1"; }
+check '1. pair this computer'
+check '2. set the openvibe-node service'
+check '3. name the coding tools'
+check '4. write the names'
+check 'claude-code: signed in (~/.claude/.credentials.json): named for your jobs'
+check 'opencode: signed in (~/.local/share/opencode/auth.json): named for your jobs'
+check "codex: installed but not signed in: skipped (run \`codex login\`"
+check "command-code: not installed: skipped (install it and sign in with \`cmd login\`"
+for secret in CLAUDE-LOGIN-SECRET OPENCODE-LOGIN-SECRET; do
+ printf '%s\n' "$dry" | grep -F -- "$secret" >/dev/null && fail "the dry run printed a login's content"
+done
+
+# A login file that is there is named even when its CLI is not on this PATH (sudo's secure_path, or a user-local CLI):
+# the line says to install that CLI system-wide so a job can run it.
+home2="$tmp/home2"
+mkdir -p "$home2/.commandcode"
+printf '{}\n' > "$home2/.commandcode/auth.json"
+dry2="$(HOME="$home2" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 sh "$installer" --plan-logins 2>&1)" ||
+ fail "the second dry run exited non-zero"
+printf '%s\n' "$dry2" | grep -F 'command-code: signed in (~/.commandcode/auth.json) but cmd is not on the job runner' >/dev/null ||
+ fail "a login without its CLI on PATH was not named with a warning"
+
+conf="$tmp/config.json"
+existing="$home/.codex/old.json"
+printf '{"server": "https://openvibe.bot", "plugins": [{"name": "dryrun"}], "worker": {"enabled": true, "plan_logins": ["%s"]}}\n' "$existing" > "$conf"
+written="$(HOME="$home" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_CONFIG_ONLY="$conf" sh "$installer" --plan-logins 2>&1)" ||
+ fail "writing worker.plan_logins exited non-zero"
+for secret in CLAUDE-LOGIN-SECRET OPENCODE-LOGIN-SECRET; do
+ printf '%s\n' "$written" | grep -F -- "$secret" >/dev/null && fail "the config run printed a login's content"
+ grep -F -- "$secret" "$conf" >/dev/null && fail "a login's content reached config.json"
+done
+python3 - "$conf" "$home" <<'PY' || fail "worker.plan_logins was not written as expected"
+import json, os, stat, sys
+path, home = sys.argv[1], sys.argv[2]
+c = json.load(open(path))
+w = c['worker']
+assert c['server'] == 'https://openvibe.bot' and c['plugins'] == [{'name': 'dryrun'}], 'the rest of the config stays'
+assert w['enabled'] is True, 'the worker section stays'
+want = sorted({os.path.join(home, '.codex', 'old.json'),
+ os.path.join(home, '.claude', '.credentials.json'),
+ os.path.join(home, '.local', 'share', 'opencode', 'auth.json')})
+assert w['plan_logins'] == want, w['plan_logins']
+assert stat.S_IMODE(os.stat(path).st_mode) == 0o600, 'the config stays private'
+PY
+
+# --setup is the card path: it cannot carry --plan-logins (which turns the worker on).
+if HOME="$home" PATH="$path" OPENVIBE_INSTALL_PLAN_LOGINS_DRY_RUN=1 \
+ sh "$installer" --plan-logins --setup "osk_$(printf '%043d' 0)" >/dev/null 2>&1; then
+ fail "--plan-logins was accepted with --setup"
+fi
+
+echo "install.sh --plan-logins: ok"