diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts index 2cfc4427be..e57854def5 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts @@ -281,6 +281,7 @@ describe('ClaudeTrustService', () => { const ctx: IExecutionContext = { root: undefined, supportsLocalSpawn: false, + isWindows: false, exec: vi.fn().mockImplementation(async (command: string, args: string[] = []) => { if (command === 'sh') { return { stdout: '/home/remote-user', stderr: '' }; diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts index 08f4f3f03d..35174a4842 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts @@ -67,6 +67,7 @@ function makeCtx(): IExecutionContext { return { root: undefined, supportsLocalSpawn: false, + isWindows: false, exec: vi.fn().mockImplementation(async (command: string) => { if (command === 'sh') return { stdout: '/home/remote-user', stderr: '' }; return { stdout: '', stderr: '' }; diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/hook-config.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/hook-config.test.ts index 43c64a0e20..eed3a60947 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/hook-config.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/hook-config.test.ts @@ -13,6 +13,7 @@ vi.mock('@main/core/dependencies/probe', () => ({ function makeExecutionContext(): IExecutionContext { return { supportsLocalSpawn: false, + isWindows: false, exec: vi.fn(async () => ({ stdout: '', stderr: '' })), execStreaming: vi.fn(async () => {}), dispose: vi.fn(), diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts index d325c48138..0eccdd2e38 100644 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts +++ b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts @@ -19,6 +19,7 @@ function makeCtx(): IExecutionContext { return { root: undefined, supportsLocalSpawn: false, + isWindows: false, exec: vi.fn(), execStreaming: vi.fn(), dispose: vi.fn(), diff --git a/apps/emdash-desktop/src/main/core/dependencies/probe.test.ts b/apps/emdash-desktop/src/main/core/dependencies/probe.test.ts new file mode 100644 index 0000000000..e8c355646b --- /dev/null +++ b/apps/emdash-desktop/src/main/core/dependencies/probe.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import { resolveCommandPath } from './probe'; + +function makeCtx( + isWindows: boolean, + handler: (command: string, args: string[]) => Promise<{ stdout: string; stderr: string }> +): IExecutionContext { + return { + root: undefined, + supportsLocalSpawn: false, + isWindows, + exec: vi.fn().mockImplementation(handler), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext; +} + +describe('resolveCommandPath', () => { + it('uses `which` on POSIX hosts', async () => { + const ctx = makeCtx(false, async () => ({ stdout: '/usr/local/bin/claude\n', stderr: '' })); + + const path = await resolveCommandPath('claude', ctx); + + expect(ctx.exec).toHaveBeenCalledWith('which', ['claude'], { timeout: 5000 }); + expect(path).toBe('/usr/local/bin/claude'); + }); + + it('uses `where` on Windows hosts', async () => { + const ctx = makeCtx(true, async () => ({ stdout: 'C:\\bin\\claude.exe\n', stderr: '' })); + + const path = await resolveCommandPath('claude', ctx); + + expect(ctx.exec).toHaveBeenCalledWith('where', ['claude'], { timeout: 5000 }); + expect(path).toBe('C:\\bin\\claude.exe'); + }); + + // Regression: a Windows client connected to a POSIX remote over SSH must run + // `which` on the remote host, not `where`. The resolve command follows the + // execution context's platform, never the local client's. See issue #2474. + it('uses `which` for a POSIX SSH context even when the client is Windows', async () => { + const ctx = makeCtx(false, async () => ({ + stdout: '/home/dev/.local/bin/claude\n', + stderr: '', + })); + + const path = await resolveCommandPath('claude', ctx); + + expect(ctx.exec).toHaveBeenCalledWith('which', ['claude'], { timeout: 5000 }); + expect(ctx.exec).not.toHaveBeenCalledWith('where', expect.anything(), expect.anything()); + expect(path).toBe('/home/dev/.local/bin/claude'); + }); + + it('returns the first match and trims surrounding whitespace', async () => { + const ctx = makeCtx(true, async () => ({ + stdout: 'C:\\bin\\claude.exe\r\nC:\\other\\claude.exe\r\n', + stderr: '', + })); + + const path = await resolveCommandPath('claude', ctx); + + expect(path).toBe('C:\\bin\\claude.exe'); + }); + + it('returns null when resolution fails', async () => { + const ctx = makeCtx(false, async () => { + throw new Error('not found'); + }); + + expect(await resolveCommandPath('claude', ctx)).toBeNull(); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/dependencies/probe.ts b/apps/emdash-desktop/src/main/core/dependencies/probe.ts index 6baeeb760e..4b676c57cf 100644 --- a/apps/emdash-desktop/src/main/core/dependencies/probe.ts +++ b/apps/emdash-desktop/src/main/core/dependencies/probe.ts @@ -4,12 +4,16 @@ import type { ProbeResult } from './types'; const WHICH_TIMEOUT_MS = 5_000; const VERSION_PROBE_TIMEOUT_MS = 10_000; -// `where` on Windows, `which` on macOS/Linux -const RESOLVE_CMD = process.platform === 'win32' ? 'where' : 'which'; +// The resolve command must match the platform the context executes on, not the +// local client: a Windows client connected to a POSIX remote over SSH must run +// `which` on the remote host, never `where`. +function resolveCmd(ctx: IExecutionContext): string { + return ctx.isWindows ? 'where' : 'which'; +} /** * Resolves the absolute path of a command binary. - * Uses `where` on Windows and `which` on macOS/Linux. + * Uses `where` on Windows hosts and `which` on macOS/Linux hosts. * Returns `null` if the command is not found or the resolution fails. */ export async function resolveCommandPath( @@ -17,7 +21,7 @@ export async function resolveCommandPath( ctx: IExecutionContext ): Promise { try { - const { stdout } = await ctx.exec(RESOLVE_CMD, [command], { timeout: WHICH_TIMEOUT_MS }); + const { stdout } = await ctx.exec(resolveCmd(ctx), [command], { timeout: WHICH_TIMEOUT_MS }); const firstLine = stdout.trim().split('\n')[0]?.trim(); return firstLine ?? null; } catch { diff --git a/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts b/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts index 11e2af2959..0f12d8bbb7 100644 --- a/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts +++ b/apps/emdash-desktop/src/main/core/execution-context/local-execution-context.ts @@ -20,6 +20,7 @@ function buildNonInteractiveGitEnv(): NodeJS.ProcessEnv { export class LocalExecutionContext implements IExecutionContext { readonly root: string; readonly supportsLocalSpawn = true; + readonly isWindows = process.platform === 'win32'; private readonly _lifetime = new AbortController(); diff --git a/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts b/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts index 4405a6369b..7626c43f0a 100644 --- a/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts +++ b/apps/emdash-desktop/src/main/core/execution-context/ssh-execution-context.ts @@ -37,6 +37,7 @@ export function buildSshCommand( export class SshExecutionContext implements IExecutionContext { readonly root?: string; readonly supportsLocalSpawn = false; + readonly isWindows = false; private readonly _lifetime = new AbortController(); diff --git a/apps/emdash-desktop/src/main/core/execution-context/types.ts b/apps/emdash-desktop/src/main/core/execution-context/types.ts index 50ebc5da7d..012ecda778 100644 --- a/apps/emdash-desktop/src/main/core/execution-context/types.ts +++ b/apps/emdash-desktop/src/main/core/execution-context/types.ts @@ -24,6 +24,15 @@ export interface IExecutionContext { */ readonly supportsLocalSpawn: boolean; + /** + * Whether the host this context executes on is Windows. Consumers must use + * this — not the local `process.platform` — when picking platform-specific + * commands (e.g. `where` vs `which`), so a Windows client connected to a + * POSIX remote over SSH still runs the right command on the remote host. + * SSH remotes are POSIX, so this is always false for them. + */ + readonly isWindows: boolean; + /** Run a command and buffer all output. Rejects on non-zero exit code. */ exec(command: string, args?: string[], opts?: ExecOptions): Promise; diff --git a/apps/emdash-desktop/src/main/core/git/impl/git-service.test.ts b/apps/emdash-desktop/src/main/core/git/impl/git-service.test.ts index ed19d08a26..1ee14a523d 100644 --- a/apps/emdash-desktop/src/main/core/git/impl/git-service.test.ts +++ b/apps/emdash-desktop/src/main/core/git/impl/git-service.test.ts @@ -49,6 +49,7 @@ function makeContext(exec: MockExec, root = '/repo'): IExecutionContext { return { root, supportsLocalSpawn: false, + isWindows: false, exec: (_cmd, args = [], _opts) => exec(_cmd, args), execStreaming: async (_cmd, _args, onChunk) => { onChunk(''); diff --git a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts index d238f8e8a9..3c8e4278af 100644 --- a/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts +++ b/apps/emdash-desktop/src/main/core/projects/worktrees/worktree-service.test.ts @@ -99,6 +99,7 @@ describe('WorktreeService', () => { const remoteCtx = { root: '/remote/repo', supportsLocalSpawn: false, + isWindows: false, exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }), execStreaming: vi.fn().mockResolvedValue(undefined), dispose: vi.fn(), @@ -149,6 +150,7 @@ describe('WorktreeService', () => { const ctx: IExecutionContext = { root: repoDir, supportsLocalSpawn: false, + isWindows: false, exec, execStreaming: async () => {}, dispose: () => {}, @@ -228,6 +230,7 @@ describe('WorktreeService', () => { const ctx: IExecutionContext = { root: repoDir, supportsLocalSpawn: false, + isWindows: false, exec, execStreaming: async () => {}, dispose: () => {}, @@ -306,6 +309,7 @@ describe('WorktreeService', () => { const ctx: IExecutionContext = { root: repoDir, supportsLocalSpawn: false, + isWindows: false, exec, execStreaming: async () => {}, dispose: () => {}, @@ -431,6 +435,7 @@ describe('WorktreeService', () => { const ctx: IExecutionContext = { root: repoDir, supportsLocalSpawn: false, + isWindows: false, exec, execStreaming: async () => {}, dispose: () => {}, diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts index f21913237b..0b7fc528ad 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/local-terminal-provider.test.ts @@ -43,6 +43,7 @@ const terminal: Terminal = { const ctx = { supportsLocalSpawn: true, + isWindows: false, exec: vi.fn(), execStreaming: vi.fn(), dispose: vi.fn(), diff --git a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts index dd47be926e..d0cbfae4b9 100644 --- a/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts +++ b/apps/emdash-desktop/src/main/core/terminals/impl/ssh-terminal-provider.test.ts @@ -54,6 +54,7 @@ const terminal: Terminal = { const ctx = { supportsLocalSpawn: false, + isWindows: false, exec: vi.fn(), execStreaming: vi.fn(), dispose: vi.fn(), diff --git a/apps/emdash-desktop/src/main/db/legacy-port/importers/relational/relational.test.ts b/apps/emdash-desktop/src/main/db/legacy-port/importers/relational/relational.test.ts index de0337877b..4b30c31f5e 100644 --- a/apps/emdash-desktop/src/main/db/legacy-port/importers/relational/relational.test.ts +++ b/apps/emdash-desktop/src/main/db/legacy-port/importers/relational/relational.test.ts @@ -677,6 +677,7 @@ describe('legacy-port table passes', () => { const tmuxExec = { root: undefined, supportsLocalSpawn: false, + isWindows: false, exec: async (command: string, args: string[] = []) => { calls.push({ command, args }); if (