diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index a0d4208f..b3856df8 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -10,6 +10,198 @@ first call site. This document records the contract of each extracted module. It will grow as further modules are pulled out of the launcher. +## Launcher reference + +The sections below record the cross-cutting launcher contract that the module +sections then refine: the trusted host tools it requires, the environment it +pins and scrubs, the exit codes it uses, and the harness-only override flags it +recognises. Every claim here is derived from `scripts/workcell`, +`scripts/lib/launcher/*.sh`, and `scripts/lib/*.sh`. + +### Required host tools + +The launcher avoids bare, unpinned `PATH` lookups for host binaries: every host +tool is either absolute-resolved or run by name on the pinned `TRUSTED_HOST_PATH` +(never an inherited `PATH`). Most tools (`go`, `colima`, `docker`, and `git`'s +`HOST_GIT_BIN`) are absolute-resolved through one of two resolvers, both of which +abort the launch (printing `Missing trusted host tool: ` to stderr and +exiting `1`) when no trusted candidate is executable; a couple (`git` and `curl` +via `run_clean_host_command`) are instead run by name on `TRUSTED_HOST_PATH`, as +the rows and notes below detail. The two resolvers are: + +- `resolve_fixed_host_tool ...` + (`scripts/lib/launcher/host-exec.sh`) returns the first caller-supplied candidate + that is executable (`-x`) — no `PATH` fallback, but also **no canonicalisation**: + it trusts the literal candidate path as written, so a fixed candidate that is a + symlink is followed by the kernel without re-validating the target. Trust here + rests on those hard-coded paths not being attacker-writable. +- `resolve_host_tool ...` (`scripts/workcell`) tries the fixed + candidates first, then falls back to `type -P `, but every path **and its + canonicalised form** must pass `is_trusted_host_tool_path` before it is accepted + (`workcell:6660-6661`), so it cannot resolve to a target outside the trusted + prefixes. Its `resolve_host_tool_optional` sibling is identical but returns `1` + instead of aborting, so it is used for non-fatal capability probes. + +| Tool | Resolver | Candidate paths | Purpose | +| --- | --- | --- | --- | +| `go` | `resolve_fixed_host_tool` (`go-hostutil.sh:29`) | `/opt/homebrew/bin/go`, `/usr/local/go/bin/go`, `/usr/local/bin/go`, `/usr/bin/go` | Runs the `workcell-hostutil` and `workcell-colimautil` Go programs via `go run` (see `HOST_GO_BIN`). | +| `colima` | `resolve_host_tool` (`workcell:625,670,5492`); `resolve_host_tool_optional` probe (`workcell:6713`) | `/opt/homebrew/bin/colima`, `/usr/local/bin/colima` | Manages the Colima VM profile that backs the container runtime (`HOST_COLIMA_BIN`). | +| `docker` | Fail-closed `resolve_host_tool`: a transient `HOST_DOCKER_BIN="docker"` default (`workcell:8136`) is **overridden** by `prepare_current_target_docker_client` (called in the main launch path at `workcell:8352`), which resolves it to an absolute, canonical-validated path (`resolve_host_tool docker`, `workcell:866`) before any Docker use; other sub-paths probe with `resolve_host_tool_optional` (`workcell:850,1663,6721`) | `/opt/homebrew/bin/docker`, `/usr/local/bin/docker`, `/Applications/Docker.app/Contents/Resources/bin/docker` | Drives the container runtime (`HOST_DOCKER_BIN`), run via `run_workcell_docker_client_command` (`trusted-docker-client.sh:151`). | +| `git` | `resolve_host_tool` → absolute `HOST_GIT_BIN` (`workcell:4462,8135`); **also** invoked by name via `run_clean_host_command` (`workcell:7092,7163,7190,7213,7232,7372`) | `/usr/bin/git`, `/opt/homebrew/bin/git`, `/usr/local/bin/git` | Inspects and extracts the launch workspace repository. | +| `curl` | **sanitised-PATH by name only** (no absolute resolve) via `run_clean_host_command` (`workcell:6241,6270`) | found on `TRUSTED_HOST_PATH` inside the sanitised `env -i` | Best-effort `HEAD` preflight for release URLs; **optional** — absence is swallowed (`\|\| true`), not fatal. | + +A resolver-backed tool that is absent aborts the launch rather than falling back +to an attacker-controlled binary — but the resolvers differ in strength, as above: +`resolve_host_tool` (`colima`, `docker`, and `git`'s `HOST_GIT_BIN`) validates the +canonical target, while `resolve_fixed_host_tool` (`go`) trusts its literal fixed +paths. Two tools are instead run **by name on the sanitised `TRUSTED_HOST_PATH`** +rather than absolute-resolved: `git` (in its `run_clean_host_command` uses) and +`curl`. These stay confined to the trusted `PATH` but are **not** +canonical-target-validated and **not** fail-closed — e.g. a missing `curl` leaves +the preflight URL empty rather than raising `Missing trusted host tool`, so `curl` +is optional, not required. + +`go` additionally has a **second, differently-scoped** resolution path: the +`--audit-transcript` PTY helper (`scripts/lib/pty_transcript`, `workcell:8778`) +sources `go-run-env.sh`, whose Go-binary resolver honours a pre-set +`WORKCELL_GO_BIN`, then `command -v go`, then the fixed candidates +(`go-run-env.sh:47-71`). Like the fixed resolver it is **fail-closed** — it exits +`1` with `Missing required tool: go` when none is found (`go-run-env.sh:66-68`) — +but it is **not confined to fixed trusted paths** (a `command -v go` hit is +accepted) and `WORKCELL_GO_BIN` is not in the scrub list, so on the +shebang-bypassed `bash` path an inherited `WORKCELL_GO_BIN` can select the `go` +used for the transcript. + +This table covers the host tools a **local** launch may resolve; the exact set +depends on the target backend — `missing_launch_host_tools_csv` probes `colima` +only when `TARGET_BACKEND == "colima"`, whereas a Docker Desktop target +(`--target docker-desktop`) probes Docker/its context instead, and `curl` is used +only by the best-effort release-URL preflight. Beyond per-launch resolution, a few +prerequisites sit outside this table entirely. Remote-preview backends probe extra tools via +`missing_launch_host_tools_csv` (`scripts/workcell:6708`) — `aws` and +`session-manager-plugin` for the `aws-ec2-ssm` backend, `gcloud` for `gcp-vm`. +Image builds require a system `docker-buildx` plugin binary +(`ensure_workcell_trusted_buildx`, `scripts/lib/trusted-docker-client.sh`), which +aborts with `Missing trusted docker-buildx binary` when none is found. And the +`workcell publish-pr` subcommand resolves `gh` on its non-dry-run path (required; +via the Go publish helper `internal/publishpr`, `ResolveHostTool(ctx, "gh", true, +…)`), failing with `Missing trusted host tool: gh` at publication time if it is +absent. + +### Environment expectations + +The launcher pins its own process environment before doing any work, then +derives a small set of host-context variables. + +**Pinned `PATH`.** On direct execution the launcher re-execs itself with a +**cleared** environment: the shebang (`scripts/workcell:1`) is +`#!/usr/bin/env -S -i PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/sbin:/usr/local/sbin:/usr/sbin:/sbin:/Applications/Docker.app/Contents/Resources/bin BASH_ENV= ENV= /bin/bash`, +so `env -i` discards the inherited environment and starts `bash` with only the +fixed `PATH` and cleared `BASH_ENV`/`ENV`. **This wholesale clear only happens +when the shebang is honored.** When the launcher is instead invoked directly by an +interpreter the shebang is bypassed, `env -i` never runs, and only the explicit +`scrub_host_process_env` list (below) plus the `PATH` reset apply — and the two +such forms differ on startup-file vectors: + +- `/bin/bash -p scripts/workcell` (the privileged form the repo's own + verify-invariants and publish-pr harnesses use) ignores `BASH_ENV`/`ENV`, so + those vectors are inert even before the scrub. +- plain `bash scripts/workcell` (no `-p`) **processes `BASH_ENV` before the script + body runs**, so a `BASH_ENV` startup-file injection has already executed by the + time `scrub_host_process_env` unsets the variable — the scrub is *not* a defence + against it on that path. + +Either way, security reasoning must not assume unlisted variables are absent on a +shebang-bypassed launch. In both cases the `PATH` string is frozen as +`readonly TRUSTED_HOST_PATH` and +re-exported as `PATH` (`scripts/workcell:5-6`), and it is the `PATH` handed to +every sanitised host command via `env -i PATH="${TRUSTED_HOST_PATH}"` +(`host-exec.sh:49,77`). + +**Scrubbed variables.** `scrub_host_process_env` (`scripts/workcell:7-30`, called +at `scripts/workcell:32`) unsets, in order: + +- `BASH_ENV`, `ENV` — startup-file injection vectors; +- `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` — always unset (the fault-injection + hook is reachable only through a CLI flag, below); +- `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS`, `_ARCH`, `_DISTRO`, `_DISTRO_VERSION` — + unset **unless** `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` is `1`; +- `PYTHONPATH`, `PYTHONHOME`, `PYTHONSAFEPATH`, `PYTHONSTARTUP`; +- `RUBYOPT`, `RUBYLIB`, `GEM_HOME`, `GEM_PATH`; +- `PERL5OPT`, `PERL5LIB`, `PERLLIB`, `PERL_MB_OPT`, `PERL_MM_OPT`; +- `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `LD_DEBUG`, `LD_DEBUG_OUTPUT`, + `LD_BIND_NOW`, `LD_ASSUME_KERNEL`; +- every `DYLD_*` variable found via `compgen -v`. + +**Derived / exported variables.** After scrubbing, the launcher establishes: + +| Variable | Value | Mechanism | +| --- | --- | --- | +| `REAL_HOME` | resolved host home | `resolve_workcell_real_home` (`workcell:71`, from `trusted-docker-client.sh`) | +| `WORKCELL_GO_CACHE_ROOT` (exported) | `${REAL_HOME}/Library/Caches/workcell/go` on Darwin, else `${XDG_CACHE_HOME:-${REAL_HOME}/.cache}/workcell/go` | `workcell:73-79` | +| `COLIMA_STATE_ROOT` | `${REAL_HOME}/.colima` | `workcell:90`; passed to `colima` as `COLIMA_HOME` (`workcell:632`) | +| `GOPATH` / `GOMODCACHE` / `GOCACHE` (exported) | `${cache_root}/gopath`, `/mod-cache`, `/build-cache` under `WORKCELL_GO_CACHE_ROOT` | `ensure_go_run_env` in `go-run-env.sh:30-44` (honours pre-set values) | + +`REAL_HOME` and `WORKCELL_GO_CACHE_ROOT` are deliberately established *before* +`go-hostutil.sh` is sourced (`workcell:80-85`), so the wrappers never run before +the host home and default Go cache root are fixed. This is an **ordering** +guarantee, not full confinement: `ensure_go_run_env` **honours a pre-set** +`GOPATH`/`GOMODCACHE`/`GOCACHE` (`go-run-env.sh:33-35`), and those three are not in +the `scrub_host_process_env` list, so on the shebang-bypassed +`bash scripts/workcell` path (above) a caller-supplied Go cache can persist — the +wrappers are guaranteed a *defined* cache root, not necessarily the default one. + +### Exit codes + +The launcher's **own** exit codes — those it originates for its own +skip/failure conditions — are: + +| Code | Meaning | Representative sources | +| --- | --- | --- | +| `0` | Success, or an intentional clean skip (no work to do). | `workcell:3651` (no session id → skip), `workcell:8147` (logs shown) | +| `1` | General failure. In particular, the trusted-tool resolvers abort here when a required host tool is missing. | `host-exec.sh` `resolve_fixed_host_tool`, `workcell:6676` `resolve_host_tool` | +| `2` | Usage / validation / precondition guard failure — by far the most common code. Covers invalid CLI arguments, mode-requirement violations, reserved-variable rejection, a missing host working directory, and Colima profile validation failures. | `workcell:8151,8155` (reserved vars), `workcell:8614-8616` (profile validation), `run_clean_host_command_in_dir` missing-dir (`host-exec.sh`) | +| `88` | Test-only fault injection: a simulated crash immediately after a managed-profile refresh, used by harness recovery tests. Reached only through the hidden CLI flag below. | `maybe_fail_after_profile_refresh_for_tests` (`workcell:2625`) | +| `124` | A managed operation timed out — e.g. `colima start` exceeding `WORKCELL_COLIMA_START_TIMEOUT_SECONDS`. The timeout wrapper returns `124` and `set -e` propagates it, so the launcher itself exits `124` (matching `stability-contract.md`). | `run_command_with_debug_log` / `start_managed_profile` timeout path | + +`0`/`1`/`2`/`88` are the *literal* `exit` codes in `scripts/workcell` +(`grep -nE 'exit [0-9]'`); `124` is originated indirectly, via `set -e` +propagating a timeout wrapper's non-zero return. Beyond the codes it originates, +a normal end-to-end invocation also **passes the supervised child's exit status +through unchanged** — a completed session exits with the container's status +(`exit "${DOCKER_STATUS}"`, `workcell:8797`), a build failure with +`${BUILD_STATUS}` (`workcell:8698`), and session subcommands propagate their +helper's status (`exit $?`, e.g. `workcell:4182,4188`). So an end-to-end run can +surface any status in `0`–`255` (including `128+N` for a signalled child); +[`stability-contract.md`](stability-contract.md) is the authoritative exit-code +reference. + +### Test override flags + +These variables exist for the validation harness / CI, not for operators. Each is +gated — hard-rejected, unconditionally unset, or ignored unless its harness +conditions hold — so a normal launch cannot *act* on one. Note the gate that +decides whether a value is **scrubbed** is, for the support-matrix vars, weaker +than the gate that decides whether it is **honoured** (see their rows). + +| Flag | Overrides | Gating | +| --- | --- | --- | +| `WORKCELL_VERIFY_INVARIANTS_SANITIZED_ENTRYPOINT` | Two distinct effects: (a) at startup its presence (`=1`) makes `scrub_host_process_env` **skip unsetting** the four support-matrix vars; (b) it is one condition the detectors check before honouring those overrides. | Effect (a) is gated **only** on the `=1` marker (`workcell:12-17`) — *not* parent-verified. Effect (b) additionally requires `support_matrix_host_override_allowed` to confirm a recognised validation-harness parent (`host-detect.sh`). | +| `WORKCELL_TEST_SUPPORT_MATRIX_HOST_OS` / `_ARCH` / `_DISTRO` / `_DISTRO_VERSION` | Forces the detected host OS / arch / distro / distro version. | **Unset at startup only when the `SANITIZED_ENTRYPOINT` marker is absent** (`workcell:12-17`); with the marker set they survive startup even under a non-harness parent, but the detectors **honour** them only when `support_matrix_host_override_allowed` returns `0` (marker **and** recognised parent). So on the shebang-bypassed `bash scripts/workcell` path they can be *present but ignored*, not guaranteed absent. | +| `WORKCELL_TEST_CODEX_AUTH_FILE` | Reserved harness variable name — **rejected legacy input**, not a credential override: no launcher resolver consumes it (the Codex resolver no longer recognises it; see `docs/security/`). | If non-empty in the launcher's own environment the launcher **refuses to run**: `exit 2`, "reserved for the Workcell test harness" (`workcell:8150-8152`). | +| `WORKCELL_TEST_CLAUDE_KEYCHAIN_EXPORT_FILE` | **Dual role.** An *operator-supplied* value is rejected; but the launcher's own self-staging probe (`--synthetic-claude-export`, `workcell:4804`) sets this var **internally** so the Claude credential resolver materialises the synthetic export (`internal/injection/prepare_bundle.go:434`, `internal/authresolve/resolve_provider_credentials.go:16`). | Reject-on-presence guard for *inherited* operator input: `exit 2` if set in the launcher's own env (`workcell:8154-8156`). The internal staging happens later, inside bundle prep, after that guard. | +| `WORKCELL_TEST_FAIL_AFTER_PROFILE_REFRESH` | Would request the mid-refresh fault injection. | The env variable is **unconditionally unset** at startup (`workcell:11`). The fault is reachable only through the hidden CLI flag `--test-fail-after-profile-refresh`, which sets the internal `TEST_FAIL_AFTER_PROFILE_REFRESH=1` (`workcell:7883-7884`) and triggers `exit 88`. | + +These gates are **harness conventions, not a security boundary against a local +operator**. The support-matrix "parent" check is a *substring match* on the parent +process command line (below), not a verified process identity, so a caller who +controls their own process tree can construct a matching command line. It deters +accidental leakage from an ordinary launch; it is not a defence against deliberate +spoofing by someone who already runs the launcher (who, being local, controls that +environment anyway). The credential-fixture variables are stricter — they force an +`exit 2` abort whenever present. The authoritative host/credential threat model +lives in the security docs under `docs/security/`, not here. + ## Host detection (`host-detect.sh`) `scripts/lib/launcher/host-detect.sh` normalises the host environment into the