diff --git a/acq b/acq index 7652f047..68daa55c 100755 --- a/acq +++ b/acq @@ -1610,7 +1610,7 @@ case "$subcommand" in # to the backend CLI. shift if _acq_secret_needs_usai_facts "$@"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" >/dev/null 2>&1 || true fi # Parse scope the same way secret set/rm do: `-g`/`--global` -> global; a # leading bare token before the service -> sandbox; a lone token -> service. diff --git a/acq.backends/agents.sh b/acq.backends/agents.sh index d5532fe9..df6fddca 100644 --- a/acq.backends/agents.sh +++ b/acq.backends/agents.sh @@ -55,13 +55,6 @@ acq_agent_template_image() { esac } -acq_agent_has_msb_install_recipe() { - case "$1" in - opencode) return 0 ;; - *) return 1 ;; - esac -} - # Agent-specific built-in kit metadata. Only the built-in bundle participates in # implicit selection; team/personal kits supplied through ACQ_EXTRA_KITS or --kit # are never matched by agent name. @@ -99,7 +92,7 @@ acq_agent_kit_start_owner() { acq_agent_builtin_kit_enabled() { case "$1" in - opencode) return 1 ;; + opencode) return 0 ;; shell|"") return 1 ;; *) return 1 ;; esac diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 47cfe23d..111aa98c 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -36,7 +36,8 @@ PATTERNS_KIT_REPO="git+https://github.com/GSA-TTS/agentic-coding-patterns.git" # the bundle-version anchor recorded in a sandbox's host-side provenance record # (see ACQ_BUILTIN_BUNDLE below + the provenance helpers) so acq can tell a # stale sandbox from a current one. -PATTERNS_KIT_REF="2dd2ad6ad5f63b842d732424bed5ba9aebeed676" # agentic-coding-patterns v1.10.0 +# agentic-coding-patterns v1.12.0 release SHA. +PATTERNS_KIT_REF="56d1f5f49ef928f50e61aa64a93d618b3d21c415" PATTERNS_KIT_DIR="integrations/isolation/acq-kits" USAI_PROVIDER_KIT_NAME="usai-provider" @@ -110,13 +111,16 @@ fi # directory under PATTERNS_KIT_DIR; DESC[i] is the one-line picker description. # `prime-agent` is intentionally omitted: it is a skeleton at the current pin # (see the patterns kits.yaml parity note) and would be non-functional if -# offered. Add it here once it is functional. +# offered. Add it here once it is functional. Heavier kits such as `oci-engine` +# still belong here when they are functional; the picker description must make +# their create-time cost clear. # shellcheck disable=SC2034 # consumed by `acq configure` (cross-function reader) -ACQ_OPTIN_KIT_NAMES=(openchamber paseo) +ACQ_OPTIN_KIT_NAMES=(openchamber paseo oci-engine) # shellcheck disable=SC2034 ACQ_OPTIN_KIT_DESCS=( "Browser UI for OpenCode alongside the terminal TUI (publishes ports 3000/4096)" "Self-hosted Paseo browser web UI for coding agents (one port, loopback only)" + "Rootless podman for docker run / docker compose inside the sandbox (installs packages)" ) # _acq_optin_kit_ref NAME — echo the fully-pinned git+https kit ref for an opt-in @@ -810,6 +814,10 @@ _acq_builtin_support_kit_names() { _acq_agent_builtin_kit_ref() { local kit_name kit_name=$(acq_agent_builtin_kit_name "$1") || return 1 + if [ -n "${BATS_TEST_NAME:-}" ] && [ -n "${ACQ_TEST_AGENT_KIT:-}" ]; then + printf '%s\n' "$ACQ_TEST_AGENT_KIT" + return 0 + fi printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$kit_name" } @@ -2339,8 +2347,8 @@ maybe_offer_bundle_refresh() { # opencode-docker template) install the package with lifecycle scripts skipped, # so the first launch fails with: # Error: opencode-ai's postinstall script was not run. -# This is backend-agnostic (the msb npm install can hit the same gap), so run -# the fix on any backend before attaching an opencode agent. +# Run the fix before attaching an opencode agent so a broken packaged binary can +# recover without changing the agent launch path. # # Idempotent and cheap: if opencode already runs (`opencode --version`), do # nothing. Otherwise locate the installed package via `npm root -g` and run its diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 9b0dae66..9e7a49a4 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -456,28 +456,6 @@ ACQ_MSB_UPSTREAM_CA_FILE="${ACQ_MSB_UPSTREAM_CA_FILE:-${ACQ_STATE_DIR:-${XDG_STA # Use it to confirm the failure and that the fix resolves it. Off by default. ACQ_MSB_NO_UPSTREAM_CA="${ACQ_MSB_NO_UPSTREAM_CA:-}" -# Agent binary install. -# --------------------------------------------------------------------------- -# Unlike sbx (whose agent templates BAKE the agent binary into the image), msb -# runs a plain OCI base, so the adapter must install the requested agent itself. -# For `opencode`, install the npm package globally (node is a base-image -# prerequisite the adapter already verifies). The registry host must be reachable -# from the guest, so the adapter allow-lists it at create (kit net-rules are -# default-deny). The install is idempotent (marker-gated + `command -v` guarded). -# -# Only agents with a known install recipe are auto-installed; `shell` is a no-op -# (there is nothing to install), and an unknown agent is a clear, non-fatal warning -# (the user can bake it into ACQ_MSB_IMAGE). Override the opencode package spec -# (e.g. to pin a version like opencode-ai@1.2.3) with ACQ_MSB_OPENCODE_PKG. -ACQ_MSB_OPENCODE_PKG="${ACQ_MSB_OPENCODE_PKG:-opencode-ai}" - -# Hosts the agent installer needs to reach, allow-listed at create so egress -# (default-deny under kit net-rules) permits the npm download. registry.npmjs.org -# serves metadata; the tarballs are on the same host for the public registry. -# Override for an internal mirror via ACQ_MSB_NPM_HOSTS (space-separated). -ACQ_MSB_NPM_HOSTS="${ACQ_MSB_NPM_HOSTS:-registry.npmjs.org}" - -# --------------------------------------------------------------------------- # Balanced egress baseline (ADR-0018) # --------------------------------------------------------------------------- # msb defaults guest egress to NONE, so without a baseline an msb sandbox is far @@ -508,7 +486,7 @@ ACQ_MSB_NPM_HOSTS="${ACQ_MSB_NPM_HOSTS:-registry.npmjs.org}" # # ALL tiers keep deny-by-default EXCEPT open; the tier only sizes the baseline # allowlist. This normalizes to a lowercase enum, fail-closed to `balanced` on an -# invalid value (matching ACQ_MSB_SHORT_NAME_MODE's validator). +# invalid value. # # DEPRECATED ALIAS — `ACQ_MSB_BALANCED_EGRESS` predates the neutral tier and is # retained for one deprecation window. It maps into the tier fail-safe (tighter, @@ -560,103 +538,6 @@ unset _acq_net_tier_source _acq_msb_balanced_egress_alias # never used to provision cannot abort an unrelated acq invocation. ACQ_NETWORK_TIER_CONFIRM_OPEN="${ACQ_NETWORK_TIER_CONFIRM_OPEN:-}" -# --------------------------------------------------------------------------- -# OCI container engine (podman) — ensure agents can run OCI images (ADR-0020) -# --------------------------------------------------------------------------- -# Agents frequently need to run OCI images inside the sandbox (e.g. `docker run`, -# bringing up a docker-compose.yaml). The default image ships the Docker CLI + -# compose plugin, but msb's microVM init (/init.krun) never starts dockerd, so -# the Docker socket is dead; and dockerd's overlay2 storage driver cannot sit on -# the sandbox's already-overlay root without a disk-backed data volume. Rather -# than retrofit the msb docker:dind entrypoint recipe (a daemon we would have to -# start and keep alive across restarts, plus a per-sandbox disk-backed volume), -# the adapter provisions **podman** — a daemonless engine that forks runc/crun -# per invocation (no socket, no restart lifecycle), uses fuse-overlayfs on the -# overlay root (no disk-backed volume), and needs no nested virtualization. We -# run podman ROOTLESS as the agent user: the install step installs the rootless -# prerequisites (uidmap for newuidmap/newgidmap, passt + slirp4netns for rootless -# networking) from the same mirror in the same step as podman itself, and grants -# the agent access to /dev/net/tun (group-scoped) so rootless networking can set -# up. Rootless keeps containers unprivileged (defense-in-depth), aligns container/ -# host UIDs, and lets the agent invoke podman directly (no sudo wrapper). See -# ADR-0020 and _acq_msb_ensure_oci. -# -# podman is CLI-compatible with docker for the run/build/compose workflows this -# targets, and the bundled Docker CLI is non-functional here anyway (dead -# socket), so we alias `docker` -> podman (in /usr/local/bin, ahead of /usr/bin) -# so both `docker run …` and `docker compose …` route to podman. `docker compose` -# resolves to `podman compose`, which drives the installed podman-compose -# provider — this is what makes docker-compose.yaml files usable (the standalone -# `docker-compose` CLI is deprecated in favour of the `docker compose` -# subcommand, so we do not provide a separate `docker-compose` binary). -# -# We also make unadorned image names resolve to Docker Hub by default (stock -# podman sends many short names to quay.io and has no default search registry), -# to reduce migration burden for users whose code assumes `docker run ` -# means Docker Hub. See _acq_msb_ensure_oci step 4 and ADR-0020. -# -# Toggle: on by default. Set ACQ_MSB_ENSURE_OCI=0 (or empty) to skip the step -# entirely (e.g. a base image that bakes its own working engine, or a lean -# sandbox that needs no OCI support). Normalized to exactly "1" (on) or "" (off): -# an unset value defaults on; "0"/"false"/"no"/"off"/empty are off -# (case-insensitive); anything else is on. -ACQ_MSB_ENSURE_OCI="${ACQ_MSB_ENSURE_OCI-1}" -case "$(printf '%s' "$ACQ_MSB_ENSURE_OCI" | tr '[:upper:]' '[:lower:]')" in - ""|0|false|no|off) ACQ_MSB_ENSURE_OCI="" ;; - *) ACQ_MSB_ENSURE_OCI="1" ;; -esac - -# The packages installed to provide the OCI engine (space-separated). Override -# for a different set or an internal mirror's package names. podman-compose is -# the `docker compose` / `podman compose` provider. fuse-overlayfs lets podman -# use the `overlay` graph driver on msb's overlay ROOT filesystem (the kernel -# `overlay` driver refuses to stack on overlayfs); without it the adapter falls -# back to the `vfs` driver, which works everywhere but is disk-heavy. uidmap -# (newuidmap/newgidmap), passt, and slirp4netns are the ROOTLESS prerequisites: -# uidmap provides the setuid helpers rootless podman needs to map the subuid/ -# subgid ranges (already present for `agent`), and passt/slirp4netns provide -# rootless container networking. See ADR-0020 and _acq_msb_ensure_oci. The install -# uses the OS package mirror (apt/dnf/apk), which under the default balanced egress -# baseline (ADR-0018) is already reachable (archive.ubuntu.com / ports.ubuntu.com -# / security.ubuntu.com / *.debian.org are in the vendored host list). With -# ACQ_NETWORK_TIER=strict (kit hosts only), or a base whose egress is otherwise -# narrowed, the mirror is unreachable and the install fails soft (a clear warning; -# provision continues; OCI is simply unavailable). -ACQ_MSB_PODMAN_PKGS="${ACQ_MSB_PODMAN_PKGS:-podman podman-compose fuse-overlayfs uidmap passt slirp4netns}" - -# podman short-name resolution mode written into the docker-first registries -# drop-in (/etc/containers/registries.conf.d/00-acq-docker-first.conf). This -# governs what happens when the agent runs an UNQUALIFIED image name (e.g. -# `docker run nginx`) that is not already fully qualified to a registry. -# -# DEFAULT: "enforcing" (least-privilege / prompt-injection defense). Per the -# PR #302 review (3-model consensus + reviewer), "permissive" is the WRONG -# default for a federal sandbox running a prompt-injectable agent: it silently -# resolves ambiguous short names, which removes the defense against image -# substitution / typosquatting (an injected `docker run nginx` could resolve to -# docker.io//nginx without any prompt). We KEEP -# unqualified-search-registries = ["docker.io"] below, so unqualified names -# still resolve deterministically to Docker Hub and migration ergonomics are -# preserved; "enforcing" only fails closed on interactively-ambiguous short -# names instead of silently resolving them. Because there is a single search -# registry, "enforcing" costs essentially no day-to-day ergonomics. -# -# Setting ACQ_MSB_SHORT_NAME_MODE=permissive is an EXPLICIT operator override -# that REMOVES the typosquatting / image-substitution guardrail. Only podman's -# accepted values are allowed: enforcing | permissive | disabled. Any other -# value (including empty) is rejected and falls back to "enforcing" -# (fail-closed), with a warning. -ACQ_MSB_SHORT_NAME_MODE="${ACQ_MSB_SHORT_NAME_MODE:-enforcing}" -_acq_msb_short_name_mode_lc="$(printf '%s' "$ACQ_MSB_SHORT_NAME_MODE" | tr '[:upper:]' '[:lower:]')" -case "$_acq_msb_short_name_mode_lc" in - enforcing|permissive|disabled) ACQ_MSB_SHORT_NAME_MODE="$_acq_msb_short_name_mode_lc" ;; - *) - printf 'msb: WARNING: invalid ACQ_MSB_SHORT_NAME_MODE=%s (expected enforcing|permissive|disabled); falling back to enforcing\n' "$ACQ_MSB_SHORT_NAME_MODE" >&2 - ACQ_MSB_SHORT_NAME_MODE="enforcing" - ;; -esac -unset _acq_msb_short_name_mode_lc - # Path to the vendored host list (a verbatim mirror of `sbx policy inspect # local-policy`; see acq.backends/msb-balanced-hosts.txt). Override to point at a # site-specific list. ACQ_SCRIPT_DIR is exported by the acq entry point (and set @@ -1114,10 +995,6 @@ acq_backend_start() { [ "$_start_rc" -eq 0 ] || return "$_start_rc" _acq_msb_wait_for_exec_ready "$_name" || \ echo "acq(msb): warning: $_name did not become exec-ready after start." >&2 - # Re-grant the rootless-podman device nodes (/dev/net/tun, /dev/fuse): /dev is a - # devtmpfs re-created each boot, so the provision-time grant is lost across - # restart. Cheap + idempotent; no-op when ENSURE_OCI is disabled or absent. - _acq_msb_grant_oci_devs "$_name" # The host ssh-agent forward's --vsock route persists in the sandbox config # across stop/start, but the in-guest socat bridge process dies on stop, so it # must be (re)started here too. Gated on the persisted marker (no provision ran @@ -3335,37 +3212,6 @@ EOF fi fi - # Allow-list the agent installer's registry host(s) so the (default-deny) guest - # egress permits the npm download. Only when we will actually install an agent - # (a known recipe exists); `shell` and unknown agents add no rule. - # - # De-dupe against the balanced set: when the baseline is ON, registry.npmjs.org - # is already allow-listed, so a second bare `allow@registry.npmjs.org` would be - # dead weight (both allow; no deny to shadow). We therefore skip any npm host - # that the balanced block ALREADY emitted a rule for, rather than skipping the - # whole block — an operator who overrides ACQ_MSB_NPM_HOSTS to an internal - # mirror NOT in the balanced set still gets its rule. Under the `strict` tier - # (or `open`) the balanced set is empty, so nothing is elided. - if _acq_msb_agent_has_install_recipe "$agent"; then - local _npm_host - for _npm_host in $ACQ_MSB_NPM_HOSTS; do - case "$_npm_host" in - ""|*[!A-Za-z0-9.*_-]*) - echo "acq(msb): warning: skipping non-hostname npm host: $_npm_host" >&2 - continue - ;; - esac - # Already covered by a balanced rule? Skip the redundant bare allow. - case "$_balanced_hosts" in - *" ${_npm_host} "*) - acq_debug "msb: npm host ${_npm_host} already in balanced set; skipping redundant rule" - continue - ;; - esac - create_flags+=(--net-rule "allow@${_npm_host}") - done - fi - # TLS interception is REQUIRED for secret substitution: msb only swaps a # placeholder for the real value on a connection it can see into (the security # docs: "a secret requires intercepted TLS"). Without --tls-intercept the USAi @@ -3735,31 +3581,6 @@ EOF acq_spin_stop "Preparing the agent user" acq_debug "msb provision: agent user ready ($name)" - # Ensure an OCI container engine (podman) so agents can run OCI images - # (docker run / docker compose). Idempotent + marker-gated; FAILS SOFT (a - # warning, never aborting provision) if the engine cannot be installed — e.g. - # the OS package mirror is unreachable under a narrowed egress. See ADR-0020. - acq_debug "msb provision: ensuring OCI engine ($name)" - if [ -n "$ACQ_MSB_ENSURE_OCI" ]; then - acq_spin_start "Ensuring an OCI engine (podman)" - _acq_msb_ensure_oci "$name" - acq_spin_stop "Ensuring an OCI engine (podman)" - fi - acq_debug "msb provision: OCI engine step done ($name)" - - # Install the requested agent binary (sbx bakes it into the template image; on - # a plain msb base acq must install it). Idempotent + marker-gated; a no-op for - # `shell`, a clear warning for an agent with no known recipe. - acq_debug "msb provision: installing agent '$agent' ($name)" - if _acq_msb_agent_has_install_recipe "$agent"; then - acq_spin_start "Installing the '$agent' agent" - _acq_msb_install_agent "$name" "$agent" - acq_spin_stop "Installing the '$agent' agent" - else - _acq_msb_install_agent "$name" "$agent" - fi - acq_debug "msb provision: agent install step done ($name)" - # Record which agent this sandbox runs, so acq_backend_attach (which only gets # the sandbox name) knows what to launch — the sbx equivalent is that # `sbx run --name` re-launches the agent baked in at create. Written as root to @@ -3830,168 +3651,6 @@ EOF } # --------------------------------------------------------------------------- -# _acq_msb_agent_has_install_recipe AGENT — 0 if acq knows how to install AGENT -# --------------------------------------------------------------------------- -# `shell` needs no binary; today only `opencode` has a recipe. Others are baked -# into ACQ_MSB_IMAGE by the user (warned at install time). Keep this in sync with -# _acq_msb_install_agent's case. -_acq_msb_agent_has_install_recipe() { - acq_agent_has_msb_install_recipe "$1" -} - -# _acq_msb_safe_agent_token AGENT -> 0 if AGENT is a safe agent token to -# interpolate into a shell command. Agent tokens are short lowercase names -# (opencode, claude, shell, …); restrict to [a-z-] so a value can never break -# out of the `sh -c "command -v '$agent'"` single-quoting (defense against a -# `acq create "x';…'"` arg or a tampered /var/lib/acq/agent marker). Callers -# that build an `sh -c` string with $agent MUST gate on this first. -_acq_msb_safe_agent_token() { - acq_agent_safe_token "$1" -} - -# --------------------------------------------------------------------------- -# _acq_msb_report_npm_install_failure NAME — diagnose a failed in-guest npm -# install, distinguishing a genuinely-missing npm from an UNREACHABLE registry. -# --------------------------------------------------------------------------- -# A network-cut install (corporate TLS interception → curl (56) unexpected eof / -# HTTP 000, or a resolver that can't see the registry → NXDOMAIN) otherwise reads -# identically to "node/npm isn't installed", which sends users down the wrong -# path (reinstalling node on the HOST, which never touches the guest). Probe the -# actual cause in-guest and print the message that matches it. -# -# Branches: -# - npm binary absent in-guest → genuinely-missing message. -# - npm present + registry probe: -# unresolved (curl exit 6) → registry name did not resolve; DNS. -# unreachable (curl exit / 000) → TLS/network cut; point at KFM §30. -# responded / inconclusive → registry rejected it or a real npm error. -# Reuses the shared _classify_key_status fingerprint so the npm path and the -# USAi path classify curl results identically. -_acq_msb_report_npm_install_failure() { - local name="$1" - echo "acq(msb): warning: 'npm install -g $ACQ_MSB_OPENCODE_PKG' failed in '$name'." >&2 - echo "acq(msb): opencode will not be available on attach." >&2 - - # Is npm actually present in the guest? If not, that is the cause outright. - if ! _acq_msb_cli exec "$name" -u 0 -- sh -c 'command -v npm' >/dev/null 2>&1; then - echo "acq(msb): Cause: npm is not present in the guest. Use a base image that" >&2 - echo "acq(msb): ships node/npm, or bake opencode into ACQ_MSB_IMAGE." >&2 - return 0 - fi - - # npm exists — classify reachability of the registry from INSIDE the guest, - # using the same curl `|` fingerprint as the USAi key probe. - # Probe the first configured registry host over HTTPS; any HTTP response (even - # a 404) proves the connection completed, i.e. NOT a network cut. - local _reg _first_host _raw _status - _first_host="" - for _reg in $ACQ_MSB_NPM_HOSTS; do _first_host="$_reg"; break; done - if [ -n "$_first_host" ] && command -v _classify_key_status >/dev/null 2>&1; then - _raw=$(_acq_msb_cli exec "$name" -u 0 -- sh -c \ - "curl -sS -o /dev/null -w '%{http_code}' https://${_first_host}/; printf '|%s' \"\$?\"" \ - 2>/dev/null || true) - _status=$(_classify_key_status "$_raw") - case "$_status" in - unresolved) - echo "acq(msb): Cause: the npm registry host (${_first_host}) did not RESOLVE from" >&2 - echo "acq(msb): the guest. This is DNS, not a missing npm. Point the guest at a" >&2 - echo "acq(msb): usable resolver via ACQ_MSB_DNS_NAMESERVER, or set ACQ_MSB_NPM_HOSTS" >&2 - echo "acq(msb): to a mirror the guest can resolve. See docs/KNOWN_FAILURE_MODES.md §30." >&2 - return 0 - ;; - unreachable) - echo "acq(msb): Cause: the npm registry host (${_first_host}) is NOT REACHABLE from" >&2 - echo "acq(msb): the guest — the connection was cut (TLS 'unexpected eof' / HTTP 000)," >&2 - echo "acq(msb): NOT a missing npm. This is a network / TLS-interception problem." >&2 - echo "acq(msb): See docs/KNOWN_FAILURE_MODES.md §30 for diagnosis." >&2 - return 0 - ;; - esac - fi - - # npm present and the registry either responded (an HTTP error) or the probe - # was inconclusive: give neutral guidance without implying node is missing. - echo "acq(msb): npm is present and the registry appears reachable, so the install" >&2 - echo "acq(msb): itself failed (registry rejected the request, disk, or a package" >&2 - echo "acq(msb): error). Re-run with ACQ_DEBUG=1 to see npm's output, set" >&2 - echo "acq(msb): ACQ_MSB_NPM_HOSTS for an internal mirror, or bake opencode into" >&2 - echo "acq(msb): ACQ_MSB_IMAGE." >&2 - return 0 -} - -# --------------------------------------------------------------------------- -# _acq_msb_install_agent NAME AGENT — install the agent binary into the guest -# --------------------------------------------------------------------------- -# sbx's agent templates ship the binary; msb runs a plain base, so acq installs -# it. For `opencode`, install the npm package globally as root (node is a -# verified base prerequisite; the registry host was allow-listed at create). -# Idempotent: skip if the binary is already present (a pre-baked ACQ_MSB_IMAGE), -# and marker-gate so a re-apply doesn't reinstall. `shell` is a no-op; an unknown -# agent is a non-fatal warning (the sandbox still comes up; the user can bake the -# binary into ACQ_MSB_IMAGE). -_acq_msb_install_agent() { - local name="$1" agent="$2" - - case "$agent" in - shell|"") acq_debug "msb: agent '$agent' needs no binary install"; return 0 ;; - esac - - # Charset-guard the agent token before it enters any `sh -c "… '$agent' …"`. - # `acq create ` does not go through is_known_agent, so a hostile - # token (e.g. "x';touch /tmp/pwn;'") could otherwise break the single-quoting - # and run as root. Refuse anything outside [a-z-]. - if ! _acq_msb_safe_agent_token "$agent"; then - echo "acq(msb): refusing agent name with unexpected characters: '$agent'" >&2 - return 0 - fi - - if ! _acq_msb_agent_has_install_recipe "$agent"; then - # Maybe the base image already provides it — don't warn if so. - if _acq_msb_cli exec "$name" -u 0 -- sh -c "command -v '$agent'" >/dev/null 2>&1; then - acq_debug "msb: agent '$agent' already present in base image" - return 0 - fi - echo "acq(msb): warning: no install recipe for agent '$agent' and it is not in the" >&2 - echo "acq(msb): base image. Attach will fail to launch it. Bake '$agent' into" >&2 - echo "acq(msb): ACQ_MSB_IMAGE, or use an agent acq can install (e.g. opencode)." >&2 - return 0 - fi - - # Already installed (pre-baked image or a prior apply)? Then done. - if _acq_msb_cli exec "$name" -u 0 -- sh -c "command -v '$agent'" >/dev/null 2>&1; then - acq_debug "msb: agent '$agent' already installed in $name" - return 0 - fi - - local marker="/var/lib/acq/agent-installed-${agent}" - if _acq_msb_cli exec "$name" -u 0 -- sh -c "test -f '$marker'" >/dev/null 2>&1; then - return 0 - fi - - case "$agent" in - opencode) - acq_debug "msb: installing opencode ($ACQ_MSB_OPENCODE_PKG) via npm in $name" - # Install globally as root so the binary lands on the system PATH for every - # user (the agent runs as `agent`). The package spec is passed as a single - # argv element (never re-split by a shell); ACQ_MSB_OPENCODE_PKG is a - # controlled tunable. `npm` is present (node prerequisite). npm needs the - # registry host, allow-listed at create. - if ! _acq_msb_cli exec "$name" -u 0 -- npm install -g --no-fund --no-audit "$ACQ_MSB_OPENCODE_PKG" >/dev/null 2>&1; then - _acq_msb_report_npm_install_failure "$name" - return 0 - fi - ;; - esac - - # Verify the binary is now on PATH before recording the marker. - if _acq_msb_cli exec "$name" -u 0 -- sh -c "command -v '$agent'" >/dev/null 2>&1; then - _acq_msb_cli exec "$name" -u 0 -- sh -c "mkdir -p /var/lib/acq && touch '$marker'" >/dev/null 2>&1 || true - acq_debug "msb: agent '$agent' installed and on PATH in $name" - else - echo "acq(msb): warning: installed '$agent' but it is not on PATH in '$name'." >&2 - fi -} - # --------------------------------------------------------------------------- # _acq_msb_ensure_agent_user NAME — satisfy the sbx/Docker base-image contract # --------------------------------------------------------------------------- @@ -4313,8 +3972,8 @@ _acq_msb_check_socat() { # exposes the forwarded host ssh-agent as a unix socket at # ACQ_MSB_SSH_AGENT_GUEST_SOCK. The --vsock route persists across msb stop/start, # but the socat process dies on stop, so this runs on provision AND on -# acq_backend_start (mirrors _acq_msb_grant_oci_devs). Fail-soft: warns, never -# aborts. Works from EITHER the in-provision flag OR the persisted marker (start +# acq_backend_start. Fail-soft: warns, never aborts. Works from EITHER the +# in-provision flag OR the persisted marker (start # has no provision flag set), so the guest sock path is resolved from whichever # source is authoritative for the call. See ADR-0021. _acq_msb_start_ssh_agent_bridge() { @@ -4620,253 +4279,6 @@ _acq_msb_has_ssh_agent_vsock_route() { printf '%s\n' "$_flat" | grep -Eq "port[:=]${_port}\$" 2>/dev/null } -# --------------------------------------------------------------------------- -# _acq_msb_grant_oci_devs NAME — grant the agent access to the device nodes -# rootless podman needs (/dev/net/tun, /dev/fuse) -# --------------------------------------------------------------------------- -# Rootless podman needs unprivileged access to two root-only device nodes on the -# default image: -# - /dev/net/tun (crw------- root root): the network backend (netavark→pasta, -# or slirp4netns) must open it to set up container networking. -# - /dev/fuse (crw------- root root): the fuse-overlayfs storage driver (our -# PREFERRED driver on the overlay root) must open it to mount image layers. -# Without it `podman info` still passes but `podman run` fails at mount time -# with "fuse: failed to open /dev/fuse: Permission denied" — the exact -# info-OK-but-run-FAILS split seen on the host. -# Group-scope each to the agent (chown root:agent, chmod 0660) — inside the -# microVM only (the security boundary), narrower than world-writable, no new host -# attack surface. -# -# Called on EVERY provision pass (before the OCI install marker gate) AND on -# restart (acq_backend_start), because /dev is a devtmpfs re-created at each boot -# — a one-time grant would be lost after `msb start`. Idempotent, cheap, and a -# best-effort no-op for any device that is absent or when ENSURE_OCI is disabled. -_acq_msb_grant_oci_devs() { - local name="$1" - [ -n "$ACQ_MSB_ENSURE_OCI" ] || return 0 - _acq_msb_cli exec "$name" -u 0 -- sh -c ' - for _dev in /dev/net/tun /dev/fuse; do - if [ -e "$_dev" ]; then - chown root:agent "$_dev" 2>/dev/null || true - chmod 0660 "$_dev" 2>/dev/null || true - fi - done' \ - >/dev/null 2>&1 || true -} - -# --------------------------------------------------------------------------- -# _acq_msb_ensure_oci NAME — ensure an OCI container engine (podman) is usable -# --------------------------------------------------------------------------- -# Guarantee agents can run OCI images inside the sandbox (`docker run`, -# `docker compose up`, etc.). See the ACQ_MSB_ENSURE_OCI block above for the -# rationale (podman over dind; ROOTLESS podman run as the agent user; alias -# docker->podman). This step is idempotent + marker-gated and FAILS SOFT: if the -# engine cannot be provisioned (mirror unreachable under a narrowed egress, -# unknown package manager, rootless prereqs absent, etc.) it warns and returns 0 -# — provision continues, OCI is simply unavailable, exactly like the -# agent-install and prereq-check steps. The package INSTALL runs as root -# (`-u 0`, needed to install), but the engine RUNS rootless as the agent user. -_acq_msb_ensure_oci() { - local name="$1" - [ -n "$ACQ_MSB_ENSURE_OCI" ] || return 0 - - # Grant the rootless-podman device nodes (/dev/net/tun for networking, - # /dev/fuse for the fuse-overlayfs storage driver) on EVERY provision pass, - # BEFORE the install-marker short-circuit below. /dev is a devtmpfs re-created - # each boot, so this must not be gated behind the (persistent) install marker, - # and it is also re-applied on restart from acq_backend_start. - _acq_msb_grant_oci_devs "$name" - - local marker="/var/lib/acq/oci-ready" - if _acq_msb_cli exec "$name" -u 0 -- sh -c "test -f '$marker'" >/dev/null 2>&1; then - return 0 - fi - - # ACQ_MSB_PODMAN_PKGS is operator-controlled config, but it is interpolated - # into a root `sh -c` string below, so charset-guard it (package names are - # word-safe: letters, digits, . _ + - and spaces). Refuse anything else rather - # than risk shell injection into the elevated install. - case "$ACQ_MSB_PODMAN_PKGS" in - *[!A-Za-z0-9._+\ -]*) - echo "acq(msb): warning: ACQ_MSB_PODMAN_PKGS contains unsafe characters; skipping OCI setup." >&2 - return 0 - ;; - esac - - acq_debug "msb: ensuring an OCI engine (podman) in $name" - - # Root setup phase: install podman if absent (distro-detected, non-interactive), - # configure a storage driver that works on msb's overlay root, grant the agent - # access to /dev/net/tun (rootless networking needs it), write a Docker-Hub-first - # registries config, and wire the docker->podman alias. The engine itself RUNS - # ROOTLESS as the agent (verified separately, below) — only this install/config - # phase needs root. The whole block is best-effort; a non-zero exit is caught - # below and treated as non-fatal. - # - # The default image ships a (non-functional) docker CLI, so rather than gate on - # its absence we place our wrapper in /usr/local/bin (ahead of /usr/bin on the - # default PATH) to SHADOW it — the bundled docker talks to a dead socket, - # whereas our wrapper routes to the working podman engine. We overwrite our own - # wrapper idempotently but never touch the base image's /usr/bin/docker. - # - # STORAGE DRIVER: msb's sandbox root filesystem is itself an overlay mount - # (/.msb/rootfs/...). podman's default KERNEL `overlay` graph driver CANNOT stack - # on an overlay root — `podman info` fails with "'overlay' is not supported over - # overlayfs, a mount_program is required". This applies to BOTH rootful and - # rootless. So we write /etc/containers/storage.conf (honored by rootless as its - # lowest-precedence source) selecting a driver that works on an overlay root: - # - PREFER `overlay` + `mount_program=fuse-overlayfs` when fuse-overlayfs is - # present (msb provides /dev/fuse; this is the fast, thin-on-disk path), else - # - FALL BACK to `vfs`, which works everywhere with no extra package or - # /dev/fuse (correct but disk-heavy — full copy per layer). - # - # ROOTLESS NETWORKING: rootless podman's network backend (netavark→pasta, or - # slirp4netns) must open /dev/net/tun, which is root-only (crw------- root root) - # on the default image. We group-scope it to the agent (chown root:agent, 0660) - # so the unprivileged agent can set up container networking. This is inside the - # microVM only (the security boundary) — no new host attack surface. Applied on - # EVERY provision pass (outside the install marker gate) because the device node - # can be re-created with default perms across restarts. - # - # REGISTRY RESOLUTION (Docker-Hub-first, ADR-0020): stock podman resolves many - # unadorned short names to quay.io (e.g. `hello-world` -> quay.io/podman/hello) - # and has no default unqualified search registry. Users migrating from Docker - # assume `docker run nginx` means Docker Hub. So we write a system - # registries.conf setting unqualified-search-registries=["docker.io"] + - # short-name-mode="$SHORT_NAME_MODE", and a shortnames drop-in remapping the - # podman `hello`/`hello-world` aliases back to docker.io/library/hello-world. - # This diverges from stock podman deliberately to reduce migration burden. - # - # SHORT-NAME MODE (PR #302 review): the default is "enforcing", NOT permissive. - # Because there is a single search registry (docker.io), unqualified names STILL - # resolve deterministically to Docker Hub — migration ergonomics are preserved. - # "enforcing" only fails closed on interactively-ambiguous short names instead - # of silently resolving them, which is the least-privilege / prompt-injection - # defense a federal sandbox wants: an injected `docker run nginx` cannot be - # silently substituted (typosquatting / image substitution) without a qualified - # name or an explicit alias. Operators MAY opt into "permissive" (removing that - # guardrail) via ACQ_MSB_SHORT_NAME_MODE; see the env-var comment above. - # - # We add fuse-overlayfs + the rootless prereqs (uidmap, passt, slirp4netns) to - # the install set so the preferred path is available on the default (apt) image; - # if the mirror lacks fuse-overlayfs the vfs fallback still yields a working - # engine. - if _acq_msb_cli exec "$name" -u 0 -e "PODMAN_PKGS=$ACQ_MSB_PODMAN_PKGS" -e "SHORT_NAME_MODE=$ACQ_MSB_SHORT_NAME_MODE" -- sh -c ' - set -e - # 1) Ensure the podman binary is present (idempotent). - if ! command -v podman >/dev/null 2>&1; then - if command -v apt-get >/dev/null 2>&1; then - export DEBIAN_FRONTEND=noninteractive - apt-get update - # shellcheck disable=SC2086 - apt-get install -y --no-install-recommends $PODMAN_PKGS - elif command -v dnf >/dev/null 2>&1; then - # shellcheck disable=SC2086 - dnf install -y $PODMAN_PKGS - elif command -v apk >/dev/null 2>&1; then - # shellcheck disable=SC2086 - apk add --no-cache $PODMAN_PKGS - else - echo "acq(msb): no supported package manager (apt-get/dnf/apk) to install podman" >&2 - exit 1 - fi - fi - # 2) Select a storage driver that works on msb'"'"'s overlay root. Only write - # the config if we have not already (idempotent); do not clobber an operator - # file that already names a driver. Prefer fuse-overlayfs, else vfs. - if ! grep -q '"'"'^[[:space:]]*driver'"'"' /etc/containers/storage.conf 2>/dev/null; then - mkdir -p /etc/containers - _fuse="" - for _c in /usr/bin/fuse-overlayfs /usr/local/bin/fuse-overlayfs /bin/fuse-overlayfs; do - [ -x "$_c" ] && _fuse="$_c" && break - done - if [ -z "$_fuse" ] && command -v fuse-overlayfs >/dev/null 2>&1; then - _fuse=$(command -v fuse-overlayfs) - fi - if [ -n "$_fuse" ] && [ -e /dev/fuse ]; then - printf "[storage]\ndriver = \"overlay\"\n[storage.options.overlay]\nmount_program = \"%s\"\n" "$_fuse" \ - > /etc/containers/storage.conf - else - printf "[storage]\ndriver = \"vfs\"\n" > /etc/containers/storage.conf - fi - fi - # 3) Grant the agent access to /dev/net/tun + /dev/fuse for rootless podman — - # handled by _acq_msb_grant_oci_devs (called un-gated above AND on restart), - # NOT here, because /dev is a devtmpfs re-created each boot: a grant baked - # behind the install marker would be lost after `msb start`. (No-op here.) - # 4) Docker-Hub-first registry resolution (ADR-0020). System-level so it - # applies to the rootless agent (read as the lowest-precedence source). - # Idempotent: overwrite our own files each pass. - mkdir -p /etc/containers/registries.conf.d - printf "unqualified-search-registries = [\"docker.io\"]\nshort-name-mode = \"$SHORT_NAME_MODE\"\n" \ - > /etc/containers/registries.conf.d/00-acq-docker-first.conf - printf "[aliases]\n\"hello-world\" = \"docker.io/library/hello-world\"\n\"hello\" = \"docker.io/library/hello-world\"\n" \ - > /etc/containers/registries.conf.d/01-acq-shortnames.conf - # 5) Alias docker -> podman in /usr/local/bin (ahead of /usr/bin on PATH), so - # `docker run …` and `docker compose …` route to the podman engine. A tiny - # exec wrapper (not a symlink) so `docker compose` -> `podman compose` - # dispatches through podman'"'"'s compose provider (podman-compose). - # Plain `podman` (NOT sudo): the engine runs ROOTLESS as the agent user, so - # the agent invokes podman directly. The heredoc is FLUSH-LEFT so the - # shebang is not indented; `\$@` is escaped so the guest writes the LITERAL - # `"$@"` into the wrapper. - mkdir -p /usr/local/bin - cat > /usr/local/bin/docker </dev/null 2>&1; then - # Root setup succeeded. Now VERIFY the engine works ROOTLESS as the agent user - # — the way agents actually use it. A bare `podman info` as root would prove - # the wrong thing (rootful), so we probe as the agent. CRUCIALLY we do more - # than `podman info`: info does NOT open /dev/fuse or mount a layer, so it - # passes even when the fuse-overlayfs storage mount would fail (the exact - # /dev/fuse-permission trap). We therefore verify with a real LAYER MOUNT: a - # `podman build` FROM scratch (no registry pull, no egress). If it fails with - # the configured driver, the agent forces a USER-level vfs storage.conf and - # retries once (covers a base whose overlay+fuse combo is still rejected under - # rootless). Only a successful build writes the ready marker. - if _acq_msb_cli exec "$name" -u agent -e HOME=/home/agent -- sh -c ' - _oci_selftest() { - d=$(mktemp -d) || return 1 - printf "FROM scratch\nCOPY hi /hi\n" > "$d/Containerfile" - echo hi > "$d/hi" - podman build -q -t acq-oci-selftest:local "$d" >/dev/null 2>&1 - rc=$? - podman rmi -f acq-oci-selftest:local >/dev/null 2>&1 || true - rm -rf "$d" - return $rc - } - _oci_selftest && exit 0 - # Retry once with a user-level vfs storage.conf (overlay+fuse rejected). - mkdir -p "$HOME/.config/containers" - printf "[storage]\ndriver = \"vfs\"\n" > "$HOME/.config/containers/storage.conf" - _oci_selftest - ' >/dev/null 2>&1; then - # Best-effort: mark ready so we do not re-run the (network-bound) install on - # every provision/restart. (The /dev/net/tun grant and config writes above - # are cheap + idempotent and re-run each pass regardless of this marker.) - _acq_msb_cli exec "$name" -u 0 -- sh -c "mkdir -p /var/lib/acq && touch '$marker'" >/dev/null 2>&1 || true - acq_debug "msb: OCI engine (rootless podman) ready in $name" - return 0 - fi - fi - - # Fail soft (ADR-0020 / the balanced-egress-off case). Name the mirror hosts and - # the rootless prereqs so the operator can allow-list / bake them into ACQ_MSB_IMAGE. - echo "acq(msb): warning: could not provision an OCI engine (rootless podman) in '$name'." >&2 - echo "acq(msb): Agents will not be able to run OCI images (docker run / docker compose)." >&2 - echo "acq(msb): Most likely the OS package mirror is unreachable: the default balanced" >&2 - echo "acq(msb): egress (ADR-0018) allows it, but ACQ_NETWORK_TIER=strict or a narrowed" >&2 - echo "acq(msb): custom base blocks archive.ubuntu.com / ports.ubuntu.com / *.debian.org," >&2 - echo "acq(msb): or the rootless prereqs (podman, fuse-overlayfs, uidmap, passt," >&2 - echo "acq(msb): slirp4netns) could not be installed / rootless podman could not start." >&2 - echo "acq(msb): Bake those into ACQ_MSB_IMAGE, widen egress, or set ACQ_MSB_ENSURE_OCI=0" >&2 - echo "acq(msb): to silence this warning." >&2 - return 0 -} - # --------------------------------------------------------------------------- # Session context shared by exec/attach/shell # --------------------------------------------------------------------------- @@ -4885,7 +4297,7 @@ acq_backend_recorded_agent() { if [ -z "$agent" ]; then agent=$({ msb exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/agent 2>/dev/null' /dev/null || true; } | tr -d '[:space:]') fi - if [ -n "$agent" ] && _acq_msb_safe_agent_token "$agent"; then + if [ -n "$agent" ] && acq_agent_safe_token "$agent"; then printf '%s\n' "$agent" fi } @@ -5074,7 +4486,7 @@ _acq_msb_attach() { # plain shell on anything unexpected. local agent agent=$({ _acq_msb_cli exec "$name" -u 0 -- sh -c 'cat /var/lib/acq/agent 2>/dev/null' /dev/null || true; } | tr -d '[:space:]') - if [ -z "$agent" ] || ! _acq_msb_safe_agent_token "$agent"; then + if [ -z "$agent" ] || ! acq_agent_safe_token "$agent"; then agent="shell" fi diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index c63b2148..10615c7a 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -3,7 +3,7 @@ title: "acq Backend Guide" description: "Per-backend strengths, tradeoffs, and configuration for acq" status: canonical tier: 2 -last_updated: "2026-09-30" +last_updated: "2026-10-03" audience: "developers" keywords: ["acq", "backend", "sbx", "msb", "microsandbox", "tradeoffs"] related_files: ["docs/howto/acq.md", "docs/howto/msb.md", "docs/howto/sbx.md", "docs/CONCEPTS.md", "docs/adr/0010-acq-pluggable-backends.md", "docs/adr/0011-msb-backend-and-neutral-kits.md", "docs/adr/0014-neutral-port-publish-and-background-vocab.md", "docs/adr/0015-msb-post-hoc-port-publish-via-ssh.md", "docs/adr/0034-host-port-selection-and-publish-override.md"] @@ -194,10 +194,6 @@ Tunables: | `ACQ_MSB_SKIP_PREREQ_CHECK` | (unset) | Skip the base-image prerequisite presence check | | `ACQ_SKIP_MSB_DOCTOR` | (unset) | Skip the automatic host-readiness check (`msb doctor`, and the `msb doctor --fix` it runs when the host is not ready). Set when the check is unreliable in your environment or you prefer to run it yourself. | | `ACQ_OPENCODE_POSTINSTALL_TIMEOUT` | `120` | Seconds to bound opencode's in-guest `postinstall.mjs` (which fetches a platform binary) so a wedged registry can't hang `acq run`; used only when the guest provides `timeout` | -| `ACQ_MSB_OPENCODE_PKG` | `opencode-ai` | npm package spec for the opencode install (pin e.g. `opencode-ai@1.2.3`) | -| `ACQ_MSB_NPM_HOSTS` | `registry.npmjs.org` | npm registry host(s) to allow-list for the agent install (space-separated; set for an internal mirror) | -| `ACQ_MSB_ENSURE_OCI` | `1` (on) | Provision an OCI container engine (podman) at create so agents can run OCI images (`docker run`, `docker compose`). Installs `ACQ_MSB_PODMAN_PKGS` and aliases `docker` → `podman`. Set `0`/`false`/`no`/`off`/empty to skip (e.g. a base that bakes its own working engine). Fails soft if the OS package mirror is unreachable. See ADR-0020. | -| `ACQ_MSB_PODMAN_PKGS` | `podman podman-compose` | Packages installed to provide the OCI engine (space-separated). `podman-compose` is the `docker compose` / `podman compose` provider. Override for a different set or an internal mirror's names. | | `ACQ_NETWORK_TIER` | `balanced` | Neutral egress posture (`strict`\|`balanced`\|`open`), the backend-agnostic selector defined by the agentic-coding-patterns network-tiers contract (ADR-0002). **All tiers are deny-by-default except `open`**; the tier only sizes the baseline allowlist. `strict` = `--net-default-egress deny` + gateway DNS + the kits' own `caps.network.allow` hosts ONLY (recommended for GFE / high-assurance). `balanced` = the same deny-default + the curated sbx-`balanced` baseline (ADR-0018) unioned with the kit hosts. `open` = **unrestricted egress** (no deny-default); testing only, never for GFE, and refused unless `ACQ_NETWORK_TIER_CONFIRM_OPEN=1`. Invalid values fail closed to `balanced`. | | `ACQ_NETWORK_TIER_CONFIRM_OPEN` | (unset) | Required confirmation for `ACQ_NETWORK_TIER=open`. Set to `1` to acknowledge that the sandbox runs with unrestricted egress; otherwise `open` is refused at provision time (fail-closed). Treated like `--privileged` — never a default. | | `ACQ_MSB_BALANCED_EGRESS` | (deprecated) | **Deprecated alias** for `ACQ_NETWORK_TIER`; retained for one deprecation window and removed in a future major. A `1`/on value maps to `ACQ_NETWORK_TIER=balanced`; a `0`/`false`/`no`/`off`/empty value maps to `ACQ_NETWORK_TIER=strict` (deny-by-default, kit hosts only — a former "off" no longer means permissive; an upgrade never silently loosens egress). `ACQ_NETWORK_TIER` wins when both are set, and a one-time notice is printed. Migrate to `ACQ_NETWORK_TIER`; use `open` if you truly need unrestricted egress. | @@ -591,17 +587,11 @@ tools are present and warns if any are missing (it does not try to install them). A custom override must ship them too. **The agent binary.** sbx's agent templates bake the requested agent (e.g. -`opencode`) into the image; a plain msb base has no agent. So at provision the -msb adapter **installs the agent it was asked to run**. For `opencode` this is -`npm install -g opencode-ai` (node is a verified prerequisite), and the adapter -allow-lists the npm registry host (`registry.npmjs.org`) at create so the -default-deny guest egress permits the download. The install is idempotent: it is -skipped when the binary is already present (e.g. a pre-baked `ACQ_MSB_IMAGE`) and -marker-gated against re-apply. `shell` installs nothing; an agent with no known -recipe that is also absent from the base image produces a clear warning (bake it -into `ACQ_MSB_IMAGE`). Tunables: `ACQ_MSB_OPENCODE_PKG` (npm spec, e.g. -`opencode-ai@1.2.3`), `ACQ_MSB_NPM_HOSTS` (registry host(s) to allow-list, for an -internal mirror). +`opencode`) into the image. On msb, `opencode` is supplied by the selected +agent-kit path from the pinned patterns bundle; the msb adapter no longer +installs `opencode-ai` directly. Custom `ACQ_MSB_IMAGE` overrides must either +ship the requested agent binary or work with the selected agent kit's install +contract. **The base-image contract (Docker sandbox templates).** sbx's templates are built on `docker/sandbox-templates:-docker`, and acq now derives the same image @@ -620,16 +610,16 @@ mirroring the sbx 1000 is already taken, e.g. by `node` on `node:22-bookworm`.) - A **`/home/agent`** home directory owned by `agent`. - **HTTP proxy env** (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`) **preserved across sudo**. -- The **agent binary** (baked into the image, or installed via a kit's install - command — for `opencode`, acq runs `npm install -g opencode-ai`). +- The **agent binary** (baked into the image, or installed/exposed by the + selected agent kit). - The four kit prerequisites present: `node`, `git`, `curl`, `update-ca-certificates`. -For OCI-run support (`docker run` / `docker compose`), the adapter installs -podman at provision (see [Running OCI images inside the sandbox](#running-oci-images-inside-the-sandbox-podman)), -so a base image does **not** need a container engine baked in — only a supported -package manager (apt-get/dnf/apk) and mirror reachability. Bake podman in (and -set `ACQ_MSB_ENSURE_OCI=0`) only if you want to skip the runtime install. +For OCI-run support (`docker run` / `docker compose`), opt into the +`oci-engine` catalog kit (see [Running OCI images inside the sandbox](#running-oci-images-inside-the-sandbox-podman)). +The base image does **not** need a container engine baked in if the kit can reach +its package mirrors; alternatively, use a base that already includes a working +engine and skip the kit. **Build on Docker's `sandbox-templates:*` images to get all of these for free** — msb derives the same agent-specific image naming convention as sbx when no @@ -679,38 +669,33 @@ If the image requires registry auth, log in with your container tooling (e.g. ### Running OCI images inside the sandbox (podman) Agents often need to run OCI images from inside the sandbox — `docker run` an -image, or bring up a `docker-compose.yaml`. The msb adapter guarantees this -capability the same way it guarantees the base-image contract: idempotently, no -matter what the base image brings. - -The default image ships the Docker CLI, but msb's microVM init (`/init.krun`) -never starts `dockerd`, so the Docker socket is dead — and Docker's `overlay2` -storage driver cannot sit on the sandbox's already-overlay root without a -disk-backed data volume. Rather than retrofit the msb docker-in-docker recipe (a -daemon to start and keep alive across restarts, plus a per-sandbox disk-backed -volume), the adapter provisions **podman** at create time and aliases `docker` → -`podman`: - -- **podman is daemonless** — no socket to start/poll, no restart lifecycle — uses - `fuse-overlayfs` on the overlay root (no disk-backed volume), and needs no - nested virtualization (containers are `runc`/`crun` processes, not VMs). -- It runs **rootful** via the agent's passwordless sudo, which avoids the - rootless prerequisites (`uidmap`, `passt`/pasta) a lean base lacks. -- A tiny `docker` → `podman` wrapper is placed in `/usr/local/bin` (ahead of - `/usr/bin`), so `docker run …` **and** `docker compose …` route to podman. The - base image's `/usr/bin/docker` is never modified. `docker compose` resolves to - `podman compose`, driven by the installed `podman-compose` provider — so - `docker-compose.yaml` files work. (The standalone `docker-compose` CLI is - deprecated in favour of the `docker compose` subcommand, so no separate - `docker-compose` binary is provided.) - -The install uses the OS package mirror, which under the default balanced egress -baseline (ADR-0018) is already reachable — no extra net-rule needed. With -`ACQ_NETWORK_TIER=strict`, or a custom base whose egress is narrowed, the -mirror is unreachable and the step **fails soft** (a warning; provision -continues; OCI is simply unavailable). Turn the step off entirely with -`ACQ_MSB_ENSURE_OCI=0` (e.g. a base that bakes its own working engine), or point -`ACQ_MSB_PODMAN_PKGS` at a different package set / internal mirror. See ADR-0020. +image, or bring up a `docker-compose.yaml`. This capability is now an **opt-in +catalog kit**, not an msb adapter default. + +Enable it through the catalog: + +```bash +acq configure +# select: oci-engine +``` + +You can also apply it for one sandbox by passing the pinned kit ref through +`--kit`, or by exporting that same ref in `ACQ_EXTRA_KITS`: + +```bash +acq run opencode . --kit 'git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=56d1f5f49ef928f50e61aa64a93d618b3d21c415&dir=integrations/isolation/acq-kits/oci-engine' +``` + +The kit provisions **rootless podman** and aliases `docker` to `podman`, so +`docker run` and `docker compose` work inside the sandbox without a Docker daemon. +It is backend-neutral: the same catalog choice applies on sbx and msb. + +This is a breaking change for msb users: sandboxes no longer get podman by +default. Existing sandboxes keep whatever was already installed, but new +sandboxes must opt into `oci-engine` if they need an in-sandbox container engine. +The old msb-only environment toggles for adapter provisioning are removed; use +`acq configure`, `ACQ_EXTRA_KITS`, or `--kit` instead. See ADR-0020 for the +superseded adapter-era decision and ADR-0031 for the catalog mechanism. ### Secrets @@ -875,7 +860,7 @@ rationale, the fixed vsock port (3552), and the trust-boundary discussion. | Snapshots | not supported (`SUPPORTS_SNAPSHOTS=0`) | `msb snapshot` verb exists but **not surfaced by `acq`** (beyond-parity; `SUPPORTS_SNAPSHOTS=0`) | | Port forwarding | `acq ports` (post-hoc) | create/run (`-p`) via neutral `publishedPorts` now (shipped); **plus** post-hoc `acq ports --publish` via `msb ssh serve` + `ssh -L` now implemented (ADR-0015) — live end-to-end verification pending a KVM host | | Kit volumes | neutral `volumes:` passed through 1:1 to kit-spec v2 §5.7 (sized block device / tmpfs, mounted at create; dies with the sandbox) | neutral `volumes:` unioned across kits (last wins by path) and mapped to a derived named disk volume (`--mount-named acq---::kind=disk,size=`) or `--tmpfs :`; derived volumes removed on `acq rm` (ADR-0023) | -| Agent binary | supplied by the sbx agent template | installed at provision on a plain base (`npm install -g opencode-ai`), then launched on attach | +| Agent binary | supplied by the sbx agent template | supplied by the selected agent kit or the base image, then launched on attach | | OpenCode web UI | `openchamber` acq kit (publishes port 4096) | same kit once it declares `backends: [sbx, msb]` against the released patterns schema (neutral port/background vocab consumed by both backends; the patterns repo's openchamber kit) | | In-place kit heal | `sbx kit add` (state-preserving, 0.35.0+; **no startup-bearing kits on 0.38+ — recreate to extend/refresh**) | re-apply kits idempotently (no state-preserving add) | @@ -925,12 +910,10 @@ rationale, the fixed vsock port (3552), and the trust-boundary discussion. [ADR-0034](adr/0034-host-port-selection-and-publish-override.md). - **No state-preserving in-place kit add.** `acq_backend_ensure_kits_applied` re-applies kits idempotently; for a clean rebuild use `acq rm && acq run`. -- **`acq` can auto-install only `opencode` on msb.** On the msb base image `acq` - installs the agent at provision time, and today only `opencode` has an install - recipe (`shell` needs no binary). Any other agent must be pre-baked into your - own `ACQ_MSB_IMAGE`; `acq` warns at attach if the requested agent has no recipe. - (On sbx the agent is supplied by the sbx template, so this constraint is - msb-specific.) +- **Agent binaries come from templates, images, or agent kits.** `acq` no longer + has an msb-native `opencode` installer fallback. If the selected agent is not + present in the base image, the corresponding agent kit must provide it before + attach. - **Snapshots not surfaced.** `msb snapshot` is a full CLI verb, but `acq` exposes no `snapshot` verb, so `SUPPORTS_SNAPSHOTS=0`. Wiring it is beyond sbx parity (sbx has no snapshots), so the flag reflects what `acq` surfaces rather diff --git a/docs/CONCEPTS.md b/docs/CONCEPTS.md index 1c282af4..f9c0dffa 100644 --- a/docs/CONCEPTS.md +++ b/docs/CONCEPTS.md @@ -3,7 +3,7 @@ title: "acq Concepts" description: "Backend-neutral concepts for working with acq sandboxes (workspaces, mounts, published ports)" status: canonical tier: 2 -last_updated: "2026-09-30" +last_updated: "2026-10-03" audience: "developers" keywords: ["acq", "concepts", "workspace", "mount", "ports", "publish", "backend-neutral", "sbx", "msb"] related_files: ["docs/howto/acq.md", "docs/BACKEND_GUIDE.md", "docs/adr/0010-acq-pluggable-backends.md", "docs/adr/0011-msb-backend-and-neutral-kits.md", "docs/adr/0034-host-port-selection-and-publish-override.md"] @@ -265,6 +265,7 @@ flags on every run. [x] git-ssh-sign SSH-based git commit signing … (always applied) ❯ [x] openchamber Browser UI for OpenCode alongside the terminal TUI [ ] paseo Self-hosted Paseo browser web UI for coding agents + [ ] oci-engine Rootless podman for docker run / docker compose ``` - The **four built-in kits** (`zscaler-ca-certificate`, `usai-provider`, @@ -276,6 +277,9 @@ flags on every run. - At `acq create`, the picker is shown again **pre-populated with your saved defaults**, so a single sandbox can enable or disable a kit without changing the global default (the deviation is remembered for that sandbox). +- Select **`oci-engine`** when the sandbox needs `docker run` or + `docker compose`. It installs rootless podman inside the sandbox and is not + enabled by default. - The **GitHub-token** preference only pre-answers the scoping prompt; the fine-grained token is still minted per-sandbox (see [`acq github-scope`](howto/acq.md)). diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 3d34b183..0feeed2e 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -3,7 +3,7 @@ title: "Known Failure Modes" description: "Real-world failure patterns when using Docker SBX + USAi + agent frameworks" status: canonical tier: 2 -last_updated: "2026-09-30" +last_updated: "2026-10-03" audience: "developers" keywords: ["debugging", "troubleshooting", "sbx", "usai", "failures"] --- @@ -647,7 +647,7 @@ worked on sbx 0.35.0–0.37.x, where `sbx kit add` recreated the container with augmented kit set while preserving state. > **sbx 0.38 caveat.** On sbx >= 0.38, `sbx kit add` no longer applies -> startup-bearing kits mid-life (see [Section 35](#35-re-attach-heal-loop-warns-every-time-on-sbx-038--sbx-kit-add-refuses-startup-bearing-kits)), +> startup-bearing kits mid-life (see [Section 36](#36-re-attach-heal-loop-warns-every-time-on-sbx-038--sbx-kit-add-refuses-startup-bearing-kits)), > and every built-in acq kit declares startup commands. In-place healing of the > built-in bundle therefore does **not** work on 0.38+: to add or refresh the > bundle you must recreate the sandbox (`acq rm && acq run`). acq detects the @@ -1284,9 +1284,8 @@ If `sbx exec` reports the sandbox is not running, start it first (opencode requires postinstall to fetch its binary since v1.15.1). - If acq's automatic remediation ever fails, the manual `node postinstall.mjs` above is the reliable per-sandbox workaround. -- The **msb** backend installs opencode itself (`npm install -g`, scripts - enabled) rather than using this image, so it is a possible alternative if the - sbx image stays broken — though it may hit the same opencode packaging issue. +- The **msb** backend now gets opencode from the selected agent kit or base image, + not from an adapter-owned `npm install -g` fallback. --- @@ -1746,7 +1745,51 @@ acq exec -- sh /home/agent/openchamber-start.sh & --- -## 34. Git signing fails on msb: socat missing or msb < 0.6.9 +## 34. `docker run` or `docker compose` Missing Inside a Sandbox + +### Symptoms + +- `docker run`, `docker compose`, or `podman` is not found inside a sandbox. +- On msb, a new sandbox no longer gets podman automatically. + +### Root Cause + +`oci-engine` is an opt-in catalog kit. acq no longer installs podman from the msb +adapter by default, and sbx/msb now use the same mechanism for in-sandbox OCI +support. + +### Fix + +Enable the kit for future sandboxes: + +```bash +acq configure +# select: oci-engine +``` + +For a single sandbox, pass the pinned kit ref with `--kit` or include it in +`ACQ_EXTRA_KITS`: + +```bash +acq run opencode . --kit 'git+https://github.com/GSA-TTS/agentic-coding-patterns.git#ref=56d1f5f49ef928f50e61aa64a93d618b3d21c415&dir=integrations/isolation/acq-kits/oci-engine' +``` + +Existing sandboxes that were created before this change keep whatever podman +state they already have, but new sandboxes require the opt-in kit unless the base +image already includes a working engine. + +### Prevention / Status + +- Breaking change in the `oci-engine` catalog migration. Covered by the + `acq configure` catalog tests, the configured-kit fresh-shell start + round-trip, the msb no-default-OCI regression test, and the dedicated live + `scripts/verify-backends --only msb --oci-only` path. +- ADR-0020 is superseded for the adapter-owned mechanism; ADR-0030 and ADR-0031 + describe the kit/catalog path. + +--- + +## 35. Git signing fails on msb: socat missing or msb < 0.6.9 ### Symptoms @@ -1866,7 +1909,7 @@ See [ADR-0021](adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md) --- -## 35. Re-attach Heal Loop Warns Every Time on sbx 0.38 — `sbx kit add` Refuses Startup-Bearing Kits +## 36. Re-attach Heal Loop Warns Every Time on sbx 0.38 — `sbx kit add` Refuses Startup-Bearing Kits ### Symptoms @@ -1956,7 +1999,7 @@ heal-loop cases). --- -## 36. msb Create-Time Published Port Returns Empty Response for a Loopback-Only Guest Service +## 37. msb Create-Time Published Port Returns Empty Response for a Loopback-Only Guest Service ### Symptoms @@ -2028,7 +2071,7 @@ from inside the sandbox and can reach guest `127.0.0.1:6767`. --- -## 37. Commit signing fails in the sandbox: `user.signingKey needs to be set` / `No signature` +## 38. Commit signing fails in the sandbox: `user.signingKey needs to be set` / `No signature` ### Symptoms @@ -2085,7 +2128,7 @@ Notes: reachable, so a rebase with `SSH_AUTH_SOCK` set does not need a separate `--amend -S` pass. -## 38. Spurious `M` (modified) diffs on scripts across the host/sandbox mount +## 39. Spurious `M` (modified) diffs on scripts across the host/sandbox mount ### Symptoms @@ -2135,7 +2178,7 @@ amending the wrong branch. --- -## 39. Windows preview: `bash.exe` resolves to the WSL shim, WHP is not a feature flag, and the execution policy blocks scripts +## 40. Windows preview: `bash.exe` resolves to the WSL shim, WHP is not a feature flag, and the execution policy blocks scripts ### Symptoms @@ -2208,7 +2251,7 @@ amending the wrong branch. --- -## 40. Recovering agent work reads the guest-writable scratch clone (residual risk) +## 41. Recovering agent work reads the guest-writable scratch clone (residual risk) ### Symptoms @@ -2251,7 +2294,7 @@ an untrusted agent could write to. --- -## 41. A Required Check Backed by a `paths:`-Filtered Workflow Deadlocks Unrelated PRs +## 42. A Required Check Backed by a `paths:`-Filtered Workflow Deadlocks Unrelated PRs ### Symptoms @@ -2319,7 +2362,7 @@ adding them to branch protection. --- -## 42. A Second Parallel Sandbox From the Same Kit Is Unreachable on Its Published Port (msb) +## 43. A Second Parallel Sandbox From the Same Kit Is Unreachable on Its Published Port (msb) ### Symptoms diff --git a/docs/adr/0011-msb-backend-and-neutral-kits.md b/docs/adr/0011-msb-backend-and-neutral-kits.md index 49aef629..d3e8307f 100644 --- a/docs/adr/0011-msb-backend-and-neutral-kits.md +++ b/docs/adr/0011-msb-backend-and-neutral-kits.md @@ -229,14 +229,12 @@ same contract sbx's templates provide, keyed off the [Docker base-image requirements](https://docs.docker.com/ai/sandboxes/customize/kit-reference/#base-image-requirements) for `docker/sandbox-templates:shell-docker`: -- **Install the agent** (`_acq_msb_install_agent`). The AGENT token is read from - the first positional at provision. `opencode` → `npm install -g opencode-ai` - (node is already a verified prerequisite), with the npm registry host - (`registry.npmjs.org`) allow-listed at create so the default-deny egress - permits the download. Idempotent: skipped if the binary is already present - (a pre-baked `ACQ_MSB_IMAGE`) and marker-gated. `shell` is a no-op; an unknown, - absent agent warns (bake it into `ACQ_MSB_IMAGE`). Tunables: - `ACQ_MSB_OPENCODE_PKG`, `ACQ_MSB_NPM_HOSTS`. +- **Select the agent kit or base-image agent.** The AGENT token is read from the + first positional at provision. For `opencode`, acq applies the built-in agent + kit from the pinned patterns bundle unless the user supplies an explicit + `--kit`; the msb adapter no longer owns an npm fallback installer. Agent install + egress belongs in the selected agent kit's network declarations, or the agent + binary must already be present in the base image. - **Satisfy the base-image contract** (`_acq_msb_ensure_agent_user`, extended). In addition to the `agent`/`/home/agent` user it already created, it now adds **passwordless sudo** (`/etc/sudoers.d/90-acq-agent`) and preserves the diff --git a/docs/adr/0018-msb-balanced-egress-baseline.md b/docs/adr/0018-msb-balanced-egress-baseline.md index bc4238f5..49a27b56 100644 --- a/docs/adr/0018-msb-balanced-egress-baseline.md +++ b/docs/adr/0018-msb-balanced-egress-baseline.md @@ -133,19 +133,11 @@ default**, composed on top of the kits' own `caps.network.allow`. the msb set is intentionally wider than sbx; it is logged. Operators who need a tighter posture set `ACQ_MSB_BALANCED_EGRESS=0` (kit-only) or point `ACQ_MSB_BALANCED_HOSTS_FILE` at a narrower list. -- **Interaction with the npm-install allow-rule:** the adapter separately - allow-lists the npm registry (`registry.npmjs.org`) only when installing an - agent (ADR-0011). That host is ALSO in the `balanced` set, so with the baseline - ON **every** sandbox — including a `shell` sandbox with no agent — can reach the - registry, exactly as it can under sbx `balanced`. This is intended parity, not a - leak: the agent-conditional npm gate still matters only in kit-only mode - (`ACQ_MSB_BALANCED_EGRESS=0`), where a `shell` sandbox gets no npm egress. To - avoid a redundant rule, the npm block de-dupes: it skips any `ACQ_MSB_NPM_HOSTS` - entry the balanced block already emitted (the default `registry.npmjs.org`), and - only emits a rule for an override host NOT in the balanced set (e.g. an internal - mirror). Because kit `caps.network.allow` rules are emitted as broader bare - `allow@host` (any proto/port) BEFORE the balanced block, a host present in both - composes harmlessly under first-match-wins. +- **No adapter-owned npm-install allow-rule:** agent binaries now come from the + selected agent kit or the base image, not from an msb-native `npm install` + fallback. Any registry egress needed by an agent kit must be declared by that + kit. The balanced baseline may still include public package hosts for sbx parity, + but strict kit-only mode emits only kit-declared hosts. - **Untrusted-input hardening (SI-10):** the host list is semi-trusted data that drives create-time flags, so every translated target is charset-validated (`[A-Za-z0-9.*_-]`), single-label suffixes (`*.com`) are rejected (msb refuses diff --git a/docs/adr/0020-msb-oci-engine-via-podman.md b/docs/adr/0020-msb-oci-engine-via-podman.md index a317fd64..58721dce 100644 --- a/docs/adr/0020-msb-oci-engine-via-podman.md +++ b/docs/adr/0020-msb-oci-engine-via-podman.md @@ -1,6 +1,6 @@ --- title: "Ensure an OCI container engine in msb sandboxes via rootless podman, not docker-in-docker" -status: accepted +status: superseded date: 2026-08-11 decision_makers: ["Bret Mogilefsky"] category: architecture @@ -9,394 +9,87 @@ impact_level: low ato_relevance: no risk_treatment: accept supersedes: [] +superseded_by: ["docs/adr/0030-agent-kits-on-devenv-base.md", "docs/adr/0031-interactive-acq-configure.md"] --- # ADR-0020: Ensure an OCI container engine in msb sandboxes via rootless podman -> **Revision note (2026-08-11, pre-merge, no PR yet):** This ADR originally chose -> **rootful** podman to avoid a create-time download dependency on the rootless -> prerequisites (`uidmap`, `passt`). Once we had a working implementation, that -> premise did not hold: the adapter *already* installs `podman podman-compose -> fuse-overlayfs` from the OS mirror at provision, so the rootless prereqs are two -> more packages from the *same mirror in the same install step* — not a new -> dependency class. Meanwhile rootful required a `sudo`-wrapping `docker` alias and -> produced a rootful/rootless probe mismatch (containers ran as root inside the -> VM, and the verifier had to probe via sudo). We therefore **reversed the decision -> to rootless** and revised this ADR in place (the branch had never merged). The -> superseded rootful reasoning is preserved under *Alternatives considered*. - -## Context - -Agents working inside an msb sandbox frequently need to run OCI images — pull -and `docker run` an image, or bring up a `docker-compose.yaml`. Just as the msb -adapter idempotently guarantees the base-image *contract* (an `agent` user, -`/home/agent`, passwordless sudo, proxy `env_keep`; see -[ADR-0011](0011-msb-backend-and-neutral-kits.md) and `_acq_msb_ensure_agent_user`), -we want to guarantee an OCI-run *capability* regardless of what the base image -brings. - -The situation on the default image -(`docker/sandbox-templates:shell-docker`, an Ubuntu base) was investigated live -inside an msb sandbox: - -- The **Docker CLI, compose v2 plugin, `dockerd`, `containerd`, and `runc` are - all installed**, but msb's microVM init is `/init.krun` — **not** systemd or - any service manager — so **nothing starts `dockerd`**. The Docker socket - (`/var/run/docker.sock`) does not exist, and every `docker` command fails with - `Cannot connect to the Docker daemon`. -- The sandbox **root filesystem is already an `overlay` mount** - (`/.msb/rootfs/...`). Docker's default `overlay2` storage driver **cannot be - stacked on an overlay root** without a dedicated lower-level filesystem — - which is exactly why the msb project's own - [docker-in-a-sandbox recipe](https://github.com/superradcompany/microsandbox/blob/main/docs/examples/docker/docker-in-sandbox.mdx) - boots the `docker:dind` image *as the sandbox entrypoint* and mounts a - **disk-backed** named volume at `/var/lib/docker`. -- **`/dev/kvm` is absent** (no nested virtualization) — but that only rules out - nested *VMs*, not containers, which run as ordinary `runc`/`crun` processes. -- The agent user has **passwordless sudo** (guaranteed by the base-image - contract), and `/dev/fuse` is present. -- The kernel supports **unprivileged user namespaces** (`max_user_namespaces` is - large, no restrictive `unprivileged_userns_clone` knob), and `/etc/subuid` + - `/etc/subgid` already grant the `agent` user a `100000:65536` range — the - prerequisites for **rootless** containers. The rootless *userland* tools - (`newuidmap`/`newgidmap` from `uidmap`, `passt`/`slirp4netns` for networking) - are **not** preinstalled, and `/dev/net/tun` is root-only (`crw------- root - root`) — both fixable at provision. - -So the bundled Docker is inert here, and the blessed way to make it work (the -dind recipe) is an *image/entrypoint* strategy that does not fit our adapter: -`acq` boots a general base image and launches the agent — it does not own the -sandbox entrypoint, and would have to (a) start `dockerd` itself post-boot and -poll the socket, (b) keep that daemon alive across every `msb start`/restart -(the same restart-durability problem [ADR-0017](0017-msb-create-time-startup-script-staging.md) -already wrestles with), and (c) provision a per-sandbox disk-backed -`--mount-named docker-data:/var/lib/docker:kind=disk` volume and tear it down. - -## Decision - -> **Revision note (2026-10-02, ADR-0030 transition):** this adapter-owned path -> remains the default while the shared `oci-engine` capability kit is introduced -> through the generic opt-in catalog described by ADR-0031. The catalog entry and -> adapter retirement are tracked by GSA-TTS/agentic-coding-quickstart#505. - -Provision **podman** at provision time as the OCI engine, run it **rootless as -the agent user**, and alias `docker` → `podman` so both `docker run …` and -`docker compose …` work inside the sandbox. Implemented as a new idempotent, -marker-gated step `_acq_msb_ensure_oci`, called from `acq_backend_provision` -right after `_acq_msb_ensure_agent_user`. - -1. **podman over docker-in-docker.** podman is **daemonless** — each invocation - forks `runc`/`crun` directly, so there is no socket to start, poll, or keep - alive across restarts (eliminating the dind lifecycle problem entirely). It - uses **`fuse-overlayfs`** (we have `/dev/fuse`) or `vfs` on the overlay root, - so **no disk-backed volume** is required. It needs **no nested - virtualization**. And it is CLI-compatible with docker for the - run/build/compose workflows this targets. - - **Storage driver on an overlay root.** podman's default **kernel `overlay`** - graph driver **cannot stack on msb's overlay root** — `podman info` fails with - `'overlay' is not supported over overlayfs, a mount_program is required`. This - applies to **both** rootful and rootless. The adapter therefore writes - `/etc/containers/storage.conf` **before** the engine verify, selecting a driver - that works on an overlay root: - - **Preferred:** `overlay` + `mount_program = fuse-overlayfs` when - `fuse-overlayfs` is installed (fast, thin-on-disk; msb provides `/dev/fuse`). - - **Fallback:** `vfs`, which works everywhere with no extra package and no - `/dev/fuse` (correct but disk-heavy — a full copy per layer). Used when - `fuse-overlayfs` is unavailable, and as a last-resort retry (a **user-level** - `~/.config/containers/storage.conf`) if the rootless verify still fails. - The system file is honored by rootless podman as its lowest-precedence source. - The adapter does not clobber an operator-provided `storage.conf` that already - names a `driver`. - -2. **Rootless, run as the agent user.** The package **install** runs as root - (`msb exec -u 0`, needed to install), but the **engine runs rootless** as the - unprivileged `agent`. Rootless needs four things the default image lacks, - which the adapter provides at provision: - - **`uidmap`** (`newuidmap`/`newgidmap`) — the setuid helpers that map the - agent's `/etc/subuid`/`/etc/subgid` `100000:65536` range (already present). - - **`passt` + `slirp4netns`** — rootless container networking backends. - - **`/dev/net/tun` access** — root-only by default (`crw------- root root`); - the rootless network backend must open it. The adapter **group-scopes** it to - the agent (`chown root:agent`, `chmod 0660`) — see item 6. - - **`/dev/fuse` access** — likewise root-only by default; the `fuse-overlayfs` - storage driver (our preferred driver on the overlay root) must open it to - mount image layers. Without it `podman info` still passes but `podman run` - fails at mount time (`fuse: failed to open /dev/fuse: Permission denied`) — - the exact info-OK-but-run-FAILS split first seen on the host. Group-scoped to - the agent the same way — see item 6. - - These are installed from the **same OS mirror in the same `apt-get install`** - as podman itself, so rootless adds **no new dependency class** over the engine - we already download. Running rootless keeps containers **unprivileged even - inside the microVM** (defense-in-depth), **aligns container/host UIDs** for - bind mounts, and lets the agent invoke podman **directly** (no sudo wrapper, - no rootful/rootless probe mismatch). Verified live: with the prereqs installed, - a `driver="vfs"` (or fuse-overlayfs) storage config, and `/dev/net/tun` + - `/dev/fuse` group-scoped, `podman info` and `docker run --rm docker.io/library/hello-world` - both succeed **as the agent user** (`rootless=true`). - -3. **Alias `docker` → `podman` (do not fix the bundled Docker).** A tiny exec - wrapper is written to **`/usr/local/bin/docker`** (ahead of `/usr/bin` on the - default PATH), shadowing the bundled-but-non-functional `/usr/bin/docker`. We - never touch the base image's `/usr/bin/docker`. Because the bundled Docker CLI - does not work here anyway (dead socket), shadowing it loses nothing and makes - both `docker run` and `docker compose` route to the working podman engine. - - **The wrapper execs a plain `podman "$@"`** (no sudo): the engine runs rootless - as the agent, so the agent invokes podman directly. This is why the - `scripts/verify-backends` OCI check probes `docker info` **as the agent** (via - `acq exec`, which already runs as the agent) — it exercises the real usable - path (rootless prereqs + storage driver + alias) with no privilege escalation. - -4. **`docker compose`, not `docker-compose`.** The standalone `docker-compose` - CLI is deprecated in favour of the `docker compose` subcommand. With the alias - in place, `docker compose …` becomes `podman compose …`, which dispatches - through the installed **`podman-compose`** provider. So we install - `podman podman-compose` (plus the storage/rootless prereqs, `ACQ_MSB_PODMAN_PKGS`) - and do **not** ship a separate `docker-compose` binary. The goal is that - `docker-compose.yaml` files work, not that the deprecated CLI name exists. - -5. **Docker-Hub-first image resolution.** Stock podman resolves many unadorned - short names to **quay.io** (e.g. `hello-world` → `quay.io/podman/hello`) and - ships **no** default unqualified search registry, so `docker run nginx` does - not "just work" the way Docker users expect. To reduce migration burden for - users whose code assumes Docker Hub, the adapter writes system drop-ins: - - `registries.conf.d/00-acq-docker-first.conf`: - `unqualified-search-registries = ["docker.io"]` + `short-name-mode = "enforcing"`. - - `registries.conf.d/01-acq-shortnames.conf`: `[aliases]` remapping - `hello`/`hello-world` to `docker.io/library/hello-world` (a user drop-in - override cleanly wins over the stock alias — no merge conflict). - These are system-level so they apply to the rootless agent. This **deliberately - diverges from stock podman**; it is a config-only change and Docker Hub's CDNs - are in the balanced egress baseline (see Consequences). - - **Short-name mode defaults to `enforcing` (least-privilege / prompt-injection - defense).** Because there is a **single** unqualified search registry - (`docker.io`), unqualified names still resolve **deterministically** to Docker - Hub — migration ergonomics are preserved. `enforcing` only fails **closed** on - interactively-ambiguous short names instead of silently resolving them, so an - injected `docker run nginx` on the prompt-injectable agent path cannot be - silently substituted (typosquatting / image substitution). With one search - registry, enforcing costs essentially no day-to-day ergonomics. Operators MAY - opt into `permissive` (or `disabled`) via **`ACQ_MSB_SHORT_NAME_MODE`**; an - invalid value warns and falls back to `enforcing` (fail-closed). Setting - `permissive` is an explicit operator override that **removes** the - typosquatting / image-substitution guardrail. - - > **Revision note (post PR #302 review).** The permissive default was - > reconsidered after review: a 3-model consensus plus the reviewer recommended - > **AGAINST** `permissive` as a federal-sandbox default, because it removes the - > defense against image substitution / typosquatting for a prompt-injectable - > agent. The default was changed to `enforcing`; `permissive` remains available - > as an explicit opt-in. - -6. **Idempotent + marker-gated + fail-soft, with an un-gated device-node - grant.** The heavy install/config step returns early if the marker - `/var/lib/acq/oci-ready` exists. It verifies **rootless** `podman info` (as the - agent) before writing the marker. If the engine cannot be provisioned it - **warns and returns 0** — provision continues, OCI is simply unavailable — - mirroring the agent-install and prereq-check steps (§Failure Handling, - `AGENTS.md`). Toggle off with `ACQ_MSB_ENSURE_OCI=0`. - - The device-node grants (`/dev/net/tun` for networking, `/dev/fuse` for - fuse-overlayfs) are handled by a **separate `_acq_msb_grant_oci_devs` helper - called on EVERY provision pass (before the install-marker gate) AND on restart - (`acq_backend_start`)** — because `/dev` is a devtmpfs re-created at each boot, - a grant baked behind the persistent marker would be lost after `msb start`. The - helper is cheap, idempotent, and a best-effort no-op for any device that is - absent or when `ACQ_MSB_ENSURE_OCI` is disabled. NOTE: because rootless - `podman info` (the verify) does NOT open `/dev/fuse` but `podman run` does, the - grant covering `/dev/fuse` must NOT be gated behind the `podman info` verify — - omitting it caused a real host regression where the engine check passed but - `docker run` failed at layer-mount time. +> **Status: SUPERSEDED by [ADR-0030](0030-agent-kits-on-devenv-base.md) and +> [ADR-0031](0031-interactive-acq-configure.md).** The rootless-podman and +> podman-over-docker-in-docker decisions remain the design basis, but the +> implementation moved out of the msb adapter and into the neutral `oci-engine` +> patterns kit. OCI support is no longer installed by default on msb; users opt +> in through `acq configure`, `ACQ_EXTRA_KITS`, or `--kit`. This ADR is retained +> for adapter-era rationale and historical context. + +## Context and Problem Statement + +Agents working inside a sandbox sometimes need to run OCI images, for example +`docker run` or `docker compose`. The default msb image included Docker client +bits, but msb did not start `dockerd`, and Docker's `overlay2` storage driver did +not fit the already-overlay sandbox root without a dedicated disk-backed data +volume. + +The adapter-era design tried to guarantee OCI support for every msb sandbox. That +made msb and sbx asymmetric: msb sandboxes received podman whether they asked for +it, while sbx sandboxes did not. ADR-0030 later moved capability provisioning to +neutral patterns kits, and ADR-0031 added a discoverable opt-in kit catalog. + +## Superseding Decision + +OCI support is now provided by the **`oci-engine` catalog kit**, sourced from the +same pinned `agentic-coding-patterns` bundle as the built-in kits. Users select it +with `acq configure` for a durable default, or pass its pinned kit ref through +`ACQ_EXTRA_KITS` / `--kit` for explicit per-run use. + +This is a breaking change for msb users: new msb sandboxes no longer install an +OCI engine by default. Existing sandboxes keep whatever was already installed; +new sandboxes that need `docker run` or `docker compose` must opt into the kit. + +## Decisions That Still Hold + +1. **podman over docker-in-docker.** podman is daemonless, avoids managing a + long-lived Docker daemon inside the sandbox, and does not require nested + virtualization. +2. **Rootless execution.** The engine should run as the unprivileged `agent` user + so in-sandbox containers do not require a rootful daemon or a sudo-wrapped + client path. +3. **`docker` compatibility.** The user-facing command path should support common + Docker workflows by routing `docker run` and `docker compose` to podman. +4. **Overlay-root handling.** The implementation must account for storage on an + overlay root, preferring fuse-overlayfs when available and falling back to a + safe working storage driver when necessary. +5. **Short-name safety.** Unqualified image-name behavior should fail closed or be + explicitly configured by the kit, because silent short-name resolution can + enable image substitution or typosquatting attacks. ## Consequences -- **OCI-run capability is guaranteed** on the default image and any base with a - supported package manager (apt-get/dnf/apk), without owning the entrypoint, - managing a daemon lifecycle, or provisioning a disk-backed volume. -- **Depends on the OS package mirror being reachable at provision.** Unlike the - npm-registry allow-rule (which had to be *added* because the registry was not - otherwise reachable), the Ubuntu/Debian mirror hosts - (`archive.ubuntu.com`, `ports.ubuntu.com` — this arch is arm64, so `ports` — - `security.ubuntu.com`, `**.debian.org`, `launchpad.net`) are **already in the - default balanced egress baseline** ([ADR-0018](0018-msb-balanced-egress-baseline.md)), - so no new net-rule is needed under the default policy. With - `ACQ_NETWORK_TIER=strict` (or the deprecated `ACQ_MSB_BALANCED_EGRESS=0`, which - now maps to `strict`), or a custom base whose egress is otherwise - narrowed, the mirror is unreachable and the step **fails soft** — chosen - deliberately so an operator's explicit egress narrowing is respected rather - than silently re-widened (the warning names the mirror hosts and the - `ACQ_MSB_ENSURE_OCI=0` escape hatch). -- **Behavioral parity with Docker is high but not total.** podman's docker-CLI - compatibility covers the run/build/compose workflows this targets; uncommon - edge cases (Docker build-secret syntax, some compose v3 keys, `buildx`-specific - features) may differ. Acceptable for agent workflows; the bundled Docker was - non-functional here regardless. -- **Unadorned image names resolve to Docker Hub, not stock podman defaults.** The - Docker-Hub-first drop-ins (Decision 5) mean `docker run nginx` / `hello-world` - behave as Docker users expect, at the cost of a deliberate divergence from stock - podman resolution. A user who wants stock behavior can remove/override the acq - drop-ins with their own `~/.config/containers/registries.conf.d/*` (user config - wins). Docker Hub's blob CDNs (`**.production.cloudflare.docker.com`, - `**.production.cloudfront.docker.com`) are in the balanced egress baseline, so - the default pull path is reachable; arbitrary other registries are not - guaranteed reachable under the default policy. -- **Security posture (SC-7 / SI-10) — improved vs. the superseded rootful design.** - No new external services; a configuration/adapter change (CM-2/CM-6/CM-7; - SA-8/SA-15). `ACQ_MSB_PODMAN_PKGS` is charset-guarded (`[A-Za-z0-9._+ -]`) - before it is interpolated into the root `sh -c`, so operator config cannot - inject shell into the elevated install (SI-10). **Containers run rootless (as - the agent) inside the microVM** — strictly less privilege than the superseded - rootful design, which ran containers as root. The `/dev/net/tun` and - `/dev/fuse` group-scopes to the agent are inside the microVM only (the sandbox - is the security boundary per `AGENTS.md`), and are narrower than the - world-writable alternative; they do not expand the host attack surface. -- **Adds a runtime package install to provision.** First provision on a fresh - sandbox pays an `apt-get install` cost (now `podman podman-compose fuse-overlayfs - uidmap passt slirp4netns`); the marker makes it a one-time cost per sandbox - (skipped on restart / re-provision). The rootless prereqs come from the same - mirror in the same step — no additional dependency class over the engine itself. - -## Alternatives considered - -- **Follow the msb docker-in-docker recipe (start `dockerd`, disk-backed - `/var/lib/docker`).** Officially blessed and gives full Docker API fidelity, - but it is an entrypoint strategy retrofitted poorly onto an adapter that boots - a general base and runs an agent: it needs a post-boot daemon start + socket - poll, a daemon kept alive across restarts, and a per-sandbox disk-backed volume - with its own teardown. Rejected as materially more complex and stateful than - podman for no benefit in the target workflows. -- **Rootful podman via the agent's passwordless sudo (the ORIGINAL, now - SUPERSEDED decision).** The first version of this ADR chose rootful to avoid a - create-time download of the rootless prerequisites (`uidmap`, `passt`). We - reversed it because: - - The premise did not hold: the adapter already downloads `podman - podman-compose fuse-overlayfs` from the OS mirror at provision, so the - rootless prereqs (`uidmap`, `passt`, `slirp4netns`) are two-to-three more - packages from the *same mirror in the same install step* — not a new - dependency class or failure mode. - - Rootful forced a `docker` alias that shells through `sudo -n podman` and a - rootful/rootless probe mismatch (the engine was provisioned/verified rootful - as root, but agents and the verifier run unprivileged), which was the source - of a real, subtle verifier failure and general fragility. - - Rootful ran containers **as root** inside the microVM; rootless is strictly - less privilege for the same capability. - Rootless does require granting the agent access to `/dev/net/tun` and - `/dev/fuse` for its network backend and fuse-overlayfs storage, but those are - small, group-scoped, in-VM changes. Net: rootless removes complexity *and* - improves the security posture, so rootful was rejected. -- **Do nothing when `dockerd` is present (literal task reading).** Would leave - the default image's dead socket / broken `docker compose` as-is. Rejected: the - bundled engine does not actually work under msb, so "present" is not "usable." -- **Always symlink/override in a way that shadows a working custom Docker.** We - place the wrapper in `/usr/local/bin` and never remove `/usr/bin/docker`; an - operator who bakes a genuinely working Docker into a custom `ACQ_MSB_IMAGE` can - set `ACQ_MSB_ENSURE_OCI=0`. -- **World-writable device nodes (`chmod 0666` on `/dev/net/tun`, `/dev/fuse`).** - Simpler than group-scoping, but broader than necessary. We chose `chown - root:agent` + `chmod 0660` to scope device access to the agent. -- **Leave stock podman image resolution (document a user opt-in instead).** - Lowest surprise vs. upstream podman, but leaves `docker run hello-world`/`nginx` - hitting quay/failing for users migrating from Docker. Rejected in favour of the - Docker-Hub-first default (Decision 5) to minimize migration burden; the behavior - is documented and user-overridable. -- **`short-name-mode = "permissive"` as the DEFAULT (the ORIGINAL Decision 5).** - The first version of this ADR shipped `permissive` so ambiguous short names - resolve without prompting. Reconsidered after PR #302 review (a 3-model - consensus plus the reviewer recommended against it) and **rejected as the - default**: `permissive` removes the defense against image substitution / - typosquatting on the prompt-injectable agent path (an injected - `docker run nginx` could silently resolve to `docker.io//nginx`), and - it yields **no DX benefit** here because the single `unqualified-search-registries - = ["docker.io"]` entry already gives deterministic Docker-Hub resolution. The - default is now `enforcing` (fail-closed on ambiguity); `permissive`/`disabled` - remain available as an explicit operator opt-in via `ACQ_MSB_SHORT_NAME_MODE`. -- **Disk-backed named volume for container storage (the msb dind recipe's - approach) instead of fuse-overlayfs — DEFERRED, uncertain payoff.** The msb - [docker-in-sandbox recipe](https://github.com/superradcompany/microsandbox/blob/main/docs/examples/docker/docker-in-sandbox.mdx) - mounts a `--mount-named …:/var/lib/docker:kind=disk` ext4 volume so the engine's - storage sits on a real filesystem, letting it use the **native kernel `overlay`** - driver instead of fuse-overlayfs. Applied to our rootless design this could give - native-overlay performance AND drop the `/dev/fuse` grant. We deferred it because: - - **The performance payoff is unquantified.** fuse-overlayfs adds FUSE overhead, - but for the target agent workflows (pull/build/run) it is unmeasured whether - native overlay is meaningfully faster here; the current path is already - verified working end-to-end. - - **It adds a persistent, per-sandbox stateful artifact acq must own.** A named - volume survives `msb rm` (the recipe calls out `msb volume rm` as a separate - cleanup), so acq would take on volume create/reuse/GC lifecycle — reintroducing - exactly the "per-sandbox disk-backed volume" statefulness we cited as a reason - to avoid the dind recipe in the first place. - - **Empirical unknowns.** Rootless podman's graphroot is under `$HOME`, not - `/var/lib/docker`; whether native kernel overlay works rootless on that ext4 - mount without `/dev/fuse`, and whether `msb create` (not just `msb run`) - threads `--mount-named …kind=disk`, are unverified and need a host spike. - This is recorded as a **future option with an uncertain efficiency payoff**, to - be pursued only if fuse-overlayfs overhead is shown to matter and the - volume-lifecycle cost is judged worth it. The disk-backed volume is orthogonal to - the (rejected) dind *daemon* strategy — only the recipe's storage half is in - scope here. - -## Verification - -- Offline: `scripts/test-acq` covers the default path — the root (`-u 0`) - install/config block (threads `ACQ_MSB_PODMAN_PKGS` via `-e PODMAN_PKGS=`, - writes the storage driver, the Docker-Hub-first registries drop-ins, and the - plain `docker`→`podman` alias) AND the separate rootless verify block - (`-u agent`), the `/dev/net/tun` + `/dev/fuse` group-scope grants, the rootless - prereq packages (uidmap/passt/slirp4netns), marker-gated skip, the `ACQ_MSB_ENSURE_OCI=0` - toggle-off, fail-soft on setup/verify failure (rc 0, warning, no marker), and the - `ACQ_MSB_PODMAN_PKGS` charset-guard against injection. -- Live (verified on the assessment host / in an msb-equivalent sandbox, - 2026-08-11): - - `podman info` FAILED with the kernel `overlay`-over-overlayfs error until - `storage.conf` selected `vfs` (or overlay + fuse-overlayfs); afterward it - reported the expected `graphDriverName`. - - **Rootless as the agent user:** with `uidmap`/`passt`/`slirp4netns` installed, - a vfs/fuse-overlayfs storage config, and `/dev/net/tun` + `/dev/fuse` - group-scoped to the agent, `podman info` reported `rootless=true` and - `docker run --rm docker.io/library/hello-world` printed "Hello from Docker!" - (rc 0) — all as the unprivileged agent, no sudo. - - **`/dev/fuse` gotcha (found on the first host run):** with only `/dev/net/tun` - granted and the PREFERRED overlay+fuse-overlayfs driver selected, `podman info` - (rootless) PASSED but `podman run`/`podman build` FAILED at mount time with - `fuse: failed to open /dev/fuse: Permission denied` — because `podman info` - does not open `/dev/fuse` but the fuse-overlayfs layer mount does. Group-scoping - `/dev/fuse` to the agent (alongside `/dev/net/tun`) fixed it. This is why the - grant helper covers BOTH devices and is not gated behind the `podman info` - verify. `scripts/verify-backends` now includes a **local `docker build` (FROM - scratch)** check that exercises exactly this layer-mount path with no registry - egress, so the regression class is caught unambiguously (verified live: build - succeeds with `/dev/fuse` granted, fails with it denied). - - **Registry/egress:** the bare `hello-world` short name resolves to - `quay.io/podman/hello`, whose blob CDN `cdn01.quay.io` is NOT in the balanced - baseline (this was the perennial WARN). The fully-qualified - `docker.io/library/hello-world` pulls via the balanced-allowlisted Docker Hub - CDNs and succeeds — hence both the Docker-Hub-first default AND the verifier's - use of the fully-qualified image. - - Re-confirm end-to-end (plus `docker compose up` from a small - `docker-compose.yaml`) on a KVM-capable host via `scripts/verify-backends` on - the quarterly re-verification cadence (cannot run inside a sandbox, per - [ADR-0011](0011-msb-backend-and-neutral-kits.md)). - -## Links / tracking - -- Emitter: `_acq_msb_ensure_oci` + `_acq_msb_grant_oci_devs` in - `acq.backends/msb.sh`; wired in `acq_backend_provision` (both) and - `acq_backend_start` (grant-oci-devs, for restart) -- Toggles: `ACQ_MSB_ENSURE_OCI` (default on), `ACQ_MSB_PODMAN_PKGS`, - `ACQ_MSB_SHORT_NAME_MODE` (default `enforcing`) -- Related: [ADR-0011](0011-msb-backend-and-neutral-kits.md) (msb backend + - base-image contract), [ADR-0018](0018-msb-balanced-egress-baseline.md) - (balanced egress — the mirror hosts the install relies on), - [ADR-0017](0017-msb-create-time-startup-script-staging.md) (restart-durability - problem that dind would have re-introduced) -- msb evidence: docker-in-sandbox recipe requires `docker:dind` entrypoint + - disk-backed `/var/lib/docker` - (`docs/examples/docker/docker-in-sandbox.mdx`); microVM init is `/init.krun` - (no service manager); root FS is overlay-backed (observed live) -- podman evidence: daemonless run model and rootless prerequisites - (`newuidmap`/`newgidmap` from shadow-utils, `passt`/pasta) — podman rootless - tutorial and basic-networking guide +- **Backend parity.** The same opt-in kit applies on sbx and msb instead of only + the msb adapter provisioning podman. +- **Narrower default exposure.** The adapter no longer grants OCI-related device + access unconditionally during provision or start. The kit owns those grants and + can gate them on the engine being present, including revoking stale grants when + absent. +- **Clearer default.** Sandboxes that do not need container-in-sandbox support no + longer pay the create-time package-install cost or require package-mirror + egress. +- **Explicit migration.** Users who relied on default msb podman must select + `oci-engine` with `acq configure`, or pass the pinned kit ref through + `ACQ_EXTRA_KITS` / `--kit`. + +## Validation + +- Offline tests cover catalog exposure, configured-name expansion to the pinned + kit ref, configured kit persistence, fresh-shell `acq start` reload, and the + absence of adapter-owned podman install/device grants when `oci-engine` is not + selected. +- `scripts/verify-backends --only msb --oci-only` provisions a dedicated live msb + sandbox with the `oci-engine` kit and verifies rootless podman, the `docker` + alias, a local `docker build`, and a `docker run` when registry egress allows. + +## References + +- [ADR-0030: Agent kits on a devenv base](0030-agent-kits-on-devenv-base.md) +- [ADR-0031: Interactive `acq configure` for extra kits and token-scoping defaults](0031-interactive-acq-configure.md) +- [Known Failure Modes: `docker run` or `docker compose` missing inside a sandbox](../KNOWN_FAILURE_MODES.md#34-docker-run-or-docker-compose-missing-inside-a-sandbox) diff --git a/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md b/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md index fbd52781..0c959119 100644 --- a/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md +++ b/docs/adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md @@ -124,8 +124,7 @@ any interactive `git`/`ssh` see the agent. The `--vsock` route persists across `msb stop`/`start` (it is part of the sandbox config), but the `socat` bridge **process** dies on stop. So the bridge is -re-started in `acq_backend_start`, the same way the OCI device-node re-grant is -re-applied on resume. +re-started in `acq_backend_start` before kit startup services are healed. ### Re-attach to a running sandbox (bridge + `SSH_AUTH_SOCK` must be re-driven) @@ -202,7 +201,7 @@ not used — the host agent socket path is platform-dependent (launchd may keep stable; a plain `ssh-agent` rotates it), so only a live connect is authoritative. Refreshing the route automatically on resume is left as future work pending an msb capability to update a `--vsock` route in place. See -`docs/KNOWN_FAILURE_MODES.md` §34 ("forwarded agent unreachable after a host +`docs/KNOWN_FAILURE_MODES.md` §35 ("forwarded agent unreachable after a host reboot"). ### Version gate diff --git a/docs/adr/0022-neutral-image-override.md b/docs/adr/0022-neutral-image-override.md index 1499b8f3..0400a5f8 100644 --- a/docs/adr/0022-neutral-image-override.md +++ b/docs/adr/0022-neutral-image-override.md @@ -128,8 +128,7 @@ published in the Docker Sandboxes kit reference - The four kit prerequisites present in the image: **`node`, `git`, `curl`, `update-ca-certificates`** (plus **`socat`** if git commit signing / ssh-agent forwarding is used). -- The **agent binary** baked in, or installable at provision (for `opencode`, - `acq` runs `npm install -g opencode-ai` on msb; sbx's agent templates bake it). +- The **agent binary** baked in, or installable/exposed by a selected agent kit. The image does **not** have to be built `FROM docker/sandbox-templates:shell` — it only has to *meet* the contract. Building `FROM diff --git a/docs/howto/acq.md b/docs/howto/acq.md index d378df18..db22b640 100644 --- a/docs/howto/acq.md +++ b/docs/howto/acq.md @@ -93,7 +93,7 @@ That's it. `acq run` creates the sandbox if it doesn't exist, heals any missing kits, validates your USAi key, and attaches the agent. > [!NOTE] -> The **first** run boots a microVM, installs the agent, and fetches kits — a +> The **first** run boots a microVM, applies kits, and prepares the agent — a > minute or two, with a progress spinner and status lines so you can follow > along. Later runs are much faster. Set `ACQ_NO_PROGRESS=1` to silence the > animation (plain status lines still print); `ACQ_DEBUG=1` also disables it in diff --git a/docs/howto/msb.md b/docs/howto/msb.md index 44b2d002..69f82591 100644 --- a/docs/howto/msb.md +++ b/docs/howto/msb.md @@ -3,7 +3,7 @@ title: "msb How-To Guide" description: "Detailed how-to for the msb (microsandbox) backend behind acq" status: canonical tier: 2 -last_updated: "2026-09-24" +last_updated: "2026-10-03" audience: "developers" keywords: ["msb", "microsandbox", "backend", "acq", "howto", "sandbox"] related_files: ["docs/BACKEND_GUIDE.md", "docs/CONCEPTS.md", "docs/howto/acq.md", "docs/howto/sbx.md", "docs/adr/0011-msb-backend-and-neutral-kits.md", "docs/adr/0024-neutral-user-facing-docs-vs-backend-specific.md"] @@ -251,7 +251,7 @@ abstract — use the raw `msb` command: msb exposes a large set of `ACQ_MSB_*` (and neutral `ACQ_*`) tunables — base image and pull policy, network egress tier, memory/CPU, DNS, host ssh-agent -forwarding, post-hoc port publishing, and OCI-engine provisioning. Rather than +forwarding, post-hoc port publishing, and opt-in OCI-engine support. Rather than duplicate them here, the authoritative reference is the [Backend Guide → msb Backend](../BACKEND_GUIDE.md#msb-backend-microsandbox-default), including: diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 30b90d37..03c7541c 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -81,7 +81,11 @@ else _ACQ_MSB_OFFLINE_KIT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/acq-nokit.XXXXXX") fi printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: x\ndisplayName: X\ndescription: x\n' > "$_ACQ_MSB_OFFLINE_KIT_DIR/spec.yaml" +_ACQ_AGENT_OFFLINE_KIT_DIR="${BATS_RUN_TMPDIR:-$_ACQ_MSB_OFFLINE_KIT_DIR}/acq-opencode-kit" +mkdir -p "$_ACQ_AGENT_OFFLINE_KIT_DIR" +printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: opencode\ndisplayName: OpenCode\ndescription: offline agent kit\nagent:\n name: opencode\n entrypoint: opencode\n' > "$_ACQ_AGENT_OFFLINE_KIT_DIR/spec.yaml" export ACQ_MSB_KIT_LOCAL_DIR="$_ACQ_MSB_OFFLINE_KIT_DIR" +export ACQ_TEST_AGENT_KIT="$_ACQ_AGENT_OFFLINE_KIT_DIR" # --------------------------------------------------------------------------- @@ -265,14 +269,6 @@ case "$_msb_sub" in exec) snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done - # `npm install -g …` is run as DIRECT argv (no `sh -c`), so it is matched on - # the full arg list, not on $snippet. STUB_NPM_FAIL=1 models the install - # failing so the #321 disambiguation path runs. Match it up front (before the - # bare-argv opencode-version probe and the $snippet cases below). - case " $* " in - *" npm install "*) - [ "${STUB_NPM_FAIL:-0}" = "1" ] && exit 1 || exit 0 ;; - esac # `opencode --version` postinstall functionality probe (bare argv). Default # broken (exit 1) so the postinstall path runs; STUB_OPENCODE_OK=1 or a # planted .opencode_fixed marker (written by a successful postinstall.mjs @@ -309,23 +305,6 @@ case "$_msb_sub" in fi exit 0 ;; *"echo ok"*) printf 'ok\n' ;; - # #321: the registry reachability probe curls `https:///` and prints - # the `|` shape _classify_key_status reads. Default - # models a reachable registry (200). STUB_NPM_REGISTRY=unreachable models a - # TLS/connection cut (000|56); =unresolved models NXDOMAIN (000|6); - # =responded models the registry answering with an HTTP ERROR (500|0) — - # the connection completed, so it is NOT a DNS/TLS problem and MUST fall - # through to the neutral "registry rejected it / real npm error" branch. - # Match BEFORE the generic `%{http_code}` arm below (that one is the USAi - # key probe with an Authorization header; this one has neither header nor - # USAi host). Must precede the generic arm because both contain `%{http_code}`. - *"registry.npmjs.org"*'%{http_code}'*|*'%{http_code}'*"registry.npmjs.org"*) - case "${STUB_NPM_REGISTRY:-reachable}" in - unreachable) printf '000|56' ;; - unresolved) printf '000|6' ;; - responded) printf '500|0' ;; - *) printf '200|0' ;; - esac ;; *'%{http_code}'*) # Match check_key's `|` shape (see the sbx stub). if [ "${STUB_KEY_UNREACHABLE:-0}" = "1" ]; then @@ -362,25 +341,6 @@ case "$_msb_sub" in # heal's marker-hit shell-sync path can be exercised. *"test -f '/var/lib/acq/agent-user-ready'"*) [ "${STUB_AGENT_USER_READY:-0}" = "1" ] && exit 0 || exit 1 ;; - # The OCI-engine setup is TWO msb-exec `sh -c` blocks: (1) a root (`-u 0`) - # install/config block carrying `/usr/local/bin/docker`, and (2) a rootless - # verify block (`-u agent`) that runs a `podman build` layer-mount self-test - # (tagged acq-oci-selftest). Match the root block by its docker-alias marker - # and the verify block by the self-test image tag. STUB_OCI_SETUP_FAIL=1 - # models the ROOT block failing; STUB_OCI_VERIFY_FAIL=1 models the rootless - # build self-test failing (engine/storage unusable). Either failure must make - # provision FAIL SOFT (warn, rc 0, no marker). Match these FIRST (before the - # generic command-v / test-f cases below would swallow them). - *"/usr/local/bin/docker"*) - [ "${STUB_OCI_SETUP_FAIL:-0}" = "1" ] && exit 1 || exit 0 ;; - *"acq-oci-selftest"*) - [ "${STUB_OCI_VERIFY_FAIL:-0}" = "1" ] && exit 1 || exit 0 ;; - # The OCI-ready marker probe (`test -f '/var/lib/acq/oci-ready'`) gates the - # setup block. Match it BEFORE the generic `test -f` (markers absent) case - # below. Default ABSENT (exit 1) so the OCI step runs; STUB_OCI_READY=1 - # makes the marker present (exit 0) to exercise the marker-gated skip. - *"test -f '/var/lib/acq/oci-ready'"*) - [ "${STUB_OCI_READY:-0}" = "1" ] && exit 0 || exit 1 ;; # `command -v ` (agent-presence probe): controllable so the install # path can be exercised. By default the agent is ABSENT (exit 1) so install # runs; STUB_AGENT_PRESENT=1 makes it "present" (skips install). The prereq @@ -395,13 +355,6 @@ case "$_msb_sub" in # tool (socat included) as present and make STUB_SOCAT_PRESENT inert. *"command -v socat"*) [ "${STUB_SOCAT_PRESENT:-1}" = "0" ] && exit 1 || exit 0 ;; - # #321 npm-install-failure disambiguation: on a failed `npm install`, acq - # probes whether npm is present in-guest (`command -v npm`) and, if so, - # curls the registry host. Model npm as present by default; STUB_NPM_MISSING=1 - # makes `command -v npm` miss (exit 1) so the genuinely-missing-npm branch is - # exercised. This arm MUST precede the generic `command -v` catch-all. - *"command -v npm"*) - [ "${STUB_NPM_MISSING:-0}" = "1" ] && exit 1 || exit 0 ;; # ADR-0021: the in-guest socat bridge (`nohup socat UNIX-LISTEN:… # VSOCK-CONNECT:2:`) started by _acq_msb_start_ssh_agent_bridge. # Model a successful bridge start (exit 0). Match BEFORE the generic cases. @@ -438,8 +391,6 @@ case "$_msb_sub" in printf '256 SHA256:stubkey stub@host (ED25519)\n'; exit 0 fi ;; *"command -v"*) : ;; # prereqs "present" (empty missing set) - *"npm install"*) - [ "${STUB_NPM_FAIL:-0}" = "1" ] && exit 1 || exit 0 ;; *"test -f "*) exit 1 ;; # markers absent *"test -s "*) exit 0 ;; # copied files present # The /var/lib/acq marker reads (agent, workspace, ssh-auth-sock, diff --git a/scripts/verify-backends b/scripts/verify-backends index 1c4cb6bd..0210ed0d 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -33,9 +33,10 @@ # 5. The migration-sensitive boot contract stays intact: the verifier's # ACQ_EXTRA_KITS volume mounts at boot, acq exec starts in the workspace as # the agent user, and an opencode sandbox re-attaches through `acq run NAME -- -# --version`. Offline bats additionally freeze the fresh `acq run opencode .` -# dispatch surface: --clone / ACQ_CLONE, ACQ_WORKSPACE, --kit ordering, -# ACQ_IMAGE, boot volumes, and login-shell shape. +# --version`. On msb, the optional `oci-engine` catalog kit is verified in a +# separate sandbox. Offline bats additionally freeze the fresh `acq run +# opencode .` dispatch surface: --clone / ACQ_CLONE, ACQ_WORKSPACE, --kit +# ordering, ACQ_IMAGE, boot volumes, and login-shell shape. # A backend that is NOT installed is SKIPPED (its row prints "skipped"). # # This CANNOT run inside an sbx/msb sandbox (no nested sandboxes) — see ADR-0011 @@ -53,11 +54,10 @@ # (default: always remove) for manual poking. # --only BACKEND Run just one backend (sbx|msb), skipping # the other — handy when iterating. -# --oci-only Skip the slow create + kit-apply and run -# ONLY the OCI (podman) checks against an -# already-existing exec-ready sandbox. -# Pair with a prior `--only msb -k` run so -# the sandbox is left in place to reuse. +# --oci-only Skip the broad backend checks and run +# ONLY the OCI (podman) checks. Creates a +# dedicated sandbox with the oci-engine kit +# unless an exec-ready one already exists. # ACQ_VERIFY_USE_GH_AUTH=1 Allow the GitHub seed to fall back to # `gh auth token`. OFF by default because a # keychain-backed gh can block on a @@ -854,8 +854,17 @@ verify_backend() { _stop_ephemeral_agent fi - # 5) OCI engine (podman) usable — msb only (ADR-0020). - _verify_oci_checks "$backend" "$name" + # 5) Default sandbox has no adapter-owned OCI install; OCI is opt-in via the + # oci-engine catalog kit, verified separately below. + if [ "$backend" = "msb" ]; then + if run_acq "exec no-default-oci" --backend "$backend" exec "$name" -- sh -c \ + 'test ! -e /var/lib/acq/oci-ready && ! command -v podman >/dev/null 2>&1'; then + pass "${backend}: OCI engine is not installed by default" + else + fail "${backend}: OCI engine appeared without selecting the oci-engine kit" + dump "no-default-oci output" "$LAST_OUT" + fi + fi # 6) Neutral kit volumes (ADR-0023): the verifier's fixture kit (appended to # ACQ_EXTRA_KITS at setup) declares a 256m block volume at /acq-verify-vol. @@ -1149,86 +1158,101 @@ _verify_ver_ge() { return 0 } -# _verify_oci_checks BACKEND NAME — the OCI engine + run checks (msb only, -# ADR-0020). Factored out of verify_backend so the --oci-only fast path can -# re-run JUST these against an existing sandbox without the slow create/kit-apply. -# acq provisions rootless podman and aliases docker->podman so agents can run OCI -# images; skipped when ACQ_MSB_ENSURE_OCI=0 (operator opted out). -_verify_oci_checks() { - local backend="$1" name="$2" - [ "$backend" = "msb" ] || return 0 - case "$(printf '%s' "${ACQ_MSB_ENSURE_OCI-1}" | tr '[:upper:]' '[:lower:]')" in - ""|0|false|no|off) - skip "${backend}: OCI engine check (ACQ_MSB_ENSURE_OCI disabled)" ;; - *) - # docker (our wrapper) resolves to podman, and the engine reports info. - # The engine runs ROOTLESS as the agent user (ADR-0020), and `acq exec` - # already runs as the agent, so `docker info` (= plain `podman info`) - # exercises the real usable path: the rootless prereqs, the storage driver - # on the overlay root, and the docker->podman alias. - if run_acq "exec oci-engine" --backend "$backend" exec "$name" -- sh -c \ - 'command -v podman >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1'; then - pass "${backend}: OCI engine (rootless podman) present and docker->podman aliased" - else - fail "${backend}: OCI engine (podman) not usable (see ADR-0020)" - dump "oci-engine output" "$LAST_OUT" - fi - # LOCAL BUILD (no registry): build a trivial `FROM scratch` image. This - # exercises the storage-driver LAYER MOUNT — the exact operation that - # `docker info` does NOT perform but every real build/run does. It is what - # caught the rootless /dev/fuse regression: with the fuse-overlayfs driver - # but /dev/fuse ungranted, `docker info` PASSES yet the layer mount fails - # ("fuse: failed to open /dev/fuse: Permission denied"). Because it needs - # neither egress nor a registry, a failure here is UNAMBIGUOUSLY an - # engine/storage fault (unlike the hello-world run below, which depends on - # Docker Hub egress) — and `docker build` is itself a capability agents use. - # We assert the BUILD succeeds; the build mounts layers, which is the mount - # path we need to prove. `FROM scratch` needs no base image (no pull). - if run_acq "exec oci-build" --backend "$backend" exec "$name" -- sh -c ' - set -e - d=$(mktemp -d) - printf "FROM scratch\nCOPY hi /hi\n" > "$d/Containerfile" - echo hi > "$d/hi" - docker build -q -t acq-oci-selftest:local "$d" >/dev/null 2>&1 - rc=$? - docker rmi -f acq-oci-selftest:local >/dev/null 2>&1 || true - rm -rf "$d" - exit $rc'; then - pass "${backend}: local 'docker build' (rootless, fuse-overlayfs mount) works" - else - fail "${backend}: local 'docker build' failed — storage-driver mount fault (see ADR-0020)" - dump "oci-build output" "$LAST_OUT" - fi - # End-to-end: run a trivial container through the docker alias. We use the - # FULLY-QUALIFIED docker.io/library/hello-world (NOT the bare `hello-world` - # short name, which podman's stock shortnames map to quay.io/podman/hello — - # whose blob CDN cdn01.quay.io is NOT in the balanced baseline). Docker - # Hub's blob CDNs (**.production.cloudflare.docker.com / - # **.production.cloudfront.docker.com) ARE in the balanced baseline, so this - # pull+run succeeds under the default policy. - # - # Escalation: distinguish "engine can't run a container" (a real FAIL) from - # "registry egress is blocked" (environmental WARN). If the run fails, probe - # registry reachability (a 401 from registry-1.docker.io/v2/ means egress is - # fine and the failure is the engine → FAIL; anything else means egress is - # the culprit → WARN). - if run_acq "exec oci-run" --backend "$backend" exec "$name" -- sh -c \ - 'docker run --rm docker.io/library/hello-world >/dev/null 2>&1'; then - pass "${backend}: 'docker run' (via podman) executes an OCI image" - else - local _oci_run_out="$LAST_OUT" - if run_acq "exec oci-egress-probe" --backend "$backend" exec "$name" -- sh -c \ - '[ "$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 https://registry-1.docker.io/v2/)" = "401" ]'; then - # Registry egress works, but the container did not run -> genuine engine fault. - fail "${backend}: 'docker run' failed though Docker Hub egress is reachable (engine fault)" - dump "oci-run output" "$_oci_run_out" - else - warn "${backend}: 'docker run hello-world' did not complete (registry egress?); engine wiring OK" - dump "oci-run output" "$_oci_run_out" - fi - fi - ;; - esac +# verify_oci_kit — provision a dedicated msb sandbox with the opt-in oci-engine +# catalog kit, then prove the docker->podman path. This is a live check: without +# msb or a sandbox-capable host it SKIPs/BLOCKS loudly rather than passing. +verify_oci_kit() { + local backend="msb" name created="" + echo "" + echo "== backend: msb (oci-engine kit) ==" + + command -v msb >/dev/null 2>&1 || { skip "msb: oci-engine kit skipped (msb not installed)"; return 0; } + _verify_backend_enabled msb || return 0 + if ! msb doctor >/dev/null 2>&1; then + skip "msb: oci-engine kit skipped (msb doctor reports host virtualization is not ready)" + return 0 + fi + + name=$( + # shellcheck disable=SC1090 + ACQ_SOURCE_ONLY=1 ACQ_SCRIPT_DIR="$REPO_ROOT" . "$ACQ" >/dev/null 2>&1 + derive_name shell "$WORKDIR-oci" 2>/dev/null + ) + [ -n "$name" ] || name="shell-$(basename "$WORKDIR" | tr '[:upper:]' '[:lower:]')-oci" + printf ' sandbox name: %s\n' "$name" + + if [ -n "${OCI_ONLY:-}" ] && run_acq "exists-oci-kit" --backend "$backend" exec "$name" -- true >/dev/null 2>&1; then + printf ' (--oci-only: reusing existing exec-ready sandbox %s)\n' "$name" + else + seed_usai_secret "$backend" "$name" + if [ "$USAI_SEEDED_FOR" != "$name" ]; then + skip "${backend}: oci-engine kit skipped (verifier did not seed sandbox-scoped USAi key)" + return 0 + fi + local oci_kit_ref + oci_kit_ref=$( + # shellcheck disable=SC1090 + ACQ_SOURCE_ONLY=1 ACQ_SCRIPT_DIR="$REPO_ROOT" . "$ACQ" >/dev/null 2>&1 + _acq_optin_kit_ref oci-engine + ) + printf ' creating sandbox %s with oci-engine kit (booting + package install can take a few minutes)...\n' "$name" + if ! run_acq "create-oci-kit" --backend "$backend" create shell "$WORKDIR" --name "$name" --kit "$oci_kit_ref"; then + fail "${backend}: provision with oci-engine kit failed" + dump "acq create output" "$LAST_OUT" + unseed_usai_secret "$backend" "$name" + [ -n "$KEEP" ] || run_acq "rm-oci-kit" --backend "$backend" rm "$name" >/dev/null 2>&1 || true + return + fi + created=1 + unseed_usai_secret "$backend" "$name" + fi + + if run_acq "exec oci-engine" --backend "$backend" exec "$name" -- sh -c \ + 'command -v podman >/dev/null 2>&1 && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1'; then + pass "${backend}: oci-engine kit installed rootless podman and docker alias" + else + fail "${backend}: oci-engine kit did not provide a usable podman engine" + dump "oci-engine output" "$LAST_OUT" + fi + + if run_acq "exec oci-build" --backend "$backend" exec "$name" -- sh -c ' + set -e + d=$(mktemp -d) + printf "FROM scratch\nCOPY hi /hi\n" > "$d/Containerfile" + echo hi > "$d/hi" + docker build -q -t acq-oci-selftest:local "$d" >/dev/null 2>&1 + rc=$? + docker rmi -f acq-oci-selftest:local >/dev/null 2>&1 || true + rm -rf "$d" + exit $rc'; then + pass "${backend}: oci-engine kit supports local docker build" + else + fail "${backend}: oci-engine kit local docker build failed" + dump "oci-build output" "$LAST_OUT" + fi + + if run_acq "exec oci-run" --backend "$backend" exec "$name" -- sh -c \ + 'docker run --rm docker.io/library/hello-world >/dev/null 2>&1'; then + pass "${backend}: oci-engine kit runs an OCI image via docker alias" + else + local _oci_run_out="$LAST_OUT" + if run_acq "exec oci-egress-probe" --backend "$backend" exec "$name" -- sh -c \ + '[ "$(curl -s -o /dev/null -w "%{http_code}" --max-time 15 https://registry-1.docker.io/v2/)" = "401" ]'; then + fail "${backend}: docker run failed though Docker Hub egress is reachable" + dump "oci-run output" "$_oci_run_out" + else + warn "${backend}: docker run hello-world did not complete (registry egress?); engine wiring OK" + dump "oci-run output" "$_oci_run_out" + fi + fi + + if [ -n "$created" ]; then + if [ -n "$KEEP" ] && [ "$FAIL" -gt 0 ]; then + printf ' (keeping sandbox %s; --keep set)\n' "$name" + else + run_acq "rm-oci-kit" --backend "$backend" rm "$name" >/dev/null 2>&1 || true + fi + fi } # verify_agent_install BACKEND — provision an `opencode` sandbox (not `shell`) @@ -1332,27 +1356,32 @@ if [ -x "${REPO_ROOT}/scripts/fix-exec-bits" ] || [ -f "${REPO_ROOT}/scripts/fix unset _fix_out fi -# sbx -if command -v sbx >/dev/null 2>&1; then - if _verify_backend_enabled sbx; then - verify_backend sbx - verify_agent_install sbx - fi +if [ -n "${OCI_ONLY:-}" ]; then + verify_oci_kit else - if _verify_backend_enabled sbx; then - echo ""; echo "== backend: sbx =="; skip "sbx: not installed" + # sbx + if command -v sbx >/dev/null 2>&1; then + if _verify_backend_enabled sbx; then + verify_backend sbx + verify_agent_install sbx + fi + else + if _verify_backend_enabled sbx; then + echo ""; echo "== backend: sbx =="; skip "sbx: not installed" + fi fi -fi -# msb -if command -v msb >/dev/null 2>&1; then - if _verify_backend_enabled msb; then - verify_backend msb - verify_agent_install msb - fi -else - if _verify_backend_enabled msb; then - echo ""; echo "== backend: msb =="; skip "msb: not installed" + # msb + if command -v msb >/dev/null 2>&1; then + if _verify_backend_enabled msb; then + verify_backend msb + verify_agent_install msb + verify_oci_kit + fi + else + if _verify_backend_enabled msb; then + echo ""; echo "== backend: msb =="; skip "msb: not installed" + fi fi fi diff --git a/test/bats/135-configure.bats b/test/bats/135-configure.bats index 11c30224..4d8e023c 100644 --- a/test/bats/135-configure.bats +++ b/test/bats/135-configure.bats @@ -152,6 +152,18 @@ _cfg_src() { assert_output --partial 'dir=integrations/isolation/acq-kits/openchamber' } +@test "catalog: oci-engine is offered and expands to the pinned ref" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + printf "%s\n" "${ACQ_OPTIN_KIT_NAMES[@]}" + _acq_optin_kit_ref oci-engine + ' + assert_success + assert_line 'oci-engine' + assert_output --partial 'ref=56d1f5f49ef928f50e61aa64a93d618b3d21c415' + assert_output --partial 'dir=integrations/isolation/acq-kits/oci-engine' +} + @test "catalog: prime-agent is intentionally NOT offered (skeleton at pin)" { run bash -c ' ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 @@ -160,6 +172,7 @@ _cfg_src() { assert_success assert_line 'openchamber' assert_line 'paseo' + assert_line 'oci-engine' refute_line 'prime-agent' } @@ -358,6 +371,17 @@ _cfg_src() { assert_line 'scope_github_token: yes' } +@test "configure: selecting oci-engine persists the catalog name" { + export XDG_CONFIG_HOME="$STUBDIR/xdg-oci" + run env ACQ_BACKEND=sbx ACQ_PROMPT_TEST_INPUT="DOWN DOWN SPACE ENTER n" "$ACQ" configure + assert_success + assert_output --partial 'oci-engine' + assert_output --partial 'Rootless podman for docker run / docker compose' + run cat "$XDG_CONFIG_HOME/acq/config.yaml" + assert_line 'extra_kits: oci-engine' + assert_line 'scope_github_token: no' +} + # --------------------------------------------------------------------------- # configured-default extra-kit application + env precedence # --------------------------------------------------------------------------- @@ -365,7 +389,7 @@ _cfg_src() { @test "apply: configured extra_kits expand to pinned refs" { export XDG_CONFIG_HOME="$STUBDIR/xdg5" mkdir -p "$XDG_CONFIG_HOME/acq" - printf 'extra_kits: openchamber paseo\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + printf 'extra_kits: openchamber paseo oci-engine\n' > "$XDG_CONFIG_HOME/acq/config.yaml" run bash -c ' ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 ACQ_EXTRA_KITS=""; ACQ_EXTRA_KITS_FROM_ENV="" @@ -375,6 +399,7 @@ _cfg_src() { assert_success assert_output --partial 'dir=integrations/isolation/acq-kits/openchamber' assert_output --partial 'dir=integrations/isolation/acq-kits/paseo' + assert_output --partial 'dir=integrations/isolation/acq-kits/oci-engine' } @test "apply: unknown configured extra_kits names fail closed" { diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 4b5fa0aa..16bd05bf 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -94,6 +94,7 @@ SPEC @test "agent kit gate: valid opencode artifact stays deferred while disabled" { ACQ_TEST_AGENT_KIT="$STUBDIR/opencode-kit-disabled" _mk_opencode_agent_kit "$ACQ_TEST_AGENT_KIT" + acq_agent_builtin_kit_enabled() { return 1; } _acq_agent_builtin_kit_ref() { printf '%s\n' "$ACQ_TEST_AGENT_KIT"; } _build_kit_list opencode diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 641a9945..2e477e0b 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -600,24 +600,24 @@ FACTS assert_regex "$log" '--kit evil-agent-arg' } -@test "agent-kits: run opencode reports inferred built-in kit" { +@test "agent-kits: run opencode applies inferred built-in kit" { local proj="$STUBDIR/agentkit-opencode"; mkdir -p "$proj" _seed_usai run env ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" assert_success - assert_output --partial 'built-in agent kit candidate (deferred; no agent kit applied): agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=deferred' - refute_regex "$(cat "$CALLS")" 'acq-kits/opencode' + assert_output --partial 'selected built-in agent kit: agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=enabled' + assert_regex "$(cat "$CALLS")" 'acq-opencode-kit' } -@test "agent-kits: create opencode reports inferred built-in kit" { +@test "agent-kits: create opencode applies inferred built-in kit" { local proj="$STUBDIR/agentkit-create"; mkdir -p "$proj" _seed_usai run env ACQ_BACKEND=sbx "$ACQ" create opencode "$proj" assert_success - assert_output --partial 'built-in agent kit candidate (deferred; no agent kit applied): agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=deferred' - refute_regex "$(cat "$CALLS")" 'acq-kits/opencode' + assert_output --partial 'selected built-in agent kit: agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=enabled' + assert_regex "$(cat "$CALLS")" 'acq-opencode-kit' } @test "agent-kits: explicit --kit suppresses implicit selection notice" { diff --git a/test/bats/36-agent-catalog.bats b/test/bats/36-agent-catalog.bats index ffee5016..48646ced 100644 --- a/test/bats/36-agent-catalog.bats +++ b/test/bats/36-agent-catalog.bats @@ -83,17 +83,17 @@ load 'helper' assert_failure } -@test "agents: opencode has built-in agent-kit metadata" { +@test "agents: opencode has enabled built-in agent-kit metadata" { run bash -c ' . "'"$REPO_ROOT"'/acq.backends/agents.sh" acq_agent_builtin_kit_name opencode acq_agent_kit_entrypoint opencode acq_agent_kit_install_owner opencode acq_agent_kit_start_owner opencode - acq_agent_builtin_kit_enabled opencode || printf "apply-deferred\n" + acq_agent_builtin_kit_enabled opencode && printf "apply-enabled\n" ' assert_success - assert_output $'opencode\nopencode\nkit\nkit\napply-deferred' + assert_output $'opencode\nopencode\nkit\nkit\napply-enabled' } @test "agents: shell selects no built-in agent kit" { diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 2a6242ae..70a865bf 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -177,7 +177,7 @@ FACTS refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/opencode' } -@test "agent-kits: opencode inference is visible while apply is deferred" { +@test "agent-kits: opencode inference applies the built-in agent kit" { load_acq ACQ_EXTRA_KITS="" ACQ_CLI_KITS=() @@ -185,9 +185,9 @@ FACTS run acq_selected_agent_kit_summary opencode assert_success - assert_output 'agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=deferred' - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" - refute_regex "$(printf '%s\n' "${KITS[@]}")" 'acq-kits/opencode' + assert_output 'agent=opencode kit=opencode entrypoint=opencode install_owner=kit start_owner=kit apply=enabled' + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" + assert_regex "$(printf '%s\n' "${KITS[@]}")" '/acq-opencode-kit' } @test "agent-kits: enabled built-in agent kit is appended after support bundle" { diff --git a/test/bats/72-msb-agent-user.bats b/test/bats/72-msb-agent-user.bats index f1f822e7..a4149f5b 100644 --- a/test/bats/72-msb-agent-user.bats +++ b/test/bats/72-msb-agent-user.bats @@ -4,10 +4,10 @@ # (ADR-0025) # # msb provision: agent-user creation + uid-1000 kit commands as `agent`, the -# Docker base-image contract (sudo + proxy env_keep), agent install + npm-failure -# disambiguation (#321), attach launching the recorded agent with a PTY, exec as -# the agent user, injection guards, and the OCI-engine (podman) setup. Provisions -# run in isolated subshells; assertions read $CALLS. +# Docker base-image contract (sudo + proxy env_keep), agent-kit selection, attach +# launching the recorded agent with a PTY, exec as the agent user, injection +# guards, and absence of adapter-owned OCI setup. +# Provisions run in isolated subshells; assertions read $CALLS. # # shellcheck shell=bats @@ -87,54 +87,21 @@ SPEC assert_regex "$log" 'HTTPS_PROXY' } -@test "msb: provision installs the requested agent via npm and allow-lists the registry" { - _provision instbox opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/inst-secrets"' +@test "msb: provision applies the opencode agent kit without native npm fallback" { + _provision instbox opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/inst-secrets" ACQ_NETWORK_TIER=strict' local log; log=$(cat "$CALLS") - assert_regex "$log" 'npm install -g --no-fund --no-audit opencode-ai' - assert_regex "$log" '--net-rule allow@registry\.npmjs\.org' + refute_regex "$log" 'npm install' + refute_regex "$log" 'allow@registry\.npmjs\.org' assert_regex "$log" '/var/lib/acq/agent' } -@test "msb: install is idempotent — skipped when the agent binary is already present" { - _provision inst2box opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/inst2-secrets" STUB_AGENT_PRESENT=1' - refute_regex "$(cat "$CALLS")" 'npm install' -} - -@test "msb: a shell sandbox installs no agent and (strict tier) adds no npm net-rule" { +@test "msb: a shell sandbox has no agent kit and adds no npm net-rule" { _provision shellbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/shell-secrets" ACQ_NETWORK_TIER=strict' local log; log=$(cat "$CALLS") refute_regex "$log" 'npm install' refute_regex "$log" 'allow@registry\.npmjs\.org' } -@test "msb #321: an unreachable npm registry is reported as network, not missing npm" { - _provision npmunreachbox opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/npmunreach-secrets" STUB_NPM_FAIL=1 STUB_NPM_REGISTRY=unreachable' - assert_output --partial 'NOT REACHABLE' - assert_output --partial 'KNOWN_FAILURE_MODES.md §30' - refute_output --partial 'npm is not present' -} - -@test "msb #321: an NXDOMAIN npm registry is reported as DNS, not missing npm" { - _provision npmunresbox opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/npmunres-secrets" STUB_NPM_FAIL=1 STUB_NPM_REGISTRY=unresolved' - assert_output --partial 'did not RESOLVE' - assert_output --partial 'ACQ_MSB_DNS_NAMESERVER' -} - -@test "msb #321: a genuinely-missing npm is reported as such" { - _provision npmmissingbox opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/npmmissing-secrets" STUB_NPM_FAIL=1 STUB_NPM_MISSING=1' - assert_output --partial 'npm is not present' - refute_output --partial 'NOT REACHABLE' -} - -@test "msb #321: a responded-but-errored registry gets neutral guidance, no DNS/TLS bleed" { - _provision npmrespbox opencode 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/npmresp-secrets" STUB_NPM_FAIL=1 STUB_NPM_REGISTRY=responded' - assert_output --partial 'registry appears reachable' - refute_output --partial 'ACQ_MSB_DNS_NAMESERVER' - refute_output --partial 'did not RESOLVE' - refute_output --partial 'NOT REACHABLE' - refute_output --partial 'npm is not present' -} - # Attach helper: source msb + run acq_backend_attach in a subshell. _attach() { # PRE_SNIPPET NAME : > "$CALLS" @@ -186,18 +153,6 @@ _attach() { # PRE_SNIPPET NAME refute_regex "$log" 'msb exec execbox --' } -@test "msb: a hostile agent token is refused on install and never emitted as sh -c" { - : > "$CALLS" - run bash -c ' - export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/inj-secrets" - . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" - . "'"$REPO_ROOT"'/acq.backends/msb.sh" - _acq_msb_install_agent injbox "x'"'"';touch /tmp/acq_pwn;'"'"'" 2>&1 - ' - assert_output --partial 'refusing agent name' - refute_regex "$(cat "$CALLS")" 'touch /tmp/acq_pwn' -} - @test "msb: attach with a tampered agent marker falls back to shell, never runs the injection" { _attach 'export STUB_RECORDED_AGENT="x'"'"';touch /tmp/acq_pwn;'"'"'" STUB_RECORDED_WORKSPACE=/tmp/wsp' injattach local log; log=$(cat "$CALLS") @@ -205,76 +160,17 @@ _attach() { # PRE_SNIPPET NAME assert_regex "$log" 'injattach -- /bin/sh -l' } -@test "msb: OCI (podman) engine setup runs as root, configures storage, verifies rootless" { +@test "msb: provision without oci-engine does not install podman or grant OCI devices" { _provision ocibox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/oci-secrets"' local log; log=$(cat "$CALLS") - assert_regex "$log" 'PODMAN_PKGS=' - assert_regex "$log" '/usr/local/bin/docker' - assert_regex "$log" "touch '/var/lib/acq/oci-ready'" - assert_regex "$log" '/etc/containers/storage\.conf' - assert_regex "$log" 'driver = ..vfs..' - assert_regex "$log" 'mount_program' - assert_regex "$log" 'fuse-overlayfs' - assert_regex "$log" 'uidmap' - assert_regex "$log" 'passt' - assert_regex "$log" 'slirp4netns' - assert_regex "$log" 'exec podman' - refute_regex "$log" 'exec sudo -n podman' - assert_regex "$log" '/dev/net/tun' - assert_regex "$log" '/dev/fuse' - assert_regex "$log" 'chown root:agent' - assert_regex "$log" 'unqualified-search-registries = ...docker.io...' - assert_regex "$log" 'docker\.io/library/hello-world' - assert_regex "$log" 'short-name-mode = ...SHORT_NAME_MODE.' - assert_regex "$log" 'SHORT_NAME_MODE=enforcing' - refute_regex "$log" 'SHORT_NAME_MODE=permissive' - assert_regex "$log" 'msb exec ocibox -u 0 -e PODMAN_PKGS=' - assert_regex "$log" 'msb exec ocibox -u agent -e HOME=/home/agent' - assert_regex "$log" 'acq-oci-selftest' -} - -@test "msb: ACQ_MSB_SHORT_NAME_MODE=permissive threads the permissive value" { - _provision ocipermbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/oci-perm-secrets" ACQ_MSB_SHORT_NAME_MODE=permissive' - local log; log=$(cat "$CALLS") - assert_regex "$log" 'SHORT_NAME_MODE=permissive' - refute_regex "$log" 'SHORT_NAME_MODE=enforcing' -} - -@test "msb: an invalid ACQ_MSB_SHORT_NAME_MODE warns and falls back to enforcing" { - _provision ocibadbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/oci-bad-secrets" ACQ_MSB_SHORT_NAME_MODE="bogus; rm -rf /"' - assert_output --partial 'invalid ACQ_MSB_SHORT_NAME_MODE' - local log; log=$(cat "$CALLS") - assert_regex "$log" 'SHORT_NAME_MODE=enforcing' - refute_regex "$log" 'SHORT_NAME_MODE=bogus' - refute_regex "$log" 'rm -rf /' -} - -@test "msb: the OCI setup is skipped when the ready marker already exists" { - _provision ocirdybox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/ociready-secrets" STUB_OCI_READY=1' - local log; log=$(cat "$CALLS") - assert_regex "$log" "test -f '/var/lib/acq/oci-ready'" refute_regex "$log" 'PODMAN_PKGS=' refute_regex "$log" '/usr/local/bin/docker' -} - -@test "msb: ACQ_MSB_ENSURE_OCI=0 skips the OCI step entirely" { - _provision ocioffbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/ocioff-secrets" ACQ_MSB_ENSURE_OCI=0' - local log; log=$(cat "$CALLS") - refute_regex "$log" 'PODMAN_PKGS=' - refute_regex "$log" 'oci-ready' -} - -@test "msb: an OCI setup failure is fail-soft (rc 0, warns, marker not touched)" { - _provision ocifailbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/ocifail-secrets" STUB_OCI_SETUP_FAIL=1' - assert_success - assert_output --partial 'could not provision an OCI engine' - refute_regex "$(cat "$CALLS")" "touch '/var/lib/acq/oci-ready'" -} - -@test "msb: an unsafe ACQ_MSB_PODMAN_PKGS is refused and never reaches an exec" { - _provision ociinjbox shell 'export ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/ociinj-secrets" ACQ_MSB_PODMAN_PKGS="podman;rm -rf"' - assert_output --partial 'unsafe characters' - refute_regex "$(cat "$CALLS")" 'rm -rf' + refute_regex "$log" "touch '/var/lib/acq/oci-ready'" + refute_regex "$log" '/etc/containers/storage\.conf' + refute_regex "$log" 'acq-oci-selftest' + refute_regex "$log" '/dev/net/tun' + refute_regex "$log" '/dev/fuse' + refute_regex "$log" 'chown root:agent' } # msb session parity: sbx is a full session transport, so diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 0b33d63a..b10342c3 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -1,7 +1,7 @@ #!/usr/bin/env bats # # 90-sbx-startup-kit.bats — bats port of scripts/test-acq.d/90-sbx-startup-kit.sh -# (quickstart #320, ADR-0025) +# (ADR-0025) # # sbx 0.38 startup-kit refusal handling, stale-probe footprint, and the # create-time extra-kit marker. All offline via the stubbed sbx. Each @test @@ -362,15 +362,11 @@ STUB chmod +x "$STUBDIR/sbx" printf 'agentrefreshbox\n' > "$STUBDIR/.sandbox_list" acq_provenance_write sbx agentrefreshbox opencode - # Simulate a READY built-in agent kit offline. The real selection gate - # (_acq_selected_builtin_kit_refs -> acq_agent_builtin_kit_ready) requires the - # pinned patterns bundle to actually ship a valid `opencode` agent kit, which - # it does not yet (enablement is deferred, quickstart #485). Stubbing - # readiness here keeps this refresh-path regression test meaningful without - # falsely enabling opencode in shipped behavior. - acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } + # Simulate a READY built-in agent kit offline. The real selection gate fetches + # the pinned patterns bundle; this refresh-path regression test should stay + # network-free while still proving agent-kit refresh wiring. acq_agent_builtin_kit_ready() { [ "$1" = "opencode" ]; } - _acq_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/%s\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR" "$1"; } + _acq_agent_builtin_kit_ref() { printf '%s#ref=%s&dir=%s/opencode\n' "$PATTERNS_KIT_REPO" "$PATTERNS_KIT_REF" "$PATTERNS_KIT_DIR"; } ( ACQ_FORCE_KIT_REAPPLY=1 acq_backend_ensure_kits_applied agentrefreshbox >/dev/null 2>&1 ) || true assert_regex "$(cat "$CALLS")" 'acq-kits/opencode' } diff --git a/test/bats/95-kit-ref-persistence.bats b/test/bats/95-kit-ref-persistence.bats index 20595d62..9e00e571 100644 --- a/test/bats/95-kit-ref-persistence.bats +++ b/test/bats/95-kit-ref-persistence.bats @@ -55,6 +55,43 @@ load 'helper' assert_regex "$ACQ_EXTRA_KITS" '/kits/b' } +@test "cli-kits: configured oci-engine catalog kit survives fresh-shell start" { + export XDG_CONFIG_HOME="$STUBDIR/xdg-oci-start" + mkdir -p "$XDG_CONFIG_HOME/acq" + printf 'extra_kits: oci-engine\n' > "$XDG_CONFIG_HOME/acq/config.yaml" + local okit="$STUBDIR/okit" + mkdir -p "$okit/files" + cat > "$okit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: oci-engine +files: + - path: /home/agent/oci-engine-marker + mode: "0644" + source: files/marker +SPEC + printf 'OCI_ENGINE_MARKER\n' > "$okit/files/marker" + printf 'ocistartbox\n' > "$STUBDIR/.msb_sandbox_list" + printf 'ocistartbox\n' > "$STUBDIR/.msb_running_list" + load_acq + ACQ_EXTRA_KITS="" + # shellcheck disable=SC2034 # read by sourced _acq_apply_configured_extra_kits + ACQ_EXTRA_KITS_FROM_ENV="" + _acq_apply_configured_extra_kits + _build_kit_list shell + acq_cli_kits_write msb ocistartbox + ACQ_EXTRA_KITS=""; ACQ_CLI_KITS=() + : > "$CALLS" + run env ACQ_MSB_KIT_LOCAL_DIR="$okit" ACQ_BACKEND=msb XDG_CONFIG_HOME="$XDG_CONFIG_HOME" "$ACQ" start ocistartbox + assert_success + assert_output --partial "started 'ocistartbox'" + local log; log=$(cat "$CALLS") + assert_regex "$log" 'ocistartbox:/home/agent/oci-engine-marker' + run cat "$(_acq_cli_kits_file msb ocistartbox)" + assert_output --partial 'dir=integrations/isolation/acq-kits/oci-engine' + assert_output --partial 'ref=56d1f5f49ef928f50e61aa64a93d618b3d21c415' +} + @test "cli-kits: backend keying — an sbx read does not see an msb record" { load_acq ACQ_CLI_KITS=("/kits/msbonly"); ACQ_EXTRA_KITS=""