# Windows support — audit result and plan (2026-09-09)

**Status: Windows is NOT supported.** This document is the output of a two-stage
audit of the codebase (readers → two independent skeptics per subsystem →
architect), not a support claim. Nothing here has been implemented; nothing here
was executed on a Windows machine. It exists so the decision to fund the port can
be made on evidence.

## How the numbers were produced

| Stage | Count |
|---|---|
| Findings claimed by first-pass readers (5 subsystems) | 99 |
| Confirmed by BOTH skeptics (Node/libuv win32 semantics lens + JUJU-impact lens) | 86 (blocker 19, degraded 45, cosmetic 22) |
| Disputed (one skeptic confirmed) | 3 |
| Refuted (false positives, excluded) | 10 |
| Added by the skeptics (missed by readers) | 28 |

## Verdict

JUJU does not run on Windows today and the failure is total rather than partial: both entry points (`juju start`, `juju stdio`) die at the IPC bind because `ipcSocketPath()` (src/config/paths.ts:82) hands a filesystem `.sock` path to `net.Server.listen`, which on win32 must be a `\\.\pipe\` name — nothing in src/ ever produces one, so the "handled elsewhere" comment is false. Behind that single wall sit four more independent hard stops that would surface the moment it fell: `shellPath()` only probes `/bin/bash` (run_command, run_commands and environment_info all throw NOT_FOUND), `resolveProgram()` requires a POSIX exec bit and never tries `.exe`/`.cmd` (exec_command and run_task can never start a process), the LSP resolver has the same `.exe` blindness (every documented `juju lsp add` registration fails), and the process-lifecycle code (`detached:true` + `process.kill(-pid)`) cannot kill a process tree on Windows so cancel/timeout would leak workloads and concurrency slots. Beyond the hard stops there are two Windows-only security regressions that must be closed before any support claim: NTFS alternate data streams and 8.3 short names (`.env::$DATA`, `GIT~1/config`) bypass the SECRET/PROTECTED path classifier, and libuv's cwd-first lookup for the bare `git`/`rg` spawns lets a repo-planted `git.exe` run with the trusted env. The rest (permissions no-ops, rename-over/AV locks, CRLF/BOM, env allowlist, path canonicalisation, UNC/Roaming state) is real but shippable with documented limitations. The codebase is well-structured for the port — most fixes are localised behind `process.platform === 'win32'` branches in a handful of files, and the repo already contains the correct idioms (lspService.ts:600, main.ts:147) — but the project has no Windows machine, no Windows CI job and no OS-sandbox story for Windows, so the honest scope for v1 is "Windows supported, unsandboxed, hard-kill-only", gated on a green `windows-latest` matrix plus a short manual acceptance run on a real Windows 11 box. Rough size: 30–50 engineer-days.

## What happens today if someone runs it on Windows

- `npm install -g juju-mcp` succeeds: better-sqlite3 13.0.3 has a win32-x64 prebuilt (ARM64 unverified), npm writes %APPDATA%\npm\juju.cmd, and `juju --version`, `juju init`, `juju doctor`, `juju trust`, `juju audit`, `juju auth add-client|clients`, `juju lsp add|list|remove` all work from cmd/PowerShell (doctor already prints 'sandbox unavailable — no OS sandbox adapter for platform win32'). The state dir is created under %APPDATA%\juju (Roaming) by paths.ts:22-23 with the mkdir mode silently ignored.
- `juju start` bootstraps (writes state.db + install secret), binds the HTTP port (appServer.ts:219), then awaits startIpcServer (appServer.ts:224) with `C:\Users\<u>\AppData\Roaming\juju\ipc\<hash>.sock`; libuv's uv_pipe_bind -> CreateNamedPipeW rejects the non-pipe name and maps it to EACCES, the 'error' listener at ipc/server.ts:36 rejects, main.ts's parseAsync catch calls fail() and the user sees `juju: listen EACCES ...` with exit code 1. The port is released with the process. Nothing else ever runs.
- `juju stdio` (and the implicit stdio mode main.ts:626-629 picks when stdin is piped) starts serveStdio, then hits the same IPC bind at stdioServer.ts:61-62 and exits 1 immediately; an MCP client (Claude Desktop, Cursor, Claude Code) shows 'server disconnected/exited' right after launch. Separately, a client config with `"command": "juju"` cannot even spawn the npm `.cmd` shim without a shell on Node >=22 (EINVAL/ENOENT depending on the client), so even after the pipe fix the docs must show `node <path>\dist\cli\main.js stdio` or `cmd /c juju stdio`.
- `juju status` (and stop, approve, deny, pending, auth pending|approve|deny|list|revoke, recover — everything routed through ipcForCwd, main.ts:56-58) reaches ipcCall because bootstrap already wrote the install secret; net.createConnection on the `.sock` path (ipc/client.ts:9) does CreateFileW on a file that never exists -> ENOENT -> prints 'no running JUJU server for this workspace (start it with `juju start`)' (client.ts:36). The message is accidentally true; `juju start` itself fails.
- run_command / run_commands are unreachable (no server). If the server were up: shellPath() (jobManager.ts:110) stats '/bin/bash', which on win32 is the drive-relative C:\bin\bash and does not exist -> JujuError NOT_FOUND 'no POSIX shell found'. environment_info (agentTools.ts:138) evaluates s.jobs.shellPath() unconditionally, so the read-only diagnostics tool throws as a whole, after all 16 probes already ran; npm/npx/yarn/pnpm probes would report null anyway (libuv only tries .com/.exe for bare names).
- exec_command 'node'/'git'/'python' -> NOT_FOUND from resolveProgram: path.join(dir,'node') has no .exe (jobManager.ts:95) and even an extension-less hit fails `(st.mode & 0o111) !== 0` because libuv's win32 stat never sets exec bits (jobManager.ts:98). run_task is 100% dead: every discovered recipe is program 'npm' (overview.ts:103) -> npm.cmd, which is neither found nor spawnable with shell:false (Node throws spawn EINVAL, jobManager.ts:158). A workspace-relative 'scripts/build.cmd' reaches spawn today and returns INTERNAL_ERROR 'spawn failed: spawn EINVAL'; 'scripts/run.sh' returns ERROR_BAD_EXE_FORMAT as INTERNAL_ERROR; only a workspace .exe could run.
- apply_changes is unreachable (no server). If it were: plain create/modify of LF, non-read-only files would work (Node handles \\?\ paths), but modifying a file with the READONLY attribute fails at fs.renameSync(tmp, abs) (applier.ts:464) with EPERM and the change is reverted/failed; AV/indexer locks cause sporadic EPERM/EBUSY on the same rename; a rename failure inside copyFileSyncDurable (applier.ts:488) leaves a `.juju-tmp-*` file in the workspace; CRLF checkouts (Git for Windows autocrlf=true default) make multi-line replace_exact finds written with '\n' return AMBIGUOUS_EDIT 'found 0'; BOM-prefixed files lose their BOM on every edit (util/bytes.ts:44 TextDecoder default ignoreBOM:false); `src\foo.ts` input is rejected PATH_DENIED (fs.ts:68).
- read_files / list_files / search_code (if reachable): `.env::$DATA` and `secrets.json::$DATA` pass normalizeRel and the ignore matcher and return the secret's bytes (fs.ts:72/115); `GIT~1/config` and `ENV~1` (8.3 aliases, enabled by default on the system volume) bypass PROTECTED/SECRET and resolveForCreate would even accept `GIT~1/hooks/pre-commit`; junctions are refused as 'symlinks'; search results on CRLF files carry a trailing '\r' and '$'-anchored regexes silently miss (searchWorker.ts:55, searchService.ts:243/410).
- git_status / git_diff / git_log / search_code (if reachable) spawn bare 'git' / 'rg' with cwd = workspace root (gitService.ts:89, searchService.ts:353/95); libuv's win32 search_path checks cwd first, so a repo-planted git.exe/rg.exe runs with the trusted env — a Windows-only violation of the JOB-12 'repo cannot supply executables' property. Read-only git also drops the system gitconfig (autocrlf, longpaths, safe.directory), so status/diff show whole-file ^M churn or 'dubious ownership' -> isRepo:false.
- `juju trust` / `juju audit` work but mint the workspaceId from fs.realpathSync(cwd) (root.ts:24), which on win32 preserves the launcher's casing and 8.3 spelling (VS Code often passes `c:\users\...`; os.tmpdir() is `C:\Users\RUNNER~1\...`), so the same folder can get two ids and two trust states depending on how the shell spelled the path (identity.ts:12, bootstrap.ts:64).
- Test suite: every launch()-based suite (tests/helpers/testServer.ts:89) fails in beforeAll at the IPC bind; tests/integration/cli.test.ts:122 times out after 15 s; jobs.test.ts fails 9/12 cases on NOT_FOUND; faultInjection.test.ts:44 would run (getuid undefined) and fail because chmod 0o555 does not lock a directory on Windows. .github/workflows/ci.yml has no windows job at all, so none of this is observed.

## Blockers (must fix before any v1 Windows claim)

- IPC transport: replace the filesystem `.sock` path with a named pipe on win32 — `\\.\pipe\juju-<16 hex of workspaceId>` (paths.ts:82) — and make ipc/server.ts skip the existsSync/unlinkSync/chmodSync file semantics on win32, stdioServer.ts:85 and appServer.ts:250 skip the unlink, and appServer.ts start IPC before binding HTTP. Owner-only access cannot come from a DACL (Node exposes no security descriptor for net.Server; default pipe DACL gives Everyone read), so add an application-level per-boot IPC token (random 32 bytes written to <configDir>/ipc/<id>.token with inherited user-private ACL / 0600 on POSIX; every request carries it, server compares in constant time). Apply on all platforms so tests are uniform; the pipe name is already derived from sha256(installSecret, root) so it is not guessable.
- Process management: on win32 spawn jobs with detached:false + windowsHide:true (so they stay in libuv's kill-on-close Job Object), replace process.kill(-pid) with `%SystemRoot%\System32\taskkill.exe /PID <pid> /T /F` (absolute path, never bare), skip the SIGTERM grace phase, and add an 'exit'-based fallback that force-closes the stdio streams and calls finish() if 'close' has not fired N seconds after 'exit' (orphans hold the pipe handles) — otherwise every cancel/timeout leaks a jobsConcurrentMax slot and shutdown() spins to its deadline (jobManager.ts:159/191/362/372/384). Also rmSync(jobDir) in the spawn catch at :162 (synchronous EINVAL/ENOEXEC leaves empty job dirs).
- Executable resolution: a shared resolver (new src/util/execResolve.ts) that on win32 iterates PATHEXT (.COM;.EXE;.BAT;.CMD default), strips quoted PATH entries, skips the 0o111 test, and returns {abs, kind:'exe'|'batch'}; used by jobManager.resolveProgram (:94-104), agentTools PROBES (:61-86, add windowsHide, probe python/py as well as python3), overview.gitBinaryAvailable (:316), lspService.resolveCommand/isTrustedExecutable (:581-600), gitService (:89) and searchService (:95/:353). Batch hits (npm.cmd, npx.cmd) must be routed through cmd.exe /d /s /c with strict argument validation because Node >=22 refuses .cmd/.bat with shell:false (EINVAL); run_task cannot work otherwise.
- Shell for run_command/run_commands: shellPath() (jobManager.ts:109-118) must never stat drive-less rooted paths on win32. Decision: prefer Git-for-Windows bash.exe from fixed install locations (%ProgramFiles%\Git\bin\bash.exe, %ProgramFiles(x86)%\Git\bin\bash.exe, %LOCALAPPDATA%\Programs\Git\bin\bash.exe — verified outside the workspace root), fall back to %ComSpec% verified under %SystemRoot%\System32 with `/d /s /c` + windowsVerbatimArguments, refuse UNC workspace roots in cmd mode, expose `shellKind` via environment_info and derive the run_command/run_commands/job_cancel descriptions from it; environment_info must tolerate a missing shell (agentTools.ts:138) instead of throwing. PowerShell is deliberately not a v1 option.
- Security regression — bare git/rg spawns: libuv's win32 search_path looks in the child's cwd (the workspace root) before PATH, so gitService.ts:89 and searchService.ts:95/353 must spawn an absolute path resolved once on the trusted PATH (Git\cmd\git.exe launcher, rg.exe) and never a bare name; add windowsHide. Without this a repo-supplied git.exe runs with the trusted env from a read-tier tool.
- Security regression — NTFS name aliases: on win32 reject ':' in any path segment (ADS `.env::$DATA`, `README.md:payload`) in normalizeRel (fs.ts:72/78) and, after the lstat walk in resolve()/resolveForCreate(), verify the canonical on-disk name via fs.realpathSync.native(abs) — the relative segments must match the requested segments case-insensitively, which defeats 8.3 aliases (`GIT~1`, `ENV~1`) — then re-run isSecret/isProtected on the canonical rel and return it. The hard-deny guarantee is otherwise false on Windows.
- Workspace identity: root.ts:24 must use fs.realpathSync.native on win32 (on-disk casing, 8.3 expansion, subst resolution) and case-fold the root before hashing in identity.ts:12 / before the `WHERE root = ?` lookup; otherwise, once pipes work, `juju status` from a differently-cased shell derives a different pipe name and trust/audit state fragments across spellings of one folder (main.ts:37-50, bootstrap.ts:64, store.ts:180). Also case-insensitive home/broad-root comparisons and Windows roots (Windows, Program Files, Program Files (x86), ProgramData) at root.ts:43/48.
- LSP resolver: lspService.ts:584 must probe `<cmd>.exe`/`<cmd>.com` on win32 and isTrustedExecutable (:600) must require .exe/.com there (rejecting the extension-less sh shim, .cmd and .ps1 with a message that names the `node.exe` + `<server>.js --stdio` form); jsonrpc.ts:102 keeps shell:false. Plus canonicalise the diagnostics map key (uriToRel(uri) ?? uri at :504/512/858) because vscode-uri servers publish file:///c%3A/... while Node mints file:///C:/... Without this every documented `juju lsp add` fails 'not found on the trusted PATH' and pyright diagnostics time out.
- Child environment: BASE_ALLOWED (env.ts:10) needs a win32 list (SYSTEMROOT, SYSTEMDRIVE, WINDIR, COMSPEC, PATHEXT, TEMP, TMP, USERPROFILE, HOMEDRIVE, HOMEPATH, APPDATA, LOCALAPPDATA, PROGRAMDATA, ProgramFiles, ProgramFiles(x86), ProgramW6432, NUMBER_OF_PROCESSORS, PROCESSOR_ARCHITECTURE, USERNAME, USERDOMAIN) and a %SystemRoot%-based fallback PATH (:21); `go build` fails outright without LOCALAPPDATA, and the envAllowlist schema regex (globalConfig.ts:51) cannot express ProgramFiles(x86).
- Claim gate: a `windows-latest` job in .github/workflows/ci.yml running the full suite (with platform-conditional skips only where the behaviour is documented as unsupported), the existing tests made shell-portable (directTools.test.ts:76/139, agentTools.test.ts:39-41/59, faultInjection.test.ts:33, cli.test.ts:43), new win32-only integration tests for pipe IPC, tree kill, PATHEXT/run_task via npm.cmd, ADS/8.3 denial, read-only rename-over, canonical workspaceId, and a MANUAL_ACCEPTANCE.md Windows section executed once on a real Windows 11 box (second-local-user pipe access, Defender, OneDrive folder, Claude Desktop/Cursor stdio config using node.exe or cmd /c juju.cmd).
- Docs for onboarding: README/COMPATIBILITY must show the MCP-client config for Windows (`"command": "node", "args": ["<global npm>\\node_modules\\juju-mcp\\dist\\cli\\main.js", "stdio"]` or `cmd /c juju stdio`), because npm's `juju.cmd` shim cannot be spawned directly by Node-based MCP clients.

## Degraded (can ship with a documented limitation)

- No OS sandbox on Windows: `commandSandbox: 'prefer'` runs unsandboxed (sandboxed:null), 'require' and per-call sandbox:true fail closed with NOT_SUPPORTED, network:false is ignored when unsandboxed (sandbox.ts:86, sandboxWiring.ts:32-41). Document in COMPATIBILITY/SECURITY and print a one-time warning for prefer+win32; explicitly reject network:false on win32 as hygiene. The directory guard remains a policy layer, not a sandbox, on every platform.
- Job termination is an immediate hard kill of the tree (taskkill /T /F), no SIGTERM grace phase, no child cleanup; taskkill enumerates the tree at call time so a process forking during the kill can escape and PID reuse is theoretically possible. job_cancel/job description wording is platform-conditional. Job Objects would need a native addon — out of scope for v1.
- Graceful server shutdown: SIGTERM listeners never fire on Windows (TerminateProcess), SIGINT only for a console Ctrl+C. Register SIGBREAK/SIGHUP additionally; document that `juju stop` (IPC) is the graceful path and that reconcileOnBoot/markInterruptedJobs cover hard kills. `juju stdio` is fine via stdin end/close (main.ts:519-520).
- Atomic write on NTFS: rename-over of a READONLY destination fails (EPERM) and AV/indexer locks on either the fresh temp file or the target cause sporadic EPERM/EBUSY. Ship a renameOverSync helper (clear READONLY on the target, bounded retry 5x with 10-160 ms backoff on EPERM/EBUSY/EACCES/ENOTEMPTY, win32 only) and typed errors naming AV/indexer locks; residual failures remain possible under aggressive AV and are reported, never silent (journal marks failed/recovery_required). Directory fsync is a silent no-op on Windows (applier.ts:495) — documented.
- Line endings and BOM: read_files reports lineEnding ('lf'|'crlf'|'mixed') and bom:true; replace_exact retries a '\n' find as CRLF on CRLF files and reports it; the planner re-prepends a stripped UTF-8 BOM; search splits on /\r?\n/ and rg gets --crlf. Residual: replace_file with LF content on a CRLF file rewrites endings (documented; model must reproduce CRLF), span_edit offsets exclude the BOM, UTF-16 files are refused as 'binary' with a clearer UNSUPPORTED_ENCODING hint.
- Read-only git runs drop the system gitconfig by design (GIT_CONFIG_NOSYSTEM + GIT_CONFIG_GLOBAL=NUL, gitService.ts:69). On win32 re-inject data-only keys via `-c`: core.longpaths=true, safe.directory=<root>, and core.autocrlf/core.symlinks/core.fscache read once from `git config --system --get` at startup; filter.lfs.* and other executable keys stay excluded. Residual: LFS smudge/clean not applied in read-only ops (documented).
- Command-line length: CreateProcessW caps at 32,767 chars. Use `git add --pathspec-from-file=-` for commit staging (gitService.ts:335) and byte-length-bounded rg batches (searchService.ts:56) with the spawn wrapped in try/catch (:353). Very deep monorepos can still hit ENAMETOOLONG and get a typed error.
- Windows name rules: reserved device names (CON, NUL, AUX, COM1-9, LPT1-9 with any extension), trailing dot/space and <>:"|?* are rejected lexically in normalizeRel on every platform so plans stay portable (a macOS-authored `aux.ts` would poison a Windows checkout). Absolute paths > 260 chars are not blocked (Node handles them; git needs core.longpaths, Explorer/MSBuild may choke) — documented, optional plan warning.
- Case-insensitive filesystem semantics: `move a.ts -> A.ts` needs a dev+ino sameAs check in resolveForCreate/verifyPreState (also fixes today's macOS APFS behaviour); planner `touched` set keys on the canonical on-disk path. Until then a two-step rename works. Junctions and app-execution aliases are refused like symlinks (message reworded to 'symlinks/junctions').
- Permissions model: mkdir/writeFile `mode` is ignored on Windows; protection is the inherited profile ACL. Default state dir moves to %LOCALAPPDATA%\juju (SQLite WAL is unsupported on redirected/roaming SMB folders; jobs/backups/audit should not roam); ensureConfigDir runs a best-effort `icacls /inheritance:r /grant:r <user>:(OI)(CI)F SYSTEM:(OI)(CI)F` when it creates the dir; empty APPDATA/LOCALAPPDATA are hardened like line 14/26; doctor on win32 warns when the dir is outside the user profile or on a UNC path and prints 'POSIX modes not enforced; ACL inherited'. Residual: a JUJU_CONFIG_DIR override to a shared location is only as private as icacls succeeds — reported by doctor, not silently accepted.
- IPC access control residual: the pipe's default DACL grants Everyone read (cannot submit requests through Node's client which needs write access; a raw Win32 client could hold a read handle and see nothing, since responses go only to the requesting connection). Owner-only is enforced by the per-boot token plus the unguessable name; document in SECURITY.md and verify with a second local user in manual acceptance.
- environment_info probes and doctor on Windows: npm/npx/yarn/pnpm are detected through the PATHEXT resolver; `python3` is often the Store alias stub -> reported null with python/py probed alongside. Console windows can flash for console-subsystem children only when JUJU itself has no console (add windowsHide everywhere; jobs use detached:false + windowsHide).
- exec_command's 'argv passed literally' contract holds only for CRT-style argument parsers on Windows (libuv serialises argv to one command line); batch targets get strict validation (reject %, !, ", CR/LF in args) rather than escaping. Documented in the tool description.
- Spool I/O: keep one fd open per segment instead of appendFileSync per chunk (AV re-scan cost) and delete segments with rmSync({maxRetries}); a segment held by AV/indexer can still leak (bounded to 1 MiB) — documented.
- Cloud placeholder files (OneDrive Files On-Demand) hydrate synchronously on read; detect size>0 && blocks===0 on win32 and refuse with a typed error rather than blocking — nice-to-have, else documented.
- Windows on ARM64: better-sqlite3 prebuilt availability not verified; document as 'x64 tested; ARM64 needs build tools until verified'. WSL2 is the recommended route for users who need an OS sandbox (bwrap) — it is Linux support, not Windows support.
- Store hygiene: config.json parse tolerates a UTF-8 BOM and diagnoses UTF-16 ('save as UTF-8'); db.ts:132 recovery text says to stop all JUJU processes and move state.db with -wal/-shm; Windows litter (Thumbs.db, desktop.ini, $RECYCLE.BIN/, System Volume Information/) added to DEFAULT_ORDINARY_PATTERNS.

## Impossible or out of scope for v1

- OS sandbox for commands: sandbox-exec (macOS) and bwrap (Linux) have no Windows equivalent that Node can drive without native code. Candidates for a future adapter, all out of scope for v1: (a) AppContainer (CreateProcess with an AppContainer SID + capability SIDs; filesystem isolation via ACLs, network via capabilities — the closest semantic match, requires a native addon or a small signed helper .exe and per-workspace ACL grants); (b) Job Objects (kill-on-close, CPU/memory/process-count limits, UI restrictions — a resource/lifecycle cage, NOT a filesystem or network sandbox); (c) restricted/low-integrity tokens (CreateRestrictedToken + SAFER levels — write-denial to medium-integrity objects only, easily bypassed for reads); (d) Windows Sandbox (a full per-session VM, Pro/Enterprise only, seconds to start, not per-command). v1 ships `commandSandbox: 'require'` refusing on win32 and documents WSL2+bwrap as the sandboxed alternative.
- Job Objects for guaranteed process-tree termination: not reachable from Node core without a native addon (node-gyp on the user's machine or a prebuilt binary per Node ABI); v1 uses taskkill /T /F with its documented race. Revisit only if a prebuilt-binary dependency is acceptable for the package.
- Owner-only pipe DACL / peer credentials: net.Server cannot set a security descriptor on the named pipe and there is no SO_PEERCRED equivalent for pipes in Node, so Unix-socket-style 0600 semantics cannot be replicated at the OS level; the token handshake is the replacement.
- Graceful termination of children: Node's process.kill/child.kill are TerminateProcess on Windows for every signal; GenerateConsoleCtrlEvent(CTRL_BREAK) is not exposed and requires a shared console the jobs do not have with windowsHide. No graceful phase is possible from Node core.
- POSIX file modes and directory fsync on NTFS: `mode` maps only to the READONLY attribute and FlushFileBuffers on a directory handle is refused; both are documented no-ops, not fixable.
- UNC (\\server\share) workspace roots under the cmd.exe shell: cmd refuses a UNC cwd and silently falls back to %SystemRoot%; refused with a typed error in cmd mode (Git bash accepts UNC). Roaming/redirected %APPDATA% on SMB for SQLite WAL: unsupported by SQLite; mitigated by moving state to %LOCALAPPDATA% and warning on UNC.
- Exact-argv passthrough for non-CRT parsers (cmd.exe, some native tools reading GetCommandLineW): Windows has no argv, so the exec_command literal-args contract cannot be guaranteed there; documented, not fixed.
- Case-sensitivity flags: per-directory case-sensitive NTFS (WSL-created dirs) and exFAT/FAT volumes without stable inodes are not handled specially; dev/ino identity is advisory only (bootstrap.ts:67 logs a note) and comparison is skipped when ino is 0.
- Windows on ARM64 and Windows Server/Nano variants: not in the v1 test matrix (GitHub hosted runners are x64 windows-latest only); no claim is made for them.

## Ordered implementation plan

| # | Step | Effort | Days | Files |
|---|---|---|---|---|
| 1 | CI bootstrap first (nothing can be verified otherwise — the team has no Windows box): add `windows-latest` x node 22/24 to the matrix with `continue-on-error: true`, add tests/helpers/platform.ts (`isWin`, `itWin`, `itPosix`, `describeWin` wrappers around it.runIf/skipIf), make globalSetup's tsc build shell-portable, and record the initial red baseline in TEST_REPORT.md. Flip to required in the last step. | small | 1 | `.github/workflows/ci.yml`, `tests/helpers/platform.ts`, `tests/helpers/globalSetup.ts`, `docs/TEST_REPORT.md` |
| 2 | IPC -> named pipes: ipcSocketPath returns `\\.\pipe\juju-<short>` on win32 (drop the ipc/ subdir there or keep it only for the token file); ipc/server.ts skips existsSync/unlink/chmod on win32 (a live pipe would otherwise be touched) and maps EADDRINUSE to a clear 'another JUJU server owns this workspace' error; stdioServer.ts:85 and appServer.ts:250 skip unlink on win32; appServer starts IPC before binding HTTP (no bound-then-dropped port). Add the per-boot IPC token on all platforms: server writes <configDir>/ipc/<short>.token (0600 / inherited ACL), client reads it in workspaceIpcPath() and sends it as `token` in every IpcRequest; server rejects mismatches with a constant-time compare and no response body; client.ts maps ENOENT/ECONNREFUSED/EPIPE to 'no running server'. Keep the protocol otherwise unchanged (owner-only IPC invariant preserved by name secrecy + token; no NoAuth path introduced). | medium | 2.5 | `src/config/paths.ts`, `src/ipc/server.ts`, `src/ipc/client.ts`, `src/ipc/protocol.ts`, `src/server/appServer.ts`, `src/server/stdioServer.ts`, `src/cli/main.ts`, `tests/unit/pathPolicy.test.ts`, `tests/integration/cli.test.ts`, `tests/integration/stdio.test.ts` |
| 3 | Process management: on win32 spawn with detached:false + windowsHide:true (jobs stay in libuv's kill-on-close job so a hard-killed JUJU takes them along); terminate() uses `%SystemRoot%\System32\taskkill.exe /PID n /T /F` via spawnSync with an absolute path (fallback child.kill), skips the grace timer on win32 and records the platform-appropriate signal; add an 'exit'-based fallback (after 'exit', if 'close' has not fired within ~2 s, destroy child.stdout/stderr and run finish()) so orphan-held pipes cannot pin a jobsConcurrentMax slot on any platform; rmSync(jobDir) in the spawn catch; shutdown() uses the same path. main.ts registers SIGBREAK and SIGHUP alongside SIGINT/SIGTERM for `juju start` and `juju stdio`. Update job_cancel / jobs descriptions to be platform-conditional. | medium | 3 | `src/services/jobs/jobManager.ts`, `src/cli/main.ts`, `src/tools/jobTools.ts`, `tests/integration/jobs.test.ts` |
| 4 | Shared executable resolver: src/util/execResolve.ts — resolveOnTrustedPath(name, {parentEnv, workspaceRoot}) returns {abs, kind:'exe'\|'batch'\|'other'}; on win32 strips surrounding quotes from PATH entries, iterates PATHEXT (default .COM;.EXE;.BAT;.CMD), stats without the 0o111 test, never consults cwd; on POSIX keeps the current exec-bit rule. trustedPath() uses path.delimiter and a %SystemRoot%\System32;%SystemRoot% fallback on win32. Wire it into jobManager.resolveProgram, agentTools PROBES (+windowsHide, probe python/py), overview.gitBinaryAvailable, gitService and searchService (absolute path spawn — closes the cwd-first security regression), lspService.resolveCommand. | medium | 2.5 | `src/util/execResolve.ts`, `src/security/env.ts`, `src/services/jobs/jobManager.ts`, `src/tools/agentTools.ts`, `src/services/overview.ts`, `src/services/git/gitService.ts`, `src/services/search/searchService.ts`, `src/services/lsp/lspService.ts`, `tests/unit/coreUtils.test.ts` |
| 5 | Shell strategy for run_command/run_commands/exec_command/run_task: shellPath() becomes shellSpec(): {kind:'bash'\|'cmd', abs, argsFor(cmd)}; on win32 prefer Git-for-Windows bash.exe at fixed install roots (%ProgramFiles%\Git\bin\bash.exe, %ProgramFiles(x86)%\Git\bin\bash.exe, %LOCALAPPDATA%\Programs\Git\bin\bash.exe, each verified outside the workspace), else %ComSpec% verified under %SystemRoot%\System32 with ['/d','/s','/c', cmd] + windowsVerbatimArguments:true; new globalConfig knob `windowsShell: 'auto'\|'bash'\|'cmd'`; refuse UNC workspace roots in cmd mode with NOT_SUPPORTED. exec_command/run_task: batch targets (npm.cmd etc.) run as cmd.exe /d /s /c "<abs>" args with strict validation (reject args containing %, !, ", CR/LF -> INVALID_INPUT with hint); workspace .sh/.js/extension-less scripts return NOT_SUPPORTED ('use program node/python with the script as an argument'). environment_info tolerates a missing shell and reports shell + shellKind; run_command/run_commands descriptions derive from the resolved shell kind. | large | 4 | `src/services/jobs/jobManager.ts`, `src/config/globalConfig.ts`, `src/tools/agentTools.ts`, `src/tools/jobTools.ts`, `src/services/overview.ts`, `tests/integration/directTools.test.ts`, `tests/integration/agentTools.test.ts`, `tests/integration/jobs.test.ts` |
| 6 | Child environment on win32: BASE_ALLOWED_WIN32 (SYSTEMROOT, SYSTEMDRIVE, WINDIR, COMSPEC, PATHEXT, TEMP, TMP, USERPROFILE, HOMEDRIVE, HOMEPATH, APPDATA, LOCALAPPDATA, PROGRAMDATA, ProgramFiles, ProgramFiles(x86), ProgramW6432, NUMBER_OF_PROCESSORS, PROCESSOR_ARCHITECTURE, USERNAME, USERDOMAIN, TZ, LANG); keep ALWAYS_DENIED; relax the envAllowlist schema regex to allow parentheses/mixed case on win32; case-insensitive name matching against process.env; document PSModulePath is deliberately excluded. | small | 1 | `src/security/env.ts`, `src/config/globalConfig.ts`, `tests/unit/spoolAndConfig.test.ts`, `tests/security/maliciousRepo.test.ts` |
| 7 | LSP on Windows: resolveCommand probes .exe/.com for bare names; isTrustedExecutable requires .exe/.com on win32 and returns a reason naming the `node.exe` + `<server>.js --stdio` form when only a .cmd/.ps1/sh shim is found; jsonrpc.ts stays shell:false; diagnostics/diagWaiters keyed on uriToRel(uri) ?? uri so vscode-uri (`file:///c%3A/`) and rust-analyzer (`file:///c:/`) forms match; LSP child env gets the win32 allowlist (gopls needs LOCALAPPDATA). Update docs/LSP.md and `juju lsp add` help with Windows examples. | medium | 2 | `src/services/lsp/lspService.ts`, `src/services/lsp/jsonrpc.ts`, `src/cli/main.ts`, `docs/LSP.md`, `tests/integration/lsp.test.ts` |
| 8 | git and ripgrep: spawn absolute paths (Git\cmd\git.exe launcher, rg.exe) with windowsHide; on win32 push `-c core.longpaths=true`, `-c safe.directory=<root>` and the data-only keys core.autocrlf/core.symlinks/core.fscache read once from `git config --system --get` into read-only runs (filters/hooks/pager keys stay excluded); commit staging via `add --pathspec-from-file=- --pathspec-file-nul` over stdin; timeout kill via taskkill /T on win32; stop conflating non-ENOENT spawn errors with 'git is not available'. ripgrep: `--crlf`, strip /\r?\n$/, byte-length-bounded batches, try/catch around spawn -> typed INTERNAL_ERROR. | medium | 3 | `src/services/git/gitService.ts`, `src/services/search/searchService.ts`, `tests/integration/gitAndDiag.test.ts`, `tests/unit/coreUtils.test.ts` |
| 9 | Path policy (security-critical half first): normalizeRel on win32 converts '\' to '/' before validation (POSIX keeps rejecting), rejects ':' in any segment, and on all platforms rejects reserved device names, trailing dot/space and <>"\|?* with a portable message; resolve()/resolveForCreate() on win32 canonicalise via fs.realpathSync.native after the lstat walk, require segment-wise case-insensitive equality with the request (defeats ADS, 8.3 aliases), re-check isSecret/isProtected on the canonical rel and return canonical rel/abs; message reworded to 'symlinks/junctions are not followed'. root.ts: realpathSync.native on win32, case-folded home/broad-root comparisons, Windows roots added, UNC-share wording; identity.ts case-folds the root on win32; planner `touched` keyed on canonical path; dev+ino sameAs check for case-only moves; Windows litter in ignores.ts. Store the canonical root; no COLLATE NOCASE. | large | 5 | `src/workspace/fs.ts`, `src/workspace/root.ts`, `src/workspace/identity.ts`, `src/workspace/ignores.ts`, `src/services/changes/planner.ts`, `src/services/changes/applier.ts`, `src/server/bootstrap.ts`, `src/cli/main.ts`, `tests/unit/pathPolicy.test.ts`, `tests/security/pathTraversal.test.ts`, `tests/integration/changes.test.ts` |
| 10 | Permissions and state location: default win32 dir -> %LOCALAPPDATA%\juju (fallback <home>\AppData\Local), trim + isAbsolute hardening for APPDATA/LOCALAPPDATA; ensureConfigDir runs best-effort icacls (absolute %SystemRoot%\System32\icacls.exe, `/inheritance:r /grant:r <user>:(OI)(CI)F SYSTEM:(OI)(CI)F`) only when it creates the dir; doctor on win32 warns when the dir is outside the profile or on a UNC root and notes 'POSIX modes not enforced; ACL inherited'; ipc chmod skipped on win32; keys.ts/globalConfig.ts header comments corrected; config.json parse tolerates UTF-8 BOM and diagnoses UTF-16; db.ts recovery text updated; SECURITY.md gains a Windows ACL + pipe DACL section. | medium | 1.5 | `src/config/paths.ts`, `src/cli/main.ts`, `src/ipc/server.ts`, `src/auth/keys.ts`, `src/config/globalConfig.ts`, `src/store/db.ts`, `docs/SECURITY.md`, `tests/unit/spoolAndConfig.test.ts` |
| 11 | Atomic write/rename/fsync on NTFS: src/util/fsAtomic.ts with renameOverSync(tmp, dest) — on win32 clear READONLY on an existing dest (chmod 0o666) then rename, bounded retry (5 attempts, 10->160 ms via Atomics.wait) on EPERM/EBUSY/EACCES/ENOTEMPTY, final error typed with a recovery hint naming AV/indexer locks; writeFileAtomic and copyFileSyncDurable both use it and both clean up tmp on failure; fsyncDir documented as a no-op on Windows; spool keeps one fd per segment and deletes with rmSync({maxRetries:3}); planner.ts:249 gets a comment that on win32 `mode` encodes only the READONLY attribute (0o444 replay is correct preservation). | medium | 2 | `src/util/fsAtomic.ts`, `src/services/changes/applier.ts`, `src/services/changes/planner.ts`, `src/services/jobs/spool.ts`, `docs/COMPATIBILITY.md`, `tests/unit/coreUtils.test.ts`, `tests/integration/faultInjection.test.ts` |
| 12 | CRLF and BOM in line-based tools: ReadItemResult gains lineEnding ('lf'\|'crlf'\|'mixed') and bom:boolean; planner.readCurrent records bom and re-prepends EF BB BF before Buffer.from(next) for replace_exact/span_edit; replace_exact retries an LF-only multi-line find as CRLF on CRLF files and reports normalizedLineEndings:true; replace_file result warns when it changes the file's dominant ending; searchService literal split and searchWorker split on /\r?\n/ and searchWorker strips a leading BOM; UTF-16 detection produces UNSUPPORTED_ENCODING {encoding:'utf-16le'} with a hint. Add fixtures with CRLF and BOM files. | medium | 2.5 | `src/services/files/readService.ts`, `src/services/changes/planner.ts`, `src/services/search/searchService.ts`, `src/services/search/searchWorker.ts`, `src/workspace/fs.ts`, `src/util/bytes.ts`, `tests/integration/changes.test.ts`, `tests/integration/semantics.test.ts` |
| 13 | Tests: make existing suites shell-portable (`node -e` instead of sleep/tail/`;` in directTools.test.ts:76/139 and agentTools.test.ts:39-41/59; faultInjection.test.ts:33 skipIf win32 or use a portable fault; cli.test.ts:43 build via process.execPath + node_modules/typescript/bin/tsc; testServer.ts comment). Add win32-only integration tests: pipe IPC round-trip and second-server EADDRINUSE, token rejection, tree kill (run_command spawning a `node -e` grandchild; assert it is gone via tasklist), run_task via npm.cmd, exec_command with batch validation rejection, environment_info shellKind + npm detected, `.env::$DATA`/`README.md:x`/`GIT~1/config`/`ENV~1` -> denied, canonical workspaceId from an 8.3 cwd (os.tmpdir() on runners is C:\Users\RUNNER~1\...), read-only file modify succeeds, CRLF replace_exact retry, BOM preservation, reserved-name rejection, git status on an autocrlf checkout shows no phantom modifications, forced `windowsShell:'cmd'` mode smoke tests. Packaging test installs the tarball into a temp prefix and runs `juju --version` + `juju doctor` on Windows. | large | 5 | `tests/integration/directTools.test.ts`, `tests/integration/agentTools.test.ts`, `tests/integration/faultInjection.test.ts`, `tests/integration/cli.test.ts`, `tests/helpers/testServer.ts`, `tests/integration/windows.test.ts`, `tests/security/windowsNames.test.ts`, `tests/packaging/pack.test.ts` |
| 14 | Docs: COMPATIBILITY.md Windows section (supported: x64 Windows 10/11, Node 22/24, Git for Windows recommended; unsupported: OS sandbox, graceful kill, ARM64 unverified; the exact limitations list from the 'degraded' set); SECURITY.md ACL/pipe/token model and the 'directory guard is not a sandbox' reminder for Windows; README + examples client configs using node.exe or `cmd /c juju stdio`; LSP.md Windows registrations; MANUAL_ACCEPTANCE.md Windows checklist; ADR-014 'Windows support: named pipes + token, taskkill tree kill, bash-or-cmd shell, no sandbox'. | small | 1.5 | `docs/COMPATIBILITY.md`, `docs/SECURITY.md`, `docs/LSP.md`, `docs/MANUAL_ACCEPTANCE.md`, `docs/adr/ADR-014-windows-support.md`, `README.md`, `examples/` |
| 15 | Claim gate: remove `continue-on-error` from the windows job and make it required; run the manual acceptance checklist once on a real Windows 11 x64 machine (not a runner): fresh `npm install -g`, `juju init/doctor/start/stdio/status/stop`, Claude Desktop + Cursor stdio configs, second local user cannot use the pipe, Defender real-time scanning on during a 200-file apply_changes + rollback, OneDrive-synced workspace, deep-path repo with core.longpaths, npm.cmd run_task, gopls/pyright registration. Record results in TEST_REPORT.md and only then change COMPATIBILITY.md from 'roadmap' to 'supported'. | medium | 3 | `.github/workflows/ci.yml`, `docs/TEST_REPORT.md`, `docs/COMPATIBILITY.md`, `docs/MANUAL_ACCEPTANCE.md` |

