Support Windows natively and prove macOS on real hosts - #10
Merged
Conversation
Host facts were scattered: install hints branched on the platform inside deps.ts, the toolchain row filter and the bubblewrap predicate each read the platform again, and every new host would have had to touch all of them. Put one module per OS behind a HostOs interface and let index.ts hold the engine's only process.platform read, so a host is described in one place and platform normalization stays injectable for tests. Behaviour on Linux and macOS is unchanged. windows.ts is authored here but unreachable: the admission gate still rejects win32.
Windows behaviour could not be proven before, because every suite read the running host. These suites pass an explicit id to hostOs, so a Linux or macOS run proves the Windows facts too. A completeness guard fails when a HostOs member gains no assertion, so the contract cannot grow silently.
DESIGN.md promised a fallback chain that the code never had: skillsSync called symlinkSync and gave up when it threw, so a Windows host without developer mode installed no universal skill at all. Try each directory-link mechanism the host declares, then copy. A copy is marked so a later sync can heal it back into a real link and so prune still reclaims it, and the skills health view accepts a copied directory instead of reporting it missing. The host module supplies only the order; the runtime decides which mechanism works, because privilege and filesystem support cannot be predicted.
The seam described Windows but nothing consumed it, so the engine still assumed a POSIX host: it probed PATH for bare names with the executable bit, spawned a resolved path directly, bootstrapped Bun with the shell installer, and wrote POSIX profile lines and shell commands. Route executable resolution, invocation, Bun discovery and bootstrap, install-path detection, profile writes, and the Claude statusline, notification, and failure-hook command shapes through HostOs. Windows needs both halves together: a discovered .cmd is not executable on its own, so the host pairs its PATH candidate suffixes with a cmd.exe invocation. POSIX output stays byte-identical, which the runtime smoke and the goldens both still prove.
Native Windows presents the PowerShell tool where Linux and macOS present Bash, so every deployed Bash permission rule had no PowerShell counterpart and Codex had no Windows sandbox setting at all. Mirror each Bash rule with the PowerShell form and add the Codex [windows] table. Existing rules are untouched: a host may present either tool, so the PowerShell rules are added beside the Bash rules rather than replacing them.
The golden harness reached the CLI by composing a bash -c string and planted its stubs as #!/bin/bash scripts, so every golden case and the launcher suite required a POSIX shell. On a native Windows runner nothing would have spawned. Spawn argv directly, plant stubs the host can actually execute, pass one shared temporary file descriptor as both stdout and stderr so the 2>&1 merge order is reproduced without a shell, and normalize snapshot tree keys to forward slashes. Not one recorded golden moves. The stubs and the platform preload stay Linux-pinned on purpose: the golden snapshots are a Linux-canonical artifact, and pinning here keeps the recorded bytes independent of whoever runs the suite.
Every layer below the gate now speaks Windows, so open it: the engine admits win32, npm declares it, and the launcher and installer gain PowerShell twins. The launcher performs version-matched binary selection and Bun bootstrap before any TypeScript runs, so a second copy of the host-to-artifact map and the generated Bun pin would have lived in four scripts. Give both one owner: targets.ts holds the map, the pin generator extends to the PowerShell twins, and a cross-script suite fails when any script drifts. Both installers invoke the downloaded Bun installer through an explicit interpreter, because a downloaded .ps1 carries a Mark-of-the-Web and calling it directly fails under the default execution policy with a misleading error.
CI ran on ubuntu-24.04 only. The darwin binaries were cross-compiled and never executed, so no job had ever run a single line of the macOS branches, and Windows would have shipped unproven. Split the workflow into a portable lane on ubuntu-24.04, macos-26, and windows-2025 that compiles the host-matching artifact and executes it, and a snapshot lane that keeps the Linux-canonical goldens and their prove-red checks on Ubuntu alone. macOS needed no port, only proof; the portable lane is that proof.
The release workflow shipped four binaries and every support contract said Linux and macOS, including the summary the docs command prints. Publish six binaries with matching checksums and correct every live claim in one pass, so no document or command output contradicts the code.
`docks-kit update` resolved and classified the global package home with host-dependent string work, so both halves misbehaved on Windows. Package homes were joined with a literal `/` while `npm root -g` and `bun pm -g ls` report Windows roots with backslashes, and the Bun classifier searched the joined path for `/.bun/`, which a real Windows Bun home never contains. The kit would then run `npm update -g` against a Bun install and report success while updating nothing. Join through the engine's `p()` rule and normalize separators only when the injected host is Windows: a backslash is a legal POSIX filename character and must never be read as a separator there.
The three-OS lane opened by the previous release exposed 37 failures in 19 files on `windows-2025` while macOS and Ubuntu stayed green. Every one was test-side host leakage, not a defect in the engine. Four causes, each fixed at its root: - Temporary paths and PATH lists were spelled with POSIX literals (`/tmp/...`, `:`); use `tmpdir()` and `delimiter`. - Fixtures overrode `HOME` alone, but `homedir()` follows `USERPROFILE` on Windows, so fixture homes were silently ignored. - Assertions compared rendered paths and commands against one host's spelling; they now derive the expectation from the host under test. - Suites that execute a Bash launcher, a `#!/bin/bash` stub, or a POSIX artifact are gated on that artifact being runnable here, and every skip states which artifact is missing. `docks-kit.ps1` gains the matching PowerShell suite so neither launcher is covered on one host only. `bunBootstrap`, `statusLineCommand`, and `materializeClaudeSettings` take the host as a parameter instead of reading the module, matching the injection pattern the rest of the engine already uses. Production behaviour is unchanged: every default resolves to the running host.
AGENTS.md said every kit-driven install is pinned, while `install.sh` has ended with `bun add -g docks-kit@latest` since before the pin rule existed and `install.ps1` now mirrors it. A reader had to guess which one was authoritative. State the exemption and why it is narrow: a global installer that pinned itself would install one fixed kit forever, and pinning it to `package.json` would request a version that is unpublished between the release-prep commit and the npm publish. The exemption covers `docks-kit` alone; the Bun installer both scripts download stays pinned to the manifest's verified version.
The `windows-2025` job went from 37 failures in 19 files to 14 in 5, all still test-side host leakage. Five causes: - The golden stub launchers were Bash scripts, unusable on Windows, so nothing recorded argv and four `pluginRefresh` assertions saw an empty log. Stub bodies are now JavaScript behind one runner, launched by a POSIX wrapper or a Windows `.cmd`. - `curl.exe` ships with Windows, so the Bun-unavailable migration cases took the installer branch and waited on a real download until the 5s timeout. Those cases now restrict PATH to the stub directory. - `bun.test.ts` asserted one host's installer argv; it now asserts both through an injected host. - Two fixtures overrode `HOME`/`USERPROFILE` on top of a `process.env` spread. Windows environment names are case-insensitive but keep their creation spelling, so an inherited `UserProfile` can survive beside an explicit `USERPROFILE` and the child reads the wrong one. `childEnv` drops every inherited key an override shadows, case-insensitively. - The new PowerShell launcher suite inherited the runner's real CPU instead of injecting the architecture under test. The stub host is now explicit rather than read from the recording machine. A Linux-preloaded child resolves extensionless names even when recording on Windows, so `hostOs()` in `makeStubDir` would have planted `.cmd`-only stubs for canonical children and broken every golden on the Windows runner. `makeStubDir` takes the same options object as the run helpers, records the host it planted for, and the run helpers refuse a mismatched pair with a named cause instead of failing as an unreproducible tool-missing branch on one runner.
The Windows launcher suite still read `<unknown>` for both architecture variables on `windows-2025`, so the case's values were not reaching the child through the spawn environment. The architecture belongs to the case under test, so each case now assigns the two variables inside the PowerShell session and forwards the launcher's own exit status; nothing about how a host merges an environment block can drop them. An exit-code assertion also carried no evidence: `expect(run.exitCode)` printed the number and discarded the engine's output, so four `pluginRefresh` and two `claudeMigration` failures named no cause. Every exit-code assertion in both suites now reports the run's output.
…r setting `syncConnectorEnv` appended the ENABLE_CLAUDEAI_MCP_SERVERS line straight to the host's profile file. Every POSIX candidate sits directly in the home directory, so the parent always existed; the Windows target is `Documents/PowerShell/Microsoft.PowerShell_profile.ps1`, which a fresh Windows home does not have, and `sync claude` aborted the whole run with ENOENT after the runtime, settings, and prune passes had already written. PowerShell requires that directory before a profile can exist, so the write site now creates it. The six `windows-2025` failures in `pluginRefresh` and `claudeMigration` are this defect and no other; on POSIX the call is a no-op against the existing home.
The Windows launcher fixture copies the interpreter running the suite as its fake `bun.exe`. On Windows that interpreter is Node, because the `vitest` bin shim is a `.cmd` that calls `node`, so the three source-fallback cases died on `Bun.argv` at the identifier. The fake source now reads `process.argv`, which both runtimes define identically for `<interpreter> main.ts <args>`.
The direct-Bun p95 ceiling of 100ms was calibrated on POSIX runners, where the measurement lands near 30ms. On `windows-2025` the same statusline program measured 123ms, because Windows process creation costs more than a fork/exec and the runner scans every spawn, so the gate reported the host rather than a regression. Windows now carries a 250ms ceiling and POSIX keeps 100ms. Both stay below the cost of one added external process, which is the regression this gate exists to catch: the statusline path must do no external work.
…e command The Windows runtime smoke failed with a CLIXML progress record on stderr: "Preparing modules for first use", emitted when resolving `Test-Path` auto-loads its module and a redirected host serializes the progress stream. This kit already fixed this exact defect while it supported Windows. The statusline redesign's fix round 1 recorded that the records recur across runs and prefixed the encoded guard with `$ProgressPreference = 'SilentlyContinue'` so the stored command is byte-clean itself (docs/plans/finished/2026-07-10-statusline-redesign.md:522,529); `feat!: drop Windows support` then removed the line with the host. Restoring the proven form rather than a new one keeps the command contract as CI last proved it. The failure-hook shape is unchanged: no evidence links it to this record.
A profile append reaches only PowerShell 7 sessions that load Documents/PowerShell/Microsoft.PowerShell_profile.ps1. cmd.exe, Windows PowerShell 5.1, and GUI-launched Claude Code never read it, and the kit's own PowerShell commands run -NoProfile. The finished Windows-support plan records this exact defect as audit row A6 and fixed it with setx, verified on a real Windows machine; the drop-Windows commit removed that code. HostOs now exposes environmentSetting, a tagged union naming HOW a host persists a user environment variable: POSIX returns the profile candidates, target, and export line; Windows returns a reg query probe and a setx apply. syncConnectorEnv switches on the mechanism, so no consumer branches on the host. POSIX behaviour and every POSIX log string are unchanged, and the recorded goldens are byte-identical. This also drops the mkdirSync that the previous commit added: with Windows on setx, a profile target always sits directly in the home directory.
The launcher rejected every non-POSIX host with "docks-kit supports only Linux and macOS on x64 or arm64", which now contradicts engine.ts and docks-kit.ps1. The Bash launcher stays POSIX-only by design, so the message states its own scope and points Windows users at docks-kit.ps1.
The golden-regression matrix is the acceptance signal, so every Windows question costs six jobs and a full suite chain. The probe workflow runs one vitest filter on one runner label, which is how a Windows-only failure gets diagnosed without re-running macOS, Ubuntu, lint, and both golden lanes. Dispatch inputs only exist once a workflow reaches the default branch, so the lane also triggers on probe/** pushes and stays usable from a feature branch. Inputs travel through env instead of the script body, and each host keeps the parent shell the gate uses.
A real setx writes HKCU and then broadcasts WM_SETTINGCHANGE, which blocks for seconds on a headless runner and mutates the machine outside the fixture home. The windows-2025 job showed it as five 5000ms timeouts, three of them in suites that never call sync - the stalled broadcast starved the whole parallel run. reg and setx now join the shared stub set, so any suite that plants stubs shadows them: reg reports the value as absent and setx records its argv and exits. POSIX takes the profile branch and never invokes either, so the recorded goldens are unchanged.
The lane failed at startup with no jobs created: job-level env may read github, needs, strategy, matrix, vars, secrets, and inputs, but not runner, and BUN_INSTALL_CACHE_DIR interpolated runner.temp there. This is exactly why parity.yml repeats the variable on every step rather than declaring it once per job. The inputs expressions stay at job level, where they are legal and evaluate to empty on a probe/** push, which selects the documented defaults.
actionlint runs shellcheck over every run body it believes is POSIX. The job's runs-on is an expression, so actionlint cannot infer the host, assumed bash for the Windows step, and read $env:PROBE_TESTS as $env followed by a literal - two SC2086 findings that failed the lint job. Naming pwsh and bash per step tells actionlint which bodies to shellcheck and matches the shells each runner already defaults to, so behaviour is unchanged.
Two suites kept timing out on windows-2025 after the environment-tool stubs cleared the stalled sync: commandsReporting and launcherWindows, both spawning the CLI as a child. Windows pays a heavier CreateProcess and a cold Bun transpile of the whole CLI, and the launcher's source fall-through case does that on purpose, so those cases sit near vitest's 5s default and cross it under runner load. They are load-sensitive, not broken - the single-runner probe lane passed the same files. Windows gets 15s of headroom; every other host keeps the strict 5s default so a hang there still surfaces in five seconds.
The work is multi-commit and carries a release step, so the repository rule requires a tracked plan rather than a session artifact. This is that record at its real state: steps 1 through 8 done, step 9 planned and gated on an owner decision, and Verification Results carrying the local acceptance output, the three-runner CI evidence, and the five Windows fix waves with the two production defects named. plan.mjs check passes all 13 validations.
The mirrored rules assumed the flags precede the target, so `Remove-Item C:\ -Recurse -Force` - PowerShell's idiomatic form, path first - matched no deny rule, and no rule named a Windows drive root at all. Every delete verb, including the `rd` alias that was missing, now denies recursive deletion of a filesystem root or a home directory in four argument shapes: flags first, positional path first, `-Path`, and `-LiteralPath`. `-Recurse` alone trips the floor, because recursive root deletion is catastrophic with or without `-Force`. Root tokens match the target itself, never a descendant, exactly as the Bash floor denies `rm -rf ~` while leaving `~/proj/node_modules` deletable. Deny is a hard block with no override, so the negative half is asserted too.
Two Windows defects in one seam. The `claude` and `codex` install hints stayed in `deps.ts` when the other four moved, so a first Windows sync with either CLI absent printed `/tmp`, `mktemp`, `bash`, and `sh` commands that PowerShell cannot run. Both now answer through `HostOs.installHint` with each vendor's documented Windows installer, downloaded to a file and then run - never `irm | iex`. The POSIX strings are unchanged. `.cmd` and `.bat` invocation handed arguments to `cmd.exe` raw, and libuv leaves a metacharacter unquoted when the argument has no whitespace, so a valid path such as `C:/R&D/npx.cmd` split into two commands. The invocation now uses `cmd.exe /d /s /c` with one fully quoted command string and verbatim arguments.
The recovery command embeds the install directory in a single-quoted PowerShell literal, so a home such as `C:\Users\O'Brien` produced guidance that cannot be pasted. Doubling the apostrophe restores it.
`cli/docs/toolchain.md` still said no kit-driven install ever floats, which both installers contradict, so it now documents the `docks-kit` self-install exemption while third-party installs stay pinned. `DESIGN.md` still described a Linux-and-macOS engine, POSIX-only runtime commands, a Linux-only parity workflow, and four release binaries. The golden entrypoints now name why they refuse a non-Linux host - the snapshots are Linux-canonical - and print the commands that host runs instead.
The review's ten findings, their dispositions, and the two bookkeeping gaps it caught: the probe lane the owner asked for mid-implementation is now a declared step, and `vitest.config.ts` joins the harness step that changed it.
`macos-26` timed out at the 5s default on a flag-less sync, while the same test takes about a second warm locally. The comment claimed only Windows needed headroom, but the cost is a cold Bun transpile per spawn, which any cold runner pays. One ceiling for every host removes the wrong assumption and the host branch with it.
Round 2 covers the seven fix commits, the re-run acceptance checks, the green three-OS run on the fixed head, and the regression the deny fix itself introduced before the negative assertions caught it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Runs docks-kit natively on Windows x64 and arm64, and proves every supported OS on its own CI runner behind one host-OS module seam.
Three outcomes:
cli/src/engine-native/os/, the admission gate opens, and the launcher, installer, release artifacts, and npm package all admitwin32.ubuntu-24.04,macos-26, andwindows-2025.Review order
One commit per plan step. Every commit is green on
bun run test:ci, not just HEAD.refactor(engine): replace os.ts with a per-OS host seamos/index.tsholds the engine's onlyprocess.platformreadtest(os): assert every host fact from any hostHostOsmember gains no assertionfix(skills): fall back from symlink to junction to copyDESIGN.mdpromised a fallback chain the code never hadfeat(engine): resolve and run Windows executables through the seam.cmdpairs with acmd.exeinvocationfeat(sot): add PowerShell rules and the Codex Windows sandboxtest(golden): spawn argv directly instead of a shellfeat(host): admit Windows and add the PowerShell launcher twinstargets.tsis the single owner of the host-to-artifact map across four scriptsci: prove every supported OS on its own runnerdocs(kit): publish Windows binaries and state the real matrixVerification
All 16 host-runnable acceptance checks pass on
darwin-arm64:bun run test:ciexits 0 (353 unit tests, 36 dry-run goldens, 59 mutation goldens)bun cli/test/golden-dryrun.ts --prove-redandbun cli/test/golden-mutation.ts --prove-redeach exit 1 with aprove-red OKlinebun run smoke:nativecompilesdocks-kit-darwin-arm64and executes it: version0.15.2, dry-run exit 0grep -rn "bash" cli/test/lib/— no match that spawns a shell to reach the CLIgrep -rn "process.platform" cli/src/engine-native --include=*.ts— matches only inos/index.tsgit diff --stat cli/test/goldens/—dryrun.jsonmoves 2 lines (the printed topic summaries this PR intentionally corrects);mutation.jsonmoves 42/42 lines, allsha256values for.claude/settings.json, from the step 5 PowerShell rulesThe two Windows-host acceptance checks (A15's
.cmdresolution andbun run test:cion a Windows host) cannot run on this machine; thewindows-2025runner in this PR is what proves them.Not in this PR
No release. Publishing Windows artifacts is gated on this three-OS lane going green first.