diff --git a/acq b/acq index 68daa55..ac643bb 100755 --- a/acq +++ b/acq @@ -1303,11 +1303,12 @@ 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_instance_uses_generic_opencode "$ACQ_RESOLVED_BACKEND" "$local_name"; then ensure_opencode_postinstall "$local_name" fi diff --git a/acq.backends/agents.sh b/acq.backends/agents.sh index df6fddc..9883eee 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 9d3223e..dbf4b51 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 @@ -913,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="" @@ -946,8 +946,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 @@ -2443,10 +2451,20 @@ 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 } +# 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 e1fd642..d7222bf 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" @@ -836,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. @@ -844,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" } # --------------------------------------------------------------------------- @@ -861,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" } # --------------------------------------------------------------------------- @@ -3108,6 +3117,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,8 +3156,12 @@ 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[@]}") + 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") || { @@ -3763,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). @@ -4543,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 @@ -4568,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 @@ -4581,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 1c17444..98ee7f0 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" } # --------------------------------------------------------------------------- @@ -510,6 +512,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 @@ -527,6 +537,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=() @@ -547,6 +577,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="" @@ -556,9 +587,18 @@ 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[*]:-}" + # 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 + fi 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 @@ -575,7 +615,27 @@ acq_backend_provision() { return 1 fi acq_spin_stop "Waiting for kit startup in '$name'" - acq_provenance_write sbx "$name" "$agent" || true + acq_host_config_clear_instance_state sbx "$name" + 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. + 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 " 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 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,10 +778,49 @@ acq_backend_workspace_for() { _acq_sbx_attach_command() { local name="$1" agent agent=$(acq_backend_recorded_agent "$name") + [ "$agent" = "opencode" ] && acq_agent_kit_entrypoint opencode && return 0 [ -n "$agent" ] || agent="bash" 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 + case "$(acq_host_config_read sbx "$name" generic-opencode 2>/dev/null || true)" in + generic-opencode|1) + printf '%s\n' shell + ;; + *) + agent=$(acq_backend_recorded_agent "$name") + printf '%s\n' "${agent:-shell}" + ;; + 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 +} + # --------------------------------------------------------------------------- # acq_backend_run — run a command inside a sandbox # --------------------------------------------------------------------------- @@ -770,32 +869,61 @@ 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 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") ws=$(acq_backend_workspace_for "$name") if [ "$#" -gt 0 ] && [ "$1" = "--" ]; then shift + local _attach_cmd=() _sbx_args=() + if [ "$agent" = "opencode" ] \ + && [ "$generic_opencode" = "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_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 "$agent" "$@" + _sbx_args=(sh -c "$(acq_guest_exec_script)" sh "${_attach_cmd[@]}") fi else - sbx run --name "$name" -- "$@" + _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 - sbx run --name "$name" + if [ "$agent" = "opencode" ] \ + && [ "$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/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index f541182..ef03493 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,20 @@ 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. +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. -**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 +1201,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 f6eaa60..642dfa3 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 7978e22..67a1a66 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 @@ -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`. @@ -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 @@ -381,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 +### Implemented rollout and verification -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): -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. +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 @@ -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/test-acq-lib.sh b/scripts/test-acq-lib.sh index 69039cb..3076b78 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 @@ -193,7 +196,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/scripts/verify-backends b/scripts/verify-backends index 0210ed0..af1100b 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 "" @@ -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/115-ssh-agent-forward.bats b/test/bats/115-ssh-agent-forward.bats index f44ac6f..a1a2927 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 57a54c3..1503ef1 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" { @@ -279,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" @@ -322,7 +360,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" { @@ -339,5 +377,148 @@ 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 shell -- -lc exec.*sh opencode --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" + 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 + assert_success + 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 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 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 + 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 ' +} + +@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): 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" + _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 ' } diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index 2e477e0..e8192d2 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -386,17 +386,30 @@ 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' } +@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 @@ -490,7 +503,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' } @@ -502,7 +515,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' @@ -515,7 +528,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' } @@ -620,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 9367a94..332216a 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" { @@ -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). @@ -127,31 +147,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 +182,12 @@ _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' + 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" { diff --git a/test/bats/50-kit-list-completeness.bats b/test/bats/50-kit-list-completeness.bats index 70a865b..becd85b 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' diff --git a/test/bats/90-sbx-startup-kit.bats b/test/bats/90-sbx-startup-kit.bats index 12da072..8fe6091 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.* shell -- -lc exec.*sh 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" {