Windows compatibility
Status — 2026-07-25: the windows-latest CI leg now exists and is red. This issue tracks Windows to the same bar Linux reached in #494.
Windows is not a supported platform today. npm install succeeds (postinstall win32-exits, the tarball ships a prebuilt dist/), then the agent silently degrades — it does not crash, which is precisely why this went unmeasured for so long. No README/docs claim Windows support, and nothing here is a regression.
Empirical scoreboard — CI run on main @ 62df42c (v5.76.2)
| Leg |
Result |
| Lint & Build |
✅ |
Test (ubuntu-latest) — required gate |
✅ |
Test (macos-latest) — advisory |
✅ |
| Test (real PTY scrollback) |
✅ |
| Docs Site · Publish Bundle Smoke |
✅ |
Test (windows-latest) — advisory |
❌ 85 of 699 test files · 536 of 13,097 tests · 9 errors |
windows-latest and macos-latest are continue-on-error: true in .github/workflows/ci.yml — advisory signal, they never block a merge. They stay advisory until the suite is green; flipping them to required is the closing condition for this issue.
Two things that scoreboard settles: macOS is now empirically CI-tested (previously asserted but never exercised), and the Windows failure list is now an authoritative inventory rather than a static prediction.
✅ Already landed — grep tool is cross-platform (#690)
The grep tool spawned a literal grep binary with no win32 fallback. #690 (merged 2026-07-24) rebuilt it on bundled @vscode/ripgrep, spawning rgPath via a lazy import so a bad/missing optional dep can never abort process startup, plus a cross-drive containment guard in _cwd-utils.ts.
Confirmed by CI: no grep or glob test appears anywhere in the Windows failure list. One of the two broken core agent primitives is closed. (Deliberate contract change: ripgrep has no basic-regex mode, so the BRE default + extended flag were removed.)
🔴 Real runtime gaps — a Windows user is actually affected
Ordered by blast radius. Every item re-derived against main source, not inferred from the failure names.
1. The bash tool runs cmd.exe, not a POSIX shell. src/agent/tools/handlers/bash.ts:225 passes shell: true with zero platform branching, so Node resolves %ComSpec%. The tool is named bash and the system prompt assumes POSIX, so every command the model emits (ls, cat, &&, 2>/dev/null, $VAR) fails. Same root cause makes src/agent/hooks/command-executor.ts:189 (spawn('sh', ['-c', …])) dead, so every config-driven hook silently no-ops on Windows.
Direction: prefer Git Bash (probe MSYSTEM / known install paths / a config knob), fall back to PowerShell. Explicitly not defaulting to cmd.exe — that would silently break the command vocabulary the prompts assume.
2. Timeout and abort cannot kill anything. process.kill(-pid, 'SIGKILL') — negative-PID process-group kill — is POSIX-only and throws ESRCH on Windows. Sites: bash.ts:255 (timeout), bash.ts:341 (abort), command-executor.ts:232, shell-jobs/streamer.ts:347/:529. 20 ESRCH errors in the run. The bash.ts:255 throw fires inside a bare setTimeout callback — no try/catch, no promise executor — so it surfaces as an uncaught exception that can take down the agent process, which is the most severe consequence in this list.
Direction: one helper — taskkill /pid <pid> /T /F on win32, process.kill(-pid, sig) on POSIX — plus detached: !isWindows at spawn (detached: true does not create a process group on Windows). Also guard the process.kill(pid, 0) liveness probes.
3. 🔒 The credential read/write denylist floor is off for nested paths. src/agent/tools/handlers/read-denylist.ts:113 and write-denylist.ts:121 compare real.startsWith(blocked + '/') — hardcoded forward slash — while both sides are backslash-normalised by resolve/safeRealpath. Only an exact directory match denies; everything inside a protected root escapes the floor. CI shows the hook failing to block reads of ~/.ssh/id_rsa, ~/.aws/credentials and ~/.afk/config/afk.env, including in allowAll bypass mode, where this is supposed to be an unconditional floor. Related: read-denylist.ts:91 / write-denylist.ts:60 split AFK_READ_DENYLIST/AFK_WRITE_DENYLIST on ':', which severs a Windows path at the drive-letter colon.
Direction: separator-agnostic containment (path.sep / path.relative rather than string prefixes) and path.delimiter for env lists. Also decide whether the POSIX-literal OS entries (/etc/shadow, /etc/sudoers) get Windows equivalents or an explicit documented no-op, so their absence is intentional rather than silent.
4. 🔒 @-file injection forwards secrets to the model API. src/cli/commands/interactive/at-file-inject.ts:88 anchors every alternative of its sensitive-path pattern on /, and the directory check at :117 prefix-matches dir + '/' — but both are applied to the backslash-bearing safeRealpath output. Running the live pattern against Windows-shaped paths returns false for .ssh\id_rsa, .git\config, .bash_history and .aws\credentials; only extension-based rules (.pem, .key) still fire. The denylist added in #688 therefore does not exist on Windows, and CI shows the production path injecting private key material, a git config carrying a token, a CLI OAuth token, and an exported API key into the model payload.
Separately, AT_TOKEN_RE at :75 excludes : and \, so @C:\path\file.ts never tokenises — Windows users cannot use @ at all, and are unprotected in the cases where they can.
Note: this one was not in the original static audit — it was found only because the CI log contradicted a clean bill of health. Practical exploitability today is near-nil (Windows is unsupported, the bash tool doesn't function there, and it needs a user-typed @ token or an induced read), which is why it is filed here in the open rather than as an advisory. Fix it before Windows is ever documented as supported.
5. No afk service install on Windows — serviceManagerFor() returns null for any non-darwin/linux platform (src/service/index.ts:24-33) and the CLI refuses with a clear message. This is a deliberate graceful refusal, not a defect; a Windows backend is net-new work (see Phase 4).
🟡 Test-only failures — the bulk of the 85
Production code is correct; the tests encode POSIX assumptions. No user-facing risk, but they are what stands between here and a green leg.
- ~100 failures from two hardcoded
/tmp literals — mkdtempSync('/tmp/…') in src/agent/tools/skill-bridge.test.ts:43 and src/agent/plugins/tool-injector.test.ts:30. Two one-line fixes (os.tmpdir()); by far the best effort-to-green ratio in the run.
EBUSY unlink on the memory DB — afterEach rmSyncs the temp dir without store.close(), so the open SQLite handle blocks unlink on Windows. Test-only, but worth noting no production caller of close() exists, so a user rotating memory.db mid-session would hit the same thing.
expected 'D:\repo' to be '/repo' (14+) — tests hardcode POSIX literals; path.resolve correctly drive-qualifies. Production containment is already Windows-hardened.
expected 438 to be 384 — transcripts are created with mode: 0o600; Windows has no POSIX mode bits. Real residue is a threat-model doc gap: that confidentiality intent is unenforceable on NTFS (ACL inheritance governs), and we should say so rather than imply the guarantee holds.
launchd/systemd suites exercise their backends unconditionally instead of guarding on platform; one also overrides HOME while the code under test reads os.homedir() (→ USERPROFILE on Windows), so it inspects the real user's config.
- 6 ×
SyntaxError: Invalid or unexpected token — dynamic import() of a bare Windows path; needs pathToFileURL().
spawn ENAMETOOLONG — mixed. The test trigger is artificial, but cmd.exe's ~8191-char command-line ceiling is real, and the throw is synchronous at bash.ts:224, bypassing the graceful proc.on('error') translation so the intended tool-result error never renders.
EPERM mkdir 'D:\' — the root-guard rejects '/' but not a drive root like C:\; misconfiguration-only.
- Windows-logic unit tests that use
it.skipIf(win32) now skip on the platform they describe — convert to real cross-platform coverage where feasible.
Meta-risk: pnpm test:coverage has hard floors. As win32 branches get added and skipped, the Windows leg can go red on coverage even with every assertion passing — may need per-OS coverage config.
Proposed phases
- Phase 1 — security + core primitives. Gaps 3 and 4 together (separator-correct containment; both are small and additive). Then the process-tree-kill helper (gap 2 — one helper, ~8 call sites, clears 20 errors and one crash-the-agent path). Then
bash shell resolution (gap 1 — largest design decision).
- Phase 2 — test-suite grind. Start with the two
/tmp one-liners, then the sqlite close(), the platform-guarded service suites, and the pathToFileURL import fix.
- Phase 3 — secondary no-ops.
.split('/') in plugin discovery/naming; Windows editor-settings path; win32 credential store unreachable (decide: real DPAPI-style store, or document Windows as API-key-only); Chrome detection; PATH splitting in doctor checks; contributor build-from-source.
- Phase 4 — service/daemon persistence. Implement
ServiceManager for win32 and add the case to serviceManagerFor(); the CLI and setup skill are already backend-generic. Direction: per-user Task Scheduler logon task registered from an XML template (flag-based /SC ONLOGON is reported to hit access-denied on recent Win11 without admin — needs validation on a real host). Weaker restart semantics than launchd KeepAlive/systemd Restart=always, and a logon task stops at logoff with no per-user linger equivalent — surface that caveat the way the systemd backend surfaces linger.
Closing condition
windows-latest green and promoted to a required gate (drop its continue-on-error), plus an honest platform-support matrix in the docs. Until then Windows stays undocumented and advisory.
Verification note
The scoreboard, the six gaps, and the taxonomy are derived from the CI run plus a source read on main; gap 4's pattern behaviour was confirmed by executing the live regex against Windows-shaped paths. Not verified: whether the dispatcher catches the bash-handler rejection (gap 2's agent-crash consequence is inferred from the call-site, not observed); the exact cmd.exe length boundary; the remaining failing files beyond the sampled clusters; and anything at all on a real Windows host — every finding here is CI-runner plus static analysis.
Windows compatibility
Status — 2026-07-25: the
windows-latestCI leg now exists and is red. This issue tracks Windows to the same bar Linux reached in #494.Windows is not a supported platform today.
npm installsucceeds (postinstall win32-exits, the tarball ships a prebuiltdist/), then the agent silently degrades — it does not crash, which is precisely why this went unmeasured for so long. No README/docs claim Windows support, and nothing here is a regression.Empirical scoreboard — CI run on
main@62df42c(v5.76.2)ubuntu-latest) — required gatemacos-latest) — advisorywindows-latest) — advisorywindows-latestandmacos-latestarecontinue-on-error: truein.github/workflows/ci.yml— advisory signal, they never block a merge. They stay advisory until the suite is green; flipping them to required is the closing condition for this issue.Two things that scoreboard settles: macOS is now empirically CI-tested (previously asserted but never exercised), and the Windows failure list is now an authoritative inventory rather than a static prediction.
✅ Already landed —
greptool is cross-platform (#690)The
greptool spawned a literalgrepbinary with no win32 fallback. #690 (merged 2026-07-24) rebuilt it on bundled@vscode/ripgrep, spawningrgPathvia a lazy import so a bad/missing optional dep can never abort process startup, plus a cross-drive containment guard in_cwd-utils.ts.Confirmed by CI: no
greporglobtest appears anywhere in the Windows failure list. One of the two broken core agent primitives is closed. (Deliberate contract change: ripgrep has no basic-regex mode, so the BRE default +extendedflag were removed.)🔴 Real runtime gaps — a Windows user is actually affected
Ordered by blast radius. Every item re-derived against
mainsource, not inferred from the failure names.1. The
bashtool runscmd.exe, not a POSIX shell.src/agent/tools/handlers/bash.ts:225passesshell: truewith zero platform branching, so Node resolves%ComSpec%. The tool is namedbashand the system prompt assumes POSIX, so every command the model emits (ls,cat,&&,2>/dev/null,$VAR) fails. Same root cause makessrc/agent/hooks/command-executor.ts:189(spawn('sh', ['-c', …])) dead, so every config-driven hook silently no-ops on Windows.Direction: prefer Git Bash (probe
MSYSTEM/ known install paths / a config knob), fall back to PowerShell. Explicitly not defaulting tocmd.exe— that would silently break the command vocabulary the prompts assume.2. Timeout and abort cannot kill anything.
process.kill(-pid, 'SIGKILL')— negative-PID process-group kill — is POSIX-only and throwsESRCHon Windows. Sites:bash.ts:255(timeout),bash.ts:341(abort),command-executor.ts:232,shell-jobs/streamer.ts:347/:529. 20ESRCHerrors in the run. Thebash.ts:255throw fires inside a baresetTimeoutcallback — no try/catch, no promise executor — so it surfaces as an uncaught exception that can take down the agent process, which is the most severe consequence in this list.Direction: one helper —
taskkill /pid <pid> /T /Fon win32,process.kill(-pid, sig)on POSIX — plusdetached: !isWindowsat spawn (detached: truedoes not create a process group on Windows). Also guard theprocess.kill(pid, 0)liveness probes.3. 🔒 The credential read/write denylist floor is off for nested paths.
src/agent/tools/handlers/read-denylist.ts:113andwrite-denylist.ts:121comparereal.startsWith(blocked + '/')— hardcoded forward slash — while both sides are backslash-normalised byresolve/safeRealpath. Only an exact directory match denies; everything inside a protected root escapes the floor. CI shows the hook failing to block reads of~/.ssh/id_rsa,~/.aws/credentialsand~/.afk/config/afk.env, including inallowAllbypass mode, where this is supposed to be an unconditional floor. Related:read-denylist.ts:91/write-denylist.ts:60splitAFK_READ_DENYLIST/AFK_WRITE_DENYLISTon':', which severs a Windows path at the drive-letter colon.Direction: separator-agnostic containment (
path.sep/path.relativerather than string prefixes) andpath.delimiterfor env lists. Also decide whether the POSIX-literal OS entries (/etc/shadow,/etc/sudoers) get Windows equivalents or an explicit documented no-op, so their absence is intentional rather than silent.4. 🔒
@-file injection forwards secrets to the model API.src/cli/commands/interactive/at-file-inject.ts:88anchors every alternative of its sensitive-path pattern on/, and the directory check at:117prefix-matchesdir + '/'— but both are applied to the backslash-bearingsafeRealpathoutput. Running the live pattern against Windows-shaped paths returns false for.ssh\id_rsa,.git\config,.bash_historyand.aws\credentials; only extension-based rules (.pem,.key) still fire. The denylist added in #688 therefore does not exist on Windows, and CI shows the production path injecting private key material, a git config carrying a token, a CLI OAuth token, and an exported API key into the model payload.Separately,
AT_TOKEN_REat:75excludes:and\, so@C:\path\file.tsnever tokenises — Windows users cannot use@at all, and are unprotected in the cases where they can.Note: this one was not in the original static audit — it was found only because the CI log contradicted a clean bill of health. Practical exploitability today is near-nil (Windows is unsupported, the
bashtool doesn't function there, and it needs a user-typed@token or an induced read), which is why it is filed here in the open rather than as an advisory. Fix it before Windows is ever documented as supported.5. No
afk service installon Windows —serviceManagerFor()returnsnullfor any non-darwin/linux platform (src/service/index.ts:24-33) and the CLI refuses with a clear message. This is a deliberate graceful refusal, not a defect; a Windows backend is net-new work (see Phase 4).🟡 Test-only failures — the bulk of the 85
Production code is correct; the tests encode POSIX assumptions. No user-facing risk, but they are what stands between here and a green leg.
/tmpliterals —mkdtempSync('/tmp/…')insrc/agent/tools/skill-bridge.test.ts:43andsrc/agent/plugins/tool-injector.test.ts:30. Two one-line fixes (os.tmpdir()); by far the best effort-to-green ratio in the run.EBUSY unlinkon the memory DB —afterEachrmSyncs the temp dir withoutstore.close(), so the open SQLite handle blocks unlink on Windows. Test-only, but worth noting no production caller ofclose()exists, so a user rotatingmemory.dbmid-session would hit the same thing.expected 'D:\repo' to be '/repo'(14+) — tests hardcode POSIX literals;path.resolvecorrectly drive-qualifies. Production containment is already Windows-hardened.expected 438 to be 384— transcripts are created withmode: 0o600; Windows has no POSIX mode bits. Real residue is a threat-model doc gap: that confidentiality intent is unenforceable on NTFS (ACL inheritance governs), and we should say so rather than imply the guarantee holds.launchd/systemdsuites exercise their backends unconditionally instead of guarding on platform; one also overridesHOMEwhile the code under test readsos.homedir()(→USERPROFILEon Windows), so it inspects the real user's config.SyntaxError: Invalid or unexpected token— dynamicimport()of a bare Windows path; needspathToFileURL().spawn ENAMETOOLONG— mixed. The test trigger is artificial, butcmd.exe's ~8191-char command-line ceiling is real, and the throw is synchronous atbash.ts:224, bypassing the gracefulproc.on('error')translation so the intended tool-result error never renders.EPERM mkdir 'D:\'— the root-guard rejects'/'but not a drive root likeC:\; misconfiguration-only.it.skipIf(win32)now skip on the platform they describe — convert to real cross-platform coverage where feasible.Meta-risk:
pnpm test:coveragehas hard floors. As win32 branches get added and skipped, the Windows leg can go red on coverage even with every assertion passing — may need per-OS coverage config.Proposed phases
bashshell resolution (gap 1 — largest design decision)./tmpone-liners, then the sqliteclose(), the platform-guarded service suites, and thepathToFileURLimport fix..split('/')in plugin discovery/naming; Windows editor-settings path; win32 credential store unreachable (decide: real DPAPI-style store, or document Windows as API-key-only); Chrome detection; PATH splitting in doctor checks; contributor build-from-source.ServiceManagerfor win32 and add thecasetoserviceManagerFor(); the CLI and setup skill are already backend-generic. Direction: per-user Task Scheduler logon task registered from an XML template (flag-based/SC ONLOGONis reported to hit access-denied on recent Win11 without admin — needs validation on a real host). Weaker restart semantics than launchdKeepAlive/systemdRestart=always, and a logon task stops at logoff with no per-user linger equivalent — surface that caveat the way the systemd backend surfaces linger.Closing condition
windows-latestgreen and promoted to a required gate (drop itscontinue-on-error), plus an honest platform-support matrix in the docs. Until then Windows stays undocumented and advisory.Verification note
The scoreboard, the six gaps, and the taxonomy are derived from the CI run plus a source read on
main; gap 4's pattern behaviour was confirmed by executing the live regex against Windows-shaped paths. Not verified: whether the dispatcher catches the bash-handler rejection (gap 2's agent-crash consequence is inferred from the call-site, not observed); the exactcmd.exelength boundary; the remaining failing files beyond the sampled clusters; and anything at all on a real Windows host — every finding here is CI-runner plus static analysis.