diff --git a/acq b/acq index ac643bb4..87a84c00 100755 --- a/acq +++ b/acq @@ -91,12 +91,17 @@ Subcommands: create AGENT PATH [...] Create a sandbox (detached). ls List sandboxes. stop NAME Stop a sandbox (without removing). + snapshot NAME [OUT] Save durable disk state for a sandbox (msb only). + restore NAME [SNAPSHOT] Restore a sandbox from SNAPSHOT, or + the newest date-stamped snapshot (msb only). + recreate NAME [OUT] Snapshot, remove, then restore NAME + from the snapshot (msb only). start NAME Start a stopped sandbox and re-run its kit startup services (msb only; on sbx a sandbox resumes automatically on 'acq run'). restart NAME Stop then start a sandbox, re-running kit startup services (msb only; see 'start'). - rm NAME Remove a sandbox. + rm [--snapshot] NAME [OUT] Remove a sandbox; optionally snapshot first. shell NAME Open an interactive shell in a sandbox. exec NAME -- CMD... Run a command inside a sandbox. cp SRC DST Copy files in/out (NAME:path syntax). @@ -329,6 +334,50 @@ Usage: acq stop NAME State is preserved; resume with `acq run NAME` (or `acq start NAME` on msb). +U + ;; + snapshot) + cat >&2 <<'U' +acq snapshot — save durable disk state for a sandbox (msb only) + +Usage: + acq snapshot NAME [OUT] + +Creates a disk-state snapshot archive of NAME. Restore always cold-boots from +the saved disk image; in-flight process state is not preserved. Every restore +re-applies kits via the exec-heal path. OUT is optional; when omitted, acq writes a +date-stamped .msb archive under its snapshot state directory. Unsupported +backends fail closed with a clear message. +U + ;; + restore) + cat >&2 <<'U' +acq restore — restore a disk-state snapshot and re-plumb acq-owned resources (msb only) + +Usage: + acq restore NAME [SNAPSHOT] + +Restores NAME from SNAPSHOT as a cold boot: the guest starts fresh from the +saved disk image (no in-flight process state is recovered). acq re-applies kits +via the exec-heal path after every restore. When SNAPSHOT is omitted, +acq selects the newest date-stamped snapshot for NAME from its snapshot state +directory. acq owns host-resource re-plumbing: it derives the current host +SSH-agent vsock route and asks the backend to inherit validated source-local +resources, rather than requiring the user to pass vsock/volume/port flags by +hand. Unsupported backends fail closed with a clear message. +U + ;; + recreate) + cat >&2 <<'U' +acq recreate — save disk state, remove, then restore a sandbox (msb only) + +Usage: + acq recreate NAME [OUT] + +Saves a disk-state snapshot archive of NAME, removes NAME, then restores it as a +cold-boot recovery (no in-flight process state is recovered), re-applying kits via the +exec-heal path. acq owns host-resource re-plumbing. OUT defaults to an acq +state directory and is left in place if restore fails. U ;; start) @@ -367,8 +416,12 @@ acq rm — remove a sandbox Usage: acq rm NAME + acq rm --snapshot NAME [OUT] Destroys the sandbox and all of its state (including uncommitted work in it). +With --snapshot, acq first saves a durable disk-state snapshot archive and +removes the sandbox only if that snapshot succeeds. Unsupported +backends fail closed. U ;; exec) @@ -476,7 +529,7 @@ U _acq_verb_backend_help_kind() { case "${1%% *}" in run|ls|stop|rm|exec|cp|ports|secret|kit) printf 'shared' ;; - start|restart|create|shell|version|doctor|github-scope|usai-rotate-api-key|backend) printf 'acqonly' ;; + start|restart|snapshot|restore|recreate|create|shell|version|doctor|github-scope|usai-rotate-api-key|backend) printf 'acqonly' ;; *) printf '' ;; esac } @@ -622,6 +675,40 @@ acq_propagate_usai_rotation() { _acq_propagate_secret_to_present_backends usai "" rotate } +_acq_snapshot_dir() { + printf '%s\n' "${ACQ_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq/snapshots}" +} + +_acq_default_snapshot_path() { + local name="$1" snapshot_dir + snapshot_dir=$(_acq_snapshot_dir) + ( umask 077; mkdir -p "$snapshot_dir" ) + chmod 700 "$snapshot_dir" 2>/dev/null || { + echo "acq: snapshot: could not make snapshot directory private: $snapshot_dir" >&2 + return 1 + } + printf '%s/%s-%s.msb\n' "$snapshot_dir" "$(slugify "$name")" "$(date -u +%Y%m%dT%H%M%SZ)" +} + +_acq_latest_snapshot_path() { + local name="$1" snapshot_dir slug latest + snapshot_dir=$(_acq_snapshot_dir) + slug=$(slugify "$name") + latest=$(find "$snapshot_dir" -maxdepth 1 -type f -name "${slug}-*.msb" 2>/dev/null \ + | grep -E "/${slug}-[0-9]{8}T[0-9]{6}Z[.]msb$" \ + | LC_ALL=C sort | tail -n 1) + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + +_acq_restore_heal_kits_if_needed() { + local local_name="$1" + [ "${ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL:-0}" = "1" ] || return 1 + acq_cli_kits_load "${ACQ_RESOLVED_BACKEND}" "$local_name" || return 2 + ACQ_STRICT_KIT_HEAL=1 acq_backend_ensure_kits_applied "$local_name" || return 2 + return 0 +} + # --------------------------------------------------------------------------- # Allow tests to source this script without running the dispatch. # --------------------------------------------------------------------------- @@ -1424,6 +1511,104 @@ case "$subcommand" in acq_backend_stop "$local_name" ;; + snapshot) + shift + if ! command -v acq_backend_snapshot >/dev/null 2>&1; then + echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not support sandbox snapshots." >&2 + echo " This verb requires native disk-state snapshot/restore support." >&2 + exit 1 + fi + acq_backend_prepare + local_name="${1:-}" + [ -n "$local_name" ] || { echo "acq: snapshot: missing sandbox name" >&2; exit 1; } + shift + if [ "$#" -gt 1 ]; then + echo "acq: snapshot: too many arguments" >&2 + echo " usage: acq snapshot NAME [OUT]" >&2 + exit 2 + fi + snapshot_out="${1:-}" + [ -n "$snapshot_out" ] || snapshot_out=$(_acq_default_snapshot_path "$local_name") + if command -v acq_backend_snapshot_safe >/dev/null 2>&1; then + acq_backend_snapshot_safe "$local_name" || exit 1 + fi + acq_backend_snapshot "$local_name" "$snapshot_out" + echo "acq: snapshot saved: $snapshot_out" >&2 + ;; + + restore) + shift + if ! command -v acq_backend_restore >/dev/null 2>&1; then + echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not support sandbox restore." >&2 + echo " This verb requires native restore with host-resource rebinding." >&2 + exit 1 + fi + acq_backend_prepare + local_name="${1:-}" + [ -n "$local_name" ] || { echo "acq: restore: missing sandbox name" >&2; exit 1; } + shift + if [ "$#" -gt 1 ]; then + echo "acq: restore: too many arguments" >&2 + echo " usage: acq restore NAME [SNAPSHOT]" >&2 + exit 2 + fi + snapshot_ref="${1:-}" + if [ -z "$snapshot_ref" ]; then + snapshot_ref=$(_acq_latest_snapshot_path "$local_name") || { + echo "acq: restore: no snapshots found for '$local_name' in $(_acq_snapshot_dir)" >&2 + exit 1 + } + fi + acq_backend_restore "$snapshot_ref" --name "$local_name" + if _acq_restore_heal_kits_if_needed "$local_name"; then + echo "acq: restored '$local_name' and re-applied kit startup." >&2 + else + heal_rc=$? + [ "$heal_rc" -eq 1 ] || exit "$heal_rc" + echo "acq: restored '$local_name'." >&2 + fi + ;; + + recreate) + shift + if ! command -v acq_backend_snapshot >/dev/null 2>&1 \ + || ! command -v acq_backend_restore >/dev/null 2>&1; then + echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not support sandbox recreate." >&2 + echo " This verb requires native disk-state snapshot/restore." >&2 + exit 1 + fi + acq_backend_prepare + local_name="${1:-}" + [ -n "$local_name" ] || { echo "acq: recreate: missing sandbox name" >&2; exit 1; } + shift + if [ "$#" -gt 1 ]; then + echo "acq: recreate: too many arguments" >&2 + echo " usage: acq recreate NAME [OUT]" >&2 + exit 2 + fi + snapshot_out="${1:-}" + if [ -z "$snapshot_out" ]; then + snapshot_out=$(_acq_default_snapshot_path "$local_name") + fi + if command -v acq_backend_snapshot_safe >/dev/null 2>&1; then + acq_backend_snapshot_safe "$local_name" || exit 1 + fi + acq_backend_snapshot "$local_name" "$snapshot_out" + if command -v acq_backend_terminate_for_restore >/dev/null 2>&1; then + acq_backend_terminate_for_restore "$local_name" + else + acq_backend_terminate "$local_name" + fi + acq_backend_restore "$snapshot_out" --name "$local_name" + if _acq_restore_heal_kits_if_needed "$local_name"; then + echo "acq: recreated '$local_name' from snapshot: $snapshot_out (kit startup re-applied)" >&2 + else + heal_rc=$? + [ "$heal_rc" -eq 1 ] || exit "$heal_rc" + echo "acq: recreated '$local_name' from snapshot: $snapshot_out" >&2 + fi + ;; + start) # Resume a stopped sandbox and re-run its kit startup services deterministically # (ADR-0017). On msb, `msb start` alone does NOT reliably replay kit @@ -1494,8 +1679,39 @@ case "$subcommand" in rm) shift acq_backend_guard_version + snapshot_first=0 + snapshot_out="" + case "${1:-}" in + --snapshot) + snapshot_first=1 + shift + ;; + esac local_name="${1:-}" [ -n "$local_name" ] || { echo "acq: rm: missing sandbox name" >&2; exit 1; } + shift + if [ "$snapshot_first" -eq 1 ]; then + if ! command -v acq_backend_snapshot >/dev/null 2>&1; then + echo "acq: the '${ACQ_RESOLVED_BACKEND}' backend does not support sandbox snapshots." >&2 + echo " Cannot run 'acq rm --snapshot' on this backend." >&2 + exit 1 + fi + acq_backend_prepare + if [ "$#" -gt 1 ]; then + echo "acq: rm --snapshot: too many arguments" >&2 + echo " usage: acq rm --snapshot NAME [OUT]" >&2 + exit 2 + fi + snapshot_out="${1:-}" + [ -n "$snapshot_out" ] || snapshot_out=$(_acq_default_snapshot_path "$local_name") + if command -v acq_backend_snapshot_safe >/dev/null 2>&1; then + acq_backend_snapshot_safe "$local_name" || exit 1 + fi + acq_backend_snapshot "$local_name" "$snapshot_out" + echo "acq: snapshot saved before removal: $snapshot_out" >&2 + fi + # A snapshot is an export, not a recreate: permanently removing the source + # must clean its derived resources and host metadata after the export exists. acq_backend_terminate "$local_name" ;; diff --git a/acq.backends/common.sh b/acq.backends/common.sh index bde00528..c4b740ee 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -856,8 +856,8 @@ kit_spec_agent_field() { if (line ~ "^ " key ":[[:space:]]*") { sub("^ " key ":[[:space:]]*","",line) line=trim(line) - gsub(/^\"|\"$/, "", line) - gsub(/^'\''|'\''$/, "", line) + if (substr(line, 1, 1) == sprintf("%c", 34) && substr(line, length(line), 1) == sprintf("%c", 34)) line=substr(line, 2, length(line)-2) + if (substr(line, 1, 1) == "'" && substr(line, length(line), 1) == "'") line=substr(line, 2, length(line)-2) if (line != "") print line exit } @@ -2097,6 +2097,22 @@ acq_provenance_field() { ' "$file" 2>/dev/null || true } +# Remove a sandbox's provenance once its resources are permanently gone. Snapshot +# sidecars and the separate CLI/extra-kit record remain available for restore. +acq_provenance_remove() { + local backend="${1:-}" name="${2:-}" provenance + [ -n "$backend" ] && [ -n "$name" ] || return 0 + provenance=$(_acq_provenance_file "$backend" "$name") || return 0 + rm -f "$provenance" 2>/dev/null || true +} + +acq_cli_kits_remove() { + local backend="${1:-}" name="${2:-}" file + [ -n "$backend" ] && [ -n "$name" ] || return 0 + file=$(_acq_cli_kits_file "$backend" "$name") || return 0 + rm -f "$file" 2>/dev/null || true +} + # Remember the primary host workspace mounted into a sandbox. This is non-secret # host-side state used by commands that take only SANDBOX later (for example, # `acq github-scope SANDBOX`) so they do not accidentally inspect the caller's @@ -2276,6 +2292,24 @@ acq_cli_kits_write() { return 0 } +acq_cli_kits_copy_sidecar() { + local backend="${1:-}" name="${2:-}" snapshot="${3:-}" file + [ -n "$backend" ] && [ -n "$name" ] && [ -n "$snapshot" ] || return 0 + file=$(_acq_cli_kits_file "$backend" "$name") || return 0 + [ -f "$file" ] || return 0 + cp "$file" "${snapshot}.kits" 2>/dev/null || return 1 +} + +acq_cli_kits_adopt_sidecar() { + local backend="${1:-}" name="${2:-}" snapshot="${3:-}" file dir + [ -n "$backend" ] && [ -n "$name" ] && [ -n "$snapshot" ] || return 0 + [ -f "${snapshot}.kits" ] || return 0 + file=$(_acq_cli_kits_file "$backend" "$name") || return 1 + dir=$(dirname "$file") + mkdir -p "$dir" 2>/dev/null || return 1 + cp "${snapshot}.kits" "$file" 2>/dev/null || return 1 +} + # Reload a sandbox's persisted CLI (`--kit`) and extra kit refs into ACQ_CLI_KITS # / ACQ_EXTRA_KITS for the CURRENT shell, so a subsequent heal re-runs their # startup. Called by the start/restart verbs before ensure_kits_applied. No-op @@ -2298,9 +2332,8 @@ acq_cli_kits_load() { extra=*) val="${line#extra=}"; [ -n "$val" ] && _extras="${_extras:+$_extras }$val" ;; esac done < "$file" - # Only overwrite ACQ_EXTRA_KITS from the record if the record carried extras; - # otherwise leave any env-supplied value in place (do not clobber the env). - [ -n "$_extras" ] && ACQ_EXTRA_KITS="$_extras" + # A persisted record is authoritative, including an empty extra-kit set. + ACQ_EXTRA_KITS="$_extras" acq_debug "cli-kits: reloaded $backend/$name (${#ACQ_CLI_KITS[@]} cli kit(s))" return 0 } diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 476ac237..3c4dbe24 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -59,7 +59,7 @@ ACQ_BACKEND_NAME="msb" # shellcheck disable=SC2034 ACQ_BACKEND_SUPPORTS_PORT_FORWARD=1 # post-hoc publish via `msb ssh serve` + OpenSSH -L forwarding (ADR-0015) # shellcheck disable=SC2034 -ACQ_BACKEND_SUPPORTS_SNAPSHOTS=0 # msb HAS `msb snapshot`, but acq exposes NO `snapshot` verb; wiring one is beyond sbx parity (sbx has none), so this flag reflects what acq surfaces (0), not what msb can do +ACQ_BACKEND_SUPPORTS_SNAPSHOTS=1 # acq surfaces msb disk-state snapshot/restore (cold-boot restore + restore-time resource rebinding) # shellcheck disable=SC2034 ACQ_BACKEND_CAN_RESUME=1 # msb stop / msb start preserve state # shellcheck disable=SC2034 @@ -583,6 +583,8 @@ ACQ_MSB_SSH_KEY="${ACQ_MSB_SSH_KEY:-${ACQ_MSB_SSH_DIR}/msb_id_ed25519}" ACQ_MSB_SSH_KNOWN_HOSTS="${ACQ_MSB_SSH_KNOWN_HOSTS:-${ACQ_MSB_SSH_DIR}/known_hosts}" ACQ_MSB_PORTS_DIR="${ACQ_MSB_PORTS_DIR:-${ACQ_STATE_DIR}/ports}" ACQ_MSB_CLONES_DIR="${ACQ_MSB_CLONES_DIR:-${ACQ_STATE_DIR}/clones}" +ACQ_MSB_RESTORE_DIR="${ACQ_MSB_RESTORE_DIR:-${ACQ_STATE_DIR}/msb-restore}" +ACQ_MSB_SNAPSHOT_DIR="${ACQ_MSB_SNAPSHOT_DIR:-${ACQ_STATE_DIR}/msb-snapshots}" # Create-time startup-script staging state (ADR-0017). The staged host file list # is reset per provision; declare it at module scope so cleanup references are # always defined even if provision is not the entry point. @@ -835,15 +837,14 @@ acq_backend_prepare() { # --------------------------------------------------------------------------- # acq_backend_exists — 0 if a named sandbox exists, else 1 # --------------------------------------------------------------------------- -# `msb list -q` prints one sandbox name per line (verified via --tree: "-q -# Show only sandbox names"). Match the whole line exactly. -# NOTE: not live-verified against a running daemon; the -q contract is from the -# CLI help. If the column layout differs on a real host, adjust the parse. - +# Prefer `msb list -q` when it reports names directly, but fall back to +# `msb inspect NAME`: some restore paths can produce sandboxes visible in the +# table output before they appear in the quiet-name listing. acq_backend_exists() { local listed listed=$(_acq_msb_cli list -q 2>/dev/null) || return 1 - grep -Fxq -- "$1" <<<"$listed" + grep -Fxq -- "$1" <<<"$listed" && return 0 + _acq_msb_cli inspect "$1" >/dev/null 2>&1 } # Return success only when msb successfully lists sandboxes and NAME is absent. @@ -1055,6 +1056,347 @@ acq_backend_start() { _acq_msb_start_ssh_agent_bridge "$_name" } +# --------------------------------------------------------------------------- +# Restore resource metadata — acq-owned host bindings for exported snapshots +# --------------------------------------------------------------------------- +_acq_msb_restore_resource_file() { + local name="$1" + case "$name" in + ""|*[!A-Za-z0-9_-]*|-*) + echo "acq(msb): restore: refusing unsafe sandbox name '$name' for state path." >&2 + return 1 ;; + esac + printf '%s/%s.resources' "$ACQ_MSB_RESTORE_DIR" "$name" +} + +_acq_msb_snapshot_resource_file() { + local snapshot="$1" sum + [ -f "$snapshot" ] || return 1 + if command -v sha256sum >/dev/null 2>&1; then + sum=$(sha256sum "$snapshot" 2>/dev/null | cut -d' ' -f1) || return 1 + elif command -v shasum >/dev/null 2>&1; then + sum=$(shasum -a 256 "$snapshot" 2>/dev/null | cut -d' ' -f1) || return 1 + else + echo "acq(msb): snapshot metadata requires sha256sum or shasum." >&2 + return 1 + fi + printf '%s/%s.resources' "$ACQ_MSB_SNAPSHOT_DIR" "$sum" +} + +_acq_msb_snapshot_stage() { + local snapshot="$1" tmp sum + [ -f "$snapshot" ] || return 1 + ( umask 077; mkdir -p "$ACQ_MSB_SNAPSHOT_DIR" ) 2>/dev/null || return 1 + chmod 700 "$ACQ_MSB_SNAPSHOT_DIR" 2>/dev/null || return 1 + tmp=$(mktemp "$ACQ_MSB_SNAPSHOT_DIR/.restore.XXXXXX") || return 1 + cp "$snapshot" "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } + chmod 600 "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; } + if command -v sha256sum >/dev/null 2>&1; then + sum=$(sha256sum "$tmp" 2>/dev/null | cut -d' ' -f1) || { rm -f "$tmp"; return 1; } + elif command -v shasum >/dev/null 2>&1; then + sum=$(shasum -a 256 "$tmp" 2>/dev/null | cut -d' ' -f1) || { rm -f "$tmp"; return 1; } + else + rm -f "$tmp" + echo "acq(msb): snapshot metadata requires sha256sum or shasum." >&2 + return 1 + fi + # Keep the per-invocation mktemp name. The digest authenticates metadata, not + # the staged archive: a shared digest filename would let concurrent restores + # of identical snapshots remove or replace each other's restore input. + printf '%s\n' "$tmp" +} + +_acq_msb_snapshot_kits_file() { + local snapshot="$1" resource_file + resource_file=$(_acq_msb_snapshot_resource_file "$snapshot") || return 1 + printf '%s.kits' "${resource_file%.resources}" +} + +_acq_msb_snapshot_state_file() { + local snapshot="$1" resource_file + resource_file=$(_acq_msb_snapshot_resource_file "$snapshot") || return 1 + printf '%s.state' "${resource_file%.resources}" +} + +_acq_msb_snapshot_state_copy() { + local name="$1" snapshot="$2" target agent workspace generic host_workspace + target=$(_acq_msb_snapshot_state_file "$snapshot") || return 1 + agent=$(acq_host_config_read msb "$name" agent 2>/dev/null || true) + [ -n "$agent" ] || agent=$(acq_provenance_field msb "$name" agent) + workspace=$(acq_host_config_read msb "$name" workspace 2>/dev/null || true) + [ -n "$workspace" ] || workspace=$(acq_workspace_record_read msb "$name" 2>/dev/null || true) + generic=$(acq_host_config_read msb "$name" generic-opencode 2>/dev/null || true) + host_workspace=$(acq_workspace_record_read msb "$name" 2>/dev/null || true) + [ -n "$agent$workspace$generic$host_workspace" ] || return 0 + ( umask 077; mkdir -p "$ACQ_MSB_SNAPSHOT_DIR" ) 2>/dev/null || return 1 + { + printf 'agent\t%s\n' "$agent" + printf 'workspace\t%s\n' "$workspace" + printf 'generic-opencode\t%s\n' "$generic" + printf 'host-workspace\t%s\n' "$host_workspace" + } >"$target" 2>/dev/null || return 1 + chmod 600 "$target" 2>/dev/null || return 1 +} + +_acq_msb_snapshot_state_adopt() { + local name="$1" snapshot="$2" source kind value agent="" workspace="" generic="" host_workspace="" + source=$(_acq_msb_snapshot_state_file "$snapshot") || return 0 + [ -f "$source" ] || return 0 + while IFS=$'\t' read -r kind value; do + case "$kind" in + agent) agent="$value" ;; + workspace) workspace="$value" ;; + generic-opencode) generic="$value" ;; + host-workspace) host_workspace="$value" ;; + esac + done <"$source" + case "$agent" in ""|*[!a-z-]*) return 1 ;; esac + # A restore's sidecar is authoritative. Never retain a removed sandbox's + # name-keyed state when this snapshot omits a field. + acq_host_config_clear msb "$name" agent + acq_host_config_clear msb "$name" workspace + acq_host_config_clear msb "$name" generic-opencode + acq_provenance_remove msb "$name" + acq_host_config_write msb "$name" agent "$agent" || return 1 + [ -z "$workspace" ] || acq_host_config_write msb "$name" workspace "$workspace" || return 1 + case "$generic" in + "") ;; + generic-opencode|1) acq_host_config_write msb "$name" generic-opencode generic-opencode || return 1 ;; + *) return 1 ;; + esac + [ -z "$host_workspace" ] || acq_workspace_record_write msb "$name" "$host_workspace" || return 1 +} + +_acq_msb_restore_resources_write() { + local name="$1" _file _spec + _file=$(_acq_msb_restore_resource_file "$name") || return 1 + ( umask 077; mkdir -p "$ACQ_MSB_RESTORE_DIR" ) 2>/dev/null || return 1 + chmod 700 "$ACQ_MSB_RESTORE_DIR" 2>/dev/null || return 1 + : >"$_file" 2>/dev/null || return 1 + shift + for _spec in ${@+"$@"}; do + printf 'volume\t%s\n' "$_spec" >>"$_file" 2>/dev/null || return 1 + done +} + +_acq_msb_restore_resources_copy_sidecar() { + local name="$1" snapshot="$2" _file _trusted + _file=$(_acq_msb_restore_resource_file "$name") || return 0 + [ -f "$_file" ] || return 0 + _trusted=$(_acq_msb_snapshot_resource_file "$snapshot") || return 1 + ( umask 077; mkdir -p "$ACQ_MSB_SNAPSHOT_DIR" ) 2>/dev/null || return 1 + chmod 700 "$ACQ_MSB_SNAPSHOT_DIR" 2>/dev/null || return 1 + cp "$_file" "$_trusted" 2>/dev/null || return 1 + chmod 600 "$_trusted" 2>/dev/null || return 1 +} + +_acq_msb_snapshot_kits_copy() { + local name="$1" snapshot="$2" source target + source=$(_acq_cli_kits_file msb "$name") || return 1 + target=$(_acq_msb_snapshot_kits_file "$snapshot") || return 1 + ( umask 077; mkdir -p "$ACQ_MSB_SNAPSHOT_DIR" ) 2>/dev/null || return 1 + chmod 700 "$ACQ_MSB_SNAPSHOT_DIR" 2>/dev/null || return 1 + if [ -f "$source" ]; then + cp "$source" "$target" 2>/dev/null || return 1 + else + printf 'schema=1\n' >"$target" 2>/dev/null || return 1 + fi + chmod 600 "$target" 2>/dev/null || return 1 +} + +_acq_msb_restore_resources_adopt_trusted() { + local name="$1" snapshot="$2" _file _trusted + _trusted=$(_acq_msb_snapshot_resource_file "$snapshot") || return 0 + [ -f "$_trusted" ] || return 0 + _file=$(_acq_msb_restore_resource_file "$name") || return 1 + ( umask 077; mkdir -p "$ACQ_MSB_RESTORE_DIR" ) 2>/dev/null || return 1 + chmod 700 "$ACQ_MSB_RESTORE_DIR" 2>/dev/null || return 1 + cp "$_trusted" "$_file" 2>/dev/null || return 1 + chmod 600 "$_file" 2>/dev/null || return 1 +} + +_acq_msb_restore_clear_stale_state() { + local name="$1" resource_file + resource_file=$(_acq_msb_restore_resource_file "$name") || return 1 + rm -f "$resource_file" 2>/dev/null || return 1 + acq_cli_kits_remove msb "$name" + acq_provenance_remove msb "$name" + acq_host_config_remove msb "$name" +} + +_acq_msb_snapshot_kits_adopt_trusted() { + local name="$1" snapshot="$2" source target dir + source=$(_acq_msb_snapshot_kits_file "$snapshot") || return 0 + target=$(_acq_cli_kits_file msb "$name") || return 1 + dir=$(dirname "$target") + ( umask 077; mkdir -p "$dir" ) 2>/dev/null || return 1 + chmod 700 "$dir" 2>/dev/null || return 1 + if [ -f "$source" ]; then + cp "$source" "$target" 2>/dev/null || return 1 + else + printf 'schema=1\n' >"$target" 2>/dev/null || return 1 + fi + chmod 600 "$target" 2>/dev/null || return 1 +} + +_acq_msb_restore_resource_flags_into() { # ARRVAR SNAPSHOT + local _arr="$1" _snapshot="$2" _file _kind _spec + eval "$_arr=()" + _file=$(_acq_msb_snapshot_resource_file "$_snapshot") || return 0 + [ -f "$_file" ] || return 0 + while IFS=$'\t' read -r _kind _spec; do + case "$_kind" in + volume) [ -n "$_spec" ] && eval "$_arr+=(--volume \"\$_spec\")" ;; + esac + done <"$_file" +} + +# --------------------------------------------------------------------------- +# acq_backend_snapshot NAME [OUT] — create a disk-state msb snapshot (cold-boot recoverable) +# --------------------------------------------------------------------------- +acq_backend_snapshot() { + local _name="${1:-}" _out="${2:-}" + [ -n "$_name" ] || { echo "acq(msb): snapshot: missing sandbox name" >&2; return 1; } + if [ -n "${3:-}" ]; then + echo "acq(msb): snapshot: too many arguments" >&2 + echo " usage: acq snapshot NAME [OUT]" >&2 + return 2 + fi + if [ -n "$_out" ]; then + local _host_out="$_out" + if command -v host_path >/dev/null 2>&1; then + _host_out=$(host_path "$_out") + fi + ( umask 077; _acq_msb_cli snapshot create --from-sandbox "$_name" --guest-flush auto -o "$_host_out" ) || return $? + chmod 600 "$_out" 2>/dev/null || return 1 + _acq_msb_restore_resources_copy_sidecar "$_name" "$_out" || return 1 + _acq_msb_snapshot_kits_copy "$_name" "$_out" || return 1 + _acq_msb_snapshot_state_copy "$_name" "$_out" || return 1 + else + _acq_msb_cli snapshot create --from-sandbox "$_name" --guest-flush auto + fi +} + +# --------------------------------------------------------------------------- +# acq_backend_restore SNAPSHOT --name NAME — restore and re-plumb resources +# --------------------------------------------------------------------------- +acq_backend_restore() { + local _snapshot="${1:-}" _name="" _arg _staged_snapshot + [ -n "$_snapshot" ] || { echo "acq(msb): restore: missing snapshot reference" >&2; return 1; } + shift || true + while [ "$#" -gt 0 ]; do + _arg="$1"; shift + case "$_arg" in + --name) + _name="${1:-}"; [ "$#" -gt 0 ] && shift || true ;; + --name=*) + _name="${_arg#--name=}" ;; + --*) + echo "acq(msb): restore: unknown flag '$_arg'" >&2 + echo " usage: acq restore SNAPSHOT --name NAME" >&2 + return 2 ;; + *) + echo "acq(msb): restore: unexpected argument '$_arg'" >&2 + echo " usage: acq restore SNAPSHOT --name NAME" >&2 + return 2 ;; + esac + done + case "$_name" in + ""|-*) + echo "acq(msb): restore: missing --name NAME" >&2 + echo " usage: acq restore SNAPSHOT --name NAME" >&2 + return 2 ;; + esac + + if acq_backend_exists "$_name"; then + echo "acq(msb): restore: destination sandbox '$_name' already exists." >&2 + echo " Remove it first or restore under a different name; existing state was left unchanged." >&2 + return 1 + fi + if ! _acq_msb_confirm_absent "$_name"; then + echo "acq(msb): restore: could not confirm destination sandbox '$_name' is absent." >&2 + echo " Existing state was left unchanged; retry when MSB is reachable." >&2 + return 1 + fi + + _staged_snapshot=$(_acq_msb_snapshot_stage "$_snapshot") || { + echo "acq(msb): restore: could not stage snapshot privately." >&2 + return 1 + } + + # Restore metadata is name-scoped. Remove stale state before collecting the + # trusted snapshot sidecars, then remount a fresh trusted host-config dir. + _acq_msb_restore_clear_stale_state "$_name" || { + rm -f "$_staged_snapshot" + return 1 + } + + local _restore_flags=(--name "$_name" --dangerously-inherit-resources) + local _resource_flags=() _vsock_flags=() _hcfg_dir _hcfg_mount _hcfg_host + if _hcfg_dir=$(acq_host_config_dir msb "$_name"); then + mkdir -p "$_hcfg_dir" 2>/dev/null || { rm -f "$_staged_snapshot"; return 1; } + chmod 711 "$_hcfg_dir" 2>/dev/null || { rm -f "$_staged_snapshot"; return 1; } + _hcfg_mount="$_hcfg_dir" + if command -v canonicalize_path >/dev/null 2>&1; then + _hcfg_mount=$(canonicalize_path "$_hcfg_mount") || { rm -f "$_staged_snapshot"; return 1; } + fi + if command -v host_path >/dev/null 2>&1; then + _hcfg_host=$(host_path "$_hcfg_mount") || { rm -f "$_staged_snapshot"; return 1; } + else + _hcfg_host="$_hcfg_mount" + fi + _restore_flags+=(--volume "${_hcfg_host}:${ACQ_HOST_CONFIG_GUEST_DIR}:ro") + fi + ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL=0 + _acq_msb_restore_resource_flags_into _resource_flags "$_staged_snapshot" + # The exported archive is MSB's disk snapshot format. It restores through the + # ordinary archive path, re-binding only acq-owned external resources. + ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL=1 + if [ "${#_resource_flags[@]}" -gt 0 ]; then + _restore_flags+=("${_resource_flags[@]}") + fi + _acq_msb_vsock_flags_into _vsock_flags + [ "${#_vsock_flags[@]}" -gt 0 ] && _restore_flags+=("${_vsock_flags[@]}") + + local _host_snapshot="$_staged_snapshot" + if command -v host_path >/dev/null 2>&1; then + _host_snapshot=$(host_path "$_staged_snapshot") + fi + + if command -v acq_provider_facts_load_from_kit_or_fallback >/dev/null 2>&1; then + acq_provider_facts_load_from_kit_or_fallback "$USAI_KIT" + fi + local _restore_secret_flags=() _restore_secret_names=() + _acq_msb_bind_secrets_into _restore_secret_flags _restore_secret_names "$_name" + local _restore_rc=0 + _acq_msb_cli restore "$_host_snapshot" "${_restore_flags[@]}" "${_restore_secret_flags[@]}" || _restore_rc=$? + local _rev + for _rev in ${_restore_secret_names[@]+"${_restore_secret_names[@]}"}; do + unset "$_rev" + done + if [ "$_restore_rc" -ne 0 ]; then + rm -f "$_staged_snapshot" + return "$_restore_rc" + fi + if ! _acq_msb_restore_resources_adopt_trusted "$_name" "$_staged_snapshot" \ + || ! _acq_msb_snapshot_kits_adopt_trusted "$_name" "$_staged_snapshot" \ + || ! _acq_msb_snapshot_state_adopt "$_name" "$_staged_snapshot"; then + rm -f "$_staged_snapshot" + return 1 + fi + rm -f "$_staged_snapshot" + if ! _acq_msb_wait_for_exec_ready "$_name"; then + echo "acq(msb): restore: $_name did not become exec-ready." >&2 + return 1 + fi + if [ "${_ACQ_MSB_SSH_AGENT_FORWARDING:-0}" = "1" ]; then + _acq_msb_check_socat "$_name" && _acq_msb_start_ssh_agent_bridge "$_name" + else + echo "acq(msb): warning: no current host SSH_AUTH_SOCK was available;" \ + "SSH signing will not be re-plumbed in restored sandbox '$_name'." >&2 + fi +} + # --------------------------------------------------------------------------- # _acq_msb_wait_for_exec_ready NAME — block until `msb exec` works in the guest # --------------------------------------------------------------------------- @@ -1072,10 +1414,11 @@ _acq_msb_wait_for_exec_ready() { while :; do _attempt=$(( _attempt + 1 )) acq_debug "msb exec-ready probe #${_attempt} for $name" - out=$(_acq_msb_cli exec "$name" -- sh -c 'echo ok' /dev/null | tr -d '\r') + out=$(_acq_msb_cli exec "$name" -- sh -c 'echo ok' /dev/null) _rc=$? + out=$(printf '%s' "$out" | tr -d '\r') acq_debug "msb exec-ready probe #${_attempt}: rc=${_rc} out='${out}'" - case "$out" in + [ "$_rc" -eq 0 ] && case "$out" in *ok*) acq_debug "msb exec-ready: $name (after ${_attempt} probe(s))"; return 0 ;; esac _now=$(date +%s) @@ -3080,6 +3423,18 @@ _acq_msb_clone_cleanup() { return 0 } +# A clone's workspace is an acq-owned external mount, not part of MSB's disk +# archive. Deleting it after snapshotting would leave an archive that cannot +# restore the clone's working tree. +acq_backend_snapshot_safe() { + local name="$1" dir="${ACQ_MSB_CLONES_DIR}/${1}" + if [ -d "$dir" ]; then + echo "acq(msb): snapshot: refusing clone-backed sandbox '$name'." >&2 + echo " Its external clone workspace is not included in the disk snapshot." >&2 + return 1 + fi +} + # --------------------------------------------------------------------------- # acq_backend_provision — create a sandbox and apply the kits # --------------------------------------------------------------------------- @@ -3506,6 +3861,7 @@ EOF ACQ_MSB_GUEST_WORKSPACE="" local _wi _wspec _wpath _wro _wsrc _whost _first_record _first_guest="" _first_host="" + local _restore_volume_specs=() for _wi in ${_ws_recs[@]+"${!_ws_recs[@]}"}; do _wspec="${_ws_recs[$_wi]}" # Split an optional trailing ":ro" (read-only) marker from the path. @@ -3554,6 +3910,7 @@ EOF _whost="$_wsrc" fi create_flags+=(--volume "${_whost}:${_wpath}${_wro}") + _restore_volume_specs+=("${_whost}:${_wpath}${_wro}") acq_debug "msb volume: ${_whost} (host) -> ${_wpath}${_wro} (guest)" done @@ -3597,14 +3954,21 @@ EOF # Mount per-sandbox trusted configuration read-only. The guest can consume # declared code and data here but cannot make acq trust a modified replacement. - local _hcfg_dir _hcfg_host + local _hcfg_dir _hcfg_mount _hcfg_host if _hcfg_dir=$(acq_host_config_dir msb "$name"); then mkdir -p "$_hcfg_dir" 2>/dev/null || true chmod 711 "$_hcfg_dir" 2>/dev/null || true + # msb rejects bind-mount sources reached through a host symlink. Host config + # commonly lives below macOS $TMPDIR (/var -> /private/var), so canonicalize + # this source just as we do workspace mounts above. + _hcfg_mount="$_hcfg_dir" + if command -v canonicalize_path >/dev/null 2>&1; then + _hcfg_mount=$(canonicalize_path "$_hcfg_mount") + fi if command -v host_path >/dev/null 2>&1; then - _hcfg_host=$(host_path "$_hcfg_dir") + _hcfg_host=$(host_path "$_hcfg_mount") else - _hcfg_host="$_hcfg_dir" + _hcfg_host="$_hcfg_mount" fi create_flags+=(--volume "${_hcfg_host}:${ACQ_HOST_CONFIG_GUEST_DIR}:ro") fi @@ -3718,6 +4082,8 @@ EOF fi acq_spin_stop "Waiting for the sandbox to finish booting" acq_debug "msb provision: exec-ready OK ($name)" + _acq_msb_restore_resources_write "$name" \ + ${_restore_volume_specs[@]+"${_restore_volume_specs[@]}"} || return 1 # A same-named sandbox removed outside acq can leave host state behind. Clear # instance-scoped values before this fresh provision records replacement values. @@ -4973,35 +5339,56 @@ acq_backend_stop() { } acq_backend_terminate() { + _acq_msb_terminate_impl "$1" cleanup +} + +acq_backend_terminate_for_restore() { + _acq_msb_terminate_impl "$1" keep-resources +} + +_acq_msb_terminate_impl() { + local _name="$1" _mode="${2:-cleanup}" # Tear down any post-hoc published-port tunnels (serve + ssh PIDs, state file) # before removing the sandbox (ADR-0015). Killing a dead PID / missing state # file is a no-op. - _acq_msb_ports_teardown "$1" + _acq_msb_ports_teardown "$_name" # Clean up derived volumes (ADR-0023) whenever the sandbox is GONE after the # remove attempt — not merely when remove succeeded. A failed remove of a # still-existing sandbox must not touch volumes that may be in use, but a # failed remove of an already-gone sandbox (removed via `msb rm` directly, or # a half-failed create that never registered) must still reach the cleanup, # or the volumes orphan forever under ~/.microsandbox/volumes/. + # + # Restore-aware removal keeps derived host resources: `acq recreate` snapshots, + # removes the VM, then restores from the snapshot, and msb restore inherits + # validated source-local resource records. Deleting the derived volumes in the + # gap would destroy the resources restore needs. + # # --clone (ADR-0027): surface unfetched agent commits BEFORE anything is deleted # (sbx-parity warning; rm proceeds — the scratch is disposable by contract). - _acq_msb_clone_warn_unfetched "$1" + _acq_msb_clone_warn_unfetched "$_name" local _rc=0 - _acq_msb_cli remove --force "$1" || _rc=$? + _acq_msb_cli remove --force "$_name" || _rc=$? if [ "$_rc" -ne 0 ]; then - if acq_backend_exists "$1" || ! _acq_msb_confirm_absent "$1"; then + if acq_backend_exists "$_name" || ! _acq_msb_confirm_absent "$_name"; then return "$_rc" fi fi - _acq_msb_remove_derived_volumes "$1" - # Same GONE-after-remove-attempt rule as the volumes above: delete the scratch - # clone and drop the fetch-back remote only once the sandbox is really gone. - _acq_msb_clone_cleanup "$1" - # The managed ssh-agent route link (ADR-0021) is per sandbox; drop it too. - _acq_msb_ssh_agent_link_remove "$1" - # Remove host-authoritative config only after the sandbox is confirmed gone; - # otherwise a failed removal could discard state still mounted by a live guest. - acq_host_config_remove msb "$1" || true + if [ "$_mode" != "keep-resources" ]; then + _acq_msb_remove_derived_volumes "$_name" + local _restore_file + _restore_file=$(_acq_msb_restore_resource_file "$_name") && rm -f "$_restore_file" 2>/dev/null || true + # Same GONE-after-remove-attempt rule as the volumes above: delete the scratch + # clone and drop the fetch-back remote only once the sandbox is really gone. + _acq_msb_clone_cleanup "$_name" + # The managed ssh-agent route link (ADR-0021) is per sandbox; drop it too. + _acq_msb_ssh_agent_link_remove "$_name" + # Remove host-authoritative config only after the sandbox is confirmed gone; + # otherwise a failed removal could discard state still mounted by a live guest. + acq_host_config_remove msb "$_name" || true + acq_provenance_remove msb "$_name" || true + acq_cli_kits_remove msb "$_name" || true + fi return "$_rc" } @@ -5482,13 +5869,18 @@ acq_backend_apply_kit() { # _acq_msb_heal_kit_set NAME BUILTIN_COUNT KITREF... — the heal's kit loop: fetch # every ref, merge their environment[] in a pre-pass (see # _acq_msb_merge_kit_env_into for why fetch and apply must be separate loops), -# reset the marker, then apply each kit with that merged env. Returns 1 if any of -# the first BUILTIN_COUNT refs failed to fetch or apply (the caller's provenance -# verdict), 0 otherwise. _builtin mirrors _dirs so a built-in's apply failure stays +# reset the marker, then apply each kit with that merged env. With --strict, +# returns 1 if any ref fails; otherwise only built-in failures affect the +# provenance verdict. _builtin mirrors _dirs so a built-in's apply failure stays # attributable across the two loops. _acq_msb_heal_kit_set() { local name="$1" builtin_count="$2" shift 2 + local strict=0 + if [ "${1:-}" = "--strict" ]; then + strict=1 + shift + fi local kitref kitdir i=0 ok=1 local _dirs=() _builtin=() for kitref in "$@"; do @@ -5497,9 +5889,9 @@ _acq_msb_heal_kit_set() { if [ "$i" -lt "$builtin_count" ]; then _builtin+=(1); else _builtin+=(0); fi else echo "acq(msb): warning: could not fetch kit for healing: $kitref" >&2 - # A built-in kit that can't even be fetched means we cannot claim the - # bundle is current. Extra-kit fetch failures don't affect the verdict. - [ "$i" -lt "$builtin_count" ] && ok=0 + if [ "$i" -lt "$builtin_count" ] || [ "$strict" -eq 1 ]; then + ok=0 + fi fi i=$((i + 1)) done @@ -5512,7 +5904,9 @@ _acq_msb_heal_kit_set() { local _ki for _ki in ${_dirs[@]+"${!_dirs[@]}"}; do if ! _acq_msb_apply_kit_dir "$name" "${_dirs[$_ki]}" _merged; then - [ "${_builtin[$_ki]}" -eq 1 ] && ok=0 + if [ "${_builtin[$_ki]}" -eq 1 ] || [ "$strict" -eq 1 ]; then + ok=0 + fi fi done [ "$ok" -eq 1 ] @@ -5577,7 +5971,11 @@ acq_backend_ensure_kits_applied() { local builtin_count="${ACQ_BUILTIN_KIT_COUNT:-4}" local kitref kitdir i=0 ok=1 acq_spin_start "Refreshing configuration kits" - _acq_msb_heal_kit_set "$name" "$builtin_count" "${kits[@]}" || ok=0 + if [ "${ACQ_STRICT_KIT_HEAL:-0}" = "1" ]; then + _acq_msb_heal_kit_set "$name" "$builtin_count" --strict "${kits[@]}" || ok=0 + else + _acq_msb_heal_kit_set "$name" "$builtin_count" "${kits[@]}" || ok=0 + fi acq_spin_stop "Refreshing configuration kits" # Record host-side bundle provenance ONLY when every built-in kit applied. # msb re-applies all built-in kits idempotently, so on full diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 5c97c26f..856163a1 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -85,7 +85,7 @@ export ACQ_BACKEND=sbx | Flag | Value | Meaning | |------|-------|---------| | `ACQ_BACKEND_SUPPORTS_PORT_FORWARD` | 1 | `acq ports` is supported | -| `ACQ_BACKEND_SUPPORTS_SNAPSHOTS` | 0 | No snapshot support | +| `ACQ_BACKEND_SUPPORTS_SNAPSHOTS` | 0 | No disk-state snapshot/restore support; sbx local templates are disk-only/fresh-boot and are not exposed as `acq snapshot` | | `ACQ_BACKEND_CAN_RESUME` | 1 | Sandboxes persist between sessions | | `ACQ_BACKEND_SUPPORTS_CREDENTIAL_REWRITE` | 1 | Secret proxy injection | @@ -136,11 +136,10 @@ automation story. `--secret GITHUB_TOKEN@github.com,api.github.com,codeload.github.com`); the real values never enter the VM. **Any** custom-endpoint secret stored with `acq secret set SVC --host H --env E` is bound generically the same way — no fixed usai/github table -- **Snapshots**: microsandbox has a full `msb snapshot` CLI verb - (create/list/inspect/verify/remove/save/load, `run --from-snapshot`), but - `acq` does not surface it — wiring `acq snapshot` is beyond sbx parity, so - `SUPPORTS_SNAPSHOTS=0` reflects what `acq` surfaces (not what msb can do); see - Known limitations +- **Snapshots**: `acq snapshot` / `acq restore` surface MSB's disk-state + snapshot path (`msb snapshot create` + `msb restore`), including + restore-time SSH-agent re-plumbing and kit reapplication. acq re-binds + validated host resources; see [ADR-0036](adr/0036-native-snapshot-restore.md) ### Requirements @@ -420,7 +419,9 @@ msb has no native clone mode, so the neutral `--clone` scratch dir. `git fetch sandbox-` pulls agent branches back as hash-verified objects; hooks and config never transfer over a fetch. - `acq rm` warns when the scratch still holds unfetched commits, then deletes - it and removes the remote. `acq stop`/restart preserve both. + it and removes the remote. `acq stop`/restart preserve both. Because the clone + is an external mount rather than disk-snapshot content, `acq snapshot`, + `acq recreate`, and `acq rm --snapshot` refuse clone-backed sandboxes. **Divergence from sbx (deliberate, per ADR-0027):** a git clone carries committed state only. Gitignored/untracked files (sbx copies these — the known @@ -874,7 +875,7 @@ rationale, the fixed vsock port (3552), and the trust-boundary discussion. | Flag | Value | Meaning | |------|-------|---------| | `ACQ_BACKEND_SUPPORTS_PORT_FORWARD` | 1 | Post-hoc `acq ports --publish HOST:GUEST` is **implemented**: `acq_backend_ports` opens `msb ssh serve` on an ephemeral loopback port against a running sandbox and tunnels the guest port to the host with OpenSSH `-L` (no re-create), using an acq-managed ed25519 key and tearing the serve/ssh pair down on `acq stop`/`rm` ([ADR-0015](adr/0015-msb-post-hoc-port-publish-via-ssh.md)). Create/run publish via neutral `publishedPorts` → `-p HOST:GUEST` also ships. **Live-verified** on a KVM-capable host via `scripts/verify-ports-live` (happy-path publish + host-reaches-guest, LIST, fail-closed on a busy host port, teardown) | -| `ACQ_BACKEND_SUPPORTS_SNAPSHOTS` | 0 | msb has a full `msb snapshot` CLI verb, but `acq` exposes **no `snapshot` verb** to invoke it. Wiring one is beyond sbx parity (sbx has none), so the flag reflects what `acq` surfaces (`0`), not what msb can do | +| `ACQ_BACKEND_SUPPORTS_SNAPSHOTS` | 1 | `acq snapshot` / `acq restore` surface msb's disk-state snapshot/restore path. `acq restore` re-derives the current host SSH-agent vsock route and asks msb to inherit validated source-local resources rather than requiring user-supplied resource flags ([ADR-0036](adr/0036-native-snapshot-restore.md)) | | `ACQ_BACKEND_CAN_RESUME` | 1 | `msb stop` / `msb start` preserve state | | `ACQ_BACKEND_SUPPORTS_CREDENTIAL_REWRITE` | 1 | `--secret ENV@HOST` + `--tls-intercept` (host-scoped substitution for REST/API hosts and HTTPS git transport hosts) | @@ -888,7 +889,7 @@ rationale, the fixed vsock port (3552), and the trust-boundary discussion. | Secret model | proxy `secret set-custom` | host-env `--secret ENV@HOST` | | Git commit signing / ssh-agent | host SSH agent forwarded implicitly by the sbx CLI (when SSH_AUTH_SOCK set) | host SSH agent forwarded via `--vsock` + an in-guest socat bridge when SSH_AUTH_SOCK set, msb >= 0.6.9 (ADR-0021) | | Secret binding breadth | 7 built-in services + any custom `--host/--env` endpoint | usai + github + **any** custom `--host/--env` endpoint bound generically via `--secret ENV@HOST` (shipped) | -| Snapshots | not supported (`SUPPORTS_SNAPSHOTS=0`) | `msb snapshot` verb exists but **not surfaced by `acq`** (beyond-parity; `SUPPORTS_SNAPSHOTS=0`) | +| Snapshots | unsupported (`SUPPORTS_SNAPSHOTS=0`; local `sbx template save/load` is disk-only/fresh-boot and is not exposed as a degraded `acq snapshot`) | supported (`SUPPORTS_SNAPSHOTS=1`; disk-state snapshot/restore via `acq snapshot` / `acq restore`) | | Port forwarding | `acq ports` (post-hoc) | create/run (`-p`) via neutral `publishedPorts` now (shipped); **plus** post-hoc `acq ports --publish` via `msb ssh serve` + `ssh -L` now implemented (ADR-0015) — live end-to-end verification pending a KVM host | | Kit volumes | neutral `volumes:` passed through 1:1 to kit-spec v2 §5.7 (sized block device / tmpfs, mounted at create; dies with the sandbox) | neutral `volumes:` unioned across kits (last wins by path) and mapped to a derived named disk volume (`--mount-named acq---::kind=disk,size=`) or `--tmpfs :`; derived volumes removed on `acq rm` (ADR-0023) | | Agent binary | supplied by the sbx agent template | supplied by the selected agent kit or the base image, then launched on attach | @@ -945,10 +946,14 @@ rationale, the fixed vsock port (3552), and the trust-boundary discussion. has an msb-native `opencode` installer fallback. If the selected agent is not present in the base image, the corresponding agent kit must provide it before attach. -- **Snapshots not surfaced.** `msb snapshot` is a full CLI verb, but `acq` - exposes no `snapshot` verb, so `SUPPORTS_SNAPSHOTS=0`. Wiring it is beyond sbx - parity (sbx has no snapshots), so the flag reflects what `acq` surfaces rather - than the verb being built. +- **Snapshots are msb-only in acq.** `acq snapshot` / `acq restore` use msb's + disk-state snapshot primitive. sbx is deliberately unsupported rather than + degraded to a disk-only template flow, because local `sbx template save/load` + does not provide restore-time host-resource rebinding. Every restore re-runs + kit startup. Clone-backed sandboxes are refused by + snapshot-based lifecycle commands because their external clone workspace is not + captured. `acq` never accepts archive-adjacent resource metadata as authority; + it uses private host metadata recorded when the snapshot was created. - **Browser-based OpenCode is via the `openchamber` kit.** The former `opencode-web.sh` helper has been removed; use the `openchamber` acq kit, which publishes the OpenCode server port (4096) plus the OpenChamber UI (3000) with a @@ -1244,8 +1249,8 @@ See [ADR-0016](adr/0016-kit-bundle-provenance-and-stale-refresh.md). ([ADR-0014](adr/0014-neutral-port-publish-and-background-vocab.md)) — the neutral fields light up end-to-end with the released patterns schema + openchamber kit (merged) -- msb `SUPPORTS_SNAPSHOTS=0` — `acq` surfaces no snapshot verb (msb's own verb - exists; wiring is beyond parity) +- msb `SUPPORTS_SNAPSHOTS=1` — `acq snapshot` / `acq restore` surface + disk-state msb snapshot/restore (ADR-0036); sbx remains unsupported - Generic custom-endpoint secret binding on msb — usai + github + **any** custom `--host/--env` endpoint bound via `--secret ENV@HOST` from a non-secret endpoint sidecar @@ -1279,8 +1284,6 @@ See [ADR-0016](adr/0016-kit-bundle-provenance-and-stale-refresh.md). the kit/agent/USAi rows, not `--publish`), so run it per the [ADR-0011](adr/0011-msb-backend-and-neutral-kits.md) periodic-validation cadence before treating the tunnel as verified working -- `acq snapshot` verb (beyond sbx parity; msb's own `msb snapshot` verb is not - surfaced) - Full Go/keychain swap-on-access secret component of design §7.5 (age fallback, `CredentialRewriteRule`); acq ships the bash keychain subset (see Secrets) - `acq policy …` — network policy subcommands diff --git a/docs/KNOWN_FAILURE_MODES.md b/docs/KNOWN_FAILURE_MODES.md index 34a71967..a4234d3a 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -1982,13 +1982,13 @@ is dead), `ssh-add` exits **1** with `communication with agent failed` here — same exit code as a healthy but empty agent, so the probe classifies on the message (a "no identities" reply is treated as healthy and stays quiet). The fix is a **one-time recreate**; the recreated sandbox routes through the managed link -and self-heals from then on: +and self-heals from then on. To preserve live agent context during planned +recovery, `acq recreate ` snapshots, removes, and restores the sandbox +while re-deriving the `--vsock` route from the current host `SSH_AUTH_SOCK`. ```bash -acq rm -# then re-run your original create/run WITH the host agent available: eval "$(ssh-agent -s)"; ssh-add ~/.ssh/id_ed25519 # if needed -acq run +acq recreate ``` If the warning fires for a sandbox that already routes through the managed link @@ -1998,7 +1998,9 @@ rather than recreating. See [ADR-0021](adr/0021-msb-host-ssh-agent-forwarding-via-vsock.md) ("Amendment (2026-10-06): reboot-proof route via a managed host symlink") for the -full mechanism. +full mechanism. Use the old `acq rm ; acq run ...` path only when you do +not need to preserve live agent context. See +[ADR-0036](adr/0036-native-snapshot-restore.md) for native snapshot restore. --- diff --git a/docs/adr/0010-acq-pluggable-backends.md b/docs/adr/0010-acq-pluggable-backends.md index ac3573a8..8fdf8c99 100644 --- a/docs/adr/0010-acq-pluggable-backends.md +++ b/docs/adr/0010-acq-pluggable-backends.md @@ -78,6 +78,8 @@ capability flag variables: | `acq_backend_attach NAME [-- AGENT_ARGS...]` | Interactive attach (TTY) | | `acq_backend_exists NAME` | 0 if sandbox exists, else 1 | | `acq_backend_stop NAME` | Stop without removing | +| `acq_backend_snapshot NAME [OUT]` | Create a disk-state snapshot when supported | +| `acq_backend_restore SNAPSHOT --name NAME` | Restore a disk-state snapshot and re-plumb acq-owned host resources when supported | | `acq_backend_terminate NAME` | Permanently remove | | `acq_backend_cp SRC DST` | Copy in/out (NAME:path syntax) | | `acq_backend_ports NAME [args...]` | List/publish ports | diff --git a/docs/adr/0014-neutral-port-publish-and-background-vocab.md b/docs/adr/0014-neutral-port-publish-and-background-vocab.md index 020441d8..72608f0d 100644 --- a/docs/adr/0014-neutral-port-publish-and-background-vocab.md +++ b/docs/adr/0014-neutral-port-publish-and-background-vocab.md @@ -81,9 +81,9 @@ msb 0.6.7** (released 2026-07-27), which corrects three assumptions: its own decision record (see Links: post-hoc publish ADR + gap K). This ADR remains scoped to create/run-time `-p`. 3. **`msb snapshot` exists as a full CLI verb** (create/list/inspect/verify/ - remove/save/load, plus `run --from-snapshot`). This is unrelated to ports but - corrects a stale "no snapshot verb" claim elsewhere; snapshot wiring stays - out of scope as beyond-sbx-parity (see epic #234 / #225). + remove/save/load, plus restore). This is unrelated to ports; acq's native + snapshot/restore decision is tracked separately in + [ADR-0036](0036-native-snapshot-restore.md). The neutral schema is owned by the **patterns** repo (`schemas/kit-hybrid-v1.schema.json`, `validate-kits.py`); the translator and diff --git a/docs/adr/0036-native-snapshot-restore.md b/docs/adr/0036-native-snapshot-restore.md new file mode 100644 index 00000000..184b138e --- /dev/null +++ b/docs/adr/0036-native-snapshot-restore.md @@ -0,0 +1,77 @@ +--- +title: "Expose disk-state snapshot/restore through acq" +status: accepted +date: 2026-09-22 +decision_makers: ["Bret Mogilefsky"] +category: architecture +nist_controls: ["CM-2", "CM-3", "CM-6", "SA-8", "SA-15", "SA-17"] +impact_level: low +ato_relevance: no +risk_treatment: accept +supersedes: [] +--- + +# ADR-0036: Expose disk-state snapshot/restore through acq + +## Context + +Agent sandboxes sometimes need to survive a planned host reboot or recreation +without losing durable agent context (conversation history, installed tools, +configuration). A selective backup of one tool's data directory is not enough: +OpenCode, for example, keeps conversation state in its own XDG data directory +while Paseo keeps only metadata that points to it. + +The msb backend has a native disk snapshot primitive. Its restore path can also bind host resources at +restore time, including `--vsock`, which is the same mechanism acq uses for host +SSH-agent forwarding. This matters because the msb SSH-agent route is otherwise +create-time only; after a host reboot the old host socket endpoint may be stale. + +acq exports a disk snapshot archive and restores it through ordinary `msb +restore`. MSB accepts `--disk-only` only for installed full snapshot references, +not direct archives. Every restore re-applies kits via the exec-heal path. + +The sbx local `template save` / `template load` model is different: it creates a +reusable template. Local capture is disk-only; memory plus microVM checkpoint is +cloud-only, and the documented local flow does not expose restore-time host +resource rebinding. That does not meet the disk-state restore contract. + +## Decision + +Add acq-owned `snapshot` and `restore` verbs that save durable disk state and +restore it as a cold boot, backed by native backend primitives only when the +backend supports disk-state snapshot with acq-managed host-resource re-plumbing. + +- msb is supported: `acq snapshot NAME [OUT]` maps to `msb snapshot create -o + OUT` and writes a date-stamped archive when OUT is omitted. `acq restore NAME + [SNAPSHOT]` maps to ordinary `msb restore`; when SNAPSHOT is omitted, acq + restores the newest date-stamped archive for NAME. Every restore re-applies kits + via the exec-heal path. acq supplies current host-resource + bindings internally. +- sbx is unsupported for these verbs. It fails closed with a clear unsupported + message rather than offering a disk-only degraded mode. +- Restore does not expose user-facing `--vsock`, `--volume`, or `--port` flags. + acq owns the re-plumbing: it records acq-created workspace volume bindings and + CLI/extra kit references in private, SHA-256-addressed host metadata. Archive- + adjacent sidecars are not trusted. acq re-derives the current SSH-agent vsock + route and asks the backend to inherit validated source-local resource records + for the rest. + +## Consequences + +- A supported backend can preserve durable agent context (disk state) across + planned recreation without a per-application backup manifest. In-flight process + state is not preserved; the restored sandbox boots fresh. +- Every restore re-applies kit services via the exec-heal path. +- msb restore can re-derive the current host SSH-agent socket route, avoiding the + stale create-time vsock endpoint that occurs after host reboot. +- Unsupported backends remain honest: the command fails clearly rather than + pretending disk-only templates are equivalent to a live stateful checkpoint. + +## Validation + +- Offline unit tests cover dispatch, capability flags, unsupported sbx behavior, + and the msb command shapes. +- Live validation requires an msb-capable host and should exercise: create an + agent sandbox, snapshot it, remove/recreate through restore, verify agent + history is present on the restored cold-boot disk, and verify SSH signing works + with the restored vsock route. diff --git a/docs/howto/acq.md b/docs/howto/acq.md index 89ffc161..6e0e2c1e 100644 --- a/docs/howto/acq.md +++ b/docs/howto/acq.md @@ -107,7 +107,10 @@ kits, validates your USAi key, and attaches the agent. | `./acq create opencode /proj` | Create detached (no attach) | | `./acq ls` | List your sandboxes | | `./acq stop NAME` | Stop a sandbox | -| `./acq rm NAME` | Remove a sandbox | +| `./acq snapshot NAME [OUT]` | Save durable disk state for a sandbox (msb only) | +| `./acq restore NAME [SNAPSHOT]` | Restore a disk-state snapshot as a cold boot, newest for NAME when omitted (msb only) | +| `./acq recreate NAME [OUT]` | Save disk state, remove, then cold-boot restore a sandbox (msb only) | +| `./acq rm NAME` | Remove a sandbox (`--snapshot` snapshots first on msb) | | `./acq shell NAME` | Open an interactive shell in a sandbox | | `./acq exec NAME -- CMD` | Run a command inside a sandbox | | `./acq cp SRC DST` | Copy files in/out (NAME:path syntax) | diff --git a/docs/howto/msb.md b/docs/howto/msb.md index cba1d32f..799086f1 100644 --- a/docs/howto/msb.md +++ b/docs/howto/msb.md @@ -219,10 +219,12 @@ acq shell my-sandbox # interactive human shell (acq exec runs a command, acq exec my-sandbox -- # run a one-off command in the sandbox ``` -Unlike sbx, msb **can** stop and resume detached long-running sandboxes and -supports snapshot/restore natively (`msb snapshot …`); `acq` does not yet -surface a neutral `acq snapshot` verb (see the Backend Guide's -known-limitations note). +Unlike sbx, msb **can** stop and resume detached long-running sandboxes. It also +supports `acq snapshot` / `acq restore` via MSB's disk-state snapshot/restore +path; every restore is a cold boot from saved disk state and re-applies kits. +sbx remains +unsupported for those acq verbs because its local template flow is +disk-only/fresh-boot. ## Common commands reference @@ -233,6 +235,10 @@ known-limitations note). | Stop sandbox | `acq stop ` | `msb stop ` | | Resume sandbox | `acq run ` | `msb run ` | | Remove sandbox | `acq rm ` | `msb remove ` | +| Snapshot then remove | `acq rm --snapshot [out.msb]` | `msb snapshot create ... && msb remove ...` | +| Snapshot sandbox | `acq snapshot [out.msb]` | `msb snapshot create --from-sandbox [-o out.msb]` | +| Restore snapshot | `acq restore [snapshot.msb]` | `msb restore --name ...` | +| Recreate from snapshot | `acq recreate [out.msb]` | `msb snapshot create ... && msb remove ... && msb restore ...` | | Run a command | `acq exec -- ` | `msb exec -- ` | | Interactive shell | `acq shell ` | `msb exec -it -- bash` | @@ -245,7 +251,6 @@ abstract — use the raw `msb` command: | Check version | `msb --version` | | Self-update | `msb self update` | | Import a local image | `msb image load -i -t ` | -| Snapshots | `msb snapshot …` | ## msb-specific configuration diff --git a/scripts/probe-msb-snapshot-restore b/scripts/probe-msb-snapshot-restore new file mode 100755 index 00000000..e527fe9a --- /dev/null +++ b/scripts/probe-msb-snapshot-restore @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# Host-only MSB snapshot/restore capability probe. +# +# This deliberately bypasses acq to establish the installed MSB CLI's supported +# archive/restore pairings before acq translates them. It creates no host mounts +# or secrets, and uses only disposable probe sandboxes. + +set -u + +usage() { + cat <<'USAGE' +Usage: + scripts/probe-msb-snapshot-restore [IMAGE] + +Environment: + MSB_PROBE_IMAGE= default: alpine + VERIFY_KEEP=1 retain probe sandboxes and artifacts under tmp/ + +The probe tests each pairing independently: + 1. default archive -> ordinary restore + 2. full archive -> ordinary restore + 3. full archive -> --disk-only restore + 4. local full snapshot -> ordinary restore + 5. local full snapshot -> --disk-only restore +USAGE +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +command -v msb >/dev/null 2>&1 || { + echo "ERROR: msb is not on PATH" >&2 + exit 1 +} + +IMAGE="${1:-${MSB_PROBE_IMAGE:-alpine}}" +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +PREFIX="msb-snapshot-probe-$$" +ARTIFACT_ROOT="$ROOT/tmp" +WORKDIR="$ARTIFACT_ROOT/$PREFIX" +mkdir -p "$WORKDIR" || exit 1 +SENTINEL="/root/msb-snapshot-probe-sentinel" +VALUE="${PREFIX}-$(date -u +%Y%m%dT%H%M%SZ)" +PASS=0 +FAIL=0 + +say() { printf '\n==> %s\n' "$*" >&2; } +LOG="$WORKDIR/probe.log" +exec > >(tee -a "$LOG") 2>&1 + +cleanup() { + [ "${VERIFY_KEEP:-0}" = "1" ] && { + say "Retaining probe sandboxes and archives" + printf 'Archive directory: %s\n' "$WORKDIR" >&2 + return + } + for name in "$PREFIX-disk" "$PREFIX-disk-restored" "$PREFIX-full" \ + "$PREFIX-full-restored" "$PREFIX-full-disk" "$PREFIX-full-disk-restored" \ + "$PREFIX-local-full" "$PREFIX-local-full-restored" "$PREFIX-local-disk" \ + "$PREFIX-local-disk-restored"; do + msb remove --force "$name" >/dev/null 2>&1 || true + done + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +create_source() { + local name="$1" + msb remove --force "$name" >/dev/null 2>&1 || true + if ! msb create --name "$name" "$IMAGE"; then + echo "FAIL: msb create failed for $name" >&2 + return 1 + fi + if ! msb exec "$name" -- sh -c "set -eu; printf '%s\\n' '$VALUE' >'$SENTINEL'; test -s '$SENTINEL'; sync '$SENTINEL' 2>/dev/null || sync"; then + echo "FAIL: writing source sentinel failed for $name" >&2 + return 1 + fi + if ! verify_sentinel "$name"; then + echo "FAIL: reading source sentinel failed for $name" >&2 + return 1 + fi + printf 'Source sentinel verified before capture.\n' + msb exec "$name" -- sh -c "ls -l '$SENTINEL'; df -T '$SENTINEL' 2>/dev/null || df '$SENTINEL'; mount 2>/dev/null | grep -F ' / ' || true" +} + +verify_sentinel() { + local name="$1" + local actual + actual=$(msb exec "$name" -- sh -c "cat '$SENTINEL'") || return 1 + # MSB 0.7.7's host-side exec transport appends CR to guest line output. + actual=${actual%$'\r'} + if [ "$actual" != "$VALUE" ]; then + printf 'Sentinel mismatch for %s: expected=%q actual=%q\n' "$name" "$VALUE" "$actual" >&2 + return 1 + fi +} + +run_case() { + local label="$1" create_args="$2" restore_args="$3" source="$4" target="$5" archive="$6" + local rc=0 + say "$label" + if ! create_source "$source"; then + echo "FAIL: source creation failed" >&2 + FAIL=$((FAIL + 1)) + return + fi + # shellcheck disable=SC2086 # Probe supplies fixed option groups, not user input. + if ! msb snapshot create --from-sandbox "$source" $create_args -o "$archive"; then + echo "FAIL: snapshot creation failed" >&2 + FAIL=$((FAIL + 1)) + return + fi + msb remove --force "$source" || { echo "FAIL: source removal failed" >&2; FAIL=$((FAIL + 1)); return; } + # shellcheck disable=SC2086 # Probe supplies fixed option groups, not user input. + msb restore "$archive" --name "$target" $restore_args || rc=$? + if [ "$rc" -ne 0 ]; then + printf 'FAIL: restore exited %s\n' "$rc" >&2 + FAIL=$((FAIL + 1)) + return + fi + if verify_sentinel "$target"; then + echo "PASS: restore succeeded and preserved the sentinel" >&2 + PASS=$((PASS + 1)) + else + echo "FAIL: restore succeeded but the sentinel was not preserved" >&2 + FAIL=$((FAIL + 1)) + fi +} + +run_local_case() { + local label="$1" create_args="$2" restore_args="$3" source="$4" target="$5" + local checkpoint="recovery" snapshot rc=0 + say "$label" + if ! create_source "$source"; then + echo "FAIL: source creation failed" >&2 + FAIL=$((FAIL + 1)) + return + fi + # shellcheck disable=SC2086 # Probe supplies fixed option groups, not user input. + # A named member gives restore a stable group:member selector. The CLI's + # human-readable creation output is intentionally not treated as a selector. + msb snapshot create --from-sandbox "$source" "$checkpoint" $create_args || { + echo "FAIL: local snapshot creation failed" >&2 + FAIL=$((FAIL + 1)) + return + } + snapshot="$source:$checkpoint" + printf '%s\n' "$snapshot" >"$WORKDIR/${target}.snapshot-ref" + printf 'Local snapshot reference: %s\n' "$snapshot" + msb remove --force "$source" || { echo "FAIL: source removal failed" >&2; FAIL=$((FAIL + 1)); return; } + # shellcheck disable=SC2086 # Probe supplies fixed option groups, not user input. + msb restore "$snapshot" --name "$target" $restore_args || rc=$? + if [ "$rc" -ne 0 ]; then + printf 'FAIL: restore exited %s\n' "$rc" >&2 + FAIL=$((FAIL + 1)) + return + fi + if verify_sentinel "$target"; then + echo "PASS: restore succeeded and preserved the sentinel" >&2 + PASS=$((PASS + 1)) + else + echo "FAIL: restore succeeded but the sentinel was not preserved" >&2 + FAIL=$((FAIL + 1)) + fi +} + +say "MSB version" +msb --version +say "Installed snapshot create help" +msb snapshot create --help +say "Installed restore help" +msb restore --help +say "Testing image: $IMAGE" +say "Probe archive directory: $WORKDIR" + +run_case "default archive -> ordinary restore" \ + "--guest-flush required" "" \ + "$PREFIX-disk" "$PREFIX-disk-restored" "$WORKDIR/default.msb" +run_case "full archive -> ordinary restore" \ + "--full --guest-flush required" "" \ + "$PREFIX-full" "$PREFIX-full-restored" "$WORKDIR/full.msb" +run_case "full archive -> disk-only restore" \ + "--full --guest-flush required" "--disk-only" \ + "$PREFIX-full-disk" "$PREFIX-full-disk-restored" "$WORKDIR/full-disk-only.msb" +run_local_case "local full snapshot -> ordinary restore" \ + "--full --guest-flush required" "" \ + "$PREFIX-local-full" "$PREFIX-local-full-restored" +run_local_case "local full snapshot -> disk-only restore" \ + "--full --guest-flush required" "--disk-only" \ + "$PREFIX-local-disk" "$PREFIX-local-disk-restored" + +say "Probe summary: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index ff41fb50..b5d75478 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -122,6 +122,7 @@ case "${1:-}" in create) [ "${STUB_SBX_CREATE_FAIL:-0}" = "1" ] && exit 1 : >"$STUBDIR/.created"; exit 0 ;; + snapshot|restore) exit 64 ;; exec) snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done @@ -261,7 +262,8 @@ case "$_msb_sub" in inspect) # `msb inspect --format json` — emit a create-time published-ports # JSON fixture if the test planted one, else nothing (models an absent field - # / no ports so _acq_msb_ports_from_inspect must degrade gracefully). + # / no ports so _acq_msb_ports_from_inspect must degrade gracefully). When no + # fixture exists, require the sandbox to exist like real `msb inspect`. # STUB_MSB_INSPECT_FAIL_TIMES=N fails the first N inspects (with a stderr # line) and then succeeds, modelling a transient inspect failure. _inspect_fails=$(cat "$STUBDIR/.msb_inspect_fails" 2>/dev/null || echo 0) @@ -270,7 +272,14 @@ case "$_msb_sub" in echo "error: stub inspect failure" >&2 exit 1 fi - [ -f "$STUBDIR/.msb_inspect_json" ] && cat "$STUBDIR/.msb_inspect_json" ;; + if [ -f "$STUBDIR/.msb_inspect_json" ]; then + cat "$STUBDIR/.msb_inspect_json" + exit 0 + fi + if [ -f "$STUBDIR/.msb_sandbox_list" ] && grep -Fxq -- "${2:-}" "$STUBDIR/.msb_sandbox_list"; then + exit 0 + fi + exit 1 ;; list|ls) [ "${STUB_MSB_LIST_FAIL:-0}" = "1" ] && exit 1 # `msb list --running -q` (running-state probe, ADR-0017 stopped detection): @@ -285,6 +294,7 @@ case "$_msb_sub" in [ -f "$STUBDIR/.msb_sandbox_list" ] && cat "$STUBDIR/.msb_sandbox_list" fi ;; exec) + [ "${STUB_MSB_EXEC_FAIL:-0}" = "1" ] && exit 1 snippet=""; prev="" for a in "$@"; do [ "$prev" = "-c" ] && { snippet="$a"; break; }; prev="$a"; done # `opencode --version` postinstall functionality probe (bare argv). Default @@ -446,6 +456,20 @@ case "$_msb_sub" in { [ -n "${USAI_API_KEY:-}" ] && printf 'USAI_API_KEY=present\n' >>"$CALLS"; } || true { [ -n "${GITHUB_TOKEN:-}" ] && printf 'GITHUB_TOKEN=present\n' >>"$CALLS"; } || true : ;; + snapshot) + _out="" + while [ "$#" -gt 0 ]; do + [ "$1" = "-o" ] && { shift; _out="${1:-}"; } + shift || true + done + [ -n "$_out" ] && : >"$_out" + { [ -n "${USAI_API_KEY:-}" ] && printf 'USAI_API_KEY=present\n' >>"$CALLS"; } || true + { [ -n "${GITHUB_TOKEN:-}" ] && printf 'GITHUB_TOKEN=present\n' >>"$CALLS"; } || true + : ;; + restore) + { [ -n "${USAI_API_KEY:-}" ] && printf 'USAI_API_KEY=present\n' >>"$CALLS"; } || true + { [ -n "${GITHUB_TOKEN:-}" ] && printf 'GITHUB_TOKEN=present\n' >>"$CALLS"; } || true + : ;; remove|rm) # STUB_MSB_RM_FAIL=1 models a failed `msb remove` (e.g. sandbox not found) # for the ADR-0022 terminate volume-cleanup matrix. diff --git a/scripts/verify-msb-host-config-mount b/scripts/verify-msb-host-config-mount new file mode 100644 index 00000000..47ab9e3b --- /dev/null +++ b/scripts/verify-msb-host-config-mount @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# verify-msb-host-config-mount — live macOS/MSB check for canonicalizing the +# host-authoritative config bind mount before `msb create` receives it. +# +# The normal verifier stages this config under its temporary state directory. +# On macOS that is usually below /var/folders, where /var is a symlink to +# /private/var. msb rejects the uncanonicalized source path. This wrapper retains +# any sandbox the verifier creates; do not remove it without explicit approval. +# +# Usage: ./scripts/verify-msb-host-config-mount +set -eu + +SCRIPT_DIR=$(cd "$(dirname "$0")/.." && pwd) +exec env VERIFY_KEEP=1 "$SCRIPT_DIR/scripts/verify-backends" --only msb diff --git a/scripts/verify-native-snapshot-restore b/scripts/verify-native-snapshot-restore new file mode 100755 index 00000000..300776a6 --- /dev/null +++ b/scripts/verify-native-snapshot-restore @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# +# Live host-side verification for acq native msb snapshot/restore. +# +# This intentionally exercises the real msb backend. It creates a sandbox, +# snapshots it, removes/restores it, verifies SSH-agent vsock re-plumbing, and +# optionally tests an explicit-copy restore and one-shot recreate. + +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/verify-native-snapshot-restore [SANDBOX] [WORKSPACE] + +Defaults: + SANDBOX snaptest-opencode + WORKSPACE current directory + +Environment: + AGENT=shell agent to create for the test sandbox + ACQ_SNAPSHOT_DIR= override snapshot archive directory + VERIFY_SNAPSHOT_CLEANUP=1 remove the test sandboxes at the end + VERIFY_SNAPSHOT_FORCE=1 allow pre-cleaning a non-snaptest name + VERIFY_SNAPSHOT_SKIP_CREATE=1 skip initial create/attach step + VERIFY_SNAPSHOT_SKIP_COPY=1 skip explicit archive restore copy test + VERIFY_SNAPSHOT_SKIP_RECREATE=1 skip acq recreate test + +Prerequisites: + - Run from a quickstart checkout with this branch checked out. + - msb must be installed and usable. + - SSH_AUTH_SOCK must point at a live host ssh-agent socket. + - A USAi key must already be stored or exported; the verifier is non-interactive. +USAGE +} + +if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then + usage + exit 0 +fi + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +ACQ="$ROOT/acq" +SANDBOX="${1:-snaptest-opencode}" +WORKSPACE="${2:-$PWD}" +AGENT="${AGENT:-shell}" +SNAPDIR="${ACQ_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq/snapshots}" +COPY_SANDBOX="${SANDBOX}-copy" +SENTINEL_PATH="/home/agent/.acq-native-snapshot-restore-sentinel" +SENTINEL_VALUE="acq-native-snapshot-restore:$SANDBOX:$(date -u +%Y%m%dT%H%M%SZ):$$" + +say() { printf '\n==> %s\n' "$*" >&2; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +sandbox_exists() { + "$ACQ" --backend msb ls 2>/dev/null | grep -F "$1" >/dev/null 2>&1 +} + +preclean_sandbox() { + local name="$1" + sandbox_exists "$name" || return 0 + case "$name" in + snaptest-*) ;; + *) + [ "${VERIFY_SNAPSHOT_FORCE:-0}" = "1" ] || fail "sandbox '$name' already exists; set VERIFY_SNAPSHOT_FORCE=1 to pre-clean non-snaptest names" + ;; + esac + say "Removing existing test sandbox before verification: $name" + "$ACQ" --backend msb rm "$name" || true +} + +write_sentinel() { + say "Writing guest-state sentinel before snapshot" + "$ACQ" --backend msb exec "$SANDBOX" \ + -e "ACQ_VERIFY_SENTINEL_PATH=$SENTINEL_PATH" \ + -e "ACQ_VERIFY_SENTINEL_VALUE=$SENTINEL_VALUE" \ + -- sh -lc ' + printf "%s\n" "$ACQ_VERIFY_SENTINEL_VALUE" >"$ACQ_VERIFY_SENTINEL_PATH" + test -s "$ACQ_VERIFY_SENTINEL_PATH" + sync "$ACQ_VERIFY_SENTINEL_PATH" 2>/dev/null || sync + printf "sentinel written: %s\n" "$ACQ_VERIFY_SENTINEL_PATH" + ' +} + +verify_sentinel() { + local name="$1" label="$2" + say "Verifying restored sentinel in $label" + "$ACQ" --backend msb exec "$name" \ + -e "ACQ_VERIFY_SENTINEL_PATH=$SENTINEL_PATH" \ + -e "ACQ_VERIFY_SENTINEL_VALUE=$SENTINEL_VALUE" \ + -- sh -lc ' + actual=$(cat "$ACQ_VERIFY_SENTINEL_PATH") || exit 1 + [ "$actual" = "$ACQ_VERIFY_SENTINEL_VALUE" ] || { + printf "sentinel mismatch: expected %s, got %s\n" "$ACQ_VERIFY_SENTINEL_VALUE" "$actual" >&2 + exit 1 + } + printf "sentinel restored: %s\n" "$ACQ_VERIFY_SENTINEL_PATH" + ' +} + +[ -x "$ACQ" ] || fail "acq is not executable at $ACQ" +[ -d "$WORKSPACE" ] || fail "workspace is not a directory: $WORKSPACE" +command -v msb >/dev/null 2>&1 || fail "msb is not on PATH" +[ -n "${SSH_AUTH_SOCK:-}" ] || fail "SSH_AUTH_SOCK is not set" +[ -S "$SSH_AUTH_SOCK" ] || fail "SSH_AUTH_SOCK is not a live socket: $SSH_AUTH_SOCK" + +say "Using acq: $ACQ" +say "Sandbox: $SANDBOX" +say "Workspace: $WORKSPACE" +say "Snapshot directory: $SNAPDIR" + +preclean_sandbox "$COPY_SANDBOX" +if [ "${VERIFY_SNAPSHOT_SKIP_CREATE:-0}" != "1" ]; then + preclean_sandbox "$SANDBOX" + say "Creating sandbox non-interactively" + "$ACQ" --backend msb create --no-update-check "$AGENT" "$WORKSPACE" --name "$SANDBOX" /dev/null | head -n 1 || true) +[ -n "$LATEST" ] || fail "no snapshot archive found for $SANDBOX in $SNAPDIR" +say "Latest snapshot: $LATEST" + +say "Removing sandbox to simulate recreate/recovery" +"$ACQ" --backend msb rm "$SANDBOX" + +say "Restoring by sandbox name only (should pick newest date-stamped snapshot)" +"$ACQ" --backend msb restore "$SANDBOX" + +say "Verifying sandbox is listed" +"$ACQ" --backend msb ls | grep -F "$SANDBOX" >/dev/null || fail "restored sandbox not listed: $SANDBOX" + +verify_sentinel "$SANDBOX" "primary restore" + +say "Verifying SSH-agent is reachable through restored vsock route" +"$ACQ" --backend msb exec "$SANDBOX" -- sh -lc 'echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK"; ssh-add -l >/dev/null && echo "ssh-agent reachable"' + +if [ "${VERIFY_SNAPSHOT_SKIP_COPY:-0}" != "1" ]; then + say "Restoring explicit archive into copy sandbox: $COPY_SANDBOX" + "$ACQ" --backend msb restore "$COPY_SANDBOX" "$LATEST" + verify_sentinel "$COPY_SANDBOX" "copy restore" + "$ACQ" --backend msb exec "$COPY_SANDBOX" -- sh -lc 'ssh-add -l >/dev/null && echo "copy ssh-agent reachable"' +else + say "Skipping explicit archive copy restore because VERIFY_SNAPSHOT_SKIP_COPY=1" +fi + +if [ "${VERIFY_SNAPSHOT_SKIP_RECREATE:-0}" != "1" ]; then + say "Testing one-shot acq recreate on primary sandbox" + "$ACQ" --backend msb recreate "$SANDBOX" + verify_sentinel "$SANDBOX" "recreate" + "$ACQ" --backend msb exec "$SANDBOX" -- sh -lc 'ssh-add -l >/dev/null && echo "recreated ssh-agent reachable"' +else + say "Skipping acq recreate because VERIFY_SNAPSHOT_SKIP_RECREATE=1" +fi + +if [ "${VERIFY_SNAPSHOT_CLEANUP:-0}" = "1" ]; then + say "Cleaning up test sandboxes" + "$ACQ" --backend msb rm --snapshot "$SANDBOX" || true + "$ACQ" --backend msb rm --snapshot "$COPY_SANDBOX" || true +else + say "Leaving sandboxes in place. Set VERIFY_SNAPSHOT_CLEANUP=1 to clean up on a future run." +fi + +say "Native snapshot/restore verification completed" diff --git a/test/bats/117-msys-path-forms.bats b/test/bats/117-msys-path-forms.bats index 5624056d..deef37e8 100644 --- a/test/bats/117-msys-path-forms.bats +++ b/test/bats/117-msys-path-forms.bats @@ -240,6 +240,25 @@ _msys_provision() { # NAME WS assert_output --partial 'forms-differ' } +@test "msys: snapshot output and restore archive paths get the host form" { + _plant_cygpath_stub + : > "$STUBDIR/saved.msb" + run bash -c ' + export ACQ_SCRIPT_DIR="'"$REPO_ROOT"'" + . "'"$REPO_ROOT"'/acq.backends/common.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_snapshot somebox "'"$STUBDIR"'/saved.msb" >/dev/null 2>&1 + acq_backend_restore "'"$STUBDIR"'/saved.msb" --name restored >/dev/null 2>&1 || true + snapshot_host=$(host_path "'"$STUBDIR"'/saved.msb") + grep -q -- "-o $snapshot_host" "'"$CALLS"'" && echo "snapshot-host-form-ok" + grep -Eq -- "restore [A-Z]:.*msb-snapshots/\\.restore\\.[[:alnum:]]{6} --name restored" "'"$CALLS"'" && echo "restore-host-form-ok" + [ "$snapshot_host" != "'"$STUBDIR"'/saved.msb" ] && echo "forms-differ" || echo "forms-same" + ' + assert_output --partial 'snapshot-host-form-ok' + assert_output --partial 'restore-host-form-ok' + assert_output --partial 'forms-differ' +} + @test "msys: msb is invoked with MSYS argument rewriting disabled" { cat >"$STUBDIR/msb" <<'MSBENVSTUB' #!/usr/bin/env bash diff --git a/test/bats/30-dispatch-routing.bats b/test/bats/30-dispatch-routing.bats index e8192d2b..5a715977 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -162,6 +162,28 @@ FACTS refute_output --partial 'passed through' } +@test "snapshot/restore: --help documents acq-owned disk-state verbs (no memory/live-state claims)" { + run env ACQ_BACKEND=sbx "$ACQ" snapshot --help + assert_output --partial 'durable disk state' + assert_output --partial 'cold-boots' + assert_output --partial 'backends fail closed' + refute_output --partial 'live state' + refute_output --partial 'execution, and device' + refute_output --partial 'passed through' + run env ACQ_BACKEND=sbx "$ACQ" restore --help + assert_output --partial 'cold boot' + assert_output --partial 'disk image' + assert_output --partial 'host-resource re-plumbing' + refute_output --partial 'live state' + refute_output --partial 'passed through' + run env ACQ_BACKEND=sbx "$ACQ" recreate --help + assert_output --partial 'disk state' + assert_output --partial 'cold-boot' + assert_output --partial 'host-resource re-plumbing' + refute_output --partial 'live state' + refute_output --partial 'passed through' +} + @test "dispatch: version reports backend and script path" { run env ACQ_BACKEND=sbx "$ACQ" version assert_output --partial 'backend:' diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index be72d4ce..2f6d64cb 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -24,6 +24,20 @@ _with_adapter() { # ADAPTER BODY ' _ "$1" "$2" } +_mk_unix_socket() { + python3 -c 'import socket,sys +s=socket.socket(socket.AF_UNIX) +s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] +} + +_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + @test "msb: auto-detect prefers msb when both present and no sbx sandboxes" { rm -f "$STUBDIR/.sandbox_list" run bash -c ' @@ -59,9 +73,346 @@ _with_adapter() { # ADAPTER BODY assert_output --partial 'msb' } -@test "msb: advertises SUPPORTS_SNAPSHOTS=0 (matches acq's surfaced verbs, #225)" { +@test "msb: advertises SUPPORTS_SNAPSHOTS=1 because acq surfaces disk-state snapshot/restore" { run bash -c '. "'"$REPO_ROOT"'/acq.backends/msb.sh"; printf "%s" "$ACQ_BACKEND_SUPPORTS_SNAPSHOTS"' - assert_output '0' + assert_output '1' +} + +@test "msb: snapshot maps to a disk-state msb archive with date-stamped default output" { + run env ACQ_BACKEND=msb ACQ_SNAPSHOT_DIR="$STUBDIR/snapshots" "$ACQ" snapshot mybox + assert_success + assert_regex "$(cat "$CALLS")" "msb snapshot create --from-sandbox mybox --guest-flush auto -o $STUBDIR/snapshots/mybox-[0-9]{8}T[0-9]{6}Z\.msb" + [ "$(stat -c '%a' "$STUBDIR/snapshots" 2>/dev/null || stat -f '%Lp' "$STUBDIR/snapshots")" = "700" ] + : > "$CALLS" + mkdir -p "$STUBDIR/state/msb-restore" "$STUBDIR/provenance/msb" "$STUBDIR/ws" + printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/state/msb-restore/mybox.resources" + local kit_record + kit_record=$(printf '%s' mybox | cksum | cut -d' ' -f1) + printf 'schema=1\nkit=%s\n' "$STUBDIR/custom-kit" > "$STUBDIR/provenance/msb/mybox.${kit_record}.kits" + acq_host_config_write msb mybox agent opencode + acq_host_config_write msb mybox workspace "$STUBDIR/ws" + acq_host_config_write msb mybox generic-opencode generic-opencode + acq_workspace_record_write msb mybox "$STUBDIR/ws" + run env ACQ_BACKEND=msb ACQ_PROVENANCE_DIR="$STUBDIR/provenance" "$ACQ" snapshot mybox "$STUBDIR/mybox.msb" + assert_success + assert_regex "$(cat "$CALLS")" "msb snapshot create --from-sandbox mybox --guest-flush auto -o $STUBDIR/mybox\.msb" + [ "$(stat -c '%a' "$STUBDIR/mybox.msb" 2>/dev/null || stat -f '%Lp' "$STUBDIR/mybox.msb")" = "600" ] + [ -f "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/mybox.msb").resources" ] + [ -f "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/mybox.msb").state" ] +} + +@test "msb: create with no workspace positional provisions without empty-array abort" { + : > "$CALLS" + run env ACQ_BACKEND=msb USAI_API_KEY="sk-ci-host" "$ACQ" create opencode + assert_success + refute_output --partial 'unbound variable' + assert_regex "$(cat "$CALLS")" 'msb create --name opencode' +} + +@test "msb: snapshot preserves legacy provenance agent state" { + mkdir -p "$STUBDIR/provenance/msb" "$STUBDIR/ws" + local record + record=$(printf '%s' legacybox | cksum | cut -d' ' -f1) + printf 'schema=1\nbackend=msb\nagent=opencode\nworkspace_source=host\nworkspace=%s\n' "$STUBDIR/ws" > "$STUBDIR/provenance/msb/legacybox.${record}.env" + : > "$CALLS" + run env ACQ_BACKEND=msb ACQ_PROVENANCE_DIR="$STUBDIR/provenance" "$ACQ" snapshot legacybox "$STUBDIR/legacy.msb" + assert_success + assert_regex "$(cat "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/legacy.msb").state")" $'agent\topencode' + assert_regex "$(cat "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/legacy.msb").state")" $'workspace\t'"$STUBDIR/ws" +} + +@test "msb: restore maps to msb restore with inherited resources and re-derived vsock" { + _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" + local sock_path="$STUBDIR/agent.sock" + command -v realpath >/dev/null 2>&1 && sock_path=$(realpath "$sock_path") + mkdir -p "$STUBDIR/secrets" "$STUBDIR/ws" "$STUBDIR/provenance/msb" + printf 'sk-restored\n' > "$STUBDIR/secrets/acq.usai" + : > "$STUBDIR/saved.msb" + mkdir -p "$STUBDIR/state/msb-snapshots" + printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/saved.msb").resources" + printf 'schema=1\nkit=%s\n' "$STUBDIR/custom-kit" > "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/saved.msb").kits" + printf 'agent\topencode\nworkspace\t%s\ngeneric-opencode\tgeneric-opencode\nhost-workspace\t%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/saved.msb").state" + mkdir -p "$STUBDIR/custom-kit" + printf 'schemaVersion: "hybrid/v1"\nkind: mixin\nname: custom\ndisplayName: Custom\ndescription: custom\n' > "$STUBDIR/custom-kit/spec.yaml" + : > "$CALLS" + run env ACQ_BACKEND=msb ACQ_PROVENANCE_DIR="$STUBDIR/provenance" SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore restored "$STUBDIR/saved.msb" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name restored --dangerously-inherit-resources --volume $STUBDIR/provenance/msb/restored\\.[0-9]+\\.config:$ACQ_HOST_CONFIG_GUEST_DIR:ro --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream --secret USAI_API_KEY@api.gsa.usai.gov" + assert_regex "$log" 'USAI_API_KEY=present' + assert_regex "$log" 'socat UNIX-LISTEN:' + assert_regex "$log" 'msb inspect restored --format json' + assert_output --partial "re-applied kit startup" + [ -f "$STUBDIR/state/msb-restore/restored.resources" ] + assert_equal "$(acq_host_config_read msb restored agent)" opencode + assert_equal "$(acq_host_config_read msb restored workspace)" "$STUBDIR/ws" + assert_equal "$(acq_host_config_read msb restored generic-opencode)" generic-opencode + assert_equal "$(acq_workspace_record_read msb restored)" "$STUBDIR/ws" + local kit_record + kit_record=$(printf '%s' restored | cksum | cut -d' ' -f1) + assert [ -f "$STUBDIR/provenance/msb/restored.${kit_record}.kits" ] + assert_regex "$(cat "$STUBDIR/provenance/msb/restored.${kit_record}.kits")" "$STUBDIR/custom-kit" + [ -f "$STUBDIR/state/msb-restore/restored.resources" ] +} + +@test "msb: restore without external workspace mounts re-applies kits" { + _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" + : > "$STUBDIR/disk-state.msb" + : > "$CALLS" + run env ACQ_BACKEND=msb SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore restored "$STUBDIR/disk-state.msb" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name restored --dangerously-inherit-resources --volume $STUBDIR/provenance/msb/restored\\.[0-9]+\\.config:$ACQ_HOST_CONFIG_GUEST_DIR:ro --vsock" + assert_regex "$log" 'msb inspect restored --format json' + assert_output --partial "re-applied kit startup" +} + +@test "msb: metadata-free restore clears stale name-scoped state" { + mkdir -p "$STUBDIR/stale-workspace" "$STUBDIR/state/msb-restore" + : > "$STUBDIR/disk-state.msb" + printf 'volume\t%s:%s\n' "$STUBDIR/stale-workspace" "$STUBDIR/stale-workspace" > "$STUBDIR/state/msb-restore/restored.resources" + acq_host_config_write msb restored agent opencode + acq_host_config_write msb restored workspace "$STUBDIR/stale-workspace" + acq_host_config_write msb restored generic-opencode generic-opencode + acq_workspace_record_write msb restored "$STUBDIR/stale-workspace" + acq_cli_kits_write msb restored /stale/kit + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" restore restored "$STUBDIR/disk-state.msb" + assert_success + refute_regex "$(cat "$CALLS")" "--volume $STUBDIR/stale-workspace:$STUBDIR/stale-workspace" + [ ! -f "$STUBDIR/state/msb-restore/restored.resources" ] + assert_equal "$(acq_host_config_read msb restored agent)" "" + assert_equal "$(acq_host_config_read msb restored workspace)" "" + assert_equal "$(acq_host_config_read msb restored generic-opencode)" "" + assert_equal "$(acq_workspace_record_read msb restored)" "" +} + +@test "msb: restore refuses an existing destination without clearing its state" { + mkdir -p "$STUBDIR/existing-workspace" "$STUBDIR/state/msb-restore" + : > "$STUBDIR/disk-state.msb" + printf 'restored\n' > "$STUBDIR/.msb_sandbox_list" + printf 'volume\t%s:%s\n' "$STUBDIR/existing-workspace" "$STUBDIR/existing-workspace" > "$STUBDIR/state/msb-restore/restored.resources" + acq_host_config_write msb restored agent opencode + acq_workspace_record_write msb restored "$STUBDIR/existing-workspace" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" restore restored "$STUBDIR/disk-state.msb" + assert_failure + assert_output --partial "destination sandbox 'restored' already exists" + refute_regex "$(cat "$CALLS")" 'msb restore' + [ -f "$STUBDIR/state/msb-restore/restored.resources" ] + assert_equal "$(acq_host_config_read msb restored agent)" opencode + assert_equal "$(acq_workspace_record_read msb restored)" "$STUBDIR/existing-workspace" +} + +@test "msb: restore preserves destination state when absence cannot be confirmed" { + mkdir -p "$STUBDIR/existing-workspace" "$STUBDIR/state/msb-restore" + : > "$STUBDIR/disk-state.msb" + printf 'volume\t%s:%s\n' "$STUBDIR/existing-workspace" "$STUBDIR/existing-workspace" > "$STUBDIR/state/msb-restore/restored.resources" + acq_host_config_write msb restored agent opencode + acq_workspace_record_write msb restored "$STUBDIR/existing-workspace" + : > "$CALLS" + run env ACQ_BACKEND=msb STUB_MSB_LIST_FAIL=1 "$ACQ" restore restored "$STUBDIR/disk-state.msb" + assert_failure + assert_output --partial "could not confirm destination sandbox 'restored' is absent" + refute_regex "$(cat "$CALLS")" 'msb restore' + [ -f "$STUBDIR/state/msb-restore/restored.resources" ] + assert_equal "$(acq_host_config_read msb restored agent)" opencode + assert_equal "$(acq_workspace_record_read msb restored)" "$STUBDIR/existing-workspace" +} + +@test "msb: restore uses the provider facts secret binding" { + local kit="$STUBDIR/provider-kit" + mkdir -p "$kit/provider-facts" "$STUBDIR/secrets" + cat > "$kit/spec.yaml" <<'SPEC' +schemaVersion: "hybrid/v1" +kind: mixin +name: usai-provider +displayName: USAi Provider +description: test fixture +SPEC + cat > "$kit/provider-facts/usai.env" <<'FACTS' +ACQ_PROVIDER_FACTS_SCHEMA=1 +ACQ_PROVIDER_ID=usai +ACQ_PROVIDER_HOST=api.restore.example.gov +ACQ_PROVIDER_BASE_URL=https://api.restore.example.gov/api/v1 +ACQ_PROVIDER_MODELS_URL=https://api.restore.example.gov/api/v1/models +ACQ_PROVIDER_KEY_ENV=RESTORE_API_KEY +ACQ_PROVIDER_KEY_MGMT_URL=https://restore.example.gov/keys +ACQ_PROVIDER_BIND_HOSTS=api.restore.example.gov +FACTS + printf 'restored-key\n' > "$STUBDIR/secrets/acq.usai" + : > "$STUBDIR/disk-state.msb" + : > "$CALLS" + run env ACQ_BACKEND=msb ACQ_TEST_USAI_KIT="$kit" "$ACQ" restore restored "$STUBDIR/disk-state.msb" + assert_success + assert_regex "$(cat "$CALLS")" 'msb restore .* --secret RESTORE_API_KEY@api\.restore\.example\.gov' +} + +@test "msb: restore ignores archive-adjacent resource sidecars" { + mkdir -p "$STUBDIR/ws" "$STUBDIR/attacker" + : > "$STUBDIR/untrusted.msb" + printf 'volume\t%s:%s\n' "$STUBDIR/attacker" "$STUBDIR/attacker" > "$STUBDIR/untrusted.msb.resources" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" restore restored "$STUBDIR/untrusted.msb" + assert_success + refute_regex "$(cat "$CALLS")" "--volume $STUBDIR/attacker:$STUBDIR/attacker" +} + +@test "msb: private staging keeps identical snapshot restores independent" { + : > "$STUBDIR/shared.msb" + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + first=$(_acq_msb_snapshot_stage "'"$STUBDIR"'/shared.msb") || exit 1 + second=$(_acq_msb_snapshot_stage "'"$STUBDIR"'/shared.msb") || exit 1 + [ "$first" != "$second" ] || exit 1 + [ -f "$first" ] && [ -f "$second" ] || exit 1 + rm -f "$first" + [ ! -e "$first" ] && [ -f "$second" ] || exit 1 + rm -f "$second" + printf "%s\n%s\n" "$first" "$second" + ' + assert_success + assert_line --index 0 --regexp "/msb-snapshots/\\.restore\\.[[:alnum:]]{6}$" + assert_line --index 1 --regexp "/msb-snapshots/\\.restore\\.[[:alnum:]]{6}$" +} + +@test "msb: restore fails when the guest never becomes exec-ready" { + : > "$STUBDIR/unready.msb" + : > "$CALLS" + run env ACQ_BACKEND=msb STUB_MSB_EXEC_FAIL=1 ACQ_MSB_EXEC_READY_TIMEOUT=0 \ + "$ACQ" restore restored "$STUBDIR/unready.msb" + assert_failure + assert_output --partial "did not become exec-ready" + refute_output --partial "acq: restored 'restored'." +} + +@test "msb: restore without a snapshot picks the newest date-stamped snapshot for the sandbox" { + _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" + mkdir -p "$STUBDIR/snapshots" "$STUBDIR/ws2" + : > "$STUBDIR/snapshots/mybox-20260920T010203Z.msb" + : > "$STUBDIR/snapshots/mybox-20260921T010203Z.msb" + mkdir -p "$STUBDIR/state/msb-snapshots" + printf 'volume\t%s:%s\n' "$STUBDIR/ws2" "$STUBDIR/ws2" > "$STUBDIR/state/msb-snapshots/$(_sha256 "$STUBDIR/snapshots/mybox-20260921T010203Z.msb").resources" + : > "$STUBDIR/snapshots/mybox-z-not-a-date.msb" + : > "$STUBDIR/snapshots/other-20260922T010203Z.msb" + : > "$CALLS" + run env ACQ_BACKEND=msb ACQ_SNAPSHOT_DIR="$STUBDIR/snapshots" SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore mybox + assert_success + assert_regex "$(cat "$CALLS")" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name mybox" +} + +@test "sbx: snapshot/restore/recreate are acq-owned unsupported verbs, not backend passthrough" { + run env ACQ_BACKEND=sbx "$ACQ" snapshot mybox + assert_failure + assert_output --partial 'does not support sandbox snapshots' + refute_regex "$(cat "$CALLS")" 'sbx snapshot' + : > "$CALLS" + run env ACQ_BACKEND=sbx "$ACQ" restore restored "$STUBDIR/saved.sbx" + assert_failure + assert_output --partial 'does not support sandbox restore' + refute_regex "$(cat "$CALLS")" 'sbx restore' + : > "$CALLS" + run env ACQ_BACKEND=sbx "$ACQ" recreate mybox + assert_failure + assert_output --partial 'does not support sandbox recreate' + refute_regex "$(cat "$CALLS")" 'sbx snapshot|sbx restore' +} + +@test "msb: recreate snapshots, removes, then restores to the same name" { + _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" + local sock_path="$STUBDIR/agent.sock" + command -v realpath >/dev/null 2>&1 && sock_path=$(realpath "$sock_path") + mkdir -p "$STUBDIR/state/msb-restore" "$STUBDIR/ws" + printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/state/msb-restore/mybox.resources" + : > "$CALLS" + run env ACQ_BACKEND=msb SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" recreate mybox "$STUBDIR/recreate.msb" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" "msb snapshot create --from-sandbox mybox --guest-flush auto -o $STUBDIR/recreate\.msb" + assert_regex "$log" 'msb remove --force mybox' + refute_regex "$log" 'msb volume rm' + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name mybox --dangerously-inherit-resources --volume $STUBDIR/provenance/msb/mybox\\.[0-9]+\\.config:$ACQ_HOST_CONFIG_GUEST_DIR:ro --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" + assert_regex "$log" 'msb inspect mybox --format json' + assert_output --partial "kit startup re-applied" +} + +@test "msb: rm --snapshot snapshots before removing and does not restore" { + acq_host_config_write msb mybox agent opencode + acq_workspace_record_write msb mybox "$STUBDIR" + acq_cli_kits_write msb mybox + printf 'acq-mybox-volume\n' > "$STUBDIR/.msb_volume_list" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" rm --snapshot mybox "$STUBDIR/rm.msb" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" "msb snapshot create --from-sandbox mybox --guest-flush auto -o $STUBDIR/rm\.msb" + assert_regex "$log" 'msb remove --force mybox' + refute_regex "$log" 'msb restore' + assert_regex "$log" 'msb volume rm' + assert_equal "$(acq_host_config_read msb mybox agent)" "" + assert_equal "$(acq_workspace_record_read msb mybox)" "" + assert_equal "$(acq_provenance_field msb mybox schema)" "" + local kit_record + kit_record=$(printf '%s' mybox | cksum | cut -d' ' -f1) + [ ! -f "$STUBDIR/provenance/msb/mybox.${kit_record}.kits" ] +} + +@test "msb: rm --snapshot refuses clone-backed workspaces without deleting them" { + local clone="$STUBDIR/state/clones/mybox/workspace" + mkdir -p "$clone" + printf 'uncommitted work\n' > "$clone/sentinel" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" rm --snapshot mybox "$STUBDIR/rm.msb" + assert_failure + assert_output --partial "refusing clone-backed sandbox" + [ -f "$clone/sentinel" ] + refute_regex "$(cat "$CALLS")" 'msb snapshot create|msb remove' +} + +@test "msb: snapshot refuses clone-backed workspaces before writing an archive" { + local clone="$STUBDIR/state/clones/mybox/workspace" + mkdir -p "$clone" + printf 'uncommitted work\n' > "$clone/sentinel" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" snapshot mybox "$STUBDIR/snapshot.msb" + assert_failure + assert_output --partial "refusing clone-backed sandbox" + [ -f "$clone/sentinel" ] + refute_regex "$(cat "$CALLS")" 'msb snapshot create' +} + +@test "msb: recreate refuses clone-backed workspaces before removal" { + local clone="$STUBDIR/state/clones/mybox/workspace" + mkdir -p "$clone" + printf 'uncommitted work\n' > "$clone/sentinel" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" recreate mybox "$STUBDIR/recreate.msb" + assert_failure + assert_output --partial "refusing clone-backed sandbox" + [ -f "$clone/sentinel" ] + refute_regex "$(cat "$CALLS")" 'msb snapshot create|msb remove' +} + +@test "msb: restore clears conflicting environment extra kits when snapshot has none" { + : > "$STUBDIR/saved.msb" + : > "$CALLS" + run env ACQ_BACKEND=msb ACQ_EXTRA_KITS=conflicting-kit "$ACQ" restore restored "$STUBDIR/saved.msb" + assert_success + refute_regex "$(cat "$CALLS")" 'conflicting-kit' +} + +@test "msb: strict restore kit heal fails for an extra kit apply failure" { + run bash -c ' + ACQ_SOURCE_ONLY=1 . "'"$ACQ"'" >/dev/null 2>&1 + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + _acq_msb_fetch_kit() { printf "/tmp/%s\n" "$1"; } + _acq_msb_merge_kit_env_into() { eval "$1=()"; } + _acq_msb_reset_kit_env() { :; } + _acq_msb_apply_kit_dir() { [ "$2" != /tmp/extra ]; } + _acq_msb_heal_kit_set box 1 --strict builtin extra + ' + assert_failure } @test "msb: ls/stop/rm/exec dispatch to the msb verbs (exec as agent user)" { diff --git a/test/bats/79-msb-readonly-kit-files.bats b/test/bats/79-msb-readonly-kit-files.bats index 5f87aa7e..588a7122 100644 --- a/test/bats/79-msb-readonly-kit-files.bats +++ b/test/bats/79-msb-readonly-kit-files.bats @@ -190,3 +190,20 @@ SPEC # The host-config dir is mounted :ro at the well-known guest path. assert_regex "$(cat "$CALLS")" -- '--volume [^ ]*romountbox[^ ]*\.config:/var/lib/acq/host:ro' } + +@test "msb ro: a symlinked host-config directory is canonicalized before mounting" { + local state="$STUBDIR/real-state" link="$STUBDIR/link-state" + mkdir -p "$state" + ln -s "$state" "$link" + : > "$CALLS" + run bash -c ' + export ACQ_PROVENANCE_DIR="'"$link"'" ACQ_SECRET_STORE_DIR="'"$STUBDIR"'/symlinked-secrets" + . "'"$REPO_ROOT"'/acq.backends/secret-store.sh" + . "'"$REPO_ROOT"'/acq.backends/kit-translate.sh" + . "'"$REPO_ROOT"'/acq.backends/msb.sh" + acq_backend_provision configlinkbox shell /tmp >/dev/null 2>&1 + ' + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" -- "--volume ${state}/msb/configlinkbox[^ ]*\.config:/var/lib/acq/host:ro" +} diff --git a/test/bats/95-kit-ref-persistence.bats b/test/bats/95-kit-ref-persistence.bats index bb39a704..f6780aa4 100644 --- a/test/bats/95-kit-ref-persistence.bats +++ b/test/bats/95-kit-ref-persistence.bats @@ -32,9 +32,10 @@ load 'helper' load_acq ACQ_CLI_KITS=(); ACQ_EXTRA_KITS="" acq_cli_kits_write msb emptybox - ACQ_CLI_KITS=("/stale/leftover") + ACQ_CLI_KITS=("/stale/leftover"); ACQ_EXTRA_KITS="/stale/extra" acq_cli_kits_load msb emptybox assert_equal "${#ACQ_CLI_KITS[@]}" "0" + assert_equal "$ACQ_EXTRA_KITS" "" } @test "cli-kits: no record at all is a no-op (in-memory set untouched)" {