Total of the step estimates: **39.5 engineer-days**.

## Tests and CI required before the project may claim Windows support

- CI: `windows-latest` x Node 22 and 24 running build, typecheck, lint, unit, integration, security, compatibility and packaging suites, required (not allowed-to-fail) before the support claim; keep ubuntu/macos as they are. Confirm better-sqlite3 loads its prebuilt on the runner (doctor 'sqlite-native').
- Existing suites must pass unmodified in intent on Windows: only shell-dependent commands are rewritten to `node -e`, and every platform skip must cite the documented limitation it corresponds to (sandbox tests, POSIX chmod fault injection).
- IPC: pipe round-trip for `juju status/stop/approve/deny/pending/auth */recover` against a running `juju start` and `juju stdio`; second `juju start` for the same workspace fails clearly and leaves the first untouched; request without/with wrong token is rejected; pipe name derived from the canonical root so `juju status` from `c:\users\...` and `C:\Users\...` spellings both connect (run the workspace under os.tmpdir()'s RUNNER~1 path to exercise 8.3 canonicalisation).
- Jobs: exec_command 'node' and 'git' start on Windows; run_task with the package.json npm recipe runs via npm.cmd; batch-target argument validation rejects % ! " and newlines; cancel and timeout of a run_command whose shell spawned a `node -e` grandchild leave no surviving grandchild (tasklist /FI "PID eq n" empty) and free the concurrency slot (4 consecutive cancels then a 5th start succeeds); shutdown() completes within its deadline; forced `windowsShell: 'cmd'` mode passes the same smoke tests; environment_info returns os:'win32', shellKind, and detects npm/node/git.
- Security (must be green on windows-latest): read_files/list_files/apply_changes on `.env::$DATA`, `secrets.json::$DATA`, `README.md:stream`, `GIT~1/config`, `ENV~1`, `GIT~1/hooks/pre-commit` are all denied (SECRET_PATH_DENIED/PATH_DENIED/NOT_FOUND, never content); a repo-planted `git.exe`/`rg.exe`/`node.exe` at the workspace root is never executed by git_status, search_code, exec_command or environment_info (tests/security/maliciousRepo.test.ts extended); child env of a job contains no JUJU_CONFIG_DIR and PATH contains no workspace entry; IPC token file is not readable via any workspace tool.
- Filesystem semantics: modify and rollback of a READONLY file succeed and preserve the attribute; rename-over retry helper unit-tested with mocked EPERM/EBUSY sequences; copyFileSyncDurable leaves no .juju-tmp-* on failure; reserved names (`aux.ts`, `nul`, `foo.`, `foo `) rejected on all platforms; backslash rels accepted on win32 and still rejected on POSIX; `..\x` and `\\srv\share` rejected; case-only move works; junction refused with the new wording; workspace roots C:\, C:\Windows, C:\Users, C:\Program Files refused without --allow-unsafe-root.
- Text semantics: CRLF fixture — replace_exact with an LF multi-line find succeeds and reports normalizedLineEndings; read_files reports lineEnding/bom; BOM fixture — replace_exact preserves EF BB BF and bytesAfter accounts for it; search_code literal, worker regex and rg backends all return '$'-anchored matches on CRLF files with no trailing '\r' in lineText; UTF-16 fixture yields UNSUPPORTED_ENCODING with encoding:'utf-16le'.
- git: on a checkout created with core.autocrlf=true in the system/global scope, git_status after an apply_changes shows only the edited file and git_diff shows the real edit (no whole-file ^M churn); a repo owned by a different SID (simulate with a directory created under runner elevation if feasible, else document manual) is still isRepo:true via safe.directory; commit with 600 staged paths succeeds (pathspec-from-file); `-c core.longpaths=true` present in every read-only argv (unit test).
- LSP: `juju lsp add go --command gopls` resolves gopls.exe on the runner (Go is preinstalled on windows-latest) and returns symbols/diagnostics; a `.cmd` registration fails fast with the node.exe hint; pyright registered as node.exe + its .js entry publishes diagnostics (URI key canonicalisation test).
- Packaging: `npm pack` -> install into a temp prefix on Windows -> `juju --version`, `juju doctor` (all checks ok except sandbox), and an MCP stdio handshake through `node <prefix>\node_modules\juju-mcp\dist\cli\main.js stdio` using the real @modelcontextprotocol/client StdioClientTransport.
- Manual acceptance on a real Windows 11 x64 machine (recorded in TEST_REPORT.md): second local user cannot connect to the pipe or read the token; Defender real-time scanning on during a large apply_changes + rollback (no failures or only typed retryable ones); OneDrive-synced workspace read/list behaviour; Claude Desktop and Cursor configs from the README connect; Ctrl+C, console close and `juju stop` all leave no orphan jobs and a clean journal on next start; Task Manager hard kill followed by restart reconciles jobs as interrupted_on_restart.

## Overall effort estimate

About 40 engineer-days as planned (range 30–50), assuming one engineer who knows the codebase and can iterate against windows-latest CI plus a borrowed Windows 11 machine for the final acceptance. The biggest uncertainties are: (1) the shell/batch layer — cmd.exe quoting, npm.cmd routing and run_task behaviour are where hidden edge cases live, and Git-bash-vs-cmd behaviour differences may force test rewrites; (2) Windows CI flakiness from AV/indexer file locks and process-tree timing, which historically costs days of retry logic and skip tuning; (3) the team currently has no Windows machine, so every debug cycle goes through CI (slow) until a local box is available; (4) unknown unknowns in dependencies not yet exercised on Windows (oidc-provider/express are portable in principle but untested here; better-sqlite3 ARM64). If the team drops external-LSP Windows support and the git system-config reinjection to a follow-up release, the floor is ~30 days; if cmd.exe mode must be first-class (no Git for Windows) with full quoting rather than validation-and-reject, expect the ceiling.

## Disputed findings — handle while implementing, not as facts

Treat all three disputed items as 'verify while implementing the step that touches them', not as independent work items or documented facts. (1) ipc/server.ts:9 stale-socket cleanup — both skeptics agree it is dead today and only matters after the pipe port; in the IPC step skip the existsSync/unlinkSync block on win32 unconditionally (never probe a live pipe name with file APIs), and add the windows-only integration test that a second `juju start` on the same workspace fails with a clear error while the first server keeps answering; if the observed error code differs from EADDRINUSE on windows-latest, map whatever appears and record it in COMPATIBILITY.md. (2) ipc/server.ts:39 chmod on the pipe — skip on win32, never rely on it or on the pipe DACL for access control anywhere; the per-boot token is the owner-only mechanism on Windows, and the residual (Everyone read on the default pipe DACL, cannot submit requests) is documented in SECURITY.md and checked once by hand with a second local user in manual acceptance, because CI cannot create a second user session. (3) planner.ts:249 captured mode — make no code change; add a comment that on win32 `mode` is only the READONLY attribute and that replaying 0o444 is correct preservation; the real failure is the rename-over (applier.ts:464) handled by the atomic-write step, and its test must assert both that a read-only original stays read-only after modify and that the destination's READONLY attribute is cleared only for the rename itself. General rule for the whole port: where a skeptic note and observed behaviour on windows-latest disagree, trust the runner, write the observed behaviour into the test and into COMPATIBILITY.md, and do not extrapolate from macOS or from libuv source reading alone.

- `src/ipc/server.ts:9` — if (fs.existsSync(socketPath)) { try { fs.unlinkSync(socketPath); } catch {} } — stale-socket cleanup (confirmed only by the semantics lens)
- `src/ipc/server.ts:39` — fs.chmodSync(socketPath, 0o600); inside the listen callback (confirmed only by the semantics lens)
- `src/services/changes/planner.ts:249` — `mode: stat.mode & 0o777` captured into the plan (0o666/0o444 synthesized by libuv on win32) (confirmed only by the impact lens)

## Appendix A — confirmed blockers by subsystem

### paths-ipc-store

| File:line | Construct | Verifier notes |
|---|---|---|
| `src/config/paths.ts:82` | if (process.platform === 'win32') return candidate; // returns <configDir>\ipc\<hash>.sock as the IPC endpoint | [semantics] Confirmed. net.Server.listen(string) on win32 goes to uv_pipe_bind2 -> CreateNamedPipeW, which only accepts \\.\pipe\ names; libuv remaps ERROR_INVALID_NAME/ERROR_PATH_NOT_FOUND to EACCES, so the server emits 'error' (listen EACCES) and startIpcServer rejects. The comment 'handled elsewhere' is false: grep of src/ finds no \\.\pipe anywhere. Fix (return `\\\\.\\pipe\\juju-${short}` on win32) is sound; pipe names are machine-global and case-insensitive, and `short` derives from the install secret so it is not guessable. Also note the `ipc` subdir (paths.ts:39) becomes unused on win32.<br>[impact] Confirmed. Node documents that on Windows a local-domain path must live in \\?\pipe\ or \\.\pipe\; libuv's uv_pipe_bind maps ERROR_INVALID_NAME/ERROR_PATH_NOT_FOUND to EACCES, so listen() emits 'listen EACCES' for a C:\...\.sock path. Grep of src/ finds no \\.\pipe anywhere, so the 'handled elsewhere' comment is false. Both entry points await startIpcServer unconditionally (src/server/stdioServer.ts:61-62, src/server/appServer.ts:224-225), so `juju start` and `juju stdio` cannot complete on Windows. docs/COMPATIBILITY.md:63 already says Windows is roadmap-only, but for a Windows user this is a hard stop. Fix belongs here; also note the unused ipc/ subdir (paths.ts:39) and the path unlinks at stdioServer.ts:85 / appServer.ts:250 once a pipe name is used. |
| `src/ipc/server.ts:37` | server.listen(socketPath, ...) with the value from ipcSocketPath() | [semantics] Confirmed; same root cause as #0. Both entry points await it unconditionally (stdioServer.ts:61-62, appServer.ts:224-225), so `juju start` and the stdio server cannot come up on Windows. The reader's EADDRINUSE claim for a pre-existing pipe is also right: libuv creates the first instance with FILE_FLAG_FIRST_PIPE_INSTANCE and maps ERROR_ACCESS_DENIED to UV_EADDRINUSE. No independent fix needed beyond #0; the suggested defensive assertion is optional.<br>[impact] Confirmed, but it is the same defect as index 0 (duplicate root cause; no independent fix in this file beyond an optional assertion). server.once('error', reject) at line 36 turns the libuv EACCES into a rejected startIpcServer() promise, which propagates out of startServer()/startStdioServer() and aborts both entry points. Reader's error code (ENOENT/INVALID_NAME) is slightly off — libuv reports EACCES — but the outcome is as claimed. With a real pipe name, an existing pipe correctly yields EADDRINUSE (FILE_FLAG_FIRST_PIPE_INSTANCE). |
| `src/ipc/client.ts:9` | const conn = net.createConnection(socketPath); | [semantics] Confirmed; same root cause as #0. uv_pipe_connect -> CreateFileW on a nonexistent filesystem path fails ERROR_FILE_NOT_FOUND -> ENOENT, so line 36 prints 'start it with `juju start`', which itself fails (#1). Every command routed through ipcForCwd (cli/main.ts:55-58) is unusable. Minor correction to the reader's aside: libuv's ERROR_PIPE_BUSY retry loop uses WaitNamedPipeW(30000), i.e. up to 30 s, not ~3 s, but the client's 10 s timer (line 12) fires first anyway.<br>[impact] Confirmed; same root cause as index 0. On Windows libuv opens the string with CreateFileW; the .sock file never exists (nothing creates it), so ERROR_FILE_NOT_FOUND -> ENOENT -> line 36 prints 'start it with `juju start`', which itself fails. The CLI does reach this call because bootstrapWorkspace() writes the install secret (bootstrap.ts:63) before the IPC listen fails, so workspaceIpcPath() in main.ts:33-54 succeeds. Blast radius: status, stop, approve, deny, pending, auth pending/approve/deny/list/revoke, recover (all via ipcForCwd, main.ts:56-58). Non-IPC commands (init, doctor, trust, auth add-client/clients, audit, lsp add/list/remove) are unaffected by this particular bug. |

### jobs-sandbox

| File:line | Construct | Verifier notes |
|---|---|---|
| `src/services/jobs/jobManager.ts:98` | if (st.isFile() && (st.mode & 0o111) !== 0) return candidate; | [semantics] Correct. libuv's win32 fs__stat_handle sets only S_IREAD/S_IWRITE (replicated to group/other) → regular files report 0o100666 or 0o100444, never an execute bit, so the predicate is always false. Every bare name (node, git, python, and the 'npm' program run_task recipes hardcode at src/services/overview.ts:103) ends in NOT_FOUND, even 'git.exe'. src/services/lsp/lspService.ts:600 already shows the right guard (process.platform !== 'win32' && mode test).<br>[impact] Confirmed. libuv's win32 stat only ever sets read/write bits (0o100666 or 0o100444), so the predicate is always false and resolveProgram() rejects every bare name including an explicit 'git.exe'. Takes down exec_command for bare names and run_task entirely (every discovered recipe is program:'npm', overview.ts:103). The repo already uses the right guard for this exact test at lspService.ts:600 and main.ts:147, so the fix is a one-line copy. Latent today only because juju start/stdio die earlier at the filesystem-path IPC listen (ipc/server.ts:37; COMPATIBILITY.md:63 says Windows is roadmap), but blocker once reached. |
| `src/services/jobs/jobManager.ts:95` | const candidate = path.join(dir, program); | [semantics] Correct. statSync('C:\\Program Files\\Git\\cmd\\git') is ENOENT because the file is git.exe; only a name already carrying its extension can be found once line 98 is fixed. Nuance: libuv's own search_path appends .com/.exe (not PATHEXT) only when given an extension-less name, but JobManager hands spawn an absolute, already-stat'ed path, so libuv cannot rescue this — resolution must iterate PATHEXT itself, and .bat/.cmd hits then run into finding 2.<br>[impact] Confirmed. path.join(dir,'git') names a file that does not exist on Windows (git.exe/npm.cmd/python.exe). Masked today by index 0 (the mode test fails first), but independently fatal once that is fixed. Note libuv's own uv_spawn would probe name.com/name.exe, but JUJU stats the candidate itself before ever calling spawn, so nothing compensates. Same fix site as index 0; PATHEXT iteration is required. |
| `src/services/jobs/jobManager.ts:158` | shell: false, (spawn of a resolved .cmd/.bat file) | [semantics] Correct for Node >=22.12 (engines). Node's IsWindowsBatchFile() (case-insensitive, trailing dots/spaces ignored) makes _handle.spawn return UV_EINVAL, which the JS routes to the synchronous-throw branch (only EACCES/EAGAIN/EMFILE/ENFILE/ENOENT are deferred to 'error'), so line 162 yields INTERNAL_ERROR 'spawn failed: spawn EINVAL'. Masked today for bare names (findings 0/1 fail first) but reachable NOW via the workspace-path branch (exec_command program 'scripts/build.cmd' or 'node_modules/.bin/tsc.cmd'), and it is the fate of run_task's npm recipe (npm.cmd) once 0/1 are fixed.<br>[impact] Confirmed for Node >=20.12.2/22.x (CVE-2024-27980 guard): spawn throws EINVAL synchronously for .bat/.cmd without shell. Reachable TODAY via the explicit-path branch (exec_command program:'node_modules/.bin/tsc.cmd' or 'scripts\\build.cmd' passes line 85-91 then hits this) -> INTERNAL_ERROR 'spawn failed: spawn EINVAL' via the catch at 162. After indexes 0/1 are fixed, run_task (npm.cmd) and exec_command('npm'\|'npx'\|'yarn'\|'pnpm') still cannot work, so run_task stays 100% dead on Windows; direct .exe programs (node, git, python) would work. Blocker for run_task, degraded for exec_command. |
| `src/services/jobs/jobManager.ts:110` | for (const candidate of ['/bin/bash', '/usr/bin/bash', '/bin/sh']) { fs.statSync(candidate) } | [semantics] Correct. On win32 '/bin/bash' resolves against the current drive (C:\\bin\\bash) which does not exist, so shellPath() always throws NOT_FOUND and run_command/run_commands are unusable. Wider reach the reader missed: environment_info (src/tools/agentTools.ts:138) calls s.jobs.shellPath() unconditionally, so that read-only diagnostics tool also fails on Windows (see missed list). The suggested ComSpec / Git-bash fix with per-shell flag sets is sound.<br>[impact] Confirmed. On win32 fs.statSync('/bin/bash') resolves drive-relative to <cwd drive>:\bin\bash, which does not exist on any normal machine, so shellPath() always throws NOT_FOUND and run_command / run_commands (described as 'the everyday tool') are unusable; Git-bash, %ComSpec% and PowerShell are never considered. Nothing guards it. See missed entry on the same line for the trust hazard the port must respect. |

### git-search-intel-lsp

| File:line | Construct | Verifier notes |
|---|---|---|
| `src/services/lsp/lspService.ts:584` | const candidate = path.join(dir, cmd); if (this.isTrustedExecutable(candidate)) ... (bare-name PATH loop 581-587) | [semantics] Confirmed with a correction. JUJU pre-resolves the bare name itself and stat()s it with no extension, so native servers (gopls.exe, rust-analyzer.exe, clangd.exe) are never found and resolveCommand/available() report 'not found on the trusted PATH' for exactly the commands docs/LSP.md tells owners to register. Correction: npm-installed servers do NOT hit this branch - cmd-shim writes an extensionless sh script next to the .cmd/.ps1, so statSync succeeds, the win32 branch at :600 skips the mode test, resolveCommand returns ok, and the failure moves to spawn (see finding 1). Net effect is the same: no documented registry entry works on Windows out of the box. A workaround exists (spell 'gopls.exe' / 'node.exe' + script path) but is undocumented and the error gives no hint, so I keep blocker at feature level. Proposed fix (try .exe/.com on win32, require .exe/.com in isTrustedExecutable) is correct.<br>[impact] fs.statSync('C:\\Go\\bin\\gopls') throws when only gopls.exe exists; Windows never auto-appends extensions for stat, and neither does this loop. Every documented registration (docs/LSP.md:14-20, `juju lsp add go --command gopls`) yields 'not found on the trusted PATH' from both available() and ensureServer(), so the LSP entry point is dead as shipped. The only escape is the undocumented `gopls.exe` spelling for native servers; npm servers have no bare-name path at all (see missed[0] — the loop picks the extension-less sh shim and then spawn ENOENTs). libuv would have found gopls.exe on its own, but JUJU pre-resolves and refuses before spawn. Upgraded reasoning: workaround exists but is undocumented and the documented path fails 100%. |

### cli-server-tests

| File:line | Construct | Verifier notes |
|---|---|---|
| `src/config/paths.ts:82` | if (process.platform === 'win32') return candidate; // named pipes handled elsewhere | [semantics] Correct. libuv uv_pipe_bind calls CreateNamedPipeW with the given string; a filesystem path like C:\...\ipc\<hash>.sock is not a \\.\pipe\ / \\?\pipe\ name, so the bind fails and net.Server emits 'error' (EACCES/ENOENT), rejecting startIpcServer. Nothing else in the tree handles pipes (grep for win32 confirms). Single root cause for verdicts 1-4, 8, 9, 12. The hash already comes from mintWorkspaceId (SHA-256 keyed by the install secret), so a \\?\pipe\juju-<user>-<short> name would not be guessable.<br>[impact] Returns <APPDATA>\juju\ipc\<hash>.sock; grep shows the only consumers are appServer.ts:224 and stdioServer.ts:61 (net.Server.listen) and main.ts:50 -> client.ts:9 (net.createConnection). Nothing creates a \\.\pipe\ name. On win32 libuv uv_pipe_bind calls CreateNamedPipeW with that string, which rejects non-pipe names (ERROR_INVALID_NAME/PATH_NOT_FOUND mapped to EACCES). This is the single root cause behind findings 1-4, 8 and 9; the fix belongs here. |
| `src/ipc/server.ts:37` | server.listen(socketPath, ...) after existsSync/unlinkSync (9-15) and chmodSync(socketPath, 0o600) (39) | [semantics] Correct. listen() rejects on win32 for a non-pipe name; existsSync/unlinkSync at 9-15 and chmodSync at 39 are file semantics that are dead or caught no-ops for a pipe. One post-fix caveat the fix text already covers: existsSync on a LIVE \\?\pipe\ name returns true and unlinkSync would CreateFileW-open another running server's pipe instance, so the 9-15 block must be skipped on win32, not merely tolerated.<br>[impact] listen() on a filesystem path fails on win32 (EACCES), the 'error' listener rejects the promise and both startServer and startStdioServer throw. existsSync is false so the unlink block is skipped; chmod is inside try/catch. Confirmed: no IPC server can ever come up on Windows with this code. |
| `src/server/appServer.ts:224` | const ipcPath = ipcSocketPath(configDir, workspaceId); const ipcServer = await startIpcServer(ipcPath, ...) | [semantics] Correct. HTTP is bound at 219, then the IPC bind rejects with a raw Node Error (not a JujuError) so the start action rethrows; program.parseAsync().catch at main.ts:633 calls fail(err.message) -> `juju: listen EACCES ...` and exit 1, tearing the port down with the process. fs.unlinkSync(ipcPath) at 250 is inside try/catch and harmless. Starting IPC before HTTP would avoid a bound-then-dropped port.<br>[impact] HTTP is bound at :219 first, then startIpcServer rejects. The rejection is a Node ErrnoException, not a JujuError, so main.ts:94-96 rethrows, parseAsync's catch calls fail() and the process exits 1 (the bound port is released with the process). `juju start` therefore cannot complete on Windows. unlinkSync at :250 is in try/catch and harmless. Fixed transitively by finding 0. |
| `src/server/stdioServer.ts:61` | const ipcPath = ipcSocketPath(ws.configDir, ws.workspaceId); const ipcServer = await startIpcServer(ipcPath, ...) | [semantics] Correct. serveStdio over process.stdin/stdout pipes is fine on Windows, but the IPC bind rejects, main.ts:633 fail()s, and the MCP client sees the server exit(1) on launch. The implicit-stdio path (main.ts:626-629, `juju` with piped stdin) lands here too, so a Windows Claude Desktop/Cursor user cannot use JUJU at all. Making IPC non-fatal for stdio mode is a reasonable mitigation.<br>[impact] serveStdio (:59) is already running when startIpcServer rejects; the error propagates out of startStdioServer to main.ts:523-525 -> fail() -> exit, so an MCP client sees the server die immediately after launch. Also kills the implicit no-args mode (main.ts:629 appends 'stdio' when stdin is piped). Since stdio is the transport a Windows user would actually use, this is the most user-visible blocker. Making IPC non-fatal for stdio is the only mitigation independent of the pipe fix. (Side note: per Node docs pipe stdout is synchronous on Windows; the reader's drain caveat applies to macOS.) |
| `src/cli/main.ts:50` | return { ipcPath: ipcSocketPath(dir, wsId), ... } -> ipcCall(ipcPath) -> net.createConnection(socketPath) | [semantics] Correct (transitive). uv_pipe_connect does CreateFileW(name, GENERIC_READ\|GENERIC_WRITE); the .sock file never exists, so ERROR_FILE_NOT_FOUND -> ENOENT -> client.ts:36 'no running JUJU server'. status/stop/approve/deny/pending/auth */recover all misreport. Note CLI-05 (expects that message) passes coincidentally. No main.ts change needed once ipcSocketPath returns a pipe name; the ENOENT mapping stays valid for a missing pipe.<br>[impact] createConnection on a non-existent filesystem name on win32 -> uv_pipe_connect/CreateFileW -> ENOENT -> client.ts:36 maps to 'no running JUJU server'. Because findings 0-3 mean no server can start on Windows, every IPC-backed command (status, stop, approve, deny, pending, auth pending/approve/deny/list/revoke, recover) is dead; the message merely happens to be true. Non-IPC commands (init, doctor, trust, audit, auth add-client/clients, lsp *) work on Windows. No change needed here once paths.ts returns a pipe name. |
| `src/services/jobs/jobManager.ts:109` | shellPath(): probes '/bin/bash', '/usr/bin/bash', '/bin/sh'; spawnArgs = ['-c', req.program] (131) | [semantics] Correct. On win32 '/bin/bash' is drive-relative (C:\bin\bash); even where Git Bash exists the extensionless name cannot stat bash.exe, so shellPath throws NOT_FOUND before spawn and run_command / run_commands never execute. Fix guidance is sound: Git Bash keeps `-c` semantics; otherwise %ComSpec% /d /s /c with windowsVerbatimArguments (Node's own shell:true quoting for cmd.exe is what CVE-2024-27980 was about). The chosen shell kind should be surfaced (see verdicts 24/13).<br>[impact] On win32 those strings are drive-relative (C:\bin\bash) and absent on a stock machine and even with Git for Windows (C:\Program Files\Git\bin\bash.exe), so start() throws NOT_FOUND at :130 for every shell:true request. run_command (jobTools.ts:268/273) and run_commands (:369/374) can never execute on Windows. commandSandbox defaults to 'off' (globalConfig.ts:32) and sandboxWiring returns undefined for 'prefer', so the sandbox adapter is NOT an additional Windows blocker; the shell is. |
| `src/tools/agentTools.ts:138` | shell: s.jobs.shellPath(), | [semantics] Correct. The JujuError from shellPath() propagates out of the handler after all 16 probes have already run, so environment_info fails as a whole on win32 even though os/arch/cpus/tools/capabilities were computable. Tool-level blocker; a one-line try/catch (shell: string\|null plus a shellKind) fixes it and is a prerequisite for the agentTools.test.ts:181 assertions.<br>[impact] Evaluated unconditionally inside the returned data literal, so the NOT_FOUND from finding 5 turns every environment_info call into an isError result on Windows although os/arch/cpus/probes/capabilities were all computed successfully. The tool cannot work at all on Windows; a try/catch with shell:null (plus a shellKind) fixes it independently of the shell decision. |
| `src/services/jobs/jobManager.ts:94` | path.join(dir, program); accept only if st.isFile() && (st.mode & 0o111) !== 0 | [semantics] Correct on both counts. libuv's fs__stat sets st_mode to S_IFREG plus rw (or r-only when FILE_ATTRIBUTE_READONLY) for every regular file and never sets execute bits, so the 0o111 test rejects everything; and `node`/`git`/`rg` exist only as node.exe etc., so statSync(dir/node) is ENOENT anyway (the extensionless `npm` sh script shipped next to npm.cmd would be found but fail the exec test). lspService.ts:600 already has the right guard. Post-fix, .cmd/.bat hits must go through cmd.exe: Node >=18.20/20.12/21.7 throws EINVAL synchronously from spawn() for a .cmd/.bat file with shell:false.<br>[impact] path.join(dir,'node') never matches node.exe (no PATHEXT probing), and libuv's win32 stat reports 0o666/0o444 for regular files (never an exec bit), so even an extensionless hit (npm's sh wrapper beside npm.cmd) fails the 0o111 test. Result: NOT_FOUND for every bare program -> exec_command (jobTools.ts:48) and run_task (jobTools.ts:104; package.json recipes are program 'npm', overview.ts:103) can never start a process on Windows. lspService.ts:600 already guards the same check; sandbox.ts:64 has the same test but only runs on darwin/linux. Note for the fix: a PATHEXT hit that is .cmd/.bat cannot be spawned with shell:false on Node 22 (EINVAL), so those need cmd.exe wrapping. |
| `tests/helpers/testServer.ts:89` | server = await startServer({ invokedCwd, portOverride: port, quiet: true }) | [semantics] Correct (transitive). launch() awaits startServer, which rejects at the IPC bind, so every suite using launch() fails in beforeAll/beforeEach. The helper itself is portable (127.0.0.1 ephemeral ports, mkdtemp under os.tmpdir()). No test-side fix exists.<br>[impact] launch() -> startServer rejects at the IPC bind (findings 0-2), so beforeAll/beforeEach of every integration and security suite that calls launch() fails before a single assertion runs. The helper is otherwise portable (os.tmpdir mkdtemp, 127.0.0.1 ports, process.env JUJU_CONFIG_DIR). Test-infra consequence of the product blocker; no test-side fix exists. |
| `tests/integration/cli.test.ts:122` | spawn('node', [CLI, 'start', '--port', ...]) then waitFor(runCli(['status','--json'])) | [semantics] Correct (transitive). The child prints `juju: listen ...` and exits 1; `status` cannot connect; waitFor times out after 15 s and the child log is attached to the failure. Upstream cause; keep the test.<br>[impact] The child `juju start` exits at the IPC bind and `status` exits 2 for the whole 15 s window, so waitFor throws with the child log. spawn('node') itself is fine on win32 (libuv resolves node.exe). Pure upstream consequence; keep the test. |
| `tests/integration/jobs.test.ts:40` | exec_command { program: 'node', ... } (40, 52, 94, 109, 123, 135, 156) and run_task npm:hello (71) | [semantics] Correct (transitive of verdict 7). 'node' -> NOT_FOUND from resolveProgram; run_task's recipe is program 'npm' (overview.ts:103) -> npm.cmd -> NOT_FOUND. Fixtures are portable. Post-fix JOB-07/JOB-13 pass: status 'canceled' is written before signalling, and after TerminateProcess libuv reports term_signal=SIGTERM so the job closes. JOB-10's literal-argv assertion holds because Node's CreateProcess quoting round-trips through node.exe.<br>[impact] Every call that reaches jobs.start hits resolveProgram NOT_FOUND on Windows (finding 7). Only JOB-01 (policyGate throws first), the stale-digest case and JOB-12 (expects NOT_FOUND) would pass; 9 of 12 cases fail, so the file is effectively blocked. Fixtures (loop.js, echo.js, `node -e` npm script) are portable. Cancel expectations (JOB-07/13) pass after the fix because status is set to canceled before signalling (jobManager.ts:371). |

## Appendix B — findings the skeptics added (severity blocker/degraded)

- **degraded** `src/store/db.ts:14` — `root TEXT NOT NULL` — workspace identity keyed on a non-canonicalised win32 path string (hashed in identity.ts:12, matched with case-sensitive `WHERE root = ?` in store.ts:180, and used to derive the IPC name via ipcSocketPath)
  - why: root.ts:24 uses the JS `fs.realpathSync`, which on win32 walks components with lstat/readlink and expands junctions and symlinks but does NOT normalise casing, 8.3 short names (C:\PROGRA~1) or SUBST drives; only `fs.realpathSync.native` (uv_fs_realpath -> GetFinalPathNameByHandleW) returns the canonical on-disk path. process.cwd() reflects whatever casing the launching shell or MCP host config used (cmd.exe/PowerShell keep typed casing; libuv only uppercases the drive letter). NTFS is case-insensitive, so `C:\Users\me\proj` and `c:\users\ME\proj` are the same directory but produce different workspace ids (different trust state, grants, approvals) and different IPC endpoints, so `juju status` from a differently-cased shell reports 'no running JUJU server'. The reader's fix for finding #8 assumed the opposite.
  - fix: In root.ts use `fs.realpathSync.native(candidate)` (at least on win32) before hashing/storing so `root` is canonical; consider storing a normalised comparison key for findWorkspaceByRoot rather than a COLLATE NOCASE column (which would be wrong on POSIX). Add a doctor note when the invoked cwd differs from the canonical path only by case.
- **degraded** `src/config/globalConfig.ts:43` — lsp record `command: z.string().min(1).max(512)` — documented (line 37) as a bare program name resolved on the trusted PATH
  - why: The reader mentioned the .cmd-shim problem only in notes, but it degrades the whole owner-LSP feature on Windows. lspService.resolveCommand (src/services/lsp/lspService.ts:581-587) joins `<dir>/<cmd>` with no PATHEXT/.exe probing, so `--command pyright-langserver` (the example at src/cli/main.ts:561) resolves to npm's extensionless sh shim and spawn fails with ENOEXEC; `--command node` is never found because only node.exe exists; and `pyright-langserver.cmd` is rejected by Node's shell:false spawn (EINVAL, CVE-2024-27980 hardening) in jsonrpc.ts:102-105. The only working form is an absolute `C:\...\node.exe` plus the server's .js entry in args, which nothing documents. Built-in TypeScript is unaffected (in-process worker).
  - fix: Document in the `lsp` schema comment and `juju lsp add` help that on Windows `command` must be an .exe (absolute or bare with extension) — e.g. node.exe + the language server's JS entry — and have lspService.resolveCommand probe `.exe`/`.com` candidates on win32 while refusing `.cmd`/`.bat`.
- **degraded** `src/services/jobs/jobManager.ts:109` — shellPath() is also called eagerly by environment_info (src/tools/agentTools.ts:138: shell: s.jobs.shellPath())
  - why: The reader scoped shellPath() to run_command, but the read-only environment_info tool — the one an agent would use to discover that it is on Windows — calls shellPath() unconditionally while building its result, so on win32 the whole tool throws NOT_FOUND 'no POSIX shell found' instead of reporting os:'win32' and the probe results.
  - fix: Make environment_info tolerate a missing shell (catch and report shell:null with the reason), or have shellPath() return a nullable/platform-aware value rather than throwing during diagnostics.
- **degraded** `src/services/changes/planner.ts:157` — const bytes = Buffer.from(next, 'utf8') where next derives from current.text decoded by TextDecoder('utf-8',{fatal:true})
  - why: util/bytes.ts:44 uses TextDecoder with the default ignoreBOM:false, which silently strips a leading U+FEFF. current.text (planner.ts:248) therefore has no BOM, so replace_exact (156-157) and span_edit (176-193) write the file back WITHOUT its BOM, and bytesBefore/bytesAfter disagree by 3. UTF-8-with-BOM files are far more common on Windows (Visual Studio .cs/.csproj/.resx, PowerShell 5.1 scripts that need a BOM for non-ASCII, legacy Notepad), so every small edit in such a repo produces a whole-first-line git diff and can break PowerShell 5.1 script encoding. Related: UTF-16LE files (PowerShell 5.1 Out-File/redirect default, .reg files) contain NUL bytes and hit looksBinary -> a typed 'file appears to be binary' error, which is acceptable but worth documenting.
  - fix: Decode with new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }) in util/bytes.ts:44 so the BOM survives as text (and re-serializes), or detect EF BB BF on the raw bytes in readCurrent and re-prepend it before Buffer.from(next). Report bom:true in ReadItemResult so models know line 1 starts with U+FEFF. Add a test that replace_exact on a BOM file preserves the first three bytes.
- **degraded** `src/services/changes/applier.ts:464` — fs.renameSync(tmp, abs) immediately after closeSync(fd) on a freshly created temp file
  - why: Independent of the destination's state (finding 13 is about the target), Windows antivirus and the Search indexer routinely open a just-closed NEW file for a few milliseconds without FILE_SHARE_DELETE; MoveFileExW on that SOURCE then fails with ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED (EBUSY/EPERM). This hits plain creates (no destination at all) and is the classic 'EPERM: operation not permitted, rename' that npm/yarn/webpack retry around. The same applies to copyFileSyncDurable at 488 and the temp-cleanup unlink at 467.
  - fix: Use the same renameOverSync helper proposed for finding 13 with a short bounded retry (e.g. 5 attempts, 10-100 ms backoff) on EPERM/EBUSY/EACCES/ENOTEMPTY for both source-lock and target-lock cases; surface the final error with a recovery hint naming AV/indexer locks.
- **degraded** `src/workspace/fs.ts:180` — `decodeUtf8Strict(bytes)` -> `new TextDecoder('utf-8', { fatal: true })` with default `ignoreBOM: false`, which strips a leading UTF-8 BOM
  - why: Probed in this Node: decoding EF BB BF 61 62 yields a 2-char string and re-encodes to 2 bytes. planner.readCurrent (planner.ts:248) uses the same decoder, so replace_exact (`current.text.split(find).join(replace)`, line 156) and span_edit (`Buffer.from(current.text,'utf8')`, line 176) rewrite every BOM-prefixed file WITHOUT its BOM -- a silent 3-byte change unrelated to the requested edit that shows up as a first-line git diff. span_edit byte offsets are also relative to the BOM-stripped text, 3 bytes off the on-disk layout. Windows relevance is high: Visual Studio saves .cs/.csproj/.resx/.sln with a BOM by default, older Notepad and PowerShell 5.1 `-Encoding utf8` write one, and Windows PowerShell 5.1 needs the BOM to parse non-ASCII .ps1 files as UTF-8, so dropping it can change script behaviour. read_files' `hash` is over raw bytes so expectedHash checks still pass, which is why this goes unnoticed.
  - fix: Use `new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })` so U+FEFF round-trips through text -> `Buffer.from(text,'utf8')` (it re-encodes to EF BB BF), or detect the BOM in readCurrent and re-prepend it when building `bytes` for replace_exact/span_edit; add a test that editing a BOM file preserves the first three bytes and that read_files reports `bom: true`.
- **degraded** `src/services/search/searchWorker.ts:54` — const text = buf.toString('utf8'); (regex fallback worker)
  - why: Buffer.toString keeps a leading UTF-8 BOM (U+FEFF), whereas the literal backend's decodeUtf8Strict (TextDecoder, default ignoreBOM:false) and ripgrep (bom sniffing + strip_bom) both strip it. BOM-prefixed files are common on Windows (Notepad, Visual Studio, PowerShell). In the worker, '^'-anchored patterns silently miss line 1 of such files, and line-1 columns are +1 relative to the other two backends, so the same query returns different results depending on which backend ran.
  - fix: Strip a leading EF BB BF (or a leading '﻿' after decode) before splitting, mirroring TextDecoder's behaviour; combine with the /\r?\n/ split from finding 12.
- **degraded** `src/services/git/gitService.ts:72` — fullArgs = ['-C', root, '-c', 'core.fsmonitor=false', ...] with no core.longpaths on win32; :69-70 drop global/system config
  - why: Git for Windows refuses paths longer than 260 chars unless core.longpaths=true, which users set in the global or system config - exactly the scopes read-only runs discard via GIT_CONFIG_NOSYSTEM and GIT_CONFIG_GLOBAL=NUL. In deep monorepos on Windows, git status emits 'warning: could not open directory ...: Filename too long' and silently omits those entries, and git diff on such paths fails, while commit (userConfig:true) works - an inconsistent, platform-specific view.
  - fix: When process.platform === 'win32', push '-c', 'core.longpaths=true' into fullArgs for every run (it is a data-only key with no execution surface).
- **degraded** `src/services/git/gitService.ts:69` — env['GIT_CONFIG_NOSYSTEM'] = '1' + GIT_CONFIG_GLOBAL=NUL also discard every safe.directory entry; isRepo() at 131-134 maps the resulting fatal to isRepo:false
  - why: Git's dubious-ownership check (2.35.2+) is only exempted by safe.directory in protected config (system, global, or -c). Ownership mismatches are far more common on Windows (directories created by elevated processes are owned by BUILTIN\Administrators, network/OneDrive shares, WSL mounts), and users fix them with `git config --global --add safe.directory`, which read-only runs ignore. rev-parse then fails with 'detected dubious ownership' and status/diff/log silently report isRepo:false while commit (userConfig:true) succeeds.
  - fix: Pass '-c', `safe.directory=${this.wfs.root}` on read-only runs (the command line counts as protected config and the workspace root is already trusted by policy), or surface the stderr instead of collapsing the probe failure into isRepo:false.
- **degraded** `src/services/lsp/lspService.ts:600` — if (process.platform !== 'win32' && (st.mode & 0o111) === 0) return false; — no extension/PE check on win32
  - why: On win32 any regular file outside the workspace passes. npm installs three shims side by side (%APPDATA%\npm\pyright-langserver, .cmd, .ps1); the extension-less one is a POSIX sh script, so the bare-name loop at 584 finds it and returns ok:true. available() then advertises the language, and ensureServer spawns that absolute path — but libuv's path_search_walk_ext tries only .com/.exe for an extension-less name, so uv_spawn returns ENOENT and lspService.ts:672 reports 'cannot start language server "pyright-langserver": spawn C:\...\npm\pyright-langserver ENOENT' for a file that visibly exists. This contradicts finding 0's 'always not found' and makes the failure mode inconsistent between native and npm servers.
  - fix: On win32 make isTrustedExecutable require path.extname(abs).toLowerCase() in {'.exe', '.com'} (so .cmd/.ps1/extension-less shims are rejected with a clear reason), and have resolveCommand probe `${cmd}.exe`/`${cmd}.com` for bare names; when only a .cmd shim is found, return ok:false with the `command: 'node.exe', args: [<shim target .js>, '--stdio']` hint.
- **blocker** `src/workspace/fs.ts:115` — cur = path.join(cur, segments[i]); st = fs.lstatSync(cur) after normalizeRel (63-72) and ignores.isSecret(rel) (102)
  - why: NTFS canonicalises names that the string-based secret classifier never sees. `.env::$DATA` (the unnamed data stream) passes normalizeRel (no backslash; the drive check only looks at position 0-1), the `ignore` matcher does not match `.env`, `.env.*` or `*.env` against it, lstat on C:\ws\.env::$DATA succeeds, and read_file returns the contents of .env. Likewise `ENV~1` - the 8.3 short name NTFS generates for `.env` on the system volume by default - resolves to .env and bypasses SECRET_PATH_DENIED. write_file to `README.md:payload` creates a stream invisible to listDir/walk/git. The hard-deny guarantee ('nothing bypasses') is broken on win32; verified DEFAULT_SECRET_PATTERNS starts with '.env' (ignores.ts:18) and isSecret is a pure rel-string match (ignores.ts:127).
  - fix: On win32 (or always) reject ':' anywhere in a segment and verify each existing component exactly against fs.readdirSync(parent) entries (which also defeats 8.3 aliases and case variants), or compare fs.realpathSync.native(abs) with path.join(root, rel) before classification. Add a test that reads '.env::$DATA' and 'ENV~1' and expects SECRET_PATH_DENIED/NOT_FOUND.
- **degraded** `src/services/overview.ts:316` — gitBinaryAvailable(): fs.existsSync('/usr/bin/git') || PATH dirs .some(d => fs.existsSync(path.join(d, 'git')))
  - why: Outside the 13 listed files but on the run_task/project_overview path the reader traced. Same bug class as finding 7: on Windows the binary is git.exe, so path.join(d,'git') never exists and project_overview reports gitAvailable=false (overview.ts:289) for any workspace that is not already a repo, even though GitService spawns bare 'git' through libuv and works. The model is told git is unavailable and avoids git_* tools.
  - fix: On win32 test the PATHEXT candidates (git.exe/git.cmd) or replace the existence scan with a cached spawnSync('git', ['--version'], { shell: false, timeout }) probe like searchService.ts:95 does for rg.
- **degraded** `src/services/jobs/jobManager.ts:88` — explicit-path branch: const resolved = this.wfs.resolve(rel); ... return resolved.abs; then spawn(programAbs, ...) with shell:false
  - why: The reader listed jobManager.ts:85-87 as 'already guarded' because backslashes are accepted, but the resulting spawn is not Windows-viable: a workspace script such as scripts/build.cmd or .bat makes Node 22 throw EINVAL synchronously (caught at :162 -> INTERNAL_ERROR 'spawn failed: spawn EINVAL'), and scripts/run.sh or .js cannot be executed by CreateProcess at all (ERROR_BAD_EXE_FORMAT -> child 'error' -> failed_to_start). exec_command's documented 'workspace-relative script path' form therefore only works for .exe files on Windows, with confusing errors. Latent behind finding 7 today.
  - fix: On win32, route .cmd/.bat workspace scripts through cmd.exe /d /s /c with quoting (or refuse them with a typed NOT_SUPPORTED that names the limitation), and return NOT_SUPPORTED with a recovery hint for extensionless/.sh scripts instead of a raw spawn error; document that script execution on Windows needs an explicit interpreter (e.g. program 'node', args ['scripts/x.js']).

## Appendix C — refuted claims (do not re-report)

- `src/store/db.ts:14` — dev INTEGER NOT NULL, ino INTEGER NOT NULL — workspace identity bound to stat().dev/ino
  - [semantics] Not confirmed as claimed. Kernel of truth: libuv fills st_ino from FILE_INTERNAL_INFORMATION.IndexNumber (NTFS 48-bit MFT index + 16-bit sequence), and Node's non-bigint Stats casts to double, so values with sequence >= 32 exceed 2^53 and round; two directories could then collide. But the rounding is deterministic, so 'a single directory can appear to change between calls' is wrong, and better-sqlite3 binds/reads the same double-exact integer, so the round trip is stable. There is no 'epoch reset': bootstrap.ts:65 mints a fresh epoch every boot and lines 67-71 only log a note when dev/ino differ. FAT32/exFAT on Windows return non-zero synthesized file IDs (dirent position); only some redirectors/virtual filesystems return 0. Consequence is at most a missed or spurious log line. `{bigint:true}` stat is a cheap correctness nicety. The fix's premise that fs.realpathSync 'already returns on-disk casing' is false on win32 for the JS implementation (see missed item on root casing). / [impact] Claimed failure modes do not materialise. The only consumer is src/server/bootstrap.ts:67-71, which merely logs '[juju] note: workspace root identity changed'; the epoch is minted fresh every boot (line 65) regardless, so there is no false 'epoch reset' to trigger and trust is epoch-based, not dev/ino-based. uint64->double conversion in fs.Stats is deterministic, so the same directory yields the same value on every call; better-sqlite3 binds numbers as doubles and INTEGER affinity stores integer-valued doubles losslessly, so stored vs live values compare equal. Two distinct directories colliding after rounding is negligible. ino=0 on FAT/exFAT/some redirectors would only suppress an informational log line. bigint stat is a nice-to-have, not a Windows defect.
- `src/config/globalConfig.ts:51` — envAllowlist: z.array(z.string().regex(/^[A-Z0-9_]{1,64}$/)) — with BASE_ALLOWED in security/env.ts:10
  - [semantics] Not confirmed as claimed. The central assertion (node.exe/python.exe fail at startup with WinSock/CRT errors without SystemRoot; run_command/LSP 'will fail') is wrong for children spawned by Node: libuv's make_program_env (src/win/process.c required_vars) copies HOMEDRIVE, HOMEPATH, LOGONSERVER, PATH, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERDOMAIN, USERNAME, USERPROFILE and WINDIR from the parent into any env block that lacks them (case-insensitive check), and every spawn here passes an env object (jobManager.ts:151-158, jsonrpc.ts:105, gitService.ts:89, searchService.ts:353). Residual, smaller gap: APPDATA, LOCALAPPDATA, TMP, PATHEXT, ComSpec, ProgramData, ProgramFiles(x86) are absent (most tools fall back via USERPROFILE/SHGetKnownFolderPath; PATHEXT only matters for shell:true, which jobTools.ts:268/369 do use, and cmd.exe has a built-in default), and the regex blocks `ProgramFiles(x86)` outright. env.ts:21's POSIX PATH fallback is only reached when PATH is empty or entirely inside the workspace. Worth a small WIN32 baseline and a relaxed regex, but it is parity polish, not a failure. / [impact] The central claim (node.exe/python.exe fail at startup without SystemRoot) is refuted: libuv's uv_spawn on Windows copies its required_vars list (HOMEDRIVE, HOMEPATH, LOGONSERVER, PATH, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERDOMAIN, USERNAME, USERPROFILE, WINDIR) from the parent into any child env that lacks them, so buildChildEnv's minimal env still yields a working process. process.env lookup and Windows env are case-insensitive, so uppercase allowlist entries (APPDATA, LOCALAPPDATA, PROGRAMDATA, COMSPEC, PATHEXT, TMP) work; only ProgramFiles(x86) is blocked by the regex — a papercut. env.ts:21's POSIX PATH fallback is effectively unreachable on Windows (drive-absolute entries survive the filter). Separately, run_command is already blocked on Windows upstream of env: jobManager.ts:98 requires st.mode & 0o111, which libuv never sets for Windows files (every bare program is 'not found'), and jobManager.ts:110 shellPath() only looks for /bin/bash|/bin/sh — outside this scope but it makes the env baseline moot until fixed.
- `src/services/jobs/jobManager.ts:131` — spawnArgs = ['-c', req.program];
  - [semantics] Not an independent Windows failure: line 130 throws before this line is reached on win32, and if the fix for finding 3 picks Git-bash, '-c' remains correct. The technical content is right (cmd.exe needs /d /s /c with one verbatim-quoted token and windowsVerbatimArguments:true; PowerShell needs -NoProfile -NonInteractive -Command) but it is a design constraint of the same change as finding 3, not a separate defect. / [impact] Not an independent Windows defect: this line is unreachable on win32 because shellPath() at line 130 throws first, and '-c' is correct for the only shell shellPath() can currently return. It is a design constraint on the fix for index 3 (cmd.exe needs /d /s /c + windowsVerbatimArguments, PowerShell -Command; Git-bash keeps -c), not a separate breakage. Fold into index 3.
- `src/services/jobs/sandboxWiring.ts:30` — const defaultWritable = [`${home}/.npm`, `${home}/.cache`, …, darwinUserCache];
  - [semantics] Unreachable on win32: sandboxAvailability() returns available:false so wrapInSandbox/resolveWritable never run. Even if reached, path.win32.isAbsolute accepts mixed separators, realpathSync normalises existing entries, and darwinUserCache (C:\\Users\\x\\AppData\\Local\\C) does not exist and is skipped. Accurate as a note for a future adapter, not a current defect — the reader says so too. / [impact] No Windows impact: the wrapper returns undefined at line 41 (or throws NOT_SUPPORTED at 37) before wrapInSandbox() ever consumes the list, and building it at bootstrap (bootstrap.ts:93) does string work only — os.homedir() and path.resolve(os.tmpdir(),'..','C') touch no filesystem. A forward-looking note for a future win32 adapter, not a finding; the reader said as much.
- `src/services/jobs/jobManager.ts:48` — /** Run `program` as a command string through the POSIX shell (`bash -c`). */
  - [semantics] The doc comment is accurate today: Windows is unsupported, shellPath() throws, and docs/COMPATIBILITY.md:63 already says 'Do not claim support'. Nothing is wrong until a Windows shell is wired; reword (and add shellKind) as part of that change, not before. / [impact] The wording is accurate for the shipped implementation — it really is bash -c, and on Windows the observable behaviour (NOT_FOUND 'no POSIX shell found') is consistent with it. Only becomes wrong after a Windows shell is wired (index 3 fix), at which point the tool description at jobTools.ts:236 needs the same rewording. Not a current Windows defect.
- `src/services/jobs/jobManager.ts:155` — spawn(programAbs, spawnArgs, { …, detached:true, stdio:['pipe','pipe','pipe'] }) — no windowsHide
  - [semantics] No current Windows effect: detached:true → DETACHED_PROCESS, so the child gets no console and no window can appear, and CREATE_NO_WINDOW is ignored when combined with DETACHED_PROCESS. Only relevant if finding 5's fix drops detached — then windowsHide:true is needed for console-less hosts. Adding it now is a harmless no-op on both platforms. / [impact] Not reachable today: detached:true maps to DETACHED_PROCESS, so the child gets no console at all and no window can appear (barring an explicit AllocConsole). The claim is conditional on dropping detached (the index-5 fix). Adding windowsHide:true is harmless, ignored on POSIX, and matches what jsonrpc.ts:107 already does for LSP children — do it as part of the lifecycle fix, but it is not a defect now.
- `src/services/changes/applier.ts:456` — `fs.openSync(tmp, 'wx', mode)` with the captured file mode (0o444 for a read-only original)
  - [semantics] Not a distinct defect. libuv does set FILE_ATTRIBUTE_READONLY when mode lacks 0o200, so the temp is read-only for a 0o444 original, but a read-only SOURCE renames fine on Windows; the failure the reader describes is the read-only DESTINATION, i.e. finding 13. The proposed fix (writable temp, chmod after) would not by itself make the rename succeed. Round-tripping 0o444 is the correct way to preserve the read-only attribute on Windows and is desirable (revert of a delete restores a read-only file as read-only). No change needed beyond 13's helper. / [impact] Misattributed. On win32 libuv turns a mode without the owner-write bit into FILE_ATTRIBUTE_READONLY on the new temp file; creating it with CREATE_NEW plus a write handle works, and after the rename the READONLY attribute is carried onto the final file -- that is mode preservation working as well as Windows allows, not 'never helps'. The read-only case fails solely because the DESTINATION is read-only at rename time (finding 13); the temp's own attribute is irrelevant, and the proposed fix (create temp writable, re-apply READONLY afterwards) would not make the rename succeed. Duplicate of 13 with an incorrect causal claim.
- `src/services/search/searchService.ts:434` — rel = this.wfs.normalizeRel(p) inside try { ... } catch { continue; } (432-438)
  - [semantics] Not a Windows defect today, as the reader concedes. ripgrep prints explicit file arguments verbatim; separator rewriting only happens when --path-separator is given (its default is None), and JUJU only ever passes files it walked itself (never directories), so the echoed path is the POSIX-relative string it sent. Hardening the catch to count/log drops is reasonable hygiene, but there is no current breakage to confirm. / [impact] Not a present defect, and the reader concedes it 'currently works'. JUJU hands rg only file paths from its own walk (directories are expanded by wfs.walk at 170-182, never passed through), and ripgrep prints explicit file arguments verbatim — it rewrites separators only when --path-separator is given — so path.text echoes the POSIX rel and normalizeRel accepts it. Latent hardening (backslash->slash on win32, count dropped matches) is reasonable but is not a Windows breakage as claimed.
- `src/ipc/server.ts:39` — fs.chmodSync(socketPath, 0o600) as the sole access control on the IPC channel
  - [semantics] Not substantiated as stated. This is a post-fix design note, not current Windows behaviour (IPC does not start at all today). Facts: CreateNamedPipeW with a NULL security attribute grants Full Control to SYSTEM, Administrators and the creator-owner and only READ to Everyone/Anonymous; libuv's client opens GENERIC_READ|GENERIC_WRITE and its ACCESS_DENIED fallbacks still need FILE_WRITE_ATTRIBUTES or GENERIC_WRITE, so another non-admin user cannot even connect, let alone send a request. The pipe name derives from mintWorkspaceId (SHA-256 keyed by the install secret held in the user-ACL'd %APPDATA% DB), so it is not guessable or squattable. Residual exposure is other local Administrators, who can read the DB regardless. True parts: the chmod is a caught no-op on a pipe and Node cannot set a pipe SD; a request token is defensible hardening but 'connectable by ANY local user' is wrong. / [impact] Not a defect of the code as it stands: no pipe exists on Windows, so nothing is exposed. The claim that a future named pipe is 'connectable by ANY local user' is overstated: with NULL SECURITY_ATTRIBUTES the documented default pipe DACL grants full control to SYSTEM/Administrators/creator-owner and only READ to Everyone; libuv's uv_pipe_connect opens GENERIC_READ|GENERIC_WRITE and falls back to read-only on ACCESS_DENIED, so another standard user can connect but cannot write an IpcRequest, and the pipe name derives from the install-secret-based workspaceId so squatting needs DB access. Worth a SECURITY.md sentence (and optionally a shared token) when the pipe is implemented, not a current degradation.
- `tests/integration/cli.test.ts:139` — child.kill('SIGTERM') (139); logFd from fs.openSync never closed (121); afterAll fs.rmSync(base, { recursive: true, force: true }) (45)
  - [semantics] The kill part is true but intended (TerminateProcess). The rmSync failure claim does not hold on Node 22: libuv opens files with FILE_SHARE_READ|WRITE|DELETE, and fs__unlink sets FileDispositionInformationEx with FILE_DISPOSITION_POSIX_SEMANTICS (NTFS, Win10 1809+), so the parent's open logFd neither blocks the unlink nor leaves a delete-pending entry that would make the parent-dir rmdir ENOTEMPTY. The realistic residual is the terminated child's SQLite handles (SQLite opens WITHOUT FILE_SHARE_DELETE) if TerminateProcess has not finished inside 300 ms - that would surface as EBUSY on cfg/state.db, not on child.log. Closing logFd and adding maxRetries are harmless hygiene, not a Windows fix. / [impact] child.kill is TerminateProcess on win32, which matches the test's intent (and the child is already dead per finding 9). The leaked logFd is real hygiene, but the claimed rmSync EBUSY/EPERM/ENOTEMPTY failure is not substantiated: libuv opens files with FILE_SHARE_READ|WRITE|DELETE, so unlinking cfg/child.log while the parent holds the fd succeeds on Windows; whether the subsequent rmdir fails depends on delete-pending vs POSIX-delete semantics on the volume, which I cannot confirm from documented behaviour. Close the fd anyway; the suite is blocked upstream regardless.

## Appendix D — constructs already guarded for win32 (per skeptics)

- src/config/paths.ts:21-24 — win32 branch of resolveConfigDir uses %APPDATA% (correct namespace; see #6/#7 for the empty-string and Roaming-vs-Local caveats)
- src/config/paths.ts:32-38 — chmodSync(dir, 0o700) skipped on win32 (correct: win32 chmod only toggles READONLY)
- src/config/paths.ts:82 early return keeps the POSIX-only sun_path length check (83) and the os.tmpdir()/os.userInfo().username 0o700 fallback dir (84-86) unreachable on win32
- src/store/db.ts:135-141 — chmodSync(dbFile, 0o600) skipped on win32
- src/config/globalConfig.ts:93-99 — chmodSync(configFile, 0o600) skipped on win32
- src/ipc/server.ts:25 and src/ipc/client.ts:22 — '\n' line framing tolerates CRLF because JSON.parse treats a trailing '\r' as whitespace
- src/security/env.ts:15-22 (consumer of globalConfig.ts:51) — uses path.delimiter (';' on win32), path.isAbsolute (accepts C:\ and \\server\share), and treats a cross-drive path.relative result (absolute) as outside the workspace; path.win32.relative compares case-insensitively
- src/cli/main.ts:147 — doctor skips the (st.mode & 0o077) check on win32; correct because libuv synthesizes 0o444/0o222 for group/other so the check would always fail there
- src/services/lsp/lspService.ts:600 — 0o111 executable-bit check skipped on win32 (win32 fs.Stats.mode never reports exec bits)
- src/services/git/gitService.ts:70 — GIT_CONFIG_GLOBAL uses 'NUL' instead of '/dev/null' on win32
- src/services/jobs/sandbox.ts:79-88 — returns available:false with a reason on win32 instead of crashing; sandboxWiring.ts:32-41 honours off/prefer/require consistently
- src/services/lsp/jsonrpc.ts:105-107 — LSP child spawned with shell:false and windowsHide:true
- libuv make_program_env (implicit, applies to every spawn that passes an env object) — injects SYSTEMROOT, SYSTEMDRIVE, WINDIR, TEMP, USERPROFILE, HOMEDRIVE, HOMEPATH, USERNAME, USERDOMAIN, LOGONSERVER, PATH when the caller's env block lacks them, which is why the BASE_ALLOWED whitelist does not break child startup on Windows
- src/config/paths.ts:21-24 — win32 branch of resolveConfigDir uses %APPDATA% (namespace correct; only the empty-string and Roaming-vs-Local points remain)
- src/config/paths.ts:32-38 — chmodSync(dir, 0o700) skipped on win32 (chmod would only toggle READONLY)
- src/config/paths.ts:82 — early return makes the 100-byte sun_path check (83), os.userInfo().username and the os.tmpdir() 0o700 fallback (84-86) POSIX-only; os.userInfo is never reached on Windows
- src/cli/main.ts:147 — doctor skips the 0o077 mode-bit check on win32 (correct, though it leaves the ACL blind spot noted in index 5)
- src/services/lsp/lspService.ts:600 — executable-bit test skipped on win32 (libuv never sets 0o111 on Windows files); note jobManager.ts:98 and sandbox.ts:61 lack this same guard
- src/services/git/gitService.ts:70 — GIT_CONFIG_GLOBAL uses NUL on win32 instead of /dev/null
- src/services/jobs/sandbox.ts:79-88 — win32 falls to 'no OS sandbox adapter' instead of crashing; doctor reports it
- src/security/env.ts:15,22 — PATH split/join uses path.delimiter (';' on Windows); parentEnv['PATH'] works because process.env is case-insensitive on Windows
- src/security/env.ts:25-41 (implicit) — libuv's Windows uv_spawn injects SYSTEMROOT/WINDIR/SYSTEMDRIVE/TEMP/USERPROFILE/HOMEDRIVE/HOMEPATH/USERNAME/LOGONSERVER/USERDOMAIN into any child env lacking them, so the minimal whitelist does not break process startup
- src/ipc/server.ts:25 and src/ipc/client.ts:22 — '\n' line framing tolerates CRLF because JSON.parse accepts trailing '\r'
- src/ipc/client.ts:36 — ENOENT is exactly what a missing Windows named pipe yields, so the 'no running server' classification remains valid post-fix
- src/services/jobs/sandbox.ts:79-88 process.platform switch returns available:false with a win32 reason; sandboxWiring.ts:34-42 fails closed (NOT_SUPPORTED) for require/sandbox:true and falls through for prefer; sandbox.ts:174-182 wrapInSandbox throws NOT_SUPPORTED with a recovery hint
- src/config/globalConfig.ts:32 commandSandbox defaults to 'off', so the sandbox wrapper returns undefined at sandboxWiring.ts:33 and the win32-unsupported branch is never entered unless a caller explicitly requests a sandbox
- path.delimiter is used for PATH splitting/joining at jobManager.ts:94, env.ts:15/22 and sandbox.ts:71 (';' on win32)
- env.ts:16-18 path.isAbsolute/path.relative are win32-aware: drive-letter and UNC entries accepted, cross-drive entries kept (path.win32.relative returns an absolute path), containment compared case-insensitively
- jobManager.ts:85-87 normalises backslashes before wfs.normalizeRel, and src/workspace/fs.ts:72 rejects drive-letter paths, so C:\... programs are refused with PATH_DENIED rather than mis-resolved
- jobManager.ts:154-164 turns synchronous win32 spawn failures (EINVAL for .cmd/.bat, ENOEXEC for non-PE files) into INTERNAL_ERROR, and the 'error' handler at 182-185 turns deferred ones (ENOENT/EACCES) into failed_to_start — the server never crashes
- jobManager.ts:361-369 nested try/catch: process.kill(-pid) throwing ESRCH on win32 falls back to child.kill without crashing (degrades silently, as finding 6 describes)
- env.ts:31/37 lookups of 'PATH'/'HOME' work on win32 because process.env is a case-insensitive proxy (a plain object with key 'Path' from tests would not match)
- src/config/globalConfig.ts:51 validates envAllowlist entries against ^[A-Z0-9_]{1,64}$, which closes the win32-specific hole where a mixed-case name (e.g. Node_Options) would pass the case-sensitive ALWAYS_DENIED check at env.ts:36 yet resolve to NODE_OPTIONS through the case-insensitive process.env at env.ts:37
- src/config/paths.ts:21-24/69 place jobsDir under %APPDATA%\juju\jobs on win32 (per-user ACL by inheritance) and ensureConfigDir (paths.ts:32) skips chmod on win32, so spool privacy holds by default despite mode bits being no-ops
- spool.ts uses path.join throughout; Node opens files with FILE_SHARE_DELETE so JUJU's own read()-while-unlink never hits a sharing violation
- sandboxWiring.ts:26 os.homedir() resolves to USERPROFILE on win32, and sandbox.ts:116-126 resolveWritable skips non-existent entries so the macOS/POSIX cache list is harmless
- child.kill('SIGTERM'/'SIGKILL') signal names are accepted on win32 (mapped to TerminateProcess) and the 'spawn' event used at jobManager.ts:186 fires on win32
- jobManager.ts:148 job directory names come from newId() (prefix + '_' + base-32 alphabet, src/util/hash.ts:38-43) — short, NTFS-safe, no reserved characters or device names
- Windows is explicitly documented as unsupported/roadmap (docs/COMPATIBILITY.md:63, docs/TEST_REPORT.md:109, paths.ts:82), so none of the above misleads users today
- src/services/jobs/sandbox.ts:79-88 switches on process.platform; win32 hits the default branch and returns available:false with a platform-specific reason — no POSIX path is touched.
- src/services/jobs/sandboxWiring.ts:34-42 turns that into NOT_SUPPORTED (with recovery text) for sandbox:true / commandSandbox:'require', and a plain unsandboxed run for 'prefer'; src/config/globalConfig.ts:32 defaults commandSandbox to 'off', so nothing is requested unless the user opts in.
- src/services/jobs/sandbox.ts:174-182 wrapInSandbox() throws NOT_SUPPORTED with a non-Linux recovery hint that is correct for Windows.
- src/services/jobs/sandbox.ts:51 (/usr/bin/sandbox-exec), :59 (NO_WORKSPACE '/dev/null/no-workspace'), :64 (mode & 0o111 in isExecutableFile), :140-148 (seatbelt /private/tmp,/tmp,/dev) and :161-168 (bwrap /tmp,/dev,/proc,/run) are unreachable on win32 because of the platform switch.
- src/services/jobs/sandboxWiring.ts:26-30 builds defaultWritable with os.homedir()/os.tmpdir() string work only; it is never consumed on win32 and resolveWritable() would skip non-existent entries anyway.
- src/services/jobs/jobManager.ts:94, src/security/env.ts:15/22 and src/services/jobs/sandbox.ts:71 split/join PATH with path.delimiter (';' on win32).
- src/security/env.ts:16-18: path.isAbsolute / path.relative are win32-aware — drive letters, cross-drive entries (relative() returns an absolute path, so they are kept) and case-insensitive comparison against workspaceRoot.
- src/services/jobs/jobManager.ts:85-87 accepts backslash program paths and normalises them before wfs.normalizeRel; src/workspace/fs.ts:68/72 then reject raw backslashes and drive-letter paths, so absolute Windows paths fail closed as PATH_DENIED, matching the workspace-only rule.
- src/services/jobs/jobManager.ts:154-164 try/catch and :182-185 'error' handler convert every win32 spawn failure (sync EINVAL/ENOEXEC, async ENOENT/EACCES) into INTERNAL_ERROR or failed_to_start instead of crashing the server.
- src/services/jobs/jobManager.ts:361-369 nested try/catch means process.kill(-pid) throwing ESRCH on win32 degrades to child.kill() rather than crashing cancel/timeout/shutdown.
- src/services/jobs/spool.ts uses path.join throughout; job ids from src/util/hash.ts:36-43 use a Windows-filename-safe alphabet (prefix_ + [0-9a-z] subset); libuv opens files with FILE_SHARE_DELETE so JUJU's own read-while-append-while-unlink works on NTFS.
- src/config/paths.ts:21-23 resolves the config dir to %APPDATA%\juju on win32, so jobsDir inherits the per-user profile ACL and the spool mode no-op does not expose job logs by default; paths.ts:32 already skips chmod on win32.
- src/security/env.ts:31/37 look names up on process.env, which is a case-insensitive proxy on win32; every caller passes process.env (jobManager.ts:93/151, gitService.ts:64, searchService.ts:94/345, lspService.ts:581/628). libuv's required_vars check (PATH, SYSTEMROOT, USERPROFILE, …) is also case-insensitive, so the uppercase 'PATH' key JUJU emits is honoured.
- src/config/globalConfig.ts:51 restricts envAllowlist names to /^[A-Z0-9_]{1,64}$/, so the case-sensitive ALWAYS_DENIED check in env.ts:36 cannot be bypassed on the case-insensitive Windows environment by allowlisting e.g. 'node_options'.
- src/services/jobs/jobManager.ts:159 detached:true -> DETACHED_PROCESS means job children currently get no console, so no console window can pop up even without windowsHide; src/services/lsp/jsonrpc.ts:107 already passes windowsHide:true for LSP children.
- src/services/lsp/lspService.ts:600 and src/cli/main.ts:147 gate their mode-bit tests on process.platform !== 'win32' — the exact pattern jobManager.ts:98 should copy; src/services/git/gitService.ts:70 already picks 'NUL' vs '/dev/null' on win32.
- docs/COMPATIBILITY.md:63, docs/TEST_REPORT.md:109 and DELIVERY_SUMMARY.md:89 state that Windows is roadmap/unsupported, and src/config/paths.ts:82 notes named-pipe IPC as roadmap — so none of the above is currently a broken promise to a Windows user; juju start/stdio fail at the IPC listen (src/ipc/server.ts:37 on a filesystem path) before any job code runs.
- fs.ts:84-88 containment uses path.relative + path.isAbsolute: on win32 relative() is case-insensitive and drive-aware, and a cross-drive result comes back absolute, so it is caught
- fs.ts:93/115/124/271/298 path.join(root, rel) turns POSIX rels into native backslash absolute paths, so the 'workspace-relative POSIX paths' contract holds internally on win32
- fs.ts:152 nlink is populated by libuv from NumberOfLinks, so NTFS hard links (mklink /H) are refused exactly as on POSIX (FAT/exFAT always report 1, same as elsewhere)
- fs.ts:130/240/282 and readService.ts:143 symlink refusal covers NTFS junctions, app-exec links and WSL symlinks because libuv lstat reports those reparse tags as S_IFLNK; unknown tags are re-stat'd as regular files, so the no-traversal policy is not weakened
- fs.ts:195 listDir skip of names containing '\\' or '/' is harmless on win32 (readdir never yields them) and correct on POSIX
- fs.ts:67 NUL rejection and fs.ts:66 length limit counted in UTF-16 code units match how Windows path APIs count
- ignores.ts:144/151/183 use path.posix for rel manipulation; ignore 7.0.9 defaults ignorecase:true (index.js:1286), strips a leading BOM (index.js:380-385), splits on \r?\n (index.js:25), and on win32 converts backslashes to slashes before matching (index.js:1412-1434) — so .ENV, Notepad-BOM and CRLF .gitignore files behave; case variants are not a secret-policy bypass
- ignores.ts:108/160 path.join(this.root, ...) yields correct native absolute paths for .gitignore/.jujuignore reads
- applier.ts:221/246/343/467 unlinkSync: libuv clears FILE_ATTRIBUTE_READONLY before deleting and uses POSIX delete semantics on Win10+, so deleting read-only files works; only rename-over is affected
- applier.ts:459/484 fsyncSync on file descriptors opened for write maps to FlushFileBuffers and works on win32
- applier.ts:451/456 mustNotExist + openSync(tmp,'wx') is atomic on NTFS too (CREATE_NEW), so the temp-file race guard holds
- applier.ts:229 dirname(dest) !== dirname(abs) comparison is safe because both sides derive from the same root string and the same path.join
- root.ts:38 os.homedir() uses USERPROFILE on win32; root.ts:39/47 path.parse(real).root and path.relative(parsed.root, real) are correct for drive-letter roots (only the list contents and case handling at 48 are wrong)
- readService.ts contains no OS or path calls of its own; all policy is inherited from WorkspaceFS
- Context outside the seven files: config/paths.ts:21-24 picks %APPDATA%\\juju and :32 skips chmod on win32; lspService.ts:949, tsWorker.ts:58 and jobManager.ts:87 convert native separators to '/' before normalizeRel, so internal producers never trip fs.ts:68
- src/workspace/fs.ts:84-88 containment uses path.relative, which on win32 compares case-insensitively and returns an absolute path for a different drive (rejected by path.isAbsolute) -- probed path.win32.relative('C:\\Users\\Me','c:\\users\\me\\src') === 'src'
- src/workspace/fs.ts:93,115,124,271,298 path.join(root, rel) turns the POSIX rel into a native backslash absolute path, so the 'workspace-relative POSIX paths' contract holds internally on win32
- src/workspace/fs.ts:152 nlink comes from NTFS NumberOfLinks via libuv, so `mklink /H` hardlinks are refused exactly as on POSIX (FAT/exFAT always report 1)
- src/workspace/fs.ts:195 backslash-in-name skip is harmless (win32 readdir never yields one) and the NUL/'/' skips are correct
- src/workspace/fs.ts:69-72 leading '/', '~', drive-letter `X:` and (via the '/' check) `//server/share` and `//?/` forms are all rejected before any fs call
- src/workspace/root.ts:40 a drive root (`C:\\`) is refused: fs.realpathSync('C:\\') equals path.win32.parse('C:\\').root; root.ts:48 path.sep is the native separator
- src/workspace/ignores.ts:144,151,183 nested-ignore and dir-like logic use path.posix explicitly; 108/160 path.join(root, ...) produce native absolute paths for the .gitignore reads
- node_modules/ignore 7.0.9: on win32 it converts backslashes to '/' before matching, defaults to ignorecase:true (so `.ENV`, `SECRETS.JSON` are still denied), strips a UTF-8 BOM and splits on \r?\n, so Notepad/CRLF .gitignore files behave
- src/services/changes/applier.ts:221,246,343 unlinkSync: libuv clears FILE_ATTRIBUTE_READONLY and uses POSIX delete semantics on Win10 1809+, so deleting read-only files works; only rename-over is affected
- src/services/changes/applier.ts:459,484 fsyncSync on regular file fds works on win32 (FlushFileBuffers on a write handle)
- src/services/changes/applier.ts:229 path.dirname(dest) !== path.dirname(abs) is safe because both sides come from wfs.absOf and share the root string and separator
- src/services/changes/applier.ts:451 existsSync + resolveForCreate at preview (fs.ts:283) agree on case-insensitive volumes, so create-over-a-case-variant fails typed (CONFLICT) at preview rather than mid-apply
- src/services/files/readService.ts contains no OS-specific path or fs calls; it inherits WorkspaceFS behaviour
- Internal producers already convert native separators before normalizeRel, so the backslash rejection is client-boundary only: src/services/intelligence/tsWorker.ts:58, src/services/lsp/lspService.ts:949, src/services/jobs/jobManager.ts:87; src/services/search/searchService.ts:349-353 passes explicit POSIX rels to rg (echoed unchanged) and gitService.visible/toPathspecs consume git's forward-slash output
- Repo-wide win32 guards outside the seven files: config/paths.ts:21-24 (%APPDATA%\\juju) and :32 (skip chmod), cli/main.ts:147, store/db.ts:135, config/globalConfig.ts:93 skip POSIX permission checks; git/gitService.ts:70 uses NUL for GIT_CONFIG_GLOBAL; lsp/lspService.ts:600 skips the exec-bit check; jobs/sandbox.ts:79-87 reports 'no OS sandbox adapter' for non-darwin/linux
- Context, not a guard: config/paths.ts:82 returns a `.sock` path on win32 and both src/server/stdioServer.ts:61-69 and src/server/appServer.ts:224-231 `await startIpcServer` on it, so `juju start` and `juju stdio` cannot boot on Windows today regardless of the seven files reviewed; the in-scope findings describe what remains once named-pipe IPC exists
- gitService.ts:70 GIT_CONFIG_GLOBAL = 'NUL' on win32 (Git for Windows also maps /dev/null to NUL internally; either spelling opens the null device).
- gitService.ts:64-71 HOME is absent from the child env on Windows, but libuv re-injects HOMEDRIVE/HOMEPATH/USERPROFILE so Git for Windows still derives its home for userConfig:true commits.
- gitService.ts:144/177/266/274/302/362 git output parsing assumes LF and forward slashes, which git emits on Windows when stdout is a pipe.
- gitService.ts:98, searchService.ts:357, jsonrpc.ts:226 child.kill('SIGKILL') maps to TerminateProcess and terminates a direct child correctly.
- lspService.ts:574-579 bare-vs-path classification rejects '/' and '\\' in bare names and uses drive-letter-aware path.isAbsolute for absolute commands.
- lspService.ts:582 trusted PATH is split on path.delimiter (';' on win32).
- lspService.ts:590-592 and :947-949 path.relative + isAbsolute correctly treat cross-drive results (path.win32.relative returns 'D:\\x') as out of scope; :949 converts path.sep to '/'.
- lspService.ts:600 skips the 0o111 mode test on win32 (fs.Stats.mode never reports execute bits there).
- lspService.ts:674 and :858 pathToFileURL yields file:///C:/... (and file://server/share/... for UNC roots).
- lspService.ts:943 fileURLToPath decodes %3A drive URIs and path.win32.relative compares case-insensitively, so references/rename/symbol URI->rel mapping works for servers that re-encode the drive letter.
- lspService.ts:168-185 TextDoc is CR/LF/CRLF aware and :1114-1116 compute byte offsets from the raw text, so rename edits stay byte-exact on CRLF files.
- lspService.ts:632-634 catches the synchronous 'spawn EINVAL' Node throws for .cmd/.bat targets so the constructor failure does not crash the service.
- jsonrpc.ts:107 windowsHide: true; :362 stderr split on /\r?\n/; :105 shell:false is the correct, CVE-2024-27980-safe choice.
- tsWorker.ts:30/49-58 path.win32.dirname/relative normalise TypeScript's forward-slash lib paths and mixed separators; :57 handles cross-drive path.relative output; :58 splits on path.sep.
- tsWorker.ts:111/201 path.join(root, rel) native paths are normalised by TypeScript so snapshot/program keys stay coherent; :164 fs.lstatSync accepts forward-slash paths on Windows.
- tsWorker.ts:139/:190 useCaseSensitiveFileNames=false is correct for Windows.
- tsWorker.ts:239 strips a trailing '\r'; :346-347 byte offsets are computed from the unmodified text (CRLF and BOM preserved), so rename edits are byte-exact.
- intelService.ts:42-48 and searchService.ts:102-105 new URL(..., import.meta.url) + fileURLToPath + existsSync + new Worker(absPath) round-trips drive letters, spaces and non-ASCII correctly.
- searchService.ts:238 decodeUtf8Strict uses TextDecoder with default ignoreBOM:false, so the literal backend strips a UTF-8 BOM consistently with ripgrep.
- searchService.ts:351-353 ripgrep accepts forward-slash relative file arguments on Windows, echoes them verbatim in --json output, and terminates JSON lines with '\n'.
- searchService.ts:95 spawnSync with timeout works on win32 (TerminateProcess on expiry) and an ENOENT probe cleanly selects the JS fallback.
- gitService.ts:70 — GIT_CONFIG_GLOBAL is 'NUL' on win32 (and Git for Windows maps /dev/null to NUL anyway); the NUL device reads as an empty config file.
- gitService.ts:75-78 — -c core.fsmonitor=false / core.untrackedCache=false neutralise Git for Windows' optional built-in fsmonitor for read-only ops.
- gitService.ts:65-67 — GIT_PAGER=cat is moot on Windows (stdout is a pipe, git never starts a pager); GIT_TERMINAL_PROMPT=0 works the same.
- gitService.ts:140-200/208/295/302 — porcelain v2 -z, --show-prefix, diff headers and %x1f log lines are LF-terminated with forward-slash paths on Windows when stdout is a pipe; parsers need no change.
- lspService.ts:574 — rejects both '/' and '\\' in bare names; :575 path.isAbsolute is drive-letter/UNC aware.
- lspService.ts:582 — trusted PATH split on path.delimiter (';' on win32).
- lspService.ts:591-592 and :947-948 — path.relative + path.isAbsolute correctly treat cross-drive results (path.win32.relative returns 'D:\\x') as outside the root.
- lspService.ts:949 — rel.split(path.sep).join('/') converts backslashes for the POSIX public contract.
- lspService.ts:674 and :858 — pathToFileURL yields file:///C:/... (rootUri, workspaceFolders, document URIs).
- lspService.ts:943 — fileURLToPath decodes %3A drive URIs and lowercase drive letters; path.win32.relative is case-insensitive, so references/rename/workspace-symbol URI->rel mapping works on Windows.
- lspService.ts:168-185 — TextDoc treats CR, LF and CRLF as line terminators; :1114-1116 byte offsets are computed from the raw text so rename edits stay byte-exact on CRLF files.
- lspService.ts:624-628 — the LSP child is spawned by resolved absolute path, so it is NOT subject to libuv's cwd-first bare-name lookup (unlike git/rg).
- lspService.ts:600 — the 0o111 mode test is skipped on win32 (correct intent; over-permissive, see missed[0]).
- jsonrpc.ts:107 — windowsHide: true; :244 CRLF header framing; :362 stderr split on /\\r?\\n/; :226 kill('SIGKILL') maps to TerminateProcess for the direct child.
- tsWorker.ts:30/49-50 — TypeScript returns forward-slash lib paths; path.win32.dirname/relative normalise mixed separators, and the !startsWith('..') && !isAbsolute pair handles cross-drive output.
- tsWorker.ts:55-58 — relOf splits path.win32.relative output on path.sep before joining with '/'.
- tsWorker.ts:111/201 — path.join(root, rel) produces native paths; TS normalises them via toPath so program.getSourceFile / snapshots Map keys stay coherent.
- tsWorker.ts:139 and :190 — useCaseSensitiveFileNames: false is correct for Windows (NTFS default).
- tsWorker.ts:164 — fs.lstatSync on TS's forward-slash names works on Windows (fs APIs accept '/').
- tsWorker.ts:239 — lineTextAt strips a trailing '\\r'; :346-347 byte offsets come from the unmodified file text (CRLF/BOM preserved).
- intelService.ts:42-48 and searchService.ts:102-105 — new URL(..., import.meta.url) -> fileURLToPath -> existsSync -> new Worker(absPath) round-trips drive letters, spaces and non-ASCII on win32.
- searchService.ts:387 — rg --json output is LF-delimited on Windows, so split('\\n') on stdout is correct; :166 path.posix.basename on POSIX rels is correct.
- env.ts:15-18 — trustedPath uses path.delimiter/isAbsolute/relative, so ';'-separated, drive-lettered and cross-drive PATH entries are handled; process.env['PATH'] resolves a parent 'Path' variable case-insensitively on Windows, and libuv matches required vars case-insensitively in the child block.
- env.ts / libuv — libuv re-injects SYSTEMROOT, SYSTEMDRIVE, WINDIR, TEMP, USERPROFILE, HOMEDRIVE, HOMEPATH, USERNAME, USERDOMAIN, LOGONSERVER, PATH into every child env, which is why git and rg start at all despite the POSIX-only whitelist.
- src/config/paths.ts:21-24 - %APPDATA%\juju config dir on win32 (falls back to <home>\AppData\Roaming)
- src/config/paths.ts:32-38, src/config/globalConfig.ts:93-98, src/store/db.ts:135-141 - chmod 0o700/0o600 skipped on win32 (mode options are harmless no-ops)
- src/cli/main.ts:147 - doctor's 0o077 group/other permission assertion skipped on win32
- src/cli/main.ts:171/173 - spawnSync('git'/'rg') bare names resolve git.exe/rg.exe through libuv's .com/.exe search (no .cmd shim involved)
- src/cli/main.ts:519-520 - stdin 'end'/'close' handlers give `juju stdio` a shutdown path that does not depend on POSIX signals
- src/cli/main.ts:633 - program.parseAsync().catch(fail) turns the raw IPC bind Error into a clean `juju: ...` message and exit 1 rather than an unhandled rejection
- src/services/lsp/lspService.ts:600 - exec-bit check skipped on win32 (the pattern jobManager.ts:98 should copy)
- src/services/git/gitService.ts:70 - GIT_CONFIG_GLOBAL=NUL instead of /dev/null on win32
- src/services/jobs/sandbox.ts:86-87 and 176-181 - win32 reports 'no OS sandbox adapter for platform win32' and wrapInSandbox throws NOT_SUPPORTED with a platform-aware recovery (simulated in tests/unit/sandbox.test.ts:181-189)
- src/services/jobs/jobManager.ts:85-87 - backslash program paths are normalised to '/' for workspace-relative scripts
- src/services/jobs/jobManager.ts:374 - after TerminateProcess libuv reports term_signal, so child.signalCode is set and the SIGKILL timer does not double-fire
- src/security/env.ts:15-18 - trustedPath splits on path.delimiter (';') and path.win32.relative compares case-insensitively, so workspace-PATH containment is correct on win32; process.env['PATH'] lookup is case-insensitive
- src/workspace/root.ts:39-41 and 47 - path.parse().root / path.sep make the drive-root and broad-root checks work with C:\ (case-sensitivity caveat listed under missed)
- src/workspace/fs.ts:68-72 with listDir/walk at 196 - backslash, rooted ('/' and '//server/share') and drive-letter inputs are rejected up front, and rel paths are always '/'-joined, so directTools.ts:266 path.posix.basename is correct
- src/tools/agentTools.ts:93 - child.kill('SIGKILL') on a direct child is TerminateProcess and works
- src/tools/agentTools.ts:109-110 - split('\n')[0].trim() strips the '\r' of CRLF tool output (java/python on Windows)
- tests/integration/agentTools.test.ts:70 - seatbelt / HOME / touch test fenced with it.skipIf(platform !== 'darwin')
- tests/integration/faultInjection.test.ts:33 - process.getuid?.() optional chaining avoids a crash on win32 (but see verdict 22 for the chmod)
- tests/integration/cli.test.ts:15 and :122, tests/integration/directTools.test.ts:175, tests/integration/stdio.test.ts:26/98/103 - execFileSync/spawn/spawnSync of 'node' and 'git' resolve node.exe/git.exe
- tests/integration/cli.test.ts:152 (CLI-06) - the HTTP EADDRINUSE at appServer.ts:219 fires before the IPC bind, so the 'in use' assertion holds on Windows
- tests/integration/jobs.test.ts:124 (JOB-10) - Node's CreateProcess argument quoting round-trips through node.exe's MSVCRT parser, so the literal-argv assertion holds once resolveProgram works
- package.json - bin + shebang are fine (npm emits juju.cmd/juju.ps1 shims and Node ignores the shebang), scripts use only && and `npm run` (cmd.exe-safe), better-sqlite3 13.x ships win32 prebuilds which `doctor` loads at main.ts:186-191
- tests/helpers/testServer.ts:39-52 - freePort on 127.0.0.1 and mkdtemp under os.tmpdir() are portable
- src/config/paths.ts:21-24 — resolveConfigDir uses APPDATA (fallback ~/AppData/Roaming) on win32
- src/config/paths.ts:32-38 — ensureConfigDir skips chmod 0o700 on win32
- src/config/globalConfig.ts:93-98 — saveGlobalConfig skips chmod 0o600 on win32
- src/store/db.ts:135-140 — openDatabase skips chmod 0o600 on win32
- src/cli/main.ts:147 — doctor's 0o077 permission assertion is skipped on win32
- src/services/jobs/sandbox.ts:78-88 — sandboxAvailability default branch reports 'no OS sandbox adapter for platform win32' (isExecutableFile's 0o111 test only runs on darwin/linux); wrapInSandbox:173-181 throws typed NOT_SUPPORTED; tests/unit/sandbox.test.ts:179-190 simulates win32
- src/services/jobs/sandboxWiring.ts:32-42 + globalConfig.ts:32 — commandSandbox defaults to 'off' and 'prefer' falls back to unsandboxed, so the sandbox is not a Windows blocker; sandbox:true yields a typed NOT_SUPPORTED
- src/services/lsp/lspService.ts:600 — isTrustedExecutable skips the 0o111 exec-bit test on win32 (the pattern jobManager.ts:98 should copy)
- src/workspace/root.ts:37-50 — filesystem-root / home / broad-root checks use path.parse().root, path.relative and path.sep, so C:\ and C:\Users are handled
- src/services/jobs/jobManager.ts:85-87 — backslash program paths are normalised to '/' before workspace resolution (but see missed entry for what happens after)
- src/services/changes/applier.ts:492-503 — fsyncDir is fully try/caught, so the EPERM a directory fsync raises on Windows is swallowed; writeFileAtomic:456-473 uses openSync 'wx' + renameSync (MoveFileEx REPLACE_EXISTING), which is portable
- src/cli/main.ts:519-520 — stdin 'end'/'close' handlers give `juju stdio` an effective graceful shutdown on Windows when the client closes stdin
- src/cli/main.ts:171/173, src/services/search/searchService.ts:95/353, agentTools.ts PROBES for .exe tools — bare-name spawn with shell:false resolves git.exe/rg.exe/node.exe via libuv's .com/.exe search (only .cmd shims are not found)
- src/tools/agentTools.ts:93, gitService.ts:98, searchService.ts:357, lsp/jsonrpc.ts:226 — child.kill('SIGKILL') is a TerminateProcess on win32 and works
- src/tools/directTools.ts:259-266 — glob_files compares '/'-joined rel paths from WorkspaceFS (normalizeRel/listDir/walk always join with '/') and uses path.posix.basename, so it is separator-safe on Windows
- src/security/env.ts:15-18 — trustedPath splits on path.delimiter (';' on win32) and path.isAbsolute accepts drive paths; process.env['PATH'] lookup is case-insensitive on win32 so 'Path' is found
- package.json:10-12 bin + src/cli/main.ts:1 shebang — npm generates juju.cmd/juju.ps1 shims and Node ignores the shebang; package.json scripts use only && / npm run, which cmd.exe accepts; better-sqlite3 13.x ships win32 prebuilds that doctor verifies at main.ts:186-191
- tests/integration/agentTools.test.ts:70 — it.skipIf(process.platform !== 'darwin') fences the seatbelt/touch/HOME test
- tests/integration/faultInjection.test.ts:33 — process.getuid?.() uses optional chaining, so no crash on win32 (the test still runs, see verdict 22)
- tests/integration/cli.test.ts:152 (CLI-06) — HTTP EADDRINUSE at appServer.ts:208-219 fires before the IPC bind at :224, so the nonzero-exit / 'in use' expectations hold on Windows
- tests/integration/cli.test.ts:15 execFileSync('node', ...) and tests/integration/directTools.test.ts:175 execFileSync('git', ...) — .exe resolution, portable
- vitest.config.ts globalSetup -> tests/helpers/globalSetup.ts:22 — builds dist through execSync (a shell), so the npx .cmd problem is avoided on the normal test path
