From 4e378527d7a6729d5356c77ea95fcd2e0ed72dab Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 27 Aug 2026 07:00:49 +0000 Subject: [PATCH] feat(msb): derive default sandbox-template image from agent token When no explicit image override is set, the msb backend now derives the sbx agent-template image name from the requested agent (docker.io/docker/sandbox-templates:-docker), matching how `sbx run ` selects its template, and falls back to shell-docker only if the derived image is not found. This shortens startup for agents like opencode whose template bakes in the binary. Fallback is conservative and live-verified against msb: it triggers only on registry not-found errors (manifest unknown) for an agent-derived image, never on auth/network failures or explicit image overrides. As the enabling cleanup, single-source the known-agent list into acq.backends/agents.sh (shared by both adapters) so adding an agent no longer means editing duplicated KNOWN_AGENTS lists and parallel case statements. A new bats suite enforces catalog/adapter parity. prime-agent is intentionally not added here. Refs: GSA-TTS/agentic-coding-quickstart#404 Refs: GSA-TTS/agentic-coding-quickstart#377 Co-authored-by: OpenCode [claude_4_8_opus] --- acq | 2 +- acq.backends/agents.sh | 49 ++++++++ acq.backends/common.sh | 14 +++ acq.backends/msb.sh | 105 +++++++++++++----- acq.backends/sbx.sh | 16 +-- docs/BACKEND_GUIDE.md | 35 +++--- docs/adr/0011-msb-backend-and-neutral-kits.md | 8 ++ docs/adr/0022-neutral-image-override.md | 21 +++- docs/howto/msb.md | 18 ++- docs/howto/sbx.md | 18 ++- scripts/test-acq-lib.sh | 8 +- test/bats/35-image-override.bats | 34 +++++- test/bats/36-agent-catalog.bats | 55 +++++++++ 13 files changed, 310 insertions(+), 73 deletions(-) create mode 100644 acq.backends/agents.sh create mode 100644 test/bats/36-agent-catalog.bats diff --git a/acq b/acq index 2755c57..bfd7908 100755 --- a/acq +++ b/acq @@ -1103,7 +1103,7 @@ case "$subcommand" in echo "acq: run: '$first' is not a known agent or an existing sandbox." >&2 fi echo " Usage: acq run [create-args] [-- CMD]" >&2 - echo " Known agents: ${KNOWN_AGENTS# }" >&2 + echo " Known agents: ${ACQ_KNOWN_AGENTS# }" >&2 echo " - To run a command in an existing sandbox: acq exec -- CMD" >&2 echo " - To list sandboxes: acq ls" >&2 exit 2 diff --git a/acq.backends/agents.sh b/acq.backends/agents.sh new file mode 100644 index 0000000..8b6e872 --- /dev/null +++ b/acq.backends/agents.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# +# acq.backends/agents.sh — shared agent catalog for acq backends +# +# This is the single source of truth for agent tokens accepted by acq dispatch. +# Keep backend-specific behavior (installation recipes, attach mechanics) in the +# adapters, but keep the token list and template naming convention here so sbx +# and msb cannot drift. + +# Space-padded for simple shell membership checks. +# shellcheck disable=SC2034 +ACQ_KNOWN_AGENTS=" claude codex copilot cursor docker-agent droid gemini kiro opencode shell " + +acq_known_agents() { + printf '%s\n' claude codex copilot cursor docker-agent droid gemini kiro opencode shell +} + +acq_is_known_agent() { + case "$ACQ_KNOWN_AGENTS" in + *" $1 "*) return 0 ;; + *) return 1 ;; + esac +} + +acq_agent_safe_token() { + case "$1" in + ""|*[!a-z-]*) return 1 ;; + *) return 0 ;; + esac +} + +acq_agent_template_image() { + local agent="${1:-shell}" + case "$agent" in + shell) printf '%s\n' "docker.io/docker/sandbox-templates:shell-docker" ;; + *) + acq_is_known_agent "$agent" || return 1 + acq_agent_safe_token "$agent" || return 1 + printf 'docker.io/docker/sandbox-templates:%s-docker\n' "$agent" + ;; + esac +} + +acq_agent_has_msb_install_recipe() { + case "$1" in + opencode) return 0 ;; + *) return 1 ;; + esac +} diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 0a65270..fac8f9d 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -95,6 +95,20 @@ KIT_SOURCE_PREFIXES=("$KIT_SOURCE_PREFIX") USAI_MODELS_URL="https://api.gsa.usai.gov/api/v1/models" KEY_MGMT_URL="https://console.gsa.usai.gov/key-management" +# Source the shared agent catalog (single source of truth for agent tokens and +# the sandbox-template image naming convention; issue #377). Both adapters also +# source it defensively so they work when loaded without common.sh (some tests +# source an adapter directly). Guard so a re-source is cheap. +if ! command -v acq_is_known_agent >/dev/null 2>&1; then + if [ -n "${ACQ_SCRIPT_DIR:-}" ] && [ -f "${ACQ_SCRIPT_DIR}/acq.backends/agents.sh" ]; then + # shellcheck disable=SC1091 + . "${ACQ_SCRIPT_DIR}/acq.backends/agents.sh" + else + # shellcheck source=acq.backends/agents.sh + . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/agents.sh" + fi +fi + # Source the neutral-kit translation layer (spec.yaml parser + shortcut # dispatch). ACQ_SCRIPT_DIR is exported by the acq entry point; in the offline # test harness it is set before common.sh is sourced. diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index c927402..7908232 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -65,6 +65,15 @@ ACQ_BACKEND_CAN_RESUME=1 # msb stop / msb start preserve state # shellcheck disable=SC2034 ACQ_BACKEND_SUPPORTS_CREDENTIAL_REWRITE=1 # msb --secret ENV@HOST + --tls-intercept +# Shared agent catalog (issue #377). common.sh normally sources this, but some +# tests source this adapter directly; guard so a re-source is cheap and so the +# catalog helpers (acq_is_known_agent, acq_agent_template_image, …) are always +# defined when this file's functions run. +if ! command -v acq_is_known_agent >/dev/null 2>&1; then + # shellcheck source=acq.backends/agents.sh + . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/agents.sh" +fi + # Minimum msb version required. Two reasons pin this to 0.6.9: # 1. 0.6.8 is the first release with the `--net-default-egress` / # `--net-default-ingress` split that the balanced-egress baseline (on by @@ -110,11 +119,15 @@ ACQ_MSB_IMAGE="${ACQ_MSB_IMAGE:-}" _ACQ_MSB_DEFAULT_IMAGE="docker.io/docker/sandbox-templates:shell-docker" _ACQ_MSB_IMAGE_NOTICE_SHOWN=0 -# _acq_msb_resolve_image — echo the OCI image `msb create` should use, applying -# the ADR-0022 precedence: explicit ACQ_MSB_IMAGE > neutral --image/ACQ_IMAGE > -# built-in default. Prints a one-time notice if BOTH the backend var and the -# neutral image are set (backend var wins). Idempotent notice (once per process). +# _acq_msb_resolve_image AGENT — set the OCI image `msb create` should use, +# applying the ADR-0022 precedence: explicit ACQ_MSB_IMAGE > neutral +# --image/ACQ_IMAGE > agent-derived sandbox-template image > built-in default. +# Prints a one-time notice if BOTH the backend var and the neutral image are set +# (backend var wins). Idempotent notice (once per process). +_ACQ_MSB_RESOLVED_IMAGE="" +_ACQ_MSB_RESOLVED_IMAGE_SOURCE="" _acq_msb_resolve_image() { + local agent="${1:-shell}" local neutral="" if command -v acq_resolve_neutral_image >/dev/null 2>&1; then neutral=$(acq_resolve_neutral_image) @@ -128,14 +141,42 @@ _acq_msb_resolve_image() { echo "acq(msb): (most-specific wins; see ADR-0022). Unset ACQ_MSB_IMAGE to use the" >&2 echo "acq(msb): neutral image '$neutral'." >&2 fi - printf '%s\n' "$ACQ_MSB_IMAGE" + _ACQ_MSB_RESOLVED_IMAGE="$ACQ_MSB_IMAGE" + _ACQ_MSB_RESOLVED_IMAGE_SOURCE="backend" return 0 fi if [ -n "$neutral" ]; then - printf '%s\n' "$neutral" + _ACQ_MSB_RESOLVED_IMAGE="$neutral" + _ACQ_MSB_RESOLVED_IMAGE_SOURCE="neutral" return 0 fi - printf '%s\n' "$_ACQ_MSB_DEFAULT_IMAGE" + if _ACQ_MSB_RESOLVED_IMAGE=$(acq_agent_template_image "$agent" 2>/dev/null) \ + && [ "$_ACQ_MSB_RESOLVED_IMAGE" != "$_ACQ_MSB_DEFAULT_IMAGE" ]; then + _ACQ_MSB_RESOLVED_IMAGE_SOURCE="agent-default" + return 0 + fi + _ACQ_MSB_RESOLVED_IMAGE="$_ACQ_MSB_DEFAULT_IMAGE" + _ACQ_MSB_RESOLVED_IMAGE_SOURCE="builtin-default" +} + +# _acq_msb_image_not_found_error STDERR — 0 if `msb create`'s error text means +# the image REF does not exist (so an agent-derived default may fall back to the +# shell image), 1 otherwise. Live-verified against msb on Docker Hub and ghcr.io: +# BOTH a nonexistent Docker Hub tag AND a private/nonexistent ghcr.io repo report +# error: image error: registry error: ... OCI API errors: [OCI API error: manifest unknown] +# i.e. the registry returns `manifest unknown` for not-found regardless of +# whether the repo is private. We therefore treat `manifest unknown` (and the +# other classic not-found phrasings) as fall-back-eligible, but NOT auth/network +# failures (`unauthorized`, TLS, connection refused, …): those are real problems +# with the requested image that must surface, not be papered over by a fallback. +# The auth/network deny-list is checked FIRST so a message that somehow carries +# both never falls back. +_acq_msb_image_not_found_error() { + case "$1" in + *unauthorized*|*Unauthorized*|*authentication\ required*|*denied*|*Denied*|*forbidden*|*Forbidden*|*TLS*|*tls*|*timeout*|*connection\ refused*|*no\ route*) return 1 ;; + *manifest\ unknown*|*name\ unknown*|*not\ found*|*Not\ found*|*No\ such\ image*|*repository\ does\ not\ exist*) return 0 ;; + *) return 1 ;; + esac } # Prerequisite tools the pinned four kits need at runtime, expected to be @@ -311,10 +352,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:-}" -# Agents recognized by acq's run dispatch (mirrors sbx.sh KNOWN_AGENTS). -# shellcheck disable=SC2034 -KNOWN_AGENTS=" claude codex copilot cursor docker-agent droid gemini kiro opencode shell " - # Agent binary install. # --------------------------------------------------------------------------- # Unlike sbx (whose agent templates BAKE the agent binary into the image), msb @@ -2261,11 +2298,13 @@ acq_backend_provision() { local _volrecs="" # Resolve the OCI image ONCE per provision (ADR-0022): explicit ACQ_MSB_IMAGE - # wins over the neutral --image/ACQ_IMAGE, which wins over the built-in default. - # Use this local everywhere below instead of $ACQ_MSB_IMAGE so the neutral knob - # and the one-time precedence notice are honored consistently. - local _msb_image - _msb_image=$(_acq_msb_resolve_image) + # wins over the neutral --image/ACQ_IMAGE, which wins over an agent-derived + # sandbox-template image, which wins over the built-in shell fallback. Use this + # local everywhere below instead of $ACQ_MSB_IMAGE so the neutral knob and the + # one-time precedence notice are honored consistently. + _acq_msb_resolve_image "$agent" + local _msb_image="$_ACQ_MSB_RESOLVED_IMAGE" + local _msb_image_source="$_ACQ_MSB_RESOLVED_IMAGE_SOURCE" # Optional pull policy (ACQ_MSB_PULL): forwarded to `msb create --pull`. # `msb create` reads an image REF and by default pulls if-missing from a @@ -2651,10 +2690,27 @@ EOF # below ever run. acq_debug "msb create --name $name ${create_flags[*]} $_msb_image" local _create_rc=0 + local _create_output="" acq_debug "msb create: invoking (this returns fast; guest boots in background)" acq_spin_start "Creating sandbox '$name'" - msb create --name "$name" "${create_flags[@]}" "$_msb_image" || _create_rc=$? + _create_output=$(msb create --name "$name" "${create_flags[@]}" "$_msb_image" 2>&1) || _create_rc=$? acq_spin_stop "Creating sandbox '$name'" + [ -z "$_create_output" ] || printf '%s\n' "$_create_output" >&2 + if [ "$_create_rc" -ne 0 ] && [ "$_msb_image_source" = "agent-default" ] \ + && _acq_msb_image_not_found_error "$_create_output" \ + && ! acq_backend_exists "$name"; then + echo "acq(msb): agent-specific image '$_msb_image' was not found;" >&2 + echo "acq(msb): falling back to '$_ACQ_MSB_DEFAULT_IMAGE'." >&2 + _msb_image="$_ACQ_MSB_DEFAULT_IMAGE" + _msb_image_source="builtin-default" + _create_rc=0 + _create_output="" + acq_debug "msb create --name $name ${create_flags[*]} $_msb_image" + acq_spin_start "Creating sandbox '$name'" + _create_output=$(msb create --name "$name" "${create_flags[@]}" "$_msb_image" 2>&1) || _create_rc=$? + acq_spin_stop "Creating sandbox '$name'" + [ -z "$_create_output" ] || printf '%s\n' "$_create_output" >&2 + fi acq_debug "msb create: returned rc=${_create_rc}" # Clear the transient secret env vars immediately after create reads them # (runs on both success and failure so the exported key never lingers). @@ -2831,10 +2887,7 @@ EOF # 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() { - case "$1" in - opencode) return 0 ;; - *) return 1 ;; - esac + acq_agent_has_msb_install_recipe "$1" } # _acq_msb_safe_agent_token AGENT -> 0 if AGENT is a safe agent token to @@ -2844,10 +2897,7 @@ _acq_msb_agent_has_install_recipe() { # `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() { - case "$1" in - ""|*[!a-z-]*) return 1 ;; - *) return 0 ;; - esac + acq_agent_safe_token "$1" } # --------------------------------------------------------------------------- @@ -4744,8 +4794,5 @@ acq_backend_doctor() { # --------------------------------------------------------------------------- is_known_agent() { - case "$KNOWN_AGENTS" in - *" $1 "*) return 0 ;; - *) return 1 ;; - esac + acq_is_known_agent "$1" } diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 04bb575..27a228d 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -30,6 +30,14 @@ ACQ_BACKEND_CAN_RESUME=1 # shellcheck disable=SC2034 ACQ_BACKEND_SUPPORTS_CREDENTIAL_REWRITE=1 +# Shared agent catalog (issue #377). common.sh normally sources this, but some +# tests source this adapter directly; guard so a re-source is cheap and so the +# catalog helpers (acq_is_known_agent, …) are always defined. +if ! command -v acq_is_known_agent >/dev/null 2>&1; then + # shellcheck source=acq.backends/agents.sh + . "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/agents.sh" +fi + # Minimum sbx version required. # # Bumped 0.35.0 -> 0.38.0: the neutral-kit translator now emits the sbx **v2 kit @@ -52,9 +60,6 @@ USAI_KIT_CONFIG_PATH="/home/agent/usai-config/opencode.jsonc" # are materialized for this run. ACQ_SBX_KIT_CACHE="${ACQ_SBX_KIT_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/acq/sbx-kits}" -# Agents recognized by `sbx run`. -KNOWN_AGENTS=" claude codex copilot cursor docker-agent droid gemini kiro opencode shell " - # Module-scope flag: set to 1 once the ssh-agent trust-boundary notice has been # printed, so it appears at most once per process. See ADR-0021. _ACQ_SBX_SSH_AGENT_NOTICE_SHOWN=0 @@ -1538,8 +1543,5 @@ acq_backend_doctor() { # --------------------------------------------------------------------------- is_known_agent() { - case "$KNOWN_AGENTS" in - *" $1 "*) return 0 ;; - *) return 1 ;; - esac + acq_is_known_agent "$1" } diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 9181199..56d74a3 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -175,7 +175,7 @@ Tunables: | Env var | Default | Meaning | |---------|---------|---------| -| `ACQ_MSB_IMAGE` | `docker.io/docker/sandbox-templates:shell-docker` | Base OCI image (the sbx agent-template: ships the `agent` user + passwordless sudo, node/git/curl/ca-certificates, and an agent-writable npm global prefix). A custom override must be pullable and ship these prerequisites. **Precedence (ADR-0022):** an explicitly set `ACQ_MSB_IMAGE` wins over the backend-neutral `--image`/`ACQ_IMAGE` (a one-time notice is printed); if only the neutral knob is set, it is used; otherwise this default. | +| `ACQ_MSB_IMAGE` | (unset) | Backend-specific base OCI image override. A custom override must be pullable and ship the base-image prerequisites. **Precedence (ADR-0022):** an explicitly set `ACQ_MSB_IMAGE` wins over the backend-neutral `--image`/`ACQ_IMAGE` (a one-time notice is printed); if only the neutral knob is set, it is used; otherwise msb derives `docker.io/docker/sandbox-templates:-docker` for known agents and falls back to `docker.io/docker/sandbox-templates:shell-docker` if that derived image is not found. | | `ACQ_IMAGE` | (unset) | Backend-**neutral** base image (ADR-0022). On msb it feeds `ACQ_MSB_IMAGE` (above); on sbx it maps to `sbx create --template `. Equivalent to the `acq run/create --image ` flag (the flag wins over the env var). See [Custom base image](#custom-base-image---image--acq_image). | | `ACQ_MSB_PULL` | (unset → msb default `if-missing`) | Image pull policy forwarded to `msb create --pull` (`always` \| `if-missing` \| `never`). `msb create` treats the image as a **registry** reference; a locally-built/registry-less image must first be imported with `msb image load -i -t `, then created with `ACQ_MSB_PULL=never` so msb uses the cache instead of trying to pull it. | | `ACQ_MSB_SKIP_PREREQ_CHECK` | (unset) | Skip the base-image prerequisite presence check | @@ -465,21 +465,22 @@ on each installed backend with `--image`, and confirms the custom image booted). ### Base image and prerequisites Unlike sbx (whose agent templates supply the image via a template mechanism), -the msb backend runs an OCI image directly and layers the kits on top. By -default it uses the **same** sbx agent-template image -(`docker/sandbox-templates:shell-docker`); a custom override may be any OCI -image. The four pinned kits need +the msb backend runs an OCI image directly and layers the kits on top. When no +explicit image override is set, msb derives the same sbx agent-template image +name from the requested agent (`docker/sandbox-templates:-docker`) and +falls back to `docker/sandbox-templates:shell-docker` if that derived image is +not found. A custom override may be any OCI image. The four pinned kits need `node` (usai merge), `git` (playbook clone + signing), `curl`, and `ca-certificates`/`update-ca-certificates` (zscaler) **already present in the base image**. These are **not** installed at runtime: the kit network rules lock egress to the kits' own hosts (`api.gsa.usai.gov`, `github.com`, `codeload.github.com`), so a -package mirror is unreachable during provision. The default -`docker/sandbox-templates:shell-docker` image (the sbx agent-template) already -ships all four tools and pulls from Docker Hub without auth. Before applying -kits, the adapter **verifies** the tools are present and warns if any are -missing (it does not try to install them). A custom override must ship them too. +package mirror is unreachable during provision. Docker's +`sandbox-templates:-docker` images already ship all four tools and pull +from Docker Hub without auth. Before applying kits, the adapter **verifies** the +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 @@ -494,10 +495,11 @@ 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). -**The base-image contract (Docker `shell-docker`).** sbx's templates are built on -`docker/sandbox-templates:shell-docker` — which acq now also uses as the default -`ACQ_MSB_IMAGE`, so msb matches sbx by construction. The synthesis below exists -only for a plain-OCI **override**: on the default image it is a short-circuit. +**The base-image contract (Docker sandbox templates).** sbx's templates are built +on `docker/sandbox-templates:-docker`, and acq now derives the same image +name for msb when no explicit image override is set. The synthesis below exists +only for a plain-OCI **override** or fallback image: on Docker's sandbox-template +images it is a short-circuit. #### Base image requirements @@ -521,8 +523,9 @@ so a base image does **not** need a container engine baked in — only a support 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. -**Build on `docker/sandbox-templates:shell-docker` to get all of these for free** -— it is the default `ACQ_MSB_IMAGE`, so msb matches sbx out of the box. +**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 +explicit image override is set. For a plain-OCI override (e.g. `node:22-bookworm`, which has `node` at uid 1000 and no `agent`, no sudoers rule) that meets none of the first three, the msb adapter diff --git a/docs/adr/0011-msb-backend-and-neutral-kits.md b/docs/adr/0011-msb-backend-and-neutral-kits.md index 9d67a2d..3d83029 100644 --- a/docs/adr/0011-msb-backend-and-neutral-kits.md +++ b/docs/adr/0011-msb-backend-and-neutral-kits.md @@ -23,6 +23,14 @@ supersedes: [] > `node:22-bookworm` as the default, read it as the override example. See > `docs/BACKEND_GUIDE.md` §"Base image requirements" for the current contract. > (The original decision text is preserved unchanged for the historical record.) +> +> **Update (2026-08-27):** When no explicit image override is set, msb now +> derives the sbx agent-template image name from the requested agent +> (`docker.io/docker/sandbox-templates:-docker`, matching how `sbx run +> ` selects its template) and falls back to +> `docker.io/docker/sandbox-templates:shell-docker` only if the derived image is +> not found. This shortens startup for agents (e.g. `opencode`) whose template +> already bakes in the agent binary. See ADR-0022 and `docs/BACKEND_GUIDE.md`. ## Context and Problem Statement diff --git a/docs/adr/0022-neutral-image-override.md b/docs/adr/0022-neutral-image-override.md index 0f44192..560e4e1 100644 --- a/docs/adr/0022-neutral-image-override.md +++ b/docs/adr/0022-neutral-image-override.md @@ -76,7 +76,8 @@ Both are resolved by a single helper, `acq_resolve_neutral_image` (in ### Precedence -`--image` flag **>** `ACQ_IMAGE` env **>** backend-specific var / default. +`ACQ_MSB_IMAGE` backend var **>** `--image` flag **>** `ACQ_IMAGE` env **>** +agent-derived backend default. When a backend-specific var is **also** set (today only `ACQ_MSB_IMAGE`), the **most-specific backend var wins**, and `acq` prints a one-time notice so the @@ -84,6 +85,13 @@ override is not silent. Rationale: a user who set `ACQ_MSB_IMAGE` did so deliberately for that backend; the neutral knob is the broader default and should yield to the narrower one. +For msb, the agent-derived default uses the same sandbox-template naming +convention as sbx for known agent tokens: +`docker.io/docker/sandbox-templates:-docker`. If that derived image is +not found, acq retries once with +`docker.io/docker/sandbox-templates:shell-docker`. Explicit image failures do not +fall back silently. + ### Per-backend mapping | Backend | Neutral image maps to | Mechanism | @@ -189,12 +197,13 @@ image reference — not a hardcoded list of hosts: ## Validation -- **Offline (`scripts/test-acq`, no Docker/KVM):** asserts `ACQ_IMAGE` and +- **Offline (`scripts/test-acq-bats`, no Docker/KVM):** asserts `ACQ_IMAGE` and `--image` reach `msb create` as the image positional; that `ACQ_MSB_IMAGE` wins over `ACQ_IMAGE` (with the notice); that a neutral image injects `sbx create --template `; that a user-supplied `--template` is not - double-injected; and that with no image set, sbx omits `--template` and msb - uses its default. + double-injected; and that with no image set, sbx omits `--template` while msb + derives an agent-specific sandbox-template image and falls back to + `shell-docker` only when that derived image is not found. - **Live (`scripts/verify-image-override`, host with a sandbox-capable runtime):** builds a tiny image `FROM docker/sandbox-templates:shell` (via `docker` or `podman` — Docker Desktop is not required), imports it into each @@ -215,5 +224,5 @@ image reference — not a hardcoded list of hosts: - Docker Sandboxes custom templates: -- Related code: `acq`, `acq.backends/common.sh`, `acq.backends/sbx.sh`, - `acq.backends/msb.sh`, `docs/BACKEND_GUIDE.md`. +- Related code: `acq`, `acq.backends/agents.sh`, `acq.backends/common.sh`, + `acq.backends/sbx.sh`, `acq.backends/msb.sh`, `docs/BACKEND_GUIDE.md`. diff --git a/docs/howto/msb.md b/docs/howto/msb.md index 28691ba..2acc71e 100644 --- a/docs/howto/msb.md +++ b/docs/howto/msb.md @@ -146,14 +146,20 @@ The sandbox boots and you land in the agent environment. ### Other supported agents ```bash -acq run claude . # Claude Code -acq run copilot . # GitHub Copilot -acq run cursor . # Cursor -acq run codex . # OpenAI Codex -acq run gemini . # Google Gemini -acq run shell . # Just a shell (no agent) +acq run claude . # Claude Code +acq run codex . # OpenAI Codex +acq run copilot . # GitHub Copilot +acq run cursor . # Cursor +acq run docker-agent . # Docker agent +acq run droid . # Droid +acq run gemini . # Google Gemini +acq run kiro . # Kiro +acq run shell . # Just a shell (no agent) ``` +This list is sourced from `acq.backends/agents.sh`; `prime-agent` is not added by +this change. + ### Create with a custom name ```bash diff --git a/docs/howto/sbx.md b/docs/howto/sbx.md index 3054f45..d2e3edb 100644 --- a/docs/howto/sbx.md +++ b/docs/howto/sbx.md @@ -283,14 +283,20 @@ The sandbox will start and you'll be inside the agent environment. ### Other Supported Agents ```bash -acq run claude . # Claude Code -acq run copilot . # GitHub Copilot -acq run cursor . # Cursor -acq run codex . # OpenAI Codex -acq run gemini . # Google Gemini -acq run shell . # Just a shell (no agent) +acq run claude . # Claude Code +acq run codex . # OpenAI Codex +acq run copilot . # GitHub Copilot +acq run cursor . # Cursor +acq run docker-agent . # Docker agent +acq run droid . # Droid +acq run gemini . # Google Gemini +acq run kiro . # Kiro +acq run shell . # Just a shell (no agent) ``` +This list is sourced from `acq.backends/agents.sh`; `prime-agent` is not added by +this change. + ### Create with Custom Name ```bash diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index ea2bc48..233a6c9 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -209,7 +209,13 @@ case "$_msb_sub" in if [ "${STUB_MSB_DOCTOR_FIXABLE:-0}" = "1" ] && [ -f "$STUBDIR/.msb_fixed" ]; then exit 0; fi if [ "${STUB_MSB_DOCTOR_UNFIT:-0}" = "1" ] || [ "${STUB_MSB_DOCTOR_FIXABLE:-0}" = "1" ]; then exit 1; fi exit 0 ;; - create) : >"$STUBDIR/.msb_created" ;; + create) + _image="${@: -1}" + if [ -n "${STUB_MSB_CREATE_FAIL_IMAGE:-}" ] && [ "$_image" = "$STUB_MSB_CREATE_FAIL_IMAGE" ]; then + printf '%s\n' "${STUB_MSB_CREATE_FAIL_MESSAGE:-manifest unknown}" >&2 + exit "${STUB_MSB_CREATE_FAIL_RC:-1}" + fi + : >"$STUBDIR/.msb_created" ;; inspect) # `msb inspect --format json` — emit a create-time published-ports # JSON fixture if the test planted one, else nothing (models an absent field diff --git a/test/bats/35-image-override.bats b/test/bats/35-image-override.bats index 6061a4b..de7c710 100644 --- a/test/bats/35-image-override.bats +++ b/test/bats/35-image-override.bats @@ -93,11 +93,43 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } assert_output --partial 'most-specific wins' } -@test "image(msb): no image set -> default shell-docker image" { +@test "image(msb): shell agent defaults to shell-docker image" { _msb_create -- create shell "$IMGPROJ" assert_regex "$(_create_line msb)" 'docker\.io/docker/sandbox-templates:shell-docker' } +@test "image(msb): known agent defaults to matching sandbox-template image" { + _msb_create -- create opencode --name imgopencode "$IMGPROJ" + local line; line=$(_create_line msb) + assert_regex "$line" 'docker\.io/docker/sandbox-templates:opencode-docker' + refute_regex "$line" 'docker\.io/docker/sandbox-templates:shell-docker' +} + +@test "image(msb): missing agent-specific default falls back to shell-docker" { + # The failure message is the VERBATIM msb stderr for a nonexistent Docker Hub + # tag (live-verified): the registry returns `manifest unknown`. This proves + # _acq_msb_image_not_found_error matches the real wording, not a paraphrase. + _msb_create \ + STUB_MSB_CREATE_FAIL_IMAGE=docker.io/docker/sandbox-templates:opencode-docker \ + STUB_MSB_CREATE_FAIL_MESSAGE="error: image error: registry error: Registry error: url https://index.docker.io/v2/docker/sandbox-templates/manifests/opencode-docker, envelope: OCI API errors: [OCI API error: manifest unknown]" \ + -- create opencode --name imgfallback "$IMGPROJ" + local log; log=$(cat "$CALLS") + assert_regex "$log" 'docker\.io/docker/sandbox-templates:opencode-docker' + assert_regex "$log" 'docker\.io/docker/sandbox-templates:shell-docker' + assert_output --partial 'falling back' +} + +@test "image(msb): auth failure for agent-specific default does not fall back" { + _msb_create \ + STUB_MSB_CREATE_FAIL_IMAGE=docker.io/docker/sandbox-templates:opencode-docker \ + STUB_MSB_CREATE_FAIL_MESSAGE="error: image error: registry error: unauthorized: authentication required" \ + -- create opencode --name imgauthfail "$IMGPROJ" + local log; log=$(cat "$CALLS") + assert_regex "$log" 'docker\.io/docker/sandbox-templates:opencode-docker' + refute_regex "$log" 'docker\.io/docker/sandbox-templates:shell-docker' + refute_output --partial 'falling back' +} + # sbx image tests: store a global usai key + seed the proxy fixture. _sbx_create() { # CREATE_ARGS... (with optional leading ENV via `env`) printf 'sk-test\n' | env ACQ_BACKEND=sbx "$ACQ" secret set -g usai >/dev/null 2>&1 || true diff --git a/test/bats/36-agent-catalog.bats b/test/bats/36-agent-catalog.bats new file mode 100644 index 0000000..7e82600 --- /dev/null +++ b/test/bats/36-agent-catalog.bats @@ -0,0 +1,55 @@ +#!/usr/bin/env bats +# +# 36-agent-catalog.bats — shared agent catalog parity checks +# +# shellcheck shell=bats + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +@test "agents: shared catalog exposes supported tokens" { + run bash -c '. "'"$REPO_ROOT"'/acq.backends/agents.sh"; acq_known_agents' + assert_success + assert_output $'claude\ncodex\ncopilot\ncursor\ndocker-agent\ndroid\ngemini\nkiro\nopencode\nshell' +} + +@test "agents: sbx and msb dispatch use the shared catalog" { + run bash -c ' + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/sbx.sh" + for agent in $(acq_known_agents); do is_known_agent "$agent" || exit 1; done + if is_known_agent notanagent; then exit 2; fi + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + for agent in $(acq_known_agents); do is_known_agent "$agent" || exit 3; done + if is_known_agent notanagent; then exit 4; fi + exit 0 + ' + assert_success +} + +@test "agents: template image naming follows sandbox-template convention" { + run bash -c ' + . "'"$REPO_ROOT"'/acq.backends/agents.sh" + acq_agent_template_image opencode + acq_agent_template_image shell + ' + assert_success + assert_output $'docker.io/docker/sandbox-templates:opencode-docker\ndocker.io/docker/sandbox-templates:shell-docker' +} + +@test "agents: known agent list is defined only in the shared catalog" { + run bash -c ' + hits=$(grep -lE "^[[:space:]]*(ACQ_)?KNOWN_AGENTS=" \ + "'"$REPO_ROOT"'/acq.backends/sbx.sh" \ + "'"$REPO_ROOT"'/acq.backends/msb.sh" 2>/dev/null || true) + [ -z "$hits" ] + ' + assert_success +} + +@test "agents: prime-agent is intentionally not supported by this change" { + run bash -c '. "'"$REPO_ROOT"'/acq.backends/agents.sh"; acq_is_known_agent prime-agent' + assert_failure +}