-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Expand file tree
/
Copy pathssh-browse.ts
More file actions
342 lines (315 loc) · 13.6 KB
/
Copy pathssh-browse.ts
File metadata and controls
342 lines (315 loc) · 13.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
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
isDirectory: boolean
isSymlink: boolean
}
// Listing lines are `<l|-><d|->/name`: symlink flag, directory flag, then the
// entry name. Names cannot contain `/`, so the first `/` always ends the
// prefix; anything else on stdout (motd noise, blank lines) is skipped.
const BROWSE_ENTRY_LINE = /^([l-])([d-])\/(.*)$/
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
// during the "add remote project" flow before any roots exist.
export function registerSshBrowseHandler(
getConnectionManager: () => SshConnectionManager | null
): void {
ipcMain.removeHandler('ssh:browseDir')
ipcMain.handle(
'ssh:browseDir',
async (
_event,
args: { targetId: string; dirPath: string }
): Promise<{ entries: RemoteDirEntry[]; resolvedPath: string }> => {
const mgr = getConnectionManager()
if (!mgr) {
throw new Error('SSH connection manager not initialized')
}
const conn = mgr.getConnection(args.targetId)
if (!conn) {
throw new Error(`SSH connection "${args.targetId}" not found`)
}
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
}
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
}
}
}
)
}
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 ...'`.
// We resolve ~ and get the absolute path via `cd <path> && pwd`.
// Each entry is emitted as `<l|-><d|->/name`: the directory flag comes from
// `[ -d ]`, which follows symlinks (unlike `ls -p`, which would list a
// symlinked directory as a file), and `[ -h ]` flags the link itself so the
// renderer can show a symlink indicator. Capturing `ls` output into a
// variable keeps a failing `ls` (e.g. permission denied after a readable
// `cd ... && pwd`) propagating through the `&&` chain as a non-zero exit
// code rather than being masked by the while-loop pipeline and misread as
// an empty directory.
return runBrowseCommand(
conn,
`cd ${shellEscape(dirPath)} && pwd && entries=$(command ls -1A) && printf '%s\\n' "$entries" | while IFS= read -r f; do if [ -h "$f" ]; then t=l; else t=-; fi; if [ -d "$f" ]; then d=d; else d=-; fi; printf '%s%s/%s\\n' "$t" "$d" "$f"; done`
)
}
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 {',
// Why: LinkType is populated only for symlinks/junctions, matching Node's
// isSymbolicLink() on the local/server path. The generic ReparsePoint
// attribute would also flag OneDrive/cloud placeholders as links.
" $t = if ($_.LinkType) { 'l' } else { '-' }",
" $d = if ($_.PSIsContainer) { 'd' } else { '-' }",
" Write-Output ($t + $d + '/' + $_.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 match = BROWSE_ENTRY_LINE.exec(lines[i])
if (!match) {
continue
}
const name = match[3]
if (!name || name === '.' || name === '..') {
continue
}
entries.push({ name, isDirectory: match[2] === 'd', isSymlink: match[1] === 'l' })
}
// 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
// substitution instead of literal quoting (single quotes suppress expansion).
function shellEscape(s: string): string {
if (s === '~') {
return '"$HOME"'
}
if (s.startsWith('~/')) {
return `"$HOME"/${shellEscapeRaw(s.slice(2))}`
}
return shellEscapeRaw(s)
}
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
}