From fa7ba81bfe8213d74fe2b2c8dd89d1072b3d8083 Mon Sep 17 00:00:00 2001 From: David Whatley Date: Sat, 1 Aug 2026 19:10:02 -0400 Subject: [PATCH 01/12] feat(windows): port CLI shells and providers from #999 Based-on: nsxdavid/ADE#999 --- apps/ade-cli/src/adeRpcServer.ts | 2 + apps/ade-cli/src/cli.test.ts | 67 ++++- apps/ade-cli/src/cli.ts | 162 ++++++++++-- apps/ade-cli/src/commands/deeplinks.ts | 6 +- apps/ade-cli/src/headlessLinearServices.ts | 2 + apps/ade-cli/src/lib/clipboard.ts | 5 +- .../tuiClient/__tests__/connection.test.ts | 68 +++-- .../__tests__/deeplinkKeybind.test.ts | 6 +- .../tuiClient/__tests__/remoteBridge.test.ts | 3 + .../src/tuiClient/__tests__/state.test.ts | 16 +- apps/ade-cli/src/tuiClient/app.tsx | 11 +- apps/ade-cli/src/tuiClient/connection.ts | 2 +- .../src/tuiClient/pairedRemoteConnector.ts | 4 +- apps/ade-cli/src/tuiClient/remoteBridge.ts | 10 +- apps/ade-cli/src/tuiClient/remoteLauncher.ts | 14 +- .../main/services/ai/cliExecutableResolver.ts | 1 + .../services/ai/providerCredentialSources.ts | 1 + .../main/services/ai/providerTaskRunner.ts | 1 + .../services/ai/tools/ctoOperatorTools.ts | 1 + .../main/services/ai/tools/universalTools.ts | 1 + apps/desktop/src/main/services/ai/utils.ts | 10 +- .../appControlLaunchCommand.test.ts | 108 +++++++- .../appControl/appControlLaunchCommand.ts | 190 +++++++++++++- .../appControl/appControlService.test.ts | 83 ++++++ .../services/appControl/appControlService.ts | 54 +++- .../src/main/services/chat/cursorSdkPool.ts | 7 +- .../src/main/services/chat/droidSdkPool.ts | 7 +- .../src/main/services/pty/ptyService.test.ts | 215 +++++++++++++++- .../src/main/services/pty/ptyService.ts | 240 ++++++++++++++---- .../pty/resourceUsageSampling.test.ts | 50 +++- .../services/pty/resourceUsageSampling.ts | 28 +- .../main/services/pty/supervisedPtyHost.ts | 8 +- .../components/chat/AgentCliAuthCard.test.tsx | 4 +- .../components/chat/AgentCliAuthCard.tsx | 2 +- .../components/terminals/LaneCombobox.tsx | 2 +- .../terminals/TerminalsPage.test.tsx | 2 +- .../components/terminals/WorkSidebar.test.tsx | 30 +++ .../components/terminals/WorkSidebar.tsx | 31 ++- .../components/terminals/cliLaunch.test.ts | 92 ++++++- .../components/terminals/cliLaunch.ts | 2 + .../laneComboboxMachineGroups.test.tsx | 10 +- .../terminals/useWorkSessions.test.ts | 34 +++ .../components/terminals/useWorkSessions.ts | 3 +- apps/desktop/src/shared/cliLaunch.ts | 129 +++++++--- 44 files changed, 1512 insertions(+), 212 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 8a8b4788d..3671d6f87 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -203,6 +203,7 @@ function resolveExecutableOnPath(command: string, env: NodeJS.ProcessEnv = proce encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); if (result.status !== 0 || typeof result.stdout !== "string") return null; const first = result.stdout @@ -3517,6 +3518,7 @@ async function runTool(args: { const result = spawnSync(command, commandArgs, { cwd: runtime.projectRoot, encoding: "utf8", + windowsHide: true, env: { ...process.env, ...(options?.env ?? {}), diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 92bab15e5..18a6b9be2 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -27,6 +27,7 @@ import { resolveSnoozeUntilIso, renderLaneGraph, resolveAdeCodeModulePath, + resolveWindowsDesktopExecutable, resolveRoots, runCli, startHeadlessRpcSocketServer, @@ -45,6 +46,7 @@ import { import { generateRpcAuthToken } from "./rpcAuth"; import { JsonRpcClient } from "./tuiClient/jsonRpcClient"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; type ResolveRootsOptions = Parameters[0]; @@ -474,7 +476,7 @@ describe("ADE CLI", () => { "laneId=lane-1", ]); - expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.options.projectRoot).toBe(path.resolve("/tmp/project")); expect(parsed.options.role).toBe("cto"); expect(parsed.command).toEqual([ "actions", @@ -536,7 +538,7 @@ describe("ADE CLI", () => { "code", "--print-state", ]); - expect(parsed.options.projectRoot).toBe("/tmp/project"); + expect(parsed.options.projectRoot).toBe(path.resolve("/tmp/project")); expect(parsed.command).toEqual(["code", "--print-state"]); const plan = buildCliPlan(parsed.command); @@ -788,14 +790,32 @@ describe("ADE CLI", () => { }, ); - it("returns null for a named-pipe socket path (desktop path; no dir/chmod)", async () => { - // isAdeRuntimeNamedPipePath matches by string prefix, so this exercises the - // named-pipe early-return branch on any platform without touching the fs. + it("declares intended-user-only access when listening on a Windows named pipe", () => { + expect(localIpcListenOptions("\\\\.\\pipe\\ade-headless-security-test")).toEqual({ + path: "\\\\.\\pipe\\ade-headless-security-test", + readableAll: false, + writableAll: false, + }); + expect(localIpcListenOptions("/tmp/ade.sock")).toBe("/tmp/ade.sock"); + }); + + (process.platform === "win32" ? it : it.skip)("hosts headless RPC on a Windows named pipe", async () => { + const socketPath = `\\\\.\\pipe\\ade-headless-${process.pid}-${Date.now()}`; const stop = await startHeadlessRpcSocketServer({ - socketPath: "//./pipe/ade-headless-named-pipe-test", + socketPath, createHandler: () => (async () => ({})) as never, }); - expect(stop).toBeNull(); + try { + expect(stop).not.toBeNull(); + const client = await JsonRpcClient.connect(socketPath); + try { + await expect(client.request("ping")).resolves.toEqual({}); + } finally { + client.close(); + } + } finally { + stop?.(); + } }); it("requires the per-boot bearer token on the headless TCP RPC listener", async () => { @@ -882,6 +902,9 @@ describe("ADE CLI", () => { expect(shouldBlockManualMachineRuntimeSpawn("tcp://127.0.0.1:9999", { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", })).toBe(false); + expect(shouldBlockManualMachineRuntimeSpawn("\\\\.\\pipe\\ade-runtime-stable-test", { + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + })).toBe(true); expect(shouldBlockManualMachineRuntimeSpawn(path.join(os.tmpdir(), "ade-code-test", "ade.sock"), { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", })).toBe(false); @@ -5908,6 +5931,26 @@ describe("ADE CLI", () => { expect(shouldAttemptDesktopSocketConnection("//./pipe/ade-123")).toBe(true); }); + it("finds the Windows desktop executable beside a packaged CLI resource", () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-desktop-")); + const cliEntry = path.join(installRoot, "resources", "ade-cli", "cli.cjs"); + const appPath = path.join(installRoot, "ADE.exe"); + fs.mkdirSync(path.dirname(cliEntry), { recursive: true }); + fs.writeFileSync(cliEntry, ""); + fs.writeFileSync(appPath, ""); + + try { + expect(resolveWindowsDesktopExecutable({ + appName: "ADE", + env: {}, + execPath: path.join(installRoot, "node.exe"), + entryPath: cliEntry, + })).toBe(appPath); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }); + it("renders a compact lane graph", () => { const graph = renderLaneGraph({ lanes: [ @@ -6064,7 +6107,7 @@ describe("ADE CLI", () => { kind: "screenshot", title: "Checkout complete", description: "Checkout complete", - path: "/tmp/done.png", + path: path.resolve("/tmp/done.png"), }, ], }, @@ -6823,8 +6866,8 @@ describe("ADE CLI", () => { projectRoot: null, workspaceRoot: null, }); - expect(roots.projectRoot).toBe("/explicit/project-root"); - expect(roots.workspaceRoot).toBe("/explicit/project-root"); + expect(roots.projectRoot).toBe(path.resolve("/explicit/project-root")); + expect(roots.workspaceRoot).toBe(path.resolve("/explicit/project-root")); } finally { if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; else process.env.ADE_PROJECT_ROOT = prevProject; @@ -6865,8 +6908,8 @@ describe("ADE CLI", () => { projectRoot: null, workspaceRoot: null, }); - expect(roots.projectRoot).toBe("/explicit/project-root"); - expect(roots.workspaceRoot).toBe("/explicit/workspace-root"); + expect(roots.projectRoot).toBe(path.resolve("/explicit/project-root")); + expect(roots.workspaceRoot).toBe(path.resolve("/explicit/workspace-root")); } finally { if (prevProject === undefined) delete process.env.ADE_PROJECT_ROOT; else process.env.ADE_PROJECT_ROOT = prevProject; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 439dab826..a24d0ef2c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -125,6 +125,7 @@ import { snoozeWakeLabel } from "../../desktop/src/renderer/lib/sessionSnooze"; import type { AdeRuntime } from "./bootstrap"; import { cleanupLegacyBundledAdeSkillsForCli } from "./bootstrap"; import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore"; +import { localIpcListenOptions } from "./services/runtime/localIpcListenOptions"; import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService"; import type { SyncHostSingletonLease } from "./services/sync/syncHostSingleton"; import { @@ -132,7 +133,10 @@ import { syncAccountAnalyticsIdentity, } from "./services/account/accountAuthService"; import { getSharedAccountAuthService } from "./services/account/sharedAccountAuthService"; -import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol"; +import { + DEFAULT_SYNC_HOST_PORT, + SYNC_HOST_MAX_PORT, +} from "./services/sync/syncProtocol"; import { runAdeCodeRemote, takeAdeCodeRemoteArgs, @@ -539,6 +543,7 @@ function maybeRunBuiltCliFallback( cwd: CLI_PACKAGE_ROOT, env: process.env, encoding: "utf8", + windowsHide: true, }); if (buildResult.error || buildResult.status !== 0 || !isBuiltCliFresh()) { error.details.nextAction = @@ -558,6 +563,7 @@ function maybeRunBuiltCliFallback( [SOURCE_FALLBACK_ENV]: "1", }, encoding: "utf8", + windowsHide: true, }); if (rerun.error) { error.details.nextAction = @@ -1127,7 +1133,7 @@ const HELP_BY_COMMAND: Record = { and explicit remote addresses continue to work while signed out. $ ade machines list --text - $ ade machines rename "Build Mac" + $ ade machines rename "Build workstation" $ ade machines rename --clear $ ade machines connect $ ade machines connect --project @@ -1197,7 +1203,7 @@ const HELP_BY_COMMAND: Record = { $ ade desktop open Flags: - --app-name macOS app name to open. Defaults to ADE, ADE Beta, + --app-name Installed app name to open. Defaults to ADE, ADE Beta, or ADE Alpha based on the installed CLI wrapper. `, github: `${ADE_BANNER} @@ -2861,6 +2867,7 @@ function detectUnmergedLaneCreateNudge( cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }), ): string | null { const cwd = args.cwd ?? process.cwd(); @@ -12593,6 +12600,7 @@ function findProjectRoots(startDir: string): { cwd: startDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); const gitRoot = git.status === 0 ? git.stdout.trim() : ""; const fallback = gitRoot ? path.resolve(gitRoot) : path.resolve(startDir); @@ -12629,6 +12637,7 @@ function commandExists(command: string): boolean { const result = spawnSync(lookupCommand, [command], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }); return result.status === 0 && result.stdout.trim().length > 0; } @@ -12767,6 +12776,7 @@ function runLocalCommand( encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000, + windowsHide: true, }); return { ok: result.status === 0, @@ -13689,12 +13699,14 @@ async function startHeadlessRpcSocketServer(args: { createHandler: () => JsonRpcHandler & { dispose?: () => void }; }): Promise<(() => void) | null> { if ( - isAdeRuntimeNamedPipePath(args.socketPath) || - fs.existsSync(args.socketPath) + !isAdeRuntimeNamedPipePath(args.socketPath) + && fs.existsSync(args.socketPath) ) { return null; } - fs.mkdirSync(path.dirname(args.socketPath), { recursive: true, mode: 0o700 }); + if (!isAdeRuntimeNamedPipePath(args.socketPath)) { + fs.mkdirSync(path.dirname(args.socketPath), { recursive: true, mode: 0o700 }); + } const serverState = createHeadlessRpcServer(args.createHandler); const { server } = serverState; @@ -13709,7 +13721,7 @@ async function startHeadlessRpcSocketServer(args: { }; server.once("listening", handleListening); server.once("error", handleError); - server.listen(args.socketPath); + server.listen(localIpcListenOptions(args.socketPath)); }); if (!isAdeRuntimeNamedPipePath(args.socketPath)) { @@ -14955,8 +14967,11 @@ function shouldRepairMachineRuntimeServiceBeforeSpawn( return !socketPathOverride?.trim() && process.env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL !== "1" && isPackagedElectronCliRuntime() - && !socketPath.startsWith("tcp://") - && !isAdeRuntimeNamedPipePath(socketPath) + && isServiceManagedMachineRuntimeSocket(socketPath); +} + +function isServiceManagedMachineRuntimeSocket(socketPath: string): boolean { + return !socketPath.startsWith("tcp://") && !isEphemeralRuntimeSocketPath(socketPath); } @@ -14965,9 +14980,7 @@ export function shouldBlockManualMachineRuntimeSpawn( env: NodeJS.ProcessEnv = process.env, ): boolean { return env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1" - && !socketPath.startsWith("tcp://") - && !isAdeRuntimeNamedPipePath(socketPath) - && !isEphemeralRuntimeSocketPath(socketPath); + && isServiceManagedMachineRuntimeSocket(socketPath); } function manualMachineRuntimeSpawnBlockedError(socketPath: string): Error { @@ -15104,6 +15117,7 @@ async function spawnMachineRuntimeDaemon( detached: true, stdio: "ignore", env, + windowsHide: true, }); child.once("error", () => {}); if (child.pid != null) recordRuntimeSpawn(socketPath, child.pid); @@ -15530,6 +15544,103 @@ async function runBrainCommand( ); } +export function resolveWindowsDesktopExecutable(args: { + appName: string; + env?: NodeJS.ProcessEnv; + execPath?: string; + entryPath?: string | null; +}): string | null { + const env = args.env ?? process.env; + const execPath = args.execPath ?? process.execPath; + const entryPath = args.entryPath ?? process.argv[1] ?? null; + const requestedName = path.basename(args.appName.trim()) || "ADE"; + const appBaseName = requestedName.toLowerCase().endsWith(".exe") + ? requestedName.slice(0, -4) + : requestedName; + const executableName = `${appBaseName}.exe`; + const execBaseName = path.basename(execPath); + const currentExecutableIsAde = + execBaseName.toLowerCase() === executableName.toLowerCase() + || /^ade(?: beta| alpha)?\.exe$/i.test(execBaseName); + const candidates: Array = [ + env.ADE_DESKTOP_APP_PATH?.trim() || null, + currentExecutableIsAde + ? execPath + : null, + entryPath + ? path.resolve(path.dirname(entryPath), "..", "..", executableName) + : null, + entryPath && executableName.toLowerCase() !== "ade.exe" + ? path.resolve(path.dirname(entryPath), "..", "..", "ADE.exe") + : null, + env.LOCALAPPDATA + ? path.join(env.LOCALAPPDATA, "Programs", appBaseName, executableName) + : null, + env.PROGRAMFILES + ? path.join(env.PROGRAMFILES, appBaseName, executableName) + : null, + ]; + for (const candidate of candidates) { + if (!candidate) continue; + const resolved = path.resolve(candidate); + if (fs.existsSync(resolved)) return resolved; + } + return null; +} + +async function launchWindowsDesktopApp( + executablePath: string, + appName: string, +): Promise> { + const env = { ...process.env }; + // The installed CLI wrapper runs ADE.exe as Node. Carrying this flag into + // the child would launch another CLI process instead of the desktop UI. + delete env.ELECTRON_RUN_AS_NODE; + return await new Promise((resolve) => { + let child: ReturnType; + try { + child = spawn(executablePath, [], { + detached: true, + stdio: "ignore", + env, + windowsHide: true, + }); + } catch (error) { + resolve({ + ok: false, + platform: process.platform, + appName, + path: executablePath, + message: error instanceof Error ? error.message : String(error), + }); + return; + } + let settled = false; + const finish = (result: Record): void => { + if (settled) return; + settled = true; + resolve(result); + }; + child.once("error", (error) => finish({ + ok: false, + platform: process.platform, + appName, + path: executablePath, + message: error.message, + })); + child.once("spawn", () => { + child.unref(); + finish({ + ok: true, + platform: process.platform, + appName, + path: executablePath, + message: `Opened ${appName}.`, + }); + }); + }); +} + async function runDesktopCommand(rest: string[]): Promise { const args = [...rest]; const sub = firstPositional(args) ?? "open"; @@ -15555,12 +15666,26 @@ async function runDesktopCommand(rest: string[]): Promise { }; } + if (process.platform === "win32") { + const executablePath = resolveWindowsDesktopExecutable({ appName }); + if (!executablePath) { + return { + ok: false, + platform: process.platform, + appName, + message: + `Unable to find the installed ${appName} executable. Reinstall ADE or set ADE_DESKTOP_APP_PATH.`, + }; + } + return await launchWindowsDesktopApp(executablePath, appName); + } + return { ok: false, platform: process.platform, appName, message: - "Launching ADE desktop from the CLI is currently supported on macOS.", + "Launching ADE desktop from the CLI is currently supported on macOS and Windows.", }; } @@ -15661,7 +15786,9 @@ async function runServe( const { getRuntimeServiceStatus } = await import("./serviceManager"); return getRuntimeServiceStatus(); } - boundLaunchdLogs(path.dirname(lastFailurePathForMachine())); + if (process.platform === "darwin") { + boundLaunchdLogs(path.dirname(lastFailurePathForMachine())); + } const previousFailure = readLastFailure({ kind: "machine" }); const startupBackoffMs = computeStartupBackoffMs(previousFailure, Date.now()); if (startupBackoffMs > 0 && previousFailure) { @@ -16202,7 +16329,12 @@ async function runServe( // brain out. const { acquireSyncHostSingleton } = await import("./services/sync/syncHostSingleton"); brainSyncHostLease ??= acquireSyncHostSingleton({ projectRoot: null }); - const listenerPort = await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]); + const listenerPort = await sharedSyncListener.ensureListening( + Array.from( + { length: SYNC_HOST_MAX_PORT - DEFAULT_SYNC_HOST_PORT + 1 }, + (_, index) => DEFAULT_SYNC_HOST_PORT + index, + ), + ); brainSyncHostLease.updatePort(listenerPort); } else if (activeScope && brainSyncHostLease) { // A scope took over hosting and holds its own lease; drop the diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index 8a16c25d3..4c29cb719 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -196,7 +196,11 @@ export function openUrlViaOs(url: string): { failed: boolean; message: string } args = [url]; } try { - const r = spawnSync(cmd, args, { stdio: "ignore", timeout: 10_000 }); + const r = spawnSync(cmd, args, { + stdio: "ignore", + timeout: 10_000, + windowsHide: true, + }); if (r.error) return { failed: true, message: r.error.message }; if (r.signal) return { failed: true, message: `${cmd} exited with signal ${r.signal}` }; if (typeof r.status === "number" && r.status !== 0) { diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index f3e74df48..a741db4e6 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -444,6 +444,7 @@ function runCommandAsync( encoding: "utf8", timeout: options.timeoutMs, maxBuffer: options.maxBuffer ?? 10 * 1024 * 1024, + windowsHide: true, }, (error, stdout, stderr) => { resolve({ @@ -473,6 +474,7 @@ function ghAuthToken(): Pick 0) { diff --git a/apps/ade-cli/src/lib/clipboard.ts b/apps/ade-cli/src/lib/clipboard.ts index dd6efa1dd..1a76f9de7 100644 --- a/apps/ade-cli/src/lib/clipboard.ts +++ b/apps/ade-cli/src/lib/clipboard.ts @@ -13,7 +13,7 @@ export type CopyToClipboardOptions = { * Test seam: override the spawn function. The override must return the * same shape as `spawnSync` (status + error). Defaults to `spawnSync`. */ - spawn?: (cmd: string, args: string[], options: { input: string }) => { + spawn?: (cmd: string, args: string[], options: { input: string; windowsHide?: boolean }) => { error?: Error; status?: number | null; }; @@ -50,7 +50,7 @@ export function copyToClipboard(text: string, options: CopyToClipboardOptions = return false; } } - const r = spawn(cmd, args, { input: text }); + const r = spawn(cmd, args, { input: text, windowsHide: true }); if (r.error || (typeof r.status === "number" && r.status !== 0)) return false; return true; } @@ -58,6 +58,7 @@ export function copyToClipboard(text: string, options: CopyToClipboardOptions = function defaultCommandExists(cmd: string): boolean { const r = spawnSync(process.platform === "win32" ? "where" : "which", [cmd], { stdio: "ignore", + windowsHide: true, }); return !r.error && r.status === 0; } diff --git a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts index 916b4d7a7..7b256517a 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/connection.test.ts @@ -14,6 +14,11 @@ import { import { JsonRpcClient } from "../jsonRpcClient"; import { startTuiHeartbeat, type TuiHeartbeat } from "../heartbeat"; import { ProcessJsonRpcClient } from "../remoteBridge"; +import { + socketSpawnLockPath, + withSocketSpawnLock, +} from "../../services/runtime/socketSpawnLock"; +import { resolveMachineAdeLayout } from "../../services/projects/machineLayout"; import { appendDedupedTuiEvent, appendReservedTuiEvent, @@ -123,7 +128,15 @@ function useMissingMachineSocket(): string { const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-machine-")); process.env.ADE_HOME = adeHome; delete process.env.ADE_RPC_SOCKET_PATH; - return path.join(adeHome, "sock", "ade.sock"); + return resolveMachineAdeLayout().socketPath; +} + +let nextTestPipeId = 1; + +function localTestSocketPath(tmpDir: string, fileName: string): string { + if (process.platform !== "win32") return path.join(tmpDir, fileName); + const stem = fileName.replace(/[^a-zA-Z0-9_-]+/g, "-"); + return `\\\\.\\pipe\\ade-code-${process.pid}-${nextTestPipeId++}-${stem}`; } function mockAttachedClient(): { @@ -255,7 +268,7 @@ describe("connectToAde embedded mode", () => { it("does not silently fall back to embedded mode when socket attach fails", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-missing-socket-")); - const socketPath = path.join(tmpDir, "missing.sock"); + const socketPath = localTestSocketPath(tmpDir, "missing.sock"); await expect(connectToAde({ project, @@ -267,7 +280,7 @@ describe("connectToAde embedded mode", () => { it("explains remote bridge failures without exposing its temporary socket path", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-bridge-")); - const socketPath = path.join(tmpDir, "bridge.sock"); + const socketPath = localTestSocketPath(tmpDir, "bridge.sock"); try { await expect(connectToAde({ @@ -298,7 +311,7 @@ describe("connectToAde embedded mode", () => { it("rejects a direct socket whose runtime role is stale", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-stale-role-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: string[] = []; const server = net.createServer((socket) => { let buffer = ""; @@ -333,7 +346,7 @@ describe("connectToAde embedded mode", () => { it("allows remote sockets to differ by build hash and project root", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-remote-socket-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: string[] = []; const server = net.createServer((socket) => { let buffer = ""; @@ -377,7 +390,7 @@ describe("connectToAde embedded mode", () => { it("registers the project and injects projectId when attached to the machine daemon", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { let buffer = ""; @@ -455,7 +468,7 @@ describe("connectToAde embedded mode", () => { it("promotes the project to a recent catalog row for an interactive launch", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { let buffer = ""; @@ -512,7 +525,7 @@ describe("connectToAde embedded mode", () => { it("adapts multi-project runtime chat events into the TUI chat stream", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const serverSocketRef: { current: net.Socket | null } = { current: null }; const requests: Array<{ method: string; params?: Record }> = []; const server = net.createServer((socket) => { @@ -599,7 +612,7 @@ describe("connectToAde embedded mode", () => { it("surfaces runtime event replay gaps to subscribers", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-connection-gap-")); - const socketPath = path.join(tmpDir, "ade.sock"); + const socketPath = localTestSocketPath(tmpDir, "ade.sock"); const server = net.createServer((socket) => { let buffer = ""; socket.on("error", () => {}); @@ -706,7 +719,7 @@ describe("connectToAde embedded mode", () => { expect(client.close).toHaveBeenCalledTimes(1); }); - it("rechecks the machine socket after taking the spawn lock", async () => { + it.skipIf(process.platform === "win32")("rechecks the machine socket after taking the spawn lock", async () => { const socketPath = useMissingMachineSocket(); const lockPath = path.join(path.dirname(socketPath), `${path.basename(socketPath)}.spawn.lock`); fs.mkdirSync(path.dirname(lockPath), { recursive: true }); @@ -730,6 +743,23 @@ describe("connectToAde embedded mode", () => { expect(fs.existsSync(lockPath)).toBe(false); }); + it("keeps Windows named-pipe spawn locks in the per-user ADE runtime directory", async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-pipe-lock-")); + process.env.ADE_HOME = adeHome; + const socketPath = `\\\\.\\pipe\\ade-runtime-stable-${process.pid}`; + const lockPath = socketSpawnLockPath(socketPath); + let ran = false; + + await withSocketSpawnLock(socketPath, async () => { + ran = true; + expect(fs.existsSync(lockPath)).toBe(true); + }); + + expect(ran).toBe(true); + expect(path.dirname(lockPath)).toBe(path.join(adeHome, "runtime", "spawn-locks")); + expect(fs.existsSync(lockPath)).toBe(false); + }); + it("does not spawn a second brain while a recently spawned one is still coming up", async () => { // The spawn lock only serializes the first attempt. A brain that has not yet // bound its socket must not attract a rival spawn from the next `ade code`, @@ -765,7 +795,7 @@ describe("connectToAde embedded mode", () => { expect(childProcess.spawn).toHaveBeenCalledTimes(2); }); - it("unlinks stale machine socket files before retrying daemon startup", async () => { + it.skipIf(process.platform === "win32")("unlinks stale machine socket files before retrying daemon startup", async () => { const socketPath = useMissingMachineSocket(); fs.mkdirSync(path.dirname(socketPath), { recursive: true }); fs.writeFileSync(socketPath, ""); @@ -945,7 +975,7 @@ function closeServer(server: net.Server): Promise { describe("JsonRpcClient", () => { it("handles framed notifications before JSONL responses", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -986,7 +1016,7 @@ describe("JsonRpcClient", () => { it("honors byte-based Content-Length framing for unicode payloads", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1025,7 +1055,7 @@ describe("JsonRpcClient", () => { it("matches responses whose ids are echoed as strings", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); const server = net.createServer((socket) => { let buffer = ""; socket.on("data", (chunk) => { @@ -1061,7 +1091,7 @@ describe("JsonRpcClient", () => { it("handles large Content-Length frames split across many chunks", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1102,7 +1132,7 @@ describe("JsonRpcClient", () => { it("fires onClose when the socket drops unexpectedly", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1124,7 +1154,7 @@ describe("JsonRpcClient", () => { it("does not fire onClose on an intentional close()", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); const server = net.createServer(() => {}); await listenRpc(server, socketPath); const client = await JsonRpcClient.connect(socketPath); @@ -1142,7 +1172,7 @@ describe("JsonRpcClient", () => { it("times out pending requests by tearing down the socket", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; @@ -1170,7 +1200,7 @@ describe("JsonRpcClient", () => { it("fails the connection on parse garbage instead of continuing", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-code-jsonrpc-")); - const socketPath = path.join(tmpDir, "rpc.sock"); + const socketPath = localTestSocketPath(tmpDir, "rpc.sock"); let resolveServerSocket: (socket: net.Socket) => void = () => {}; const serverSocketReady = new Promise((resolve) => { resolveServerSocket = resolve; diff --git a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts index c5ae353be..11ca61008 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/deeplinkKeybind.test.ts @@ -117,16 +117,16 @@ describe("copy ADE deeplink keybinding", () => { describe("clipboard helper dispatches the right OS command", () => { it("uses pbcopy on darwin with the deeplink as stdin", () => { - const calls: Array<{ cmd: string; args: string[]; input: string }> = []; + const calls: Array<{ cmd: string; args: string[]; input: string; windowsHide: boolean | undefined }> = []; const ok = copyToClipboard("ade://lane/abc", { platform: "darwin", spawn: (cmd, args, opts) => { - calls.push({ cmd, args, input: opts.input }); + calls.push({ cmd, args, input: opts.input, windowsHide: opts.windowsHide }); return { status: 0 }; }, }); expect(ok).toBe(true); - expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc" }]); + expect(calls).toEqual([{ cmd: "pbcopy", args: [], input: "ade://lane/abc", windowsHide: true }]); }); it("uses clip on win32", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts b/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts index 6d3945344..c3753202f 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/remoteBridge.test.ts @@ -82,6 +82,9 @@ describe("startSyncRemoteBridge", () => { connectionLabel: "local network (studio.local:8787)", })), }); + if (process.platform === "win32") { + expect(bridge.socketUrl).toMatch(/^\\\\\.\\pipe\\ade-code-paired-/); + } const socket = bridge.socketUrl.startsWith("tcp://") ? net.connect(Number(new URL(bridge.socketUrl).port), "127.0.0.1") : net.connect(bridge.socketUrl); diff --git a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts index 94463b7e6..7241a30df 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/state.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/state.test.ts @@ -17,25 +17,27 @@ afterEach(() => { describe("ade code persisted state", () => { it("prefers project-scoped lane and chat state over legacy global fallback", () => { + const repoA = path.resolve("/repo-a"); + const repoB = path.resolve("/repo-b"); const state = normalizeAdeCodeState({ lastChatByLane: { main: "legacy-chat" }, lastLaneId: "legacy-lane", lastChatByProjectLane: { - "/repo-a": { main: "repo-a-chat" }, - "/repo-b": { main: "repo-b-chat" }, + [repoA]: { main: "repo-a-chat" }, + [repoB]: { main: "repo-b-chat" }, }, lastLaneByProject: { - "/repo-a": "repo-a-lane", - "/repo-b": "repo-b-lane", + [repoA]: "repo-a-lane", + [repoB]: "repo-b-lane", }, draftKind: "chat", draftKindByProject: { - "/repo-a": "chat", - "/repo-b": "cli", + [repoA]: "chat", + [repoB]: "cli", }, }); - expect(scopedAdeCodeState(state, "/repo-b")).toEqual({ + expect(scopedAdeCodeState(state, repoB)).toEqual({ lastChatByLane: { main: "repo-b-chat" }, lastLaneId: "repo-b-lane", draftKind: "cli", diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index c372666ee..637bc9cd6 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -12658,7 +12658,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return true; } if (!attachment) { - addNotice("No clipboard image was found. On macOS, copy an image or image file path; ADE Code checks pngpaste and pbpaste.", "error"); + const clipboardHint = process.platform === "win32" + ? "On Windows, copy an image or image file path; ADE Code reads the system clipboard through PowerShell." + : process.platform === "darwin" + ? "On macOS, copy an image or image file path; ADE Code checks pngpaste and pbpaste." + : "Copy an image or image file path; ADE Code checks wl-paste and xclip when available."; + addNotice(`No clipboard image was found. ${clipboardHint}`, "error"); return true; } if (activePaneRef.current !== "chat") { @@ -16737,7 +16742,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, : EMPTY_TERMINAL_CHUNKS; if (error && !connection) { - const remoteLabel = project.remoteLabel?.trim() || "the remote Mac"; + const remoteLabel = project.remoteLabel?.trim() || "the remote computer"; return ( @@ -16748,7 +16753,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, {error} {remoteLaunch ? ( - The remote Mac may be restarting; every retry re-evaluates its saved connection paths. + The remote computer may be restarting; every retry re-evaluates its saved connection paths. ) : null} diff --git a/apps/ade-cli/src/tuiClient/connection.ts b/apps/ade-cli/src/tuiClient/connection.ts index c01e12acc..60d3c5c30 100644 --- a/apps/ade-cli/src/tuiClient/connection.ts +++ b/apps/ade-cli/src/tuiClient/connection.ts @@ -880,7 +880,7 @@ export async function connectToAde(args: { const message = errorMessage(error); if (args.requireSocket) { if (args.remote) { - const remoteLabel = args.project.remoteLabel?.trim() || "the remote Mac"; + const remoteLabel = args.project.remoteLabel?.trim() || "the remote computer"; throw new Error( `Remote ADE connection to ${remoteLabel} was interrupted while ADE Code was starting: ` + `${remoteSocketFailureDetail(message, explicitSocketPath)}. ` + diff --git a/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts b/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts index d1a6fe492..383ca6ee8 100644 --- a/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts +++ b/apps/ade-cli/src/tuiClient/pairedRemoteConnector.ts @@ -64,7 +64,7 @@ export async function pairedRouteAccountProof(args: { const expectedOwnerUserId = args.credentials.accountOwnerUserId?.trim() ?? ""; if (expectedOwnerUserId && proof.userId.trim() !== expectedOwnerUserId) { throw new PairedRuntimeRelayAuthRequiredError( - "Sign in with the same ADE account as this Mac to connect through Relay. Local network and Tailscale connections still work without an account.", + "Sign in with the same ADE account as this computer to connect through Relay. Local network and Tailscale connections still work without an account.", ); } return { userId: proof.userId.trim(), token: proof.token.trim() }; @@ -78,7 +78,7 @@ export async function assertRelayAccountUnchanged( const currentProof = await getAccountRelayProof().catch(() => null); if (currentProof?.userId.trim() === initialProof.userId) return; throw new PairedRuntimeRelayAuthRequiredError( - "Your ADE account changed before the Relay connection finished. Sign in with the same account as this Mac and try again.", + "Your ADE account changed before the Relay connection finished. Sign in with the same account as this computer and try again.", ); } diff --git a/apps/ade-cli/src/tuiClient/remoteBridge.ts b/apps/ade-cli/src/tuiClient/remoteBridge.ts index 451915357..2500eab12 100644 --- a/apps/ade-cli/src/tuiClient/remoteBridge.ts +++ b/apps/ade-cli/src/tuiClient/remoteBridge.ts @@ -1,8 +1,10 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import net, { type AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; +import { localIpcListenOptions } from "../services/runtime/localIpcListenOptions"; import { RemoteTargetRegistry } from "../../../desktop/src/main/services/remoteRuntime/remoteTargetRegistry"; import type { RemoteRuntimeTarget, @@ -300,7 +302,11 @@ async function startLocalBridgeListener( if (bridgeDir) { try { fs.chmodSync(bridgeDir, 0o700); } catch {} } - const bridgeSocketPath = bridgeDir ? path.join(bridgeDir, "bridge.sock") : null; + const bridgeSocketPath = process.platform === "win32" + ? `\\\\.\\pipe\\${directoryPrefix}${process.pid}-${randomUUID()}` + : bridgeDir + ? path.join(bridgeDir, "bridge.sock") + : null; const server = net.createServer(onConnection); server.maxConnections = 1; const removeFiles = (): void => { @@ -322,7 +328,7 @@ async function startLocalBridgeListener( }; server.once("listening", onListening); server.once("error", onError); - if (bridgeSocketPath) server.listen(bridgeSocketPath); + if (bridgeSocketPath) server.listen(localIpcListenOptions(bridgeSocketPath)); else server.listen(0, "127.0.0.1"); }); } catch (error) { diff --git a/apps/ade-cli/src/tuiClient/remoteLauncher.ts b/apps/ade-cli/src/tuiClient/remoteLauncher.ts index 38c7644a9..2a44b97e3 100644 --- a/apps/ade-cli/src/tuiClient/remoteLauncher.ts +++ b/apps/ade-cli/src/tuiClient/remoteLauncher.ts @@ -252,10 +252,10 @@ export function parseRemoteAdeCodeArgs(argv: string[]): RemoteCliOptions { function printRemoteHelp(): void { process.stdout.write(`ade code remote -Connect ADE Code to a Mac already saved in ADE Connections. +Connect ADE Code to a computer already saved in ADE Connections. Local network and Tailscale connections work without an ADE account. ADE Relay -requires both Macs to be signed in to the same account. Advanced SSH is used +requires both computers to be signed in to the same account. Advanced SSH is used only when you explicitly save an SSH connection. Usage: @@ -1072,8 +1072,8 @@ export async function listRemoteSessions(client: RemoteRpcClientLike, projectId: async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null): Promise { if (!targets.length) { throw new Error( - "No saved Macs yet. In ADE desktop, open Connections and choose Add machine. " + - "You can sign in to find your Macs, pair directly, scan your network, or use advanced SSH setup.", + "No saved computers yet. In ADE desktop, open Connections and choose Add machine. " + + "You can sign in to find your computers, pair directly, scan your network, or use advanced SSH setup.", ); } if (query) { @@ -1087,10 +1087,10 @@ async function selectTarget(targets: RemoteRuntimeTarget[], query: string | null const selectionMode = machineSelectionMode(targets.length, canPrompt()); if (selectionMode === "auto") return targets[0]!; if (selectionMode === "flag-required") { - throw new Error("Choose a Mac: pass --target non-interactively."); + throw new Error("Choose a computer: pass --target non-interactively."); } return await promptInteractiveChoice( - "Choose a Mac", + "Choose a computer", targets, remoteTargetChoiceLabel, ); @@ -1408,7 +1408,7 @@ export async function runAdeCodeRemote( ); if (target.transport !== "paired" && options.routePreference !== "auto") { throw new Error( - `--route ${options.routePreference} applies only to paired Macs. ` + + `--route ${options.routePreference} applies only to paired computers. ` + `${target.name} is configured for advanced SSH.`, ); } diff --git a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts index 248b72b64..35188885e 100644 --- a/apps/desktop/src/main/services/ai/cliExecutableResolver.ts +++ b/apps/desktop/src/main/services/ai/cliExecutableResolver.ts @@ -310,6 +310,7 @@ function readShellPath( env, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, + windowsHide: true, }, ); const startIdx = raw.indexOf(PATH_MARKER_START); diff --git a/apps/desktop/src/main/services/ai/providerCredentialSources.ts b/apps/desktop/src/main/services/ai/providerCredentialSources.ts index 794613a2a..9da9824ce 100644 --- a/apps/desktop/src/main/services/ai/providerCredentialSources.ts +++ b/apps/desktop/src/main/services/ai/providerCredentialSources.ts @@ -102,6 +102,7 @@ export function runShellCommand( stdio: ["ignore", "pipe", "pipe"], env: process.env, windowsVerbatimArguments: useCmd, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index 77f4489ec..9a9837eaf 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -161,6 +161,7 @@ async function runCommand(args: { env, stdio: [args.stdinText != null ? "pipe" : "ignore", "pipe", "pipe"], windowsVerbatimArguments: invocation.windowsVerbatimArguments, + windowsHide: true, }); let stdout = ""; diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 9064a7a7e..416d69c88 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -2075,6 +2075,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record/dev/null 2>&1`], { encoding: "utf8" }); + const result = spawnSync("sh", ["-lc", `command -v ${command} >/dev/null 2>&1`], { + encoding: "utf8", + windowsHide: true, + }); return result.status === 0; } catch { return false; diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts index 5f5579a38..6e66d4751 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.test.ts @@ -7,12 +7,102 @@ import { commandLooksLikeDirectElectronLaunch, commandLooksLikePackageScriptLaunch, insertDebugFlagsIntoDirectElectronCommand, + resolveDirectElectronLaunch, + resolvePackageScriptElectronLaunch, rewritePackageScriptElectronLaunch, + shellQuote, } from "./appControlLaunchCommand"; const DEBUG_FLAGS = ["--remote-debugging-port=9222"]; describe("appControlLaunchCommand", () => { + it("resolves direct Windows Electron commands into argv and env without shell interpolation", () => { + const value = "C:\\Program Files\\ADE's $lane %TEMP% & café"; + expect(resolveDirectElectronLaunch( + `ADE_TEST="${value}" npx electron "C:\\Program Files\\My & App café"`, + DEBUG_FLAGS, + { platform: "win32" }, + )).toEqual({ + command: "npx", + args: ["electron", ...DEBUG_FLAGS, "C:\\Program Files\\My & App café"], + env: { ADE_TEST: value }, + commandForDisplay: expect.any(String), + }); + }); + + it("falls back to the configured shell for single-quoted Windows argv", () => { + expect(resolveDirectElectronLaunch( + "electron 'C:\\Program Files\\My App\\main.js'", + DEBUG_FLAGS, + { platform: "win32" }, + )).toBeNull(); + }); + + it("resolves package scripts into a direct local Electron invocation on Windows", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade app control $ % & café-")); + try { + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { + dev: "ADE_TEST=\"quoted $value %TEMP% & café\" electron \"app folder\"", + }, + }), "utf8"); + + const resolved = resolvePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32" }, + ); + expect(resolved).toEqual({ + command: path.join(projectRoot, "node_modules", ".bin", "electron.cmd"), + args: [...DEBUG_FLAGS, "app folder"], + cwd: projectRoot, + env: { ADE_TEST: "quoted $value %TEMP% & café" }, + commandForDisplay: expect.any(String), + }); + expect(resolved?.commandForDisplay).not.toContain("PATH="); + expect(resolved?.commandForDisplay).not.toContain("$PATH"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it("emits native PowerShell and cmd environment syntax for complex Windows script fallbacks", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade app control $ % & café-")); + try { + fs.writeFileSync(path.join(projectRoot, "package.json"), JSON.stringify({ + scripts: { + dev: "electron . && node post-launch.js", + }, + }), "utf8"); + + const powershell = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "powershell" }, + ); + expect(powershell).toContain("Set-Location -LiteralPath '"); + expect(powershell).toContain("$env:PATH = '"); + expect(powershell).toContain("' + $env:PATH;"); + expect(powershell).not.toContain(":$PATH"); + expect(powershell).not.toContain(" && "); + + const cmd = rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "win32", shell: "cmd" }, + ); + expect(cmd).toContain('cd /d "'); + expect(cmd).toContain('set "PATH='); + expect(cmd).toContain(';%PATH%" &&'); + expect(cmd).not.toContain(":$PATH"); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("detects direct Electron launches and injects debug flags after electron", () => { expect(commandLooksLikeDirectElectronLaunch("FOO=bar npx electron .")).toBe(true); @@ -30,8 +120,13 @@ describe("appControlLaunchCommand", () => { }), "utf8"); expect(commandLooksLikePackageScriptLaunch("npm run dev")).toBe(true); - expect(rewritePackageScriptElectronLaunch("npm run dev", DEBUG_FLAGS, projectRoot)) - .toBe(`PATH=${path.join(projectRoot, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`); + expect(rewritePackageScriptElectronLaunch( + "npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "linux" }, + )) + .toBe(`PATH=${shellQuote(path.join(projectRoot, "node_modules", ".bin"))}:$PATH electron --remote-debugging-port=9222 .`); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } @@ -64,8 +159,13 @@ describe("appControlLaunchCommand", () => { }, }), "utf8"); - expect(rewritePackageScriptElectronLaunch("cd apps/desktop && npm run dev", DEBUG_FLAGS, projectRoot)) - .toBe(`cd apps/desktop && PATH=${path.join(appDir, "node_modules", ".bin")}:$PATH electron --remote-debugging-port=9222 .`); + expect(rewritePackageScriptElectronLaunch( + "cd apps/desktop && npm run dev", + DEBUG_FLAGS, + projectRoot, + { platform: "linux" }, + )) + .toBe(`cd apps/desktop && PATH=${shellQuote(path.join(appDir, "node_modules", ".bin"))}:$PATH electron --remote-debugging-port=9222 .`); } finally { fs.rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts index efcd667ab..af146205d 100644 --- a/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts +++ b/apps/desktop/src/main/services/appControl/appControlLaunchCommand.ts @@ -1,5 +1,23 @@ import fs from "node:fs"; import path from "node:path"; +import { commandArrayToLine, parseCommandLine } from "../../../shared/shell"; + +export type AppControlDirectLaunch = { + command: string; + args: string[]; + commandForDisplay: string; + env?: Record; +}; + +export type AppControlPackageLaunch = AppControlDirectLaunch & { + cwd: string; +}; + +type WindowsShell = "powershell" | "cmd"; +type LaunchOptions = { + platform?: NodeJS.Platform; + shell?: WindowsShell; +}; export function shellQuote(value: string): string { if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value; @@ -37,6 +55,120 @@ export function insertDebugFlagsIntoDirectElectronCommand(command: string, debug ); } +function takeLeadingEnv(input: string): { env: Record; rest: string } { + const env: Record = {}; + let rest = input.trim(); + const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|]+))(?:\s+|$)/; + while (rest) { + const match = rest.match(assignment); + if (!match) break; + env[match[1]!] = match[2] ?? match[3] ?? match[4] ?? ""; + rest = rest.slice(match[0].length).trimStart(); + } + return { env, rest }; +} + +export function resolveDirectElectronLaunch( + command: string, + debugFlags: string[], + options: LaunchOptions = {}, +): AppControlDirectLaunch | null { + const platform = options.platform ?? process.platform; + const { env, rest } = takeLeadingEnv(command); + // Windows' CRT argv rules do not recognize PowerShell-style single quotes. + // Accepting them here would silently split paths containing spaces; leave + // those commands to the selected shell, which owns their quoting semantics. + if (platform === "win32" && rest.includes("'")) return null; + let argv: string[]; + try { + argv = parseCommandLine(rest, { platform }); + } catch { + return null; + } + if (argv.some((arg) => arg === "&&" || arg === "||" || arg === ";" || arg === "|")) { + return null; + } + + const usesNpx = argv[0]?.toLowerCase() === "npx" && argv[1]?.toLowerCase() === "electron"; + const directElectron = argv[0]?.toLowerCase() === "electron" || argv[0]?.toLowerCase() === "electron.exe"; + if (!usesNpx && !directElectron) return null; + + const executable = argv[0]!; + const prefixArgs = usesNpx ? [argv[1]!] : []; + const appArgs = argv.slice(usesNpx ? 2 : 1); + const args = [...prefixArgs, ...debugFlags, ...appArgs]; + return { + command: executable, + args, + commandForDisplay: commandArrayToLine([executable, ...args], { platform }), + ...(Object.keys(env).length ? { env } : {}), + }; +} + +function packageScriptMatch(command: string): RegExpMatchArray | null { + return command.trim().match( + /^(?.*?)(?(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s;&|]+)\s+)*)(?npm|pnpm|yarn|bun)\s+(?:run\s+)?(?