diff --git a/.github/workflows/deploy-lint.yml b/.github/workflows/deploy-lint.yml index 9b3290b0..d092c5cc 100644 --- a/.github/workflows/deploy-lint.yml +++ b/.github/workflows/deploy-lint.yml @@ -5,7 +5,7 @@ # plist ships silently and only fails on the operator's box. This workflow is the cheap gate: it checks # STRUCTURE, not runtime behaviour. # -# - worker.service / receiver.service : `systemd-analyze verify` parses the units. The PLACEHOLDER +# - worker.service / receiver.service / console.service : `systemd-analyze verify` parses the units. The PLACEHOLDER # paths (/opt/pi-dispatch, /usr/bin/node) do not exist on the runner, so path-existence warnings are # EXPECTED and ignored; only genuine syntax errors (unknown directives, parse failures) fail. # - com.pi-dispatch.worker.plist : `xmllint --noout` proves the plist is well-formed XML. plutil is @@ -61,7 +61,7 @@ jobs: # verify exits non-zero for the missing PLACEHOLDER exec paths, which is expected -- so gate on # the CONTENT of the diagnostics, not the exit code. A real defect is an unknown directive or a # parse failure; a missing /opt/pi-dispatch or /usr/bin/node is not. - systemd-analyze verify --no-pager deploy/worker.service deploy/receiver.service > verify.log 2>&1 || true + systemd-analyze verify --no-pager deploy/worker.service deploy/receiver.service deploy/console.service > verify.log 2>&1 || true cat verify.log if grep -Eiq 'Unknown (key name|lvalue|section)|Failed to parse|Invalid setting|not a valid|expected ' verify.log; then echo "::error::systemd-analyze found a syntax error in a unit file. See the log above." @@ -96,6 +96,34 @@ jobs: fi echo "OK: the rendered openshell unit is syntactically valid" + # Issue #708: the console's unit as `service install --console` renders it: the whole ExecStart replaced by the + # console package's entry with `serve --deployment`, which the template alone cannot show. The admin package is + # built first, since its dist/console.mjs is the entry the render resolves (and refuses without). + - name: a rendered console unit parses (issue #708) + run: | + node admin/build.mjs + d="$RUNNER_TEMP/render-708" + mkdir -p "$d" + : > "$d/.env" + (cd "$d" && node "$GITHUB_WORKSPACE/worker/src/cli.mjs" service render --console) > "$d/pi-dispatch-console.service" + cat "$d/pi-dispatch-console.service" + grep -Eq "^ExecStart=/[^ ]*node $GITHUB_WORKSPACE/admin/dist/console\.mjs serve --deployment $d\$" "$d/pi-dispatch-console.service" || { echo "::error::the rendered console unit does not run the console package's entry with serve --deployment"; exit 1; } + grep -qx 'RestartPreventExitStatus=2' "$d/pi-dispatch-console.service" || { echo "::error::the rendered console unit lost RestartPreventExitStatus=2"; exit 1; } + # And one folder holding characters a unit must quote or escape (a space, a quote, % and $), so the quoting + # (`systemdWord`, `%%` in WorkingDirectory=) is parsed by systemd itself, not only by the unit tests' reader. + q="$RUNNER_TEMP/it's 100% \$HOME" + mkdir -p "$q" "$d/quoted" + (cd "$q" && node "$GITHUB_WORKSPACE/worker/src/cli.mjs" service render --console) > "$d/quoted/pi-dispatch-console.service" + grep -q '^WorkingDirectory=.*100%% \$HOME$' "$d/quoted/pi-dispatch-console.service" || { echo "::error::the quoted folder's % is not doubled in WorkingDirectory="; exit 1; } + cat "$d/quoted/pi-dispatch-console.service" + systemd-analyze verify --no-pager "$d/pi-dispatch-console.service" "$d/quoted/pi-dispatch-console.service" > verify-708.log 2>&1 || true + cat verify-708.log + if grep -Eiq 'Unknown (key name|lvalue|section)|Failed to parse|Invalid setting|not a valid|expected ' verify-708.log; then + echo "::error::systemd-analyze found a syntax error in the rendered console unit. See the log above." + exit 1 + fi + echo "OK: the rendered console unit is syntactically valid" + - name: plist is well-formed XML run: | xmllint --noout deploy/com.pi-dispatch.worker.plist \ diff --git a/README.md b/README.md index fd028ff8..2997483b 100644 --- a/README.md +++ b/README.md @@ -458,7 +458,8 @@ pi-dispatch service restart --drain # pause, wait for running jobs, restart, r commands instead). A unit uses the folder you run `install` from as its working directory and reads that folder's `.env`. `--receiver` installs the receiver's unit instead of the worker's (the receiver package must be installed -beside the worker), and `--env-setup ` wraps the worker in +beside the worker), `--console` installs the operator console's (`pi-dispatch-console serve`, once the admin +package is installed and a console token with the operator role exists: [`docs/console.md`](docs/console.md)), and `--env-setup ` wraps the worker in your secrets manager's setup script ([`docs/secrets.md`](docs/secrets.md)). On rootless Podman (`PI_BACKENDS=podman`) it also installs Valkey, the egress proxy and the proxy's network keeper as systemd units (Podman's Quadlet format), and `up` offers the same ([`docs/podman.md`](docs/podman.md)). Run one diff --git a/deploy/console.service b/deploy/console.service new file mode 100644 index 00000000..2a6fec9c --- /dev/null +++ b/deploy/console.service @@ -0,0 +1,52 @@ +# UNTESTED EXAMPLE (DES-OPERATOR-CONSOLE) -- a starting point, not a shipped, verified unit. Adapt it. +# +# The operator console (`pi-dispatch-console serve`, issue #640) as a service, so it keeps running after the terminal +# that started it closes (issue #708). `pi-dispatch service install --console` renders this file for THIS host: the +# node, the console package's own entry (admin/dist/console.mjs of @edgehero/pi-dispatch-admin, wherever npm put it) +# and the deployment folder are substituted, so prefer that to adapting it by hand. +# +# The console listens on PI_CONSOLE_LISTEN (default 127.0.0.1:4790, loopback only) and reads its tokens file +# (PI_CONSOLE_TOKENS_FILE, default console-tokens.json in the deployment folder). It never needs Docker or Podman, so +# this unit is ordered after nothing but the network and has no Quadlet companion. Reach it from another machine +# with ssh -L, never by binding a public address: it speaks plain HTTP (docs/console.md). +# +# NO EnvironmentFile= on purpose: `serve --deployment` reads the deployment's `.env` itself, takes only its own +# PI_CONSOLE_* keys and the Valkey keys it needs from it, and refuses a `.env` another account can change. Loading the +# whole file into the process environment would hand it every worker secret (the provider key, the forge token) and +# would make each of those keys read as this shell's, which wins over the file and skips that trust check. +# WorkingDirectory / User / node path below are PLACEHOLDERS: set them to wherever your deployment folder is and +# whoever owns it (the tokens file must be owned by that account, mode 0600, or serve refuses). + +[Unit] +Description=pi-dispatch operator console (the web console over the queue, runs and spend; loopback by default) +# For a system unit. In a --user unit these two are inert (the user manager has no network-online.target), which is +# harmless: the console binds loopback or a socket, and its Valkey reads retry on their own. +After=network-online.target +Wants=network-online.target +# Crash-loop bound, as the worker and receiver units carry it: at most StartLimitBurst restarts within +# StartLimitIntervalSec, then systemd stops trying. Pairs with Restart=on-failure below. +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +User=pi +WorkingDirectory=/opt/pi-dispatch +ExecStart=/usr/bin/node /usr/lib/node_modules/@edgehero/pi-dispatch-admin/dist/console.mjs serve --deployment /opt/pi-dispatch +Restart=on-failure +RestartSec=5 +# Exit 2 is the console's refusal: no tokens file, no operator token in it, an unusable PI_CONSOLE_LISTEN, or the +# single-instance lock held by another console of this deployment. The next start reads the same files and fails +# the same way, so it is never restarted; add a token (`pi-dispatch-console token add`) or fix the setting, then +# start the unit again. +# A crash leaves the lock (/console-audit/.console.lock) behind holding a dead pid, which the next start +# takes over. But if that pid has since been reused by another process (or by another account's, which reads as +# alive), every start refuses with exit 2 and the unit stays stopped: when the pid the refusal names is not a console, +# remove the lock file and start the unit again. +RestartPreventExitStatus=2 +# serve handles SIGTERM: it ends the open streams, closes the listener and releases the lock, then exits. +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/worker-env-wrapper.cmd b/deploy/worker-env-wrapper.cmd index 5366bde5..7e5d39c2 100644 --- a/deploy/worker-env-wrapper.cmd +++ b/deploy/worker-env-wrapper.cmd @@ -54,6 +54,10 @@ REM number. So after the load the wrapper re-asserts its own: ENV_SETUP and PI_E REM %PI_ENV_SETUP%, which cmd expands when it READS this parenthesized block, before its first line runs REM (`set /?`, the reason delayed expansion exists), so the value is the unit's and no .env line reaches it; REM and ERRORLEVEL cleared just before the command runs. RC is assigned after the command, before it is read. +REM PI_WRAPPER_NO_DOTENV=1 (issue #708): the command reads the deployment's .env ITSELF and takes only the keys it +REM owns (the operator console, `serve --deployment`), so .env is neither loaded nor required here; see +REM worker-env-wrapper.sh for why. Read before the load, from the service's own environment, and cleared after it. +if "%PI_WRAPPER_NO_DOTENV%"=="1" goto wrapper_env_loaded if exist ".env" ( for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do set "%%A=%%B" set "ENV_SETUP=%PI_ENV_SETUP%" @@ -65,6 +69,8 @@ if exist ".env" ( ) echo worker-env-wrapper: no .env in "%CD%" -- the environment comes from "%ENV_SETUP%" ^(PI_ENV_SETUP^) 1>&2 ) +:wrapper_env_loaded +set "PI_WRAPPER_NO_DOTENV=" REM AFTER .env, deliberately: the manager is the newer source of truth, so a stale key left in the file REM loses instead of silently shadowing the managed one (mirrors the .sh twin). A missing or failing diff --git a/deploy/worker-env-wrapper.sh b/deploy/worker-env-wrapper.sh index 08b6ac32..27e61617 100644 --- a/deploy/worker-env-wrapper.sh +++ b/deploy/worker-env-wrapper.sh @@ -77,7 +77,15 @@ trap wrapper_stopped_early TERM INT # until the `shift` below. set -- "${PI_ENV_SETUP:-}" "$@" -if [ -f ./.env ]; then +# PI_WRAPPER_NO_DOTENV=1 (issue #708) is set by the unit for a command that reads the deployment's .env ITSELF and +# takes only the keys it owns: the operator console (`serve --deployment`), which also refuses a .env another account +# can change. Sourcing the whole file here would hand that process every worker secret and make each key read as its +# own shell's, which wins over the file and skips that check. So ./.env is not sourced and not required. Tested +# before the load, from the unit's environment, so no .env line can set it; PI_ENV_SETUP is honoured exactly as +# without it (an operator who configured a script asked for it). Unset after, so the command never sees it. +if [ "${PI_WRAPPER_NO_DOTENV:-}" = "1" ]; then + : +elif [ -f ./.env ]; then set -a; . ./.env; set +a elif [ -z "$1" ]; then echo "worker-env-wrapper: .env not found in $PWD -- this wrapper must be started in the deployment folder (the unit's WorkingDirectory / nssm AppDirectory); it no longer guesses a location from its own path" >&2 @@ -90,6 +98,7 @@ fi # AFTER THE LOAD, every variable this wrapper reads is assigned, so no assignment line of ./.env # reaches one. The worker sees the unit's PI_ENV_SETUP too, never one the file set (the setup script, # which is unit configuration, may still set its own). +unset PI_WRAPPER_NO_DOTENV env_setup=$1 shift if [ -n "$env_setup" ]; then diff --git a/docs/console.md b/docs/console.md index 9941ba77..a672dc00 100644 --- a/docs/console.md +++ b/docs/console.md @@ -80,6 +80,43 @@ Host it likes, so against a job the token is what holds. A socket only you can o Docker Desktop, use it. It also stops another account from spending the login rate limit and slowing your own sign-in. +## Keep it running as a service + +`serve` stops with its terminal. To keep the console running, install it as a service from the deployment folder: + +```bash +pi-dispatch service install --console +``` + +That is a LaunchAgent on macOS, a `systemctl --user` unit on Linux (`--system` prints the sudo commands instead) and +an nssm service on Windows, like the worker's ([`README.md`](../README.md#run-as-a-service)). The unit runs +`pi-dispatch-console serve --deployment `, so it reads this folder's `.env` and tokens file. +`service status --console`, `start`, `stop`, `restart` and `uninstall` take the same flag. + +`install` refuses in two cases, before it writes anything: + +- the console is not installed where it can find it. Install the admin package where the worker is installed: + `npm install -g @edgehero/pi-dispatch-admin` beside a global worker, or `npm install @edgehero/pi-dispatch-admin` in + the folder whose `node_modules` holds the worker. A `pi-dispatch-console` on your PATH is used only when it is that + package's own file (a pnpm or Volta shim is not), and on Windows the PATH is not searched; +- the tokens file holds no operator token: `serve` would refuse to start, so the service would never run. Add one + with `pi-dispatch-console token add --name --role operator` first. + +When `serve` refuses (exit 2: no operator token, another console holding the lock, an unusable address), the service +stays stopped instead of restarting. Fix the cause, then `pi-dispatch service start --console`. + +After a crash the lock (`console-audit/.console.lock` in your logs folder) still names the dead console's pid, and the +next start takes it over. If another process has taken that pid since, every start refuses and names it: when that +pid is not a console, remove the lock file and start the service again. + +The service does not load your `.env` into its environment: `serve --deployment` reads the file itself and takes only +the console's own settings from it, so the worker's secrets never reach the console process. + +`pi-dispatch doctor` says whether a console service is installed for the deployment and whether the console answers +on its address (one `GET /healthz` to the loopback address or the socket, never anywhere else). An installed service +that does not answer is a failure; no service and nothing running is a warning. `pi-dispatch up` names the install +command once an operator token exists. + ## From another machine Forward a local port over SSH and open it in your browser. To a console on the default TCP port: @@ -203,6 +240,9 @@ set). It then checks: - `PI_CONSOLE_LISTEN` is a value `serve` would accept; - `PI_CONSOLE_BIND_PUBLIC=1` (a warning, every time); - the audit chain verifies (a warning when it does not). +- whether a console service is installed for this folder (`pi-dispatch service install --console`) and whether the + console answers on its address: a failure when the service is installed and nothing answers, a warning when there + is no service and nothing runs. ## The screens diff --git a/specs/design.md b/specs/design.md index 9966c259..8c1b5056 100644 --- a/specs/design.md +++ b/specs/design.md @@ -1817,7 +1817,11 @@ money with no upstream turn limit (`REQ-RUNNER-TURN-BUDGET`). `nssm set … AppEnvironmentExtra`) and the existing `worker-env-wrapper.sh` / `.cmd` sources it after `./.env`; `ProgramArguments` and `AppParameters` do not change at all, and with the variable set `./.env` becomes optional. With no flag, every rendered artifact is byte-identical to what shipped - before. + before. (AMENDED 2026-10-21, #708: an argv word that sh's double quotes or systemd's single-quoted word cannot carry + as itself, such as a console unit's deployment folder holding `'`, `$` or `%`, goes AFTER the script as its own + systemd word and the script runs `exec "$$@"`; the prefix `readUnitSeam` reads is the same, and a plain argv keeps + the line above byte for byte. `%` is refused in the setup path on Linux, beside `'`, `"`, `$` and `\`, since + systemd expands specifiers inside that word too.) - **Why**: The command already composed the whole `ExecStart`, so an operator running a manager had one option: hand-edit the rendered unit. The obvious hand-edit is wrong in a way that costs money. Measured against Infisical's CLI: `infisical run -- ` collapses every nonzero child exit to `1`, @@ -11908,6 +11912,61 @@ a tunnel. and the deployment folder, nothing in #640 depends on the host, and the audit's Valkey mirror (`console:audit`) is how such a console would read the log. Where run transcripts can be shown was `OQ-044`, resolved by #643: from the Valkey stream the worker publishes, so a console on any host shows any host's live run. +- **As a service** (2026-10-21, #708): `pi-dispatch service install --console` (worker/src/service.mjs) installs the + console as the third component beside the worker and `--receiver` (mutually exclusive with it: every subcommand + addresses one unit). One `execArgvFor(ctx)` gives every service manager the command (there were three copies, one + per manager); the console's is `[, , "serve", "--deployment", ]`, so it reads this + folder's `.env` and tokens even where a manager's working directory were not honoured. One systemd template, + `deploy/console.service` (mirrored, pinned in `TEMPLATE_PINS`, `RestartPreventExitStatus=2`, ordered after the + network alone, inert in a user unit), and the launchd and nssm forms derived from the worker's as the receiver's + are (label `com.pi-dispatch.console`, `console.{out,err}.log`, `AppExit 2 Exit`), so `serve`'s refusals (no + operator token, the lock held, an unusable address) leave it stopped on every OS. A crash leaves the lock holding a + dead pid, which the next start takes over; a pid since reused (or another account's, which reads as alive) keeps + every start refusing, so the unit's comment, docs/console.md and doctor's fix say to remove the lock when its pid is + not a console. **No `.env` in the console's environment** (round 1 review): the unit has no `EnvironmentFile=`, + and the launchd and nssm forms set `PI_WRAPPER_NO_DOTENV=1`, which `worker-env-wrapper.sh` and `.cmd` test before + their load (so no `.env` line can set it) and then neither source nor require `./.env`, `PI_ENV_SETUP` still run + as configured. `serve --deployment` reads the file itself, only its own keys and only when no other account can + change it; loaded whole, every worker secret would sit in the console's environment and each key would read as the + shell's, which wins over the file and skips that check. **Paths are quoted per form**: each ExecStart word is a + systemd word (`systemdWord`: bare when plain, else double-quoted with `\` and `"` escaped, `%` and `$` doubled), the + `--env-setup` form keeps its old shape while every argv word is plain and otherwise passes the argv after the + script (`exec "$$@"`), WorkingDirectory doubles `%`, every plist string has `&`, `<` and `>` escaped (no string + lands in an attribute, so quotes stay literal), and `readUnitSeam` undoes both, for the folder and the setup path; + a control character (anywhere) and surrounding whitespace (Linux) are refused before anything renders, and `%` + joins the Linux `--env-setup` refusals. A `--` in a macOS folder is NOT refused: plutil accepts it in the template's + comments, and with `>` escaped a path cannot write `-->`. Worker and receiver renders of plain paths are + byte-identical to before. Two consequences for existing installs, both deliberate: a unit whose paths hold a + character outside the bare class (`[A-Za-z0-9_@+=:,./-]`, or on macOS `&`, `<`, `>`) renders different bytes after + an upgrade and a re-render (`--force`), both forms being valid and the old one having split or expanded that path, + so a path that was broken now works; and on Windows `"`, `%`, `&`, `|`, `<`, `>` and `^` are now refused in ANY + command word, the worker's node and cli paths included, where `nssm` and the wrapper's `%*` would already have + split or rewritten them. Scope as the receiver's: user by default, + `--system` staged and printed. No cross-scope refusal: a second console is refused by `serve`'s own lock, not + destructive. The entry is `import.meta.resolve("@edgehero/pi-dispatch-admin/dist/console.mjs")` when that file + exists (the admin package is not a worker dependency and has no `exports` map, and `import.meta.resolve` does not + check existence, measured on node 23.5), else the REAL path of the `pi-dispatch-console` on this shell's PATH, taken + only when it ends in `dist/console.mjs` beside a package.json naming the admin package (a pnpm or Volta shim is + refused: `node ` would crash at every start); the PATH lookup does not run on Windows, which the refusal + says. With neither, render and install refuse, saying to install the admin package where this worker is + installed. `--drain` is refused for the receiver and the console (it drains the worker's queue). `install` also refuses when the tokens file the + SERVICE would read (PI_CONSOLE_TOKENS_FILE from the deployment `.env`, through the reader the console uses) holds no + operator token or cannot be used, since the unit would then exit 2 on its first start and stay stopped; `up` + judges the same file through the same helper (`consoleTokensPathFor`). `doctor` + (`consoleServiceChecks`) names an installed console unit for this folder (`installedUnitPaths` gains the console; + `readUnitSeam`'s WorkingDirectory; `nssm status pi-dispatch-console` on Windows) and whether the console answers + (worker/src/console-probe.mjs, imported lazily: one `GET /healthz`, `Host: 127.0.0.1`, 1 s, to the loopback address + as written, `127.0.0.1` or `::1` for an any-address bind, or the unix socket; port 0, a non-loopback address and an + unparseable value answer "unknown" and contact nothing): ✗ when a unit is installed and nothing answers, ⚠ when + there is no unit and nothing runs, ✓ otherwise, and no line at all while `PI_CONSOLE_LISTEN` is one `serve` refuses + (its own ✗ says so). The worker exports the probe as `@edgehero/pi-dispatch/console-probe`, and the admin's + `/dispatch console` (#708 part 1) imports it, so there is one rule. `up` prints `pi-dispatch service install + --console` among its next steps once an operator token exists and no console unit is installed for the folder. Rejected: a Quadlet unit (the console runs no container); a unit naming the bare + `pi-dispatch-console` (a service manager's PATH is not the shell's, and launchd's cannot see an nvm or Homebrew + prefix); refusing at `render` over the token (it writes nothing, and the unit is right once a token exists); + judging liveness by the lock's pid (an alive pid is not an answering listener, and a recycled pid is not the + console); and probing a configured non-loopback address (a read-only command would send a request across a + network). - **The cookie's attributes**: `Secure` and the `__Host-` prefix are set only if Chrome, Firefox and Safari keep such a cookie on `http://127.0.0.1` and `http://localhost`, where the console is served without TLS; a browser that drops it would make login silently fail. Measured on 2026-10-14 with a server setting three cookies over plain @@ -13685,3 +13744,4 @@ a tunnel. | 2026-10-21 (#712) | Issue #712, PR C: doctor results per host. **NEW `DES-DOCTOR-RESULTS-PUBLISHED`**: every doctor run publishes its checks per host (`doctor:h:`, 7 days), and the worker runs `pi-dispatch doctor --publish` as a child a minute after boot and every `PI_DOCTOR_INTERVAL_HOURS`; what is published (ids and levels; label and fix on a warn or fail only, redacted: paths to ``, URLs to scheme and host, secret values and token shapes to `[redacted]`, controls out, 300 characters), how ids are made (named where a console sentence matches, else `
.` over 33 pinned sections), best effort, the child's bounds (120 s kill, no overlap, killed on close, names and integers in its log lines); why the worker reruns doctor (a host nobody looks at is the one nobody has checked), why the redaction is stricter than the terminal's, why the labels are for both roles; rejected: doctor in the worker's process, raw labels, no TTL, a HASH, publishing only on hand runs, fix text only when fixed; residuals named. **`DES-OPERATOR-CONSOLE` AMENDED**, a dated "Doctor results" bullet: the snapshot's `doctor`, read at most every 15 s and never pruned from the console, the Fleet page's Doctor panel, the quoted "does not load" sentences, the HOSTS view's lines, both roles, not on `/api/state`. **`DES-RETENTION-SWEEPS-ON-A-TIMER` UNCHANGED, checked**: its interval idiom is reused by the doctor spawner, two divergences marked there (a delayed first run, a kill on close); the sweep itself is untouched. **Code evidence**: worker/src/doctor-results.mjs; worker/src/doctor-spawner.mjs; worker/src/doctor.mjs -> collectChecks, publishResults; worker/src/start.mjs; admin/src/doctor-view.mjs; admin/src/console/panels.mjs -> fleetDoctor, doctorReason; admin/src/dashboard.ts -> doctorLines. Review round 1, folded in before merge: **`DES-DOCTOR-RESULTS-PUBLISHED` AMENDED**, a Review round 1 bullet (the JSON.parse quote cut, the path scanner, live hosts before the index and the writer's prune, this host's record first in a quoted sentence, `by` and `workerRun`, the refused worker name, the internal `--by-worker`, the stderr tail and the process-group kill) and the residuals; **`DES-CLI-SURFACE` AMENDED**: doctor stays on the read-only tier, stated as read-only on the HOST with one write, its own results to Valkey, a display key nothing decides on. Review round 2, folded in before merge: **`DES-DOCTOR-RESULTS-PUBLISHED` AMENDED**, a Review round 2 bullet (invisible characters deleted first, the JSON.parse cut anchored to the message's start, more credential forms, numeric knob values not secrets, `--by-worker` bounded) and the residuals named: fullwidth and relative paths, the scp user name, the detached child that outlives a crashed worker. | | 2026-10-21 | Issue #712, part B: the Venues view. **`DES-OPERATOR-CONSOLE` AMENDED**, a dated bullet "The Venues view": the Fleet page's Venues panel, the hosts tables' Venues column and the panel's VENUES view (`v` from HOSTS) from one model, `venuesView`, over the snapshot's host rows (`hostRowOf` keeps `backends` and `caps`); every property as the table's question and word with `DECLARATION_TEXT`, `PROPERTY_LABEL`, `GATEWAY_TEXT`, each bolted by `admin/test/venues-view.test.mjs`; nothing resolved live (two rejected options: a live check from the console, a column on the HOSTS lines); the run lists' venue (a console column, a panel cell off `local` only, a `/dispatch runs` column only when a run is off `local`) and the `backend` runs filter (by `HOST_VENUE_NAME_RE`, a newer worker's venue included); a race fixed: a 7-day read that lands while VENUES is open is kept for HOSTS; the README images regenerated and the fixture's records naming `local`. **`DES-HOST-REGISTRY` UNCHANGED, checked**: the new field is display only, so deleting `host:*` still changes nothing (its contract is `INT-HOST-REGISTRY-CONTRACT`'s). **Code evidence**: worker/src/backends.mjs -> HOST_GATEWAY_WORDS, hostBackendsField, parseHostBackends; worker/src/backend-openshell.mjs -> openshellBootVersions (`gatewayLocal`); worker/src/start.mjs (the `backends` field); admin/src/read-model.mjs -> hostRowOf, venuesView, DECLARATION_TEXT, PROPERTY_LABEL, GATEWAY_TEXT; admin/src/console/panels.mjs -> fleetVenues, blessedText, runVenue; admin/src/dashboard.ts -> venuesPanelView, recordVenue; admin/src/render.mjs (VENUE); admin/src/run-filter.mjs (`backend`); launch/fixture-deployment.mjs. | | 2026-10-21 (#712) | Issue #712, part D: OpenShell run facts and the secret profiles view. **`DES-OPENSHELL-VENUE` AMENDED**, a dated "Recorded run facts" bullet: the stage sets `openshell` (`mode`, the written policy's endpoint hosts, the providers made, the keys they bind) once every provider exists, `collectRun` carries it on every result, the settle carries it to the record; the hosts are operator config (allowlist, model endpoints, the forge's fixed host; the repository is a push-rule path and is not read), held to the host-name charset and capped at 32 with the total counted; counts for credentials, never names; MCP connection state is named as not recorded (#684 records none). **`DES-OPERATOR-CONSOLE` AMENDED**, a dated bullet: the run page's "Sandbox policy", "Rule hosts" and "Secrets profile" rows and the panel's RUN_DETAIL lines; the configuration's "Secret profiles in use" table, the panel's secret profiles section and `/dispatch secrets list`'s profile lines (`secretProfilesView`: source, the roots by `resolverRootsStatus`, which the add's judgement now reads too, the triggers and projects, the last resolve as refused, resolved or not reached, and the hosts advertising `s:` through `hostRowOf`'s new `caps`); no resolver path on the console or the panel; `dispatch_runs` returns the facts and the profile name rebuilt, never a resolver path. Review round 1: the add's `resolverRootsJudgement` stays strict on its fs seam (no realpath throws) and only the view tolerates a row it cannot judge (`unresolved`); `hostRowOf` carries `capsSeen` so a host that publishes no list is counted; a `BUNDLE_SHARED_REASONS` refusal on a bundled run is said as either read's; `dispatch_runs`' list carries only the host count; no facts for a stage thrown or stopped before the exec or a detached run, said so; the bundles view's lexical roots check named as a residual. **`DES-CONTAINER-BACKEND-REGISTRY` UNCHANGED, checked**: no `serves` word or custody changes; the facts are recorded, not declared. **`DES-PROJECT-SECRET-BUNDLES` UNCHANGED, checked**: the bundle's own record keys and surfaces are as #704 left them; the openshell `placeholders` count includes the bundle hosts' keys beside `bundle.placeholders`, which it does not replace. **Code evidence**: worker/src/backend-openshell.mjs -> stageRun, collectRun; worker/src/index.mjs; worker/src/run-history.mjs -> recordedOpenshell, recordedSecretsProfile; worker/src/capabilities.mjs -> secretProfileNeeded (exported); admin/src/read-model.mjs -> secretProfilesView, resolverRootsStatus; admin/src/console/panels.mjs; admin/src/dashboard.ts; admin/src/secrets-command.ts. | +| 2026-10-21 (#708) | Issue #708, PR 3: the console as a service. **`DES-OPERATOR-CONSOLE` AMENDED**: an "As a service" bullet: `pi-dispatch service install --console`, the third component (mutually exclusive with `--receiver`), one `execArgvFor` for every manager, `deploy/console.service` (mirrored, `TEMPLATE_PINS`, `RestartPreventExitStatus=2`) with the plist (`com.pi-dispatch.console`) and nssm sequence derived as the receiver's, the receiver's scope, no Quadlet and no cross-scope refusal; the entry from `import.meta.resolve` of the admin package's `dist/console.mjs` when it exists, else the real path of `pi-dispatch-console` on PATH, refused with the npm line otherwise; install refused without an operator token in the tokens file the service reads; doctor's `consoleServiceChecks` (the unit by `readUnitSeam`, the answer by `worker/src/console-probe.mjs`: one `GET /healthz` with `Host: 127.0.0.1` and 1 s to loopback or the socket only, ✗ installed and silent, ⚠ no unit and nothing running, no line on a listen `serve` refuses); the worker exports the probe (`./console-probe`) and the admin's `/dispatch console` (#708 part 1) imports it, so there is one rule; `up`'s Next line. Five rejected options recorded. **`DES-CONCURRENCY-3` UNCHANGED, checked**: the cross-scope refusal stays the worker's; a second console is refused by `serve`'s lock. **`DES-SERVICE-ENV-SETUP-SEAM` AMENDED** (round 1 review): an argv word the old sh -c form cannot carry as itself goes after the script as a systemd word (`exec "$$@"`), the old form kept byte for byte otherwise, and `%` joins the Linux setup-path refusals. Round 1 review also: no `EnvironmentFile=` in the console unit and `PI_WRAPPER_NO_DOTENV=1` in its plist and nssm entry (both wrappers then source no `.env`), every ExecStart word quoted the systemd way and `%` doubled in WorkingDirectory, `&`, `<` and `>` escaped in every plist string (`readUnitSeam` undoes both, for the folder and the setup path), a control character or (Linux) surrounding whitespace in the folder refused and `--` on macOS accepted, Windows refusing `"%&\|<>^` in any command word (the worker's too), units with such paths rendering new bytes after a re-render, a PATH hit taken only when it is the admin package's own `dist/console.mjs`, `--drain` refused for the receiver and the console, the stale-lock case documented, `up` and install judging one tokens file and `up` silent where a console unit exists. **Code evidence**: worker/src/service.mjs -> COMPONENTS, COMPONENT_TEMPLATES, execArgvFor, systemdWord, composeEnvSetupExec, xmlText, renderRefusal, consoleTokensPathFor, installedConsoleUnitPath, resolveConsoleEntry, readConsoleOperators, refuseMissingConsole, refuseConsoleWithoutOperator, installedUnitPaths, renderPlist; worker/src/console-probe.mjs -> consoleProbeTarget, probeConsole; worker/src/doctor.mjs -> consoleServiceChecks, installedConsoleUnit; worker/src/up.mjs -> defaultConsoleOperators; deploy/console.service; deploy/worker-env-wrapper.sh and .cmd; .github/workflows/deploy-lint.yml. | diff --git a/specs/interfaces.md b/specs/interfaces.md index 6a838747..3256a52b 100644 --- a/specs/interfaces.md +++ b/specs/interfaces.md @@ -9882,7 +9882,12 @@ in. `sessions-ended`) with the actor `{name: "shell", role: "shell", session: null}`. The pi extension's `guardedFiles()` holds the file, so the operator's pi session cannot write it with `write` or `edit`. - **Read protocol**: the server reads it at start and on every request through the same reader, so a change - takes effect on the next request without a restart. + takes effect on the next request without a restart. Two more readers since issue #708, both through the same + reader and both read-only: `pi-dispatch service install --console` refuses when the file the SERVICE would read + (`PI_CONSOLE_TOKENS_FILE` from the deployment `.env`, never this shell's export) is refused or holds no `operator` + entry, the start refusal above made before a unit is written; and `pi-dispatch up` counts its `operator` entries + in that same file (one helper, `consoleTokensPathFor`) to decide whether to name that install among its + next steps. Neither prints a name, a hash or a count beyond "none". - **Acceptance**: `REQ-CONSOLE-SECURITY` (a) and (i). ## INT-CONSOLE-AUDIT-LOG @@ -10901,3 +10906,4 @@ the worker's, exported from `@edgehero/pi-dispatch/doctor-results`, so the reade | 2026-10-21 (#712) | Issue #712, PR C: doctor results per host. **NEW `INT-DOCTOR-RESULTS-KEY`**: `doctor:hosts` (a SET the reader prunes) and `doctor:h:` (a STRING written whole by `SET ... PX` 7 days, refreshed by every publish); the `v: 1` record (`at`, `version`, `openshell`, `every`, `counts` over every check, `checks` of `{ id, level, label?, fix? }`, `more`); the id rule and its two pinned lists; the content rule the writer applies; when it is written and to which Valkey; failure as one stderr line; the reader's allowlist, its one MGET over at most 64 worker names, `unreachable` apart from empty; deleting it changes nothing. **`INT-HOST-REGISTRY-CONTRACT` AMENDED**, a dated paragraph: doctor's keys are a separate keyspace under the same worker name, 7 days rather than 90 s, and they obey the same falsification test; no registry field is added. **`INT-CONSOLE-STATE-API` UNCHANGED, checked**: its body is built field by field and names no doctor field. **Code evidence**: worker/src/doctor-results.mjs -> doctorKey, DOCTOR_HOSTS, publishDoctorRecord, parseDoctorRecord, readDoctorResults, NAMED_CHECK_IDS, DOCTOR_SECTIONS; worker/src/doctor.mjs -> publishResults; admin/src/read-model.mjs -> createFleetReader. Review round 1, folded in before merge: **`INT-DOCTOR-RESULTS-KEY` AMENDED**: `by` and `workerRun` in the record, `every` from the worker's spawn, the content rule's order and its new rules (JSON.parse's quoted input, credential query values, scheme-less userinfo, four token shapes, the path scanner) with what it does not catch, the refused worker name, the writer's prune, the live hosts read before the index. Review round 2, folded in before merge: **`INT-DOCTOR-RESULTS-KEY` AMENDED**, the content rule: invisible characters deleted first, the further credential forms, numeric knob values not secrets; `--by-worker` is bounded to 1 to 168. | | 2026-10-21 | Issue #712, part B. **`INT-HOST-REGISTRY-CONTRACT` AMENDED**: a `backends` field, `blessed=[,...];floor=:[,...];os=:`, written once at boot by `hostBackendsField` and read back by `parseHostBackends` (both in `worker/src/backends.mjs`): the blessed venues in `PI_BACKENDS` order (the first the default), `PI_BACKEND_FLOOR`'s property names and words, and while an OpenShell venue is blessed its engine and a word for where its gateway is, from the boot's `openshell status` read (`openshellBootVersions` now returns `gatewayLocal`), never an address or a gateway name; names and fixed words only, so the content rule holds and the path-shape check never drops it; the reader re-checks every element and reads a row without it as null (a row that blesses an OpenShell venue with no `os` part is shown as not published, never as the failed-boot `unknown`); `HOST_VENUE_NAME_RE` is exported, the runs filter's `backend` shape; the consumers list gains the admin's host rows (`hostRowOf`, also carrying `caps` through `parseCaps`, sorted, which #712's PR D profiles view reads to show which hosts serve each profile), the console's Venues panel and hosts tables and the panel's VENUES view. Display only: no decision reads it, so deleting `host:*` still changes nothing. **`INT-RUN-HISTORY-FILE-CONTRACT` UNCHANGED, checked**: the run lists and the runs filter read the existing `backend` key, absence read as `local` as the worker reads it. | | 2026-10-21 (#712) | Issue #712, part D. **`INT-RUN-HISTORY-FILE-CONTRACT` AMENDED**: two conditional TAIL keys after `liveSkipped`. `openshell`, `{ mode, ruleHosts, ruleHostCount, providers, placeholders }`, on the two OpenShell venues only and only in the venue's own mode, written by the adapter's stage once its providers exist and carried by `collectRun` and the settle (`recordedOpenshell` rebuilds it): the hosts are the generated policy's endpoints, operator config (the allowlist, the model endpoints, the forge's fixed host; the repository is a push-rule path and is not read), held to the host-name charset, at most 32 with the total counted, so the record still holds no attacker-chosen string; counts for credentials, never a key name or a value; MCP connection state is named as not recorded. no `openshell` for a stage that throws or is stopped before the exec, or a detached run (review round 1); `dispatch_runs` lists carry only its `ruleHostCount`. `secretsProfile`, the profile NAME an armed job's `run.secrets` resolved through (`recordedSecretsProfile`, capabilities.mjs' `secretProfileNeeded`, the routing rule, held to `secretsArmed` and `resolveSecrets` by a test). **`INT-HOST-REGISTRY-CONTRACT` UNCHANGED, checked**: the admin reads the existing `caps` field through the worker's `parseCaps`; nothing is written. **`INT-CONSOLE-STATE-API` UNCHANGED, checked**: the snapshot gains `secretProfiles` and each host row `caps`, and the document is built field by field, so neither reaches it. **Code evidence**: worker/src/run-history.mjs -> recordedOpenshell, recordedSecretsProfile, buildRecord; worker/src/backend-openshell.mjs -> stageRun, collectRun; worker/src/index.mjs; admin/src/read-model.mjs -> recordOpenshell, recordSecretsProfile, modelRunRecord, hostRowOf. | +| 2026-10-21 (#708) | Issue #708, PR 3: the console as a service. **`INT-CONSOLE-TOKENS-FILE-CONTRACT` AMENDED**, its Read protocol: two read-only readers through the one reader, `pi-dispatch service install --console` (refuses when the file the service would read, `PI_CONSOLE_TOKENS_FILE` from the deployment `.env`, is refused or holds no `operator` entry, before a unit is written) and `pi-dispatch up` (counts the `operator` entries of that same file, one helper, for its Next line). Location, shape, modes, the start refusals and the write protocol UNCHANGED, checked. `INT-CONSOLE-STATE-API` UNCHANGED, checked: doctor's probe asks only `GET /healthz`, which `DES-OPERATOR-CONSOLE` already serves without a session and outside that API. | diff --git a/specs/requirements.md b/specs/requirements.md index 0bdc4e00..fd7711af 100644 --- a/specs/requirements.md +++ b/specs/requirements.md @@ -5067,7 +5067,11 @@ only one, its own ladder, OpenShell's pinned release first, then `.env`, `up`, ` `pi-dispatch service ` renders the shipped deploy/ templates with computed absolutes (`process.execPath`, the real repo root — the shipped `/usr/bin/node` literal does not exist on an nvm host) and installs **user-level** by - default; system-wide stays a printed sudo command, never executed. `render` and `install` also take + default; system-wide stays a printed sudo command, never executed. It addresses one service per + invocation: the worker, the receiver (`--receiver`) or, since issue #708, the operator console (`--console`, + `pi-dispatch-console serve --deployment `); the two flags are mutually exclusive. `install --console` + refuses, before it writes anything, when the console package cannot be found or when the tokens file the console + would read holds no operator token, since `serve` then exits 2 on every start and the unit would never run. `render` and `install` also take **`--env-setup `**, the one seam by which anything runs before the worker does: the renderer sources that script and then **`exec`s** the worker itself, so a determinate refusal still reaches the service manager as exit 2 (`DES-SERVICE-ENV-SETUP-SEAM`). Three clauses are normative. @@ -5258,6 +5262,7 @@ instead of drifting. | Date | Change | |---|---| +| 2026-10-21 (#708) | Issue #708, PR 3: the console as a service. **`REQ-DEPLOYMENT-BOOTSTRAP` AMENDED**, its Scope: `pi-dispatch service` addresses one service per invocation, the worker, `--receiver` or `--console` (the operator console, `pi-dispatch-console serve --deployment `), the two flags mutually exclusive; `install --console` refuses before writing anything when the console package cannot be found or the tokens file holds no operator token. User level by default, system scope printed, the `--env-setup` clauses (1) to (3) and doctor's unit read UNCHANGED, checked. **`REQ-CONSOLE-SECURITY` UNCHANGED, checked**: the unit runs the same `serve` with the same refusals, and doctor's probe sends one request to a loopback address or the socket, never across a network. | | 2026-10-21 (#712) | Issue #712, part D: OpenShell run facts and the secret profiles view. **`REQ-OPERATOR-CONSOLE-SCREENS` AMENDED**, (g): each secret profile's use on the configuration page (source, roots, triggers, projects, last resolve, advertising hosts), still with no resolver path, and the run page's sandbox policy, rule hosts and secrets profile; the names-only rule UNCHANGED, checked. **`REQ-TRIGGER-SECRETS` AMENDED**, a dated acceptance paragraph: an armed run records `secretsProfile` (a name); the console, the panel and `/dispatch secrets list` show each profile's use; no model-callable tool returns a resolver path. **`REQ-CONSOLE-SECURITY` UNCHANGED, checked**: every new value is escaped by the console's one helper and scrubbed in the panel, and nothing new reaches `/api/state` (`stateOf` builds it field by field). | | 2026-10-21 | Issue #712, part B: the Venues view. **`REQ-OPERATOR-CONSOLE-SCREENS` AMENDED**: (b) the runs filter gains `backend`, a venue name by the registry row's venue shape (so a venue a newer worker blesses, which the Venues view names, is a filter too), a record naming no venue read as `local`, and the run lists show each run's venue; (f) the hosts table shows each host's blessed venues and the Fleet page gains a Venues panel (every venue, the hosts that bless it and default to it, the floors, each property's question and word in sentences, `serves`, custody, the OpenShell engine, modes and gateway word per host), from the hosts' published facts and the static table, nothing resolved live; the panel's VENUES view (`v` from HOSTS) shows the same. **`REQ-CONSOLE-SECURITY` and `REQ-ADMIN-VIA-PI-EXTENSION` UNCHANGED, checked**: the new field is names and fixed words (never an address or a path), every value is escaped and scrubbed, no write and no tool is added. | | 2026-10-21 (#712) | Issue #712, PR C: doctor results per host, in the console and the panel. **`REQ-DEPLOYMENT-BOOTSTRAP` AMENDED**, a dated paragraph: every `pi-dispatch doctor` run and `up`'s doctor step publish the run's checks to the Valkey doctor talked to (`INT-DOCTOR-RESULTS-KEY`): ids and levels, and for a warning or a failure the label and fix with paths cut to ``, URLs to scheme and host, secret values redacted, kept 7 days; the one write doctor makes without asking, stated as a display key and not a host mutation, best effort (one stderr line, exit code and stdout unchanged), off with `--no-publish` (`--publish` beside it exits 2); the worker runs the same command a minute after boot and every `PI_DOCTOR_INTERVAL_HOURS` (24, 0 = never, at most 168) without `--live`, `--secrets` or `--fix`. **`REQ-OPERATOR-CONSOLE-SCREENS` AMENDED**, (f): the Fleet page's Doctor panel (`p-doctor`, after Hosts), stale past two of a host's intervals, a silent live host named, an unreadable read said; the configuration page's "does not load" sentences quote the newest published check about that file with its host and age; the `/dispatch` panel's HOSTS view gives each host the same lines. **`REQ-CONSOLE-SECURITY` UNCHANGED, checked**: the labels are doctor's own sentences after the publisher's redaction, about the deployment's configuration, not run text, so (l) does not reach them and both roles see them; every value is escaped after the control-byte scrub, as (e) asks, held by `admin/test/doctor-view.test.mjs` with a hostile label; `/api/state` (k) names no doctor field; no page carries a path or a value from them. **`REQ-ADMIN-VIA-PI-EXTENSION` UNCHANGED, checked**: no tool is added or changed. **Code evidence**: worker/src/doctor-results.mjs -> assignCheckIds, redactText, secretValuesIn, buildDoctorRecord, publishDoctorRecord, readDoctorResults; worker/src/doctor-spawner.mjs -> makeDoctorSpawner; worker/src/doctor.mjs -> collectChecks, bootFileId, publishResults, runDoctor; worker/src/cli.mjs; worker/src/up.mjs; worker/src/config.mjs; worker/src/start.mjs; admin/src/doctor-view.mjs; admin/src/read-model.mjs -> createFleetReader; admin/src/console/panels.mjs -> fleetDoctor, doctorReason; admin/src/dashboard.ts -> doctorLines. Review round 1, folded in before merge: **`REQ-DEPLOYMENT-BOOTSTRAP` AMENDED** again, its #712 paragraph: the record cuts the file text a JSON parse error quotes and records who ran it; the claims say what the rules guarantee rather than "never a value"; acceptance gains the refused triggers file. `REQ-OPERATOR-CONSOLE-SCREENS` (f) UNCHANGED, checked: the panel now says who ran a result and names a worker run it replaced, and the quoted sentence prefers this host's own record, within what (f) says. | diff --git a/worker/README.md b/worker/README.md index 092d4c56..6666d94a 100644 --- a/worker/README.md +++ b/worker/README.md @@ -46,7 +46,7 @@ with at least one commit, and `run` refuses any other folder before it queues an | `run --task "..."` | queue one job against a local folder | | `run --trigger ` | fire one cron trigger from the triggers file now, once, as its schedule would | | `status`, `pause`, `resume`, `cancel ` | steer the running worker from any terminal | -| `service render\|install\|status\|restart` | run the worker (or `--receiver`) as a service (user level on macOS and Linux, nssm on Windows) | +| `service render\|install\|status\|restart` | run the worker (or `--receiver`, or `--console` for the operator console) as a service (user level on macOS and Linux, nssm on Windows) | | `setup github` | create a GitHub App in one browser click | | `import-pi` | stage your own pi setup (models, skills, persona) for every job, without credentials | | `chat ` | follow and steer a running job, answer a parked one, or fork a finished one into your own pi, through the operator console | diff --git a/worker/deploy/console.service b/worker/deploy/console.service new file mode 100644 index 00000000..2a6fec9c --- /dev/null +++ b/worker/deploy/console.service @@ -0,0 +1,52 @@ +# UNTESTED EXAMPLE (DES-OPERATOR-CONSOLE) -- a starting point, not a shipped, verified unit. Adapt it. +# +# The operator console (`pi-dispatch-console serve`, issue #640) as a service, so it keeps running after the terminal +# that started it closes (issue #708). `pi-dispatch service install --console` renders this file for THIS host: the +# node, the console package's own entry (admin/dist/console.mjs of @edgehero/pi-dispatch-admin, wherever npm put it) +# and the deployment folder are substituted, so prefer that to adapting it by hand. +# +# The console listens on PI_CONSOLE_LISTEN (default 127.0.0.1:4790, loopback only) and reads its tokens file +# (PI_CONSOLE_TOKENS_FILE, default console-tokens.json in the deployment folder). It never needs Docker or Podman, so +# this unit is ordered after nothing but the network and has no Quadlet companion. Reach it from another machine +# with ssh -L, never by binding a public address: it speaks plain HTTP (docs/console.md). +# +# NO EnvironmentFile= on purpose: `serve --deployment` reads the deployment's `.env` itself, takes only its own +# PI_CONSOLE_* keys and the Valkey keys it needs from it, and refuses a `.env` another account can change. Loading the +# whole file into the process environment would hand it every worker secret (the provider key, the forge token) and +# would make each of those keys read as this shell's, which wins over the file and skips that trust check. +# WorkingDirectory / User / node path below are PLACEHOLDERS: set them to wherever your deployment folder is and +# whoever owns it (the tokens file must be owned by that account, mode 0600, or serve refuses). + +[Unit] +Description=pi-dispatch operator console (the web console over the queue, runs and spend; loopback by default) +# For a system unit. In a --user unit these two are inert (the user manager has no network-online.target), which is +# harmless: the console binds loopback or a socket, and its Valkey reads retry on their own. +After=network-online.target +Wants=network-online.target +# Crash-loop bound, as the worker and receiver units carry it: at most StartLimitBurst restarts within +# StartLimitIntervalSec, then systemd stops trying. Pairs with Restart=on-failure below. +StartLimitIntervalSec=60 +StartLimitBurst=5 + +[Service] +Type=simple +User=pi +WorkingDirectory=/opt/pi-dispatch +ExecStart=/usr/bin/node /usr/lib/node_modules/@edgehero/pi-dispatch-admin/dist/console.mjs serve --deployment /opt/pi-dispatch +Restart=on-failure +RestartSec=5 +# Exit 2 is the console's refusal: no tokens file, no operator token in it, an unusable PI_CONSOLE_LISTEN, or the +# single-instance lock held by another console of this deployment. The next start reads the same files and fails +# the same way, so it is never restarted; add a token (`pi-dispatch-console token add`) or fix the setting, then +# start the unit again. +# A crash leaves the lock (/console-audit/.console.lock) behind holding a dead pid, which the next start +# takes over. But if that pid has since been reused by another process (or by another account's, which reads as +# alive), every start refuses with exit 2 and the unit stays stopped: when the pid the refusal names is not a console, +# remove the lock file and start the unit again. +RestartPreventExitStatus=2 +# serve handles SIGTERM: it ends the open streams, closes the listener and releases the lock, then exits. +KillSignal=SIGTERM +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/worker/deploy/worker-env-wrapper.cmd b/worker/deploy/worker-env-wrapper.cmd index 5366bde5..7e5d39c2 100644 --- a/worker/deploy/worker-env-wrapper.cmd +++ b/worker/deploy/worker-env-wrapper.cmd @@ -54,6 +54,10 @@ REM number. So after the load the wrapper re-asserts its own: ENV_SETUP and PI_E REM %PI_ENV_SETUP%, which cmd expands when it READS this parenthesized block, before its first line runs REM (`set /?`, the reason delayed expansion exists), so the value is the unit's and no .env line reaches it; REM and ERRORLEVEL cleared just before the command runs. RC is assigned after the command, before it is read. +REM PI_WRAPPER_NO_DOTENV=1 (issue #708): the command reads the deployment's .env ITSELF and takes only the keys it +REM owns (the operator console, `serve --deployment`), so .env is neither loaded nor required here; see +REM worker-env-wrapper.sh for why. Read before the load, from the service's own environment, and cleared after it. +if "%PI_WRAPPER_NO_DOTENV%"=="1" goto wrapper_env_loaded if exist ".env" ( for /f "usebackq eol=# tokens=1,* delims==" %%A in (".env") do set "%%A=%%B" set "ENV_SETUP=%PI_ENV_SETUP%" @@ -65,6 +69,8 @@ if exist ".env" ( ) echo worker-env-wrapper: no .env in "%CD%" -- the environment comes from "%ENV_SETUP%" ^(PI_ENV_SETUP^) 1>&2 ) +:wrapper_env_loaded +set "PI_WRAPPER_NO_DOTENV=" REM AFTER .env, deliberately: the manager is the newer source of truth, so a stale key left in the file REM loses instead of silently shadowing the managed one (mirrors the .sh twin). A missing or failing diff --git a/worker/deploy/worker-env-wrapper.sh b/worker/deploy/worker-env-wrapper.sh index 08b6ac32..27e61617 100644 --- a/worker/deploy/worker-env-wrapper.sh +++ b/worker/deploy/worker-env-wrapper.sh @@ -77,7 +77,15 @@ trap wrapper_stopped_early TERM INT # until the `shift` below. set -- "${PI_ENV_SETUP:-}" "$@" -if [ -f ./.env ]; then +# PI_WRAPPER_NO_DOTENV=1 (issue #708) is set by the unit for a command that reads the deployment's .env ITSELF and +# takes only the keys it owns: the operator console (`serve --deployment`), which also refuses a .env another account +# can change. Sourcing the whole file here would hand that process every worker secret and make each key read as its +# own shell's, which wins over the file and skips that check. So ./.env is not sourced and not required. Tested +# before the load, from the unit's environment, so no .env line can set it; PI_ENV_SETUP is honoured exactly as +# without it (an operator who configured a script asked for it). Unset after, so the command never sees it. +if [ "${PI_WRAPPER_NO_DOTENV:-}" = "1" ]; then + : +elif [ -f ./.env ]; then set -a; . ./.env; set +a elif [ -z "$1" ]; then echo "worker-env-wrapper: .env not found in $PWD -- this wrapper must be started in the deployment folder (the unit's WorkingDirectory / nssm AppDirectory); it no longer guesses a location from its own path" >&2 @@ -90,6 +98,7 @@ fi # AFTER THE LOAD, every variable this wrapper reads is assigned, so no assignment line of ./.env # reaches one. The worker sees the unit's PI_ENV_SETUP too, never one the file set (the setup script, # which is unit configuration, may still set its own). +unset PI_WRAPPER_NO_DOTENV env_setup=$1 shift if [ -n "$env_setup" ]; then diff --git a/worker/package.json b/worker/package.json index 88772fb1..745252c0 100644 --- a/worker/package.json +++ b/worker/package.json @@ -48,6 +48,7 @@ "./git-hardening": "./src/git-hardening.mjs", "./http-body": "./src/http-body.mjs", "./console-tokens": "./src/console-tokens.mjs", + "./console-probe": "./src/console-probe.mjs", "./console-audit": "./src/console-audit.mjs", "./console-answer": "./src/console-answer.mjs", "./steer-inbox": "./src/steer-inbox.mjs", diff --git a/worker/src/cli.mjs b/worker/src/cli.mjs index 101e1f89..063ff7f5 100755 --- a/worker/src/cli.mjs +++ b/worker/src/cli.mjs @@ -47,8 +47,8 @@ const USAGE = `pi-dispatch — run pi coding-agent flows on your own folders pi-dispatch worker drain the queue (run this in another terminal, or as a service) pi-dispatch-receiver webhook receiver for forge triggers, its own bin (see docs/github.md) - pi-dispatch service [--receiver] [--user|--system] [--force] - run the worker (or --receiver) as an OS service — the deploy/ templates + pi-dispatch service [--receiver|--console] [--user|--system] [--force] + run the worker (or --receiver, or --console) as an OS service — the deploy/ templates rendered with this host's real paths, installed user-level; \`service restart --drain\` lets the in-flight job finish first pi-dispatch egress render diff --git a/worker/src/console-probe.mjs b/worker/src/console-probe.mjs new file mode 100644 index 00000000..469a6a1b --- /dev/null +++ b/worker/src/console-probe.mjs @@ -0,0 +1,106 @@ +/** + * Is the operator console answering (issue #708)? One `GET /healthz` (the console's unauthenticated liveness route, + * which answers `200 ok`) to the address `pi-dispatch-console serve` binds, for doctor's console line. + * + * The rule (the admin's `/dispatch console`, issue #708 part 1, imports this module rather than keeping a copy, so + * there is one rule): + * - the address is PI_CONSOLE_LISTEN as the console reads it (trimmed; unset or empty is 127.0.0.1:4790); + * - a loopback TCP address (127.0.0.0/8, ::1) is asked as written; + * - an any-address bind (0.0.0.0, ::) is asked on 127.0.0.1 or ::1, its own family's loopback, which it also binds; + * - a unix socket is asked through the socket (`socketPath`); + * - port 0 (the OS picks the port, so only the running console knows it), any other address, and a value the console + * would refuse answer "unknown" and contact nothing. NO OTHER ADDRESS IS EVER CONTACTED: a probe that followed a + * configured non-loopback address would send a request across a network from a command that only reads, and a + * hostname is never resolved (the console refuses one anyway); + * - the request carries `Host: 127.0.0.1`, which the console's rebinding guard accepts for every listener, and is + * given 1 s: a console on this host answers in milliseconds, and a doctor run must not hang on one that does not. + * + * It lives in the worker because the dependency runs one way: the worker cannot import the admin package (not a + * dependency), while the admin already imports the worker's subpath exports and bundles them, so it takes this one + * as `@edgehero/pi-dispatch/console-probe`. + * + * `request` is `node:http`'s `request` signature, a seam: a test records what is contacted and answers without a socket. + */ +import { request as httpRequest } from "node:http"; +import { BlockList, isIPv4, isIPv6 } from "node:net"; +import { isAbsolute } from "node:path"; + +/** The console's default listen address, as `pi-dispatch-console serve` binds it. */ +export const CONSOLE_DEFAULT_LISTEN = "127.0.0.1:4790"; + +const LOOPBACK = new BlockList(); +LOOPBACK.addSubnet("127.0.0.0", 8, "ipv4"); +LOOPBACK.addAddress("::1", "ipv6"); + +/** True for 0.0.0.0 and :: in any IPv6 spelling (`::0`, `0:0:0:0:0:0:0:0`). */ +function unspecified(host, family) { + if (family === "ipv4") return host === "0.0.0.0"; + try { + return new URL(`http://[${host}]/`).hostname === "[::]"; + } catch { + return false; + } +} + +/** + * Where the probe would ask, for a PI_CONSOLE_LISTEN value: `{ kind: "tcp", host, port, shown }`, + * `{ kind: "unix", path, shown }`, or `{ unknown: reason }` when nothing may or can be asked. + */ +export function consoleProbeTarget(value) { + const text = typeof value === "string" && value.trim() !== "" ? value.trim() : CONSOLE_DEFAULT_LISTEN; + if (text.startsWith("unix:")) { + const path = text.slice("unix:".length); + if (!isAbsolute(path)) return { unknown: "PI_CONSOLE_LISTEN names a relative socket path, which the console refuses" }; + return { kind: "unix", path, shown: text }; + } + const m = /^(?:\[([^\]]*)\]|([^:[\]]*)):([0-9]{1,5})$/.exec(text); + if (!m || Number(m[3]) > 65535) return { unknown: "PI_CONSOLE_LISTEN is not ip:port, [ipv6]:port or unix:/abs/path" }; + const host = m[1] ?? m[2]; + const family = m[1] !== undefined ? (isIPv6(host) ? "ipv6" : null) : isIPv4(host) ? "ipv4" : null; + if (family === null) return { unknown: `PI_CONSOLE_LISTEN names ${host}, which is not an IP literal` }; + const port = Number(m[3]); + if (port === 0) return { unknown: "PI_CONSOLE_LISTEN asks for port 0, so the OS picks the port and only the running console knows it" }; + if (unspecified(host, family)) { + const loopback = family === "ipv4" ? "127.0.0.1" : "::1"; + return { kind: "tcp", host: loopback, port, shown: `${family === "ipv4" ? loopback : `[${loopback}]`}:${port}` }; + } + if (!LOOPBACK.check(host, family)) return { unknown: `PI_CONSOLE_LISTEN names ${host}, which is not a loopback address, and the probe asks nothing across a network` }; + return { kind: "tcp", host, port, shown: family === "ipv6" ? `[${host}]:${port}` : `${host}:${port}` }; +} + +/** + * `{ state: "running" | "not-running" | "unknown", target?, reason? }` for the console of `env` (its PI_CONSOLE_LISTEN). + * `target` is the address asked, as shown; `reason` says why it is not running or cannot be told. Never throws. + */ +export function probeConsole(env, { request = httpRequest, timeoutMs = 1000 } = {}) { + const target = consoleProbeTarget(env?.PI_CONSOLE_LISTEN); + if (target.unknown) return Promise.resolve({ state: "unknown", reason: target.unknown }); + const where = target.kind === "unix" ? { socketPath: target.path } : { host: target.host, port: target.port, family: isIPv6(target.host) ? 6 : 4 }; + return new Promise((resolvePromise) => { + let settled = false; + let req = null; + const done = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + req?.destroy(); + } catch {} + resolvePromise({ target: target.shown, ...result }); + }; + // Our own timer rather than the socket's idle timeout: that one restarts on every byte, so a listener that trickles + // bytes would hold doctor past the second this promises. + const timer = setTimeout(() => done({ state: "not-running", reason: `nothing answered within ${timeoutMs} ms` }), timeoutMs); + try { + req = request({ ...where, method: "GET", path: "/healthz", headers: { host: "127.0.0.1" }, agent: false }, (res) => { + const code = res.statusCode; + res.resume?.(); + done(code === 200 ? { state: "running" } : { state: "not-running", reason: `something answered HTTP ${code}, not the console's 200` }); + }); + req.on("error", (err) => done({ state: "not-running", reason: err?.code === "ECONNREFUSED" || err?.code === "ENOENT" ? "nothing listens there" : `the request failed (${err?.code ?? "error"})` })); + req.end(); + } catch (err) { + done({ state: "not-running", reason: `the request failed (${err?.code ?? "error"})` }); + } + }); +} diff --git a/worker/src/doctor.mjs b/worker/src/doctor.mjs index 4ca5dfe6..e4196aa6 100644 --- a/worker/src/doctor.mjs +++ b/worker/src/doctor.mjs @@ -98,7 +98,7 @@ import { runLiveProbes } from "./live-probes.mjs"; import { VALKEY_PASSWORD_KEY, VALKEY_PASSWORD_HOWTO, VALKEY_PORT_KEY, isLoopbackHost, valkeyPasswordProblem, valkeyPortConflict } from "./valkey-auth.mjs"; import { urlShown, valkeyContextFromResolution, valkeyPasswordFor, valkeyUrlProblem } from "./valkey-endpoint.mjs"; import { SANDBOX_TOMBSTONE_STUCK_MS, isSandboxTombstone, sandboxTombstoneAge } from "./sandbox-store.mjs"; -import { installedUnitPaths, readUnitSeam, readUnitUser } from "./service.mjs"; +import { installedConsoleUnitPath, installedUnitPaths, readUnitSeam, readUnitUser } from "./service.mjs"; import { CONTAINER_HOME, SHIPPED_IMAGE_UID, SIZE_LABEL_CPU, SIZE_LABEL_MEM } from "./container-spec.mjs"; import { DEFAULT_JOB_SIZE, cpuCeilingCenti, formatCpus, formatMemory, jobSizeDefaults, resolveJobSize } from "./job-size.mjs"; import { SUGGEST_WINDOW_DAYS, cpusText, hostCap, refusalWords, sizeRefusal, suggestSize, suggestionCall, suggestionEvidence } from "./size-suggest.mjs"; @@ -241,6 +241,9 @@ export async function runDoctor(shellVars = process.env, deps = {}) { // Issue #599, phase 2: the capacity line's read of the run history (the run mirror and the logs directory). // Undefined means the default, which reads the deployment's real Valkey and files. readCapacity, + // Issue #708: whether the operator console answers (`probeConsole`, console-probe.mjs). Undefined means the default, + // which sends one GET /healthz to a loopback address or the socket, so a test passes its own. + consoleProbe, // --live (issue #278, INT-LIVE-PROBE-CONTRACT): read the backend declarations back off short-lived real containers. // STRICTLY `=== true`, so only the CLI's own flag arms it: a truthy string from a caller that forwarded an // option bag runs nothing. The fs, PID-liveness and nonce are seams so the sequence is driven without Docker. @@ -425,7 +428,7 @@ export async function runDoctor(shellVars = process.env, deps = {}) { return { ...(await valkeyAuthState(url, { context, withoutPassword })), passwordSet: Boolean(sent.password), from: sent.from }; } : null; - const seams = { secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) }; + const seams = { secrets: secrets === true, ...(bundleSpawn ? { bundleSpawn } : {}), openshell: openshell === true, ...(openshellRun ? { openshellRun } : {}), cwd, out, spawn, probeValkey, valkeyAuth: valkeyAuthSeam, readHosts, ...(modelCatalog ? { modelCatalog } : {}), ...(piModelLoader ? { piModelLoader } : {}), ...(dollarKeysExist ? { dollarKeysExist } : {}), ...(readAppliedSplit ? { readAppliedSplit } : {}), ...(readCapacity ? { readCapacity } : {}), ...(consoleProbe ? { consoleProbe } : {}), fileExists, nodeVersion, mkdir, chmod, rm, agentDir, platform, home, providerOracle, facts, jobUserIdentity, stat, passwd, readUnit, readEnvFile: readEnvFileShared, observationFs, jobsDirFs, jobsDirUid, valkeyOwner: valkeyOwnerSeam, isAlive, pid, runTimeouts, live: live === true, wallClock, ...(readRunRecords ? { readRunRecords } : {}), venueChecks, userName, proxyFilesExist, proxyFileIsDirectory, ...(includeNeeds ? { includeNeeds } : {}), ...(declaredEndpoints ? { declaredEndpoints } : {}), ...(readOverlayFile ? { readOverlayFile } : {}), ...(lstatOverlayFile ? { lstatOverlayFile } : {}), ...(hostAddresses ? { hostAddresses } : {}), ...(readProxyConf ? { readProxyConf } : {}), ...(readPackagedConf ? { readPackagedProxyConf: readPackagedConf } : {}), ...(readPodmanService ? { readPodmanService } : {}), serviceEnvFile: envValues === null ? null : serviceEnvFileOf(envValues, envPath, serviceEnvLoader(platform)) }; // Issue #471: every other service key, resolved ONCE for the whole run (the fix pass's re-collect and `--live` judge the // same resolution). THE RULE (PR #474's round cap, after three rounds of trust patches): no program doctor starts is // handed anything from `.env`. Every child gets this shell's own environment, the one it had before #471; a `.env` @@ -4325,7 +4328,12 @@ export async function collectChecks(shellVars, seams) { // starts from a copy of this shell), so nothing here reads the shell a second way; and the resolution is apart from the // service record, so a console key is never reported as a service's. const consoleShell = Object.fromEntries(CONSOLE_ENV_KEYS.filter((k) => Object.hasOwn(env, k)).map((k) => [k, env[k]])); - checks.push(...consoleChecks({ ...env, ...resolveServiceEnv({ env: consoleShell, file: serviceEnvFile, keys: CONSOLE_ENV_KEYS }).env }, { cwd, home, platform, folders, skillsDirs })); + const consoleEnv = { ...env, ...resolveServiceEnv({ env: consoleShell, file: serviceEnvFile, keys: CONSOLE_ENV_KEYS }).env }; + const consoleLines = consoleChecks(consoleEnv, { cwd, home, platform, folders, skillsDirs }); + checks.push(...consoleLines); + // Issue #708: whether a service keeps the console running, and whether it answers. Only for a deployment that has a + // console at all (the lines above), so a deployment that never set one up meets no probe. + if (consoleLines.length > 0) checks.push(...(await consoleServiceChecks(consoleEnv, { ...seams, cwd, home, platform }))); section("limits"); // Issue #498: a BARE repo scope (`acme/web`) matches that repo on every forge, so with triggers on more than one forge @@ -5296,6 +5304,67 @@ export function consoleChecks(env, { cwd = process.cwd(), fs = nodeFs, uid = pro return checks; } +/** The console's service name on Windows, where nssm keeps one machine-scoped service per name (service.mjs). */ +const CONSOLE_NSSM_SERVICE = "pi-dispatch-console"; + +/** + * The console service unit installed for the deployment in `cwd` (issue #708): `{ path }` (the unit file, or the nssm + * service's name on win32), else null. Read as the worker's unit is (`installedWorkerUnit`): a unit FILE whose + * WorkingDirectory is this folder (`readUnitSeam`), in either scope; on win32 the nssm service by name, since nssm + * keeps the folder apart (AppDirectory) and there is one such service per machine. + */ +async function installedConsoleUnit({ platform, home, cwd, fileExists, readUnit = (path) => readFileSync(path, "utf8"), spawn }) { + if (platform === "win32") { + if (typeof spawn !== "function") return null; + const got = await runCmdCapture(spawn, "nssm", ["status", CONSOLE_NSSM_SERVICE]); + return got.code === 0 ? { path: CONSOLE_NSSM_SERVICE } : null; + } + const path = installedConsoleUnitPath({ platform, home, cwd, fileExists, readUnit }); + return path === null ? null : { path }; +} + +/** + * The console's service lines (issue #708): whether a service unit keeps the console of this deployment running, and + * whether the console answers (`probeConsole`: one GET /healthz to the loopback address or the socket it binds, 1 s). + * - a unit installed and the console answering: ✓; + * - a unit installed and nothing answering: ✗, since the service exists to keep it answering (`serve` exits 2 on a + * refusal and the unit then stays stopped, so the unit's log says which); + * - no unit and nothing answering: ⚠, with the install command; + * - no unit and the console answering: ✓, saying it was started by hand and stops with its terminal; + * - an address the probe may not or cannot ask (port 0, a non-loopback bind): the unit line alone, and why the + * answer is not known. + * NOTHING when the console could not start on its listen address at all: `consoleChecks` already fails that line, and a + * second line saying it does not answer would be the same fact twice. `env` carries the console keys resolved as + * `collectChecks` resolves them; no other key is read. Seams: `consoleProbe(env)`, `fileExists`, `readUnit`, `spawn`. + */ +export async function consoleServiceChecks(env, seams = {}) { + const { platform = process.platform, cwd = process.cwd() } = seams; + const listen = consoleListenOf(env.PI_CONSOLE_LISTEN, platform); + if (listen.refusal || (!listen.loopback && env.PI_CONSOLE_BIND_PUBLIC !== "1")) return []; + const unit = await installedConsoleUnit({ ...seams, platform, cwd, fileExists: seams.fileExists ?? existsSync }); + const probe = seams.consoleProbe ?? (async (e) => (await import("./console-probe.mjs")).probeConsole(e)); + let answer; + try { + answer = await probe(env); + } catch (err) { + answer = { state: "unknown", reason: `the probe failed (${escapeControls(err?.message ?? String(err))})` }; + } + const at = answer.target ? ` on ${escapeControls(answer.target)}` : ""; + const why = answer.reason ? ` (${escapeControls(answer.reason)})` : ""; + const unitShown = unit ? quotedShown(unit.path) : null; + const logs = platform === "linux" ? "`journalctl --user -u pi-dispatch-console` (or without --user for a system unit)" : platform === "darwin" ? `${join(cwd, "logs", "console.err.log")}` : `${join(cwd, "logs", "console.err.log")} and \`nssm status ${CONSOLE_NSSM_SERVICE}\``; + if (answer.state === "unknown") { + return [{ ok: true, label: `console: ${unit ? `the service ${unitShown} serves this deployment; ` : "no service unit keeps it running; "}whether it answers is not checked${why}` }]; + } + if (unit && answer.state === "running") return [{ ok: true, label: `console: the service ${unitShown} keeps it running, and it answers${at}` }]; + if (unit) { + const lock = join(logsDirPath(env, seams.home), "console-audit", ".console.lock"); + return [{ ok: false, label: `console: the service ${unitShown} is installed for this deployment, but nothing answers${at}${why}`, fix: `\`pi-dispatch service status --console\` says whether it runs, and ${logs} says why it stopped: \`pi-dispatch-console serve\` exits 2 on a refusal (no operator token, the lock held by another console, an address in use), and the unit then stays stopped until it is started again (\`pi-dispatch service start --console\`). After a crash the lock ${escapeControls(lock)} can name a pid another process has since taken, which keeps every start refusing: when that pid is not a console, remove the lock, then start the unit` }]; + } + if (answer.state === "running") return [{ ok: true, label: `console: answers${at}, started by hand (no service unit for this deployment, so it stops with the terminal that runs it)` }]; + return [{ ok: false, warn: true, label: `console: not running${at}${why}, and no service unit keeps it running`, fix: "`pi-dispatch service install --console` keeps it running (it needs an operator token); or start it in a terminal with `pi-dispatch-console serve`" }]; +} + /** * Whether a job container can see the console tokens file: `{}` or `{ problem, fix }`. The file's REAL path is judged * (the containment check requires a canonical path, and the console reads whatever the configured one resolves to). A @@ -7830,7 +7899,8 @@ export function allowlistHostAliases(text) { } /** - * The worker and receiver services installed for THIS folder, as `[{ which, source, setup }]` (`setup` null for a unit + * The services installed for THIS folder (the worker, the receiver and, since issue #708, the console), as + * `[{ which, source, setup }]` (`setup` null for a unit * rendered without `--env-setup`): an installed unit whose WorkingDirectory is this folder (systemd, launchd), or the * nssm service by name on win32, where nssm keeps the folder in a separate AppDirectory property and there is exactly * one machine-scoped service per name. Read ONCE (issue #481, PR #485's final review) and shared by the env-setup lines @@ -7840,7 +7910,7 @@ async function installedServices(seams) { const { cwd, spawn, fileExists, platform, home } = seams; const found = []; if (platform === "win32") { - for (const which of ["worker", "receiver"]) { + for (const which of ["worker", "receiver", "console"]) { const service = `pi-dispatch-${which}`; const got = await runCmdCapture(spawn, "nssm", ["get", service, "AppEnvironmentExtra"]); // Not installed, or nssm not on PATH: silence. Same doctrine as check-ignore below -- a check diff --git a/worker/src/env-file.mjs b/worker/src/env-file.mjs index d59a6d69..1878dd97 100644 --- a/worker/src/env-file.mjs +++ b/worker/src/env-file.mjs @@ -2065,14 +2065,16 @@ export function decodeEnvFile(content, { loader = "systemd" } = {}) { * effect, and the reader and writer name it rather than let an operator believe it does anything. PI_ENV_SETUP is on * both lists: it is unit configuration and never honoured from the file. The cmd list is compared ignoring case, as * `set` does. Kept beside the wrappers' text by a test, which fails when either wrapper's own variables change. + * PI_WRAPPER_NO_DOTENV (issue #708) joins both: the console's unit sets it, the wrappers test it BEFORE the load and + * unset it after, so a `.env` line naming it can neither start nor stop the load. */ export const WRAPPER_INTERNAL_KEYS = Object.freeze({ - shell: Object.freeze(["PI_ENV_SETUP", "env_setup", "signaled", "child", "rc"]), - cmd: Object.freeze(["PI_ENV_SETUP", "ENV_SETUP", "RC", "ERRORLEVEL"]), + shell: Object.freeze(["PI_ENV_SETUP", "PI_WRAPPER_NO_DOTENV", "env_setup", "signaled", "child", "rc"]), + cmd: Object.freeze(["PI_ENV_SETUP", "PI_WRAPPER_NO_DOTENV", "ENV_SETUP", "RC", "ERRORLEVEL"]), }); /** What a line assigning one of the wrapper's own variables is, and what to do, in the words doctor and the writer use. */ -const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : ""}`; +const WRAPPER_INTERNAL_WHAT = (name) => `assigns ${name}, a variable the service wrapper keeps for itself (deploy/worker-env-wrapper.sh on macOS, .cmd on Windows) and assigns again after loading this file, so the line has no effect${/^pi_env_setup$/i.test(name) ? "; PI_ENV_SETUP is never honoured from this file" : /^pi_wrapper_no_dotenv$/i.test(name) ? "; PI_WRAPPER_NO_DOTENV is unit configuration (the console's service sets it) and never honoured from this file" : ""}`; const WRAPPER_INTERNAL_FIX = "remove the line (a setup script is named with pi-dispatch service install --env-setup , never in .env)"; /** A `envFileWrapperInternal` finding as one sentence: the line, what it is, and the fix. */ diff --git a/worker/src/service.mjs b/worker/src/service.mjs index c3453344..344ea561 100644 --- a/worker/src/service.mjs +++ b/worker/src/service.mjs @@ -23,6 +23,9 @@ * package's own exported entry, wherever npm (or the workspace symlink) put it. * null when the package is not installed — receiver renders refuse loudly instead * of writing a unit that would crash-loop at boot. + * - consoleEntry the admin package's bin, dist/console.mjs (issue #708, resolveConsoleEntry): the + * same resolution, else the `pi-dispatch-console` a global install put on PATH. null + * refuses the console renders, for the receiver's reason. * * Scope doctrine: * - User-level by default, everywhere. macOS REFUSES root outright (a LaunchAgent is per-user, and a @@ -45,7 +48,7 @@ import { accessSync, constants as fsConstants, existsSync, mkdirSync, readFileSy import { lookup as dnsLookup } from "node:dns/promises"; import { connect as netConnect } from "node:net"; import { homedir, networkInterfaces, tmpdir, userInfo } from "node:os"; -import { delimiter, dirname, join, resolve } from "node:path"; +import { basename, delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { openshellSetupOf, parseBackendList, venuesOf } from "./backends.mjs"; @@ -53,6 +56,9 @@ import { OPENSHELL_BREW_INSTALL, OPENSHELL_GATEWAY_UNIT, OPENSHELL_NO_WINDOWS } import { sharedShellIgnored } from "./deployment-venue.mjs"; import { egressArmed, egressProxyName } from "./egress.mjs"; import { ENV_WRITER_FS, updateEnvFile } from "./env-file.mjs"; +import { operatorCount, readTokensFile, tokensFilePath } from "./console-tokens.mjs"; +import { deploymentServiceEnv } from "./service-env.mjs"; +import * as nodeFs from "node:fs"; import { MODEL_ENDPOINTS_INCLUDE_NAME } from "./model-endpoints.mjs"; import { VALKEY_HEALTH_SCRIPT, VALKEY_PASSWORD_KEY, VALKEY_START_SCRIPT, dollarsDoubled, newValkeyPassword, valkeyPasswordDecision } from "./valkey-auth.mjs"; import { ALL_QUADLET_FILES, ALLOWLIST_PLACEHOLDER, MODEL_ENDPOINTS_PLACEHOLDER, NETNS_KEEPER, NETNS_KEEPER_FORMAT, judgeNetnsKeeper, keeperUnderRunningProxyHint, managerEnvRefusal, PROXY_CONF_PLACEHOLDER, QUADLET_FILES, applyStack, decideValkey, describeAction, passwdNameFrom, readSubuidRanges, readValkeyKeys, valkeySharedOn, VALKEY_SHARED_KEY, describeRollBack, journalWrite, rollBackWrites, foreignContainerRefusal, foreignContainers, lingerNote, planStack, proxyConfCopyPath, proxyRestartWarning, quadletDir, readLinger, readStackKeys, stackComponents, unknownContainerRefusal, userBusRefusal, valkeyEnvPath, valkeyPasswordRestartWarning, workerUnitDeps } from "./podman-stack.mjs"; @@ -81,6 +87,70 @@ function resolveReceiverStart() { } } +/** The admin package, whose bin is the console (`pi-dispatch-console` -> ./dist/console.mjs, in its published `files`). */ +export const CONSOLE_PACKAGE = "@edgehero/pi-dispatch-admin"; + +/** + * The console's entry point (issue #708): the admin package's dist/console.mjs as module resolution finds it from here, + * else the real path of the `pi-dispatch-console` on this shell's PATH. null when neither exists. + * + * Two lookups because the admin package is NOT a dependency of this one: it is a pi extension an operator installs + * with `pi install` or `npm i -g`. Resolution from here finds it when it sits in the same node_modules tree as this + * worker (both global, or both in the deployment folder); the PATH lookup covers the rest. The package has no + * `exports` map, so the subpath resolves as a plain file, and `import.meta.resolve` does not check that the file + * exists (measured on node 23.5: a checkout without `node admin/build.mjs` resolves to a dist/ that is not there), + * hence the existence check. + * + * The PATH hit is accepted only when its real path IS the package's file: it ends in dist/console.mjs and the + * package.json two levels up names this package (round 1 review of #708). npm's bin is a symlink to exactly that file, + * but pnpm and Volta put a SHIM on PATH (a shell script, or a binary that picks a version at run time), and a unit that + * ran `node ` would crash at every start. The real path, not the PATH name, because a service manager's PATH is + * not this shell's and launchd's cannot see an nvm or Homebrew prefix at all. `findOnPath` reads only absolute POSIX + * directories, so on Windows this lookup does not run and resolution is the only way (`refuseMissingConsole` says so). + */ +export function resolveConsoleEntry({ findBin, realpath, exists = existsSync, readFile = (p) => readFileSync(p, "utf8"), resolveModule = (specifier) => import.meta.resolve(specifier) }) { + try { + const resolved = fileURLToPath(resolveModule(`${CONSOLE_PACKAGE}/dist/console.mjs`)); + if (exists(resolved)) return resolved; + } catch {} + const found = findBin("pi-dispatch-console"); + if (!found) return null; + try { + const real = realpath(found); + const dist = dirname(real); + if (basename(real) !== "console.mjs" || basename(dist) !== "dist") return null; + const pkg = JSON.parse(readFile(join(dirname(dist), "package.json"))); + return pkg?.name === CONSOLE_PACKAGE ? real : null; + } catch { + return null; + } +} + +/** + * The console tokens file the SERVICE's console reads for the deployment in `deployDir` (issue #708): PI_CONSOLE_TOKENS_FILE + * from the deployment `.env` (through the reader the console itself uses), else console-tokens.json there. Never this + * shell's export: the unit inherits no shell, loads no EnvironmentFile and its wrapper sources no `.env`, so `serve` + * finds only the file's value. One helper for `service install --console` and `up`'s next step, so the two cannot + * name different files. + */ +export function consoleTokensPathFor(deployDir, platform) { + const { env } = deploymentServiceEnv({ env: {}, dir: deployDir, keys: ["PI_CONSOLE_TOKENS_FILE"], platform, fs: nodeFs }); + return tokensFilePath(env, deployDir); +} + +/** + * How many operator tokens the console this unit starts would find (issue #708): `{ path, operators }` or + * `{ path, refusal }`. The tokens file is where `serve --deployment ` looks: PI_CONSOLE_TOKENS_FILE as the + * SERVICE sees it (the deployment `.env`, through the reader the console itself uses), never this shell's export, + * which the unit does not inherit. Read with the real filesystem on purpose: `readTokensFile` judges the file the way + * `serve` does (owner-only, regular, no symlink), and a fake that answered differently would prove nothing. + */ +export function readConsoleOperators(deployDir, platform) { + const path = consoleTokensPathFor(deployDir, platform); + const read = readTokensFile({ path }); + return read.refusal ? { path, refusal: read.refusal } : { path, operators: operatorCount(read.file) }; +} + /** * The whole substitution surface, template by template. The render replaces ONLY these literals (plus * the scope-dependent `User=` / `WantedBy=` rewrites called out below); the pin test in @@ -126,8 +196,21 @@ export const TEMPLATE_PINS = { "KillSignal=SIGTERM", "TimeoutStopSec=30", ], + // Issue #708: the console, the receiver's shape. Its ExecStart names the console package's entry and the deployment + // (`serve --deployment`), so the folder is in the line twice and the WHOLE line is replaced, as for the other two. + "console.service": [ + "ExecStart=/usr/bin/node /usr/lib/node_modules/@edgehero/pi-dispatch-admin/dist/console.mjs serve --deployment /opt/pi-dispatch", // the WHOLE line → ` serve --deployment ` + "WorkingDirectory=/opt/pi-dispatch", + "\nUser=pi\n", + "WantedBy=multi-user.target", + "RestartPreventExitStatus=2", // serve's refusals (no operator token, the lock held, an unusable listen) exit 2, and the next start would refuse the same way + "StartLimitIntervalSec=60", + "StartLimitBurst=5", + "KillSignal=SIGTERM", + "TimeoutStopSec=30", + ], "com.pi-dispatch.worker.plist": [ - "com.pi-dispatch.worker", // → com.pi-dispatch.receiver for --receiver + "com.pi-dispatch.worker", // → com.pi-dispatch.receiver for --receiver, com.pi-dispatch.console for --console "/opt/pi-dispatch/deploy/worker-env-wrapper.sh", // → the PACKAGE's wrapper copy, followed by the exec argv (