From 8b44ed8ec3948eb2e4b9f72e537313fc18fa18ce Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Tue, 22 Sep 2026 19:35:56 +0000 Subject: [PATCH 01/15] feat(acq): add msb native snapshot restore --- acq | 181 +++++++++++++++++- acq.backends/msb.sh | 129 +++++++++++-- docs/BACKEND_GUIDE.md | 30 ++- docs/KNOWN_FAILURE_MODES.md | 12 +- docs/adr/0010-acq-pluggable-backends.md | 2 + ...utral-port-publish-and-background-vocab.md | 6 +- docs/adr/0030-native-snapshot-restore.md | 68 +++++++ docs/howto/acq.md | 5 +- docs/howto/msb.md | 13 +- scripts/test-acq-lib.sh | 5 + test/bats/117-msys-path-forms.bats | 19 ++ test/bats/30-dispatch-routing.bats | 15 ++ test/bats/70-msb-backend.bats | 85 +++++++- 13 files changed, 522 insertions(+), 48 deletions(-) create mode 100644 docs/adr/0030-native-snapshot-restore.md diff --git a/acq b/acq index ac643bb4..ba01352b 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] Snapshot a sandbox's live state (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,46 @@ 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 a sandbox's live state (msb only) + +Usage: + acq snapshot NAME [OUT] + +Creates a full native snapshot archive of NAME. On msb this captures disk, +memory, execution, and device state. 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 native snapshot and re-plumb acq-owned resources (msb only) + +Usage: + acq restore NAME [SNAPSHOT] + +Restores NAME from SNAPSHOT. 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 — snapshot, remove, then restore a sandbox (msb only) + +Usage: + acq recreate NAME [OUT] + +Creates a full native snapshot archive, removes NAME, then restores the archive +back to NAME with acq-owned host-resource re-plumbing. OUT defaults to an acq +state directory and is left in place if restore fails. U ;; start) @@ -367,8 +412,11 @@ 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 writes a full native snapshot archive and removes the +sandbox only if that snapshot succeeds. Unsupported backends fail closed. U ;; exec) @@ -476,7 +524,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 +670,30 @@ 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) + mkdir -p "$snapshot_dir" + 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 | while IFS= read -r _snap; do + case "${_snap##*/}" in + ${slug}-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z.msb) printf '%s\n' "$_snap" ;; + esac + done | LC_ALL=C sort | tail -n 1) + [ -n "$latest" ] || return 1 + printf '%s\n' "$latest" +} + # --------------------------------------------------------------------------- # Allow tests to source this script without running the dispatch. # --------------------------------------------------------------------------- @@ -1424,6 +1496,85 @@ 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 stateful snapshots." >&2 + echo " This verb requires a native full-state snapshot/restore primitive." >&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") + 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 stateful 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" + ;; + + 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 stateful recreate." >&2 + echo " This verb requires native full-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 + 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" + echo "acq: recreated '$local_name' from snapshot: $snapshot_out" >&2 + ;; + 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 +1645,34 @@ 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 stateful 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") + acq_backend_snapshot "$local_name" "$snapshot_out" + echo "acq: snapshot saved before removal: $snapshot_out" >&2 + fi acq_backend_terminate "$local_name" ;; diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index d7222bf6..ca82f24e 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 native msb snapshot/restore (full state + restore-time resource rebinding) # shellcheck disable=SC2034 ACQ_BACKEND_CAN_RESUME=1 # msb stop / msb start preserve state # shellcheck disable=SC2034 @@ -1055,6 +1055,90 @@ acq_backend_start() { _acq_msb_start_ssh_agent_bridge "$_name" } +# --------------------------------------------------------------------------- +# acq_backend_snapshot NAME [OUT] — create a native full-state msb snapshot +# --------------------------------------------------------------------------- +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 + _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto -o "$_host_out" + else + _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto + fi +} + +# --------------------------------------------------------------------------- +# acq_backend_restore SNAPSHOT --name NAME — restore and re-plumb resources +# --------------------------------------------------------------------------- +acq_backend_restore() { + local _snapshot="${1:-}" _name="" _arg + [ -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 + + local _restore_flags=(--name "$_name" --dangerously-inherit-resources) + local _vsock_flags=() + _acq_msb_vsock_flags_into _vsock_flags + [ "${#_vsock_flags[@]}" -gt 0 ] && _restore_flags+=("${_vsock_flags[@]}") + + local _host_snapshot="$_snapshot" + if command -v host_path >/dev/null 2>&1; then + _host_snapshot=$(host_path "$_snapshot") + 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_rc=$? + local _rev + for _rev in ${_restore_secret_names[@]+"${_restore_secret_names[@]}"}; do + unset "$_rev" + done + [ "$_restore_rc" -eq 0 ] || return "$_restore_rc" + + _acq_msb_wait_for_exec_ready "$_name" || \ + echo "acq(msb): warning: $_name did not become exec-ready after restore." >&2 + _acq_msb_grant_oci_devs "$_name" + 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 # --------------------------------------------------------------------------- @@ -4968,35 +5052,52 @@ 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" + # 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 + fi return "$_rc" } diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index ef03493e..4a2f7034 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 stateful 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 native full-state + snapshot/restore path (`msb snapshot create --full` + `msb restore`), including + restore-time host-resource re-plumbing for the SSH-agent vsock route; see + [ADR-0030](adr/0030-native-snapshot-restore.md) ### Requirements @@ -874,7 +873,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 native full-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-0030](adr/0030-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 +887,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`; native full-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 +944,11 @@ 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 + native full-state primitive. sbx is deliberately unsupported rather than + degraded to a disk-only template flow, because local `sbx template save/load` + does not preserve execution state or provide restore-time host-resource + rebinding. - **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 +1244,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 native + full-state msb snapshot/restore (ADR-0030); 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 +1279,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 642dfa34..3d1ec730 100644 --- a/docs/KNOWN_FAILURE_MODES.md +++ b/docs/KNOWN_FAILURE_MODES.md @@ -1981,13 +1981,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 @@ -1997,7 +1997,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..4829687a 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 native full-state snapshot when supported | +| `acq_backend_restore SNAPSHOT --name NAME` | Restore a native full-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..03128eaa 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-0030](0030-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/0030-native-snapshot-restore.md b/docs/adr/0030-native-snapshot-restore.md new file mode 100644 index 00000000..2f630572 --- /dev/null +++ b/docs/adr/0030-native-snapshot-restore.md @@ -0,0 +1,68 @@ +--- +title: "Expose native stateful 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-0030: Expose native stateful snapshot/restore through acq + +## Context + +Agent sandboxes sometimes need to survive a planned host reboot or recreation +without losing the running agent's context. 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 full-state snapshot primitive: `msb snapshot create +--full` captures disk, memory, execution, and device state. 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. + +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 stateful restore contract. + +## Decision + +Add acq-owned `snapshot` and `restore` verbs that are backed by native backend +primitives only when the backend supports full stateful restore with acq-managed +host-resource re-plumbing. + +- msb is supported: `acq snapshot NAME [OUT]` maps to `msb snapshot create + --full -o OUT` and writes a date-stamped archive when OUT is omitted. `acq + restore NAME [SNAPSHOT]` maps to `msb restore`; when SNAPSHOT is omitted, acq + restores the newest date-stamped archive for NAME. 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 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 live agent context across planned recreation + without a per-application backup manifest. +- 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 full stateful restore. + +## 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, and verify SSH signing works with the restored vsock route. diff --git a/docs/howto/acq.md b/docs/howto/acq.md index d79c010c..2de227d7 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]` | Snapshot a sandbox's live state (msb only) | +| `./acq restore NAME [SNAPSHOT]` | Restore a native snapshot, newest for NAME when omitted (msb only) | +| `./acq recreate NAME [OUT]` | Snapshot, remove, then 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..7452ff03 100644 --- a/docs/howto/msb.md +++ b/docs/howto/msb.md @@ -219,10 +219,10 @@ 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 native full-state +snapshot/restore path; sbx remains unsupported for those acq verbs because its +local template flow is disk-only/fresh-boot. ## Common commands reference @@ -233,6 +233,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 --full [-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 +249,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/test-acq-lib.sh b/scripts/test-acq-lib.sh index 3076b785..efe5840d 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 @@ -438,6 +439,10 @@ 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|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/test/bats/117-msys-path-forms.bats b/test/bats/117-msys-path-forms.bats index 5624056d..f23d955e 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 + host=$(host_path "'"$STUBDIR"'/saved.msb") + grep -q -- "-o $host" "'"$CALLS"'" && echo "snapshot-host-form-ok" + grep -q -- "restore $host --name restored" "'"$CALLS"'" && echo "restore-host-form-ok" + [ "$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..3f845ec0 100644 --- a/test/bats/30-dispatch-routing.bats +++ b/test/bats/30-dispatch-routing.bats @@ -162,6 +162,21 @@ FACTS refute_output --partial 'passed through' } +@test "snapshot/restore: --help documents acq-owned native state verbs" { + run env ACQ_BACKEND=sbx "$ACQ" snapshot --help + assert_output --partial 'save a sandbox' + assert_output --partial 'backends fail closed' + refute_output --partial 'passed through' + run env ACQ_BACKEND=sbx "$ACQ" restore --help + assert_output --partial 're-plumb acq-owned resources' + assert_output --partial 'host-resource re-plumbing' + refute_output --partial 'passed through' + run env ACQ_BACKEND=sbx "$ACQ" recreate --help + assert_output --partial 'snapshot, remove, then restore' + assert_output --partial 'acq-owned host-resource re-plumbing' + 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..5f412fb0 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -24,6 +24,12 @@ _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" ] +} + @test "msb: auto-detect prefers msb when both present and no sbx sandboxes" { rm -f "$STUBDIR/.sandbox_list" run bash -c ' @@ -59,9 +65,84 @@ _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 native 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 msb snapshot create --full 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 --full --guest-flush auto -o $STUBDIR/snapshots/mybox-[0-9]{8}T[0-9]{6}Z\.msb" + : > "$CALLS" + run env ACQ_BACKEND=msb "$ACQ" snapshot mybox "$STUBDIR/mybox.msb" + assert_success + assert_regex "$(cat "$CALLS")" "msb snapshot create --from-sandbox mybox --full --guest-flush auto -o $STUBDIR/mybox\.msb" +} + +@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" + mkdir -p "$STUBDIR/secrets" + printf 'sk-restored\n' > "$STUBDIR/secrets/acq.usai" + : > "$CALLS" + run env ACQ_BACKEND=msb 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/saved\.msb --name restored --dangerously-inherit-resources --vsock $STUBDIR/agent\.sock:3552/stream" + assert_regex "$log" 'USAI_API_KEY=present' + assert_regex "$log" 'socat UNIX-LISTEN:' +} + +@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/snapshots/mybox-20260920T010203Z.msb" + : > "$STUBDIR/snapshots/mybox-20260921T010203Z.msb" + : > "$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/snapshots/mybox-20260921T010203Z\.msb --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 stateful 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 stateful restore' + refute_regex "$(cat "$CALLS")" 'sbx restore' + : > "$CALLS" + run env ACQ_BACKEND=sbx "$ACQ" recreate mybox + assert_failure + assert_output --partial 'does not support stateful 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" + : > "$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 --full --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/recreate\.msb --name mybox --dangerously-inherit-resources --vsock $STUBDIR/agent\.sock:3552/stream" +} + +@test "msb: rm --snapshot snapshots before removing and does not restore" { + : > "$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 --full --guest-flush auto -o $STUBDIR/rm\.msb" + assert_regex "$log" 'msb remove --force mybox' + refute_regex "$log" 'msb restore' } @test "msb: ls/stop/rm/exec dispatch to the msb verbs (exec as agent user)" { From 50d8ca74923f3b9079cbda3c2ef1564bf307fe44 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 02:40:50 +0000 Subject: [PATCH 02/15] test(acq): add native snapshot restore verifier --- scripts/verify-native-snapshot-restore | 116 +++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 scripts/verify-native-snapshot-restore diff --git a/scripts/verify-native-snapshot-restore b/scripts/verify-native-snapshot-restore new file mode 100644 index 00000000..21f4e55b --- /dev/null +++ b/scripts/verify-native-snapshot-restore @@ -0,0 +1,116 @@ +#!/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=opencode 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_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. + - The initial acq run is interactive; exit the agent session to continue. +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:-opencode}" +SNAPDIR="${ACQ_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/acq/snapshots}" +COPY_SANDBOX="${SANDBOX}-copy" + +say() { printf '\n==> %s\n' "$*" >&2; } +fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } + +[ -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" + +if [ "${VERIFY_SNAPSHOT_SKIP_CREATE:-0}" != "1" ]; then + say "Creating and attaching to sandbox. Exit the agent session to continue." + "$ACQ" --backend msb run "$AGENT" "$WORKSPACE" --name "$SANDBOX" +else + say "Skipping initial create/attach step because VERIFY_SNAPSHOT_SKIP_CREATE=1" +fi + +say "Creating date-stamped snapshot archive" +"$ACQ" --backend msb snapshot "$SANDBOX" + +LATEST=$(ls -t "$SNAPDIR"/"$SANDBOX"-*.msb 2>/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" + +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" + "$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" + "$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 + +say "Re-attaching to primary sandbox so you can inspect restored agent context. Exit to finish." +"$ACQ" --backend msb run "$SANDBOX" + +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" From dcf12b02419162a0426cee29c48d955e57c58f16 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 06:36:12 +0000 Subject: [PATCH 03/15] fix(acq): keep latest snapshot lookup bash 3.2 compatible --- acq | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/acq b/acq index ba01352b..19fa8384 100755 --- a/acq +++ b/acq @@ -685,11 +685,9 @@ _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 | while IFS= read -r _snap; do - case "${_snap##*/}" in - ${slug}-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]T[0-9][0-9][0-9][0-9][0-9][0-9]Z.msb) printf '%s\n' "$_snap" ;; - esac - done | LC_ALL=C sort | tail -n 1) + 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" } From 0ae32d4b78870005f732835cec61e728e1d3ae11 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:10:56 +0000 Subject: [PATCH 04/15] fix(acq): restore exported snapshots with workspace bindings --- acq.backends/msb.sh | 57 +++++++++++++++++++++++- docs/adr/0030-native-snapshot-restore.md | 7 +-- test/bats/70-msb-backend.bats | 11 +++-- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index ca82f24e..9cf701e7 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -583,6 +583,7 @@ 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}" # 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. @@ -1055,6 +1056,52 @@ 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_restore_resources_write() { + local name="$1" _file _spec + _file=$(_acq_msb_restore_resource_file "$name") || return 0 + mkdir -p "$ACQ_MSB_RESTORE_DIR" 2>/dev/null || return 0 + : >"$_file" 2>/dev/null || return 0 + shift + for _spec in ${@+"$@"}; do + printf 'volume\t%s\n' "$_spec" >>"$_file" 2>/dev/null || true + done +} + +_acq_msb_restore_resources_copy_sidecar() { + local name="$1" snapshot="$2" _file + _file=$(_acq_msb_restore_resource_file "$name") || return 0 + [ -f "$_file" ] || return 0 + cp "$_file" "${snapshot}.resources" 2>/dev/null || true +} + +_acq_msb_restore_resource_flags_into() { # ARRVAR SNAPSHOT NAME + local _arr="$1" _snapshot="$2" _name="$3" _file _kind _spec + eval "$_arr=()" + _file="${_snapshot}.resources" + if [ ! -f "$_file" ]; then + _file=$(_acq_msb_restore_resource_file "$_name") || return 0 + fi + [ -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 native full-state msb snapshot # --------------------------------------------------------------------------- @@ -1072,6 +1119,7 @@ acq_backend_snapshot() { _host_out=$(host_path "$_out") fi _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto -o "$_host_out" + _acq_msb_restore_resources_copy_sidecar "$_name" "$_out" else _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto fi @@ -1109,7 +1157,9 @@ acq_backend_restore() { esac local _restore_flags=(--name "$_name" --dangerously-inherit-resources) - local _vsock_flags=() + local _resource_flags=() _vsock_flags=() + _acq_msb_restore_resource_flags_into _resource_flags "$_snapshot" "$_name" + [ "${#_resource_flags[@]}" -gt 0 ] && _restore_flags+=("${_resource_flags[@]}") _acq_msb_vsock_flags_into _vsock_flags [ "${#_vsock_flags[@]}" -gt 0 ] && _restore_flags+=("${_vsock_flags[@]}") @@ -3590,6 +3640,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. @@ -3638,6 +3689,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 @@ -3802,6 +3854,7 @@ 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[@]}" # A same-named sandbox removed outside acq can leave host state behind. Clear # instance-scoped values before this fresh provision records replacement values. @@ -5089,6 +5142,8 @@ _acq_msb_terminate_impl() { fi 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" diff --git a/docs/adr/0030-native-snapshot-restore.md b/docs/adr/0030-native-snapshot-restore.md index 2f630572..dbb0a622 100644 --- a/docs/adr/0030-native-snapshot-restore.md +++ b/docs/adr/0030-native-snapshot-restore.md @@ -46,9 +46,10 @@ host-resource re-plumbing. - 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 re-derives the current SSH-agent vsock route and - asks the backend to inherit validated source-local resource records for the - rest. + acq owns the re-plumbing: it records acq-created workspace volume bindings as + host-side sidecar metadata next to exported snapshots, re-derives the current + SSH-agent vsock route, and asks the backend to inherit validated source-local + resource records for the rest. ## Consequences diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 5f412fb0..0fd00dfb 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -75,29 +75,34 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] assert_success assert_regex "$(cat "$CALLS")" "msb snapshot create --from-sandbox mybox --full --guest-flush auto -o $STUBDIR/snapshots/mybox-[0-9]{8}T[0-9]{6}Z\.msb" : > "$CALLS" + mkdir -p "$STUBDIR/state/msb-restore" + printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/state/msb-restore/mybox.resources" run env ACQ_BACKEND=msb "$ACQ" snapshot mybox "$STUBDIR/mybox.msb" assert_success assert_regex "$(cat "$CALLS")" "msb snapshot create --from-sandbox mybox --full --guest-flush auto -o $STUBDIR/mybox\.msb" + [ -f "$STUBDIR/mybox.msb.resources" ] } @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" - mkdir -p "$STUBDIR/secrets" + mkdir -p "$STUBDIR/secrets" "$STUBDIR/ws" printf 'sk-restored\n' > "$STUBDIR/secrets/acq.usai" + printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/saved.msb.resources" : > "$CALLS" run env ACQ_BACKEND=msb 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/saved\.msb --name restored --dangerously-inherit-resources --vsock $STUBDIR/agent\.sock:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/saved\.msb --name restored --dangerously-inherit-resources --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" assert_regex "$log" 'USAI_API_KEY=present' assert_regex "$log" 'socat UNIX-LISTEN:' } @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" + mkdir -p "$STUBDIR/snapshots" "$STUBDIR/ws2" : > "$STUBDIR/snapshots/mybox-20260920T010203Z.msb" : > "$STUBDIR/snapshots/mybox-20260921T010203Z.msb" + printf 'volume\t%s:%s\n' "$STUBDIR/ws2" "$STUBDIR/ws2" > "$STUBDIR/snapshots/mybox-20260921T010203Z.msb.resources" : > "$STUBDIR/snapshots/mybox-z-not-a-date.msb" : > "$STUBDIR/snapshots/other-20260922T010203Z.msb" : > "$CALLS" From e99af7f19c1b44bad2ba771ad6bdb244741623e1 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:31:10 +0000 Subject: [PATCH 05/15] fix(acq): relax restored external workspace mounts --- acq.backends/msb.sh | 5 ++++- test/bats/70-msb-backend.bats | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 9cf701e7..35dec60b 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1159,7 +1159,10 @@ acq_backend_restore() { local _restore_flags=(--name "$_name" --dangerously-inherit-resources) local _resource_flags=() _vsock_flags=() _acq_msb_restore_resource_flags_into _resource_flags "$_snapshot" "$_name" - [ "${#_resource_flags[@]}" -gt 0 ] && _restore_flags+=("${_resource_flags[@]}") + if [ "${#_resource_flags[@]}" -gt 0 ]; then + _restore_flags+=(--external-mount-policy relaxed) + _restore_flags+=("${_resource_flags[@]}") + fi _acq_msb_vsock_flags_into _vsock_flags [ "${#_vsock_flags[@]}" -gt 0 ] && _restore_flags+=("${_vsock_flags[@]}") diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 0fd00dfb..2ecc326b 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -92,7 +92,7 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] run env ACQ_BACKEND=msb 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/saved\.msb --name restored --dangerously-inherit-resources --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/saved\.msb --name restored --dangerously-inherit-resources --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" assert_regex "$log" 'USAI_API_KEY=present' assert_regex "$log" 'socat UNIX-LISTEN:' } From be4317e615785cf014b94013db58c51392e3a2ad Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:37:07 +0000 Subject: [PATCH 06/15] test(acq): preclean native snapshot verifier sandboxes --- scripts/verify-native-snapshot-restore | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/verify-native-snapshot-restore b/scripts/verify-native-snapshot-restore index 21f4e55b..e16dd59e 100644 --- a/scripts/verify-native-snapshot-restore +++ b/scripts/verify-native-snapshot-restore @@ -21,6 +21,7 @@ Environment: AGENT=opencode 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 @@ -49,6 +50,23 @@ COPY_SANDBOX="${SANDBOX}-copy" 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 +} + [ -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" @@ -60,7 +78,9 @@ 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 and attaching to sandbox. Exit the agent session to continue." "$ACQ" --backend msb run "$AGENT" "$WORKSPACE" --name "$SANDBOX" else From 5f48a2f59cf8dd8d8921120b51d85db8acbcc4ed Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:54:35 +0000 Subject: [PATCH 07/15] fix(acq): disk-boot snapshots with external mounts --- acq.backends/msb.sh | 7 ++++++- docs/BACKEND_GUIDE.md | 7 ++++--- docs/adr/0030-native-snapshot-restore.md | 7 +++++-- docs/howto/msb.md | 8 +++++--- test/bats/70-msb-backend.bats | 2 +- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 35dec60b..5d1ac5df 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1160,7 +1160,12 @@ acq_backend_restore() { local _resource_flags=() _vsock_flags=() _acq_msb_restore_resource_flags_into _resource_flags "$_snapshot" "$_name" if [ "${#_resource_flags[@]}" -gt 0 ]; then - _restore_flags+=(--external-mount-policy relaxed) + # Full memory/device restore currently fails for exported snapshots that + # contain an external virtio-fs workspace mount (virtio_fs device-state + # restore reports ENOENT). Cold-boot the captured disk and re-bind acq-owned + # resources instead; this preserves agent context on disk (OpenCode/Paseo + # state) without restoring stale external filesystem device state. + _restore_flags+=(--disk-only --external-mount-policy relaxed) _restore_flags+=("${_resource_flags[@]}") fi _acq_msb_vsock_flags_into _vsock_flags diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 4a2f7034..3e12b81f 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -137,9 +137,10 @@ automation story. **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**: `acq snapshot` / `acq restore` surface msb's native full-state - snapshot/restore path (`msb snapshot create --full` + `msb restore`), including - restore-time host-resource re-plumbing for the SSH-agent vsock route; see - [ADR-0030](adr/0030-native-snapshot-restore.md) + path (`msb snapshot create --full` + `msb restore`), including restore-time + SSH-agent re-plumbing. Snapshots with acq-managed external workspace mounts + cold-boot the captured disk (`--disk-only`) and re-bind validated host resources; + see [ADR-0030](adr/0030-native-snapshot-restore.md) ### Requirements diff --git a/docs/adr/0030-native-snapshot-restore.md b/docs/adr/0030-native-snapshot-restore.md index dbb0a622..dddd199d 100644 --- a/docs/adr/0030-native-snapshot-restore.md +++ b/docs/adr/0030-native-snapshot-restore.md @@ -41,8 +41,11 @@ host-resource re-plumbing. - msb is supported: `acq snapshot NAME [OUT]` maps to `msb snapshot create --full -o OUT` and writes a date-stamped archive when OUT is omitted. `acq restore NAME [SNAPSHOT]` maps to `msb restore`; when SNAPSHOT is omitted, acq - restores the newest date-stamped archive for NAME. acq supplies current - host-resource bindings internally. + restores the newest date-stamped archive for NAME. For snapshots that include + acq-managed external workspace mounts, acq uses `--disk-only` on restore and + re-binds those resources explicitly because msb full device-state restore of + exported virtio-fs mounts currently fails. 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. diff --git a/docs/howto/msb.md b/docs/howto/msb.md index 7452ff03..13abd87b 100644 --- a/docs/howto/msb.md +++ b/docs/howto/msb.md @@ -220,9 +220,11 @@ acq exec my-sandbox -- # run a one-off command in the sandbox ``` Unlike sbx, msb **can** stop and resume detached long-running sandboxes. It also -supports `acq snapshot` / `acq restore` via msb's native full-state -snapshot/restore path; sbx remains unsupported for those acq verbs because its -local template flow is disk-only/fresh-boot. +supports `acq snapshot` / `acq restore` via msb's native snapshot/restore path; +acq restores snapshots with external workspace mounts using disk-only boot plus +explicit resource re-binding to avoid stale virtio-fs device state. sbx remains +unsupported for those acq verbs because its local template flow is +disk-only/fresh-boot. ## Common commands reference diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 2ecc326b..1cc6dfaf 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -92,7 +92,7 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] run env ACQ_BACKEND=msb 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/saved\.msb --name restored --dangerously-inherit-resources --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/saved\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" assert_regex "$log" 'USAI_API_KEY=present' assert_regex "$log" 'socat UNIX-LISTEN:' } From 9d1e1b380215cdac15d99b6438f45db57350c1d3 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 23 Sep 2026 07:59:50 +0000 Subject: [PATCH 08/15] fix(acq): retain restore metadata after restore --- acq.backends/msb.sh | 9 +++++++++ test/bats/70-msb-backend.bats | 1 + 2 files changed, 10 insertions(+) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 5d1ac5df..bfd63a2b 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1087,6 +1087,14 @@ _acq_msb_restore_resources_copy_sidecar() { cp "$_file" "${snapshot}.resources" 2>/dev/null || true } +_acq_msb_restore_resources_adopt_sidecar() { + local name="$1" snapshot="$2" _file + [ -f "${snapshot}.resources" ] || return 0 + _file=$(_acq_msb_restore_resource_file "$name") || return 0 + mkdir -p "$ACQ_MSB_RESTORE_DIR" 2>/dev/null || return 0 + cp "${snapshot}.resources" "$_file" 2>/dev/null || true +} + _acq_msb_restore_resource_flags_into() { # ARRVAR SNAPSHOT NAME local _arr="$1" _snapshot="$2" _name="$3" _file _kind _spec eval "$_arr=()" @@ -1185,6 +1193,7 @@ acq_backend_restore() { unset "$_rev" done [ "$_restore_rc" -eq 0 ] || return "$_restore_rc" + _acq_msb_restore_resources_adopt_sidecar "$_name" "$_snapshot" _acq_msb_wait_for_exec_ready "$_name" || \ echo "acq(msb): warning: $_name did not become exec-ready after restore." >&2 diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 1cc6dfaf..429a03dd 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -95,6 +95,7 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] assert_regex "$log" "msb restore $STUBDIR/saved\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" assert_regex "$log" 'USAI_API_KEY=present' assert_regex "$log" 'socat UNIX-LISTEN:' + [ -f "$STUBDIR/state/msb-restore/restored.resources" ] } @test "msb: restore without a snapshot picks the newest date-stamped snapshot for the sandbox" { From ec03990ebc85c23f9256e780f87351439df0c841 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 24 Sep 2026 02:37:40 +0000 Subject: [PATCH 09/15] test(acq): make snapshot verifier non-interactive --- acq.backends/msb.sh | 11 +++--- scripts/test-acq-lib.sh | 13 +++++-- scripts/verify-native-snapshot-restore | 50 +++++++++++++++++++++----- 3 files changed, 57 insertions(+), 17 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index bfd63a2b..a132eb27 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -836,15 +836,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. diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index efe5840d..752f5a81 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -261,9 +261,16 @@ case "$_msb_sub" in : >"$STUBDIR/.msb_created" ;; 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). - [ -f "$STUBDIR/.msb_inspect_json" ] && cat "$STUBDIR/.msb_inspect_json" ;; + # JSON fixture if the test planted one, else require the sandbox to exist. + # This matches real inspect semantics and keeps acq_backend_exists honest. + 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): diff --git a/scripts/verify-native-snapshot-restore b/scripts/verify-native-snapshot-restore index e16dd59e..300776a6 100644 --- a/scripts/verify-native-snapshot-restore +++ b/scripts/verify-native-snapshot-restore @@ -18,7 +18,7 @@ Defaults: WORKSPACE current directory Environment: - AGENT=opencode agent to create for the test sandbox + 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 @@ -30,7 +30,7 @@ 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. - - The initial acq run is interactive; exit the agent session to continue. + - A USAi key must already be stored or exported; the verifier is non-interactive. USAGE } @@ -43,9 +43,11 @@ ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) ACQ="$ROOT/acq" SANDBOX="${1:-snaptest-opencode}" WORKSPACE="${2:-$PWD}" -AGENT="${AGENT:-opencode}" +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; } @@ -67,6 +69,35 @@ preclean_sandbox() { "$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" @@ -81,12 +112,14 @@ say "Snapshot directory: $SNAPDIR" preclean_sandbox "$COPY_SANDBOX" if [ "${VERIFY_SNAPSHOT_SKIP_CREATE:-0}" != "1" ]; then preclean_sandbox "$SANDBOX" - say "Creating and attaching to sandbox. Exit the agent session to continue." - "$ACQ" --backend msb run "$AGENT" "$WORKSPACE" --name "$SANDBOX" + say "Creating sandbox non-interactively" + "$ACQ" --backend msb create --no-update-check "$AGENT" "$WORKSPACE" --name "$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" @@ -117,14 +153,12 @@ 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 -say "Re-attaching to primary sandbox so you can inspect restored agent context. Exit to finish." -"$ACQ" --backend msb run "$SANDBOX" - if [ "${VERIFY_SNAPSHOT_CLEANUP:-0}" = "1" ]; then say "Cleaning up test sandboxes" "$ACQ" --backend msb rm --snapshot "$SANDBOX" || true From 78bb75fceb74abe51ee9eba12f3c9b73130f8740 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 30 Sep 2026 23:15:51 +0000 Subject: [PATCH 10/15] fix(acq): address snapshot restore review feedback --- acq | 35 +++++++++++- acq.backends/common.sh | 18 ++++++ acq.backends/msb.sh | 29 ++++++---- docs/BACKEND_GUIDE.md | 6 +- ...utral-port-publish-and-background-vocab.md | 2 +- ...ore.md => 0033-native-snapshot-restore.md} | 9 +-- test/bats/70-msb-backend.bats | 55 +++++++++++++++++-- 7 files changed, 126 insertions(+), 28 deletions(-) rename docs/adr/{0030-native-snapshot-restore.md => 0033-native-snapshot-restore.md} (90%) diff --git a/acq b/acq index 19fa8384..af7f9c39 100755 --- a/acq +++ b/acq @@ -677,7 +677,11 @@ _acq_snapshot_dir() { _acq_default_snapshot_path() { local name="$1" snapshot_dir snapshot_dir=$(_acq_snapshot_dir) - mkdir -p "$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)" } @@ -692,6 +696,14 @@ _acq_latest_snapshot_path() { 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_backend_ensure_kits_applied "$local_name" || return 2 + return 0 +} + # --------------------------------------------------------------------------- # Allow tests to source this script without running the dispatch. # --------------------------------------------------------------------------- @@ -1540,6 +1552,13 @@ case "$subcommand" in } 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) @@ -1570,7 +1589,13 @@ case "$subcommand" in acq_backend_terminate "$local_name" fi acq_backend_restore "$snapshot_out" --name "$local_name" - echo "acq: recreated '$local_name' from snapshot: $snapshot_out" >&2 + 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) @@ -1671,7 +1696,11 @@ case "$subcommand" in acq_backend_snapshot "$local_name" "$snapshot_out" echo "acq: snapshot saved before removal: $snapshot_out" >&2 fi - acq_backend_terminate "$local_name" + if [ "$snapshot_first" -eq 1 ] && 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 ;; exec) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index dbf4b51f..1942f759 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2254,6 +2254,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 diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index a132eb27..8cce34cc 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1070,12 +1070,13 @@ _acq_msb_restore_resource_file() { _acq_msb_restore_resources_write() { local name="$1" _file _spec - _file=$(_acq_msb_restore_resource_file "$name") || return 0 - mkdir -p "$ACQ_MSB_RESTORE_DIR" 2>/dev/null || return 0 - : >"$_file" 2>/dev/null || 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 + : >"$_file" 2>/dev/null || return 1 shift for _spec in ${@+"$@"}; do - printf 'volume\t%s\n' "$_spec" >>"$_file" 2>/dev/null || true + printf 'volume\t%s\n' "$_spec" >>"$_file" 2>/dev/null || return 1 done } @@ -1083,15 +1084,16 @@ _acq_msb_restore_resources_copy_sidecar() { local name="$1" snapshot="$2" _file _file=$(_acq_msb_restore_resource_file "$name") || return 0 [ -f "$_file" ] || return 0 - cp "$_file" "${snapshot}.resources" 2>/dev/null || true + cp "$_file" "${snapshot}.resources" 2>/dev/null || return 1 } _acq_msb_restore_resources_adopt_sidecar() { local name="$1" snapshot="$2" _file [ -f "${snapshot}.resources" ] || return 0 - _file=$(_acq_msb_restore_resource_file "$name") || return 0 - mkdir -p "$ACQ_MSB_RESTORE_DIR" 2>/dev/null || return 0 - cp "${snapshot}.resources" "$_file" 2>/dev/null || true + _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 "${snapshot}.resources" "$_file" 2>/dev/null || return 1 } _acq_msb_restore_resource_flags_into() { # ARRVAR SNAPSHOT NAME @@ -1126,7 +1128,8 @@ acq_backend_snapshot() { _host_out=$(host_path "$_out") fi _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto -o "$_host_out" - _acq_msb_restore_resources_copy_sidecar "$_name" "$_out" + _acq_msb_restore_resources_copy_sidecar "$_name" "$_out" || return 1 + acq_cli_kits_copy_sidecar msb "$_name" "$_out" || return 1 else _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto fi @@ -1165,6 +1168,7 @@ acq_backend_restore() { local _restore_flags=(--name "$_name" --dangerously-inherit-resources) local _resource_flags=() _vsock_flags=() + ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL=0 _acq_msb_restore_resource_flags_into _resource_flags "$_snapshot" "$_name" if [ "${#_resource_flags[@]}" -gt 0 ]; then # Full memory/device restore currently fails for exported snapshots that @@ -1174,6 +1178,7 @@ acq_backend_restore() { # state) without restoring stale external filesystem device state. _restore_flags+=(--disk-only --external-mount-policy relaxed) _restore_flags+=("${_resource_flags[@]}") + ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL=1 fi _acq_msb_vsock_flags_into _vsock_flags [ "${#_vsock_flags[@]}" -gt 0 ] && _restore_flags+=("${_vsock_flags[@]}") @@ -1192,7 +1197,8 @@ acq_backend_restore() { unset "$_rev" done [ "$_restore_rc" -eq 0 ] || return "$_restore_rc" - _acq_msb_restore_resources_adopt_sidecar "$_name" "$_snapshot" + _acq_msb_restore_resources_adopt_sidecar "$_name" "$_snapshot" || return 1 + acq_cli_kits_adopt_sidecar msb "$_name" "$_snapshot" || return 1 _acq_msb_wait_for_exec_ready "$_name" || \ echo "acq(msb): warning: $_name did not become exec-ready after restore." >&2 @@ -3870,7 +3876,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[@]}" + _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. diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 3e12b81f..4196eae6 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -140,7 +140,7 @@ automation story. path (`msb snapshot create --full` + `msb restore`), including restore-time SSH-agent re-plumbing. Snapshots with acq-managed external workspace mounts cold-boot the captured disk (`--disk-only`) and re-bind validated host resources; - see [ADR-0030](adr/0030-native-snapshot-restore.md) + see [ADR-0033](adr/0033-native-snapshot-restore.md) ### Requirements @@ -874,7 +874,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` | 1 | `acq snapshot` / `acq restore` surface msb's native full-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-0030](adr/0030-native-snapshot-restore.md)) | +| `ACQ_BACKEND_SUPPORTS_SNAPSHOTS` | 1 | `acq snapshot` / `acq restore` surface msb's native full-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-0033](adr/0033-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) | @@ -1246,7 +1246,7 @@ See [ADR-0016](adr/0016-kit-bundle-provenance-and-stale-refresh.md). neutral fields light up end-to-end with the released patterns schema + openchamber kit (merged) - msb `SUPPORTS_SNAPSHOTS=1` — `acq snapshot` / `acq restore` surface native - full-state msb snapshot/restore (ADR-0030); sbx remains unsupported + full-state msb snapshot/restore (ADR-0033); 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 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 03128eaa..5a95b9d1 100644 --- a/docs/adr/0014-neutral-port-publish-and-background-vocab.md +++ b/docs/adr/0014-neutral-port-publish-and-background-vocab.md @@ -83,7 +83,7 @@ msb 0.6.7** (released 2026-07-27), which corrects three assumptions: 3. **`msb snapshot` exists as a full CLI verb** (create/list/inspect/verify/ remove/save/load, plus restore). This is unrelated to ports; acq's native snapshot/restore decision is tracked separately in - [ADR-0030](0030-native-snapshot-restore.md). + [ADR-0033](0033-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/0030-native-snapshot-restore.md b/docs/adr/0033-native-snapshot-restore.md similarity index 90% rename from docs/adr/0030-native-snapshot-restore.md rename to docs/adr/0033-native-snapshot-restore.md index dddd199d..c4a4dea4 100644 --- a/docs/adr/0030-native-snapshot-restore.md +++ b/docs/adr/0033-native-snapshot-restore.md @@ -11,7 +11,7 @@ risk_treatment: accept supersedes: [] --- -# ADR-0030: Expose native stateful snapshot/restore through acq +# ADR-0033: Expose native stateful snapshot/restore through acq ## Context @@ -50,9 +50,10 @@ host-resource re-plumbing. 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 as - host-side sidecar metadata next to exported snapshots, re-derives the current - SSH-agent vsock route, and asks the backend to inherit validated source-local - resource records for the rest. + host-side sidecar metadata next to exported snapshots, carries the persisted + CLI/extra kit reference record as a snapshot sidecar for restore-to-copy flows, + re-derives the current SSH-agent vsock route, and asks the backend to inherit + validated source-local resource records for the rest. ## Consequences diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 429a03dd..d17eb1e3 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -74,28 +74,64 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] 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 --full --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" + mkdir -p "$STUBDIR/state/msb-restore" "$STUBDIR/provenance/msb" printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/state/msb-restore/mybox.resources" - run env ACQ_BACKEND=msb "$ACQ" snapshot mybox "$STUBDIR/mybox.msb" + 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" + 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 --full --guest-flush auto -o $STUBDIR/mybox\.msb" [ -f "$STUBDIR/mybox.msb.resources" ] + [ -f "$STUBDIR/mybox.msb.kits" ] +} + +@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: restore maps to msb restore with inherited resources and re-derived vsock" { _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" - mkdir -p "$STUBDIR/secrets" "$STUBDIR/ws" + 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" printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/saved.msb.resources" + printf 'schema=1\nkit=%s\n' "$STUBDIR/custom-kit" > "$STUBDIR/saved.msb.kits" + 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 SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore restored "$STUBDIR/saved.msb" + 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/saved\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock $STUBDIR/agent\.sock:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/saved\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" 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" ] + 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" +} + +@test "msb: full-state restore does not re-run kit startup" { + _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" + : > "$CALLS" + run env ACQ_BACKEND=msb SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore restored "$STUBDIR/full-state.msb" + assert_success + local log; log=$(cat "$CALLS") + assert_regex "$log" "msb restore $STUBDIR/full-state\.msb --name restored --dangerously-inherit-resources --vsock" + refute_regex "$log" 'msb inspect restored --format json' + refute_output --partial "re-applied kit startup" + assert_output --partial "acq: restored 'restored'." } @test "msb: restore without a snapshot picks the newest date-stamped snapshot for the sandbox" { @@ -131,6 +167,10 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] @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 @@ -138,7 +178,9 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] assert_regex "$log" "msb snapshot create --from-sandbox mybox --full --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/recreate\.msb --name mybox --dangerously-inherit-resources --vsock $STUBDIR/agent\.sock:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/recreate\.msb --name mybox --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --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" { @@ -149,6 +191,7 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] assert_regex "$log" "msb snapshot create --from-sandbox mybox --full --guest-flush auto -o $STUBDIR/rm\.msb" assert_regex "$log" 'msb remove --force mybox' refute_regex "$log" 'msb restore' + refute_regex "$log" 'msb volume rm' } @test "msb: ls/stop/rm/exec dispatch to the msb verbs (exec as agent user)" { From 3e111c93196ea964304d65371b0d44c70b6f4f21 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Fri, 2 Oct 2026 09:09:08 +0000 Subject: [PATCH 11/15] docs(adr): renumber native snapshot restore adr to 0036 ADR-0033 was double-claimed with GSA-TTS/agentic-coding-quickstart#519, which keeps 0033. Move this PR's native snapshot/restore ADR to the next free number. Co-authored-by: OpenCode [gpt-5.5] --- docs/BACKEND_GUIDE.md | 6 +++--- docs/adr/0014-neutral-port-publish-and-background-vocab.md | 2 +- ...-snapshot-restore.md => 0036-native-snapshot-restore.md} | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename docs/adr/{0033-native-snapshot-restore.md => 0036-native-snapshot-restore.md} (98%) diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 4196eae6..27d2a10b 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -140,7 +140,7 @@ automation story. path (`msb snapshot create --full` + `msb restore`), including restore-time SSH-agent re-plumbing. Snapshots with acq-managed external workspace mounts cold-boot the captured disk (`--disk-only`) and re-bind validated host resources; - see [ADR-0033](adr/0033-native-snapshot-restore.md) + see [ADR-0036](adr/0036-native-snapshot-restore.md) ### Requirements @@ -874,7 +874,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` | 1 | `acq snapshot` / `acq restore` surface msb's native full-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-0033](adr/0033-native-snapshot-restore.md)) | +| `ACQ_BACKEND_SUPPORTS_SNAPSHOTS` | 1 | `acq snapshot` / `acq restore` surface msb's native full-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) | @@ -1246,7 +1246,7 @@ See [ADR-0016](adr/0016-kit-bundle-provenance-and-stale-refresh.md). neutral fields light up end-to-end with the released patterns schema + openchamber kit (merged) - msb `SUPPORTS_SNAPSHOTS=1` — `acq snapshot` / `acq restore` surface native - full-state msb snapshot/restore (ADR-0033); sbx remains unsupported + full-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 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 5a95b9d1..72608f0d 100644 --- a/docs/adr/0014-neutral-port-publish-and-background-vocab.md +++ b/docs/adr/0014-neutral-port-publish-and-background-vocab.md @@ -83,7 +83,7 @@ msb 0.6.7** (released 2026-07-27), which corrects three assumptions: 3. **`msb snapshot` exists as a full CLI verb** (create/list/inspect/verify/ remove/save/load, plus restore). This is unrelated to ports; acq's native snapshot/restore decision is tracked separately in - [ADR-0033](0033-native-snapshot-restore.md). + [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/0033-native-snapshot-restore.md b/docs/adr/0036-native-snapshot-restore.md similarity index 98% rename from docs/adr/0033-native-snapshot-restore.md rename to docs/adr/0036-native-snapshot-restore.md index c4a4dea4..56d13d1a 100644 --- a/docs/adr/0033-native-snapshot-restore.md +++ b/docs/adr/0036-native-snapshot-restore.md @@ -11,7 +11,7 @@ risk_treatment: accept supersedes: [] --- -# ADR-0033: Expose native stateful snapshot/restore through acq +# ADR-0036: Expose native stateful snapshot/restore through acq ## Context From 787536e1df98794012ca6df9e4943e387ffcefa0 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 8 Oct 2026 07:45:44 +0000 Subject: [PATCH 12/15] fix(acq): harden snapshot restore metadata Rebase native snapshot restore on the v4 kit and provenance architecture. Keep restore metadata in private SHA-256-addressed state, stage archives before restore, and fail closed when readiness fails. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/msb.sh | 151 ++++++++++++++++++----- docs/BACKEND_GUIDE.md | 5 + docs/adr/0036-native-snapshot-restore.md | 10 +- scripts/test-acq-lib.sh | 13 +- scripts/verify-native-snapshot-restore | 0 test/bats/117-msys-path-forms.bats | 9 +- test/bats/70-msb-backend.bats | 51 ++++++-- 7 files changed, 186 insertions(+), 53 deletions(-) mode change 100644 => 100755 scripts/verify-native-snapshot-restore diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 8cce34cc..846bed1f 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -584,6 +584,7 @@ ACQ_MSB_SSH_KNOWN_HOSTS="${ACQ_MSB_SSH_KNOWN_HOSTS:-${ACQ_MSB_SSH_DIR}/known_hos 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. @@ -1068,6 +1069,48 @@ _acq_msb_restore_resource_file() { 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 staged + [ -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 + staged="$ACQ_MSB_SNAPSHOT_DIR/${sum}.msb" + mv -f "$tmp" "$staged" 2>/dev/null || { rm -f "$tmp"; return 1; } + printf '%s\n' "$staged" +} + +_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_restore_resources_write() { local name="$1" _file _spec _file=$(_acq_msb_restore_resource_file "$name") || return 1 @@ -1081,28 +1124,54 @@ _acq_msb_restore_resources_write() { } _acq_msb_restore_resources_copy_sidecar() { - local name="$1" snapshot="$2" _file + local name="$1" snapshot="$2" _file _trusted _file=$(_acq_msb_restore_resource_file "$name") || return 0 [ -f "$_file" ] || return 0 - cp "$_file" "${snapshot}.resources" 2>/dev/null || return 1 -} - -_acq_msb_restore_resources_adopt_sidecar() { - local name="$1" snapshot="$2" _file - [ -f "${snapshot}.resources" ] || 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 + [ -f "$source" ] || return 0 + 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 + cp "$source" "$target" 2>/dev/null || return 1 + 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 "${snapshot}.resources" "$_file" 2>/dev/null || return 1 + cp "$_trusted" "$_file" 2>/dev/null || return 1 + chmod 600 "$_file" 2>/dev/null || return 1 } -_acq_msb_restore_resource_flags_into() { # ARRVAR SNAPSHOT NAME - local _arr="$1" _snapshot="$2" _name="$3" _file _kind _spec +_acq_msb_snapshot_kits_adopt_trusted() { + local name="$1" snapshot="$2" source target dir + source=$(_acq_msb_snapshot_kits_file "$snapshot") || return 0 + [ -f "$source" ] || 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 + cp "$source" "$target" 2>/dev/null || return 1 + 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="${_snapshot}.resources" - if [ ! -f "$_file" ]; then - _file=$(_acq_msb_restore_resource_file "$_name") || return 0 - fi + _file=$(_acq_msb_snapshot_resource_file "$_snapshot") || return 0 [ -f "$_file" ] || return 0 while IFS=$'\t' read -r _kind _spec; do case "$_kind" in @@ -1127,9 +1196,10 @@ acq_backend_snapshot() { if command -v host_path >/dev/null 2>&1; then _host_out=$(host_path "$_out") fi - _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto -o "$_host_out" + ( umask 077; _acq_msb_cli snapshot create --from-sandbox "$_name" --full --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_cli_kits_copy_sidecar msb "$_name" "$_out" || return 1 + _acq_msb_snapshot_kits_copy "$_name" "$_out" || return 1 else _acq_msb_cli snapshot create --from-sandbox "$_name" --full --guest-flush auto fi @@ -1139,7 +1209,7 @@ acq_backend_snapshot() { # acq_backend_restore SNAPSHOT --name NAME — restore and re-plumb resources # --------------------------------------------------------------------------- acq_backend_restore() { - local _snapshot="${1:-}" _name="" _arg + 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 @@ -1166,16 +1236,21 @@ acq_backend_restore() { return 2 ;; esac + _staged_snapshot=$(_acq_msb_snapshot_stage "$_snapshot") || { + echo "acq(msb): restore: could not stage snapshot privately." >&2 + return 1 + } + local _restore_flags=(--name "$_name" --dangerously-inherit-resources) local _resource_flags=() _vsock_flags=() ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL=0 - _acq_msb_restore_resource_flags_into _resource_flags "$_snapshot" "$_name" + _acq_msb_restore_resource_flags_into _resource_flags "$_staged_snapshot" if [ "${#_resource_flags[@]}" -gt 0 ]; then - # Full memory/device restore currently fails for exported snapshots that - # contain an external virtio-fs workspace mount (virtio_fs device-state - # restore reports ENOENT). Cold-boot the captured disk and re-bind acq-owned - # resources instead; this preserves agent context on disk (OpenCode/Paseo - # state) without restoring stale external filesystem device state. + # msb 0.7.7 validates missing destination bindings, but with a matching + # virtio-fs binding it can return success while silently cold-booting. Use the + # explicit disk-only mode for trusted acq-owned resources so that outcome is + # intentional and the kit-heal path is always selected. Do not use msb's + # --allow-missing-resources escape hatch: it leaves the guest mount unusable. _restore_flags+=(--disk-only --external-mount-policy relaxed) _restore_flags+=("${_resource_flags[@]}") ACQ_BACKEND_RESTORE_NEEDS_KIT_HEAL=1 @@ -1183,9 +1258,9 @@ acq_backend_restore() { _acq_msb_vsock_flags_into _vsock_flags [ "${#_vsock_flags[@]}" -gt 0 ] && _restore_flags+=("${_vsock_flags[@]}") - local _host_snapshot="$_snapshot" + local _host_snapshot="$_staged_snapshot" if command -v host_path >/dev/null 2>&1; then - _host_snapshot=$(host_path "$_snapshot") + _host_snapshot=$(host_path "$_staged_snapshot") fi local _restore_secret_flags=() _restore_secret_names=() @@ -1196,13 +1271,20 @@ acq_backend_restore() { for _rev in ${_restore_secret_names[@]+"${_restore_secret_names[@]}"}; do unset "$_rev" done - [ "$_restore_rc" -eq 0 ] || return "$_restore_rc" - _acq_msb_restore_resources_adopt_sidecar "$_name" "$_snapshot" || return 1 - acq_cli_kits_adopt_sidecar msb "$_name" "$_snapshot" || return 1 - - _acq_msb_wait_for_exec_ready "$_name" || \ - echo "acq(msb): warning: $_name did not become exec-ready after restore." >&2 - _acq_msb_grant_oci_devs "$_name" + 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"; 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 @@ -1228,10 +1310,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) diff --git a/docs/BACKEND_GUIDE.md b/docs/BACKEND_GUIDE.md index 27d2a10b..c2620bf0 100644 --- a/docs/BACKEND_GUIDE.md +++ b/docs/BACKEND_GUIDE.md @@ -950,6 +950,11 @@ rationale, the fixed vsock port (3552), and the trust-boundary discussion. degraded to a disk-only template flow, because local `sbx template save/load` does not preserve execution state or provide restore-time host-resource rebinding. + For acq-owned workspace bindings, restore intentionally uses `--disk-only` and + re-runs kit startup: msb can otherwise report a successful restore while + cold-booting after a virtio-fs rebind. `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 diff --git a/docs/adr/0036-native-snapshot-restore.md b/docs/adr/0036-native-snapshot-restore.md index 56d13d1a..74bbf276 100644 --- a/docs/adr/0036-native-snapshot-restore.md +++ b/docs/adr/0036-native-snapshot-restore.md @@ -49,11 +49,11 @@ host-resource re-plumbing. - 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 as - host-side sidecar metadata next to exported snapshots, carries the persisted - CLI/extra kit reference record as a snapshot sidecar for restore-to-copy flows, - re-derives the current SSH-agent vsock route, and asks the backend to inherit - validated source-local resource records for the rest. + 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 diff --git a/scripts/test-acq-lib.sh b/scripts/test-acq-lib.sh index 752f5a81..091775a9 100644 --- a/scripts/test-acq-lib.sh +++ b/scripts/test-acq-lib.sh @@ -285,6 +285,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,7 +447,17 @@ 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|restore) + 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 : ;; diff --git a/scripts/verify-native-snapshot-restore b/scripts/verify-native-snapshot-restore old mode 100644 new mode 100755 diff --git a/test/bats/117-msys-path-forms.bats b/test/bats/117-msys-path-forms.bats index f23d955e..f49b1472 100644 --- a/test/bats/117-msys-path-forms.bats +++ b/test/bats/117-msys-path-forms.bats @@ -249,10 +249,11 @@ _msys_provision() { # NAME WS . "'"$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 - host=$(host_path "'"$STUBDIR"'/saved.msb") - grep -q -- "-o $host" "'"$CALLS"'" && echo "snapshot-host-form-ok" - grep -q -- "restore $host --name restored" "'"$CALLS"'" && echo "restore-host-form-ok" - [ "$host" != "'"$STUBDIR"'/saved.msb" ] && echo "forms-differ" || echo "forms-same" + snapshot_host=$(host_path "'"$STUBDIR"'/saved.msb") + staged_host=$(host_path "'"$STUBDIR"'/state/msb-snapshots/$(sha256sum "'"$STUBDIR"'/saved.msb" | cut -d" " -f1).msb") + grep -q -- "-o $snapshot_host" "'"$CALLS"'" && echo "snapshot-host-form-ok" + grep -q -- "restore $staged_host --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' diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index d17eb1e3..6f2ca295 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -30,6 +30,14 @@ 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 ' @@ -84,8 +92,8 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] 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 --full --guest-flush auto -o $STUBDIR/mybox\.msb" - [ -f "$STUBDIR/mybox.msb.resources" ] - [ -f "$STUBDIR/mybox.msb.kits" ] + [ "$(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" ] } @test "msb: create with no workspace positional provisions without empty-array abort" { @@ -102,15 +110,17 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] 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" - printf 'volume\t%s:%s\n' "$STUBDIR/ws" "$STUBDIR/ws" > "$STUBDIR/saved.msb.resources" - printf 'schema=1\nkit=%s\n' "$STUBDIR/custom-kit" > "$STUBDIR/saved.msb.kits" + : > "$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" 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/saved\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/[0-9a-f]{64}\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" assert_regex "$log" 'USAI_API_KEY=present' assert_regex "$log" 'socat UNIX-LISTEN:' assert_regex "$log" 'msb inspect restored --format json' @@ -120,32 +130,55 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] 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: full-state restore does not re-run kit startup" { _mk_unix_socket "$STUBDIR/agent.sock" || skip "python3 AF_UNIX socket unavailable" + : > "$STUBDIR/full-state.msb" : > "$CALLS" run env ACQ_BACKEND=msb SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore restored "$STUBDIR/full-state.msb" assert_success local log; log=$(cat "$CALLS") - assert_regex "$log" "msb restore $STUBDIR/full-state\.msb --name restored --dangerously-inherit-resources --vsock" + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/[0-9a-f]{64}\.msb --name restored --dangerously-inherit-resources --vsock" refute_regex "$log" 'msb inspect restored --format json' refute_output --partial "re-applied kit startup" assert_output --partial "acq: restored 'restored'." } +@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: 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" - printf 'volume\t%s:%s\n' "$STUBDIR/ws2" "$STUBDIR/ws2" > "$STUBDIR/snapshots/mybox-20260921T010203Z.msb.resources" + 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/snapshots/mybox-20260921T010203Z\.msb --name mybox" + assert_regex "$(cat "$CALLS")" "msb restore $STUBDIR/state/msb-snapshots/[0-9a-f]{64}\.msb --name mybox" } @test "sbx: snapshot/restore/recreate are acq-owned unsupported verbs, not backend passthrough" { @@ -178,7 +211,7 @@ s.bind(sys.argv[1])' "$1" >/dev/null 2>&1 && [ -S "$1" ] assert_regex "$log" "msb snapshot create --from-sandbox mybox --full --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/recreate\.msb --name mybox --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/[0-9a-f]{64}\.msb --name mybox --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --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" } From a6103cdac8516fceb6df4cac0068e4100e3b6b81 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 14 Oct 2026 03:28:24 +0000 Subject: [PATCH 13/15] fix(acq): isolate concurrent snapshot restores --- acq.backends/msb.sh | 9 ++++---- docs/adr/0036-native-snapshot-restore.md | 7 +++--- test/bats/117-msys-path-forms.bats | 3 +-- test/bats/70-msb-backend.bats | 27 ++++++++++++++++++++---- 4 files changed, 33 insertions(+), 13 deletions(-) diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 846bed1f..a73b9b4f 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1084,7 +1084,7 @@ _acq_msb_snapshot_resource_file() { } _acq_msb_snapshot_stage() { - local snapshot="$1" tmp sum staged + 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 @@ -1100,9 +1100,10 @@ _acq_msb_snapshot_stage() { echo "acq(msb): snapshot metadata requires sha256sum or shasum." >&2 return 1 fi - staged="$ACQ_MSB_SNAPSHOT_DIR/${sum}.msb" - mv -f "$tmp" "$staged" 2>/dev/null || { rm -f "$tmp"; return 1; } - printf '%s\n' "$staged" + # 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() { diff --git a/docs/adr/0036-native-snapshot-restore.md b/docs/adr/0036-native-snapshot-restore.md index 74bbf276..29550ec4 100644 --- a/docs/adr/0036-native-snapshot-restore.md +++ b/docs/adr/0036-native-snapshot-restore.md @@ -43,9 +43,10 @@ host-resource re-plumbing. restore NAME [SNAPSHOT]` maps to `msb restore`; when SNAPSHOT is omitted, acq restores the newest date-stamped archive for NAME. For snapshots that include acq-managed external workspace mounts, acq uses `--disk-only` on restore and - re-binds those resources explicitly because msb full device-state restore of - exported virtio-fs mounts currently fails. acq supplies current host-resource - bindings internally. + re-binds those resources explicitly. A full restore with a valid exported + virtio-fs rebinding can otherwise return success while silently cold-booting, + so disk-only mode makes the cold boot and subsequent kit healing explicit. + 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. diff --git a/test/bats/117-msys-path-forms.bats b/test/bats/117-msys-path-forms.bats index f49b1472..deef37e8 100644 --- a/test/bats/117-msys-path-forms.bats +++ b/test/bats/117-msys-path-forms.bats @@ -250,9 +250,8 @@ _msys_provision() { # NAME WS 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") - staged_host=$(host_path "'"$STUBDIR"'/state/msb-snapshots/$(sha256sum "'"$STUBDIR"'/saved.msb" | cut -d" " -f1).msb") grep -q -- "-o $snapshot_host" "'"$CALLS"'" && echo "snapshot-host-form-ok" - grep -q -- "restore $staged_host --name restored" "'"$CALLS"'" && echo "restore-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' diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 6f2ca295..7573b591 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -120,7 +120,7 @@ _sha256() { 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/[0-9a-f]{64}\.msb --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name restored --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" assert_regex "$log" 'USAI_API_KEY=present' assert_regex "$log" 'socat UNIX-LISTEN:' assert_regex "$log" 'msb inspect restored --format json' @@ -140,7 +140,7 @@ _sha256() { run env ACQ_BACKEND=msb SSH_AUTH_SOCK="$STUBDIR/agent.sock" "$ACQ" restore restored "$STUBDIR/full-state.msb" assert_success local log; log=$(cat "$CALLS") - assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/[0-9a-f]{64}\.msb --name restored --dangerously-inherit-resources --vsock" + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name restored --dangerously-inherit-resources --vsock" refute_regex "$log" 'msb inspect restored --format json' refute_output --partial "re-applied kit startup" assert_output --partial "acq: restored 'restored'." @@ -156,6 +156,25 @@ _sha256() { 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" @@ -178,7 +197,7 @@ _sha256() { : > "$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/[0-9a-f]{64}\.msb --name mybox" + 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" { @@ -211,7 +230,7 @@ _sha256() { assert_regex "$log" "msb snapshot create --from-sandbox mybox --full --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/[0-9a-f]{64}\.msb --name mybox --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --volume $STUBDIR/ws:$STUBDIR/ws --vsock ${sock_path}:3552/stream" + assert_regex "$log" "msb restore $STUBDIR/state/msb-snapshots/\.restore\.[[:alnum:]]{6} --name mybox --dangerously-inherit-resources --disk-only --external-mount-policy relaxed --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" } From 8a35367028d1ec6309ffedba30b01cb9709c5b5f Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Thu, 15 Oct 2026 09:28:08 +0000 Subject: [PATCH 14/15] fix(msb): canonicalize host config mount Resolve the host-authoritative configuration source before passing it to msb so macOS /var-backed state directories mount successfully. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq.backends/msb.sh | 13 ++++++++++--- scripts/verify-msb-host-config-mount | 14 ++++++++++++++ test/bats/79-msb-readonly-kit-files.bats | 17 +++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 scripts/verify-msb-host-config-mount diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index a73b9b4f..4a4903e0 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -3839,14 +3839,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 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/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" +} From 20579dcc7b4ba37a905af4df64767ff189015702 Mon Sep 17 00:00:00 2001 From: Bret Mogilefsky Date: Wed, 21 Oct 2026 09:30:47 +0000 Subject: [PATCH 15/15] fix(msb): preserve snapshot launch state Restore agent metadata with exported snapshots and clean live metadata after snapshot-backed removal. Co-authored-by: OpenCode [gpt-5.6-terra] --- acq | 8 ++--- acq.backends/common.sh | 16 +++++++++ acq.backends/msb.sh | 61 ++++++++++++++++++++++++++++++++++- test/bats/70-msb-backend.bats | 36 +++++++++++++++++++-- 4 files changed, 113 insertions(+), 8 deletions(-) diff --git a/acq b/acq index af7f9c39..542e0f4a 100755 --- a/acq +++ b/acq @@ -1696,11 +1696,9 @@ case "$subcommand" in acq_backend_snapshot "$local_name" "$snapshot_out" echo "acq: snapshot saved before removal: $snapshot_out" >&2 fi - if [ "$snapshot_first" -eq 1 ] && 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 + # 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" ;; exec) diff --git a/acq.backends/common.sh b/acq.backends/common.sh index 1942f759..1db96d94 100644 --- a/acq.backends/common.sh +++ b/acq.backends/common.sh @@ -2075,6 +2075,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 diff --git a/acq.backends/msb.sh b/acq.backends/msb.sh index 4a4903e0..ae5c2bd2 100644 --- a/acq.backends/msb.sh +++ b/acq.backends/msb.sh @@ -1112,6 +1112,61 @@ _acq_msb_snapshot_kits_file() { 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 @@ -1201,6 +1256,7 @@ acq_backend_snapshot() { 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" --full --guest-flush auto fi @@ -1277,7 +1333,8 @@ acq_backend_restore() { return "$_restore_rc" fi if ! _acq_msb_restore_resources_adopt_trusted "$_name" "$_staged_snapshot" \ - || ! _acq_msb_snapshot_kits_adopt_trusted "$_name" "$_staged_snapshot"; then + || ! _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 @@ -5266,6 +5323,8 @@ _acq_msb_terminate_impl() { # 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" } diff --git a/test/bats/70-msb-backend.bats b/test/bats/70-msb-backend.bats index 7573b591..f71fc7af 100644 --- a/test/bats/70-msb-backend.bats +++ b/test/bats/70-msb-backend.bats @@ -84,16 +84,21 @@ _sha256() { assert_regex "$(cat "$CALLS")" "msb snapshot create --from-sandbox mybox --full --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" + 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 --full --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" { @@ -104,6 +109,18 @@ _sha256() { 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" @@ -114,6 +131,7 @@ _sha256() { 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" @@ -126,6 +144,10 @@ _sha256() { 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" ] @@ -236,6 +258,10 @@ _sha256() { } @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 @@ -243,7 +269,13 @@ _sha256() { assert_regex "$log" "msb snapshot create --from-sandbox mybox --full --guest-flush auto -o $STUBDIR/rm\.msb" assert_regex "$log" 'msb remove --force mybox' refute_regex "$log" 'msb restore' - refute_regex "$log" 'msb volume rm' + 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: ls/stop/rm/exec dispatch to the msb verbs (exec as agent user)" {