Skip to content

fix(windows): port acq provider-input path to Windows/MSYS - #644

Draft
mogul wants to merge 52 commits into
mainfrom
fix/acq-windows-portability
Draft

mogul wants to merge 52 commits into
mainfrom
fix/acq-windows-portability

Conversation

@mogul

@mogul mogul commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes five Windows/MSYS portability bugs in the provider-input and kit-fetch paths that surfaced while running acq --backend msb under Git-Bash (MSYS2) on Windows against the provider-neutral branch. Without these, acq create fails partway through provisioning.

Fixes

  • process/path helpers (common.sh):
    • ps -o ppid= / ps -o lstart= are rejected by MSYS ps (no -o flag), so the guardian's transaction-PID check and the lock's process-identity aborted. Added MSYS-aware fallbacks (parse ps -p columns / WINPID).
    • os.O_DIRECTORY / os.O_NOFOLLOW / dir_fd are missing from Windows Python, so the safe-dir traversal helpers raised AttributeError. Guarded with hasattr and fell back to os.path.islink / os.makedirs.
    • MSYS2's argv conversion rewrites /c/... → C:/..., which the manual /-split turned into a bogus /C: component. Switched to drive-aware os.path / os.makedirs.
  • anonymous kit fetch (kit-translate.sh): an empty insteadOf value makes git treat the empty string as a match prefix and prepend https://github.com/ to every URL (fetch 404s on https://github.com/https://github.com/...). Replaced with an identity rewrite (insteadOf=https://github.com/), a no-op that still shadows a user's url.<x>.insteadOf=https://github.com/ rewrite for the anonymous attempt.
  • kit cache slug (msb.sh): the cache subdir was the full sanitized kit ref (URL + sha + dir), which exceeds Windows MAX_PATH once git materializes .git/objects/pack/* under it (Filename too long on fetch). Keyed by a hash of the ref instead.

Verification

Ran acq --backend msb create ... live against the branch and iterated past each failure in order: msb detection → provider-credential/secret → agent-kit selection → provider-input staging → kit fetch. bash -n clean on all three files.

Not fixed here (separate issue)

The pinned patterns validator requires the normalizer's executable bit (normalizer_path.stat().st_mode & 0o111), which Windows never reports: Windows Python's os.stat has no exec bits, and neither os.chmod nor a chmod +x round-trips one. That check needs a Windows-aware guard in agentic-coding-patterns (scripts/validate_provider_input_contract.py).

Related

Test 151 now calls the shared scripts/scan-heredoc-subst.py instead of a second copy, so the here-document-in-substitution check lives in one place.

Closes #665

AI-assisted (OpenCode). Human review required.

mogul and others added 27 commits October 24, 2026 20:50
Add a non-mutating source seam for production verifier functions, generic provider-facts regression coverage, and guarded live SBX/MSB validation procedures that record PASS, FAIL, or BLOCKED honestly.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
On macOS the host-config dir often resolves through the /var ->
/private/var alias (e.g. when HOME or XDG_STATE_HOME sits under
/var/folders/...). msb cannot mount a symlinked host path and fails
with "mount ...: Not a directory (os error 20)".

The workspace mount already calls canonicalize_path for the same
reason (diagnosed at msb.sh lines 3574-3579); apply the identical
guard to the host-config dir mount source.

Falls back to the original path when canonicalize_path is unavailable.
The dir is acq-owned (not user-supplied) so there is no
arbitrary-symlink-traversal risk.

Closes #594

Tests: 71c-msb-canonical-host-config.bats covers the Darwin /var alias
simulation (case a) and an arbitrary symlink negative-control (case b).

Co-authored-by: OpenCode [claude_4_6_sonnet] <bret.amogilefsky@gsa.gov>
Pass the provider generation through SBX workspace mount syntax after the agent token so it is not resolved as a kit path.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Verify the provider root environment token independently of trailing kit and workspace arguments in the consolidated adoption branch.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Mount SBX provider inputs after the primary workspace, require trusted canonical MSB host-config paths, replace obsolete provider-owned config assertions, and preserve durable live failure evidence.

Closes #594
Closes #629

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Record observed SBX/MSB results without overstating unexecuted rows and add an actionable opencode models check for the next live run.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Preserve observed SBX and MSB startup, config-generation, model-listing, and verifier results while keeping unexecuted destructive/negative rows explicitly blocked.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Keep the focused MSB signal regression self-contained until the trusted macOS canonicalizer lands on main.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Install the adapter projection boundary after sourcing the MSB adapter while preserving the narrow canonicalizer contract.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Preserve SBX positional grammar by placing the provider generation mount after the agent and primary workspace.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Update SBX create assertions for the corrected positional mount order.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Reflect provider mount placement and current interactive environment tokens.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Record post-fix SBX and MSB live validation totals and model-list evidence.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Record the observed interactive MSB run, USAi model listing, and successful model response.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Record successful interactive USAi model requests on both SBX and MSB without retaining secret material.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Convert malformed provider-input JSON into an actionable acq diagnostic before schema validation.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Record observed fail-closed malformed provider input rejection on SBX and MSB.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Allow an authoritative empty provider set without changing the default USAi selection.

Co-authored-by: OpenCode [gpt-5.6-terra] <bret.mogilefsky@gsa.gov>
Use Bash 3.2-safe empty-array expansion when no provider kits are selected.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Record successful no-provider SBX and MSB validation evidence.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Silence dynamic-global ShellCheck checks and tolerate current interactive environment tokens.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Use a per-test sandbox, lock, and PID path so parallel workers cannot share signal lifecycle state.

Co-authored-by: OpenCode Agent <agent@gsa.gov>
Replace guest-writable extra-kit markers with host-authoritative state for SBX create and heal paths.

Refs: #636

Co-authored-by: OpenCode [gpt-5.6-terra] <bret.mogilefsky@gsa.gov>
…ntity

Replace the bare `opencode models | grep -q "^usai/"` with a comparison of the
projected snapshot's model IDs against both the rendered OpenCode config's model
keys for the provider and the `opencode models` output. Missing or unexpected IDs
fail and print the difference. Offline bats cases cover exact match, stale,
missing, extra and empty-snapshot inputs plus the guest collection script.
Not yet exercised on a live SBX or MSB host.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
…ending row

Replace the unspecified temporary override and BLOCKED instruction with the
supported flag and reproducible omission assertions (no USAi kit or credential
gate, no usai projection, no usai models). Record the new identity-bound model
check as PENDING: it has not been run on a live SBX or MSB host.

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
…rocedure notes

Co-authored-by: OpenCode Agent <bret.mogilefsky@gsa.gov>
@mogul
mogul force-pushed the fix/acq-windows-portability branch from 7c07a31 to 5c80304 Compare October 28, 2026 20:29
@mogul

mogul commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖: AI agent (First Mate), posting on behalf of @mogul. Branch update for the Windows re-run: PR 642 was merged into feat/601-provider-neutral-adoption as a squash commit (4daf57f1), and this PR's branch has been rebased onto it. The head is now 5c80304e; the SHA 7c07a312 named in my previous comment is no longer on the branch, so git checkout 7c07a312 will fail on a fresh fetch.

Please run the re-run steps from my previous comment against 5c80304e instead (same commands, replace the SHA, and report the full git rev-parse HEAD). The junction fix (walk the literal path instead of an MSYS-resolved one) is now commit 07e43c11 on this branch.

@mogul

mogul commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

Windows re-run on the junction-fix head + the earlier follow-ups, combined.

Re-run: windows: tests on the junction fix

Tested SHA: 5c80304e8b5a90fa3d9a472660f53949fe0476e5

# result test
1 ok windows: safe_mkdir succeeds on a real path
2 ok windows: safe_mkdir rejects an ancestor junction
3 ok windows: safe_dir rejects an ancestor junction
4 ok # skip windows: safe_mkdir rejects an ancestor symlink — directory symlink creation unavailable (developer mode / elevation required)
5 ok # skip windows: safe_dir rejects an ancestor symlink — directory symlink creation unavailable (developer mode / elevation required)

The safe_mkdir ancestor-junction test now passes. 3 passed, 0 failed, 2 skipped (symlink-only). Test 2 asserts both assert_failure and [ ! -e "$target/new_child" ], so the projection is rejected and not redirected into the junction target.

The MSYS2_ARG_CONV_EXCL/MSYS_NO_PATHCONV opt-out is honored on this host — cross-checked directly:

MSYS2_ARG_CONV_EXCL='*' MSYS_NO_PATHCONV=1 python3 -c 'import sys;print(sys.argv[1])' /tmp/diag-jt/link/new_child
→ /tmp/diag-jt/link/new_child            # link still present (unrewritten)

# control, no env vars:
python3 -c 'import sys;print(sys.argv[1])' /tmp/diag-jt/link/new_child
→ C:/Users/bmogi/AppData/Local/Temp/diag-jt/target/new_child   # junction resolved

So the literal-path walk fix works as intended on real Git Bash.

Earlier follow-ups (from the previous run)

Developer Mode: BLOCKED on this host. The session is non-elevated — reg add HKLM\...\AppModelUnlock returns ERROR: Access is denied, and the token has no SeCreateSymbolicLinkPrivilege. mklink /D fails with "You do not have sufficient privilege to perform this operation", while mklink /J succeeds. So the two directory-symlink tests (4/5) can only skip here; they need an elevated shell or a Dev-Mode-enabled runner. (Also note: MSYS=winsymlinks:nativestrict breaks bats-core itself — it can't create its test-source symlink and runs 0 of 5 tests — so that specific repro can't be run here regardless.)

Baseline on main (worktree ec73abd, submodules pinned):

  • 112 msb ports: LIST mode surfaces … — not ok on main too (byte-identical TAP), so pre-existing. Root-caused: native Windows jq (WinGet) writes CRLF, and the digit guard in _acq_msb_ports_from_inspect drops every record with a trailing \r (all but the last create-time port). Reproducible A/B (stripping \r makes it pass). Separate Windows-portability defect, not this PR.
  • 117 msys: ssh authorize --file … — passes on both branches; no hang in isolation (~31 s). The earlier "hang" was a contention/timeout artifact. (Full 117: PR is 11/11; main is 9/11 — this PR fixes two of those.)

146 / 148 / 149 run individually (ACQ_BATS_JOBS=1, TMPDIR=/c/tmp):

file ok not ok skip
146-provider-verification 28 0 0
148-provider-credential-bindings 56 0 0
149-kit-source 38 0 0

All green. The earlier fork-exhaustion / loader errors were contention/timeout artifacts — no fork: Resource temporarily unavailable or /src/output.bash error in any serial run, and git submodule status is healthy (all three vendors pinned/populated).

Bottom line: the junction bypass is fixed and confirmed on a real Windows host; the only windows: tests still not exercisable here are the two directory-symlink ones (need elevation/Developer Mode, which this host can't provide).

@mogul

mogul commented Oct 28, 2026

Copy link
Copy Markdown
Contributor Author

🤖: AI agent (First Mate), posting on behalf of @mogul. Thank you; this is exactly the evidence needed.

  • Junction fix confirmed on a real Windows host at 5c80304e: the safe_mkdir ancestor-junction test now passes, the conversion opt-out is honoured, and the two directory-symlink tests remain skip because the host cannot elevate. Those two are the only windows: tests without real-host evidence; they need an elevated shell or a Developer-Mode runner.
  • Baseline on main: the 112 failure is pre-existing (native Windows jq emits CRLF and the digit guard in _acq_msb_ports_from_inspect drops those records). Filed separately as fix(msb): acq ports drops all but the last port when jq emits CRLF (Windows) #648, out of scope for this PR.
  • 117 hang, 146/148/149 fork and loader errors: contention or timeout artifacts, not regressions; 117 is 11/11 on this branch against 9/11 on main, and 146, 148 and 149 are green when run individually.
  • Developer Mode: noted as BLOCKED on that host. No further Windows runs are needed from you for this head; I will ask again only if the Windows code changes.

Base automatically changed from feat/601-provider-neutral-adoption to main October 28, 2026 22:48
mogul added 3 commits October 28, 2026 22:51
…bility

# Conflicts:
#	acq.backends/common.sh
#	acq.backends/msb.sh
#	acq.backends/sbx.sh
#	test/bats/149-kit-source.bats
#	test/bats/50-kit-list-completeness.bats
Bash 3.2 does not understand a here-document inside a process substitution; it
scans the body for quote and parenthesis balance. The Python comment added for
the CRLF fix contained an apostrophe and a backtick, so the whole of
common.sh failed to parse under bash 3.2 and every facts reader test failed
there. Move the explanation to the shell side above the function and keep the
heredoc body free of apostrophes, backticks, odd double quotes, and unbalanced
parentheses.

Add a static scan, run as a bats test under whatever bash executes the suite,
that fails when a heredoc nested in a command or process substitution has such
a body anywhere in acq, acq.backends, or scripts, and a test that the facts
reader output carries no carriage return.

@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.

Verdict: request changes — 077677fab1e4c6438ab77c1d0796fb150fb274f2

The earlier descriptor-read, inherited-git-rewrite, and ancestor-junction findings are resolved. One Windows blocker remains:

  • Major — acq.backends/common.sh:4609: provider-input publication still uses ln -s for the current pointer. On the real Windows run, Git Bash’s symlink fallback caused the readlink "$root/current" tests to fail. Therefore a successful backend create can still fail during publication or leave current as the wrong object type on a non-elevated, non-Developer-Mode host. Please use a reliable Windows-safe current-pointer mechanism or fail before creating the backend, and add Windows publication coverage.

CI’s Bash 3.2 job is still in progress; elevated Windows directory-symlink tests also remain unverified.

Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review

Publication used `ln -s` for the `current` pointer, which Git Bash
silently degrades to a directory copy on a non-elevated, non-Developer-
Mode Windows host — leaving `current` as the wrong object type and
breaking `readlink`-based checks. Use a junction (`mklink /J`, which
needs no elevation) on Windows instead, and remove the old pointer before
swapping since Windows cannot atomically replace an existing junction.
Add Windows publication coverage that asserts `current` is a junction and
that both generations survive repeated publication.
@mogul

mogul commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the remaining Windows blocker (review on 077677fab).

_acq_provider_input_publish no longer uses ln -s for the current pointer. On Windows/MSYS it now creates a junction (mklink /J, which needs no elevation) and swaps it into place — Windows cannot atomically replace an existing junction, so the old pointer is removed first (the provider-input lock serializes creates, and prior generations stay intact). POSIX keeps the relative ln -s + atomic os.replace path unchanged.

Added windows: publication makes current a junction and retains generations, verified on a real Windows host (Git Bash, no elevation): current is a junction, resolves to the published generation, and both generations survive repeated publication.

Still unverifiable here (unchanged): the two elevated/Developer-Mode directory-symlink tests, which need a Developer-Mode or elevated runner.

@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.

Verdict: request changes — 388d2aa34eb00610f79d34299890b7eaf9e03b49

The previous Windows publication finding is resolved: current is now created as a junction, and the real-Windows success-path evidence covers repeated publication. One failure-path regression remains:

  • Major — acq.backends/common.sh:4651-4652: the Windows path removes the existing current junction before os.replace, but does not restore it if replacement fails. I reproduced the failure through the helper seam: publication returned nonzero and left current absent while both generations remained intact. Please preserve or restore the previous pointer on swap failure and add an MSYS-branch regression corresponding to the existing “failed atomic swap preserves prior current generation” test.

Bash 3.2 CI is still in progress; elevated Windows directory-symlink tests also remain unverified.

Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review

…swap

The Windows swap moved the old `current` junction aside before the
`os.replace`, but did not restore it if the replace failed — leaving
`current` absent even though both generations stayed intact. Back the old
pointer up and restore it on failure, and add an MSYS-branch regression
test for the failed-swap case.
@mogul

mogul commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the failed-swap regression (review on 388d2aa).

The Windows swap now backs up the existing current junction before os.replace and restores it on failure, so current is never left absent. Added windows: failed swap restores the prior current junction, verified on a real Windows host (Git Bash, no elevation): stubbing the atomic replace to fail still leaves current pointing at the old generation, with both generations intact. All seven windows: tests pass.

basiliskus
basiliskus previously approved these changes Oct 29, 2026

@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.

Verdict: approve — 2b6f62ccc4fc13aa8a415e5f1e426ce27c269b89

The prior Windows failed-swap finding is resolved. The implementation backs up the existing current junction before replacement and restores it on failure; the new real-Windows regression verifies that current still points to the old generation and both generations remain intact. I found no new correctness, security, scope, test-adequacy, or regression issues.

Assisted-by: OpenCode [gpt-5.6-sol-latest-guardrails-defaultv2] skill:change-review

@wz-gsa wz-gsa 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.

Fixed-head rereview of 2b6f62ccc4fc13aa8a415e5f1e426ce27c269b89 (full eight-file diff inspected; required checks pass). The revised Windows path helpers reject pre-planted junctions, but publication still has a failure-path blocker: acq.backends/common.sh:4665-4673 moves current to backup, attempts the new pointer, then ignores _acq_provider_input_move_link "$backup" "$current" failure before deleting temp and returning. If restore fails, current stays absent despite the new comment promising otherwise; the Windows test at test/bats/147-provider-input-projection.bats:1125-1144 injects only the initial swap failure. Check restore status, preserve/report the backup on failure, and add a restore-failure negative fixture. Also, the successful Windows two-rename swap necessarily has a period with no current name; readers in common.sh use that path without the transaction lock. Either make reader behavior explicitly tolerate this gap (with tests) or stop claiming the pointer is never absent. Further trust-boundary concern: common.sh:4575-4578,4653-4655 uses pathname checks then recursive chmod/rm on paths that may be swapped for reparse points; the portable lstat tests cover pre-planted links only. Establish a private non-attacker-writable parent or use handle-bound operations and add a swap test before presenting this as a race-safe Windows boundary. No PR code was executed on the host. AI-assisted adversarial review (OpenCode); rereview a corrected head.

@mogul

mogul commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

🤖: AI agent (OpenCode), posting on behalf of @mogul. Dependency note: this PR should land after #667.

@mogul

mogul commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

🤖: AI agent (OpenCode), posting on behalf of @mogul. Correction to my earlier dependency note on this PR. This PR still waits on #667, but #667 is not yet ready to merge: basiliskus requested changes on 6ea1758e (a short-name 8.3 path comparison in provider_input_pathguard.py _vet), and a fix is in progress. Order of operations is unchanged: #667 lands first, then this PR gets a merge of main (resolving the safe_* conflicts in favor of #667) and the held follow-up commits are pushed. Nothing here is pushed or changed in the meantime.

@mogul
mogul marked this pull request as draft November 4, 2026 22:43
@mogul

mogul commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Note: I'm holding off on chasing down those Windows-related bash-ism PRs because I would rather get the 🐍 version upstreamed and not deal with them. I've made that explicit by putting them back in the draft state. I'll keep them open until I determine that the 🐍 version will be arriving soon, so we're sure don't need to do this specific fix merged as an interim state.

mogul added 2 commits November 7, 2026 00:26
Point 151-heredoc-in-substitution at scripts/scan-heredoc-subst.py instead of the duplicate test/bats/heredoc-scan.py, and remove that duplicate. The canonical scanner was introduced by the bash 3.2 test runner (#653); this deconflicts the two in-flight heredoc scanners.

Co-authored-by: OpenCode [deepseek-v4-pro] <bmogilefsky@gmail.com>
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.

test(bash32): unify the two heredoc-in-substitution scanners after #644 and #653 merge

3 participants