Skip to content

Support Windows natively and prove macOS on real hosts - #10

Merged
DocksDocks merged 33 commits into
mainfrom
feat/cross-os-windows-support
Aug 16, 2026
Merged

Support Windows natively and prove macOS on real hosts#10
DocksDocks merged 33 commits into
mainfrom
feat/cross-os-windows-support

Conversation

@DocksDocks

Copy link
Copy Markdown
Owner

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:

  1. macOS needed no port, only proof. No OS-version gate existed anywhere in the kit. What was missing was execution: no macOS runner, no executed darwin binary, and a test preload that pinned every suite to Linux.
  2. Windows becomes a first-class host. Per-OS facts move into cli/src/engine-native/os/, the admission gate opens, and the launcher, installer, release artifacts, and npm package all admit win32.
  3. Each OS proves itself. The portable CI lane compiles and executes the host-matching artifact on ubuntu-24.04, macos-26, and windows-2025.

Review order

One commit per plan step. Every commit is green on bun run test:ci, not just HEAD.

# Commit What to look for
1 refactor(engine): replace os.ts with a per-OS host seam Behaviour-identical on Linux/macOS. os/index.ts holds the engine's only process.platform read
2 test(os): assert every host fact from any host Windows facts proven from a Linux or macOS run; completeness guard fails when a HostOs member gains no assertion
3 fix(skills): fall back from symlink to junction to copy Pre-existing defect: DESIGN.md promised a fallback chain the code never had
4 feat(engine): resolve and run Windows executables through the seam POSIX output byte-identical; a discovered .cmd pairs with a cmd.exe invocation
5 feat(sot): add PowerShell rules and the Codex Windows sandbox PowerShell rules added beside Bash rules, never replacing them
6 test(golden): spawn argv directly instead of a shell Not one recorded golden moves. Stubs and platform preload stay Linux-pinned on purpose
7 feat(host): admit Windows and add the PowerShell launcher twins targets.ts is the single owner of the host-to-artifact map across four scripts
8 ci: prove every supported OS on its own runner Portable lane on three runners; snapshot lane keeps Linux-canonical goldens and prove-red on Ubuntu
9 docs(kit): publish Windows binaries and state the real matrix Six binaries; no live document or command output still says Linux and macOS only

Verification

All 16 host-runnable acceptance checks pass on darwin-arm64:

  • bun run test:ci exits 0 (353 unit tests, 36 dry-run goldens, 59 mutation goldens)
  • bun cli/test/golden-dryrun.ts --prove-red and bun cli/test/golden-mutation.ts --prove-red each exit 1 with a prove-red OK line
  • bun run smoke:native compiles docks-kit-darwin-arm64 and executes it: version 0.15.2, dry-run exit 0
  • grep -rn "bash" cli/test/lib/ — no match that spawns a shell to reach the CLI
  • grep -rn "process.platform" cli/src/engine-native --include=*.ts — matches only in os/index.ts
  • git diff --stat cli/test/goldens/dryrun.json moves 2 lines (the printed topic summaries this PR intentionally corrects); mutation.json moves 42/42 lines, all sha256 values for .claude/settings.json, from the step 5 PowerShell rules

The two Windows-host acceptance checks (A15's .cmd resolution and bun run test:ci on a Windows host) cannot run on this machine; the windows-2025 runner 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.

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.
@DocksDocks
DocksDocks merged commit e396a81 into main Aug 16, 2026
6 checks passed
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.

1 participant