Skip to content

Windows port foundations: MCP IPC, CLI resolution, portable tooling#182

Closed
arul28 wants to merge 13 commits intomainfrom
cursor/windows-port-foundations-ede6
Closed

Windows port foundations: MCP IPC, CLI resolution, portable tooling#182
arul28 wants to merge 13 commits intomainfrom
cursor/windows-port-foundations-ede6

Conversation

@arul28
Copy link
Copy Markdown
Owner

@arul28 arul28 commented Apr 23, 2026

Summary

Foundational Windows port for the ADE desktop app and ADE CLI. No feature work; this lane enables the app to build, ship, and run on Windows with parity for the core agent surfaces (chat, files, MCP, orchestrator, PTY-like workflows).

Scope

  • MCP IPC portability — named-pipe path on Windows, Unix socket on macOS/Linux (adeMcpIpc.ts, preload, registerIpc).
  • ADE CLI resolution on Windows — PATH search, .cmd/.exe probing, wrapper scripts (ade-cli-windows-wrapper.cmd, ade-cli-install-path.cmd), install-path validator.
  • Renderer path helpers — Windows drive/UNC/absolute-path predicates in pathUtils.ts, shell detection in shell.ts.
  • Packagingafter-pack-runtime-fixes.cjs runtime fixes, validate-win-artifacts.mjs artifact validator, cr-sqlite win32-x64 vendor binary, release-core.yml workflow tweaks, .ico icon.
  • Tools & orchestrator — Windows-aware safe/unsafe command list in universalTools.ts, providerTaskRunner + providerOrchestratorAdapter + orchestratorService worker-launch plumbing, taskkill /T /F process-tree termination in processExecution.ts.
  • RendererAgentChatMessageList, FilesPage, dirtyWorkspaceBuffers portability fixes.

Tests added

  • providerTaskRunner.test.ts, windowsPackaging.test.ts, new tests in processExecution.test.ts (killWindowsProcessTree, terminateProcessTree), pathUtils.test.ts Windows predicates, shell.test.ts, dirtyWorkspaceBuffers.test.ts.
  • Total: ~46 new/extended tests across colocated files. All 8 vitest shards + ade-cli tests green locally.

Tangentially included (unblocked ade prs create for this lane)

  • Drop trailing slash on node_modules gitignore (worktree symlinks were showing up as untracked).
  • Fallback to gh auth token in ADE CLI when ADE_GITHUB_TOKEN / GITHUB_TOKEN env vars are missing.
  • Minor /finalize playbook addition (Phase 3j — stale worker-process cleanup).

Test plan

  • macOS: desktop app boots, MCP IPC reconnects across lane switches, PTY works.
  • Windows: desktop app installs from release artifact, ADE CLI resolves on PATH, MCP named pipe connects, PTY/shell workflows work.
  • CI: 8 desktop shards + ade-cli + typecheck + lint + build all green.

Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Windows desktop app support with NSIS installer and bundled ADE CLI wrapper.
    • Added "default" system file browser option for opening files in external editors.
    • Added computer-use screenshot capability checks with platform-specific commands.
  • Bug Fixes

    • Improved path handling for Windows backslashes and case-insensitive comparisons.
    • Fixed shell command execution on Windows via proper cmd.exe wrapping.
    • Fixed process tree termination and timeout handling across platforms.
  • Improvements

    • Enhanced platform-aware UI labels (Cmd/Ctrl based on OS).
    • Improved CLI executable resolution and PATH handling.
    • Strengthened sandbox enforcement with Windows command patterns.

Greptile Summary

This is a large Windows port laying the groundwork for building and running ADE on Windows: named-pipe MCP IPC, Windows-aware CLI resolution and PATH augmentation, stdin-based prompt delivery to avoid shell quoting issues, a node-script launcher for orchestrator workers replacing PTY shell startup, PowerShell/cmd.exe sandbox enforcement, and a full release validation pipeline.

  • P1 — worker launcher crashes on every invocation: WORKER_CLI_LAUNCHER_SCRIPT reads process.argv[1] for the launch-file path, but Node.js (and Electron with ELECTRON_RUN_AS_NODE) sets process.argv[1] to "" when using the -e flag; the first positional argument is at process.argv[2]. fs.readFileSync("", "utf8") throws ENOENT and every orchestrator worker exits with code 1.
  • P1 — PowerShell sandbox tokenizer mishandles '' escape: tokenizePowerShellCommand treats the first ' in a '' pair as the closing delimiter for a single-quoted string, silently splitting a path like 'C:\work\user''s dir\file' into two tokens. An agent-submitted path traversal sequence (e.g., 'C:\workspace\allowed''/../outside.txt') can pass the workspace-root check while PowerShell resolves the full, out-of-sandbox path.

Confidence Score: 3/5

Two P1 defects — a worker launcher that crashes on every invocation and a sandbox tokenizer path-traversal — need resolution before merging.

The process.argv[1] bug would make all orchestrator workers fail silently with exit code 1. The PowerShell tokenizer '' handling is a security boundary bypass in the sandbox enforcement introduced in this PR. Both are present in new code and would affect real users.

providerOrchestratorAdapter.ts (argv index bug) and universalTools.ts (PowerShell tokenizer '' escape) require fixes before merge.

Security Review

  • PowerShell sandbox path-traversal via tokenizer bug (universalTools.ts): The tokenizePowerShellCommand function does not implement PowerShell's ''-escape rule for single-quoted strings. A crafted path argument containing '' can cause the sandbox path extractor to check only the prefix of the path (which may be inside the workspace), while PowerShell operates on the full path (which may be outside). This allows an agent-supplied PowerShell command to bypass workspace-boundary enforcement.
  • No credential leakage, injection, or auth/authz issues identified in the remaining changes.

Important Files Changed

Filename Overview
apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts Major refactor: replaces shell-string worker launch with direct node -e SCRIPT launchFile invocation via nodeWorkerLaunch. Contains a likely bug where WORKER_CLI_LAUNCHER_SCRIPT reads process.argv[1] for the launch file path, but Node's -e mode places the first positional argument at process.argv[2].
apps/desktop/src/main/services/ai/tools/universalTools.ts Extensive new Windows sandbox enforcement: PowerShell command tokenizer and inspector, MUTATING_CMD_RE for cmd.exe. The PowerShell tokenizer does not handle '' as an escaped single-quote within single-quoted strings, which can cause path-argument extraction to fail for paths containing apostrophes, potentially weakening sandbox path checks.
apps/desktop/src/main/services/shared/processExecution.ts New file providing Windows-portable process management: quoteWindowsCmdArg, resolveCliSpawnInvocation, killWindowsProcessTree, and terminateProcessTree. Well-structured with good error handling.
apps/desktop/src/main/services/ai/cliExecutableResolver.ts Extended with getPathEnvKey/getPathEnvValue/setPathEnvValue for case-insensitive Windows PATH lookup, plus getWindowsKnownBinDirs covering Scoop, Winget, Volta, pnpm, npm, and common install paths.
apps/desktop/src/main/services/ipc/registerIpc.ts New resolveRendererSuppliedPath helper centralizes ade-artifact:// and file:// URI-to-path resolution across IPC handlers; adds "default" editor target using shell.openPath; adds Windows start "" cmd.exe editor launch.
apps/desktop/src/main/services/ai/providerTaskRunner.ts Refactored to deliver prompts via stdin rather than a positional shell argument, using resolveCliSpawnInvocation for cross-platform spawn and terminateProcessTree on timeout.
apps/desktop/src/renderer/lib/shell.ts Extended with Windows-aware argument quoting (quoteWindowsArg) and parseWindowsCommandLine implementing CommandLineToArgvW rules. Uses deprecated navigator.platform.
apps/desktop/src/renderer/lib/pathUtils.ts New renderer-side path utilities for Windows drive/UNC path detection, normalization, and case-insensitive comparison. Comprehensive coverage of edge cases with corresponding test file.
apps/desktop/src/shared/adeMcpIpc.ts New file: resolves MCP IPC socket path as a Windows named pipe (\\.\pipe\ade-<sha256[:24]>) or POSIX Unix socket. Correct canonical path normalization before hashing.
apps/desktop/scripts/validate-win-artifacts.mjs New CI validation script checking package.json configuration, NSIS installer artifact integrity, and latest.yml sha512 metadata. Thorough preflight and release validation stages.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[buildStartupCommand] --> B[writeWorkerPromptFile\n.ade/worker-prompts/worker-ID.txt]
    A --> C[writeWorkerLaunchFile\n.ade/worker-prompts/worker-ID.launch.json]
    C --> D[resolveCliSpawnInvocation\nwraps .cmd/.bat in cmd.exe on Windows]
    A --> E[nodeWorkerLaunch]
    E --> F["spawn(process.execPath,\n  ['-e', WORKER_CLI_LAUNCHER_SCRIPT, launchFilePath],\n  {ELECTRON_RUN_AS_NODE: '1'})"]
    F -->|"reads process.argv[1]\n⚠ should be argv[2]"| G[fs.readFileSync launch.json]
    G --> H[spawn worker CLI\ne.g. claude/codex with args+env]
    H --> I[stdin piped from promptFilePath]
    H --> J[child.on exit → finish code]
    K[terminateProcessTree] --> L{win32?}
    L -->|yes| M[taskkill /T /F /PID]
    L -->|no| N[child.kill SIGTERM]
    M -->|fails| N
    style F fill:#ffd,stroke:#f90
    style G fill:#fdd,stroke:#f00
Loading

