diff --git a/src/main/ipc/ssh-browse.test.ts b/src/main/ipc/ssh-browse.test.ts index 4ca47c3aa63..6d04e8c7b0f 100644 --- a/src/main/ipc/ssh-browse.test.ts +++ b/src/main/ipc/ssh-browse.test.ts @@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } { }) } +// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand ` +// command so tests can assert on the actual (UTF-16LE) payload sent to the host. +function decodeEncodedCommand(command: string): string { + const match = /-EncodedCommand (\S+)/.exec(command) + if (!match) { + throw new Error(`no -EncodedCommand in: ${command}`) + } + return Buffer.from(match[1], 'base64').toString('utf16le') +} + describe('registerSshBrowseHandler', () => { let handler: BrowseHandler @@ -88,6 +98,275 @@ describe('registerSshBrowseHandler', () => { expect(exec).toHaveBeenCalledWith("cd '/tmp/it'\\''s here' && pwd && command ls -1Ap") }) + it('falls back to PowerShell when a Windows SSH shell rejects POSIX exec', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users/alice' }) + await Promise.resolve() + posixChannel.stderr.emit( + 'data', + Buffer.from('"exec" no se reconoce como un comando interno o externo') + ) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + // Windows OpenSSH exec emits CRLF; the parser must strip \r so directories + // aren't misclassified as files with a stray carriage return in the name. + // The script emits a forward-slash resolvedPath (the -replace '\\','/' line) + // so the renderer's parentPath/joinPath, which only split on `/`, still work. + windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\nDesktop/\r\nnotes.txt\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: 'C:/Users/alice', + entries: [ + { name: 'Desktop', isDirectory: true }, + { name: 'notes.txt', isDirectory: false } + ] + }) + expect(exec).toHaveBeenCalledTimes(2) + expect(exec).toHaveBeenNthCalledWith(1, "cd 'C:/Users/alice' && pwd && command ls -1Ap") + expect(exec.mock.calls[1]?.[0]).toMatch(/^powershell\.exe /) + expect(exec.mock.calls[1]?.[1]).toEqual({ wrapCommand: false }) + + // Decode the -EncodedCommand payload so an accidental switch from the + // single-quote-escaped PowerShell literal to raw interpolation (an injection + // regression) is caught, and to lock in the UTF-8 output pin. + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + expect(script).toContain('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') + expect(script).toContain("$dir = 'C:/Users/alice'") + expect(script).toContain('Get-ChildItem -LiteralPath $resolved -Force') + // resolvedPath must be emitted with forward slashes so the renderer's + // parentPath/joinPath (which only split on `/`) keep working on Windows. + expect(script).toContain("Write-Output ($resolved -replace '\\\\', '/')") + }) + + it('falls back for a non-English cmd.exe reject (exit 1, localized stderr)', async () => { + // Regression: real Windows OpenSSH + cmd.exe forwards exit 1 (not 9009) with + // localized stderr. The old 9009/English-string trigger silently missed this, + // so German/Japanese/etc. hosts never fell back. Keying off the non-zero exit + // of a RemoteBrowseError fixes it regardless of OS display language. + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/Users' }) + await Promise.resolve() + // Japanese cmd.exe "not recognized" text — matches none of the removed English + // /Spanish substrings, and exit 1 is not the removed 9009 sentinel. + posixChannel.stderr.emit( + 'data', + Buffer.from("'exec' は、内部コマンドとして認識されていません。") + ) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users\r\nAdmin/\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: 'C:/Users', + entries: [{ name: 'Admin', isDirectory: true }] + }) + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('escapes single quotes in the PowerShell literal path', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: "C:/O'Brien" }) + await Promise.resolve() + // A cmd.exe reject exits 1 over SSH (its 9009 ERRORLEVEL never crosses the + // process boundary) with localized German stderr. The fallback must still fire, + // since it keys off the non-zero exit, not the (localized, unmatchable) text. + posixChannel.stderr.emit('data', Buffer.from('Der Befehl "exec" ist falsch geschrieben')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from("C:/O'Brien\r\n")) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ + resolvedPath: "C:/O'Brien", + entries: [] + }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + // Single quote must be doubled inside the PowerShell literal, not passed raw. + expect(script).toContain("$dir = 'C:/O''Brien'") + }) + + it('expands ~ to $HOME in the PowerShell fallback (the default browse path)', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '~' }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users/alice\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users/alice', entries: [] }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + // ~ must expand to $HOME, not be passed literally to Set-Location. + expect(script).toContain('$dir = $HOME') + }) + + // The renderer rebuilds forward-slash Windows paths with POSIX helpers: the + // breadcrumb prepends a spurious leading '/' before the drive, and "Up" from a + // first-level dir yields a bare drive letter. Both must be rooted for + // Set-Location, or navigation lands in the drive-relative cwd / errors. + it.each([ + { dirPath: '/C:/Users', expected: "$dir = 'C:/Users'" }, + { dirPath: 'C:', expected: "$dir = 'C:/'" }, + // Combined strip + root, so a future refactor can't break the ordering. + { dirPath: '/C:', expected: "$dir = 'C:/'" } + ])( + 'roots the Windows drive path $dirPath in the PowerShell fallback', + async ({ dirPath, expected }) => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.emit('data', Buffer.from('C:/Users\r\n')) + windowsChannel.emit('exit', 0) + windowsChannel.emit('close') + + await expect(resultPromise).resolves.toEqual({ resolvedPath: 'C:/Users', entries: [] }) + const script = decodeEncodedCommand(exec.mock.calls[1]?.[0] ?? '') + expect(script).toContain(expected) + } + ) + + it('surfaces the original POSIX failure when the PowerShell retry shows the host is not Windows', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/root/secret' }) + await Promise.resolve() + // A genuine POSIX permission failure exits non-zero; it's indistinguishable + // from a Windows shell reject without probing, so the fallback is attempted... + posixChannel.stderr.emit('data', Buffer.from('ls: /root/secret: Permission denied')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + // ...but on a POSIX host the login shell can't find powershell.exe (exit 127), + // so the original permission error is surfaced, never masked by the retry. + windowsChannel.stderr.emit('data', Buffer.from('bash: powershell.exe: command not found')) + windowsChannel.emit('exit', 127) + windowsChannel.emit('close') + + await expect(resultPromise).rejects.toThrow('Permission denied') + expect(exec).toHaveBeenCalledTimes(2) + }) + + it('surfaces the PowerShell error when the Windows fallback runs but fails', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: 'C:/missing' }) + await Promise.resolve() + posixChannel.stderr.emit('data', Buffer.from('"exec" is not recognized')) + posixChannel.emit('exit', 1) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.stderr.emit('data', Buffer.from('Cannot find path C:/missing')) + windowsChannel.emit('exit', 1) + windowsChannel.emit('close') + + // PowerShell exited non-127, so it genuinely ran on a Windows host — its error + // is the real cause. Surface it rather than the cmd.exe "not recognized" prose. + await expect(resultPromise).rejects.toThrow('Cannot find path') + }) + + it('surfaces the original POSIX error when the fallback shows powershell.exe is missing', async () => { + const posixChannel = createMockChannel() + const windowsChannel = createMockChannel() + const exec = vi.fn().mockResolvedValueOnce(posixChannel).mockResolvedValueOnce(windowsChannel) + const getConnectionManager = () => ({ + getConnection: () => ({ exec }) + }) + registerSshBrowseHandler(getConnectionManager as never) + + const resultPromise = handler(null, { targetId: 'ssh-1', dirPath: '/opt/exec' }) + await Promise.resolve() + // A non-zero POSIX failure triggers the fallback probe on any host... + posixChannel.stderr.emit('data', Buffer.from('exec: command not found')) + posixChannel.emit('exit', 127) + posixChannel.emit('close') + await vi.waitFor(() => { + expect(windowsChannel.listenerCount('close')).toBe(1) + }) + windowsChannel.stderr.emit('data', Buffer.from('sh: powershell.exe: not found')) + windowsChannel.emit('exit', 127) + windowsChannel.emit('close') + + // The original POSIX failure is the real one — don't mask it with the + // misleading "powershell.exe: not found" from the doomed retry. + await expect(resultPromise).rejects.toThrow('exec: command not found') + }) + it('rejects and detaches listeners when the browse channel errors', async () => { const channel = createMockChannel() const exec = vi.fn().mockResolvedValue(channel) @@ -101,6 +380,9 @@ describe('registerSshBrowseHandler', () => { channel.emit('error', new Error('remote disconnected')) await expect(resultPromise).rejects.toThrow('remote disconnected') + // A transport failure is not a RemoteBrowseError, so it must not trigger a + // pointless PowerShell retry. + expect(exec).toHaveBeenCalledTimes(1) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('exit')).toBe(0) expect(channel.listenerCount('close')).toBe(0) diff --git a/src/main/ipc/ssh-browse.ts b/src/main/ipc/ssh-browse.ts index 83afbd9de01..2729cd59ab7 100644 --- a/src/main/ipc/ssh-browse.ts +++ b/src/main/ipc/ssh-browse.ts @@ -1,5 +1,7 @@ import { ipcMain } from 'electron' import type { SshConnectionManager } from '../ssh/ssh-connection' +import type { SshExecOptions } from '../ssh/ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell' export type RemoteDirEntry = { name: string @@ -8,6 +10,33 @@ export type RemoteDirEntry = { const SSH_BROWSE_TIMEOUT_MS = 15_000 +// Why: a POSIX login shell that can't find powershell.exe exits 127 (the POSIX +// "command not found" convention, identical across sh/bash/zsh and locales). It's +// the locale-independent signal that the Windows fallback never actually ran, so +// the original POSIX failure — not the doomed retry — is the real error. +// +// Note: cmd.exe's ERRORLEVEL for an unrecognized command is 9009, but that value +// never crosses cmd.exe's process boundary. sshd forwards cmd.exe's *process* exit +// code, which is 1 — verified on real Windows OpenSSH + cmd.exe over both the ssh2 +// and system-ssh transports. So a Windows host rejecting Orca's POSIX `exec` +// wrapper is detected by "the remote command ran and exited non-zero" +// (RemoteBrowseError), not by a magic exit code or localized stderr text. +const POSIX_COMMAND_NOT_FOUND_EXIT = 127 + +// Carries the raw exit code so the fallback can (a) recognize that the remote +// command actually ran and failed — the locale-independent trigger for the +// Windows retry — and (b) tell a POSIX "powershell.exe not found" (127) apart +// from a genuine PowerShell error, without parsing localized shell prose. +class RemoteBrowseError extends Error { + constructor( + message: string, + readonly exitCode: number | null + ) { + super(message) + this.name = 'RemoteBrowseError' + } +} + // Why: the relay's fs.readDir enforces workspace root ACLs, which aren't // registered until a repo is added. This handler uses a raw SSH exec channel // to list directories, allowing the user to browse the remote filesystem @@ -32,156 +61,229 @@ export function registerSshBrowseHandler( throw new Error(`SSH connection "${args.targetId}" not found`) } - // Why: using one line per entry preserves filenames containing spaces. - // `command ls` bypasses user aliases/functions like `ls='eza ...'`. - // The -1 flag outputs one entry per line. The -p flag appends / to directories. - // We resolve ~ and get the absolute path via `cd && pwd`. - // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission - // denied after a readable `cd ... && pwd`) propagates as a non-zero exit - // code rather than being indistinguishable from an empty directory. - const command = `cd ${shellEscape(args.dirPath)} && pwd && command ls -1Ap` - const channel = await conn.exec(command) - - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let exitCode: number | null = null - let settled = false - let timeout: ReturnType | null = null - - const cleanup = (): void => { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('exit', onExit) - channel.off('close', onClose) - channel.off('error', onError) - channel.stderr.off('error', onError) + try { + return await browseWithPosixShell(conn, args.dirPath) + } catch (posixError) { + // Why: a Windows login shell (cmd.exe/PowerShell) rejects Orca's POSIX + // `exec` wrapper, and the only locale-independent signal for that is "the + // remote command executed and exited non-zero" (RemoteBrowseError). Its + // stderr prose is localized, and cmd.exe's 9009 ERRORLEVEL never reaches + // us (sshd forwards process exit 1). Transport errors/timeouts aren't + // RemoteBrowseErrors, so a dropped connection is never retried as Windows. + if (!(posixError instanceof RemoteBrowseError)) { + throw posixError } - const rejectOnce = (error: Error): void => { - if (settled) { - return - } - settled = true - cleanup() - reject(error) + try { + return await browseWithWindowsPowerShell(conn, args.dirPath) + } catch (fallbackError) { + // Why: if the login shell couldn't find powershell.exe (exit 127) the + // host isn't Windows — surface the original POSIX failure rather than a + // misleading "powershell.exe: not found". Otherwise PowerShell genuinely + // ran and its error (e.g. "Cannot find path") is the real cause. + throw isPosixCommandNotFound(fallbackError) ? posixError : fallbackError } - const closeChannel = (): void => { - const closable = channel as { close?: () => void; destroy?: () => void } - try { - if (typeof closable.close === 'function') { - closable.close() - } else if (typeof closable.destroy === 'function') { - closable.destroy() - } - } catch { - /* best effort */ - } - } - const onTimeout = (): void => { - // Why: remote browsing runs before a relay workspace root exists, so - // it cannot rely on relay request deadlines. Bound this raw exec - // channel directly to keep Add Remote Project from hanging forever. - rejectOnce(new Error('Remote directory listing timed out')) - closeChannel() - } - const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve(result) - } - - const onStdoutData = (data: Buffer): void => { - stdout += data.toString() - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString() - } - // `exit` fires before `close`; capture the code so we can distinguish - // a failed `ls` that still produced `pwd` output from an empty listing. - const onExit = (code: number | null): void => { - exitCode = code - } - const onError = (error: Error): void => { - rejectOnce(error) - } - const onClose = (): void => { - // A null exitCode means the server closed the channel without - // sending an exit-status message (or signalled termination). We - // can't assume success — falling back to "empty stdout = empty - // directory" is exactly the bug the exit-code branch was added to - // fix. Treat any non-zero OR null exit as a failure when stderr - // has content, and otherwise require stdout to contain at least - // the resolved `pwd` line before accepting the result. - if (exitCode !== 0) { - const msg = - stderr.trim() || - (exitCode === null - ? 'Remote listing failed (channel closed without exit status)' - : `Remote listing failed (exit ${exitCode})`) - rejectOnce(new Error(msg)) - return - } - if (stderr.trim() && !stdout.trim()) { - rejectOnce(new Error(stderr.trim())) - return - } - - const lines = stdout.trim().split('\n') - if (lines.length === 0) { - rejectOnce(new Error('Empty response from remote')) - return - } - - const resolvedPath = lines[0] - const entries: RemoteDirEntry[] = [] - - for (let i = 1; i < lines.length; i++) { - const line = lines[i] - if (!line || line === './' || line === '../') { - continue - } - if (line.endsWith('/')) { - entries.push({ name: line.slice(0, -1), isDirectory: true }) - } else { - entries.push({ name: line, isDirectory: false }) - } - } - - // Sort: directories first, then alphabetical - entries.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1 - } - return a.name.localeCompare(b.name) - }) - - resolveOnce({ entries, resolvedPath }) - } - - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('exit', onExit) - channel.on('close', onClose) - // Why: SSH exec streams emit `error` on transport loss; without a - // scoped listener, a disappearing remote can become process-fatal. - channel.on('error', onError) - channel.stderr.on('error', onError) - timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) - if (typeof timeout.unref === 'function') { - timeout.unref() - } - }) + } } ) } +type SshBrowseConnection = NonNullable> + +function browseWithPosixShell( + conn: SshBrowseConnection, + dirPath: string +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + // Why: using one line per entry preserves filenames containing spaces. + // `command ls` bypasses user aliases/functions like `ls='eza ...'`. + // The -1 flag outputs one entry per line. The -p flag appends / to directories. + // We resolve ~ and get the absolute path via `cd && pwd`. + // `cd` and `ls` are chained with `&&` so a failing `ls` (e.g. permission + // denied after a readable `cd ... && pwd`) propagates as a non-zero exit + // code rather than being indistinguishable from an empty directory. + return runBrowseCommand(conn, `cd ${shellEscape(dirPath)} && pwd && command ls -1Ap`) +} + +function browseWithWindowsPowerShell( + conn: SshBrowseConnection, + dirPath: string +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + const script = [ + "$ErrorActionPreference = 'Stop'", + // Why: Windows PowerShell 5.1 writes redirected stdout in the legacy OEM + // code page, but runBrowseCommand decodes as UTF-8; pin UTF-8 output so + // non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) don't come back mojibake. + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + `$dir = ${powerShellPathExpression(dirPath)}`, + 'Set-Location -LiteralPath $dir', + '$resolved = (Get-Location).ProviderPath', + // Why: the renderer's parentPath/joinPath only split on `/`, so a native + // backslash path (C:\Users\alice) breaks "Up" and mixes separators. Emit a + // forward-slash resolvedPath (matching the POSIX branch) while keeping the + // native $resolved for Get-ChildItem -LiteralPath. + "Write-Output ($resolved -replace '\\\\', '/')", + 'Get-ChildItem -LiteralPath $resolved -Force | ForEach-Object {', + " if ($_.PSIsContainer) { Write-Output ($_.Name + '/') } else { Write-Output $_.Name }", + '}' + ].join('; ') + + return runBrowseCommand(conn, powerShellCommand(script), { wrapCommand: false }) +} + +async function runBrowseCommand( + conn: SshBrowseConnection, + command: string, + options?: SshExecOptions +): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> { + const channel = options ? await conn.exec(command, options) : await conn.exec(command) + + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let exitCode: number | null = null + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('exit', onExit) + channel.off('close', onClose) + channel.off('error', onError) + channel.stderr.off('error', onError) + } + const rejectOnce = (error: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const closeChannel = (): void => { + const closable = channel as { close?: () => void; destroy?: () => void } + try { + if (typeof closable.close === 'function') { + closable.close() + } else if (typeof closable.destroy === 'function') { + closable.destroy() + } + } catch { + /* best effort */ + } + } + const onTimeout = (): void => { + // Why: remote browsing runs before a relay workspace root exists, so + // it cannot rely on relay request deadlines. Bound this raw exec + // channel directly to keep Add Remote Project from hanging forever. + rejectOnce(new Error('Remote directory listing timed out')) + closeChannel() + } + const resolveOnce = (result: { entries: RemoteDirEntry[]; resolvedPath: string }): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve(result) + } + + const onStdoutData = (data: Buffer): void => { + stdout += data.toString() + } + const onStderrData = (data: Buffer): void => { + stderr += data.toString() + } + // `exit` fires before `close`; capture the code so we can distinguish + // a failed `ls` that still produced `pwd` output from an empty listing. + const onExit = (code: number | null): void => { + exitCode = code + } + const onError = (error: Error): void => { + rejectOnce(error) + } + const onClose = (): void => { + // A null exitCode means the server closed the channel without + // sending an exit-status message (or signalled termination). We + // can't assume success — falling back to "empty stdout = empty + // directory" is exactly the bug the exit-code branch was added to + // fix. Treat any non-zero OR null exit as a failure when stderr + // has content, and otherwise require stdout to contain at least + // the resolved `pwd` line before accepting the result. + if (exitCode !== 0) { + const msg = + stderr.trim() || + (exitCode === null + ? 'Remote listing failed (channel closed without exit status)' + : `Remote listing failed (exit ${exitCode})`) + rejectOnce(new RemoteBrowseError(msg, exitCode)) + return + } + if (stderr.trim() && !stdout.trim()) { + rejectOnce(new Error(stderr.trim())) + return + } + + // Why: Windows OpenSSH exec emits CRLF, so split on \r?\n — otherwise a + // trailing \r defeats the endsWith('/') dir check and leaves a stray CR + // in every name. + const lines = stdout.trim().split(/\r?\n/) + if (lines.length === 0) { + rejectOnce(new Error('Empty response from remote')) + return + } + + const resolvedPath = lines[0] + const entries: RemoteDirEntry[] = [] + + for (let i = 1; i < lines.length; i++) { + const line = lines[i] + if (!line || line === './' || line === '../') { + continue + } + if (line.endsWith('/')) { + entries.push({ name: line.slice(0, -1), isDirectory: true }) + } else { + entries.push({ name: line, isDirectory: false }) + } + } + + // Sort: directories first, then alphabetical + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) { + return a.isDirectory ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) + + resolveOnce({ entries, resolvedPath }) + } + + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('exit', onExit) + channel.on('close', onClose) + // Why: SSH exec streams emit `error` on transport loss; without a + // scoped listener, a disappearing remote can become process-fatal. + channel.on('error', onError) + channel.stderr.on('error', onError) + timeout = setTimeout(onTimeout, SSH_BROWSE_TIMEOUT_MS) + if (typeof timeout.unref === 'function') { + timeout.unref() + } + }) +} + +// Why: a POSIX login shell that can't find powershell.exe exits 127, marking the +// Windows fallback as "never ran" — the host isn't Windows, so the original POSIX +// failure, not the doomed retry, is the error worth surfacing. +function isPosixCommandNotFound(error: unknown): boolean { + return error instanceof RemoteBrowseError && error.exitCode === POSIX_COMMAND_NOT_FOUND_EXIT +} + // Why: prevent shell injection in the directory path. Single-quote wrapping // with escaped internal single quotes is the safest approach for sh/bash. // Tilde must be expanded by the shell, so paths starting with ~ use $HOME @@ -199,3 +301,24 @@ function shellEscape(s: string): string { function shellEscapeRaw(s: string): string { return `'${s.replace(/'/g, "'\\''")}'` } + +function powerShellPathExpression(s: string): string { + if (s === '~') { + return '$HOME' + } + if (s.startsWith('~/') || s.startsWith('~\\')) { + return `Join-Path $HOME ${powerShellLiteral(s.slice(2))}` + } + return powerShellLiteral(normalizeWindowsDrivePath(s)) +} + +// Why: browse emits forward-slash Windows paths, so the renderer rebuilds them +// with POSIX helpers — the breadcrumb prepends a spurious leading '/' before the +// drive (/C:/Users) and "Up" from a first-level dir yields a bare drive letter +// (C:). Both are wrong for Set-Location: a leading '/' means the current drive's +// root, and 'C:' is drive-relative (the process cwd), not 'C:\'. Normalize both +// back to a rooted drive path here so navigation lands where the user clicked. +function normalizeWindowsDrivePath(s: string): string { + const stripped = s.replace(/^\/(?=[A-Za-z]:(?:[/\\]|$))/, '') + return /^[A-Za-z]:$/.test(stripped) ? `${stripped}/` : stripped +}