From e4d828d3b5d53875cdb817bc012281c47d9dff17 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:52:54 -0400 Subject: [PATCH 01/11] Port ADE desktop to Windows and add ADE CLI launch support Consolidates the windows-port-foundations work (MCP named pipes, CLI PATH, portable shell usage, sandbox defaults, cmd worker shell, WMIC process list, CLI resolution/sandbox hardening, process spawning) and the subsequent ADE CLI launch + path handling polish into a single squashed commit on top of the latest main. Previously this lane consisted of: - c2a299f3 feat(windows): MCP named pipes, CLI PATH, portable shell usage - bd5eb3b4 feat(windows): sandbox defaults, cmd worker shell, WMIC process list - b8508fa3 fix(pr-160): address Greptile and CodeRabbit review comments - 1d528baf Harden Windows CLI resolution and sandbox checks - 5fa50fcc Fix Windows CLI process spawning - 0ecf0398 Merge origin/main (absorbed MCP proxy removal) - 8ada5605 Enable Windows ADE CLI launches and path handling Squashed because main removed the MCP proxy (PR #166) after the earlier Windows commits were authored, so replaying the original commits atop the new main hits many modify/delete conflicts against files that no longer exist. The final state here matches what the merge commit already reconciled plus the subsequent ADE CLI launch work. --- apps/ade-cli/src/adeRpcServer.test.ts | 17 +- apps/ade-cli/src/adeRpcServer.ts | 52 ++-- apps/ade-cli/src/bootstrap.ts | 40 ++- apps/ade-cli/src/cli.test.ts | 7 +- apps/ade-cli/src/cli.ts | 35 ++- apps/desktop/package.json | 16 ++ apps/desktop/scripts/ade-cli-install-path.cmd | 36 +++ .../scripts/ade-cli-windows-wrapper.cmd | 35 +++ .../scripts/after-pack-runtime-fixes.cjs | 57 +++-- apps/desktop/scripts/dev.cjs | 37 +-- .../scripts/validate-win-artifacts.mjs | 51 ++++ apps/desktop/src/main/main.ts | 25 +- apps/desktop/src/main/packagedRuntimeSmoke.ts | 6 +- .../services/ai/cliExecutableResolver.test.ts | 115 ++++++++- .../main/services/ai/cliExecutableResolver.ts | 165 ++++++++++--- .../services/ai/providerCredentialSources.ts | 15 +- .../main/services/ai/providerTaskRunner.ts | 18 +- .../main/services/ai/tools/globSearch.test.ts | 12 + .../src/main/services/ai/tools/globSearch.ts | 4 +- .../services/ai/tools/universalTools.test.ts | 89 ++++++- .../main/services/ai/tools/universalTools.ts | 228 +++++++++++++---- .../main/services/ai/tools/workflowTools.ts | 14 +- .../automations/automationPlannerService.ts | 37 +-- .../services/automations/automationService.ts | 4 +- .../main/services/chat/agentChatService.ts | 20 +- .../src/main/services/chat/cursorAcpPool.ts | 40 ++- .../main/services/cli/adeCliService.test.ts | 84 +++++++ .../src/main/services/cli/adeCliService.ts | 165 +++++++++++-- .../services/computerUse/localComputerUse.ts | 2 +- .../services/conflicts/conflictService.ts | 6 +- .../src/main/services/ipc/registerIpc.ts | 79 ++++-- .../services/lanes/laneEnvironmentService.ts | 48 +++- .../opencode/openCodeBinaryManager.ts | 28 ++- .../main/services/opencode/openCodeRuntime.ts | 1 + .../opencode/openCodeServerManager.test.ts | 98 ++++++-- .../opencode/openCodeServerManager.ts | 233 ++++++++++++++++-- .../orchestrator/baseOrchestratorAdapter.ts | 45 +++- .../orchestrator/orchestratorConstants.ts | 41 +-- .../orchestrator/orchestratorContext.ts | 13 +- .../orchestrator/orchestratorService.ts | 38 ++- .../providerOrchestratorAdapter.test.ts | 67 +++++ .../providerOrchestratorAdapter.ts | 227 ++++++++++++++--- .../src/main/services/pty/ptyService.test.ts | 65 ++++- .../src/main/services/pty/ptyService.ts | 6 +- .../services/shared/processExecution.test.ts | 51 ++++ .../main/services/shared/processExecution.ts | 105 ++++++++ .../desktop/src/main/services/shared/utils.ts | 19 +- apps/desktop/src/main/services/state/kvDb.ts | 2 + .../src/main/services/sync/syncService.ts | 14 +- .../src/main/services/tests/testService.ts | 35 +-- .../usage/usageTrackingService.test.ts | 209 ++++++++++++++++ .../services/usage/usageTrackingService.ts | 95 ++++++- .../components/app/CommandPalette.tsx | 2 + .../chat/AgentChatComposer.test.tsx | 58 ++++- .../components/chat/AgentChatComposer.tsx | 3 +- .../chat/AgentChatMessageList.test.tsx | 203 +++++++++++++++ .../components/chat/AgentChatMessageList.tsx | 61 +++-- .../components/chat/ChatComputerUsePanel.tsx | 14 +- .../renderer/components/files/FilesPage.tsx | 34 +-- .../components/lanes/LaneGitActionsPane.tsx | 3 +- .../components/prs/detail/PrDetailPane.tsx | 5 +- .../components/settings/AdeCliSection.tsx | 5 +- .../lib/dirtyWorkspaceBuffers.test.ts | 35 +++ .../src/renderer/lib/dirtyWorkspaceBuffers.ts | 47 +++- .../src/renderer/lib/pathUtils.test.ts | 26 ++ apps/desktop/src/renderer/lib/pathUtils.ts | 75 ++++++ apps/desktop/src/renderer/lib/platform.ts | 1 + apps/desktop/src/renderer/lib/shell.test.ts | 22 ++ apps/desktop/src/renderer/lib/shell.ts | 62 ++++- .../renderer/onboarding/tours/filesTour.ts | 2 +- apps/desktop/src/shared/adeLayout.ts | 3 +- apps/desktop/src/shared/adeMcpIpc.test.ts | 48 ++++ apps/desktop/src/shared/adeMcpIpc.ts | 18 ++ .../vendor/crsqlite/win32-x64/crsqlite.dll | Bin 0 -> 1443655 bytes 74 files changed, 3167 insertions(+), 511 deletions(-) create mode 100644 apps/desktop/scripts/ade-cli-install-path.cmd create mode 100644 apps/desktop/scripts/ade-cli-windows-wrapper.cmd create mode 100644 apps/desktop/scripts/validate-win-artifacts.mjs create mode 100644 apps/desktop/src/main/services/shared/processExecution.test.ts create mode 100644 apps/desktop/src/main/services/shared/processExecution.ts create mode 100644 apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.test.ts create mode 100644 apps/desktop/src/renderer/lib/pathUtils.test.ts create mode 100644 apps/desktop/src/renderer/lib/pathUtils.ts create mode 100644 apps/desktop/src/shared/adeMcpIpc.test.ts create mode 100644 apps/desktop/src/shared/adeMcpIpc.ts create mode 100644 apps/desktop/vendor/crsqlite/win32-x64/crsqlite.dll diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index f2763e853..9e44ea17a 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1823,7 +1823,12 @@ describe("adeRpcServer", () => { cols: 120, rows: 36, tracked: true, - toolType: "claude-orchestrated" + toolType: "claude-orchestrated", + command: "claude", + args: expect.arrayContaining(["--model", "claude-sonnet-4-6", "--permission-mode", "default", "Implement API wiring"]), + env: expect.objectContaining({ + ADE_DEFAULT_ROLE: "agent", + }), }) ); expect(response.structuredContent.startupCommand).toContain("claude"); @@ -1853,6 +1858,16 @@ describe("adeRpcServer", () => { expect(response.structuredContent.startupCommand).toContain("claude"); expect(response.structuredContent.startupCommand).toContain("ADE_RUN_ID=run-1"); expect(response.structuredContent.startupCommand).toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + command: "claude", + env: expect.objectContaining({ + ADE_RUN_ID: "run-1", + ADE_ATTEMPT_ID: "attempt-workspace-roots", + ADE_DEFAULT_ROLE: "agent", + }), + }) + ); }); it("rejects config-toml permission mode for Claude spawn_agent sessions", async () => { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 9e2b23cfd..d4211b4a1 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -5886,46 +5886,63 @@ async function runTool(args: { } const finalPrompt = promptSegments.join("\n").trim(); - const commandParts: string[] = [provider]; + const commandArgs: string[] = []; + const commandPreviewParts: string[] = [provider]; if (model) { - commandParts.push("--model", shellEscapeArg(model)); + commandArgs.push("--model", model); + commandPreviewParts.push("--model", shellEscapeArg(model)); } if (provider === "codex") { if (permissionMode === "full-auto") { - commandParts.push("--dangerously-bypass-approvals-and-sandbox"); + commandArgs.push("--dangerously-bypass-approvals-and-sandbox"); + commandPreviewParts.push("--dangerously-bypass-approvals-and-sandbox"); } else if (permissionMode === "default") { - commandParts.push("--full-auto"); + commandArgs.push("--full-auto"); + commandPreviewParts.push("--full-auto"); } else if (permissionMode === "config-toml") { // No explicit Codex permission flags; let the host config.toml decide. } else if (permissionMode === "plan") { - commandParts.push("--sandbox", "read-only", "--ask-for-approval", "on-request"); + commandArgs.push("--sandbox", "read-only", "--ask-for-approval", "on-request"); + commandPreviewParts.push("--sandbox", "read-only", "--ask-for-approval", "on-request"); } else { - commandParts.push("--sandbox", "workspace-write", "--ask-for-approval", "untrusted"); + commandArgs.push("--sandbox", "workspace-write", "--ask-for-approval", "untrusted"); + commandPreviewParts.push("--sandbox", "workspace-write", "--ask-for-approval", "untrusted"); } } else { const claudePermission = permissionMode === "plan" ? "plan" : permissionMode === "full-auto" ? "bypassPermissions" : permissionMode === "edit" ? "acceptEdits" : "default"; - commandParts.push("--permission-mode", claudePermission); + commandArgs.push("--permission-mode", claudePermission); + commandPreviewParts.push("--permission-mode", shellEscapeArg(claudePermission)); // ADE-owned actions are exposed through the `ade` CLI. Child agent // sessions receive identity env vars below instead of an attached server. } if (finalPrompt) { - commandParts.push(shellEscapeArg(finalPrompt)); + commandArgs.push(finalPrompt); + commandPreviewParts.push(shellEscapeArg(finalPrompt)); } - // Prepend env vars for worker identity + // Attach worker identity through the process environment. The startup + // command remains a display/resume preview only; the actual launch uses + // command/args/env so it works on Windows without POSIX inline assignment. + const workerEnv: Record = {}; const envPrefixParts: string[] = []; - if (runId) envPrefixParts.push(`ADE_RUN_ID=${shellEscapeArg(runId)}`); - if (stepId) envPrefixParts.push(`ADE_STEP_ID=${shellEscapeArg(stepId)}`); - if (attemptId) envPrefixParts.push(`ADE_ATTEMPT_ID=${shellEscapeArg(attemptId)}`); - if (callerCtx.missionId) envPrefixParts.push(`ADE_MISSION_ID=${shellEscapeArg(callerCtx.missionId)}`); - if (callerCtx.ownerId) envPrefixParts.push(`ADE_OWNER_ID=${shellEscapeArg(callerCtx.ownerId)}`); + const addWorkerEnv = (key: string, value: string | null | undefined) => { + if (!value) return; + workerEnv[key] = value; + envPrefixParts.push(`${key}=${shellEscapeArg(value)}`); + }; + addWorkerEnv("ADE_RUN_ID", runId); + addWorkerEnv("ADE_STEP_ID", stepId); + addWorkerEnv("ADE_ATTEMPT_ID", attemptId); + addWorkerEnv("ADE_MISSION_ID", callerCtx.missionId); + addWorkerEnv("ADE_OWNER_ID", callerCtx.ownerId); + workerEnv.ADE_DEFAULT_ROLE = "agent"; envPrefixParts.push("ADE_DEFAULT_ROLE=agent"); const startupCommand = envPrefixParts.length > 0 - ? `${envPrefixParts.join(" ")} ${commandParts.join(" ")}` - : commandParts.join(" "); + ? `${envPrefixParts.join(" ")} ${commandPreviewParts.join(" ")}` + : commandPreviewParts.join(" "); const created = await runtime.ptyService.create({ laneId, @@ -5934,6 +5951,9 @@ async function runTool(args: { title, tracked: true, toolType: `${provider}-orchestrated`, + command: provider, + args: commandArgs, + env: workerEnv, startupCommand }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 65b1a7341..e983d3292 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -15,6 +15,8 @@ import { createDiffService } from "../../desktop/src/main/services/diffs/diffSer import { createMissionService } from "../../desktop/src/main/services/missions/missionService"; import { createPtyService } from "../../desktop/src/main/services/pty/ptyService"; import { createTestService } from "../../desktop/src/main/services/tests/testService"; +import { createProcessService } from "../../desktop/src/main/services/processes/processService"; +import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../../desktop/src/main/services/ai/cliExecutableResolver"; import type { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService"; import type { createPrService } from "../../desktop/src/main/services/prs/prService"; import { createIssueInventoryService } from "../../desktop/src/main/services/prs/issueInventoryService"; @@ -37,7 +39,6 @@ import { type ComputerUseArtifactBrokerService, } from "../../desktop/src/main/services/computerUse/computerUseArtifactBrokerService"; import type { createFileService } from "../../desktop/src/main/services/files/fileService"; -import type { createProcessService } from "../../desktop/src/main/services/processes/processService"; import { createHeadlessLinearServices } from "./headlessLinearServices"; import { createEventBuffer, type BufferedEvent, type EventBuffer } from "./eventBuffer"; @@ -121,6 +122,17 @@ export function ensureAdePaths(projectRoot: string): AdeRuntimePaths { }; } +function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = { ...baseEnv }; + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ + env: next, + includeInteractiveShell: true, + timeoutMs: 1_000, + }); + if (nextPath) setPathEnvValue(next, nextPath); + return next; +} + export async function createAdeRuntime(args: { projectRoot: string; workspaceRoot?: string } | string): Promise { const resolvedArgs = typeof args === "string" ? { projectRoot: args, workspaceRoot: args } @@ -218,6 +230,7 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo broadcastData: () => {}, broadcastExit: () => {}, onSessionEnded: () => {}, + getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv, loadPty: () => nodePty }); @@ -231,6 +244,22 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo broadcastEvent: () => {} }); const issueInventoryService = createIssueInventoryService({ db }); + const eventBuffer = createEventBuffer(); + + function pushEvent(category: BufferedEvent["category"], payload: Record): void { + eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); + } + + const processService = createProcessService({ + db, + projectId, + logger, + laneService, + projectConfigService, + sessionService, + ptyService, + broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record), + }); // Ensure evaluation tables exist for headless runtime checks. db.run(` @@ -257,12 +286,6 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo ON orchestrator_evaluations(run_id, evaluated_at) `); - const eventBuffer = createEventBuffer(); - - function pushEvent(category: BufferedEvent["category"], payload: Record): void { - eventBuffer.push({ timestamp: new Date().toISOString(), category, payload }); - } - const memoryService = createMemoryService(db); const ctoStateService = createCtoStateService({ db, @@ -392,13 +415,14 @@ export async function createAdeRuntime(args: { projectRoot: string; workspaceRoo linearSyncService: headlessLinearServices.linearSyncService, linearIngressService: headlessLinearServices.linearIngressService, linearRoutingService: headlessLinearServices.linearRoutingService, - processService: headlessLinearServices.processService, + processService, computerUseArtifactBrokerService, orchestratorService, aiOrchestratorService, eventBuffer, dispose: () => { const swallow = (fn: () => void) => { try { fn(); } catch { /* ignore */ } }; + swallow(() => processService.disposeAll()); swallow(() => headlessLinearServices.dispose()); swallow(() => aiOrchestratorService.dispose()); swallow(() => testService.disposeAll()); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 13f797b29..37beda030 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildCliPlan, formatOutput, parseCliArgs, renderLaneGraph, summarizeExecution, unwrapToolResult } from "./cli"; +import { buildCliPlan, formatOutput, parseCliArgs, renderLaneGraph, shouldAttemptDesktopSocketConnection, summarizeExecution, unwrapToolResult } from "./cli"; describe("ADE CLI", () => { it("parses global options without stealing command flags", () => { @@ -235,6 +235,11 @@ describe("ADE CLI", () => { expect(output).toContain("Git repository detected"); }); + it("attempts Windows named-pipe desktop sockets without filesystem existence checks", () => { + expect(shouldAttemptDesktopSocketConnection("\\\\.\\pipe\\ade-123")).toBe(true); + expect(shouldAttemptDesktopSocketConnection("//./pipe/ade-123")).toBe(true); + }); + it("renders a compact lane graph", () => { const graph = renderLaneGraph({ lanes: [ diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 6aab71899..f057c2f6a 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; import { type JsonRpcHandler, type JsonRpcId, type JsonRpcRequest } from "./jsonrpc"; +import { isAdeMcpNamedPipePath } from "../../desktop/src/shared/adeMcpIpc"; type JsonObject = Record; @@ -1641,7 +1642,8 @@ function resolveRoots(options: GlobalOptions): { projectRoot: string; workspaceR } function commandExists(command: string): boolean { - const result = spawnSync("which", [command], { + const lookupCommand = process.platform === "win32" ? "where" : "which"; + const result = spawnSync(lookupCommand, [command], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); @@ -1787,9 +1789,9 @@ function checkProviderReadiness(value: unknown): ReadinessCheck { function checkComputerUseReadiness(): ReadinessCheck { const isDarwin = process.platform === "darwin"; - const screenshotReady = !isDarwin || commandExists("screencapture"); - const appLaunchReady = !isDarwin || commandExists("open"); - const guiReady = !isDarwin || commandExists("swift") || commandExists("osascript"); + const screenshotReady = isDarwin && commandExists("screencapture"); + const appLaunchReady = isDarwin && commandExists("open"); + const guiReady = isDarwin && (commandExists("swift") || commandExists("osascript")); const ready = isDarwin && screenshotReady && appLaunchReady && guiReady; return { ready, @@ -1814,9 +1816,11 @@ function checkComputerUseReadiness(): ReadinessCheck { } function checkPathReadiness(): ReadinessCheck { - const which = runLocalCommand("which", ["ade"], process.cwd()); + const lookup = process.platform === "win32" + ? runLocalCommand("where", ["ade"], process.cwd()) + : runLocalCommand("which", ["ade"], process.cwd()); const current = path.resolve(process.argv[1] ?? ""); - const whichPath = which.ok && which.stdout ? path.resolve(which.stdout.split("\n")[0]!) : null; + const whichPath = lookup.ok && lookup.stdout ? path.resolve(lookup.stdout.split(/\r?\n/)[0]!) : null; const onPath = Boolean(whichPath); return { ready: onPath, @@ -1831,6 +1835,7 @@ function checkPathReadiness(): ReadinessCheck { sameBinary: Boolean(whichPath && current && whichPath === current), electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE === "1", electronVersion: process.versions.electron ?? null, + lookupCommand: process.platform === "win32" ? "where" : "which", }, }; } @@ -1862,8 +1867,10 @@ function buildReadinessSnapshot(args: { const adeDir = path.join(connection.projectRoot, ".ade"); const sharedConfigPath = path.join(adeDir, "ade.yaml"); const localConfigPath = path.join(adeDir, "local.yaml"); - const socketExists = fs.existsSync(connection.socketPath); const desktopSocketAvailable = connection.mode === "desktop-socket"; + const socketExists = isAdeMcpNamedPipePath(connection.socketPath) + ? desktopSocketAvailable + : fs.existsSync(connection.socketPath); const checks = { git: checkGitReadiness(connection.projectRoot), github: checkGitHubReadiness(connection.projectRoot), @@ -2082,6 +2089,10 @@ class InProcessJsonRpcClient { } } +export function shouldAttemptDesktopSocketConnection(socketPath: string): boolean { + return isAdeMcpNamedPipePath(socketPath) || fs.existsSync(socketPath); +} + async function initializeConnection(connection: CliConnection, options: GlobalOptions): Promise { await connection.request("ade/initialize", { protocolVersion: PROTOCOL_VERSION, @@ -2103,7 +2114,7 @@ async function createConnection(options: GlobalOptions): Promise const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); const layout = resolveAdeLayout(roots.projectRoot); - if (!options.headless && fs.existsSync(layout.socketPath)) { + if (!options.headless && shouldAttemptDesktopSocketConnection(layout.socketPath)) { try { const socketClient = await SocketJsonRpcClient.connect(layout.socketPath, options.timeoutMs); const connection: CliConnection = { @@ -2665,7 +2676,13 @@ async function executePlan(plan: CliPlan & { kind: "execute" }, options: GlobalO connection = await createConnection(options); } catch (error) { const roots = resolveRoots(options); - const socketPath = path.join(roots.projectRoot, ".ade", "ade.sock"); + let socketPath = path.join(roots.projectRoot, ".ade", "ade.sock"); + try { + const { resolveAdeLayout } = await import("../../desktop/src/shared/adeLayout"); + socketPath = resolveAdeLayout(roots.projectRoot).socketPath; + } catch { + // Keep the conventional Unix fallback if shared layout loading fails. + } const requestedMode = options.requireSocket ? "desktop-socket" : options.headless ? "headless" : "auto"; const cause = error instanceof Error ? error.message : String(error); const sourceRuntimeInterop = isSourceRuntimeInteropError(cause); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c056ec544..d83d3aad7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,6 +11,7 @@ "prebuild": "node ./scripts/normalize-runtime-binaries.cjs && npm run ade:build", "dev": "node ./scripts/ensure-electron.cjs && node ./scripts/dev.cjs", "build": "tsup && vite build", + "dist:win": "npm run validate:win:artifacts && npm run build && electron-builder --win --x64 --publish never", "dist:mac": "npm run build && electron-builder --mac --publish never", "dist:mac:dir": "npm run build && electron-builder --dir --mac --publish never -c.mac.identity=null -c.mac.notarize=false", "dist:mac:signed": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac --publish never", @@ -19,6 +20,7 @@ "dist:mac:universal:signed:zip": "node ./scripts/require-macos-release-secrets.cjs && npm run build && electron-builder --mac zip --universal --publish never", "notarize:mac:dmg": "node ./scripts/notarize-mac-dmg.mjs", "validate:mac:artifacts": "node ./scripts/validate-mac-artifacts.mjs", + "validate:win:artifacts": "node ./scripts/validate-win-artifacts.mjs", "release:mac:local": "node ./scripts/release-mac-local.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", @@ -162,9 +164,17 @@ "from": "scripts/ade-cli-macos-wrapper.sh", "to": "ade-cli/bin/ade" }, + { + "from": "scripts/ade-cli-windows-wrapper.cmd", + "to": "ade-cli/bin/ade.cmd" + }, { "from": "scripts/ade-cli-install-path.sh", "to": "ade-cli/install-path.sh" + }, + { + "from": "scripts/ade-cli-install-path.cmd", + "to": "ade-cli/install-path.cmd" } ], "afterPack": "./scripts/after-pack-runtime-fixes.cjs", @@ -176,6 +186,12 @@ "publishAutoUpdate": true }, "npmRebuild": false, + "win": { + "target": [ + "nsis" + ], + "artifactName": "${productName}-${version}-win-${arch}.${ext}" + }, "mac": { "target": [ "dmg", diff --git a/apps/desktop/scripts/ade-cli-install-path.cmd b/apps/desktop/scripts/ade-cli-install-path.cmd new file mode 100644 index 000000000..7a7ef8ed2 --- /dev/null +++ b/apps/desktop/scripts/ade-cli-install-path.cmd @@ -0,0 +1,36 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "ADE_BIN=%ADE_BIN%" +if "%ADE_BIN%"=="" set "ADE_BIN=%SCRIPT_DIR%bin\ade.cmd" + +set "TARGET_PATH=%~1" +if "%TARGET_PATH%"=="" ( + if defined LOCALAPPDATA ( + set "TARGET_PATH=%LOCALAPPDATA%\ADE\bin\ade.cmd" + ) else ( + set "TARGET_PATH=%USERPROFILE%\.ade\bin\ade.cmd" + ) +) + +if not exist "%ADE_BIN%" ( + echo ade install: missing bundled CLI wrapper at %ADE_BIN% 1>&2 + exit /b 1 +) + +for %%I in ("%TARGET_PATH%") do set "TARGET_DIR=%%~dpI" +if not exist "%TARGET_DIR%" mkdir "%TARGET_DIR%" >nul 2>nul + +( + echo @echo off + echo "%ADE_BIN%" %%* +) > "%TARGET_PATH%" + +if errorlevel 1 ( + echo ade install: failed to write %TARGET_PATH% 1>&2 + exit /b 1 +) + +echo Installed ade -^> %ADE_BIN% +echo Ensure %TARGET_DIR% is on PATH, then run: ade doctor diff --git a/apps/desktop/scripts/ade-cli-windows-wrapper.cmd b/apps/desktop/scripts/ade-cli-windows-wrapper.cmd new file mode 100644 index 000000000..2d1dc0cc3 --- /dev/null +++ b/apps/desktop/scripts/ade-cli-windows-wrapper.cmd @@ -0,0 +1,35 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "CLI_JS=%ADE_CLI_JS%" +if "%CLI_JS%"=="" set "CLI_JS=%SCRIPT_DIR%..\cli.cjs" + +if defined ADE_CLI_NODE ( + "%ADE_CLI_NODE%" "%CLI_JS%" %* + exit /b %ERRORLEVEL% +) + +set "RESOURCES_DIR=%SCRIPT_DIR%..\.." +set "APP_EXE=%RESOURCES_DIR%\..\ADE.exe" + +if exist "%APP_EXE%" ( + set "NODE_PATH_VALUE=%RESOURCES_DIR%\app.asar.unpacked\node_modules;%RESOURCES_DIR%\app.asar\node_modules" + if defined NODE_PATH set "NODE_PATH_VALUE=%NODE_PATH_VALUE%;%NODE_PATH%" + set "ELECTRON_RUN_AS_NODE=1" + set "NODE_PATH=%NODE_PATH_VALUE%" + "%APP_EXE%" "%CLI_JS%" %* + exit /b %ERRORLEVEL% +) + +where node >nul 2>nul +if not errorlevel 1 ( + node -e "process.exit(Number(process.versions.node.split('.')[0]) >= 22 ? 0 : 1)" >nul 2>nul + if not errorlevel 1 ( + node "%CLI_JS%" %* + exit /b %ERRORLEVEL% + ) +) + +echo ade: Node.js 22+ or the packaged ADE.exe runtime is required to run this CLI. 1>&2 +exit /b 127 diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 93381d230..9315f57a9 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -5,39 +5,62 @@ const { resolvePackagedRuntimeRoot, } = require("./runtimeBinaryPermissions.cjs"); -module.exports = async function afterPack(context) { +function resolveUnpackedRuntimeRoot(context) { const productFilename = context?.packager?.appInfo?.productFilename || "ADE"; const appBundlePath = path.join(context?.appOutDir || "", `${productFilename}.app`); - if (!appBundlePath || !fs.existsSync(appBundlePath)) { - throw new Error(`[afterPack] Missing packaged app bundle: ${String(appBundlePath)}`); - } - const runtimeRoot = resolvePackagedRuntimeRoot(appBundlePath); - const bundledCliPath = path.join(appBundlePath, "Contents", "Resources", "ade-cli", "cli.cjs"); - const bundledCliBinPath = path.join(appBundlePath, "Contents", "Resources", "ade-cli", "bin", "ade"); - const bundledCliInstallerPath = path.join(appBundlePath, "Contents", "Resources", "ade-cli", "install-path.sh"); - if (!fs.existsSync(runtimeRoot)) { - throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); + if (fs.existsSync(appBundlePath)) { + return { runtimeRoot: resolvePackagedRuntimeRoot(appBundlePath), appBundlePath }; } - if (!fs.existsSync(bundledCliPath)) { - throw new Error(`[afterPack] Missing bundled ADE CLI entry: ${bundledCliPath}`); + + const resourcesRoot = path.join(context?.appOutDir || "", "resources", "app.asar.unpacked"); + if (!fs.existsSync(resourcesRoot)) { + throw new Error( + `[afterPack] Missing unpacked runtime payload (tried ${appBundlePath} and ${resourcesRoot})`, + ); } - if (!fs.existsSync(bundledCliBinPath)) { - throw new Error(`[afterPack] Missing bundled ADE CLI wrapper: ${bundledCliBinPath}`); + return { runtimeRoot: resourcesRoot, appBundlePath: null }; +} + +function resolveExtraResourcesRoot(context, appBundlePath) { + if (appBundlePath) return path.join(appBundlePath, "Contents", "Resources"); + return path.join(context?.appOutDir || "", "resources"); +} + +function requireFile(filePath, label) { + if (!fs.existsSync(filePath)) { + throw new Error(`[afterPack] Missing ${label}: ${filePath}`); } - if (!fs.existsSync(bundledCliInstallerPath)) { - throw new Error(`[afterPack] Missing bundled ADE CLI PATH installer: ${bundledCliInstallerPath}`); +} + +module.exports = async function afterPack(context) { + const { runtimeRoot, appBundlePath } = resolveUnpackedRuntimeRoot(context); + if (!fs.existsSync(runtimeRoot)) { + throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); } + + const resourcesRoot = resolveExtraResourcesRoot(context, appBundlePath); + const bundledCliPath = path.join(resourcesRoot, "ade-cli", "cli.cjs"); + requireFile(bundledCliPath, "bundled ADE CLI entry"); + + const bundledCliBinPath = path.join(resourcesRoot, "ade-cli", "bin", "ade"); + const bundledCliInstallerPath = path.join(resourcesRoot, "ade-cli", "install-path.sh"); + requireFile(bundledCliBinPath, "bundled ADE CLI wrapper"); + requireFile(bundledCliInstallerPath, "bundled ADE CLI PATH installer"); + requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade.cmd"), "bundled ADE CLI Windows wrapper"); + requireFile(path.join(resourcesRoot, "ade-cli", "install-path.cmd"), "bundled ADE CLI Windows PATH installer"); fs.chmodSync(bundledCliBinPath, 0o755); fs.chmodSync(bundledCliInstallerPath, 0o755); const normalized = normalizeDesktopRuntimeBinaries(runtimeRoot); for (const entry of normalized) { - console.log(`[afterPack] Restored executable mode: ${entry.label} -> ${path.relative(appBundlePath, entry.filePath)}`); + console.log(`[afterPack] Restored executable mode: ${entry.label} -> ${path.relative(runtimeRoot, entry.filePath)}`); } const requiredScripts = [ path.join(runtimeRoot, "dist", "main", "packagedRuntimeSmoke.cjs"), + path.join(runtimeRoot, "vendor", "crsqlite", "darwin-arm64", "crsqlite.dylib"), + path.join(runtimeRoot, "vendor", "crsqlite", "win32-x64", "crsqlite.dll"), ]; for (const scriptPath of requiredScripts) { diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index fc32d2e91..414a84349 100644 --- a/apps/desktop/scripts/dev.cjs +++ b/apps/desktop/scripts/dev.cjs @@ -7,6 +7,7 @@ const path = require("node:path"); const projectRoot = path.resolve(__dirname, ".."); const distMainFile = path.join(projectRoot, "dist", "main", "main.cjs"); +const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx"; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -118,6 +119,22 @@ function spawnProcess(name, cmd, args, extraEnv = {}) { return child; } +function terminateChild(child, signal) { + if (child.killed) return; + if (process.platform === "win32" && typeof child.pid === "number") { + const result = cp.spawnSync("taskkill.exe", ["/T", "/F", "/PID", String(child.pid)], { + stdio: "ignore", + windowsHide: true, + }); + if (!result.error && result.status === 0) return; + } + try { + child.kill(signal); + } catch { + // ignore + } +} + async function main() { const devPort = await choosePort(5173, 32); const devServerUrl = `http://localhost:${devPort}`; @@ -138,13 +155,7 @@ async function main() { shuttingDown = true; fs.unwatchFile(distMainFile); for (const child of children) { - if (!child.killed) { - try { - child.kill(signal); - } catch { - // ignore - } - } + terminateChild(child, signal); } }; @@ -152,8 +163,8 @@ async function main() { process.on("SIGTERM", () => teardown("SIGTERM")); process.on("exit", () => teardown("SIGTERM")); - const vite = spawnProcess("renderer", "npx", ["vite", "--port", String(devPort), "--strictPort", "--force"]); - const main = spawnProcess("main", "npx", ["tsup", "--watch"]); + const vite = spawnProcess("renderer", npxCommand, ["vite", "--port", String(devPort), "--strictPort", "--force"]); + const main = spawnProcess("main", npxCommand, ["tsup", "--watch"]); children.add(vite); children.add(main); @@ -176,7 +187,7 @@ async function main() { const electronEnv = { VITE_DEV_SERVER_URL: devServerUrl }; const launchElectron = () => { - const child = spawnProcess("electron", "npx", ["electron", ".", `--remote-debugging-port=${remoteDebugPort}`], electronEnv); + const child = spawnProcess("electron", npxCommand, ["electron", ".", `--remote-debugging-port=${remoteDebugPort}`], electronEnv); electron = child; children.add(child); child.on("exit", (code, signal) => { @@ -214,11 +225,7 @@ async function main() { if (electronRestartPending) return; electronRestartPending = true; process.stdout.write(`[ade] restarting electron (${reason})\n`); - try { - electron.kill("SIGTERM"); - } catch { - electronRestartPending = false; - } + terminateChild(electron, "SIGTERM"); }; launchElectron(); diff --git a/apps/desktop/scripts/validate-win-artifacts.mjs b/apps/desktop/scripts/validate-win-artifacts.mjs new file mode 100644 index 000000000..a0b50a32a --- /dev/null +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -0,0 +1,51 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const desktopRoot = path.resolve(__dirname, ".."); +const packageJsonPath = path.join(desktopRoot, "package.json"); +const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + +function fail(message) { + console.error(`[validate-win-artifacts] ${message}`); + process.exitCode = 1; +} + +function requireFile(relativePath, label) { + const absolutePath = path.join(desktopRoot, relativePath); + if (!fs.existsSync(absolutePath)) { + fail(`Missing ${label}: ${absolutePath}`); + } +} + +function hasExtraResource(to) { + return Array.isArray(pkg.build?.extraResources) + && pkg.build.extraResources.some((entry) => entry && entry.to === to); +} + +requireFile("scripts/ade-cli-windows-wrapper.cmd", "Windows ADE CLI wrapper"); +requireFile("scripts/ade-cli-install-path.cmd", "Windows ADE CLI PATH installer"); +requireFile("vendor/crsqlite/win32-x64/crsqlite.dll", "Windows cr-sqlite extension"); + +if (!hasExtraResource("ade-cli/bin/ade.cmd")) { + fail("package.json build.extraResources must ship ade-cli/bin/ade.cmd"); +} +if (!hasExtraResource("ade-cli/install-path.cmd")) { + fail("package.json build.extraResources must ship ade-cli/install-path.cmd"); +} +if (!Array.isArray(pkg.build?.win?.target) || pkg.build.win.target.length === 0) { + fail("package.json build.win.target must define at least one Windows target"); +} +if (typeof pkg.scripts?.["dist:win"] !== "string" || !/\s--x64(?:\s|$)/.test(pkg.scripts["dist:win"])) { + fail("package.json scripts.dist:win must pass --x64 until a Windows ARM64 cr-sqlite binary is bundled"); +} +if (!Array.isArray(pkg.build?.asarUnpack) || !pkg.build.asarUnpack.includes("vendor/crsqlite/**")) { + fail("package.json build.asarUnpack must unpack vendor/crsqlite/**"); +} + +if (process.exitCode) { + process.exit(process.exitCode); +} + +console.log("[validate-win-artifacts] Windows package inputs are present."); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 81dfdf5f3..fc953ac70 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,7 +1,9 @@ import { app, BrowserWindow, dialog, nativeImage, protocol, safeStorage, shell } from "electron"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import type * as NodePty from "node-pty"; type NodePtyType = typeof NodePty; +import { isAdeMcpNamedPipePath } from "../shared/adeMcpIpc"; import { registerIpc } from "./services/ipc/registerIpc"; import { createFileLogger } from "./services/logging/logger"; import { openKvDb } from "./services/state/kvDb"; @@ -209,7 +211,7 @@ if (process.env.VITE_DEV_SERVER_URL) { function getRendererUrl(): string { const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) return devUrl; - return `file://${path.join(__dirname, "../renderer/index.html")}`; + return pathToFileURL(path.join(__dirname, "../renderer/index.html")).toString(); } async function createWindow(args: { @@ -520,10 +522,18 @@ app.whenReady().then(async () => { protocol.handle("ade-artifact", (request) => { const url = new URL(request.url); let filePath = decodeURIComponent(url.pathname); + if (url.hostname === "project") { + if (!activeProjectRoot) return new Response("Not found", { status: 404 }); + filePath = path.resolve(activeProjectRoot, filePath.replace(/^[/\\]+/, "")); + } // On Windows, pathname starts with /C:/... — strip leading slash if (process.platform === "win32" && /^\/[a-zA-Z]:/.test(filePath)) { filePath = filePath.slice(1); } + if (!path.isAbsolute(filePath)) { + if (!activeProjectRoot) return new Response("Not found", { status: 404 }); + filePath = path.resolve(activeProjectRoot, filePath); + } filePath = path.resolve(filePath); let resolvedFile: string; try { @@ -3073,10 +3083,11 @@ app.whenReady().then(async () => { destroyActiveRpcConnections, ); - // Clean stale socket from prior crash - try { - fs.unlinkSync(rpcSocketPath); - } catch {} + if (!isAdeMcpNamedPipePath(rpcSocketPath)) { + try { + fs.unlinkSync(rpcSocketPath); + } catch {} + } const rpcSocketServer = net.createServer((conn) => { activeRpcConnections.add(conn); @@ -3347,7 +3358,9 @@ app.whenReady().then(async () => { // ignore } try { - if (ctx.rpcSocketPath) fs.unlinkSync(ctx.rpcSocketPath); + if (ctx.rpcSocketPath && !isAdeMcpNamedPipePath(ctx.rpcSocketPath)) { + fs.unlinkSync(ctx.rpcSocketPath); + } } catch { // ignore } diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 52ecca294..f9768b616 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -12,7 +12,11 @@ async function probePty(): Promise<{ ok: true; output: string }> { const pty = await import("node-pty"); return new Promise((resolve, reject) => { let output = ""; - const term = pty.spawn("/bin/sh", ["-lc", 'printf "ADE_PTY_OK\\n"'], { + const shellSpec = + process.platform === "win32" + ? { file: "powershell.exe", args: ["-NoProfile", "-Command", 'Write-Output "ADE_PTY_OK"'] } + : { file: "/bin/sh", args: ["-lc", 'printf "ADE_PTY_OK\\n"'] }; + const term = pty.spawn(shellSpec.file, shellSpec.args, { name: "xterm-256color", cols: 80, rows: 24, diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts index bb85cebc0..efe6a04a6 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts @@ -4,10 +4,14 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { augmentPathWithKnownCliDirs, + augmentProcessPathWithShellAndKnownCliDirs, + getPathEnvValue, resolveExecutableFromKnownLocations, + setPathEnvValue, } from "./cliExecutableResolver"; const originalPlatform = process.platform; +const originalPathDelimiter = path.delimiter; function makeExecutable(filePath: string): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -22,12 +26,24 @@ function setPlatform(value: NodeJS.Platform): void { }); } +function setPathDelimiter(value: string): void { + Object.defineProperty(path, "delimiter", { + value, + configurable: true, + }); +} + +function currentPathDelimiter(): string { + return process.platform === "win32" ? ";" : path.delimiter; +} + describe("cliExecutableResolver", () => { let tempRoot: string | null = null; afterEach(() => { vi.restoreAllMocks(); setPlatform(originalPlatform); + setPathDelimiter(originalPathDelimiter); if (tempRoot) { fs.rmSync(tempRoot, { recursive: true, force: true }); tempRoot = null; @@ -78,7 +94,7 @@ describe("cliExecutableResolver", () => { PATH: "/usr/bin:/bin", }); - expect(nextPath.split(path.delimiter)).toContain(path.join(homeDir, ".npm-global", "bin")); + expect(nextPath.split(currentPathDelimiter())).toContain(path.join(homeDir, ".npm-global", "bin")); }); it("keeps both Intel and Apple Silicon Homebrew bins on PATH", () => { @@ -87,11 +103,90 @@ describe("cliExecutableResolver", () => { PATH: "/usr/local/bin:/usr/bin:/bin", }); - const entries = nextPath.split(path.delimiter); + const entries = nextPath.split(currentPathDelimiter()); expect(entries).toContain("/usr/local/bin"); expect(entries).toContain("/opt/homebrew/bin"); }); + it("augments PATH with known CLI dirs on Windows", () => { + setPlatform("win32"); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); + const homeDir = path.join(tempRoot, "home"); + const scoopShims = path.join(homeDir, "scoop", "shims"); + fs.mkdirSync(scoopShims, { recursive: true }); + const fakeCodex = path.join(scoopShims, "codex.cmd"); + fs.writeFileSync(fakeCodex, "@echo off\r\n", "utf8"); + + const nextPath = augmentProcessPathWithShellAndKnownCliDirs({ + env: { + USERPROFILE: homeDir, + HOME: homeDir, + PATH: "C:\\Windows\\System32", + }, + }); + + expect(nextPath.split(currentPathDelimiter())).toContain(scoopShims); + }); + + it("reads and updates Windows Path without creating duplicate PATH keys", () => { + setPlatform("win32"); + const env: NodeJS.ProcessEnv = { + Path: "C:\\Windows\\System32", + }; + + expect(getPathEnvValue(env)).toBe("C:\\Windows\\System32"); + setPathEnvValue(env, "C:\\Tools;C:\\Windows\\System32"); + + expect(env.Path).toBe("C:\\Tools;C:\\Windows\\System32"); + expect(env.PATH).toBeUndefined(); + }); + + it("prefers USERPROFILE over a Git Bash-style HOME for Windows known dirs", () => { + setPlatform("win32"); + setPathDelimiter(";"); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); + const gitBashHome = "/c/Users/Alice"; + const userProfile = "C:\\Users\\Alice"; + const scoopShims = path.join(userProfile, "scoop", "shims"); + const voltaBin = path.join(userProfile, ".volta", "bin"); + const opencodeBin = path.join(userProfile, ".opencode", "bin"); + const realExecutable = path.join(tempRoot, "codex.CMD"); + makeExecutable(realExecutable); + + const realStatSync = fs.statSync; + vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, opts?: any) => { + const normalizedCandidate = path.normalize(String(candidatePath)); + if (normalizedCandidate.toLowerCase() === path.normalize(path.join(scoopShims, "codex.CMD")).toLowerCase()) { + return realStatSync(realExecutable, opts); + } + const err: NodeJS.ErrnoException = new Error("ENOENT"); + err.code = "ENOENT"; + throw err; + }) as typeof fs.statSync); + + const nextPath = augmentPathWithKnownCliDirs("C:\\Windows\\System32", { + HOME: gitBashHome, + USERPROFILE: userProfile, + PATH: "C:\\Windows\\System32", + }); + + expect(nextPath).toContain(scoopShims); + expect(nextPath).toContain(voltaBin); + expect(nextPath).toContain(opencodeBin); + expect(nextPath).not.toContain(path.join(gitBashHome, "scoop", "shims")); + expect(nextPath).not.toContain(path.join(gitBashHome, ".volta", "bin")); + expect(nextPath).not.toContain(path.join(gitBashHome, ".opencode", "bin")); + + expect(resolveExecutableFromKnownLocations("codex", { + HOME: gitBashHome, + USERPROFILE: userProfile, + PATH: "C:\\Windows\\System32", + })).toEqual({ + path: path.join(scoopShims, "codex.CMD"), + source: "known-dir", + }); + }); + it("resolves Windows executables using PATHEXT suffixes", () => { setPlatform("win32"); tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); @@ -110,4 +205,20 @@ describe("cliExecutableResolver", () => { source: "path", }); }); + + it("resolves Windows executables from Path when PATH is absent", () => { + setPlatform("win32"); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-")); + const binDir = path.join(tempRoot, "bin"); + const executablePath = path.join(binDir, "codex.cmd"); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync(executablePath, "@echo off\r\n", "utf8"); + + const resolved = resolveExecutableFromKnownLocations("codex", { + Path: binDir, + PATHEXT: ".CMD;.EXE", + }); + expect(resolved?.source).toBe("path"); + expect(resolved?.path.toLowerCase()).toBe(executablePath.toLowerCase()); + }); }); diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 381a0955c..f54b8c5a9 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -13,8 +13,16 @@ export type ResolvedExecutable = { }; function getHomeDir(env: NodeJS.ProcessEnv): string { + const profile = env.USERPROFILE?.trim(); + if (process.platform === "win32") { + if (profile && profile.length > 0) return profile; + const home = env.HOME?.trim(); + if (home && home.length > 0) return home; + return os.homedir(); + } const home = env.HOME?.trim(); - return home && home.length > 0 ? home : os.homedir(); + if (home && home.length > 0) return home; + return os.homedir(); } function uniqueNonEmpty(values: Iterable): string[] { @@ -27,6 +35,31 @@ function uniqueNonEmpty(values: Iterable): string[] { return [...out]; } +function pathListDelimiter(): string { + return process.platform === "win32" ? ";" : path.delimiter; +} + +export function getPathEnvKey(env: NodeJS.ProcessEnv): string { + if (process.platform !== "win32") return "PATH"; + return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "Path"; +} + +export function getPathEnvValue(env: NodeJS.ProcessEnv): string | undefined { + return env[getPathEnvKey(env)]; +} + +export function setPathEnvValue(env: NodeJS.ProcessEnv, value: string): void { + const key = getPathEnvKey(env); + if (process.platform === "win32") { + for (const existing of Object.keys(env)) { + if (existing.toLowerCase() === "path" && existing !== key) { + delete env[existing]; + } + } + } + env[key] = value; +} + function expandHomePath(input: string, homeDir: string): string { if (input === "~") return homeDir; if (input.startsWith("~/")) return path.join(homeDir, input.slice(2)); @@ -61,13 +94,66 @@ function readNpmPrefixBinDirs(env: NodeJS.ProcessEnv): string[] { } } - return [...prefixes].map((prefix) => path.join(prefix, "bin")); + return uniqueNonEmpty( + [...prefixes].flatMap((prefix) => + process.platform === "win32" + ? [prefix, path.join(prefix, "bin")] + : [path.join(prefix, "bin")], + ), + ); } -function getKnownBinDirs( - command: string, - env: NodeJS.ProcessEnv, -): string[] { +function getWindowsKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string[] { + const homeDir = getHomeDir(env); + const localAppData = env.LOCALAPPDATA?.trim(); + const appData = env.APPDATA?.trim(); + const programFiles = env.ProgramFiles?.trim(); + const programFilesX86 = env["ProgramFiles(x86)"]?.trim(); + const programData = env.ProgramData?.trim(); + const scoop = env.SCOOP?.trim(); + const bunInstall = env.BUN_INSTALL?.trim(); + const voltaHome = env.VOLTA_HOME?.trim(); + const pnpmHome = env.PNPM_HOME?.trim(); + const asdfDataDir = env.ASDF_DATA_DIR?.trim(); + + return uniqueNonEmpty([ + appData ? path.join(appData, "npm") : "", + localAppData ? path.join(localAppData, "Programs", "cursor", "resources", "app", "bin") : "", + localAppData ? path.join(localAppData, "Programs", "Microsoft VS Code", "bin") : "", + localAppData ? path.join(localAppData, "Microsoft", "WinGet", "Links") : "", + programFiles ? path.join(programFiles, "cursor", "resources", "app", "bin") : "", + programFiles ? path.join(programFiles, "Microsoft VS Code", "bin") : "", + programFiles ? path.join(programFiles, "Git", "cmd") : "", + programFiles ? path.join(programFiles, "nodejs") : "", + programFilesX86 ? path.join(programFilesX86, "Microsoft VS Code", "bin") : "", + programData ? path.join(programData, "chocolatey", "bin") : "", + scoop ? path.join(scoop, "shims") : path.join(homeDir, "scoop", "shims"), + path.join(homeDir, ".local", "bin"), + path.join(homeDir, ".npm-global", "bin"), + path.join(homeDir, ".yarn", "bin"), + path.join(homeDir, ".config", "yarn", "global", "node_modules", ".bin"), + localAppData ? path.join(localAppData, "pnpm") : "", + path.join(homeDir, ".pnpm-global", "bin"), + path.join(homeDir, ".bun", "bin"), + path.join(homeDir, ".opencode", "bin"), + path.join(homeDir, ".volta", "bin"), + path.join(homeDir, ".asdf", "shims"), + path.join(homeDir, ".asdf", "bin"), + path.join(homeDir, ".nvm", "current", "bin"), + path.join(homeDir, ".mise", "shims"), + path.join(homeDir, ".mise", "bin"), + path.join(homeDir, "bin"), + bunInstall ? path.join(bunInstall, "bin") : "", + voltaHome ? path.join(voltaHome, "bin") : "", + pnpmHome || "", + asdfDataDir ? path.join(asdfDataDir, "shims") : "", + ...readNpmPrefixBinDirs(env), + command === "codex" && programFiles ? path.join(programFiles, "Codex") : "", + command === "codex" && localAppData ? path.join(localAppData, "Programs", "Codex") : "", + ]); +} + +function getUnixLikeKnownBinDirs(env: NodeJS.ProcessEnv, command: string): string[] { const homeDir = getHomeDir(env); const bunInstall = env.BUN_INSTALL?.trim(); const voltaHome = env.VOLTA_HOME?.trim(); @@ -81,21 +167,21 @@ function getKnownBinDirs( "/usr/local/sbin", "/usr/bin", "/bin", - `${homeDir}/.local/bin`, - `${homeDir}/.npm-global/bin`, - `${homeDir}/.yarn/bin`, - `${homeDir}/.config/yarn/global/node_modules/.bin`, - `${homeDir}/Library/pnpm`, - `${homeDir}/.pnpm-global/bin`, - `${homeDir}/.bun/bin`, - `${homeDir}/.opencode/bin`, - `${homeDir}/.volta/bin`, - `${homeDir}/.asdf/shims`, - `${homeDir}/.asdf/bin`, - `${homeDir}/.nvm/current/bin`, - `${homeDir}/.mise/shims`, - `${homeDir}/.mise/bin`, - `${homeDir}/bin`, + path.join(homeDir, ".local", "bin"), + path.join(homeDir, ".npm-global", "bin"), + path.join(homeDir, ".yarn", "bin"), + path.join(homeDir, ".config", "yarn", "global", "node_modules", ".bin"), + path.join(homeDir, "Library", "pnpm"), + path.join(homeDir, ".pnpm-global", "bin"), + path.join(homeDir, ".bun", "bin"), + path.join(homeDir, ".opencode", "bin"), + path.join(homeDir, ".volta", "bin"), + path.join(homeDir, ".asdf", "shims"), + path.join(homeDir, ".asdf", "bin"), + path.join(homeDir, ".nvm", "current", "bin"), + path.join(homeDir, ".mise", "shims"), + path.join(homeDir, ".mise", "bin"), + path.join(homeDir, "bin"), bunInstall ? path.join(bunInstall, "bin") : "", voltaHome ? path.join(voltaHome, "bin") : "", pnpmHome || "", @@ -105,6 +191,15 @@ function getKnownBinDirs( ]); } +function getKnownBinDirs( + command: string, + env: NodeJS.ProcessEnv, +): string[] { + return process.platform === "win32" + ? getWindowsKnownBinDirs(env, command) + : getUnixLikeKnownBinDirs(env, command); +} + function isExecutableFile(candidatePath: string): boolean { try { const stat = fs.statSync(candidatePath); @@ -121,6 +216,7 @@ function resolveFromDirs( ): string | null { const pathext = process.platform === "win32" ? uniqueNonEmpty((env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")) + .flatMap((ext) => [ext, ext.toLowerCase(), ext.toUpperCase()]) : []; const commandHasExtension = path.extname(command).length > 0; @@ -141,11 +237,11 @@ function resolveFromDirs( export function splitPathEntries(pathValue: string | undefined): string[] { if (!pathValue) return []; - return uniqueNonEmpty(pathValue.split(path.delimiter)); + return uniqueNonEmpty(pathValue.split(pathListDelimiter())); } export function mergePathEntries(...values: Array): string { - return uniqueNonEmpty(values.flatMap((value) => splitPathEntries(value ?? undefined))).join(path.delimiter); + return uniqueNonEmpty(values.flatMap((value) => splitPathEntries(value ?? undefined))).join(pathListDelimiter()); } export function augmentPathWithKnownCliDirs( @@ -154,10 +250,10 @@ export function augmentPathWithKnownCliDirs( ): string { return mergePathEntries( pathValue, - getKnownBinDirs("claude", env).join(path.delimiter), - getKnownBinDirs("codex", env).join(path.delimiter), - getKnownBinDirs("agent", env).join(path.delimiter), - getKnownBinDirs("opencode", env).join(path.delimiter), + getKnownBinDirs("claude", env).join(pathListDelimiter()), + getKnownBinDirs("codex", env).join(pathListDelimiter()), + getKnownBinDirs("agent", env).join(pathListDelimiter()), + getKnownBinDirs("opencode", env).join(pathListDelimiter()), ); } @@ -192,11 +288,18 @@ export function augmentProcessPathWithShellAndKnownCliDirs(args?: { includeInteractiveShell?: boolean; timeoutMs?: number; }): string { + const env = args?.env ?? process.env; + + if (process.platform === "win32") { + // Windows has no direct `sh -ic` equivalent here; includeInteractiveShell + // and timeoutMs are intentionally ignored in favor of env PATH + known CLI dirs. + return augmentPathWithKnownCliDirs(getPathEnvValue(env), env); + } + if (process.platform !== "darwin" && process.platform !== "linux") { - return args?.env?.PATH ?? process.env.PATH ?? ""; + return getPathEnvValue(env) ?? process.env.PATH ?? ""; } - const env = args?.env ?? process.env; const shellPath = env.SHELL?.trim() || "/bin/sh"; const timeoutMs = args?.timeoutMs ?? 1_000; const loginPath = readShellPath(shellPath, "-lc", timeoutMs, env); @@ -205,7 +308,7 @@ export function augmentProcessPathWithShellAndKnownCliDirs(args?: { : null; return augmentPathWithKnownCliDirs( - mergePathEntries(env.PATH, loginPath, interactivePath), + mergePathEntries(getPathEnvValue(env), loginPath, interactivePath), env, ); } @@ -214,7 +317,7 @@ export function resolveExecutableFromKnownLocations( command: string, env: NodeJS.ProcessEnv = process.env, ): ResolvedExecutable | null { - const fromPath = resolveFromDirs(command, splitPathEntries(env.PATH), env); + const fromPath = resolveFromDirs(command, splitPathEntries(getPathEnvValue(env)), env); if (fromPath) { return { path: fromPath, source: "path" }; } diff --git a/apps/desktop/src/main/services/ai/providerCredentialSources.ts b/apps/desktop/src/main/services/ai/providerCredentialSources.ts index dc3a412f2..f6f00da2b 100644 --- a/apps/desktop/src/main/services/ai/providerCredentialSources.ts +++ b/apps/desktop/src/main/services/ai/providerCredentialSources.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import type { Logger } from "../logging/logger"; import { isRecord, safeJsonParse } from "../shared/utils"; +import { killWindowsProcessTree } from "../shared/processExecution"; const CLAUDE_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token"; const CLAUDE_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; @@ -88,9 +89,13 @@ export function runShellCommand( timeoutMs: number, ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { return new Promise((resolve, reject) => { - const child = spawn("sh", ["-c", command], { + const useCmd = process.platform === "win32"; + const executable = useCmd ? (process.env.ComSpec?.trim() || "cmd.exe") : "sh"; + const args = useCmd ? ["/d", "/s", "/c", command] : ["-c", command]; + const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], env: process.env, + windowsVerbatimArguments: useCmd, }); let stdout = ""; @@ -104,7 +109,13 @@ export function runShellCommand( const timer = setTimeout(() => { try { - child.kill("SIGKILL"); + if (process.platform === "win32") { + killWindowsProcessTree(child.pid ?? 0, (detail) => { + console.warn("provider_credentials.taskkill_failed", detail); + }); + } else { + child.kill("SIGKILL"); + } } catch { // ignore } diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index 591ff5216..02cc996ff 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -13,6 +13,7 @@ import { resolveCodexExecutable } from "./codexExecutable"; import { resolveCursorAgentExecutable } from "./cursorAgentExecutable"; import { parseStructuredOutput } from "./utils"; import { runOpenCodeTextPrompt } from "../opencode/openCodeRuntime"; +import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; export type ProviderTaskRunnerArgs = { cwd: string; @@ -74,14 +75,17 @@ async function runCommand(args: { timeoutMs?: number; }): Promise { return await new Promise((resolve, reject) => { - const child = spawn(args.command, args.argv, { + const env = { + ...process.env, + NO_COLOR: "1", + TERM: "dumb", + }; + const invocation = resolveCliSpawnInvocation(args.command, args.argv, env); + const child = spawn(invocation.command, invocation.args, { cwd: args.cwd, - env: { - ...process.env, - NO_COLOR: "1", - TERM: "dumb", - }, + env, stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; @@ -91,7 +95,7 @@ async function runCommand(args: { const timeoutHandle = setTimeout(() => { if (settled) return; settled = true; - child.kill("SIGTERM"); + terminateProcessTree(child, "SIGTERM"); reject(new Error(`Provider task timed out after ${timeoutMs}ms.`)); }, timeoutMs); diff --git a/apps/desktop/src/main/services/ai/tools/globSearch.test.ts b/apps/desktop/src/main/services/ai/tools/globSearch.test.ts index 8d8e1a098..bdf82d7a4 100644 --- a/apps/desktop/src/main/services/ai/tools/globSearch.test.ts +++ b/apps/desktop/src/main/services/ai/tools/globSearch.test.ts @@ -65,6 +65,18 @@ describe("createGlobSearchTool", () => { expect(result.displayFiles).toContain("src/lib/deep/nested.ts"); }); + it("accepts Windows-style separators in glob patterns", async () => { + const cwd = makeTmpDir("glob-windows-pattern-"); + writeFixtureFile(cwd, "src/lib/helper.ts", "export {}"); + writeFixtureFile(cwd, "src/lib/helper.md", "# Hello"); + + const tool = createGlobSearchTool(cwd); + const result = await tool.execute({ pattern: "src\\**\\*.ts" }); + + expect(result.count).toBe(1); + expect(result.displayFiles).toEqual(["src/lib/helper.ts"]); + }); + it("handles brace expansion: src/**/*.{ts,tsx}", async () => { const cwd = makeTmpDir("glob-brace-"); writeFixtureFile(cwd, "src/App.tsx", "export {}"); diff --git a/apps/desktop/src/main/services/ai/tools/globSearch.ts b/apps/desktop/src/main/services/ai/tools/globSearch.ts index 40a13fd2f..68904c486 100644 --- a/apps/desktop/src/main/services/ai/tools/globSearch.ts +++ b/apps/desktop/src/main/services/ai/tools/globSearch.ts @@ -83,7 +83,7 @@ function walkAndMatch(root: string, globPattern: string, maxFiles = 5000): strin for (const entry of entries) { if (results.length >= maxFiles) return; const fullPath = path.join(dir, entry.name); - const relativePath = path.relative(root, fullPath); + const relativePath = path.relative(root, fullPath).replace(/\\/g, "/"); if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) { @@ -103,7 +103,7 @@ function walkAndMatch(root: string, globPattern: string, maxFiles = 5000): strin function globPatternToRegex(glob: string): RegExp { // Split into segments and process - const segments = glob.split("/"); + const segments = glob.replace(/\\/g, "/").split("/"); const regexParts: string[] = []; for (const seg of segments) { diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.test.ts b/apps/desktop/src/main/services/ai/tools/universalTools.test.ts index 127d92301..5e4b9d852 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.test.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { WorkerSandboxConfig } from "../../../../shared/types"; import { DEFAULT_WORKER_SANDBOX_CONFIG } from "../../orchestrator/orchestratorConstants"; -import { checkWorkerSandbox, createUniversalToolSet } from "./universalTools"; +import { checkWorkerSandbox, createUniversalToolSet, resolveWorkerShellInvocation } from "./universalTools"; + +const isWin = process.platform === "win32"; const tmpDirs: string[] = []; function makeTmpDir(prefix: string): string { @@ -144,12 +146,77 @@ describe("checkWorkerSandbox", () => { expect(result.allowed).toBe(true); }); + it("treats POSIX double-slash paths as POSIX paths", () => { + const result = checkWorkerSandbox( + "//mnt/shared/tool --version", + sandboxWith({ allowedPaths: ["/"] }), + "/tmp/project", + ); + + expect(result.allowed).toBe(true); + }); + it("rejects mutating writes into /usr/local/bin even under the default sandbox", () => { const result = checkWorkerSandbox("cp ./payload /usr/local/bin/tool", DEFAULT_WORKER_SANDBOX_CONFIG, "/tmp/project"); expect(result.allowed).toBe(false); expect(result.reason).toContain("Path outside sandbox"); }); + it("blocks Windows registry mutation commands", () => { + const result = checkWorkerSandbox( + "reg add HKCU\\Software\\Foo /v Bar /t REG_SZ /d 1", + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Blocked command pattern"); + }); + + it("blocks reg.exe mutation commands", () => { + const result = checkWorkerSandbox( + "reg.exe add HKCU\\Software\\Foo /v Bar /t REG_SZ /d 1", + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Blocked command pattern"); + }); + + it("blocks format.exe drive commands", () => { + const result = checkWorkerSandbox("format.exe c:", DEFAULT_WORKER_SANDBOX_CONFIG, "C:\\projects\\repo"); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Blocked command pattern"); + }); + + it("blocks Windows drive paths outside the sandbox", () => { + const result = checkWorkerSandbox( + "type C:\\Windows\\win.ini", + sandboxWith({ allowedPaths: ["./"] }), + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("blocks Windows copy commands that target protected files", () => { + const result = checkWorkerSandbox( + "copy foo .env", + sandboxWith({ protectedFiles: ["\\.env"] }), + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("protected file pattern"); + }); + + it("allows git.exe read-only subcommands like Unix git", () => { + const result = checkWorkerSandbox( + "git.exe status", + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(true); + }); + it("blocks commands that are not in the safe list when blockByDefault is enabled", () => { const config = sandboxWith({ blockByDefault: true, @@ -1129,17 +1196,31 @@ describe("createUniversalToolSet", () => { // ── bash tool ─────────────────────────────────────────────────── + it("resolveWorkerShellInvocation uses cmd on Windows and bash elsewhere", () => { + const inv = resolveWorkerShellInvocation("echo test"); + if (isWin) { + expect(inv.file.toLowerCase().endsWith("cmd.exe")).toBe(true); + expect(inv.args[0]).toBe("/d"); + expect(inv.args[1]).toBe("/s"); + expect(inv.args[2]).toBe("/c"); + expect(inv.args[3]).toBe("echo test"); + } else { + expect(inv.file).toBe("bash"); + expect(inv.args).toEqual(["-c", "echo test"]); + } + }); + it("executes a basic bash command and returns output", async () => { const cwd = makeTmpDir("ade-tools-bash-basic-"); const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); const result = await (tools.bash as any).execute({ - command: "echo hello from bash", + command: isWin ? "echo hello from worker-shell" : "echo hello from bash", timeout: 5_000, }); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("hello from bash"); + expect(result.stdout).toContain(isWin ? "hello from worker-shell" : "hello from bash"); }); it("returns nonzero exit code for failing commands", async () => { @@ -1147,7 +1228,7 @@ describe("createUniversalToolSet", () => { const tools = createUniversalToolSet(cwd, { permissionMode: "full-auto" }); const result = await (tools.bash as any).execute({ - command: "exit 42", + command: isWin ? "exit /b 42" : "exit 42", timeout: 5_000, }); diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.ts b/apps/desktop/src/main/services/ai/tools/universalTools.ts index 8829d8f3a..8bb862f02 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.ts @@ -15,6 +15,7 @@ import type { createMemoryService } from "../../memory/memoryService"; import type { AgentChatApprovalDecision, AgentChatEvent, WorkerSandboxConfig, CtoCoreMemory } from "../../../../shared/types"; import { DEFAULT_WORKER_SANDBOX_CONFIG } from "../../orchestrator/orchestratorConstants"; import { getErrorMessage, isEnoentError, isWithinDir, resolvePathWithinRoot } from "../../shared/utils"; +import { terminateProcessTree } from "../../shared/processExecution"; const execFileAsync = promisify(execFile); @@ -174,6 +175,7 @@ function compileSandbox(config: WorkerSandboxConfig): CompiledSandbox { const WRITE_COMMAND_RE = /(?:>|>>|\btee\b|\bcp\s|\bmv\s|\brm\s|\bwrite\b|\bedit\b)/; const MUTATING_BASH_RE = /\b(?:rm|mv|cp|mkdir|touch|chmod|chown|patch|install|uninstall|add|remove|upgrade|apply|commit|rebase|merge|reset|checkout|switch|restore|sed\s+-i|perl\s+-i)\b|>>?|tee\b/i; +const MUTATING_CMD_RE = /\b(?:copy|xcopy|robocopy|move|del|erase|rd|rmdir|md|mkdir|ren|rename)\b|>>?|tee\b/i; const MEMORY_GUARD_REASON = "Search memory before mutating files or running mutating commands for this turn."; @@ -189,22 +191,49 @@ function requiresTurnMemoryGuard(state?: TurnMemoryPolicyState): boolean { } function bashCommandLikelyMutates(command: string): boolean { - return MUTATING_BASH_RE.test(command) || WRITE_COMMAND_RE.test(command); + return MUTATING_BASH_RE.test(command) || MUTATING_CMD_RE.test(command) || WRITE_COMMAND_RE.test(command); } -function resolveAllowedWriteRoots(cwd: string, sandboxConfig?: WorkerSandboxConfig): string[] { - const roots = new Set([path.resolve(cwd)]); +function resolveAllowedWriteRoots(cwd: string, sandboxConfig?: WorkerSandboxConfig, pathApi: SandboxPathApi = getSandboxPathApi(cwd)): string[] { + const roots = new Set([pathApi.resolve(cwd)]); if (sandboxConfig?.allowedPaths) { for (const allowedPath of sandboxConfig.allowedPaths) { if (typeof allowedPath !== "string" || allowedPath.trim().length === 0) continue; - roots.add(path.resolve(cwd, allowedPath)); + roots.add(pathApi.resolve(cwd, allowedPath)); } } return [...roots]; } -function canonicalizePathForContainment(absPath: string): string { - const resolved = path.resolve(absPath); +type SandboxPathApi = typeof path | typeof path.win32; + +function isWindowsPathLike(value: string): boolean { + const trimmed = value.trim(); + return ( + /^[a-zA-Z]:$/.test(trimmed) || + /(?:^|[\s"'`(=])[a-zA-Z]:[\\/]/.test(value) || + trimmed.startsWith("\\\\") + ); +} + +function getSandboxPathApi(cwd: string, commandOrPath = ""): SandboxPathApi { + return process.platform === "win32" || isWindowsPathLike(cwd) || isWindowsPathLike(commandOrPath) + ? path.win32 + : path; +} + +function isWithinDirForPathApi(pathApi: SandboxPathApi, root: string, candidate: string): boolean { + const normalizedRoot = pathApi === path.win32 ? root.toLowerCase() : root; + const normalizedCandidate = pathApi === path.win32 ? candidate.toLowerCase() : candidate; + const rel = pathApi.relative(normalizedRoot, normalizedCandidate); + return rel === "" || (!rel.startsWith("..") && !pathApi.isAbsolute(rel)); +} + +function canonicalizePathForContainment(absPath: string, pathApi: SandboxPathApi = getSandboxPathApi(absPath)): string { + const resolved = pathApi.resolve(absPath); + if (pathApi === path.win32 && process.platform !== "win32") { + return resolved; + } try { return fs.realpathSync(resolved); } catch (error) { @@ -213,11 +242,11 @@ function canonicalizePathForContainment(absPath: string): string { } } - const parent = path.dirname(resolved); + const parent = pathApi.dirname(resolved); if (parent === resolved) { return resolved; } - return path.join(canonicalizePathForContainment(parent), path.basename(resolved)); + return pathApi.join(canonicalizePathForContainment(parent, pathApi), pathApi.basename(resolved)); } function toPortablePath(value: string): string { @@ -229,17 +258,18 @@ function matchesProtectedPathPattern( cwd: string, filePath: string, targetPath: string, + pathApi: SandboxPathApi = getSandboxPathApi(cwd, filePath), ): boolean { - const resolvedCwd = path.resolve(cwd); + const resolvedCwd = pathApi.resolve(cwd); const normalizedRaw = normalizePathToken(filePath); const normalizedTarget = toPortablePath(targetPath); - const relativeTarget = toPortablePath(path.relative(resolvedCwd, targetPath)); + const relativeTarget = toPortablePath(pathApi.relative(resolvedCwd, targetPath)); const candidates = new Set([ normalizedRaw, normalizedTarget, - path.basename(normalizedTarget), + pathApi.basename(targetPath), ]); - if (relativeTarget.length && !relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) { + if (relativeTarget.length && !relativeTarget.startsWith("..") && !pathApi.isAbsolute(relativeTarget)) { candidates.add(relativeTarget); } return [...candidates].some((candidate) => candidate.length > 0 && pattern.re.test(candidate)); @@ -250,13 +280,14 @@ function resolveWritableTargetPath( filePath: string, sandboxConfig?: WorkerSandboxConfig, ): { targetPath: string | null; error?: string } { - const targetPath = path.resolve(cwd, filePath); - const realCwd = canonicalizePathForContainment(cwd); - const realTargetPath = canonicalizePathForContainment(targetPath); - const allowedRoots = resolveAllowedWriteRoots(cwd, sandboxConfig).map((allowedRoot) => - canonicalizePathForContainment(allowedRoot), + const pathApi = getSandboxPathApi(cwd, filePath); + const targetPath = pathApi.resolve(cwd, filePath); + const realCwd = canonicalizePathForContainment(cwd, pathApi); + const realTargetPath = canonicalizePathForContainment(targetPath, pathApi); + const allowedRoots = resolveAllowedWriteRoots(cwd, sandboxConfig, pathApi).map((allowedRoot) => + canonicalizePathForContainment(allowedRoot, pathApi), ); - const withinAllowedRoots = allowedRoots.some((allowedRoot) => isWithinDir(allowedRoot, realTargetPath)); + const withinAllowedRoots = allowedRoots.some((allowedRoot) => isWithinDirForPathApi(pathApi, allowedRoot, realTargetPath)); if (!withinAllowedRoots) { return { targetPath: null, @@ -266,7 +297,7 @@ function resolveWritableTargetPath( if (sandboxConfig) { const protectedPatterns = compileSandbox(sandboxConfig).protected; const matchedPattern = protectedPatterns.find((pattern) => - matchesProtectedPathPattern(pattern, realCwd, filePath, realTargetPath), + matchesProtectedPathPattern(pattern, realCwd, filePath, realTargetPath, pathApi), ); if (matchedPattern) { return { @@ -282,19 +313,20 @@ function normalizePathToken(token: string): string { return token.trim().replace(/^[("'`]+/, "").replace(/[)"'`,;]+$/, ""); } -function tokenizeCommand(command: string): string[] { +function tokenizeCommand(command: string, windowsMode = process.platform === "win32"): string[] { const tokens: string[] = []; let current = ""; let quote: "'" | '"' | "`" | null = null; let escaped = false; - for (const ch of command) { + for (let i = 0; i < command.length; i += 1) { + const ch = command[i]!; if (escaped) { current += ch; escaped = false; continue; } - if (ch === "\\") { + if (!windowsMode && ch === "\\") { escaped = true; continue; } @@ -310,6 +342,20 @@ function tokenizeCommand(command: string): string[] { quote = ch; continue; } + if (ch === "&" || ch === "|" || (!windowsMode && ch === ";")) { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + const next = command[i + 1]; + if ((ch === "&" || ch === "|") && next === ch) { + tokens.push(`${ch}${ch}`); + i += 1; + continue; + } + tokens.push(ch); + continue; + } if (/\s/.test(ch)) { if (current.length > 0) { tokens.push(current); @@ -326,11 +372,18 @@ function tokenizeCommand(command: string): string[] { return tokens; } -function looksLikePathToken(value: string): boolean { +function looksLikePathToken(value: string, windowsMode = process.platform === "win32"): boolean { return ( value.startsWith(".") || value.startsWith("~") || - value.includes("/") + value.includes("/") || + (windowsMode && ( + /^[a-zA-Z]:(?:[\\/]|$)/.test(value) || + value.startsWith("\\\\") || + value.startsWith(".\\") || + value.startsWith("..\\") || + value.includes("\\") + )) ); } @@ -351,8 +404,9 @@ function splitCommandSegments(tokens: string[]): string[][] { return segments; } -function collectPathReferences(command: string, cwd: string): PathReference[] { +function collectPathReferences(command: string, cwd: string, pathApi: SandboxPathApi = getSandboxPathApi(cwd, command)): PathReference[] { const refs = new Map(); + const windowsMode = pathApi === path.win32; const accessPriority: Record = { unknown: 0, read: 1, @@ -368,9 +422,11 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { normalizedRaw === "~" ? os.homedir() : normalizedRaw.startsWith("~/") - ? path.join(os.homedir(), normalizedRaw.slice(2)) + ? pathApi.join(os.homedir(), normalizedRaw.slice(2)) + : windowsMode && normalizedRaw.startsWith("~\\") + ? pathApi.join(os.homedir(), normalizedRaw.slice(2)) : normalizedRaw; - const resolved = path.resolve(cwd, expandedPath); + const resolved = pathApi.resolve(cwd, expandedPath); const key = `${normalizedRaw}::${resolved}`; const existing = refs.get(key); if (!existing || accessPriority[access] > accessPriority[existing.access]) { @@ -378,15 +434,22 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { } }; - for (const token of tokenizeCommand(command)) { + for (const token of tokenizeCommand(command, windowsMode)) { const value = normalizePathToken(token); if (!value.length) continue; if (value.startsWith("-")) continue; if (value === "|" || value === "||" || value === "&&" || value === ";" || value === "&") continue; - if (value.includes("=") && !value.startsWith("./") && !value.startsWith("../") && !value.startsWith("/") && !value.startsWith(".")) { + if ( + value.includes("=") + && !value.startsWith("./") + && !value.startsWith("../") + && !value.startsWith("/") + && !value.startsWith(".") + && !(windowsMode && (value.startsWith(".\\") || value.startsWith("..\\"))) + ) { continue; } - if (looksLikePathToken(value)) { + if (looksLikePathToken(value, windowsMode)) { addPath(value, "unknown"); } } @@ -396,25 +459,38 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { } const markOperands = (commandName: string, args: string[]) => { - const normalizedCommand = path.basename(commandName).toLowerCase(); - const pathOperands = args + const normalizedCommand = pathApi.basename(commandName).toLowerCase().replace(/\.(?:exe|cmd|bat)$/i, ""); + const normalizedArgs = args .map((value) => normalizePathToken(value)) - .filter((value) => value.length > 0 && !value.startsWith("-") && looksLikePathToken(value)); + .filter((value) => value.length > 0 && !value.startsWith("-") && !(windowsMode && /^\/[a-z?]/i.test(value))); + const pathOperands = normalizedArgs.filter((value) => looksLikePathToken(value, windowsMode)); if (!pathOperands.length) return; switch (normalizedCommand) { case "cp": + case "copy": + case "xcopy": + case "robocopy": case "install": case "ln": { - if (pathOperands.length >= 2) { + const destination = [...normalizedArgs].reverse().find((value) => looksLikePathToken(value, windowsMode)); + if (destination) { pathOperands.slice(0, -1).forEach((value) => addPath(value, "read")); - addPath(pathOperands[pathOperands.length - 1]!, "write"); + addPath(destination, "write"); } return; } case "mv": + case "move": + case "ren": + case "rename": case "rm": + case "del": + case "erase": case "mkdir": + case "md": + case "rmdir": + case "rd": case "touch": case "chmod": case "chown": @@ -438,12 +514,12 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { } }; - for (const segment of splitCommandSegments(tokenizeCommand(command))) { + for (const segment of splitCommandSegments(tokenizeCommand(command, windowsMode))) { let commandIndex = 0; while ( commandIndex < segment.length && normalizePathToken(segment[commandIndex] ?? "").includes("=") - && !looksLikePathToken(normalizePathToken(segment[commandIndex] ?? "")) + && !looksLikePathToken(normalizePathToken(segment[commandIndex] ?? ""), windowsMode) ) { commandIndex += 1; } @@ -457,6 +533,26 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { return [...refs.values()]; } +function isNullDevicePath(resolved: string): boolean { + if (resolved === "/dev/null") return true; + if (process.platform === "win32") { + const norm = resolved.replace(/\\/g, "/").toLowerCase(); + return norm === "nul" || norm.endsWith("/nul"); + } + return false; +} + +function isSystemExecutableSandboxPath(resolved: string): boolean { + const norm = resolved.replace(/\\/g, "/"); + if (process.platform !== "win32") { + return norm.startsWith("/usr/bin/") || norm.startsWith("/usr/local/bin/"); + } + const lower = norm.toLowerCase(); + const systemRoot = (process.env.SystemRoot || process.env.windir || "C:\\Windows").replace(/\\/g, "/"); + const sr = systemRoot.toLowerCase(); + return lower.startsWith(`${sr}/system32/`) || lower.startsWith(`${sr}/syswow64/`); +} + /** * Check a bash command against the worker sandbox config. * Returns { allowed: true } or { allowed: false, reason: string }. @@ -467,6 +563,7 @@ export function checkWorkerSandbox( projectRoot: string, ): { allowed: boolean; reason?: string } { const compiled = compileSandbox(config); + const pathApi = getSandboxPathApi(projectRoot, command); // 1. Check blocked patterns first (always reject) for (const { re, src } of compiled.blocked) { @@ -479,20 +576,20 @@ export function checkWorkerSandbox( const commandMutates = bashCommandLikelyMutates(command); // 2. Validate file paths against allowedPaths (absolute + relative) - const rootResolved = canonicalizePathForContainment(projectRoot); - const pathRefs = collectPathReferences(command, projectRoot); + const rootResolved = canonicalizePathForContainment(projectRoot, pathApi); + const pathRefs = collectPathReferences(command, projectRoot, pathApi); for (const entry of pathRefs) { const p = entry.raw; - const resolved = canonicalizePathForContainment(entry.resolved); - const isSystemExecutablePath = resolved.startsWith("/usr/bin/") || resolved.startsWith("/usr/local/bin/"); - if (resolved === "/dev/null") continue; + const resolved = canonicalizePathForContainment(entry.resolved, pathApi); + const isSystemExecutablePath = isSystemExecutableSandboxPath(resolved); + if (isNullDevicePath(resolved)) continue; if (isSystemExecutablePath && (entry.access === "read" || (!commandMutates && entry.access !== "write"))) continue; const withinAllowed = config.allowedPaths.some((allowed) => { - const allowedAbs = canonicalizePathForContainment(path.resolve(projectRoot, allowed)); - return isWithinDir(allowedAbs, resolved); + const allowedAbs = canonicalizePathForContainment(pathApi.resolve(projectRoot, allowed), pathApi); + return isWithinDirForPathApi(pathApi, allowedAbs, resolved); }); - if (!withinAllowed && !isWithinDir(rootResolved, resolved)) { + if (!withinAllowed && !isWithinDirForPathApi(pathApi, rootResolved, resolved)) { return { allowed: false, reason: `Path outside sandbox: ${p}` }; } } @@ -504,7 +601,7 @@ export function checkWorkerSandbox( if (re.test(command)) { return { allowed: false, reason: `Command targets protected file pattern: ${src}` }; } - const targetsProtectedPath = protectedRefs.some((entry) => matchesProtectedPathPattern({ re, src }, projectRoot, entry.raw, entry.resolved)); + const targetsProtectedPath = protectedRefs.some((entry) => matchesProtectedPathPattern({ re, src }, projectRoot, entry.raw, entry.resolved, pathApi)); if (targetsProtectedPath) { return { allowed: false, reason: `Command targets protected file pattern: ${src}` }; } @@ -526,6 +623,15 @@ export function checkWorkerSandbox( // ── New tool implementations ──────────────────────────────────────── +/** Spawn argv for worker shell execution (bash on Unix, cmd via ComSpec on Windows). */ +export function resolveWorkerShellInvocation(command: string): { file: string; args: string[] } { + if (process.platform === "win32") { + const comSpec = process.env.ComSpec?.trim() || "cmd.exe"; + return { file: comSpec, args: ["/d", "/s", "/c", command] }; + } + return { file: "bash", args: ["-c", command] }; +} + function createBashTool( cwd: string, mode: PermissionMode, @@ -536,7 +642,9 @@ function createBashTool( return tool({ description: "Execute a shell command and return stdout/stderr. " + - "Commands run in a non-interactive shell with a 120-second timeout.", + (process.platform === "win32" + ? "On Windows, commands run via cmd.exe (ComSpec) with a 120-second timeout; on macOS/Linux they run in bash. " + : "Commands run in a non-interactive bash shell with a 120-second timeout. "), inputSchema: z.object({ command: z.string().describe("The shell command to execute"), timeout: z @@ -582,15 +690,25 @@ function createBashTool( } } const clampedTimeout = Math.min(timeout, 600_000); + const killProc = (proc: ReturnType): void => { + terminateProcessTree(proc, "SIGTERM"); + }; try { const result = await new Promise<{ stdout: string; stderr: string; exitCode: number }>( (resolve, reject) => { - const proc = spawn("bash", ["-c", command], { + const { file, args } = resolveWorkerShellInvocation(command); + const proc = spawn(file, args, { cwd, - timeout: clampedTimeout, stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, TERM: "dumb" }, + windowsVerbatimArguments: process.platform === "win32", + env: + process.platform === "win32" + ? { ...process.env } + : { ...process.env, TERM: "dumb" }, }); + const timeoutId = setTimeout(() => { + killProc(proc); + }, clampedTimeout); let stdout = ""; let stderr = ""; @@ -599,24 +717,28 @@ function createBashTool( stdout += d.toString(); // Cap output at 1MB if (stdout.length > 1_000_000) { - proc.kill("SIGTERM"); + killProc(proc); } }); proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); if (stderr.length > 1_000_000) { - proc.kill("SIGTERM"); + killProc(proc); } }); proc.on("close", (code) => { + clearTimeout(timeoutId); resolve({ stdout: stdout.slice(0, 200_000), stderr: stderr.slice(0, 50_000), exitCode: code ?? 1, }); }); - proc.on("error", reject); + proc.on("error", (error) => { + clearTimeout(timeoutId); + reject(error); + }); } ); return result; diff --git a/apps/desktop/src/main/services/ai/tools/workflowTools.ts b/apps/desktop/src/main/services/ai/tools/workflowTools.ts index b6046261b..f4fb2cd20 100644 --- a/apps/desktop/src/main/services/ai/tools/workflowTools.ts +++ b/apps/desktop/src/main/services/ai/tools/workflowTools.ts @@ -12,6 +12,7 @@ import fs from "node:fs"; import type { createLaneService } from "../../lanes/laneService"; import type { createPrService } from "../../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../../computerUse/computerUseArtifactBrokerService"; +import { getLocalComputerUseCapabilities } from "../../computerUse/localComputerUse"; import { nowIso } from "../../shared/utils"; import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; import type { AgentChatCompletionReport } from "../../../../shared/types"; @@ -157,11 +158,20 @@ export function createWorkflowTools( execute: async ({ title, description }) => { let tmpDir: string | null = null; try { + const capabilities = getLocalComputerUseCapabilities(); + if (!capabilities.screenshot.available) { + return { + success: false, + error: capabilities.screenshot.detail, + blocked: capabilities.screenshot.state, + platform: capabilities.platform, + }; + } + tmpDir = fs.mkdtempSync(path.join(require("node:os").tmpdir(), "ade-screenshot-")); const tmpPath = path.join(tmpDir, `screenshot-${Date.now()}.png`); - // Use macOS screencapture to grab the screen - await execFileAsync("screencapture", ["-x", tmpPath], { + await execFileAsync(capabilities.screenshot.command ?? "screencapture", ["-x", tmpPath], { timeout: 15_000, }); diff --git a/apps/desktop/src/main/services/automations/automationPlannerService.ts b/apps/desktop/src/main/services/automations/automationPlannerService.ts index eb5c32ab6..1178b72d3 100644 --- a/apps/desktop/src/main/services/automations/automationPlannerService.ts +++ b/apps/desktop/src/main/services/automations/automationPlannerService.ts @@ -31,6 +31,7 @@ import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createProjectConfigService } from "../config/projectConfigService"; import type { createLaneService } from "../lanes/laneService"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; import { getErrorMessage, quoteIfNeeded, resolvePathWithinRoot } from "../shared/utils"; function resolveAutomationCwdBase( @@ -388,15 +389,18 @@ async function runCodexExec(args: { } const commandPreview = [quoteIfNeeded(codexExecutable), ...cliArgs.map(quoteIfNeeded)].join(" "); - const child = spawn(codexExecutable, cliArgs, { + const env = { + ...process.env, + // Keep output parseable. + NO_COLOR: "1", + TERM: "dumb" + }; + const invocation = resolveCliSpawnInvocation(codexExecutable, cliArgs, env); + const child = spawn(invocation.command, invocation.args, { cwd: args.cwd, - env: { - ...process.env, - // Keep output parseable. - NO_COLOR: "1", - TERM: "dumb" - }, - stdio: ["ignore", "pipe", "pipe"] + env, + stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stderr = ""; @@ -469,14 +473,17 @@ async function runClaudeHeadless(args: { const claudeExecutable = resolveClaudeCodeExecutable().path; const commandPreview = [quoteIfNeeded(claudeExecutable), ...cliArgs.map(quoteIfNeeded)].join(" "); - const child = spawn(claudeExecutable, cliArgs, { + const env = { + ...process.env, + NO_COLOR: "1", + TERM: "dumb" + }; + const invocation = resolveCliSpawnInvocation(claudeExecutable, cliArgs, env); + const child = spawn(invocation.command, invocation.args, { cwd: args.cwd, - env: { - ...process.env, - NO_COLOR: "1", - TERM: "dumb" - }, - stdio: ["ignore", "pipe", "pipe"] + env, + stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/automations/automationService.ts b/apps/desktop/src/main/services/automations/automationService.ts index 136289d92..d9d71cc27 100644 --- a/apps/desktop/src/main/services/automations/automationService.ts +++ b/apps/desktop/src/main/services/automations/automationService.ts @@ -1324,7 +1324,9 @@ export function createAutomationService({ const runCommand = async (args: { command: string; cwd: string; timeoutMs: number }): Promise<{ output: string; exitCode: number | null }> => { const startedAt = Date.now(); - const child = spawn(process.platform === "win32" ? "cmd.exe" : "sh", process.platform === "win32" ? ["/c", args.command] : ["-lc", args.command], { + const shellFile = process.platform === "win32" ? (process.env.ComSpec?.trim() || "cmd.exe") : "sh"; + const shellArgs = process.platform === "win32" ? ["/d", "/s", "/c", args.command] : ["-lc", args.command]; + const child = spawn(shellFile, shellArgs, { cwd: args.cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"] diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 8b3a9caef..e8317b3e6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -61,6 +61,10 @@ import { readFileWithinRootSecure, resolvePathWithinRoot, } from "../shared/utils"; +import { + resolveCliSpawnInvocation, + terminateProcessTree, +} from "../shared/processExecution"; import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import { DEFAULT_FLUSH_PROMPT } from "../memory/compactionFlushPrompt"; import type { @@ -635,8 +639,12 @@ function signalChildProcessTree( child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals, ): boolean { + if (process.platform === "win32") { + return terminateProcessTree(child, signal); + } + const pid = child.pid ?? null; - if (process.platform !== "win32" && pid != null && Number.isInteger(pid) && pid > 0) { + if (pid != null && Number.isInteger(pid) && pid > 0) { try { process.kill(-pid, signal); return true; @@ -683,12 +691,12 @@ function terminateChildProcessTree( } const timer = setTimeout(() => { - if (process.platform !== "win32") { - if (!isProcessGroupAlive(pid)) return; + if (process.platform === "win32") { + if (!isProcessAlive(pid)) return; signalChildProcessTree(child, "SIGKILL"); return; } - if (!isProcessAlive(pid)) return; + if (!isProcessGroupAlive(pid)) return; signalChildProcessTree(child, "SIGKILL"); }, killAfterMs); timer.unref?.(); @@ -9433,11 +9441,13 @@ export function createAgentChatService(args: { }); throw error; } - const proc = spawn(codexExecutable, ["app-server"], { + const invocation = resolveCliSpawnInvocation(codexExecutable, ["app-server"]); + const proc = spawn(invocation.command, invocation.args, { cwd: managed.laneWorktreePath, env: spawnEnv, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); const reader = readline.createInterface({ input: proc.stdout }); diff --git a/apps/desktop/src/main/services/chat/cursorAcpPool.ts b/apps/desktop/src/main/services/chat/cursorAcpPool.ts index fd8e9307e..0d6d66b63 100644 --- a/apps/desktop/src/main/services/chat/cursorAcpPool.ts +++ b/apps/desktop/src/main/services/chat/cursorAcpPool.ts @@ -23,6 +23,7 @@ import { type WriteTextFileResponse, } from "@agentclientprotocol/sdk"; import { hasNullByte, readFileWithinRootSecure, secureWriteTextAtomicWithinRoot } from "../shared/utils"; +import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; export type CursorAcpBridge = { onPermission: ((req: RequestPermissionRequest) => Promise) | null; @@ -136,11 +137,13 @@ function createCursorAcpClient(bridge: CursorAcpBridge, terminals: Map 0 ? params.outputByteLimit : 512 * 1024; - const proc = spawn(params.command, params.args ?? [], { + const env = mergeEnvVars(process.env, params.env ?? undefined); + const invocation = resolveCliSpawnInvocation(params.command, params.args ?? [], env); + const proc = spawn(invocation.command, invocation.args, { cwd, - env: mergeEnvVars(process.env, params.env ?? undefined), - shell: process.platform === "win32", + env, stdio: ["pipe", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); proc.on("error", (err) => { console.error(`[CursorAcpPool] terminal process error for termId=${termId}:`, err); @@ -210,22 +213,14 @@ function createCursorAcpClient(bridge: CursorAcpBridge, terminals: Map { const t = terminals.get(params.terminalId); if (t && !t.exited) { - try { - t.proc.kill("SIGTERM"); - } catch { - // ignore - } + terminateProcessTree(t.proc, "SIGTERM"); } }, async releaseTerminal(params: ReleaseTerminalRequest): Promise { const t = terminals.get(params.terminalId); if (t) { - try { - if (!t.exited) t.proc.kill("SIGKILL"); - } catch { - // ignore - } + if (!t.exited) terminateProcessTree(t.proc, "SIGKILL"); const id = params.terminalId; terminals.delete(id); bridge.onTerminalDisposed?.(id); @@ -300,11 +295,14 @@ export async function acquireCursorAcpConnection(args: { spawnArgs.push("--api-key", apiKey); } - const proc = spawn(args.agentPath, spawnArgs, { + const env = { ...process.env }; + const invocation = resolveCliSpawnInvocation(args.agentPath, spawnArgs, env); + const proc = spawn(invocation.command, invocation.args, { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env }, + env, cwd: args.workspacePath, detached: process.platform !== "win32", + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); proc.on("error", (err) => { @@ -364,18 +362,10 @@ export async function acquireCursorAcpConnection(args: { bridge.onTerminalDisposed?.(termId); } for (const t of terminals.values()) { - try { - if (!t.exited) t.proc.kill("SIGKILL"); - } catch { - // ignore - } + if (!t.exited) terminateProcessTree(t.proc, "SIGKILL"); } terminals.clear(); - try { - proc.kill("SIGTERM"); - } catch { - // ignore - } + terminateProcessTree(proc, "SIGTERM"); }, }; diff --git a/apps/desktop/src/main/services/cli/adeCliService.test.ts b/apps/desktop/src/main/services/cli/adeCliService.test.ts index 8ab31991b..de26ff570 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.test.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.test.ts @@ -5,6 +5,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createAdeCliService } from "./adeCliService"; const tmpRoots: string[] = []; +const originalPlatform = process.platform; +const originalLocalAppData = process.env.LOCALAPPDATA; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} function makeTempRoot(): string { const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-service-")); @@ -29,6 +38,9 @@ function logger() { afterEach(() => { vi.restoreAllMocks(); + setPlatform(originalPlatform); + if (originalLocalAppData === undefined) delete process.env.LOCALAPPDATA; + else process.env.LOCALAPPDATA = originalLocalAppData; for (const root of tmpRoots.splice(0)) { fs.rmSync(root, { recursive: true, force: true }); } @@ -62,6 +74,45 @@ describe("createAdeCliService", () => { expect(service.agentEnv({ PATH: "/usr/bin:/bin" }).PATH?.split(path.delimiter)[0]).toBe(packagedBinDir); }); + it("uses packaged Windows cmd wrappers and Path casing", async () => { + setPlatform("win32"); + const root = makeTempRoot(); + process.env.LOCALAPPDATA = path.join(root, "LocalAppData"); + const resourcesPath = path.join(root, "resources"); + const packagedBinDir = path.join(resourcesPath, "ade-cli", "bin"); + const packagedCommandPath = path.join(packagedBinDir, "ade.cmd"); + writeExecutable(packagedCommandPath, "@echo off\r\nexit /b 0\r\n"); + writeExecutable(path.join(resourcesPath, "ade-cli", "install-path.cmd"), "@echo off\r\nexit /b 0\r\n"); + fs.writeFileSync(path.join(resourcesPath, "ade-cli", "cli.cjs"), "console.log('ade')\n"); + + const service = createAdeCliService({ + isPackaged: true, + resourcesPath, + userDataPath: path.join(root, "user-data"), + appExecutablePath: path.join(root, "ADE.exe"), + env: { + Path: `${packagedBinDir};C:\\Windows\\System32`, + PATHEXT: ".EXE;.CMD", + }, + logger: logger() as any, + }); + + expect(service.resolved).toEqual({ + source: "packaged", + binDir: packagedBinDir, + commandPath: packagedCommandPath, + installerPath: path.join(resourcesPath, "ade-cli", "install-path.cmd"), + cliJsPath: path.join(resourcesPath, "ade-cli", "cli.cjs"), + }); + expect(service.agentEnv({ Path: "C:\\Windows\\System32" }).Path?.split(";")[0]).toBe(packagedBinDir); + expect(service.agentEnv({ Path: "C:\\Windows\\System32" }).PATH).toBeUndefined(); + + const status = await service.getStatus(); + expect(status.terminalInstalled).toBe(true); + expect(status.terminalCommandPath?.toLowerCase()).toBe(packagedCommandPath.toLowerCase()); + expect(status.installTargetPath.endsWith(path.join("ADE", "bin", "ade.cmd"))).toBe(true); + }); + it("reports Terminal install status from the original host PATH after agent PATH is applied", async () => { const root = makeTempRoot(); const resourcesPath = path.join(root, "Resources"); @@ -156,6 +207,39 @@ describe("createAdeCliService", () => { expect(service.agentEnv({ PATH: "/usr/bin:/bin" }).PATH?.split(path.delimiter)[0]).toBe(path.dirname(shimPath)); }); + it("creates a Windows dev cmd shim under userData", () => { + setPlatform("win32"); + const root = makeTempRoot(); + const repoRoot = path.join(root, "repo"); + const userDataPath = path.join(root, "user-data"); + const cliJsPath = path.join(repoRoot, "apps", "ade-cli", "dist", "cli.cjs"); + fs.mkdirSync(path.dirname(cliJsPath), { recursive: true }); + fs.writeFileSync(cliJsPath, "console.log('ade')\n"); + fs.mkdirSync(path.join(repoRoot, "apps", "desktop"), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, "apps", "ade-cli", "package.json"), "{}\n"); + fs.writeFileSync(path.join(repoRoot, "apps", "desktop", "package.json"), "{}\n"); + vi.spyOn(process, "cwd").mockReturnValue(repoRoot); + + const service = createAdeCliService({ + isPackaged: false, + resourcesPath: path.join(root, "missing-resources"), + userDataPath, + appExecutablePath: path.join(root, "ADE.exe"), + logger: logger() as any, + }); + + const shimPath = path.join(userDataPath, "ade-cli", "bin", "ade.cmd"); + const script = fs.readFileSync(shimPath, "utf8"); + + expect(service.resolved.source).toBe("dev"); + expect(service.resolved.commandPath).toBe(shimPath); + expect(script).toContain("@echo off"); + expect(script).toContain("set \"APP_EXE="); + expect(script).toContain("\"%APP_EXE%\" \"%CLI_JS%\" %*"); + expect(script).toContain(path.join("node_modules", ".bin", "tsx.cmd")); + expect(service.agentEnv({ Path: "C:\\Windows\\System32" }).Path?.split(";")[0]).toBe(path.dirname(shimPath)); + }); + it("falls back to source CLI when dist/cli.cjs is missing in a dev repo", () => { const root = makeTempRoot(); const repoRoot = path.join(root, "repo"); diff --git a/apps/desktop/src/main/services/cli/adeCliService.ts b/apps/desktop/src/main/services/cli/adeCliService.ts index b0b752668..7543d6625 100644 --- a/apps/desktop/src/main/services/cli/adeCliService.ts +++ b/apps/desktop/src/main/services/cli/adeCliService.ts @@ -35,9 +35,46 @@ function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } +function pathDelimiter(): string { + return process.platform === "win32" ? ";" : PATH_DELIMITER; +} + +function commandFileName(): "ade" | "ade.cmd" { + return process.platform === "win32" ? "ade.cmd" : "ade"; +} + +function installerFileName(): "install-path.sh" | "install-path.cmd" { + return process.platform === "win32" ? "install-path.cmd" : "install-path.sh"; +} + +function findPathEnvKey(env: NodeJS.ProcessEnv): string { + if (process.platform !== "win32") return "PATH"; + const existing = Object.keys(env).find((key) => key.toLowerCase() === "path"); + return existing ?? "Path"; +} + +function getPathEnvValue(env: NodeJS.ProcessEnv): string | undefined { + return env[findPathEnvKey(env)]; +} + +function setPathEnvValue(env: NodeJS.ProcessEnv, value: string): void { + const key = findPathEnvKey(env); + if (process.platform === "win32") { + for (const existing of Object.keys(env)) { + if (existing.toLowerCase() === "path" && existing !== key) { + delete env[existing]; + } + } + } + env[key] = value; +} + function isExecutable(filePath: string | null | undefined): boolean { if (!filePath) return false; try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) return false; + if (process.platform === "win32") return true; fs.accessSync(filePath, fs.constants.X_OK); return true; } catch { @@ -46,15 +83,16 @@ function isExecutable(filePath: string | null | undefined): boolean { } function splitPathEntries(value: string | null | undefined): string[] { - return (value ?? "").split(PATH_DELIMITER).map((entry) => entry.trim()).filter(Boolean); + return (value ?? "").split(pathDelimiter()).map((entry) => entry.trim()).filter(Boolean); } function pathContainsDir(pathValue: string | null | undefined, dir: string | null): boolean { if (!dir) return false; - const resolved = path.resolve(dir); + const resolved = process.platform === "win32" ? path.resolve(dir).toLowerCase() : path.resolve(dir); return splitPathEntries(pathValue).some((entry) => { try { - return path.resolve(entry) === resolved; + const candidate = process.platform === "win32" ? path.resolve(entry).toLowerCase() : path.resolve(entry); + return candidate === resolved; } catch { return false; } @@ -65,15 +103,19 @@ function prependPathDir(pathValue: string | null | undefined, dir: string | null if (!dir) return pathValue ?? undefined; if (pathContainsDir(pathValue, dir)) return pathValue ?? undefined; const current = pathValue?.trim(); - return current ? `${dir}${PATH_DELIMITER}${current}` : dir; + return current ? `${dir}${pathDelimiter()}${current}` : dir; } -function resolveCommandOnPath(command: string, pathValue: string | null | undefined): string | null { - const extensions = process.platform === "win32" - ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean) +function resolveCommandOnPath(command: string, pathValue: string | null | undefined, env: NodeJS.ProcessEnv = process.env): string | null { + const rawExtensions = process.platform === "win32" + ? (env.PATHEXT ?? env.Pathext ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""]; + const extensions = process.platform === "win32" + ? Array.from(new Set(rawExtensions.flatMap((ext) => [ext, ext.toLowerCase(), ext.toUpperCase()]))) + : rawExtensions; + const suffixes = process.platform === "win32" && path.extname(command) ? [""] : extensions; for (const entry of splitPathEntries(pathValue)) { - for (const ext of extensions) { + for (const ext of suffixes) { const candidate = path.join(entry, `${command}${ext}`); if (isExecutable(candidate)) return candidate; } @@ -81,6 +123,77 @@ function resolveCommandOnPath(command: string, pathValue: string | null | undefi return null; } +function escapeCmdSetValue(value: string): string { + return value.replace(/%/g, "%%").replace(/"/g, "\"\""); +} + +function createWindowsShimScript(args: { + cliJsPath: string; + entryKind: "built" | "source"; + tsxBinPath: string | null; + tsxImportPath: string | null; + appExecutablePath: string; +}): string { + return [ + "@echo off", + "setlocal", + `set "CLI_JS=${escapeCmdSetValue(args.cliJsPath)}"`, + `set "CLI_ENTRY_KIND=${escapeCmdSetValue(args.entryKind)}"`, + `set "TSX_BIN=${escapeCmdSetValue(args.tsxBinPath ?? "")}"`, + `set "TSX_IMPORT=${escapeCmdSetValue(args.tsxImportPath ?? "")}"`, + `set "APP_EXE=${escapeCmdSetValue(args.appExecutablePath)}"`, + "if /I \"%CLI_ENTRY_KIND%\"==\"source\" (", + " if exist \"%TSX_BIN%\" (", + " \"%TSX_BIN%\" \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + " )", + " if not exist \"%TSX_IMPORT%\" (", + " echo ade: Local source CLI fallback requires repo-local tsx. Run npm --prefix apps/ade-cli install or npm --prefix apps/ade-cli run build. 1>&2", + " exit /b 127", + " )", + " if defined ADE_CLI_NODE (", + " \"%ADE_CLI_NODE%\" --import \"%TSX_IMPORT%\" \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + " )", + " if exist \"%APP_EXE%\" (", + " set \"ELECTRON_RUN_AS_NODE=1\"", + " \"%APP_EXE%\" --import \"%TSX_IMPORT%\" \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + " )", + " where node >nul 2>nul", + " if not errorlevel 1 (", + " node -e \"process.exit(Number(process.versions.node.split('.')[0]) >= 22 ? 0 : 1)\" >nul 2>nul", + " if not errorlevel 1 (", + " node --import \"%TSX_IMPORT%\" \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + " )", + " )", + " echo ade: Node.js 22+ or the ADE Electron runtime is required to run this source CLI. 1>&2", + " exit /b 127", + ")", + "if defined ADE_CLI_NODE (", + " \"%ADE_CLI_NODE%\" \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + ")", + "if exist \"%APP_EXE%\" (", + " set \"ELECTRON_RUN_AS_NODE=1\"", + " \"%APP_EXE%\" \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + ")", + "where node >nul 2>nul", + "if not errorlevel 1 (", + " node -e \"process.exit(Number(process.versions.node.split('.')[0]) >= 22 ? 0 : 1)\" >nul 2>nul", + " if not errorlevel 1 (", + " node \"%CLI_JS%\" %*", + " exit /b %ERRORLEVEL%", + " )", + ")", + "echo ade: Node.js 22+ or the ADE Electron runtime is required to run this CLI. 1>&2", + "exit /b 127", + "", + ].join("\r\n"); +} + function findRepoRoot(startDir: string): string | null { let cursor = path.resolve(startDir); while (true) { @@ -182,8 +295,8 @@ function writeDevShim(args: { logger: Logger; }): { commandPath: string; binDir: string } | null { const binDir = path.join(args.userDataPath, "ade-cli", "bin"); - const commandPath = path.join(binDir, "ade"); - const script = [ + const commandPath = path.join(binDir, commandFileName()); + const script = process.platform === "win32" ? createWindowsShimScript(args) : [ "#!/bin/sh", "set -eu", `CLI_JS=${shellQuote(args.cliJsPath)}`, @@ -239,8 +352,10 @@ function writeDevShim(args: { try { fs.mkdirSync(binDir, { recursive: true }); - fs.writeFileSync(commandPath, script, { encoding: "utf8", mode: 0o755 }); - fs.chmodSync(commandPath, 0o755); + fs.writeFileSync(commandPath, script, process.platform === "win32" + ? { encoding: "utf8" } + : { encoding: "utf8", mode: 0o755 }); + if (process.platform !== "win32") fs.chmodSync(commandPath, 0o755); return { commandPath, binDir }; } catch (error) { args.logger.warn("ade_cli.dev_shim_failed", { @@ -254,9 +369,9 @@ function writeDevShim(args: { function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { const resourcesPath = args.resourcesPath ? path.resolve(args.resourcesPath) : null; const packagedBinDir = resourcesPath ? path.join(resourcesPath, "ade-cli", "bin") : null; - const packagedCommandPath = packagedBinDir ? path.join(packagedBinDir, "ade") : null; + const packagedCommandPath = packagedBinDir ? path.join(packagedBinDir, commandFileName()) : null; const packagedCliJsPath = resourcesPath ? path.join(resourcesPath, "ade-cli", "cli.cjs") : null; - const packagedInstallerPath = resourcesPath ? path.join(resourcesPath, "ade-cli", "install-path.sh") : null; + const packagedInstallerPath = resourcesPath ? path.join(resourcesPath, "ade-cli", installerFileName()) : null; if (args.isPackaged && isExecutable(packagedCommandPath)) { return { @@ -273,7 +388,7 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { const shim = writeDevShim({ cliJsPath: devCli.cliPath, entryKind: devCli.entryKind, - tsxBinPath: path.join(devCli.repoRoot, "apps", "ade-cli", "node_modules", ".bin", "tsx"), + tsxBinPath: path.join(devCli.repoRoot, "apps", "ade-cli", "node_modules", ".bin", process.platform === "win32" ? "tsx.cmd" : "tsx"), tsxImportPath: path.join(devCli.repoRoot, "apps", "ade-cli", "node_modules", "tsx", "dist", "loader.mjs"), userDataPath: args.userDataPath, appExecutablePath: args.appExecutablePath, @@ -300,6 +415,10 @@ function resolveCliPaths(args: CreateAdeCliServiceArgs): ResolvedCliPaths { } function installTargetPath(): string { + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA?.trim() || path.join(os.homedir(), "AppData", "Local"); + return path.join(localAppData, "ADE", "bin", "ade.cmd"); + } return path.join(os.homedir(), ".local", "bin", "ade"); } @@ -342,12 +461,13 @@ function statusMessage(args: { export function createAdeCliService(args: CreateAdeCliServiceArgs) { const resolved = resolveCliPaths(args); - const hostPathSnapshot = args.env?.PATH ?? process.env.PATH; + const envSnapshot = args.env ?? process.env; + const hostPathSnapshot = getPathEnvValue(envSnapshot); const agentEnv = (baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv => { const next: NodeJS.ProcessEnv = { ...baseEnv }; - const nextPath = prependPathDir(next.PATH, resolved.binDir); - if (nextPath) next.PATH = nextPath; + const nextPath = prependPathDir(getPathEnvValue(next), resolved.binDir); + if (nextPath) setPathEnvValue(next, nextPath); if (resolved.commandPath) next.ADE_CLI_PATH = resolved.commandPath; if (resolved.binDir) next.ADE_CLI_BIN_DIR = resolved.binDir; return next; @@ -355,18 +475,21 @@ export function createAdeCliService(args: CreateAdeCliServiceArgs) { const applyToProcessEnv = (): void => { const next = agentEnv(process.env); - process.env.PATH = next.PATH; + const nextPath = getPathEnvValue(next); + if (nextPath) setPathEnvValue(process.env, nextPath); if (next.ADE_CLI_PATH) process.env.ADE_CLI_PATH = next.ADE_CLI_PATH; if (next.ADE_CLI_BIN_DIR) process.env.ADE_CLI_BIN_DIR = next.ADE_CLI_BIN_DIR; }; const getStatus = async (): Promise => { - const terminalCommandPath = resolveCommandOnPath("ade", hostPathSnapshot); + const terminalCommandPath = resolveCommandOnPath("ade", hostPathSnapshot, envSnapshot); const targetPath = installTargetPath(); const targetDir = path.dirname(targetPath); const terminalInstalled = Boolean(terminalCommandPath); const bundledAvailable = Boolean(resolved.commandPath && isExecutable(resolved.commandPath)); - const agentPathReady = bundledAvailable && pathContainsDir(agentEnv({ PATH: hostPathSnapshot }).PATH, resolved.binDir); + const hostPathEnv: NodeJS.ProcessEnv = {}; + if (hostPathSnapshot) setPathEnvValue(hostPathEnv, hostPathSnapshot); + const agentPathReady = bundledAvailable && pathContainsDir(getPathEnvValue(agentEnv(hostPathEnv)), resolved.binDir); const installAvailable = resolved.source === "packaged" && isExecutable(resolved.installerPath); const message = statusMessage({ terminalInstalled, diff --git a/apps/desktop/src/main/services/computerUse/localComputerUse.ts b/apps/desktop/src/main/services/computerUse/localComputerUse.ts index 9972660a0..75b28f0c4 100644 --- a/apps/desktop/src/main/services/computerUse/localComputerUse.ts +++ b/apps/desktop/src/main/services/computerUse/localComputerUse.ts @@ -144,7 +144,7 @@ export function createComputerUseArtifactPath(projectRoot: string, stem: string, export function toProjectArtifactUri(projectRoot: string, absolutePath: string): string { const relative = path.relative(projectRoot, absolutePath); if (!relative.startsWith("..") && !path.isAbsolute(relative)) { - return relative; + return relative.replace(/\\/g, "/"); } return absolutePath; } diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index c768f38e8..101186fac 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; import type { ApplyConflictProposalArgs, BatchOverlapEntry, @@ -3498,10 +3499,13 @@ export function createConflictService({ }); const proc = await new Promise<{ stdout: string; stderr: string; status: number | null; signal: NodeJS.Signals | null }>((resolve) => { - const child = spawn(bin, renderedCommand.slice(1), { + const invocation = resolveCliSpawnInvocation(bin, renderedCommand.slice(1), process.env); + const child = spawn(invocation.command, invocation.args, { cwd: cwdLane.worktreePath, + env: process.env, stdio: ["ignore", "pipe", "pipe"], timeout: 8 * 60_000, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; let stderr = ""; diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 0f936f4a5..62629b7d3 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import type { Server as NetServer } from "node:net"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { IPC } from "../../../shared/ipc"; import { getModelById } from "../../../shared/modelRegistry"; import { buildPrAiResolutionContextKey } from "../../../shared/types"; @@ -583,6 +584,7 @@ import type { AdeProjectService } from "../projects/adeProjectService"; import type { ConfigReloadService } from "../projects/configReloadService"; import type { createAdeCliService } from "../cli/adeCliService"; import { getErrorMessage, isRecord, nowIso, resolvePathWithinRoot, toMemoryEntryDto } from "../shared/utils"; +import { quoteWindowsCmdArg } from "../shared/processExecution"; import { resolveAdeLayout } from "../../../shared/adeLayout"; export type AppContext = { @@ -1859,10 +1861,27 @@ export function registerIpc({ await shell.openExternal(parsed.toString()); }); + const resolveRendererSuppliedPath = (rawPath: string, projectRoot: string): string => { + let inputPath = rawPath; + if (/^ade-artifact:\/\/project(?:\/|$)/i.test(inputPath)) { + const parsed = new URL(inputPath); + inputPath = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); + } + if (/^file:\/\//i.test(inputPath)) { + try { + inputPath = fileURLToPath(inputPath); + } catch { + inputPath = decodeURIComponent(inputPath.replace(/^file:\/\//i, "")); + } + } + return path.resolve(path.isAbsolute(inputPath) ? inputPath : path.join(projectRoot, inputPath)); + }; + ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise => { const raw = typeof arg?.path === "string" ? arg.path.trim() : ""; if (!raw) return; - const normalized = path.resolve(raw); + const ctx = getCtx(); + const normalized = resolveRendererSuppliedPath(raw, ctx.project.rootPath); // Validate the path is within known safe directories only. // Reject requests to reveal arbitrary paths (e.g. ~/.ssh, /etc, /System). const allowedDirs = getAllowedDirs(getCtx); @@ -1883,7 +1902,8 @@ export function registerIpc({ ipcMain.handle(IPC.appOpenPath, async (_event, arg: { path: string }): Promise => { const raw = typeof arg?.path === "string" ? arg.path.trim() : ""; if (!raw) return; - const normalized = path.resolve(raw); + const ctx = getCtx(); + const normalized = resolveRendererSuppliedPath(raw, ctx.project.rootPath); const allowedDirs = getAllowedDirs(getCtx); const allowed = allowedDirs.some((dir) => { try { @@ -1954,33 +1974,59 @@ export function registerIpc({ return; } - const launchDetached = async (command: string, args: string[]): Promise => { + const launchDetached = async ( + command: string, + args: string[], + options?: { windowsVerbatimArguments?: boolean; resolveOn?: "spawn" | "exit" }, + ): Promise => { await new Promise((resolve, reject) => { let settled = false; + const resolveOn = options?.resolveOn ?? "spawn"; try { - const child = spawn(command, args, { detached: true, stdio: "ignore" }); + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsVerbatimArguments: options?.windowsVerbatimArguments, + }); child.once("error", (error) => { if (settled) return; settled = true; reject(error); }); child.once("spawn", () => { + if (resolveOn !== "spawn") return; if (settled) return; settled = true; child.unref(); resolve(); }); + child.once("exit", (code) => { + if (resolveOn !== "exit") return; + if (settled) return; + settled = true; + child.unref(); + if (code === 0) { + resolve(); + } else { + reject(new Error(`exit code ${code}`)); + } + }); } catch (error) { reject(error); } }); }; - const launchAttempts = async (attempts: Array<{ command: string; args: string[] }>): Promise => { + const launchAttempts = async ( + attempts: Array<{ command: string; args: string[]; windowsVerbatimArguments?: boolean; resolveOn?: "spawn" | "exit" }>, + ): Promise => { let lastError: unknown = null; for (const attempt of attempts) { try { - await launchDetached(attempt.command, attempt.args); + await launchDetached(attempt.command, attempt.args, { + windowsVerbatimArguments: attempt.windowsVerbatimArguments, + resolveOn: attempt.resolveOn, + }); return; } catch (error) { lastError = error; @@ -1989,13 +2035,23 @@ export function registerIpc({ throw lastError instanceof Error ? lastError : new Error("Failed to launch external editor."); }; - const attempts: Array<{ command: string; args: string[] }> = []; + const attempts: Array<{ command: string; args: string[]; windowsVerbatimArguments?: boolean; resolveOn?: "spawn" | "exit" }> = []; const cliCommand = target === "vscode" ? "code" : target === "cursor" ? "cursor" : "zed"; if (process.platform === "darwin") { const appName = target === "vscode" ? "Visual Studio Code" : target === "cursor" ? "Cursor" : "Zed"; attempts.push({ command: "open", args: ["-a", appName, targetPath] }); } + if (process.platform === "win32") { + // `start "" ` — empty title is required when the next token is quoted. + const windowsShell = process.env.ComSpec?.trim() || "cmd.exe"; + attempts.push({ + command: windowsShell, + args: ["/d", "/s", "/c", `start "" ${quoteWindowsCmdArg(cliCommand)} ${quoteWindowsCmdArg(targetPath)}`], + windowsVerbatimArguments: true, + resolveOn: "exit", + }); + } attempts.push({ command: cliCommand, args: [targetPath] }); try { @@ -4574,14 +4630,7 @@ export function registerIpc({ // handler in main.ts which validates exclusively against currentArtifactsDir. const allowedRoots = [layout.artifactsDir]; - let filePath = arg.uri; - if (filePath.startsWith("file://")) { - const { fileURLToPath } = await import("node:url"); - try { filePath = fileURLToPath(filePath); } catch { filePath = decodeURIComponent(filePath.replace(/^file:\/\//i, "")); } - } - if (!path.isAbsolute(filePath)) { - filePath = path.resolve(projectRoot, filePath); - } + const filePath = resolveRendererSuppliedPath(arg.uri, projectRoot); // Canonicalize and verify the resolved path is inside an allowed artifact root. const canonical = path.normalize(path.resolve(filePath)); const inside = allowedRoots.some((root) => { diff --git a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts index 3803cb8c6..bf91f4654 100644 --- a/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts +++ b/apps/desktop/src/main/services/lanes/laneEnvironmentService.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { execFile } from "node:child_process"; +import { spawn } from "node:child_process"; import type { LaneEnvInitConfig, LaneEnvInitProgress, @@ -22,6 +22,7 @@ import { secureCopyPathIntoRoot, secureWriteFileWithinRoot, } from "../shared/utils"; +import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; /** Resolve a relative path against `root` and throw if it escapes. Logs a warning on escape. */ function resolveCheckedPath( @@ -227,15 +228,42 @@ export function createLaneEnvironmentService({ ): Promise<{ exitCode: number; stdout: string; stderr: string }> { return new Promise((resolve) => { const [cmd, ...args] = command; - execFile(cmd, args, { cwd, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { - const exitCode = error && typeof (error as { code?: unknown }).code === "number" - ? (error as { code: number }).code - : 1; - resolve({ - exitCode: error ? exitCode : 0, - stdout: stdout ?? "", - stderr: stderr ?? "" - }); + if (!cmd) { + resolve({ exitCode: 1, stdout: "", stderr: "Missing command" }); + return; + } + const invocation = resolveCliSpawnInvocation(cmd, args, process.env); + const child = spawn(invocation.command, invocation.args, { + cwd, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const maxBuffer = 10 * 1024 * 1024; + const timer = setTimeout(() => { + if (settled) return; + terminateProcessTree(child); + }, timeoutMs); + const append = (current: string, chunk: Buffer): string => + current.length >= maxBuffer + ? current + : current + chunk.toString("utf8").slice(0, maxBuffer - current.length); + child.stdout?.on("data", (chunk: Buffer) => { stdout = append(stdout, chunk); }); + child.stderr?.on("data", (chunk: Buffer) => { stderr = append(stderr, chunk); }); + child.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ exitCode: 1, stdout, stderr: error instanceof Error ? error.message : String(error) }); + }); + child.on("close", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ exitCode: code ?? 1, stdout, stderr }); }); }); } diff --git a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts index b63c60575..501f604b3 100644 --- a/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts @@ -12,19 +12,30 @@ export type OpenCodeBinaryInfo = { let cachedInfo: OpenCodeBinaryInfo | null = null; -function bundledBinaryPath(): string { - const ext = process.platform === "win32" ? ".exe" : ""; +function bundledBinaryCandidatePaths(): string[] { + const fileNames = process.platform === "win32" + ? ["opencode.exe", "opencode.cmd", "opencode.bat", "opencode"] + : ["opencode"]; // In packaged app, process.resourcesPath points to Resources/ // In dev, fall back to node_modules/.bin const resourcesPath = (process as any).resourcesPath; if (resourcesPath) { - return join(resourcesPath, `opencode${ext}`); + return fileNames.map((fileName) => join(resourcesPath, fileName)); } // Dev fallback: check node_modules if (typeof __dirname !== "string") { - return join(process.cwd(), "apps", "desktop", "node_modules", ".bin", `opencode${ext}`); + return fileNames.map((fileName) => join(process.cwd(), "apps", "desktop", "node_modules", ".bin", fileName)); + } + return fileNames.map((fileName) => join(__dirname, "..", "..", "..", "..", "node_modules", ".bin", fileName)); +} + +function canRunBundledBinary(filePath: string): boolean { + try { + accessSync(filePath, process.platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; } - return join(__dirname, "..", "..", "..", "..", "node_modules", ".bin", `opencode${ext}`); } export function resolveOpenCodeBinary(): OpenCodeBinaryInfo { @@ -41,13 +52,10 @@ export function resolveOpenCodeBinary(): OpenCodeBinaryInfo { } // 2. Fall back to bundled binary - const bundled = bundledBinaryPath(); - try { - accessSync(bundled, constants.X_OK); + const bundled = bundledBinaryCandidatePaths().find((candidate) => canRunBundledBinary(candidate)); + if (bundled) { cachedInfo = { path: bundled, source: "bundled" }; return cachedInfo; - } catch { - // Bundled binary not found or not executable } cachedInfo = { path: null, source: "missing" }; diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index 481d5e2fb..ceb972205 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -22,6 +22,7 @@ import type { OpenCodeRuntimeSnapshot, ProjectConfigFile, } from "../../../shared/types"; +import { isAdeMcpNamedPipePath } from "../../../shared/adeMcpIpc"; import { stableStringify } from "../shared/utils"; import { resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; import type { PermissionMode } from "../ai/tools/universalTools"; diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts index 9b170a261..408057c9b 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -11,15 +11,62 @@ vi.mock("./openCodeBinaryManager", () => ({ import { __buildOpenCodeServeLaunchSpecForTests, + __isManagedOpenCodeServeCommandForTests, __resetOpenCodeServerManagerForTests, __setOpenCodeProcessControllerForTests, __setOpenCodeServerLauncherForTests, acquireDedicatedOpenCodeServer, acquireSharedOpenCodeServer, getOpenCodeRuntimeDiagnostics, + parseWindowsWmicProcessCsv, recoverManagedOpenCodeOrphans, } from "./openCodeServerManager"; +const originalProcessPlatform = process.platform; + +function setProcessPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value: platform, + configurable: true, + }); +} + +describe("parseWindowsWmicProcessCsv", () => { + it("parses WMIC CSV rows into pid, ppid, and command", () => { + const csv = [ + "Node,CommandLine,ParentProcessId,ProcessId", + ",C:\\\\Windows\\\\System32\\\\notepad.exe,100,200", + ].join("\r\n"); + const rows = parseWindowsWmicProcessCsv(csv); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + pid: 200, + ppid: 100, + command: "C:\\\\Windows\\\\System32\\\\notepad.exe", + }); + }); + + it("parses PowerShell ConvertTo-Csv rows", () => { + const csv = [ + '"ProcessId","ParentProcessId","CommandLine"', + '"300","200","C:\\\\Windows\\\\System32\\\\cmd.exe /d /s /c opencode.cmd serve"', + ].join("\r\n"); + const rows = parseWindowsWmicProcessCsv(csv); + expect(rows).toHaveLength(1); + expect(rows[0]?.pid).toBe(300); + expect(rows[0]?.ppid).toBe(200); + expect(rows[0]?.command).toContain("opencode.cmd"); + }); +}); + +describe("Windows managed OpenCode command detection", () => { + it("detects cmd-wrapped serve with inline managed markers", () => { + const cmdLine = + 'C:\\\\Windows\\\\System32\\\\cmd.exe /d /s /c set "ADE_OPENCODE_MANAGED=1"&&set "OPENCODE_DISABLE_PROJECT_CONFIG=1"&&set "ADE_OPENCODE_OWNER_PID=999"&&C:\\\\opencode\\\\opencode.cmd serve --hostname=127.0.0.1 --port=4310'; + expect(__isManagedOpenCodeServeCommandForTests(cmdLine)).toBe(true); + }); +}); + describe("openCodeServerManager", () => { const originalEnv = { PATH: process.env.PATH, @@ -48,6 +95,7 @@ describe("openCodeServerManager", () => { listProcesses: () => [], isProcessAlive: () => false, killProcess: () => {}, + killProcessTree: () => false, waitForMs: async () => {}, }); __setOpenCodeServerLauncherForTests(async ({ port }) => { @@ -63,6 +111,7 @@ describe("openCodeServerManager", () => { afterEach(() => { __resetOpenCodeServerManagerForTests(); + setProcessPlatform(originalProcessPlatform as NodeJS.Platform); vi.useRealTimers(); restoreEnv("PATH"); restoreEnv("HOME"); @@ -407,35 +456,45 @@ describe("openCodeServerManager", () => { expect(spec.env.OPENCODE_BIN_PATH).toBeUndefined(); }); - it("reaps orphaned ADE-managed OpenCode processes and skips ones with a live owner", async () => { + it("quotes the OpenCode executable in Windows cmd launch specs", () => { + setProcessPlatform("win32"); + process.env.ADE_OPENCODE_XDG_ROOT = "/tmp/ade-opencode-test-home"; + + const spec = __buildOpenCodeServeLaunchSpecForTests({ + config: { share: "disabled" } as const, + port: 4310, + }); + + expect(spec.executable).toBe("cmd.exe"); + expect(spec.args[0]).toBe("/d"); + expect(spec.args[1]).toBe("/s"); + expect(spec.args[2]).toBe("/c"); + expect(spec.args[3]).toContain('&&"/Users/admin/.opencode/bin/opencode" serve --hostname=127.0.0.1 --port=4310'); + }); + + it("reaps orphaned ADE-managed OpenCode processes on Windows with a tree kill and skips ones with a live owner", async () => { + setProcessPlatform("win32"); let orphanAlive = true; - const killProcess = vi.fn((pid: number, signal: NodeJS.Signals) => { - if (pid === 4101 && signal === "SIGKILL") { + const killProcess = vi.fn(); + const killProcessTree = vi.fn((pid: number) => { + if (pid === 4101) { orphanAlive = false; } + return true; }); - const homeDir = os.homedir(); __setOpenCodeProcessControllerForTests({ listProcesses: () => ([ { pid: 4101, ppid: 1, - command: [ - "/Users/admin/.opencode/bin/opencode serve --hostname=127.0.0.1 --port=62298", - "OPENCODE_DISABLE_PROJECT_CONFIG=1", - `XDG_CONFIG_HOME=${homeDir}/.ade/opencode-runtime/xdg-v1/config`, - ].join(" "), + command: + 'C:\\Windows\\System32\\cmd.exe /d /s /c set "ADE_OPENCODE_MANAGED=1"&&set "OPENCODE_DISABLE_PROJECT_CONFIG=1"&&set "ADE_OPENCODE_OWNER_PID=999999"&&C:\\opencode\\opencode.cmd serve --hostname=127.0.0.1 --port=62298', }, { pid: 4102, ppid: 1, - command: [ - "/Users/admin/.opencode/bin/opencode serve --hostname=127.0.0.1 --port=62299", - "OPENCODE_DISABLE_PROJECT_CONFIG=1", - "ADE_OPENCODE_MANAGED=1", - "ADE_OPENCODE_OWNER_PID=7788", - `XDG_CONFIG_HOME=${homeDir}/.ade/opencode-runtime/xdg-v1/config`, - ].join(" "), + command: + 'C:\\Windows\\System32\\cmd.exe /d /s /c set "ADE_OPENCODE_MANAGED=1"&&set "OPENCODE_DISABLE_PROJECT_CONFIG=1"&&set "ADE_OPENCODE_OWNER_PID=7788"&&C:\\opencode\\opencode.cmd serve --hostname=127.0.0.1 --port=62299', }, ]), isProcessAlive: (pid) => { @@ -443,14 +502,15 @@ describe("openCodeServerManager", () => { return pid === 4102 || pid === 7788; }, killProcess, + killProcessTree, }); const result = await recoverManagedOpenCodeOrphans(); expect(result.recoveredPids).toEqual([4101]); expect(result.skippedPids).toEqual([4102]); - expect(killProcess).toHaveBeenCalledWith(4101, "SIGTERM"); - expect(killProcess).toHaveBeenCalledWith(4101, "SIGKILL"); - expect(killProcess).not.toHaveBeenCalledWith(4102, "SIGTERM"); + expect(killProcessTree).toHaveBeenCalledWith(4101); + expect(killProcessTree).not.toHaveBeenCalledWith(4102); + expect(killProcess).not.toHaveBeenCalled(); }); it("does not mark stubborn orphaned processes as recovered", async () => { diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts index c9e400988..7fc787e26 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -7,6 +7,7 @@ import path from "node:path"; import type { Config as OpenCodeConfig } from "@opencode-ai/sdk"; import type { Logger } from "../logging/logger"; import { stableStringify } from "../shared/utils"; +import { processOutputToString } from "../shared/processExecution"; import { resolveOpenCodeBinaryPath } from "./openCodeBinaryManager"; export type OpenCodeServerLeaseKind = "shared" | "dedicated"; @@ -62,6 +63,7 @@ type OpenCodeProcessController = { listProcesses(): OpenCodeProcessSnapshot[]; isProcessAlive(pid: number): boolean; killProcess(pid: number, signal: NodeJS.Signals): void; + killProcessTree(pid: number): boolean; waitForMs(ms: number): Promise; }; @@ -144,9 +146,125 @@ function readLinuxProcessEnvironment(pid: number): string[] { } } +function parseOneCsvLine(line: string): string[] { + const out: string[] = []; + let cur = ""; + let i = 0; + let inQuotes = false; + while (i < line.length) { + const c = line[i]!; + if (inQuotes) { + if (c === "\"") { + if (line[i + 1] === "\"") { + cur += "\""; + i += 2; + continue; + } + inQuotes = false; + i += 1; + continue; + } + cur += c; + i += 1; + continue; + } + if (c === "\"") { + inQuotes = true; + i += 1; + continue; + } + if (c === ",") { + out.push(cur); + cur = ""; + i += 1; + continue; + } + cur += c; + i += 1; + } + out.push(cur); + return out; +} + +/** Parses WMIC `process get ... /FORMAT:CSV` stdout into snapshots (exported for unit tests). */ +export function parseWindowsWmicProcessCsv(stdout: string): OpenCodeProcessSnapshot[] { + const rows: OpenCodeProcessSnapshot[] = []; + const lines = stdout + .split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l.length > 0); + if (lines.length < 2) return rows; + + const header = parseOneCsvLine(lines[0]!); + const processIdIdx = header.indexOf("ProcessId"); + const parentProcessIdIdx = header.indexOf("ParentProcessId"); + const commandLineIdx = header.indexOf("CommandLine"); + if (processIdIdx < 0 || parentProcessIdIdx < 0 || commandLineIdx < 0) { + return rows; + } + + const maxIdx = Math.max(processIdIdx, parentProcessIdIdx, commandLineIdx); + for (let li = 1; li < lines.length; li += 1) { + const cells = parseOneCsvLine(lines[li]!); + if (cells.length <= maxIdx) continue; + const pid = Number(cells[processIdIdx]?.trim()); + const ppid = Number(cells[parentProcessIdIdx]?.trim()); + if (!Number.isInteger(pid) || pid <= 0 || !Number.isInteger(ppid) || ppid < 0) { + continue; + } + const command = (cells[commandLineIdx] ?? "").trim(); + rows.push({ pid, ppid, command }); + } + return rows; +} + +function listWindowsProcessesFromWmic(): OpenCodeProcessSnapshot[] { + const result = spawnSync( + "wmic", + ["process", "get", "ProcessId,ParentProcessId,CommandLine", "/FORMAT:CSV"], + { + encoding: "utf8", + windowsHide: true, + maxBuffer: 50 * 1024 * 1024, + }, + ); + if (result.error || result.status !== 0 || typeof result.stdout !== "string") { + return []; + } + return parseWindowsWmicProcessCsv(result.stdout); +} + +function listWindowsProcessesFromPowerShell(): OpenCodeProcessSnapshot[] { + const script = + "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Csv -NoTypeInformation"; + const result = spawnSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], + { + encoding: "utf8", + windowsHide: true, + maxBuffer: 50 * 1024 * 1024, + }, + ); + if (result.error || result.status !== 0 || typeof result.stdout !== "string") { + return []; + } + return parseWindowsWmicProcessCsv(result.stdout); +} + +function listWindowsProcesses(): OpenCodeProcessSnapshot[] { + const fromWmic = listWindowsProcessesFromWmic(); + if (fromWmic.length > 0) { + return fromWmic; + } + return listWindowsProcessesFromPowerShell(); +} + const defaultOpenCodeProcessController: OpenCodeProcessController = { listProcesses(): OpenCodeProcessSnapshot[] { - if (process.platform === "win32") return []; + if (process.platform === "win32") { + return listWindowsProcesses(); + } const psArgs = process.platform === "linux" ? ["-ww", "-axo", "pid=,ppid=,command="] : ["-wwE", "-axo", "pid=,ppid=,command="]; @@ -188,6 +306,33 @@ const defaultOpenCodeProcessController: OpenCodeProcessController = { // ignore } }, + killProcessTree(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + if (process.platform === "win32") { + try { + const out = spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { windowsHide: true }); + if (!out.error && out.status === 0) { + return true; + } + console.error("opencode.kill_process_tree_taskkill_failed", { + pid, + status: out.status, + stdout: processOutputToString(out.stdout), + stderr: processOutputToString(out.stderr), + error: out.error, + }); + } catch (error) { + console.error("opencode.kill_process_tree_taskkill_failed", { pid, error }); + } + return false; + } + try { + process.kill(pid); + return true; + } catch { + return false; + } + }, waitForMs(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms); @@ -270,9 +415,8 @@ function isPortConflict(error: unknown): boolean { function stopChildProcess(proc: ChildProcess): void { if (proc.exitCode !== null || proc.signalCode !== null) return; - if (process.platform === "win32" && proc.pid) { - const out = spawnSync("taskkill", ["/pid", String(proc.pid), "/T", "/F"], { windowsHide: true }); - if (!out.error && out.status === 0) return; + if (process.platform === "win32" && proc.pid && openCodeProcessController.killProcessTree(proc.pid)) { + return; } proc.kill(); } @@ -374,6 +518,15 @@ function buildManagedConfigMarkers(): string[] { } function isManagedOpenCodeServeCommand(command: string, configMarkers: string[]): boolean { + // Windows: managed markers are injected into the cmd.exe command line (WMIC/CIM omit child env). + if ( + /\bcmd(?:\.exe)?\b/i.test(command) + && command.includes(`${ADE_OPENCODE_MANAGED_ENV}=1`) + && /\bopencode(?:\.cmd|\.exe)?\b/i.test(command) + && /\bserve\b/i.test(command) + ) { + return command.includes("OPENCODE_DISABLE_PROJECT_CONFIG=1"); + } if (!/\bopencode(?:\.cmd|\.exe)?\b\s+serve\b/i.test(command)) return false; if (!command.includes("OPENCODE_DISABLE_PROJECT_CONFIG=1")) return false; if (command.includes(`${ADE_OPENCODE_MANAGED_ENV}=1`)) return true; @@ -381,7 +534,7 @@ function isManagedOpenCodeServeCommand(command: string, configMarkers: string[]) } function parseManagedOwnerPid(command: string): number | null { - const match = command.match(new RegExp(`\\b${ADE_OPENCODE_OWNER_PID_ENV}=(\\d+)\\b`)); + const match = command.match(new RegExp(`${ADE_OPENCODE_OWNER_PID_ENV}=(\\d+)`, "i")); if (!match) return null; const pid = Number(match[1]); return Number.isInteger(pid) && pid > 0 ? pid : null; @@ -449,19 +602,37 @@ export async function recoverManagedOpenCodeOrphans(args: { continue; } - openCodeProcessController.killProcess(proc.pid, "SIGTERM"); - const exitedGracefully = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); - if (!exitedGracefully && openCodeProcessController.isProcessAlive(proc.pid)) { - openCodeProcessController.killProcess(proc.pid, "SIGKILL"); - const exitedAfterKill = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); - if (!exitedAfterKill && openCodeProcessController.isProcessAlive(proc.pid)) { - skippedPids.push(proc.pid); - args.logger?.warn("opencode.server_orphan_recovery_failed", { - pid: proc.pid, - ownerPid, - ppid: proc.ppid, - }); - continue; + if (process.platform === "win32") { + openCodeProcessController.killProcessTree(proc.pid); + const exitedGracefully = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + if (!exitedGracefully && openCodeProcessController.isProcessAlive(proc.pid)) { + openCodeProcessController.killProcessTree(proc.pid); + const exitedAfterKill = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + if (!exitedAfterKill && openCodeProcessController.isProcessAlive(proc.pid)) { + skippedPids.push(proc.pid); + args.logger?.warn("opencode.server_orphan_recovery_failed", { + pid: proc.pid, + ownerPid, + ppid: proc.ppid, + }); + continue; + } + } + } else { + openCodeProcessController.killProcess(proc.pid, "SIGTERM"); + const exitedGracefully = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + if (!exitedGracefully && openCodeProcessController.isProcessAlive(proc.pid)) { + openCodeProcessController.killProcess(proc.pid, "SIGKILL"); + const exitedAfterKill = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + if (!exitedAfterKill && openCodeProcessController.isProcessAlive(proc.pid)) { + skippedPids.push(proc.pid); + args.logger?.warn("opencode.server_orphan_recovery_failed", { + pid: proc.pid, + ownerPid, + ppid: proc.ppid, + }); + continue; + } } } recoveredPids.push(proc.pid); @@ -490,6 +661,21 @@ function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeS } const xdgPaths = resolveOpenCodeIsolationPaths(); ensureOpenCodeIsolationDirs(xdgPaths); + const env = buildIsolatedOpenCodeEnv(args.config, xdgPaths); + if (process.platform === "win32") { + const comSpec = process.env.ComSpec?.trim() || "cmd.exe"; + const exeForCmd = `"${executable.replace(/"/g, "\"\"")}"`; + const cmdLine = + `set "${ADE_OPENCODE_MANAGED_ENV}=1"&&set "OPENCODE_DISABLE_PROJECT_CONFIG=1"&&set "${ADE_OPENCODE_OWNER_PID_ENV}=${process.pid}"&&${exeForCmd} serve --hostname=127.0.0.1 --port=${args.port}`; + return { + executable: comSpec, + args: ["/d", "/s", "/c", cmdLine], + env, + useShell: false, + xdgPaths, + }; + } + return { executable, args: [ @@ -497,8 +683,8 @@ function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeS "--hostname=127.0.0.1", `--port=${args.port}`, ], - env: buildIsolatedOpenCodeEnv(args.config, xdgPaths), - useShell: process.platform === "win32" && /\.(cmd|bat)$/i.test(executable), + env, + useShell: false, xdgPaths, }; } @@ -511,6 +697,7 @@ async function defaultOpenCodeServerLauncher( env: launchSpec.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + windowsVerbatimArguments: process.platform === "win32", shell: launchSpec.useShell, }); @@ -957,6 +1144,7 @@ export function __setOpenCodeProcessControllerForTests( listProcesses: controller.listProcesses ?? (() => []), isProcessAlive: controller.isProcessAlive ?? (() => false), killProcess: controller.killProcess ?? (() => {}), + killProcessTree: controller.killProcessTree ?? (() => false), waitForMs: controller.waitForMs ?? (async () => {}), } : defaultOpenCodeProcessController; @@ -974,3 +1162,8 @@ export function __buildOpenCodeServeLaunchSpecForTests(args: { config: args.config, }); } + +/** Test hook: whether a WMIC/CIM command line would be treated as an ADE-managed OpenCode serve. */ +export function __isManagedOpenCodeServeCommandForTests(command: string): boolean { + return isManagedOpenCodeServeCommand(command, buildManagedConfigMarkers()); +} diff --git a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts index 105487cb3..12c714205 100644 --- a/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts @@ -25,6 +25,34 @@ export function compactText(value: string, maxChars = 220): string { return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; } +export type AdapterLaunchCommand = { + /** Human-readable preview stored with the session and metadata. */ + startupCommand: string; + /** Optional direct executable to launch instead of typing startupCommand into a shell. */ + command?: string; + args?: string[]; + env?: Record; +}; + +function normalizeAdapterLaunch(value: string | AdapterLaunchCommand): AdapterLaunchCommand { + if (typeof value === "string") return { startupCommand: value }; + return value; +} + +function ptyLaunchFields(launch: AdapterLaunchCommand): { + startupCommand: string; + command?: string; + args?: string[]; + env?: Record; +} { + return { + startupCommand: launch.startupCommand, + ...(launch.command ? { command: launch.command } : {}), + ...(launch.args ? { args: launch.args } : {}), + ...(launch.env ? { env: launch.env } : {}), + }; +} + export function buildCompactPlanView(currentStep: OrchestratorStep, allSteps: OrchestratorStep[]): string { if (!allSteps.length) return ""; const stepIdToKey = new Map(allSteps.map((s) => [s.id, s.stepKey])); @@ -81,7 +109,7 @@ export interface BaseAdapterConfig { /** Session type for tracked sessions, e.g. "claude-orchestrated". */ sessionType: TerminalToolType; /** Build the startup command for a startup-command-override case. */ - buildOverrideCommand: (args: { prompt: string }) => string; + buildOverrideCommand: (args: { prompt: string }) => string | AdapterLaunchCommand; /** Build the full startup command from the assembled prompt + resolved config. */ buildStartupCommand: (args: { prompt: string; @@ -91,7 +119,7 @@ export interface BaseAdapterConfig { attempt: import("../../../shared/types").OrchestratorAttempt; permissionConfig: OrchestratorExecutorStartArgs["permissionConfig"]; teamRuntime?: TeamRuntimeConfig; - }) => string; + }) => string | AdapterLaunchCommand; /** Build adapter-specific metadata to include in the accepted result. */ buildAcceptedMetadata: (args: { model: string; @@ -748,12 +776,13 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches : null; if (startupCommandOverride) { + const launch = normalizeAdapterLaunch(buildOverrideCommand({ prompt: startupCommandOverride })); // Use the startup command directly as the prompt const session = await args.createTrackedSession({ laneId: step.laneId, toolType: sessionType, title: `[Orchestrator] ${step.title}`, - startupCommand: buildOverrideCommand({ prompt: startupCommandOverride }), + ...ptyLaunchFields(launch), cols: 120, rows: 40 }); @@ -765,7 +794,7 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches adapterKind: executorKind, startupCommandOverride: true, promptLength: startupCommandOverride.length, - startupCommandPreview: startupCommandOverride.slice(0, 320) + startupCommandPreview: launch.startupCommand.slice(0, 320) } }; } @@ -791,7 +820,7 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches const teamRuntime = run.metadata && typeof run.metadata === "object" && !Array.isArray(run.metadata) ? (run.metadata as Record).teamRuntime as TeamRuntimeConfig | undefined : undefined; - const startupCommand = buildStartupCommand({ + const launch = normalizeAdapterLaunch(buildStartupCommand({ prompt, model, step, @@ -799,14 +828,14 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches attempt, permissionConfig: args.permissionConfig, teamRuntime - }); + })); // 5. Create tracked session const session = await args.createTrackedSession({ laneId: step.laneId, toolType: sessionType, title: `[Orchestrator] ${step.title}`, - startupCommand, + ...ptyLaunchFields(launch), cols: 120, rows: 40 }); @@ -829,7 +858,7 @@ export function createBaseOrchestratorAdapter(config: BaseAdapterConfig): Orches steeringDirectiveCount, promptLength: prompt.length, reasoningEffort, - startupCommandPreview: startupCommand.slice(0, 320) + startupCommandPreview: launch.startupCommand.slice(0, 320) }) }; } catch (error) { diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts b/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts index 5cd9e7258..3420f7968 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts @@ -113,30 +113,37 @@ export const DEFAULT_WORKER_SANDBOX_CONFIG: WorkerSandboxConfig = { "\\bshutdown\\b", "\\breboot\\b", ":\\(\\)\\{", + "\\breg(?:\\.exe)?\\s+(add|delete|import|load|unload|copy|save|restore)\\b", + "\\bdiskpart(?:\\.exe)?\\b", + "\\bformat(?:\\.exe)?\\s+[a-z]:", + "\\bbcdedit(?:\\.exe)?\\b", + "\\btakeown(?:\\.exe)?\\b", + ">\\s*[^\\n\\r]*[/\\\\]windows[/\\\\]system32\\b", + ">\\s*[^\\n\\r]*[/\\\\]windows[/\\\\]syswow64\\b", ], safeCommands: [ - "^pnpm\\s", - "^npm\\s", - "^yarn\\s", - "^npx\\s", - "^git\\s+status\\b", - "^git\\s+diff\\b", - "^git\\s+log\\b", - "^git\\s+show\\b", - "^git\\s+branch\\s*$", - "^git\\s+ls-files\\b", + "^pnpm(\\.cmd)?\\s", + "^npm(\\.cmd)?\\s", + "^yarn(\\.cmd)?\\s", + "^npx(\\.cmd)?\\s", + "^git(\\.exe)?\\s+status\\b", + "^git(\\.exe)?\\s+diff\\b", + "^git(\\.exe)?\\s+log\\b", + "^git(\\.exe)?\\s+show\\b", + "^git(\\.exe)?\\s+branch\\s*$", + "^git(\\.exe)?\\s+ls-files\\b", "^ls\\s", "^ls$", "^pwd\\b", "^echo\\s", "^date\\b", - "^node\\s", - "^tsx\\s", - "^vitest\\s", - "^jest\\s", - "^eslint\\s", - "^prettier\\s", - "^tsc\\b", + "^node(\\.exe)?\\s", + "^tsx(\\.cmd)?\\s", + "^vitest(\\.cmd)?\\s", + "^jest(\\.cmd)?\\s", + "^eslint(\\.cmd)?\\s", + "^prettier(\\.cmd)?\\s", + "^tsc(\\.cmd)?\\b", "^lsof\\s", "^ps\\s", ], diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts index 3c3786f2d..4271117c2 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorContext.ts @@ -8,6 +8,7 @@ import { createHash } from "node:crypto"; import { spawn } from "node:child_process"; +import { terminateProcessTree } from "../shared/processExecution"; import { nowIso } from "../shared/utils"; import type { GetModelCapabilitiesResult } from "../../../shared/types"; import type { @@ -1059,18 +1060,10 @@ export const runOrchestratorHookCommand: OrchestratorHookCommandRunner = async ( if (args.timeoutMs > 0) { killTimer = setTimeout(() => { timedOut = true; - try { - child.kill("SIGTERM"); - } catch { - // Best-effort only. - } + terminateProcessTree(child, "SIGTERM"); setTimeout(() => { if (child.exitCode == null && !child.killed) { - try { - child.kill("SIGKILL"); - } catch { - // Best-effort only. - } + terminateProcessTree(child, "SIGKILL"); } }, 1_000).unref(); }, args.timeoutMs); diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 09ec034dd..e5069417b 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -3954,7 +3954,8 @@ export function createOrchestratorService({ }; } const cliCommand = descriptor?.cliCommand === "codex" ? "codex" : "claude"; - const commandParts: string[] = [cliCommand]; + const commandArgs: string[] = []; + const commandPreviewParts: string[] = [cliCommand]; const model = modelRef; if (model) { const effectiveModel = cliCommand === "claude" @@ -3962,7 +3963,8 @@ export function createOrchestratorService({ : cliCommand === "codex" ? resolveCodexCliModel(model) : model; - commandParts.push("--model", shellEscapeArg(effectiveModel)); + commandArgs.push("--model", effectiveModel); + commandPreviewParts.push("--model", shellEscapeArg(effectiveModel)); } const cliMode = args.permissionConfig?.cli?.mode ?? "full-auto"; if (cliCommand === "codex") { @@ -3971,24 +3973,36 @@ export function createOrchestratorService({ if (!readOnlyExecution && codexProviderMode === "config-toml") { // Let Codex read its own repository/user config without forcing flags. } else { - commandParts.push( + const sandboxMode = readOnlyExecution || cliMode === "read-only" + ? "read-only" + : mappedCodex?.sandbox ?? args.permissionConfig?.cli?.sandboxPermissions ?? "workspace-write"; + const approvalPolicy = readOnlyExecution || cliMode === "read-only" ? "on-request" : mappedCodex?.approvalPolicy ?? "untrusted"; + commandArgs.push( "--sandbox", - readOnlyExecution || cliMode === "read-only" - ? "read-only" - : mappedCodex?.sandbox ?? args.permissionConfig?.cli?.sandboxPermissions ?? "workspace-write", + sandboxMode, "--ask-for-approval", - readOnlyExecution || cliMode === "read-only" ? "on-request" : mappedCodex?.approvalPolicy ?? "untrusted", + approvalPolicy, + ); + commandPreviewParts.push( + "--sandbox", + shellEscapeArg(sandboxMode), + "--ask-for-approval", + shellEscapeArg(approvalPolicy), ); } } else { if (!readOnlyExecution && cliMode === "full-auto") { - commandParts.push("--dangerously-skip-permissions"); + commandArgs.push("--dangerously-skip-permissions"); + commandPreviewParts.push("--dangerously-skip-permissions"); } else { - commandParts.push("--permission-mode", readOnlyExecution || cliMode === "read-only" ? "plan" : "acceptEdits"); + const claudePermissionMode = readOnlyExecution || cliMode === "read-only" ? "plan" : "acceptEdits"; + commandArgs.push("--permission-mode", claudePermissionMode); + commandPreviewParts.push("--permission-mode", shellEscapeArg(claudePermissionMode)); } } - commandParts.push(shellInlineDecodedArg(prompt)); - const startupCommand = commandParts.join(" "); + commandArgs.push(prompt); + commandPreviewParts.push(shellInlineDecodedArg(prompt)); + const startupCommand = commandPreviewParts.join(" "); const session = await args.createTrackedSession({ laneId: args.step.laneId, @@ -3996,6 +4010,8 @@ export function createOrchestratorService({ rows: 36, title, toolType: `${kind}-orchestrated` as TerminalToolType, + command: cliCommand, + args: commandArgs, startupCommand }); return { diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts index 295a1d379..5c2a4646c 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts @@ -76,4 +76,71 @@ describe("providerOrchestratorAdapter", () => { codexConfigSource: "config-toml", })); }); + + it("launches CLI-wrapped fallback workers without shell-only command syntax", async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); + const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })); + const adapter = createProviderOrchestratorAdapter({ + projectRoot, + workspaceRoot: projectRoot, + agentChatService: null, + }); + + const result = await adapter.start({ + run: { + id: "run-1", + missionId: "mission-1", + metadata: {}, + }, + step: { + id: "step-1", + runId: "run-1", + stepKey: "codex-worker", + title: "Codex worker", + stepIndex: 0, + dependencyStepIds: [], + dependencyStepKeys: [], + laneId: "lane-1", + status: "ready", + metadata: { + modelId: "openai/gpt-5.3-codex", + }, + }, + attempt: { + id: "attempt-1", + runId: "run-1", + stepId: "step-1", + }, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { content: "Project context", truncated: false }, + docsRefs: [], + fullDocs: [], + createTrackedSession, + permissionConfig: { + _providers: { + codex: "default", + codexSandbox: "workspace-write", + }, + }, + } as any); + + expect(result.status).toBe("accepted"); + expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ + command: process.execPath, + args: expect.arrayContaining(["-e"]), + env: expect.objectContaining({ + ELECTRON_RUN_AS_NODE: "1", + ADE_MISSION_ID: "mission-1", + ADE_RUN_ID: "run-1", + ADE_STEP_ID: "step-1", + ADE_ATTEMPT_ID: "attempt-1", + ADE_DEFAULT_ROLE: "agent", + }), + startupCommand: expect.stringContaining("exec codex"), + })); + const firstCreateArgs = (createTrackedSession.mock.calls as any[])[0]?.[0]; + expect(firstCreateArgs?.startupCommand).toContain("< "); + }); }); diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts index b50dbf16d..53a6000e7 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OrchestratorExecutorAdapter } from "./orchestratorService"; -import { buildFullPrompt, createBaseOrchestratorAdapter, shellEscapeArg, shellInlineDecodedArg } from "./baseOrchestratorAdapter"; +import { buildFullPrompt, createBaseOrchestratorAdapter, shellEscapeArg, shellInlineDecodedArg, type AdapterLaunchCommand } from "./baseOrchestratorAdapter"; import { classifyWorkerExecutionPath, getModelById, @@ -27,9 +27,21 @@ import { providerPermissionsToLegacyConfig, } from "./permissionMapping"; +const WORKER_ENV_KEYS = [ + "ADE_MISSION_ID", + "ADE_RUN_ID", + "ADE_STEP_ID", + "ADE_ATTEMPT_ID", + "ADE_DEFAULT_ROLE", + "ADE_OWNER_ID", +] as const; + +type WorkerEnvKey = typeof WORKER_ENV_KEYS[number]; +type WorkerEnvVars = Partial> & Record; + /** - * Build environment variable assignments for worker identity. - * These env vars allow ADE-aware CLIs and child processes to resolve caller context. + * Build worker identity env vars. ADE-aware CLIs and child processes use these + * to resolve caller context without POSIX-only inline assignment syntax. */ function buildWorkerEnvVars(args: { missionId: string; @@ -37,15 +49,25 @@ function buildWorkerEnvVars(args: { stepId: string; attemptId: string; ownerId?: string | null; -}): string[] { - return [ - `ADE_MISSION_ID=${shellEscapeArg(args.missionId)}`, - `ADE_RUN_ID=${shellEscapeArg(args.runId)}`, - `ADE_STEP_ID=${shellEscapeArg(args.stepId)}`, - `ADE_ATTEMPT_ID=${shellEscapeArg(args.attemptId)}`, - `ADE_DEFAULT_ROLE=agent`, - ...(args.ownerId ? [`ADE_OWNER_ID=${shellEscapeArg(args.ownerId)}`] : []), - ]; +}): WorkerEnvVars { + return { + ADE_MISSION_ID: args.missionId, + ADE_RUN_ID: args.runId, + ADE_STEP_ID: args.stepId, + ADE_ATTEMPT_ID: args.attemptId, + ADE_DEFAULT_ROLE: "agent", + ...(args.ownerId ? { ADE_OWNER_ID: args.ownerId } : {}), + }; +} + +function previewWorkerEnvVars(env: WorkerEnvVars): string[] { + const parts: string[] = []; + for (const key of WORKER_ENV_KEYS) { + const value = env[key]; + if (!value) continue; + parts.push(key === "ADE_DEFAULT_ROLE" ? `${key}=agent` : `${key}=${shellEscapeArg(value)}`); + } + return parts; } function resolveWorkerOwnerId(metadata: Record | null | undefined): string | null { @@ -69,6 +91,53 @@ function workerPromptFilePath(projectRoot: string, attemptId: string): string { return path.join(resolveAdeLayout(projectRoot).workerPromptsDir, `worker-${attemptId}.txt`); } +function workerLaunchFilePath(projectRoot: string, attemptId: string): string { + return path.join(resolveAdeLayout(projectRoot).workerPromptsDir, `worker-${attemptId}.launch.json`); +} + +const WORKER_CLI_LAUNCHER_SCRIPT = ` +const fs = require("fs"); +const { spawn } = require("child_process"); +const specPath = process.argv[1]; +let done = false; +function finish(code) { + if (done) return; + done = true; + process.exit(typeof code === "number" ? code : 1); +} +const spec = JSON.parse(fs.readFileSync(specPath, "utf8")); +const childEnv = { ...process.env, ...(spec.env || {}) }; +delete childEnv.ELECTRON_RUN_AS_NODE; +const child = spawn(spec.command, Array.isArray(spec.args) ? spec.args : [], { + cwd: spec.cwd || process.cwd(), + env: childEnv, + shell: false, + stdio: [spec.stdinFilePath ? "pipe" : "inherit", "inherit", "inherit"], + windowsHide: false +}); +child.on("error", (err) => { + console.error("[ADE] Failed to launch worker CLI: " + (err && err.message ? err.message : String(err))); + finish(127); +}); +child.on("exit", (code, signal) => { + if (signal) { + console.error("[ADE] Worker CLI exited from signal " + signal + "."); + finish(1); + return; + } + finish(code == null ? 0 : code); +}); +if (spec.stdinFilePath && child.stdin) { + const stream = fs.createReadStream(spec.stdinFilePath); + stream.on("error", (err) => { + console.error("[ADE] Failed to read worker prompt: " + (err && err.message ? err.message : String(err))); + try { child.kill(); } catch {} + finish(1); + }); + stream.pipe(child.stdin); +} +`; + const CLAUDE_READ_ONLY_NATIVE_TOOLS = [ "Read", "Glob", @@ -105,6 +174,45 @@ function writeWorkerPromptFile(args: { return promptPath; } +function writeWorkerLaunchFile(args: { + projectRoot: string; + attemptId: string; + command: string; + commandArgs: string[]; + promptFilePath: string; + env?: Record; +}): string { + const launchPath = workerLaunchFilePath(args.projectRoot, args.attemptId); + fs.mkdirSync(path.dirname(launchPath), { recursive: true }); + fs.writeFileSync( + launchPath, + JSON.stringify({ + command: args.command, + args: args.commandArgs, + stdinFilePath: args.promptFilePath, + env: args.env ?? {}, + }), + "utf8", + ); + return launchPath; +} + +function nodeWorkerLaunch(args: { + startupCommand: string; + launchFilePath: string; + env?: Record; +}): AdapterLaunchCommand { + return { + startupCommand: args.startupCommand, + command: process.execPath, + args: ["-e", WORKER_CLI_LAUNCHER_SCRIPT, args.launchFilePath], + env: { + ELECTRON_RUN_AS_NODE: "1", + ...(args.env ?? {}), + }, + }; +} + export function resolveOpenCodeRuntimeRoot(): string { const startPoints = [ process.cwd(), @@ -135,6 +243,11 @@ export function cleanupWorkerRuntimeFiles(projectRoot: string, attemptId: string } catch { // Ignore — prompt file may already be removed or never created. } + try { + fs.unlinkSync(workerLaunchFilePath(projectRoot, attemptId)); + } catch { + // Ignore — launch file may already be removed or never created. + } } /** @@ -159,6 +272,10 @@ function cleanupStaleWorkerRuntimeFiles(projectRoot: string): void { layout.workerPromptsDir, "worker-", ".txt", ); + cleanupStaleFilesInDir( + layout.workerPromptsDir, + "worker-", ".launch.json", + ); } const VALID_PERMISSION_MODES = new Set(["default", "plan", "edit", "full-auto", "config-toml"]); @@ -300,7 +417,11 @@ export function createProviderOrchestratorAdapter(options?: { buildOverrideCommand: ({ prompt }) => { // For override commands, try to detect the best CLI // Default to claude since it's the most common - return `exec claude -p ${shellInlineDecodedArg(prompt)}`; + return { + startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`, + command: "claude", + args: ["-p", prompt], + }; }, buildStartupCommand: ({ prompt, model, step, run, attempt, permissionConfig, teamRuntime }) => { @@ -332,16 +453,20 @@ export function createProviderOrchestratorAdapter(options?: { ? buildClaudeReadOnlyWorkerAllowedTools() : dedupeAllowedTools(configuredAllowedTools); - const parts: string[] = ["claude", "--model", shellEscapeArg(cliModel)]; + const commandArgs: string[] = ["--model", cliModel]; + const previewParts: string[] = ["claude", "--model", shellEscapeArg(cliModel)]; if (dangerouslySkip) { - parts.push("--dangerously-skip-permissions"); + commandArgs.push("--dangerously-skip-permissions"); + previewParts.push("--dangerously-skip-permissions"); } else { - parts.push("--permission-mode", shellEscapeArg(permissionMode)); + commandArgs.push("--permission-mode", permissionMode); + previewParts.push("--permission-mode", shellEscapeArg(permissionMode)); } if (allowedTools.length > 0) { - parts.push("--allowedTools", shellEscapeArg(allowedTools.join(","))); + commandArgs.push("--allowedTools", allowedTools.join(",")); + previewParts.push("--allowedTools", shellEscapeArg(allowedTools.join(","))); } const promptFilePath = writeWorkerPromptFile({ @@ -349,20 +474,36 @@ export function createProviderOrchestratorAdapter(options?: { attemptId: attempt.id, prompt, }); - parts.push("-p", `"$(cat ${shellEscapeArg(promptFilePath)})"`); + commandArgs.push("-p"); + previewParts.push("-p", `"$(cat ${shellEscapeArg(promptFilePath)})"`); - const envParts: string[] = [...workerEnv]; + const launchEnv: Record = { ...workerEnv }; + const envParts = previewWorkerEnvVars(workerEnv); if ( teamRuntime?.enabled && teamRuntime.allowClaudeAgentTeams !== false && (teamRuntime.targetProvider === "claude" || teamRuntime.targetProvider === "auto") ) { + launchEnv.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; envParts.push("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1"); } - const cmd = parts.join(" "); + const cmd = previewParts.join(" "); const startup = `exec ${cmd}`; - return envParts.length > 0 ? `${envParts.join(" ")} ${startup}` : startup; + const startupCommand = envParts.length > 0 ? `${envParts.join(" ")} ${startup}` : startup; + const launchFilePath = writeWorkerLaunchFile({ + projectRoot: canonicalProjectRoot, + attemptId: attempt.id, + command: "claude", + commandArgs, + promptFilePath, + env: launchEnv, + }); + return nodeWorkerLaunch({ + startupCommand, + launchFilePath, + env: launchEnv, + }); } if (descriptor?.isCliWrapped && descriptor.family === "openai") { @@ -378,37 +519,61 @@ export function createProviderOrchestratorAdapter(options?: { : mappedCodex?.sandbox ?? effectivePermissionConfig?._providers?.codexSandbox ?? effectivePermissionConfig?.cli?.sandboxPermissions ?? "workspace-write"; const writablePaths = effectivePermissionConfig?._providers?.writablePaths ?? effectivePermissionConfig?.cli?.writablePaths ?? []; - const parts: string[] = [ + const commandArgs: string[] = ["--model", resolveCodexCliModel(descriptor.providerModelId)]; + const previewParts: string[] = [ "codex", "--model", shellEscapeArg(resolveCodexCliModel(descriptor.providerModelId)), ]; if (!useCodexConfig) { - parts.push("-a", shellEscapeArg(approvalPolicy), "-s", shellEscapeArg(sandboxMode)); + commandArgs.push("-a", approvalPolicy, "-s", sandboxMode); + previewParts.push("-a", shellEscapeArg(approvalPolicy), "-s", shellEscapeArg(sandboxMode)); } - parts.push("exec"); + commandArgs.push("exec"); + previewParts.push("exec"); for (const wp of writablePaths) { - if (wp.trim().length) parts.push("--add-dir", shellEscapeArg(wp.trim())); + if (!wp.trim().length) continue; + commandArgs.push("--add-dir", wp.trim()); + previewParts.push("--add-dir", shellEscapeArg(wp.trim())); } - parts.push("-"); + commandArgs.push("-"); + previewParts.push("-"); - const envParts = [...workerEnv]; - const cmd = parts.join(" "); + const launchEnv: Record = { ...workerEnv }; + const envParts = previewWorkerEnvVars(workerEnv); + const cmd = previewParts.join(" "); const promptFilePath = writeWorkerPromptFile({ projectRoot: canonicalProjectRoot, attemptId: attempt.id, prompt, }); - const startup = `${envParts.length > 0 ? `${envParts.join(" ")} ` : ""}exec ${cmd} < ${shellEscapeArg(promptFilePath)}`; - return startup; + const startupCommand = `${envParts.length > 0 ? `${envParts.join(" ")} ` : ""}exec ${cmd} < ${shellEscapeArg(promptFilePath)}`; + const launchFilePath = writeWorkerLaunchFile({ + projectRoot: canonicalProjectRoot, + attemptId: attempt.id, + command: "codex", + commandArgs, + promptFilePath, + env: launchEnv, + }); + return nodeWorkerLaunch({ + startupCommand, + launchFilePath, + env: launchEnv, + }); } // Non-CLI or unknown models can still run via the managed chat path. // This shell fallback only exists for CLI-wrapped workers. const unsupportedReason = getProviderAdapterUnsupportedModelReason(model) ?? `Model '${model}' is not supported by the provider adapter.`; const failureMessage = `[ADE] Shell-startup fallback for the provider adapter only supports CLI-wrapped Anthropic/OpenAI models. ${unsupportedReason}`; - return `printf '%s\\n' ${shellEscapeArg(failureMessage)} >&2; exit 64`; + return { + startupCommand: `printf '%s\\n' ${shellEscapeArg(failureMessage)} >&2; exit 64`, + command: process.execPath, + args: ["-e", "console.error(process.argv[1]); process.exit(64);", failureMessage], + env: { ELECTRON_RUN_AS_NODE: "1" }, + }; }, buildAcceptedMetadata: ({ model, filePatterns, steeringDirectiveCount, promptLength, reasoningEffort, startupCommandPreview }) => { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 895968ab3..40eee8b74 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; @@ -171,6 +171,15 @@ vi.mock("../../utils/terminalSessionSignals", async () => { import { createPtyService, PTY_AI_TITLE_DEBOUNCE_MS } from "./ptyService"; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -320,6 +329,10 @@ function createHarness(overrides: { // --------------------------------------------------------------------------- describe("ptyService", () => { + afterEach(() => { + setPlatform(originalPlatform); + }); + beforeEach(() => { vi.clearAllMocks(); mocks.existsSyncResults.clear(); @@ -418,6 +431,56 @@ describe("ptyService", () => { ); }); + it("does not type startupCommand preview into direct command sessions", async () => { + const { service, mockPty } = createHarness(); + + await service.create({ + laneId: "lane-1", + title: "Direct worker", + cols: 80, + rows: 24, + command: "codex", + args: ["exec", "-"], + startupCommand: "ADE_RUN_ID=run-1 exec codex exec - < prompt.txt", + }); + + expect(mockPty.write).not.toHaveBeenCalled(); + }); + + it("wraps direct Windows command shims through cmd.exe", async () => { + setPlatform("win32"); + const harness = createHarness(); + const ptyService = createPtyService({ + projectRoot: "/tmp/test-project", + transcriptsDir: "/tmp/transcripts", + laneService: harness.laneService as any, + sessionService: harness.sessionService as any, + logger: harness.logger as any, + broadcastData: vi.fn(), + broadcastExit: vi.fn(), + onSessionEnded: vi.fn(), + onSessionRuntimeSignal: vi.fn(), + loadPty: harness.loadPty as any, + }); + + await ptyService.create({ + laneId: "lane-1", + title: "Direct command", + cols: 80, + rows: 24, + command: "npm.cmd", + args: ["run", "dev"], + env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + }); + + const ptyLib = harness.loadPty.mock.results.at(-1)?.value as { spawn: ReturnType }; + expect(ptyLib.spawn).toHaveBeenCalledWith( + "C:\\Windows\\System32\\cmd.exe", + ["/d", "/s", "/c", '"npm.cmd" "run" "dev"'], + expect.any(Object), + ); + }); + it("registers the session via sessionService.create", async () => { const { service, sessionService } = createHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 88ff0310e..f277724e9 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -11,6 +11,7 @@ import type { createSessionService } from "../sessions/sessionService"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createProjectConfigService } from "../config/projectConfigService"; import { runGit } from "../git/git"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; import type { PtyDataEvent, PtyExitEvent, @@ -1119,7 +1120,8 @@ export function createPtyService({ let created: IPty | null = null; if (directCommand) { try { - created = ptyLib.spawn(directCommand, directArgs, opts); + const invocation = resolveCliSpawnInvocation(directCommand, directArgs, launchEnv); + created = ptyLib.spawn(invocation.command, invocation.args, opts); } catch (err) { lastErr = err; } @@ -1318,7 +1320,7 @@ export function createPtyService({ closeEntry(ptyId, exitCode ?? null); }); - if (startupCommand) { + if (startupCommand && !directCommand) { try { pty.write(`${startupCommand}\r`); setRuntimeState(sessionId, "running"); diff --git a/apps/desktop/src/main/services/shared/processExecution.test.ts b/apps/desktop/src/main/services/shared/processExecution.test.ts new file mode 100644 index 000000000..76727e57f --- /dev/null +++ b/apps/desktop/src/main/services/shared/processExecution.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { + quoteWindowsCmdArg, + resolveCliSpawnInvocation, + resolveWindowsCmdInvocation, + shouldUseWindowsCmdWrapper, +} from "./processExecution"; + +describe("processExecution", () => { + it("detects Windows command shims and extensionless commands", () => { + expect(shouldUseWindowsCmdWrapper("codex", "win32")).toBe(true); + expect(shouldUseWindowsCmdWrapper("C:\\tools\\codex.cmd", "win32")).toBe(true); + expect(shouldUseWindowsCmdWrapper("C:\\tools\\codex.bat", "win32")).toBe(true); + expect(shouldUseWindowsCmdWrapper("C:\\tools\\codex.exe", "win32")).toBe(false); + expect(shouldUseWindowsCmdWrapper("codex", "linux")).toBe(false); + }); + + it("quotes cmd arguments consistently", () => { + expect(quoteWindowsCmdArg("C:\\Program Files\\tool.cmd")).toBe('"C:\\Program Files\\tool.cmd"'); + expect(quoteWindowsCmdArg("100% done")).toBe('"100%% done"'); + expect(quoteWindowsCmdArg('say "hi"')).toBe('"say ""hi"""'); + }); + + it("wraps Windows shim invocations with ComSpec", () => { + const invocation = resolveCliSpawnInvocation( + "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd", + ["exec", "--cd", "C:\\repo path"], + { ComSpec: "C:\\Windows\\System32\\cmd.exe" } as NodeJS.ProcessEnv, + "win32", + ); + + expect(invocation).toEqual({ + command: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + '"C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd" "exec" "--cd" "C:\\repo path"', + ], + windowsVerbatimArguments: true, + }); + }); + + it("builds explicit Windows shell invocations", () => { + expect(resolveWindowsCmdInvocation("npm", ["run", "test"], {} as NodeJS.ProcessEnv)).toEqual({ + command: "cmd.exe", + args: ["/d", "/s", "/c", '"npm" "run" "test"'], + windowsVerbatimArguments: true, + }); + }); +}); diff --git a/apps/desktop/src/main/services/shared/processExecution.ts b/apps/desktop/src/main/services/shared/processExecution.ts new file mode 100644 index 000000000..be0aaef0a --- /dev/null +++ b/apps/desktop/src/main/services/shared/processExecution.ts @@ -0,0 +1,105 @@ +import { spawnSync, type ChildProcess } from "node:child_process"; +import path from "node:path"; + +export type SpawnInvocation = { + command: string; + args: string[]; + windowsVerbatimArguments?: boolean; +}; + +export type ProcessTreeFailureDetail = { + pid: number; + status: number | null; + stdout: string; + stderr: string; + error: unknown; +}; + +export function processOutputToString(value: Buffer | string | null | undefined): string { + return Buffer.isBuffer(value) ? value.toString("utf8") : String(value ?? ""); +} + +export function quoteWindowsCmdArg(value: string): string { + return `"${value.replace(/"/g, "\"\"").replace(/%/g, "%%")}"`; +} + +export function shouldUseWindowsCmdWrapper(command: string, platform: NodeJS.Platform = process.platform): boolean { + if (platform !== "win32") return false; + const ext = path.win32.extname(command).toLowerCase(); + return ext === "" || ext === ".cmd" || ext === ".bat"; +} + +export function resolveWindowsCmdInvocation( + command: string, + args: string[], + env: NodeJS.ProcessEnv = process.env, +): SpawnInvocation { + const comSpec = env.ComSpec?.trim() || "cmd.exe"; + const cmdLine = [command, ...args].map(quoteWindowsCmdArg).join(" "); + return { + command: comSpec, + args: ["/d", "/s", "/c", cmdLine], + windowsVerbatimArguments: true, + }; +} + +export function resolveCliSpawnInvocation( + command: string, + args: string[], + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, +): SpawnInvocation { + if (shouldUseWindowsCmdWrapper(command, platform)) { + return resolveWindowsCmdInvocation(command, args, env); + } + return { + command, + args, + windowsVerbatimArguments: false, + }; +} + +export function killWindowsProcessTree( + pid: number, + onFailure?: (detail: ProcessTreeFailureDetail) => void, +): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + const out = spawnSync("taskkill.exe", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }); + if (!out.error && out.status === 0) return true; + onFailure?.({ + pid, + status: out.status, + stdout: processOutputToString(out.stdout), + stderr: processOutputToString(out.stderr), + error: out.error ?? null, + }); + } catch (error) { + onFailure?.({ + pid, + status: null, + stdout: "", + stderr: "", + error, + }); + } + return false; +} + +export function terminateProcessTree( + child: Pick, + signal: NodeJS.Signals = "SIGTERM", + onWindowsTaskkillFailure?: (detail: ProcessTreeFailureDetail) => void, +): boolean { + if (process.platform === "win32") { + if (child.exitCode !== null || child.signalCode !== null) return false; + if (typeof child.pid === "number" && killWindowsProcessTree(child.pid, onWindowsTaskkillFailure)) { + return true; + } + } + try { + return child.kill(signal); + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index 84408a44b..5636a0091 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -9,6 +9,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { resolveCliSpawnInvocation, terminateProcessTree } from "./processExecution"; // ── Type guards ───────────────────────────────────────────────────── @@ -83,10 +84,14 @@ export function spawnAsync( ): Promise<{ status: number | null; stdout: string; stderr: string }> { return new Promise((resolve) => { try { - const child = spawn(command, args, { + const invocation = resolveCliSpawnInvocation(command, args); + const child = spawn(invocation.command, invocation.args, { stdio: ["ignore", "pipe", "pipe"], - timeout: opts?.timeout ?? 5_000, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); + const timeout = setTimeout(() => { + terminateProcessTree(child); + }, opts?.timeout ?? 5_000); let stdout = ""; let stderr = ""; const limit = opts?.maxOutputBytes ?? 10_000; @@ -96,8 +101,14 @@ export function spawnAsync( child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8").slice(0, Math.max(0, limit - stderr.length)); }); - child.on("error", () => resolve({ status: null, stdout, stderr })); - child.on("close", (code) => resolve({ status: code, stdout, stderr })); + child.on("error", () => { + clearTimeout(timeout); + resolve({ status: null, stdout, stderr }); + }); + child.on("close", (code) => { + clearTimeout(timeout); + resolve({ status: code, stdout, stderr }); + }); } catch { resolve({ status: null, stdout: "", stderr: "" }); } diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 05a6daf2a..e806bb60f 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -18,6 +18,7 @@ const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncC export type SqlValue = string | number | null | Uint8Array; export type AdeDbSyncApi = { + isAvailable?: () => boolean; getSiteId: () => string; getDbVersion: () => number; exportChangesSince: (version: number) => CrsqlChangeRow[]; @@ -3314,6 +3315,7 @@ export async function openKvDb(dbPath: string, logger: Logger): Promise { }; const sync: AdeDbSyncApi = { + isAvailable: () => hasCrsqlite, getSiteId: () => desiredSiteId, getDbVersion: () => { if (!hasCrsqlite) return 0; diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index b1f0fa1b5..76d42ba94 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -300,6 +300,7 @@ export function createSyncService(args: SyncServiceArgs) { let disposed = false; const hostStartupEnabled = args.hostStartupEnabled !== false; let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; + const isCrdtSyncAvailable = (): boolean => args.db.sync.isAvailable?.() !== false; let activeLocalLanePresenceIds: string[] = []; const localLanePresenceHeartbeatTimer = setInterval(() => { if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; @@ -386,7 +387,7 @@ export function createSyncService(args: SyncServiceArgs) { }; const startHostIfNeeded = async (): Promise => { - if (!hostStartupEnabled) { + if (!hostStartupEnabled || !isCrdtSyncAvailable()) { if (hostService) { await stopHostIfRunning(); } @@ -682,7 +683,8 @@ export function createSyncService(args: SyncServiceArgs) { ? cluster.brainDeviceId === localDevice.deviceId : !savedDraft && !syncPeerService.isConnected(); const role = isLocalBrain ? "brain" : "viewer"; - const canHostPhonePairing = role === "brain" && hostStartupEnabled; + const crdtSyncAvailable = isCrdtSyncAvailable(); + const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; const client = syncPeerService.getStatus(); const mode = role === "viewer" @@ -717,9 +719,13 @@ export function createSyncService(args: SyncServiceArgs) { client, transferReadiness: await getTransferReadiness(), survivableStateText: - "Paused and idle state will remain available on the new host.", + crdtSyncAvailable + ? "Paused and idle state will remain available on the new host." + : "Desktop sync is disabled because the CRDT database extension is unavailable on this platform.", blockingStateText: - "Live missions, chats, terminals, or run processes must stop first.", + crdtSyncAvailable + ? "Live missions, chats, terminals, or run processes must stop first." + : "Install a Windows cr-sqlite runtime before pairing or syncing devices.", }; }, diff --git a/apps/desktop/src/main/services/tests/testService.ts b/apps/desktop/src/main/services/tests/testService.ts index 3ef56dd0e..016fc8e30 100644 --- a/apps/desktop/src/main/services/tests/testService.ts +++ b/apps/desktop/src/main/services/tests/testService.ts @@ -22,6 +22,7 @@ import type { createProjectConfigService } from "../config/projectConfigService" import type { createLaneService } from "../lanes/laneService"; import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; import { nowIso, resolvePathWithinRoot } from "../shared/utils"; +import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; type ActiveRunEntry = { laneId: string; @@ -295,11 +296,17 @@ export function createTestService({ } })(); - const child: ChildProcessByStdio = spawn(suite.command[0]!, suite.command.slice(1), { + const invocation = resolveCliSpawnInvocation(suite.command[0]!, suite.command.slice(1), { + ...process.env, + ...suite.env, + ...(overlay.env ?? {}) + }); + const child: ChildProcessByStdio = spawn(invocation.command, invocation.args, { cwd, env: { ...process.env, ...suite.env, ...(overlay.env ?? {}) }, shell: false, - stdio: ["ignore", "pipe", "pipe"] + stdio: ["ignore", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); const entry: ActiveRunEntry = { @@ -354,18 +361,10 @@ export function createTestService({ if (suite.timeoutMs && suite.timeoutMs > 0) { entry.timeoutTimer = setTimeout(() => { entry.stopIntent = "timed_out"; - try { - child.kill("SIGTERM"); - } catch { - // ignore - } + terminateProcessTree(child, "SIGTERM"); entry.killTimer = setTimeout(() => { if (activeRuns.has(runId)) { - try { - child.kill("SIGKILL"); - } catch { - // ignore - } + terminateProcessTree(child, "SIGKILL"); } }, 3000); }, suite.timeoutMs); @@ -411,18 +410,10 @@ export function createTestService({ entry.killTimer = null; } entry.stopIntent = "canceled"; - try { - entry.child.kill("SIGTERM"); - } catch { - // ignore - } + terminateProcessTree(entry.child, "SIGTERM"); entry.killTimer = setTimeout(() => { if (!activeRuns.has(arg.runId)) return; - try { - entry.child.kill("SIGKILL"); - } catch { - // ignore - } + terminateProcessTree(entry.child, "SIGKILL"); }, 3000); }, diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 644e7528c..09769ad08 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -1,7 +1,24 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; + +const mockState = vi.hoisted(() => ({ + spawn: vi.fn(), + spawnSync: vi.fn(), + resolveCodexExecutable: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: (...args: unknown[]) => mockState.spawn(...args), + spawnSync: (...args: unknown[]) => mockState.spawnSync(...args), +})); + +vi.mock("../ai/codexExecutable", () => ({ + resolveCodexExecutable: (...args: unknown[]) => mockState.resolveCodexExecutable(...args), +})); + import { createUsageTrackingService, _testing } from "./usageTrackingService"; const { @@ -11,6 +28,7 @@ const { isTokenExpiredOrExpiring, parseClaudeWindows, parseCodexRateLimitWindows, + pollCodexViaCliRpc, resolveTokenPrice, } = _testing; @@ -29,6 +47,61 @@ function makeTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "ade-usage-test-")); } +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} + +function createFakeCodexChild({ + closeCode = 0, + stdout = "", + stderr = "", + stdinError = null, +}: { + closeCode?: number | null; + stdout?: string; + stderr?: string; + stdinError?: Error | null; +}) { + const child = new EventEmitter() as any; + const stdoutEmitter = new EventEmitter(); + const stderrEmitter = new EventEmitter(); + const stdinEmitter = new EventEmitter() as any; + const written: string[] = []; + + stdinEmitter.write = vi.fn((chunk: string) => { + written.push(chunk); + return true; + }); + stdinEmitter.end = vi.fn(() => { + queueMicrotask(() => { + if (stdinError) { + stdinEmitter.emit("error", stdinError); + return; + } + if (stdout) stdoutEmitter.emit("data", Buffer.from(stdout)); + if (stderr) stderrEmitter.emit("data", Buffer.from(stderr)); + child.emit("close", closeCode); + }); + }); + + child.stdout = stdoutEmitter; + child.stderr = stderrEmitter; + child.stdin = stdinEmitter; + child.kill = vi.fn(); + + return { child, written, stdinEmitter, stdoutEmitter, stderrEmitter }; +} + +beforeEach(() => { + mockState.spawn.mockReset(); + mockState.spawnSync.mockReset(); + mockState.spawnSync.mockReturnValue({ status: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }); + mockState.resolveCodexExecutable.mockReset(); +}); + // ── calculatePacing ────────────────────────────────────────────── describe("calculatePacing", () => { @@ -374,6 +447,142 @@ describe("parseCodexRateLimitWindows", () => { }); }); +describe("pollCodexViaCliRpc", () => { + const originalPlatform = process.platform; + const originalComSpec = process.env.ComSpec; + + beforeEach(() => { + setPlatform("win32"); + process.env.ComSpec = "cmd.exe"; + }); + + afterEach(() => { + setPlatform(originalPlatform); + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } + }); + + it("wraps extensionless Windows codex paths with cmd.exe and writes the combined JSONL payload once", async () => { + const fake = createFakeCodexChild({ + stdout: `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + rateLimits: { + primary: { usedPercent: 17, resetsAt: 1773446952 }, + secondary: { usedPercent: 64, resetsAt: 1773853354 }, + }, + }, + })}\n`, + }); + + mockState.resolveCodexExecutable.mockReturnValue({ + path: "C:\\Users\\me\\AppData\\Local\\Programs\\codex", + source: "path", + }); + mockState.spawn.mockReturnValue(fake.child); + + const logger = createLogger(); + const result = await pollCodexViaCliRpc(logger as any); + + expect(mockState.spawn).toHaveBeenCalledTimes(1); + expect(mockState.spawn).toHaveBeenCalledWith( + "cmd.exe", + ["/d", "/s", "/c", '"C:\\Users\\me\\AppData\\Local\\Programs\\codex" "-s" "read-only" "-a" "untrusted" "app-server"'], + expect.objectContaining({ windowsVerbatimArguments: true }), + ); + expect(fake.stdinEmitter.write).toHaveBeenCalledTimes(1); + expect(fake.written[0]).toMatch(/\n$/); + expect(fake.written[0]).not.toMatch(/\n\n$/); + expect(result.errors).toEqual([]); + expect(result.windows).toHaveLength(2); + expect(result.windows.find((window) => window.windowType === "five_hour")?.percentUsed).toBe(17); + }); + + it("spawns codex directly on POSIX without Windows shell options", async () => { + setPlatform("linux"); + const fake = createFakeCodexChild({ + stdout: `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { + rateLimits: { + primary: { usedPercent: 17, resetsAt: 1773446952 }, + secondary: { usedPercent: 64, resetsAt: 1773853354 }, + }, + }, + })}\n`, + }); + + mockState.resolveCodexExecutable.mockReturnValue({ + path: "codex", + source: "path", + }); + mockState.spawn.mockReturnValue(fake.child); + + const logger = createLogger(); + const result = await pollCodexViaCliRpc(logger as any); + + expect(mockState.spawn).toHaveBeenCalledTimes(1); + const [spawnFile, spawnArgs, spawnOptions] = mockState.spawn.mock.calls[0]!; + expect(spawnFile).toBe("codex"); + expect(spawnArgs).toEqual(["-s", "read-only", "-a", "untrusted", "app-server"]); + expect(spawnOptions).toEqual(expect.objectContaining({ windowsVerbatimArguments: false })); + expect(fake.stdinEmitter.write).toHaveBeenCalledTimes(1); + expect(fake.written[0]).toMatch(/\n$/); + expect(fake.written[0]).not.toMatch(/\n\n$/); + expect(result.errors).toEqual([]); + expect(result.windows).toHaveLength(2); + expect(result.windows.find((window) => window.windowType === "five_hour")?.percentUsed).toBe(17); + }); + + it("routes stdin EPIPE errors through cleanup and reports a CLI RPC failure", async () => { + const stdinError = new Error("EPIPE"); + const fake = createFakeCodexChild({ stdinError }); + + mockState.resolveCodexExecutable.mockReturnValue({ + path: "codex.exe", + source: "path", + }); + mockState.spawn.mockReturnValue(fake.child); + + const logger = createLogger(); + const result = await pollCodexViaCliRpc(logger as any); + + expect(result.windows).toEqual([]); + expect(result.errors[0]).toContain("codex: CLI RPC error:"); + expect(logger.warn).toHaveBeenCalledWith( + "usage.poll.codex_cli_rpc_stdin_failed", + expect.objectContaining({ error: "EPIPE" }), + ); + }); + + it("logs non-zero exits after parsing close output", async () => { + const fake = createFakeCodexChild({ + closeCode: 1, + stderr: "codex said no\n", + }); + + mockState.resolveCodexExecutable.mockReturnValue({ + path: "codex.exe", + source: "path", + }); + mockState.spawn.mockReturnValue(fake.child); + + const logger = createLogger(); + const result = await pollCodexViaCliRpc(logger as any); + + expect(result.errors).toContain("codex: CLI RPC exited with non-zero code"); + expect(logger.warn).toHaveBeenCalledWith( + "usage.poll.codex_cli_rpc_non_zero_exit", + expect.objectContaining({ exitCode: 1, stderr: "codex said no\n" }), + ); + }); +}); + // ── Service Integration ────────────────────────────────────────── describe("createUsageTrackingService", () => { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.ts b/apps/desktop/src/main/services/usage/usageTrackingService.ts index 30ff6c85f..ca00eac69 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.ts @@ -9,6 +9,7 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; +import { spawn } from "node:child_process"; import type { Logger } from "../logging/logger"; import type { UsageProvider, @@ -27,8 +28,9 @@ import { readClaudeCredentialsWithRefresh, readCodexCredentials, refreshClaudeCredentials, - runShellCommand, } from "../ai/providerCredentialSources"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; +import { resolveCliSpawnInvocation, terminateProcessTree } from "../shared/processExecution"; // ── Constants ──────────────────────────────────────────────────── @@ -37,6 +39,7 @@ const MIN_POLL_INTERVAL_MS = 60_000; // 1 min const MAX_POLL_INTERVAL_MS = 15 * 60_000; // 15 min const COST_CACHE_TTL_MS = 60_000; // 60s const CODEX_TOKEN_REFRESH_DAYS = 8; +const CODEX_CLI_RPC_TIMEOUT_MS = 10_000; const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; @@ -347,7 +350,6 @@ async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindo const errors: string[] = []; try { - // Initialize the Codex CLI JSON-RPC connection. const initPayload = JSON.stringify({ jsonrpc: "2.0", id: 0, @@ -378,12 +380,94 @@ async function pollCodexViaCliRpc(logger: Logger): Promise<{ windows: UsageWindo const combined = `${initPayload}\n${initializedPayload}\n${rateLimitsPayload}\n`; - const result = await runShellCommand( - `echo '${combined.replace(/'/g, "'\\''")}' | codex -s read-only -a untrusted app-server 2>/dev/null`, - 10_000 + const codexPath = resolveCodexExecutable().path; + const env = { ...process.env }; + const invocation = resolveCliSpawnInvocation( + codexPath, + ["-s", "read-only", "-a", "untrusted", "app-server"], + env, + ); + + const result = await new Promise<{ stdout: string; stderr: string; exitCode: number | null }>( + (resolve, reject) => { + let settled = false; + let timer: ReturnType | null = null; + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + callback(); + }; + const child = spawn(invocation.command, invocation.args, { + stdio: ["pipe", "pipe", "pipe"], + env, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + }); + + let stdout = ""; + let stderr = ""; + const maxStdout = 50_000; + const maxStderr = 10_000; + child.stdout?.on("data", (chunk: Buffer) => { + if (stdout.length >= maxStdout) return; + const s = chunk.toString("utf8"); + stdout += s.slice(0, maxStdout - stdout.length); + }); + child.stderr?.on("data", (chunk: Buffer) => { + if (stderr.length >= maxStderr) return; + const s = chunk.toString("utf8"); + stderr += s.slice(0, maxStderr - stderr.length); + }); + + timer = setTimeout(() => { + terminateProcessTree(child, "SIGKILL", (detail) => { + logger.warn("usage.poll.codex_cli_rpc_taskkill_failed", { + ...detail, + error: detail.error ? getErrorMessage(detail.error) : null, + }); + }); + logger.warn("usage.poll.codex_cli_rpc_timeout", { + timeoutMs: CODEX_CLI_RPC_TIMEOUT_MS, + }); + finish(() => reject(new Error(`codex CLI RPC timed out after ${CODEX_CLI_RPC_TIMEOUT_MS}ms`))); + }, CODEX_CLI_RPC_TIMEOUT_MS); + + child.on("error", (error) => { + logger.warn("usage.poll.codex_cli_rpc_spawn_failed", { + error: getErrorMessage(error), + }); + finish(() => reject(error)); + }); + child.on("close", (code) => { + finish(() => resolve({ stdout, stderr, exitCode: code })); + }); + child.stdin?.on("error", (error) => { + logger.warn("usage.poll.codex_cli_rpc_stdin_failed", { + error: getErrorMessage(error), + }); + finish(() => reject(error)); + }); + + try { + child.stdin?.write(combined); + child.stdin?.end(); + } catch (err) { + logger.warn("usage.poll.codex_cli_rpc_stdin_failed", { + error: getErrorMessage(err), + }); + finish(() => reject(err)); + } + }, ); if (result.exitCode !== 0) { + logger.warn("usage.poll.codex_cli_rpc_non_zero_exit", { + exitCode: result.exitCode, + stderr: result.stderr, + }); errors.push("codex: CLI RPC exited with non-zero code"); return { windows, errors }; } @@ -915,4 +999,5 @@ export const _testing = { fetchJson, findJsonlFiles, resolveTokenPrice, + pollCodexViaCliRpc, }; diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 2f3afef3c..3cd7f0ad8 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -45,6 +45,8 @@ type BrowseRow = { function stripTrailingSeparator(input: string): string { if (input.length <= 1) return input; + if (/^[a-z]:[\\/]$/i.test(input)) return input; + if (/^[/\\]{2}[^/\\]+[/\\][^/\\]+[/\\]?$/i.test(input)) return input; return input.endsWith("/") || input.endsWith("\\") ? input.slice(0, -1) : input; } diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 624c5c0ec..5a7768907 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -4,6 +4,61 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, type RenderResult } from "@testing-library/react"; import type { ComponentProps } from "react"; import { AgentChatComposer } from "./AgentChatComposer"; +import { modifierKeyLabel } from "../../lib/platform"; + +function installMatchMediaMock(): void { + if (typeof window.matchMedia === "function") return; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +vi.mock("@emoji-mart/data", () => ({ + default: { categories: [], emojis: {}, aliases: {}, sheet: { cols: 0, rows: 0 } }, +})); + +vi.mock("@emoji-mart/data/sets/15/native.json", () => ({ + default: { categories: [], emojis: {}, aliases: {}, sheet: { cols: 0, rows: 0 } }, +})); + +vi.mock("@lobehub/icons", () => { + const brand = () => { + const Component = () => null; + Object.assign(Component, { + Avatar: () => null, + Color: () => null, + Combine: () => null, + Text: () => null, + colorPrimary: "#888", + title: "stub", + }); + return Component; + }; + return { + Anthropic: brand(), + Claude: brand(), + Codex: brand(), + Cursor: brand(), + Gemini: brand(), + Google: brand(), + Grok: brand(), + Groq: brand(), + OpenAI: brand(), + OpenCode: brand(), + OpenRouter: brand(), + XAI: brand(), + }; +}); afterEach(cleanup); @@ -52,6 +107,7 @@ function buildComposerProps(overrides: Partial> = {}) { + installMatchMediaMock(); const props = buildComposerProps(overrides); const view = render(); @@ -88,7 +144,7 @@ describe("AgentChatComposer", () => { it("stop only interrupts the active turn", () => { const props = renderComposer(); - const stopButtons = screen.getAllByTitle("Stop the active turn only (Cmd+.)"); + const stopButtons = screen.getAllByTitle(`Stop the active turn only (${modifierKeyLabel}+.)`); fireEvent.click(stopButtons[stopButtons.length - 1]!); expect(props.onInterrupt).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index b7c05fd04..bb6e7c235 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -32,6 +32,7 @@ import { CURSOR_MODE_LABELS } from "../../../shared/cursorModes"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { ChatProposedPlanCard } from "./ChatProposedPlanCard"; import { ChatCommandMenu, type ChatCommandMenuItem, type ChatCommandMenuHandle } from "./ChatCommandMenu"; +import { modifierKeyLabel } from "../../lib/platform"; const MAX_TEMP_ATTACHMENT_BYTES = 10 * 1024 * 1024; @@ -1880,7 +1881,7 @@ export function AgentChatComposer({