diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000000..e1ece1853c --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,117 @@ +name: Windows + +# Windows is the one supported platform the Linux CI cannot speak for: the +# things that break there are path layout (Scripts\python.exe), archive format +# (zip instead of tar.gz), console allocation, and Win32 error codes for +# filesystem races. None of those have a Linux equivalent that would catch a +# regression, so they get their own job. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: windows-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-check: + name: Build and typecheck (Windows) + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Lint and format + run: npx biome check --error-on-warnings . + + test: + name: Platform tests (Windows) + runs-on: windows-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # Scoped to the suites whose behaviour is platform-dependent. The rest of + # the suite still carries POSIX-only fixtures (shell-scripted stubs, chmod + # permission bits, symlinks) and is covered by the Linux jobs. + - name: Test + working-directory: packages/coding-agent + run: >- + npx vitest --run + test/kernel-venv-python-path.test.ts + test/expand-tilde-path.test.ts + test/session-lease.test.ts + test/command-recovery-journal.test.ts + test/worker-recovery-journal.test.ts + test/orphan-process-journal.test.ts + test/child-process.test.ts + test/bash-close-hang-windows.test.ts + test/daemon-socket.test.ts + test/paths.test.ts + test/path-utils.test.ts + test/args.test.ts + + kernel-smoke: + name: IPython kernel smoke (Windows) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install uv + run: | + irm https://astral.sh/uv/install.ps1 | iex + "$env:USERPROFILE\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + # The regression this guards: the venv interpreter used to be looked up at + # /bin/python, which does not exist on Windows. Every readiness probe + # failed, so the venv was rebuilt on each launch and the IPython tool never + # came up. Bootstrapping twice proves the second run is a cache hit. + - name: Bootstrap kernel venv and execute a cell + working-directory: packages/coding-agent + run: node scripts/windows-kernel-smoke.mjs diff --git a/README.md b/README.md index 1d6f850c5f..185b190b4f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh The installer downloads a versioned release, verifies its SHA-256 checksum, installs the `prime-agent` command, and can prepare the IPython runtime used by the agent. +On Windows, install from npm and run it from PowerShell, Command Prompt, or Windows Terminal — no WSL or Git Bash session required: + +```powershell +npm install -g prime-agent +``` + +Windows needs Node.js 22.8+ and [Git for Windows](https://git-scm.com/download/win), whose bundled Bash backs the agent's shell tool. See [Windows support](packages/coding-agent/docs/windows.md) for the full requirements and known limitations. + Start Prime Agent from the repository or directory you want it to work in: ```bash @@ -98,6 +106,7 @@ Prime Agent is built for long-running work, especially for evaluations in resear - [Provider setup](packages/coding-agent/docs/providers.md) — subscription and API-key providers - [Architecture overview](packages/coding-agent/docs/architecture.md) — daemon, worker, kernel, and persistence boundaries - [Development](packages/coding-agent/docs/development.md) — build and run from source +- [Windows support](packages/coding-agent/docs/windows.md) — requirements, what differs from macOS and Linux, and troubleshooting ## Acknowledgements diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 95fd940eaa..fcd9abf8ed 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -18,6 +18,14 @@ curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh -s -- beta Both commands fetch versioned Prime Agent release artifacts and install the `prime-agent` command. The inherited npm workspace identifiers in the source tree are not the public install path. +The bootstrap installer is POSIX-only. On Windows, install from npm and run Prime Agent from PowerShell, Command Prompt, or Windows Terminal: + +```powershell +npm install -g prime-agent +``` + +See [Windows](windows.md) for requirements and platform notes. + Then start Prime Agent in the project directory you want it to work on: ```bash diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 3f7da6c61d..17fc803d0c 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,17 +1,144 @@ -# Windows Setup +# Windows support -Prime Agent requires a bash shell on Windows. Checked locations (in order): +Prime Agent runs natively on Windows. It is launched from PowerShell, Command +Prompt, or Windows Terminal like any other console program — a WSL distribution +or a Git Bash session is not required, and the daemon, session workers, and the +IPython kernel all run as ordinary Windows processes. -1. Custom path from `~/.prime/agent/settings.json` -2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) -3. `bash.exe` on PATH (Cygwin, MSYS2, WSL) +## Requirements -For most users, [Git for Windows](https://git-scm.com/download/win) is sufficient. +| Requirement | Notes | +| --- | --- | +| Windows 10 1803+ or Windows 11 | Earlier builds lack `tar.exe`, which is used to unpack the ripgrep and fd archives. | +| Node.js 22.8 or newer | Same floor as every other platform. | +| [Git for Windows](https://git-scm.com/download/win) | Supplies the `bash.exe` that backs the agent's shell tool. | +| [uv](https://docs.astral.sh/uv/) | Installed automatically on first launch if missing; `PRIME_AGENT_INSTALL_UV=1` skips the prompt. | -## Custom Shell Path +Install and run: + +```powershell +npm install -g prime-agent +cd C:\path\to\project +prime-agent +``` + +## What differs from macOS and Linux + +**The Python kernel lives in `Scripts\`.** `uv` creates the kernel virtualenv at +`%USERPROFILE%\.prime\agent\kernel-venv` with the interpreter at +`Scripts\python.exe` rather than `bin/python`. Set `PRIME_AGENT_KERNEL_PYTHON` +to point at your own interpreter, or `PRIME_AGENT_KERNEL_VENV` to relocate the +managed one. + +**The shell tool runs Bash, not `cmd.exe` or PowerShell.** Commands the model +writes are executed with Git for Windows' `bash.exe`, so the agent composes +POSIX-style command lines on every platform. Resolution order: + +1. `shellPath` in `settings.json`, if set. +2. Git for Windows in `%ProgramFiles%`, `%ProgramFiles(x86)%`, or + `%LOCALAPPDATA%\Programs` — this covers the machine-wide, 32-bit, and + per-user (winget default) installers. +3. A Git install found via `git.exe` on `PATH`, which covers scoop and + Chocolatey shims. +4. Any other `bash.exe` on `PATH` (MSYS2, Cygwin). + +`%SystemRoot%\System32\bash.exe` — the WSL launcher — is deliberately ranked +last. It resolves a different filesystem (`/mnt/c/...`) than the Windows paths +the agent works with, so it is only used when nothing else is available. + +To pin a specific shell, set `shellPath` in `~/.prime/agent/settings.json`: ```json { "shellPath": "C:\\cygwin64\\bin\\bash.exe" } ``` + +**`fd` and `ripgrep` are unpacked with `tar.exe`.** These optional search +helpers ship as `.zip` on Windows and are extracted into +`%USERPROFILE%\.prime\agent\bin`. Both are optional: without them the agent +falls back to slower search paths. Installing them yourself +(`winget install BurntSushi.ripgrep.MSVC`, `winget install sharkdp.fd`) makes +Prime Agent use the copies on `PATH` instead. + +**asyncio subprocesses need their own event loop.** ipykernel runs on a +Windows *selector* event loop (pyzmq needs `add_reader`), and a selector loop +cannot spawn subprocesses. Prime Agent restores the proactor event loop +*policy* at kernel startup while leaving the kernel's own running loop alone, +so any loop created afterwards can spawn processes. Because the kernel's main +thread is already driving a loop, async libraries that start helper binaries — +playwright is the common one — have to run on a loop of their own: + +```python +import asyncio, threading + +def run_async(factory): + box = {} + def worker(): + loop = asyncio.new_event_loop() # proactor loop: subprocesses work + asyncio.set_event_loop(loop) + try: + box["value"] = loop.run_until_complete(factory()) + except BaseException as exc: + box["error"] = exc + finally: + loop.close() + thread = threading.Thread(target=worker) + thread.start() + thread.join() + if "error" in box: + raise box["error"] + return box["value"] +``` + +Use the async API rather than the sync one (`playwright.async_api`, not +`sync_playwright`): the sync API drives its own loop and conflicts with the +kernel's. + +**Subprocesses are spawned with `windowsHide`.** The daemon and its session +workers are detached and therefore have no console of their own, so any console +tool they run (git, uv, python) would otherwise allocate — and flash — a console +window per invocation. Every spawn whose output is piped or discarded now +suppresses that window. + +## Configuration paths + +| Path | Contents | +| --- | --- | +| `%USERPROFILE%\.prime\agent` | Agent state root | +| `%USERPROFILE%\.prime\agent\kernel-venv` | Managed Python kernel virtualenv | +| `%USERPROFILE%\.prime\agent\bin` | Downloaded `rg.exe` and `fd.exe` | +| `%USERPROFILE%\.prime\agent\logs` | Daemon, worker, and client logs | +| `%USERPROFILE%\.prime\agent\sessions` | Saved session transcripts | +| `\\.\pipe\prime-agent-daemon` | Daemon socket (a named pipe, not a Unix socket) | + +## Troubleshooting + +**`No bash shell found`** — install Git for Windows, or point `shellPath` in +`settings.json` at a `bash.exe` you already have. + +**Kernel setup fails.** First launch needs network access to install uv, Python +3.11, `ipykernel`, and the runtime packages. Check +`%USERPROFILE%\.prime\agent\logs` and re-run; to inspect the venv directly, use +`%USERPROFILE%\.prime\agent\kernel-venv\Scripts\python.exe`. + +**Antivirus interference.** Real-time scanning can hold handles on freshly +extracted binaries. Provisioning `rg`/`fd` tolerates this — a failed cleanup +leaves a temp directory behind rather than failing the install — but repeated +failures usually mean `%USERPROFILE%\.prime\agent\bin` needs an exclusion. + +**Stale background services** — `prime-agent doctor` inspects them and +`prime-agent shutdown --force` stops everything, including sessions in other +windows. + +## Not covered + +- The `install.sh` bootstrap installer is POSIX-only; use npm on Windows. +- Terminal-dependent behaviour (bracketed paste, image protocols, hyperlinks) + varies by host. Windows Terminal is the best-supported console. +- The kernel forkserver fast path is Linux-only; Windows always cold-starts the + kernel via `python -m ipykernel_launcher`. +- Much of the test suite uses POSIX-only fixtures (shell-scripted stubs, `chmod` + permission bits, symlinks, `/tmp` paths) and does not run on Windows. The + platform-sensitive suites plus an end-to-end kernel smoke test run in the + `Windows` CI workflow. diff --git a/packages/coding-agent/scripts/windows-kernel-smoke.mjs b/packages/coding-agent/scripts/windows-kernel-smoke.mjs new file mode 100644 index 0000000000..5f0a6e0f19 --- /dev/null +++ b/packages/coding-agent/scripts/windows-kernel-smoke.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +// End-to-end guard for the Windows kernel path, run from .github/workflows/windows.yml. +// +// Asserts three things the Linux jobs cannot: +// 1. ensureKernelPython resolves to \Scripts\python.exe and that file exists. +// 2. A second ensureKernelPython call is a cache hit rather than a full rebuild. +// (The venv used to be torn down and rebuilt on every launch because the +// readiness probe looked for a POSIX interpreter path that never exists.) +// 3. A kernel actually starts over ZMQ and runs a cell. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const REBUILD_BUDGET_MS = 30_000; + +const { ensureKernelPython, getKernelVenvDir, getVenvPythonPath } = await import( + "../dist/core/kernel/bootstrap.js" +); +const { KernelManager } = await import("../dist/core/kernel/index.js"); + +function fail(message) { + console.error(`FAIL: ${message}`); + process.exit(1); +} + +const venv = getKernelVenvDir(); +const expectedPython = join(venv, "Scripts", "python.exe"); + +if (getVenvPythonPath(venv) !== expectedPython) { + fail(`getVenvPythonPath returned ${getVenvPythonPath(venv)}, expected ${expectedPython}`); +} + +console.log("bootstrapping kernel venv (cold)..."); +const coldStarted = Date.now(); +const python = await ensureKernelPython({ onProgress: (message) => console.log(` ${message}`) }); +console.log(`cold bootstrap finished in ${Math.round((Date.now() - coldStarted) / 1000)}s -> ${python}`); + +if (python !== expectedPython) { + fail(`ensureKernelPython returned ${python}, expected ${expectedPython}`); +} +if (!existsSync(python)) { + fail(`${python} does not exist`); +} + +console.log("bootstrapping again (should be a cache hit)..."); +const warmStarted = Date.now(); +await ensureKernelPython(); +const warmMs = Date.now() - warmStarted; +console.log(`warm bootstrap finished in ${warmMs}ms`); + +if (warmMs > REBUILD_BUDGET_MS) { + fail(`second bootstrap took ${warmMs}ms; the venv is being rebuilt instead of reused`); +} + +console.log("starting kernel..."); +const kernel = new KernelManager({ cwd: process.cwd() }); +try { + await kernel.start(); + const result = await kernel.execute("import platform; print(platform.system())"); + console.log(`kernel stdout: ${JSON.stringify(result.stdout)}`); + if (result.status !== "ok") { + fail(`kernel execution status was ${result.status}: ${result.stderr}`); + } + if (!result.stdout.includes("Windows")) { + fail(`kernel did not report a Windows interpreter: ${result.stdout}`); + } +} finally { + await kernel.shutdown().catch(() => undefined); +} + +console.log("OK: Windows kernel path is healthy"); diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 9883707bd8..2a49fa5b18 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -696,6 +696,7 @@ async function runStart(parsed: ParsedDaemonClientCommand): Promise { detached: true, env: process.env, stdio: "ignore", + windowsHide: true, }); child.unref(); diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 6de9285e90..d1efb12761 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -379,6 +379,11 @@ async function ensureDaemonRunning(socketPath: string, spawnCwd?: string): Promi // (EPIPE once it exits); crash details come from the daemon log, // which the supervisor writes to before rethrowing startup errors. stdio: "ignore", + // Without this the detached daemon gets no console at all on Windows, + // so every console tool it later spawns (git, uv, python) allocates a + // console window of its own that flashes open and shut. CREATE_NO_WINDOW + // gives the daemon an invisible console that its children inherit. + windowsHide: true, }, ); let childFailure: diff --git a/packages/coding-agent/src/cli/daemon-update-restart.ts b/packages/coding-agent/src/cli/daemon-update-restart.ts index 7dc44ed577..4bde41fb19 100644 --- a/packages/coding-agent/src/cli/daemon-update-restart.ts +++ b/packages/coding-agent/src/cli/daemon-update-restart.ts @@ -552,6 +552,7 @@ export async function launchDaemonUpdateRestartCoordinator( detached: true, env: coordinatorEnvironment(agentDir), stdio: "ignore", + windowsHide: true, }); let launchError: Error | undefined; let exitDescription: string | undefined; diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index b709ab10e9..5354b58e59 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -210,6 +210,7 @@ function readCommandOutput( encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), + windowsHide: true, }); if (result.status === 0) return result.stdout.trim() || undefined; if (options.requireSuccess) { @@ -514,7 +515,13 @@ export const ENV_LEGACY_SESSION_DIR = `${envPrefix}_CODING_AGENT_SESSION_DIR`; export function expandTildePath(path: string): string { if (path === "~") return homedir(); - if (path.startsWith("~/")) return homedir() + path.slice(1); + // join() rather than string concatenation: on Windows the home directory is + // backslash-separated, so concatenating "~/sessions" produced a mixed + // "C:\Users\me/sessions" that no longer compares equal to the same path + // built with join(). + if (path.startsWith("~/") || (process.platform === "win32" && path.startsWith("~\\"))) { + return join(homedir(), path.slice(2)); + } return path; } diff --git a/packages/coding-agent/src/core/autonomous.ts b/packages/coding-agent/src/core/autonomous.ts index 75930d415c..84c7e66717 100644 --- a/packages/coding-agent/src/core/autonomous.ts +++ b/packages/coding-agent/src/core/autonomous.ts @@ -496,6 +496,7 @@ function runChildProcess( detached: process.platform !== "win32", shell: options.shell === true, stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); if (child.pid) { trackDetachedChildPid(child.pid); diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index bd62eb5a0e..df8418160b 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -65,6 +65,8 @@ export async function execCommand( // Merge per-call env over the parent env so callers can scope vars // (e.g. herdr pane identity) without mutating the shared process.env. env: mergeExecEnv(options?.env), + // Output is piped, so a console window would only ever flash and close. + windowsHide: true, }); let stdout = ""; diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index 2aff8ac321..291091879b 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -10,6 +10,7 @@ function resolveBranchWithGitSync(repoDir: string): string | null { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); const branch = result.status === 0 ? result.stdout.trim() : ""; return branch || null; @@ -24,6 +25,7 @@ function resolveBranchWithGitAsync(repoDir: string): Promise { { cwd: repoDir, encoding: "utf8", + windowsHide: true, }, (error: ExecFileException | null, stdout: string) => { if (error) { diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 9b12b4b413..8e0e0ba5f9 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -36,6 +36,23 @@ export const DEFAULT_RLM_EXTRA_UV_ARGS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => export const DEFAULT_RLM_EXTRA_IMPORT_NAMES = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.importName); export const DEFAULT_RLM_EXTRA_IMPORT_LABELS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.promptLabel); const UV_INSTALL_COMMAND = "curl -LsSf https://astral.sh/uv/install.sh | sh"; +const UV_INSTALL_COMMAND_WINDOWS = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"'; + +function uvInstallCommand(): string { + return process.platform === "win32" ? UV_INSTALL_COMMAND_WINDOWS : UV_INSTALL_COMMAND; +} + +// The POSIX installer is piped through `sh`, which does not exist on a stock +// Windows box; the official Windows installer is a PowerShell one-liner. +function uvInstallSpawn(): { command: string; args: string[] } { + if (process.platform === "win32") { + return { + command: "powershell", + args: ["-NoProfile", "-ExecutionPolicy", "ByPass", "-c", "irm https://astral.sh/uv/install.ps1 | iex"], + }; + } + return { command: "sh", args: ["-c", UV_INSTALL_COMMAND] }; +} const REQUIRED_HARNESS_METHODS = [ "create_memory", "update_memory", @@ -342,6 +359,13 @@ export function getKernelVenvDir(): string { return path.join(os.homedir(), ".prime", "agent", "kernel-venv"); } +// A venv puts its interpreter in Scripts\python.exe on Windows and bin/python +// everywhere else. Hardcoding the POSIX layout makes every readiness probe fail, +// which silently re-bootstraps the venv on each launch and then fails the install. +export function getVenvPythonPath(venv: string): string { + return process.platform === "win32" ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python"); +} + function getXdgKernelVenvDir(): string { const dataHome = process.env.XDG_DATA_HOME ? path.resolve(expandHome(process.env.XDG_DATA_HOME)) @@ -376,6 +400,9 @@ function run(command: string, args: string[], options: { stdio?: "ignore" | "inh const child = spawn(command, args, { env: process.env, stdio: options.stdio ?? "ignore", + // A daemon-hosted agent has no console of its own on Windows, so every + // console child (uv, python) would otherwise pop up its own window. + windowsHide: true, }); child.on("error", reject); child.on("exit", (code, signal) => { @@ -522,17 +549,18 @@ async function ensureUv(options: EnsureKernelPythonOptions): Promise { process.env.PRIME_AGENT_INSTALL_UV === "1" || (!options.onProgress && (await confirmUvInstall())); if (!shouldInstallUv) { throw new Error( - `uv is required to set up the Python kernel. Install uv yourself: ${UV_INSTALL_COMMAND}, ` + + `uv is required to set up the Python kernel. Install uv yourself: ${uvInstallCommand()}, ` + "or set PRIME_AGENT_INSTALL_UV=1 to let prime-agent run that installer.", ); } reportProgress(options, "› installing uv (one-time)…"); + const installSpawn = uvInstallSpawn(); try { - await run("sh", ["-c", UV_INSTALL_COMMAND], { stdio: options.onProgress ? "ignore" : "inherit" }); + await run(installSpawn.command, installSpawn.args, { stdio: options.onProgress ? "ignore" : "inherit" }); } catch (error) { throw new Error( - `couldn't install uv from astral.sh; install it yourself: ${UV_INSTALL_COMMAND}, then re-run prime-agent. ${errorMessage(error)}`, + `couldn't install uv from astral.sh; install it yourself: ${uvInstallCommand()}, then re-run prime-agent. ${errorMessage(error)}`, ); } @@ -725,7 +753,7 @@ async function bootstrapVenv( ): Promise { await mkdir(path.dirname(venv), { recursive: true }); const uv = await ensureUv(options); - const python = path.join(venv, "bin", "python"); + const python = getVenvPythonPath(venv); const sourceDir = await resolveRuntimeSourceDir(); const runtimeRequirement = sourceDir ?? RUNTIME_REQUIREMENT; const runtimeIdentity = await resolveRuntimeIdentity(); @@ -886,7 +914,7 @@ async function ensureKernelPythonUncached( } const venv = await resolveWritableKernelVenvDir(); - const python = path.join(venv, "bin", "python"); + const python = getVenvPythonPath(venv); const runtimeIdentity = await resolveRuntimeIdentity(); if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; diff --git a/packages/coding-agent/src/core/kernel/index.ts b/packages/coding-agent/src/core/kernel/index.ts index b760a2e1e2..629da2194b 100644 --- a/packages/coding-agent/src/core/kernel/index.ts +++ b/packages/coding-agent/src/core/kernel/index.ts @@ -1,9 +1,9 @@ // TODO: reconsider persistent kernel vs stateless `python -c` once RLM-1 weights land. import { type ChildProcess, spawn } from "node:child_process"; import { createHmac, randomBytes } from "node:crypto"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, dirname, join } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { registerSessionResourceCleanup } from "@earendil-works/pi-ai"; import { v4 as uuid } from "uuid"; @@ -26,6 +26,8 @@ const DELIM = Buffer.from(""); const PROTOCOL_VERSION = "5.3"; const PORTS_RESOLVE_TIMEOUT_MS = 5000; const READY_TIMEOUT_MS = 5000; +// Startup code runs on an idle kernel that has just answered kernel_info_request. +const KERNEL_PRELUDE_TIMEOUT_MS = 5000; // Loopback PUB/SUB subscription propagation is usually sub-ms, but keep a small guard before first execute. const IOPUB_SUBSCRIBE_DELAY_MS = 50; const DEFAULT_MAX_OUTPUT_CHARS = 65536; @@ -448,6 +450,34 @@ function readConnectionInfo(path: string): ConnectionInfo | null { } } +/** + * Environment that marks the kernel's virtualenv as the active one, the way + * `activate` would. Without VIRTUAL_ENV and the script directory on PATH, a + * `uv pip install` or `pip install` run from inside the kernel resolves against + * whatever interpreter PATH happens to point at — typically a system Python — + * so packages the model installs land where the kernel cannot import them. + * + * Returns undefined when the interpreter is not in a venv (a + * PRIME_AGENT_KERNEL_PYTHON pointing at a system or conda interpreter), leaving + * that environment untouched. + */ +function venvActivationEnv(python: string, baseEnv: NodeJS.ProcessEnv): Record | undefined { + const scriptDir = dirname(python); + const venvDir = dirname(scriptDir); + if (!venvDir || !existsSync(join(venvDir, "pyvenv.cfg"))) { + return undefined; + } + const pathKey = Object.keys(baseEnv).find((key) => key.toLowerCase() === "path") ?? "PATH"; + const currentPath = baseEnv[pathKey] ?? ""; + if (currentPath.split(delimiter)[0] === scriptDir) { + return { VIRTUAL_ENV: venvDir }; + } + return { + VIRTUAL_ENV: venvDir, + [pathKey]: currentPath ? `${scriptDir}${delimiter}${currentPath}` : scriptDir, + }; +} + function makeConnection(): { info: ConnectionInfo; path: string; tempDir: string } { const info: ConnectionInfo = { ip: "127.0.0.1", @@ -610,6 +640,14 @@ export class KernelManager { let connection = makeConnection(); this.tempDir = connection.tempDir; + // Per-kernel overrides win over the venv activation, which in turn wins + // over the host env, so an explicit VIRTUAL_ENV from the caller is kept. + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + ...venvActivationEnv(python, process.env), + ...this.options.env, + }; + // Fast path: fork a pre-imported kernel from the forkserver. Any failure // (disabled, unavailable, fork error) degrades to the direct-spawn path so // correctness never depends on fork. @@ -622,7 +660,7 @@ export class KernelManager { // Match the direct-spawn env exactly: merge the current host env with // the per-kernel overrides, applied fresh in the child (the template's // inherited env snapshot may be stale by fork time). - env: this.options.env ? { ...process.env, ...this.options.env } : { ...process.env }, + env: baseEnv, }); forked = true; } catch (err) { @@ -646,8 +684,11 @@ export class KernelManager { if (!forked) { const kernel = spawn(python, ["-m", "ipykernel_launcher", "-f", connection.path], { cwd: this.options.cwd, - env: this.options.env ? { ...process.env, ...this.options.env } : process.env, + env: baseEnv, stdio: ["ignore", "pipe", "pipe"], + // The kernel talks over ZMQ and its stdio is piped; on Windows it + // would otherwise get a console window of its own per session. + windowsHide: true, }); this.kernel = kernel; @@ -708,6 +749,8 @@ export class KernelManager { throw e; } + await this.enableWindowsSubprocessEventLoops(); + this.state = "running"; this.startForkedLivenessMonitor(); } @@ -800,6 +843,101 @@ export class KernelManager { ); } + /** + * ipykernel installs a Windows *selector* event loop policy because pyzmq + * needs add_reader, and a selector loop cannot spawn subprocesses. Anything + * in the kernel that shells out through asyncio — playwright, + * asyncio.create_subprocess_exec, any async driver that starts a helper + * binary — then dies with a bare NotImplementedError, and the usual + * workaround of running it on a fresh loop in a worker thread fails too, + * because asyncio.new_event_loop() inherits that same policy. + * + * Swapping the policy back to the proactor one fixes loops created from here + * on. The kernel's own loop is already running and is left alone: it is + * re-bound to the main thread so ipykernel keeps the selector loop it needs. + * + * Best-effort by design — a kernel that cannot apply this is still a working + * kernel, just without asyncio subprocesses. + */ + private async enableWindowsSubprocessEventLoops(): Promise { + if (process.platform !== "win32") return; + + const code = [ + "def _pi_enable_subprocess_event_loops():", + " import asyncio, sys", + ' if sys.platform != "win32":', + " return", + // Removed in Python 3.14, where the policy no longer forces a selector loop. + ' policy_cls = getattr(asyncio, "WindowsProactorEventLoopPolicy", None)', + " if policy_cls is None or isinstance(asyncio.get_event_loop_policy(), policy_cls):", + " return", + " kernel_loop = asyncio.get_event_loop()", + " asyncio.set_event_loop_policy(policy_cls())", + " asyncio.set_event_loop(kernel_loop)", + "try:", + " _pi_enable_subprocess_event_loops()", + "except Exception:", + " pass", + "finally:", + " del _pi_enable_subprocess_event_loops", + "", + ].join("\n"); + + try { + await this.executeSilently(code, KERNEL_PRELUDE_TIMEOUT_MS); + } catch (error) { + this.appendKernelDiagnostic( + `could not enable asyncio subprocess support: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Run setup code without touching execution history, the iopub pump, or the + * activeExecution machinery that user-visible cells go through. + */ + private async executeSilently(code: string, timeoutMs: number): Promise { + const conn = this.connection; + const shell = this.shell; + if (!conn || !shell) return; + + const msg = buildMessage( + "execute_request", + { + code, + silent: true, + store_history: false, + user_expressions: {}, + allow_stdin: false, + stop_on_error: false, + }, + this.session, + this.options.username, + ); + const requestMsgId = msg.header.msg_id; + await shell.send(encode(msg, conn.key)); + + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if ((this.state as string) === "shutdown") return; + const remaining = timeoutMs - (Date.now() - startedAt); + const winner = await Promise.race([ + shell.receive().then((frames) => ({ kind: "frames" as const, frames })), + sleep(remaining).then(() => ({ kind: "timeout" as const })), + ]); + if (winner.kind === "timeout") break; + + const incoming = decode(winner.frames); + if ( + incoming?.header.msg_type === "execute_reply" && + (incoming.parent_header as { msg_id?: string }).msg_id === requestMsgId + ) { + return; + } + } + throw new Error(`kernel did not acknowledge setup code within ${timeoutMs}ms`); + } + async execute(code: string, opts: ExecuteOptions = {}): Promise { const result = await this.enqueueExecute(code, opts); // Refresh the on-disk snapshot after real work so a later resume (or a diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index fd818595f3..0ea2db7f9d 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -2397,6 +2397,7 @@ export class DefaultPackageManager implements PackageManager { stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), env: options?.env ? { ...baseEnv, ...options.env } : baseEnv, + windowsHide: true, }); } @@ -2464,6 +2465,7 @@ export class DefaultPackageManager implements PackageManager { encoding: "utf-8", shell: shouldUseWindowsShell(command), env: getEnv(), + windowsHide: true, }); if (result.error || result.status !== 0) { throw new Error( diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 646042e1c9..be47128c0c 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -58,6 +58,7 @@ function executeWithDefaultShell(command: string): string | undefined { encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); return output.trim() || undefined; } catch { diff --git a/packages/coding-agent/src/core/session-lease.ts b/packages/coding-agent/src/core/session-lease.ts index 6c4e2975cf..5550d7458d 100644 --- a/packages/coding-agent/src/core/session-lease.ts +++ b/packages/coding-agent/src/core/session-lease.ts @@ -116,6 +116,7 @@ function runProcessQuery(command: string, args: string[]): string { return execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); } @@ -215,6 +216,26 @@ function withLeaseGuard(directory: string, action: () => T): T { } } +/** + * A lease is claimed by renaming a candidate directory onto the lease path. + * POSIX reports an already-claimed lease as EEXIST/ENOTEMPTY, which is the + * signal this module is built around. Windows rejects a directory-over- + * directory rename with EPERM (sometimes EACCES) instead, so without this the + * "lease is taken" branch never runs there: a stale lease is never reclaimed + * and a live one surfaces as a raw EPERM instead of SessionAlreadyActiveError. + * The existence check keeps a genuine permission failure fatal. + */ +function renameFailedBecauseLeaseIsTaken(directory: string, error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ENOTEMPTY") { + return true; + } + if (process.platform !== "win32") { + return false; + } + return (code === "EPERM" || code === "EACCES") && existsSync(directory); +} + function reclaimStaleLease(directory: string): boolean { const stalePath = `${directory}.stale-${process.pid}-${randomUUID()}`; try { @@ -264,8 +285,7 @@ export function acquireSessionLease( return new SessionLease(canonicalPath, directory, token); } catch (error) { rmSync(candidateDirectory, { recursive: true, force: true }); - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST" && code !== "ENOTEMPTY") { + if (!renameFailedBecauseLeaseIsTaken(directory, error)) { throw error; } const existingOwner = readLeaseOwner(directory); diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index efc260eae1..4e03000b91 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -77,6 +77,8 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas detached: process.platform !== "win32", env: env ?? getShellEnv(), stdio: ["ignore", "pipe", "pipe"], + // The agent reads this output; a console window would only flash. + windowsHide: true, }); if (child.pid) trackDetachedChildPid(child.pid); let timedOut = false; diff --git a/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts b/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts index 05fcec0644..bde2e1c778 100644 --- a/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts +++ b/packages/coding-agent/src/modes/daemon/command-recovery-journal.ts @@ -206,11 +206,16 @@ export class CommandRecoveryJournal { closeSync(descriptor); } renameSync(tempPath, this.path); - const directoryDescriptor = openSync(dirname(this.path), "r"); try { - fsyncSync(directoryDescriptor); - } finally { - closeSync(directoryDescriptor); + const directoryDescriptor = openSync(dirname(this.path), "r"); + try { + fsyncSync(directoryDescriptor); + } finally { + closeSync(directoryDescriptor); + } + } catch { + // Directory fsync is unavailable on some platforms (EPERM on Windows); + // the atomic rename above still protects readers. } this.recordCount = records.length; } diff --git a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts index c841b5b79c..0822a28e46 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -396,6 +396,7 @@ export class DaemonCatalogClient { cwd: process.cwd(), env: createCliSubprocessEnv({ ...process.env, [DAEMON_CATALOG_ROLE_ENV]: "1" }), stdio: ["ignore", "ignore", "ignore", "ipc"], + windowsHide: true, }); this.child = child; child.on("message", (value: unknown) => this.handleMessage(value)); diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index dfebcdf61e..f8d6feb1cf 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -820,6 +820,7 @@ export class AgentDaemon { detached: true, env: environment, stdio: "ignore", + windowsHide: true, }); child.unref(); const deadline = Date.now() + 10_000; diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index b22701bbcb..3712238759 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -2131,6 +2131,10 @@ export class DaemonSupervisor { [SESSION_LEASE_OWNER_ID_ENV]: rootActiveSessionId, }), stdio: ["ignore", "ignore", "pipe", "pipe"], + // A detached child gets no console on Windows, so every console tool the + // worker later runs would pop its own window. An invisible console keeps + // the whole subtree windowless. + windowsHide: true, }); const detachWorkerStderr = child.stderr ? attachJsonlLineReader(child.stderr, (line) => this.log(`Session worker ${workerId} stderr: ${line}`), { @@ -4864,6 +4868,7 @@ export class DaemonSupervisor { detached: true, env: environment, stdio: "ignore", + windowsHide: true, }); replacement.unref(); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0ec1d8bcd7..a96dff486c 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -8703,7 +8703,7 @@ export class InteractiveMode { private async handleShareCommand(): Promise { // Check if gh is available and logged in try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8", windowsHide: true }); if (authResult.status !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; @@ -8752,7 +8752,7 @@ export class InteractiveMode { try { const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { - proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile], { windowsHide: true }); let stdout = ""; let stderr = ""; proc.stdout?.on("data", (data) => { diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 4cf44908f8..84383dc616 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -98,6 +98,7 @@ function runCommand( timeout: timeoutMs, maxBuffer: maxBufferBytes, env: options?.env, + windowsHide: true, }); if (result.error) { diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index b60d98a003..b4bf29fbb5 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -253,6 +253,7 @@ function runGit(cwd: string, args: string[]): string | null { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); if (result.status !== 0 || typeof result.stdout !== "string") return null; return result.stdout.trim() || null; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index cbf289d86a..28e502761d 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -16,12 +16,19 @@ function findBashOnPath(): string | null { if (process.platform === "win32") { // Windows: Use 'where' and verify file exists (where can return non-existent paths) try { - const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSync("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000, windowsHide: true }); if (result.status === 0 && result.stdout) { - const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; - if (firstMatch && existsSync(firstMatch)) { - return firstMatch; - } + const matches = result.stdout + .trim() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && existsSync(line)); + // %SystemRoot%\System32\bash.exe is the WSL launcher, not a Windows + // bash: it sees a different filesystem (/mnt/c/...), so a command the + // agent composes with Windows paths silently runs against the wrong + // tree. Prefer any other bash and fall back to WSL only if it is all + // that exists. + return matches.find((match) => !isWslBashLauncher(match)) ?? matches[0] ?? null; } } catch { // Ignore errors @@ -44,6 +51,48 @@ function findBashOnPath(): string | null { return null; } +function isWslBashLauncher(bashPath: string): boolean { + const systemRoot = (process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows") + .toLowerCase() + .replaceAll("/", "\\") + .replace(/\\+$/, ""); + const normalized = bashPath.toLowerCase().replaceAll("/", "\\"); + // Sysnative is the 32-bit process view of System32; both reach the WSL stub. + return normalized.startsWith(`${systemRoot}\\system32\\`) || normalized.startsWith(`${systemRoot}\\sysnative\\`); +} + +/** + * Git for Windows install locations, in preference order. Covers the machine-wide + * installer, the 32-bit installer, per-user installs (the winget default), and + * Git resolved from PATH — which is how scoop/chocolatey shims land, since + * `\cmd\git.exe` always sits beside `\bin\bash.exe`. + */ +function windowsGitBashCandidates(): string[] { + const candidates: string[] = []; + const roots = [ + process.env.ProgramFiles, + process.env["ProgramFiles(x86)"], + process.env.LOCALAPPDATA ? `${process.env.LOCALAPPDATA}\\Programs` : undefined, + ]; + for (const root of roots) { + if (root) candidates.push(`${root}\\Git\\bin\\bash.exe`); + } + + try { + const result = spawnSync("where", ["git.exe"], { encoding: "utf-8", timeout: 5000, windowsHide: true }); + for (const line of result.stdout?.trim().split(/\r?\n/) ?? []) { + const gitPath = line.trim(); + // \cmd\git.exe or \bin\git.exe -> \bin\bash.exe + const match = /^(.*)\\(?:cmd|bin|mingw64\\bin)\\git\.exe$/i.exec(gitPath); + if (match?.[1]) candidates.push(`${match[1]}\\bin\\bash.exe`); + } + } catch { + // PATH lookup is best-effort; the fixed locations above still apply. + } + + return [...new Set(candidates)]; +} + /** * Resolve shell configuration based on platform and an optional explicit shell path. * Resolution order: @@ -62,15 +111,7 @@ export function getShellConfig(customShellPath?: string): ShellConfig { if (process.platform === "win32") { // 2. Try Git Bash in known locations - const paths: string[] = []; - const programFiles = process.env.ProgramFiles; - if (programFiles) { - paths.push(`${programFiles}\\Git\\bin\\bash.exe`); - } - const programFilesX86 = process.env["ProgramFiles(x86)"]; - if (programFilesX86) { - paths.push(`${programFilesX86}\\Git\\bin\\bash.exe`); - } + const paths = windowsGitBashCandidates(); for (const path of paths) { if (existsSync(path)) { @@ -194,6 +235,7 @@ export function killProcessTree(pid: number): void { spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, + windowsHide: true, }); } catch { // Ignore errors if taskkill fails diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index c3da7045ec..953aa679c1 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -12,6 +12,7 @@ const TOOLS_DIR = getBinDir(); const NETWORK_TIMEOUT_MS = 10_000; const DOWNLOAD_TIMEOUT_MS = 120_000; const COMMAND_TIMEOUT_MS = 5_000; +const ZIP_EXTRACT_TIMEOUT_MS = 60_000; const RIPGREP_INSTALL_URL = "https://github.com/BurntSushi/ripgrep#installation"; export type ManagedTool = "fd" | "rg"; @@ -100,7 +101,7 @@ const TOOLS: Record = { // Check that a command both launches and reports a successful version. function commandWorks(cmd: string): boolean { try { - const result = spawnSync(cmd, ["--version"], { stdio: "pipe", timeout: COMMAND_TIMEOUT_MS }); + const result = spawnSync(cmd, ["--version"], { stdio: "pipe", timeout: COMMAND_TIMEOUT_MS, windowsHide: true }); return !result.error && result.status === 0; } catch { return false; @@ -162,6 +163,53 @@ async function downloadFile(url: string, dest: string): Promise { await pipeline(Readable.fromWeb(response.body as any), fileStream); } +// Zip archives are only downloaded on Windows (every other platform gets a +// tar.gz). bsdtar ships in System32 on Windows 10 1803+ and reads zip, so it is +// the primary path; extract-zip stays as a fallback but is time-boxed because +// yauzl read streams can stall indefinitely on current Node releases, which +// silently wedged tool provisioning at the first multi-chunk entry. +async function extractZipArchive(archivePath: string, extractDir: string, assetName: string): Promise { + const tarResult = spawnSync("tar", ["-xf", archivePath, "-C", extractDir], { + stdio: "pipe", + timeout: ZIP_EXTRACT_TIMEOUT_MS, + windowsHide: true, + }); + if (!tarResult.error && tarResult.status === 0) { + return; + } + + const tarError = tarResult.error?.message ?? tarResult.stderr?.toString().trim() ?? `exit code ${tarResult.status}`; + try { + await withTimeout( + extractZip(archivePath, { dir: extractDir }), + ZIP_EXTRACT_TIMEOUT_MS, + `extracting ${assetName} timed out`, + ); + } catch (fallbackError) { + throw new Error( + `Failed to extract ${assetName}: tar failed (${tarError}) and the bundled unzip failed (${ + fallbackError instanceof Error ? fallbackError.message : String(fallbackError) + })`, + ); + } +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + function findBinaryRecursively(rootDir: string, binaryFileName: string): string | null { const stack: string[] = [rootDir]; @@ -224,13 +272,16 @@ async function downloadTool(tool: ManagedTool): Promise { try { if (assetName.endsWith(".tar.gz")) { - const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); + const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { + stdio: "pipe", + windowsHide: true, + }); if (extractResult.error || extractResult.status !== 0) { const errMsg = extractResult.error?.message ?? extractResult.stderr?.toString().trim() ?? "unknown error"; throw new Error(`Failed to extract ${assetName}: ${errMsg}`); } } else if (assetName.endsWith(".zip")) { - await extractZip(archivePath, { dir: extractDir }); + await extractZipArchive(archivePath, extractDir, assetName); } else { throw new Error(`Unsupported archive format: ${assetName}`); } @@ -262,9 +313,18 @@ async function downloadTool(tool: ManagedTool): Promise { throw new Error(`Installed ${config.name} binary failed its version check`); } } finally { - // Cleanup - rmSync(archivePath, { force: true }); - rmSync(extractDir, { recursive: true, force: true }); + // Cleanup. A failure here (Windows can hold a handle on a just-extracted + // file) must not replace the real error from the block above. + try { + rmSync(archivePath, { force: true }); + } catch { + // Leave the archive behind; the next run overwrites it. + } + try { + rmSync(extractDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } catch { + // Leave the temp directory behind rather than failing the install. + } } return binaryPath; diff --git a/packages/coding-agent/test/expand-tilde-path.test.ts b/packages/coding-agent/test/expand-tilde-path.test.ts new file mode 100644 index 0000000000..ae9c11000c --- /dev/null +++ b/packages/coding-agent/test/expand-tilde-path.test.ts @@ -0,0 +1,31 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { expandTildePath } from "../src/config.js"; + +describe("expandTildePath", () => { + it("returns the home directory for a bare tilde", () => { + expect(expandTildePath("~")).toBe(homedir()); + }); + + it("joins with the platform separator rather than concatenating", () => { + // String concatenation produced "C:\Users\me/sessions" on Windows, which is + // a working path but never compares equal to the same location built with + // join() — so a configured session dir looked like a different directory. + expect(expandTildePath("~/sessions")).toBe(join(homedir(), "sessions")); + }); + + it.runIf(process.platform === "win32")("produces a path with no forward slashes on Windows", () => { + expect(expandTildePath("~/sessions")).not.toContain("/"); + expect(expandTildePath("~\\sessions")).toBe(join(homedir(), "sessions")); + }); + + it("expands nested paths", () => { + expect(expandTildePath("~/a/b/c")).toBe(join(homedir(), "a", "b", "c")); + }); + + it("leaves paths without a leading tilde alone", () => { + expect(expandTildePath("relative/path")).toBe("relative/path"); + expect(expandTildePath("~notahome")).toBe("~notahome"); + }); +}); diff --git a/packages/coding-agent/test/kernel-venv-python-path.test.ts b/packages/coding-agent/test/kernel-venv-python-path.test.ts new file mode 100644 index 0000000000..3f0a7ab045 --- /dev/null +++ b/packages/coding-agent/test/kernel-venv-python-path.test.ts @@ -0,0 +1,32 @@ +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { getVenvPythonPath } from "../src/core/kernel/bootstrap.js"; + +const realPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); +} + +afterEach(() => { + setPlatform(realPlatform); +}); + +describe("getVenvPythonPath", () => { + it("uses the POSIX venv layout off Windows", () => { + setPlatform("linux"); + expect(getVenvPythonPath("/home/u/.prime/agent/kernel-venv")).toBe( + join("/home/u/.prime/agent/kernel-venv", "bin", "python"), + ); + }); + + it("uses the Windows venv layout on win32", () => { + setPlatform("win32"); + // uv/virtualenv place the interpreter in Scripts\python.exe on Windows; + // the POSIX path never exists there, so every readiness probe would fail + // and the venv would be torn down and rebuilt on every launch. + expect(getVenvPythonPath("C:\\Users\\u\\.prime\\agent\\kernel-venv")).toBe( + join("C:\\Users\\u\\.prime\\agent\\kernel-venv", "Scripts", "python.exe"), + ); + }); +}); diff --git a/packages/coding-agent/test/paths.test.ts b/packages/coding-agent/test/paths.test.ts index 208da3f91c..e2b0530f62 100644 --- a/packages/coding-agent/test/paths.test.ts +++ b/packages/coding-agent/test/paths.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { canonicalizePath, getCwdRelativePath, isLocalPath } from "../src/utils/paths.js"; +import { SYMLINKS_SUPPORTED } from "./platform-support.js"; let tempDir: string; @@ -26,7 +27,7 @@ describe("canonicalizePath", () => { expect(canonicalizePath(file)).toBe(realpathSync(file)); }); - it("resolves symlinks to their targets", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("resolves symlinks to their targets", () => { const dir = createTempDir(); const target = join(dir, "target.txt"); const link = join(dir, "link.txt"); @@ -35,7 +36,7 @@ describe("canonicalizePath", () => { expect(canonicalizePath(link)).toBe(realpathSync(target)); }); - it("resolves directory symlinks", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("resolves directory symlinks", () => { const dir = createTempDir(); const targetDir = join(dir, "target-dir"); const linkDir = join(dir, "link-dir"); @@ -50,7 +51,7 @@ describe("canonicalizePath", () => { expect(canonicalizePath(nonexistent)).toBe(nonexistent); }); - it("falls back to the raw path for a dangling symlink", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("falls back to the raw path for a dangling symlink", () => { const dir = createTempDir(); const target = join(dir, "target.txt"); const link = join(dir, "link.txt"); diff --git a/packages/coding-agent/test/platform-support.ts b/packages/coding-agent/test/platform-support.ts new file mode 100644 index 0000000000..5ad43769f5 --- /dev/null +++ b/packages/coding-agent/test/platform-support.ts @@ -0,0 +1,36 @@ +/** + * Capability probes for tests whose fixtures need something the host OS may not + * provide. Prefer these over a `process.platform` check: they describe what the + * test actually needs, and they keep the test running wherever the capability + * happens to exist (unprivileged Windows cannot create symlinks, but an + * elevated shell and the GitHub Actions Windows runners can). + */ + +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +function probeSymlinkSupport(): boolean { + let dir: string | undefined; + try { + dir = mkdtempSync(join(tmpdir(), "pi-symlink-probe-")); + const target = join(dir, "target"); + writeFileSync(target, ""); + symlinkSync(target, join(dir, "link")); + return true; + } catch { + return false; + } finally { + if (dir) rmSync(dir, { recursive: true, force: true }); + } +} + +/** False on Windows without Developer Mode or an elevated shell. */ +export const SYMLINKS_SUPPORTED = probeSymlinkSupport(); + +/** + * POSIX permission bits are advisory on Windows: chmod() succeeds but does not + * make a directory read-only, so tests that revoke write access to assert a + * fallback cannot express their precondition there. + */ +export const POSIX_PERMISSIONS_ENFORCED = process.platform !== "win32"; diff --git a/packages/coding-agent/test/session-lease.test.ts b/packages/coding-agent/test/session-lease.test.ts index 87338b6866..751e587045 100644 --- a/packages/coding-agent/test/session-lease.test.ts +++ b/packages/coding-agent/test/session-lease.test.ts @@ -12,6 +12,7 @@ import { SESSION_LEASES_ENABLED_ENV, SessionAlreadyActiveError, } from "../src/core/session-lease.js"; +import { SYMLINKS_SUPPORTED } from "./platform-support.js"; const tempDirs: string[] = []; @@ -144,7 +145,7 @@ describe("session leases", () => { } }); - it("treats symlink aliases as the same persisted session", () => { + it.skipIf(!SYMLINKS_SUPPORTED)("treats symlink aliases as the same persisted session", () => { const agentDir = createTempDir(); const sessionPath = join(agentDir, "session.jsonl"); const aliasPath = join(agentDir, "session-alias.jsonl");