diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e892af225..c87a4799c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -459,6 +459,18 @@ jobs: - name: Test Windows sync-port holder identification run: cd apps/ade-cli && npx vitest run src/services/sync/sharedSyncListener.test.ts + # cli.test.ts carries the win32-gated headless-RPC named-pipe case. It is + # green only once this layer's CLI fixes compose, so the step lives here + # rather than in the foundation layer. + - name: Test Windows CLI contracts + run: cd apps/ade-cli && npx vitest run src/cli.test.ts + + # Daemon supervision, restart, and version/role-compatibility. Spawns real + # `ade serve` daemons over the platform transport, so this is the only gate + # that exercises the always-on-brain contract on a named pipe. + - name: Test Windows stdio RPC daemon bridge contracts + run: cd apps/ade-cli && npx vitest run src/stdioRpcDaemon.test.ts + # `trustedWindowsTools` is a security control whose only substantive case # is win32-gated, so before this step it ran on no runner at all. The # credential store and the `ade://` deeplink command-injection guard are diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 9289f6e7d..fbadc33a3 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2246,31 +2246,37 @@ describe("adeRpcServer", () => { }); expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( - expect.objectContaining({ - laneId: "lane-1", - cols: 120, - rows: 36, - tracked: true, - toolType: "claude-orchestrated", - command: claudePath, - args: expect.arrayContaining(["--model", "claude-sonnet-5", "--permission-mode", "default"]), - env: expect.objectContaining({ - ADE_DEFAULT_ROLE: "agent", - }), - }) - ); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(expect.objectContaining({ + laneId: "lane-1", + cols: 120, + rows: 36, + tracked: true, + toolType: "claude-orchestrated", + env: expect.objectContaining({ ADE_DEFAULT_ROLE: "agent" }), + })); + const createCall = (fixture.runtime.ptyService.create as ReturnType).mock.calls[0]?.[0] as { + command?: string; + args?: string[]; + startupCommand?: string; + }; + // Provider resolution is platform-native on every OS: POSIX goes through + // `command -v`, Windows through `where.exe` (which honours PATHEXT, hence + // the `.cmd` fixture). A resolved provider always becomes a direct + // command/args launch so worker identity rides the process env instead of + // a POSIX-only `VAR=value cmd` prefix. + expect(createCall.command).toBe(claudePath); + expect(createCall.args).toEqual(expect.arrayContaining(["--model", "claude-sonnet-5", "--permission-mode", "default"])); + expect(createCall.startupCommand).toContain("claude --model claude-sonnet-5 --permission-mode default"); // The final arg concatenates ADE_CLI_INLINE_GUIDANCE with the user prompt; assert // it ends with the user prompt and carries the inline guidance preamble. - const createCall = (fixture.runtime.ptyService.create as ReturnType).mock.calls[0]?.[0] as { args: string[] }; - const finalArg = createCall.args[createCall.args.length - 1]; - expect(finalArg).toContain("CLI controls ADE state"); - expect(finalArg).toContain("PRs, proof, apps"); - expect(finalArg).toContain("clean up started processes"); - expect(finalArg).toContain("ade chat note"); - expect(finalArg).toContain("ade chat ask"); - expect(finalArg).toContain("You cannot settle or unsettle a session"); - expect(finalArg.endsWith("Implement API wiring")).toBe(true); + const launchText = createCall.args?.at(-1) ?? createCall.startupCommand ?? ""; + expect(launchText).toContain("CLI controls ADE state"); + expect(launchText).toContain("PRs, proof, apps"); + expect(launchText).toContain("clean up started processes"); + expect(launchText).toContain("ade chat note"); + expect(launchText).toContain("ade chat ask"); + expect(launchText).toContain("You cannot settle or unsettle a session"); + expect(launchText).toContain("Implement API wiring"); expect(response.structuredContent.startupCommand).toContain("claude"); expect(response.structuredContent.startupCommand).toContain("--model"); expect(response.structuredContent.startupCommand).toContain("--permission-mode"); @@ -2297,12 +2303,12 @@ describe("adeRpcServer", () => { expect(response?.isError).toBeUndefined(); const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { args?: string[]; startupCommand?: string }; - expect(createCall.args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"])); - const finalArg = createCall.args?.at(-1) ?? ""; - expect(finalArg).toContain("ade chat note"); - expect(finalArg).toContain("ade chat ask"); - expect(finalArg).toContain("You cannot settle or unsettle a session"); - expect(createCall.args).not.toContain("--full-auto"); + const launchText = createCall.args?.join(" ") ?? createCall.startupCommand ?? ""; + expect(launchText).toContain("--sandbox workspace-write --ask-for-approval on-request"); + expect(launchText).toContain("ade chat note"); + expect(launchText).toContain("ade chat ask"); + expect(launchText).toContain("You cannot settle or unsettle a session"); + expect(launchText).not.toContain("--full-auto"); expect(createCall.startupCommand).toContain("--sandbox workspace-write --ask-for-approval on-request"); expect(createCall.startupCommand).not.toContain("--full-auto"); }); @@ -2520,22 +2526,30 @@ describe("adeRpcServer", () => { }); expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( - expect.objectContaining({ - laneId: "lane-1", - title: "Shell", - toolType: "shell", - command: "/bin/zsh", - args: ["-f"], - env: { ZDOTDIR: "/var/empty" }, - }), - ); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith(expect.objectContaining( + process.platform === "win32" + ? { + laneId: "lane-1", + title: "Shell", + toolType: "shell", + command: "powershell.exe", + args: ["-NoLogo", "-NoProfile"], + } + : { + laneId: "lane-1", + title: "Shell", + toolType: "shell", + command: "/bin/zsh", + args: ["-f"], + env: { ZDOTDIR: "/var/empty" }, + }, + )); }); it("starts Codex spawn_agent with current default permission flags", async () => { const fixture = createRuntime(); const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-bin-")); - createFakePathExecutable(binDir, "codex"); + const codexPath = createFakePathExecutable(binDir, "codex"); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); const response = await withEnv({ PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, SHELL: "/bin/sh" }, async () => { @@ -2548,13 +2562,14 @@ describe("adeRpcServer", () => { }); expect(response?.isError).toBeUndefined(); - expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( - expect.objectContaining({ - command: expect.stringMatching(/codex$/), - args: expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"]), - startupCommand: expect.stringContaining("codex --sandbox workspace-write --ask-for-approval on-request"), - }), - ); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { + command?: string; + args?: string[]; + startupCommand?: string; + }; + expect(createCall.startupCommand).toContain("codex --sandbox workspace-write --ask-for-approval on-request"); + expect(createCall.command).toBe(codexPath); + expect(createCall.args).toEqual(expect.arrayContaining(["--sandbox", "workspace-write", "--ask-for-approval", "on-request"])); expect(response.structuredContent.startupCommand).not.toContain("--full-auto"); }); @@ -2793,11 +2808,15 @@ describe("adeRpcServer", () => { expect(response?.isError).toBeUndefined(); expect(response.structuredContent.startupCommand).toContain("claude"); - expect(response.structuredContent.startupCommand).toContain("ADE_RUN_ID=run-1"); - expect(response.structuredContent.startupCommand).toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + if (process.platform === "win32") { + expect(response.structuredContent.startupCommand).not.toContain("ADE_RUN_ID=run-1"); + expect(response.structuredContent.startupCommand).not.toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + } else { + expect(response.structuredContent.startupCommand).toContain("ADE_RUN_ID=run-1"); + expect(response.structuredContent.startupCommand).toContain("ADE_ATTEMPT_ID=attempt-workspace-roots"); + } expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( expect.objectContaining({ - command: claudePath, env: expect.objectContaining({ ADE_RUN_ID: "run-1", ADE_ATTEMPT_ID: "attempt-workspace-roots", @@ -2805,6 +2824,8 @@ describe("adeRpcServer", () => { }), }) ); + const createCall = fixture.runtime.ptyService.create.mock.calls[0]?.[0] as { command?: string }; + expect(createCall.command).toBe(claudePath); }); it("keeps spawn_agent on shell startup when the provider executable cannot be resolved", async () => { @@ -2946,7 +2967,7 @@ describe("adeRpcServer", () => { expect(response.structuredContent.startupCommand).toContain("CLI controls ADE state"); const contextPath = response.structuredContent.contextRef?.path as string | null; expect(contextPath).toBeTruthy(); - expect(contextPath?.includes("/.ade/cache/orchestrator/agent-context/run-123/")).toBe(true); + expect(contextPath?.replace(/\\/g, "/")).toContain("/.ade/cache/orchestrator/agent-context/run-123/"); if (!contextPath) { throw new Error("Expected context manifest path"); } diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index d8f022ccc..dec23e49d 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 @@ -3507,6 +3508,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..f9bded5fe 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, @@ -42,9 +43,12 @@ import { DEVELOPMENT_ADE_CLERK_ISSUER, DEVELOPMENT_ADE_CLERK_OAUTH_CLIENT_ID, } from "../../desktop/src/shared/accountDirectory"; +import { isAdeRuntimeNamedPipePath } from "../../desktop/src/shared/adeRuntimeIpc"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; 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 +478,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 +540,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 +792,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 () => { @@ -874,6 +896,42 @@ describe("ADE CLI", () => { expect(isEphemeralRuntimeSocketPath("tcp://127.0.0.1:8765")).toBe(false); }); + // Only a Windows runner can exercise this: `resolveMachineAdeLayout` yields a + // named pipe there and a filesystem socket everywhere else, so off win32 + // there is no pipe endpoint to classify. Gated by the "Test Windows CLI + // contracts" step, which already runs this file natively. + (process.platform === "win32" ? it : it.skip)( + "classifies a scratch-home named pipe as ephemeral", + () => { + const scratchHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); + try { + withEnv({ ADE_HOME: scratchHome }, () => { + const scratchPipe = resolveMachineAdeLayout().socketPath; + expect(isAdeRuntimeNamedPipePath(scratchPipe)).toBe(true); + expect(isEphemeralRuntimeSocketPath(scratchPipe)).toBe(true); + // Win32 treats `/` and `\` interchangeably in a pipe path and matches + // pipe names case-insensitively, so every spelling of this endpoint + // has to classify the same way. + expect( + isEphemeralRuntimeSocketPath(scratchPipe.replace(/\\/g, "/").toUpperCase()), + ).toBe(true); + // A pipe that is not this home's own endpoint stays non-ephemeral, + // so the scratch-home check cannot leak onto a real machine brain. + expect( + isEphemeralRuntimeSocketPath("\\\\.\\pipe\\ade-runtime-stable-0123456789abcdef"), + ).toBe(false); + }); + withEnv({ ADE_HOME: path.join(os.homedir(), ".ade") }, () => { + expect( + isEphemeralRuntimeSocketPath(resolveMachineAdeLayout().socketPath), + ).toBe(false); + }); + } finally { + fs.rmSync(scratchHome, { recursive: true, force: true }); + } + }, + ); + it("blocks manual service-socket runtime spawn when service mutation is disabled", () => { expect(shouldBlockManualMachineRuntimeSpawn("/Users/example/.ade-beta/sock/ade.sock", { ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", @@ -882,6 +940,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 +5969,50 @@ 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("does not reuse a Stable executable when ADE Beta was requested", () => { + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-windows-beta-desktop-")); + const stableRoot = path.join(installRoot, "Programs", "ADE"); + const stablePath = path.join(stableRoot, "ADE.exe"); + const stableEntryPath = path.join(stableRoot, "resources", "ade-cli", "cli.cjs"); + const betaPath = path.join(installRoot, "Programs", "ADE Beta", "ADE Beta.exe"); + fs.mkdirSync(path.dirname(stableEntryPath), { recursive: true }); + fs.writeFileSync(stablePath, ""); + fs.writeFileSync(stableEntryPath, ""); + fs.mkdirSync(path.dirname(betaPath), { recursive: true }); + fs.writeFileSync(betaPath, ""); + + try { + expect(resolveWindowsDesktopExecutable({ + appName: "ADE Beta", + env: { LOCALAPPDATA: installRoot }, + execPath: stablePath, + entryPath: stableEntryPath, + })).toBe(betaPath); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }); + it("renders a compact lane graph", () => { const graph = renderLaneGraph({ lanes: [ @@ -6064,7 +6169,7 @@ describe("ADE CLI", () => { kind: "screenshot", title: "Checkout complete", description: "Checkout complete", - path: "/tmp/done.png", + path: path.resolve("/tmp/done.png"), }, ], }, @@ -6823,8 +6928,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 +6970,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 2633aaf83..0d7b90370 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -32,6 +32,7 @@ export { readInstalledDesktopVersion } from "./commands/doctor"; import { buildDeeplink, type DeeplinkEnvelope } from "../../desktop/src/shared/deeplinks"; import { buildPairingQrPayload } from "../../desktop/src/shared/pairingQr"; import { buildWebClientPairUrl } from "../../desktop/src/shared/webClientUrl"; +import { CURSOR_CLI_EXECUTABLES } from "../../desktop/src/shared/providerCliExecutables"; import { accountMachineDisplayName, accountMachineConnectionState, @@ -56,7 +57,7 @@ import type { ListMyGitHubReposInput, ProjectBrowseInput, } from "../../desktop/src/shared/types/core"; -import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; +import { resolveMachineAdeDir, resolveMachineAdeLayout } from "./services/projects/machineLayout"; import { markActiveHostProjectOpen } from "./services/projects/projectCatalog"; import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver"; import type { ProjectRecord } from "./services/projects/projectRegistry"; @@ -133,7 +134,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, @@ -540,6 +544,7 @@ function maybeRunBuiltCliFallback( cwd: CLI_PACKAGE_ROOT, env: process.env, encoding: "utf8", + windowsHide: true, }); if (buildResult.error || buildResult.status !== 0 || !isBuiltCliFresh()) { error.details.nextAction = @@ -559,6 +564,7 @@ function maybeRunBuiltCliFallback( [SOURCE_FALLBACK_ENV]: "1", }, encoding: "utf8", + windowsHide: true, }); if (rerun.error) { error.details.nextAction = @@ -1128,7 +1134,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 @@ -1198,7 +1204,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} @@ -2866,6 +2872,7 @@ function detectUnmergedLaneCreateNudge( cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, }), ): string | null { const cwd = args.cwd ?? process.cwd(); @@ -12550,6 +12557,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); @@ -12586,6 +12594,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; } @@ -12724,6 +12733,7 @@ function runLocalCommand( encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000, + windowsHide: true, }); return { ok: result.status === 0, @@ -12887,7 +12897,7 @@ function checkProviderReadiness(value: unknown): ReadinessCheck { claude: commandExists("claude"), codex: commandExists("codex"), opencode: commandExists("opencode"), - cursor: commandExists("agent") || commandExists("cursor-agent"), + cursor: CURSOR_CLI_EXECUTABLES.launchCandidates.some((command) => commandExists(command)), droid: commandExists("droid"), }; const apiKeyProviders = Object.keys(apiKeys).filter((key) => @@ -13687,12 +13697,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; @@ -13707,7 +13719,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)) { @@ -14683,23 +14695,71 @@ function normalizeRuntimeSocketPath(rawSocketPath: string): string { : path.resolve(rawSocketPath); } -function isEphemeralRuntimeSocketPath(socketPath: string): boolean { - if (socketPath.startsWith("tcp://") || isAdeRuntimeNamedPipePath(socketPath)) { - return false; - } - const normalizedSocketPath = path.resolve(socketPath); +/** + * The `ade--XXXXXX` naming convention every throwaway ADE brain already + * follows for its scratch directory under the system temp dir. + */ +const EPHEMERAL_RUNTIME_SCRATCH_PATTERN = + /(^|[/\\])ade-(stdio-rpc|code|local-runtime)[^/\\]*/; + +function isEphemeralRuntimeScratchPath(candidate: string): boolean { + const normalizedPath = path.resolve(candidate); const tmpDirs = Array.from(new Set( [os.tmpdir(), realpathSyncSafe(os.tmpdir()), "/tmp", realpathSyncSafe("/tmp")] .map((dir) => path.resolve(dir)), )); for (const tmpDir of tmpDirs) { - const relativeToTmp = path.relative(tmpDir, normalizedSocketPath); + const relativeToTmp = path.relative(tmpDir, normalizedPath); if (relativeToTmp.startsWith("..") || path.isAbsolute(relativeToTmp)) continue; - return /(^|[/\\])ade-(stdio-rpc|code|local-runtime)[^/\\]*/.test(relativeToTmp); + return EPHEMERAL_RUNTIME_SCRATCH_PATTERN.test(relativeToTmp); } return false; } +/** + * Whether this endpoint belongs to a throwaway brain rather than the machine's + * real one. An ephemeral brain is spawned with `--no-sync` and an idle-exit + * budget, and is excluded from runtime-service repair. + * + * On macOS/Linux the endpoint is `/sock/ade.sock`, so inspecting the + * socket path answers the question directly. + * + * Windows has no filesystem socket to inspect: the machine endpoint is a named + * pipe whose name is a hash of ADE_HOME, so there is no path to match and this + * used to return `false` for every pipe. What the POSIX branch is really asking + * is "does this brain belong to a scratch ADE_HOME under the temp dir", since + * the socket always lives inside that home — so on Windows we ask that question + * of the home itself, and confirm the endpoint is the pipe that home derives. + * + * Without it every Windows scratch brain was misread as the real, + * service-managed machine brain: it was spawned WITH mobile sync and so lost + * the singleton race against the user's actual brain (which the sync loop + * treats as fatal before the RPC socket is ever bound), it never idle-exited, + * and under a packaged Electron CLI it was eligible to trigger repair of the + * installed runtime service. + */ +function isEphemeralRuntimeSocketPath(socketPath: string): boolean { + if (socketPath.startsWith("tcp://")) return false; + if (isAdeRuntimeNamedPipePath(socketPath)) { + if (!isEphemeralRuntimeScratchPath(resolveMachineAdeDir())) return false; + return namedPipeComparisonKey(resolveMachineAdeLayout().socketPath) + === namedPipeComparisonKey(socketPath); + } + return isEphemeralRuntimeScratchPath(socketPath); +} + +/** + * Collapse the equivalent spellings of one Windows named pipe onto a single + * comparison key: Win32 accepts `/` and `\` interchangeably in a pipe path and + * matches pipe names case-insensitively. Mirrors the identically named helper + * in the desktop local runtime pool. + * + * This is a comparison key ONLY — never an address to connect to or listen on. + */ +function namedPipeComparisonKey(socketPath: string): string { + return socketPath.trim().replace(/\//g, "\\").toLowerCase(); +} + function realpathSyncSafe(filePath: string): string { try { return fs.realpathSync.native(filePath); @@ -14953,8 +15013,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); } @@ -14963,9 +15026,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 { @@ -15102,6 +15163,7 @@ async function spawnMachineRuntimeDaemon( detached: true, stdio: "ignore", env, + windowsHide: true, }); child.once("error", () => {}); if (child.pid != null) recordRuntimeSpawn(socketPath, child.pid); @@ -15528,6 +15590,99 @@ 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 currentExecutableMatchesRequest = + execBaseName.toLowerCase() === executableName.toLowerCase(); + const candidates: Array = [ + env.ADE_DESKTOP_APP_PATH?.trim() || null, + currentExecutableMatchesRequest + ? execPath + : null, + entryPath + ? path.resolve(path.dirname(entryPath), "..", "..", executableName) + : 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"; @@ -15553,12 +15708,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.", }; } @@ -15659,7 +15828,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) { @@ -16206,7 +16377,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.test.ts b/apps/ade-cli/src/commands/deeplinks.test.ts index 864bf6d2b..48ee146af 100644 --- a/apps/ade-cli/src/commands/deeplinks.test.ts +++ b/apps/ade-cli/src/commands/deeplinks.test.ts @@ -1,10 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CliDeeplinkUsageError, + openUrlViaOs, runDeeplinkCommand, runDeeplinkCommandAsync, runLinearInstall, @@ -12,8 +14,33 @@ import { runOpenCommand, } from "./deeplinks"; +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(() => ({ status: 0, signal: null, error: undefined })), +})); + const UUID = "550e8400-e29b-41d4-a716-446655440000"; +describe("openUrlViaOs", () => { + it.runIf(process.platform === "win32")( + "passes Windows URLs containing shell metacharacters as one opaque argv value", + () => { + const url = 'https://accounts.google.com/o/oauth2/auth?client=a b&percent=%PATH%"e="yes"&meta=^|<>()!'; + + expect(openUrlViaOs(url)).toEqual({ failed: false, message: "" }); + expect(spawnSync).toHaveBeenCalledWith( + "rundll32.exe", + ["url.dll,FileProtocolHandler", url], + { + shell: false, + stdio: "ignore", + timeout: 10_000, + windowsHide: true, + }, + ); + }, + ); +}); + describe("ade link", () => { it("emits an https lane link by default", () => { const r = runLinkCommand(["lane", UUID, "--no-clipboard"]); diff --git a/apps/ade-cli/src/commands/deeplinks.ts b/apps/ade-cli/src/commands/deeplinks.ts index 8a16c25d3..07c7aeabe 100644 --- a/apps/ade-cli/src/commands/deeplinks.ts +++ b/apps/ade-cli/src/commands/deeplinks.ts @@ -189,14 +189,23 @@ export function openUrlViaOs(url: string): { failed: boolean; message: string } cmd = "open"; args = [url]; } else if (platform === "win32") { - cmd = "cmd"; - args = ["/c", "start", "", url]; + // Pass the URL as one argv value to a native Windows protocol handler. + // OAuth URLs routinely contain cmd.exe metacharacters such as `&` and `%`; + // routing them through `cmd /c start` can split or expand the URL even when + // Node itself was spawned with shell:false. + cmd = "rundll32.exe"; + args = ["url.dll,FileProtocolHandler", url]; } else { cmd = "xdg-open"; 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, + shell: false, + }); 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.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 2bc68ad2b..e64e14e87 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -310,7 +310,7 @@ describe("headlessLinearServices", () => { } }); - it("coalesces concurrent forced GitHub status lookups", async () => { + it("coalesces concurrent forced GitHub status lookups and lets ordinary callers join", async () => { const previousAdeHome = process.env.ADE_HOME; process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-status-coalesce-")); let resolveResponse: ((response: Response) => void) | undefined; @@ -325,10 +325,15 @@ describe("headlessLinearServices", () => { ); try { githubService.setToken("ghp_test_token"); - const lookups = Array.from( - { length: 16 }, - () => githubService.getStatus({ forceRefresh: true }), - ); + const firstForcedLookup = githubService.getStatus({ forceRefresh: true }); + const lookups = [ + firstForcedLookup, + githubService.getStatus(), + ...Array.from( + { length: 14 }, + () => githubService.getStatus({ forceRefresh: true }), + ), + ]; await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); // The callers independently resolve the repository and credential inventory // before joining the shared HTTP probe. Keep that probe pending long enough @@ -354,6 +359,48 @@ describe("headlessLinearServices", () => { } }); + it("does not let a forced GitHub status lookup join an older ordinary lookup", async () => { + const previousAdeHome = process.env.ADE_HOME; + const previousFetch = globalThis.fetch; + process.env.ADE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ade-headless-github-status-force-order-")); + const responseResolvers: Array<(response: Response) => void> = []; + const fetchImpl = vi.fn(async () => await new Promise((resolve) => { + responseResolvers.push(resolve); + })) as unknown as typeof fetch; + globalThis.fetch = fetchImpl; + const githubService = createHeadlessGitHubService( + "/tmp/ade-project", + { debug() {}, info() {}, warn() {}, error() {} } as any, + ); + const responseFor = (login: string): Response => new Response(JSON.stringify({ login }), { + status: 200, + headers: { + "content-type": "application/json", + "x-oauth-scopes": "repo, workflow", + }, + }); + + try { + githubService.setToken("ghp_test_token"); + const ordinaryLookup = githubService.getStatus(); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(1)); + + const forcedLookup = githubService.getStatus({ forceRefresh: true }); + await vi.waitFor(() => expect(fetchImpl).toHaveBeenCalledTimes(2)); + responseResolvers[1]?.(responseFor("forced-user")); + await expect(forcedLookup).resolves.toMatchObject({ userLogin: "forced-user" }); + + responseResolvers[0]?.(responseFor("ordinary-user")); + await expect(ordinaryLookup).resolves.toMatchObject({ userLogin: "ordinary-user" }); + await expect(githubService.getStatus()).resolves.toMatchObject({ userLogin: "forced-user" }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + globalThis.fetch = previousFetch; + if (previousAdeHome == null) delete process.env.ADE_HOME; + else process.env.ADE_HOME = previousAdeHome; + } + }); + it("creates secret gists through the headless GitHub service", async () => { const previousAdeHome = process.env.ADE_HOME; const previousFetch = globalThis.fetch; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index f3e74df48..75e81d56f 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) { @@ -677,6 +679,10 @@ export function createHeadlessGitHubService( binding: string; promise: Promise; } | null = null; + let forcedStatusLookupInFlight: { + generation: number; + promise: Promise; + } | null = null; const invalidateStatusCache = (): void => { cachedStatus = null; @@ -1586,255 +1592,113 @@ export function createHeadlessGitHubService( }); }; - service = { - verifyStoredPat, - async getStatus(opts: { forceRefresh?: boolean } = {}) { - if (opts.forceRefresh) { - invalidateStatusCache(); - } - const [origin, inventory] = await Promise.all([ - readGitOriginAsync(projectRoot), - readCredentialInventoryAsync(), - ]); - const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); - const hasOrigin = Boolean(origin); - const inventoryFailuresBySource = new Map( - inventory.failures.map((failure) => [failure.source, failure] as const), - ); - const binding = `${githubCredentialInventoryKey(inventory.candidates)}:${repo?.owner ?? ""}/${repo?.name ?? ""}`; - const now = Date.now(); - if ( - !opts.forceRefresh - && cachedStatus - && cachedStatusBinding === binding - && now - cachedAt < 30_000 - ) { - const cachedReadCandidate = cachedStatus.authSource === "none" - ? null - : inventory.candidates.find( - (candidate) => candidate.source === cachedStatus?.authSource, - ) ?? null; - const cachedWriteSource = cachedStatus.writeAuthSource - && cachedStatus.writeAuthSource !== "none" - ? cachedStatus.writeAuthSource - : null; - const cachedWriteCandidate = cachedWriteSource == null - ? null - : inventory.candidates.find((candidate) => candidate.source === cachedWriteSource) ?? null; - const cachedReadUnavailable = cachedStatus.authSource !== "none" - && (!cachedReadCandidate || githubCredentialCooldown( - cachedReadCandidate, - now, - { resource: "core" }, - ) != null); - const cachedWriteUnavailable = cachedWriteSource != null - && (!cachedWriteCandidate || githubCredentialCooldown( - cachedWriteCandidate, - now, - { resource: "core" }, - ) != null); - if (!cachedReadUnavailable && !cachedWriteUnavailable) { - const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); - const pauseUntilMs = githubBackgroundRequestPauseUntilMs(now, readCandidates); - return { - ...cachedStatus, - repo, - hasOrigin, - patTokenStored: inventory.patTokenStored, - ghCliPath: inventory.ghCliPath ?? cachedStatus.ghCliPath, - ghAuthError: inventory.ghAuthError, - credentialStates: githubCredentialStates({ - candidates: inventory.candidates, - availableSources: inventory.availableSources, - sourceFailures: inventoryFailuresBySource, - activeReadSource: cachedStatus.authSource === "none" - ? null - : cachedStatus.authSource, - activeWriteSource: cachedWriteSource, - }), - backgroundRefreshPausedUntil: pauseUntilMs == null - ? null - : new Date(pauseUntilMs).toISOString(), - }; - } - cachedStatus = null; - cachedAt = 0; - cachedStatusBinding = null; - } - const generation = statusLookupGeneration; - if ( - statusLookupInFlight?.generation === generation - && statusLookupInFlight.binding === binding - ) { - return await statusLookupInFlight.promise; - } - - const lookup = (async (): Promise => { + const performStatusLookup = async ( + forceRefresh: boolean, + generation: number, + ): Promise => { + const [origin, inventory] = await Promise.all([ + readGitOriginAsync(projectRoot), + readCredentialInventoryAsync(), + ]); + const repo = parseGitHubRepoFromRemoteUrl(origin ?? ""); + const hasOrigin = Boolean(origin); + const inventoryFailuresBySource = new Map( + inventory.failures.map((failure) => [failure.source, failure] as const), + ); + const binding = `${githubCredentialInventoryKey(inventory.candidates)}:${repo?.owner ?? ""}/${repo?.name ?? ""}`; + const now = Date.now(); + if ( + !forceRefresh + && cachedStatus + && cachedStatusBinding === binding + && now - cachedAt < 30_000 + ) { + const cachedReadCandidate = cachedStatus.authSource === "none" + ? null + : inventory.candidates.find( + (candidate) => candidate.source === cachedStatus?.authSource, + ) ?? null; + const cachedWriteSource = cachedStatus.writeAuthSource + && cachedStatus.writeAuthSource !== "none" + ? cachedStatus.writeAuthSource + : null; + const cachedWriteCandidate = cachedWriteSource == null + ? null + : inventory.candidates.find((candidate) => candidate.source === cachedWriteSource) ?? null; + const cachedReadUnavailable = cachedStatus.authSource !== "none" + && (!cachedReadCandidate || githubCredentialCooldown( + cachedReadCandidate, + now, + { resource: "core" }, + ) != null); + const cachedWriteUnavailable = cachedWriteSource != null + && (!cachedWriteCandidate || githubCredentialCooldown( + cachedWriteCandidate, + now, + { resource: "core" }, + ) != null); + if (!cachedReadUnavailable && !cachedWriteUnavailable) { const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); - const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); - const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => opts.forceRefresh === true - ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) - : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); - const primaryCandidate = readCandidates[0] ?? null; - if (!primaryCandidate) { - const failure = inventory.failures[0] ?? null; - return { - tokenStored: inventory.appTokenStored, - patTokenStored: inventory.patTokenStored, - tokenDecryptionFailed, - storageScope: "app", - authSource: failure?.source ?? "none", - writeAuthSource: "none", - tokenType: "unknown", - repo, - hasOrigin, - userLogin: null, - scopes: [], - ghCliPath: inventory.ghCliPath, - ghAuthError: inventory.ghAuthError, - checkedAt: null, - authFailure: failure?.authFailure ?? null, - rateLimit: failure?.rateLimit ?? null, - credentialStates: githubCredentialStates({ - candidates: inventory.candidates, - availableSources: inventory.availableSources, - sourceFailures: inventoryFailuresBySource, - activeReadSource: null, - activeWriteSource: null, - }), - credentialFallback: null, - backgroundRefreshPausedUntil: null, - repoAccessOk: null, - repoAccessError: null, - connected: false, - }; - } - - const { active, activeWrite, failures } = await resolveGithubStatusCredentials({ - readCandidates, - writeCandidates, - cooldown: statusCooldown, - probe: (candidate) => probeCandidate(candidate, repo, opts.forceRefresh === true), - capabilities: (candidate, value) => validatedCredentialCapabilities( - candidate, - value, - repo, - ), - isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" - && result.value?.repoAccessOk === false, - onAuthenticatedProbe: (candidate, value) => { - registerGithubCredentialIdentity(candidate, value.validated.userLogin); - }, - onUsableProbe: (candidate, value) => { - recordGithubCredentialProbeSuccess( - candidate, - value.validated.rateLimit, - value.validated.userLogin, - ); - }, - onRejectedProbe: (candidate, result, context) => { - if (!context.repositoryAccessFailure) { - recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); - } - if (context.phase === "read") { - logger.warn("github.token_validation_failed", { - source: candidate.source, - error: result.error, - kind: result.authFailure.kind, - retryAt: result.authFailure.retryAt, - }); - } - }, - }); - const activeWriteSource = activeWrite?.source ?? null; - const credentialFailures = [ - ...inventory.failures, - ...failures.map((failure) => ({ - source: failure.candidate.source, - authFailure: failure.authFailure, - rateLimit: failure.rateLimit, - })), - ]; - const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); - if (active) { - const { candidate, value } = active; - const { validated, repoAccessOk, repoAccessError } = value; - const failuresBySource = new Map( - credentialFailures.map((failure) => [failure.source, failure] as const), - ); - const activePrecedenceIndex = githubOperationCredentialPrecedence("read") - .indexOf(candidate.source); - const fallbackFailure = githubOperationCredentialPrecedence("read") - .slice(0, activePrecedenceIndex) - .map((source) => failuresBySource.get(source) ?? null) - .find((failure) => failure != null) ?? null; - return { - tokenStored: true, - patTokenStored: inventory.patTokenStored, - tokenDecryptionFailed: false, - storageScope: "app", - authSource: candidate.source, - writeAuthSource: activeWriteSource ?? "none", - writeUserLogin: activeWrite?.value.validated.userLogin ?? null, - tokenType: validated.tokenType, - repo, - hasOrigin, - userLogin: validated.userLogin, - scopes: validated.scopes, - ghCliPath: inventory.ghCliPath, - ghAuthError: inventory.ghAuthError, - checkedAt: new Date(now).toISOString(), - authFailure: null, - rateLimit: validated.rateLimit, - credentialStates: githubCredentialStates({ - candidates: inventory.candidates, - availableSources: inventory.availableSources, - sourceFailures: inventoryFailuresBySource, - activeReadSource: candidate.source, - activeWriteSource, - }), - credentialFallback: fallbackFailure - ? { - capability: "read", - fromSource: fallbackFailure.source, - toSource: candidate.source, - reason: fallbackFailure.authFailure.kind, - retryAt: fallbackFailure.authFailure.retryAt, - } - : null, - backgroundRefreshPausedUntil: pauseUntilMs == null + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(now, readCandidates); + return { + ...cachedStatus, + repo, + hasOrigin, + patTokenStored: inventory.patTokenStored, + ghCliPath: inventory.ghCliPath ?? cachedStatus.ghCliPath, + ghAuthError: inventory.ghAuthError, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: cachedStatus.authSource === "none" ? null - : new Date(pauseUntilMs).toISOString(), - repoAccessOk, - repoAccessError, - connected: validatedCredentialCapabilities(candidate, value, repo).read, - }; - } + : cachedStatus.authSource, + activeWriteSource: cachedWriteSource, + }), + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + }; + } + cachedStatus = null; + cachedAt = 0; + cachedStatusBinding = null; + } + if ( + !forceRefresh + && statusLookupInFlight?.generation === generation + && statusLookupInFlight.binding === binding + ) { + return await statusLookupInFlight.promise; + } - const failure = credentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") - ?? credentialFailures[0] - ?? { - source: primaryCandidate.source, - authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, - rateLimit: null, - }; + const lookup = (async (): Promise => { + const readCandidates = githubOperationCredentialCandidates(inventory.candidates, "read"); + const writeCandidates = githubOperationCredentialCandidates(inventory.candidates, "write"); + const statusCooldown = (candidate: HeadlessGitHubTokenCandidate) => forceRefresh + ? githubCredentialRateLimitCooldown(candidate, Date.now(), { resource: "core" }) + : githubCredentialCooldown(candidate, Date.now(), { resource: "core" }); + const primaryCandidate = readCandidates[0] ?? null; + if (!primaryCandidate) { + const failure = inventory.failures[0] ?? null; return { - tokenStored: true, + tokenStored: inventory.appTokenStored, patTokenStored: inventory.patTokenStored, - tokenDecryptionFailed: false, + tokenDecryptionFailed, storageScope: "app", - authSource: primaryCandidate.source, + authSource: failure?.source ?? "none", writeAuthSource: "none", - tokenType: getTokenType(primaryCandidate.token), + tokenType: "unknown", repo, hasOrigin, userLogin: null, scopes: [], ghCliPath: inventory.ghCliPath, ghAuthError: inventory.ghAuthError, - checkedAt: new Date(now).toISOString(), - authFailure: failure.authFailure, - rateLimit: failure.rateLimit, + checkedAt: null, + authFailure: failure?.authFailure ?? null, + rateLimit: failure?.rateLimit ?? null, credentialStates: githubCredentialStates({ candidates: inventory.candidates, availableSources: inventory.availableSources, @@ -1843,27 +1707,191 @@ export function createHeadlessGitHubService( activeWriteSource: null, }), credentialFallback: null, - backgroundRefreshPausedUntil: pauseUntilMs == null - ? null - : new Date(pauseUntilMs).toISOString(), + backgroundRefreshPausedUntil: null, repoAccessOk: null, repoAccessError: null, connected: false, }; - })(); + } + + const { active, activeWrite, failures } = await resolveGithubStatusCredentials({ + readCandidates, + writeCandidates, + cooldown: statusCooldown, + probe: (candidate) => probeCandidate(candidate, repo, forceRefresh), + capabilities: (candidate, value) => validatedCredentialCapabilities( + candidate, + value, + repo, + ), + isRepositoryAccessFailure: (result) => result.authFailure.kind === "permission_denied" + && result.value?.repoAccessOk === false, + onAuthenticatedProbe: (candidate, value) => { + registerGithubCredentialIdentity(candidate, value.validated.userLogin); + }, + onUsableProbe: (candidate, value) => { + recordGithubCredentialProbeSuccess( + candidate, + value.validated.rateLimit, + value.validated.userLogin, + ); + }, + onRejectedProbe: (candidate, result, context) => { + if (!context.repositoryAccessFailure) { + recordGithubCredentialFailure(candidate, result.authFailure, result.rateLimit); + } + if (context.phase === "read") { + logger.warn("github.token_validation_failed", { + source: candidate.source, + error: result.error, + kind: result.authFailure.kind, + retryAt: result.authFailure.retryAt, + }); + } + }, + }); + const activeWriteSource = activeWrite?.source ?? null; + const credentialFailures = [ + ...inventory.failures, + ...failures.map((failure) => ({ + source: failure.candidate.source, + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + })), + ]; + const pauseUntilMs = githubBackgroundRequestPauseUntilMs(Date.now(), readCandidates); + if (active) { + const { candidate, value } = active; + const { validated, repoAccessOk, repoAccessError } = value; + const failuresBySource = new Map( + credentialFailures.map((failure) => [failure.source, failure] as const), + ); + const activePrecedenceIndex = githubOperationCredentialPrecedence("read") + .indexOf(candidate.source); + const fallbackFailure = githubOperationCredentialPrecedence("read") + .slice(0, activePrecedenceIndex) + .map((source) => failuresBySource.get(source) ?? null) + .find((failure) => failure != null) ?? null; + return { + tokenStored: true, + patTokenStored: inventory.patTokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: candidate.source, + writeAuthSource: activeWriteSource ?? "none", + writeUserLogin: activeWrite?.value.validated.userLogin ?? null, + tokenType: validated.tokenType, + repo, + hasOrigin, + userLogin: validated.userLogin, + scopes: validated.scopes, + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + checkedAt: new Date(now).toISOString(), + authFailure: null, + rateLimit: validated.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: candidate.source, + activeWriteSource, + }), + credentialFallback: fallbackFailure + ? { + capability: "read", + fromSource: fallbackFailure.source, + toSource: candidate.source, + reason: fallbackFailure.authFailure.kind, + retryAt: fallbackFailure.authFailure.retryAt, + } + : null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk, + repoAccessError, + connected: validatedCredentialCapabilities(candidate, value, repo).read, + }; + } + + const failure = credentialFailures.find((entry) => entry.authFailure.kind === "rate_limited") + ?? credentialFailures[0] + ?? { + source: primaryCandidate.source, + authFailure: classifyGitHubAuthFailure({ message: "GitHub authentication could not be verified." }).authFailure, + rateLimit: null, + }; + return { + tokenStored: true, + patTokenStored: inventory.patTokenStored, + tokenDecryptionFailed: false, + storageScope: "app", + authSource: primaryCandidate.source, + writeAuthSource: "none", + tokenType: getTokenType(primaryCandidate.token), + repo, + hasOrigin, + userLogin: null, + scopes: [], + ghCliPath: inventory.ghCliPath, + ghAuthError: inventory.ghAuthError, + checkedAt: new Date(now).toISOString(), + authFailure: failure.authFailure, + rateLimit: failure.rateLimit, + credentialStates: githubCredentialStates({ + candidates: inventory.candidates, + availableSources: inventory.availableSources, + sourceFailures: inventoryFailuresBySource, + activeReadSource: null, + activeWriteSource: null, + }), + credentialFallback: null, + backgroundRefreshPausedUntil: pauseUntilMs == null + ? null + : new Date(pauseUntilMs).toISOString(), + repoAccessOk: null, + repoAccessError: null, + connected: false, + }; + })(); + if (!forceRefresh) { statusLookupInFlight = { generation, binding, promise: lookup }; + } + try { + const status = await lookup; + if (statusLookupGeneration === generation) { + cachedStatus = status; + cachedAt = Date.now(); + cachedStatusBinding = binding; + } + return status; + } finally { + if (!forceRefresh && statusLookupInFlight?.promise === lookup) { + statusLookupInFlight = null; + } + } + }; + + service = { + verifyStoredPat, + async getStatus(opts: { forceRefresh?: boolean } = {}) { + const forceRefresh = opts.forceRefresh === true; + if (forcedStatusLookupInFlight?.generation === statusLookupGeneration) { + return await forcedStatusLookupInFlight.promise; + } + if (!forceRefresh) { + return await performStatusLookup(false, statusLookupGeneration); + } + + invalidateStatusCache(); + const generation = statusLookupGeneration; + const lookup = performStatusLookup(true, generation); + forcedStatusLookupInFlight = { generation, promise: lookup }; try { - const status = await lookup; - if (statusLookupGeneration === generation) { - cachedStatus = status; - cachedAt = Date.now(); - cachedStatusBinding = binding; - } - return status; + return await lookup; } finally { - if (statusLookupInFlight?.promise === lookup) { - statusLookupInFlight = null; - } + if (forcedStatusLookupInFlight?.promise === lookup) forcedStatusLookupInFlight = null; } }, async getBackgroundRequestPauseUntilMs() { 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/services/agentRegistry.test.ts b/apps/ade-cli/src/services/agentRegistry.test.ts index 0d7e8d9dd..f92aebe58 100644 --- a/apps/ade-cli/src/services/agentRegistry.test.ts +++ b/apps/ade-cli/src/services/agentRegistry.test.ts @@ -1,9 +1,20 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { classifyAgentCliError } from "./agentRegistry"; +const originalPlatform = process.platform; + +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value, configurable: true }); +} + +afterEach(() => setPlatform(originalPlatform)); + describe("classifyAgentCliError", () => { - it("classifies missing agent CLIs with install/auth commands", () => { - expect(classifyAgentCliError("spawn codex ENOENT")).toMatchObject({ + it("classifies missing agent CLIs with POSIX install/auth commands", async () => { + setPlatform("linux"); + vi.resetModules(); + const { classifyAgentCliError: classifyForLinux } = await import("./agentRegistry"); + expect(classifyForLinux("spawn codex ENOENT")).toMatchObject({ agent: "codex", displayName: "Codex CLI", category: "missing", @@ -14,6 +25,31 @@ describe("classifyAgentCliError", () => { }); }); + it("uses Windows-native recovery commands without POSIX shell setup", async () => { + setPlatform("win32"); + vi.resetModules(); + const { classifyAgentCliError: classifyForWindows } = await import("./agentRegistry"); + expect(classifyForWindows("spawn codex ENOENT")).toMatchObject({ + agent: "codex", + category: "missing", + installCommand: "npm install -g @openai/codex", + authCommand: "codex login", + }); + expect(classifyForWindows("spawn cursor-agent ENOENT", "cursor")).toMatchObject({ + agent: "cursor", + category: "missing", + installCommand: `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`, + authCommand: "cursor-agent login", + }); + expect(classifyForWindows("'droid.cmd' is not recognized as an internal or external command")).toMatchObject({ + agent: "droid", + displayName: "Factory Droid", + category: "missing", + installCommand: "npm install -g droid", + authCommand: "droid", + }); + }); + it("classifies unauthenticated agent CLIs with auth commands", () => { expect(classifyAgentCliError("codex failed: login required")).toMatchObject({ agent: "codex", @@ -32,6 +68,40 @@ describe("classifyAgentCliError", () => { }); }); + it("provides Factory Droid install and interactive authentication recovery", () => { + expect(classifyAgentCliError("spawn droid ENOENT")).toMatchObject({ + agent: "droid", + displayName: "Factory Droid", + category: "missing", + authCommand: "droid", + }); + expect(classifyAgentCliError("Factory Droid authentication failed: login required")).toMatchObject({ + agent: "droid", + displayName: "Factory Droid", + category: "unauthenticated", + authCommand: "droid", + }); + expect(classifyAgentCliError("No Factory API key was found", "droid")).toMatchObject({ + agent: "droid", + category: "unauthenticated", + authCommand: "droid", + }); + expect(classifyAgentCliError("Factory Droid completed successfully", "droid")).toBeNull(); + }); + + it("uses the installed legacy Cursor alias for authentication recovery", () => { + expect(classifyAgentCliError("agent failed: login required", "cursor")).toMatchObject({ + agent: "cursor", + category: "unauthenticated", + authCommand: "agent login", + }); + expect(classifyAgentCliError("Cursor agent failed: login required", "cursor")).toMatchObject({ + agent: "cursor", + category: "unauthenticated", + authCommand: "cursor-agent login", + }); + }); + it("classifies legacy Claude login hints", () => { expect(classifyAgentCliError("Please run 'claude /login'", "claude")).toMatchObject({ agent: "claude", diff --git a/apps/ade-cli/src/services/agentRegistry.ts b/apps/ade-cli/src/services/agentRegistry.ts index f33b1e016..415260a31 100644 --- a/apps/ade-cli/src/services/agentRegistry.ts +++ b/apps/ade-cli/src/services/agentRegistry.ts @@ -1,11 +1,17 @@ +import { CURSOR_CLI_EXECUTABLES } from "../../../desktop/src/shared/providerCliExecutables"; + export type AgentCliErrorCategory = "missing" | "unauthenticated"; export type AgentCliDescriptor = { agent: string; displayName: string; - binaryNames: string[]; + binaryNames: readonly string[]; installCommand: string; authCommand: string; + authRecoveryRules?: readonly { + authCommand: string; + patterns: readonly RegExp[]; + }[]; missingErrorPatterns: RegExp[]; notAuthErrorPatterns: RegExp[]; }; @@ -25,6 +31,13 @@ function npmGlobalInstallCommand(packageName: string): string { return `mkdir -p "$HOME/.npm-global" "$HOME/.local/bin" && NPM_CONFIG_PREFIX="$HOME/.npm-global" npm install -g ${packageName}`; } +function cursorInstallCommand(): string { + if (typeof process !== "undefined" && process.platform === "win32") { + return `powershell.exe -NoProfile -Command "irm 'https://cursor.com/install?win32=true' | iex"`; + } + return 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash'; +} + export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ { agent: "claude", @@ -75,9 +88,10 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ { agent: "cursor", displayName: "Cursor Agent", - binaryNames: ["cursor-agent", "cursor"], - installCommand: 'mkdir -p "$HOME/.local/bin" && curl https://cursor.com/install -fsS | bash', + binaryNames: CURSOR_CLI_EXECUTABLES.recoveryMentionNames, + installCommand: cursorInstallCommand(), authCommand: "cursor-agent login", + authRecoveryRules: CURSOR_CLI_EXECUTABLES.authRecoveryRules, missingErrorPatterns: [ /\bcursor-agent\b.*\b(command not found|not recognized|not found|enoent)\b/i, /\bcursor\b.*\b(command not found|not recognized|enoent)\b/i, @@ -87,6 +101,26 @@ export const AGENT_CLI_REGISTRY: AgentCliDescriptor[] = [ /\bcursor(?:-agent)?\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, ], }, + { + agent: "droid", + displayName: "Factory Droid", + binaryNames: ["droid"], + installCommand: npmGlobalInstallCommand("droid"), + // Factory exposes sign-in through the interactive CLI's /login flow rather + // than a non-interactive `login` subcommand. Launching `droid` is therefore + // the portable recovery command on Windows, macOS, and Linux. + authCommand: "droid", + missingErrorPatterns: [ + /\bdroid\b.*\b(command not found|not recognized|not found|enoent)\b/i, + /\bspawn\s+droid\s+enoent\b/i, + ], + notAuthErrorPatterns: [ + /\bdroid\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\bfactory\b.*\b(not logged in|not authenticated|unauthorized|authentication failed|login required)\b/i, + /\b(?:invalid|missing|no)\s+factory(?:_api_key| api key)\b/i, + /\bfactory(?:_api_key| api key)\b.*\b(invalid|missing|not found|not set|required|unauthorized|must be set)\b/i, + ], + }, ]; function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAgent: string | null | undefined): boolean { @@ -98,17 +132,24 @@ function descriptorMatchesPreferred(descriptor: AgentCliDescriptor, preferredAge } function descriptorMentioned(descriptor: AgentCliDescriptor, text: string): boolean { - return descriptor.binaryNames.some((name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text)) + return descriptor.binaryNames.some((name) => binaryNameMentioned(text, name)) || new RegExp(`\\b${descriptor.agent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(text); } -function toMatch(descriptor: AgentCliDescriptor, category: AgentCliErrorCategory): AgentCliErrorMatch { +function binaryNameMentioned(text: string, name: string): boolean { + return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\.exe|\\.cmd|\\.bat|\\.ps1)?\\b`, "i").test(text); +} + +function toMatch(descriptor: AgentCliDescriptor, category: AgentCliErrorCategory, text: string): AgentCliErrorMatch { + const aliasAuthCommand = category === "unauthenticated" + ? descriptor.authRecoveryRules?.find((rule) => rule.patterns.some((pattern) => pattern.test(text)))?.authCommand + : undefined; return { agent: descriptor.agent, displayName: descriptor.displayName, category, installCommand: descriptor.installCommand, - authCommand: descriptor.authCommand, + authCommand: aliasAuthCommand ?? descriptor.authCommand, }; } @@ -124,10 +165,10 @@ export function classifyAgentCliError(message: string, preferredAgent?: string | const mentioned = descriptorMentioned(descriptor, text); if (!mentioned && descriptor !== preferred) continue; if (descriptor.missingErrorPatterns.some((pattern) => pattern.test(text))) { - return toMatch(descriptor, "missing"); + return toMatch(descriptor, "missing", text); } if (descriptor.notAuthErrorPatterns.some((pattern) => pattern.test(text))) { - return toMatch(descriptor, "unauthenticated"); + return toMatch(descriptor, "unauthenticated", text); } } @@ -136,10 +177,10 @@ export function classifyAgentCliError(message: string, preferredAgent?: string | /\b(command not found|not recognized|enoent|executable file not found|no such file or directory)\b/i.test(text) || /\b(?:spawn|exec(?:ute)?|binary|command|executable)\b.*\bnot found\b/i.test(text) ) { - return toMatch(preferred, "missing"); + return toMatch(preferred, "missing", text); } if (/\b(not logged in|not authenticated|unauthorized|authentication failed|login required|invalid api key|401|403)\b/i.test(text)) { - return toMatch(preferred, "unauthenticated"); + return toMatch(preferred, "unauthenticated", text); } } diff --git a/apps/ade-cli/src/services/credentials/credentialStore.ts b/apps/ade-cli/src/services/credentials/credentialStore.ts index 1f7962a2d..4616f78e7 100644 --- a/apps/ade-cli/src/services/credentials/credentialStore.ts +++ b/apps/ade-cli/src/services/credentials/credentialStore.ts @@ -114,6 +114,26 @@ function isEexist(error: unknown): boolean { && (error as { code?: unknown }).code === "EEXIST"; } +/** + * Windows does not report lock contention as EEXIST the way POSIX does. + * + * Deleting a file on Windows only unlinks the name once every open handle to it + * closes, so between one holder's unlink and the last handle drop the lock name + * still occupies the directory in a "delete pending" state. A concurrent + * `open(lockPath, "wx")` against that name fails with a delete-pending or + * sharing violation, which Node surfaces as EPERM, EACCES or EBUSY instead of + * EEXIST. Those are the same "someone else holds it, try again" condition, so + * they have to keep the acquisition loop running; treating them as fatal makes + * every concurrent credential write a coin flip on Windows. + */ +function isLockContention(error: unknown): boolean { + if (isEexist(error)) return true; + if (process.platform !== "win32") return false; + if (typeof error !== "object" || error === null || !("code" in error)) return false; + const code = (error as { code?: unknown }).code; + return code === "EPERM" || code === "EACCES" || code === "EBUSY"; +} + function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } @@ -280,10 +300,10 @@ function withCredentialFileLock(lockPath: string, fn: () => T): T { } ensureMode600(lockPath); } catch (error: unknown) { - if (!isEexist(error)) throw error; + if (!isLockContention(error)) throw error; removeStaleLock(lockPath); if (Date.now() >= deadline) { - throw new Error("Timed out waiting for ADE credential store lock."); + throw new Error("Timed out waiting for ADE credential store lock.", { cause: error }); } sleepSync(LOCK_RETRY_MS); } diff --git a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts index 4db2ab6dc..790701917 100644 --- a/apps/ade-cli/src/services/runtime/socketSpawnLock.ts +++ b/apps/ade-cli/src/services/runtime/socketSpawnLock.ts @@ -66,6 +66,19 @@ function processExists(pid: number): boolean { } } +// A contended `open(..., "wx")` reports EEXIST on POSIX, but Windows keeps a +// deleted name in the directory until the last handle closes. Between the +// holder's unlink and that final drop, a contending open hits the +// delete-pending name and Node surfaces EPERM, EACCES or EBUSY instead. Those +// are contention, not failure, so the caller must wait rather than abort -- +// this is the brain-spawn path, where burst contention is the normal case. +function isSocketSpawnLockContention(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "EEXIST") return true; + if (process.platform !== "win32") return false; + return code === "EPERM" || code === "EACCES" || code === "EBUSY"; +} + function unlinkSocketSpawnLockIfStale(lockPath: string): boolean { try { const stat = fs.statSync(lockPath); @@ -134,11 +147,10 @@ export async function withSocketSpawnLock(socketPath: string, task: () => Pro fs.writeFileSync(fd, serializeSocketSpawnLockOwner(owner), "utf8"); break; } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "EEXIST") throw error; + if (!isSocketSpawnLockContention(error)) throw error; if (unlinkSocketSpawnLockIfStale(lockPath)) continue; if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for ADE socket spawn lock at ${lockPath}.`); + throw new Error(`Timed out waiting for ADE socket spawn lock at ${lockPath}.`, { cause: error }); } await new Promise((resolve) => setTimeout(resolve, 100)); } diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index 741481e84..8e70e2b61 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -6,6 +6,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; +import { resolveMachineAdeLayout } from "./services/projects/machineLayout"; type JsonRpcResponse = { id?: number; @@ -30,6 +31,35 @@ function fileSha256(filePath: string): string { return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); } +/** + * Resolve the machine runtime endpoint for a temp `ADE_HOME` exactly the way + * production does (`resolveMachineRuntimeSocketPath` in cli.ts falls through to + * this same layout), so daemon-backed tests exercise the real per-platform + * transport: a Unix domain socket at `/sock/ade.sock` on macOS and + * Linux, and a per-user named pipe on Windows. + * + * Hardcoding `/sock/ade.sock` is not portable. Windows has no Unix + * domain sockets, so `net` treats a path-style endpoint as a named pipe name + * there; a filesystem path is not a connectable address and every such test + * died with `connect ENOENT`. Deriving the endpoint keeps the POSIX value + * byte-identical — `resolveMachineAdeLayout` returns exactly + * `path.join(adeHome, "sock", "ade.sock")` off win32 — while giving Windows the + * address the runtime actually listens on. + */ +function machineRuntimeSocketPath(adeHome: string): string { + return resolveMachineAdeLayout({ ...process.env, ADE_HOME: adeHome }).socketPath; +} + +/** + * Mirrors `LOCAL_RUNTIME_STARTUP_TIMEOUT_MS` in the desktop local runtime pool: + * a cold Windows daemon start (process spawn + tsx transform + SQLite init) is + * genuinely slower than on macOS/Linux, so production already waits 30s there + * against 10s elsewhere. This is a ceiling, not a sleep — a healthy daemon is + * reachable in a few seconds on every platform — so widening it on Windows only + * removes a false failure under load. + */ +const RUNTIME_SOCKET_READY_TIMEOUT_MS = process.platform === "win32" ? 30_000 : 10_000; + async function getFreeTcpPort(): Promise { const server = net.createServer(); await new Promise((resolve, reject) => { @@ -55,7 +85,7 @@ async function getFreeTcpPort(): Promise { async function waitForConnection( label: string, connect: () => net.Socket, - timeoutMs = 10_000, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, ): Promise { const startedAt = Date.now(); let lastError: Error | null = null; @@ -87,11 +117,17 @@ async function waitForConnection( throw lastError ?? new Error(`ADE runtime socket did not become available: ${label}`); } -async function waitForSocket(socketPath: string, timeoutMs = 10_000): Promise { +async function waitForSocket( + socketPath: string, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, +): Promise { await waitForConnection(socketPath, () => net.createConnection(socketPath), timeoutMs); } -async function waitForTcpUrl(tcpUrl: string, timeoutMs = 10_000): Promise { +async function waitForTcpUrl( + tcpUrl: string, + timeoutMs = RUNTIME_SOCKET_READY_TIMEOUT_MS, +): Promise { const parsed = new URL(tcpUrl); const port = Number.parseInt(parsed.port, 10); const host = parsed.hostname; @@ -225,10 +261,8 @@ class StdioRpcProcess { } } -const itUnix = process.platform === "win32" ? it.skip : it; - describe("ade rpc --stdio daemon bridge", () => { - itUnix("keeps the machine runtime alive after the stdio client exits", async () => { + it("keeps the machine runtime alive after the stdio client exits", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-")); @@ -236,7 +270,7 @@ describe("ade rpc --stdio daemon bridge", () => { fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-project-")), ); const expectedProjectRoot = projectRoot; - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const env = { ...process.env, ADE_HOME: adeHome, @@ -290,11 +324,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts a stale daemon before bridging stdio requests", async () => { + it("restarts a stale daemon before bridging stdio requests", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-version-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -345,11 +379,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts a same-version daemon when its build hash is stale", async () => { + it("restarts a same-version daemon when its build hash is stale", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-build-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -404,11 +438,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("accepts a compatible TCP daemon and computes a build hash when none is advertised", async () => { + it("accepts a compatible TCP daemon and computes a build hash when none is advertised", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-tcp-build-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const tcpPort = await getFreeTcpPort(); const tcpUrl = `tcp://127.0.0.1:${tcpPort}`; const baseEnv = { @@ -466,11 +500,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("keeps a compatible cto daemon when the proxy requests an agent role", async () => { + it("keeps a compatible cto daemon when the proxy requests an agent role", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-role-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -525,11 +559,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("does not replace a real daemon when the bridging CLI has only the placeholder version", async () => { + it("does not replace a real daemon when the bridging CLI has only the placeholder version", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-placeholder-version-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, @@ -579,11 +613,11 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("restarts an incompatible-role daemon even when the proxy has only the placeholder version", async () => { + it("restarts an incompatible-role daemon even when the proxy has only the placeholder version", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-placeholder-role-")); - const socketPath = path.join(adeHome, "sock", "ade.sock"); + const socketPath = machineRuntimeSocketPath(adeHome); const baseEnv = { ...process.env, ADE_HOME: adeHome, 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/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 352dbef7c..02b375c8c 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -13,6 +13,18 @@ const reportProviderRuntimeAuthFailureMock = vi.fn(); const reportProviderRuntimeFailureMock = vi.fn(); const reportProviderRuntimeReadyMock = vi.fn(); +vi.mock("node:path", async () => { + const actual = await vi.importActual("node:path"); + const dynamicDefault = new Proxy({} as typeof actual, { + get(_target, property) { + const implementation = process.platform === "win32" ? actual.win32 : actual.posix; + const value = implementation[property as keyof typeof implementation]; + return typeof value === "function" ? value.bind(implementation) : value; + }, + }); + return { ...actual, default: dynamicDefault }; +}); + /** Helper: create a fake ChildProcess that immediately emits close with the given result. */ function fakeChild(result: { status: number | null; stdout?: string; stderr?: string }) { const child = new EventEmitter() as any; @@ -40,6 +52,20 @@ function fakeError() { return child; } +function commandBasename(command: string): string { + return command.replace(/\\/g, "/").split("/").pop() ?? command; +} + +function withExecutableMode(stat: fs.Stats): fs.Stats { + return new Proxy(stat, { + get(target, property, receiver) { + if (property === "mode") return target.mode | 0o111; + const value = Reflect.get(target, property, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} + vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { @@ -131,7 +157,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + if (commandBasename(command) === "claude" && args[0] === "auth") { return fakeChild({ status: 1, stderr: "Not logged in. Run `claude auth login`." }); } return fakeChild({ status: 1 }); @@ -159,7 +185,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + if (commandBasename(command) === "claude" && args[0] === "auth") { throw new Error("auth probe should not run"); } return fakeChild({ status: 1 }); @@ -181,6 +207,40 @@ describe("authDetector", () => { })).toBe(false); }); + it("detects and probes a Windows cursor-agent.cmd outside PATH", async () => { + setPlatform("win32"); + tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cursor-auth-win-")); + const npmBin = path.join(tempHomeDir, "npm"); + const cursorAgentPath = path.join(npmBin, "cursor-agent.cmd"); + fs.mkdirSync(npmBin, { recursive: true }); + fs.writeFileSync(cursorAgentPath, "@echo off\r\n", "utf8"); + process.env.APPDATA = tempHomeDir; + process.env.PATH = "C:\\Windows\\System32"; + process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; + + spawnMock.mockImplementation((command: string, args: string[] = []) => { + const commandLine = args.join(" ").toLowerCase(); + if (command.toLowerCase().endsWith("cmd.exe") && commandLine.includes("cursor-agent.cmd")) { + if (commandLine.includes("--version")) return fakeChild({ status: 0, stdout: "1.0.0\n" }); + if (commandLine.includes("status")) { + return fakeChild({ status: 0, stdout: '{"authenticated":true,"plan":"pro"}\n' }); + } + } + if (command === "where") return fakeChild({ status: 1 }); + return fakeError(); + }); + + const statuses = await detectCliAuthStatuses({ force: true }); + expect(statuses.find((entry) => entry.cli === "cursor")).toMatchObject({ + cli: "cursor", + installed: true, + authenticated: true, + verified: true, + paidPlan: true, + }); + expect(statuses.find((entry) => entry.cli === "cursor")?.path?.toLowerCase()).toBe(cursorAgentPath.toLowerCase()); + }); + it("merges config, store, env, and local endpoint auth sources", async () => { getAllApiKeysMock.mockReturnValue({ anthropic: "store-anthropic", @@ -199,7 +259,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if ((command === "claude" || command.endsWith("/claude")) && args[0] === "auth") { + if (commandBasename(command) === "claude" && args[0] === "auth") { return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); } return fakeChild({ status: 1 }); @@ -294,20 +354,20 @@ describe("authDetector", () => { spawnMock.mockImplementation((command: string, args: string[] = []) => { if (args[0] === "--version") { - if (command === "droid" || command.endsWith("/droid")) return fakeChild({ status: 0, stdout: "0.70.0\n" }); + if (commandBasename(command) === "droid") return fakeChild({ status: 0, stdout: "0.70.0\n" }); return fakeError(); } if (command === "which") { if (args[0] === "droid") return fakeChild({ status: 0, stdout: `${fakeDroidPath}\n` }); return fakeChild({ status: 1 }); } - if ((command === "droid" || command.endsWith("/droid")) && args[0] === "exec" && args[1] === "--list-tools") { + if (commandBasename(command) === "droid" && args[0] === "exec" && args[1] === "--list-tools") { return fakeChild({ status: 0, stdout: "Available tools for Claude Opus 4.6\n" }); } - if ((command === "droid" || command.endsWith("/droid")) && args[0] === "account") { + if (commandBasename(command) === "droid" && args[0] === "account") { return fakeChild({ status: 1, stderr: "unknown command 'account'\n" }); } - if ((command === "droid" || command.endsWith("/droid")) && args[0] === "whoami") { + if (commandBasename(command) === "droid" && args[0] === "whoami") { return fakeChild({ status: 1, stderr: "unknown command 'whoami'\n" }); } return fakeChild({ status: 1 }); @@ -333,31 +393,40 @@ describe("authDetector", () => { const fakeDroidPath = path.join(droidBinDir, "droid"); fs.writeFileSync(fakeDroidPath, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); process.env.PATH = ""; + const realStatSync = fs.statSync.bind(fs); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { + const stat = realStatSync(candidatePath, options as fs.StatOptions | undefined); + return String(candidatePath) === fakeDroidPath ? withExecutableMode(stat) : stat; + }) as typeof fs.statSync); - spawnMock.mockImplementation((command: string, _args: string[] = []) => { - if (command === "which") { - return fakeChild({ status: 1 }); - } - return fakeError(); - }); + try { + spawnMock.mockImplementation((command: string, _args: string[] = []) => { + if (command === "which") { + return fakeChild({ status: 1 }); + } + return fakeError(); + }); - const statuses = await detectCliAuthStatuses(); - const droid = statuses.find((entry) => entry.cli === "droid"); + const statuses = await detectCliAuthStatuses(); + const droid = statuses.find((entry) => entry.cli === "droid"); - expect(droid).toEqual({ - cli: "droid", - installed: true, - path: fakeDroidPath, - authenticated: false, - verified: false, - }); - const droidDeepProbeCalls = spawnMock.mock.calls.filter(([command, args]) => { - const commandText = String(command); - const argv = Array.isArray(args) ? args as string[] : []; - return (commandText === "droid" || commandText.endsWith("/droid")) - && (argv[0] === "exec" || argv[0] === "account" || argv[0] === "whoami"); - }); - expect(droidDeepProbeCalls).toHaveLength(0); + expect(droid).toEqual({ + cli: "droid", + installed: true, + path: fakeDroidPath, + authenticated: false, + verified: false, + }); + const droidDeepProbeCalls = spawnMock.mock.calls.filter(([command, args]) => { + const commandText = String(command); + const argv = Array.isArray(args) ? args as string[] : []; + return commandBasename(commandText) === "droid" + && (argv[0] === "exec" || argv[0] === "account" || argv[0] === "whoami"); + }); + expect(droidDeepProbeCalls).toHaveLength(0); + } finally { + statSpy.mockRestore(); + } }); it("does not report openai-compatible local providers when no models are loaded", async () => { @@ -427,7 +496,7 @@ describe("authDetector", () => { if (args[0] === "claude") return fakeChild({ status: 0, stdout: "/usr/local/bin/claude\n" }); return fakeChild({ status: 1 }); } - if (command === "claude" || command.endsWith("/claude")) { + if (commandBasename(command) === "claude") { return fakeChild({ status: 1, stderr: "unknown command 'auth'" }); } return fakeChild({ status: 1 }); @@ -453,12 +522,13 @@ describe("authDetector", () => { const realStatSync = fs.statSync.bind(fs); const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { const resolved = String(candidatePath); - if (resolved.endsWith("/codex") && resolved !== preferredCodexPath) { + if (commandBasename(resolved) === "codex" && resolved !== preferredCodexPath) { const error = new Error(`ENOENT: no such file or directory, stat '${resolved}'`) as NodeJS.ErrnoException; error.code = "ENOENT"; throw error; } - return realStatSync(candidatePath, options as fs.StatOptions | undefined); + const stat = realStatSync(candidatePath, options as fs.StatOptions | undefined); + return resolved === preferredCodexPath ? withExecutableMode(stat) : stat; }) as typeof fs.statSync); try { @@ -471,7 +541,7 @@ describe("authDetector", () => { if (command === "which") { return fakeChild({ status: 1 }); } - if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + if (commandBasename(command) === "codex" && args[0] === "login" && args[1] === "status") { return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); } return fakeChild({ status: 1 }); @@ -519,7 +589,7 @@ describe("authDetector", () => { } return fakeChild({ status: 1 }); } - if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + if (commandBasename(command) === "codex" && args[0] === "login" && args[1] === "status") { return fakeChild({ status: 0, stdout: "Logged in using ChatGPT\n" }); } return fakeChild({ status: 1 }); diff --git a/apps/desktop/src/main/services/ai/authDetector.ts b/apps/desktop/src/main/services/ai/authDetector.ts index 7508b1fde..631c1f63b 100644 --- a/apps/desktop/src/main/services/ai/authDetector.ts +++ b/apps/desktop/src/main/services/ai/authDetector.ts @@ -12,6 +12,7 @@ import { setPathEnvValue, } from "./cliExecutableResolver"; import { getLocalProviderDefaultEndpoint, type LocalProviderFamily } from "../../../shared/modelRegistry"; +import { CURSOR_CLI_EXECUTABLES } from "../../../shared/providerCliExecutables"; import type { AiLocalProviderConfigs } from "../../../shared/types"; import { inspectLocalProvider, clearLocalProviderInspectionCache } from "./localModelDiscovery"; import { resolveDroidExecutable } from "./droidExecutable"; @@ -85,9 +86,13 @@ const CLI_AUTH_PROBES: Record = { droid: [["--version"], ["-V"], ["version"]], }; +function cliSpawnCommands(cli: CliName): readonly string[] { + if (cli === "cursor") return CURSOR_CLI_EXECUTABLES.launchCandidates; + return [cli]; +} + function cliSpawnCommand(cli: CliName): string { - if (cli === "cursor") return "agent"; - return cli; + return cliSpawnCommands(cli)[0]!; } const AUTH_INDICATORS = [ @@ -173,7 +178,10 @@ async function commandPath(command: string): Promise { try { if (process.platform === "win32") { const result = await spawnAsync("where", [command], { timeout: 5_000 }); - return result.stdout?.trim().split(/\r?\n/)[0] ?? command; + if (result.status === 0 && result.stdout?.trim()) { + return result.stdout.trim().split(/\r?\n/)[0] ?? command; + } + return findExplicitCommandPath(command) ?? command; } // Try which first (simpler, doesn't load full login shell) const which = await spawnAsync("which", [command], { timeout: 3_000 }); @@ -1100,8 +1108,15 @@ export async function detectCliAuthStatuses(options?: { force?: boolean; skipAut // Probe all CLIs in parallel const statuses = await Promise.all( cliChecks.map(async (cli) => { - const spawnName = cliSpawnCommand(cli); - const installed = await commandExists(spawnName); + let spawnName = cliSpawnCommand(cli); + let installed = false; + for (const candidate of cliSpawnCommands(cli)) { + if (await commandExists(candidate)) { + spawnName = candidate; + installed = true; + break; + } + } const path = installed ? await commandPath(spawnName) : null; const cmd = path ?? spawnName; if (!installed) { 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 7ab5abb73..eade02652 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -176,6 +176,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+)?(?