diff --git a/packages/coding-agent/.changes/res-1268-windows-platform-seams.md b/packages/coding-agent/.changes/res-1268-windows-platform-seams.md new file mode 100644 index 0000000000..70124486b8 --- /dev/null +++ b/packages/coding-agent/.changes/res-1268-windows-platform-seams.md @@ -0,0 +1,6 @@ +- Fixed the Python kernel bootstrap on native Windows: the venv python now resolves under `Scripts\python.exe` (uv layout). ([Discussion #1401](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1401), [Discussion #1969](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1969)) +- Fixed `~/` and `~\` path expansion on Windows, including mixed-separator paths like `C:\Users\u/rest`. ([Discussion #1442](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1442), [Discussion #1469](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1469)) +- Fixed daemon worker handshakes timing out on slow machines: per-attempt hello/auth waits now consume the remaining connect budget instead of restarting a fixed 1s clock on every retry. ([Discussion #1622](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1622), [Discussion #1678](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1678)) +- Fixed console windows flashing on Windows: all background spawns now run with hidden windows. ([Discussion #1461](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1461)) +- Fixed bash resolution picking WSL's System32 `bash.exe` over a per-user Git Bash on PATH. ([Discussion #1437](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1437)) +- Fixed the built-in Herdr reporter never connecting on Windows by dialing the socket inside the named-pipe namespace. ([Discussion #1399](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1399)) diff --git a/packages/coding-agent/.changes/windows-hardening-current-node.md b/packages/coding-agent/.changes/windows-hardening-current-node.md new file mode 100644 index 0000000000..499d638e8c --- /dev/null +++ b/packages/coding-agent/.changes/windows-hardening-current-node.md @@ -0,0 +1,2 @@ +- Fixed Windows worker startup deadlines, session lease contention, and UTF-8 Python execution. +- Fixed deleted subagents returning in saved display state and duplicate cleanup failure notices. diff --git a/packages/coding-agent/src/cli/daemon-command.ts b/packages/coding-agent/src/cli/daemon-command.ts index 4fd655ef95..0154275727 100644 --- a/packages/coding-agent/src/cli/daemon-command.ts +++ b/packages/coding-agent/src/cli/daemon-command.ts @@ -3,7 +3,6 @@ import { clearLine, createInterface, cursorTo, type Interface } from "node:readl import { setTimeout as delay } from "node:timers/promises"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import chalk from "chalk"; -import { spawn } from "child_process"; import { expandTildePath } from "../config.js"; import type { AgentSessionEvent } from "../core/agent-session.js"; import type { AgentSessionRuntimeConfig } from "../core/agent-session-config.js"; @@ -14,6 +13,7 @@ import type { DaemonOutbound, DaemonResponse } from "../modes/daemon/daemon-prot import { matchesSessionIdSuffix } from "../modes/daemon/daemon-session-id.js"; import type { SessionSummary } from "../modes/daemon/daemon-session-list.js"; import { defaultDaemonSocketPath, normalizeSocketPath } from "../modes/daemon/daemon-socket.js"; +import { spawnHidden } from "../utils/child-process.js"; import { isLocalPath } from "../utils/paths.js"; import { isValidThinkingLevel } from "./args.js"; import { formatSessionListTable } from "./daemon-list-format.js"; @@ -688,7 +688,7 @@ async function runStart(parsed: ParsedDaemonClientCommand): Promise { parsed.socketPath, ...sessionArgs.daemonArgs.filter((arg) => arg !== "--background" && arg !== "-d"), ]; - const child = spawn(process.execPath, daemonArgs, { + const child = spawnHidden(process.execPath, daemonArgs, { cwd: sessionArgs.config?.cwd ?? process.cwd(), detached: true, env: process.env, diff --git a/packages/coding-agent/src/cli/daemon-launch.ts b/packages/coding-agent/src/cli/daemon-launch.ts index 0d57425064..40943d0a14 100644 --- a/packages/coding-agent/src/cli/daemon-launch.ts +++ b/packages/coding-agent/src/cli/daemon-launch.ts @@ -5,7 +5,6 @@ * the heavy main module graph loads. main.ts reuses the same memoized promise. */ -import { spawn } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { appendRotatingLog, expandTildePath, getClientErrorLogPath, getDaemonLogPath, VERSION } from "../config.js"; @@ -23,6 +22,7 @@ import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, DAEMON_WORKER_TOKEN_ENV, } from "../modes/daemon/daemon-worker-protocol.js"; +import { spawnHidden } from "../utils/child-process.js"; import { isHelpCommandRequest, PUBLIC_COMMAND_NAMES, REMOVED_COMMAND_NAMES } from "./command-registry.js"; import { createCliSubprocessEnv, formatCurrentCliCommand } from "./subprocess-launch.js"; @@ -394,7 +394,7 @@ Then retry the original command.`, delete env[SESSION_LEASE_OWNER_ID_ENV]; const logOffset = currentDaemonLogSize(socketPath); - const child = spawn( + const child = spawnHidden( process.execPath, [...process.execArgv, entrypoint, "--mode", "daemon", "--daemon-socket", socketPath], { diff --git a/packages/coding-agent/src/cli/daemon-ps.ts b/packages/coding-agent/src/cli/daemon-ps.ts index ed7cdbdfc0..ae73eebbb8 100644 --- a/packages/coding-agent/src/cli/daemon-ps.ts +++ b/packages/coding-agent/src/cli/daemon-ps.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "node:child_process"; import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, unlinkSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import chalk from "chalk"; @@ -24,6 +23,7 @@ import { processGroupHasLiveMember, processIdExists, signalProcessGroupIfHeld, + spawnSyncHidden, } from "../utils/child-process.js"; import { formatDaemonListTable } from "./daemon-ps-format.js"; import { promptYesNo } from "./daemon-stop-confirm.js"; @@ -180,18 +180,18 @@ function scanListeningDaemons(): DiscoveredDaemonProcess[] { if (process.platform === "win32") { return []; } - const ss = spawnSync("ss", ["-lxp"], { encoding: "utf8" }); + const ss = spawnSyncHidden("ss", ["-lxp"], { encoding: "utf8" }); if (!ss.error && ss.status === 0 && typeof ss.stdout === "string") { return enrichUptimes(parseSsListeners(ss.stdout, APP_NAME)); } - const lsof = spawnSync("lsof", ["-nP", "-F", "pn", "-U", "-a", "-c", APP_NAME], { encoding: "utf8" }); + const lsof = spawnSyncHidden("lsof", ["-nP", "-F", "pn", "-U", "-a", "-c", APP_NAME], { encoding: "utf8" }); const byName = !lsof.error && typeof lsof.stdout === "string" ? parseLsofListeners(lsof.stdout) : []; let byPid: DiscoveredDaemonProcess[] = []; - const ps = spawnSync("ps", ["-axo", "pid=,comm=,args="], { encoding: "utf8" }); + const ps = spawnSyncHidden("ps", ["-axo", "pid=,comm=,args="], { encoding: "utf8" }); if (!ps.error && ps.status === 0 && typeof ps.stdout === "string") { const pids = parsePrimeAgentProcessIds(ps.stdout, APP_NAME); if (pids.length > 0) { - const lsofByPid = spawnSync("lsof", ["-nP", "-F", "pn", "-U", "-a", "-p", pids.join(",")], { + const lsofByPid = spawnSyncHidden("lsof", ["-nP", "-F", "pn", "-U", "-a", "-p", pids.join(",")], { encoding: "utf8", }); if (!lsofByPid.error && typeof lsofByPid.stdout === "string") { @@ -212,7 +212,7 @@ function enrichUptimes(daemons: DiscoveredDaemonProcess[]): DiscoveredDaemonProc if (pids.length === 0) { return daemons; } - const ps = spawnSync("ps", ["-o", "pid=,etimes=", "-p", pids.join(",")], { encoding: "utf8" }); + const ps = spawnSyncHidden("ps", ["-o", "pid=,etimes=", "-p", pids.join(",")], { encoding: "utf8" }); if (ps.error || typeof ps.stdout !== "string") { return daemons; } @@ -808,7 +808,7 @@ function recordResidualListenerFailures( } } function describeDaemonParent(pid: number): string { - const result = spawnSync("ps", ["-o", "ppid=,tty=,command=", "-p", String(pid)], { encoding: "utf8" }); + const result = spawnSyncHidden("ps", ["-o", "ppid=,tty=,command=", "-p", String(pid)], { encoding: "utf8" }); if (result.error || result.status !== 0 || typeof result.stdout !== "string") { return ""; } diff --git a/packages/coding-agent/src/cli/daemon-update-restart.ts b/packages/coding-agent/src/cli/daemon-update-restart.ts index 73af5e37a4..f02ed081f0 100644 --- a/packages/coding-agent/src/cli/daemon-update-restart.ts +++ b/packages/coding-agent/src/cli/daemon-update-restart.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -14,7 +13,7 @@ import { DAEMON_WORKER_SUPERVISOR_SOCKET_ENV, DAEMON_WORKER_TOKEN_ENV, } from "../modes/daemon/daemon-worker-protocol.js"; -import { isProcessAlive } from "../utils/child-process.js"; +import { isProcessAlive, spawnHidden } from "../utils/child-process.js"; import { createCliSubprocessLaunchSpec } from "./subprocess-launch.js"; export const DAEMON_UPDATE_RESTART_COORDINATOR_FLAG = "--internal-update-restart-coordinator"; @@ -548,7 +547,7 @@ export async function launchDaemonUpdateRestartCoordinator( statusPath, ...(originActiveSessionId ? [DAEMON_UPDATE_RESTART_ORIGIN_FLAG, originActiveSessionId] : []), ]); - const child = spawn(launch.command, launch.args, { + const child = spawnHidden(launch.command, launch.args, { cwd: options.cwd ?? process.cwd(), detached: true, env: coordinatorEnvironment(agentDir), diff --git a/packages/coding-agent/src/cli/owned-session-worker.ts b/packages/coding-agent/src/cli/owned-session-worker.ts index 26937ae3db..aa6467ed35 100644 --- a/packages/coding-agent/src/cli/owned-session-worker.ts +++ b/packages/coding-agent/src/cli/owned-session-worker.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, type StdioOptions, spawn } from "node:child_process"; +import type { ChildProcess, StdioOptions } from "node:child_process"; import { randomUUID } from "node:crypto"; import { chmodSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -14,6 +14,7 @@ import { } from "../core/orphan-process-journal.js"; import { SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV } from "../core/session-lease.js"; import { attachJsonlLineReader, serializeJsonLine } from "../modes/rpc/jsonl.js"; +import { spawnHidden } from "../utils/child-process.js"; import { isHelpCommandRequest, PUBLIC_COMMAND_NAMES, REMOVED_COMMAND_NAMES } from "./command-registry.js"; import { type CliSubprocessLaunchSpec, createCliSubprocessLaunchSpec } from "./subprocess-launch.js"; @@ -335,7 +336,7 @@ export async function runOwnedSessionWorkerFrontend( const stdio: StdioOptions = interactive ? ["inherit", "inherit", "inherit", "ipc"] : [bridgeStdin ? "pipe" : "inherit", "pipe", "pipe", "ipc"]; - const child = spawn(launch.command, launch.args, { + const child = spawnHidden(launch.command, launch.args, { cwd: process.cwd(), detached: process.platform !== "win32", env: { diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 596d468d1a..9754e432c7 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -1,4 +1,3 @@ -import { spawnSync } from "child_process"; import { createHash } from "crypto"; import { accessSync, @@ -13,9 +12,9 @@ import { statSync, } from "fs"; import { homedir } from "os"; -import { basename, dirname, join, resolve, sep, win32 } from "path"; +import { basename, dirname, join, posix, resolve, sep, win32 } from "path"; import { fileURLToPath } from "url"; -import { shouldUseWindowsShell } from "./utils/child-process.js"; +import { shouldUseWindowsShell, spawnSyncHidden } from "./utils/child-process.js"; import { normalizeSocketPath } from "./utils/daemon-socket-path.js"; // ============================================================================= @@ -207,7 +206,7 @@ function readCommandOutput( args: string[], options: { requireSuccess?: boolean } = {}, ): string | undefined { - const result = spawnSync(command, args, { + const result = spawnSyncHidden(command, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), @@ -366,9 +365,7 @@ export function getPackageDir(): string { // Allow override via environment variable (useful for Nix/Guix where store paths tokenize poorly) const envDir = process.env.PI_PACKAGE_DIR; if (envDir) { - if (envDir === "~") return homedir(); - if (envDir.startsWith("~/")) return homedir() + envDir.slice(1); - return envDir; + return expandTildePath(envDir); } if (isBunBinary) { @@ -503,9 +500,11 @@ export const ENV_AGENT_DIR = `${envPrefix}_CODING_AGENT_DIR`; export const ENV_SESSION_DIR = `${envPrefix}_SESSION_DIR`; export const ENV_LEGACY_SESSION_DIR = `${envPrefix}_CODING_AGENT_SESSION_DIR`; -export function expandTildePath(path: string): string { +export function expandTildePath(path: string, platform: NodeJS.Platform = process.platform): string { if (path === "~") return homedir(); - if (path.startsWith("~/")) return homedir() + path.slice(1); + if (path.startsWith("~/") || (platform === "win32" && path.startsWith("~\\"))) { + return (platform === "win32" ? win32 : posix).join(homedir(), path.slice(2)); + } return path; } diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 9ca04cf29b..ef39ffada5 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -943,6 +943,7 @@ interface RlmChildRun { deletionCleanupFailed?: boolean; deletionRunFinished?: boolean; deletionNotice?: Promise; + deletionFailureNotice?: Promise; deletionNeedsCompletionNotice?: boolean; completeDeletion?: () => Promise; reportDeletionCleanupFailure?: (error: unknown) => Promise; @@ -10177,6 +10178,7 @@ export class AgentSession { // resolved child. A failed preflight must leave the prior retry boundary // intact so a later call can acquire it. run.deletionCleanupFailed = false; + run.deletionFailureNotice = undefined; run.deletionReservation = createAgentMessageDeferred(); } // The detached task remains the sole lifecycle owner. Mark deletion before @@ -10809,14 +10811,17 @@ export class AgentSession { run.reportDeletionCleanupFailure = (error) => { if (run.suppressTerminalNotice || this._disposed || this._disposing) return Promise.resolve(); + if (run.deletionFailureNotice) return run.deletionFailureNotice; const cleanupError = error instanceof Error ? error.message : String(error); - return deliverTerminalMessageToParent( + const notice = deliverTerminalMessageToParent( createRlmChildFailureMessage({ childId: run.id, sessionName, error: `Deletion cleanup failed; retry rlm.delete_subagent("${run.id}") before completion: ${cleanupError}`, }), ); + run.deletionFailureNotice = notice; + return notice; }; // Runtime startup and the task run are deliberately detached. The public diff --git a/packages/coding-agent/src/core/autonomous.ts b/packages/coding-agent/src/core/autonomous.ts index 75930d415c..b303c0dd3e 100644 --- a/packages/coding-agent/src/core/autonomous.ts +++ b/packages/coding-agent/src/core/autonomous.ts @@ -1,10 +1,9 @@ -import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { lstat, readlink } from "node:fs/promises"; import { resolve } from "node:path"; import type { AssistantMessage, Usage, UserMessage } from "@earendil-works/pi-ai"; -import { waitForChildProcess } from "../utils/child-process.js"; +import { spawnHidden, waitForChildProcess } from "../utils/child-process.js"; import { killProcessTree, trackDetachedChildPid, untrackDetachedChildPid } from "../utils/shell.js"; export interface AgentAutonomousConfig { @@ -491,7 +490,7 @@ function runChildProcess( ): Promise { options.signal?.throwIfAborted(); return new Promise((resolve) => { - const child = spawn(command, args, { + const child = spawnHidden(command, args, { cwd: options.cwd, detached: process.platform !== "win32", shell: options.shell === true, diff --git a/packages/coding-agent/src/core/exec.ts b/packages/coding-agent/src/core/exec.ts index c3cc408068..a96fc89fa2 100644 --- a/packages/coding-agent/src/core/exec.ts +++ b/packages/coding-agent/src/core/exec.ts @@ -2,8 +2,7 @@ * Shared command execution utilities for extensions and custom tools. */ -import { spawn } from "node:child_process"; -import { waitForChildProcess } from "../utils/child-process.js"; +import { spawnHidden, waitForChildProcess } from "../utils/child-process.js"; /** * Options for executing shell commands. @@ -58,7 +57,7 @@ export async function execCommand( options?: ExecOptions, ): Promise { return new Promise((resolve) => { - const proc = spawn(command, args, { + const proc = spawnHidden(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts b/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts index 38b6e1efd2..fd8afe7038 100644 --- a/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts +++ b/packages/coding-agent/src/core/extensions/builtin/herdr-agent-state.ts @@ -17,7 +17,7 @@ */ import { createConnection } from "node:net"; -import { basename } from "node:path"; +import { basename, win32 } from "node:path"; import type { ExtensionAPI, ExtensionFactory } from "../types.js"; type AgentState = "working" | "blocked" | "idle"; @@ -42,6 +42,16 @@ export function hasFileBasedHerdrIntegration(loadedExtensionPaths: string[]): bo }); } +/** Windows dials local-domain sockets inside \\.\pipe\; Herdr exports a unix-style path, so map it (namespaced paths pass through). */ +export function herdrSocketTarget(socketPath: string, platform: NodeJS.Platform = process.platform): string { + // The pipe namespace is case-insensitive, so only the prefix check lowercases. + const lowered = socketPath.toLowerCase(); + if (platform !== "win32" || lowered.startsWith("\\\\.\\pipe\\") || lowered.startsWith("\\\\?\\pipe\\")) { + return socketPath; + } + return win32.join("\\\\.\\pipe\\", socketPath); +} + interface QueuedState { state: AgentState; message?: string; @@ -171,7 +181,7 @@ function herdrAgentStateExtensionImpl(pi: ExtensionAPI, getLoadedExtensionPaths: resolve(); }; - const socket = createConnection(socketPath!); + const socket = createConnection(herdrSocketTarget(socketPath!)); socket.on("error", finish); socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); socket.on("data", finish); diff --git a/packages/coding-agent/src/core/footer-data-provider.ts b/packages/coding-agent/src/core/footer-data-provider.ts index 2aff8ac321..2f6d8acd45 100644 --- a/packages/coding-agent/src/core/footer-data-provider.ts +++ b/packages/coding-agent/src/core/footer-data-provider.ts @@ -1,12 +1,13 @@ -import { type ExecFileException, execFile, spawnSync } from "child_process"; +import type { ExecFileException } from "child_process"; import { existsSync, type FSWatcher, readFileSync, unwatchFile, watchFile } from "fs"; import { dirname, join } from "path"; +import { execFileHidden, spawnSyncHidden } from "../utils/child-process.js"; import { closeWatcher, FS_WATCH_RETRY_DELAY_MS, watchWithErrorHandler } from "../utils/fs-watch.js"; import { findGitPaths, type GitPaths } from "../utils/git.js"; /** Ask git for the current branch. Returns null on detached HEAD or if git is unavailable. */ function resolveBranchWithGitSync(repoDir: string): string | null { - const result = spawnSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { + const result = spawnSyncHidden("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], @@ -18,7 +19,7 @@ function resolveBranchWithGitSync(repoDir: string): string | null { /** Ask git for the current branch asynchronously. Returns null on detached HEAD or if git is unavailable. */ function resolveBranchWithGitAsync(repoDir: string): Promise { return new Promise((resolvePromise) => { - execFile( + execFileHidden( "git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { diff --git a/packages/coding-agent/src/core/kernel/bootstrap.ts b/packages/coding-agent/src/core/kernel/bootstrap.ts index 7ad13bc156..d3e4ddcceb 100644 --- a/packages/coding-agent/src/core/kernel/bootstrap.ts +++ b/packages/coding-agent/src/core/kernel/bootstrap.ts @@ -1,5 +1,4 @@ -import { spawn } from "node:child_process"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { constants, existsSync, readdirSync, readFileSync } from "node:fs"; import { access, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; @@ -9,7 +8,7 @@ import { createInterface } from "node:readline/promises"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { getPackageDir } from "../../config.js"; -import { isProcessAlive } from "../../utils/child-process.js"; +import { isProcessAlive, spawnHidden } from "../../utils/child-process.js"; import { tryAcquireDirLock } from "../../utils/dir-lock.js"; import type { PythonSkillRuntimeInfo } from "../skills.js"; @@ -36,6 +35,42 @@ const DEFAULT_RLM_EXTRA_PACKAGES = [ export const DEFAULT_RLM_EXTRA_UV_ARGS = DEFAULT_RLM_EXTRA_PACKAGES.map((pkg) => pkg.uvArg); 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 WINDOWS_PATHEXT_DEFAULT = [".COM", ".EXE", ".BAT", ".CMD"]; +const WINDOWS_SUPPORTED_EXECUTABLE_EXTENSIONS = new Set( + WINDOWS_PATHEXT_DEFAULT.map((extension) => extension.toLowerCase()), +); + +export interface BatchShimInvocation { + args: string[]; + env: NodeJS.ProcessEnv; +} + +/** Build a cmd.exe invocation without embedding user-controlled values in its command string. */ +export function buildBatchShimInvocation( + command: string, + args: readonly string[], + baseEnv: NodeJS.ProcessEnv, + token = randomUUID().replaceAll("-", ""), +): BatchShimInvocation { + if (!/^[A-Za-z0-9_]+$/.test(token)) { + throw new Error("Windows batch shim token contains unsupported characters"); + } + const values = [command, ...args]; + if (values.some((value) => /["\0\r\n]/.test(value))) { + throw new Error("Windows batch shim paths and arguments cannot contain quotes, NUL, or line breaks"); + } + const env = { ...baseEnv }; + const variables = values.map((value, index) => { + const name = `PRIME_AGENT_BATCH_${token}_${index}`; + env[name] = value; + return `"%${name}%"`; + }); + return { + args: ["/d", "/v:off", "/s", "/c", `"${variables.join(" ")}"`], + env, + }; +} + const UV_INSTALL_COMMAND = "curl -LsSf https://astral.sh/uv/install.sh | sh"; const REQUIRED_HARNESS_METHODS = [ "create_memory", @@ -367,11 +402,19 @@ async function resolveWritableKernelVenvDir(): Promise { } } +function isBatchShim(command: string): boolean { + return process.platform === "win32" && /\.(cmd|bat)$/i.test(command); +} + function run(command: string, args: string[], options: { stdio?: "ignore" | "inherit" } = {}): Promise { return new Promise((resolve, reject) => { - const child = spawn(command, args, { - env: process.env, + // CPython must read UTF-8 .pth files even under a Windows legacy code page. + const env = { ...process.env, ...(process.platform === "win32" ? { PYTHONUTF8: "1" } : {}) }; + const batch = isBatchShim(command) ? buildBatchShimInvocation(command, args, env) : undefined; + const child = spawnHidden(batch ? (process.env.ComSpec ?? "cmd.exe") : command, batch?.args ?? args, { + env: batch?.env ?? env, stdio: options.stdio ?? "ignore", + ...(batch ? { windowsVerbatimArguments: true } : {}), }); child.on("error", reject); child.on("exit", (code, signal) => { @@ -464,10 +507,31 @@ async function acquireBootstrapLock(venv: string): Promise<() => Promise> } } +/** Try a bare command followed by supported PATHEXT extensions in the configured order. */ +export function windowsExecutableCandidates(name: string, pathext: string | undefined): string[] { + const extensions = (pathext ?? "") + .split(";") + .map((ext) => ext.trim().toLowerCase()) + .filter((ext) => WINDOWS_SUPPORTED_EXECUTABLE_EXTENSIONS.has(ext)); + const lowerName = name.toLowerCase(); + if (WINDOWS_PATHEXT_DEFAULT.some((ext) => lowerName.endsWith(ext.toLowerCase()))) { + return [name]; + } + const seen = new Set([name.toLowerCase()]); + const candidates = [name]; + for (const ext of extensions.length > 0 ? extensions : WINDOWS_PATHEXT_DEFAULT) { + const candidate = `${name}${ext}`; + if (seen.has(candidate.toLowerCase())) continue; + seen.add(candidate.toLowerCase()); + candidates.push(candidate); + } + return candidates; +} + async function findExecutable(name: string): Promise { const pathValue = process.env.PATH; if (!pathValue) return null; - const candidates = process.platform === "win32" ? [name, `${name}.exe`] : [name]; + const candidates = process.platform === "win32" ? windowsExecutableCandidates(name, process.env.PATHEXT) : [name]; for (const dir of pathValue.split(path.delimiter)) { if (!dir) continue; for (const candidate of candidates) { @@ -682,6 +746,10 @@ async function hashRuntimeSource(sourceDir: string): Promise { return `sha256:${hash.digest("hex")}`; } +export function kernelVenvPython(venv: string, platform: NodeJS.Platform = process.platform): string { + return platform === "win32" ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python"); +} + async function bootstrapVenv( venv: string, pythonSkills: readonly BootstrapPythonSkill[], @@ -689,7 +757,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 = kernelVenvPython(venv); const sourceDir = await resolveRuntimeSourceDir(); const runtimeRequirement = sourceDir ?? RUNTIME_REQUIREMENT; const runtimeIdentity = await resolveRuntimeIdentity(); @@ -820,6 +888,11 @@ async function ensureKernelPythonUncached( const override = process.env.PRIME_AGENT_KERNEL_PYTHON; if (override) { const python = path.resolve(expandHome(override)); + if (isBatchShim(python)) { + throw new Error( + `PRIME_AGENT_KERNEL_PYTHON must point directly to a Python executable, not a Windows batch shim: ${python}`, + ); + } const missing: string[] = []; if (!(await hasPrimeAgentRuntime(python))) { missing.push( @@ -846,7 +919,7 @@ async function ensureKernelPythonUncached( } const venv = await resolveWritableKernelVenvDir(); - const python = path.join(venv, "bin", "python"); + const python = kernelVenvPython(venv); const runtimeIdentity = await resolveRuntimeIdentity(); if (await kernelReady(python, venv, runtimeIdentity, pythonSkills)) return python; diff --git a/packages/coding-agent/src/core/kernel/repl-manager.ts b/packages/coding-agent/src/core/kernel/repl-manager.ts index 537123943f..51e4bf6452 100644 --- a/packages/coding-agent/src/core/kernel/repl-manager.ts +++ b/packages/coding-agent/src/core/kernel/repl-manager.ts @@ -1,11 +1,12 @@ // Kernel client for the REPL runtime: the kernel is a JSON-lines subprocess // (`python -m rlm.repl`) — requests on stdin, events on stdout, stderr kept as // a diagnostics tail. The protocol is documented in prime-agent-runtime/src/rlm/repl.md. -import { type ChildProcess, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { closeSync, existsSync, mkdirSync, openSync, renameSync, rmSync, statSync, writeSync } from "node:fs"; import { dirname } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { v4 as uuid } from "uuid"; +import { spawnHidden } from "../../utils/child-process.js"; import { reapKernelOrphanProcesses, recordOrphanProcessState } from "../orphan-process-journal.js"; import { ensureKernelPython } from "./bootstrap.js"; import { @@ -305,13 +306,14 @@ export class ReplKernelManager { throw new Error("Kernel was disposed during startup"); } - const child = spawn(python, ["-m", "rlm.repl"], { + const child = spawnHidden(python, ["-m", "rlm.repl"], { cwd: this.options.cwd, // bash.py journals its process groups under this pid so the host can // reap them if the runtime dies without running its shutdown hook. env: { ...process.env, ...this.options.env, + ...(process.platform === "win32" ? { PYTHONUTF8: "1" } : {}), PRIME_AGENT_KERNEL_OWNER_PID: String(process.pid), }, stdio: ["pipe", "pipe", "pipe"], diff --git a/packages/coding-agent/src/core/orphan-process-journal.ts b/packages/coding-agent/src/core/orphan-process-journal.ts index e336f2200c..dc52fd531b 100644 --- a/packages/coding-agent/src/core/orphan-process-journal.ts +++ b/packages/coding-agent/src/core/orphan-process-journal.ts @@ -1,6 +1,6 @@ -import { spawnSync } from "node:child_process"; import { closeSync, fsyncSync, openSync, readFileSync, rmSync, writeSync } from "node:fs"; import { win32 } from "node:path"; +import { spawnSyncHidden } from "../utils/child-process.js"; import { getProcessStartId } from "./session-lease.js"; export const ORPHAN_PROCESS_JOURNAL_ENV = "PRIME_AGENT_INTERNAL_ORPHAN_PROCESS_JOURNAL"; @@ -150,7 +150,7 @@ export function reapKernelOrphanProcesses(kernelPid: number): void { export function killOrphanProcess(pid: number): boolean { if (process.platform === "win32") { // In-kernel bash() kill paths use taskkill /T; the reaper must kill the same tree, not just the shell pid. - const result = spawnSync( + const result = spawnSyncHidden( win32.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/F", "/T", "/PID", String(pid)], { diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index eaca1c746d..fe9e294214 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, type ChildProcessByStdio, spawn, spawnSync } from "node:child_process"; +import { type ChildProcess, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { homedir, tmpdir } from "node:os"; @@ -23,12 +23,11 @@ function getEnv(): NodeJS.ProcessEnv { } import { basename, dirname, join, relative, resolve, sep } from "node:path"; -import type { Readable } from "node:stream"; import { globSync } from "glob"; import ignore from "ignore"; import { minimatch } from "minimatch"; import { CONFIG_DIR_NAME, getBundledSkillsDir } from "../config.js"; -import { shouldUseWindowsShell } from "../utils/child-process.js"; +import { shouldUseWindowsShell, spawnHidden, spawnSyncHidden } from "../utils/child-process.js"; import { type GitSource, parseGitUrl } from "../utils/git.js"; import { canonicalizePath, isLocalPath } from "../utils/paths.js"; import type { ResourceDiagnostic } from "./diagnostics.js"; @@ -2359,9 +2358,9 @@ export class DefaultPackageManager implements PackageManager { command: string, args: string[], options?: { cwd?: string; env?: Record }, - ): ChildProcessByStdio { + ): ChildProcess { const baseEnv = getEnv(); - return spawn(command, args, { + return spawnHidden(command, args, { cwd: options?.cwd, stdio: ["ignore", "pipe", "pipe"], shell: shouldUseWindowsShell(command), @@ -2428,7 +2427,7 @@ export class DefaultPackageManager implements PackageManager { } private runCommandSync(command: string, args: string[]): string { - const result = spawnSync(command, args, { + const result = spawnSyncHidden(command, args, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", shell: shouldUseWindowsShell(command), diff --git a/packages/coding-agent/src/core/resolve-config-value.ts b/packages/coding-agent/src/core/resolve-config-value.ts index 965d2098a1..13997795b5 100644 --- a/packages/coding-agent/src/core/resolve-config-value.ts +++ b/packages/coding-agent/src/core/resolve-config-value.ts @@ -3,7 +3,7 @@ * Used by auth-storage.ts and model-registry.ts. */ -import { execSync, spawnSync } from "child_process"; +import { execSyncHidden, spawnSyncHidden } from "../utils/child-process.js"; import { getShellConfig } from "../utils/shell.js"; const commandResultCache = new Map(); @@ -32,12 +32,11 @@ function resolveEnvOrLiteral(config: string): string | undefined { function executeWithConfiguredShell(command: string): { executed: boolean; value: string | undefined } { try { const { shell, args } = getShellConfig(); - const result = spawnSync(shell, [...args, command], { + const result = spawnSyncHidden(shell, [...args, command], { encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], shell: false, - windowsHide: true, }); if (result.error) { @@ -61,7 +60,7 @@ function executeWithConfiguredShell(command: string): { executed: boolean; value function executeWithDefaultShell(command: string): string | undefined { try { - const output = execSync(command, { + const output = execSyncHidden(command, { encoding: "utf-8", timeout: 10000, stdio: ["ignore", "pipe", "ignore"], diff --git a/packages/coding-agent/src/core/session-file-actions.ts b/packages/coding-agent/src/core/session-file-actions.ts index 6002263704..7818c4fd21 100644 --- a/packages/coding-agent/src/core/session-file-actions.ts +++ b/packages/coding-agent/src/core/session-file-actions.ts @@ -1,7 +1,7 @@ -import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { rm, unlink } from "node:fs/promises"; import { basename } from "node:path"; +import { spawnSyncHidden } from "../utils/child-process.js"; import { getSessionArtifactPathForFile } from "./session-manager.js"; export type DeleteSessionFileResult = { ok: true; method: "trash" | "unlink" } | { ok: false; error: string }; @@ -25,7 +25,7 @@ export async function deleteSessionArtifacts(sessionPath: string): Promise /** Remove the session `.jsonl`, trying the `trash` CLI first, then falling back to unlink. */ async function removeSessionFile(sessionPath: string): Promise { const trashArgs = sessionPath.startsWith("-") ? ["--", sessionPath] : [sessionPath]; - const trashResult = spawnSync("trash", trashArgs, { encoding: "utf-8" }); + const trashResult = spawnSyncHidden("trash", trashArgs, { encoding: "utf-8" }); const getTrashErrorHint = (): string | null => { const parts: string[] = []; diff --git a/packages/coding-agent/src/core/session-lease.ts b/packages/coding-agent/src/core/session-lease.ts index feb5f79b77..da911128e3 100644 --- a/packages/coding-agent/src/core/session-lease.ts +++ b/packages/coding-agent/src/core/session-lease.ts @@ -1,9 +1,8 @@ -import { execFileSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; import { lockSync } from "proper-lockfile"; -import { isProcessAlive } from "../utils/child-process.js"; +import { execFileSyncHidden, isProcessAlive } from "../utils/child-process.js"; export const SESSION_LEASES_ENABLED_ENV = "PRIME_AGENT_INTERNAL_SESSION_LEASES"; export const SESSION_LEASE_OWNER_ID_ENV = "PRIME_AGENT_INTERNAL_SESSION_LEASE_OWNER_ID"; @@ -52,7 +51,7 @@ export class SessionLease { withLeaseGuard(this.directory, () => { const owner = readLeaseOwner(this.directory); if (typeof owner === "object" && owner.token === this.token) { - rmSync(this.directory, { recursive: true, force: true }); + reclaimStaleLease(this.directory); } }); } catch { @@ -84,11 +83,12 @@ export function canonicalSessionPath(sessionPath: string): string { } } -// "absent" (missing/garbage) is safely stale; "unreadable" may be a LIVE lease and must never be reclaimed. +// An unreadable owner may hold a live lease. Only a missing owner is safely absent. function readLeaseOwner(directory: string): SessionLeaseOwner | "absent" | "unreadable" { + const ownerPath = join(directory, "owner.json"); let raw: string; try { - raw = readFileSync(join(directory, "owner.json"), "utf8"); + raw = readFileSync(ownerPath, "utf8"); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unreadable"; } @@ -101,11 +101,14 @@ function readLeaseOwner(directory: string): SessionLeaseOwner | "absent" | "unre typeof parsed.sessionPath !== "string" || typeof parsed.createdAt !== "string" ) { - return "absent"; + throw new TypeError(`Corrupt session lease owner file: ${ownerPath} (missing or invalid required fields)`); } return parsed as SessionLeaseOwner; - } catch { - return "absent"; + } catch (error) { + if (error instanceof SyntaxError || error instanceof TypeError) { + throw new Error(`Corrupt session lease owner file: ${ownerPath} - ${error.message}`); + } + throw error; } } @@ -116,7 +119,7 @@ interface ProcessQueryOptions { type ProcessQuery = (command: string, args: string[], options?: ProcessQueryOptions) => string; function runProcessQuery(command: string, args: string[], options?: ProcessQueryOptions): string { - return execFileSync(command, args, { + return execFileSyncHidden(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: options?.env, @@ -247,17 +250,48 @@ function withLeaseGuard(directory: string, action: () => T): T { } } +export function isRenameTargetContention( + directory: string, + code: string | undefined, + platform: string = process.platform, +): boolean { + // POSIX: renameSync into an existing directory raises EEXIST or ENOTEMPTY. + if (code === "EEXIST" || code === "ENOTEMPTY") { + return true; + } + // Windows: renameSync into an existing directory raises EPERM or EACCES + // instead of EEXIST. Only treat them as contention when the target + // actually exists so real permission errors still propagate. + if ((code === "EPERM" || code === "EACCES") && platform === "win32") { + try { + return existsSync(directory); + } catch { + return false; + } + } + return false; +} + function reclaimStaleLease(directory: string): boolean { const stalePath = `${directory}.stale-${process.pid}-${randomUUID()}`; - try { - renameSync(directory, stalePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return true; + const sleepBuffer = new Int32Array(new SharedArrayBuffer(4)); + for (let attempt = 1; ; attempt++) { + try { + renameSync(directory, stalePath); + break; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return true; + const transient = process.platform === "win32" && (code === "EBUSY" || code === "EPERM" || code === "EACCES"); + if (!transient || attempt >= 8) return false; + Atomics.wait(sleepBuffer, 0, 0, 10 * attempt); } - return false; } - rmSync(stalePath, { recursive: true, force: true }); + try { + rmSync(stalePath, { recursive: true, force: true, maxRetries: 8, retryDelay: 10 }); + } catch { + // The quarantined directory no longer owns the lease path. + } return true; } @@ -295,20 +329,24 @@ export function acquireSessionLease( renameSync(candidateDirectory, directory); return new SessionLease(canonicalPath, directory, token); } catch (error) { + const err = error as NodeJS.ErrnoException; rmSync(candidateDirectory, { recursive: true, force: true }); - const code = (error as NodeJS.ErrnoException).code; - // win32 reports rename-onto-existing-directory as EPERM/EACCES, not EEXIST. - if (code !== "EEXIST" && code !== "ENOTEMPTY" && code !== "EPERM" && code !== "EACCES") { - throw error; - } - const existingOwner = readLeaseOwner(directory); - if (existingOwner === "unreadable") { + if (err.code === "ENOENT") { + // Candidate vanished - treat as retryable race. continue; } - if (existingOwner !== "absent" && isLeaseOwnerAlive(existingOwner)) { - throw new SessionAlreadyActiveError(canonicalPath, existingOwner.activeSessionId); + if (isRenameTargetContention(directory, err.code)) { + const existingOwner = readLeaseOwner(directory); + if (existingOwner === "unreadable") { + continue; + } + if (existingOwner !== "absent" && isLeaseOwnerAlive(existingOwner)) { + throw new SessionAlreadyActiveError(canonicalPath, existingOwner.activeSessionId); + } + reclaimStaleLease(directory); + continue; } - reclaimStaleLease(directory); + throw error; } } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index b6e93adb2a..9b72ffcc24 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -1,12 +1,11 @@ import { existsSync } from "node:fs"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; -import { spawn } from "child_process"; import { type Static, Type } from "typebox"; import { expandCollapseHint } from "../../modes/interactive/components/keybinding-hints.js"; import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate.js"; import { theme } from "../../modes/interactive/theme/theme.js"; -import { waitForChildProcess } from "../../utils/child-process.js"; +import { spawnHidden, waitForChildProcess } from "../../utils/child-process.js"; import { getShellConfig, getShellEnv, @@ -72,7 +71,7 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); return; } - const child = spawn(shell, [...args, command], { + const child = spawnHidden(shell, [...args, command], { cwd, detached: process.platform !== "win32", env: env ?? getShellEnv(), diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index e1e121f3c6..d2e466656e 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,6 +1,6 @@ import { accessSync, constants } from "node:fs"; import * as os from "node:os"; -import { isAbsolute, resolve as resolvePath } from "node:path"; +import { isAbsolute, posix, resolve as resolvePath, win32 } from "node:path"; const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; const NARROW_NO_BREAK_SPACE = "\u202F"; @@ -36,13 +36,13 @@ function normalizeAtPrefix(filePath: string): string { return filePath.startsWith("@") ? filePath.slice(1) : filePath; } -export function expandPath(filePath: string): string { +export function expandPath(filePath: string, platform: NodeJS.Platform = process.platform): string { const normalized = normalizeUnicodeSpaces(normalizeAtPrefix(filePath)); if (normalized === "~") { return os.homedir(); } - if (normalized.startsWith("~/")) { - return os.homedir() + normalized.slice(1); + if (normalized.startsWith("~/") || (platform === "win32" && normalized.startsWith("~\\"))) { + return (platform === "win32" ? win32 : posix).join(os.homedir(), normalized.slice(2)); } return normalized; } 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 17453aaabb..b8fc6e89b8 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-catalog-process.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; @@ -9,6 +9,7 @@ import { getPackageDir, isBunBinary } from "../../config.js"; import type { DeleteSessionFileResult } from "../../core/session-file-actions.js"; import { deleteSessionFile } from "../../core/session-file-actions.js"; import { readSessionInfo, type SessionInfo, SessionManager } from "../../core/session-manager.js"; +import { spawnHidden } from "../../utils/child-process.js"; export const DAEMON_CATALOG_ROLE_ENV = "PRIME_AGENT_INTERNAL_DAEMON_CATALOG"; const DAEMON_CATALOG_START_TIMEOUT_MS = 30_000; @@ -359,7 +360,7 @@ export class DaemonCatalogClient { args = launch.args; environment = createCliSubprocessEnv(environment, catalogEntry, execArgs); } - const child = spawn(command, args, { + const child = spawnHidden(command, args, { cwd: process.cwd(), env: environment, stdio: ["ignore", "ignore", "ignore", "ipc"], diff --git a/packages/coding-agent/src/modes/daemon/daemon-client.ts b/packages/coding-agent/src/modes/daemon/daemon-client.ts index b5ded67982..f75cbed8e2 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-client.ts @@ -127,6 +127,16 @@ export interface DaemonTransportClient { close(): void; } +const DEFAULT_DAEMON_REQUEST_TIMEOUT_MS = 30_000; +// Windows worker startup can exceed 30 seconds under antivirus scanning. +const WINDOWS_DAEMON_CREATE_TIMEOUT_MS = 120_000; + +function defaultDaemonRequestTimeout(command: DaemonCommandBody): number { + return command.type === "create" && process.platform === "win32" + ? WINDOWS_DAEMON_CREATE_TIMEOUT_MS + : DEFAULT_DAEMON_REQUEST_TIMEOUT_MS; +} + const DEFAULT_RECONNECT_TIMEOUT_MS = 60_000; const RECONNECT_CONNECT_TIMEOUT_MS = 1000; const RECONNECT_HELLO_TIMEOUT_MS = 3000; @@ -320,7 +330,7 @@ export class DaemonClient { async request( command: DaemonCommandBody, - timeoutMs = 30000, + timeoutMs = defaultDaemonRequestTimeout(command), options: DaemonClientRequestOptions = {}, ): Promise { if (!this.socket || this.socket.destroyed) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-mode.ts b/packages/coding-agent/src/modes/daemon/daemon-mode.ts index 1a6f7d05b6..ab71ecfc60 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-mode.ts @@ -6,7 +6,6 @@ * disposing the underlying agent loop. */ -import { spawn } from "node:child_process"; import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { stat } from "node:fs/promises"; @@ -108,7 +107,7 @@ import { import { resolveSessionPath } from "../../core/session-resolver.js"; import type { SessionStats } from "../../core/session-stats.js"; import { type SideQuestionRun, startSideQuestion } from "../../core/side-question.js"; -import { isProcessAlive } from "../../utils/child-process.js"; +import { isProcessAlive, spawnHidden } from "../../utils/child-process.js"; import { tryAcquireDirLock } from "../../utils/dir-lock.js"; import { killTrackedDetachedChildren } from "../../utils/shell.js"; import { @@ -875,7 +874,7 @@ export class AgentDaemon { delete environment[ORPHAN_PROCESS_JOURNAL_ENV]; delete environment[SESSION_LEASES_ENABLED_ENV]; delete environment[SESSION_LEASE_OWNER_ID_ENV]; - const child = spawn(launch.command, launch.args, { + const child = spawnHidden(launch.command, launch.args, { cwd: this.options.defaultSessionConfig.cwd ?? process.cwd(), detached: true, env: environment, @@ -1036,7 +1035,7 @@ export class AgentDaemon { this.pendingRlmSpawnAppends.set(`${parentState.activeSessionId}#${input.childId}`, spawnAppend); } try { - writeRlmSubagentDisplayEntry({ + const written = writeRlmSubagentDisplayEntry({ type: "rlm_subagent", childId: input.childId, sessionName: input.sessionName, @@ -1047,7 +1046,10 @@ export class AgentDaemon { createdAt: input.createdAt ?? Date.now(), updatedAt: new Date().toISOString(), }); - return true; + if (!written) { + this.log(`skipped RLM subagent display entry for ${input.childId}: deleted tombstone exists`); + } + return written; } catch (error) { this.log( `failed to persist RLM subagent display entry: ${error instanceof Error ? error.message : String(error)}`, @@ -1079,8 +1081,28 @@ export class AgentDaemon { } else if (edges.length > 0) { // Only tombstoned edges: the tombstones are already durable, nothing // to re-append. A prior deletion may have crashed before its artifact - // sweep, so retry it here. + // sweep. Restore the display tombstone before sweeping artifacts. for (const tombstoned of edges) { + try { + const currentDisplay = await readRlmSubagentDisplayEntry(dirname(tombstoned.child)); + if (!currentDisplay || currentDisplay.status !== "deleted") { + writeRlmSubagentDisplayEntry({ + type: "rlm_subagent", + childId, + sessionName: currentDisplay?.sessionName ?? tombstoned.name, + sessionDir: dirname(tombstoned.child), + sessionFile: currentDisplay?.sessionFile ?? tombstoned.child, + ...rlmSubagentMetadataFields(currentDisplay ?? {}), + status: "deleted", + createdAt: currentDisplay?.createdAt ?? 0, + updatedAt: new Date().toISOString(), + }); + } + } catch { + // Best-effort: the ledger tombstone is the authority; the display + // file is display-grade and the sweep below will remove artifacts. + this.log(`failed to reconcile display entry for tombstoned RLM subagent ${childId}`); + } await this.deleteRlmSubagentArtifacts(childId, tombstoned.child); } return; @@ -5552,7 +5574,7 @@ export class AgentDaemon { const client = new DaemonClient(supervisorSocketPath); try { await client.connect(1000); - await client.waitForHello(1000); + await client.waitForHello(); const response = await client.request( { type: "list_agent_peers", workerToken: this.options.worker.authenticationToken }, 5000, @@ -5756,7 +5778,7 @@ export class AgentDaemon { const client = new DaemonClient(supervisorSocketPath); try { await client.connect(1000); - await client.waitForHello(1000); + await client.waitForHello(); const response = await client.request( { type: "set_session_name", @@ -6056,7 +6078,7 @@ export class AgentDaemon { const candidate = new DaemonClient(supervisorSocketPath); try { await candidate.connect(1000); - await candidate.waitForHello(1000); + await candidate.waitForHello(); client = candidate; break; } catch (error) { diff --git a/packages/coding-agent/src/modes/daemon/daemon-routed-client.ts b/packages/coding-agent/src/modes/daemon/daemon-routed-client.ts index 54bc512899..92f67bb0dc 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-routed-client.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-routed-client.ts @@ -241,8 +241,8 @@ export async function createDaemonSessionTransport( } direct = new DaemonWorkerClient(ticket.socketPath); await direct.connect(1000); - await direct.waitForHello(1000); - await direct.authenticatePeer(ticket, 1000); + await direct.waitForHello(); + await direct.authenticatePeer(ticket); return new DaemonRoutedClient(supervisor, direct); } catch { direct?.close(); diff --git a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts index 61ca037136..5a2782c777 100644 --- a/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts +++ b/packages/coding-agent/src/modes/daemon/daemon-supervisor.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { createHash, randomBytes, randomUUID } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { createServer, type Server, type Socket } from "node:net"; @@ -53,7 +53,12 @@ import { getSessionArtifactPathForFile, readSessionInfo, type SessionInfo } from import { looksLikeSessionPath } from "../../core/session-resolver.js"; import { SettingsManager } from "../../core/settings-manager.js"; import { writeFileAtomicSync } from "../../utils/atomic-file.js"; -import { isProcessAlive, processIdExists, signalProcessGroupOrProcess } from "../../utils/child-process.js"; +import { + isProcessAlive, + processIdExists, + signalProcessGroupOrProcess, + spawnHidden, +} from "../../utils/child-process.js"; import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; import { attachJsonlLineReader, serializeJsonLine } from "../rpc/jsonl.js"; import type { PrivateFrame } from "../session-worker/private-framing.js"; @@ -168,7 +173,18 @@ type DistributiveOmit = T extends unknown ? Omit : n type DaemonCommandBody = DistributiveOmit; const structuredLog = getLogger("coding-agent.daemon-supervisor"); -const WORKER_CONNECT_TIMEOUT_MS = 30_000; +// Windows antivirus scanning can delay worker startup beyond 30 seconds. +const WORKER_CONNECT_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 30_000; +const WORKER_CONNECT_PROBE_MS = process.platform === "win32" ? 2_000 : 500; +const WORKER_PROBE_BACKOFF_MIN_MS = 25; +const WORKER_PROBE_BACKOFF_MAX_MS = process.platform === "win32" ? 2_000 : 25; + +/** Per-attempt handshake waits consume the remaining outer connect budget; a smaller fixed clock makes a consistently slow (win32) handshake fail every retry. */ +export function handshakeBudgetMs(deadline: number, now = Date.now()): number { + const remaining = deadline - now; + if (remaining <= 0) throw new DaemonWorkerProbeTimeoutError("Worker connection deadline elapsed"); + return remaining; +} const ROSTER_WATCHDOG_INTERVAL_MS = 15_000; const ROSTER_STALE_AFTER_MS = 3 * ROSTER_HEARTBEAT_INTERVAL_MS; const SUPERVISOR_SERVER_CAPABILITIES: readonly DaemonServerCapability[] = [ @@ -196,7 +212,8 @@ const STALE_RECLAIM_WAIT_MS = 10_000; // Polling loops probe existence cheaply via kill(0); the ps-backed zombie and // identity checks are throttled so a wedged worker cannot saturate the // supervisor event loop with synchronous subprocess spawns. -const LIVENESS_IDENTITY_RECHECK_MS = 500; +// Windows identity lookups launch PowerShell, so recheck less often there. +const LIVENESS_IDENTITY_RECHECK_MS = process.platform === "win32" ? 3_000 : 500; const OWNED_WORKER_DISCONNECT_GRACE_MS = 30_000; const IDLE_EVICTION_MAX_SWEEP_INTERVAL_MS = 5 * 60_000; const IDLE_EVICTION_MIN_SWEEP_INTERVAL_MS = 60_000; @@ -3108,7 +3125,7 @@ export class DaemonSupervisor { }); delete workerEnvironment.RLM_DEPTH; await this.assertRecoveryAllowed(); - const child: ChildProcess = spawn(launch.command, launch.args, { + const child: ChildProcess = spawnHidden(launch.command, launch.args, { cwd: createCommand.config?.cwd ?? process.cwd(), detached: true, env: workerEnvironment, @@ -3328,12 +3345,13 @@ export class DaemonSupervisor { private async connectWorker(worker: ResidentWorker, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; let lastError: unknown; + let backoffMs = WORKER_PROBE_BACKOFF_MIN_MS; while (Date.now() < deadline) { await this.assertRecoveryAllowed(); const client = new DaemonWorkerClient(worker.descriptor.socketPath); try { - await client.connect(Math.min(500, Math.max(50, deadline - Date.now()))); - await client.waitForHello(1000); + await client.connect(Math.min(WORKER_CONNECT_PROBE_MS, handshakeBudgetMs(deadline))); + await client.waitForHello(handshakeBudgetMs(deadline)); // Listen before authenticating: the worker flushes its roster snapshot right after auth succeeds. client.onFrame((frame) => this.handleWorkerFrame(worker, frame, client)); client.onClose((error) => void this.handleWorkerClose(worker, client, error)); @@ -3347,7 +3365,7 @@ export class DaemonSupervisor { ? { workerInstanceId: worker.descriptor.workerInstanceId } : {}), }, - 1000, + handshakeBudgetMs(deadline), ); await this.assertRecoveryAllowed(); if (!workerAuthAdvertisesRoster(authResponse.data)) { @@ -3371,7 +3389,10 @@ export class DaemonSupervisor { ) { throw error; } - await delay(25); + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + await delay(Math.min(backoffMs, remaining)); + backoffMs = Math.min(backoffMs * 2, WORKER_PROBE_BACKOFF_MAX_MS); } } throw new DaemonWorkerProbeTimeoutError(`Timed out connecting to daemon session worker: ${String(lastError)}`); @@ -3410,7 +3431,7 @@ export class DaemonSupervisor { if (worker.descriptor.processStartId === undefined && isProcessAlive(worker.descriptor.pid)) { const observedProcessStartId = getProcessStartId(worker.descriptor.pid); try { - await this.connectWorker(worker, 2000); + await this.connectWorker(worker, WORKER_CONNECT_TIMEOUT_MS); if (observedProcessStartId) { worker.descriptor.processStartId = observedProcessStartId; this.persistWorker(worker); @@ -3437,7 +3458,7 @@ export class DaemonSupervisor { throw new Error("Session worker process is no longer running"); } observedProcessStartId = getProcessStartId(worker.descriptor.pid); - await this.connectWorker(worker, 2000); + await this.connectWorker(worker, WORKER_CONNECT_TIMEOUT_MS); await this.subscribeWorker(worker, worker.descriptor.rootActiveSessionId); await this.refreshWorkerSummaries(worker, true); if (worker.descriptor.processStartId === undefined && observedProcessStartId) { @@ -3899,7 +3920,7 @@ export class DaemonSupervisor { (identityNow === "unknown" && worker.descriptor.processStartId === undefined); if (identityCompatible) { try { - await this.connectWorker(worker, 1500); + await this.connectWorker(worker, WORKER_CONNECT_TIMEOUT_MS); await this.subscribeWorker(worker, worker.descriptor.rootActiveSessionId); await this.refreshWorkerSummaries(worker, true); if (this.isWorkerRecoveryCancelled(worker)) { @@ -6446,8 +6467,7 @@ export class DaemonSupervisor { if (directChild) { sigkillSent = directChild.child.kill("SIGKILL"); } else if (this.processIdentity(entryPid, entryStartId) === "current") { - // Fresh, unthrottled check: the cached verdict may be up to 500ms - // old, long enough for the pid to be recycled. + // Recheck without the cache: the pid may have been recycled. signalProcessGroupOrProcess(entryPid, "SIGKILL"); sigkillSent = true; } @@ -6560,10 +6580,8 @@ export class DaemonSupervisor { break; } if (!killed && stoppedCanSignal && Date.now() >= sigkillDeadline) { - // Fresh, unthrottled identity check right before signalling: the - // cached verdict may be up to 500ms old, long enough for the pid - // to be recycled by an unrelated process. A transiently - // unobservable identity skips this attempt but keeps escalation + // Recheck without the cache before signalling a possibly recycled pid. + // An unobservable identity skips this attempt but keeps escalation // armed so a wedged worker is still killed on a later pass. const observedNow = processStartId === undefined ? undefined : getProcessStartId(pid); if (processStartId === undefined || observedNow === processStartId) { @@ -6976,7 +6994,7 @@ export class DaemonSupervisor { delete environment[ORPHAN_PROCESS_JOURNAL_ENV]; delete environment[SESSION_LEASES_ENABLED_ENV]; delete environment[SESSION_LEASE_OWNER_ID_ENV]; - const replacement = spawn(launch.command, launch.args, { + const replacement = spawnHidden(launch.command, launch.args, { cwd: this.defaultSessionConfig.cwd ?? process.cwd(), detached: true, env: environment, diff --git a/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts b/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts index 271993d854..99bd32224b 100644 --- a/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts +++ b/packages/coding-agent/src/modes/daemon/rlm-subagent-display.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs"; +import { mkdirSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { writeFileAtomicSync } from "../../utils/atomic-file.js"; @@ -52,10 +52,32 @@ function isRlmSubagentDisplayEntry(value: unknown): value is RlmSubagentDisplayE ); } -export function writeRlmSubagentDisplayEntry(entry: RlmSubagentDisplayEntry): void { +function readRlmSubagentDisplayEntrySync(sessionDir: string): RlmSubagentDisplayEntry | undefined { + let contents: string; + try { + contents = readFileSync(rlmSubagentDisplayPath(sessionDir), "utf8"); + } catch (error) { + // An unreadable file may hold a deletion tombstone. + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined; + throw error; + } + try { + const parsed = JSON.parse(contents) as unknown; + return isRlmSubagentDisplayEntry(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +// The daemon supervisor owns all writes synchronously, so the check and rename cannot interleave. +export function writeRlmSubagentDisplayEntry(entry: RlmSubagentDisplayEntry): boolean { const path = rlmSubagentDisplayPath(entry.sessionDir); + if (entry.status !== "deleted" && readRlmSubagentDisplayEntrySync(entry.sessionDir)?.status === "deleted") { + return false; + } mkdirSync(entry.sessionDir, { recursive: true }); writeFileAtomicSync(path, `${JSON.stringify(entry)}\n`, { mode: 0o600, fsync: true }); + return true; } export async function readRlmSubagentDisplayEntry( diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index be41de0a3b..c3a20aaca8 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -12,8 +12,8 @@ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import { execFile } from "child_process"; import { PRIME_BUTTERFLY_LOGO } from "../../../themes/prime-logo.js"; +import { execFileHidden } from "../../../utils/child-process.js"; import { copyToClipboard } from "../../../utils/clipboard.js"; import { theme } from "../theme/theme.js"; import { formatKeyText, keyHint } from "./keybinding-hints.js"; @@ -182,7 +182,7 @@ export class LoginDialogComponent extends Container implements Focusable { url, ] : ["xdg-open", url]; - execFile(command, args, () => {}); + execFileHidden(command, args, {}, () => {}); this.tui.requestRender(); } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 84d0019b0f..2ba8b292c5 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -42,7 +42,7 @@ import { truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; -import { spawn, spawnSync } from "child_process"; +import { spawnSync } from "child_process"; import { buildDaemonUpdateRestartReport, launchDaemonUpdateRestartCoordinator, @@ -136,6 +136,7 @@ import { import { type TruncationResult, truncateTail } from "../../core/tools/truncate.js"; import { PRIME_BUTTERFLY_LOGO } from "../../themes/prime-logo.js"; import { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; +import { spawnHidden, spawnSyncHidden } from "../../utils/child-process.js"; import { copyToClipboard } from "../../utils/clipboard.js"; import { readClipboardImage } from "../../utils/clipboard-image.js"; import { parseGitUrl } from "../../utils/git.js"; @@ -9088,7 +9089,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 = spawnSyncHidden("gh", ["auth", "status"], { encoding: "utf-8" }); if (authResult.status !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; @@ -9127,7 +9128,7 @@ export class InteractiveMode { }; // Create a secret gist asynchronously - let proc: ReturnType | null = null; + let proc: ReturnType | null = null; loader.onAbort = () => { proc?.kill(); @@ -9137,7 +9138,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 = spawnHidden("gh", ["gist", "create", "--public=false", tmpFile]); let stdout = ""; let stderr = ""; proc.stdout?.on("data", (data) => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 7c32788164..f718809688 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -4,7 +4,7 @@ * Spawns the agent in RPC mode and provides a typed API for all operations. */ -import { type ChildProcess, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import type { AgentEvent, AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ImageContent } from "@earendil-works/pi-ai"; import type { AgentSessionMessageReceipt, AgentSessionMessageSafetyStatus } from "../../core/agent-messages.js"; @@ -18,6 +18,7 @@ import type { } from "../../core/cron-jobs.js"; import type { RefinementResult } from "../../core/refinement/index.js"; import type { SessionStats } from "../../core/session-stats.js"; +import { spawnHidden } from "../../utils/child-process.js"; import type { AgentConnectionHeartbeat } from "../agent-connection/types.js"; import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; import type { @@ -103,7 +104,7 @@ export class RpcClient { args.push(...this.options.args); } - this.process = spawn("node", [cliPath, ...args], { + this.process = spawnHidden("node", [cliPath, ...args], { cwd: this.options.cwd, env: { ...process.env, ...this.options.env }, stdio: ["pipe", "pipe", "pipe"], diff --git a/packages/coding-agent/src/modes/shared/startup-notices.ts b/packages/coding-agent/src/modes/shared/startup-notices.ts index 1fe9ea1ad8..69323d52a9 100644 --- a/packages/coding-agent/src/modes/shared/startup-notices.ts +++ b/packages/coding-agent/src/modes/shared/startup-notices.ts @@ -7,9 +7,9 @@ * identical wording. */ -import { spawn } from "node:child_process"; import { DefaultPackageManager } from "../../core/package-manager.js"; import type { SettingsManager } from "../../core/settings-manager.js"; +import { spawnHidden } from "../../utils/child-process.js"; import { checkForNewPiVersion } from "../../utils/version-check.js"; import { theme } from "../interactive/theme/theme.js"; @@ -66,7 +66,7 @@ export async function checkTmuxKeyboardSetup(): Promise { const runTmuxShow = (option: string): Promise => { return new Promise((resolve) => { - const proc = spawn("tmux", ["show", "-gv", option], { + const proc = spawnHidden("tmux", ["show", "-gv", option], { stdio: ["ignore", "pipe", "ignore"], }); let stdout = ""; diff --git a/packages/coding-agent/src/utils/child-process.ts b/packages/coding-agent/src/utils/child-process.ts index e6f59ef831..cde2e69915 100644 --- a/packages/coding-agent/src/utils/child-process.ts +++ b/packages/coding-agent/src/utils/child-process.ts @@ -1,10 +1,79 @@ -import { type ChildProcess, execFileSync } from "node:child_process"; +import { + type ChildProcess, + type ExecFileException, + type ExecFileOptionsWithStringEncoding, + type ExecFileSyncOptions, + type ExecFileSyncOptionsWithStringEncoding, + type ExecSyncOptions, + type ExecSyncOptionsWithStringEncoding, + execFile, + execFileSync, + execSync, + type SpawnOptions, + type SpawnSyncOptions, + type SpawnSyncOptionsWithStringEncoding, + type SpawnSyncReturns, + spawn, + spawnSync, +} from "node:child_process"; import { readFileSync } from "node:fs"; import { constants } from "node:os"; import { basename } from "node:path"; const EXIT_STDIO_GRACE_MS = 100; +/** windowsHide for every non-interactive spawn (console children of a windowless parent flash a fresh console on Windows); only spawns that intentionally hand the user a console call node:child_process directly. */ +export function spawnHidden(command: string, args: readonly string[], options: SpawnOptions = {}): ChildProcess { + return spawn(command, args, { ...options, windowsHide: true }); +} + +export function spawnSyncHidden( + command: string, + args: readonly string[], + options: SpawnSyncOptionsWithStringEncoding, +): SpawnSyncReturns; +export function spawnSyncHidden( + command: string, + args?: readonly string[], + options?: SpawnSyncOptions, +): SpawnSyncReturns; +export function spawnSyncHidden( + command: string, + args: readonly string[] = [], + options: SpawnSyncOptions = {}, +): SpawnSyncReturns { + return spawnSync(command, args, { ...options, windowsHide: true }); +} + +export function execSyncHidden(command: string, options: ExecSyncOptionsWithStringEncoding): string; +export function execSyncHidden(command: string, options?: ExecSyncOptions): Buffer; +export function execSyncHidden(command: string, options: ExecSyncOptions = {}): string | Buffer { + return execSync(command, { ...options, windowsHide: true }); +} + +export function execFileHidden( + file: string, + args: readonly string[], + options: ExecFileOptionsWithStringEncoding, + callback: (error: ExecFileException | null, stdout: string, stderr: string) => void, +): ChildProcess { + return execFile(file, args, { ...options, windowsHide: true }, callback); +} + +export function execFileSyncHidden( + file: string, + args: readonly string[], + options: ExecFileSyncOptionsWithStringEncoding, +): string; +export function execFileSyncHidden(file: string, args?: readonly string[], options?: ExecFileSyncOptions): Buffer; +export function execFileSyncHidden( + file: string, + args: readonly string[] = [], + options: ExecFileSyncOptions = {}, +): string | Buffer { + return execFileSync(file, args, { ...options, windowsHide: true }); +} + const WINDOWS_SHELL_COMMANDS = new Set(["npm", "npx", "pnpm", "yarn", "yarnpkg", "corepack"]); export function shouldUseWindowsShell(command: string): boolean { @@ -39,7 +108,7 @@ export function isZombieProcess(pid: number): boolean { // Fall through to the portable process listing used on macOS and BSD. } try { - const state = execFileSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8" }).trim(); + const state = execFileSyncHidden("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8" }).trim(); return state.startsWith("Z"); } catch { return false; diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 653bf8cb5f..dc40d3a700 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -1,8 +1,8 @@ -import { spawnSync } from "child_process"; import { randomUUID } from "crypto"; import { readFileSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { spawnSyncHidden } from "./child-process.js"; import { clipboard } from "./clipboard-native.js"; import { loadPhoton } from "./photon.js"; @@ -79,7 +79,7 @@ function runCommand( const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS; const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; - const result = spawnSync(command, args, { + const result = spawnSyncHidden(command, args, { timeout: timeoutMs, maxBuffer: maxBufferBytes, env: options?.env, diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 84166e95ec..a0a921a62e 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,5 +1,5 @@ -import { execSync, spawn } from "child_process"; import { platform } from "os"; +import { execSyncHidden, spawnHidden } from "./child-process.js"; import { isWaylandSession } from "./clipboard-image.js"; import { clipboard } from "./clipboard-native.js"; @@ -11,9 +11,9 @@ type NativeClipboardExecOptions = { function copyToX11Clipboard(options: NativeClipboardExecOptions): void { try { - execSync("xclip -selection clipboard", options); + execSyncHidden("xclip -selection clipboard", options); } catch { - execSync("xsel --clipboard --input", options); + execSyncHidden("xsel --clipboard --input", options); } } @@ -66,16 +66,16 @@ export async function copyToClipboard(text: string): Promise { if (!copied) { try { if (p === "darwin") { - execSync("pbcopy", options); + execSyncHidden("pbcopy", options); copied = true; } else if (p === "win32") { - execSync("clip", options); + execSyncHidden("clip", options); copied = true; } else { // Linux. Try Termux, Wayland, or X11 clipboard tools. if (process.env.TERMUX_VERSION) { try { - execSync("termux-clipboard-set", options); + execSyncHidden("termux-clipboard-set", options); copied = true; } catch { // Fall back to Wayland or X11 tools. @@ -89,14 +89,14 @@ export async function copyToClipboard(text: string): Promise { if (isWayland && hasWaylandDisplay) { try { // Verify wl-copy exists (spawn errors are async and won't be caught) - execSync("which wl-copy", { stdio: "ignore" }); + execSyncHidden("which wl-copy", { stdio: "ignore" }); // wl-copy with execSync hangs due to fork behavior; use spawn instead - const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] }); - proc.stdin.on("error", () => { + const proc = spawnHidden("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] }); + proc.stdin?.on("error", () => { // Ignore EPIPE errors if wl-copy exits early }); - proc.stdin.write(text); - proc.stdin.end(); + proc.stdin?.write(text); + proc.stdin?.end(); proc.unref(); copied = true; } catch { diff --git a/packages/coding-agent/src/utils/git.ts b/packages/coding-agent/src/utils/git.ts index b60d98a003..d55348f558 100644 --- a/packages/coding-agent/src/utils/git.ts +++ b/packages/coding-agent/src/utils/git.ts @@ -1,7 +1,7 @@ -import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import hostedGitInfo from "hosted-git-info"; +import { spawnSyncHidden } from "./child-process.js"; /** * Parsed git URL information. @@ -249,7 +249,7 @@ export function gitContextsEqual(a: GitContext, b: GitContext): boolean { } function runGit(cwd: string, args: string[]): string | null { - const result = spawnSync("git", ["--no-optional-locks", ...args], { + const result = spawnSyncHidden("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index 3a85440011..4e5fc19201 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -1,14 +1,22 @@ import { existsSync } from "node:fs"; -import { delimiter } from "node:path"; -import { spawn, spawnSync } from "child_process"; +import { delimiter, win32 } from "node:path"; import { getBinDir } from "../config.js"; import { recordOrphanProcessState } from "../core/orphan-process-journal.js"; +import { spawnHidden, spawnSyncHidden } from "./child-process.js"; export interface ShellConfig { shell: string; args: string[]; } +/** System32\bash.exe is the WSL launcher (runs Linux-side), so %SystemRoot% matches are only a last resort. */ +export function orderWindowsBashCandidates(matches: readonly string[], systemRoot: string | undefined): string[] { + if (!systemRoot) return [...matches]; + const prefix = win32.join(systemRoot, "\\").toLowerCase(); + const underSystemRoot = (match: string) => win32.normalize(match).toLowerCase().startsWith(prefix); + return [...matches.filter((match) => !underSystemRoot(match)), ...matches.filter(underSystemRoot)]; +} + /** * Find bash executable on PATH (cross-platform) */ @@ -16,11 +24,13 @@ 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 = spawnSyncHidden("where", ["bash.exe"], { encoding: "utf-8", timeout: 5000 }); 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/).filter(Boolean); + for (const match of orderWindowsBashCandidates(matches, process.env.SystemRoot)) { + if (existsSync(match)) { + return match; + } } } } catch { @@ -31,7 +41,7 @@ function findBashOnPath(): string | null { // Unix: Use 'which' and trust its output (handles Termux and special filesystems) try { - const result = spawnSync("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); + const result = spawnSyncHidden("which", ["bash"], { encoding: "utf-8", timeout: 5000 }); if (result.status === 0 && result.stdout) { const firstMatch = result.stdout.trim().split(/\r?\n/)[0]; if (firstMatch) { @@ -219,7 +229,7 @@ export function killProcessTree(pid: number): void { if (process.platform === "win32") { // Use taskkill on Windows to kill process tree try { - spawn("taskkill", ["/F", "/T", "/PID", String(pid)], { + spawnHidden("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore", detached: true, }); diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index c3da7045ec..57aa77e373 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -1,5 +1,4 @@ import chalk from "chalk"; -import { spawnSync } from "child_process"; import extractZip from "extract-zip"; import { chmodSync, createWriteStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "fs"; import { arch, platform } from "os"; @@ -7,6 +6,7 @@ import { join } from "path"; import { Readable } from "stream"; import { pipeline } from "stream/promises"; import { APP_NAME, getBinDir } from "../config.js"; +import { spawnSyncHidden } from "./child-process.js"; const TOOLS_DIR = getBinDir(); const NETWORK_TIMEOUT_MS = 10_000; @@ -100,7 +100,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 = spawnSyncHidden(cmd, ["--version"], { stdio: "pipe", timeout: COMMAND_TIMEOUT_MS }); return !result.error && result.status === 0; } catch { return false; @@ -224,7 +224,7 @@ async function downloadTool(tool: ManagedTool): Promise { try { if (assetName.endsWith(".tar.gz")) { - const extractResult = spawnSync("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); + const extractResult = spawnSyncHidden("tar", ["xzf", archivePath, "-C", extractDir], { stdio: "pipe" }); 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}`); diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 4ef7ca7f70..598b4c59df 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -3943,6 +3943,52 @@ describe("AgentSession rlm recursion", () => { }); }); + it("reports one failure per delete attempt and one terminal notice after a successful retry", async () => { + const { child: hostedChild, completion: childCompletion, hasStarted } = createAbortInsensitiveChild(); + const cleanups = [deferred(), deferred(), deferred()]; + let cleanupAttempts = 0; + const root = createSession({ + subagentRuntimeHost: { + createRlmSubagentRuntime: async () => ({ session: hostedChild }), + deleteRlmSubagentRuntime: () => cleanups[cleanupAttempts++]!.promise, + }, + }); + const spawned = await root.runRlmChild("retry cleanup", { name: "notice-worker" }); + await waitFor(hasStarted); + const internals = root as unknown as InspectableRlmSession; + const failures = () => + root.messages.filter((message) => message.role === "custom" && message.customType === "rlm_child_failure"); + const terminalNotices = () => + root.messages.filter( + (message) => message.role === "custom" && message.customType === "rlm_child_terminal_notice", + ); + + for (let attempt = 0; attempt < 2; attempt++) { + await Promise.all([root.deleteRlmSubagent(spawned.rlm_child_id), root.deleteRlmSubagent("notice-worker")]); + expect(cleanupAttempts).toBe(attempt + 1); + cleanups[attempt]!.reject(new Error(`cleanup failure ${attempt + 1}`)); + await vi.waitFor(() => { + expect(failures()).toHaveLength(attempt + 1); + expect(internals._deletingRlmChildren.size).toBe(0); + }); + expect(terminalNotices()).toHaveLength(0); + expect((await root.listRlmSubagents()).subagents).toEqual([]); + } + + await root.deleteRlmSubagent("notice-worker"); + expect(cleanupAttempts).toBe(3); + childCompletion.resolve(); + cleanups[2]!.resolve(); + await root.waitForRlmQuiescence(); + expect(failures()).toHaveLength(2); + expect(terminalNotices()).toEqual([ + expect.objectContaining({ details: expect.objectContaining({ kind: "cancelled" }) }), + ]); + expect(internals._rlmChildCleanupFailures.size).toBe(0); + expect(root.getRlmChildSession(spawned.rlm_child_id)).toBeUndefined(); + await hostedChild.disposeAsync(); + }); + it("settles a pre-existing deletion cleanup failure during parent disposal", async () => { const { child: hostedChild, completion: childCompletion, hasStarted } = createAbortInsensitiveChild(); const disposeHostedChild = vi.spyOn(hostedChild, "disposeAsync"); diff --git a/packages/coding-agent/test/child-process.test.ts b/packages/coding-agent/test/child-process.test.ts index 921df6a34b..84e8a699e2 100644 --- a/packages/coding-agent/test/child-process.test.ts +++ b/packages/coding-agent/test/child-process.test.ts @@ -1,17 +1,43 @@ import { type ChildProcess, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { + execFileHidden, + execFileSyncHidden, + execSyncHidden, isProcessAlive, isZombieProcess, processGroupExists, processGroupHasLiveMember, signalProcessGroupIfHeld, signalProcessGroupOrProcess, + spawnHidden, + spawnSyncHidden, waitForChildProcess, } from "../src/utils/child-process.js"; + import { spawnZombieProcess } from "./fixtures/zombie-process.js"; +const recordedWindowsHide = vi.hoisted(() => [] as Array); + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + const wrap = + (fn: (...args: A) => R, optionsIndex: number) => + (...args: A): R => { + recordedWindowsHide.push((args[optionsIndex] as { windowsHide?: boolean } | undefined)?.windowsHide); + return fn(...args); + }; + return { + ...actual, + spawn: wrap(actual.spawn, 2), + spawnSync: wrap(actual.spawnSync, 2), + execSync: wrap(actual.execSync, 1), + execFileSync: wrap(actual.execFileSync, 2), + execFile: wrap(actual.execFile, 2), + }; +}); + describe("waitForChildProcess", () => { it("reports signaled already-exited children as failures", async () => { const child = Object.assign(new EventEmitter(), { @@ -92,3 +118,16 @@ describe("process liveness", () => { } }); }); + +it("hidden child-process wrappers force windowsHide on every wrapped spawn/exec form", async () => { + recordedWindowsHide.length = 0; + const child = spawnHidden(process.execPath, ["--version"], { stdio: "ignore" }); + await waitForChildProcess(child); + spawnSyncHidden(process.execPath, ["--version"], { stdio: "ignore" }); + execSyncHidden(`"${process.execPath}" --version`, { stdio: "ignore" }); + execFileSyncHidden(process.execPath, ["--version"], { stdio: "ignore" }); + await new Promise((resolveDone) => { + execFileHidden(process.execPath, ["--version"], {}, () => resolveDone()); + }); + expect(recordedWindowsHide).toEqual([true, true, true, true, true]); +}); diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index ea231b572f..a8c52bcf5d 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -121,6 +121,7 @@ describe("copyToClipboard", () => { input: "hello", stdio: ["pipe", "ignore", "ignore"], timeout: 5000, + windowsHide: true, }); expect(osc52Writes()).toHaveLength(0); }); diff --git a/packages/coding-agent/test/daemon-mode.test.ts b/packages/coding-agent/test/daemon-mode.test.ts index 7af474c404..41e53fc92e 100644 --- a/packages/coding-agent/test/daemon-mode.test.ts +++ b/packages/coding-agent/test/daemon-mode.test.ts @@ -6951,6 +6951,52 @@ describe("daemon mode helpers", () => { } }); + it("reconciles a stale display status on a tombstoned ledger edge (idempotent delete)", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-delete-reconcile-")); + try { + const fixture = makePersistedRlmDaemonFixture(tempDir); + const internals = fixture.daemon as unknown as { + createRuntime(command: Extract): Promise; + createSubagentRuntimeHost(parent: ActiveSessionState): SubagentRuntimeHost; + }; + const parentState = await internals.createRuntime({ type: "create", sessionPath: fixture.parentSessionFile }); + const host = internals.createSubagentRuntimeHost(parentState); + const displayPath = join(fixture.childSessionDir, "rlm-subagent.json"); + + // First delete: write the deletion tombstone to the display and + // append a delete record to the ledger. + await host.deleteRlmSubagentRuntime(fixture.childId); + expect(JSON.parse(readFileSync(displayPath, "utf8"))).toMatchObject({ status: "deleted" }); + + // Simulate a stale overwrite by a completion write that raced + // before the ledger tombstone was durable. The display now claims + // the child is running again. + writeFileSync( + displayPath, + `${JSON.stringify({ + type: "rlm_subagent", + childId: fixture.childId, + sessionName: "stale", + sessionDir: fixture.childSessionDir, + sessionFile: fixture.childSessionFile, + status: "running", + createdAt: 1, + updatedAt: new Date().toISOString(), + })}\n`, + ); + + // Retry the delete: the tombstoned-edge path must reconcile the + // display back to "deleted" and sweep the artifact dir. + await host.deleteRlmSubagentRuntime(fixture.childId); + + expect(JSON.parse(readFileSync(displayPath, "utf8"))).toMatchObject({ status: "deleted" }); + expect(existsSync(fixture.childArtifactDir)).toBe(false); + expect(existsSync(fixture.childSessionFile)).toBe(true); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("resolves a delete that joins an in-flight passivation close without awaiting the trace upload", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-agent-daemon-delete-passivation-flush-")); const originalAgentDir = process.env[ENV_AGENT_DIR]; diff --git a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts index 79c25c4994..50178543c4 100644 --- a/packages/coding-agent/test/daemon-supervisor-monitor.test.ts +++ b/packages/coding-agent/test/daemon-supervisor-monitor.test.ts @@ -21,7 +21,7 @@ import { } from "../src/modes/daemon/daemon-protocol.js"; import type { SessionSummary } from "../src/modes/daemon/daemon-session-list.js"; import { DaemonSocketPathLease } from "../src/modes/daemon/daemon-socket.js"; -import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import { DaemonSupervisor, handshakeBudgetMs } from "../src/modes/daemon/daemon-supervisor.js"; import { DaemonWorkerAuthenticationError, DaemonWorkerClient, @@ -4580,4 +4580,13 @@ describe("daemon worker supervisor monitoring", () => { await expect(supervisor.prepareUpdateRestartFenced()).rejects.toThrow(/resident-1.*recovering.*disconnected/); expect(requestWorker).not.toHaveBeenCalled(); }); + + it("derives per-attempt handshake budgets from the remaining outer connect deadline", () => { + const now = 1_000_000; + expect(handshakeBudgetMs(now + 30_000, now)).toBe(30_000); + expect(handshakeBudgetMs(now + 2_500, now)).toBe(2_500); + expect(handshakeBudgetMs(now + 25, now)).toBe(25); + expect(() => handshakeBudgetMs(now, now)).toThrow(DaemonWorkerProbeTimeoutError); + expect(() => handshakeBudgetMs(now - 1, now)).toThrow(DaemonWorkerProbeTimeoutError); + }); }); diff --git a/packages/coding-agent/test/daemon-worker-connect.test.ts b/packages/coding-agent/test/daemon-worker-connect.test.ts new file mode 100644 index 0000000000..707641ff12 --- /dev/null +++ b/packages/coding-agent/test/daemon-worker-connect.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DAEMON_PROTOCOL_INFO } from "../src/modes/daemon/daemon-protocol.js"; +import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import { DaemonWorkerClient, DaemonWorkerProbeTimeoutError } from "../src/modes/daemon/daemon-worker-client.js"; + +const hello = { + type: "daemon_hello" as const, + socketPath: "unused-test-socket", + protocol: DAEMON_PROTOCOL_INFO, + clientId: "test-client", + serverCapabilities: [], +}; + +function createProbe() { + const worker = { + descriptor: { socketPath: hello.socketPath, authenticationToken: "test-token" }, + pendingClient: undefined, + }; + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + assertRecoveryAllowed: async () => {}, + supervisorAuthenticationClaim: () => ({}), + }) as { connectWorker(candidate: typeof worker, timeout: number): Promise }; + return { worker, connect: (timeout = 100) => supervisor.connectWorker(worker, timeout) }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("daemon worker connection deadline", () => { + it.each(["hello", "authentication"])("bounds %s by time remaining after earlier stages", async (stage) => { + let now = Date.now(); + const started = now; + vi.spyOn(Date, "now").mockImplementation(() => now); + vi.spyOn(DaemonWorkerClient.prototype, "connect").mockImplementation(async () => { + now += 75; + }); + const waitForHello = vi + .spyOn(DaemonWorkerClient.prototype, "waitForHello") + .mockImplementation(async (timeout = 0) => { + if (stage === "hello") { + now += timeout; + throw new DaemonWorkerProbeTimeoutError("hello timed out"); + } + now += 5; + return hello; + }); + const authenticate = vi + .spyOn(DaemonWorkerClient.prototype, "authenticateWorker") + .mockImplementation(async (_token, _owner, timeout = 0) => { + now += timeout; + throw new DaemonWorkerProbeTimeoutError("authentication timed out"); + }); + const close = vi.spyOn(DaemonWorkerClient.prototype, "close"); + const probe = createProbe(); + await expect(probe.connect()).rejects.toBeInstanceOf(DaemonWorkerProbeTimeoutError); + expect(now - started).toBe(100); + expect(waitForHello).toHaveBeenCalledWith(25); + if (stage === "authentication") expect(authenticate.mock.calls[0]?.[2]).toBe(20); + else expect(authenticate).not.toHaveBeenCalled(); + expect(probe.worker.pendingClient).toBeUndefined(); + expect(close).toHaveBeenCalledTimes(1); + }); + + it.each(["connect", "hello"])("does not start another stage after %s exhausts the budget", async (stage) => { + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + vi.spyOn(DaemonWorkerClient.prototype, "connect").mockImplementation(async () => { + now += stage === "connect" ? 100 : 75; + }); + const waitForHello = vi.spyOn(DaemonWorkerClient.prototype, "waitForHello").mockImplementation(async () => { + now += 25; + return hello; + }); + const authenticate = vi + .spyOn(DaemonWorkerClient.prototype, "authenticateWorker") + .mockRejectedValue(new Error("unexpected authentication")); + const close = vi.spyOn(DaemonWorkerClient.prototype, "close"); + await expect(createProbe().connect()).rejects.toBeInstanceOf(DaemonWorkerProbeTimeoutError); + if (stage === "connect") expect(waitForHello).not.toHaveBeenCalled(); + expect(authenticate).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); + +describe("daemon worker probe retries", () => { + it("keeps the platform retry cadence and bounds the last delay by the deadline", async () => { + vi.useFakeTimers(); + const started = Date.now(); + const attempts: number[] = []; + vi.spyOn(DaemonWorkerClient.prototype, "connect").mockImplementation(async () => { + attempts.push(Date.now() - started); + throw new Error("pipe not ready"); + }); + const probe = createProbe(); + const failure = expect(probe.connect(100)).rejects.toBeInstanceOf(DaemonWorkerProbeTimeoutError); + await vi.advanceTimersByTimeAsync(100); + await failure; + expect(attempts).toEqual(process.platform === "win32" ? [0, 25, 75] : [0, 25, 50, 75]); + expect(Date.now() - started).toBe(100); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts b/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts new file mode 100644 index 0000000000..5cfca84593 --- /dev/null +++ b/packages/coding-agent/test/daemon-worker-windows-timeouts.test.ts @@ -0,0 +1,170 @@ +import type { Socket } from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as sessionLease from "../src/core/session-lease.js"; +import { DaemonClient, type DaemonCommandBody } from "../src/modes/daemon/daemon-client.js"; +import { DAEMON_PROTOCOL_INFO } from "../src/modes/daemon/daemon-protocol.js"; +import { DaemonSupervisor } from "../src/modes/daemon/daemon-supervisor.js"; +import { DaemonWorkerClient, DaemonWorkerProbeTimeoutError } from "../src/modes/daemon/daemon-worker-client.js"; +import { DAEMON_WORKER_ROSTER_CAPABILITY } from "../src/modes/daemon/daemon-worker-protocol.js"; +import * as childProcess from "../src/utils/child-process.js"; + +// Load the Windows timing constants without running Windows processes on the test host. +const hostPlatform = vi.hoisted(() => { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32" }); + return descriptor; +}); +Object.defineProperty(process, "platform", hostPlatform); + +const hello = { + type: "daemon_hello" as const, + socketPath: "unused-test-socket", + protocol: DAEMON_PROTOCOL_INFO, + clientId: "test-client", + serverCapabilities: [], +}; + +function createProbe() { + const worker = { + descriptor: { socketPath: hello.socketPath, authenticationToken: "test-token" }, + pendingClient: undefined, + }; + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + assertRecoveryAllowed: async () => {}, + supervisorAuthenticationClaim: () => ({}), + }) as { connectWorker(candidate: typeof worker, timeout: number): Promise }; + return { worker, connect: (timeout: number) => supervisor.connectWorker(worker, timeout) }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + Object.defineProperty(process, "platform", hostPlatform); +}); + +describe("Windows worker connection timing", () => { + it("gives hello and authentication the remaining budget, not fixed short probe caps", async () => { + let now = Date.now(); + vi.spyOn(Date, "now").mockImplementation(() => now); + const connect = vi.spyOn(DaemonWorkerClient.prototype, "connect").mockImplementation(async () => { + now += 1500; + }); + const waitForHello = vi.spyOn(DaemonWorkerClient.prototype, "waitForHello").mockImplementation(async () => { + now += 20_000; + return hello; + }); + const authenticate = vi.spyOn(DaemonWorkerClient.prototype, "authenticateWorker").mockImplementation(async () => { + now += 20_000; + return { + type: "response" as const, + command: "worker_auth", + success: true as const, + data: { capabilities: [DAEMON_WORKER_ROSTER_CAPABILITY] }, + }; + }); + const probe = createProbe(); + const client = await probe.connect(90_000); + expect(connect).toHaveBeenCalledWith(2000); + expect(waitForHello).toHaveBeenCalledWith(88_500); + expect(authenticate.mock.calls[0]?.[2]).toBe(68_500); + expect(probe.worker.pendingClient).toBeUndefined(); + client.close(); + }); + + it("backs off failed pipe probes up to two seconds without exceeding the outer deadline", async () => { + vi.useFakeTimers(); + const started = Date.now(); + const attempts: number[] = []; + vi.spyOn(DaemonWorkerClient.prototype, "connect").mockImplementation(async () => { + attempts.push(Date.now() - started); + throw new Error("pipe not ready"); + }); + const failed = expect(createProbe().connect(7200)).rejects.toBeInstanceOf(DaemonWorkerProbeTimeoutError); + await vi.advanceTimersByTimeAsync(7200); + await failed; + expect(attempts).toEqual([0, 25, 75, 175, 375, 775, 1575, 3175, 5175, 7175]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("uses the 90-second budget when adopting a resident worker", async () => { + vi.spyOn(childProcess, "isProcessAlive").mockReturnValue(true); + vi.spyOn(sessionLease, "getProcessStartId").mockReturnValue("start-id"); + const worker = { + descriptor: { pid: 123, processStartId: "start-id", rootActiveSessionId: "root", lifecycle: "recovering" }, + }; + const connectWorker = vi.fn(async () => undefined); + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + assertRecoveryAllowed: async () => {}, + connectWorker, + subscribeWorker: async () => {}, + refreshWorkerSummaries: async () => {}, + persistWorker: () => {}, + broadcastHeartbeatsChanged: () => {}, + }) as { adoptOrRecoverWorker(candidate: typeof worker): Promise }; + await supervisor.adoptOrRecoverWorker(worker); + expect(connectWorker).toHaveBeenCalledWith(worker, 90_000); + expect(worker.descriptor.lifecycle).toBe("ready"); + }); + + it("throttles Windows identity checks but rechecks before signalling", async () => { + vi.useFakeTimers(); + const started = Date.now(); + const checks: number[] = []; + vi.spyOn(childProcess, "processIdExists").mockImplementation(() => Date.now() - started < 6000); + vi.spyOn(childProcess, "isProcessAlive").mockReturnValue(true); + vi.spyOn(sessionLease, "getProcessStartId").mockImplementation(() => { + checks.push(Date.now() - started); + return "start-id"; + }); + const signal = vi.spyOn(childProcess, "signalProcessGroupOrProcess").mockImplementation(() => {}); + const worker = { + descriptor: { workerId: "worker", pid: 123, processStartId: "start-id", stopRequestedAt: "stopped" }, + stopRevision: 1, + }; + const stopWorker = vi.fn(async () => {}); + const supervisor = Object.assign(Object.create(DaemonSupervisor.prototype), { + workers: new Map([[worker.descriptor.workerId, worker]]), + shuttingDown: false, + stopWorker, + log: () => {}, + }) as { finalizeTimedOutWorkerStop(candidate: typeof worker): Promise }; + const stopped = supervisor.finalizeTimedOutWorkerStop(worker); + await vi.advanceTimersByTimeAsync(6000); + await stopped; + expect(checks).toEqual([0, 3000, 5000]); + expect(signal).toHaveBeenCalledWith(123, "SIGKILL"); + expect(stopWorker).toHaveBeenCalledOnce(); + }); +}); + +describe("daemon request timeouts", () => { + it.each([ + { platform: "win32", command: "create", override: undefined, expected: 120_000 }, + { platform: "win32", command: "list", override: undefined, expected: 30_000 }, + { platform: "linux", command: "create", override: undefined, expected: 30_000 }, + { platform: "darwin", command: "create", override: undefined, expected: 30_000 }, + { platform: "win32", command: "create", override: 1234, expected: 1234 }, + ] as const)( + "times out $platform $command with override=$override after $expected ms", + async ({ platform, command, override, expected }) => { + vi.useFakeTimers(); + Object.defineProperty(process, "platform", { value: platform }); + const socket = { destroyed: false, write: vi.fn(), end: vi.fn(), destroy: vi.fn() } as unknown as Socket; + const client = new DaemonClient(hello.socketPath); + Object.assign(client, { socket, helloMessage: hello }); + let settled = false; + const request = client.request({ type: command } as DaemonCommandBody, override); + const failed = expect(request).rejects.toThrow(`Timed out after ${expected}ms`); + void request.catch(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(expected - 1); + expect(settled).toBe(false); + expect(socket.write).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + await failed; + expect(settled).toBe(true); + client.close(); + }, + ); +}); diff --git a/packages/coding-agent/test/frontmatter.test.ts b/packages/coding-agent/test/frontmatter.test.ts index 4af3abefcc..c20d36e236 100644 --- a/packages/coding-agent/test/frontmatter.test.ts +++ b/packages/coding-agent/test/frontmatter.test.ts @@ -24,6 +24,27 @@ describe("parseFrontmatter", () => { expect(body).toBe("Line one\nLine two"); }); + it("strips a UTF-8 BOM before frontmatter (Windows editors)", () => { + const input = "\uFEFF---\nname: skill-name\ndescription: A desc\n---\n\nBody text"; + const { frontmatter, body } = parseFrontmatter>(input); + expect(frontmatter.name).toBe("skill-name"); + expect(frontmatter.description).toBe("A desc"); + expect(body).toBe("Body text"); + }); + + it("strips a UTF-8 BOM with CRLF newlines", () => { + const input = "\uFEFF---\r\nname: test\r\n---\r\nLine one"; + const { frontmatter, body } = parseFrontmatter>(input); + expect(frontmatter.name).toBe("test"); + expect(body).toBe("Line one"); + }); + + it("strips a UTF-8 BOM from content without frontmatter", () => { + const input = "\uFEFFJust text"; + const result = parseFrontmatter(input); + expect(result.body).toBe("Just text"); + }); + it("throws on invalid YAML frontmatter", () => { const input = "---\nfoo: [bar\n---\nBody"; expect(() => parseFrontmatter>(input)).toThrow(/at line 1, column 10/); diff --git a/packages/coding-agent/test/herdr-agent-state.test.ts b/packages/coding-agent/test/herdr-agent-state.test.ts index 6c3f5909ff..006bbaf2aa 100644 --- a/packages/coding-agent/test/herdr-agent-state.test.ts +++ b/packages/coding-agent/test/herdr-agent-state.test.ts @@ -7,6 +7,7 @@ import { createHerdrAgentStateExtension, hasFileBasedHerdrIntegration, herdrAgentStateExtension, + herdrSocketTarget, } from "../src/core/extensions/builtin/herdr-agent-state.js"; import type { ExtensionAPI } from "../src/core/extensions/types.js"; @@ -76,7 +77,7 @@ async function startFakeHerdrServer(socketPath: string): Promise<{ await new Promise((resolve, reject) => { server.on("error", reject); - server.listen(socketPath, resolve); + server.listen(herdrSocketTarget(socketPath), resolve); }); const waitForRequests = (count: number, timeoutMs = 3000): Promise => { @@ -135,6 +136,22 @@ describe("herdrAgentStateExtension", () => { } }); + it.skipIf(process.platform !== "win32")("reports through a bare Windows named pipe endpoint", async () => { + const socketName = `pi-herdr-test-${process.pid}-${Date.now()}.sock`; + const { server, requests, waitForRequests } = await startFakeHerdrServer(socketName); + cleanupServers.push(server); + process.env.HERDR_ENV = "1"; + process.env.HERDR_SOCKET_PATH = socketName; + process.env.HERDR_PANE_ID = "w1:p1"; + + const { pi, handlers } = createMockPi(); + herdrAgentStateExtension(pi); + const ctx = { sessionManager: { getSessionFile: () => undefined, getSessionId: () => "s" } }; + handlers.get("session_start")?.[0]?.({ type: "session_start", reason: "startup" }, ctx); + await waitForRequests(1); + expect(requests[0]?.params.state).toBe("idle"); + }); + it("registers no handlers when HERDR_ENV is not set", () => { delete process.env.HERDR_ENV; delete process.env.HERDR_SOCKET_PATH; @@ -492,3 +509,12 @@ describe("herdrAgentStateExtension", () => { expect(seqs[1]).toBeGreaterThan(seqs[0]); }); }); + +describe("herdrSocketTarget", () => { + it("maps unix-style socket paths into the named-pipe namespace on win32 only", () => { + expect(herdrSocketTarget("/tmp/herdr/pane.sock", "win32")).toBe("\\\\.\\pipe\\tmp\\herdr\\pane.sock"); + expect(herdrSocketTarget("\\\\.\\pipe\\herdr-pane", "win32")).toBe("\\\\.\\pipe\\herdr-pane"); + expect(herdrSocketTarget("\\\\.\\PIPE\\herdr-pane", "win32")).toBe("\\\\.\\PIPE\\herdr-pane"); + expect(herdrSocketTarget("/tmp/herdr/pane.sock", "linux")).toBe("/tmp/herdr/pane.sock"); + }); +}); diff --git a/packages/coding-agent/test/kernel-bash-shell.test.ts b/packages/coding-agent/test/kernel-bash-shell.test.ts index bced79ae99..d781c00b17 100644 --- a/packages/coding-agent/test/kernel-bash-shell.test.ts +++ b/packages/coding-agent/test/kernel-bash-shell.test.ts @@ -17,7 +17,7 @@ vi.mock("child_process", async (importOriginal) => { return { ...actual, spawnSync: mocks.spawnSync }; }); -import { resolveKernelBashShell } from "../src/utils/shell.js"; +import { orderWindowsBashCandidates, resolveKernelBashShell } from "../src/utils/shell.js"; const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); @@ -61,3 +61,29 @@ describe("resolveKernelBashShell on win32", () => { expect(mocks.existsSync).not.toHaveBeenCalled(); }); }); + +it("orderWindowsBashCandidates prefers any other bash over WSL's System32 trampoline, keeping it only as a last resort", () => { + const wsl = "C:\\Windows\\System32\\bash.exe"; + const scoopGitBash = "C:\\Users\\u\\scoop\\shims\\bash.exe"; + expect(orderWindowsBashCandidates([wsl, scoopGitBash], "C:\\Windows")).toEqual([scoopGitBash, wsl]); + expect(orderWindowsBashCandidates([wsl], "C:\\Windows")).toEqual([wsl]); + expect(orderWindowsBashCandidates([wsl, scoopGitBash], undefined)).toEqual([wsl, scoopGitBash]); +}); + +it.each(["C:\\Windows", "C:\\Windows\\", "C:/Windows/", "c:\\WINDOWS\\\\"])( + "normalizes candidate comparisons under %s without changing paths or stable order", + (systemRoot) => { + const wsl = "C:/Windows/System32/bash.exe"; + const neighboringDirectory = "C:\\WindowsExtra\\bash.exe"; + const scoop = "C:\\Users\\u\\scoop\\shims\\bash.exe"; + const winget = "D:/Git/bin/bash.exe"; + expect(orderWindowsBashCandidates([wsl, neighboringDirectory, scoop, winget], systemRoot)).toEqual([ + neighboringDirectory, + scoop, + winget, + wsl, + ]); + const backslashWsl = wsl.replaceAll("/", "\\"); + expect(orderWindowsBashCandidates([backslashWsl, scoop], systemRoot)).toEqual([scoop, backslashWsl]); + }, +); diff --git a/packages/coding-agent/test/kernel-bootstrap-windows.test.ts b/packages/coding-agent/test/kernel-bootstrap-windows.test.ts new file mode 100644 index 0000000000..408bdfeb45 --- /dev/null +++ b/packages/coding-agent/test/kernel-bootstrap-windows.test.ts @@ -0,0 +1,147 @@ +import { spawn } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { buildBatchShimInvocation, windowsExecutableCandidates } from "../src/core/kernel/bootstrap.js"; + +let tempDir = ""; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "prime-kernel-cmd-")); +}); + +afterEach(() => { + rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); +}); + +describe("buildBatchShimInvocation", () => { + it("keeps argument values out of the cmd.exe command string", () => { + const invocation = buildBatchShimInvocation( + "C:\\Tools & More\\uv.cmd", + ["with space", "%PATH%", "a&b", "pipe|value"], + { PATH: "original" }, + "testtoken", + ); + expect(invocation.args).toEqual([ + "/d", + "/v:off", + "/s", + "/c", + '""%PRIME_AGENT_BATCH_testtoken_0%" "%PRIME_AGENT_BATCH_testtoken_1%" "%PRIME_AGENT_BATCH_testtoken_2%" "%PRIME_AGENT_BATCH_testtoken_3%" "%PRIME_AGENT_BATCH_testtoken_4%""', + ]); + expect(invocation.args.join(" ")).not.toContain("Tools & More"); + expect(invocation.args.join(" ")).not.toContain("%PATH%"); + expect(invocation.env.PRIME_AGENT_BATCH_testtoken_0).toBe("C:\\Tools & More\\uv.cmd"); + expect(invocation.env.PRIME_AGENT_BATCH_testtoken_2).toBe("%PATH%"); + }); + + it.each(['a"b', "line\nbreak", "line\rbreak", "null\0byte"])("rejects unsafe command or argument %j", (value) => { + expect(() => buildBatchShimInvocation("uv.cmd", [value], {}, "testtoken")).toThrow(/cannot contain/); + expect(() => buildBatchShimInvocation(value, [], {}, "testtoken")).toThrow(/cannot contain/); + }); + + it("rejects tokens that could alter the cmd.exe command string", () => { + expect(() => buildBatchShimInvocation("uv.cmd", [], {}, "bad%PATH%")).toThrow(/unsupported characters/); + }); +}); + +describe("batch shim round-trip (Windows only)", () => { + it.skipIf(process.platform !== "win32")( + "passes metacharacter arguments exactly through cmd /s /c + .cmd shim", + async () => { + const shimDir = join(tempDir, "工具 & shims!"); + mkdirSync(shimDir, { recursive: true }); + const shimPath = join(shimDir, "capture.cmd"); + const captureJs = join(shimDir, "capture.cjs"); + const outPath = join(shimDir, "args.json"); + + // Capture the arguments received by Node. + writeFileSync( + captureJs, + [ + "// capture args as JSON", + `require("fs").writeFileSync("${outPath.replace(/\\/g, "\\\\")}", JSON.stringify(process.argv.slice(2)) + "\\n", "utf8");`, + ].join("\n"), + "utf8", + ); + + // .cmd shim that delegates to the capture script. + // %* passes through the shell-split arguments as cmd.exe split them. + writeFileSync(shimPath, `@echo off\r\n"${process.execPath}" "%~dp0capture.cjs" %*\r\n`, "utf8"); + + const testArgs = [ + "simple", + "with space", + "%PATH%", + "100%", + "bang!", + "caret^here", + "ampers&nd", + "pipe|char", + "lessthan", + "", + "工具", + "(parens)", + ")closeParen(", + ]; + const invocation = buildBatchShimInvocation(shimPath, testArgs, process.env, "roundtrip"); + const comSpec = process.env.ComSpec ?? "cmd.exe"; + + await new Promise((resolve, reject) => { + const child = spawn(comSpec, invocation.args, { + env: invocation.env, + stdio: "ignore", + windowsVerbatimArguments: true, + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) resolve(); + else reject(new Error(`cmd.exe exited with code ${code}`)); + }); + }); + + const raw = readFileSync(outPath, "utf8").trim(); + const actual = JSON.parse(raw) as string[]; + expect(actual).toEqual(testArgs); + }, + ); +}); + +describe("windowsExecutableCandidates", () => { + it("appends PATHEXT extensions in order for a bare name", () => { + const candidates = windowsExecutableCandidates("uv", ".COM;.EXE;.BAT;.CMD"); + expect(candidates).toEqual(["uv", "uv.com", "uv.exe", "uv.bat", "uv.cmd"]); + }); + + it("returns the name as-is when it already carries a known extension", () => { + expect(windowsExecutableCandidates("uv.exe", ".EXE;.CMD")).toEqual(["uv.exe"]); + expect(windowsExecutableCandidates("build.cmd", ".COM;.EXE;.BAT;.CMD")).toEqual(["build.cmd"]); + expect(windowsExecutableCandidates("UV.CMD", ".cmd;.exe")).toEqual(["UV.CMD"]); + expect(windowsExecutableCandidates("uv.exe", ".CMD")).toEqual(["uv.exe"]); + expect(windowsExecutableCandidates("uv.exe", undefined)).toEqual(["uv.exe"]); + }); + + it("skips duplicate candidates when PATHEXT has duplicate entries", () => { + const candidates = windowsExecutableCandidates("uv", ".EXE;.exe;.BAT;.bat"); + expect(candidates.filter((c) => c.toLowerCase().endsWith(".exe"))).toHaveLength(1); + expect(candidates.filter((c) => c.toLowerCase().endsWith(".bat"))).toHaveLength(1); + }); + + it("falls back to WINDOWS_PATHEXT_DEFAULT when pathext is empty", () => { + const candidates = windowsExecutableCandidates("uv", ""); + expect(candidates).toContain("uv.EXE"); + expect(candidates).toContain("uv.CMD"); + expect(candidates).toContain("uv.BAT"); + }); + + it("trims whitespace from PATHEXT entries", () => { + const candidates = windowsExecutableCandidates("uv", ".EXE; .BAT"); + expect(candidates).toEqual(["uv", "uv.exe", "uv.bat"]); + }); + + it("ignores PATHEXT entries that CreateProcess cannot execute", () => { + expect(windowsExecutableCandidates("uv", ".JS;.EXE;.VBS;.CMD")).toEqual(["uv", "uv.exe", "uv.cmd"]); + }); +}); diff --git a/packages/coding-agent/test/kernel-bootstrap.test.ts b/packages/coding-agent/test/kernel-bootstrap.test.ts index cda17807c4..f1cf37a88f 100644 --- a/packages/coding-agent/test/kernel-bootstrap.test.ts +++ b/packages/coding-agent/test/kernel-bootstrap.test.ts @@ -9,6 +9,7 @@ import { ensureKernelPython, getKernelVenvDir, type KernelPythonSkill, + kernelVenvPython, resolveRuntimeIdentity, } from "../src/core/kernel/bootstrap.js"; @@ -551,4 +552,10 @@ dependencies = ["httpx"] await expect(ensureKernelPython()).rejects.toThrow(/PRIME_AGENT_KERNEL_PYTHON points to a Python missing/); }); + + it("resolves the venv python under Scripts\\python.exe on win32 (uv layout)", () => { + const venv = join(tempDir, "kernel-venv"); + expect(kernelVenvPython(venv, "win32")).toBe(join(venv, "Scripts", "python.exe")); + expect(kernelVenvPython(venv, "linux")).toBe(join(venv, "bin", "python")); + }); }); diff --git a/packages/coding-agent/test/kernel-windows-process.test.ts b/packages/coding-agent/test/kernel-windows-process.test.ts new file mode 100644 index 0000000000..1c8b2b5342 --- /dev/null +++ b/packages/coding-agent/test/kernel-windows-process.test.ts @@ -0,0 +1,99 @@ +import type * as childProcess from "node:child_process"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ensureKernelPython } from "../src/core/kernel/bootstrap.js"; +import { ReplKernelManager } from "../src/core/kernel/repl-manager.js"; + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() })); +vi.mock("node:child_process", async (importOriginal) => ({ + ...(await importOriginal()), + spawn, +})); + +const originalPlatform = process.platform; +let root = ""; +let originalEnv: NodeJS.ProcessEnv; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "prime-kernel-windows-")); + originalEnv = { ...process.env }; + spawn.mockReset().mockImplementation(() => { + throw new Error("spawn refused by test"); + }); + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.PYTHONUTF8 = "0"; +}); + +afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + process.env = originalEnv; + spawn.mockReset(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("Windows kernel subprocesses", () => { + test("forces UTF-8 for background Python bootstrap without changing the parent environment", async () => { + process.env.PRIME_AGENT_KERNEL_PYTHON = join(root, "python.exe"); + await expect(ensureKernelPython()).rejects.toThrow("PRIME_AGENT_KERNEL_PYTHON"); + expect(spawn.mock.calls[0]?.[2]).toMatchObject({ + windowsHide: true, + stdio: "ignore", + env: { PYTHONUTF8: "1" }, + }); + expect(process.env.PYTHONUTF8).toBe("0"); + }); + + test.each(["cmd", "bat"])( + "rejects a .%s Python override instead of accepting an unowned child", + async (extension) => { + process.env.PRIME_AGENT_KERNEL_PYTHON = join(root, `Python & tools!.${extension}`); + await expect(ensureKernelPython()).rejects.toThrow("must point directly to a Python executable"); + expect(spawn).not.toHaveBeenCalled(); + }, + ); + + test("finds a uv.cmd shim through PATHEXT and launches it hidden", async () => { + delete process.env.PRIME_AGENT_KERNEL_PYTHON; + process.env.PRIME_AGENT_KERNEL_VENV = join(root, "venv"); + process.env.PATH = root; + process.env.PATHEXT = ".CMD"; + const uv = join(root, "uv.cmd"); + writeFileSync(uv, "@echo off\r\n"); + chmodSync(uv, 0o755); + await expect(ensureKernelPython({ onProgress: () => {} })).rejects.toThrow("spawn refused by test"); + const call = spawn.mock.calls.at(-1); + expect(call?.[0]).toBe(process.env.ComSpec ?? "cmd.exe"); + expect(call?.[2]).toMatchObject({ windowsHide: true, windowsVerbatimArguments: true, stdio: "ignore" }); + expect(Object.values(call?.[2]?.env ?? {})).toContain(uv); + }); + + test("launches the canonical piped CPython REPL directly with UTF-8", async () => { + const python = join(root, "python.exe"); + const manager = new ReplKernelManager({ python, cwd: root, env: { PYTHONUTF8: "0" } }); + try { + await expect(manager.start()).rejects.toThrow("spawn refused by test"); + const call = spawn.mock.calls.at(-1); + expect(call?.[2]).toMatchObject({ + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"], + env: { PYTHONUTF8: "1", PRIME_AGENT_KERNEL_OWNER_PID: String(process.pid) }, + }); + expect(call?.[0]).toBe(python); + expect(call?.[1]).toEqual(["-m", "rlm.repl"]); + expect(call?.[2]?.windowsVerbatimArguments).toBeUndefined(); + } finally { + await manager.shutdown(); + } + }); + + test("preserves the configured Python encoding and direct launch outside Windows", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + const python = join(root, "python.cmd"); + process.env.PRIME_AGENT_KERNEL_PYTHON = python; + await expect(ensureKernelPython()).rejects.toThrow("PRIME_AGENT_KERNEL_PYTHON"); + expect(spawn.mock.calls[0]?.[0]).toBe(python); + expect(spawn.mock.calls[0]?.[2]?.env?.PYTHONUTF8).toBe("0"); + }); +}); diff --git a/packages/coding-agent/test/login-dialog.test.ts b/packages/coding-agent/test/login-dialog.test.ts index 34e7695bdc..28a55889ab 100644 --- a/packages/coding-agent/test/login-dialog.test.ts +++ b/packages/coding-agent/test/login-dialog.test.ts @@ -108,7 +108,12 @@ describe("LoginDialogComponent", () => { dialog.showAuth(url); - expect(mocks.execFile).toHaveBeenCalledWith(command, [...prefixArgs, url], expect.any(Function)); + expect(mocks.execFile).toHaveBeenCalledWith( + command, + [...prefixArgs, url], + { windowsHide: true }, + expect.any(Function), + ); } finally { platformSpy.mockRestore(); } diff --git a/packages/coding-agent/test/path-utils.test.ts b/packages/coding-agent/test/path-utils.test.ts index 2ddf127c4b..4f52c9e041 100644 --- a/packages/coding-agent/test/path-utils.test.ts +++ b/packages/coding-agent/test/path-utils.test.ts @@ -1,7 +1,8 @@ import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join, posix, resolve, win32 } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { expandTildePath } from "../src/config.js"; import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.js"; describe("path-utils", () => { @@ -16,6 +17,28 @@ describe("path-utils", () => { expect(result).not.toContain("~/"); }); + it("joins ~/ paths with the platform separator (win32 must not keep the posix slash)", () => { + const home = homedir(); + expect(expandPath("~/docs/file.txt", "win32")).toBe(win32.join(home, "docs", "file.txt")); + expect(expandPath("~/docs/file.txt", "linux")).toBe(posix.join(home, "docs/file.txt")); + expect(expandTildePath("~/docs/file.txt", "win32")).toBe(win32.join(home, "docs", "file.txt")); + }); + + for (const expand of [expandPath, expandTildePath]) { + it(`${expand.name} expands a backslash tilde prefix only on Windows`, () => { + const input = "~\\Documents\\file.txt"; + expect(expand(input, "win32")).toBe(win32.join(homedir(), "Documents", "file.txt")); + expect(expand(input, "linux")).toBe(input); + expect(expand(input, "darwin")).toBe(input); + expect(expand(input)).toBe(process.platform === "win32" ? join(homedir(), "Documents", "file.txt") : input); + }); + + it(`${expand.name} preserves backslashes within POSIX paths`, () => { + expect(expand("~/Documents\\file.txt", "linux")).toBe(posix.join(homedir(), "Documents\\file.txt")); + expect(expand("~/Documents\\file.txt", "darwin")).toBe(posix.join(homedir(), "Documents\\file.txt")); + }); + } + it("should normalize Unicode spaces", () => { // Non-breaking space (U+00A0) should become regular space const withNBSP = "file\u00A0name.txt"; diff --git a/packages/coding-agent/test/rlm-subagent-display.test.ts b/packages/coding-agent/test/rlm-subagent-display.test.ts index 6dbccac065..ce1b460fe3 100644 --- a/packages/coding-agent/test/rlm-subagent-display.test.ts +++ b/packages/coding-agent/test/rlm-subagent-display.test.ts @@ -1,7 +1,8 @@ -import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import type fs from "node:fs"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { type RlmSubagentDisplayEntry, readRlmSubagentDisplayEntry, @@ -9,6 +10,19 @@ import { writeRlmSubagentDisplayEntry, } from "../src/modes/daemon/rlm-subagent-display.js"; +const { readDisplayFile, renameDisplayFile } = vi.hoisted(() => ({ + readDisplayFile: vi.fn(), + renameDisplayFile: vi.fn(), +})); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readFileSync: readDisplayFile.mockImplementation(actual.readFileSync), + renameSync: renameDisplayFile.mockImplementation(actual.renameSync), + }; +}); + function makeEntry(sessionDir: string, overrides: Partial = {}): RlmSubagentDisplayEntry { return { type: "rlm_subagent", @@ -46,6 +60,82 @@ describe("rlm subagent display files", () => { } }); + it("keeps deletion authoritative over late running and completion writes", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-display-deleted-")); + try { + const sessionDir = join(tempDir, "sub-1234abcd"); + const deleted = makeEntry(sessionDir, { status: "deleted" }); + expect(writeRlmSubagentDisplayEntry(deleted)).toBe(true); + for (const status of ["running", "completed"] as const) { + expect(writeRlmSubagentDisplayEntry(makeEntry(sessionDir, { status }))).toBe(false); + await expect(readRlmSubagentDisplayEntry(sessionDir)).resolves.toEqual(deleted); + } + expect(writeRlmSubagentDisplayEntry(deleted)).toBe(true); + expect(readdirSync(sessionDir)).toEqual(["rlm-subagent.json"]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it.each(["EBUSY", "EPERM", "EACCES"])("preserves unreadable tombstones on %s", (code) => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-display-unreadable-")); + try { + const sessionDir = join(tempDir, "sub-1234abcd"); + writeRlmSubagentDisplayEntry(makeEntry(sessionDir, { status: "deleted" })); + const path = rlmSubagentDisplayPath(sessionDir); + const contents = readFileSync(path, "utf8"); + for (const status of ["running", "completed"] as const) { + const failure = Object.assign(new Error("display read blocked"), { code }); + readDisplayFile.mockImplementationOnce(() => { + throw failure; + }); + expect(() => writeRlmSubagentDisplayEntry(makeEntry(sessionDir, { status }))).toThrow(failure); + expect(readFileSync(path, "utf8")).toBe(contents); + expect(writeRlmSubagentDisplayEntry(makeEntry(sessionDir, { status }))).toBe(false); + } + expect(readdirSync(sessionDir)).toEqual(["rlm-subagent.json"]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("leaves failed deletion writes intact and allows an explicit retry", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-display-delete-retry-")); + try { + const sessionDir = join(tempDir, "sub-1234abcd"); + const running = makeEntry(sessionDir); + const deleted = makeEntry(sessionDir, { status: "deleted" }); + writeRlmSubagentDisplayEntry(running); + const failure = Object.assign(new Error("rename failed"), { code: "EIO" }); + renameDisplayFile.mockImplementationOnce(() => { + throw failure; + }); + expect(() => writeRlmSubagentDisplayEntry(deleted)).toThrow(failure); + await expect(readRlmSubagentDisplayEntry(sessionDir)).resolves.toEqual(running); + expect(readdirSync(sessionDir)).toEqual(["rlm-subagent.json"]); + expect(writeRlmSubagentDisplayEntry(deleted)).toBe(true); + await expect(readRlmSubagentDisplayEntry(sessionDir)).resolves.toEqual(deleted); + expect(writeRlmSubagentDisplayEntry(running)).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("replaces malformed display metadata without temp-file residue", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-display-repair-")); + try { + const sessionDir = join(tempDir, "sub-1234abcd"); + mkdirSync(sessionDir); + writeFileSync(rlmSubagentDisplayPath(sessionDir), "{torn json"); + const entry = makeEntry(sessionDir, { status: "completed" }); + expect(writeRlmSubagentDisplayEntry(entry)).toBe(true); + await expect(readRlmSubagentDisplayEntry(sessionDir)).resolves.toEqual(entry); + expect(readdirSync(sessionDir)).toEqual(["rlm-subagent.json"]); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("reads tolerantly: missing, malformed, and invalid files are undefined", async () => { const tempDir = mkdtempSync(join(tmpdir(), "prime-rlm-display-tolerant-")); try { diff --git a/packages/coding-agent/test/session-lease.test.ts b/packages/coding-agent/test/session-lease.test.ts index ee9f2f0640..715f8cb40a 100644 --- a/packages/coding-agent/test/session-lease.test.ts +++ b/packages/coding-agent/test/session-lease.test.ts @@ -8,6 +8,7 @@ import { acquireSessionLease, canonicalSessionPath, getWindowsProcessStartId, + isRenameTargetContention, SESSION_LEASE_OWNER_ID_ENV, SESSION_LEASES_ENABLED_ENV, SessionAlreadyActiveError, @@ -144,7 +145,6 @@ describe("session leases", () => { lockfilePath: `${lockDirectory}.guard`, stale: 5000, }); - // Keep the owner fresh while exercising the bounded synchronous retry count. const wait = vi.spyOn(Atomics, "wait").mockReturnValue("timed-out"); try { @@ -201,6 +201,81 @@ describe("session leases", () => { lease?.release(); }); + it("fails closed on corrupt owner.json instead of reclaiming the lease", () => { + const agentDir = createTempDir(); + const sessionPath = canonicalSessionPath(resolve(agentDir, "corrupt.jsonl")); + const key = createHash("sha256").update(sessionPath).digest("hex"); + const lockDirectory = join(agentDir, "session-leases", `${key}.lock`); + mkdirSync(lockDirectory, { recursive: true }); + writeFileSync(join(lockDirectory, "owner.json"), "this is not json"); + expect(() => acquireSessionLease(sessionPath, agentDir, enabledEnvironment("replacement"))).toThrow("Corrupt"); + }); + + it("fails closed on owner.json with missing required fields", () => { + const agentDir = createTempDir(); + const sessionPath = canonicalSessionPath(resolve(agentDir, "partial.jsonl")); + const key = createHash("sha256").update(sessionPath).digest("hex"); + const lockDirectory = join(agentDir, "session-leases", `${key}.lock`); + mkdirSync(lockDirectory, { recursive: true }); + writeFileSync(join(lockDirectory, "owner.json"), JSON.stringify({ version: 1, token: "orphan" })); + expect(() => acquireSessionLease(sessionPath, agentDir, enabledEnvironment("replacement"))).toThrow("Corrupt"); + }); + + it("reclaims a lease when owner.json is absent", () => { + const agentDir = createTempDir(); + const sessionPath = canonicalSessionPath(resolve(agentDir, "absent-lock.jsonl")); + const key = createHash("sha256").update(sessionPath).digest("hex"); + const lockDirectory = join(agentDir, "session-leases", `${key}.lock`); + mkdirSync(lockDirectory, { recursive: true }); + const lease = acquireSessionLease(sessionPath, agentDir, enabledEnvironment("replacement")); + expect(lease?.sessionPath).toBe(sessionPath); + lease?.release(); + }); + + it("isRenameTargetContention returns true for EEXIST and ENOTEMPTY", () => { + expect(isRenameTargetContention("/tmp", "EEXIST")).toBe(true); + expect(isRenameTargetContention("/tmp", "ENOTEMPTY")).toBe(true); + }); + + it("isRenameTargetContention returns false for EPERM on a nonexistent target", () => { + const dir = createTempDir(); + const missing = join(dir, "nonexistent.lock"); + // Target does not exist, so EPERM is a real permission error. + // No existing target and platform does not matter for that case. + expect(isRenameTargetContention(missing, "EPERM")).toBe(false); + }); + + it("isRenameTargetContention returns true for EPERM on an existing target", () => { + const dir = createTempDir(); + const target = join(dir, "existing.lock"); + mkdirSync(target, { recursive: true }); + // Target exists, so EPERM from renameSync means contention on Windows. + expect(isRenameTargetContention(target, "EPERM", "win32")).toBe(true); + expect(isRenameTargetContention(target, "EPERM", "darwin")).toBe(false); + expect(isRenameTargetContention(target, "EPERM", "linux")).toBe(false); + }); + + it("isRenameTargetContention returns true for EACCES on an existing target", () => { + const dir = createTempDir(); + const target = join(dir, "existing-eacces.lock"); + mkdirSync(target, { recursive: true }); + expect(isRenameTargetContention(target, "EACCES", "win32")).toBe(true); + expect(isRenameTargetContention(target, "EACCES", "darwin")).toBe(false); + expect(isRenameTargetContention(target, "EACCES", "linux")).toBe(false); + }); + + it("isRenameTargetContention returns false for EACCES on a nonexistent target", () => { + const dir = createTempDir(); + const missing = join(dir, "missing-eacces.lock"); + expect(isRenameTargetContention(missing, "EACCES")).toBe(false); + }); + + it("isRenameTargetContention returns false for unrelated error codes", () => { + expect(isRenameTargetContention("/tmp", "EIO")).toBe(false); + expect(isRenameTargetContention("/tmp", "EBUSY")).toBe(false); + expect(isRenameTargetContention("/tmp", undefined)).toBe(false); + }); + it("is inert for direct SDK runtimes unless worker isolation enables it", () => { const agentDir = createTempDir(); expect(acquireSessionLease(join(agentDir, "session.jsonl"), agentDir, {})).toBeUndefined(); diff --git a/packages/tui/.changes/res-1268-windows-platform-seams.md b/packages/tui/.changes/res-1268-windows-platform-seams.md new file mode 100644 index 0000000000..c144f72081 --- /dev/null +++ b/packages/tui/.changes/res-1268-windows-platform-seams.md @@ -0,0 +1 @@ +- Fixed console windows flashing on Windows from background path-completion and open-link spawns. ([Discussion #1461](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1461)) diff --git a/packages/tui/.changes/windows-hardening-current-node.md b/packages/tui/.changes/windows-hardening-current-node.md new file mode 100644 index 0000000000..e14fbf9130 --- /dev/null +++ b/packages/tui/.changes/windows-hardening-current-node.md @@ -0,0 +1 @@ +- Fixed raw multiline terminal paste being handled as separate key events. diff --git a/packages/tui/src/autocomplete.ts b/packages/tui/src/autocomplete.ts index 4b50464df7..25660b7ab4 100644 --- a/packages/tui/src/autocomplete.ts +++ b/packages/tui/src/autocomplete.ts @@ -163,6 +163,7 @@ async function walkDirectoryWithFd( const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); let stdout = ""; let resolved = false; diff --git a/packages/tui/src/stdin-buffer.ts b/packages/tui/src/stdin-buffer.ts index 4b393c5113..c4d9d02381 100644 --- a/packages/tui/src/stdin-buffer.ts +++ b/packages/tui/src/stdin-buffer.ts @@ -168,6 +168,12 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde return codepoint >= 32 ? codepoint : undefined; } +function isRawMultilinePaste(data: string): boolean { + if (data.includes(ESC)) return false; + // A leading or trailing Enter alone is ordinary key input, not evidence of a multiline paste. + return /[^\r\n][\r\n]+[^\r\n]/.test(data); +} + function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } { const sequences: string[] = []; let pos = 0; @@ -319,6 +325,14 @@ export class StdinBuffer extends EventEmitter { return; } + if (isRawMultilinePaste(this.buffer)) { + const pastedContent = this.buffer; + this.buffer = ""; + this.pendingKittyPrintableCodepoint = undefined; + this.emit("paste", pastedContent); + return; + } + const result = extractCompleteSequences(this.buffer); this.buffer = result.remainder; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 318513c824..ba5b42e264 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -787,7 +787,7 @@ export class TUI extends Container { href, ] : ["xdg-open", href]; - execFile(command, args, () => {}); + execFile(command, args, { windowsHide: true }, () => {}); } private copySelection(text: string): void { diff --git a/packages/tui/test/stdin-buffer.test.ts b/packages/tui/test/stdin-buffer.test.ts index b502f51144..264eb5f1e2 100644 --- a/packages/tui/test/stdin-buffer.test.ts +++ b/packages/tui/test/stdin-buffer.test.ts @@ -391,6 +391,97 @@ describe("StdinBuffer", () => { }); }); + describe("Raw Multiline Paste", () => { + let emittedPaste: string[]; + + beforeEach(() => { + buffer = new StdinBuffer({ timeout: 10 }); + emittedSequences = []; + emittedPaste = []; + buffer.on("data", (sequence) => emittedSequences.push(sequence)); + buffer.on("paste", (data) => emittedPaste.push(data)); + }); + + for (const [name, input] of [ + ["CRLF", "line1\r\nline2"], + ["LF", "line1\nline2"], + ["CR", "line1\rline2"], + ["blank lines", "line1\r\n\r\nline2"], + ["mixed line endings", "a\rb\nc"], + ["Unicode", "Hello 世界\n🎉"], + ] as const) { + it(`emits ${name} text in one raw chunk as paste`, () => { + processInput(input); + assert.deepStrictEqual(emittedPaste, [input]); + assert.deepStrictEqual(emittedSequences, []); + }); + } + + for (const input of ["hello\r", "hello\n", "hello\r\n", "\rhello"] as const) { + it(`preserves text and Enter regardless of chunk boundary: ${JSON.stringify(input)}`, () => { + for (let split = 0; split <= input.length; split++) { + buffer.clear(); + emittedSequences.length = 0; + emittedPaste.length = 0; + if (split > 0) processInput(input.slice(0, split)); + if (split < input.length) processInput(input.slice(split)); + assert.deepStrictEqual(emittedPaste, []); + assert.deepStrictEqual(emittedSequences, [...input]); + } + }); + } + + it("clears pending Kitty duplicate suppression after raw paste", () => { + processInput("\x1b[97u"); + processInput("a\nb"); + processInput("a"); + assert.deepStrictEqual(emittedPaste, ["a\nb"]); + assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]); + assert.strictEqual(buffer.getBuffer(), ""); + }); + + it("emits multiline Buffer input as paste", () => { + processInput(Buffer.from("line1\r\nline2")); + assert.deepStrictEqual(emittedPaste, ["line1\r\nline2"]); + assert.deepStrictEqual(emittedSequences, []); + }); + + for (const input of ["\r", "\n", "\r\n", "\r\r\r"] as const) { + it(`keeps linebreak-only chunk ${JSON.stringify(input)} as key data`, () => { + processInput(input); + assert.deepStrictEqual(emittedPaste, []); + assert.deepStrictEqual(emittedSequences, [...input]); + }); + } + + it("does not disturb bracketed paste", () => { + processInput("\x1b[200~pasted\r\ntext\x1b[201~"); + assert.deepStrictEqual(emittedPaste, ["pasted\r\ntext"]); + assert.deepStrictEqual(emittedSequences, []); + }); + + it("keeps escape-containing chunks on the escape parser path", () => { + processInput("a\x1b[Aline1\r\nline2"); + assert.deepStrictEqual(emittedPaste, []); + assert.deepStrictEqual(emittedSequences, [ + "a", + "\x1b[A", + "l", + "i", + "n", + "e", + "1", + "\r", + "\n", + "l", + "i", + "n", + "e", + "2", + ]); + }); + }); + describe("Destroy", () => { it("should clear buffer on destroy", () => { processInput("\x1b[<35");