Comments Outside Diff (1)

  1. apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts, line 348-413 (link)

    P1 process.argv[1] is empty when Node runs with -e

    The WORKER_CLI_LAUNCHER_SCRIPT is launched as:

    node -e SCRIPT launchFilePath
    

    When Node.js (or Electron in ELECTRON_RUN_AS_NODE mode) runs with -e, process.argv[1] is set to the empty string "" — the first positional argument lands at process.argv[2]. The script does:

    const specPath = process.argv[1]; // ← always "" when launched with -e

    fs.readFileSync("", "utf8") will throw ENOENT, the finish(1) handler fires, and every orchestrator worker launch fails with exit code 1 rather than the real exit code. This makes the worker silently fail without a meaningful error message.

    Change the script to read process.argv[2]:

    const specPath = process.argv[2];

    The same fix applies to the failure-path inline script on the last return branch of buildStartupCommand:

    args: ["-e", "console.error(process.argv[2]); process.exit(64);", failureMessage],
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
    Line: 348-413
    
    Comment:
    **`process.argv[1]` is empty when Node runs with `-e`**
    
    The `WORKER_CLI_LAUNCHER_SCRIPT` is launched as:
    ```
    node -e SCRIPT launchFilePath
    ```
    When Node.js (or Electron in `ELECTRON_RUN_AS_NODE` mode) runs with `-e`, `process.argv[1]` is set to the empty string `""` — the first positional argument lands at `process.argv[2]`. The script does:
    
    ```js
    const specPath = process.argv[1]; // ← always "" when launched with -e
    ```
    
    `fs.readFileSync("", "utf8")` will throw `ENOENT`, the `finish(1)` handler fires, and every orchestrator worker launch fails with exit code 1 rather than the real exit code. This makes the worker silently fail without a meaningful error message.
    
    Change the script to read `process.argv[2]`:
    ```js
    const specPath = process.argv[2];
    ```
    
    The same fix applies to the failure-path inline script on the last `return` branch of `buildStartupCommand`:
    ```js
    args: ["-e", "console.error(process.argv[2]); process.exit(64);", failureMessage],
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/services/ai/tools/universalTools.ts
Line: 247-268

Comment:
**PowerShell single-quoted string `''` escape not handled in tokenizer**

In PowerShell, `''` inside a single-quoted string is the escape sequence for a literal single-quote character (analogous to SQL). The tokenizer closes the string when it sees the first `'`, so `Remove-Item 'C:\project\user''s dir\file.txt'` is parsed as two tokens — `C:\project\user` and `s dir\file.txt` — rather than the single path `C:\project\user's dir\file.txt`.

The `addResolvedPath` logic then checks `C:\project\user` (which is inside the allowed workspace root) and ignores the trailing fragment. An agent could craft a command like `Remove-Item 'C:\workspace\allowed''/../outside.txt'` where the first token is an allowed workspace path, causing the sandbox path check to pass while PowerShell actually resolves and operates on a path outside the sandbox.

When the `quote` is `'` and the next character is also `'`, the tokenizer should emit a literal `'` and stay inside the string rather than closing it:
```typescript
if (quote) {
  if (ch === quote) {
    if (quote === "'" && command[i + 1] === "'") {
      // PowerShell escaped single-quote inside single-quoted string
      current += "'";
      i += 1;
    } else {
      quote = null;
    }
  } else {
    current += ch;
  }
  continue;
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/renderer/lib/shell.ts
Line: 951-955

Comment:
**`navigator.platform` is deprecated**

`navigator.platform` is deprecated in all modern browsers and may return an empty string in some environments. The MDN-recommended replacement is `navigator.userAgentData?.platform` for browser contexts. In practice the Electron renderer always has `process.platform`, so the `navigator.platform` branch is only reached in unit tests or pure-browser contexts. Consider checking `navigator.userAgentData?.platform` first:

```typescript
function currentShellPlatform(): ShellPlatform {
  if (typeof navigator !== "undefined") {
    const p = (navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform?.toLowerCase()
              ?? navigator.platform;
    if (/win/i.test(p)) return "win32";
  }
  if (typeof process !== "undefined" && typeof process.platform === "string") return process.platform;
  return "browser";
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
Line: 348-413

Comment:
**`process.argv[1]` is empty when Node runs with `-e`**

The `WORKER_CLI_LAUNCHER_SCRIPT` is launched as:
```
node -e SCRIPT launchFilePath
```
When Node.js (or Electron in `ELECTRON_RUN_AS_NODE` mode) runs with `-e`, `process.argv[1]` is set to the empty string `""` — the first positional argument lands at `process.argv[2]`. The script does:

```js
const specPath = process.argv[1]; // ← always "" when launched with -e
```

`fs.readFileSync("", "utf8")` will throw `ENOENT`, the `finish(1)` handler fires, and every orchestrator worker launch fails with exit code 1 rather than the real exit code. This makes the worker silently fail without a meaningful error message.

Change the script to read `process.argv[2]`:
```js
const specPath = process.argv[2];
```

The same fix applies to the failure-path inline script on the last `return` branch of `buildStartupCommand`:
```js
args: ["-e", "console.error(process.argv[2]); process.exit(64);", failureMessage],
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (9): Last reviewed commit: "Merge main into Windows port lane" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 23, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ade Ready Ready Preview, Comment Apr 23, 2026 9:53pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

Warning

Rate limit exceeded

@arul28 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 12 minutes and 37 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 12 minutes and 37 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ba037a55-024f-4cec-b3a2-a356d14a730b

📥 Commits

Reviewing files that changed from the base of the PR and between ddecf19 and a1c13fe.

📒 Files selected for processing (20)
  • apps/ade-cli/src/adeRpcServer.ts
  • apps/ade-cli/src/bootstrap.ts
  • apps/desktop/scripts/ade-cli-install-path.cmd
  • apps/desktop/src/main/services/ai/providerTaskRunner.ts
  • apps/desktop/src/main/services/ai/tools/universalTools.test.ts
  • apps/desktop/src/main/services/ai/tools/universalTools.ts
  • apps/desktop/src/main/services/opencode/openCodeServerManager.ts
  • apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
  • apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
  • apps/desktop/src/main/services/shared/processExecution.test.ts
  • apps/desktop/src/main/services/shared/processExecution.ts
  • apps/desktop/src/main/services/sync/syncService.ts
  • apps/desktop/src/main/services/usage/usageTrackingService.ts
  • apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
  • apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx
  • apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx
  • apps/desktop/src/renderer/components/files/FilesPage.tsx
  • apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx
  • apps/desktop/src/renderer/lib/pathUtils.test.ts
  • apps/desktop/src/renderer/lib/pathUtils.ts
📝 Walkthrough

Walkthrough

This PR introduces comprehensive Windows platform support for the desktop application and CLI. It adds Windows-specific build/release pipelines, Windows-aware process execution utilities, path normalization and comparison for Windows paths, shell command parsing for Windows, Windows MCP IPC named-pipe resolution, and Windows-specific sandbox enforcement. Additionally, the shipLane command description is updated from "max 5 iterations" to "max 10 iterations".

Changes

Cohort / File(s) Summary
Command metadata
.claude/commands/shipLane.md
Updated iteration limit from max 5 to max 10 iterations and added scheduling control-flow constraints.
Windows build and release pipeline
.github/workflows/release-core.yml, apps/desktop/package.json
Added Windows build job, NSIS packaging configuration with icon, Windows-specific npm scripts (dist:win, validate:win:), and refactored publish job to depend on both Mac and Windows builds with multi-platform artifact handling.
Windows build validation and scripts
apps/desktop/scripts/validate-win-artifacts.mjs, apps/desktop/scripts/ade-cli-windows-wrapper.cmd, apps/desktop/scripts/ade-cli-install-path.cmd, apps/desktop/scripts/after-pack-runtime-fixes.cjs
Introduced comprehensive Windows artifact validation (434 lines), batch CLI wrappers for Node runtime resolution and PATH installation, and refactored post-pack script with platform-specific CLI artifact checks and unpacked runtime helpers.
Shared process execution utilities
apps/desktop/src/main/services/shared/processExecution.ts, apps/desktop/src/main/services/shared/processExecution.test.ts
Added new shared module for platform-aware process spawning (cmd.exe wrapping on Windows), Windows process-tree termination via taskkill, and standardized spawn invocation handling with SpawnInvocation abstraction.
Windows path utilities
apps/desktop/src/renderer/lib/pathUtils.ts, apps/desktop/src/renderer/lib/pathUtils.test.ts
Introduced path normalization, comparison, and rename remapping utilities supporting Windows drive/UNC paths with case-insensitive workspace-aware matching.
Windows shell parsing and utilities
apps/desktop/src/renderer/lib/shell.ts, apps/desktop/src/renderer/lib/shell.test.ts
Added platform-aware shell argument quoting and command-line parsing with Windows-specific handling of backslashes, quote escaping, and doubled-quote semantics.
ADE MCP IPC path resolution
apps/desktop/src/shared/adeMcpIpc.ts, apps/desktop/src/shared/adeMcpIpc.test.ts, apps/desktop/src/shared/adeLayout.ts
Introduced Windows named-pipe IPC path resolution (\\.\pipe\ade-<hash>) with fallback to Unix socket, and updated layout resolution to use platform-specific socket paths.
CLI executable resolution and PATH handling
apps/desktop/src/main/services/ai/cliExecutableResolver.ts, apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts, apps/desktop/src/main/services/cli/adeCliService.ts, apps/desktop/src/main/services/cli/adeCliService.test.ts, apps/ade-cli/src/cli.ts, apps/ade-cli/src/cli.test.ts
Made PATH handling case-aware and delimiter-aware on Windows, added Windows home directory preference (USERPROFILE over HOME), Windows batch shim generation for dev, and cross-platform command lookup (where vs which).
Windows CLI runtime setup
apps/desktop/src/main/services/cli/windowsPackaging.test.ts, apps/ade-cli/src/bootstrap.ts, apps/ade-cli/src/adeRpcServer.ts, apps/ade-cli/src/adeRpcServer.test.ts
Updated ADE CLI bootstrap to use dedicated headless process service with PATH augmentation, refactored worker spawn logic to use file-based prompts and executable resolution with commandArgs/workerEnv separation, and added comprehensive Windows worker spawn testing.
Orchestrator and adapter services
apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts, apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts, apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts, apps/desktop/src/main/services/orchestrator/orchestratorService.ts, apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts, apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts, apps/desktop/src/main/services/orchestrator/orchestratorContext.ts, apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts
Refactored worker launch to use AdapterLaunchCommand structure with file-based prompts and executable resolution, added Windows registry/disk command blocking patterns, tightened health-sweep concurrency control, and updated hook timeouts to use process-tree termination.
AI provider and task execution
apps/desktop/src/main/services/ai/providerTaskRunner.ts, apps/desktop/src/main/services/ai/providerTaskRunner.test.ts, apps/desktop/src/main/services/ai/providerCredentialSources.ts, apps/desktop/src/main/services/usage/usageTrackingService.ts, apps/desktop/src/main/services/usage/usageTrackingService.test.ts
Updated provider execution to use resolveCliSpawnInvocation with stdin injection support, made shell command execution Windows-aware (cmd.exe /d /s /c), and refactored Codex CLI polling to spawn-based execution with process-tree termination.
AI tools and sandbox enforcement
apps/desktop/src/main/services/ai/tools/universalTools.ts, apps/desktop/src/main/services/ai/tools/universalTools.test.ts, apps/desktop/src/main/services/ai/tools/globSearch.ts, apps/desktop/src/main/services/ai/tools/globSearch.test.ts, apps/desktop/src/main/services/ai/tools/workflowTools.ts
Added comprehensive Windows PowerShell mutation detection, Windows path containment semantics via SandboxPathApi, Windows drive/UNC path support in glob patterns, and capability-aware screenshot tool with computer-use availability checks.
Chat and agent services
apps/desktop/src/main/services/chat/agentChatService.ts, apps/desktop/src/main/services/chat/cursorAcpPool.ts
Updated Codex subprocess spawning and process termination to use resolveCliSpawnInvocation with Windows-aware process-tree killing; updated ACP pool process management with platform-safe invocation handling.
Conflict resolution and lane execution
apps/desktop/src/main/services/conflicts/conflictService.ts, apps/desktop/src/main/services/lanes/laneEnvironmentService.ts, apps/desktop/src/main/services/automations/automationService.ts, apps/desktop/src/main/services/automations/automationPlannerService.ts
Migrated conflict resolver to manual timeout management with process-tree termination, converted lane command execution from execFile to spawn with platform-aware invocation, and made automation shell selection Windows-aware.
OpenCode server management
apps/desktop/src/main/services/opencode/openCodeServerManager.ts, apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts, apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts, apps/desktop/src/main/services/opencode/openCodeRuntime.ts
Added Windows process discovery via WMIC CSV parsing, Windows process-tree termination for orphan recovery, Windows launch spec generation with cmd.exe quoting, and executable candidate scanning with platform-aware accessibility checks.
Test and shared utilities
apps/desktop/src/main/services/tests/testService.ts, apps/desktop/src/main/services/shared/utils.ts, apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts, apps/desktop/src/main/services/state/kvDb.ts, apps/desktop/src/main/services/sync/syncService.ts, apps/desktop/src/main/services/sync/syncService.test.ts, apps/desktop/src/main/services/pty/ptyService.ts, apps/desktop/src/main/services/pty/ptyService.test.ts
Updated test runner and shared utilities to use resolveCliSpawnInvocation with process-tree termination, added CRDT sync availability checks, updated PTY direct-command handling with platform-aware spawn invocation, and fixed startupCommand skip logic for direct commands.
IPC and artifact handling
apps/desktop/src/main/services/ipc/registerIpc.ts, apps/desktop/src/main/services/computerUse/localComputerUse.ts
Added renderer-supplied path normalization for ade-artifact:// URLs, configurable Windows command-line quoting for detached editors (cmd.exe start with quoteWindowsCmdArg), and forward-slash normalization for safe relative paths in artifact URIs.
Chat UI and message rendering
apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx, apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx, apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx
Updated markdown URL transformation to bypass Windows-absolute and file: paths, added workspace path location parsing with line/column extraction, improved file:// URI normalization via fileUriToFsPath with hostname mapping, and extended glob search testing for Windows backslash patterns.
Files and editor integration
apps/desktop/src/renderer/components/files/FilesPage.tsx, apps/desktop/src/renderer/components/files/FilesPage.test.tsx
Added workspace-aware path comparison across tab management/rename/delete, cursor positioning with startLine/startColumn, and case-insensitive path matching on Windows; extended editor mock with navigation methods and updated "open in default" UI action.
Dirty buffer tracking
apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.ts, apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.test.ts
Refactored dirty-buffer path handling to use pathUtils normalization and workspace-aware comparison instead of path-browserify, with Windows-case and separator-agnostic matching.
Platform-aware UI labels
apps/desktop/src/renderer/lib/platform.ts, apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx, apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx, apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx, apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx, apps/desktop/src/renderer/components/settings/AdeCliSection.tsx
Added modifierKeyLabel constant ("Cmd" on macOS, "Ctrl" elsewhere) and updated UI placeholders/tooltips to use dynamic platform-appropriate labels instead of hardcoded "Cmd+Enter" or "Cmd+.".
Misc renderer components
apps/desktop/src/renderer/components/app/CommandPalette.tsx, apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx, apps/desktop/src/renderer/onboarding/tours/filesTour.ts, apps/desktop/src/preload/global.d.ts, apps/desktop/src/preload/preload.ts, apps/desktop/src/main/main.ts, apps/desktop/src/main/packagedRuntimeSmoke.ts
Updated path separator preservation for Windows drive/UNC roots, extended "default" editor target support, improved renderer URL generation via pathToFileURL, added Windows icon support, conditional socket unlinking for MCP named pipes, and Windows PowerShell PTY probe.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

desktop, windows, ci, process-management, path-handling

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cursor/windows-port-foundations-ede6

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Comment thread apps/desktop/src/main/services/pty/ptyService.ts
Comment thread apps/desktop/src/main/services/cli/adeCliService.ts
@arul28 arul28 force-pushed the cursor/windows-port-foundations-ede6 branch from cb78734 to 79926e6 Compare April 23, 2026 19:49
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Comment thread apps/ade-cli/src/adeRpcServer.ts Outdated
title,
tracked: true,
toolType: `${provider}-orchestrated`,
command: provider,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

This change switches spawn_agent from shell-typing a startup string to PTY direct spawn, but it still passes the bare provider name instead of a resolved executable path:

// apps/ade-cli/src/adeRpcServer.ts
toolType: `${provider}-orchestrated`,
command: provider,
args: commandArgs,
env: workerEnv,
startupCommand

On the direct path, ptyService.create() calls resolveCliSpawnInvocation() against the ADE process env only, so it no longer benefits from the interactive shell that previously discovered user-installed CLIs via shell init. ADE’s existing direct-launch path in providerOrchestratorAdapter explicitly resolves claude/codex first (resolveClaudeCodeExecutable() / resolveCodexExecutable()), which is the resolution step this code now needs as well. In packaged desktop setups where the app process PATH does not already contain the provider binary, spawn_agent will now fail to start the worker session instead of launching the CLI. Resolve the provider executable before calling ptyService.create, or keep shell execution for unresolved providers.

Comment thread apps/desktop/src/main/services/shared/processExecution.ts
@arul28 arul28 force-pushed the cursor/windows-port-foundations-ede6 branch from 79926e6 to bcd3638 Compare April 23, 2026 20:13
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Comment thread apps/desktop/src/renderer/lib/shell.ts
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 8 comments

Comment thread docs/ARCHITECTURE.md Outdated
- `npm run dist:win` — x64 installer via `electron-builder --win --x64`, wrapped with `validate:win:artifacts` (preflight) and `validate:win:release` (post-build) checks in `apps/desktop/scripts/validate-win-artifacts.mjs`.
- Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (launcher) and `ade-cli-install-path.cmd` (idempotent PATH install helper). The platform-agnostic `.sh` wrapper covers macOS/Linux.
- The Windows installer bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/` plus a Windows node-pty ConPTY worker.
- GitHub Actions `release-core.yml` drives signed/notarized Windows artifacts.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This new bullet says the Windows release path is signed/notarized, but the checked-in workflow only wires signing/notarization credentials for the macOS job and the Windows job just runs npm run dist:win; Windows also has no notarization step. That means the architecture doc is asserting a release guarantee the current pipeline does not enforce, which can mislead release operators and agents validating artifact provenance. Update the wording to describe the actual behavior today (build + validate + upload for publish) unless Windows signing is added to release-core.yml.

# docs/ARCHITECTURE.md
- `npm run dist:win` — x64 installer via `electron-builder --win --x64`, wrapped with `validate:win:artifacts` (preflight) and `validate:win:release` (post-build) checks in `apps/desktop/scripts/validate-win-artifacts.mjs`.
- Windows-only wrappers for the bundled `ade` CLI ship in `apps/desktop/scripts/`: `ade-cli-windows-wrapper.cmd` (launcher) and `ade-cli-install-path.cmd` (idempotent PATH install helper). The platform-agnostic `.sh` wrapper covers macOS/Linux.
- The Windows installer bundles the prebuilt `cr-sqlite` native binary from `apps/desktop/vendor/crsqlite/win32-x64/` plus a Windows node-pty ConPTY worker.
- GitHub Actions `release-core.yml` drives signed/notarized Windows artifacts.
Suggested change
- GitHub Actions `release-core.yml` drives signed/notarized Windows artifacts.
- GitHub Actions `release-core.yml` builds and validates Windows release artifacts, then uploads them for the publish step.

const env = buildIsolatedOpenCodeEnv(args.config, xdgPaths);
if (process.platform === "win32") {
const comSpec = process.env.ComSpec?.trim() || "cmd.exe";
const exeForCmd = `"${executable.replace(/"/g, "\"\"")}"`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This Windows launch path hand-rolls quoting for the executable token, but it only doubles " and does not escape %. cmd.exe expands %...% even inside quoted arguments, which is why the shared helper in processExecution.ts converts % to %%. As written, a resolved OpenCode path containing % will be rewritten before execution and opencode serve will fail to start on Windows. Reuse quoteWindowsCmdArg() / resolveWindowsCmdInvocation() here so the OpenCode launcher follows the same escaping contract as the rest of the Windows process code.

// apps/desktop/src/main/services/opencode/openCodeServerManager.ts
const comSpec = process.env.ComSpec?.trim() || "cmd.exe";
const exeForCmd = `"${executable.replace(/"/g, "\"\"")}"`;
const cmdLine =
  `set "${ADE_OPENCODE_MANAGED_ENV}=1"&&set "OPENCODE_DISABLE_PROJECT_CONFIG=1"&&set "${ADE_OPENCODE_OWNER_PID_ENV}=${process.pid}"&&${exeForCmd} serve --hostname=127.0.0.1 --port=${args.port}`;

if (
/\bcmd(?:\.exe)?\b/i.test(command)
&& command.includes(`${ADE_OPENCODE_MANAGED_ENV}=1`)
&& /\bopencode(?:\.cmd|\.exe)?\b/i.test(command)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The Windows resolver/launcher now accepts opencode.bat as a valid executable candidate, but the managed-process detector still only matches opencode, opencode.exe, and opencode.cmd. If Windows resolves a .bat wrapper (via the new bundled fallback list or PATHEXT search), ADE can launch it, but recoverManagedOpenCodeOrphans() will never classify that command line as ADE-managed, so stale opencode serve trees survive crashes/restarts and can interfere with later launches. Keep the executable matcher in sync with the resolver by including .bat in both regexes or centralizing the pattern.

// apps/desktop/src/main/services/opencode/openCodeServerManager.ts
/\bopencode(?:\.cmd|\.exe)?\b/i.test(command)
...
if (!/\bopencode(?:\.cmd|\.exe)?\b\s+serve\b/i.test(command)) return false;

// Default to claude since it's the most common
return `exec claude -p ${shellInlineDecodedArg(prompt)}`;
return {
startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new direct-launch override path hard-codes claude instead of using the same executable resolver as the normal worker-launch path, so provider steps that set metadata.startupCommand will fail on Windows/package installs where Claude is found via CLAUDE_CODE_EXECUTABLE_PATH, auth detection, or common-dir probing rather than plain PATH lookup. The rest of this file already treats executable resolution as required for Windows portability, so this override branch should do the same.

// apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
return {
  startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`,
  command: "claude",
  args: ["-p", prompt],
};
Suggested change
startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`,
command: resolveClaudeCodeExecutable().path,

cwd: cwdLane.worktreePath,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
timeout: 8 * 60_000,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

resolveCliSpawnInvocation() now routes extensionless and .cmd resolver commands through cmd.exe /d /s /c ... on Windows, but this call site still relies on Node's built-in timeout, which only sends the kill signal to the spawned child process. In the new Windows path that child is cmd.exe, so a timed-out npm-style resolver can keep running after ADE has marked the run failed and started snapshotting git diff, leaving the lane mutating in the background and the recorded patch/log stale or incomplete. Use an explicit timer here and call terminateProcessTree(child, ...) instead of spawn(..., { timeout }) so the entire resolver tree is terminated consistently, matching the timeout handling already used in providerTaskRunner and shared/utils.

// apps/desktop/src/main/services/conflicts/conflictService.ts
const child = spawn(invocation.command, invocation.args, {
  cwd: cwdLane.worktreePath,
  env: process.env,
  stdio: ["ignore", "pipe", "pipe"],
  timeout: 8 * 60_000,
  windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});

Comment on lines +1123 to +1124
const invocation = resolveCliSpawnInvocation(directCommand, directArgs, launchEnv);
created = ptyLib.spawn(invocation.command, invocation.args, opts);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

resolveCliSpawnInvocation() is doing the right thing for Windows .cmd/extensionless commands, but this PTY path drops the only part that matters to node-pty: its Windows API accepts either string[] argv or a single pre-escaped command-line string, and it does not expose windowsVerbatimArguments. The new code still passes an argv array into ptyLib.spawn(), so cmd.exe /c shims get re-escaped by node-pty and can misparse spaced/quoted arguments (for example workspace paths or resolved codex.cmd/claude.cmd launches). That means the new Windows direct-command PTY flow can fail even though the helper already produced the correct command line. Pass a single joined command-line string when invocation.windowsVerbatimArguments is true.

// apps/desktop/src/main/services/pty/ptyService.ts
const invocation = resolveCliSpawnInvocation(directCommand, directArgs, launchEnv);
created = ptyLib.spawn(invocation.command, invocation.args, opts);
Suggested change
const invocation = resolveCliSpawnInvocation(directCommand, directArgs, launchEnv);
created = ptyLib.spawn(invocation.command, invocation.args, opts);
const invocation = resolveCliSpawnInvocation(directCommand, directArgs, launchEnv);
created = ptyLib.spawn(invocation.command, invocation.windowsVerbatimArguments ? invocation.args.join(" ") : invocation.args, opts);

}
}

if (!script) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

inspectPowerShellInvocations() only extracts scripts from -Command and -EncodedCommand, then silently skips every other PowerShell entrypoint with if (!script) continue;. PowerShell itself accepts both -File <script.ps1> and a bare script path as the default parameter, so commands like pwsh -File .\worker.ps1 or pwsh .\worker.ps1 will reach checkWorkerSandbox() with no inspected inner operations. That lets an agent run a workspace-local .ps1 whose contents write outside allowedPaths or touch protected files, which defeats the new Windows sandboxing path. Fail closed for -File/default-script invocation unless you also parse and validate the target script contents.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
if (POWERSHELL_COMMAND_FLAGS.has(flag)) { /* ... */ }
if (POWERSHELL_ENCODED_COMMAND_FLAGS.has(flag)) { /* ... */ }

if (!script) continue;

for (const flag of flags) {
const values = parsed.named.get(flag);
if (values?.length) {
return addResolvedPath(values[0]!, access);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

The new parser fails open for non-trivial -Path forms. parsePowerShellArgs() records an empty string when a path flag is followed by punctuation such as (, and addFirstNamedOrPositionalPath() only checks values[0]. As a result, Set-Content -Path (Join-Path .. outside.txt) ... records no path at all, and array-valued forms like Remove-Item .\ok.txt, ..\outside.txt validate only the first target. Because checkWorkerSandbox() still marks these commands as mutating, but never sees the real destination(s), writes outside the sandbox or to protected files slip through. Treat parenthesized/subexpression/array path arguments as uninspectable and reject them unless every resolved target is enumerated and checked.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
if (next.length > 0 && !isPowerShellParameterToken(next) && !POWERSHELL_PUNCTUATION_TOKENS.has(next)) {
  values.push(next);
} else {
  values.push("");
}
...
if (values?.length) {
  return addResolvedPath(values[0]!, access);
}

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 8 comments


for (const segment of outerSegments) {
const commandName = normalizePathToken(segment[0] ?? "");
if (!commandName.length || !isPowerShellHostCommand(commandName)) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

inspectPowerShellInvocations only analyzes segments whose first token is itself a PowerShell host, so a wrapped command like cmd /c powershell.exe -Command "Set-Content -Path .env -Value secret" never reaches the new PowerShell parser even though Windows workers are now executed through cmd.exe. The generic token/path scan also treats the quoted script as opaque, so protected targets such as .env or paths outside allowedPaths are never recorded and checkWorkerSandbox() allows the command. That weakens the API-worker sandbox boundary on Windows for exactly the PowerShell mutation cases this diff adds support for. Fix by unwrapping cmd.exe /c (and equivalent shell wrappers) before PowerShell inspection or by recursively inspecting nested shell hosts.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
const outerSegments = splitCommandSegments(tokenizeCommand(command, pathApi === path.win32));
let mutates = false;

for (const segment of outerSegments) {
  const commandName = normalizePathToken(segment[0] ?? "");
  if (!commandName.length || !isPowerShellHostCommand(commandName)) continue;

const role = isLocalBrain ? "brain" : "viewer";
const canHostPhonePairing = role === "brain" && hostStartupEnabled;
const crdtSyncAvailable = isCrdtSyncAvailable();
const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new status gate only hides pairing data; it does not enforce the same invariant in the pairing mutators. ts // apps/desktop/src/main/services/sync/syncService.ts const crdtSyncAvailable = isCrdtSyncAvailable(); const canHostPhonePairing = role === "brain" && hostStartupEnabled && crdtSyncAvailable; ... pairingPin: canHostPhonePairing ? pinStore.getPin() : null, pairingPinConfigured: canHostPhonePairing ? pinStore.hasPin() : false, On platforms where db.sync.isAvailable() is false, setPin() still succeeds because it only checks hostStartupEnabled, while getStatus() immediately reports pairingPinConfigured: false and pairingConnectInfo: null. In the renderer this produces a false-success flow (PIN updated. followed by a still-disabled pairing card), which is not graceful degradation. Apply the same isCrdtSyncAvailable() guard to setPin/clearPin or expose an explicit unavailable state that disables the pairing controls.

export function resolveAdeMcpIpcPath(projectRoot: string): string {
const root = path.resolve(projectRoot);
if (process.platform === "win32") {
const id = createHash("sha256").update(root.toLowerCase()).digest("hex").slice(0, 24);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

resolveAdeMcpIpcPath() hashes path.resolve(projectRoot) directly, so on Windows the same repo opened through different aliases (junction/symlink, mapped drive vs UNC, etc.) produces different pipe names even though both paths point at the same project. I verified that both the desktop server and the CLI consume this helper via resolveAdeLayout(), so a path-alias mismatch makes the CLI miss the live desktop socket and fall back to headless mode.

// apps/desktop/src/shared/adeMcpIpc.ts
const root = path.resolve(projectRoot);
if (process.platform === "win32") {
  const id = createHash("sha256").update(root.toLowerCase()).digest("hex").slice(0, 24);
  return `\\\\.\\pipe\\ade-${id}`;
}

Canonicalize the Windows root with fs.realpathSync.native() (or equivalent fallback) before hashing so every alias of the same project resolves to one pipe id.

await new Promise((resolve) => setTimeout(resolve, 25));
}
if (activeHealthSweepRuns.has(run.id)) continue;
if (activeHealthSweepRuns.has(run.id)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

When an explicit sweep (chat_status, manual, etc.) arrives while another sweep for the same run is still active for more than 3 seconds, this branch now logs the overlap and then falls through into the sweep body without acquiring activeHealthSweepRuns. That reintroduces concurrent per-run sweeps, so two loops can race through tick(), onTrackedSessionEnded(), watchdog completeAttempt(), and autopilot startup for the same run, producing duplicate recovery side effects and nondeterministic state transitions. ```ts
// apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts
if (activeHealthSweepRuns.has(run.id)) {
logger.debug("ai_orchestrator.health_sweep_explicit_overlap", {
runId: run.id,
reason
});
}
if (!activeHealthSweepRuns.has(run.id)) {


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"]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

The new Windows branch routes all three launcher processes through npx.cmd, but spawnProcess() still calls child_process.spawn(cmd, args, ...) without shell: true or a cmd.exe /c wrapper. On Node 22.x (the repo's required runtime), .cmd files cannot be spawned this way on Windows, so the first Vite/Tsup launch fails with EINVAL and npm run dev never comes up. This also affects the later Electron launch/restart path because it uses the same helper. Fix by spawning through cmd.exe/shell: true on Windows, or by avoiding the .cmd wrapper entirely.

// apps/desktop/scripts/dev.cjs
const vite = spawnProcess("renderer", npxCommand, ["vite", "--port", String(devPort), "--strictPort", "--force"]);
const main = spawnProcess("main", npxCommand, ["tsup", "--watch"]);

Comment thread apps/ade-cli/src/adeRpcServer.ts Outdated
workerEnv.ADE_DEFAULT_ROLE = "agent";
envPrefixParts.push("ADE_DEFAULT_ROLE=agent");

const startupCommand = envPrefixParts.length > 0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

When resolveExecutableOnPath(provider) returns null, this code falls back to startupCommand only, but the preview string still embeds POSIX-style inline env assignments. ts // apps/ade-cli/src/adeRpcServer.ts const startupCommand = envPrefixParts.length > 0 ? `${envPrefixParts.join(" ")} ${commandPreviewParts.join(" ")}` : commandPreviewParts.join(" "); const providerExecutable = resolveExecutableOnPath(provider); In @apps/desktop/src/main/services/pty/ptyService.ts, sessions without command are launched by opening powershell.exe/cmd.exe on Windows and literally typing startupCommand into that shell. That makes the new fallback path fail on Windows for unresolved-but-shell-launchable providers, because ADE_RUN_ID=... claude ... is not valid PowerShell/cmd syntax. Fix by avoiding POSIX env-prefix fallback on Windows: either always pass command/args for Windows PTY launches, or build a shell-specific fallback command that sets env vars with Windows syntax before invoking the provider.

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)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

inspectPowerShellInvocations() only extracts script text from -Command and -EncodedCommand, then silently skips every other valid host form. PowerShell's own CLI supports -File (and even bare pwsh .\script.ps1 as the default parameter), so a worker can place a .ps1 file inside the repo and run pwsh -File .\mutate.ps1 to mutate protected files or paths outside allowedPaths without any of the new PowerShell path checks running. That breaks the sandbox’s security contract. Treat -File/bare-script execution as non-inspectable and block it, or read and inspect the referenced script before allowing execution.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
if (POWERSHELL_COMMAND_FLAGS.has(flag)) {
  ...
}
if (POWERSHELL_ENCODED_COMMAND_FLAGS.has(flag)) {
  ...
}
if (!script) continue;


if (isPowerShellParameterToken(raw)) {
const key = raw.toLowerCase();
const next = normalizePathToken(args[i + 1] ?? "");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

parsePowerShellArgs() assumes every -foo parameter consumes the next token unless that token also starts with -. That is not true for PowerShell switch parameters such as -NoNewline, -Force, or -Recurse. In a command like powershell -Command "Set-Content -NoNewline ..\\outside.txt secret", the parser incorrectly binds ..\\outside.txt as the value of -NoNewline, so the later positional-path lookup never sees the real write target and the sandbox can miss an out-of-root or protected-file write. Fix this by modeling switch parameters for the supported mutating cmdlets, or failing closed when an unknown flag appears before a positional mutation target.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
if (isPowerShellParameterToken(raw)) {
  const key = raw.toLowerCase();
  const next = normalizePathToken(args[i + 1] ?? "");
  if (next.length > 0 && !isPowerShellParameterToken(next) && !POWERSHELL_PUNCTUATION_TOKENS.has(next)) {
    values.push(next);

Comment thread apps/desktop/src/main/services/ai/providerTaskRunner.ts
Comment thread apps/desktop/src/main/services/ai/providerTaskRunner.ts
Comment thread apps/ade-cli/src/adeRpcServer.ts Outdated
Comment thread apps/desktop/src/main/services/shared/processExecution.ts Outdated
Comment thread apps/desktop/src/main/services/ai/tools/universalTools.ts
@arul28 arul28 force-pushed the cursor/windows-port-foundations-ede6 branch from 5eed441 to ddecf19 Compare April 23, 2026 21:05
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 9 comments

"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.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

These new status strings say the desktop cannot pair or sync when CRDT support is unavailable, but the guard is only applied to host startup and PIN management. ts // apps/desktop/src/main/services/sync/syncService.ts blockingStateText: crdtSyncAvailable ? "Live missions, chats, terminals, or run processes must stop first." : "Install a Windows cr-sqlite runtime before pairing or syncing devices.", connectToBrain() and transferBrainToLocal() still run without checking isCrdtSyncAvailable(), SyncDevicesSection still enables host transfer from status.transferReadiness.ready, and once connected the peer layer keeps calling db.sync.exportChangesSince() / applyChanges() even though kvDb turns both into empty/no-op results when hasCrsqlite is false. The result is a Windows viewer that can appear connected or take over hosting while replication silently does nothing. Apply the CRDT-availability guard symmetrically to viewer connect/transfer paths (or make transferReadiness.ready false when CRDT is unavailable) so the action fails fast instead of entering a non-working sync state.


function installTargetPath(): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA?.trim() || path.join(os.homedir(), "AppData", "Local");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

// apps/desktop/src/main/services/cli/adeCliService.ts
if (process.platform === "win32") {
  const localAppData = process.env.LOCALAPPDATA?.trim() || path.join(os.homedir(), "AppData", "Local");
  return path.join(localAppData, "ADE", "bin", "ade.cmd");
}

When LOCALAPPDATA is unset, this service reports and validates ...\AppData\Local\ADE\bin\ade.cmd, but @apps/desktop/scripts/ade-cli-install-path.cmd falls back to %USERPROFILE%\.ade\bin\ade.cmd in the same situation. That means installForUser() can succeed while getStatus() still points the settings UI and post-install PATH hint at a different location, so the Windows Terminal install flow remains visibly broken for users running without LOCALAPPDATA. Make both code paths share the same fallback target (or pass the explicit target path into the installer).

const resolveOn = options?.resolveOn ?? "spawn";
try {
const child = spawn(command, args, { detached: true, stdio: "ignore" });
const child = spawn(command, args, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

On Windows this new editor-launch path shells through cmd.exe /d /s /c start ..., but the detached spawn() call does not set windowsHide, so Node uses its default (false) and the helper shell can visibly flash a console window every time the user opens a file in VS Code/Cursor/Zed from ADE. This is a deterministic user-facing regression introduced by the Windows portability path, and nearby Windows helper spawns in this repo already opt into hidden windows for the same reason. Set windowsHide: true (or gate it on process.platform === "win32") in launchDetached() so the shell wrapper stays background-only.

// apps/desktop/src/main/services/ipc/registerIpc.ts
const child = spawn(command, args, {
  detached: true,
  stdio: "ignore",
  windowsVerbatimArguments: options?.windowsVerbatimArguments,
});

broadcastData: () => {},
broadcastExit: () => {},
onSessionEnded: () => {},
getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This change adds ADE CLI PATH augmentation to headless PTY launches, but it still never passes getLaneRuntimeEnv into either createPtyService or the newly-added createProcessService:

// apps/ade-cli/src/bootstrap.ts
const ptyService = createPtyService({
  ...
  getAdeCliAgentEnv: createHeadlessAdeCliAgentEnv,
  loadPty: () => nodePty
});

In the desktop app, both services receive getLaneRuntimeEnv, and processService.startByDefinition() explicitly reads that hook before launching a PTY-backed command. In headless mode the hook stays undefined, so lane-scoped vars like PORT, PORT_RANGE_*, and HOSTNAME are never injected when ade --headless starts a session or managed process in a lane. That breaks lane-runtime parity and causes processes to bind to default ports/hostnames instead of the lane allocation. Add a headless getLaneRuntimeEnv provider and pass it to both service constructors.

env: childEnv,
shell: false,
stdio: [spec.stdinFilePath ? "pipe" : "inherit", "inherit", "inherit"],
windowsHide: false,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new Node-based worker launcher still spawns the inner Claude/Codex process with windowsHide: false, so packaged Windows builds will show a transient console window for every orchestrated worker launch instead of keeping the helper process hidden. That regresses the Windows UX this refactor is meant to enable, and it is isolated to the newly added launcher path. Set windowsHide to true on the inner spawn so the launcher stays behaviorally consistent with the rest of ADE's Windows subprocess handling.

// apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
  shell: false,
  stdio: [spec.stdinFilePath ? "pipe" : "inherit", "inherit", "inherit"],
  windowsHide: false,
  windowsVerbatimArguments: !!spec.windowsVerbatimArguments
});
Suggested change
windowsHide: false,
windowsHide: true,

} catch {
// Best-effort only.
}
terminateProcessTree(child, "SIGTERM");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

On Windows, terminateProcessTree(child, "SIGTERM") already delegates to taskkill /T /F, so the first timeout step is not graceful anymore — it is the final force-kill. The follow-up timer still treats the child as killable because taskkill does not set child.killed, only child.kill() does. That means these runners can issue a second PID-based kill before 'close' has updated exitCode, which is unsafe once the original process has exited because PIDs can be reassigned. This pattern is present here and in the external-resolver timeout path at @apps/desktop/src/main/services/conflicts/conflictService.ts. Fix by treating a successful Windows tree kill as terminal (skip the second timer / track your own killRequested flag instead of relying on child.killed).

// apps/desktop/src/main/services/orchestrator/orchestratorContext.ts
terminateProcessTree(child, "SIGTERM");
setTimeout(() => {
  if (child.exitCode == null && !child.killed) {
    terminateProcessTree(child, "SIGKILL");
  }
}, 1_000).unref();

NO_COLOR: "1",
TERM: "dumb"
};
const invocation = resolveCliSpawnInvocation(codexExecutable, cliArgs, env);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

runCodexExec() always appends the planner prompt as a single argv entry, and that prompt is built with embedded \n lines by buildPlannerPrompt(). After this change, Windows shim installs are routed through cmd.exe /d /s /c, but cmd.exe does not preserve raw CR/LF inside a quoted command string, so a normal npm-installed codex.cmd launch will split the prompt at newlines and misparse/fail instead of delivering the full prompt to Codex. That makes the automation planner path fail on a standard Windows Codex install. Feed multiline prompt text through stdin/a temp file (or apply cmd-aware newline escaping) instead of passing it as a raw argv element through the cmd.exe wrapper.

// apps/desktop/src/main/services/automations/automationPlannerService.ts
cliArgs.push(args.prompt);
...
const invocation = resolveCliSpawnInvocation(codexExecutable, cliArgs, env);
const child = spawn(invocation.command, invocation.args, {

child.on("close", (code) => {
finish(() => resolve({ stdout, stderr, exitCode: code }));
});
child.stdin?.on("error", (error) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new JSON-RPC path rejects immediately on any child.stdin error, which means a post-write EPIPE now wins over the close handler and throws away the collected stdout/exitCode. If Codex closes stdin immediately after consuming the three JSONL requests, Node can emit EPIPE even though the child has already produced the rate-limit response; in that case this code reports codex: CLI RPC error: EPIPE and never parses the returned windows. The new test at usageTrackingService.test.ts:542 codifies that failure mode, so this is an introduced regression rather than a hypothetical race. Treat post-write EPIPE as best-effort cleanup and let close/exitCode decide success.

// apps/desktop/src/main/services/usage/usageTrackingService.ts
child.stdin?.on("error", (error) => {
  logger.warn("usage.poll.codex_cli_rpc_stdin_failed", {
    error: getErrorMessage(error),
  });
  finish(() => reject(error));
});

continue;
}
if (looksLikePathToken(value)) {
if (looksLikePathToken(value, windowsMode)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The first token pass now runs in Windows mode, but it still feeds every token through looksLikePathToken() before excluding /c, /d, /s, and similar slash-style switches. Because looksLikePathToken() treats any token containing / as a path, a benign wrapper like cmd.exe /c type .\\README.md adds /c as a fake path and gets rejected as Path outside sandbox, which breaks the newly-added nested cmd.exe support for read-only commands. ```ts
// apps/desktop/src/main/services/ai/tools/universalTools.ts
if (value.startsWith("-")) continue;
if (value === "|" || value === "||" || value === "&&" || value === ";" || value === "&") continue;
...
if (looksLikePathToken(value, windowsMode)) {
addPath(value, "unknown");
}

Skip Windows `/[a-z?]` switches in this raw-token loop the same way `markOperands()` already does, so wrapper flags are not mistaken for filesystem paths.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/desktop/src/main/services/ai/tools/workflowTools.ts (1)

161-184: ⚠️ Potential issue | 🟡 Minor

Args and backend name are darwin-only by design; consider parameterizing them if adding non-darwin support.

The current code is safe: localComputerUse.ts explicitly blocks non-darwin platforms (line 44) by setting all capabilities to available: false, so line 174 is only reachable on macOS where command will always be "screencapture". However, if non-darwin screenshot support is added in the future (e.g., Windows, Linux), the hardcoded args ["-x", tmpPath] (macOS-specific flag) and backend.name: "screencapture" would need updating.

To future-proof this:

  • Extend LocalComputerUseCapability to include args?: string[] and backend?: string fields, or
  • Add a platform check at line 174 to branch args/backend per platform if adding non-darwin support
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/tools/workflowTools.ts` around lines 161 -
184, The code assumes macOS-only screencapture args and backend name; update
getLocalComputerUseCapabilities / LocalComputerUseCapability to expose args?:
string[] and backendName?: string (or similar) so capabilities.screenshot
includes the platform-specific exec args and backend identifier, then use
capabilities.screenshot.args (fallback to ["-x", tmpPath] for existing macOS)
and capabilities.screenshot.backendName (fallback to "screencapture") when
calling execFileAsync and computerUseArtifactBrokerService.ingest;
alternatively, if you prefer not to change the capability shape, add an explicit
platform branch around the execFileAsync call and the ingest backend.name
assignment that selects args and backend name per platform (using
getLocalComputerUseCapabilities(), capabilities.platform, and
capabilities.screenshot.command to locate the correct logic).
apps/desktop/src/main/services/automations/automationService.ts (1)

1327-1355: ⚠️ Potential issue | 🟠 Major

Use terminateProcessTree to kill the full command tree on timeout.

On line 1327 the code routes Windows commands through cmd.exe /d /s /c, but line 1350 still calls child.kill("SIGKILL"). That only terminates the shell wrapper process; the actual command survives timeout and continues running in the background. Switch to terminateProcessTree which uses taskkill.exe /T /F on Windows to kill the entire process tree.

🛠️ Proposed fix
 import { randomUUID } from "node:crypto";
 import { spawn } from "node:child_process";
+import { terminateProcessTree } from "../shared/processExecution";
@@
       const timer = setTimeout(() => {
-        try {
-          child.kill("SIGKILL");
-        } catch {
-          // ignore
-        }
+        terminateProcessTree(child, "SIGKILL");
         settled = true;
         reject(new Error(`Command timed out after ${timeoutMs}ms`));
       }, timeoutMs);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/automations/automationService.ts` around lines
1327 - 1355, The timeout handler currently calls child.kill("SIGKILL") which
only kills the shell wrapper and leaves the real command running; replace that
call with the project's terminateProcessTree utility to kill the whole process
tree (e.g., call terminateProcessTree(child.pid) and await it or call it in the
try block), ensure you import/require terminateProcessTree at the top of the
file, and wrap the call in try/catch as the existing code does so you still set
settled = true and reject with the timeout Error if termination fails; locate
this change around the Promise timeout code that references child, spawn, and
the timeoutMs variable.
apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts (1)

53-108: ⚠️ Potential issue | 🟠 Major

Pin the POSIX-path cases to POSIX semantics.

These three tests still inherit the host platform, so on a native Windows run they'll take the win32 branch and the /usr/... / Homebrew expectations can fail spuriously. Since this PR is adding Windows coverage, please force a POSIX platform and : delimiter in these cases.

Suggested fix
 it("discovers codex from an npm prefix configured in ~/.npmrc", () => {
+  setPlatform("darwin");
+  setPathDelimiter(":");
   tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-"));
   const homeDir = path.join(tempRoot, "home");
   const prefixDir = path.join(homeDir, ".npm-global");
@@
 it("augments PATH with npm-global bins discovered from ~/.npmrc", () => {
+  setPlatform("darwin");
+  setPathDelimiter(":");
   tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-path-"));
   const homeDir = path.join(tempRoot, "home");
@@
 it("keeps both Intel and Apple Silicon Homebrew bins on PATH", () => {
+  setPlatform("darwin");
+  setPathDelimiter(":");
   const nextPath = augmentPathWithKnownCliDirs("/usr/local/bin:/usr/bin:/bin", {
     HOME: "/tmp/ade-home",
     PATH: "/usr/local/bin:/usr/bin:/bin",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts` around lines
53 - 108, The tests assume POSIX path semantics and fail on Windows; force POSIX
behavior by using path.posix when constructing expected paths and by stubbing
currentPathDelimiter() to return ':' for these test cases. Update the three
tests that call resolveExecutableFromKnownLocations and
augmentPathWithKnownCliDirs to build paths with
path.posix.join/path.posix.normalize and, where the test checks
split(currentPathDelimiter()), mock or spyOn currentPathDelimiter to return ':'
so PATH splitting and expected entries are evaluated with POSIX semantics.
apps/ade-cli/src/cli.ts (1)

1829-1831: ⚠️ Potential issue | 🟡 Minor

Make the PATH remediation hint platform-aware.

On Windows this still tells users to run install-path.sh, which is the wrong recovery path for the exact scenario this PR is fixing. Please return Windows-specific install-path guidance here instead of the Unix shell script hint.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ade-cli/src/cli.ts` around lines 1829 - 1831, The current nextAction
message uses a Unix-specific hint; update the logic that sets nextAction (the
ternary using onPath in apps/ade-cli/src/cli.ts) to choose a platform-aware
string: when on Windows (process.platform === 'win32') return a Windows-specific
remediation (e.g., instruct running npm link in apps/ade-cli or running the
packaged install-path.bat / PowerShell instructions to add the CLI to PATH),
otherwise keep the existing install-path.sh hint for Unix. Ensure you modify the
nextAction assignment that references onPath so it selects the appropriate
message based on platform.
♻️ Duplicate comments (9)
apps/desktop/src/main/services/sync/syncService.ts (1)

555-569: ⚠️ Potential issue | 🟠 Major

Viewer-side connection paths are still not gated on CRDT availability.

connectToBrain() (and the saved-draft reconnect in refreshRoleState) call syncPeerService.connect(draft) without consulting isCrdtSyncAvailable(). When CR-SQLite is unavailable, db.sync.applyChanges is a no-op and getDbVersion() stays 0, so a viewer on an unsupported platform can show as "connected" while no remote changes will ever land locally and no local edits will ever be exported. Reject viewer connections (both manual and saved-draft auto-reconnect) when !isCrdtSyncAvailable() so the new "sync unavailable" state is enforced consistently with the host-side gate at Line 402.

🔒 Suggested guard locations
     async connectToBrain(
       draft: SyncDesktopConnectionDraft,
     ): Promise<SyncRoleSnapshot> {
+      if (!isCrdtSyncAvailable()) {
+        throw new Error(
+          "Desktop sync is unavailable because the CRDT database extension is unavailable on this platform.",
+        );
+      }
       await stopHostIfRunning();
         } else {
           await stopHostIfRunning();
           const draft = savedDraft ?? resolveViewerDraftFromRegistry();
-          if (draft && !syncPeerService.isConnected()) {
+          if (draft && !syncPeerService.isConnected() && isCrdtSyncAvailable()) {
             syncPeerService.setSavedDraft(draft);

Also applies to: 771-791

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/sync/syncService.ts` around lines 555 - 569,
Both manual viewer connects (connectToBrain) and the saved-draft auto-reconnect
path in refreshRoleState currently call syncPeerService.connect(draft) without
checking CRDT availability; add a guard using isCrdtSyncAvailable() in both
places and refuse the viewer connect when it returns false. Concretely, before
calling syncPeerService.setSavedDraft(...) / syncPeerService.connect(draft) in
refreshRoleState and before invoking syncPeerService.connect in connectToBrain,
check if (!isCrdtSyncAvailable()) then log/warn the appropriate "sync
unavailable" message and skip setting draft/connecting so the viewer state is
not marked connected.
apps/desktop/src/main/services/ai/providerTaskRunner.ts (1)

109-114: ⚠️ Potential issue | 🟠 Major

Stdin error handler still rejects eagerly — EPIPE on early child exit will mask the real exit code.

This path settles the promise on any stdin error, including the EPIPE/ERR_STREAM_DESTROYED that Node emits when Claude/Codex exits before finishing stdin consumption. When that happens, close is skipped and runProviderTask throws a pipe error instead of Claude exited with code N\n\n<stderr>. Prefer letting close own resolution for early-exit cases and only reject on genuine setup failures.

🐛 Proposed fix
-    child.stdin?.on("error", (error) => {
-      if (settled) return;
-      settled = true;
-      clearTimeout(timeoutHandle);
-      reject(error);
-    });
+    child.stdin?.on("error", (error) => {
+      // Swallow EPIPE / ERR_STREAM_DESTROYED — the child likely exited before
+      // consuming stdin, and the `close` handler will surface the real exit
+      // code + stderr. Only fail hard on other, unexpected stdin errors.
+      const code = (error as NodeJS.ErrnoException)?.code;
+      if (code === "EPIPE" || code === "ERR_STREAM_DESTROYED") return;
+      if (settled) return;
+      settled = true;
+      clearTimeout(timeoutHandle);
+      reject(error);
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/providerTaskRunner.ts` around lines 109 -
114, The stdin error handler in runProviderTask (child.stdin?.on("error", ...))
currently rejects on any error and sets settled/clears timeout, which causes
EPIPE/ERR_STREAM_DESTROYED from an early child exit to mask the real exit
handled in the child's "close" path; change the handler to ignore transient
stream-destroy errors by returning early when error.code is "EPIPE" or
"ERR_STREAM_DESTROYED" (do not clear timeout or set settled), and only
clearTimeout/set settled and reject for genuine setup failures (other error
codes) so the existing "close" logic owns resolution on early-exit cases.
apps/ade-cli/src/adeRpcServer.ts (1)

5909-5967: ⚠️ Potential issue | 🟠 Major

Windows unresolved-provider fallback still uses POSIX quoting.

If resolveExecutableOnPath(provider) returns null, Lines 5963-5967 still rely on startupCommand, but the pieces feeding that string were quoted with shellEscapeArg() on Lines 5912-5942. Prompts with apostrophes or other shell-sensitive characters will still become POSIX fragments that PowerShell/cmd won't execute correctly, so the Windows fallback path remains broken for unresolved-but-shell-launchable providers. Either build a Windows-shell command here or avoid shell auto-start on Windows when resolution fails.

#!/bin/bash
set -euo pipefail

printf '\n# spawn_agent command/startupCommand construction\n'
sed -n '5909,5968p' apps/ade-cli/src/adeRpcServer.ts

printf '\n# PTY Windows startupCommand handling\n'
rg -n -C3 'startupCommand|powershell\.exe|cmd\.exe|shellEscapeArg\(' \
  apps/ade-cli/src/adeRpcServer.ts \
  apps/desktop/src/main/services/pty/ptyService.ts
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ade-cli/src/adeRpcServer.ts` around lines 5909 - 5967, The
startupCommand is built using shellEscapeArg (used when pushing into
commandPreviewParts) but is later used for a Windows fallback when
resolveExecutableOnPath(provider) returns null, producing POSIX-quoted fragments
that won’t run in cmd/powershell; update the logic around
resolveExecutableOnPath, startupCommand and the process.platform === "win32"
branch so that when providerExecutable === null on Windows you do not attempt to
use the POSIX-quoted startupCommand — either construct a proper Windows-safe
command string (using Windows quoting/escaping) or, simpler and safer,
refuse/skip auto-start on Windows and surface an error/require the caller to
resolve the executable; modify code paths referencing startupCommand and
providerExecutable (and places using shellEscapeArg/addWorkerEnv) to enforce
this behavior.
apps/desktop/src/main/services/ai/tools/universalTools.ts (3)

726-749: ⚠️ Potential issue | 🔴 Critical

cmd.exe /c still hides non-PowerShell mutations.

The /c payload is only re-routed through inspectPowerShellInvocations(). For commands like cmd.exe /c "copy foo ..\outside.txt" or cmd.exe /c "del .env", that recursive call records no generic path refs, so the sandbox never sees the real write/delete target. Recurse into the generic command/path collector for /c payloads too, not just the PowerShell inspector.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/tools/universalTools.ts` around lines 726 -
749, The cmd.exe /c branch only forwards the payload to
inspectPowerShellInvocations, which misses generic file operations; update the
handler in the isCmdHostCommand block so that when commandArgIndex >= 0 you also
run the generic command/path collector (the same collector used for non-cmd
branches — e.g., the function that produces path refs for plain shell commands)
on nestedCommand, merge its result via mergeInspection (or equivalent), and
combine its mutates/pathRefs with nestedInspection (and respect blockedReason)
before continuing; ensure you still call inspectPowerShellInvocations for
PowerShell-specific checks but also call and merge the generic inspection so
copy/del style mutations are detected.

764-801: ⚠️ Potential issue | 🔴 Critical

Handle bare pwsh script.ps1 entrypoints.

This branch only inspects -Command, -EncodedCommand, and -File. A valid invocation like pwsh .\worker.ps1 leaves script null, so the sandbox never reads or validates the script contents. Treat the first non-flag .ps1 argument as a script file, or fail closed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/tools/universalTools.ts` around lines 764 -
801, The loop over args currently only handles -Command, -EncodedCommand and
-File flags so a bare invocation like "pwsh script.ps1" leaves script null and
skips inspection; fix by adding a branch inside that loop (near the checks using
POWERSHELL_COMMAND_FLAGS, POWERSHELL_ENCODED_COMMAND_FLAGS,
POWERSHELL_FILE_FLAGS and normalizePathToken) that treats the first non-flag
argument ending with ".ps1" (allowing relative prefixes like "./" or ".\\") as a
script file: call inspectScriptFile(scriptPath),
mergeInspection(scriptFileInspection), update mutates from
scriptFileInspection.mutates, handle scriptFileInspection.blockedReason by
returning { ...scriptFileInspection, pathRefs: [...refs.values()] } and set
script = null/break to prevent continuing; alternatively if you prefer
fail-closed, return a blockedReason when an apparent .ps1 argument is present
but inspectScriptFile fails to produce a valid inspection.

535-555: ⚠️ Potential issue | 🔴 Critical

Validate every PowerShell target, not just the first one.

addFirstNamedOrPositionalPath() stops after values[0] or a single positional operand. Commands like Remove-Item .\ok.txt ..\outside.txt or Copy-Item a,b ..\dest can still hide a later out-of-root/protected target because only the first path is checked. Enumerate all mutating path operands, or fail closed when more than one target is supplied.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/tools/universalTools.ts` around lines 535 -
555, addFirstNamedOrPositionalPath currently only checks the first named value
(values[0]) or a single positional, letting later targets escape validation;
change it to validate every path target by iterating all entries in
parsed.named.get(flag) and all parsed.positional entries starting at
positionalIndex (for each named/positional entry also split on commas and trim
to handle comma-separated lists), call addResolvedPath for each target and if
any call returns a blockedReason return it immediately; if no targets are found
and access !== "read" and failOnMissing is true keep returning the same
blockedReason; reference addFirstNamedOrPositionalPath, parsePowerShellArgs,
parsed.named.get, parsed.positional and addResolvedPath when making the change.
apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts (1)

101-144: ⚠️ Potential issue | 🟠 Major

Propagate wrapper shutdown to the spawned worker.

This launcher only watches the child’s error/exit. If the Node wrapper itself is terminated, the real Claude/Codex process can keep running in the background, which breaks cancel/close flows and can leave a worker alive after the PTY/session is gone. Please trap wrapper shutdown and terminate the child process tree before exiting.

Suggested fix
-const { spawn } = require("child_process");
+const { spawn, spawnSync } = require("child_process");
 ...
 const child = spawn(spec.command, Array.isArray(spec.args) ? spec.args : [], {
   cwd: spec.cwd || process.cwd(),
   env: childEnv,
   shell: false,
   stdio: [spec.stdinFilePath ? "pipe" : "inherit", "inherit", "inherit"],
   windowsHide: false,
   windowsVerbatimArguments: !!spec.windowsVerbatimArguments
 });
+function terminateChild() {
+  if (!child || child.exitCode !== null || child.signalCode !== null) return;
+  try {
+    if (process.platform === "win32" && child.pid) {
+      spawnSync("taskkill.exe", ["/T", "/F", "/PID", String(child.pid)], { windowsHide: true });
+      return;
+    }
+    child.kill("SIGTERM");
+  } catch {}
+}
+for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
+  process.on(signal, () => {
+    terminateChild();
+    finish(1);
+  });
+}
+process.on("exit", terminateChild);
 child.on("error", (err) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts`
around lines 101 - 144, The launcher string (WORKER_CLI_LAUNCHER_SCRIPT)
currently only listens to child "error"/"exit" and can leave the spawned child
running if the wrapper exits; add shutdown handlers in the script to propagate
wrapper termination to the spawned process: register listeners on process events
(at least 'exit', 'SIGINT', 'SIGTERM', 'SIGQUIT' and fallback for
'uncaughtException'/'unhandledRejection') and in each handler call a cleanup
that attempts to kill the child process or child process tree (use child.kill()
on POSIX and a Windows fallback such as spawning "taskkill /PID <pid> /T /F"
when process.platform === "win32"), ensure the cleanup sets a flag to avoid
reentrancy and then calls finish(...) to exit the wrapper; refer to the child
variable, finish function, and WORKER_CLI_LAUNCHER_SCRIPT to locate where to
inject these handlers.
apps/desktop/src/main/services/opencode/openCodeServerManager.ts (1)

255-260: ⚠️ Potential issue | 🟠 Major

Fallback to PowerShell when WMIC omits CommandLine.

This still treats any non-empty WMIC result set as authoritative. parseWindowsWmicProcessCsv() keeps rows even when command is blank, so orphan recovery never sees the ADE markers for those processes and stale managed opencode serve trees can survive restarts. Please fall back whenever WMIC returns rows with missing command text, not only when it returns zero rows.

Suggested fix
 function listWindowsProcesses(): OpenCodeProcessSnapshot[] {
   const fromWmic = listWindowsProcessesFromWmic();
-  if (fromWmic.length > 0) {
-    return fromWmic;
+  if (fromWmic.length > 0 && fromWmic.every((row) => row.command.trim().length > 0)) {
+    return fromWmic;
   }
-  return listWindowsProcessesFromPowerShell();
+  const fromPowerShell = listWindowsProcessesFromPowerShell();
+  return fromPowerShell.length > 0 ? fromPowerShell : fromWmic;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/opencode/openCodeServerManager.ts` around
lines 255 - 260, The current listWindowsProcesses() returns the WMIC results if
listWindowsProcessesFromWmic() yields any rows even when some rows have an empty
command/CommandLine, which prevents orphan recovery; change
listWindowsProcesses() to inspect the array returned by
listWindowsProcessesFromWmic() (or by parseWindowsWmicProcessCsv()) and if any
snapshot is missing the command text (e.g., snapshot.command or
snapshot.CommandLine is falsy/empty) then ignore WMIC and return
listWindowsProcessesFromPowerShell() instead; ensure you reference
listWindowsProcessesFromWmic, parseWindowsWmicProcessCsv, and
listWindowsProcessesFromPowerShell when implementing the check so WMIC is only
treated as authoritative when all returned snapshots include non-empty command
text.
apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx (1)

459-459: ⚠️ Potential issue | 🟠 Major

UNC file://host/share/... URIs still lose their authority.

trimmed.replace(/^file:\/\//i, "") strips the scheme and the authority, so file://server/share/path becomes server/share/path. Downstream it no longer starts with / and isn't Windows-absolute, so looksLikeWorkspacePath accepts it as a relative path and resolveFilesNavigationTarget falls through to the relative branch (Line 598), mis-navigating within the current lane instead of into the UNC workspace — the exact Windows/UNC portability this PR is intended to land.

Only the two-slash form (empty authority) should be collapsed; a non-empty authority needs to be preserved as a UNC root, e.g. via URL or by conditionally reinserting //:

🛠️ Suggested fix
-  const withoutScheme = trimmed.replace(/^file:\/\//i, "");
+  // Preserve UNC authority: file://server/share/path -> //server/share/path.
+  // file:///C:/... and file:///posix/... collapse to the local form as before.
+  const fileUriMatch = trimmed.match(/^file:\/\/([^/?#]*)(.*)$/i);
+  const withoutScheme = fileUriMatch
+    ? (fileUriMatch[1] ? `//${fileUriMatch[1]}${fileUriMatch[2]}` : fileUriMatch[2])
+    : trimmed;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` at line
459, The current replacement that builds withoutScheme from trimmed removes the
entire authority for UNC paths (e.g. file://server/share), so preserve UNC
authorities: change the logic that computes withoutScheme (used by
looksLikeWorkspacePath and resolveFilesNavigationTarget) to only collapse an
empty authority (file:/// -> remove leading file:///) but keep non-empty
authorities (file://server/... -> retain //server/...) — implement this by
parsing trimmed with the URL API or by detecting a host and reinserting the
leading '//' when a host is present so UNC roots remain Windows-absolute and
downstream resolveFilesNavigationTarget handles them correctly.
🧹 Nitpick comments (14)
apps/desktop/src/main/services/ai/tools/globSearch.test.ts (1)

68-78: LGTM — good Windows-separator coverage.

The fixture/assertion pair is tight: helper.ts matches and helper.md is excluded, confirming both the \/ normalization and that the extension filter still applies after normalization.

Optional: consider adding one more assertion for a mixed-separator pattern (e.g. src\**/*.ts or **\*.ts) to lock in behavior for patterns that might be hand-edited or concatenated on Windows. Not required.

🧪 Optional: add a mixed-separator case
   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"]);
+
+    const mixed = await tool.execute({ pattern: "src\\**/*.ts" });
+    expect(mixed.count).toBe(1);
+    expect(mixed.displayFiles).toEqual(["src/lib/helper.ts"]);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/tools/globSearch.test.ts` around lines 68 -
78, Add an additional assertion to the test to cover mixed Windows/Unix
separators: call createGlobSearchTool(cwd) and invoke tool.execute with a
mixed-separator pattern such as "src\\**/*.ts" (or "**\\*.ts"), then assert
result.count === 1 and result.displayFiles equals ["src/lib/helper.ts"] to
ensure normalization still works when patterns contain both "\" and "/"; update
the existing "accepts Windows-style separators in glob patterns" test or add a
new it block that uses createGlobSearchTool and tool.execute to verify the
mixed-separator behavior.
apps/desktop/scripts/after-pack-runtime-fixes.cjs (1)

47-62: Minor: Linux branch asserts files but never chmods the ade wrapper.

No Linux target is defined in build, so this is a latent concern, but if a Linux dist is ever enabled the packaged ade-cli/bin/ade will ship non-executable (unlike the darwin branch). Consider either restricting the else branch to throw-unsupported until Linux is a real target, or applying the same chmodSync(..., 0o755) there.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/after-pack-runtime-fixes.cjs` around lines 47 - 62, The
Linux/else branch currently calls requireFile for the bundled ADE files but
doesn’t mark the POSIX wrapper executable, so ade-cli/bin/ade will be
non-executable on Linux; update the else branch in after-pack-runtime-fixes.cjs
(the platform === "darwin" code uses fs.chmodSync(bundledCliBinPath, 0o755) and
chmodSync for the installer) to likewise call fs.chmodSync on the POSIX paths
(path.join(resourcesRoot, "ade-cli", "bin", "ade") and path.join(resourcesRoot,
"ade-cli", "install-path.sh")) or, if Linux is not supported, replace the else
branch with an explicit unsupported-platform throw to avoid shipping
non-executable binaries; adjust code around requireFile, fs.chmodSync, and the
platform conditional accordingly.
apps/desktop/scripts/ade-cli-windows-wrapper.cmd (1)

10-17: Nit: dead branch — NODE_PATH_VALUE is always defined at this point.

Line 10 unconditionally sets NODE_PATH_VALUE, so the inner if defined NODE_PATH_VALUE on line 12 is always true and the else on lines 14‑16 is unreachable. Collapse to a single append:

♻️ Simplification
 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 NODE_PATH set "NODE_PATH_VALUE=%NODE_PATH_VALUE%;%NODE_PATH%"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/ade-cli-windows-wrapper.cmd` around lines 10 - 17, The
branch using NODE_PATH_VALUE is dead because NODE_PATH_VALUE is always set
earlier; simplify the if block by removing the inner conditional and, inside if
defined NODE_PATH, just append NODE_PATH onto the existing NODE_PATH_VALUE
(i.e., update NODE_PATH_VALUE by concatenating its current value and
%NODE_PATH%). Refer to the symbols NODE_PATH_VALUE and NODE_PATH to locate and
change the logic in the script.
apps/desktop/src/main/services/cli/windowsPackaging.test.ts (1)

11-20: Test coverage gap: wrapper tests are content-only.

These assertions grep for substrings (e.g. call :run_with_runtime_env …) but don't exercise the wrapper end-to-end, so they won't catch behavioral regressions like exit-code propagation. If feasible on the windows-latest runner, consider adding a test that invokes the .cmd with ADE_CLI_NODE pointing at a stub script that exit /b 42 and asserts the wrapper returns 42 — this is exactly the kind of issue flagged in ade-cli-windows-wrapper.cmd.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/cli/windowsPackaging.test.ts` around lines 11
- 20, The current tests only assert string contents of the ADE Windows wrapper;
add an end-to-end test that actually executes the ade-cli-windows-wrapper.cmd to
verify exit-code propagation: create a temporary stub Windows script (e.g., a
.cmd that does exit /b 42), set the ADE_CLI_NODE environment variable to point
at that stub, spawn the wrapper (use the same
wrapperPath/ade-cli-windows-wrapper.cmd used in windowsPackaging.test.ts),
capture its exit code, and assert it equals 42; run this test conditionally on
windows-latest CI (or skip on non-Windows) so it only runs where cmd execution
is supported.
apps/desktop/scripts/validate-win-artifacts.mjs (2)

363-373: Rename/restore of appExe can leave the packaged app broken if the script is interrupted.

If this process is killed (CI timeout, Ctrl+C, crash) between the rename(appExe → .bak) on line 364 and the finally rename back on line 372, win-unpacked/<ProductName>.exe will be left missing until a human cleans it up, which also means any later re-run of this validator (or subsequent pipeline step that expects the packaged exe) will fail with a confusing "missing packaged Windows app executable" error from assertPathExists on line 288.

Consider either (a) installing a SIGINT/SIGTERM handler that restores the .bak before propagating, (b) detecting a leftover *.exe.bak at the start of validatePackagedRuntime and restoring it, or (c) exercising the plain-node fallback in a temp copy of the unpacked app rather than mutating the release output in place.

♻️ Optional: self-healing leftover handling before the fallback test
   const disabledAppExe = `${appExe}.bak`;
+  // Recover from a previous interrupted run before we rename again.
+  if (fs.existsSync(disabledAppExe) && !fs.existsSync(appExe)) {
+    await fsp.rename(disabledAppExe, appExe);
+  }
   await fsp.rename(appExe, disabledAppExe);
-  try {
+  const restore = async () => {
+    if (fs.existsSync(disabledAppExe)) {
+      await fsp.rename(disabledAppExe, appExe);
+    }
+  };
+  const onSignal = () => { restore().finally(() => process.exit(1)); };
+  process.once("SIGINT", onSignal);
+  process.once("SIGTERM", onSignal);
+  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);
+    process.off("SIGINT", onSignal);
+    process.off("SIGTERM", onSignal);
+    await restore();
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/validate-win-artifacts.mjs` around lines 363 - 373, The
rename/restore around appExe (variables appExe and disabledAppExe in
validatePackagedRuntime) can leave the packaged exe missing if the process is
interrupted; to fix, add a recovery step at the start of validatePackagedRuntime
that checks for an existing `${appExe}.bak` (or pattern `*.exe.bak`) and renames
it back to the original appExe before proceeding, and additionally wrap the
temporary disable/restore block with a SIGINT/SIGTERM handler that restores
disabledAppExe to appExe before rethrowing so interruptions don't leave the
unpacked app mutated; reference the rename calls around appExe/disabledAppExe
and the runCommand/assertAdeCliHelp block to ensure the restore logic runs both
at startup and on signals.

336-346: Fix fail-closed state check for claudeStartup.state—success state is "ready", not "ok", and "auth-failed" must also fail validation.

Lines 339–343 only branch on "binary-missing" (warn) and "runtime-failed" (fail). This leaves "ready" (success) and "auth-failed" (authentication failure) unhandled, both silently passing validation. Since packagedRuntimeSmoke.ts always returns one of four states ("ready", "auth-failed", "binary-missing", "runtime-failed"), the validator should explicitly assert the expected success state and fail on anything else, including authentication failures.

♻️ Fail-closed state check
   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")}`);
+  } else if (payload.claudeStartup.state !== "ready") {
+    fail(`Packaged smoke reported an unexpected Claude startup state: ${String(payload.claudeStartup.state)}`);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/validate-win-artifacts.mjs` around lines 336 - 346, The
validator currently only handles "binary-missing" and "runtime-failed" for
payload.claudeStartup.state; update the logic to explicitly treat "ready" as the
only success state and call fail(...) for "auth-failed" and any other unexpected
state—keep the existing console.warn for "binary-missing", and for failing cases
include payload.claudeStartup.message in the failure text; refer to
payload.claudeStartup.state and the fail(...) helper when making these checks so
packagedRuntimeSmoke's four states ("ready", "auth-failed", "binary-missing",
"runtime-failed") are all handled explicitly.
apps/desktop/src/main/services/shared/processExecution.ts (1)

86-86: Consider resolving taskkill.exe via %SystemRoot%\System32 rather than PATH.

In hardened/packaged Electron environments, PATH can be stripped down and taskkill.exe may not resolve. Using the absolute path avoids PATH-shadowing or DLL-search issues and is a common hardening pattern for calling Windows system utilities from Electron main.

♻️ Proposed refactor
-    const out = spawnSync("taskkill.exe", ["/T", "/F", "/PID", String(pid)], { windowsHide: true });
+    const systemRoot = process.env.SystemRoot || process.env.windir || "C:\\Windows";
+    const taskkill = path.win32.join(systemRoot, "System32", "taskkill.exe");
+    const out = spawnSync(taskkill, ["/T", "/F", "/PID", String(pid)], { windowsHide: true });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/shared/processExecution.ts` at line 86, The
spawnSync call that invokes "taskkill.exe" (see the call to spawnSync in
processExecution.ts) can fail in hardened Electron environments where PATH is
stripped; change the command to resolve the absolute Windows system executable
(e.g. build the path from process.env.SystemRoot || "C:\\Windows" and append
"\\System32\\taskkill.exe") and pass that absolute path as the first argument to
spawnSync (keeping the same args and options) so taskkill is located reliably
without depending on PATH.
apps/desktop/src/main/services/pty/ptyService.test.ts (1)

450-482: Windows cmd.exe shim test: tight coupling to the invocation format.

The assertion matches /d /s /c "npm.cmd" "run" "dev" as a single verbatim string. That tightly couples the test to the current quoting/flag choice (e.g., if resolveCliSpawnInvocation ever omits the trailing quotes for bare tokens, or swaps /d /s /c ordering, this test breaks even though behavior is still correct).

Consider asserting structural invariants instead — e.g., first arg is expect.stringContaining("cmd.exe"), second arg expect.stringContaining("npm.cmd") and expect.stringContaining("run") — to keep the test resilient to minor formatting changes in the shim resolver. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/pty/ptyService.test.ts` around lines 450 -
482, The test "wraps direct Windows command shims through cmd.exe" is too
tightly coupled to the exact cmd.exe invocation string; change the assertion on
ptyLib.spawn to assert structural invariants instead of a verbatim second
argument — e.g., verify the first arg contains the ComSpec path
(expect.stringContaining("cmd.exe")), and assert the second arg uses
expect.stringContaining for the command and each arg such as
expect.stringContaining("npm.cmd") and expect.stringContaining("run") (while
still keeping expect.any(Object) for the options), locating this change around
the ptyService.create call and the ptyLib.spawn expectation in the test file.
apps/desktop/src/main/services/tests/testService.ts (2)

299-310: Minor: env object is duplicated — consider factoring for DRY.

The merged env is computed twice (once for resolveCliSpawnInvocation, once for spawn options). Hoist into a local to avoid drift if one copy is later adjusted.

♻️ Proposed tweak
-    const invocation = resolveCliSpawnInvocation(suite.command[0]!, suite.command.slice(1), {
-      ...process.env,
-      ...suite.env,
-      ...(overlay.env ?? {})
-    });
-    const child: ChildProcessByStdio<null, Readable, Readable> = spawn(invocation.command, invocation.args, {
-      cwd,
-      env: { ...process.env, ...suite.env, ...(overlay.env ?? {}) },
-      shell: false,
-      stdio: ["ignore", "pipe", "pipe"],
-      windowsVerbatimArguments: invocation.windowsVerbatimArguments,
-    });
+    const spawnEnv = { ...process.env, ...suite.env, ...(overlay.env ?? {}) };
+    const invocation = resolveCliSpawnInvocation(suite.command[0]!, suite.command.slice(1), spawnEnv);
+    const child: ChildProcessByStdio<null, Readable, Readable> = spawn(invocation.command, invocation.args, {
+      cwd,
+      env: spawnEnv,
+      shell: false,
+      stdio: ["ignore", "pipe", "pipe"],
+      windowsVerbatimArguments: invocation.windowsVerbatimArguments,
+    });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/tests/testService.ts` around lines 299 - 310,
The merged environment object is built twice (once when calling
resolveCliSpawnInvocation and again in the spawn options); to make this DRY,
compute a single envMerged (e.g., const mergedEnv = { ...process.env,
...suite.env, ...(overlay.env ?? {}) }) and pass mergedEnv into
resolveCliSpawnInvocation and into spawn's env option, keeping other options
(cwd, shell, stdio, windowsVerbatimArguments) unchanged and still using the
existing invocation, child, and suite identifiers.

480-496: disposeAll still uses child.kill("SIGKILL") — inconsistent with new tree-termination.

Timeout/stop paths now route SIGTERM/SIGKILL through terminateProcessTree, but app-shutdown disposeAll still calls entry.child.kill("SIGKILL") directly. On Windows (and for Unix test runners that spawn grandchildren like npm → node) this can leak descendants at shutdown, which is exactly the parity issue this PR is trying to close elsewhere.

♻️ Proposed fix
       for (const entry of activeRuns.values()) {
         if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer);
         if (entry.killTimer) clearTimeout(entry.killTimer);
         try {
-          entry.child.kill("SIGKILL");
+          terminateProcessTree(entry.child, "SIGKILL");
         } catch {
           // ignore
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/tests/testService.ts` around lines 480 - 496,
disposeAll currently calls entry.child.kill("SIGKILL") directly which bypasses
the new tree-aware shutdown and can leak descendants; update disposeAll to call
the tree-termination helper (terminateProcessTree) with the child (or child's
pid) and the SIGKILL signal instead of entry.child.kill, and ensure
terminateProcessTree is imported/available in this module so that activeRuns
cleanup uses the same termination logic as the timeout/stop paths.
apps/desktop/src/main/services/lanes/laneEnvironmentService.ts (1)

246-249: Timeout path has no SIGKILL fallback — a process that ignores SIGTERM will hang execCommand forever.

testService escalates to SIGKILL after a 3s grace window; here the timer only calls terminateProcessTree(child) (default SIGTERM on Unix). If the child masks/ignores SIGTERM, close never fires and the returned promise never settles, leaving callers like startDocker / installDependencies / cleanupLaneEnvironment stuck. Consider mirroring the two-phase termination used elsewhere in the PR.

♻️ Proposed fix
-      const timer = setTimeout(() => {
-        if (settled) return;
-        terminateProcessTree(child);
-      }, timeoutMs);
+      let killTimer: NodeJS.Timeout | null = null;
+      const timer = setTimeout(() => {
+        if (settled) return;
+        terminateProcessTree(child, "SIGTERM");
+        killTimer = setTimeout(() => {
+          if (settled) return;
+          terminateProcessTree(child, "SIGKILL");
+        }, 3000);
+      }, timeoutMs);

Make sure to also clearTimeout(killTimer) inside the error/close handlers alongside clearTimeout(timer).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/lanes/laneEnvironmentService.ts` around lines
246 - 249, The timeout handler that currently calls terminateProcessTree(child)
with default SIGTERM should be changed to a two-phase termination: when the
timer (timer) fires, send a graceful SIGTERM via terminateProcessTree(child,
'SIGTERM') and start a secondary killTimer that after a short graceWindow (e.g.,
3s) forces termination with terminateProcessTree(child, 'SIGKILL'); ensure both
timers (timer and killTimer) are cleared inside the process handlers (the error
and close callbacks that set/observe settled) so the execCommand promise always
settles; update references in execCommand and callers (startDocker,
installDependencies, cleanupLaneEnvironment) as needed and keep the settled flag
logic intact.
apps/ade-cli/src/bootstrap.ts (1)

125-134: Cache the augmented agent env instead of recomputing per PTY spawn.

getAdeCliAgentEnv is invoked on every PTY launch in ptyService.ts:1104, so each spawn re-runs augmentProcessPathWithShellAndKnownCliDirs with includeInteractiveShell: true — which spawns shell subprocesses (via execFileSync with -lc and -ic flags, bounded by the 1s timeout) to resolve PATH. This pattern differs from the desktop implementation, which caches adeCliService.agentEnv at startup.

♻️ Proposed refactor: compute once, reuse
-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;
-}
+// Resolve the augmented PATH once at module load and reuse it for every
+// PTY spawn so we don't re-spawn shells on each session.
+const headlessAugmentedPath = augmentProcessPathWithShellAndKnownCliDirs({
+  env: process.env,
+  includeInteractiveShell: true,
+  timeoutMs: 1_000,
+});
+if (headlessAugmentedPath) setPathEnvValue(process.env, headlessAugmentedPath);
+
+function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+  const next: NodeJS.ProcessEnv = { ...baseEnv };
+  if (headlessAugmentedPath) setPathEnvValue(next, headlessAugmentedPath);
+  return next;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ade-cli/src/bootstrap.ts` around lines 125 - 134, The code currently
calls createHeadlessAdeCliAgentEnv (which runs
augmentProcessPathWithShellAndKnownCliDirs with includeInteractiveShell:true) on
every PTY spawn via getAdeCliAgentEnv, causing repeated shell subprocesses;
instead compute the augmented env once at startup and cache it on the service
(e.g., set adeCliService.agentEnv = createHeadlessAdeCliAgentEnv()) and change
getAdeCliAgentEnv to return that cached adeCliService.agentEnv (falling back to
process.env if missing); keep createHeadlessAdeCliAgentEnv as the initializer
but remove per-spawn calls to augmentProcessPathWithShellAndKnownCliDirs so PTY
spawn no longer recomputes the PATH.
apps/desktop/src/main/services/ipc/registerIpc.ts (1)

1936-1958: Reuse resolveRendererSuppliedPath() for rootPath here.

appRevealPath, appOpenPath, and computerUseReadArtifactPreview now normalize file:// and ade-artifact:// inputs, but appOpenPathInEditor still does a raw path.resolve(rootRaw). If a renderer caller reuses those URI forms with the new "default" target, this handler will reject them before the allowlist check.

Suggested refactor
       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 !== "default" && target !== "finder" && target !== "vscode" && target !== "cursor" && target !== "zed") {
         throw new Error("Unsupported editor target.");
       }
-      const rootPath = path.resolve(rootRaw);
+      const ctx = getCtx();
+      const rootPath = resolveRendererSuppliedPath(rootRaw, ctx.project.rootPath);
 
       // Validate the renderer-supplied root is a known workspace root
       // (same pattern as appRevealPath).
-      const allowedRoots = getAllowedDirs(getCtx);
+      const allowedRoots = getAllowedDirs(() => ctx);

As per coding guidelines, Electron desktop app — check for IPC security, proper main/renderer process separation, and React best practices.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ipc/registerIpc.ts` around lines 1936 - 1958,
The handler in appOpenPathInEditor currently does path.resolve(rootRaw) and
rejects renderer-supplied file:// or ade-artifact:// URIs; replace that raw
resolution with the same normalization used elsewhere by calling
resolveRendererSuppliedPath(rootRaw) (the same helper used by
appRevealPath/appOpenPath/computerUseReadArtifactPreview), assign its result to
rootPath, and preserve the existing allowlist check that uses
resolvePathWithinRoot and getAllowedDirs(getCtx); ensure you propagate or throw
the same error behavior when resolveRendererSuppliedPath fails so the security
checks remain intact.
apps/desktop/src/renderer/lib/shell.test.ts (1)

50-56: Optional: improve readability of the expected Windows-quoted line.

The assertion on line 54 mixes JS string escapes with Windows double-quote/backslash escapes, which makes it hard to audit visually. Using String.raw plus explicit concatenation or a helper (e.g., a small expected built from ['"', 'C:\\repo path\\\\', '""quoted""', '"'].join("")) would make the intent clearer without changing behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/lib/shell.test.ts` around lines 50 - 56, The
expected Windows-quoted string in the test is hard to read due to nested JS and
Windows escaping; update the assertion by constructing the expected value with a
clearer expression (e.g., use String.raw or build an `expected` variable from
joined parts) instead of the inline escaped literal, keeping the same content
and using the existing symbols (`argv`, `commandArrayToLine`,
`parseCommandLine`) so the test still asserts equality of `line` to `expected`
and that `parseCommandLine(line, { platform: "win32" })` round-trips to `argv`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8f73f454-e9ba-4319-a320-d5fe06836108

📥 Commits

Reviewing files that changed from the base of the PR and between 3ea5f33 and ddecf19.

⛔ Files ignored due to path filters (5)
  • .claude/scheduled_tasks.lock is excluded by !**/*.lock
  • apps/desktop/build/icon.ico is excluded by !**/*.ico
  • apps/desktop/vendor/crsqlite/win32-x64/crsqlite.dll is excluded by !**/*.dll
  • docs/ARCHITECTURE.md is excluded by !docs/**
  • docs/playbooks/ship-lane.md is excluded by !docs/**
📒 Files selected for processing (84)
  • .claude/commands/shipLane.md
  • .github/workflows/release-core.yml
  • apps/ade-cli/src/adeRpcServer.test.ts
  • apps/ade-cli/src/adeRpcServer.ts
  • apps/ade-cli/src/bootstrap.ts
  • apps/ade-cli/src/cli.test.ts
  • apps/ade-cli/src/cli.ts
  • apps/desktop/package.json
  • apps/desktop/scripts/ade-cli-install-path.cmd
  • apps/desktop/scripts/ade-cli-windows-wrapper.cmd
  • apps/desktop/scripts/after-pack-runtime-fixes.cjs
  • apps/desktop/scripts/dev.cjs
  • apps/desktop/scripts/validate-win-artifacts.mjs
  • apps/desktop/src/main/main.ts
  • apps/desktop/src/main/packagedRuntimeSmoke.ts
  • apps/desktop/src/main/services/ai/cliExecutableResolver.test.ts
  • apps/desktop/src/main/services/ai/cliExecutableResolver.ts
  • apps/desktop/src/main/services/ai/providerCredentialSources.ts
  • apps/desktop/src/main/services/ai/providerTaskRunner.test.ts
  • apps/desktop/src/main/services/ai/providerTaskRunner.ts
  • apps/desktop/src/main/services/ai/tools/globSearch.test.ts
  • apps/desktop/src/main/services/ai/tools/globSearch.ts
  • apps/desktop/src/main/services/ai/tools/universalTools.test.ts
  • apps/desktop/src/main/services/ai/tools/universalTools.ts
  • apps/desktop/src/main/services/ai/tools/workflowTools.ts
  • apps/desktop/src/main/services/automations/automationPlannerService.ts
  • apps/desktop/src/main/services/automations/automationService.ts
  • apps/desktop/src/main/services/chat/agentChatService.ts
  • apps/desktop/src/main/services/chat/cursorAcpPool.ts
  • apps/desktop/src/main/services/cli/adeCliService.test.ts
  • apps/desktop/src/main/services/cli/adeCliService.ts
  • apps/desktop/src/main/services/cli/windowsPackaging.test.ts
  • apps/desktop/src/main/services/computerUse/localComputerUse.ts
  • apps/desktop/src/main/services/conflicts/conflictService.ts
  • apps/desktop/src/main/services/ipc/registerIpc.ts
  • apps/desktop/src/main/services/lanes/laneEnvironmentService.ts
  • apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts
  • apps/desktop/src/main/services/opencode/openCodeRuntime.ts
  • apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts
  • apps/desktop/src/main/services/opencode/openCodeServerManager.ts
  • apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts
  • apps/desktop/src/main/services/orchestrator/aiOrchestratorService.ts
  • apps/desktop/src/main/services/orchestrator/baseOrchestratorAdapter.ts
  • apps/desktop/src/main/services/orchestrator/orchestratorConstants.ts
  • apps/desktop/src/main/services/orchestrator/orchestratorContext.ts
  • apps/desktop/src/main/services/orchestrator/orchestratorService.ts
  • apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.test.ts
  • apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
  • apps/desktop/src/main/services/pty/ptyService.test.ts
  • apps/desktop/src/main/services/pty/ptyService.ts
  • apps/desktop/src/main/services/shared/processExecution.test.ts
  • apps/desktop/src/main/services/shared/processExecution.ts
  • apps/desktop/src/main/services/shared/utils.ts
  • apps/desktop/src/main/services/state/kvDb.ts
  • apps/desktop/src/main/services/sync/syncService.test.ts
  • apps/desktop/src/main/services/sync/syncService.ts
  • apps/desktop/src/main/services/tests/testService.ts
  • apps/desktop/src/main/services/usage/usageTrackingService.test.ts
  • apps/desktop/src/main/services/usage/usageTrackingService.ts
  • apps/desktop/src/preload/global.d.ts
  • apps/desktop/src/preload/preload.ts
  • apps/desktop/src/renderer/components/app/CommandPalette.tsx
  • apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx
  • apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
  • apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx
  • apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx
  • apps/desktop/src/renderer/components/chat/ChatComputerUsePanel.tsx
  • apps/desktop/src/renderer/components/files/FilesPage.test.tsx
  • apps/desktop/src/renderer/components/files/FilesPage.tsx
  • apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx
  • apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx
  • apps/desktop/src/renderer/components/settings/AdeCliSection.tsx
  • apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx
  • apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.test.ts
  • apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.ts
  • apps/desktop/src/renderer/lib/pathUtils.test.ts
  • apps/desktop/src/renderer/lib/pathUtils.ts
  • apps/desktop/src/renderer/lib/platform.ts
  • apps/desktop/src/renderer/lib/shell.test.ts
  • apps/desktop/src/renderer/lib/shell.ts
  • apps/desktop/src/renderer/onboarding/tours/filesTour.ts
  • apps/desktop/src/shared/adeLayout.ts
  • apps/desktop/src/shared/adeMcpIpc.test.ts
  • apps/desktop/src/shared/adeMcpIpc.ts

@@ -0,0 +1,37 @@
@echo off
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Convert file to CRLF line endings.

Batch files with LF-only line endings are known to trigger the Windows batch parser's 512‑byte boundary bug and break GOTO/CALL label resolution — worth fixing before this ships as an installer script. Either commit as CRLF or add a .gitattributes entry pinning *.cmd to CRLF.

As per static-analysis hint (Blinter E018): "Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures".

🧰 Tools
🪛 Blinter (1.0.112)

[error] 1-1: Unix line endings detected. Explanation: Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures and script malfunction due to Windows batch parser 512-byte boundary bugs. Recommendation: Convert file to Windows line endings (CRLF). Use tools like dos2unix, notepad++, or configure git with 'git config core.autocrlf true'. Context: File uses Unix line endings (LF-only) - 37 LF sequences found

(E018)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/ade-cli-install-path.cmd` at line 1, The file
apps/desktop/scripts/ade-cli-install-path.cmd uses LF-only line endings which
can trigger Windows batch parser bugs; convert this .cmd file to CRLF line
endings (or add a .gitattributes rule forcing *.cmd text eol=crlf) so the batch
parser handles GOTO/CALL labels correctly; update the repository by committing
the converted ade-cli-install-path.cmd (or adding the .gitattributes entry) and
verify the file now has CRLF endings.

@@ -0,0 +1,45 @@
@echo off
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Convert file to CRLF line endings.

Same concern as ade-cli-install-path.cmd — the Windows batch parser has known 512‑byte-boundary issues with LF-only line endings that can corrupt GOTO/CALL label resolution. Commit as CRLF or pin via .gitattributes.

As per static-analysis hint (Blinter E018): "Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures".

🧰 Tools
🪛 Blinter (1.0.112)

[error] 1-1: Unix line endings detected. Explanation: Batch file uses Unix line endings (LF-only) which can cause GOTO/CALL label parsing failures and script malfunction due to Windows batch parser 512-byte boundary bugs. Recommendation: Convert file to Windows line endings (CRLF). Use tools like dos2unix, notepad++, or configure git with 'git config core.autocrlf true'. Context: File uses Unix line endings (LF-only) - 45 LF sequences found

(E018)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/ade-cli-windows-wrapper.cmd` at line 1, The batch file
starting with the line "@echo off" uses LF-only endings which can break Windows
batch label parsing; convert this file (ade-cli-windows-wrapper.cmd) to CRLF
line endings or add a .gitattributes rule forcing CRLF for *.cmd files so the
Windows batch parser handles GOTO/CALL labels correctly; ensure the commit
contains the converted CRLF file (or the .gitattributes change) and re-run the
Windows batch linter to verify the issue is resolved.

Comment on lines +19 to +37
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%
)
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

cmd.exe batch ERRORLEVEL inside parentheses parse time expansion delayed expansion

💡 Result:

In Windows cmd.exe batch files, using %ERRORLEVEL% inside parentheses (parenthesized blocks like IF or FOR) fails to show the current value after a command changes it. This is due to parse-time expansion: the entire block is parsed and variables expanded before execution begins. So %ERRORLEVEL% captures the value at parse time (usually 0), not runtime. To fix this, use delayed expansion: add setlocal EnableDelayedExpansion at the script start (or top of scope), then use !ERRORLEVEL! instead of %ERRORLEVEL% inside the block. This expands variables at execution time. Example (fails): if 1==1 ( exit /b 1 echo %ERRORLEVEL% REM Shows 0 ) Example (works): setlocal EnableDelayedExpansion if 1==1 ( exit /b 1 echo !ERRORLEVEL! REM Shows 1 ) endlocal IF ERRORLEVEL n checks the runtime value correctly without delayed expansion, as it compares the current ERRORLEVEL directly (true if >= n). Alternatives without delayed expansion: - Use && / || for conditional execution. - call :subroutine to check %ERRORLEVEL% outside the block. - if errorlevel 1 (handle error) else if not errorlevel 1 (handle non-zero). This behavior is unchanged as of 2026; see Microsoft SETLOCAL docs and SS64 for details.

Citations:


🏁 Script executed:

# Check if the file exists and examine lines 19-37
if [ -f "apps/desktop/scripts/ade-cli-windows-wrapper.cmd" ]; then
  echo "=== File exists, examining lines 1-50 ==="
  head -50 "apps/desktop/scripts/ade-cli-windows-wrapper.cmd" | cat -n
else
  echo "File not found at expected path"
  # Search for the file
  find . -name "ade-cli-windows-wrapper.cmd" 2>/dev/null
fi

Repository: arul28/ADE

Length of output: 1586


🏁 Script executed:

# Search for install-path shim or similar references
rg -i "install.*shim|shim.*install" --type batch,cmd,powershell 2>/dev/null | head -20
# Also search for other .cmd files related to the CLI
find . -name "*.cmd" -o -name "*.bat" | grep -i "cli\|install" | head -20

Repository: arul28/ADE

Length of output: 152


🏁 Script executed:

cat -n "apps/desktop/scripts/ade-cli-install-path.cmd"

Repository: arul28/ADE

Length of output: 1138


Critical: exit /b %ERRORLEVEL% inside ( ... ) captures the parse-time value, not the post-call exit code.

In cmd.exe, all %VAR% references inside a parenthesized compound statement are expanded once when the block is first parsed, before the block executes. That means all three of these branches:

  • Lines 19‑22 (ADE_CLI_NODE)
  • Lines 24‑28 (APP_EXE / Electron-as-Node)
  • Lines 31‑37 (system node)

will exit with whatever ERRORLEVEL was before the call :run_with_runtime_env ran — effectively 0/stale — regardless of the actual CLI exit code. This silently breaks &&/|| piping, CI gates, and any caller checking %ERRORLEVEL%.

The install-path shim (which generates flat, non-parenthesized statements) does not have this issue.

Fix: Replace exit /b %ERRORLEVEL% with bare exit /b (uses the live ERRORLEVEL at exit time):

Proposed fix (use bare `exit /b`)
 if defined ADE_CLI_NODE (
   call :run_with_runtime_env "%ADE_CLI_NODE%" "%CLI_JS%" %*
-  exit /b %ERRORLEVEL%
+  exit /b
 )

 if exist "%APP_EXE%" (
   set "ELECTRON_RUN_AS_NODE=1"
   call :run_with_runtime_env "%APP_EXE%" "%CLI_JS%" %*
-  exit /b %ERRORLEVEL%
+  exit /b
 )

 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%
+    exit /b
   )
 )

Consider adding an end-to-end test in windowsPackaging.test.ts that verifies a failing CLI propagates a non-zero exit code through the wrapper.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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%
)
)
if defined ADE_CLI_NODE (
call :run_with_runtime_env "%ADE_CLI_NODE%" "%CLI_JS%" %*
exit /b
)
if exist "%APP_EXE%" (
set "ELECTRON_RUN_AS_NODE=1"
call :run_with_runtime_env "%APP_EXE%" "%CLI_JS%" %*
exit /b
)
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
)
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/scripts/ade-cli-windows-wrapper.cmd` around lines 19 - 37, The
three branches that call :run_with_runtime_env (the ADE_CLI_NODE branch, the
APP_EXE/ELECTRON_RUN_AS_NODE branch, and the system node branch) currently use
"exit /b %ERRORLEVEL%" which is expanded at parse time and therefore returns a
stale value; change each "exit /b %ERRORLEVEL%" to a bare "exit /b" so cmd.exe
uses the live ERRORLEVEL set by the called CLI, ensuring :run_with_runtime_env's
exit code (and any failures from node/Electron) propagate correctly; update the
statements that reference ADE_CLI_NODE, APP_EXE, and the node check around
CLI_JS accordingly and consider adding a windowsPackaging.test.ts case that runs
the wrapper with a failing CLI to assert non-zero exit propagation.

Comment on lines +130 to +132
if (args.stdinText != null && child.stdin) {
child.stdin.end(args.stdinText);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Defend the stdin write against early-exit races.

Paired with the stdin error-handler fix above, wrap the end() call so a synchronous throw on an already-destroyed stdin doesn't escape the Promise executor. Today, if the child dies between spawn and this line, child.stdin.end(...) can throw ERR_STREAM_DESTROYED synchronously and bypass the outer handlers.

🛡️ Proposed fix
-    if (args.stdinText != null && child.stdin) {
-      child.stdin.end(args.stdinText);
-    }
+    if (args.stdinText != null && child.stdin) {
+      try {
+        child.stdin.end(args.stdinText);
+      } catch {
+        // Ignore — stdin may already be destroyed if the child exited immediately.
+        // The `close` handler will surface the real exit code.
+      }
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (args.stdinText != null && child.stdin) {
child.stdin.end(args.stdinText);
}
if (args.stdinText != null && child.stdin) {
try {
child.stdin.end(args.stdinText);
} catch {
// Ignore — stdin may already be destroyed if the child exited immediately.
// The `close` handler will surface the real exit code.
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/ai/providerTaskRunner.ts` around lines 130 -
132, Protect the synchronous call to child.stdin.end from throwing if the stream
was destroyed: inside providerTaskRunner where you check if (args.stdinText !=
null && child.stdin) wrap the child.stdin.end(args.stdinText) invocation in a
try/catch so any immediate ERR_STREAM_DESTROYED (or other sync throws) are
caught and converted into the same Promise rejection/handlers used elsewhere
(matching the stdin 'error' handler change); reference variables: child,
args.stdinText, and the stdin.end call to locate and update the code.

@@ -388,15 +389,18 @@ async function runCodexExec(args: {
}
const commandPreview = [quoteIfNeeded(codexExecutable), ...cliArgs.map(quoteIfNeeded)].join(" ");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check where plannerCommandPreview / commandPreview surfaces to users
rg -nP --type=ts -C2 '\b(plannerCommandPreview|commandPreview)\b'

Repository: arul28/ADE

Length of output: 12061


🏁 Script executed:

#!/bin/bash
# Read the automationPlannerService.ts file around lines 390-430 and 474-530
# to see how the child process is invoked and if Windows wrapping occurs

sed -n '390,430p' apps/desktop/src/main/services/automations/automationPlannerService.ts
echo "---"
sed -n '474,530p' apps/desktop/src/main/services/automations/automationPlannerService.ts

Repository: arul28/ADE

Length of output: 3402


🏁 Script executed:

#!/bin/bash
# Find where resolveCliSpawnInvocation is defined
rg -n "resolveCliSpawnInvocation" --type=ts -B2 -A10

Repository: arul28/ADE

Length of output: 35211


🏁 Script executed:

#!/bin/bash
# Read the processExecution.ts file to see the full implementation
sed -n '64,80p' apps/desktop/src/main/services/shared/processExecution.ts

Repository: arul28/ADE

Length of output: 480


🏁 Script executed:

#!/bin/bash
# Find the shouldUseWindowsCmdWrapper and resolveWindowsCmdInvocation implementations
sed -n '1,100p' apps/desktop/src/main/services/shared/processExecution.ts | grep -A 20 "shouldUseWindowsCmdWrapper\|resolveWindowsCmdInvocation"

Repository: arul28/ADE

Length of output: 1547


🏁 Script executed:

#!/bin/bash
# Verify the invocation object is available and could be used for preview
# Look at lines 398-430 and 481-530 to see if invocation is available for the preview
sed -n '398,430p' apps/desktop/src/main/services/automations/automationPlannerService.ts

Repository: arul28/ADE

Length of output: 1146


commandPreview diverges from the actual invocation on Windows.

commandPreview is built from the original codexExecutable/claudeExecutable + cliArgs (lines 390 and 474), but on Windows the real child process is cmd.exe /d /s /c "..." (wrapped by resolveCliSpawnInvocation). If this preview surfaces in logs, UI, or the plannerCommandPreview returned to callers, Windows users may see a command that doesn't match what actually ran and wouldn't be directly copy-pasteable to reproduce failures.

If preserving the logical command is intentional (likely — it reads better), consider a brief comment noting the divergence; otherwise consider deriving the preview from invocation.command/invocation.args which are available immediately after the resolveCliSpawnInvocation call.

Also applies to: 474-474

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/automations/automationPlannerService.ts` at
line 390, commandPreview currently concatenates codexExecutable/claudeExecutable
with cliArgs which diverges from the actual Windows spawn (resolved by
resolveCliSpawnInvocation), so update the code to either (a) derive the
displayed preview from the resolved invocation (use invocation.command and
invocation.args immediately after resolveCliSpawnInvocation) so the preview
matches the real child process on all platforms, or (b) if you want to preserve
the logical command string, add a brief comment where commandPreview is
constructed (and in plannerCommandPreview) stating that the preview is a
human-readable logical command and may differ from the actual Windows invocation
(cmd.exe /d /s /c "..."); reference symbols: commandPreview, codexExecutable,
claudeExecutable, cliArgs, resolveCliSpawnInvocation, invocation.command,
invocation.args, plannerCommandPreview.

Comment thread apps/desktop/src/main/services/shared/processExecution.ts
Comment thread apps/desktop/src/renderer/components/files/FilesPage.tsx
Comment thread apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx
Comment thread apps/desktop/src/renderer/lib/pathUtils.ts Outdated
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 9 comments


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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

The new Windows sandbox path never treats < file as a path-bearing operation. checkWorkerSandbox() only derives mutating/read targets from >>? and the new MUTATING_CMD_RE, so a command such as type nul < C:\Windows\win.ini | upload-tool reaches the final allowed: true path without any allowedPaths or protected-file check on win.ini. That is a real sandbox escape on the new cmd.exe execution path, because redirected input can read arbitrary files outside the repo. Extend both mutation detection and path-reference extraction to include < so redirected input files are validated the same way as > targets.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
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;

try {
const realCwd = canonicalizePathForContainment(cwd, pathApi);
const realScriptPath = canonicalizePathForContainment(scriptPath, pathApi);
if (!isWithinDirForPathApi(pathApi, realCwd, realScriptPath)) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new inspectScriptFile() path check hard-codes the script to be under cwd, so powershell.exe -File rejects scripts from directories that the sandbox explicitly allowlisted in WorkerSandboxConfig.allowedPaths. The rest of the sandbox honors those extra roots (for example normal reads/writes do), so this new PowerShell branch is not symmetric with the existing contract and will block valid Windows worker workflows that keep helper scripts in an approved external tools directory. Reuse the same allowed-root containment logic here instead of checking only against cwd.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
const realCwd = canonicalizePathForContainment(cwd, pathApi);
const realScriptPath = canonicalizePathForContainment(scriptPath, pathApi);
if (!isWithinDirForPathApi(pathApi, realCwd, realScriptPath)) {
  return {

await startHostIfNeeded();
} else {
await stopHostIfRunning();
if (!isCrdtSyncAvailable()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new unavailable-CRDT branch disconnects the viewer but preserves the saved draft/cluster state, which leaves unsupported desktops looking like transferable viewers even though they cannot host sync: ts // apps/desktop/src/main/services/sync/syncService.ts await stopHostIfRunning(); if (!isCrdtSyncAvailable()) { if (syncPeerService.isConnected()) { syncPeerService.disconnect({ preserveDraft: true }); } continue; } I verified that getStatus() still derives role: "viewer" from the saved draft / remote cluster, SyncDevicesSection enables the handoff button from status.transferReadiness.ready, and transferBrainToLocal() has no matching availability guard. On a Windows machine without cr-sqlite opening a previously paired project, clicking “Make this desktop the host” will rewrite local cluster ownership without starting a host or propagating the handoff to the real brain, leaving sync state split. Make the transfer path fail fast when !isCrdtSyncAvailable() or report transfer readiness as blocked in this state.

Comment thread apps/ade-cli/src/cli.ts
}

export function shouldAttemptDesktopSocketConnection(socketPath: string): boolean {
return isAdeMcpNamedPipePath(socketPath) || fs.existsSync(socketPath);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This helper now treats any Windows named-pipe path as immediately eligible for a desktop connection attempt: ts // apps/ade-cli/src/cli.ts export function shouldAttemptDesktopSocketConnection(socketPath: string): boolean { return isAdeMcpNamedPipePath(socketPath) || fs.existsSync(socketPath); } ... if (!options.headless && shouldAttemptDesktopSocketConnection(layout.socketPath)) { const socketClient = await SocketJsonRpcClient.connect(layout.socketPath, options.timeoutMs); } I verified that SocketJsonRpcClient.connect() waits on that probe for up to min(timeoutMs, 5000), so every ade invocation inside a Windows ADE project now goes through a failed desktop-socket connect path before headless fallback when the desktop app is not running. That is a user-visible startup regression for headless-only workflows. Restore a fast-fail readiness check for named pipes (or another side channel that proves a live desktop instance) before attempting the full socket connection.

linearIngressService: headlessLinearServices.linearIngressService,
linearRoutingService: headlessLinearServices.linearRoutingService,
processService: headlessLinearServices.processService,
processService,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

This change exposes a real processService from the transient headless CLI runtime:

// apps/ade-cli/src/bootstrap.ts
linearSyncService: headlessLinearServices.linearSyncService,
linearIngressService: headlessLinearServices.linearIngressService,
linearRoutingService: headlessLinearServices.linearRoutingService,
processService,
computerUseArtifactBrokerService,

But the CLI still creates that runtime per invocation and always disposes it in InProcessJsonRpcClient.close() / executePlan(... finally { await connection.close(); }), which calls runtime.dispose() immediately after the action finishes. process.startAll/stopAll are exposed through run_ade_action in @apps/ade-cli/src/adeRpcServer.ts, so a headless command like ade run start-all --lane ... now appears to succeed and then tears down the started PTYs before the user can use them. That makes the new headless ProcessService integration semantically broken rather than merely incomplete. Fix by either keeping process actions unavailable in headless mode until a long-lived daemon owns the sessions, or moving process execution/state to a runtime that survives past a single CLI command.

const getHeadlessLaneRuntimeEnv = async (laneId: string): Promise<Record<string, string>> => {
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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

getHeadlessLaneRuntimeEnv() derives a lane's runtime ports from its current index in laneService.list(), but laneService.list() is backed by created_at desc ordering, so creating any newer lane renumbers every older lane on the next process start/restart. That makes headless ade run start sessions change PORT/PORT_RANGE_*/HOSTNAME for the same lane over time, unlike desktop where main.ts uses the persisted portAllocationService lease for stable values. A process that was previously reachable on one port can restart on a different port after unrelated lane creation. Reuse a stable per-lane lease/mapping in headless mode instead of deriving from transient list position.

// apps/ade-cli/src/bootstrap.ts
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;

return {
startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`,
command: resolvedClaude.path,
args: ["-p", prompt],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This refactor changes override execution from a shell-decoded prompt to a raw argv prompt:

// apps/desktop/src/main/services/orchestrator/providerOrchestratorAdapter.ts
return {
  startupCommand: `exec claude -p ${shellInlineDecodedArg(prompt)}`,
  command: resolvedClaude.path,
  args: ["-p", prompt],
};

On Windows the resolved Claude binary is typically a .cmd, so ptyService routes it through resolveCliSpawnInvocation(), and quoteWindowsCmdArg() explicitly rewrites \r/\n to spaces before building the cmd.exe command line (the new test in @apps/desktop/src/main/services/shared/processExecution.test.ts even asserts "line one\r\nline two" -> "line one line two"). step.metadata.startupCommand is treated here as free-form prompt text, not a shell command, so multiline overrides now arrive at Claude as one flattened line only on Windows. That changes fenced code blocks, numbered instructions, and copied command sequences. Route override prompts through the same prompt-file/stdin launcher used for regular workers, or otherwise avoid the Windows cmd.exe argv path for multiline prompt text.

child: ReturnType<typeof spawn>,
signal: NodeJS.Signals,
): boolean {
if (child.exitCode !== null || child.signalCode !== null) return false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

terminateExternalResolverProcessTree now relies on a detached Unix process group so the timeout path can escalate from SIGTERM to SIGKILL, but the new early return stops the second-stage kill as soon as the group leader records an exit/signal. In the common timeout case the leader can die on SIGTERM while grandchildren in the same process group keep running, so the 2-second fallback at lines 3534-3535 never sends SIGKILL to -child.pid and the resolver leaks background processes after ADE marks the run failed. Restrict that short-circuit to Windows, or otherwise keep attempting process.kill(-child.pid, signal) on Unix whenever a valid detached-group pid exists.

// apps/desktop/src/main/services/conflicts/conflictService.ts
function terminateExternalResolverProcessTree(
  child: ReturnType<typeof spawn>,
  signal: NodeJS.Signals,
): boolean {
  if (child.exitCode !== null || child.signalCode !== null) return false;
Suggested change
if (child.exitCode !== null || child.signalCode !== null) return false;
if (process.platform === "win32" && (child.exitCode !== null || child.signalCode !== null)) return false;

}

const mutationKind = POWERSHELL_MUTATION_KIND_BY_COMMAND.get(innerCommandName);
if (!mutationKind) continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

Inside the new deep-inspection path, decoded -EncodedCommand payloads and inspectable .ps1 files are parsed only against POWERSHELL_MUTATION_KIND_BY_COMMAND, and any other inner command is ignored entirely: ts // apps/desktop/src/main/services/ai/tools/universalTools.ts const mutationKind = POWERSHELL_MUTATION_KIND_BY_COMMAND.get(innerCommandName); if (!mutationKind) continue; scriptMutates = true; Because checkWorkerSandbox() applies the blocked-command regexes only to the outer command string before trusting powerShellInspection, dangerous native commands hidden inside the decoded/scripted PowerShell body (for example reg.exe add ..., bcdedit.exe ..., or cmd.exe /c copy ...) can pass the sandbox even though this PR’s goal is to enforce Windows policy directly. Treat unknown inner commands in inspectable PowerShell bodies as deny-by-default, or run the blocked/mutating checks recursively on each inner segment before allowing execution.

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@copilot review but do not make fixes

Comment on lines +247 to +268
["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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security PowerShell single-quoted string '' escape not handled in tokenizer

In PowerShell, '' inside a single-quoted string is the escape sequence for a literal single-quote character (analogous to SQL). The tokenizer closes the string when it sees the first ', so Remove-Item 'C:\project\user''s dir\file.txt' is parsed as two tokens — C:\project\user and s dir\file.txt — rather than the single path C:\project\user's dir\file.txt.

The addResolvedPath logic then checks C:\project\user (which is inside the allowed workspace root) and ignores the trailing fragment. An agent could craft a command like Remove-Item 'C:\workspace\allowed''/../outside.txt' where the first token is an allowed workspace path, causing the sandbox path check to pass while PowerShell actually resolves and operates on a path outside the sandbox.

When the quote is ' and the next character is also ', the tokenizer should emit a literal ' and stay inside the string rather than closing it:

if (quote) {
  if (ch === quote) {
    if (quote === "'" && command[i + 1] === "'") {
      // PowerShell escaped single-quote inside single-quoted string
      current += "'";
      i += 1;
    } else {
      quote = null;
    }
  } else {
    current += ch;
  }
  continue;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/services/ai/tools/universalTools.ts
Line: 247-268

Comment:
**PowerShell single-quoted string `''` escape not handled in tokenizer**

In PowerShell, `''` inside a single-quoted string is the escape sequence for a literal single-quote character (analogous to SQL). The tokenizer closes the string when it sees the first `'`, so `Remove-Item 'C:\project\user''s dir\file.txt'` is parsed as two tokens — `C:\project\user` and `s dir\file.txt` — rather than the single path `C:\project\user's dir\file.txt`.

The `addResolvedPath` logic then checks `C:\project\user` (which is inside the allowed workspace root) and ignores the trailing fragment. An agent could craft a command like `Remove-Item 'C:\workspace\allowed''/../outside.txt'` where the first token is an allowed workspace path, causing the sandbox path check to pass while PowerShell actually resolves and operates on a path outside the sandbox.

When the `quote` is `'` and the next character is also `'`, the tokenizer should emit a literal `'` and stay inside the string rather than closing it:
```typescript
if (quote) {
  if (ch === quote) {
    if (quote === "'" && command[i + 1] === "'") {
      // PowerShell escaped single-quote inside single-quoted string
      current += "'";
      i += 1;
    } else {
      quote = null;
    }
  } else {
    current += ch;
  }
  continue;
}
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 14 comments

value.startsWith("\\\\") ||
value.startsWith(".\\") ||
value.startsWith("..\\") ||
value.includes("\\")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

This new Windows heuristic treats any token containing \ as a filesystem path, even when the token is just a regex/pattern literal. ts // apps/desktop/src/main/services/ai/tools/universalTools.ts (windowsMode && ( /^[a-zA-Z]:(?:[\\/]|$)/.test(value) || value.startsWith("\\\\") || value.startsWith(".\\") || value.startsWith("..\\") || value.includes("\\") )) checkWorkerSandbox() still feeds every token accepted here through collectPathReferences() and path.win32.resolve(...), so a read-only command like rg "\\bfoo\\b" src on Windows resolves the regex token to C:\bfoo\b and rejects the command as Path outside sandbox. That breaks common search workflows the sandbox is supposed to allow. Tighten this predicate so only actual Windows path forms (drive paths, UNC paths, or explicit relative prefixes like .\ / ..\) are treated as paths.

};
}

function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

createHeadlessAdeCliAgentEnv() only returns an augmented clone for PTY launches; it never updates the headless runtime's own process.env. ```ts
// apps/ade-cli/src/bootstrap.ts
function createHeadlessAdeCliAgentEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
const next: NodeJS.ProcessEnv = { ...baseEnv };
const nextPath = augmentProcessPathWithShellAndKnownCliDirs({
env: next,
includeInteractiveShell: true,

That means helpers running inside the CLI process still execute on the unmodified PATH. In this same headless runtime, `createHeadlessGitHubService()` falls back to `spawnSync("gh", ["auth", "token"])` when `ADE_GITHUB_TOKEN` / `GITHUB_TOKEN` are absent, so Windows installs discoverable only through the new known-dir augmentation remain invisible there and `ade prs create` still fails to pick up GitHub auth. Apply the augmented PATH to `process.env` during `createAdeRuntime()` as desktop does in `fixElectronShellPath()`, or route internal spawns through the same augmented env helper.

? resolveRouteRebaseSelection({ rebaseNeeds: [], routeItemId: route.laneId })
: null,
};
const params = new URLSearchParams(window.location.search);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

PrsContext now initializes the PR page from window.location.search only, even though the production app runs under HashRouter and stores PR route state in the hash. ts // apps/desktop/src/renderer/components/prs/state/PrsContext.tsx const params = new URLSearchParams(window.location.search); const tab = params.get("tab"); ... const prId = params.get("prId"); This means routes opened as #/prs?tab=rebase&laneId=... (still emitted by CreatePrModal/IntegrationTab) mount with activeTab === "normal" and no selection. On that first render, @apps/desktop/src/renderer/components/prs/PRsPage.tsx still runs its URL-sync effect and serializes the stale state back into the route, so the requested rebase/PR deep link is dropped before the page can settle. Restore the old route-based initialization here — e.g. parse the same hash/search contract that PRsPage uses instead of reading the outer search string directly.

let stderr = "";
let settled = false;
const maxBuffer = 10 * 1024 * 1024;
const timer = setTimeout(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

execCommand() no longer settles its promise on timeout; it only asks the shared helper to kill the child and waits for a later close event. In @apps/desktop/src/main/services/shared/processExecution.ts, terminateProcessTree() is explicitly best-effort and returns false when taskkill/child.kill cannot terminate the process, so this timeout branch has a real no-settlement path. That leaves callers like Docker startup, dependency install, and Docker cleanup stuck awaiting forever, with the lane progress step never leaving running.

// apps/desktop/src/main/services/lanes/laneEnvironmentService.ts
const timer = setTimeout(() => {
  if (settled) return;
  terminateProcessTree(child);
}, timeoutMs);

Settle the promise from the timeout handler as well (or at minimum when terminateProcessTree() returns false) so a failed kill attempt becomes a normal command failure instead of an infinite wait.

`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] ?? ""}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

resolveWindowsCmdInvocation() already returns args[3] as a complete cmd.exe /c payload wrapped in outer quotes (for example the shared helper tests assert ""npm" "run" "test""). Appending that string verbatim after && produces a mid-command segment that starts with ""C:\... instead of a normal quoted executable, so cmd.exe sees a malformed command when the OpenCode binary resolves to a Windows shim (.cmd/.bat), which is the common npm-installed case. Strip the outer wrapper before concatenating so the injected marker chain executes the actual OpenCode command.

// apps/desktop/src/main/services/opencode/openCodeServerManager.ts
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] ?? ""}`;
Suggested change
+ `&&${invocation.args[3] ?? ""}`;
+ `&&${(invocation.args[3] ?? "").replace(/^"(.*)"$/, "$1")}`;

}

if (!inQuotes && /\s/.test(ch)) {
if (current.length) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

parseWindowsCommandLine() only emits an argument when current.length is nonzero, so a valid quoted empty argument ("") is discarded instead of preserved. That breaks the renderer’s edit/save round-trip on Windows: @apps/desktop/src/renderer/components/run/RunPage.tsx formats stored argv back into text with commandArrayToLine(definition.command) and later reparses that text via normalizeCommandForConfig()parseCommandLine(), so a saved definition containing an empty string arg is silently rewritten without it after editing. Track whether an argument has started (including opening quotes) and push current when that flag is set, even when the resulting string is empty.

// apps/desktop/src/renderer/lib/shell.ts
if (!inQuotes && /\s/.test(ch)) {
  if (current.length) {
    out.push(current);
    current = "";
  }
}

}
return {
command: env.ComSpec && env.ComSpec.trim() ? env.ComSpec.trim() : "cmd.exe",
args: ["/d", "/s", "/c", [cmd, ...args].map(quoteWindowsCmdArg).join(" ")],
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

The new Windows branch in resolveSpawnInvocation() no longer matches the shared, tested resolveWindowsCmdInvocation() contract used elsewhere in the app: it passes the joined command as the /c payload without wrapping the full command string. The repo’s own Windows quoting tests expect the outer "..." wrapper for cmd.exe /d /s /c, and omitting it here means npm run dev uses a different parsing path than the one already validated in @apps/desktop/src/main/services/shared/processExecution.test.ts. That makes the Windows dev launcher fragile to normal quoted arguments and can misparse the spawned npx command line. Re-wrap the full joined command string so this script uses the same quoting shape as the shared helper.

// apps/desktop/scripts/dev.cjs
return {
  command: env.ComSpec && env.ComSpec.trim() ? env.ComSpec.trim() : "cmd.exe",
  args: ["/d", "/s", "/c", [cmd, ...args].map(quoteWindowsCmdArg).join(" ")],
  windowsVerbatimArguments: true,
};
Suggested change
args: ["/d", "/s", "/c", [cmd, ...args].map(quoteWindowsCmdArg).join(" ")],
args: ["/d", "/s", "/c", `"${[cmd, ...args].map(quoteWindowsCmdArg).join(" ")}"`],

if (finalPrompt) {
commandParts.push(shellEscapeArg(finalPrompt));
commandArgs.push(finalPrompt);
commandPreviewParts.push(previewShellEscapeArg(finalPrompt));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

When resolveExecutableOnPath(provider) fails on Windows, this code falls back to a startupCommand string, and that string is later executed by the PTY shell rather than used as display-only text. The problem is that the fallback command is built with previewShellEscapeArg(...), which uses Windows cmd.exe quoting rules, while ptyService still prefers powershell.exe for shell startup on Windows. That means prompt text containing PowerShell interpolation characters like $..., $(...), or PowerShell-specific quoting cases can be rewritten before Claude/Codex ever sees it. This breaks exactly the unresolved-executable path the new tests exercise.

// apps/ade-cli/src/adeRpcServer.ts
if (finalPrompt) {
  commandArgs.push(finalPrompt);
  commandPreviewParts.push(previewShellEscapeArg(finalPrompt));
}
const startupEnvPrefixParts = process.platform === "win32" ? [] : envPrefixParts;

Use PowerShell-safe quoting for fallback startupCommand strings, or force the fallback shell to cmd.exe when emitting windowsShellEscapeArg output.

env: NodeJS.ProcessEnv = process.env,
): SpawnInvocation {
const comSpec = env.ComSpec?.trim() || "cmd.exe";
const cmdLine = [command, ...args].map(quoteWindowsCmdArg).join(" ");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟡 Medium] [🔵 Bug]

shouldUseWindowsCmdWrapper() routes every extensionless Windows command through cmd.exe, but resolveWindowsCmdInvocation() then quotes the command token itself. That is correct for .cmd/.bat shims, but not for built-ins like echo or dir: per cmd /?, preserved quotes only work when the quoted token names an executable file. So a call such as createTerminal({ command: "echo", args: ["hi"] }) becomes cmd.exe /d /s /c ""echo" "hi"", which cmd.exe misparses and fails to run. This is a user-facing regression for generic Windows terminal requests introduced by the new shared wrapper.

// apps/desktop/src/main/services/shared/processExecution.ts
const cmdLine = [command, ...args].map(quoteWindowsCmdArg).join(" ");
return {
  command: comSpec,
  args: ["/d", "/s", "/c", `"${cmdLine}"`],

Only emit the fully quoted ""path\\tool.cmd" ..."" form for actual batch/shim files, and leave plain built-ins / extensionless shell commands unquoted or handle them via a true shell-command path.

continue;
}
if (quote) {
if (ch === quote) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 High] [🔵 Bug]

checkWorkerSandbox() now trusts tokenizePowerShellCommand() to extract mutation targets from powershell.exe -Command ..., but this branch closes a single-quoted string on the first ' it sees and never handles PowerShell's documented '' escape for a literal apostrophe. That means a path like 'C:\repo\allow''ed\file.txt' is parsed as C:\repo\allowed\file.txt, so a worker can point at a sibling directory outside the sandbox whose name differs only by an apostrophe while the inspector resolves it as an allowed in-sandbox path. This weakens the new Windows sandbox boundary and needs to preserve doubled single quotes as a literal ' while staying inside the same token; add a regression test for a mutation command using '' inside a single-quoted path.

// apps/desktop/src/main/services/ai/tools/universalTools.ts
    if (quote) {
      if (ch === quote) {
        quote = null;
      } else {
        current += ch;
Suggested change
if (ch === quote) {
if (ch === quote) {
if (quote === "'" && command[i + 1] === "'") {
current += "'";
i += 1;
} else {
quote = null;
}
} else {
current += ch;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant