diff --git a/src/main/external-editor-launch.test.ts b/src/main/external-editor-launch.test.ts index 638e1ff02f7..bd7d27509e2 100644 --- a/src/main/external-editor-launch.test.ts +++ b/src/main/external-editor-launch.test.ts @@ -405,14 +405,44 @@ describe('resolveExternalEditorLaunchSpec', () => { } ) + it.each([ + ['C:\\Tools\\cursor.exe', '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project', 'wsl+Ubuntu'], + [ + 'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\resources\\app\\bin\\cursor.cmd', + '\\\\wsl$\\Debian\\home\\ada\\project', + 'wsl+Debian' + ] + ])('opens WSL UNC workspaces with %s in a new WSL remote window', (command, pathValue, auth) => { + expect(resolveExternalEditorLaunchSpec(command, pathValue, { platform: 'win32' })).toEqual({ + kind: 'executable', + hideWindowsConsole: true, + spawnCmd: command, + spawnArgs: ['--new-window', '--remote', auth, '/home/ada/project'] + }) + }) + + it.each(['darwin', 'linux'] as const)( + 'keeps WSL-looking Cursor paths local on %s', + (platform) => { + const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' + expect(resolveExternalEditorLaunchSpec('cursor', pathValue, { platform }).spawnArgs).toEqual([ + '--new-window', + pathValue + ]) + } + ) + + it('keeps non-WSL Windows paths local for Cursor', () => { + expect( + resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', '\\\\server\\share\\project', { + platform: 'win32' + }).spawnArgs + ).toEqual(['--new-window', '\\\\server\\share\\project']) + }) + it('does not add VS Code remote arguments to other editors', () => { const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project' - expect( - resolveExternalEditorLaunchSpec('C:\\Tools\\cursor.exe', pathValue, { - platform: 'win32' - }).spawnArgs - ).toEqual(['--new-window', pathValue]) expect( resolveExternalEditorLaunchSpec('C:\\Tools\\codium.exe', pathValue, { platform: 'win32' diff --git a/src/main/external-editor-launch.ts b/src/main/external-editor-launch.ts index 6fa0e867508..528256cb4ff 100644 --- a/src/main/external-editor-launch.ts +++ b/src/main/external-editor-launch.ts @@ -98,20 +98,25 @@ function buildExecutableArgs( pathValue: string, platform: NodeJS.Platform ): string[] { - const launcherBaseName = getLauncherBaseName(editorCommand) - if (launcherBaseName === 'cursor') { - // Why: Cursor can route bare folder launches through the last active - // workbench. A new window keeps "Open in Cursor" scoped to this worktree. - return ['--new-window', pathValue] + const isCursor = getLauncherBaseName(editorCommand) === 'cursor' + const targetArgs = buildWslRemoteTargetArgs(editorCommand, pathValue, platform, isCursor) + // Why: Cursor can route bare folder launches through the last active + // workbench. A new window keeps "Open in Cursor" scoped to this worktree. + return isCursor ? ['--new-window', ...targetArgs] : targetArgs +} + +function buildWslRemoteTargetArgs( + editorCommand: string, + pathValue: string, + platform: NodeJS.Platform, + isCursor: boolean +): string[] { + if (platform !== 'win32' || !(isCursor || isVsCodeLauncherExecutable(editorCommand))) { + return [pathValue] } - if (platform === 'win32' && isVsCodeLauncherExecutable(editorCommand)) { - const wslPath = parseWslUncPath(pathValue) - if (wslPath) { - // Why: VS Code otherwise treats a WSL UNC path as a local Windows folder. - return ['--remote', `wsl+${wslPath.distro}`, wslPath.linuxPath] - } - } - return [pathValue] + const wslPath = parseWslUncPath(pathValue) + // Why: both editors otherwise treat a WSL UNC path as a local Windows folder. + return wslPath ? ['--remote', `wsl+${wslPath.distro}`, wslPath.linuxPath] : [pathValue] } function isCompoundShellCommand(command: string): boolean { diff --git a/src/wsl-guest/preflight-handler.test.ts b/src/wsl-guest/preflight-handler.test.ts index 0a415f72015..8da1f6cf682 100644 --- a/src/wsl-guest/preflight-handler.test.ts +++ b/src/wsl-guest/preflight-handler.test.ts @@ -40,28 +40,20 @@ import { PreflightHandler } from './preflight-handler' -function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { +function shLookupScript(command: string): string { return [ - mode, - [ - buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), - 'if [ -n "$resolved" ]; then', - 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', - 'fi' - ].join('\n') - ] + buildPosixCommandPathLookupScript( + { kind: 'literal', value: command }, + { skipWindowsMountDirs: true } + ), + 'if [ -n "$resolved" ]; then', + 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', + 'fi' + ].join('\n') } -function fishLookupArgs(command: string): string[] { - return [ - '-ilc', - [ - `set -l resolved (command -v ${command} 2>/dev/null)`, - 'if test -n "$resolved"', - 'printf \'__ORCA_AGENT_PATH__%s\\n\' "$resolved"', - 'end' - ].join('\n') - ] +function lookupArgs(command: string, mode: '-lc' | '-ilc' = '-lc'): string[] { + return [mode, shLookupScript(command)] } beforeEach(() => { @@ -102,10 +94,18 @@ describe('buildCommandLookupSpecs', () => { }) }) - it('uses fish syntax for trusted fish shells', () => { + it('skips Windows-mounted PATH dirs so a Windows-only shim is not a guest install', () => { + const [shellSpec] = buildCommandLookupSpecs('codex', 'linux', { SHELL: '/bin/bash' }, null) + expect(shellSpec.args[1]).toContain('/proc/mounts') + expect(shellSpec.args[1]).toBe(shLookupScript('codex')) + }) + + it('runs the POSIX lookup under the fish login PATH for trusted fish shells', () => { + const script = shLookupScript('codex') + const fishQuoted = `'${script.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` expect(buildCommandLookupSpecs('codex', 'linux', { SHELL: '/usr/bin/fish' }, null)[0]).toEqual({ file: '/usr/bin/fish', - args: fishLookupArgs("'codex'") + args: ['-ilc', `exec /bin/sh -c ${fishQuoted}`] }) }) diff --git a/src/wsl-guest/preflight-handler.ts b/src/wsl-guest/preflight-handler.ts index 28324b95a7e..77a41217f33 100644 --- a/src/wsl-guest/preflight-handler.ts +++ b/src/wsl-guest/preflight-handler.ts @@ -268,7 +268,12 @@ function buildPosixCommandLookupSpec(command: string, shell: string): CommandLoo function buildShCommandLookupScript(command: string): string { // Why: login shells may define aliases or functions that mask the PATH executable. return [ - buildPosixCommandPathLookupScript({ kind: 'literal', value: command }), + // Why: WSL appends the Windows PATH, so a Windows-only npm shim would + // otherwise read as a guest install; same skip as host-side WSL detection. + buildPosixCommandPathLookupScript( + { kind: 'literal', value: command }, + { skipWindowsMountDirs: true } + ), 'if [ -n "$resolved" ]; then', `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, 'fi' @@ -276,13 +281,8 @@ function buildShCommandLookupScript(command: string): string { } function buildFishCommandLookupScript(command: string): string { - const quotedCommand = shellQuote(command) - return [ - `set -l resolved (command -v ${quotedCommand} 2>/dev/null)`, - 'if test -n "$resolved"', - `printf '${AGENT_PATH_PREFIX}%s\\n' "$resolved"`, - 'end' - ].join('\n') + // Why: fish only supplies its login PATH; the POSIX walk owns the Windows-mount skip. + return `exec /bin/sh -c ${fishQuote(buildShCommandLookupScript(command))}` } function resolveAccountLoginShell( @@ -327,6 +327,7 @@ function getShellCommandMode(shell: string): '-lc' | '-ilc' { return shellName === 'sh' || shellName === 'dash' ? '-lc' : '-ilc' } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'` +// Fish single quotes treat only `\\` and `\'` as escapes. +function fishQuote(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` }