diff --git a/docs/launcher-contract.md b/docs/launcher-contract.md index e37cb264..a0d4208f 100644 --- a/docs/launcher-contract.md +++ b/docs/launcher-contract.md @@ -108,3 +108,52 @@ command from a caller-supplied working directory. Called as prints `Missing host working directory: ` to stderr and exits `2`. With no command arguments it is a no-op returning `0`. `HOME` resolution and the pinned `PATH`/locale are identical to `run_clean_host_command()`. + +## Go/Colima host-utility wrappers (`go-hostutil.sh`) + +`scripts/lib/launcher/go-hostutil.sh` invokes the `workcell-hostutil` and +`workcell-colimautil` Go programs on the host via `go run`, always routed +through `run_clean_host_command_in_dir` (from `host-exec.sh`) so the child +executes from `${ROOT_DIR}` under the sanitised `env -i` host environment. Every +helper depends only on `ensure_go_run_env` plus the `GOPATH`/`GOMODCACHE`/ +`GOCACHE` it exports (`scripts/lib/go-run-env.sh`), +`run_clean_host_command_in_dir` (`scripts/lib/launcher/host-exec.sh`), and the +`ROOT_DIR` global set in `scripts/workcell`. `HOST_GO_BIN` is resolved by this +module itself (via `resolve_fixed_host_tool` from `host-exec.sh`, sourced +immediately before), since these wrappers are its sole consumer — so every +dependency is sourced or assigned before the first wrapper call, which makes the +module self-contained. + +### `go_hostutil()` + +Runs `workcell-hostutil` on the host. Calls `ensure_go_run_env`, then executes +`"${HOST_GO_BIN}" run ./cmd/workcell-hostutil "$@"` from `${ROOT_DIR}` via +`run_clean_host_command_in_dir`, forwarding only `GOPATH`/`GOMODCACHE`/`GOCACHE` +into the sanitised environment. All other host context must be passed on argv +because the `env -i` boundary strips inherited environment variables. + +### `run_go_hostutil_preserve_exit()` + +Wraps `go_hostutil()` and recovers the Go child's real exit code. It captures +the child's stderr to a temp file (cleaned up via a `RETURN` trap and +explicitly), and if the stderr ends in an `exit status N` trailer emitted by +`go run`, it substitutes `N` for the generic exit code `1` and strips the +trailer from the forwarded stderr. Non-trailer stderr is re-emitted unchanged +and the resolved exit code is returned. + +### `go_hostutil_publish_pr()` + +Same `go run ./cmd/workcell-hostutil` invocation as `go_hostutil()`, but forwards +an explicit allowlist of terminal, GnuPG, SSH, XDG, and GitHub environment +variables (for example `TERM`, `GPG_TTY`, `GNUPGHOME`, `SSH_AUTH_SOCK`, +`GIT_ASKPASS`, the `XDG_*` dirs, `GH_TOKEN`/`GITHUB_TOKEN`, `GH_HOST`, +`GH_CONFIG_DIR`) — each added only when non-empty — so host-side PR publication +can reach the operator's credentials and interactive signing agents while the +rest of the environment stays sanitised. + +### `go_colimautil()` + +Runs `workcell-colimautil` on the host. Identical structure to `go_hostutil()` +(`ensure_go_run_env`, forwarding `GOPATH`/`GOMODCACHE`/`GOCACHE` from +`${ROOT_DIR}` via `run_clean_host_command_in_dir`), targeting +`./cmd/workcell-colimautil` instead. diff --git a/internal/metadatautil/core.go b/internal/metadatautil/core.go index 9f8b62ee..4a1c6d65 100644 --- a/internal/metadatautil/core.go +++ b/internal/metadatautil/core.go @@ -469,6 +469,7 @@ func GenerateControlPlaneManifest(rootDir, outputPath string) error { "scripts/lib/extract_direct_mounts", "scripts/lib/launcher/host-detect.sh", "scripts/lib/launcher/host-exec.sh", + "scripts/lib/launcher/go-hostutil.sh", "scripts/lib/render_injection_bundle", "scripts/lib/sessionctl-shim.sh", "scripts/lib/shellproto.sh", diff --git a/internal/testkit/security_boundary_test.go b/internal/testkit/security_boundary_test.go index aaede920..f290999d 100644 --- a/internal/testkit/security_boundary_test.go +++ b/internal/testkit/security_boundary_test.go @@ -185,15 +185,30 @@ func TestWorkcellBootstrapResolvesRealHomeBeforeGoHostutil(t *testing.T) { t.Fatalf("%s exports WORKCELL_GO_CACHE_ROOT before REAL_HOME is resolved", scriptPath) } - goHostutilIndex := strings.Index(script, `go_hostutil() {`) - if goHostutilIndex == -1 { - t.Fatalf("%s must define go_hostutil", scriptPath) + // go_hostutil now lives in scripts/lib/launcher/go-hostutil.sh, so it + // becomes defined at the point scripts/workcell sources that module. The + // ordering invariant therefore targets the source line: the module must be + // sourced only after REAL_HOME is resolved and WORKCELL_GO_CACHE_ROOT is + // exported, so the go_hostutil/go_colimautil wrappers can never run with an + // unsanitised host home or Go cache root. + goHostutilSourceIndex := strings.Index(script, `source "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh"`) + if goHostutilSourceIndex == -1 { + t.Fatalf("%s must source scripts/lib/launcher/go-hostutil.sh", scriptPath) } - if realHomeIndex > goHostutilIndex { - t.Fatalf("%s resolves REAL_HOME after go_hostutil is defined; want bootstrap home resolved first", scriptPath) + if realHomeIndex > goHostutilSourceIndex { + t.Fatalf("%s resolves REAL_HOME after go-hostutil.sh is sourced; want bootstrap home resolved first", scriptPath) } - if cacheRootIndex > goHostutilIndex { - t.Fatalf("%s exports WORKCELL_GO_CACHE_ROOT after go_hostutil is defined; want cache root fixed first", scriptPath) + if cacheRootIndex > goHostutilSourceIndex { + t.Fatalf("%s exports WORKCELL_GO_CACHE_ROOT after go-hostutil.sh is sourced; want cache root fixed first", scriptPath) + } + + modulePath := filepath.Join(repoRoot(t), "scripts", "lib", "launcher", "go-hostutil.sh") + moduleContent, err := os.ReadFile(modulePath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(moduleContent), `go_hostutil() {`) { + t.Fatalf("%s must define go_hostutil", modulePath) } } diff --git a/runtime/container/control-plane-manifest.json b/runtime/container/control-plane-manifest.json index 83cccd83..e0f21b75 100644 --- a/runtime/container/control-plane-manifest.json +++ b/runtime/container/control-plane-manifest.json @@ -2,7 +2,7 @@ "host_artifacts": [ { "repo_path": "scripts/workcell", - "sha256": "8cad3d7c2f99071a346a478a45915c6a3ff2997c94133c6c8384e3893b79eec3" + "sha256": "6e9c2fa71b8b1c5f971a710c9e4e33769bcf2210a61ab2c8f1420caebeedf002" }, { "repo_path": "scripts/check-publish-commit-signatures.sh", @@ -20,6 +20,10 @@ "repo_path": "scripts/lib/launcher/host-exec.sh", "sha256": "475bfcf95c6e3d20631ba2aacd43261fa116323191b1898a47afbf35c96fbd50" }, + { + "repo_path": "scripts/lib/launcher/go-hostutil.sh", + "sha256": "775d3dd6e5d946229d6eb38ccfcc36f6a89711662c4fcd2df9d097a8741e0116" + }, { "repo_path": "scripts/lib/render_injection_bundle", "sha256": "f1057951d6f65e12d82a7bc2b5002afde5664670d8f4ed7d9bc289bbec5f5901" diff --git a/scripts/dev-quick-check.sh b/scripts/dev-quick-check.sh index 6c8edc82..7f821140 100755 --- a/scripts/dev-quick-check.sh +++ b/scripts/dev-quick-check.sh @@ -52,6 +52,7 @@ shell_files=( "${ROOT_DIR}/scripts/lib/go-run-env.sh" "${ROOT_DIR}/scripts/lib/launcher/host-detect.sh" "${ROOT_DIR}/scripts/lib/launcher/host-exec.sh" + "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" "${ROOT_DIR}/scripts/lib/manage_injection_policy" "${ROOT_DIR}/scripts/lib/pty_transcript" "${ROOT_DIR}/scripts/lib/render_injection_bundle" diff --git a/scripts/lib/launcher/go-hostutil.sh b/scripts/lib/launcher/go-hostutil.sh new file mode 100755 index 00000000..60081930 --- /dev/null +++ b/scripts/lib/launcher/go-hostutil.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env -S BASH_ENV= ENV= bash +# shellcheck shell=bash +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 Omkhar Arasaratnam +# +# scripts/lib/launcher/go-hostutil.sh — Go/Colima host-utility wrapper +# module extracted from scripts/workcell as the next increment of the +# launcher decomposition (roadmap item D4). These helpers invoke the +# workcell-hostutil and workcell-colimautil Go programs on the host via +# `go run`, always routed through run_clean_host_command_in_dir so the +# child executes from ${ROOT_DIR} under the sanitised host environment +# (env -i with a pinned PATH/HOME and C locale) provided by +# scripts/lib/launcher/host-exec.sh. They depend only on +# ensure_go_run_env plus the GOPATH/GOMODCACHE/GOCACHE it exports +# (scripts/lib/go-run-env.sh), run_clean_host_command_in_dir +# (scripts/lib/launcher/host-exec.sh), and the readonly ROOT_DIR global +# set in scripts/workcell — all sourced or assigned before the first +# wrapper call — so they are a self-contained, behaviour-preserving unit. +# HOST_GO_BIN is resolved by this module (below) since it is the sole +# consumer; resolve_fixed_host_tool comes from the host-exec.sh module +# sourced immediately before this one. +# run_go_hostutil_preserve_exit additionally +# recovers the Go child's real exit code from its `exit status N` stderr +# trailer. go_hostutil_publish_pr forwards an explicit allowlist of +# terminal/GnuPG/SSH/XDG/GitHub environment variables (read at call time) +# so host-side PR publication can reach the operator's credentials. See +# docs/launcher-contract.md for the module contract. + +HOST_GO_BIN="$(resolve_fixed_host_tool go /opt/homebrew/bin/go /usr/local/go/bin/go /usr/local/bin/go /usr/bin/go)" + +go_hostutil() { + ensure_go_run_env + run_clean_host_command_in_dir "${ROOT_DIR}" env \ + GOPATH="${GOPATH}" \ + GOMODCACHE="${GOMODCACHE}" \ + GOCACHE="${GOCACHE}" \ + "${HOST_GO_BIN}" run ./cmd/workcell-hostutil "$@" +} + +run_go_hostutil_preserve_exit() { + local stderr_file="" + local stderr_capture="" + local rc=0 + + stderr_file="$(mktemp "${TMPDIR:-/tmp}/workcell-hostutil-stderr.XXXXXX")" + trap 'rm -f "${stderr_file}"' RETURN + go_hostutil "$@" 2>"${stderr_file}" || rc=$? + stderr_capture="$(cat "${stderr_file}")" + rm -f "${stderr_file}" + trap - RETURN + + if [[ "${stderr_capture}" =~ (^|$'\n')exit\ status\ ([0-9]+)$ ]]; then + if [[ ${rc} -eq 1 ]]; then + rc="${BASH_REMATCH[2]}" + fi + if [[ -z "${BASH_REMATCH[1]}" ]]; then + stderr_capture="" + else + stderr_capture="${stderr_capture%$'\n'exit status [0-9]*}" + fi + fi + if [[ -n "${stderr_capture}" ]]; then + printf '%s\n' "${stderr_capture}" >&2 + fi + return "${rc}" +} + +go_hostutil_publish_pr() { + ensure_go_run_env + local -a env_args=( + "GOPATH=${GOPATH}" + "GOMODCACHE=${GOMODCACHE}" + "GOCACHE=${GOCACHE}" + ) + + [[ -n "${TERM:-}" ]] && env_args+=("TERM=${TERM}") + [[ -n "${GPG_TTY:-}" ]] && env_args+=("GPG_TTY=${GPG_TTY}") + [[ -n "${GNUPGHOME:-}" ]] && env_args+=("GNUPGHOME=${GNUPGHOME}") + [[ -n "${SSH_AUTH_SOCK:-}" ]] && env_args+=("SSH_AUTH_SOCK=${SSH_AUTH_SOCK}") + [[ -n "${SSH_AGENT_PID:-}" ]] && env_args+=("SSH_AGENT_PID=${SSH_AGENT_PID}") + [[ -n "${SSH_ASKPASS:-}" ]] && env_args+=("SSH_ASKPASS=${SSH_ASKPASS}") + [[ -n "${GIT_ASKPASS:-}" ]] && env_args+=("GIT_ASKPASS=${GIT_ASKPASS}") + [[ -n "${XDG_CONFIG_HOME:-}" ]] && env_args+=("XDG_CONFIG_HOME=${XDG_CONFIG_HOME}") + [[ -n "${XDG_STATE_HOME:-}" ]] && env_args+=("XDG_STATE_HOME=${XDG_STATE_HOME}") + [[ -n "${XDG_CACHE_HOME:-}" ]] && env_args+=("XDG_CACHE_HOME=${XDG_CACHE_HOME}") + [[ -n "${XDG_DATA_HOME:-}" ]] && env_args+=("XDG_DATA_HOME=${XDG_DATA_HOME}") + [[ -n "${XDG_RUNTIME_DIR:-}" ]] && env_args+=("XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}") + [[ -n "${GH_TOKEN:-}" ]] && env_args+=("GH_TOKEN=${GH_TOKEN}") + [[ -n "${GITHUB_TOKEN:-}" ]] && env_args+=("GITHUB_TOKEN=${GITHUB_TOKEN}") + [[ -n "${GH_HOST:-}" ]] && env_args+=("GH_HOST=${GH_HOST}") + [[ -n "${GH_CONFIG_DIR:-}" ]] && env_args+=("GH_CONFIG_DIR=${GH_CONFIG_DIR}") + + run_clean_host_command_in_dir "${ROOT_DIR}" env \ + "${env_args[@]}" \ + "${HOST_GO_BIN}" run ./cmd/workcell-hostutil "$@" +} + +go_colimautil() { + ensure_go_run_env + run_clean_host_command_in_dir "${ROOT_DIR}" env \ + GOPATH="${GOPATH}" \ + GOMODCACHE="${GOMODCACHE}" \ + GOCACHE="${GOCACHE}" \ + "${HOST_GO_BIN}" run ./cmd/workcell-colimautil "$@" +} diff --git a/scripts/validate-repo.sh b/scripts/validate-repo.sh index 3929eb25..e2fb6d22 100755 --- a/scripts/validate-repo.sh +++ b/scripts/validate-repo.sh @@ -157,6 +157,7 @@ shell_files=( "${ROOT_DIR}/scripts/lib/go-run-env.sh" "${ROOT_DIR}/scripts/lib/launcher/host-detect.sh" "${ROOT_DIR}/scripts/lib/launcher/host-exec.sh" + "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" "${ROOT_DIR}/scripts/lib/trusted-entrypoint.sh" "${ROOT_DIR}/scripts/lib/manage_injection_policy" "${ROOT_DIR}/scripts/lib/pty_transcript" diff --git a/scripts/verify-invariants.sh b/scripts/verify-invariants.sh index 3963dcaf..01b0a114 100755 --- a/scripts/verify-invariants.sh +++ b/scripts/verify-invariants.sh @@ -2766,7 +2766,7 @@ WORKCELL_COLIMA_TIMEOUT_HARNESS="${BARRIER_VERIFY_ROOT}/workcell-colima-timeout- printf '\n' extract_top_level_bash_function "${ROOT_DIR}/scripts/workcell" terminate_process_tree_by_pid printf '\n' - extract_top_level_bash_function "${ROOT_DIR}/scripts/workcell" run_go_hostutil_preserve_exit + extract_top_level_bash_function "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" run_go_hostutil_preserve_exit printf '\n' extract_top_level_bash_function "${ROOT_DIR}/scripts/workcell" run_host_colima_with_timeout printf '\n' @@ -2843,12 +2843,12 @@ WORKCELL_RUNTIME_BUILD_RETRY_HARNESS="${BARRIER_VERIFY_ROOT}/workcell-runtime-bu } >"${WORKCELL_RUNTIME_BUILD_RETRY_HARNESS}" bash "${WORKCELL_RUNTIME_BUILD_RETRY_HARNESS}" -if ! rg -q 'run_clean_host_command_in_dir "\$\{ROOT_DIR\}" env' "${ROOT_DIR}/scripts/workcell" || - ! rg -q 'GOPATH="\$\{GOPATH\}"' "${ROOT_DIR}/scripts/workcell" || - ! rg -q 'GOMODCACHE="\$\{GOMODCACHE\}"' "${ROOT_DIR}/scripts/workcell" || - ! rg -q 'GOCACHE="\$\{GOCACHE\}"' "${ROOT_DIR}/scripts/workcell" || - ! rg -q '"\$\{HOST_GO_BIN\}" run ./cmd/workcell-hostutil "\$@"' "${ROOT_DIR}/scripts/workcell"; then - echo "Expected scripts/workcell to invoke the bootstrap Go helper from the repo root under a scrubbed environment with explicit Go caches" >&2 +if ! rg -q 'run_clean_host_command_in_dir "\$\{ROOT_DIR\}" env' "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" || + ! rg -q 'GOPATH="\$\{GOPATH\}"' "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" || + ! rg -q 'GOMODCACHE="\$\{GOMODCACHE\}"' "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" || + ! rg -q 'GOCACHE="\$\{GOCACHE\}"' "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" || + ! rg -q '"\$\{HOST_GO_BIN\}" run ./cmd/workcell-hostutil "\$@"' "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh"; then + echo "Expected scripts/lib/launcher/go-hostutil.sh to invoke the bootstrap Go helper from the repo root under a scrubbed environment with explicit Go caches" >&2 exit 1 fi diff --git a/scripts/workcell b/scripts/workcell index 745fa925..b867ab6a 100755 --- a/scripts/workcell +++ b/scripts/workcell @@ -68,7 +68,6 @@ source "${ROOT_DIR}/scripts/lib/sessionctl-shim.sh" source "${ROOT_DIR}/scripts/lib/launcher/host-detect.sh" # shellcheck source=/dev/null source "${ROOT_DIR}/scripts/lib/launcher/host-exec.sh" -HOST_GO_BIN="$(resolve_fixed_host_tool go /opt/homebrew/bin/go /usr/local/go/bin/go /usr/local/bin/go /usr/bin/go)" REAL_HOME="$(resolve_workcell_real_home)" case "$(uname -s 2>/dev/null || true)" in Darwin) @@ -78,81 +77,12 @@ case "$(uname -s 2>/dev/null || true)" in export WORKCELL_GO_CACHE_ROOT="${XDG_CACHE_HOME:-${REAL_HOME}/.cache}/workcell/go" ;; esac -go_hostutil() { - ensure_go_run_env - run_clean_host_command_in_dir "${ROOT_DIR}" env \ - GOPATH="${GOPATH}" \ - GOMODCACHE="${GOMODCACHE}" \ - GOCACHE="${GOCACHE}" \ - "${HOST_GO_BIN}" run ./cmd/workcell-hostutil "$@" -} - -run_go_hostutil_preserve_exit() { - local stderr_file="" - local stderr_capture="" - local rc=0 - - stderr_file="$(mktemp "${TMPDIR:-/tmp}/workcell-hostutil-stderr.XXXXXX")" - trap 'rm -f "${stderr_file}"' RETURN - go_hostutil "$@" 2>"${stderr_file}" || rc=$? - stderr_capture="$(cat "${stderr_file}")" - rm -f "${stderr_file}" - trap - RETURN - - if [[ "${stderr_capture}" =~ (^|$'\n')exit\ status\ ([0-9]+)$ ]]; then - if [[ ${rc} -eq 1 ]]; then - rc="${BASH_REMATCH[2]}" - fi - if [[ -z "${BASH_REMATCH[1]}" ]]; then - stderr_capture="" - else - stderr_capture="${stderr_capture%$'\n'exit status [0-9]*}" - fi - fi - if [[ -n "${stderr_capture}" ]]; then - printf '%s\n' "${stderr_capture}" >&2 - fi - return "${rc}" -} - -go_hostutil_publish_pr() { - ensure_go_run_env - local -a env_args=( - "GOPATH=${GOPATH}" - "GOMODCACHE=${GOMODCACHE}" - "GOCACHE=${GOCACHE}" - ) - - [[ -n "${TERM:-}" ]] && env_args+=("TERM=${TERM}") - [[ -n "${GPG_TTY:-}" ]] && env_args+=("GPG_TTY=${GPG_TTY}") - [[ -n "${GNUPGHOME:-}" ]] && env_args+=("GNUPGHOME=${GNUPGHOME}") - [[ -n "${SSH_AUTH_SOCK:-}" ]] && env_args+=("SSH_AUTH_SOCK=${SSH_AUTH_SOCK}") - [[ -n "${SSH_AGENT_PID:-}" ]] && env_args+=("SSH_AGENT_PID=${SSH_AGENT_PID}") - [[ -n "${SSH_ASKPASS:-}" ]] && env_args+=("SSH_ASKPASS=${SSH_ASKPASS}") - [[ -n "${GIT_ASKPASS:-}" ]] && env_args+=("GIT_ASKPASS=${GIT_ASKPASS}") - [[ -n "${XDG_CONFIG_HOME:-}" ]] && env_args+=("XDG_CONFIG_HOME=${XDG_CONFIG_HOME}") - [[ -n "${XDG_STATE_HOME:-}" ]] && env_args+=("XDG_STATE_HOME=${XDG_STATE_HOME}") - [[ -n "${XDG_CACHE_HOME:-}" ]] && env_args+=("XDG_CACHE_HOME=${XDG_CACHE_HOME}") - [[ -n "${XDG_DATA_HOME:-}" ]] && env_args+=("XDG_DATA_HOME=${XDG_DATA_HOME}") - [[ -n "${XDG_RUNTIME_DIR:-}" ]] && env_args+=("XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}") - [[ -n "${GH_TOKEN:-}" ]] && env_args+=("GH_TOKEN=${GH_TOKEN}") - [[ -n "${GITHUB_TOKEN:-}" ]] && env_args+=("GITHUB_TOKEN=${GITHUB_TOKEN}") - [[ -n "${GH_HOST:-}" ]] && env_args+=("GH_HOST=${GH_HOST}") - [[ -n "${GH_CONFIG_DIR:-}" ]] && env_args+=("GH_CONFIG_DIR=${GH_CONFIG_DIR}") - - run_clean_host_command_in_dir "${ROOT_DIR}" env \ - "${env_args[@]}" \ - "${HOST_GO_BIN}" run ./cmd/workcell-hostutil "$@" -} - -go_colimautil() { - ensure_go_run_env - run_clean_host_command_in_dir "${ROOT_DIR}" env \ - GOPATH="${GOPATH}" \ - GOMODCACHE="${GOMODCACHE}" \ - GOCACHE="${GOCACHE}" \ - "${HOST_GO_BIN}" run ./cmd/workcell-colimautil "$@" -} +# Sourced after REAL_HOME and WORKCELL_GO_CACHE_ROOT are established so the +# go_hostutil/go_colimautil wrappers this module defines can never be invoked +# with an unsanitised host home or Go cache root (security ordering invariant; +# see internal/testkit/security_boundary_test.go). +# shellcheck source=/dev/null +source "${ROOT_DIR}/scripts/lib/launcher/go-hostutil.sh" HOST_GIT_BIN="" HOST_COLIMA_BIN=""