diff --git a/.claude/commands/finalize.md b/.claude/commands/finalize.md index 5e4ace643..1151b6ef3 100644 --- a/.claude/commands/finalize.md +++ b/.claude/commands/finalize.md @@ -12,14 +12,6 @@ It guarantees three outcomes: 2. Docs are current 3. Local CI checks pass -It does **not** guarantee that remote PR review is complete after a push. GitHub's -first visible check list can look quiet before delayed checks, bot reviews, and -inline comments arrive. After pushing a finalized branch, hand off to -`/shipLane` or an equivalent PR poll loop. Use the ship-lane cadence: poll -immediately after a push, wait 270s if CI has not registered, wait 720s while CI -is running, and wait 1800s only when CI is done and the PR is just waiting on -review. - **Usage:** `/finalize` ## Execution Mode: Autonomous @@ -420,34 +412,6 @@ Kill selectively only if the parent is clearly gone (PPID == 1 on macOS/Linux). Report killed PIDs in the Phase 4 summary under "Cleanup" so the user can see what happened. -### 3k. Remote PR poll handoff - -If this finalize run is followed by a push or PR update, do not treat the first -`gh pr checks` result as authoritative proof that remote review is done. Some -checks and bot review systems appear late or post comments after the initial CI -surface looks complete. In particular: - -- `gh pr checks` can omit delayed or still-registering provider checks. -- Bot reviewers can post inline comments after CI jobs have already gone green. -- The absence of new comments immediately after a push is not evidence that no - more comments are coming. - -Handoff rule: - -```bash -# After the branch is pushed, continue with /shipLane or equivalent: -# - poll PR checks, status rollup, review comments, issue comments, and reviews -# - poll immediately after a push so early CI registration/failures are visible -# - if CI has not started yet, wait 270s -# - if any check is QUEUED/IN_PROGRESS/PENDING, wait 720s -# - if CI is done and the PR is only waiting on review, wait 1800s -# - poll again before declaring the PR clean or ready for human merge -``` - -If `/finalize` is running as a sub-step inside `/shipLane`, return a summary that -explicitly says remote checks/comments still require the ship-lane poll loop. -Do not report "PR clean" from `/finalize` alone. - --- ## Phase 4: Summary @@ -483,11 +447,6 @@ Do not report "PR clean" from `/finalize` alone. ### Cleanup: - Orphan processes killed: N (PIDs: [list] or "none") -### Remote PR Handoff: -- Post-push polling required: YES -- Poll loop: `/shipLane` branch-specific cadence -- Reason: delayed checks and bot comments may arrive after first visible green state - ### Status: Ready to push / Issues found ``` @@ -507,4 +466,3 @@ Before marking complete: - [ ] All apps build successfully - [ ] Doc validation passed - [ ] Orphan worker processes cleaned up (vitest/tsup/tsc) — scoped to apps/ paths only -- [ ] Remote PR review is not declared clean by finalize alone; after push, `/shipLane` or an equivalent poll loop must use the branch-specific cadence and re-check comments/reviews diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index bc1d26542..309d60a9e 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"bab81aa2-1bdb-495e-9bf6-3d87ede93f1f","pid":85962,"procStart":"Thu Apr 23 05:29:47 2026","acquiredAt":1776922287064} \ No newline at end of file +{"sessionId":"5364eda2-5696-4227-b94c-5f2678de1f2e","pid":64448,"procStart":"Thu Apr 23 18:51:52 2026","acquiredAt":1776973376438} \ No newline at end of file diff --git a/.github/workflows/release-core.yml b/.github/workflows/release-core.yml index 8f5a07158..2464170c9 100644 --- a/.github/workflows/release-core.yml +++ b/.github/workflows/release-core.yml @@ -38,7 +38,7 @@ jobs: needs: verify runs-on: macos-15 concurrency: - group: release-${{ inputs.release_tag }} + group: release-${{ inputs.release_tag }}-mac cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -110,7 +110,6 @@ jobs: run: cd apps/desktop && npm run validate:mac:artifacts - name: Upload validated artifacts to workflow run - if: ${{ !inputs.publish }} uses: actions/upload-artifact@v4 with: name: ade-mac-release-${{ inputs.release_tag }} @@ -121,8 +120,79 @@ jobs: apps/desktop/release/latest-mac.yml if-no-files-found: error + build-win-release: + needs: verify + runs-on: windows-latest + concurrency: + group: release-${{ inputs.release_tag }}-win + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + apps/desktop/package-lock.json + apps/ade-cli/package-lock.json + + - name: Install desktop dependencies + run: cd apps/desktop && npm ci + + - name: Install ADE CLI dependencies + run: cd apps/ade-cli && npm ci + + - name: Stamp release version + env: + ADE_RELEASE_TAG: ${{ inputs.release_tag }} + run: cd apps/desktop && npm run version:release + + - name: Reset release output + shell: pwsh + run: | + Remove-Item -Recurse -Force apps/desktop/release, apps/desktop/.cache -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path apps/desktop/.cache | Out-Null + + - name: Build and validate Windows release + env: + ELECTRON_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron + ELECTRON_BUILDER_CACHE: ${{ github.workspace }}\apps\desktop\.cache\electron-builder + run: cd apps/desktop && npm run dist:win + + - name: Upload validated Windows artifacts to workflow run + uses: actions/upload-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: | + apps/desktop/release/*.exe + apps/desktop/release/*.exe.blockmap + apps/desktop/release/latest.yml + if-no-files-found: error + + publish-release: + if: ${{ inputs.publish }} + needs: + - build-mac-release + - build-win-release + runs-on: ubuntu-latest + steps: + - name: Download macOS release artifacts + uses: actions/download-artifact@v4 + with: + name: ade-mac-release-${{ inputs.release_tag }} + path: release-assets/mac + + - name: Download Windows release artifacts + uses: actions/download-artifact@v4 + with: + name: ade-win-release-${{ inputs.release_tag }} + path: release-assets/win + - name: Create or update draft GitHub release - if: ${{ inputs.publish }} env: GH_TOKEN: ${{ github.token }} TAG_NAME: ${{ inputs.release_tag }} @@ -130,10 +200,13 @@ jobs: run: | shopt -s nullglob files=( - apps/desktop/release/*.dmg - apps/desktop/release/*.zip - apps/desktop/release/*-mac.zip.blockmap - apps/desktop/release/latest-mac.yml + release-assets/mac/*.dmg + release-assets/mac/*.zip + release-assets/mac/*-mac.zip.blockmap + release-assets/mac/latest-mac.yml + release-assets/win/*.exe + release-assets/win/*.exe.blockmap + release-assets/win/latest.yml ) if [ "${#files[@]}" -eq 0 ]; then diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index f2763e853..6c404c620 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -5,6 +5,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createAdeRpcRequestHandler, _resetGlobalAskUserRateLimit } from "./adeRpcServer"; type RuntimeFixture = ReturnType; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} + +afterEach(() => { + setPlatform(originalPlatform); +}); function createRuntime() { const operationStart = vi.fn((args: any) => ({ operationId: `op-${args.kind}-${Date.now()}` })); @@ -946,6 +958,14 @@ async function withEnv(vars: Record, fn: () => Pr } } +function createFakePathExecutable(dir: string, name: string): string { + fs.mkdirSync(dir, { recursive: true }); + const executablePath = path.join(dir, process.platform === "win32" ? `${name}.cmd` : name); + fs.writeFileSync(executablePath, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); + if (process.platform !== "win32") fs.chmodSync(executablePath, 0o755); + return executablePath; +} + describe("adeRpcServer", () => { it("treats requested privileged roles as external without trusted env identity", async () => { const { runtime } = createRuntime(); @@ -1805,15 +1825,19 @@ describe("adeRpcServer", () => { it("routes spawn_agent to lane-scoped tracked pty sessions", async () => { const fixture = createRuntime(); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-bin-")); + const claudePath = createFakePathExecutable(binDir, "claude"); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { role: "orchestrator" }); - const response = await callTool(handler, "spawn_agent", { - laneId: "lane-1", - provider: "claude", - model: "claude-sonnet-4-6", - prompt: "Implement API wiring", - title: "Orchestrator Spawn" + const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` }, async () => { + await initialize(handler, { role: "orchestrator" }); + return await callTool(handler, "spawn_agent", { + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + prompt: "Implement API wiring", + title: "Orchestrator Spawn" + }); }); expect(response?.isError).toBeUndefined(); @@ -1823,7 +1847,12 @@ describe("adeRpcServer", () => { cols: 120, rows: 36, tracked: true, - toolType: "claude-orchestrated" + toolType: "claude-orchestrated", + command: claudePath, + 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"); @@ -1836,23 +1865,95 @@ describe("adeRpcServer", () => { it("starts spawn_agent without writing an attached ADE server config", async () => { const fixture = createRuntime(); fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-")); + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-bin-")); + const claudePath = createFakePathExecutable(binDir, "claude"); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); - await initialize(handler, { role: "orchestrator", runId: "run-from-identity" }); - const response = await callTool(handler, "spawn_agent", { - laneId: "lane-1", - provider: "claude", - model: "claude-sonnet-4-6", - prompt: "Implement API wiring", - title: "Orchestrator Spawn", - runId: "run-1", - attemptId: "attempt-workspace-roots" + const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}` }, async () => { + await initialize(handler, { role: "orchestrator", runId: "run-from-identity" }); + return await callTool(handler, "spawn_agent", { + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + prompt: "Implement API wiring", + title: "Orchestrator Spawn", + runId: "run-1", + attemptId: "attempt-workspace-roots" + }); }); expect(response?.isError).toBeUndefined(); 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: claudePath, + env: expect.objectContaining({ + ADE_RUN_ID: "run-1", + ADE_ATTEMPT_ID: "attempt-workspace-roots", + ADE_DEFAULT_ROLE: "agent", + }), + }) + ); + }); + + it("keeps spawn_agent on shell startup when the provider executable cannot be resolved", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + const response = await withEnv({ PATH: fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-empty-path-")) }, async () => { + await initialize(handler, { role: "orchestrator" }); + return await callTool(handler, "spawn_agent", { + laneId: "lane-1", + provider: "claude", + prompt: "Implement API wiring", + }); + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + command: expect.any(String), + }) + ); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + startupCommand: expect.stringContaining("claude"), + }) + ); + }); + + it("does not use POSIX env assignment in unresolved Windows spawn_agent startup commands", async () => { + setPlatform("win32"); + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + const response = await withEnv({ PATH: fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-empty-win-path-")) }, async () => { + await initialize(handler, { role: "orchestrator" }); + return await callTool(handler, "spawn_agent", { + laneId: "lane-1", + provider: "claude", + prompt: "Implement API wiring", + runId: "run-1", + attemptId: "attempt-win-fallback", + }); + }); + + expect(response?.isError).toBeUndefined(); + expect(response.structuredContent.startupCommand).toContain("claude"); + expect(response.structuredContent.startupCommand).not.toContain("ADE_RUN_ID=run-1"); + expect(response.structuredContent.startupCommand).not.toContain("ADE_ATTEMPT_ID=attempt-win-fallback"); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + ADE_RUN_ID: "run-1", + ADE_ATTEMPT_ID: "attempt-win-fallback", + ADE_DEFAULT_ROLE: "agent", + }), + startupCommand: response.structuredContent.startupCommand, + }) + ); }); 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..31450bfa0 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -83,6 +83,26 @@ const DEFAULT_PTY_ROWS = 36; const RESOURCE_MIME_JSON = "application/json"; +function resolveExecutableOnPath(command: string, env: NodeJS.ProcessEnv = process.env): string | null { + const trimmed = command.trim(); + if (!trimmed) return null; + const lookup = process.platform === "win32" + ? { command: "where.exe", args: [trimmed] } + : { command: env.SHELL?.trim() || "/bin/sh", args: ["-lc", `command -v ${shellEscapeArg(trimmed)}`] }; + const result = spawnSync(lookup.command, lookup.args, { + encoding: "utf8", + env, + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0 || typeof result.stdout !== "string") return null; + const first = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (!first) return null; + return path.isAbsolute(first) ? first : null; +} + const TOOL_SPECS: ToolSpec[] = [ { name: "spawn_agent", @@ -2607,6 +2627,35 @@ function shellEscapeArg(value: string): string { return `'${sanitized.replace(/'/g, `'"'"'`)}'`; } +function windowsShellEscapeArg(value: string): string { + const sanitized = stripInjectionChars(value); + if (!sanitized.length) return "\"\""; + if (/^[a-zA-Z0-9_.:/\\-]+$/.test(sanitized)) return sanitized; + let quoted = "\""; + let backslashes = 0; + for (const char of sanitized.replace(/%/g, "%%")) { + if (char === "\\") { + backslashes += 1; + continue; + } + if (char === "\"") { + quoted += "\\".repeat(backslashes * 2); + quoted += "\"\""; + } else { + quoted += "\\".repeat(backslashes); + quoted += char; + } + backslashes = 0; + } + quoted += "\\".repeat(backslashes * 2); + quoted += "\""; + return quoted; +} + +function previewShellEscapeArg(value: string): string { + return process.platform === "win32" ? windowsShellEscapeArg(value) : shellEscapeArg(value); +} + function clipText(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, Math.max(0, maxChars - 18))}\n...`; @@ -5886,46 +5935,65 @@ 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", previewShellEscapeArg(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", previewShellEscapeArg(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(previewShellEscapeArg(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(" "); + const startupEnvPrefixParts = process.platform === "win32" ? [] : envPrefixParts; + const startupCommand = startupEnvPrefixParts.length > 0 + ? `${startupEnvPrefixParts.join(" ")} ${commandPreviewParts.join(" ")}` + : commandPreviewParts.join(" "); + const providerExecutable = resolveExecutableOnPath(provider); const created = await runtime.ptyService.create({ laneId, @@ -5934,6 +6002,8 @@ async function runTool(args: { title, tracked: true, toolType: `${provider}-orchestrated`, + ...(providerExecutable ? { command: providerExecutable, args: commandArgs } : {}), + env: workerEnv, startupCommand }); diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 65b1a7341..44453020b 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,44 @@ 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 getHeadlessLaneRuntimeEnv = async (laneId: string): Promise> => { + const lanes = await laneService.list({ includeArchived: false, includeStatus: false }); + const lane = lanes.find((entry) => entry.id === laneId); + const laneIndex = Math.max(0, lanes.findIndex((entry) => entry.id === laneId)); + const portStart = 3000 + laneIndex * 100; + const portEnd = portStart + 99; + const slug = (lane?.name ?? lane?.branchRef ?? laneId) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "lane"; + const hostname = `${slug}.localhost`; + return { + PORT: String(portStart), + PORT_RANGE_START: String(portStart), + PORT_RANGE_END: String(portEnd), + HOSTNAME: hostname, + PROXY_HOSTNAME: hostname, + }; + }; + + const processService = createProcessService({ + db, + projectId, + logger, + laneService, + projectConfigService, + sessionService, + ptyService, + getLaneRuntimeEnv: getHeadlessLaneRuntimeEnv, + broadcastEvent: (event) => pushEvent("runtime", event as unknown as Record), + }); // Ensure evaluation tables exist for headless runtime checks. db.run(` @@ -257,12 +308,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 +437,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/build/icon.ico b/apps/desktop/build/icon.ico new file mode 100644 index 000000000..67664f895 Binary files /dev/null and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c056ec544..27ee8ba22 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 && npm run validate:win:release", "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,8 @@ "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 --mode=preflight", + "validate:win:release": "node ./scripts/validate-win-artifacts.mjs --mode=release", "release:mac:local": "node ./scripts/release-mac-local.mjs", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", @@ -146,6 +149,7 @@ "asarUnpack": [ "dist/main/packagedRuntimeSmoke.cjs", "node_modules/node-pty/**/*", + "node_modules/sql.js/**/*", "node_modules/@huggingface/transformers/node_modules/onnxruntime-node/**", "vendor/crsqlite/**" ], @@ -162,9 +166,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 +188,18 @@ "publishAutoUpdate": true }, "npmRebuild": false, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "icon": "build/icon.ico", + "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..7b6503ecd --- /dev/null +++ b/apps/desktop/scripts/ade-cli-install-path.cmd @@ -0,0 +1,37 @@ +@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%\AppData\Local\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 call "%ADE_BIN%" %%* + echo exit /b %%ERRORLEVEL%% +) > "%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..ab9ea10cc --- /dev/null +++ b/apps/desktop/scripts/ade-cli-windows-wrapper.cmd @@ -0,0 +1,45 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "CLI_JS=%ADE_CLI_JS%" +if "%CLI_JS%"=="" set "CLI_JS=%SCRIPT_DIR%..\cli.cjs" + +set "RESOURCES_DIR=%SCRIPT_DIR%..\.." +set "APP_EXE=%RESOURCES_DIR%\..\ADE.exe" +set "NODE_PATH_VALUE=%RESOURCES_DIR%\app.asar.unpacked\node_modules;%RESOURCES_DIR%\app.asar\node_modules" +if defined NODE_PATH ( + if defined NODE_PATH_VALUE ( + set "NODE_PATH_VALUE=%NODE_PATH_VALUE%;%NODE_PATH%" + ) else ( + set "NODE_PATH_VALUE=%NODE_PATH%" + ) +) + +if defined ADE_CLI_NODE ( + call :run_with_runtime_env "%ADE_CLI_NODE%" "%CLI_JS%" %* + exit /b %ERRORLEVEL% +) + +if exist "%APP_EXE%" ( + set "ELECTRON_RUN_AS_NODE=1" + call :run_with_runtime_env "%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 ( + call :run_with_runtime_env 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 + +:run_with_runtime_env +if defined NODE_PATH_VALUE set "NODE_PATH=%NODE_PATH_VALUE%" +%* +exit /b %ERRORLEVEL% diff --git a/apps/desktop/scripts/after-pack-runtime-fixes.cjs b/apps/desktop/scripts/after-pack-runtime-fixes.cjs index 93381d230..c80ae03d1 100644 --- a/apps/desktop/scripts/after-pack-runtime-fixes.cjs +++ b/apps/desktop/scripts/after-pack-runtime-fixes.cjs @@ -5,40 +5,73 @@ 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)}`); + + if (fs.existsSync(appBundlePath)) { + return { runtimeRoot: resolvePackagedRuntimeRoot(appBundlePath), 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}`); + 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(bundledCliPath)) { - throw new Error(`[afterPack] Missing bundled ADE CLI entry: ${bundledCliPath}`); + 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(bundledCliBinPath)) { - throw new Error(`[afterPack] Missing bundled ADE CLI wrapper: ${bundledCliBinPath}`); +} + +module.exports = async function afterPack(context) { + const platform = context?.electronPlatformName; + const { runtimeRoot, appBundlePath } = resolveUnpackedRuntimeRoot(context); + if (!fs.existsSync(runtimeRoot)) { + throw new Error(`[afterPack] Missing unpacked runtime payload: ${runtimeRoot}`); } - if (!fs.existsSync(bundledCliInstallerPath)) { - throw new Error(`[afterPack] Missing bundled ADE CLI PATH installer: ${bundledCliInstallerPath}`); + + const resourcesRoot = resolveExtraResourcesRoot(context, appBundlePath); + const bundledCliPath = path.join(resourcesRoot, "ade-cli", "cli.cjs"); + requireFile(bundledCliPath, "bundled ADE CLI entry"); + + if (platform === "darwin") { + 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"); + fs.chmodSync(bundledCliBinPath, 0o755); + fs.chmodSync(bundledCliInstallerPath, 0o755); + } else if (platform === "win32") { + 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"); + } else { + requireFile(path.join(resourcesRoot, "ade-cli", "bin", "ade"), "bundled ADE CLI wrapper"); + requireFile(path.join(resourcesRoot, "ade-cli", "install-path.sh"), "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"), - ]; + const requiredScripts = [path.join(runtimeRoot, "dist", "main", "packagedRuntimeSmoke.cjs")]; + if (platform === "darwin") { + requiredScripts.push(path.join(runtimeRoot, "vendor", "crsqlite", "darwin-arm64", "crsqlite.dylib")); + } else if (platform === "win32") { + requiredScripts.push(path.join(runtimeRoot, "vendor", "crsqlite", "win32-x64", "crsqlite.dll")); + } for (const scriptPath of requiredScripts) { if (!fs.existsSync(scriptPath)) { diff --git a/apps/desktop/scripts/dev.cjs b/apps/desktop/scripts/dev.cjs index fc32d2e91..3603b808e 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 = "npx"; function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -108,16 +109,74 @@ async function waitForStableFile(filePath, timeoutMs, stableWindowMs = 300) { } } +function quoteWindowsCmdArg(value) { + let quoted = "\""; + let backslashes = 0; + for (const char of String(value).replace(/%/g, "%%")) { + if (char === "\\") { + backslashes += 1; + continue; + } + if (char === "\"") { + quoted += "\\".repeat(backslashes * 2); + quoted += "\"\""; + } else { + quoted += "\\".repeat(backslashes); + quoted += char; + } + backslashes = 0; + } + quoted += "\\".repeat(backslashes * 2); + quoted += "\""; + return quoted; +} + +function shouldUseWindowsCmdWrapper(cmd) { + if (process.platform !== "win32") return false; + const ext = path.win32.extname(cmd).toLowerCase(); + return ext === "" || ext === ".cmd" || ext === ".bat"; +} + +function resolveSpawnInvocation(cmd, args, env) { + if (!shouldUseWindowsCmdWrapper(cmd)) { + return { command: cmd, args, windowsVerbatimArguments: false }; + } + return { + command: env.ComSpec && env.ComSpec.trim() ? env.ComSpec.trim() : "cmd.exe", + args: ["/d", "/s", "/c", [cmd, ...args].map(quoteWindowsCmdArg).join(" ")], + windowsVerbatimArguments: true, + }; +} + function spawnProcess(name, cmd, args, extraEnv = {}) { - const child = cp.spawn(cmd, args, { + const env = { ...process.env, ...extraEnv }; + const invocation = resolveSpawnInvocation(cmd, args, env); + const child = cp.spawn(invocation.command, invocation.args, { cwd: projectRoot, - env: { ...process.env, ...extraEnv }, - stdio: "inherit" + env, + stdio: "inherit", + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); child.__adeName = name; 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 +197,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 +205,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 +229,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 +267,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..c70b6e854 --- /dev/null +++ b/apps/desktop/scripts/validate-win-artifacts.mjs @@ -0,0 +1,434 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +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")); +const productName = pkg.build?.productName ?? pkg.productName ?? "ADE"; + +function readFlag(name) { + const prefix = `${name}=`; + for (const arg of process.argv.slice(2)) { + if (arg.startsWith(prefix)) { + return arg.slice(prefix.length).trim(); + } + } + return null; +} + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function resolveAbsolute(input) { + if (!input) return null; + return path.isAbsolute(input) ? input : path.resolve(desktopRoot, input); +} + +function fail(message) { + throw new Error(`[validate-win-artifacts] ${message}`); +} + +async function assertPathExists(targetPath, description) { + try { + await fsp.access(targetPath); + } catch { + fail(`Missing ${description}: ${targetPath}`); + } +} + +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); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function parseWinTargets() { + const targets = pkg.build?.win?.target; + if (!Array.isArray(targets)) return []; + return targets.map((entry) => { + if (typeof entry === "string") { + return { target: entry, arch: [] }; + } + const arch = Array.isArray(entry?.arch) + ? entry.arch.filter(Boolean) + : entry?.arch + ? [entry.arch] + : []; + return { + target: entry?.target ?? null, + arch, + }; + }); +} + +function validatePreflight() { + requireFile("build/icon.ico", "Windows app icon"); + 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?.asarUnpack) || !pkg.build.asarUnpack.includes("vendor/crsqlite/**")) { + fail("package.json build.asarUnpack must unpack vendor/crsqlite/**"); + } + if (!Array.isArray(pkg.build?.asarUnpack) || !pkg.build.asarUnpack.includes("node_modules/sql.js/**/*")) { + fail("package.json build.asarUnpack must unpack node_modules/sql.js/**/* for the plain node fallback"); + } + if (pkg.build?.win?.icon !== "build/icon.ico") { + fail("package.json build.win.icon must point to build/icon.ico"); + } + + const winTargets = parseWinTargets(); + if (winTargets.length === 0) { + fail("package.json build.win.target must define at least one Windows target"); + } + if (!winTargets.every((entry) => entry.target === "nsis" && entry.arch.length === 1 && entry.arch[0] === "x64")) { + fail("package.json build.win.target must pin NSIS to x64 until a Windows ARM64 cr-sqlite binary is bundled"); + } + + 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 (typeof pkg.scripts?.["dist:win"] !== "string" || !pkg.scripts["dist:win"].includes("validate:win:release")) { + fail("package.json scripts.dist:win must validate the packaged Windows release output"); + } + + console.log("[validate-win-artifacts] Windows package inputs are present."); +} + +async function findArtifact(releaseDir, regex, description) { + const entries = await fsp.readdir(releaseDir, { withFileTypes: true }); + const matches = entries + .filter((entry) => entry.isFile() && regex.test(entry.name)) + .map((entry) => path.join(releaseDir, entry.name)) + .sort(); + + if (matches.length === 0) { + fail(`Unable to find ${description} in ${releaseDir}`); + } + if (matches.length > 1) { + fail( + `Found multiple ${description} artifacts in ${releaseDir}: ${matches + .map((filePath) => path.basename(filePath)) + .join(", ")}`, + ); + } + + return matches[0]; +} + +function collectLatestReferencedFiles(latest) { + return new Set( + [ + latest?.path, + ...(Array.isArray(latest?.files) + ? latest.files.map((file) => file?.url ?? file?.path ?? null) + : []), + ].filter(Boolean), + ); +} + +async function validateLatestYaml(latestPath, installerPath) { + await assertPathExists(latestPath, "latest.yml"); + const latest = parseYaml(await fsp.readFile(latestPath, "utf8")); + const expectedInstallerName = path.basename(installerPath); + const referencedFiles = collectLatestReferencedFiles(latest); + + if (!referencedFiles.has(expectedInstallerName)) { + fail( + `latest.yml does not reference ${expectedInstallerName}. ` + + `Referenced entries: ${Array.from(referencedFiles).join(", ") || "none"}`, + ); + } + + const hasSha512 = + Boolean(latest?.sha512) || + (Array.isArray(latest?.files) && latest.files.some((file) => Boolean(file?.sha512))); + if (!hasSha512) { + fail("latest.yml is missing sha512 metadata for the installer artifact"); + } +} + +function createCommandError(command, args, status, stdout, stderr) { + const rendered = [command, ...args].join(" "); + const details = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n"); + return new Error( + `[validate-win-artifacts] Command failed (${status ?? "null"}): ${rendered}` + + (details ? `\n${details}` : ""), + ); +} + +function runCommand(command, args, options = {}) { + return new Promise((resolve, reject) => { + const useShell = process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command); + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + shell: useShell, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (chunk) => { + stdout += chunk.toString("utf8"); + }); + child.stderr?.on("data", (chunk) => { + stderr += chunk.toString("utf8"); + }); + + child.on("error", reject); + child.on("close", (status) => { + if (status === 0) { + resolve({ stdout, stderr }); + return; + } + reject(createCommandError(command, args, status, stdout, stderr)); + }); + }); +} + +async function findFirstNodeAddon(rootPath) { + const entries = await fsp.readdir(rootPath, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(rootPath, entry.name); + if (entry.isDirectory()) { + const nestedMatch = await findFirstNodeAddon(entryPath); + if (nestedMatch) return nestedMatch; + continue; + } + if (entry.isFile() && entry.name.endsWith(".node")) { + return entryPath; + } + } + + return null; +} + +async function findNodePtyAddon(moduleRootPath) { + const candidateRoots = [ + path.join(moduleRootPath, "build", "Release"), + path.join(moduleRootPath, "build", "Debug"), + ]; + + try { + const prebuildRoot = path.join(moduleRootPath, "prebuilds"); + const prebuildDirs = await fsp.readdir(prebuildRoot, { withFileTypes: true }); + for (const entry of prebuildDirs) { + if (entry.isDirectory()) candidateRoots.push(path.join(prebuildRoot, entry.name)); + } + } catch { + // Keep the explicit candidate roots only. + } + + for (const candidateRoot of candidateRoots) { + try { + await fsp.access(candidateRoot); + } catch { + continue; + } + + const addonPath = await findFirstNodeAddon(candidateRoot); + if (addonPath) { + return addonPath; + } + } + + return null; +} + +function createNodePathValue(paths, options = {}) { + return paths.filter((entry) => options.includeMissing || fs.existsSync(entry)).join(";"); +} + +function assertAdeCliHelp(stdout, label) { + if (!stdout.includes("Agent-focused command-line interface for ADE")) { + fail(`${label} did not print ADE CLI help`); + } +} + +async function validatePackagedRuntime(appDir) { + const appExe = path.join(appDir, `${productName}.exe`); + const resourcesPath = path.join(appDir, "resources"); + const appAsarPath = path.join(resourcesPath, "app.asar"); + const unpackedPath = path.join(resourcesPath, "app.asar.unpacked"); + const adeCliPath = path.join(resourcesPath, "ade-cli", "cli.cjs"); + const adeCliBinPath = path.join(resourcesPath, "ade-cli", "bin", "ade.cmd"); + const adeCliInstallerPath = path.join(resourcesPath, "ade-cli", "install-path.cmd"); + const nodeModulesPath = path.join(unpackedPath, "node_modules"); + const nodePtyModulePath = path.join(nodeModulesPath, "node-pty"); + const sqlJsModulePath = path.join(nodeModulesPath, "sql.js"); + const smokeScriptPath = path.join(unpackedPath, "dist", "main", "packagedRuntimeSmoke.cjs"); + const crsqliteDllPath = path.join(unpackedPath, "vendor", "crsqlite", "win32-x64", "crsqlite.dll"); + + await assertPathExists(appExe, "packaged Windows app executable"); + await assertPathExists(appAsarPath, "app.asar payload"); + await assertPathExists(unpackedPath, "app.asar.unpacked runtime payload"); + await assertPathExists(adeCliPath, "bundled ADE CLI entry"); + await assertPathExists(adeCliBinPath, "bundled ADE CLI wrapper"); + await assertPathExists(adeCliInstallerPath, "bundled ADE CLI PATH installer"); + await assertPathExists(nodePtyModulePath, "unpacked node-pty module"); + await assertPathExists(sqlJsModulePath, "unpacked sql.js module"); + await assertPathExists(smokeScriptPath, "unpacked packaged runtime smoke script"); + await assertPathExists(crsqliteDllPath, "unpacked Windows cr-sqlite extension"); + + const nodePtyAddon = await findNodePtyAddon(nodePtyModulePath); + if (!nodePtyAddon) { + fail(`Missing node-pty native addon under ${nodePtyModulePath}`); + } + + if (process.platform !== "win32" || hasFlag("--skip-live-runtime")) { + console.log("[validate-win-artifacts] Skipping live Windows runtime validation on this host."); + return; + } + + const runtimeNodePath = createNodePathValue([ + path.join(resourcesPath, "app.asar.unpacked", "node_modules"), + path.join(resourcesPath, "app.asar", "node_modules"), + ], { includeMissing: true }); + + const { stdout: smokeStdout } = await runCommand(appExe, [smokeScriptPath], { + cwd: unpackedPath, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: "1", + NODE_PATH: runtimeNodePath, + }, + }); + + const payload = JSON.parse(smokeStdout.trim()); + if (payload?.nodePty !== "function") { + fail(`Packaged smoke expected node-pty.spawn to be a function, got ${String(payload?.nodePty)}`); + } + if (!payload?.ptyProbe?.ok) { + fail("Packaged smoke failed to execute a PTY probe"); + } + if (payload?.claudeQuery !== "function") { + fail(`Packaged smoke expected Claude SDK query() to be available, got ${String(payload?.claudeQuery)}`); + } + if (typeof payload?.claudeExecutablePath !== "string" || payload.claudeExecutablePath.trim().length === 0) { + fail("Packaged smoke did not report a Claude executable path"); + } + if (!payload?.claudeStartup || typeof payload.claudeStartup !== "object") { + fail("Packaged smoke did not report a Claude startup result"); + } + if (payload.claudeStartup.state === "binary-missing") { + console.warn("[validate-win-artifacts] Claude CLI is not installed on this machine; skipping live Claude startup check."); + } else if (payload.claudeStartup.state === "runtime-failed") { + fail(`Packaged smoke could not start Claude from the packaged app: ${String(payload.claudeStartup.message || "unknown error")}`); + } + if (payload?.codexExecutable !== "function") { + fail(`Packaged smoke expected Codex executable resolver to be available, got ${String(payload?.codexExecutable)}`); + } + + const defaultHelp = await runCommand(adeCliBinPath, ["--help"], { + cwd: resourcesPath, + env: { ...process.env }, + }); + assertAdeCliHelp(defaultHelp.stdout, "Bundled ADE CLI wrapper"); + + const nodeOverrideHelp = await runCommand(adeCliBinPath, ["--help"], { + cwd: resourcesPath, + env: { + ...process.env, + ADE_CLI_NODE: process.execPath, + }, + }); + assertAdeCliHelp(nodeOverrideHelp.stdout, "Bundled ADE CLI wrapper with ADE_CLI_NODE"); + + const disabledAppExe = `${appExe}.bak`; + await fsp.rename(appExe, disabledAppExe); + try { + const plainNodeHelp = await runCommand(adeCliBinPath, ["--help"], { + cwd: resourcesPath, + env: { ...process.env }, + }); + assertAdeCliHelp(plainNodeHelp.stdout, "Bundled ADE CLI wrapper with plain node fallback"); + } finally { + await fsp.rename(disabledAppExe, appExe); + } + + const installRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "ade-win-install-")); + const installedCommandPath = path.join(installRoot, "bin", "ade.cmd"); + try { + await runCommand(adeCliInstallerPath, [installedCommandPath], { + cwd: resourcesPath, + env: { + ...process.env, + ADE_BIN: adeCliBinPath, + }, + }); + await assertPathExists(installedCommandPath, "installed ADE CLI shim"); + + const installedHelp = await runCommand(installedCommandPath, ["--help"], { + cwd: resourcesPath, + env: { ...process.env }, + }); + assertAdeCliHelp(installedHelp.stdout, "Installed ADE CLI shim"); + } finally { + await fsp.rm(installRoot, { recursive: true, force: true }); + } + + console.log(`[validate-win-artifacts] Windows packaged runtime smoke passed: ${path.relative(appDir, nodePtyAddon)}`); +} + +async function validateReleaseArtifacts() { + const releaseDir = resolveAbsolute(readFlag("--release-dir")) ?? path.join(desktopRoot, "release"); + const installerRegex = new RegExp(`^${escapeRegExp(productName)}-.+-win-x64\\.exe$`); + const installerPath = + resolveAbsolute(readFlag("--installer")) ?? (await findArtifact(releaseDir, installerRegex, "Windows installer")); + const installerBlockmapPath = + resolveAbsolute(readFlag("--installer-blockmap")) ?? `${installerPath}.blockmap`; + const latestPath = resolveAbsolute(readFlag("--latest")) ?? path.join(releaseDir, "latest.yml"); + const appDir = resolveAbsolute(readFlag("--app")) ?? path.join(releaseDir, "win-unpacked"); + + await assertPathExists(releaseDir, "release output directory"); + await assertPathExists(installerPath, "Windows installer"); + await assertPathExists(installerBlockmapPath, "Windows installer blockmap"); + await assertPathExists(appDir, "win-unpacked app directory"); + await validateLatestYaml(latestPath, installerPath); + await validatePackagedRuntime(appDir); + + console.log("[validate-win-artifacts] Windows release artifacts passed updater and packaged-runtime checks."); +} + +const mode = readFlag("--mode") ?? "preflight"; + +try { + if (mode === "preflight") { + validatePreflight(); + } else if (mode === "release") { + validatePreflight(); + await validateReleaseArtifacts(); + } else { + fail(`Unknown mode: ${mode}`); + } +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(message); + process.exit(1); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 81dfdf5f3..34aa9ffab 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: { @@ -218,10 +220,13 @@ async function createWindow(args: { } = {}): Promise { // Load the app icon from the build directory. const iconDir = path.join(__dirname, "../../build"); + const icoPath = path.join(iconDir, "icon.ico"); const pngPath = path.join(iconDir, "icon.png"); const icnsPath = path.join(iconDir, "icon.icns"); let icon: Electron.NativeImage; - if (fs.existsSync(pngPath)) { + if (process.platform === "win32" && fs.existsSync(icoPath)) { + icon = nativeImage.createFromPath(icoPath); + } else if (fs.existsSync(pngPath)) { icon = nativeImage.createFromPath(pngPath); } else if (fs.existsSync(icnsPath)) { icon = nativeImage.createFromPath(icnsPath); @@ -520,10 +525,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 +3086,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 +3361,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.test.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts new file mode 100644 index 000000000..07a3f6585 --- /dev/null +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.test.ts @@ -0,0 +1,162 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type * as childProcess from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const spawnMock = vi.fn(); +const resolveClaudeCodeExecutableMock = vi.fn(() => ({ + path: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", + source: "path", +})); +const resolveCodexExecutableMock = vi.fn(() => ({ + path: "C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd", + source: "path", +})); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawn: (...args: unknown[]) => spawnMock(...args), + }; +}); + +vi.mock("./claudeCodeExecutable", () => ({ + resolveClaudeCodeExecutable: () => resolveClaudeCodeExecutableMock(), +})); + +vi.mock("./codexExecutable", () => ({ + resolveCodexExecutable: () => resolveCodexExecutableMock(), +})); + +import { runProviderTask } from "./providerTaskRunner"; + +type MockSpawnProcess = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + stdin: EventEmitter & { end: ReturnType }; + kill: ReturnType; + pid: number; + exitCode: number | null; + signalCode: NodeJS.Signals | null; +}; + +function createMockProcess(args: { + stdout?: string; + stderr?: string; + exitCode?: number; + onStart?: () => void; +} = {}): MockSpawnProcess { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const stdin = Object.assign(new EventEmitter(), { + end: vi.fn(), + }); + const child = Object.assign(new EventEmitter(), { + stdout, + stderr, + stdin, + kill: vi.fn(), + pid: 1234, + exitCode: null, + signalCode: null, + }) as MockSpawnProcess; + + queueMicrotask(() => { + args.onStart?.(); + if (args.stdout) stdout.emit("data", Buffer.from(args.stdout, "utf8")); + if (args.stderr) stderr.emit("data", Buffer.from(args.stderr, "utf8")); + child.emit("close", args.exitCode ?? 0); + }); + + return child; +} + +afterEach(() => { + spawnMock.mockReset(); + resolveClaudeCodeExecutableMock.mockClear(); + resolveCodexExecutableMock.mockClear(); +}); + +describe("runProviderTask", () => { + it("pipes Claude prompts over stdin instead of argv", async () => { + const child = createMockProcess({ + stdout: '{"result":"READY"}', + }); + spawnMock.mockReturnValueOnce(child); + + const result = await runProviderTask({ + cwd: process.cwd(), + descriptor: { + family: "anthropic", + isCliWrapped: true, + providerModelId: "claude-sonnet-4-6", + } as any, + prompt: "Summarize the worktree state.", + feature: "unit-test", + projectConfig: {} as any, + }); + + expect(result.text).toBe("READY"); + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, argv, options] = spawnMock.mock.calls[0]!; + expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd"); + expect(argv).toContain("-p"); + expect(argv).not.toContain("Summarize the worktree state."); + expect(options).toMatchObject({ + stdio: ["pipe", "pipe", "pipe"], + }); + expect(child.stdin.end).toHaveBeenCalledWith("Summarize the worktree state."); + }); + + it("pipes Codex prompts over stdin instead of argv", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-task-runner-")); + spawnMock.mockImplementationOnce((_command: unknown, argv: string[]) => { + const outputIndex = argv.indexOf("--output-last-message"); + const outputPath = outputIndex >= 0 ? argv[outputIndex + 1] : null; + return createMockProcess({ + onStart: () => { + if (outputPath) { + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + fs.writeFileSync(outputPath, "DONE", "utf8"); + } + }, + }); + }); + const mkdtempSpy = vi.spyOn(fs, "mkdtempSync").mockReturnValueOnce(tmpDir); + + try { + const result = await runProviderTask({ + cwd: process.cwd(), + descriptor: { + family: "openai", + isCliWrapped: true, + providerModelId: "gpt-5.3-codex", + } as any, + prompt: "Fix the Windows launcher.", + system: "Be concise.", + feature: "unit-test", + permissionMode: "edit", + projectConfig: {} as any, + }); + + expect(result.text).toBe("DONE"); + expect(spawnMock).toHaveBeenCalledTimes(1); + const [command, argv, options] = spawnMock.mock.calls[0]!; + expect(command).toBe("C:\\Users\\me\\AppData\\Roaming\\npm\\codex.cmd"); + expect(argv).toContain("exec"); + expect(argv).toContain("-"); + expect(argv).not.toContain("Fix the Windows launcher."); + expect(options).toMatchObject({ + stdio: ["pipe", "pipe", "pipe"], + }); + const child = spawnMock.mock.results[0]!.value as MockSpawnProcess; + expect(child.stdin.end).toHaveBeenCalledWith("Be concise.\n\nFix the Windows launcher."); + } finally { + mkdtempSpy.mockRestore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index 591ff5216..5a85cc866 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; @@ -42,6 +43,12 @@ type SpawnResult = { exitCode: number | null; }; +function isBenignStdinCloseError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const code = (error as { code?: unknown }).code; + return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; +} + function appendStructuredOutputInstruction(prompt: string, jsonSchema?: unknown): string { if (!jsonSchema) return prompt; return `${prompt} @@ -72,16 +79,20 @@ async function runCommand(args: { argv: string[]; cwd: string; timeoutMs?: number; + stdinText?: string; }): 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", - }, - stdio: ["ignore", "pipe", "pipe"], + env, + stdio: [args.stdinText != null ? "pipe" : "ignore", "pipe", "pipe"], + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; @@ -91,7 +102,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); @@ -101,6 +112,12 @@ async function runCommand(args: { child.stderr?.on("data", (chunk) => { stderr += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); }); + child.stdin?.on("error", (error) => { + if (settled || isBenignStdinCloseError(error)) return; + settled = true; + clearTimeout(timeoutHandle); + reject(error); + }); child.on("error", (error) => { if (settled) return; @@ -115,6 +132,10 @@ async function runCommand(args: { clearTimeout(timeoutHandle); resolve({ stdout, stderr, exitCode }); }); + + if (args.stdinText != null && child.stdin) { + child.stdin.end(args.stdinText); + } }); } @@ -139,7 +160,6 @@ async function runClaudeTask(args: ProviderTaskRunnerArgs): Promise { 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..d7cd61e0b 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,257 @@ 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("blocks PowerShell writes to protected files", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-Content -Path .env -Value secret"', + sandboxWith({ protectedFiles: ["\\.env"] }), + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("protected file pattern"); + }); + + it("blocks PowerShell writes outside the sandbox root", () => { + const result = checkWorkerSandbox( + 'pwsh -Command "Add-Content ..\\outside.txt secret"', + sandboxWith({ allowedPaths: ["./"] }), + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("blocks PowerShell registry provider mutations", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-ItemProperty -Path HKCU:\\Software\\Foo -Name Bar -Value 1"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("non-filesystem provider path"); + }); + + it("blocks PowerShell mutations that use non-literal path arguments", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "$path = \'.env\'; Set-Content -Path $path -Value secret"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("non-literal path argument"); + }); + + it("blocks uninspected PowerShell -File script execution", () => { + const result = checkWorkerSandbox( + "powershell.exe -File .\\scripts\\setup.ps1", + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("PowerShell script file is not inspectable"); + }); + + it("blocks uninspected bare PowerShell script execution", () => { + const result = checkWorkerSandbox( + ".\\scripts\\setup.ps1", + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("PowerShell script file is not inspectable"); + }); + + it("detects PowerShell mutations through nested cmd.exe /c wrappers", () => { + const result = checkWorkerSandbox( + 'cmd.exe /d /s /c cmd.exe /c powershell.exe -Command "Set-Content -Path ..\\outside.txt -Value hi"', + sandboxWith({ allowedPaths: ["./"] }), + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("fails closed for generic mutating cmd.exe /c payloads", () => { + const result = checkWorkerSandbox( + 'cmd.exe /c "copy foo ..\\outside.txt"', + sandboxWith({ allowedPaths: ["./"] }), + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("cmd.exe /c mutating payload"); + }); + + it("blocks PowerShell mutation paths hidden behind parenthesized expressions", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-Content -Path (Join-Path .. outside.txt) -Value hi"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("uninspectable path argument"); + }); + + it("blocks PowerShell mutation paths hidden behind subexpressions", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-Content -Path $(Join-Path .. outside.txt) -Value hi"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("uninspectable path argument"); + }); + + it("blocks PowerShell mutation paths hidden behind array expressions", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-Content -Path @(\'..\\outside.txt\') -Value hi"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("uninspectable path argument"); + }); + + it("blocks PowerShell mutations with empty path captures", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-Content -Path -Value secret"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("uninspectable path argument"); + }); + + it("does not let PowerShell switch parameters consume positional mutation paths", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Set-Content -NoNewline ..\\outside.txt -Value hi"', + sandboxWith({ allowedPaths: ["./"] }), + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("keeps inspecting positional paths after PowerShell -Force and -Recurse switches", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Remove-Item -Force -Recurse ..\\outside"', + sandboxWith({ allowedPaths: ["./"] }), + "C:\\projects\\repo", + ); + + expect(result.allowed).toBe(false); + expect(result.reason).toContain("Path outside sandbox"); + }); + + it("blocks opaque PowerShell encoded commands", () => { + const result = checkWorkerSandbox( + "powershell.exe -EncodedCommand not-base64!!!", + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("EncodedCommand payload is not inspectable"); + }); + + it("blocks PowerShell encoded writes to protected files", () => { + const encoded = Buffer.from("Set-Content -Path .env -Value secret", "utf16le").toString("base64"); + const result = checkWorkerSandbox( + `powershell.exe -EncodedCommand ${encoded}`, + sandboxWith({ protectedFiles: ["\\.env"] }), + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(false); + expect(result.reason).toContain("protected file pattern"); + }); + + it("allows read-only PowerShell file reads inside the sandbox", () => { + const result = checkWorkerSandbox( + 'powershell.exe -Command "Get-Content .\\README.md"', + DEFAULT_WORKER_SANDBOX_CONFIG, + "C:\\projects\\repo", + ); + expect(result.allowed).toBe(true); + }); + + 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, @@ -784,6 +1031,26 @@ describe("createUniversalToolSet", () => { expect(result.stderr).toContain("EXECUTION DENIED"); }); + it("blocks mutating PowerShell commands on required turns", async () => { + const cwd = makeTmpDir("ade-tools-memory-guard-powershell-"); + const tools = createUniversalToolSet(cwd, { + permissionMode: "full-auto", + turnMemoryPolicyState: { + classification: "required", + orientationSatisfied: false, + explicitSearchPerformed: false, + }, + }); + + const result = await (tools.bash as any).execute({ + command: 'powershell.exe -Command "Set-Content -Path .\\blocked.txt -Value hi"', + timeout: 5_000, + }); + + expect(result.exitCode).toBe(126); + expect(result.stderr).toContain("EXECUTION DENIED"); + }); + it("does not block read-only bash commands on required turns", async () => { const cwd = makeTmpDir("ade-tools-memory-readonly-"); const tools = createUniversalToolSet(cwd, { @@ -1129,17 +1396,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 +1428,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..cd8bab9bb 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."; @@ -184,27 +186,684 @@ type PathReference = { access: PathAccessMode; }; +type PowerShellInspection = { + mutates: boolean; + pathRefs: PathReference[]; + blockedReason?: string; +}; + +type PowerShellMutationKind = + | "copy" + | "move" + | "new-item" + | "out-file" + | "path-write" + | "provider-item" + | "remove" + | "rename"; + +const POWERSHELL_COMMAND_FLAGS = new Set(["-command", "-c"]); +const POWERSHELL_ENCODED_COMMAND_FLAGS = new Set(["-encodedcommand", "-enc", "-e"]); +const POWERSHELL_FILE_FLAGS = new Set(["-file"]); +const POWERSHELL_SEGMENT_SEPARATORS = new Set(["|", "||", "&&", ";"]); +const POWERSHELL_PUNCTUATION_TOKENS = new Set(["{", "}", "(", ")", ","]); +const POWERSHELL_SWITCH_PARAMETERS = new Set([ + "-append", + "-confirm", + "-debug", + "-force", + "-noclobber", + "-nonewline", + "-passthru", + "-recurse", + "-verbose", + "-whatif", +]); +const POWERSHELL_MUTATION_KIND_BY_COMMAND = new Map([ + ["add-content", "path-write"], + ["ac", "path-write"], + ["clear-content", "path-write"], + ["copy", "copy"], + ["copy-item", "copy"], + ["cpi", "copy"], + ["cp", "copy"], + ["move", "move"], + ["move-item", "move"], + ["mv", "move"], + ["mi", "move"], + ["new-item", "new-item"], + ["ni", "new-item"], + ["out-file", "out-file"], + ["remove-item", "remove"], + ["del", "remove"], + ["erase", "remove"], + ["rd", "remove"], + ["ri", "remove"], + ["rm", "remove"], + ["rmdir", "remove"], + ["rename-item", "rename"], + ["ren", "rename"], + ["rni", "rename"], + ["set-content", "path-write"], + ["set-item", "provider-item"], + ["set-itemproperty", "provider-item"], + ["new-itemproperty", "provider-item"], + ["remove-itemproperty", "provider-item"], + ["rename-itemproperty", "provider-item"], + ["move-itemproperty", "provider-item"], + ["clear-itemproperty", "provider-item"], +]); + function requiresTurnMemoryGuard(state?: TurnMemoryPolicyState): boolean { return !!state && state.classification === "required" && !state.orientationSatisfied && !state.explicitSearchPerformed; } -function bashCommandLikelyMutates(command: string): boolean { - return MUTATING_BASH_RE.test(command) || WRITE_COMMAND_RE.test(command); +function tokenizePowerShellCommand(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: "'" | '"' | null = null; + let escaped = false; + + const pushCurrent = () => { + if (current.length > 0) { + tokens.push(current); + current = ""; + } + }; + + for (let i = 0; i < command.length; i += 1) { + const ch = command[i]!; + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "`") { + escaped = true; + continue; + } + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + continue; + } + if (ch === ">") { + if (/^\d$/.test(current)) { + const fd = current; + current = ""; + const next = command[i + 1]; + if (next === ">") { + tokens.push(`${fd}>>`); + i += 1; + } else { + tokens.push(`${fd}>`); + } + continue; + } + pushCurrent(); + const next = command[i + 1]; + if (next === ">") { + tokens.push(">>"); + i += 1; + } else { + tokens.push(">"); + } + continue; + } + if (ch === "|" || ch === "&" || ch === ";") { + pushCurrent(); + const next = command[i + 1]; + if ((ch === "|" || ch === "&") && next === ch) { + tokens.push(`${ch}${ch}`); + i += 1; + } else { + tokens.push(ch); + } + continue; + } + if (/\s/.test(ch)) { + pushCurrent(); + continue; + } + if (POWERSHELL_PUNCTUATION_TOKENS.has(ch)) { + pushCurrent(); + tokens.push(ch); + continue; + } + current += ch; + } + + pushCurrent(); + return tokens; +} + +function splitPowerShellSegments(tokens: string[]): string[][] { + const segments: string[][] = []; + let current: string[] = []; + for (const token of tokens) { + const normalizedToken = normalizePathToken(token); + if (POWERSHELL_SEGMENT_SEPARATORS.has(token) || POWERSHELL_SEGMENT_SEPARATORS.has(normalizedToken)) { + if (current.length > 0) segments.push(current); + current = []; + continue; + } + current.push(token); + } + if (current.length > 0) segments.push(current); + return segments; +} + +function isPowerShellHostCommand(commandName: string): boolean { + const baseName = path.win32.basename(normalizePathToken(commandName)).toLowerCase(); + return baseName === "powershell" || baseName === "powershell.exe" || baseName === "pwsh" || baseName === "pwsh.exe"; +} + +function isCmdHostCommand(commandName: string): boolean { + const baseName = path.win32.basename(normalizePathToken(commandName)).toLowerCase(); + return baseName === "cmd" || baseName === "cmd.exe"; } -function resolveAllowedWriteRoots(cwd: string, sandboxConfig?: WorkerSandboxConfig): string[] { - const roots = new Set([path.resolve(cwd)]); +function isPowerShellScriptPath(value: string): boolean { + return /\.ps1$/i.test(normalizePathToken(value)); +} + +function decodePowerShellEncodedCommand(value: string): string | null { + const trimmed = normalizePathToken(value); + if (!trimmed.length || !/^[a-z0-9+/=]+$/i.test(trimmed)) { + return null; + } + try { + const decoded = Buffer.from(trimmed, "base64").toString("utf16le"); + return decoded.trim().length > 0 ? decoded : null; + } catch { + return null; + } +} + +function isPowerShellParameterToken(value: string): boolean { + return /^-[a-z]/i.test(value); +} + +function isPowerShellSwitchParameterToken(value: string): boolean { + return POWERSHELL_SWITCH_PARAMETERS.has(value.toLowerCase()); +} + +function isPowerShellDynamicArgument(value: string): boolean { + return value.startsWith("$") || value.startsWith("@(") || value.includes("$("); +} + +function isPowerShellOpaquePathSyntax(value: string): boolean { + const trimmed = value.trim(); + return ( + !trimmed.length || + trimmed === "@" || + trimmed === "$" || + POWERSHELL_PUNCTUATION_TOKENS.has(trimmed) || + trimmed.startsWith("(") || + trimmed.startsWith("@(") || + trimmed.includes("$(") + ); +} + +function normalizePowerShellPathArgument(value: string): { path?: string; nonFilesystemProvider?: string; opaque?: string } { + const normalized = normalizePathToken(value); + if (!normalized.length) return {}; + if (isPowerShellDynamicArgument(normalized)) { + return { opaque: normalized }; + } + + const fileSystemQualified = normalized.match(/^(?:microsoft\.powershell\.core\\)?filesystem::(.+)$/i); + if (fileSystemQualified?.[1]) { + return { path: fileSystemQualified[1] }; + } + if (/^(?:microsoft\.powershell\.core\\)?registry::/i.test(normalized)) { + return { nonFilesystemProvider: normalized }; + } + if (/^[a-z]:/.test(normalized)) { + return { path: normalized }; + } + if (/^(?:[a-z][a-z0-9+.-]*::|[a-z][a-z0-9+.-]*:)/i.test(normalized)) { + return { nonFilesystemProvider: normalized }; + } + return { path: normalized }; +} + +function parsePowerShellArgs(args: string[]): { positional: string[]; named: Map } { + const positional: string[] = []; + const named = new Map(); + + for (let i = 0; i < args.length; i += 1) { + const raw = normalizePathToken(args[i] ?? ""); + if (!raw.length || POWERSHELL_PUNCTUATION_TOKENS.has(raw)) continue; + + const inlineMatch = raw.match(/^(-[a-z][a-z0-9-]*):(.*)$/i); + if (inlineMatch) { + const key = inlineMatch[1]!.toLowerCase(); + const value = normalizePathToken(inlineMatch[2] ?? ""); + named.set(key, value.length > 0 ? [value] : [""]); + continue; + } + + if (isPowerShellParameterToken(raw)) { + const key = raw.toLowerCase(); + if (isPowerShellSwitchParameterToken(key)) { + const values = named.get(key) ?? []; + values.push(""); + named.set(key, values); + continue; + } + const next = normalizePathToken(args[i + 1] ?? ""); + if (next.length > 0 && !isPowerShellParameterToken(next) && !POWERSHELL_PUNCTUATION_TOKENS.has(next)) { + const values = named.get(key) ?? []; + values.push(next); + named.set(key, values); + i += 1; + } else { + const values = named.get(key) ?? []; + values.push(""); + named.set(key, values); + } + continue; + } + + positional.push(raw); + } + + return { positional, named }; +} + +function inspectPowerShellInvocations( + command: string, + cwd: string, + pathApi: SandboxPathApi = getSandboxPathApi(cwd, command), + depth = 0, +): PowerShellInspection { + const refs = new Map(); + const accessPriority: Record = { + unknown: 0, + read: 1, + write: 2, + }; + + const addResolvedPath = (rawValue: string, access: PathAccessMode): { blockedReason?: string } => { + if (access !== "read" && isPowerShellOpaquePathSyntax(rawValue)) { + return { blockedReason: `PowerShell mutation uses an uninspectable path argument: ${rawValue}` }; + } + const normalized = normalizePathToken(rawValue); + if (!normalized.length || normalized.includes("://")) return {}; + + const normalizedPath = normalizePowerShellPathArgument(normalized); + if (normalizedPath.opaque) { + if (access !== "read") { + return { blockedReason: `PowerShell mutation uses a non-literal path argument: ${normalizedPath.opaque}` }; + } + return {}; + } + if (normalizedPath.nonFilesystemProvider) { + if (access !== "read") { + return { blockedReason: `PowerShell mutation targets non-filesystem provider path: ${normalizedPath.nonFilesystemProvider}` }; + } + return {}; + } + + const candidate = normalizedPath.path; + if (!candidate || candidate === "/dev/null") return {}; + const expandedPath = + candidate === "~" + ? os.homedir() + : candidate.startsWith("~/") + ? pathApi.join(os.homedir(), candidate.slice(2)) + : pathApi === path.win32 && candidate.startsWith("~\\") + ? pathApi.join(os.homedir(), candidate.slice(2)) + : candidate; + const resolved = pathApi.resolve(cwd, expandedPath); + const key = `${candidate}::${resolved}`; + const existing = refs.get(key); + if (!existing || accessPriority[access] > accessPriority[existing.access]) { + refs.set(key, { raw: candidate, resolved, access }); + } + return {}; + }; + + const addFirstNamedOrPositionalPath = ( + parsed: ReturnType, + flags: string[], + positionalIndex: number | null, + access: PathAccessMode, + failOnMissing = true, + ): { blockedReason?: string } => { + for (const flag of flags) { + const values = parsed.named.get(flag); + if (values?.length) { + return addResolvedPath(values[0]!, access); + } + } + if (positionalIndex !== null && parsed.positional[positionalIndex]) { + return addResolvedPath(parsed.positional[positionalIndex]!, access); + } + if (access !== "read" && failOnMissing) { + return { blockedReason: "PowerShell mutation path argument is not inspectable by the worker sandbox" }; + } + return {}; + }; + + const mergeInspection = (inspection: PowerShellInspection) => { + for (const entry of inspection.pathRefs) { + const key = `${entry.raw}::${entry.resolved}`; + const existing = refs.get(key); + if (!existing || accessPriority[entry.access] > accessPriority[existing.access]) { + refs.set(key, entry); + } + } + }; + + const inspectScriptFile = (rawScriptPath: string): PowerShellInspection => { + const trimmed = rawScriptPath.trim(); + if (isPowerShellOpaquePathSyntax(trimmed) || isPowerShellDynamicArgument(trimmed)) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: `PowerShell script path is not inspectable by the worker sandbox: ${trimmed}`, + }; + } + + const normalizedPath = normalizePowerShellPathArgument(trimmed); + if (normalizedPath.opaque || normalizedPath.nonFilesystemProvider || !normalizedPath.path) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: `PowerShell script path is not inspectable by the worker sandbox: ${trimmed}`, + }; + } + + const addScriptRef = addResolvedPath(normalizedPath.path, "read"); + if (addScriptRef.blockedReason) { + return { mutates: true, pathRefs: [...refs.values()], blockedReason: addScriptRef.blockedReason }; + } + + const scriptPath = pathApi.resolve(cwd, normalizedPath.path); + try { + const realCwd = canonicalizePathForContainment(cwd, pathApi); + const realScriptPath = canonicalizePathForContainment(scriptPath, pathApi); + if (!isWithinDirForPathApi(pathApi, realCwd, realScriptPath)) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: `PowerShell script file is outside the inspectable sandbox root: ${normalizedPath.path}`, + }; + } + if (!fs.existsSync(realScriptPath) || !fs.statSync(realScriptPath).isFile()) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: `PowerShell script file is not inspectable by the worker sandbox: ${normalizedPath.path}`, + }; + } + const script = fs.readFileSync(realScriptPath, "utf-8"); + return inspectPowerShellScript(script); + } catch { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: `PowerShell script file is not inspectable by the worker sandbox: ${normalizedPath.path}`, + }; + } + }; + + const inspectPowerShellScript = (script: string): PowerShellInspection => { + let scriptMutates = false; + const tokens = tokenizePowerShellCommand(script); + for (let i = 0; i < tokens.length; i += 1) { + const token = normalizePathToken(tokens[i] ?? ""); + if (!token.length) continue; + if (token === ">" || token === ">>" || /^\d>>?$/.test(token)) { + scriptMutates = true; + const writeTarget = addResolvedPath(tokens[i + 1] ?? "", "write"); + if (writeTarget.blockedReason) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: writeTarget.blockedReason, + }; + } + } + } + + for (const innerSegment of splitPowerShellSegments(tokens)) { + let innerCommandIndex = 0; + while (innerCommandIndex < innerSegment.length) { + const token = normalizePathToken(innerSegment[innerCommandIndex] ?? ""); + if (!token.length || token === "&" || token === "." || POWERSHELL_PUNCTUATION_TOKENS.has(token)) { + innerCommandIndex += 1; + continue; + } + break; + } + if (innerCommandIndex >= innerSegment.length) continue; + + const innerCommandToken = normalizePathToken(innerSegment[innerCommandIndex] ?? ""); + const innerCommandName = innerCommandToken.toLowerCase(); + if (isPowerShellScriptPath(innerCommandToken)) { + const scriptFileInspection = inspectScriptFile(innerCommandToken); + mergeInspection(scriptFileInspection); + if (scriptFileInspection.blockedReason) return { ...scriptFileInspection, pathRefs: [...refs.values()] }; + scriptMutates = scriptMutates || scriptFileInspection.mutates; + continue; + } + + const mutationKind = POWERSHELL_MUTATION_KIND_BY_COMMAND.get(innerCommandName); + if (!mutationKind) continue; + scriptMutates = true; + + const parsed = parsePowerShellArgs(innerSegment.slice(innerCommandIndex + 1)); + let result: { blockedReason?: string } = {}; + + switch (mutationKind) { + case "path-write": + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "write"); + break; + case "out-file": + result = addFirstNamedOrPositionalPath(parsed, ["-filepath", "-literalpath", "-path", "-pspath", "-lp"], 0, "write"); + break; + case "copy": { + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "read"); + if (result.blockedReason) break; + result = addFirstNamedOrPositionalPath(parsed, ["-destination"], 1, "write"); + break; + } + case "move": { + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "write"); + if (result.blockedReason) break; + result = addFirstNamedOrPositionalPath(parsed, ["-destination"], 1, "write"); + break; + } + case "remove": + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "write"); + break; + case "rename": + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "write"); + if (result.blockedReason) break; + result = addFirstNamedOrPositionalPath(parsed, ["-newname"], 1, "write"); + break; + case "new-item": + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "write", false); + if (result.blockedReason) break; + if (parsed.named.get("-name")?.[0]) { + result = addResolvedPath(parsed.named.get("-name")![0]!, "write"); + } + break; + case "provider-item": + result = addFirstNamedOrPositionalPath(parsed, ["-path", "-literalpath", "-pspath", "-lp"], 0, "write"); + break; + } + + if (result.blockedReason) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: result.blockedReason, + }; + } + } + + return { mutates: scriptMutates, pathRefs: [...refs.values()] }; + }; + + const outerSegments = splitCommandSegments(tokenizeCommand(command, pathApi === path.win32)); + let mutates = false; + + for (const segment of outerSegments) { + const commandName = normalizePathToken(segment[0] ?? ""); + if (!commandName.length) continue; + + if (isCmdHostCommand(commandName)) { + if (depth >= 4) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: "Nested cmd.exe /c command is not inspectable by the worker sandbox", + }; + } + const commandArgIndex = segment.findIndex((arg, index) => index > 0 && /^\/c$/i.test(normalizePathToken(arg))); + if (commandArgIndex >= 0) { + const nestedCommand = segment.slice(commandArgIndex + 1).join(" ").trim(); + if (!nestedCommand.length) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: "cmd.exe /c payload is not inspectable by the worker sandbox", + }; + } + const nestedInspection = inspectPowerShellInvocations(nestedCommand, cwd, pathApi, depth + 1); + mergeInspection(nestedInspection); + if (nestedInspection.blockedReason) return { ...nestedInspection, pathRefs: [...refs.values()] }; + if (MUTATING_CMD_RE.test(nestedCommand)) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: "cmd.exe /c mutating payload is not inspectable by the worker sandbox", + }; + } + mutates = mutates || nestedInspection.mutates; + } + continue; + } + + if (isPowerShellScriptPath(commandName)) { + const scriptFileInspection = inspectScriptFile(commandName); + mergeInspection(scriptFileInspection); + if (scriptFileInspection.blockedReason) return { ...scriptFileInspection, pathRefs: [...refs.values()] }; + mutates = mutates || scriptFileInspection.mutates; + continue; + } + + if (!isPowerShellHostCommand(commandName)) continue; + + const args = segment.slice(1); + let script: string | null = null; + for (let i = 0; i < args.length; i += 1) { + const flag = normalizePathToken(args[i] ?? "").toLowerCase(); + if (POWERSHELL_COMMAND_FLAGS.has(flag)) { + const remainder = args.slice(i + 1).join(" ").trim(); + if (!remainder.length || remainder === "-") { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: "PowerShell -Command input is not inspectable by the worker sandbox", + }; + } + script = remainder; + break; + } + if (POWERSHELL_ENCODED_COMMAND_FLAGS.has(flag)) { + const decoded = decodePowerShellEncodedCommand(args[i + 1] ?? ""); + if (!decoded) { + return { + mutates: true, + pathRefs: [...refs.values()], + blockedReason: "PowerShell -EncodedCommand payload is not inspectable by the worker sandbox", + }; + } + script = decoded; + break; + } + if (POWERSHELL_FILE_FLAGS.has(flag)) { + const scriptPath = args[i + 1] ?? ""; + const scriptFileInspection = inspectScriptFile(scriptPath); + mergeInspection(scriptFileInspection); + if (scriptFileInspection.blockedReason) return { ...scriptFileInspection, pathRefs: [...refs.values()] }; + mutates = mutates || scriptFileInspection.mutates; + script = null; + break; + } + } + + if (!script) continue; + const scriptInspection = inspectPowerShellScript(script); + mergeInspection(scriptInspection); + if (scriptInspection.blockedReason) return { ...scriptInspection, pathRefs: [...refs.values()] }; + mutates = mutates || scriptInspection.mutates; + } + + return { mutates, pathRefs: [...refs.values()] }; +} + +function bashCommandLikelyMutates( + command: string, + cwd = process.cwd(), + powerShellInspection?: PowerShellInspection, +): boolean { + const inspection = powerShellInspection ?? inspectPowerShellInvocations(command, cwd); + return inspection.mutates || !!inspection.blockedReason || MUTATING_BASH_RE.test(command) || MUTATING_CMD_RE.test(command) || WRITE_COMMAND_RE.test(command); +} + +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 +872,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 +888,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 +910,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 +927,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 +943,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 +972,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 +1002,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("\\") + )) ); } @@ -340,7 +1023,8 @@ function splitCommandSegments(tokens: string[]): string[][] { const segments: string[][] = []; let current: string[] = []; for (const token of tokens) { - if (COMMAND_SEPARATORS.has(normalizePathToken(token))) { + const normalizedToken = normalizePathToken(token); + if (COMMAND_SEPARATORS.has(token) || COMMAND_SEPARATORS.has(normalizedToken)) { if (current.length > 0) segments.push(current); current = []; continue; @@ -351,8 +1035,14 @@ 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), + powerShellInspection?: PowerShellInspection, +): PathReference[] { const refs = new Map(); + const windowsMode = pathApi === path.win32; const accessPriority: Record = { unknown: 0, read: 1, @@ -368,9 +1058,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 +1070,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 +1095,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 +1150,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; } @@ -454,9 +1166,38 @@ function collectPathReferences(command: string, cwd: string): PathReference[] { markOperands(commandName, args); } + const nestedPowerShellRefs = (powerShellInspection ?? inspectPowerShellInvocations(command, cwd, pathApi)).pathRefs; + for (const entry of nestedPowerShellRefs) { + const key = `${entry.raw}::${entry.resolved}`; + const existing = refs.get(key); + if (!existing || accessPriority[entry.access] > accessPriority[existing.access]) { + refs.set(key, entry); + } + } + 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 +1208,8 @@ export function checkWorkerSandbox( projectRoot: string, ): { allowed: boolean; reason?: string } { const compiled = compileSandbox(config); + const pathApi = getSandboxPathApi(projectRoot, command); + const powerShellInspection = inspectPowerShellInvocations(command, projectRoot, pathApi); // 1. Check blocked patterns first (always reject) for (const { re, src } of compiled.blocked) { @@ -475,24 +1218,28 @@ export function checkWorkerSandbox( } } + if (powerShellInspection.blockedReason) { + return { allowed: false, reason: powerShellInspection.blockedReason }; + } + const safeMatch = compiled.safe.some((re) => re.test(command)); - const commandMutates = bashCommandLikelyMutates(command); + const commandMutates = bashCommandLikelyMutates(command, projectRoot, powerShellInspection); // 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, powerShellInspection); 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 +1251,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 +1273,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 +1292,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 +1340,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 +1367,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/cli/windowsPackaging.test.ts b/apps/desktop/src/main/services/cli/windowsPackaging.test.ts new file mode 100644 index 000000000..3f23cf418 --- /dev/null +++ b/apps/desktop/src/main/services/cli/windowsPackaging.test.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { parse as parseYaml } from "yaml"; +import { describe, expect, it } from "vitest"; + +const desktopRoot = path.resolve(__dirname, "../../../../"); +const repoRoot = path.resolve(desktopRoot, "..", ".."); + +describe("Windows packaging", () => { + it("keeps the packaged Windows wrapper on a shared runtime-env path", () => { + const wrapperPath = path.join(desktopRoot, "scripts", "ade-cli-windows-wrapper.cmd"); + const wrapper = fs.readFileSync(wrapperPath, "utf8"); + + expect(wrapper).toContain('set "NODE_PATH_VALUE=%RESOURCES_DIR%\\app.asar.unpacked\\node_modules;%RESOURCES_DIR%\\app.asar\\node_modules"'); + expect(wrapper).toContain('call :run_with_runtime_env "%ADE_CLI_NODE%" "%CLI_JS%" %*'); + expect(wrapper).toContain('call :run_with_runtime_env "%APP_EXE%" "%CLI_JS%" %*'); + expect(wrapper).toContain('call :run_with_runtime_env node "%CLI_JS%" %*'); + expect(wrapper).toContain('if defined NODE_PATH_VALUE set "NODE_PATH=%NODE_PATH_VALUE%"'); + }); + + it("keeps the Windows install-path shim callable and exit-code preserving", () => { + const installerPath = path.join(desktopRoot, "scripts", "ade-cli-install-path.cmd"); + const installer = fs.readFileSync(installerPath, "utf8"); + + expect(installer).toContain('echo call "%ADE_BIN%" %%*'); + expect(installer).toContain("echo exit /b %%ERRORLEVEL%%"); + }); + + it("pins the Windows desktop build to x64 and unpacks sql.js for node fallback", () => { + const packageJsonPath = path.join(desktopRoot, "package.json"); + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + + expect(pkg.scripts["dist:win"]).toContain("validate:win:release"); + expect(pkg.build.asarUnpack).toContain("node_modules/sql.js/**/*"); + expect(pkg.build.win.icon).toBe("build/icon.ico"); + expect(pkg.build.win.target).toEqual([ + { + target: "nsis", + arch: ["x64"], + }, + ]); + }); + + it("passes the Windows artifact preflight", () => { + const validateScriptPath = path.join(desktopRoot, "scripts", "validate-win-artifacts.mjs"); + const result = spawnSync(process.execPath, [validateScriptPath, "--mode=preflight"], { + cwd: desktopRoot, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Windows package inputs are present."); + }); + + it("builds and publishes Windows release artifacts in release-core", () => { + const workflowPath = path.join(repoRoot, ".github", "workflows", "release-core.yml"); + const workflow = parseYaml(fs.readFileSync(workflowPath, "utf8")); + const winJob = workflow.jobs["build-win-release"]; + const publishJob = workflow.jobs["publish-release"]; + + expect(winJob["runs-on"]).toBe("windows-latest"); + expect(winJob.steps.some((step: { run?: string }) => step.run?.includes("npm run dist:win"))).toBe(true); + + const winUploadStep = winJob.steps.find((step: { name?: string }) => step.name === "Upload validated Windows artifacts to workflow run"); + expect(winUploadStep.with.path).toContain("apps/desktop/release/latest.yml"); + + expect(publishJob.needs).toEqual(expect.arrayContaining(["build-mac-release", "build-win-release"])); + const publishStep = publishJob.steps.find((step: { name?: string }) => step.name === "Create or update draft GitHub release"); + expect(publishStep.run).toContain("release-assets/win/latest.yml"); + expect(publishStep.run).toContain("release-assets/win/*.exe.blockmap"); + }); +}); 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..c7a57eafe 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, terminateProcessTree } from "../shared/processExecution"; import type { ApplyConflictProposalArgs, BatchOverlapEntry, @@ -198,6 +199,23 @@ const PREFILTER_MAX_GLOBAL_PAIRS = 800; const PREFILTER_MAX_TOUCHED_FILES = 800; const STALE_MS = 5 * 60_000; const EXTERNAL_DIFF_MAX_OUTPUT_BYTES = 32 * 1024 * 1024; +const EXTERNAL_RESOLVER_TIMEOUT_MS = 8 * 60_000; + +function terminateExternalResolverProcessTree( + child: ReturnType, + signal: NodeJS.Signals, +): boolean { + if (child.exitCode !== null || child.signalCode !== null) return false; + if (process.platform !== "win32" && typeof child.pid === "number") { + try { + process.kill(-child.pid, signal); + return true; + } catch { + // Fall through to the direct child kill path. + } + } + return terminateProcessTree(child, signal); +} function safeJsonArray(raw: string | null): T[] { const parsed = safeJsonParse(raw, null); @@ -3497,22 +3515,44 @@ export function createConflictService({ command: renderedCommand }); - 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 proc = await new Promise<{ stdout: string; stderr: string; status: number | null; signal: NodeJS.Signals | null; timedOut: boolean }>((resolve) => { + 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, + detached: process.platform !== "win32", + windowsVerbatimArguments: invocation.windowsVerbatimArguments, }); let stdout = ""; let stderr = ""; + let timedOut = false; + let forceKillTimer: NodeJS.Timeout | null = null; + const timeoutTimer = setTimeout(() => { + timedOut = true; + terminateExternalResolverProcessTree(child, "SIGTERM"); + forceKillTimer = setTimeout(() => { + terminateExternalResolverProcessTree(child, "SIGKILL"); + }, 2_000); + }, EXTERNAL_RESOLVER_TIMEOUT_MS); + const cleanupTimers = () => { + clearTimeout(timeoutTimer); + if (forceKillTimer) clearTimeout(forceKillTimer); + }; child.stdout?.on("data", (chunk: Buffer) => { if (stdout.length < 8 * 1024 * 1024) stdout += chunk.toString("utf8"); }); child.stderr?.on("data", (chunk: Buffer) => { if (stderr.length < 8 * 1024 * 1024) stderr += chunk.toString("utf8"); }); - child.on("error", () => resolve({ stdout, stderr, status: 1, signal: null })); - child.on("close", (code, signal) => resolve({ stdout, stderr, status: code, signal })); + child.on("error", () => { + cleanupTimers(); + resolve({ stdout, stderr, status: 1, signal: null, timedOut }); + }); + child.on("close", (code, signal) => { + cleanupTimers(); + resolve({ stdout, stderr, status: code, signal, timedOut }); + }); }); const stdout = proc.stdout ?? ""; @@ -3532,7 +3572,7 @@ export function createConflictService({ finalPatchPath = patchPath; } - const status: ConflictExternalResolverRunStatus = proc.status === 0 ? "completed" : "failed"; + const status: ConflictExternalResolverRunStatus = proc.status === 0 && !proc.timedOut ? "completed" : "failed"; const runRecord: ExternalResolverRunRecord = { ...existingRun, status, @@ -3544,12 +3584,17 @@ export function createConflictService({ logPath: outputLogPath, warnings: [ ...existingRun.warnings, + ...(proc.timedOut ? ["resolver_timeout_process_tree_terminated"] : []), ...(proc.signal ? [`process_signal:${proc.signal}`] : []), ...(diffResult.stdoutTruncated ? ["git_diff_stdout_truncated"] : []), ...(diffResult.stderrTruncated ? ["git_diff_stderr_truncated"] : []), ...missingRequiredContexts.map((relPath) => `missing_context:${relPath}`) ], - error: proc.status === 0 ? null : (stderr.trim() || `Exit code ${proc.status ?? -1}`) + error: status === "completed" + ? null + : proc.timedOut + ? `External resolver timed out after ${EXTERNAL_RESOLVER_TIMEOUT_MS}ms.` + : (stderr.trim() || `Exit code ${proc.status ?? -1}`) }; writeExternalRunRecord(runRecord); return toRunSummary(runRecord); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 0f936f4a5..e18bf3a5d 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 { @@ -1911,13 +1931,13 @@ export function registerIpc({ IPC.appOpenPathInEditor, async ( _event, - arg: { rootPath: string; relativePath?: string; target: "finder" | "vscode" | "cursor" | "zed" } + arg: { rootPath: string; relativePath?: string; target: "default" | "finder" | "vscode" | "cursor" | "zed" } ): Promise => { const rootRaw = typeof arg?.rootPath === "string" ? arg.rootPath.trim() : ""; const relRaw = typeof arg?.relativePath === "string" ? arg.relativePath.trim() : ""; const target = arg?.target; if (!rootRaw) throw new Error("Missing root path."); - if (target !== "finder" && target !== "vscode" && target !== "cursor" && target !== "zed") { + if (target !== "default" && target !== "finder" && target !== "vscode" && target !== "cursor" && target !== "zed") { throw new Error("Unsupported editor target."); } const rootPath = path.resolve(rootRaw); @@ -1949,38 +1969,72 @@ export function registerIpc({ throw resolveError; } + if (target === "default") { + const errorMessage = await shell.openPath(targetPath); + if (errorMessage) { + throw new Error(`Failed to open path: ${errorMessage}`); + } + return; + } + if (target === "finder") { shell.showItemInFolder(targetPath); 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 +2043,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 +4638,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/autoRebaseService.ts b/apps/desktop/src/main/services/lanes/autoRebaseService.ts index 308b1c924..39cccb963 100644 --- a/apps/desktop/src/main/services/lanes/autoRebaseService.ts +++ b/apps/desktop/src/main/services/lanes/autoRebaseService.ts @@ -112,17 +112,17 @@ function blockedMessage( laneId: string | null, reason: "conflict" | "manual" | "lookup" | "failed" | "unavailable" | null, ): string { - if (!laneId) return "Pending: auto-rebase stopped at an earlier lane. Open the Rebase/Merge tab to continue."; + if (!laneId) return "Pending: auto-rebase stopped at an earlier lane. Open the Rebase tab to continue."; if (reason === "manual") { - return `Pending: ancestor lane '${laneId}' has a fixed PR base. Rebase that lane manually from the Rebase/Merge tab before descendants can continue.`; + return `Pending: ancestor lane '${laneId}' has a fixed PR base. Rebase that lane manually from the Rebase tab before descendants can continue.`; } if (reason === "lookup" || reason === "unavailable") { - return `Pending: ancestor lane '${laneId}' needs review before descendants can continue. Open the Rebase/Merge tab to inspect it.`; + return `Pending: ancestor lane '${laneId}' needs review before descendants can continue. Open the Rebase tab to inspect it.`; } if (reason === "failed") { - return `Pending: ancestor lane '${laneId}' failed automatic rebase. Open the Rebase/Merge tab to retry.`; + return `Pending: ancestor lane '${laneId}' failed automatic rebase. Open the Rebase tab to retry.`; } - return `Pending: ancestor lane '${laneId}' has unresolved rebase conflicts. Open the Rebase/Merge tab to continue.`; + return `Pending: ancestor lane '${laneId}' has unresolved rebase conflicts. Open the Rebase tab to continue.`; } function resolveAffectedChainLaneId( @@ -459,7 +459,7 @@ export function createAutoRebaseService(args: { parentHeadSha: null, state: "rebasePending", conflictCount: 0, - message: "Pending: parent lane is unavailable. Open the Rebase/Merge tab to review the lane." + message: "Pending: parent lane is unavailable. Open the Rebase tab to review the lane." }); blocked = true; blockedLaneId = lane.id; @@ -522,7 +522,7 @@ export function createAutoRebaseService(args: { parentHeadSha, state: "rebaseConflict", conflictCount: Math.max(1, need.conflictingFiles.length), - message: `Auto-rebase blocked: ${Math.max(1, need.conflictingFiles.length)} conflict(s) expected. Open the Rebase/Merge tab to resolve and publish.` + message: `Auto-rebase blocked: ${Math.max(1, need.conflictingFiles.length)} conflict(s) expected. Open the Rebase tab to resolve and publish.` }); continue; } @@ -541,7 +541,7 @@ export function createAutoRebaseService(args: { parentHeadSha, state: "rebasePending", conflictCount: 0, - message: "PR carries an immutable base — drift detected. Rebase manually from the Rebase/Merge tab when ready." + message: "PR carries an immutable base — drift detected. Rebase manually from the Rebase tab when ready." }); continue; } @@ -572,8 +572,8 @@ export function createAutoRebaseService(args: { state: conflictHint ? "rebaseConflict" : "rebaseFailed", conflictCount: conflictHint ? 1 : 0, message: conflictHint - ? "Auto-rebase stopped due to conflicts. Open the Rebase/Merge tab to resolve, then publish." - : `Auto-rebase failed: ${rebaseRun.run.error}. Open the Rebase/Merge tab to retry.` + ? "Auto-rebase stopped due to conflicts. Open the Rebase tab to resolve, then publish." + : `Auto-rebase failed: ${rebaseRun.run.error}. Open the Rebase tab to retry.` }); continue; } @@ -625,8 +625,8 @@ export function createAutoRebaseService(args: { state: "rebaseFailed", conflictCount: 0, message: rollbackError - ? `Auto-push failed: ${pushError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase/Merge tab to retry.` - : `Auto-push failed: ${pushError}. The lane was restored to its pre-rebase state. Open the Rebase/Merge tab to retry.` + ? `Auto-push failed: ${pushError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase tab to retry.` + : `Auto-push failed: ${pushError}. The lane was restored to its pre-rebase state. Open the Rebase tab to retry.` }); } } 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..51f65dee0 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -3,23 +3,77 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ created: [] as Array<{ close: ReturnType; url: string }>, + resolveOpenCodeBinaryPath: vi.fn(() => "/Users/admin/.opencode/bin/opencode"), })); vi.mock("./openCodeBinaryManager", () => ({ - resolveOpenCodeBinaryPath: vi.fn(() => "/Users/admin/.opencode/bin/opencode"), + resolveOpenCodeBinaryPath: mockState.resolveOpenCodeBinaryPath, })); 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); + }); + + it("detects cmd-wrapped .bat OpenCode serve shims", () => { + 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:\\\\tools\\\\opencode.bat" serve --hostname=127.0.0.1 --port=4310'; + expect(__isManagedOpenCodeServeCommandForTests(cmdLine)).toBe(true); + }); +}); + describe("openCodeServerManager", () => { const originalEnv = { PATH: process.env.PATH, @@ -43,11 +97,13 @@ describe("openCodeServerManager", () => { beforeEach(() => { vi.useFakeTimers(); mockState.created.length = 0; + mockState.resolveOpenCodeBinaryPath.mockReturnValue("/Users/admin/.opencode/bin/opencode"); __resetOpenCodeServerManagerForTests(); __setOpenCodeProcessControllerForTests({ listProcesses: () => [], isProcessAlive: () => false, killProcess: () => {}, + killProcessTree: () => false, waitForMs: async () => {}, }); __setOpenCodeServerLauncherForTests(async ({ port }) => { @@ -63,6 +119,7 @@ describe("openCodeServerManager", () => { afterEach(() => { __resetOpenCodeServerManagerForTests(); + setProcessPlatform(originalProcessPlatform as NodeJS.Platform); vi.useRealTimers(); restoreEnv("PATH"); restoreEnv("HOME"); @@ -407,35 +464,46 @@ 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"; + mockState.resolveOpenCodeBinaryPath.mockReturnValue("C:\\Users\\100% dev\\bin\\opencode.bat"); + + 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('&&"C:\\Users\\100%% dev\\bin\\opencode.bat" "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 +511,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..c553e1d5f 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, quoteWindowsCmdArg, resolveWindowsCmdInvocation } 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 && fromWmic.every((process) => process.command.trim().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,14 +518,23 @@ function buildManagedConfigMarkers(): string[] { } function isManagedOpenCodeServeCommand(command: string, configMarkers: string[]): boolean { - if (!/\bopencode(?:\.cmd|\.exe)?\b\s+serve\b/i.test(command)) return false; + // 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|\.bat|\.exe)?\b/i.test(command) + && /\bserve\b/i.test(command) + ) { + return command.includes("OPENCODE_DISABLE_PROJECT_CONFIG=1"); + } + if (!/\bopencode(?:\.cmd|\.bat|\.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; return configMarkers.some((marker) => command.includes(marker)); } 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,27 @@ function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeS } const xdgPaths = resolveOpenCodeIsolationPaths(); ensureOpenCodeIsolationDirs(xdgPaths); + const env = buildIsolatedOpenCodeEnv(args.config, xdgPaths); + if (process.platform === "win32") { + const invocation = resolveWindowsCmdInvocation( + executable, + ["serve", "--hostname=127.0.0.1", `--port=${args.port}`], + env, + ); + const cmdLine = + `set ${quoteWindowsCmdArg(`${ADE_OPENCODE_MANAGED_ENV}=1`)}` + + `&&set ${quoteWindowsCmdArg("OPENCODE_DISABLE_PROJECT_CONFIG=1")}` + + `&&set ${quoteWindowsCmdArg(`${ADE_OPENCODE_OWNER_PID_ENV}=${process.pid}`)}` + + `&&${invocation.args[3] ?? ""}`; + return { + executable: invocation.command, + args: ["/d", "/s", "/c", cmdLine], + env, + useShell: false, + xdgPaths, + }; + } + return { executable, args: [ @@ -497,8 +689,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 +703,7 @@ async function defaultOpenCodeServerLauncher( env: launchSpec.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, + windowsVerbatimArguments: process.platform === "win32", shell: launchSpec.useShell, }); @@ -957,6 +1150,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 +1168,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/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index 9ee4324ea..27c3e6f0a 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -3171,6 +3171,14 @@ describe("aiOrchestratorService", () => { `, ["2000-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z", attempt.id] ); + fixture.db.run( + ` + update orchestrator_claims + set heartbeat_at = '2000-01-01T00:00:00.000Z' + where attempt_id = ? + `, + [attempt.id] + ); // A startup/interval sweep can be in flight on slower CI runners. Retry the // explicit sweep until this attempt is reconciled instead of racing it. @@ -3192,6 +3200,93 @@ describe("aiOrchestratorService", () => { } }); + it("does not enter a run body while another health sweep owns it", async () => { + const fixture = await createFixture(); + let releaseFirstSweep: () => void = () => {}; + const firstSweepGate = new Promise((resolve) => { + releaseFirstSweep = resolve; + }); + try { + const mission = fixture.missionService.create({ + prompt: "Verify overlapping health sweeps keep single ownership.", + laneId: fixture.laneId + }); + const launch = await fixture.aiOrchestratorService.startMissionRun({ + missionId: mission.id, + runMode: "manual", + defaultExecutorKind: "manual" + }); + if (!launch.started) throw new Error("Expected mission run to start"); + const runId = launch.started.run.id; + + fixture.orchestratorService.addSteps({ + runId, + steps: [{ stepKey: "implement-changes", title: "Implement requested changes", stepIndex: 0, dependencyStepKeys: [], executorKind: "manual", metadata: { instructions: "Do the work" } }] + }); + fixture.orchestratorService.tick({ runId }); + const graph = fixture.orchestratorService.getRunGraph({ runId }); + const readyStep = graph.steps.find((s) => s.status === "ready"); + if (!readyStep) throw new Error("Expected a ready step"); + + const attempt = await fixture.orchestratorService.startAttempt({ + runId, + stepId: readyStep.id, + ownerId: "test-owner", + executorKind: "manual" + }); + const sessionId = "session-ended-overlap"; + fixture.db.run( + ` + update orchestrator_attempts + set executor_kind = 'opencode', + executor_session_id = ? + where id = ? + `, + [sessionId, attempt.id] + ); + fixture.db.run( + ` + insert into terminal_sessions( + id, lane_id, pty_id, tracked, title, started_at, ended_at, exit_code, + transcript_path, head_sha_start, head_sha_end, status, last_output_preview, + summary, tool_type, resume_command, last_output_at + ) values (?, ?, null, 1, 'Worker', ?, ?, 0, '', null, null, 'ended', null, null, 'codex-orchestrated', null, ?) + `, + [ + sessionId, + fixture.laneId, + "2000-01-01T00:00:00.000Z", + "2000-01-01T00:00:01.000Z", + "2000-01-01T00:00:00.000Z", + ] + ); + + const originalReconcile = fixture.orchestratorService.onTrackedSessionEnded.bind(fixture.orchestratorService); + let reconcileCalls = 0; + fixture.orchestratorService.onTrackedSessionEnded = (async (args: any) => { + reconcileCalls += 1; + await firstSweepGate; + return await originalReconcile(args); + }) as typeof fixture.orchestratorService.onTrackedSessionEnded; + + const firstSweep = fixture.aiOrchestratorService.runHealthSweep("overlap-owner"); + for (let tries = 0; tries < 40 && reconcileCalls === 0; tries += 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + expect(reconcileCalls).toBe(1); + const overlappedSweep = await fixture.aiOrchestratorService.runHealthSweep("overlap-contender"); + + expect(overlappedSweep.sweeps).toBe(0); + expect(reconcileCalls).toBe(1); + + releaseFirstSweep(); + await firstSweep; + } finally { + releaseFirstSweep(); + fixture.dispose(); + } + }, 10_000); + it("skips background health sweeps for runs blocked on open interventions", async () => { const fixture = await createFixture(); try { diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts index d8cfaf8c5..d3d6aa904 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts @@ -4265,6 +4265,7 @@ Check all worker statuses and continue managing the mission from here. Read work skippedBlockedRuns += 1; continue; } + let ownsHealthSweepRun = false; if (activeHealthSweepRuns.has(run.id)) { // Interval/startup sweeps are opportunistic; manual/chat/status sweeps should wait briefly // so explicit health checks don't get dropped due to an in-flight background sweep. @@ -4273,9 +4274,18 @@ Check all worker statuses and continue managing the mission from here. Read work while (activeHealthSweepRuns.has(run.id) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 25)); } - if (activeHealthSweepRuns.has(run.id)) continue; + if (activeHealthSweepRuns.has(run.id)) { + logger.debug("ai_orchestrator.health_sweep_explicit_overlap", { + runId: run.id, + reason + }); + continue; + } + } + if (!activeHealthSweepRuns.has(run.id)) { + activeHealthSweepRuns.add(run.id); + ownsHealthSweepRun = true; } - activeHealthSweepRuns.add(run.id); try { if (disposed) break; sweeps += 1; @@ -4589,7 +4599,7 @@ Check all worker statuses and continue managing the mission from here. Read work error: error instanceof Error ? error.message : String(error) }); } finally { - activeHealthSweepRuns.delete(run.id); + if (ownsHealthSweepRun) activeHealthSweepRuns.delete(run.id); } } 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..a347c8a0a 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -71,8 +71,13 @@ import { evaluateRunCompletion, evaluateRunCompletionFromPhases, validateRunComp import { createProviderOrchestratorAdapter, cleanupWorkerRuntimeFiles, + nodeWorkerLaunch, + writeWorkerLaunchFile, + writeWorkerPromptFile, } from "./providerOrchestratorAdapter"; +import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; import { runGit } from "../git/git"; import type { AdeDb, SqlValue } from "../state/kvDb"; import type { createPtyService } from "../pty/ptyService"; @@ -90,7 +95,7 @@ import type { ProceduralLearningService } from "../memory/proceduralLearningServ import { asRecord, nowIso, parseJsonRecord, TERMINAL_STEP_STATUSES, filterExecutionSteps } from "./orchestratorContext"; import { parseNumericDependencyIndices } from "./missionLifecycle"; import { getMissionStateDocumentPath } from "./missionStateDoc"; -import { buildFullPrompt, shellEscapeArg, shellInlineDecodedArg } from "./baseOrchestratorAdapter"; +import { buildFullPrompt, shellEscapeArg } from "./baseOrchestratorAdapter"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import { classifyWorkerExecutionPath, resolveModelDescriptor } from "../../../shared/modelRegistry"; import { @@ -3954,7 +3959,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 +3968,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 +3978,73 @@ 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(" "); + const promptFilePath = writeWorkerPromptFile({ + projectRoot, + attemptId: args.attempt.id, + prompt, + }); + + let launchCommand; + if (cliCommand === "codex") { + const resolvedCodex = resolveCodexExecutable(); + commandArgs.push("exec", "-"); + commandPreviewParts.push("exec", "-"); + const startupCommand = `exec ${commandPreviewParts.join(" ")} < ${shellEscapeArg(promptFilePath)}`; + const launchFilePath = writeWorkerLaunchFile({ + projectRoot, + attemptId: args.attempt.id, + command: resolvedCodex.path, + commandArgs, + promptFilePath, + }); + launchCommand = nodeWorkerLaunch({ + startupCommand, + launchFilePath, + }); + } else { + const resolvedClaude = resolveClaudeCodeExecutable(); + commandArgs.push("-p"); + commandPreviewParts.push("-p"); + const startupCommand = `exec ${commandPreviewParts.join(" ")} < ${shellEscapeArg(promptFilePath)}`; + const launchFilePath = writeWorkerLaunchFile({ + projectRoot, + attemptId: args.attempt.id, + command: resolvedClaude.path, + commandArgs, + promptFilePath, + }); + launchCommand = nodeWorkerLaunch({ + startupCommand, + launchFilePath, + }); + } const session = await args.createTrackedSession({ laneId: args.step.laneId, @@ -3996,7 +4052,10 @@ export function createOrchestratorService({ rows: 36, title, toolType: `${kind}-orchestrated` as TerminalToolType, - startupCommand + command: launchCommand.command, + args: launchCommand.args, + env: launchCommand.env, + startupCommand: launchCommand.startupCommand, }); return { status: "accepted", @@ -4007,7 +4066,7 @@ export function createOrchestratorService({ contextFilePath, contextDigest: sha256(JSON.stringify(contextManifest)), planMode: readOnlyExecution, - startupCommandPreview: startupCommand.slice(0, 320), + startupCommandPreview: launchCommand.startupCommand.slice(0, 320), localFirst: true } }; diff --git a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts index 295a1d379..d83d3dcc4 100644 --- a/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts +++ b/apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts @@ -1,12 +1,25 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockState = vi.hoisted(() => ({ + resolveClaudeCodeExecutable: vi.fn(() => ({ path: "/mock/bin/claude", source: "path" as const })), +})); + +vi.mock("../ai/claudeCodeExecutable", () => ({ + resolveClaudeCodeExecutable: mockState.resolveClaudeCodeExecutable, +})); + import { createProviderOrchestratorAdapter } from "./providerOrchestratorAdapter"; describe("providerOrchestratorAdapter", () => { let projectRoot: string | null = null; + beforeEach(() => { + mockState.resolveClaudeCodeExecutable.mockReturnValue({ path: "/mock/bin/claude", source: "path" }); + }); + afterEach(() => { if (projectRoot) { fs.rmSync(projectRoot, { recursive: true, force: true }); @@ -76,4 +89,127 @@ describe("providerOrchestratorAdapter", () => { codexConfigSource: "config-toml", })); }); + + it("resolves the Claude executable for direct startup-command overrides", async () => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-provider-adapter-")); + mockState.resolveClaudeCodeExecutable.mockReturnValue({ + path: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", + source: "path", + }); + const createTrackedSession = vi.fn(async () => ({ ptyId: "pty-override", sessionId: "session-override" })); + 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: "override-worker", + title: "Override worker", + stepIndex: 0, + dependencyStepIds: [], + dependencyStepKeys: [], + laneId: "lane-1", + status: "ready", + metadata: { + startupCommand: "diagnose the failing check", + }, + }, + attempt: { + id: "attempt-1", + runId: "run-1", + stepId: "step-1", + }, + allSteps: [], + contextProfile: {} as any, + laneExport: null, + projectExport: { content: "", truncated: false }, + docsRefs: [], + fullDocs: [], + createTrackedSession, + } as any); + + expect(result.status).toBe("accepted"); + expect(mockState.resolveClaudeCodeExecutable).toHaveBeenCalledTimes(1); + expect(createTrackedSession).toHaveBeenCalledWith(expect.objectContaining({ + command: "C:\\Users\\me\\AppData\\Roaming\\npm\\claude.cmd", + args: ["-p", "diagnose the failing check"], + startupCommand: expect.stringContaining("exec claude -p"), + })); + }); + + 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..2e92f65dd 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, @@ -18,7 +18,9 @@ import type { } from "../../../shared/types"; import type { MissionPermissionConfig, MissionProviderPermissions } from "../../../shared/types/missions"; import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { resolveClaudeCliModel, resolveCodexCliModel } from "../ai/claudeModelUtils"; +import { resolveCodexExecutable } from "../ai/codexExecutable"; import type { createAgentChatService } from "../chat/agentChatService"; import { mapPermissionToClaude, @@ -26,10 +28,23 @@ import { normalizeMissionPermissions, providerPermissionsToLegacyConfig, } from "./permissionMapping"; +import { resolveCliSpawnInvocation } from "../shared/processExecution"; + +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 +52,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 +94,77 @@ 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, spawnSync } = require("child_process"); +const specPath = process.argv[1]; +let done = false; +let child = null; +function terminateChild() { + if (!child || !child.pid) return; + try { + if (process.platform === "win32") { + spawnSync("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }); + } else { + child.kill("SIGTERM"); + } + } catch {} +} +function finish(code) { + if (done) return; + done = true; + process.exit(typeof code === "number" ? code : 1); +} +process.on("SIGTERM", () => { + terminateChild(); + finish(143); +}); +process.on("SIGINT", () => { + terminateChild(); + finish(130); +}); +process.on("exit", () => { + terminateChild(); +}); +const spec = JSON.parse(fs.readFileSync(specPath, "utf8")); +const childEnv = { ...process.env, ...(spec.env || {}) }; +delete childEnv.ELECTRON_RUN_AS_NODE; +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, + windowsVerbatimArguments: !!spec.windowsVerbatimArguments +}); +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) { + child.stdin.on("error", () => {}); + 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", @@ -94,7 +190,7 @@ export function buildClaudeReadOnlyWorkerAllowedTools(extraToolNames: readonly s ]); } -function writeWorkerPromptFile(args: { +export function writeWorkerPromptFile(args: { projectRoot: string; attemptId: string; prompt: string; @@ -105,6 +201,51 @@ function writeWorkerPromptFile(args: { return promptPath; } +export 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 }); + const invocation = resolveCliSpawnInvocation( + args.command, + args.commandArgs, + { ...process.env, ...(args.env ?? {}) }, + ); + fs.writeFileSync( + launchPath, + JSON.stringify({ + command: invocation.command, + args: invocation.args, + windowsVerbatimArguments: invocation.windowsVerbatimArguments ?? false, + stdinFilePath: args.promptFilePath, + env: args.env ?? {}, + }), + "utf8", + ); + return launchPath; +} + +export 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 +276,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 +305,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 +450,12 @@ 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)}`; + const resolvedClaude = resolveClaudeCodeExecutable(); + return { + startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`, + command: resolvedClaude.path, + args: ["-p", prompt], + }; }, buildStartupCommand: ({ prompt, model, step, run, attempt, permissionConfig, teamRuntime }) => { @@ -332,16 +487,21 @@ export function createProviderOrchestratorAdapter(options?: { ? buildClaudeReadOnlyWorkerAllowedTools() : dedupeAllowedTools(configuredAllowedTools); - const parts: string[] = ["claude", "--model", shellEscapeArg(cliModel)]; + const resolvedClaude = resolveClaudeCodeExecutable(); + 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 +509,36 @@ export function createProviderOrchestratorAdapter(options?: { attemptId: attempt.id, prompt, }); - parts.push("-p", `"$(cat ${shellEscapeArg(promptFilePath)})"`); + commandArgs.push("-p"); + previewParts.push("-p"); - 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 startup = `exec ${cmd}`; - return envParts.length > 0 ? `${envParts.join(" ")} ${startup}` : startup; + const cmd = previewParts.join(" "); + const startup = `exec ${cmd} < ${shellEscapeArg(promptFilePath)}`; + const startupCommand = envParts.length > 0 ? `${envParts.join(" ")} ${startup}` : startup; + const launchFilePath = writeWorkerLaunchFile({ + projectRoot: canonicalProjectRoot, + attemptId: attempt.id, + command: resolvedClaude.path, + commandArgs, + promptFilePath, + env: launchEnv, + }); + return nodeWorkerLaunch({ + startupCommand, + launchFilePath, + env: launchEnv, + }); } if (descriptor?.isCliWrapped && descriptor.family === "openai") { @@ -378,37 +554,62 @@ 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 resolvedCodex = resolveCodexExecutable(); + 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: resolvedCodex.path, + 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/prs/prRebaseResolver.test.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts index b53587497..0349af8c7 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.test.ts @@ -141,8 +141,8 @@ describe("launchRebaseResolutionChat", () => { expect(sendMessage).toHaveBeenCalledWith( expect.objectContaining({ sessionId: "session-rebase-1", - reasoningEffort: "high", displayText: "Rebase feature/rebase-target onto main", + reasoningEffort: "high", }), ); expect(result).toEqual({ diff --git a/apps/desktop/src/main/services/prs/prRebaseResolver.ts b/apps/desktop/src/main/services/prs/prRebaseResolver.ts index d7bfecfa2..c9cbd3cdf 100644 --- a/apps/desktop/src/main/services/prs/prRebaseResolver.ts +++ b/apps/desktop/src/main/services/prs/prRebaseResolver.ts @@ -158,10 +158,6 @@ export async function launchRebaseResolutionChat( await deps.agentChatService.sendMessage({ sessionId: session.id, text: prompt, - // Show the short human-readable title in the transcript instead of the - // full composed prompt (which is long and technical). Mirrors - // prIssueResolver's pattern; agentChatService falls back to `text` when - // displayText is absent, which would surface the raw prompt to the user. displayText: title, ...(reasoningEffort ? { reasoningEffort } : {}), }); diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 22841aa4e..2d29ecb20 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -871,7 +871,7 @@ export function createPrService({ parentHeadSha: null, state: "rebaseFailed", conflictCount: 0, - message: `Auto-rebase failed after '${args.landedLaneName}' merged because ADE could not find a new parent lane. Open the Rebase/Merge tab to recover this lane.`, + message: `Auto-rebase failed after '${args.landedLaneName}' merged because ADE could not find a new parent lane. Open the Rebase tab to recover this lane.`, }, child.id); } return { @@ -979,8 +979,8 @@ export function createPrService({ state: "rebaseFailed", conflictCount: 0, message: rollbackError - ? `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase/Merge tab to recover this lane.` - : `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. The lane was restored to its pre-rebase state. Open the Rebase/Merge tab to recover this lane.`, + ? `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. Automatic rollback also failed: ${rollbackError}. Open the Rebase tab to recover this lane.` + : `Auto-rebase failed after '${args.landedLaneName}' merged: ${childError}. The lane was restored to its pre-rebase state. Open the Rebase tab to recover this lane.`, }, child.id); failedLaneIds.push(child.id); } diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 895968ab3..57ed6d025 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..9f513b12d 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,11 @@ export function createPtyService({ let created: IPty | null = null; if (directCommand) { try { - created = ptyLib.spawn(directCommand, directArgs, opts); + const invocation = resolveCliSpawnInvocation(directCommand, directArgs, launchEnv); + const ptyArgs = invocation.windowsVerbatimArguments + ? invocation.args.join(" ") + : invocation.args; + created = ptyLib.spawn(invocation.command, ptyArgs, opts); } catch (err) { lastErr = err; } @@ -1318,7 +1323,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..40b4a90eb --- /dev/null +++ b/apps/desktop/src/main/services/shared/processExecution.test.ts @@ -0,0 +1,236 @@ +import type * as childProcess from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const spawnSyncMock = vi.fn(); + +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + spawnSync: (...args: unknown[]) => spawnSyncMock(...args), + }; +}); + +import { + killWindowsProcessTree, + quoteWindowsCmdArg, + resolveCliSpawnInvocation, + resolveWindowsCmdInvocation, + shouldUseWindowsCmdWrapper, + terminateProcessTree, +} 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("C:\\Program Files\\")).toBe('"C:\\Program Files\\\\"'); + expect(quoteWindowsCmdArg("100% done")).toBe('"100%% done"'); + expect(quoteWindowsCmdArg('say "hi"')).toBe('"say ""hi"""'); + expect(quoteWindowsCmdArg('C:\\path\\"quoted"')).toBe('"C:\\path\\\\\"\"quoted\"\"\"'); + expect(quoteWindowsCmdArg("line one\r\nline two")).toBe('"line one line two"'); + }); + + 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, + }); + }); +}); + +describe("killWindowsProcessTree", () => { + afterEach(() => { + spawnSyncMock.mockReset(); + }); + + it("shells out to taskkill /T /F and returns true on exit 0", () => { + spawnSyncMock.mockReturnValueOnce({ status: 0, stdout: "", stderr: "", error: null }); + const failure = vi.fn(); + + expect(killWindowsProcessTree(4321, failure)).toBe(true); + expect(failure).not.toHaveBeenCalled(); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + const [command, args, options] = spawnSyncMock.mock.calls[0]!; + expect(command).toBe("taskkill.exe"); + expect(args).toEqual(["/T", "/F", "/PID", "4321"]); + expect(options).toMatchObject({ windowsHide: true }); + }); + + it("invokes the failure callback with taskkill stderr when exit is non-zero", () => { + spawnSyncMock.mockReturnValueOnce({ + status: 128, + stdout: Buffer.from("out", "utf8"), + stderr: Buffer.from("Access denied.", "utf8"), + error: null, + }); + const failure = vi.fn(); + + expect(killWindowsProcessTree(1234, failure)).toBe(false); + expect(failure).toHaveBeenCalledTimes(1); + expect(failure).toHaveBeenCalledWith({ + pid: 1234, + status: 128, + stdout: "out", + stderr: "Access denied.", + error: null, + }); + }); + + it("invokes the failure callback when spawnSync throws", () => { + const thrown = new Error("spawn failed"); + spawnSyncMock.mockImplementationOnce(() => { + throw thrown; + }); + const failure = vi.fn(); + + expect(killWindowsProcessTree(55, failure)).toBe(false); + expect(failure).toHaveBeenCalledWith({ + pid: 55, + status: null, + stdout: "", + stderr: "", + error: thrown, + }); + }); + + it("rejects non-positive or non-integer pids without shelling out", () => { + const failure = vi.fn(); + + expect(killWindowsProcessTree(0, failure)).toBe(false); + expect(killWindowsProcessTree(-7, failure)).toBe(false); + expect(killWindowsProcessTree(3.14, failure)).toBe(false); + expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(failure).not.toHaveBeenCalled(); + }); +}); + +type FakeChildShape = Pick; + +function fakeChild(overrides: Partial = {}): FakeChildShape & { kill: ReturnType } { + return { + pid: 4321, + exitCode: null, + signalCode: null, + kill: vi.fn(() => true), + ...overrides, + } as FakeChildShape & { kill: ReturnType }; +} + +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + value, + configurable: true, + }); +} + +describe("terminateProcessTree", () => { + afterEach(() => { + spawnSyncMock.mockReset(); + setPlatform(originalPlatform); + }); + + it("on non-Windows, forwards the signal to child.kill and returns its result", () => { + setPlatform("linux"); + const child = fakeChild(); + + expect(terminateProcessTree(child, "SIGTERM")).toBe(true); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(spawnSyncMock).not.toHaveBeenCalled(); + }); + + it("on non-Windows, returns false and does not throw when child.kill throws", () => { + setPlatform("linux"); + const child = fakeChild({ + kill: vi.fn(() => { + throw new Error("ESRCH"); + }), + }); + + expect(terminateProcessTree(child, "SIGKILL")).toBe(false); + }); + + it("on Windows, calls taskkill for a live child and skips child.kill on success", () => { + setPlatform("win32"); + spawnSyncMock.mockReturnValueOnce({ status: 0, stdout: "", stderr: "", error: null }); + const child = fakeChild(); + + expect(terminateProcessTree(child)).toBe(true); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + expect(spawnSyncMock.mock.calls[0]![1]).toEqual(["/T", "/F", "/PID", "4321"]); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("on Windows, falls back to child.kill when taskkill fails", () => { + setPlatform("win32"); + spawnSyncMock.mockReturnValueOnce({ + status: 1, + stdout: "", + stderr: Buffer.from("err", "utf8"), + error: null, + }); + const child = fakeChild(); + const failure = vi.fn(); + + expect(terminateProcessTree(child, "SIGTERM", failure)).toBe(true); + expect(failure).toHaveBeenCalledTimes(1); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("on Windows, skips taskkill entirely when the child has already exited", () => { + setPlatform("win32"); + const child = fakeChild({ exitCode: 0 }); + + expect(terminateProcessTree(child)).toBe(false); + expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("on Windows, skips taskkill entirely when the child already received a signal", () => { + setPlatform("win32"); + const child = fakeChild({ signalCode: "SIGTERM" }); + + expect(terminateProcessTree(child)).toBe(false); + expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it("on Windows, falls back to child.kill when pid is missing", () => { + setPlatform("win32"); + const child = fakeChild({ pid: undefined }); + + expect(terminateProcessTree(child, "SIGKILL")).toBe(true); + expect(spawnSyncMock).not.toHaveBeenCalled(); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + }); +}); 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..c18bc8eac --- /dev/null +++ b/apps/desktop/src/main/services/shared/processExecution.ts @@ -0,0 +1,123 @@ +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 { + let quoted = "\""; + let backslashes = 0; + for (const char of value.replace(/%/g, "%%").replace(/[\r\n]/g, " ")) { + if (char === "\\") { + backslashes += 1; + continue; + } + if (char === "\"") { + quoted += "\\".repeat(backslashes * 2); + quoted += "\"\""; + } else { + quoted += "\\".repeat(backslashes); + quoted += char; + } + backslashes = 0; + } + quoted += "\\".repeat(backslashes * 2); + quoted += "\""; + return quoted; +} + +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.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index f73c6850b..084111020 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -510,6 +510,64 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { expect(service.getPin()).toBeNull(); }, 30_000); + it("rejects PIN changes when CRDT sync is unavailable", async () => { + const projectRoot = makeProjectRoot("ade-sync-service-crdt-disabled-"); + const db = await openKvDb( + path.join(projectRoot, ".ade", "ade.db"), + createLogger() as any, + ); + db.sync.isAvailable = () => false; + + const service = createSyncService({ + db, + logger: createLogger() as any, + projectRoot, + fileService: { dispose: () => {} } as any, + laneService: { + list: async () => [], + create: async () => ({}), + archive: async () => {}, + } as any, + prService: { + listAll: async () => [], + getDetail: async () => null, + getStatus: async () => null, + getChecks: async () => [], + getReviews: async () => [], + getComments: async () => [], + getFiles: async () => [], + createFromLane: async () => ({}), + land: async () => ({}), + closePr: async () => {}, + requestReviewers: async () => {}, + } as any, + sessionService: { list: () => [] } as any, + ptyService: {} as any, + computerUseArtifactBrokerService: {} as any, + missionService: { list: () => [] } as any, + agentChatService: { listSessions: async () => [] } as any, + processService: { listRuntime: () => [] } as any, + } as any); + + activeDisposers.push(async () => { + await service.dispose(); + db.close(); + }); + + await service.initialize(); + const status = await service.getStatus(); + + expect(status.bootstrapToken).toBeNull(); + expect(status.pairingPinConfigured).toBe(false); + await expect(service.setPin("123456")).rejects.toThrow( + "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", + ); + await expect(service.clearPin()).rejects.toThrow( + "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", + ); + expect(service.getPin()).toBeNull(); + }, 30_000); + it("retries the sync host on bind conflicts so another project can still initialize", async () => { const projectRoot = makeProjectRoot("ade-sync-service-port-retry-"); const db = await openKvDb( diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index b1f0fa1b5..7a4a7148a 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -300,6 +300,19 @@ 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; + const assertPhonePairingAvailable = (): void => { + if (!hostStartupEnabled) { + throw new Error( + "Phone pairing is unavailable because the sync host is disabled for this ADE process.", + ); + } + if (!isCrdtSyncAvailable()) { + throw new Error( + "Phone pairing is unavailable because the CRDT database extension is unavailable on this platform.", + ); + } + }; let activeLocalLanePresenceIds: string[] = []; const localLanePresenceHeartbeatTimer = setInterval(() => { if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; @@ -386,7 +399,7 @@ export function createSyncService(args: SyncServiceArgs) { }; const startHostIfNeeded = async (): Promise => { - if (!hostStartupEnabled) { + if (!hostStartupEnabled || !isCrdtSyncAvailable()) { if (hostService) { await stopHostIfRunning(); } @@ -541,6 +554,12 @@ export function createSyncService(args: SyncServiceArgs) { await startHostIfNeeded(); } else { await stopHostIfRunning(); + if (!isCrdtSyncAvailable()) { + if (syncPeerService.isConnected()) { + syncPeerService.disconnect({ preserveDraft: true }); + } + continue; + } const draft = savedDraft ?? resolveViewerDraftFromRegistry(); if (draft && !syncPeerService.isConnected()) { syncPeerService.setSavedDraft(draft); @@ -682,7 +701,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 +737,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.", }; }, @@ -753,6 +777,9 @@ export function createSyncService(args: SyncServiceArgs) { async connectToBrain( draft: SyncDesktopConnectionDraft, ): Promise { + if (!isCrdtSyncAvailable()) { + throw new Error("Desktop sync is unavailable because the CRDT database extension is not loaded."); + } await stopHostIfRunning(); deviceRegistryService.clearClusterRegistryForViewerJoin(); writeSavedDraft(draft); @@ -785,11 +812,7 @@ export function createSyncService(args: SyncServiceArgs) { }, async setPin(pin: string): Promise { - if (!hostStartupEnabled) { - throw new Error( - "Phone pairing is unavailable because the sync host is disabled for this ADE process.", - ); - } + assertPhonePairingAvailable(); const current = await service.getStatus(); if (current.role !== "brain") { throw new Error("Phone pairing PINs can only be managed on the host desktop."); @@ -801,6 +824,7 @@ export function createSyncService(args: SyncServiceArgs) { }, async clearPin(): Promise { + assertPhonePairingAvailable(); const current = await service.getStatus(); if (current.role !== "brain") { throw new Error("Phone pairing PINs can only be managed on the host desktop."); 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..4c365155a 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,13 @@ 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; + +function isBenignStdinCloseError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const code = (error as { code?: unknown }).code; + return code === "EPIPE" || code === "ERR_STREAM_DESTROYED"; +} const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; @@ -347,7 +356,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 +386,96 @@ 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) => { + if (isBenignStdinCloseError(error)) return; + 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) { + if (isBenignStdinCloseError(err)) return; + 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 +1007,5 @@ export const _testing = { fetchJson, findJsonlFiles, resolveTokenPrice, + pollCodexViaCliRpc, }; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 196d4b513..2ed9141a2 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -602,7 +602,7 @@ declare global { openPathInEditor: (args: { rootPath: string; relativePath?: string; - target: "finder" | "vscode" | "cursor" | "zed"; + target: "default" | "finder" | "vscode" | "cursor" | "zed"; }) => Promise; logDebugEvent: ( event: string, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 4205489b7..a61b80feb 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -612,7 +612,7 @@ contextBridge.exposeInMainWorld("ade", { openPathInEditor: async (args: { rootPath: string; relativePath?: string; - target: "finder" | "vscode" | "cursor" | "zed"; + target: "default" | "finder" | "vscode" | "cursor" | "zed"; }): Promise => ipcRenderer.invoke(IPC.appOpenPathInEditor, args), logDebugEvent: (event: string, payload: Record = {}): void => ipcRenderer.send(IPC.appLogDebugEvent, { event, payload }), 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..361875e48 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -1,9 +1,68 @@ /* @vitest-environment jsdom */ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, 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(), + }; +}); + +beforeEach(() => { + installMatchMediaMock(); +}); afterEach(cleanup); @@ -88,7 +147,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({ ) : ( @@ -1676,7 +1677,7 @@ export function LaneGitActionsPane({ effect: hasStaged ? `${amendCommit ? "Amend" : "Commit"} ${stagedCount} staged file${stagedCount === 1 ? "" : "s"}` : "No staged files to commit", - shortcut: "\u2318+Enter", + shortcut: `${modifierKeyLabel}+Enter`, }}> @@ -120,13 +120,13 @@ export function LaneRebaseBanner({
- + diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 2fbc521eb..15fd9cba8 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1101,7 +1101,7 @@ export function LanesPage() { const failedLane = start.run.failedLaneId ? lanesById.get(start.run.failedLaneId)?.name ?? start.run.failedLaneId : null; const detail = start.run.error ?? "Rebase failed."; setRebaseSuggestionError(`Rebase needs attention${failedLane ? ` for ${failedLane}` : ""}. ${detail}`); - navigate("/prs?tab=workflows&workflow=rebase"); + navigate("/prs?tab=rebase"); return; } @@ -1121,7 +1121,7 @@ export function LanesPage() { } catch (err) { const message = err instanceof Error ? err.message : String(err); setRebaseSuggestionError(message); - navigate("/prs?tab=workflows&workflow=rebase"); + navigate("/prs?tab=rebase"); } }, [lanesById, navigate, refreshLanes, requestPushSelection, requestRebaseScope]); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx index a15f5e12b..7c3fa158e 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -4,13 +4,8 @@ import React from "react"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MemoryRouter } from "react-router-dom"; import type { LaneSummary } from "../../../shared/types"; -function renderWithRouter(ui: React.ReactElement) { - return render({ui}); -} - function makeLane(overrides: Partial = {}): LaneSummary { return { id: "lane-1", @@ -122,7 +117,7 @@ describe("CreatePrModal queue workflow", () => { it("adds selected lanes to the queue order and removes them from the queue builder", async () => { const user = userEvent.setup(); - renderWithRouter(); + render(); await user.click(screen.getAllByRole("button", { name: /queue workflow/i })[0]!); await user.click(screen.getByRole("checkbox", { name: /01 queue lane/i })); @@ -141,7 +136,7 @@ describe("CreatePrModal queue workflow", () => { it("uses the dragged queue order when creating queue PRs", async () => { const user = userEvent.setup(); - renderWithRouter(); + render(); await user.click(screen.getAllByRole("button", { name: /queue workflow/i })[0]!); await user.click(screen.getByRole("checkbox", { name: /01 queue lane/i })); @@ -170,7 +165,7 @@ describe("CreatePrModal queue workflow", () => { it("lets single-PR creation target a different branch than Primary's current branch", async () => { const user = userEvent.setup(); - renderWithRouter(); + render(); // Select source lane const comboboxes = screen.getAllByRole("combobox"); @@ -196,7 +191,7 @@ describe("CreatePrModal queue workflow", () => { it("warns when the PR target branch differs from the lane base branch", async () => { const user = userEvent.setup(); - renderWithRouter(); + render(); // Select source lane const comboboxes = screen.getAllByRole("combobox"); @@ -215,7 +210,7 @@ describe("CreatePrModal queue workflow", () => { it("lets queue creation target a different branch than Primary's current branch", async () => { const user = userEvent.setup(); - renderWithRouter(); + render(); await user.click(screen.getAllByRole("button", { name: /queue workflow/i })[0]!); await user.click(screen.getByRole("checkbox", { name: /01 queue lane/i })); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index bae39811b..a078a9f7f 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; import * as Dialog from "@radix-ui/react-dialog"; import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, GitBranch, Sparkle, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash, ArrowUp, ArrowDown } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; @@ -447,7 +446,7 @@ function LaneWarningPanel({ cursor: "pointer", }} > - Open Rebase/Merge tab + Open Rebase Tab Review rebase status before PR creation{rebaseLaneIds.length > 1 ? ` (${rebaseLaneIds.length} lanes)` : ""}. @@ -471,7 +470,6 @@ export function CreatePrModal({ onOpenChange: (open: boolean) => void; onCreated?: (created: PrSummary[]) => void | Promise; }) { - const navigate = useNavigate(); const lanes = useAppStore((s) => s.lanes); const primaryLane = React.useMemo(() => lanes.find((l) => l.laneType === "primary") ?? null, [lanes]); @@ -598,9 +596,8 @@ export function CreatePrModal({ const openRebaseTab = React.useCallback((laneId: string) => { onOpenChange(false); - const search = new URLSearchParams({ tab: "workflows", workflow: "rebase", laneId }); - navigate({ pathname: "/prs", search: `?${search.toString()}` }); - }, [navigate, onOpenChange]); + window.location.hash = `#/prs?tab=rebase&laneId=${encodeURIComponent(laneId)}`; + }, [onOpenChange]); // Reset on close React.useEffect(() => { diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index eab3a5fa1..e10366d45 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -11,7 +11,7 @@ import { GitHubTab } from "./tabs/GitHubTab"; import { WorkflowsTab, type WorkflowCategory } from "./tabs/WorkflowsTab"; import { SANS_FONT } from "../lanes/laneDesignTokens"; import { isMissionLaneHiddenByDefault } from "../lanes/laneUtils"; -import { buildPrsRouteSearch, parsePrsRouteState, resolvePrsActiveTab } from "./prsRouteState"; +import { buildPrsRouteSearch, parsePrsRouteState } from "./prsRouteState"; import { resolveRouteRebaseSelection } from "./shared/rebaseNeedUtils"; import type { PrSummary } from "../../../shared/types"; @@ -84,21 +84,31 @@ function PRsPageInner() { search: location.search, hash: window.location.hash, }); - const resolved = resolvePrsActiveTab(routeState); + const tab = routeState.tab; + const workflowTab = routeState.workflowTab; const routeRebaseItemId = resolveRouteRebaseSelection({ rebaseNeeds, routeItemId: routeState.laneId, }); - setActiveTab(resolved.activeTab); + if (tab === "github" || tab === "normal") { + setActiveTab("normal"); + } else if (tab === "workflows") { + const nextWorkflowTab = workflowTab === "queue" || workflowTab === "integration" || workflowTab === "rebase" + ? workflowTab + : "integration"; + setActiveTab(nextWorkflowTab); + } else if (tab === "queue" || tab === "integration" || tab === "rebase") { + setActiveTab(tab); + } - if (!resolved.isWorkflowRoute) { + if (tab === "normal" || tab === "github") { setSelectedPrId(routeState.prId ?? null); } - if (resolved.effectiveWorkflow === "queue") { + if (tab === "queue" || workflowTab === "queue") { setSelectedQueueGroupId(routeState.queueGroupId ?? null); } - if (resolved.effectiveWorkflow === "rebase") { + if (tab === "rebase" || workflowTab === "rebase") { setSelectedRebaseItemId(routeRebaseItemId); } } catch { diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 6d66cd4b2..26771cf9e 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -34,6 +34,7 @@ import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; import { describePrTargetDiff } from "../shared/laneBranchTargets"; import { findMatchingRebaseNeed, rebaseNeedItemKey } from "../shared/rebaseNeedUtils"; import { usePrs } from "../state/PrsContext"; +import { modifierKeyLabel } from "../../../lib/platform"; // ---- Sub-tab type ---- type DetailTab = "overview" | "convergence" | "files" | "checks" | "activity"; @@ -2746,7 +2747,7 @@ function OverviewTab(props: OverviewTabProps) {