Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion acq
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent|sandbox-name> [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 <name> -- CMD" >&2
echo " - To list sandboxes: acq ls" >&2
exit 2
Expand Down
49 changes: 49 additions & 0 deletions acq.backends/agents.sh
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions acq.backends/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
105 changes: 76 additions & 29 deletions acq.backends/msb.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand All @@ -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"
}

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"
}
16 changes: 9 additions & 7 deletions acq.backends/sbx.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"
}
35 changes: 19 additions & 16 deletions docs/BACKEND_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<agent>-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 <ref>`. Equivalent to the `acq run/create --image <ref>` 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 <tar> -t <ref>`, 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 |
Expand Down Expand Up @@ -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:<agent>-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:<agent>-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
Expand All @@ -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:<agent>-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

Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/adr/0011-msb-backend-and-neutral-kits.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<agent>-docker`, matching how `sbx run
> <agent>` 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

Expand Down
Loading