Skip to content

fix(msb): block unsafe 0.7 migration versions - #510

Merged
basiliskus merged 15 commits into
mainfrom
fix/msb-version-block-0-7-0-0-7-2
Oct 3, 2026
Merged

basiliskus merged 15 commits into
mainfrom
fix/msb-version-block-0-7-0-0-7-2

Conversation

@mogul

@mogul mogul commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Summary for reviewers

In one line: msb 0.7.0-0.7.2 silently destroy the ability to read your own sandboxes, and installing msb the documented way is enough to land there. This PR makes acq refuse those versions, install a known-good one verifiably, and recover a host that is already stuck.

Three things are worth knowing before reading the diff:

  • The trap needs no user error. Every upstream install channel resolves to the newest release — msb self update, brew upgrade, and curl … install.microsandbox.dev | sh alike. Worse, each release publishes a byte-identical copy of install.sh as a release asset, so a URL like .../releases/download/v0.6.18/install.sh looks like a version pin and installs whatever is newest. An earlier revision of this branch used exactly that URL and was wrong to. That is why acq now places the artifact itself and verifies it against the release's published checksum.
  • The fix is to go forward, not back. msb 0.7.3 carries upstream's compatibility fix and reads an already-migrated catalog as-is. So for a stuck host acq offers msb self update first, and only falls back to rolling back to the 0.6.18 pin. Upstream's migration guide, published while this PR was open, independently recommends the same order.
  • The reflexive recovery bricks the install. msb self downgrade run by too old a binary doesn't just fail — it leaves a journal after which every catalog-opening msb command from every version refuses, with no abort flag. acq now refuses to attempt that, and can clear the journal if you already hit it.

Everything above was verified by running real 0.6.18 / 0.7.2 / 0.7.3 binaries against throwaway state, not by reading release notes — see scripts/verify-msb-pin (29/29) below.

Scope: install.sh, install.ps1, the msb adapter's version gate, docs, tests, and one new ADR. No sandbox-provisioning behavior changes. The one genuinely risky item is the journal removal, called out under Reviewer focus.


Trying it before this merges

The installer is a single self-contained sh script with no dependency on the rest of the repo, so you can run this branch's copy directly against the released acq. Nothing below needs the PR merged.

Everything is consent-gated and --dry-run changes nothing, so start there.

macOS / Linux

# Read it first — it is one file and you are about to run it.
curl -fsSLO https://raw.githubusercontent.com/GSA-TTS/agentic-coding-quickstart/fix/msb-version-block-0-7-0-0-7-2/install.sh
less install.sh

# 1. See what it WOULD do. No changes, no network writes, no prompts.
sh install.sh --dry-run --yes

# 2. Real run. It asks before every state change (PATH edit, msb install,
#    anything touching an existing msb).
sh install.sh

# 3. Confirm what you ended up with.
command -v msb && msb --version     # expect 0.6.18, or >= 0.7.3
acq doctor

If you already have a blocked msb (0.7.0-0.7.2) installed, that is the interesting case and the one worth a volunteer. Run step 2 and it will detect whether your catalog was already migrated, then offer the forward path first. Back up ~/.microsandbox first if you have sandboxes you care about — the recovery paths are the parts least exercised outside the verification script:

cp -a ~/.microsandbox ~/.microsandbox.bak    # stop sandboxes first for a consistent copy

To verify the underlying claims yourself rather than taking the PR's word for it, the live verifier needs only curl, tar, and ~1.2GB of scratch. It boots no VM, touches neither your real ~/.microsandbox nor your ~/.local/bin, and needs no virtualization:

git clone -b fix/msb-version-block-0-7-0-0-7-2 \
  https://github.com/GSA-TTS/agentic-coding-quickstart.git acq-pr510
cd acq-pr510
./scripts/verify-msb-pin          # 29 checks; TMPDIR=~/scratch if /tmp is small
./scripts/test-acq-bats           # 566 offline unit tests

Add --install to have it place the verified pinned msb on your host for real; it prompts first and declines by default with no terminal.

Windows (preview)

The Windows-specific logic has now had a real-host smoke test for pinned msb install, blocked-version forward recovery, in-use msb.exe handling, version parsing, and fail-closed behavior. Broader Windows coverage is still thinner than the POSIX path, so additional Windows review remains useful.

Requires Windows Hypervisor Platform already enabled — the installer checks but deliberately never enables features, elevates, or reboots.

# Read it first.
irm https://raw.githubusercontent.com/GSA-TTS/agentic-coding-quickstart/fix/msb-version-block-0-7-0-0-7-2/install.ps1 -OutFile install.ps1
notepad install.ps1

# 1. Dry run — prints every action, changes nothing.
.\install.ps1 -DryRun

# 2. Prerequisites only, skipping the acq zip (which still comes from the
#    released tag, so it will not contain this branch's changes):
.\install.ps1 -NoMsb -DryRun

# 3. Real run.
.\install.ps1

# 4. Confirm.
msb --version        # expect 0.6.18, or >= 0.7.3
acq version

What is most worth reporting back, in rough priority order:

  1. Does the pinned msb install work at all — does the bundle download, verify, and extract, and does msb --version then report 0.6.18?
  2. With a blocked msb already installed, does the blocked-version branch trigger and offer msb self update?
  3. With msb.exe running (start a sandbox, leave it up), does the install fail with the actionable in-use message rather than a mid-copy sharing violation?
  4. Does the PATH prompt behave — and does -NoPath correctly decline?
  5. Anything that throws a raw .NET exception instead of a readable message.

-DryRun and -Help are safe to run anywhere, including on macOS/Linux pwsh, and are the cheapest possible smoke test.


What this fixes

msb (microsandbox) 0.7.0-0.7.2 migrate 0.6.x sandbox state one-way into a format the 0.6.x line cannot read. Two users hit it, and the sequence needs no mistake on their part — installing msb the documented way is enough, because msb self update, brew upgrade, and curl -fsSL https://install.microsandbox.dev | sh all resolve to the newest release:

database schema is newer than this msb binary; applied migration
"m20260910_000001_snapshot_groups" is not in this binary's migration prefix

Every 0.6.x msb on that host then refuses, including read-only msb list. The state at risk is the user's work.

What changed

  1. Runtime guard. acq refuses msb 0.7.0-0.7.2, accepts >= 0.6.9 and >= 0.7.3. Split into acq_backend_check_version (presence + policy; one msb --version) and acq_backend_prepare (that plus the msb doctor probe), so verbs that only touch existing state (acq ls, stop, rm, …) are guarded too. They previously reached msb unguarded and surfaced the raw upstream error — exactly when a stranded user most needs acq's explanation.

  2. Pinned, verified install. install.sh installs msb 0.6.18 from the release bundle verified against that release's own checksums.sha256. This exists because neither upstream channel can express a version: the one-liner takes no version argument and reads releases/latest, and every release publishes a byte-identical copy of install.sh as an asset — so .../releases/download/v0.6.18/install.sh looks like a pin and installs whatever is newest. An earlier revision of this branch used exactly that URL and was wrong to. The libkrunfw name and ABI are derived from the artifact; hardcoding it is the live bug in upstream's own formula (libkrunfw.so.5.2.1, stale since 0.6.8).

  3. Forward-first recovery. For an already-migrated catalog, acq offers msb self update (to 0.7.3) before the rollback, and re-checks the result rather than assuming it. Upstream's new migration guide independently agrees: "upgrade to v0.7.3 and retry first. A v0.7.2 database can upgrade directly; no downgrade to v0.6 is needed."

  4. Wedge guards. msb self downgrade run by too old a binary leaves an operation journal, after which every catalog-opening msb command from every version refuses, with no abort flag. recover_migrated_catalog refuses to drive a rollback from older than the blocked line (making the wedge unreachable through acq) and points at the keg-only formulae — which is also upstream's documented remedy. clear_stale_downgrade_journal detects a pre-existing wedge and removes only that operation directory.

  5. Windows. install.ps1 had no msb version guard and installed via unpinned irm … | iex. It now mirrors install.sh, with a component-wise comparator (not [version], so a build suffix cannot throw mid-install) and an in-use-msb.exe check before copying.

  6. ADR-0032 records all of it. It partly supersedes ADR-0026, whose "one remaining unpinned leg" is no longer unpinned.

Reconciling with upstream's "do not delete the catalog"

Upstream's guide says "Do not delete the catalog or edit migration history to bypass the refusal." clear_stale_downgrade_journal removes a directory under $MSB_HOME/db, so this deserves to be stated rather than inferred:

Upstream says do not touch What the guard removes
the catalog database ($MSB_HOME/db/*.db) — never touched
migration history (the table inside it) — never touched
retained downgrade backups — never touched
any sandbox, snapshot, or image — never touched
db/self-downgrade/<id>/ — one incomplete operation's journal

The journal is a lock, not schema state: phase != complete means an operation is in flight, and msb's own downgrade path retires it on success. Removing it abandons an operation that never began mutating the database; the catalog left behind is bit-identical and no schema check is bypassed. What upstream warns against — deleting the database, or editing the applied-migrations table, to force an older binary to accept a newer catalog — acq never does.

Verification

Live, on linux-aarch64. verify-msb-pin runs real 0.6.18 / 0.7.2 / 0.7.3 binaries against throwaway state — a mocked pass would prove none of these claims. It isolates MSB_HOME and HOME, the latter because msb self downgrade writes symlinks into $HOME/.local/bin and would otherwise replace the caller's real msb link (observed during development). It boots no VM, so it needs no virtualization and runs inside a sandbox.

Gate Result
./scripts/test-acq-bats 566/566 in the latest local run; PowerShell tests requiring pwsh and live sbx grammar tests skip when unavailable
shellcheck --severity=warning (all shell + .bats) clean
pre-commit run --all-files 11/11 hooks pass
PSScriptAnalyzer on install.ps1 no new findings vs main; main's PSAvoidUsingInvokeExpression is gone
pwsh behavioral probes see below
./scripts/verify-msb-pin 29/29
verify-msb-pin — 29/29
== preconditions ==
  ok   curl, tar, and a sha256 tool are present
  ok   platform bundle: microsandbox-linux-aarch64.tar.gz
  ok   scratch space: 2157MB free on /home/agent/tmp-verify
  ok   install.sh MSB_PINNED_VERSION matches this script (0.6.18)
  ok   install.sh MSB_BLOCKED_VERSION_MAX matches this script (0.7.2)
  ok   install.sh MSB_FIXED_VERSION matches this script (0.7.3)

== 1. upstream install.sh cannot pin a version ==
  ok   the v0.6.18 and v0.7.3 install.sh assets are byte-identical
  ok   the installer resolves its version from releases/latest at run time

== 2. pinned msb 0.6.18 installs from a verified bundle ==
  ok   bundle for v0.6.18 verified against its published checksums.sha256
  ok   the installed binary reports: msb 0.6.18

== 3. a blocked msb strands the catalog for the pinned line ==
  ok   msb 0.6.18 initialized its catalog cleanly
  ok   bundle for the blocked v0.7.2 verified against its published checksum
  ok   msb 0.7.2 migrated the catalog forward (25 -> 27 migrations)
  ok   msb 0.6.18 now refuses the migrated catalog (the reported failure)

== 4. moving FORWARD to msb 0.7.3 recovers without a rollback ==
  ok   bundle for the fixed v0.7.3 verified against its published checksum
  ok   msb 0.7.3 read the already-migrated catalog directly (now 28 migrations)

== 5. rollback recovery, driven BY the blocked binary ==
  ok   msb 0.7.2 rolled its own migrations back to 0.6.18
  ok   the catalog is back to its pre-0.7.2 migration count (25)
  ok   a database backup was retained: msb.db.bak-0.7.2-to-0.6.18
  ok   msb 0.6.18 can read the rolled-back catalog again

== 5b. a snapshot group blocks the rollback (and leaves no wedge) ==
  ok   msb 0.7.2 refuses the rollback while a snapshot group exists
  ok   no database backup was written: the refusal precedes any mutation
  ok   no active journal after the refusal (the operation dir is retired as .cancelled-*)
  ok   msb 0.7.2 still works normally after the refusal

== 6. the WRONG order fails, and wedges later catalog-opening msb commands ==
  ok   msb 0.6.18 refuses to roll back migrations it does not know
  ok   the failed attempt left an interrupted-downgrade journal behind
  ok   both msb versions refuse catalog-opening commands while the journal exists
  ok   removing the journal directory unblocks msb catalog access

== 6b. a keg-only versioned formula can drive the recovery ==
  ok   a keg-layout msb runs by path with no LD_LIBRARY_PATH: msb 0.7.2
  ok   the keg binary rolled the catalog back without ever being on PATH

  passed: 29   failed: 0   skipped: 0
Windows probes under pwsh 7.5.4 (not a Windows host)

Functions extracted from install.ps1 via its own AST and exercised directly:

  • Compare-MsbVersion correct on 10 cases including 0.7 vs 0.7.0, 0.7.10 vs 0.7.2, and 0.7.2-rc1.
  • Test-MsbVersionBlocked blocks exactly 0.7.0-0.7.2 (and 0.7, 0.07.2); does not block 0.7.3, 0.7.10, 0.8, 1.0.0.
  • Get-MsbVersion fails closed: empty on nonzero exit even when a version is printed, empty on unparseable output, empty (no throw) on a nonexistent path, and unaffected by a stale $LASTEXITCODE from a prior command.
  • The checksums.sha256 regex matches the real published manifest for microsandbox-windows-x86_64.zip and rejects decoy lines (not-<bundle>, <bundle>.sig).
  • Expand-Archive handles the real Windows bundle — confirmed it is a zip containing exactly msb.exe + libkrunfw.dll, and the published checksum verifies.

Also smoke-tested on a real Windows host for pinned install, blocked-version recovery, in-use msb.exe handling, version parsing, and fail-closed behavior. Treat as preview until it has broader user coverage.

Defects found reviewing the checkpoint

All fixed in 28265b8:

  • MSB_ALREADY_SUPPORTED was assigned in two branches and read nowhere.
  • verify_active_msb_pinned reported "msb 0.6.18 was installed" after the forward path installed 0.7.3. Renamed verify_active_msb_supported, messages de-versioned.
  • msb_report_candidates built an unused versions string and probed --version on every candidate before its single-candidate early return.
  • scripts/verify-msb-pin was committed non-executable (100644), so ./scripts/verify-msb-pin — the command install.sh and msb.sh both print — failed with Permission denied.
  • verify-msb-pin failed misleadingly on a small TMPDIR. It needs ~1.2GB; on a 512MB /tmp tmpfs, exhaustion surfaced as msb self downgrade reporting "failed to unpack libkrunfw" — indistinguishable from a real defect in the path under test. Now checked up front.
  • The version-drift check reached its expected value via eval (SC2154). Now passes values directly, and also covers MSB_BLOCKED_VERSION_MAX.
  • --dry-run failed on a host with no msb (fbd20a3). verify_active_msb_supported did not exempt dry runs, so it verified a state the run deliberately never created: every step printed OK, then error: msb was installed, but no msb is active on PATH and a non-zero exit. install.ps1 has always had this guard; the shell side did not. Found while writing the reviewer instructions above — the first command they suggest is the one that failed. Every other dry-run test in the suite passes --no-msb, which is why none of them caught it; the new regression test deliberately does not, and also asserts a dry run writes nothing.

Two corrections to claims the checkpoint made:

  • msb --version is not wedged by the journal — only commands that open the catalog are. The ADR's "refuses EVERY command" was too strong and is narrowed. The guard ordering in replace_active_msb (clear the journal before msb_catalog_ahead) is still required and unchanged.
  • §35 → §42. The old numbering collided with an existing §35 and sat mid-file; main has since added §41 (fix(ci): unblock unrelated PRs from the bash32 required-check deadlock #513). git merge-tree confirms a clean merge.

Also newly documented

msb 0.7.3 enables --net-strict by default, so a hostname allow rule requires inspectable request authority. acq's balanced/strict tiers are hostname-based and acq enables --tls-intercept by default, so the default path is correct on 0.7.3. The combination that breaks is ACQ_MSB_NO_TLS_INTERCEPT=1 on 0.7.3+: every allowed host becomes unreachable rather than merely losing secret substitution. This PR is what makes 0.7.3 reachable, so it is this PR that exposes it.

Rollback

Revert the two commits (97226fa, 28265b8). acq returns to accepting the blocked range, and install.sh returns to the upstream installer — i.e. back to the reported failure. No migration or on-disk state is created by this change, so there is nothing to undo beyond the code.

Release-cut note for reviewers: Formula/acq.rb in the tap pins the release tarball, so Homebrew users do not get the fixed install.sh until release-please cuts a release. Not a blocker for merge, but the fix is not in users' hands at merge time.

Security impact

  • Attack surface reduced. No downloaded script is executed any more, on either platform (PSAvoidUsingInvokeExpression on main is gone). Artifacts are verified against the release's published checksums.sha256 before anything is written, and ABI validation completes before the first write so a malformed bundle cannot half-install a runtime.
  • Honest limit: the checksum is fetched from the same release as the artifact, so this verifies integrity against the published manifest, not provenance. Recorded as such in ADR-0032.
  • rm -rf reaches only paths derived from $MSB_HOME globbing plus a $$-suffixed scratch dir; the journal removal is consent-gated and prints the exact path.
  • No secrets, credentials, auth, or authorization paths are touched.

Reviewer focus

  1. The journal-removal argument (install.sh clear_stale_downgrade_journal, ADR-0032 §4). It is the one place acq deletes something under $MSB_HOME/db, against an upstream instruction that superficially reads as covering it. If the reasoning is wrong, that is the finding that matters most.
  2. recover_migrated_catalog's ordering guard — it is what makes the wedge unreachable, and it is a negation over a version range.
  3. install.ps1, especially beyond the real-host smoke-tested paths; Windows coverage remains newer and narrower than POSIX coverage.
  4. The four-way duplication of the version policy (install.sh, install.ps1, acq.backends/msb.sh, scripts/verify-msb-pin). Only the Windows copy is drift-tested, against install.sh.

🤖 AI-assisted: written with OpenCode (claude-opus-4). All verification output above is from real runs, not reconstructed.

@mogul
mogul requested a review from a team as a code owner September 24, 2026 02:12
…-0-7-0-0-7-2

# Conflicts:
#	docs/KNOWN_FAILURE_MODES.md
#	test/bats/70-msb-backend.bats
@mogul
mogul marked this pull request as draft September 24, 2026 09:07
@mogul

mogul commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

The upstream msb distribution situation is kind of a disaster. There's no way to get anything but the latest version through their curl | sh and brew vectors. I'm converting this to draft while I sort this out.

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at f611522. Draft PR.

This change blocks msb 0.7.0-0.7.2 at runtime in acq_backend_prepare, and reworks install.sh to report duplicate msb binaries on PATH, install a "pinned safe" msb 0.6.18, and fail closed when a blocked binary still shadows it. Docs record the blocked range, the safe pin, and the PATH-shadowing failure mode.

Intent: The runtime block matches the stated intent and is well tested. The installer half does not: the "pinned safe version" is not pinned, and the new fail-closed gate fires on paths where nothing was installed.

Findings (3)

  1. Blocking: install_msb_safe_version does not install msb 0.6.18. The URL it fetches is only the installer asset attached to the v0.6.18 tag — that script has no version pin, no env override, and no argument parsing: it calls get_latest_version() against api.github.com/repos/superradcompany/microsandbox/releases/latest and installs whatever that returns. The v0.6.18 and v0.7.2 assets are byte-identical (diff -q → identical), so msb_install_script_url is indistinguishable from https://install.microsandbox.dev. During the blocked window this installs a blocked release and links it into ~/.local/bin, after which verify_active_msb_safe dies blaming PATH shadowing — misdiagnosing a problem the installer just created. It also violates the project's own dependency rule: "Version pinning: Exact versions only, no floating ranges". The same non-pin is published as user guidance in docs/howto/msb.md:68 and docs/KNOWN_FAILURE_MODES.md:2240. A real pin needs the versioned bundle URL (releases/download/v0.6.18/microsandbox-<target>.tar.gz + checksums.sha256), not the tag's installer asset.
    install.sh:242
  2. Blocking: verify_active_msb_safe runs unconditionally after install_msb_safe_version || INSTALL_MSB=0, so it fires even when no install happened. Two regressions against pre-change behavior: sh install.sh --dry-run --yes on a host with no msb now exits 1 with error: msb 0.6.18 was installed, but no msb is active on PATH — nothing was installed, and README.md documents --dry-run as "show what it WOULD do, changing nothing" (old: exit 0). And when the download fails, INSTALL_MSB=0 is set intending the "Skipping msb" degrade at install.sh:739-743, but the gate dies first, so a successful acq install reports total failure (old: warn, exit 0). That degrade path is unreachable on the failure path. No test covers either: every --dry-run test also passes --no-msb, and no test stubs a failing curl.
    install.sh:733
  3. Should fix: New ## 35. collides with the existing ## 35. Re-attach Heal Loop Warns Every Time on sbx 0.38 at line 1866, and lands out of sequence between 39 and 40 — it should be 41. MD024 is disabled in .markdownlint-cli2.yaml, so CI will not catch it; commit e43a647 in this branch's own history is a dedicated fix for exactly this collision class.
    docs/KNOWN_FAILURE_MODES.md:2208

Below the bar: acq_backend_prepare's "msb not found" branch (acq.backends/msb.sh:774) still recommends bare install.microsandbox.dev / brew install with no caveat, unlike the too-old branch this PR updated — an internal inconsistency, though its bite has lapsed now that upstream v0.7.3 is published and passes the block.

Verdict: needs changes

Assisted-by: OpenCode [claude-opus-5] team-code-review

…ve verification

Interim checkpoint of the msb blocked-version work. The installer side and the
live verification are complete; docs, ADR-0031, and the bats installer stubs are
still outstanding (7 tests in test/bats/05-install.bats still assert the removed
upstream-installer path and currently fail).

msb 0.7.3 shipped mid-change with the upstream cross-version compatibility fix
(microsandbox#1634, closing microsandbox#1616), which is the reason the
0.7.0-0.7.2 range was blocked at all. That changes the recommended recovery from
"roll back to 0.6.18" to "move forward to 0.7.3", so:

- Add MSB_FIXED_VERSION=0.7.3 to install.sh and acq.backends/msb.sh, and offer
  `msb self update` FIRST for a blocked msb whose catalog was already migrated.
  The migration sets are additive, so the fixed line reads an already-migrated
  catalog with no rollback, no backup dance, no data-affecting step, and none of
  the refusals a downgrade can hit. The rollback to the pinned version remains as
  the fallback. The result is re-checked rather than assumed, because
  `msb self update` targets whatever is newest.
- Drop the ">= 0.7.3 once available" phrasing throughout; it is available.

Guard the wedge discovered while verifying the recovery. A `msb self downgrade`
attempted by too-old an msb fails AND leaves an operation journal that makes
every later msb command from every version refuse, with no upstream flag to
abort or resume it. So:

- recover_migrated_catalog now refuses to drive a rollback with a binary from
  older than the blocked line, making the wedge unreachable through acq.
- msb_stale_downgrade_journal / clear_stale_downgrade_journal detect a
  pre-existing wedge and offer to remove only the operation directory, never the
  database, retained backups, or sandbox state. This runs before any msb probe,
  since a wedged install would otherwise make msb_catalog_ahead misread.

Close the Windows gap. install.ps1 had no msb version guard at all, so Windows
hosts could land on a blocked release via the unpinned one-line installer:

- Add the version policy constants, a component-wise comparator (not [version],
  so a build suffix cannot throw mid-install), and blocked/too-old/unparseable
  handling that mirrors install.sh, including the forward-to-0.7.3 path.
- Install the pinned msb from its release bundle verified against the release's
  own checksums.sha256, rather than piping the upstream installer into
  Invoke-Expression and trusting it to honor an environment variable we cannot
  verify. The Windows bundle is two files, so placing them directly is checkable.
- Check for an in-use msb.exe before copying, since Windows cannot overwrite a
  running image, and fail with an actionable message instead of a sharing
  violation mid-copy.
- Verify the active msb after every install path, including WinGet, whose package
  version is outside our control.
- Extract Add-UserPath from Ensure-Path so the msb bin dir gets the same
  ask-before-changing-PATH treatment as the acq install dir.

Add scripts/verify-msb-pin, which verifies all of the above against real msb
binaries instead of mocks. It runs each check against a throwaway MSB_HOME *and*
HOME -- both are required, because `msb self downgrade` writes command symlinks
into $HOME/.local/bin and would otherwise replace the caller's real msb link. It
boots no VM, so unlike the other verify-* scripts it needs no virtualization and
can run inside a sandbox. Verified 24/24 on linux-aarch64 and darwin-arm64:

- the upstream installer cannot pin (per-release install.sh assets are
  byte-identical and resolve releases/latest at run time)
- the pinned bundle verifies against its published checksum and runs
- 0.6.18 -> blocked 0.7.x -> 0.6.18 reproduces the reported stranding
- 0.7.3 reads that already-migrated catalog directly
- `self downgrade` driven BY the blocked binary restores it, retaining a backup
- the same attempted by the older binary fails, wedges every msb command, and is
  cleared only by removing the journal directory
- a keg-only formula's layout runs by path, with no LD_LIBRARY_PATH and no
  signature-invalidating binary edit, and can drive the recovery unlinked

Also stop probing keg-only brew formulae in msb_candidate_paths: they are never
symlinked into the prefix and so cannot shadow anything, and reporting them would
turn a correct side-by-side install into a spurious "multiple msb binaries"
warning.

Refs: #510
Refs: GSA-TTS/homebrew-tap@475cce3
@mogul

mogul commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

Interim checkpoint pushed (97226fa) — installer and live verification done, docs/ADR/tests outstanding

Pushing a work-in-progress commit so nothing is lost. This PR is not ready to merge yet; the remaining items are listed at the bottom.

msb 0.7.3 changed what this PR should recommend

v0.7.3 shipped on 2026-09-24 carrying superradcompany/microsandbox#1634, which closes superradcompany/microsandbox#1616 — the bug this whole blocked range exists for. That makes "move forward" strictly better than "roll back", so the installer now offers msb self update first when a blocked msb has already migrated the catalog, and falls back to the 0.6.18 pin only if that is declined or lands somewhere unacceptable. The pin remains the default for a fresh install, and the >= 0.7.3 once available phrasing is gone throughout.

The reason forward is safe is verifiable rather than assumed: the applied-migration lists are strict prefixes (0.6.18 has 25, 0.7.2 has 27, 0.7.3 has 28), so the fixed line reads an already-migrated catalog with no rollback, no affects_user_data step, and no exposure to the grouped-snapshot refusal a downgrade can hit.

A wedge worth knowing about

While verifying the rollback path I found that msb self downgrade attempted by too old an msb does not just fail — it leaves an operation journal at $MSB_HOME/db/self-downgrade/<id>/journal.json, after which every msb command from every version refuses with self_downgrade_recovery_required. I tried to escape it four ways (resume from 0.6.18, 0.7.2, 0.7.3, and with the exact target the journal demands); all four refuse, and self downgrade exposes no abort flag. The only exit is removing the journal directory.

Since "try the downgrade with the msb I have" is the reflexive user action, that turns a recoverable state mismatch into an apparent brick. Two guards now: recover_migrated_catalog refuses to drive a rollback from older than the blocked line (making the wedge unreachable through acq), and clear_stale_downgrade_journal detects a pre-existing one and offers to remove only the operation directory — never the database, the retained backups, or sandbox state.

Windows had no guard at all

install.ps1 previously installed msb via unpinned irm https://install.microsandbox.dev/windows | iex with no version check, so a Windows host could land on a blocked release. It now mirrors install.sh: version policy constants, blocked/too-old/unparseable handling including the forward-to-0.7.3 path, and a pinned install from the release bundle verified against that release's own checksums.sha256 rather than piping a remote script into Invoke-Expression. Also checks for an in-use msb.exe before copying, since Windows cannot overwrite a running image.

Windows remains a preview path and this is untested on a real Windows host — reviewers should treat it as such.

scripts/verify-msb-pin — 24/24 on both platforms

New live verification script. It runs real msb binaries (0.6.18, 0.7.2, 0.7.3) against throwaway state rather than mocking anything, isolating both MSB_HOME and HOME — the latter is required because msb self downgrade writes command symlinks into $HOME/.local/bin and would otherwise clobber the caller's real msb link. It boots no VM, so unlike the other verify-* scripts it needs no virtualization and runs inside a sandbox.

Verified on linux-aarch64 and darwin-arm64, 24 checks each: the unpinnable upstream installer, the pinned bundle install, the stranding reproduction, the forward recovery, the correctly-ordered rollback, the wrong-order wedge and its escape, and the keg-only formula layout.

Tap changes (merged separately)

GSA-TTS/homebrew-tap now carries microsandbox-acq (the pin acq depends on) plus keg-only [email protected] and @0.7.3. Keg-only matters: nothing is symlinked, so versions coexist and cannot shadow the msb on PATH — which is what makes them usable for the "run the downgrade with the binary that performed the migration" case when that binary has already been uninstalled. A blocked release is deliberately not published as a formula; verify-msb-pin fetches 0.7.2 by pinned URL and checksum instead.

Two bugs fixed there: the Linux libkrunfw.so.5.2.1 filename was hardcoded and has been stale since 0.6.8 (the bundles ship 5.6.1), which broke brew install on Linux — this bug is still live in upstream's own formula and is reported upstream. And msb_candidate_paths here no longer probes keg-only formulae, which would otherwise produce a spurious "multiple msb binaries" warning for a correct side-by-side install.

Still outstanding before this is mergeable

  • docs/adr/0031-* — referenced from code already; not yet written
  • Docs: KNOWN_FAILURE_MODES.md §35, BACKEND_GUIDE.md, howto/msb.md, howto/acq.md (§35 and howto/msb.md still show the v0.6.18/install.sh command that does not pin)
  • 7 failing tests in test/bats/05-install.bats — stubs still assert the removed upstream-installer path
  • Adversarial review pass
  • Release-cut note: Formula/acq.rb pins the release tarball, so brew users do not get the fixed install.sh until release-please cuts a new release

Also filed upstream from this work: the install.sh version-pin gap, the always-latest tap formula (plus the libkrunfw bug), and a comment on superradcompany/microsandbox#1616 about the downgrade ordering constraint being undocumented.

…with upstream's migration guide

Completes the msb blocked-version work checkpointed in 97226fa. That commit landed
the installer and the live verification but left the ADR unwritten, the docs stale,
and 7 tests asserting a code path it had removed. Upstream also published a v0.6->v0.7
migration guide in the meantime, which changes what this can claim and what it must
reconcile against.

ADR-0032 (renumbered from 0031, which PR #500 already
owns) records the version policy, the pinned+checksummed install, the forward-first
recovery, and the wedge guards. It cites upstream's guide as primary evidence where one
now exists, and keeps the migration-prefix analysis as independent corroboration --
re-derived from upstream source at each tag rather than carried over: the applied
migration lists are strict prefixes (0.6.18 has 25, 0.7.2 adds split_snapshot_identity
and snapshot_groups, 0.7.3 adds migrate_secret_config), which is why the fixed line
opens an already-migrated catalog with no rollback.

Reconcile the journal guard with upstream's instruction. The guide says "Do not delete
the catalog or edit migration history to bypass the refusal", and clear_stale_downgrade_journal
removes a directory under $MSB_HOME/db. That is a different action and the code, the ADR,
and the failure-mode doc now say so explicitly rather than leaving a reader to infer it:
the journal is a lock whose `phase != complete` means an operation is in flight, msb's own
downgrade path retires it on success, and removing it leaves a bit-identical catalog. What
upstream warns against -- deleting the database or editing the applied-migrations table --
acq never does.

Fix the 7 failing tests in test/bats/05-install.bats. They asserted the upstream-installer
path that install_msb_pinned_tarball replaced, so they were testing a string that no longer
exists rather than the behavior. They now build a stand-in release bundle plus a matching
checksums.sha256 and drive the real install: platform-derived bundle name, the real
libkrunfw filename shapes (.so.5.6.1 / .5.dylib) so the ABI-derivation code is exercised,
and a curl stub that hard-errors on any unexpected URL. Three new cases cover the
fail-closed paths that had none: a checksum mismatch, a release with no checksum entry for
our asset, and a source-level assertion that no fetched artifact is handed to a shell.

Six defects found reviewing the checkpoint:

- MSB_ALREADY_SUPPORTED was assigned in two branches and read nowhere.
- verify_active_msb_pinned reported "msb 0.6.18 was installed" after the forward path had
  installed 0.7.3. Renamed to verify_active_msb_supported and de-versioned its messages.
- msb_report_candidates accumulated an unused `versions` string and probed --version on
  every candidate before the single-candidate early return.
- scripts/verify-msb-pin was committed non-executable, so `./scripts/verify-msb-pin` --
  the command install.sh and msb.sh both print -- failed with Permission denied.
- verify-msb-pin gave a misleading failure on a small TMPDIR: it needs ~1.2GB, and
  exhausting the filesystem surfaced as `msb self downgrade` reporting "failed to unpack
  libkrunfw", indistinguishable from a real defect in the path under test. It now checks
  up front and says so.
- The version-drift check reached its expected value through `eval`, tripping SC2154. It
  now passes values directly, and covers MSB_BLOCKED_VERSION_MAX as well.

Also document the 0.7.3 interaction this PR is what makes reachable: upstream enabled
--net-strict by default there, so a hostname allow rule requires inspectable request
authority. acq's tiers are hostname-based and acq enables --tls-intercept by default, so
the default path is correct -- but ACQ_MSB_NO_TLS_INTERCEPT=1 on 0.7.3+ now makes every
allowed host unreachable rather than merely unsubstituted.

verify-msb-pin gains check 5b for the snapshot-group refusal, which is the load-bearing
reason the rollback cannot be treated as always-available: every 0.7 capture creates a
group by default, and a group blocks the downgrade even when empty. Verified fail-closed
too -- no backup written, no journal left, msb still usable.

Verification (linux-aarch64):
- bats: 528/528 pass (was 521/528)
- shellcheck --severity=warning, all shell + .bats files: clean
- pre-commit run --all-files: all 11 hooks pass
- PSScriptAnalyzer on install.ps1: no new findings vs origin/main; the
  PSAvoidUsingInvokeExpression finding on main is gone with the piped installer
- pwsh: install.ps1 parses, -Help and -DryRun run, Compare-MsbVersion correct on 10
  cases, Get-MsbVersion fails closed on nonzero exit and on a stale $LASTEXITCODE,
  the checksum regex matches the real published manifest and rejects decoys, and
  Expand-Archive handles the real Windows bundle (it is a zip: msb.exe + libkrunfw.dll)
- ./scripts/verify-msb-pin: 29/29 live against real 0.6.18/0.7.2/0.7.3 binaries

Windows remains a preview path, still unexercised on a real Windows host.

Refs: #510
`--dry-run` installs nothing, so verifying the active msb afterwards checks a
state the run deliberately did not create. On a host with no msb, a plain
`sh install.sh --dry-run --yes` therefore printed every step as OK and then
died on the final check:

    error: msb was installed, but no msb is active on PATH.

That is a false alarm on the exact command a cautious user runs first, and it
exits non-zero, so it also fails any script that smoke-tests the installer.

install.ps1's Assert-MsbSupported has always had this guard (`if ($DryRun) { return }`);
the shell side did not. Found while writing reviewer instructions for the PR --
the first command those instructions suggest is the one that failed.

Every other dry-run test in the suite passes --no-msb, which is why none of them
caught it. The new regression test deliberately does not, and also asserts that a
dry run writes nothing.

Refs: #510
@mogul
mogul marked this pull request as ready for review September 28, 2026 19:29
Reviewer finding (F2, second half): a FAILED msb download used to die in
verify_active_msb_supported before reaching the 'Skipping msb' degrade, turning
a successful acq install into a total-failure exit -- and no test stubbed a
failing curl, so nothing caught it. The gate is already conditional on
install_msb_pinned succeeding (fixed earlier in this branch); this adds the
missing coverage: a curl that fails the bundle download must leave acq installed,
print 'Skipping msb', write no msb, and exit 0.

Refs: #510
@mogul

mogul commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

@basiliskus — thanks for the review at f611522; all three findings and the below-the-bar note are addressed. Head is now 330e671. The scope also grew substantially after your review (upstream shipped v0.7.3 with the compat fix, and a migration guide), so this is a re-review request on a larger change, not just a diff of the fixes — the summary and per-platform testing instructions in the PR description are the fastest way in.

F1 (blocking) — the pin is now a real pin

You were right that fetching the tag's install.sh asset was indistinguishable from install.microsandbox.dev — it reads releases/latest, and the assets are byte-identical across releases, so during the blocked window it installed a blocked release and then blamed PATH shadowing for a problem it had just created.

Rewritten to your prescription: it now fetches microsandbox-<target>.tar.gz + checksums.sha256 and verifies the checksum before writing anything (install.sh:729). MSB_PINNED_VERSION="0.6.18" is an exact version, satisfying the dependency rule. The byte-identical-installer fact you found is preserved as a live regression check (verify-msb-pin check 1 fails if upstream ever makes the asset version-specific).

On the doc guidance you flagged in docs/howto/msb.md and docs/KNOWN_FAILURE_MODES.md: both are reworked. The .../releases/download/v0.6.18/install.sh URL that survives in howto/msb.md is now inside a "Do not use the upstream one-liners to get a specific version" warning that explains it is not a pin — i.e. it documents your finding rather than repeating the mistake.

F2 (blocking) — the gate no longer fires when nothing was installed

Both halves fixed, and both now tested — your "no test stubs a failing curl" point was exactly right, nothing was covering either path.

  • --dry-run on a host with no msb returns 0 again. verify_active_msb_supported short-circuits under --dry-run (install.sh:1155), matching what install.ps1's Assert-MsbSupported already did. New test: "install: --dry-run succeeds on a host with no msb". (I hit this independently while writing reviewer instructions before re-reading your review — it's a good catch that the first command a cautious user runs was the broken one.)
  • Failed download degrades to "Skipping msb" and exits 0. The gate is now conditional on install_msb_pinned succeeding. New test: "install: a failed msb download degrades to Skipping msb, acq still succeeds", driven by a curl stub that fails the bundle fetch — the missing failing-curl coverage you noted.

F3 (should fix) — heading collision

The section is renumbered to ## 42., unique and at end-of-file. main has since added ## 41. (#513); git merge-tree confirms 42 slots in cleanly after it with no collision.

Below the bar — msb-not-found guidance

acq.backends/msb.sh:792 now gives the same version-pinned guidance and "the one-liner cannot pin" caveat as the too-old branch, instead of the bare install.microsandbox.dev / brew install it had.

One correction I owe you

I initially described the downgrade wedge as "refuses EVERY command." Verifying against real binaries, msb --version still works — only catalog-opening commands refuse. The ADR and docs now say that precisely, and the guard ordering that depends on it is unchanged.

Verification (linux-aarch64)

  • ./scripts/test-acq-bats: 531/531 (both new regression tests confirmed to fail against the unfixed code)
  • shellcheck --severity=warning, all shell + .bats: clean
  • pre-commit run --all-files: 11/11 hooks pass
  • ./scripts/verify-msb-pin: 29/29 against real 0.6.18 / 0.7.2 / 0.7.3 binaries
  • install.ps1: PSScriptAnalyzer shows no new findings vs main (and main's PSAvoidUsingInvokeExpression is gone, since the piped installer is removed); functions exercised under Linux pwsh

Two things still worth a skeptical eye, both called out in the PR description: the journal-removal argument (the one place acq deletes under $MSB_HOME/db, against an upstream instruction that superficially reads as covering it), and install.ps1, which no Windows host has run — code-review-level confidence only.

Assisted-by: OpenCode [claude-opus-4]

@mogul
mogul requested a review from basiliskus September 28, 2026 19:56
@mogul

mogul commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Two things still worth a skeptical eye, both called out in the PR description: [...] and install.ps1, which no Windows host has run — code-review-level confidence only.

Now verified on my home Windows machine:

The Windows-specific logic (pinned install, blocked→forward recovery, in-use guard, comparator, fail-closed parsing) all behaves correctly on a real Windows host. The one real-world blocker is release-timing: until a release is cut with the new release.yml, the acq half of the real run 404s and surfaces a raw .NET exception.

@basiliskus

Copy link
Copy Markdown
Contributor

Reviewed at 437d07f using the change-review skill.

This change now does the main install-side work it set out to do: the shell installer no longer uses the unpinnable upstream installer asset, checksum verification gates writes, and the dry-run / failed-download regressions from my earlier review are fixed. I still found one user-visible regression in the new runtime guard path, plus a few smaller accuracy issues in the new version-policy docs.

Findings (4)

  1. Major, confirmed: unparseable msb --version now makes state-touching verbs fail silently.
    acq.backends/msb.sh:804

    The intended empty-version branch never runs. acq is under set -euo pipefail, and _acq_msb_version is a pipeline ending in grep -oE ... | head -n1; when grep finds no version, the command substitution assignment itself exits nonzero and kills the shell before this branch can print the warning.

    Concrete failure scenario: a local wrapper/dev build/broken msb that prints msb dev-build makes acq ls, acq stop, acq rm, etc. exit 1 with no stdout and no stderr. This PR newly routes everyday state-touching verbs through acq_backend_guard_version, so a latent prepare-path bug becomes an undiagnosable failure on acq ls.

    Evidence from a fresh scratch stub at /tmp/paseo-review.coFiKs:

    msb --version -> "msb dev-build"
    ACQ_BACKEND=msb ACQ_SKIP_MSB_DOCTOR=1 ./acq ls
    rc=1 stdout=<empty> stderr=<empty>
    

    This is fail-closed, but not actionable. The guard should survive an unparseable version and print a clear diagnostic instead of dying inside the assignment.

  2. Minor, confirmed: 0.7.3-rc1 is accepted as if it were the fixed final 0.7.3.
    acq.backends/msb.sh:777

    The parser extracts only the numeric prefix, so msb 0.7.3-rc1 becomes 0.7.3 and passes. SemVer prereleases are lower than the final release and should not be assumed to contain the compatibility fix that justifies accepting 0.7.3+.

    Evidence from a scratch stub:

    msb --version -> "msb 0.7.3-rc1"
    ACQ_BACKEND=msb ACQ_SKIP_MSB_DOCTOR=1 ./acq ls
    rc=0 stdout=<CALLED_MSB_LIST>
    

    I also verified 0.7.0-rc1 is blocked because it normalizes to 0.7.0, and gh release list --repo superradcompany/microsandbox --limit 15 shows upstream currently publishes final tags only (v0.6.9 through v0.7.4). So this is limited to local/third-party/prerelease builds, not the normal upstream release channel.

  3. Minor, confirmed: ADR-0032 still links to the old failure-mode section number.
    docs/adr/0032-msb-version-policy-and-migration-recovery.md:335

    The new known-failure section is ## 42. acq Refuses msb 0.7.0-0.7.2... at docs/KNOWN_FAILURE_MODES.md:2334, but ADR-0032 says docs/KNOWN_FAILURE_MODES.md §35. Section 35 is now the sbx re-attach heal-loop entry, so the durable ADR link points at the wrong recovery instructions.

    This looks isolated: docs/howto/msb.md:97, docs/BACKEND_GUIDE.md:170, and docs/KNOWN_FAILURE_MODES.md:1783 all refer to §42 correctly.

  4. Minor, confirmed: the durable docs still say the Windows path was never run on a real Windows host.
    docs/adr/0032-msb-version-policy-and-migration-recovery.md:214

    The follow-up PR comment says the Windows-specific logic was verified on a real Windows machine, but the new durable docs still say “The Windows path remains untested on a real Windows host” and “this ADR's Windows claims are code-review-level only” (docs/adr/0032-msb-version-policy-and-migration-recovery.md:321; also docs/KNOWN_FAILURE_MODES.md:2478). That leaves reviewers and future maintainers with stale risk information for one of the areas the PR explicitly calls out as lower confidence.

Prior review resolution status

F1, unpinned “pinned” install: resolved. install.sh:759-772 downloads the versioned bundle and checksums.sha256, verifies the checksum before extraction/write, and tests cover checksum mismatch/no-checksum paths.

F2, dry-run/no-msb and failed-download gate: resolved. install.sh:1148-1158 skips active-msb verification during dry-run, and the failed-download path reaches Skipping msb with exit 0. New tests cover both.

F3, heading collision: resolved for the known-failure heading; partially resolved overall because ADR-0032 has the stale §35 link above.

Below-the-bar note from the earlier review: resolved. acq.backends/msb.sh:791-799 now points at the version-pinned install paths and warns that the upstream one-liner cannot pin.

Below the bar

The journal-removal argument looks acceptable to me: install.sh only removes $MSB_HOME/db/self-downgrade/<id>/, not db/*.db, migration history, backups, sandboxes, snapshots, or images, and the ADR explains why that differs from upstream’s “do not delete the catalog” warning. I would not block on it. There is still some over-broad wording saying the journal blocks “every/all msb command” (install.sh:918-923, install.sh:1117, ADR-0032:84, docs/KNOWN_FAILURE_MODES.md:2395) even though msb --version can still work; the ordering still makes sense because the next probe opens catalog state, but the docs/messages should eventually say “catalog-opening commands” or “read-only state commands” instead.

I am not treating the Windows acq-windows-x64.zip 404 as a PR-introduced blocker. This PR does not change .github/workflows/release.yml, and v3.1.0 predates the release workflow that publishes the Windows zip. That should clear on the next release, though the raw exception remains unfortunate.

Validation

  • git rev-parse HEAD: 437d07f44a2eeafc25ee105c1f1e77a1da9b4f03
  • git status --short: clean
  • Scratch dir used: /tmp/paseo-review.coFiKs
  • ./scripts/test-acq-bats: 1..532, all ok; PowerShell tests skipped because pwsh is not available
  • ShellCheck command from the PR instructions: not run, xargs: shellcheck: No such file or directory
  • npm run lint:md: pass, Summary: 0 issues in 0 files
  • gh pr checks 510: all reported checks pass at head
  • ./scripts/verify-msb-pin and ./scripts/verify-backends: not run; still live/sandbox-capable-host validation gaps

Verdict: needs changes

Assisted-by: OpenCode [gpt_5_5_default_v2]

@mogul

mogul commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

@basiliskus — thanks for the follow-up review. Addressed the four findings and the below-the-bar wording cleanup in cf672cb.

What changed:

  • acq.backends/msb.sh now survives unparseable msb --version output under set -euo pipefail and fails closed with an actionable diagnostic instead of exiting silently.
  • 0.7.3-rc1 / prerelease or build-suffixed versions are rejected instead of being normalized to final 0.7.3.
  • ADR-0032 now links to docs/KNOWN_FAILURE_MODES.md §42 and updates the Windows status to reflect real-host smoke testing.
  • ADR/docs/install/verifier wording now says the downgrade journal blocks catalog-opening/state commands, not literally every msb command.

Verification:

  • bash -n acq.backends/msb.sh install.sh scripts/verify-msb-pin: pass
  • direct smoke: msb dev-build + acq ls exits 1 with a clear diagnostic
  • direct smoke: msb 0.7.3-rc1 + acq ls exits 1 with a clear diagnostic
  • direct smoke: msb 0.7.3 + acq ls exits 0 and dispatches to msb list
  • ./scripts/test-acq-bats test/bats/70-msb-backend.bats: 19/19 pass
  • ACQ_BATS_JOBS=1 ./scripts/test-acq-bats: 534/534 pass
  • ShellCheck command from repo docs: clean
  • npm run lint:md: pass
  • npm run lint: pass

Note: the commit is intentionally unsigned because this worktree has SSH commit signing enabled, but the signing agent socket is unavailable (Couldnt get agent socket?).

Assisted-by: OpenCode [gpt_5_5_default_v2]

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at cf672cb4e7c0b898669dee56a8aca0591163c06a using the change-review skill.

Thanks — the runtime fixes hold up. Unparseable and non-final msb --version output now fails closed with an actionable diagnostic instead of exiting mute, msb 0.7.3 linux-arm64 still parses as final 0.7.3 (the platform suffix is after a space, so the new capture group can't reach it), and the two new tests genuinely fail at the prior head. One new gap, introduced by this round's fix.

  1. Major, confirmed: the installers still accept prerelease/build-suffixed versions that acq itself now refuses.
    install.sh:271-272, install.ps1:303

    The runtime parser was widened to capture a [+-] suffix and _acq_msb_version_is_final rejects it. Neither installer changed: install.sh still greps [0-9]+\.[0-9]+(\.[0-9]+)?, and install.ps1's Get-MsbVersion still matches \d+\.\d+(\.\d+)?. Same payloads, two answers:

    msb --version        installer parses   runtime parses    runtime accepts?
    0.7.3-rc1         -> 0.7.3              0.7.3-rc1         NO
    0.7.3+build.1     -> 0.7.3              0.7.3+build.1     NO
    0.7.3 linux-arm64 -> 0.7.3              0.7.3             YES
    0.7.3             -> 0.7.3              0.7.3             YES
    

    Concrete failure: a host with msb 0.7.3-rc1 runs install.sh. active_msb_version is set at install.sh:1208 to 0.7.3, which is parseable, not too old, and not blocked — so the elif chain falls through to ok " msb is already installed" at install.sh:1253-1254 and changes nothing. The next acq ls refuses the exact binary the installer just blessed. That's a worse state than the original bug: the tool whose job is to repair the install reports no problem.

    This also contradicts ADR-0032's claim that install.ps1 carries the same blocked/too-old/unparseable handling as the POSIX path. The install.ps1 half is code-traced only — pwsh isn't available here.

  2. Minor, confirmed: the "every msb command" wording cleanup is partial.
    install.ps1:529, docs/KNOWN_FAILURE_MODES.md:2357, scripts/verify-msb-pin:40, :422, :438

    The POSIX runtime and install text now say "catalog access" / "catalog-opening commands", but five copies still claim the interrupted downgrade blocks every command — including one users actually see, the thrown error at install.ps1:529, and the KFM symptom heading "3. Every msb command refuses". msb --version still works, which is the whole reason the narrower phrasing matters.

Below the bar: the PR body still says "No Windows host has run this code." (line 62) and "Still untested on a real Windows host" (line 215), while the durable docs now say it was smoke-tested on a real host and your comment reports the same. The docs are right; please update the body so reviewers aren't working from stale risk information.

Prior findings: mute exit — resolved. Prerelease accepted as final — partially resolved (runtime rejects, installers don't). ADR-0032 §35→§42 — resolved, now matches docs/KNOWN_FAILURE_MODES.md:2334. Windows durable-doc status — resolved. "Every msb command" wording — partially resolved, see finding 2.

Validation. Ran: full suite ./scripts/test-acq-bats → 534 ok / 0 not ok / 2 skips (both skips are pwsh not available, so the PowerShell parse tests never execute locally or in CI); the two new 70-msb-backend.bats tests against the prior head 437d07f, where both fail with the old acq: warning: could not determine msb version (need >= 0.6.9); continuing. / RC=0 — not vacuous; stubbed msb dev-build → acq ls/stop/rm all exit 1 with the new diagnostic; 0.7.3-rc1 → exit 1, 0.7.3 and 0.7.4 → exit 0; bash -n on acq, acq.backends/msb.sh, install.sh, scripts/verify-msb-pin; git diff --check; gh pr checks 510 → all 7 pass.

Not run: ShellCheck — not installed in this environment, so it did not run (CI's pre-commit covers it and is green). ./scripts/verify-msb-pin and ./scripts/verify-backends need a sandbox-capable host. install.ps1 behavior is code-traced only; the real-Windows smoke test remains author-reported from my side.

Verdict: request changes — the installer/runtime finality mismatch should be closed in both install.sh and install.ps1 before this merges, since the two halves now disagree about what a supported version is.

Assisted-by: OpenCode [gpt_5_5_default_v2] change-review

@mogul

mogul commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@basiliskus — addressed the latest review feedback and pushed 73b919b.

Fixed:

  • POSIX and PowerShell installers now preserve and reject prerelease/build suffixes (0.7.3-rc1, 0.7.3+build.1) instead of treating them as final 0.7.3.
  • install.sh also applies that final-version check after msb self update, so an unsupported updated binary falls back instead of being accepted.
  • Kept whitespace platform suffixes accepted (msb 0.7.3 linux-arm64 -> final 0.7.3).
  • Cleaned the remaining “every msb command” wording to catalog-opening/state-command wording.
  • Updated the PR body’s stale Windows status.

Verification: local ./scripts/test-acq-bats 566/566, ShellCheck clean, npm run lint clean, and all GitHub PR checks are passing.

Please re-review when you have a chance.

Assisted-by: OpenCode [gpt_5_5_default_v2]

@basiliskus

Copy link
Copy Markdown
Contributor

Reviewed at 73b919bcc2a540a9a75b00081b7e9ece7cbfc56d.

The round-3 finding is fixed and I verified it by building the agreement table rather than reading the diff — the installers and the runtime now reach the same verdict on every payload I could construct, including the platform-suffix case I was most worried about regressing. One new issue in the same family, on a different axis.

  1. Major, confirmed by reproduction: the POSIX paths accept a msb --version that exits non-zero as long as something version-shaped lands on stdout.
    install.sh:278-280, acq.backends/msb.sh:778-781

    Both discard the command's exit status:

    # install.sh:279
    "$1" --version 2>/dev/null | grep -oE '...' | head -n1 || true
    # acq.backends/msb.sh:779
    raw=$(_acq_msb_cli --version 2>/dev/null || true)

    Reproduced with a stub that prints a valid final version and then fails:

    stub: printf 'msb 0.7.3\n'; exit 42
      acq ls  ->  exit=0, output "sb1"      (accepted)
    same stub with exit 0
      acq ls  ->  exit=0, output "sb1"      (accepted, identical)
    

    The two cases are indistinguishable. Concrete failure: a damaged binary or a wrapper that emits a cached version banner then fails is reported by the installer as msb is already installed and passes acq_backend_prepare, so a user is told their install is healthy when the version probe itself did not work. That is the same not-fail-closed shape as the round-3 finding, just on exit status rather than on the suffix.

    It is also an asymmetry you have already solved on the other side. install.ps1:305-307 does exactly the right thing:

    if ($LASTEXITCODE -ne 0) {
        return ""
    }

    So the PowerShell installer rejects this and both POSIX paths accept it. Worth closing for consistency as much as for the defect — the whole point of this PR is that the three implementations agree about what a supported version is.

Round-3 finding: resolved. Installer and runtime agreement, measured payload by payload:

msb --version output        install.sh            acq runtime
0.7.3-rc1                   reject(non-final)     reject(exit 1)
0.7.3+build.1               reject(non-final)     reject(exit 1)
0.7.3                       accept 0.7.3          accept
0.7.4                       accept 0.7.4          accept
msb 0.7.3 linux-arm64       accept 0.7.3          accept
dev-build                   reject(unparseable)   reject(exit 1)
<empty output>              reject(unparseable)   reject(exit 1)

The platform-suffix row is the one I flagged as the regression risk when the regex was widened to capture [-+] — a hyphen in linux-arm64 sits after a space, so [^[:space:]]* cannot reach it, and msb 0.7.3 linux-arm64 still resolves to final 0.7.3 on both sides. Good.

Post-msb self update check: confirmed present and concrete. install.sh:1099-1110 re-resolves msb, re-parses, and rejects empty/non-final/blocked/too-old, then warns and returns 1 so replace_active_msb falls back to the pinned path — "falls back" means something specific rather than continuing silently. install.ps1:544-546 calls Assert-MsbSupported and throws instead, which is fail-closed but not a fallback; the asymmetry is defensible given the Windows install path, worth a sentence in the ADR if you think it is surprising.

"Every msb command" wording: resolved. All five copies from round 3 now say catalog-opening/state commands — install.ps1:535, docs/KNOWN_FAILURE_MODES.md:2357, scripts/verify-msb-pin:40, :422, :438.

Below the bar: test/bats/06-windows-install.bats:142-178 extracts Get-MsbVersion and Test-MsbVersionFinal from install.ps1 through the PowerShell AST and asserts the three suffix cases — a good shape, and it would catch suffix-policy drift if pwsh were present. It does not cover the non-zero-exit case, which is the one place the PowerShell implementation is currently stricter than POSIX; worth an assertion there once item 1 is fixed, so the two stay aligned in the direction that matters.

Validation. Ran: full suite ./scripts/test-acq-bats → 566 ok / 0 not ok / 2 skips, matching your count, with both skips pwsh not available; stub-driven payload matrix against both the install.sh parser and live acq ls for the seven rows above; the non-zero-exit reproduction; bash -n over the changed shell files; the repo ShellCheck batch per AGENTS.md (clean — shellcheck is present in my environment, so that is a real pass); npm run lint:md → 0 issues; gh pr checks 510 all passing.

Not run: install.ps1 behaviour — pwsh is absent here, so the two PowerShell tests skip and my reading of that file is a trace, not an execution. ./scripts/verify-msb-pin and ./scripts/verify-backends need a sandbox-capable host. The real-Windows smoke test remains author-reported from my side.

One correction to my own evidence, since it affects how you read the table: my first pass showed <empty output> as accepted. That was my harness defaulting (${STUB_V:-0.7.3}), not the code. Retested with a genuinely empty stub and both sides reject with error: acq could not determine a supported final msb version.

Verdict: request changes — item 1 only. It is a one-line fix in each POSIX path (capture the status, or drop the || true and test it), and everything else in this round is ready.

Assisted-by: OpenCode [gpt_5_5_default_v2] change-review

@mogul

mogul commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

@basiliskus — addressed the latest finding and the below-the-bar PowerShell test note in 72b44ff.

Fixed:

  • acq.backends/msb.sh now treats a non-zero msb --version as no supported version, even if stdout contains msb 0.7.3. The guard fails closed with the existing actionable diagnostic instead of dispatching state-touching verbs.
  • install.sh now does the same for msb_version_of, so the installer no longer blesses a binary whose version probe printed a valid-looking banner but exited non-zero.
  • Added runtime regression coverage via STUB_MSB_VERSION_RC=42.
  • Added installer regression coverage for an existing msb that prints 0.7.3 and exits 42; it is replaced with the pinned 0.6.18 path.
  • Added the below-the-bar PowerShell assertion: the AST-extracted Get-MsbVersion test now asserts a failed probe parses empty (failed-probe-parsed=[]) when pwsh is available.

Verification:

  • ./scripts/test-acq-bats test/bats/70-msb-backend.bats test/bats/05-install.bats test/bats/06-windows-install.bats: 67/67 pass; expected pwsh not available skips.
  • bash -n acq.backends/msb.sh install.sh scripts/test-acq-lib.sh test/bats/70-msb-backend.bats test/bats/05-install.bats test/bats/06-windows-install.bats: pass.
  • shellcheck --severity=warning acq.backends/msb.sh install.sh scripts/test-acq-lib.sh test/bats/70-msb-backend.bats test/bats/05-install.bats test/bats/06-windows-install.bats: pass.
  • git diff --check: pass.
  • npm run lint: pass.
  • Direct smoke with a stub msb --version printing msb 0.7.3 then exiting 42: ACQ_BACKEND=msb ACQ_SKIP_MSB_DOCTOR=1 ./acq ls exits 1 with error: acq could not determine a supported final msb version.
  • Full ./scripts/test-acq-bats: attempted twice; no failures observed, but this environment timed out before completion. Serial reached 566/568 before the 300s tool timeout; default runner reached 498/568 before the same timeout. The remaining tail batch test/bats/76-msb-dns.bats test/bats/77-usai-rotate.bats test/bats/78-msb-kit-env.bats test/bats/80-kit-subcommands.bats test/bats/85-kit-provenance.bats test/bats/90-sbx-startup-kit.bats test/bats/95-kit-ref-persistence.bats passed 75/75.

Also ran an adversarial sub-agent review before push. It found only the missing Bats-level PowerShell output assertion, which is now fixed.

Note: the commit is intentionally unsigned because this worktree has SSH commit signing enabled, but the signing agent socket is unavailable (Couldn't get agent socket?).

Assisted-by: OpenCode [gpt_5_5_default_v2]

…-0-7-0-0-7-2-2

# Conflicts:
#	docs/BACKEND_GUIDE.md
#	docs/KNOWN_FAILURE_MODES.md
@mogul

mogul commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Merged origin/main into the PR branch and resolved the conflicts in 346993d.

Conflict resolution:

  • docs/BACKEND_GUIDE.md: conflict was only frontmatter last_updated; kept 2026-09-30 from origin/main while preserving the PR’s related-file additions/content.
  • docs/KNOWN_FAILURE_MODES.md: conflict was only frontmatter last_updated; kept 2026-09-30.
  • Follow-up from adversarial review: origin/main had added a new §42 for host-port publishing, while this PR also had an msb version-policy §42. Renumbered the msb version-policy section to §43 and updated all durable references to it.

Verification:

  • git diff --name-only --diff-filter=U: no unmerged paths.
  • git diff --check: pass.
  • bash -n acq acq.backends/common.sh acq.backends/kit-translate.sh acq.backends/msb.sh acq.backends/prompt.sh scripts/test-acq-lib.sh test/bats/30-dispatch-routing.bats test/bats/105-ports-background.bats test/bats/112-msb-ports.bats test/bats/113-host-port-override.bats test/bats/135-configure.bats: pass.
  • ShellCheck batch from AGENTS.md across acq, backend scripts, test runner, and Bats files: pass.
  • npm run lint: pass, 0 markdown issues.
  • Targeted Bats over the PR and merged-main touchpoints: ./scripts/test-acq-bats test/bats/05-install.bats test/bats/06-windows-install.bats test/bats/70-msb-backend.bats test/bats/30-dispatch-routing.bats test/bats/105-ports-background.bats test/bats/112-msb-ports.bats test/bats/113-host-port-override.bats test/bats/135-configure.bats: 180/180 pass, expected pwsh not available skips.

Note: the merge commit is intentionally unsigned because this worktree still has SSH commit signing enabled but the signing-agent socket is unavailable (Couldn't get agent socket?).

Assisted-by: OpenCode [gpt_5_5_default_v2]

@basiliskus basiliskus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 346993d7b0f49754036c7060bda934a5810421ba.

The finding is fixed, and I reproduced the exact case rather than reading the patch. Clearing my CHANGES_REQUESTED.

stub prints "msb 0.7.3" then exits 42:
  at 73b919b:  acq ls -> exit=0, output "sb1"        (accepted)
  at 346993d:  acq ls -> exit=1, "error: acq could not determine a
                         supported final msb version."
same stub exiting 0:
  at 346993d:  acq ls -> exit=0, output "sb1"        (still accepted)

The fix is the right shape in both places — capture the status and bail rather than papering over it with || true:

# acq.backends/msb.sh
if ! raw=$(_acq_msb_cli --version 2>/dev/null); then
  return 0
fi
# install.sh
_raw=$("$1" --version 2>/dev/null) || return 0

Returning empty on a failed probe routes into the existing unparseable path, so the diagnostic is the one users already get rather than a new message. And POSIX now matches install.ps1, which already rejected non-zero $LASTEXITCODE — the three implementations agree again, which was the point.

Full agreement matrix re-measured at this head, installer parser against live acq ls:

msb --version output         install.sh            acq runtime
msb 0.7.3        (rc=0)      accept 0.7.3          accept
msb 0.7.3        (rc=42)     reject(unparseable)   reject(exit 1)
msb 0.7.3-rc1                reject(non-final)     reject(exit 1)
msb 0.7.3+build.1            reject(non-final)     reject(exit 1)
msb 0.7.4                    accept 0.7.4          accept
msb 0.7.3 linux-arm64        accept 0.7.3          accept
msb dev-build                reject(unparseable)   reject(exit 1)
<empty>                      reject(unparseable)   reject(exit 1)
msb 0.6.9                    accept 0.6.9          accept

Nine rows, no disagreement in either direction. The platform-suffix row still resolves to final 0.7.3, so widening the regex to capture [-+] has not regressed into a false rejection.

The new tests are not vacuous. Copied onto the prior head 73b919b and run there:

not ok  msb: failed version probe fails closed even with parseable output
not ok  install: failed active msb version probe is replaced with the pinned version
ok (skipped)  windows PowerShell: msb version parser preserves suffix policy  # pwsh not available

Both executable assertions fail without the fix. The installer test is the better of the two — it asserts the consequence (Active msb is 0.6.18, the pinned binary actually in place) rather than just an error string, so it would survive a reworded diagnostic.

You also took the below-the-bar note: test/bats/06-windows-install.bats now asserts the AST-extracted Get-MsbVersion returns empty for a non-zero probe, so the PowerShell and POSIX halves are pinned to the same policy in both directions. It still skips here — pwsh is absent — so that assertion is unexecuted by me and will only ever run where pwsh exists.

The §42 collision was caught and resolved correctly. main added a host-port-publishing §42 while this branch had an msb version-policy §42. Renumbered to §43, and I checked every durable reference agrees: docs/adr/0032:339, docs/BACKEND_GUIDE.md:170, docs/howto/acq.md:326, docs/howto/msb.md:97 all say §43, and KNOWN_FAILURE_MODES.md:2380 is the §43 heading. No stragglers — which is the failure mode that bit this PR back in round 2, so worth noting it did not recur.

Validation. Ran at 346993d: full suite ./scripts/test-acq-bats → 622 ok / 0 not ok / 6 skips (3 pwsh not available, 3 sbx not on PATH — grammar acceptance UNVERIFIED, 1 msb authorize); the nine-row matrix above through live acq ls; the prior-head comparison for the two new tests; bash -n over acq, the adapters, install.sh, scripts/verify-msb-pin; the repo ShellCheck batch per AGENTS.md (clean, and shellcheck is genuinely installed in my environment so that is a real pass); npm run lint:md → 52 files, 0 issues; git diff --check clean; gh pr checks 510 → all 7 pass.

Not independently verified, and worth stating plainly on a PR whose whole subject is refusing bad installs: install.ps1 behaviour is a code trace only, since pwsh is absent here and its three tests skip. ./scripts/verify-msb-pin and ./scripts/verify-backends need a sandbox-capable host. The real-Windows smoke test remains your report, not my observation. Everything I ran was stubbed — no real msb binary was involved at any point, so the live recovery path through an actually-migrated catalog is unexercised by me.

Four rounds is a lot for one PR, and each round's finding was real and distinct — unpinned install, a mute failure, an installer/runtime suffix split, and now an exit-status split. The version-policy surface turned out to have more edges than it looks like from the outside, and the matrix above is the artifact I would keep.

Verdict: approve.

Assisted-by: OpenCode [gpt_5_5_default_v2] change-review

@basiliskus
basiliskus merged commit 3634046 into main Oct 3, 2026
7 checks passed
@basiliskus
basiliskus deleted the fix/msb-version-block-0-7-0-0-7-2 branch October 3, 2026 14:17
mogul added a commit that referenced this pull request Oct 6, 2026
🤖 I have created a release *beep* *boop*
---


##
[4.0.0](v3.1.0...v4.0.0)
(2026-10-06)


### ⚠ BREAKING CHANGES

* **kits:** acq no longer installs podman/OCI tooling by default. Users who need docker, docker compose, or rootless podman when the base image doesn't provide it must opt into the oci-engine kit with `acq configure`, `--kit`, or `ACQ_EXTRA_KITS`.
* **kits:** acq no longer performs native OpenCode npm fallback installation. OpenCode is supplied by the pinned opencode agent kit from agentic-coding-patterns v1.12.0, currently delivering OpenCode v2.0.22. If the kit is unavailable or invalid, acq fails closed instead of installing opencode-ai from npm.
* **kits:** existing acq sandboxes with adapter-installed podman are not migrated in place. Recreate affected sandboxes or add the oci-engine kit before relying on OCI behavior after stop/start.

### Features

* **acq:** choose the host port per sandbox, and let --publish pin it
([#526](#526))
([410f332](410f332))
* **acq:** export ACQ_WORKSPACE and ACQ_CLONE into the guest on both
backends
([#457](#457))
([e0b9a3b](e0b9a3b))
* **acq:** interactive `acq configure` UI for extra kits + token-scoping
defaults
([#500](#500))
([13d9d3a](13d9d3a))
* **kits:** adopt pinned agent and capability kits
([#474](#474))
([6a67063](6a67063))
* **secrets:** add Windows-native DPAPI secret backend
([#464](#464))
([907f404](907f404))
* **windows:** add acq preview install path
([#463](#463))
([d6d76f4](d6d76f4))


### Bug Fixes

* **acq:** use sandbox workspace for github scoping
([#476](#476))
([1610835](1610835))
* **ci:** bump js-yaml to 4.3.2 to clear a high-severity CPU-DoS
advisory
([#454](#454))
([3217b49](3217b49))
* **ci:** bump smol-toml to 1.7.1 to clear an infinite-loop DoS advisory
([#459](#459))
([c2291bf](c2291bf))
* **ci:** centralize markdownlint coverage
([#548](#548))
([a27c31e](a27c31e))
* **ci:** pin markdownlint-cli2 once, clear GHSA-253c, unify file
selection
([#527](#527))
([e712685](e712685))
* **ci:** unblock unrelated PRs from the bash32 required-check deadlock
([#513](#513))
([187aa57](187aa57))
* **install:** keep curl | sh stdin intact and tap msb formula first
([#462](#462))
([c808d74](c808d74))
* **install:** trust the microsandbox-acq dependency before brew install
([#534](#534))
([ffac0fa](ffac0fa))
* **kits:** land collapsed kit stack implementation
([#549](#549))
([bcd5b9c](bcd5b9c))
* **msb:** allow Playwright browser downloads
([#477](#477))
([0c399c5](0c399c5))
* **msb:** block unsafe 0.7 migration versions
([#510](#510))
([3634046](3634046))
* **msb:** carry every origin URL, keep the scratch private, fail closed
on a copy miss
([#488](#488))
([babcd49](babcd49))
* **msb:** carry the source checkout's git identity into the --clone
scratch
([#443](#443))
([320d784](320d784))
* **msb:** follow the host resolvers and disable DNS rebind protection
in the guest
([#444](#444))
([d2f4bdc](d2f4bdc))
* **msb:** point the --clone scratch's origin at the source checkout's
remote
([#455](#455))
([7f48b94](7f48b94))
* **msb:** refresh ssh authorization for port publish
([#446](#446))
([72a6bd5](72a6bd5))
* **msb:** session parity for exec/attach/shell (cwd, terminal identity,
login shell)
([#427](#427))
([6261eda](6261eda))
* **msb:** skip the recursive home chown when the image baked the agent
user
([#432](#432))
([d8f66a4](d8f66a4))
* **msb:** thread the merged kit env onto kit lifecycle commands
([#519](#519))
([2248a92](2248a92))
* **msb:** translate kit wildcard network rules
([#451](#451))
([e22bbc3](e22bbc3))
* **msb:** verify post-hoc port forward listener
([#530](#530))
([8ca50e1](8ca50e1))
* **msb:** warn on broadened kit wildcard rules
([#460](#460))
([6ded3ce](6ded3ce))
* **msb:** warn when ssh-agent route is missing
([#448](#448))
([3a767a4](3a767a4))
* **sbx:** emit valid setup.install shape for the git-identity mixin
([#492](#492))
([8e6160c](8e6160c))
* **sbx:** raise the version floor to 0.39.0, where sbx create --env
first exists
([#491](#491))
([30f9ecf](30f9ecf))
* **sbx:** wait for startup barrier before attach
([#512](#512))
([33c83c5](33c83c5))
* **secret:** reject multiline test secret values
([#447](#447))
([f2da950](f2da950))
* **secrets:** propagate rotation across backends
([#509](#509))
([9641958](9641958))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Bret Mogilefsky <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants