From 17afe2d4b7c9833cca38ea6eb197c0d8f1ecc2da Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 14 Oct 2026 09:26:19 +0000 Subject: [PATCH 1/6] feat(opencode): use generic devenv base by default Move default OpenCode provisioning on both backends to the pinned generic devenv base with its required agent kit. Preserve explicit image and template overrides. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq | 4 +- acq.backends/agents.sh | 4 + acq.backends/common.sh | 17 +++- acq.backends/msb.sh | 18 +++- acq.backends/sbx.sh | 24 ++++- docs/BACKEND_GUIDE.md | 102 ++++++++++----------- docs/KNOWN_FAILURE_MODES.md | 23 +++++ docs/adr/0030-agent-kits-on-devenv-base.md | 12 ++- scripts/verify-backends | 8 +- test/bats/140-opencode-migration-gate.bats | 52 ++++++++--- test/bats/30-dispatch-routing.bats | 5 +- test/bats/35-image-override.bats | 36 ++------ test/bats/90-sbx-startup-kit.bats | 11 +-- 13 files changed, 195 insertions(+), 121 deletions(-) diff --git a/acq b/acq index 68daa55c..f8a9a0b5 100755 --- a/acq +++ b/acq @@ -1303,11 +1303,11 @@ case "$subcommand" in [ -n "$ws" ] || ws="$PWD" warn_if_no_git_identity "$ws" - # opencode (since v1.15.1) fetches its binary in a postinstall step that + # Legacy/custom opencode images can fetch their binary in a postinstall step that # some sandbox images skip, so a fresh launch fails with "postinstall # script was not run". Run it here — on any backend — before attaching an # opencode agent. Best-effort; never blocks the run. - if [ "$first" = "opencode" ]; then + if [ "$first" = "opencode" ] && [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" != "1" ]; then ensure_opencode_postinstall "$local_name" fi diff --git a/acq.backends/agents.sh b/acq.backends/agents.sh index df6fddca..9883eeea 100644 --- a/acq.backends/agents.sh +++ b/acq.backends/agents.sh @@ -11,6 +11,10 @@ # shellcheck disable=SC2034 ACQ_KNOWN_AGENTS=" claude codex copilot cursor docker-agent droid gemini kiro opencode shell " +# Derived from ghcr.io/gsa-tts/agentic-coding-patterns/devenv-shell:2.0.0 on +# 2026-10-14; covers published Linux amd64 and arm64 manifests. +ACQ_DEFAULT_OPENCODE_DEVENV_IMAGE="ghcr.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7" + acq_known_agents() { printf '%s\n' claude codex copilot cursor docker-agent droid gemini kiro opencode shell } diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 9d3223e2..8749a67b 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -904,8 +904,9 @@ _acq_selected_support_kit_refs() { _acq_selected_builtin_kit_refs() { local agent="${1:-}" kit _acq_selected_support_kit_refs || return 1 - if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ - && acq_is_known_agent "$agent" && acq_agent_builtin_kit_ready "$agent"; then + if [ -n "$agent" ] && acq_is_known_agent "$agent" \ + && { [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] || [ "${ACQ_REQUIRED_AGENT_KIT:-}" = "$agent" ]; } \ + && acq_agent_builtin_kit_ready "$agent"; then kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 printf '%s\n' "$kit" fi @@ -946,8 +947,16 @@ _build_kit_list() { done < <(_acq_selected_support_kit_refs) # shellcheck disable=SC2034 # read by adapters/tests after _build_kit_list ACQ_BUILTIN_SUPPORT_KIT_COUNT="${#KITS[@]}" - if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] && [ -n "$agent" ] \ - && acq_is_known_agent "$agent" && acq_agent_builtin_kit_ready "$agent"; then + if [ -n "${ACQ_REQUIRED_AGENT_KIT:-}" ] \ + && [ "${ACQ_REQUIRED_AGENT_KIT:-}" = "$agent" ] \ + && ! acq_agent_builtin_kit_ready "$agent"; then + echo "acq: required built-in ${agent} kit is unavailable or has invalid agent metadata." >&2 + echo " Refusing the generic default image because it has no bundled ${agent} binary." >&2 + return 1 + fi + if [ -n "$agent" ] && acq_is_known_agent "$agent" \ + && { [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] || [ "${ACQ_REQUIRED_AGENT_KIT:-}" = "$agent" ]; } \ + && acq_agent_builtin_kit_ready "$agent"; then kit=$(_acq_agent_builtin_kit_ref "$agent") || return 1 KITS+=("$kit") fi diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index e1fd642f..a683cdf3 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -161,7 +161,7 @@ MSB_ROLLBACK_VERSION="0.6.18" # duplicate snapshots exist. See ADR-0032. MSB_FIXED_VERSION="0.7.3" -# Default OCI image for provisioned sandboxes. We default to the SAME image +# Default OCI image for provisioned non-OpenCode sandboxes. We default to the SAME image # family sbx uses: `docker/sandbox-templates:shell-docker`, an Ubuntu-based # agent template. It ALREADY ships the non-root `agent` user (with passwordless # sudo), node, git, curl, and ca-certificates (the four kits' runtime @@ -192,7 +192,7 @@ _ACQ_MSB_IMAGE_NOTICE_SHOWN=0 # _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. +# --image/ACQ_IMAGE > generic OpenCode base > 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="" @@ -221,6 +221,11 @@ _acq_msb_resolve_image() { _ACQ_MSB_RESOLVED_IMAGE_SOURCE="neutral" return 0 fi + if [ "$agent" = "opencode" ]; then + _ACQ_MSB_RESOLVED_IMAGE="$ACQ_DEFAULT_OPENCODE_DEVENV_IMAGE" + _ACQ_MSB_RESOLVED_IMAGE_SOURCE="generic-opencode-default" + return 0 + fi 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" @@ -3108,6 +3113,13 @@ acq_backend_provision() { _acq_msb_resolve_image "$agent" local _msb_image="$_ACQ_MSB_RESOLVED_IMAGE" local _msb_image_source="$_ACQ_MSB_RESOLVED_IMAGE_SOURCE" + if [ "$_msb_image_source" = "generic-opencode-default" ]; then + ACQ_REQUIRED_AGENT_KIT="opencode" + ACQ_DEFAULT_OPENCODE_GENERIC=1 + else + ACQ_REQUIRED_AGENT_KIT="" + ACQ_DEFAULT_OPENCODE_GENERIC="" + fi # 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 @@ -3140,7 +3152,7 @@ acq_backend_provision() { # Zscaler CA trust FIRST so later network-fetching kits (playbook clone, USAi # validation) succeed behind a TLS-intercepting proxy (e.g. Zscaler). local kitref kitdir - _acq_msb_select_kits "$agent" + _acq_msb_select_kits "$agent" || return 1 local kits=("${KITS[@]}") for kitref in "${kits[@]}"; do diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 1c17444e..b6a33a7f 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -510,6 +510,14 @@ acq_backend_provision() { if command -v acq_resolve_neutral_image >/dev/null 2>&1; then _neutral_image=$(acq_resolve_neutral_image) fi + if [ "$agent" = "opencode" ] && [ "$_user_template" -eq 0 ] && [ -z "$_neutral_image" ]; then + _tf=(--template "$ACQ_DEFAULT_OPENCODE_DEVENV_IMAGE") + ACQ_REQUIRED_AGENT_KIT="opencode" + ACQ_DEFAULT_OPENCODE_GENERIC=1 + else + ACQ_REQUIRED_AGENT_KIT="" + ACQ_DEFAULT_OPENCODE_GENERIC="" + fi if [ -n "$_neutral_image" ]; then if [ "$_user_template" -eq 1 ]; then echo "acq(sbx): both --image ('$_neutral_image') and an explicit --template were given;" >&2 @@ -547,6 +555,7 @@ acq_backend_provision() { [ "${ACQ_CLONE:-0}" = "1" ] && _ef+=(--env ACQ_CLONE=1) fi + _build_kit_list "$agent" || return 1 local kf=() while IFS= read -r line; do kf+=("$line"); done < <(_acq_sbx_kit_flags) local _git_identity_kit="" @@ -576,6 +585,9 @@ acq_backend_provision() { fi acq_spin_stop "Waiting for kit startup in '$name'" acq_provenance_write sbx "$name" "$agent" || true + if [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" = "1" ]; then + acq_host_config_write sbx "$name" generic-opencode 1 || true + fi acq_workspace_record_write sbx "$name" "$_primary_ws" || true _acq_sbx_seed_extra_kit_marker "$name" # Backend parity (ADR-0030): sbx DELIVERS ~/.rc.d/*.sh via kit files[] but, @@ -718,6 +730,11 @@ acq_backend_workspace_for() { _acq_sbx_attach_command() { local name="$1" agent agent=$(acq_backend_recorded_agent "$name") + if [ "$agent" = "opencode" ] \ + && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then + acq_agent_kit_entrypoint opencode + return 0 + fi [ -n "$agent" ] || agent="bash" printf '%s\n' "$agent" } @@ -794,7 +811,12 @@ acq_backend_attach() { sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "$agent" fi else - sbx run --name "$name" + if [ "$agent" = "opencode" ] \ + && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then + sbx run --name "$name" -- "$agent" + else + sbx run --name "$name" + fi fi fi } diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index f5411820..181672f9 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -188,7 +188,7 @@ Tunables: | Env var | Default | Meaning | |---------|---------|---------| -| `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 (with `claude` → `claude-code-docker` and `cursor` → `cursor-agent-docker`) and falls back to `docker.io/docker/sandbox-templates:shell-docker` if that derived image is not found. | +| `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). With no override, OpenCode uses the ADR-0030 pinned generic devenv base; other agents keep their existing derived-template defaults. | | `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 | @@ -564,16 +564,40 @@ Verify the whole path live on a sandbox-capable host with `./scripts/verify-image-override` (builds a tiny marked image, creates a sandbox on each installed backend with `--image`, and confirms the custom image booted). +### Default OpenCode generic base + +`acq run opencode .` uses the same immutable generic base on both backends: + +```text +ghcr.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7 +``` + +The digest was derived from `ghcr.io/gsa-tts/agentic-coding-patterns/devenv-shell:2.0.0` +on 2026-10-14 and covers published Linux amd64 and arm64 manifests. `acq` applies +the pinned OpenCode agent kit with the support kits and fails before provisioning +if that kit cannot be fetched, is malformed, or does not declare the expected +OpenCode metadata and entrypoint. Passing `--kit` adds a kit; it cannot remove the +required OpenCode kit. SBX injects this base with `--template` and explicitly +launches the kit-declared entrypoint, rather than using an OpenCode template. + +An explicit MSB image override or explicit SBX `--template` retains its existing +custom-image behavior. The obsolete `opencode-ai` postinstall recovery is not used +on the generic default path because the pinned kit installs the direct binary. + +Existing OpenCode sandboxes keep the image and kit set they were created with. +Recreate a sandbox to adopt this default: + +```bash +./acq rm opencode-your-project +./acq run opencode /path/to/your/project +``` + ### 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. When no -explicit image override is set, msb derives the same sbx agent-template image -name from the requested agent (`docker/sandbox-templates:-docker`, with -`claude` → `claude-code-docker` and `cursor` → `cursor-agent-docker` as the two -product-name exceptions) 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 +Unlike sbx (whose templates select an image), the msb backend runs an OCI image +directly and layers kits on top. The default OpenCode path uses the generic base +above; non-OpenCode agents retain their established template-derived defaults. A +custom override may be any OCI image. The pinned kits need `node` (usai merge), `git` (playbook clone + signing), `curl`, and `ca-certificates`/`update-ca-certificates` (zscaler) **already present in the base image**. @@ -586,12 +610,10 @@ 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. 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 agent binary.** Default OpenCode receives its direct binary and entrypoint +from the pinned agent kit on both backends. Custom image/template overrides retain +their existing responsibility for the requested agent binary or compatible kit +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 @@ -1156,14 +1178,16 @@ practical floor (`acq kit validate` warns below it). Manage kits with `acq kit list | validate PATH | apply NAME KITREF`. -### ADR-0030 migration sequencing: agent-kit/devenv model +### ADR-0030 generic-base implementation -ADR-0030 moves the `acq run opencode .` implementation toward an agent-kit and -devenv model. The migration is deliberately sequenced so user-visible behavior -stays stable while internals move behind the existing `acq` surface. +ADR-0030 now makes `acq run opencode .` use the generic pinned devenv base and +the inferred pinned OpenCode kit on both backends. The kit is mandatory for this +default and must declare `agent.name: opencode` and `agent.entrypoint: opencode`. +The generic default is create-time behavior, so existing sandboxes must be +recreated to migrate; their existing sessions are not changed in place. -**Increment 0: freeze the current contract before moving code.** Keep the -observable backend-neutral contract green: `--clone` and `ACQ_CLONE=1`, +The retained migration verification keeps the observable backend-neutral contract +green: `--clone` and `ACQ_CLONE=1`, `ACQ_WORKSPACE`, `ACQ_EXTRA_KITS` before CLI `--kit`, `ACQ_IMAGE`, boot-time `volumes`, `acq exec` workspace/user behavior, and `acq shell` login-shell behavior. Verification: @@ -1173,38 +1197,10 @@ behavior. Verification: - `./scripts/verify-backends --only sbx` on an sbx-capable host - `./scripts/verify-backends --only msb` on an msb-capable host -**Increment 1: introduce the opencode agent-kit inputs without changing dispatch.** -Add the new kit/devenv artifacts and validation in the patterns/team-kit layer, -but keep `acq run opencode .` wired through the existing wrapper behavior until -Increment 0 remains green. When the team kit ships its own verifier, run it from -that repository or checked-out kit path with `scripts/verify` in addition to the -Quickstart gates above. Unresolved schema or team-kit vocabulary questions stay -inside this increment; do not change `acq` dispatch to depend on them first. - -**Increment 2: switch `opencode` provisioning to consume the agent kit.** Replace -only the internal source of the opencode configuration/devenv setup. Preserve the -same create-time inputs and guest markers. Verification: - -- Increment 0 offline bats commands -- team-kit `scripts/verify` when available -- `./scripts/verify-backends --only sbx` -- `./scripts/verify-backends --only msb` - -**Increment 3: move devenv-specific setup behind the kit boundary.** Remove any -now-duplicated wrapper-side setup only after the kit verifier and Quickstart gates -prove the kit supplies it. Defer unresolved image-layout, login-shell, and -workspace-start-directory questions to this increment so earlier increments do -not overfit speculative behavior. Verification: - -- Increment 0 offline bats commands -- focused live `./scripts/verify-backends --only ` for every backend - whose setup changed -- team-kit `scripts/verify` when available - -**Increment 4: cleanup and documentation.** Only after all gates above pass, -remove obsolete wrapper code and update user docs. Re-run the full offline suite -(`./scripts/test-acq-bats`) and live backend verifier on installed backends before -claiming the ADR-0030 migration complete. +Live validation remains required on hosts that can create each sandbox type. Run +`./scripts/verify-backends --only sbx` and +`./scripts/verify-backends --only msb` after changing the generic base, pinned +kit, or backend translation. ### Kit-bundle provenance and stale-sandbox refresh diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index f6eaa60c..642dfa34 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -2852,3 +2852,26 @@ When you discover a new failure mode: 3. Provide a minimal fix 4. Add it to this document 5. Consider if it indicates a gap in AGENTS.md rules + +--- + +## Default OpenCode Generic Base Requires Recreation + +**Symptom:** An existing OpenCode sandbox still starts through an older +OpenCode-specific template or does not receive the pinned kit behavior. + +**Cause:** The ADR-0030 generic base and kit composition are selected only when +the sandbox is created. Existing sandboxes intentionally keep the image and kit +set they were created with. + +**Resolution:** Recreate the sandbox from the project path: + +```bash +./acq rm opencode-your-project +./acq run opencode /path/to/your/project +``` + +The new default uses the pinned generic `devenv-shell` image and validates the +pinned OpenCode kit before provisioning. If that validation fails, correct the +pinned kit availability or metadata rather than falling back to an unpinned npm +installation. diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index 7978e226..59c4893a 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -1,6 +1,6 @@ --- title: "Use Agent Kits on a Devenv-Enabled Agent-Less Base Image" -status: proposed +status: accepted date: 2026-09-15 decision_makers: ["Bret Mogilefsky", "William Zujkowski", "Basilio Bogado"] category: architecture @@ -169,8 +169,9 @@ acq └─ personal kit ``` -This ADR is intentionally proposed, not accepted. It records the direction, the -review outcomes, and the questions that remain open before implementation. +The default OpenCode path is implemented with a pinned generic base image and +the pinned OpenCode kit. It remains subject to the live verification cadence +defined below. ## Lifecycle Sketch @@ -417,6 +418,9 @@ continues. agent-populated images only as optional cache optimizations. - The same environment/capability model can span msb and sbx. - Kit-authoring agents and humans can rely on the broader devenv ecosystem. +- Default OpenCode no longer selects an OpenCode-specific sandbox template; both + backends use the same immutable generic devenv base and the kit-declared + `opencode` entrypoint. ### Negative Consequences @@ -431,6 +435,8 @@ continues. - Debugging startup failures may require better introspection into generated devenv config and kit application order. - Agent install steps must be idempotent and safe to re-run. +- Existing default OpenCode sandboxes retain their original image and launch + behavior. Recreate them to adopt the generic base and pinned kit path. ### Compliance Consequences diff --git a/scripts/verify-backends b/scripts/verify-backends index 0210ed0d..503bae46 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -3,9 +3,9 @@ # verify-backends — HOST-SIDE end-to-end check that the four neutral acq kits # apply cleanly on each INSTALLED backend, and that the core kit behaviors work. # It is also the live sequencing gate for the ADR-0030 migration of -# `acq run opencode .` to the agent-kit/devenv model: keep this verifier green -# before, during, and after the internal migration so the observable acq contract -# remains stable. Unlike scripts/test-acq-bats (which stubs sbx/msb and runs +# `acq run opencode .` generic-base migration: each backend creates OpenCode from +# the pinned generic devenv base plus the inferred agent kit and then validates +# the kit-provided binary and attach path. Unlike scripts/test-acq-bats (which stubs sbx/msb and runs # anywhere), this exercises the REAL toolchain and therefore must run on a # machine that can create sandboxes: # - sbx: Docker + KVM, `sbx login` done, sbx >= 0.39.0 @@ -1258,7 +1258,7 @@ verify_oci_kit() { # verify_agent_install BACKEND — provision an `opencode` sandbox (not `shell`) # and assert the agent binary is actually installed and launchable. This catches # the class of bug where a sandbox comes up but the agent was never installed -# (quickstart#228): on sbx the template bakes it in; on msb acq installs it. +# (the default path is now kit-owned on both backends). verify_agent_install() { local backend="$1" echo "" diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 57a54c33..de63e8cc 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -9,7 +9,10 @@ # # shellcheck shell=bats -setup() { acq_setup_stubs; } +setup() { + acq_setup_stubs + unset ACQ_REQUIRED_AGENT_KIT ACQ_DEFAULT_OPENCODE_GENERIC +} teardown() { acq_teardown_stubs; } load 'helper' @@ -104,15 +107,16 @@ SPEC assert_regex "$(acq_selected_agent_kit_summary opencode 2>/dev/null)" 'apply=deferred' } -@test "agent kit gate: missing opencode artifact fails closed" { +@test "agent kit gate: missing opencode artifact fails before generic-default provisioning" { acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } _acq_agent_builtin_kit_ref() { printf '%s\n' "$STUBDIR/missing-opencode-kit"; } - _build_kit_list opencode + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_REQUIRED_AGENT_KIT=opencode + run _build_kit_list opencode - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" - assert_equal "${#KITS[@]}" "4" - assert_regex "$(acq_selected_agent_kit_summary opencode 2>/dev/null)" 'apply=deferred' + assert_failure + assert_output --partial 'required built-in opencode kit is unavailable' } @test "agent kit gate: wrong opencode kit name fails closed" { @@ -184,19 +188,24 @@ SPEC assert_equal "${#KITS[@]}" "4" } -@test "agent kit gate: explicit --kit suppresses inferred opencode kit" { +@test "agent kit gate: explicit --kit cannot suppress required inferred opencode kit" { local cli="$STUBDIR/cli-kit" mkdir -p "$cli" acq_agent_builtin_kit_enabled() { [ "$1" = "opencode" ]; } - _acq_agent_builtin_kit_ref() { printf '%s\n' "$STUBDIR/missing-opencode-kit"; } + local built_in="$STUBDIR/opencode-kit" + _mk_opencode_agent_kit "$built_in" + _acq_agent_builtin_kit_ref() { printf '%s\n' "$built_in"; } # shellcheck disable=SC2034 # read by sourced _build_kit_list ACQ_CLI_KITS=("$cli") + # shellcheck disable=SC2034 # read by sourced _build_kit_list + ACQ_REQUIRED_AGENT_KIT=opencode _build_kit_list opencode - assert_equal "$ACQ_BUILTIN_KIT_COUNT" "4" - assert_equal "${#KITS[@]}" "5" - assert_equal "${KITS[4]}" "$cli" + assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" + assert_equal "${#KITS[@]}" "6" + assert_equal "${KITS[4]}" "$built_in" + assert_equal "${KITS[5]}" "$cli" } @test "agent kit gate: shell remains no-agent-kit" { @@ -341,3 +350,24 @@ SPEC assert_regex "$line" '--env ACQ_CLONE=1( |$)' assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-envclone-sbx -- --version' } + +@test "migration gate(sbx): generic default attach launches kit entrypoint and preserves -- escape" { + local proj="$STUBDIR/sbx-generic-attach" + _mk_repo "$proj" + _seed_sbx_usai + run env ACQ_BACKEND=sbx STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 \ + "$ACQ" create opencode --name genericattach "$proj" + assert_success + printf 'genericattach\n' >"$STUBDIR/.sandbox_list" + + : >"$CALLS" + run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach + assert_success + assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- opencode' + + : >"$CALLS" + run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach -- sh -c 'echo escape' + assert_success + assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- sh -c echo escape' + refute_regex "$(cat "$CALLS")" 'opencode sh -c' +} diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 2e477e0b..963d8e7f 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -386,14 +386,13 @@ FACTS assert_regex "$(cat "$CALLS")" 'Authorization: Bearer \$USAI_API_KEY' } -@test "run(opencode): runs postinstall under a timeout guard when binary not functional" { +@test "run(opencode): generic default does not run an unpinned npm postinstall fallback" { local proj="$STUBDIR/ocrun"; mkdir -p "$proj" _seed_usai run env STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" assert_success local log; log=$(cat "$CALLS") - assert_regex "$log" 'postinstall\.mjs' - assert_regex "$log" 'timeout' + refute_regex "$log" 'postinstall\.mjs|opencode-ai|npm root -g' refute_output --partial 'does not appear runnable' } diff --git a/test/bats/35-image-override.bats b/test/bats/35-image-override.bats index 9367a946..d9565807 100644 --- a/test/bats/35-image-override.bats +++ b/test/bats/35-image-override.bats @@ -98,11 +98,11 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } assert_regex "$(_create_line msb)" 'docker\.io/docker/sandbox-templates:shell-docker' } -@test "image(msb): known agent defaults to matching sandbox-template image" { +@test "image(msb): default opencode uses the pinned generic devenv base" { _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' + assert_regex "$line" 'ghcr\.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7' + refute_regex "$line" 'devenv-opencode|sandbox-templates:opencode-docker' } @test "image(msb): claude/cursor derive their real product template tags" { @@ -127,31 +127,6 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } refute_regex "$line" 'docker\.io/docker/sandbox-templates:opencode-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 @@ -187,9 +162,10 @@ _sbx_create() { # CREATE_ARGS... (with optional leading ENV via `env`) assert_output --partial 'ignoring --image' } -@test "image(sbx): no image set -> no --template injected" { +@test "image(sbx): default opencode injects the pinned generic template" { _sbx_create ACQ_BACKEND=sbx "$ACQ" create opencode "$IMGPROJ" - refute_regex "$(_create_line sbx)" '--template' + assert_regex "$(_create_line sbx)" '--template ghcr\.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7' + refute_regex "$(_create_line sbx)" 'devenv-opencode|sandbox-templates:opencode-docker' } @test "image(reattach): --image on an existing sandbox warns and does not re-provision" { diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 12da0721..b721d2eb 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -399,7 +399,7 @@ STUB assert [ -f "$STUBDIR/.removed" ] } -@test "run(sbx): waits for the startup barrier before opencode postinstall probe" { +@test "run(sbx): waits for the startup barrier before generic-default opencode attach" { cat >"$STUBDIR/sbx" <<'STUB' #!/usr/bin/env bash { printf 'sbx'; for a in "$@"; do printf ' %s' "$a"; done; printf '\n'; } >>"$CALLS" @@ -411,7 +411,6 @@ case "${1:-}" in snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done case " $* " in - *" opencode --version "*) printf 'opencode 1.18.12\n'; exit 0 ;; esac case "$snippet" in *"startup-complete"*) printf 'ready\n' ;; @@ -434,17 +433,15 @@ STUB run env ACQ_BACKEND=sbx "$ACQ" run opencode "$proj" assert_success - local log create_line barrier_line opencode_line attach_line + local log create_line barrier_line attach_line log=$(cat "$CALLS") create_line=$(printf '%s\n' "$log" | grep -n '^sbx create' | cut -d: -f1) barrier_line=$(printf '%s\n' "$log" | grep -n 'startup-complete' | cut -d: -f1) - opencode_line=$(printf '%s\n' "$log" | grep -n 'opencode --version' | cut -d: -f1) - attach_line=$(printf '%s\n' "$log" | grep -n '^sbx run --name' | cut -d: -f1) + attach_line=$(printf '%s\n' "$log" | grep -n '^sbx run --name.* -- opencode' | cut -d: -f1) assert_regex "$log" 'startup-barrier' assert [ "$create_line" -lt "$barrier_line" ] - assert [ "$barrier_line" -lt "$opencode_line" ] - assert [ "$opencode_line" -lt "$attach_line" ] + assert [ "$barrier_line" -lt "$attach_line" ] } @test "wait(sbx): startup barrier requires this create's token" { From a43a20d5c82bafde3aa9f17706c9af2692bedb5c Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 14 Oct 2026 15:12:11 +0000 Subject: [PATCH 2/6] fix(sbx): avoid duplicate opencode kit on generic base Use the shell template-selection token for the generic OpenCode base while retaining OpenCode provenance, kit installation, and attach behavior. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/sbx.sh | 24 ++++++++++++++++++++-- scripts/verify-backends | 16 ++++++++++++++- test/bats/140-opencode-migration-gate.bats | 4 ++++ test/bats/35-image-override.bats | 6 ++++-- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index b6a33a7f..f553f4e2 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -535,6 +535,26 @@ acq_backend_provision() { fi fi + # SBX derives and injects its native agent kit from the positional agent token + # even when --template selects a generic image. The generic OpenCode path adds + # the pinned OpenCode kit itself, so pass `shell` to SBX to avoid a duplicate + # native `opencode` kit. Keep $agent for acq provenance and attach behavior. + local _sbx_args=("${_stripped[@]}") _arg _prev="" _idx + if [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" = "1" ]; then + for _idx in "${!_sbx_args[@]}"; do + _arg="${_sbx_args[$_idx]}" + if _takes_value "$_prev"; then + _prev="$_arg" + continue + fi + case "$_arg" in + -*) _prev="$_arg"; continue ;; + esac + _sbx_args[$_idx]=shell + break + done + fi + # Neutral --clone (ADR-0027): acq owns the flag, so re-inject sbx's native # `--clone` here (the acq dispatch stripped it and exported ACQ_CLONE). local _cf=() @@ -565,9 +585,9 @@ acq_backend_provision() { _startup_barrier_token="acq-$$-$(date +%s)-$RANDOM-$RANDOM" kf+=(--kit "$(_acq_sbx_startup_barrier_kit "$_startup_barrier_token")") - acq_debug "sbx create --name $name ${_cf[*]:-} ${_ef[*]:-} ${_tf[*]:-} ${kf[*]} ${_stripped[*]:-}" + acq_debug "sbx create --name $name ${_cf[*]:-} ${_ef[*]:-} ${_tf[*]:-} ${kf[*]} ${_sbx_args[*]:-}" acq_spin_start "Creating sandbox '$name'" - sbx create --name "$name" ${_cf[@]+"${_cf[@]}"} ${_ef[@]+"${_ef[@]}"} ${_tf[@]+"${_tf[@]}"} "${kf[@]}" ${_stripped[@]+"${_stripped[@]}"} + sbx create --name "$name" ${_cf[@]+"${_cf[@]}"} ${_ef[@]+"${_ef[@]}"} ${_tf[@]+"${_tf[@]}"} "${kf[@]}" ${_sbx_args[@]+"${_sbx_args[@]}"} local _rc=$? acq_spin_stop "Creating sandbox '$name'" # Record host-side bundle provenance ONLY after a successful create — a failed diff --git a/scripts/verify-backends b/scripts/verify-backends index 503bae46..af1100b7 100755 --- a/scripts/verify-backends +++ b/scripts/verify-backends @@ -1280,7 +1280,7 @@ verify_agent_install() { fi printf ' creating sandbox %s (booting + installing the agent; this can take a minute)...\n' "$aname" - if ! run_acq "create-opencode" --backend "$backend" create opencode "$WORKDIR"; then + if ! ACQ_DEBUG=1 run_acq "create-opencode" --backend "$backend" create opencode "$WORKDIR"; then fail "${backend}: provision (acq create opencode) failed" dump "acq create output" "$LAST_OUT" unseed_usai_secret "$backend" "$aname" @@ -1288,6 +1288,20 @@ verify_agent_install() { return fi + # SBX derives a native kit from its positional agent token. The generic default + # must use the neutral `shell` token so SBX does not add a second opencode kit. + if [ "$backend" = sbx ]; then + case "$LAST_OUT" in + *"sbx create"*"--template"*"shell $WORKDIR"*) + pass "sbx: generic OpenCode create uses shell token (no native duplicate kit)" + ;; + *) + fail "sbx: generic OpenCode create retained the native opencode token" + dump "acq create output" "$LAST_OUT" + ;; + esac + fi + # The agent binary must be on PATH inside the sandbox (the bug: it wasn't). if run_acq "exec opencode-present" --backend "$backend" exec "$aname" -- sh -c "command -v opencode >/dev/null 2>&1"; then pass "${backend}: opencode binary installed and on PATH" diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index de63e8cc..b9a1ed6c 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -359,6 +359,10 @@ SPEC "$ACQ" create opencode --name genericattach "$proj" assert_success printf 'genericattach\n' >"$STUBDIR/.sandbox_list" + local create_line + create_line=$(_create_line sbx) + assert_regex "$create_line" " shell ${proj}$" + assert_equal "$(printf '%s\n' "$create_line" | grep -o 'acq-opencode-kit' | wc -l | tr -d ' ')" "1" : >"$CALLS" run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach diff --git a/test/bats/35-image-override.bats b/test/bats/35-image-override.bats index d9565807..05cf45b1 100644 --- a/test/bats/35-image-override.bats +++ b/test/bats/35-image-override.bats @@ -164,8 +164,10 @@ _sbx_create() { # CREATE_ARGS... (with optional leading ENV via `env`) @test "image(sbx): default opencode injects the pinned generic template" { _sbx_create ACQ_BACKEND=sbx "$ACQ" create opencode "$IMGPROJ" - assert_regex "$(_create_line sbx)" '--template ghcr\.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7' - refute_regex "$(_create_line sbx)" 'devenv-opencode|sandbox-templates:opencode-docker' + local line; line=$(_create_line sbx) + assert_regex "$line" '--template ghcr\.io/gsa-tts/agentic-coding-patterns/devenv-shell@sha256:7b923fb5e722ad27c751ead836f24145c4cea3fac015a3ba753bd7473891e1e7' + assert_regex "$line" " shell ${IMGPROJ}$" + refute_regex "$line" 'devenv-opencode|sandbox-templates:opencode-docker' } @test "image(reattach): --image on an existing sandbox warns and does not re-provision" { From b297152e86ed502f17919fb004b2aca2092dc8c0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 14 Oct 2026 17:48:33 +0000 Subject: [PATCH 3/6] fix(sbx): route generic opencode flags to kit entrypoint Treat option-led post-separator arguments as OpenCode agent flags while preserving command-led shell escapes. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/sbx.sh | 16 +++++++++++++--- test/bats/140-opencode-migration-gate.bats | 7 ++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index f553f4e2..7ff90339 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -812,15 +812,25 @@ acq_backend_attach() { ws=$(acq_backend_workspace_for "$name") if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then shift + local _attach_cmd=() + if [ "$agent" = "opencode" ] \ + && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then + case "${1:-}" in + -*) _attach_cmd=("$agent" "$@") ;; # agent flags, including short options + *) _attach_cmd=("$@") ;; # explicit command escape + esac + else + _attach_cmd=("$@") + fi if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ && command -v acq_guest_exec_script >/dev/null 2>&1; then if [ -n "$ws" ]; then - sbx run --name "$name" -- env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "$agent" "$@" + sbx run --name "$name" -- env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}" else - sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "$agent" "$@" + sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}" fi else - sbx run --name "$name" -- "$@" + sbx run --name "$name" -- "${_attach_cmd[@]}" fi else if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index b9a1ed6c..6aff7665 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -348,7 +348,7 @@ SPEC assert_regex "$line" '--clone' assert_regex "$line" "--env ACQ_WORKSPACE=${logical}( |$)" assert_regex "$line" '--env ACQ_CLONE=1( |$)' - assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-envclone-sbx -- --version' + assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-envclone-sbx -- opencode --version' } @test "migration gate(sbx): generic default attach launches kit entrypoint and preserves -- escape" { @@ -369,6 +369,11 @@ SPEC assert_success assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- opencode' + : >"$CALLS" + run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach -- --version + assert_success + assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- opencode --version' + : >"$CALLS" run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach -- sh -c 'echo escape' assert_success From 9bac87bf660531464e0e8f1a4604ef6b340eaa97 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 15 Oct 2026 00:41:31 +0000 Subject: [PATCH 4/6] fix(sbx): launch generic opencode on reattach Use the shell agent's exec launcher so SBX reattach forwards OpenCode arguments without interpreting the ELF binary as shell input. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/sbx.sh | 43 +++++++++++++++++----- scripts/test-acq-lib.sh | 8 +++- test/bats/140-opencode-migration-gate.bats | 10 ++--- test/bats/30-dispatch-routing.bats | 6 +-- test/bats/90-sbx-startup-kit.bats | 2 +- 5 files changed, 50 insertions(+), 19 deletions(-) diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 7ff90339..ba24ffeb 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -759,6 +759,19 @@ _acq_sbx_attach_command() { printf '%s\n' "$agent" } +# SBX requires an agent positional before `-- AGENT_ARGS`. Generic OpenCode +# sandboxes were created with SBX's `shell` token to avoid its native OpenCode +# kit, while the pinned kit supplies `opencode` as the command to run. +_acq_sbx_attach_agent() { + local name="$1" agent + if [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then + printf '%s\n' shell + else + agent=$(acq_backend_recorded_agent "$name") + printf '%s\n' "${agent:-shell}" + fi +} + # --------------------------------------------------------------------------- # acq_backend_run — run a command inside a sandbox # --------------------------------------------------------------------------- @@ -807,12 +820,14 @@ acq_backend_attach() { echo "acq: refusing to attach to '$name' because kit heal did not finish." >&2 return 1 fi - local agent ws + local agent sbx_agent ws generic_opencode agent=$(_acq_sbx_attach_command "$name") + sbx_agent=$(_acq_sbx_attach_agent "$name") + generic_opencode=$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true) ws=$(acq_backend_workspace_for "$name") if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then shift - local _attach_cmd=() + local _attach_cmd=() _sbx_args=() if [ "$agent" = "opencode" ] \ && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then case "${1:-}" in @@ -825,29 +840,39 @@ acq_backend_attach() { if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ && command -v acq_guest_exec_script >/dev/null 2>&1; then if [ -n "$ws" ]; then - sbx run --name "$name" -- env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}" + _sbx_args=(env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}") else - sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}" + _sbx_args=(sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}") fi else - sbx run --name "$name" -- "${_attach_cmd[@]}" + _sbx_args=("${_attach_cmd[@]}") fi + if [ "$generic_opencode" = "1" ]; then + _sbx_args=(-lc 'exec "$@"' sh "${_sbx_args[@]}") + fi + sbx run --name "$name" "$sbx_agent" -- "${_sbx_args[@]}" else + local _sbx_args=() if [ "${ACQ_ACTIVATE_PROJECT_ENV:-0}" = "1" ] \ && command -v acq_guest_exec_script >/dev/null 2>&1; then if [ -n "$ws" ]; then - sbx run --name "$name" -- env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "$agent" + _sbx_args=(env "ACQ_WORKSPACE=$ws" sh -c "$(acq_guest_exec_script)" sh "$agent") else - sbx run --name "$name" -- sh -c "$(acq_guest_exec_script)" sh "$agent" + _sbx_args=(sh -c "$(acq_guest_exec_script)" sh "$agent") fi else if [ "$agent" = "opencode" ] \ - && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then - sbx run --name "$name" -- "$agent" + && [ "$generic_opencode" = "1" ]; then + _sbx_args=("$agent") else sbx run --name "$name" + return fi fi + if [ "$generic_opencode" = "1" ]; then + _sbx_args=(-lc 'exec "$@"' sh "${_sbx_args[@]}") + fi + sbx run --name "$name" "$sbx_agent" -- "${_sbx_args[@]}" fi } diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 69039cb8..7f73bab8 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -193,7 +193,13 @@ case "${1:-}" in touch "$STUBDIR/.opencode_fixed"; exit 0 ;; *) exit 0 ;; esac ;; - run) [ "${2:-}" = "--help" ] && printf 'SBX-RUN-HELP\n'; exit 0 ;; + run) + [ "${2:-}" = "--help" ] && printf 'SBX-RUN-HELP\n' + # Agent arguments require an agent positional before `--`. SBX otherwise + # forwards the separator to its shell launcher, where Bash rejects it. + [ "${2:-}" = "--name" ] && [ "${4:-}" = "--" ] && exit 2 + exit 0 + ;; stop) exit 0 ;; start) exit 0 ;; rm) exit 0 ;; diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 6aff7665..258fd6cd 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -331,7 +331,7 @@ SPEC assert_regex "$line" '--env ACQ_CLONE=1( |$)' assert_regex "$line" '--template ghcr\.io/acq/migration:v1' assert_regex "$line" '--kit /extra/env-one .*--kit /extra/env-two .*--kit /extra/cli-one' - assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-sbxproj -- --version' + assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-sbxproj opencode -- --version' } @test "migration gate(sbx): ACQ_CLONE=1 run opencode matches --clone markers" { @@ -348,7 +348,7 @@ SPEC assert_regex "$line" '--clone' assert_regex "$line" "--env ACQ_WORKSPACE=${logical}( |$)" assert_regex "$line" '--env ACQ_CLONE=1( |$)' - assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-envclone-sbx -- opencode --version' + assert_regex "$(cat "$CALLS")" 'sbx run --name opencode-envclone-sbx shell -- -lc exec.*sh opencode --version' } @test "migration gate(sbx): generic default attach launches kit entrypoint and preserves -- escape" { @@ -367,16 +367,16 @@ SPEC : >"$CALLS" run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach assert_success - assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- opencode' + assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach shell -- -lc exec.*sh opencode' : >"$CALLS" run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach -- --version assert_success - assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- opencode --version' + assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach shell -- -lc exec.*sh opencode --version' : >"$CALLS" run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericattach -- sh -c 'echo escape' assert_success - assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach -- sh -c echo escape' + assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach shell -- -lc exec.*sh sh -c echo escape' refute_regex "$(cat "$CALLS")" 'opencode sh -c' } diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 963d8e7f..7edb65a8 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -489,7 +489,7 @@ FACTS assert_output --partial 'ACQ_ACTIVATE_PROJECT_ENV=1' local log; log=$(cat "$CALLS") local run_line; run_line=$(grep '^sbx run --name opencode-direnvproj' "$CALLS") - assert_regex "$run_line" 'sbx run --name opencode-direnvproj -- echo hi' + assert_regex "$run_line" 'sbx run --name opencode-direnvproj shell -- -lc exec.*sh echo hi' refute_regex "$log" 'direnv export|direnv allow' } @@ -501,7 +501,7 @@ FACTS assert_success assert_output --partial 'project environment detected (direnv)' local log; log=$(cat "$CALLS") - assert_regex "$log" 'sbx run --name opencode-direnvproj2 -- env ACQ_WORKSPACE=.* sh -c' + assert_regex "$log" 'sbx run --name opencode-direnvproj2 shell -- -lc exec.*sh env ACQ_WORKSPACE=.* sh -c' assert_regex "$log" 'direnv export sh' assert_regex "$log" 'sh opencode --version' refute_regex "$log" 'direnv allow' @@ -514,7 +514,7 @@ FACTS run env ACQ_BACKEND=sbx ACQ_ACTIVATE_PROJECT_ENV=1 "$ACQ" run opencode "$proj" assert_success local log; log=$(cat "$CALLS") - assert_regex "$log" 'sbx run --name opencode-direnvproj3 -- env ACQ_WORKSPACE=.* sh -c' + assert_regex "$log" 'sbx run --name opencode-direnvproj3 shell -- -lc exec.*sh env ACQ_WORKSPACE=.* sh -c' assert_regex "$log" 'sh opencode' } diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index b721d2eb..8fe60910 100644 --- a/test/bats/90-sbx-startup-kit.bats +++ b/test/bats/90-sbx-startup-kit.bats @@ -437,7 +437,7 @@ STUB log=$(cat "$CALLS") create_line=$(printf '%s\n' "$log" | grep -n '^sbx create' | cut -d: -f1) barrier_line=$(printf '%s\n' "$log" | grep -n 'startup-complete' | cut -d: -f1) - attach_line=$(printf '%s\n' "$log" | grep -n '^sbx run --name.* -- opencode' | cut -d: -f1) + attach_line=$(printf '%s\n' "$log" | grep -n '^sbx run --name.* shell -- -lc exec.*sh opencode' | cut -d: -f1) assert_regex "$log" 'startup-barrier' assert [ "$create_line" -lt "$barrier_line" ] From 5ac0bf517b74b9297b5c69d02f9424c99c872822 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 15 Oct 2026 18:17:35 +0000 Subject: [PATCH 5/6] fix(sbx): require recorded opencode launch mode Refuse ambiguous OpenCode reattach instead of guessing between generic and legacy SBX launch grammar. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/common.sh | 2 +- acq.backends/sbx.sh | 79 ++++++++++++++++---- docs/BACKEND_GUIDE.md | 4 ++ docs/adr/0030-agent-kits-on-devenv-base.md | 6 +- test/bats/140-opencode-migration-gate.bats | 84 ++++++++++++++++++++++ 5 files changed, 158 insertions(+), 17 deletions(-) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 8749a67b..4ae102e5 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2452,7 +2452,7 @@ acq_host_config_clear_instance_state() { case "$dir" in ""|/|/*/) return 0 ;; esac rm -f "$dir"/install-* "$dir"/agent-installed-* \ "$dir"/agent-user-ready \ - "$dir"/workspace "$dir"/ssh-auth-sock 2>/dev/null || true + "$dir"/workspace "$dir"/ssh-auth-sock "$dir"/generic-opencode 2>/dev/null || true return 0 } diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index ba24ffeb..096f29ad 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -414,6 +414,16 @@ _acq_sbx_wait_for_startup_barrier() { done } +_acq_sbx_clear_opencode_launch_mode() { + local name="$1" + acq_host_config_clear sbx "$name" generic-opencode + if acq_host_config_has sbx "$name" generic-opencode; then + echo "acq(sbx): error: could not clear stale OpenCode launch mode for '$name'." >&2 + echo " Refusing to create because reattach could select stale SBX grammar." >&2 + return 1 + fi +} + # Probe a feature inside a sandbox. Returns 0=absent, 1=present, 2=probe failed. # IMPORTANT: `snippet` MUST end with " && echo present" — e.g.: # "test -f '/path/to/file' && echo present" @@ -586,6 +596,16 @@ acq_backend_provision() { kf+=(--kit "$(_acq_sbx_startup_barrier_kit "$_startup_barrier_token")") acq_debug "sbx create --name $name ${_cf[*]:-} ${_ef[*]:-} ${_tf[*]:-} ${kf[*]} ${_sbx_args[*]:-}" + # The name-keyed host record may outlive a sandbox removed outside acq. Clear + # OpenCode's routing mode before create, and refuse to create if it persists. + # A later write failure then leaves no stale mode that could enable reattach. + if [ "$agent" = "opencode" ]; then + if acq_backend_exists "$name"; then + echo "acq(sbx): error: sandbox '$name' already exists; refusing to replace its launch mode." >&2 + return 1 + fi + _acq_sbx_clear_opencode_launch_mode "$name" || return 1 + fi acq_spin_start "Creating sandbox '$name'" sbx create --name "$name" ${_cf[@]+"${_cf[@]}"} ${_ef[@]+"${_ef[@]}"} ${_tf[@]+"${_tf[@]}"} "${kf[@]}" ${_sbx_args[@]+"${_sbx_args[@]}"} local _rc=$? @@ -604,9 +624,19 @@ acq_backend_provision() { return 1 fi acq_spin_stop "Waiting for kit startup in '$name'" + acq_host_config_clear_instance_state sbx "$name" acq_provenance_write sbx "$name" "$agent" || true - if [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" = "1" ]; then - acq_host_config_write sbx "$name" generic-opencode 1 || true + if [ "$agent" = "opencode" ]; then + # Reattach grammar is determined by the create-time SBX agent token. Persist + # it host-side so a missing record never guesses between shell and OpenCode. + local _opencode_mode=legacy-opencode + [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" = "1" ] && _opencode_mode=generic-opencode + if ! acq_host_config_write sbx "$name" generic-opencode "$_opencode_mode"; then + echo "acq(sbx): error: could not record OpenCode launch mode for '$name'." >&2 + echo " Refusing to attach because acq cannot safely choose SBX grammar." >&2 + echo " Remove and recreate this sandbox." >&2 + return 1 + fi fi acq_workspace_record_write sbx "$name" "$_primary_ws" || true _acq_sbx_seed_extra_kit_marker "$name" @@ -750,11 +780,7 @@ acq_backend_workspace_for() { _acq_sbx_attach_command() { local name="$1" agent agent=$(acq_backend_recorded_agent "$name") - if [ "$agent" = "opencode" ] \ - && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then - acq_agent_kit_entrypoint opencode - return 0 - fi + [ "$agent" = "opencode" ] && acq_agent_kit_entrypoint opencode && return 0 [ -n "$agent" ] || agent="bash" printf '%s\n' "$agent" } @@ -764,12 +790,37 @@ _acq_sbx_attach_command() { # kit, while the pinned kit supplies `opencode` as the command to run. _acq_sbx_attach_agent() { local name="$1" agent - if [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then + case "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" in + generic-opencode|1) printf '%s\n' shell - else + ;; + *) agent=$(acq_backend_recorded_agent "$name") printf '%s\n' "${agent:-shell}" - fi + ;; + esac +} + +_acq_sbx_opencode_launch_mode() { + local name="$1" agent mode + agent=$(acq_backend_recorded_agent "$name") + [ "$agent" = "opencode" ] || return 0 + mode=$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true) + case "$mode" in + generic-opencode|1) printf '%s\n' generic-opencode; return 0 ;; + legacy-opencode) printf '%s\n' "$mode"; return 0 ;; + "") + echo "acq(sbx): error: cannot determine OpenCode launch mode for '$name'." >&2 + echo " Its host launch-mode record is missing, so acq will not fall back" >&2 + echo " to legacy SBX grammar. Remove and recreate this sandbox." >&2 + return 1 + ;; + *) + echo "acq(sbx): error: invalid OpenCode launch mode record for '$name'." >&2 + echo " Remove and recreate this sandbox." >&2 + return 1 + ;; + esac } # --------------------------------------------------------------------------- @@ -820,16 +871,18 @@ acq_backend_attach() { echo "acq: refusing to attach to '$name' because kit heal did not finish." >&2 return 1 fi - local agent sbx_agent ws generic_opencode + local agent sbx_agent ws opencode_mode generic_opencode + opencode_mode=$(_acq_sbx_opencode_launch_mode "$name") || return 1 + generic_opencode="" + [ "$opencode_mode" = "generic-opencode" ] && generic_opencode=1 agent=$(_acq_sbx_attach_command "$name") sbx_agent=$(_acq_sbx_attach_agent "$name") - generic_opencode=$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true) ws=$(acq_backend_workspace_for "$name") if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then shift local _attach_cmd=() _sbx_args=() if [ "$agent" = "opencode" ] \ - && [ "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" = "1" ]; then + && [ "$generic_opencode" = "1" ]; then case "${1:-}" in -*) _attach_cmd=("$agent" "$@") ;; # agent flags, including short options *) _attach_cmd=("$@") ;; # explicit command escape diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 181672f9..ef03493e 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -1185,6 +1185,10 @@ the inferred pinned OpenCode kit on both backends. The kit is mandatory for this default and must declare `agent.name: opencode` and `agent.entrypoint: opencode`. The generic default is create-time behavior, so existing sandboxes must be recreated to migrate; their existing sessions are not changed in place. +SBX OpenCode sandboxes without a recorded launch mode are also refused at +reattach: acq cannot safely distinguish a legacy template sandbox from a generic +one whose host state was lost. Recreate the sandbox rather than selecting a +launch grammar by guesswork. The retained migration verification keeps the observable backend-neutral contract green: `--clone` and `ACQ_CLONE=1`, diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index 59c4893a..3d137f48 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -116,9 +116,9 @@ Consequences for the substrate and the kit contract: plus agent kits.** This keeps the base image generic, preserves familiar OS choices, and moves agent-specific knowledge to kits. -## Proposed Decision Outcome +## Decision Outcome -Proposed option: **Option 3**. +Accepted option: **Option 3**. `acq` should target an agent-less, devenv-enabled base-image contract: Nix (single-user), devenv, and direnv layered onto a parametrized `BASE_IMAGE`. @@ -385,7 +385,7 @@ sandbox on the active backend and can gate the refactor. ### Migration sequencing The `acq run opencode .` behavior changes in the staged sequence documented in -[`docs/BACKEND_GUIDE.md`](../BACKEND_GUIDE.md#adr-0030-migration-sequencing-agent-kitdevenv-model): +[`docs/BACKEND_GUIDE.md`](../BACKEND_GUIDE.md#adr-0030-generic-base-implementation): freeze the current contract first, introduce agent-kit inputs without changing dispatch, switch OpenCode provisioning to consume the agent kit, move remaining devenv-specific setup behind the kit boundary, then remove obsolete diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index 258fd6cd..ed37bea7 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -380,3 +380,87 @@ SPEC assert_regex "$(cat "$CALLS")" 'sbx run --name genericattach shell -- -lc exec.*sh sh -c echo escape' refute_regex "$(cat "$CALLS")" 'opencode sh -c' } + +@test "migration gate(sbx): generic default fails closed when launch-mode persistence fails" { + local proj="$STUBDIR/sbx-generic-mode-write-fail" + _mk_repo "$proj" + _seed_sbx_usai + # shellcheck disable=SC2034 # read by acq_backend_provision + ACQ_DEFAULT_OPENCODE_GENERIC=1 + acq_host_config_write sbx genericwritefail generic-opencode legacy-opencode + acq_host_config_write() { return 1; } + + run acq_backend_provision genericwritefail opencode "$proj" + + assert_failure + assert_output --partial 'could not record OpenCode launch mode' + assert_regex "$(_create_line sbx)" ' shell ' + run acq_host_config_has sbx genericwritefail generic-opencode + assert_failure + refute_regex "$(cat "$CALLS")" '^sbx run ' +} + +@test "migration gate(sbx): lost generic launch mode refuses legacy attach grammar" { + local proj="$STUBDIR/sbx-generic-mode-lost" + _mk_repo "$proj" + _seed_sbx_usai + run env ACQ_BACKEND=sbx STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 \ + "$ACQ" create opencode --name genericmodelost "$proj" + assert_success + printf 'genericmodelost\n' >"$STUBDIR/.sandbox_list" + acq_host_config_clear sbx genericmodelost generic-opencode + + : >"$CALLS" + run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericmodelost + + assert_failure + assert_output --partial 'cannot determine OpenCode launch mode' + refute_regex "$(cat "$CALLS")" '^sbx run --name genericmodelost opencode' + refute_regex "$(cat "$CALLS")" '^sbx run --name genericmodelost shell' +} + +@test "migration gate(sbx): prior generic launch-mode record keeps the shell launcher" { + local proj="$STUBDIR/sbx-generic-mode-prior" + _mk_repo "$proj" + _seed_sbx_usai + run env ACQ_BACKEND=sbx STUB_KEY_STATUS=200 STUB_OPENCODE_OK=1 \ + "$ACQ" create opencode --name genericmodeprior "$proj" + assert_success + printf 'genericmodeprior\n' >"$STUBDIR/.sandbox_list" + acq_host_config_write sbx genericmodeprior generic-opencode 1 + + : >"$CALLS" + run env ACQ_BACKEND=sbx ACQ_UPDATE_CHECK=0 "$ACQ" run genericmodeprior -- --version + + assert_success + assert_regex "$(cat "$CALLS")" 'sbx run --name genericmodeprior shell -- -lc exec.*sh opencode --version' +} + +@test "migration gate(sbx): failed same-name create retains the existing launch mode" { + local proj="$STUBDIR/sbx-generic-mode-existing" + _mk_repo "$proj" + printf 'genericmodeexisting\n' >"$STUBDIR/.sandbox_list" + acq_host_config_write sbx genericmodeexisting generic-opencode generic-opencode + + run acq_backend_provision genericmodeexisting opencode "$proj" + + assert_failure + assert_output --partial "sandbox 'genericmodeexisting' already exists" + assert_equal "$(acq_host_config_read sbx genericmodeexisting generic-opencode)" generic-opencode + refute_regex "$(cat "$CALLS")" '^sbx create ' +} + +@test "migration gate(sbx): replacement create overwrites a stale launch mode" { + local proj="$STUBDIR/sbx-generic-mode-replacement" + _mk_repo "$proj" + _seed_sbx_usai + # shellcheck disable=SC2034 # read by acq_backend_provision + ACQ_DEFAULT_OPENCODE_GENERIC=1 + acq_host_config_write sbx genericmodereplacement generic-opencode legacy-opencode + + run acq_backend_provision genericmodereplacement opencode "$proj" + + assert_success + assert_equal "$(acq_host_config_read sbx genericmodereplacement generic-opencode)" generic-opencode + assert_regex "$(_create_line sbx)" ' shell ' +} From 0408b93a199a66ddad727fe58d5a67f9b2057230 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 21 Oct 2026 07:38:29 +0000 Subject: [PATCH 6/6] fix(opencode): harden generic base state Resolve review findings for durable generic OpenCode state, post-create cleanup, and fallback behavior. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq | 3 +- acq.backends/common.sh | 11 +++- acq.backends/msb.sh | 72 ++++++++++++++++++---- acq.backends/sbx.sh | 34 +++++----- docs/adr/0030-agent-kits-on-devenv-base.md | 24 ++++---- scripts/test-acq-lib.sh | 5 +- test/bats/115-ssh-agent-forward.bats | 17 +++++ test/bats/140-opencode-migration-gate.bats | 58 +++++++++++++++++ test/bats/30-dispatch-routing.bats | 18 +++++- test/bats/35-image-override.bats | 20 ++++++ test/bats/50-kit-list-completeness.bats | 5 +- 11 files changed, 219 insertions(+), 48 deletions(-) diff --git a/acq b/acq index f8a9a0b5..ac643bb4 100755 --- a/acq +++ b/acq @@ -1307,7 +1307,8 @@ case "$subcommand" in # some sandbox images skip, so a fresh launch fails with "postinstall # script was not run". Run it here — on any backend — before attaching an # opencode agent. Best-effort; never blocks the run. - if [ "$first" = "opencode" ] && [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" != "1" ]; then + if [ "$first" = "opencode" ] \ + && ! acq_instance_uses_generic_opencode "$ACQ_RESOLVED_BACKEND" "$local_name"; then ensure_opencode_postinstall "$local_name" fi diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 4ae102e5..dbf4b51f 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -914,7 +914,6 @@ _acq_selected_builtin_kit_refs() { acq_selected_agent_kit_summary() { local agent="${1:-}" kit_name entrypoint install_owner start_owner apply_state="deferred" - [ "${#ACQ_CLI_KITS[@]}" -eq 0 ] || return 1 kit_name=$(acq_agent_builtin_kit_name "$agent") || return 1 entrypoint=$(acq_agent_kit_entrypoint "$agent") || entrypoint="" install_owner=$(acq_agent_kit_install_owner "$agent") || install_owner="" @@ -2456,6 +2455,16 @@ acq_host_config_clear_instance_state() { return 0 } +# True when a sandbox was created with the generic OpenCode base and its pinned +# kit. This is host-authoritative create-time state, not a per-invocation flag. +# Usage: acq_instance_uses_generic_opencode BACKEND NAME +acq_instance_uses_generic_opencode() { + case "$(acq_host_config_read "$1" "$2" generic-opencode 2>/dev/null || true)" in + generic-opencode|1) return 0 ;; + *) return 1 ;; + esac +} + # Remove a sandbox's entire host config dir (called from terminate). Best-effort. # Usage: acq_host_config_remove BACKEND NAME acq_host_config_remove() { diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index a683cdf3..d7222bf6 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -841,7 +841,9 @@ acq_backend_prepare() { # CLI help. If the column layout differs on a real host, adjust the parse. acq_backend_exists() { - _acq_msb_cli list -q 2>/dev/null | grep -Fxq -- "$1" + local listed + listed=$(_acq_msb_cli list -q 2>/dev/null) || return 1 + grep -Fxq -- "$1" <<<"$listed" } # Return success only when msb successfully lists sandboxes and NAME is absent. @@ -849,7 +851,7 @@ acq_backend_exists() { _acq_msb_confirm_absent() { local name="$1" listed listed=$(_acq_msb_cli list -q 2>/dev/null) || return 1 - ! printf '%s\n' "$listed" | grep -Fxq -- "$name" + ! grep -Fxq -- "$name" <<<"$listed" } # --------------------------------------------------------------------------- @@ -866,7 +868,9 @@ _acq_msb_confirm_absent() { # help and is not live-verified against a running daemon; if the layout differs on # a real host, adjust here and in acq_backend_exists together. _acq_msb_is_running() { - _acq_msb_cli list --running -q 2>/dev/null | grep -Fxq -- "$1" + local listed + listed=$(_acq_msb_cli list --running -q 2>/dev/null) || return 1 + grep -Fxq -- "$1" <<<"$listed" } # --------------------------------------------------------------------------- @@ -3154,6 +3158,10 @@ acq_backend_provision() { local kitref kitdir _acq_msb_select_kits "$agent" || return 1 local kits=("${KITS[@]}") + local _generic_opencode_kit="" + if [ "$_msb_image_source" = "generic-opencode-default" ]; then + _generic_opencode_kit=$(_acq_agent_builtin_kit_ref opencode) || return 1 + fi for kitref in "${kits[@]}"; do kitdir=$(_acq_msb_fetch_kit "$kitref") || { @@ -3775,19 +3783,41 @@ EOF # individually non-fatal (the playbook kit already self-heals on next start), # matching acq_backend_ensure_kits_applied's best-effort heal loop. Each kit's # commands get the merged env from the pre-pass above. - local kd + local kd _kit_index _generic_opencode_kit_applied=0 acq_spin_start "Applying configuration kits" _acq_msb_reset_kit_env "$name" - for kd in "${kitdirs[@]}"; do + for _kit_index in "${!kitdirs[@]}"; do + kd="${kitdirs[$_kit_index]}" + kitref="${kits[$_kit_index]}" acq_debug "msb provision: applying kit dir $kd ($name)" if _acq_msb_apply_kit_dir "$name" "$kd" _merged_kit_env; then acq_debug "msb provision: applied kit dir $kd ($name)" + if [ "$kitref" = "$_generic_opencode_kit" ]; then + _generic_opencode_kit_applied=1 + fi else + if [ "$kitref" = "$_generic_opencode_kit" ]; then + acq_spin_stop "Applying configuration kits" + echo "acq(msb): error: required OpenCode kit did not fully apply for '$name'." >&2 + echo " Removing the new sandbox because its generic image has no OpenCode binary." >&2 + acq_backend_terminate "$name" >/dev/null 2>&1 || \ + echo "acq(msb): warning: could not remove '$name'; run 'acq rm $name' before retrying." >&2 + return 1 + fi echo "acq(msb): warning: kit did not fully apply: $kd" >&2 echo "acq(msb): the sandbox is up; re-run 'acq run' to re-apply, or inspect with ACQ_DEBUG=1." >&2 fi done acq_spin_stop "Applying configuration kits" + if [ "$_msb_image_source" = "generic-opencode-default" ] \ + && { [ "$_generic_opencode_kit_applied" != 1 ] \ + || ! acq_host_config_write msb "$name" generic-opencode generic-opencode; }; then + echo "acq(msb): error: could not record completed generic OpenCode state for '$name'." >&2 + echo " Removing the new sandbox to avoid an unpinned postinstall fallback." >&2 + acq_backend_terminate "$name" >/dev/null 2>&1 || \ + echo "acq(msb): warning: could not remove '$name'; run 'acq rm $name' before retrying." >&2 + return 1 + fi acq_debug "msb provision: all kits applied; provision complete ($name)" # Start the in-guest socat bridge for the host ssh-agent forward (ADR-0021). @@ -4555,7 +4585,15 @@ _acq_msb_ensure_ssh_agent_forward() { # same-name sandbox recreated outside this create path (or after wiping msb # state but keeping acq's expectations around the name). Recreate through acq # with SSH_AUTH_SOCK set so the create-time route is recorded. - if ! _acq_msb_has_ssh_agent_vsock_route "$name"; then + local _route_rc=0 + _acq_msb_has_ssh_agent_vsock_route "$name" || _route_rc=$? + if [ "$_route_rc" -eq 2 ]; then + echo "acq(msb): warning: could not verify the ssh-agent --vsock route for '$name'" \ + "(msb inspect failed: ${_ACQ_MSB_ROUTE_PROBE_ERR:-no output});" \ + "continuing without ssh-agent forwarding for this run." >&2 + return 0 + fi + if [ "$_route_rc" -ne 0 ]; then echo "acq(msb): warning: host SSH_AUTH_SOCK is set, but existing sandbox '$name'" \ "has no ssh-agent --vsock route. The route is create-time only, so" \ "SSH_AUTH_SOCK will not be set in the guest and git signing will fail." >&2 @@ -4580,9 +4618,19 @@ _acq_msb_ensure_ssh_agent_forward() { # `msb inspect NAME --format json`; best-effort (a missing tool/field returns # non-zero, never hard-fails). See ADR-0021. _acq_msb_has_ssh_agent_vsock_route() { - local name="$1" json _port="$ACQ_MSB_SSH_AGENT_VSOCK_PORT" - json=$(_acq_msb_cli inspect "$name" --format json 2>/dev/null) || return 1 - [ -n "$json" ] || return 1 + local name="$1" json _port="$ACQ_MSB_SSH_AGENT_VSOCK_PORT" _errf _rc _try + _ACQ_MSB_ROUTE_PROBE_ERR="" + _errf=$(mktemp "${TMPDIR:-/tmp}/acq-msb-inspect.XXXXXX" 2>/dev/null) || _errf=/dev/null + for _try in 1 2; do + _rc=0 + json=$(_acq_msb_cli inspect "$name" --format json 2>"$_errf") || _rc=$? + [ "$_rc" -eq 0 ] && [ -n "$json" ] && break + done + if [ "$_errf" != /dev/null ]; then + _ACQ_MSB_ROUTE_PROBE_ERR=$(tr '\n' ' ' <"$_errf" 2>/dev/null) + rm -f "$_errf" 2>/dev/null + fi + [ "$_rc" -eq 0 ] && [ -n "$json" ] || return 2 # Require BOTH a `vsock` key AND the ssh-agent guest-port token, so a published # `ports:[{port:}]` that merely happens to equal the (user- # overridable) vsock port cannot masquerade as a route on a sandbox that has no @@ -4593,10 +4641,12 @@ _acq_msb_has_ssh_agent_vsock_route() { # and `port:3552` land on separate lines, so both greps must be run over the # full text (not a single line). The port is anchored as a whole token so 3552 # can't match e.g. 35521. See ADR-0021; shape confirmed against msb 0.6.12. + # Grep reads a here-string, not a pipe: under pipefail, `grep -q` exiting on + # an early match can SIGPIPE a writer still emitting a large document. local _flat _flat=$(printf '%s' "$json" | tr -d '" ' | tr ',{}[]' '\n\n\n\n\n') - printf '%s\n' "$_flat" | grep -Eq '(^|:)vsock:?$' 2>/dev/null || return 1 - printf '%s\n' "$_flat" | grep -Eq "port[:=]${_port}\$" 2>/dev/null + grep -Eq '(^|:)vsock:?$' <<<"$_flat" 2>/dev/null || return 1 + grep -Eq "port[:=]${_port}\$" <<<"$_flat" 2>/dev/null } # --------------------------------------------------------------------------- diff --git a/acq.backends/sbx.sh b/acq.backends/sbx.sh index 096f29ad..98ee7f09 100644 --- a/acq.backends/sbx.sh +++ b/acq.backends/sbx.sh @@ -163,7 +163,9 @@ acq_backend_prepare() { # --------------------------------------------------------------------------- acq_backend_exists() { - sbx ls -q 2>/dev/null | grep -Fxq -- "$1" + local listed + listed=$(sbx ls -q 2>/dev/null) || return 1 + grep -Fxq -- "$1" <<<"$listed" } # --------------------------------------------------------------------------- @@ -414,16 +416,6 @@ _acq_sbx_wait_for_startup_barrier() { done } -_acq_sbx_clear_opencode_launch_mode() { - local name="$1" - acq_host_config_clear sbx "$name" generic-opencode - if acq_host_config_has sbx "$name" generic-opencode; then - echo "acq(sbx): error: could not clear stale OpenCode launch mode for '$name'." >&2 - echo " Refusing to create because reattach could select stale SBX grammar." >&2 - return 1 - fi -} - # Probe a feature inside a sandbox. Returns 0=absent, 1=present, 2=probe failed. # IMPORTANT: `snippet` MUST end with " && echo present" — e.g.: # "test -f '/path/to/file' && echo present" @@ -596,15 +588,14 @@ acq_backend_provision() { kf+=(--kit "$(_acq_sbx_startup_barrier_kit "$_startup_barrier_token")") acq_debug "sbx create --name $name ${_cf[*]:-} ${_ef[*]:-} ${_tf[*]:-} ${kf[*]} ${_sbx_args[*]:-}" - # The name-keyed host record may outlive a sandbox removed outside acq. Clear - # OpenCode's routing mode before create, and refuse to create if it persists. - # A later write failure then leaves no stale mode that could enable reattach. + # Do not replace the launch mode of an existing OpenCode sandbox. Stale host + # state is cleared only after this create succeeds, avoiding races with a + # concurrent create and avoiding destructive guesses when `sbx ls` fails. if [ "$agent" = "opencode" ]; then if acq_backend_exists "$name"; then echo "acq(sbx): error: sandbox '$name' already exists; refusing to replace its launch mode." >&2 return 1 fi - _acq_sbx_clear_opencode_launch_mode "$name" || return 1 fi acq_spin_start "Creating sandbox '$name'" sbx create --name "$name" ${_cf[@]+"${_cf[@]}"} ${_ef[@]+"${_ef[@]}"} ${_tf[@]+"${_tf[@]}"} "${kf[@]}" ${_sbx_args[@]+"${_sbx_args[@]}"} @@ -625,7 +616,13 @@ acq_backend_provision() { fi acq_spin_stop "Waiting for kit startup in '$name'" acq_host_config_clear_instance_state sbx "$name" - acq_provenance_write sbx "$name" "$agent" || true + if ! acq_provenance_write sbx "$name" "$agent"; then + echo "acq(sbx): error: could not record provenance for '$name'." >&2 + echo " Removing the new sandbox because safe reattach requires complete state." >&2 + acq_backend_terminate "$name" >/dev/null 2>&1 || \ + echo "acq(sbx): warning: could not remove '$name'; run 'acq rm $name' before retrying." >&2 + return 1 + fi if [ "$agent" = "opencode" ]; then # Reattach grammar is determined by the create-time SBX agent token. Persist # it host-side so a missing record never guesses between shell and OpenCode. @@ -633,8 +630,9 @@ acq_backend_provision() { [ "${ACQ_DEFAULT_OPENCODE_GENERIC:-}" = "1" ] && _opencode_mode=generic-opencode if ! acq_host_config_write sbx "$name" generic-opencode "$_opencode_mode"; then echo "acq(sbx): error: could not record OpenCode launch mode for '$name'." >&2 - echo " Refusing to attach because acq cannot safely choose SBX grammar." >&2 - echo " Remove and recreate this sandbox." >&2 + echo " Removing the new sandbox because acq cannot safely choose SBX grammar." >&2 + acq_backend_terminate "$name" >/dev/null 2>&1 || \ + echo "acq(sbx): warning: could not remove '$name'; run 'acq rm $name' before retrying." >&2 return 1 fi fi diff --git a/docs/adr/0030-agent-kits-on-devenv-base.md b/docs/adr/0030-agent-kits-on-devenv-base.md index 3d137f48..67a1a66d 100644 --- a/docs/adr/0030-agent-kits-on-devenv-base.md +++ b/docs/adr/0030-agent-kits-on-devenv-base.md @@ -382,18 +382,18 @@ as the image override; and `volumes:` mounted at boot before any exec. The login.gov team's `scripts/verify` asserts each of these against a real `--clone` sandbox on the active backend and can gate the refactor. -### Migration sequencing - -The `acq run opencode .` behavior changes in the staged sequence documented in -[`docs/BACKEND_GUIDE.md`](../BACKEND_GUIDE.md#adr-0030-generic-base-implementation): -freeze the current contract first, introduce agent-kit inputs without changing -dispatch, switch OpenCode provisioning to consume the agent kit, move -remaining devenv-specific setup behind the kit boundary, then remove obsolete -wrapper code. Each increment has explicit offline and live verification commands. -The Increment 0 freeze is implemented in -[`test/bats/140-opencode-migration-gate.bats`](../../test/bats/140-opencode-migration-gate.bats), -which keeps the current backend-neutral contract green while the migration -continues. +### Implemented rollout and verification + +The default `acq run opencode .` path now selects the pinned generic devenv base +and pinned OpenCode kit on both backends. Explicit image overrides retain their +documented precedence, and SBX uses its neutral `shell` launcher token while +retaining OpenCode provenance and the kit entrypoint for attachment. The current +backend behavior and verification commands are documented in +[`docs/BACKEND_GUIDE.md`](../BACKEND_GUIDE.md#adr-0030-generic-base-implementation). + +[`test/bats/140-opencode-migration-gate.bats`](../../test/bats/140-opencode-migration-gate.bats) +exercises the offline create and reattach contract. The backend verifier performs +the corresponding live checks when a sandbox-capable host is available. ## Open Questions diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 7f73bab8..3076b785 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -116,9 +116,12 @@ case "${1:-}" in version) printf 'sbx version: v%s abc123\n' "${STUB_SBX_VERSION:-0.39.0}" ;; --help|-h) printf 'SBX-TOPLEVEL-HELP\n' ;; ls) + [ "${STUB_SBX_LS_FAIL:-0}" = "1" ] && exit 1 [ -f "$STUBDIR/.sandbox_list" ] && cat "$STUBDIR/.sandbox_list" exit 0 ;; - create) : >"$STUBDIR/.created"; exit 0 ;; + create) + [ "${STUB_SBX_CREATE_FAIL:-0}" = "1" ] && exit 1 + : >"$STUBDIR/.created"; exit 0 ;; exec) snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done diff --git a/test/bats/115-ssh-agent-forward.bats b/test/bats/115-ssh-agent-forward.bats index f44ac6f5..a1a2927c 100644 --- a/test/bats/115-ssh-agent-forward.bats +++ b/test/bats/115-ssh-agent-forward.bats @@ -410,6 +410,23 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] assert_output --partial 'RC=1' } +@test "vsock: route probe accepts a large inspect document under pipefail" { + printf '%s\n' '{"vsock":{"routes":[{"host_socket":"/private/var/run/x/Listeners","port":3552}]}}' \ + >"$STUBDIR/.msb_inspect_json" + local padding + padding=$(printf '%4096s' '') + printf '%s\n' "{\"padding\":\"$padding\"}" >>"$STUBDIR/.msb_inspect_json" + + run bash -c ' + set -o pipefail + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + _acq_msb_has_ssh_agent_vsock_route pbox + ' + + assert_success +} + @test "vsock(10c19): a stale --vsock route (host reboot) warns with the recreate remedy, not silently" { # After the bridge + marker are (re)placed, the forwarded-agent liveness probe # runs `ssh-add -l` over the guest sock. The reboot dead-bridge signature is diff --git a/test/bats/140-opencode-migration-gate.bats b/test/bats/140-opencode-migration-gate.bats index ed37bea7..1503ef12 100644 --- a/test/bats/140-opencode-migration-gate.bats +++ b/test/bats/140-opencode-migration-gate.bats @@ -288,6 +288,35 @@ SPEC assert_regex "$(cat "$CALLS")" 'msb exec -t -u agent -w .* opencode-envclone-msb -- opencode --version' } +@test "migration gate(msb): generic-state persistence failure removes the new sandbox" { + local proj="$STUBDIR/msb-generic-state-write-fail" + _mk_repo "$proj" + acq_host_config_write() { return 1; } + + run env ACQ_MSB_KIT_PASSTHROUGH=1 \ + bash -c '. "$1/acq.backends/common.sh"; . "$1/acq.backends/msb.sh"; acq_host_config_write() { return 1; }; acq_backend_provision msbgenericstatefail opencode "$2"' \ + _ "$REPO_ROOT" "$proj" + + assert_failure + assert_output --partial 'could not record completed generic OpenCode state' + assert_regex "$(cat "$CALLS")" 'msb remove --force msbgenericstatefail' +} + +@test "migration gate(msb): failed required OpenCode kit removes the new sandbox" { + local proj="$STUBDIR/msb-generic-kit-fail" + _mk_repo "$proj" + + run env ACQ_MSB_KIT_PASSTHROUGH=1 \ + bash -c '. "$1/acq.backends/common.sh"; . "$1/acq.backends/msb.sh"; _acq_msb_apply_kit_dir() { return 1; }; acq_backend_provision msbgenerickitfail opencode "$2"' \ + _ "$REPO_ROOT" "$proj" + + assert_failure + assert_output --partial 'required OpenCode kit did not fully apply' + assert_regex "$(cat "$CALLS")" 'msb remove --force msbgenerickitfail' + run acq_host_config_has msb msbgenerickitfail generic-opencode + assert_failure +} + @test "migration gate(msb): acq exec remains non-login; acq shell uses the agent login shell" { printf 'migbox\n' >"$STUBDIR/.msb_sandbox_list" printf 'migbox\n' >"$STUBDIR/.msb_running_list" @@ -397,6 +426,20 @@ SPEC assert_regex "$(_create_line sbx)" ' shell ' run acq_host_config_has sbx genericwritefail generic-opencode assert_failure + assert_regex "$(cat "$CALLS")" 'sbx rm --force genericwritefail' +} + +@test "migration gate(sbx): provenance persistence failure removes the new sandbox" { + local proj="$STUBDIR/sbx-generic-provenance-write-fail" + _mk_repo "$proj" + _seed_sbx_usai + acq_provenance_write() { return 1; } + + run acq_backend_provision genericprovenancefail opencode "$proj" + + assert_failure + assert_output --partial 'could not record provenance' + assert_regex "$(cat "$CALLS")" 'sbx rm --force genericprovenancefail' refute_regex "$(cat "$CALLS")" '^sbx run ' } @@ -450,6 +493,21 @@ SPEC refute_regex "$(cat "$CALLS")" '^sbx create ' } +@test "migration gate(sbx): unavailable name lookup and failed create retain launch mode" { + local proj="$STUBDIR/sbx-generic-mode-ls-fail" + _mk_repo "$proj" + acq_host_config_write sbx genericmodelsfail generic-opencode generic-opencode + + run env STUB_SBX_LS_FAIL=1 STUB_SBX_CREATE_FAIL=1 \ + bash -c '. "$1/acq.backends/common.sh"; . "$1/acq.backends/sbx.sh"; acq_backend_provision genericmodelsfail opencode "$2"' \ + _ "$REPO_ROOT" "$proj" + + assert_failure + assert_equal "$(acq_host_config_read sbx genericmodelsfail generic-opencode)" generic-opencode + assert_regex "$(cat "$CALLS")" 'sbx create --name genericmodelsfail' + refute_regex "$(cat "$CALLS")" 'sbx rm --force genericmodelsfail' +} + @test "migration gate(sbx): replacement create overwrites a stale launch mode" { local proj="$STUBDIR/sbx-generic-mode-replacement" _mk_repo "$proj" diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 7edb65a8..e8192d2b 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -396,6 +396,20 @@ FACTS refute_output --partial 'does not appear runnable' } +@test "run(opencode): generic default reattach does not run an unpinned npm postinstall fallback" { + local proj="$STUBDIR/ocreattach"; mkdir -p "$proj" + _seed_usai + run env STUB_KEY_STATUS=200 ACQ_BACKEND=sbx "$ACQ" create opencode --name ocreattach "$proj" + assert_success + printf 'ocreattach\n' >"$STUBDIR/.sandbox_list" + : >"$CALLS" + + run env ACQ_UPDATE_CHECK=0 ACQ_BACKEND=sbx "$ACQ" run ocreattach + + assert_success + refute_regex "$(cat "$CALLS")" 'postinstall\.mjs|opencode-ai|npm root -g' +} + @test "run(opencode): skips postinstall when already runnable" { local proj="$STUBDIR/ocok"; mkdir -p "$proj" _seed_usai @@ -619,13 +633,13 @@ FACTS assert_regex "$(cat "$CALLS")" 'acq-opencode-kit' } -@test "agent-kits: explicit --kit suppresses implicit selection notice" { +@test "agent-kits: explicit --kit still reports the selected agent kit" { local proj="$STUBDIR/agentkit-explicit"; mkdir -p "$proj" _seed_usai run env ACQ_BACKEND=sbx "$ACQ" run opencode --kit /tmp/team-opencode "$proj" assert_success - refute_output --partial 'built-in agent kit candidate' + assert_output --partial 'selected built-in agent kit: agent=opencode kit=opencode' local create_line; create_line=$(grep '^sbx create' "$CALLS") assert_regex "$create_line" '--kit /tmp/team-opencode' refute_regex "$create_line" 'acq-kits/opencode' diff --git a/test/bats/35-image-override.bats b/test/bats/35-image-override.bats index 05cf45b1..332216a9 100644 --- a/test/bats/35-image-override.bats +++ b/test/bats/35-image-override.bats @@ -118,6 +118,26 @@ _create_line() { printf '%s\n' "$(cat "$CALLS")" | grep "^$1 create"; } refute_regex "$(_create_line msb)" 'sandbox-templates:cursor-docker' } +@test "image(msb): missing non-OpenCode agent image falls back to shell" { + _msb_create STUB_MSB_CREATE_FAIL_IMAGE=docker.io/docker/sandbox-templates:claude-code-docker -- \ + create claude --name imgclaudefallback "$IMGPROJ" + + assert_success + assert_output --partial 'agent-specific image' + assert_output --partial 'falling back' + assert_regex "$(cat "$CALLS")" 'msb create .*claude-code-docker' + assert_regex "$(cat "$CALLS")" 'msb create .*shell-docker' +} + +@test "image(msb): auth failure for a non-OpenCode agent image does not fall back" { + _msb_create STUB_MSB_CREATE_FAIL_IMAGE=docker.io/docker/sandbox-templates:claude-code-docker \ + STUB_MSB_CREATE_FAIL_MESSAGE=unauthorized -- create claude --name imgclaudeauth "$IMGPROJ" + + assert_failure + refute_output --partial 'falling back' + refute_regex "$(cat "$CALLS")" 'msb create .*shell-docker' +} + @test "image(msb): explicit ACQ_MSB_IMAGE beats the agent-derived default" { # 3-way precedence: ACQ_MSB_IMAGE must win over a known agent's derived image # (not just over the neutral --image/ACQ_IMAGE tier). diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 70a865bf..becd85b6 100644 --- a/test/bats/50-kit-list-completeness.bats +++ b/test/bats/50-kit-list-completeness.bats @@ -212,13 +212,14 @@ SPEC assert_equal "$ACQ_BUILTIN_KIT_COUNT" "5" } -@test "agent-kits: explicit CLI kit suppresses implicit agent-kit inference" { +@test "agent-kits: explicit CLI kit preserves the selected-kit notice" { load_acq ACQ_EXTRA_KITS="" ACQ_CLI_KITS=(/tmp/team-opencode) run acq_selected_agent_kit_summary opencode - assert_failure + assert_success + assert_output --partial 'agent=opencode kit=opencode' _build_kit_list opencode local joined; joined=$(printf '%s\n' "${KITS[@]}") assert_regex "$joined" '/tmp/team-opencode'