diff --git a/acq b/acq index 83a37925..c48cead2 100755 --- a/acq +++ b/acq @@ -188,7 +188,7 @@ HELP printf '%s%s' "$_backend_sep" "$_backend_name" _backend_sep=', ' done - printf '. OpenShell is explicit-only until its adapter is available.\n' + printf '. OpenShell is explicit-only and experimental.\n' } # _acq_secret_usage / _acq_kit_usage — acq's OWN per-verb usage blocks. Printed @@ -761,7 +761,13 @@ case "$subcommand" in printf 'Registered backends:\n' for local_backend in "${ACQ_BACKEND_NAMES[@]}"; do if acq_backend_is_explicit_only "$local_backend"; then - printf ' %s (explicit-only; adapter not yet available)\n' "$local_backend" + _ensure_local_bin_on_path "$local_backend" >/dev/null 2>&1 || true + if command -v "$local_backend" >/dev/null 2>&1; then + local_ver=$(acq_backend_cli_version "$local_backend") + printf ' %s v%s (explicit-only; experimental)\n' "$local_backend" "$local_ver" + else + printf ' %s (explicit-only; not found)\n' "$local_backend" + fi else _ensure_local_bin_on_path "$local_backend" >/dev/null 2>&1 || true if command -v "$local_backend" >/dev/null 2>&1; then @@ -1097,9 +1103,13 @@ case "$subcommand" in # would silently re-pin the host port of every later sandbox in the shell. fi extract_kit_flags "$@" + if [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] && [ "${#ACQ_CLI_KITS[@]}" -gt 0 ]; then + echo "acq(openshell): --kit is unsupported; OpenShell kit translation is not implemented." >&2 + exit 1 + fi _acq_kit_agent="" _acq_kit_first=$(first_positional "${ACQ_KIT_REMAINING[@]}" || true) - if [ -n "$_acq_kit_first" ] && is_known_agent "$_acq_kit_first"; then + if [ -n "$_acq_kit_first" ] && acq_is_known_agent "$_acq_kit_first"; then _acq_kit_agent="$_acq_kit_first" fi _build_kit_list "$_acq_kit_agent" @@ -1128,18 +1138,24 @@ case "$subcommand" in create) shift - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" acq_backend_prepare # First-run onboarding (ADR-0031): offer `acq configure` once before we # provision, so a brand-new user picks kits up front. No-op after first run # and in non-interactive sessions. - maybe_first_run_configure + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || maybe_first_run_configure # Apply configured-default extra kits (config.yaml), then offer a create-time # picker pre-populated with those defaults so THIS sandbox can deviate. create_agent=$(first_positional "$@") - _acq_apply_configured_extra_kits || exit 1 - create_time_kit_picker - _build_kit_list "$create_agent" + if [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] \ + && ! acq_backend_validate_agent "$create_agent"; then + exit 1 + fi + if [ "${ACQ_RESOLVED_BACKEND}" != "openshell" ]; then + _acq_apply_configured_extra_kits || exit 1 + create_time_kit_picker + _build_kit_list "$create_agent" + fi # Pre-flight: validate the workspace path before provisioning (#issue). ws=$(workspace_path "$@") preflight_workspace_path "$ws" || exit 1 @@ -1150,7 +1166,7 @@ case "$subcommand" in # at create time, so a sandbox created before the key exists carries no USAi # binding. Gate on key presence first (fail closed if declined) so create # picks it up. `create` is otherwise detached and never attaches. - if ! ensure_key_present "$local_name"; then + if [ "${ACQ_RESOLVED_BACKEND}" != "openshell" ] && ! ensure_key_present "$local_name"; then exit 1 fi @@ -1161,9 +1177,9 @@ case "$subcommand" in # create. Warn-not-block (ADR-0013): declining never aborts the create, it # just proceeds without a scoped token (the token is optional). [ -n "$ws" ] || ws="$PWD" - advise_github_scope "$local_name" "$ws" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || advise_github_scope "$local_name" "$ws" advise_project_env_activation "$ws" - acq_print_selected_agent_kit "$create_agent" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || acq_print_selected_agent_kit "$create_agent" acq_backend_provision "$local_name" "$@" @@ -1171,7 +1187,7 @@ case "$subcommand" in # so unlike `run` we don't gate on the key or offer interactive rotation — # just warn if it's already invalid so a create-only user knows before they # later `acq run`. - advise_valid_key "$local_name" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || advise_valid_key "$local_name" ;; run) @@ -1195,12 +1211,16 @@ case "$subcommand" in first="" [ "${#create_args[@]}" -gt 0 ] && first=$(first_positional "${create_args[@]}") - if [ -n "$first" ] && is_known_agent "$first"; then - acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + if [ -n "$first" ] && acq_is_known_agent "$first"; then + if [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] \ + && ! acq_backend_validate_agent "$first"; then + exit 1 + fi + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" # AGENT form: create if missing, then attach. local_name=$(derive_name "${create_args[@]}") || exit 1 [ -n "$local_name" ] || { echo "acq: could not determine sandbox name" >&2; exit 1; } - acq_print_selected_agent_kit "$first" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || acq_print_selected_agent_kit "$first" # Pre-flight: validate the workspace path before provisioning (#issue). # Only enforce on a fresh create — a re-attach to an existing sandbox @@ -1241,7 +1261,7 @@ case "$subcommand" in # this invocation, so we must not clobber it — hence the empty-guard. # See acq_cli_kits_load and the "--kit Services Dead After a # Resume/Reboot" section in docs/KNOWN_FAILURE_MODES.md. - if [ "${#ACQ_CLI_KITS[@]}" -eq 0 ]; then + if [ "${ACQ_RESOLVED_BACKEND}" != "openshell" ] && [ "${#ACQ_CLI_KITS[@]}" -eq 0 ]; then acq_cli_kits_load "${ACQ_RESOLVED_BACKEND}" "$local_name" fi acq_backend_ensure_kits_applied "$local_name" || true @@ -1257,7 +1277,7 @@ case "$subcommand" in # Fresh creates run the advisory BEFORE provision (below) so msb binds it # at create; this re-attach case is the live-refeed path. [ -n "$ws" ] || ws="$PWD" - advise_github_scope "$local_name" "$ws" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || advise_github_scope "$local_name" "$ws" advise_project_env_activation "$ws" else # Fresh create: the USAi API key MUST be stored before we provision, @@ -1272,12 +1292,14 @@ case "$subcommand" in # is a no-op after first run / non-interactive; the picker pre-populates # from the configured defaults and folds the result into ACQ_EXTRA_KITS # for this create (persisted per-sandbox by the existing kit-record path). - maybe_first_run_configure - _acq_apply_configured_extra_kits || exit 1 - create_time_kit_picker - _build_kit_list "$first" + if [ "${ACQ_RESOLVED_BACKEND}" != "openshell" ]; then + maybe_first_run_configure + _acq_apply_configured_extra_kits || exit 1 + create_time_kit_picker + _build_kit_list "$first" + fi - if ! ensure_key_present "$local_name"; then + if [ "${ACQ_RESOLVED_BACKEND}" != "openshell" ] && ! ensure_key_present "$local_name"; then exit 1 fi @@ -1288,7 +1310,7 @@ case "$subcommand" in # create. Warn-not-block (ADR-0013): declining never aborts, it just # proceeds without a scoped token (the token is optional). [ -n "$ws" ] || ws="$PWD" - advise_github_scope "$local_name" "$ws" + [ "${ACQ_RESOLVED_BACKEND}" = "openshell" ] || advise_github_scope "$local_name" "$ws" advise_project_env_activation "$ws" acq_backend_provision "$local_name" "${create_args[@]}" @@ -1298,7 +1320,7 @@ case "$subcommand" in # abort explicitly on a non-zero return rather than relying on `set -e` # to propagate from a bare function call (its behavior for a function # used as the tested command is not portable across bash versions). - if ! ensure_valid_key "$local_name" "${create_args[@]}"; then + if [ "${ACQ_RESOLVED_BACKEND}" != "openshell" ] && ! ensure_valid_key "$local_name" "${create_args[@]}"; then exit 1 fi diff --git a/acq.backends/common.sh b/acq.backends/common.sh index fe0f5b2c..8b1f946d 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -3687,8 +3687,10 @@ acq_print_doctor() { echo "acq: backend health check" for backend in "${ACQ_BACKEND_NAMES[@]}"; do - if acq_backend_is_explicit_only "$backend"; then - status="explicit-only; adapter not yet available" + if acq_backend_is_explicit_only "$backend" && command -v "$backend" >/dev/null 2>&1; then + status="installed v$(acq_backend_cli_version "$backend") (explicit-only; experimental)" + elif acq_backend_is_explicit_only "$backend"; then + status="explicit-only; not found" elif command -v "$backend" >/dev/null 2>&1; then status="installed v$(acq_backend_cli_version "$backend")" else diff --git a/acq.backends/openshell.sh b/acq.backends/openshell.sh new file mode 100644 index 00000000..9c884def --- /dev/null +++ b/acq.backends/openshell.sh @@ -0,0 +1,251 @@ +#!/bin/bash +# +# Experimental OpenShell lifecycle adapter. OpenShell 0.1.4 was inspected locally; +# this adapter intentionally covers only the local Docker gateway lifecycle. + +# shellcheck disable=SC2034 +ACQ_BACKEND_NAME="openshell" +# shellcheck disable=SC2034 +ACQ_BACKEND_SUPPORTS_PORT_FORWARD=0 +# shellcheck disable=SC2034 +ACQ_BACKEND_SUPPORTS_SNAPSHOTS=0 +# shellcheck disable=SC2034 +ACQ_BACKEND_CAN_RESUME=1 +# shellcheck disable=SC2034 +ACQ_BACKEND_SUPPORTS_CREDENTIAL_REWRITE=0 +# shellcheck disable=SC2034 +ACQ_BACKEND_SUPPORTS_PROVIDERS=0 +# shellcheck disable=SC2034 +ACQ_BACKEND_SUPPORTS_KITS=0 + +MIN_OPENSHELL_VERSION="0.1.4" +ACQ_OPENSHELL_DRIVER="${ACQ_OPENSHELL_DRIVER:-docker}" +ACQ_OPENSHELL_WORKSPACE_MODE="${ACQ_OPENSHELL_WORKSPACE_MODE:-bind}" +ACQ_OPENSHELL_ALLOW_BIND_MOUNTS="${ACQ_OPENSHELL_ALLOW_BIND_MOUNTS:-}" +ACQ_OPENSHELL_IMAGE="${ACQ_OPENSHELL_IMAGE:-docker.io/docker/sandbox-templates:shell-docker}" +ACQ_OPENSHELL_READY_TIMEOUT="${ACQ_OPENSHELL_READY_TIMEOUT:-60}" +ACQ_OPENSHELL_CLONES_DIR="${ACQ_OPENSHELL_CLONES_DIR:-${ACQ_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq}/openshell-clones}" + +_acq_openshell_cli() { + openshell "$@" +} + +_acq_openshell_safe_name() { + case "$1" in ''|*[!A-Za-z0-9._-]*) return 1 ;; *) return 0 ;; esac +} + +_acq_openshell_safe_path() { + case "$1" in /*) ;; *) return 1 ;; esac + case "$1" in *[!A-Za-z0-9._/-]*) return 1 ;; esac + return 0 +} + +_acq_openshell_version() { + _acq_openshell_cli --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 +} + +acq_backend_check_version() { + if ! command -v openshell >/dev/null 2>&1; then + echo "error: openshell CLI not found on PATH; install OpenShell $MIN_OPENSHELL_VERSION." >&2 + exit 1 + fi + local version + version=$(_acq_openshell_version) + if [ "$version" != "$MIN_OPENSHELL_VERSION" ]; then + echo "error: acq experimental OpenShell support requires openshell $MIN_OPENSHELL_VERSION, found ${version:-unknown}." >&2 + echo " Refusing an unverified CLI/gateway contract." >&2 + exit 1 + fi +} + +acq_backend_prepare() { + acq_backend_check_version + if ! _acq_openshell_cli status --output json >/dev/null 2>&1; then + echo "acq(openshell): no usable selected gateway." >&2 + echo " Select a reviewed local gateway with 'openshell gateway select NAME'." >&2 + echo " Remote gateways and non-Docker drivers are unsupported by this adapter." >&2 + return 1 + fi + case "$ACQ_OPENSHELL_WORKSPACE_MODE" in + bind) + if [ "$ACQ_OPENSHELL_DRIVER" != "docker" ] || [ "$ACQ_OPENSHELL_ALLOW_BIND_MOUNTS" != "1" ]; then + echo "acq(openshell): bind mode requires ACQ_OPENSHELL_DRIVER=docker and" >&2 + echo " ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 after local-gateway mount enablement." >&2 + echo " Set ACQ_OPENSHELL_WORKSPACE_MODE=upload only for the explicit copy fallback." >&2 + return 1 + fi + ;; + upload) ;; + *) echo "acq(openshell): invalid ACQ_OPENSHELL_WORKSPACE_MODE='$ACQ_OPENSHELL_WORKSPACE_MODE' (bind|upload)." >&2; return 1 ;; + esac +} + +acq_backend_exists() { + local name="$1" result + _acq_openshell_safe_name "$name" || return 1 + result=$(_acq_openshell_cli sandbox get "$name" --output json 2>/dev/null) || return 1 + printf '%s' "$result" | grep -Eq "\"name\"[[:space:]]*:[[:space:]]*\"${name}\"" +} + +_acq_openshell_wait_ready() { + local name="$1" deadline + deadline=$(( $(date +%s) + ACQ_OPENSHELL_READY_TIMEOUT )) + while :; do + if _acq_openshell_cli sandbox exec "$name" --no-login-shell -- true /dev/null 2>&1; then + return 0 + fi + [ "$(date +%s)" -ge "$deadline" ] && break + sleep 2 + done + echo "acq(openshell): sandbox '$name' did not become ready within ${ACQ_OPENSHELL_READY_TIMEOUT}s." >&2 + return 1 +} + +_acq_openshell_clone_setup() { + local name="$1" workspace="$2" top scratch + top=$(git -C "$workspace" rev-parse --show-toplevel 2>/dev/null) || { + echo "acq(openshell): --clone requires a git workspace." >&2; return 1; } + [ "$top" = "$workspace" ] || { + echo "acq(openshell): --clone requires the repository root, not '$workspace'." >&2; return 1; } + _acq_openshell_safe_name "$name" || return 1 + scratch="$ACQ_OPENSHELL_CLONES_DIR/$name/workspace" + [ ! -e "$scratch" ] || { echo "acq(openshell): scratch clone already exists: $scratch" >&2; return 1; } + ( umask 077; mkdir -p "${scratch%/*}" ) || return 1 + git clone --quiet --no-hardlinks -- "$workspace" "$scratch" || return 1 + git -C "$workspace" remote add "sandbox-$name" "$scratch" 2>/dev/null || { + rm -rf "${scratch%/*}"; return 1; } + _ACQ_OPENSHELL_CLONE_DIR="$scratch" +} + +_acq_openshell_clone_cleanup() { + local name="$1" workspace="${2:-}" scratch="${3:-}" + [ -n "$workspace" ] || workspace=$(acq_host_config_read openshell "$name" workspace) + [ -n "$scratch" ] || scratch=$(acq_host_config_read openshell "$name" clone-source) + [ -n "$workspace" ] && git -C "$workspace" remote remove "sandbox-$name" >/dev/null 2>&1 || true + [ -n "$scratch" ] && rm -rf "${scratch%/*}" 2>/dev/null || true +} + +# Neutral agent kits do not yet provide OpenShell entrypoints. Keep this +# increment shell-only rather than attaching a generic shell for another agent. +acq_backend_validate_agent() { + if [ "$1" != "shell" ]; then + echo "acq(openshell): '$1' requires OpenShell neutral kit translation and an agent entrypoint (GSA-TTS/agentic-coding-quickstart#577)." >&2 + echo " This lifecycle increment supports only: acq --backend openshell create shell PATH" >&2 + return 1 + fi +} + +_acq_openshell_driver_config() { + local source="$1" target="$2" + _acq_openshell_safe_path "$source" && _acq_openshell_safe_path "$target" || return 1 + printf '{"docker":{"mounts":[{"source":"%s","target":"%s","type":"bind","read_only":false}]}}' "$source" "$target" +} + +acq_backend_provision() { + local name="$1"; shift + local agent workspace source image driver_config output rc=0 create_flags=() + agent=$(first_positional "$@") + workspace=$(workspace_path "$@") + [ -n "$workspace" ] || workspace="$PWD" + workspace=$(canonicalize_path "$workspace") + _acq_openshell_safe_name "$name" && _acq_openshell_safe_path "$workspace" || { + echo "acq(openshell): unsafe sandbox name or workspace path." >&2; return 1; } + source="$workspace" + if [ "${ACQ_CLONE:-0}" = "1" ]; then + [ "$ACQ_OPENSHELL_WORKSPACE_MODE" = "bind" ] || { + echo "acq(openshell): --clone requires controlled bind mode; upload fallback cannot provide fetch-back semantics." >&2; return 1; } + _acq_openshell_clone_setup "$name" "$workspace" || return 1 + source="$_ACQ_OPENSHELL_CLONE_DIR" + fi + image=$(acq_resolve_neutral_image 2>/dev/null || true) + [ -n "$image" ] || image="$ACQ_OPENSHELL_IMAGE" + acq_host_config_clear_instance_state openshell "$name" + create_flags=(sandbox create --name "$name" --from "$image" --env "ACQ_WORKSPACE=$workspace") + [ "${ACQ_CLONE:-0}" = "1" ] && create_flags+=(--env "ACQ_CLONE=1") + if [ "$ACQ_OPENSHELL_WORKSPACE_MODE" = "bind" ]; then + driver_config=$(_acq_openshell_driver_config "$source" "$workspace") || return 1 + create_flags+=(--driver-config-json "$driver_config") + else + create_flags+=(--upload "$source:$workspace") + fi + output=$(_acq_openshell_cli "${create_flags[@]}" --detach --output json 2>&1) || rc=$? + if [ "$rc" -ne 0 ]; then + echo "$output" >&2 + if [ "$ACQ_OPENSHELL_WORKSPACE_MODE" = "bind" ]; then + echo "acq(openshell): bind mount creation failed; no upload/download fallback was selected." >&2 + fi + [ -n "${_ACQ_OPENSHELL_CLONE_DIR:-}" ] && _acq_openshell_clone_cleanup "$name" "$workspace" "$_ACQ_OPENSHELL_CLONE_DIR" + return "$rc" + fi + _acq_openshell_wait_ready "$name" || { + _acq_openshell_cli sandbox delete "$name" >/dev/null 2>&1 || true + [ -n "${_ACQ_OPENSHELL_CLONE_DIR:-}" ] && _acq_openshell_clone_cleanup "$name" "$workspace" "$_ACQ_OPENSHELL_CLONE_DIR" + return 1 + } + acq_host_config_write openshell "$name" agent "$agent" || true + acq_host_config_write openshell "$name" workspace "$workspace" || true + acq_host_config_write openshell "$name" workspace-mode "$ACQ_OPENSHELL_WORKSPACE_MODE" || true + [ -n "${_ACQ_OPENSHELL_CLONE_DIR:-}" ] && acq_host_config_write openshell "$name" clone-source "$_ACQ_OPENSHELL_CLONE_DIR" || true + acq_provenance_write openshell "$name" "$agent" "$workspace" || true + echo "acq(openshell): experimental local Docker driver; no providers, secrets, kits, or public services were configured." >&2 +} + +acq_backend_workspace_for() { acq_host_config_read openshell "$1" workspace; } +acq_backend_recorded_agent() { acq_host_config_read openshell "$1" agent; } + +acq_backend_run() { + local name="$1"; shift + _acq_openshell_cli sandbox exec "$name" "$@" +} + +# OpenShell attach is deliberately a shell session in this increment. Do not use +# a recorded non-shell agent until #577 supplies a verified agent entrypoint. +acq_backend_attach() { + local agent + agent=$(acq_backend_recorded_agent "$1") + if [ -n "$agent" ] && [ "$agent" != "shell" ]; then + acq_backend_validate_agent "$agent" + return 1 + fi + exec openshell sandbox connect "$1" +} +acq_backend_shell() { exec openshell sandbox exec --tty "$1" -- sh -l; } +acq_backend_stop() { _acq_openshell_cli sandbox stop "$1"; } +acq_backend_start() { _acq_openshell_cli sandbox start "$1" && _acq_openshell_wait_ready "$1"; } + +acq_backend_terminate() { + local name="$1" rc=0 + _acq_openshell_cli sandbox delete "$name" || rc=$? + [ "$rc" -eq 0 ] || return "$rc" + _acq_openshell_clone_cleanup "$name" + acq_host_config_remove openshell "$name" || true + return 0 +} + +acq_backend_list() { _acq_openshell_cli sandbox list --output json "$@"; } + +acq_backend_cp() { + local src="$1" dst="$2" name path + if [[ "$src" =~ ^([A-Za-z0-9._-]+):(/.*)$ ]]; then + name="${BASH_REMATCH[1]}"; path="${BASH_REMATCH[2]}" + _acq_openshell_safe_path "$path" || return 1 + _acq_openshell_cli sandbox download "$name" "$path" "$dst" + elif [[ "$dst" =~ ^([A-Za-z0-9._-]+):(/.*)$ ]]; then + name="${BASH_REMATCH[1]}"; path="${BASH_REMATCH[2]}" + _acq_openshell_safe_path "$path" || return 1 + _acq_openshell_cli sandbox upload "$name" "$src" "$path" + else + echo "acq(openshell): cp requires exactly one NAME:/sandbox/path endpoint." >&2 + return 1 + fi +} + +acq_backend_ports() { echo "acq(openshell): ports and public service exposure are unsupported." >&2; return 1; } +acq_backend_apply_kit() { echo "acq(openshell): neutral kit application is unsupported." >&2; return 1; } +acq_backend_ensure_kits_applied() { return 0; } +acq_backend_secret_set() { echo "acq(openshell): providers and secrets are unsupported." >&2; return 1; } +acq_backend_secret_rm() { echo "acq(openshell): providers and secrets are unsupported." >&2; return 1; } +acq_backend_rotate_key() { echo "acq(openshell): providers and key rotation are unsupported." >&2; return 1; } +acq_backend_version() { _acq_openshell_version; } +acq_backend_doctor() { printf 'openshell: experimental local Docker lifecycle adapter (no providers, kits, or services)\n'; } +acq_backend_doctor_sandbox() { _acq_openshell_cli sandbox get "$1" --output json; } diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 69039cb8..fb456396 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -214,6 +214,38 @@ esac STUB chmod +x "$STUBDIR/sbx" + # Stub OpenShell's structured lifecycle surface. JSON is deliberately emitted + # for list/get/status so adapter tests cannot accidentally rely on table output. + cat >"$STUBDIR/openshell" <<'OPENSHELLSTUB' +#!/usr/bin/env bash +_line="openshell"; for a in "$@"; do _line="$_line $a"; done +printf '%s\n' "$_line" >>"$CALLS" +case "${1:-}" in + --version|-V) printf 'openshell %s\n' "${STUB_OPENSHELL_VERSION:-0.1.4}" ;; + status) + [ "${STUB_OPENSHELL_NO_GATEWAY:-0}" = "1" ] && { printf 'no active gateway\n' >&2; exit 1; } + printf '{"gateway":"local","driver":"docker"}\n' ;; + doctor) exit "${STUB_OPENSHELL_DOCTOR_RC:-0}" ;; + sandbox) + case "${2:-}" in + get) + [ "${STUB_OPENSHELL_MISSING:-0}" = "1" ] && exit 1 + printf '{"name":"%s","status":"running"}\n' "${3:-box}" ;; + list) printf '[{"name":"box","status":"running"}]\n' ;; + create) + [ "${STUB_OPENSHELL_CREATE_FAIL:-0}" = "1" ] && { printf 'driver does not support bind mounts\n' >&2; exit 1; } + : >"$STUBDIR/.openshell_created"; printf '{"name":"box","status":"creating"}\n' ;; + exec) + [ "${STUB_OPENSHELL_NOT_READY:-0}" = "1" ] && exit 1 + case " $* " in *' opencode --version '*) printf 'opencode 1.18.12\n' ;; esac ;; + connect|stop|start|delete|upload|download) : ;; + *) exit 1 ;; + esac ;; + *) exit 1 ;; +esac +OPENSHELLSTUB + chmod +x "$STUBDIR/openshell" + # Stub `msb` (microsandbox) the same way, logging every call to $CALLS. # IMPORTANT: this stub DRAINS STDIN (`cat >/dev/null`) like a real `msb exec` # / `msb ssh` does. That is what surfaced the loop-stdin-consumption bug (a diff --git a/test/bats/20-backend-resolution.bats b/test/bats/20-backend-resolution.bats index d60049c5..cc29f8bd 100644 --- a/test/bats/20-backend-resolution.bats +++ b/test/bats/20-backend-resolution.bats @@ -82,7 +82,7 @@ _resolve() { # ADAPTER EXPLICIT -> prints "|" refute_output --partial 'acq: using' } -@test "backend: OpenShell registration is explicit-only and never auto-detected" { +@test "backend: OpenShell is explicit-only and never auto-detected" { unset ACQ_BACKEND export XDG_CONFIG_HOME="$STUBDIR/noconfig" export ACQ_TEST_INSTALLED_BACKENDS="openshell" @@ -95,8 +95,8 @@ _resolve() { # ADAPTER EXPLICIT -> prints "|" assert_output --partial 'no backend detected' run env ACQ_BACKEND=openshell "$ACQ" ls - assert_failure - assert_output --partial "no backend adapter found for 'openshell'" + assert_success + assert_output --partial '"name":"box"' } @test "backend: both installed, no sbx sandboxes -> msb + notice" { diff --git a/test/bats/69-openshell-backend.bats b/test/bats/69-openshell-backend.bats new file mode 100644 index 00000000..f8d7579a --- /dev/null +++ b/test/bats/69-openshell-backend.bats @@ -0,0 +1,114 @@ +#!/usr/bin/env bats + +# OpenShell is explicit-only. These tests cover the pinned 0.1.4 local Docker +# contract without requiring an actual gateway or Docker daemon. + +setup() { acq_setup_stubs; } +teardown() { acq_teardown_stubs; } + +load 'helper' + +_openshell_create() { + local project="$STUBDIR/project" + mkdir -p "$project" + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 \ + ACQ_OPENSHELL_DRIVER=docker "$ACQ" create shell "$project" +} + +@test "openshell: explicit backend resolves, lists, and reports its version" { + run env ACQ_BACKEND=openshell "$ACQ" ls + assert_success + assert_regex "$(cat "$CALLS")" 'openshell sandbox list --output json' + run env "$ACQ" backend list + assert_output --partial 'openshell v0.1.4 (explicit-only; experimental)' +} + +@test "openshell: bind create maps image and workspace to driver config" { + _openshell_create + assert_success + local line; line=$(grep '^openshell sandbox create' "$CALLS") + assert_regex "$line" '--from docker\.io/docker/sandbox-templates:shell-docker' + assert_regex "$line" 'driver-config-json.*"type":"bind"' + assert_regex "$line" 'ACQ_WORKSPACE=' + assert [ -f "$STUBDIR/.openshell_created" ] +} + +@test "openshell: neutral ACQ_IMAGE is creation-only --from input" { + local project="$STUBDIR/image-project"; mkdir -p "$project" + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 ACQ_OPENSHELL_DRIVER=docker \ + ACQ_IMAGE=registry.example/acq:test "$ACQ" create shell "$project" + assert_success + assert_regex "$(grep '^openshell sandbox create' "$CALLS")" '--from registry\.example/acq:test' +} + +@test "openshell: bind mode fails closed until explicitly enabled" { + local project="$STUBDIR/no-bind"; mkdir -p "$project" + run env ACQ_BACKEND=openshell "$ACQ" create shell "$project" + assert_failure + assert_output --partial 'ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1' + refute_regex "$(cat "$CALLS")" 'openshell sandbox create' +} + +@test "openshell: unavailable gateway blocks creation" { + local project="$STUBDIR/no-gateway"; mkdir -p "$project" + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 STUB_OPENSHELL_NO_GATEWAY=1 \ + "$ACQ" create shell "$project" + assert_failure + assert_output --partial 'no usable selected gateway' +} + +@test "openshell: readiness timeout cleans up the just-created sandbox" { + local project="$STUBDIR/not-ready"; mkdir -p "$project" + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 STUB_OPENSHELL_NOT_READY=1 \ + ACQ_OPENSHELL_READY_TIMEOUT=0 "$ACQ" create shell "$project" + assert_failure + assert_regex "$(cat "$CALLS")" 'openshell sandbox delete shell-not-ready' +} + +@test "openshell: readiness timeout cleans up clone scratch and source remote" { + local project="$STUBDIR/clone-project" clones="$STUBDIR/clones" + mkdir -p "$project" + git -C "$project" init -q + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 STUB_OPENSHELL_NOT_READY=1 \ + ACQ_OPENSHELL_READY_TIMEOUT=0 ACQ_OPENSHELL_CLONES_DIR="$clones" "$ACQ" create --clone shell "$project" + assert_failure + assert_regex "$(cat "$CALLS")" 'openshell sandbox delete shell-clone-project' + refute [ -d "$clones/shell-clone-project" ] + run git -C "$project" remote get-url sandbox-shell-clone-project + assert_failure +} + +@test "openshell: non-shell creates and runs fail before provisioning" { + local project="$STUBDIR/agent-project"; mkdir -p "$project" + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 "$ACQ" create opencode "$project" + assert_failure + assert_output --partial 'neutral kit translation and an agent entrypoint' + refute_regex "$(cat "$CALLS")" 'openshell sandbox create' + : > "$CALLS" + run env ACQ_BACKEND=openshell ACQ_OPENSHELL_ALLOW_BIND_MOUNTS=1 "$ACQ" run opencode "$project" + assert_failure + assert_output --partial 'GSA-TTS/agentic-coding-quickstart#577' + refute_regex "$(cat "$CALLS")" 'openshell sandbox create' +} + +@test "openshell: cp maps one sandbox endpoint to upload or download" { + run env ACQ_BACKEND=openshell "$ACQ" cp local.txt box:/workspace/local.txt + assert_success + assert_regex "$(cat "$CALLS")" 'openshell sandbox upload box local.txt /workspace/local.txt' + : > "$CALLS" + run env ACQ_BACKEND=openshell "$ACQ" cp box:/workspace/out.txt out.txt + assert_success + assert_regex "$(cat "$CALLS")" 'openshell sandbox download box /workspace/out.txt out.txt' +} + +@test "openshell: unsupported kits, ports, and secrets fail closed" { + run env ACQ_BACKEND=openshell "$ACQ" ports box + assert_failure + assert_output --partial 'public service exposure are unsupported' + run env ACQ_BACKEND=openshell "$ACQ" kit apply box openchamber + assert_failure + assert_output --partial 'neutral kit application is unsupported' + run env ACQ_BACKEND=openshell "$ACQ" secret set -g usai + assert_failure + assert_output --partial 'providers and secrets are unsupported' +} diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 4eb22613..d04319fe 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -88,7 +88,7 @@ _with_adapter() { # ADAPTER BODY assert_output --partial '0.6.9' run env "$ACQ" backend list assert_output --partial 'msb v0.6.9' - assert_output --partial 'openshell (explicit-only; adapter not yet available)' + assert_output --partial 'openshell v0.1.4 (explicit-only; experimental)' } @test "backend set/unset: persists a default then clears it (idempotent)" { @@ -109,7 +109,7 @@ _with_adapter() { # ADAPTER BODY @test "msb: doctor shows the installed msb version, not the old placeholder" { run bash -c 'printf "n\n" | "$1" doctor' _ "$ACQ" assert_output --partial 'msb: installed v0.6.9' - assert_output --partial 'openshell: explicit-only; adapter not yet available' + assert_output --partial 'openshell: installed v0.1.4 (explicit-only; experimental)' } @test "msb: acq_backend_prepare is silent on a ready host" {