openvibers/openvibe.link Commits

install: --plan-logins names the owner's coding-tool logins (#118)

OP OpenVibers committed 6 days ago · 9c81c07a40 · 8 files, +325 −6

Pairing a workstation for Codes can now set up the plan logins in the
same command: --plan-logins turns on the worker, names the Claude Code,
Codex, OpenCode and Command Code login files that are signed in, skips
the rest with their own login command, and gives the service the
owner's HOME. The installer only checks that each file is there, never
reads or prints it.

The worker config now resolves "the Node user's home" to the sudo
user's home when the CLI runs under sudo, so install, pair and later
sudo commands see the same folder as the service. Tests cover the dry
run steps, missing and signed-out tools, the named files, no content
in the log or config, and the sudo home.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 16447cd..bfbb0bf 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -164,11 +164,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- 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.sh
esp32-conformance:
ddiff --git a/README.md b/README.md
iindex 300b3c3..a45691a 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
(`codes.worker`, `desks.host`, `ai.host`, `bot.robot`). The Node reads them at its check-in, keeps them, and applies
ddiff --git a/docs/install.md b/docs/install.md
iindex c730d61..8ca5e2c 100644
---- a/docs/install.md
++++ b/docs/install.md
@@@ -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.
ddiff --git a/docs/worker.md b/docs/worker.md
iindex 0d131a2..26eb2f4 100644
---- a/docs/worker.md
++++ b/docs/worker.md
@@@ -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 win
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*
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.
++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
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
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
ddiff --git a/install/install.sh b/install/install.sh
iindex de87fa7..9e7ee0c 100755
---- a/install/install.sh
++++ b/install/install.sh
@@@ -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 -eu
REPO_URL="https://github.com/OpenVibers/OpenVibe.Node"
@@@ -91,6 +101,7 @@ SERVICE=1
LOCAL=""
TAG="latest"
WORKER=0
++PLAN_LOGINS=0
COMPUTER=""
DESK_HOST=0
SHARED=0
@@@ -111,13 +122,14 @@ CODES_DIR=/opt/openvibe-codes
usage() {
echo "usage: install.sh [--robot rob_… | --network URL --pairing pair_…] [--code CODE | CODE] [--name NAME]"
-- echo " [--driver adeept|adeept-mecanum|cozmo|looi|ros2|ros2-holonomic|none] [--worker]"
++ echo " [--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" ]; then
printf '%s' "$SETUP" | grep -Eq '^osk_[A-Za-z0-9_-]{43}$' || die "--setup takes the osk_… key from your card files (openvibe.bot/setup/sd-card)"
[ -z "$CODE$ROBOT_ID$NETWORK$PAIRING" ] || die "--setup is used on its own (no --robot, --code, --network or --pairing)"
-- if [ "$CLAIM_OK" = 0 ] || [ "$WORKER" = 1 ] || [ -n "$COMPUTER" ] || [ "$DESK_HOST" != 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
# 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 ]; then
pair_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 0
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.
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"
++ fi
if ! id openvibe-job >/dev/null 2>&1; then
$SUDO useradd --system --no-create-home --shell /usr/sbin/nologin --user-group openvibe-job 2>/dev/null ||
$SUDO adduser --system --no-create-home --shell /usr/sbin/nologin --group openvibe-job
@@@ -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
++ fi
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 " worker.functions[openvibe-codes].env in $CONF/config.json, and install agent CLIs system-wide"
ddiff --git a/internal/config/config.go b/internal/config/config.go
iindex 58beff1..a2578f9 100644
---- a/internal/config/config.go
++++ b/internal/config/config.go
@@@ -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)
ddiff --git a/internal/config/planlogins_test.go b/internal/config/planlogins_test.go
iindex 185d971..3f5bfa6 100644
---- a/internal/config/planlogins_test.go
++++ b/internal/config/planlogins_test.go
@@@ -2,7 +2,9 @@ package config
import (
"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)
++ }
++}
ddiff --git a/scripts/test-install-plan-logins.sh b/scripts/test-install-plan-logins.sh
nnew file mode 100755
iindex 0000000..e5add40
---- /dev/null
++++ b/scripts/test-install-plan-logins.sh
@@@ -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"