mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Handle Windows SSH directory browsing fallback (#7123)
* Allow Windows SSH directory browsing to recover The remote project picker runs before the relay filesystem ACLs exist, so it uses a raw SSH exec channel with POSIX shell commands. Windows OpenSSH targets whose default shell is cmd.exe reject Orca's POSIX exec wrapper, which prevented browsing any remote directories. Keep the existing POSIX path as the first attempt, then fall back to a narrowly scoped PowerShell listing that emits the same line-based format. Constraint: Add Remote Project needs raw SSH browsing before relay roots are registered Constraint: Windows OpenSSH may use cmd.exe as the remote command shell Rejected: Replace the POSIX command for all hosts | riskier for existing Linux/macOS SSH targets Confidence: medium Scope-risk: narrow Directive: Keep POSIX browsing as the primary path; Windows fallback exists only for shell-wrapper rejection errors Tested: npx --yes pnpm@10.24.0 exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-browse.test.ts Tested: npx --yes pnpm@10.24.0 exec oxlint src/main/ipc/ssh-browse.ts src/main/ipc/ssh-browse.test.ts Tested: npx --yes pnpm@10.24.0 exec oxfmt --check src/main/ipc/ssh-browse.ts src/main/ipc/ssh-browse.test.ts Tested: npx --yes pnpm@10.24.0 run typecheck:node Not-tested: Live Windows OpenSSH host with cmd.exe default shell * fix(ssh-browse): strip CRLF in Windows PowerShell browse output Windows OpenSSH exec emits CRLF, but the browse parser split on \n only and never stripped the trailing \r. Every directory line then failed the endsWith('/') check and was misclassified as a file with a stray CR in its name (and resolvedPath kept a trailing \r) — breaking the exact Windows path this PR adds. Split on /\r?\n/ to match the existing ssh-relay-versioned-install convention; POSIX (LF-only) output and filenames with legitimate leading/trailing spaces are unaffected. The added fallback test used \n-only fixtures that no real Windows host produces, masking the bug; switch it to realistic CRLF output so it now guards the regression. Co-authored-by: Orca <help@stably.ai> * fix(ssh-browse): locale-independent Windows fallback + UTF-8 output - Trigger the PowerShell fallback on cmd.exe's locale-independent 9009 exit code, not just English/Spanish stderr text, so non-English Windows hosts actually recover. - Pin [Console]::OutputEncoding to UTF-8 in the PowerShell script so non-ASCII names (e.g. C:\Users\José, CJK, Cyrillic) aren't mojibake'd when decoded. - Rethrow the original POSIX error when the PowerShell retry also fails, so a false-positive predicate match doesn't mask the real failure. - Tests: decode the -EncodedCommand payload to guard injection-safe escaping and the UTF-8 pin; add a 9009-locale fallback case and a negative-predicate (permission denied must not retry) case. - Drop an incorrect sentence from the CRLF-split comment. Co-authored-by: Orca <help@stably.ai> * fix(ssh-browse): surface PowerShell error on proven-Windows fallback When the fallback was triggered by cmd.exe's 9009 exit, the host is provably Windows and PowerShell genuinely ran, so its error ('Cannot find path' / 'Access is denied') is the real cause — surface it instead of the misleading cmd.exe "exec is not recognized" prose. Only the string-heuristic path (a possible POSIX false positive) still rethrows the original error. Also: note the system-ssh transport's 8-bit exit-code truncation caveat in the 9009 comment, and add tests for both double-failure paths (9009 -> surface PowerShell error; heuristic -> surface original). Co-authored-by: Orca <help@stably.ai> * test(ssh-browse): guard the ~ -> $HOME PowerShell fallback branch The tilde expansion in powerShellPathExpression (~ is the default browse path) was unguarded — collapsing it to a literal would pass the whole suite. Add a fallback test asserting the decoded script contains $dir = $HOME. Also tighten the 9009 comment: the locale-independent trigger only holds for a cmd.exe DefaultShell on the ssh2 transport; a powershell.exe DefaultShell exits 1 (not 9009) and, like the system-ssh transport, relies on the stderr heuristics. Co-authored-by: Orca <help@stably.ai> * fix(ssh-browse): emit forward-slash resolvedPath from Windows fallback Get-Location.ProviderPath returns a native backslash path (C:\Users\alice), but the renderer's parentPath/joinPath only split on '/': backslash paths make the browser's 'Up' button a no-op and produce mixed separators when descending. Normalize the emitted resolvedPath to forward slashes (matching the POSIX branch) while keeping the native $resolved for Get-ChildItem -LiteralPath. Update the fallback-test fixtures to the real forward-slash output and assert the -replace normalization is present in the generated script. Co-authored-by: Orca <help@stably.ai> * fix(ssh-browse): root Windows drive paths in the PowerShell fallback The forward-slash resolvedPath means the renderer rebuilds Windows paths with POSIX helpers, so it hands back drive paths Set-Location mishandles: the breadcrumb prepends a spurious leading '/' (/C:/Users -> current drive's root), and 'Up' from a first-level dir yields a bare drive letter ('C:' is drive-relative, not C:\). Normalize both to a rooted drive path in powerShellPathExpression so navigation lands where the user clicked. POSIX, UNC, and relative paths are left untouched. Add parametrized tests for /C:/Users and C: -> rooted $dir literals. Co-authored-by: Orca <help@stably.ai> * test(ssh-browse): cover combined /C: drive-path normalization Guards the strip-then-root ordering in normalizeWindowsDrivePath so a future refactor can't regress the combined leading-slash + bare-drive case. Co-authored-by: Orca <help@stably.ai> * fix(ssh-browse): trigger Windows fallback on non-zero exit, not exit 9009 Verified on real Windows OpenSSH + cmd.exe that a rejected POSIX exec wrapper arrives over SSH as process exit code 1, not 9009 (cmd.exe's 9009 ERRORLEVEL never crosses its process boundary; sshd forwards the process exit code). Confirmed on both the ssh2 and system-ssh transports. The old trigger keyed off exit 9009 (dead code) with an English/Spanish stderr string fallback, so non-English cmd.exe hosts (German/French/ Japanese/etc.) never fell back and directory browsing failed. Fix: retry via PowerShell whenever the POSIX attempt fails with a RemoteBrowseError (command ran, exited non-zero) - locale-independent and covers every cmd.exe locale. Transport errors/timeouts aren't RemoteBrowseErrors, so dropped connections aren't mis-retried. Pick the error to surface via the POSIX "command not found" exit 127 (no powershell.exe means the host isn't Windows, so surface the original POSIX error). Removes the fragile 9009/stderr-string heuristics. Tests: correct injected exit codes to the real value (1), add a Japanese-locale regression test, and lock the retry/no-mask contract. --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai> Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
This commit is contained in:
co-authored by
Jinwoo-H
Orca
Jinwoo Hong
parent
98d850aeba
commit
b23fcf3b2b
@@ -25,6 +25,16 @@ function createMockChannel(): EventEmitter & { stderr: EventEmitter } {
|
||||
})
|
||||
}
|
||||
|
||||
// Recover the PowerShell script from a `powershell.exe ... -EncodedCommand <b64>`
|
||||
// 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)
|
||||
|
||||
+267
-144
@@ -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 <path> && 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<typeof setTimeout> | 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<ReturnType<SshConnectionManager['getConnection']>>
|
||||
|
||||
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 <path> && 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<typeof setTimeout> | 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user