Skip to content

Windows compatibility (bash shell + process-kill + separator-correct security floors + CI green) #703

Description

@griffinwork40

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 WindowsserviceManagerFor() 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 literalsmkdtempSync('/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 DBafterEach 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority: highP1 — high user-facing impact, address soon

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions