Fix SSH relay connect when default shell is fish (#2239)

This commit is contained in:
Neil 2026-05-18 01:31:45 -07:00 committed by GitHub
parent db8af09f86
commit fdc38bfc33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 30 additions and 3 deletions

View File

@ -72,6 +72,12 @@ export function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`
}
export function wrapRemoteCommandForPosixShell(command: string): string {
// Why: sshd asks the user's login shell to parse exec commands. Orca emits
// POSIX sh snippets; `exec` avoids leaving that shell around for relay bridges.
return `exec /bin/sh -c ${shellEscape(command)}`
}
function cmdEscape(s: string): string {
return `"${s.replace(/"/g, '""')}"`
}
@ -200,7 +206,9 @@ export function spawnProxyCommand(
proxy.kind === 'jump-host'
? // Why: ProxyJump is structured input, not a shell snippet. Spawn ssh
// directly so jump-host values cannot escape through shell parsing.
spawn('ssh', ['-W', `${host}:${port}`, '--', proxy.jumpHost], { stdio: ['pipe', 'pipe', 'pipe'] })
spawn('ssh', ['-W', `${host}:${port}`, '--', proxy.jumpHost], {
stdio: ['pipe', 'pipe', 'pipe']
})
: (() => {
const escape = process.platform === 'win32' ? cmdEscape : shellEscape
const expanded = proxy.command

View File

@ -8,6 +8,7 @@ let connectErrorMessage = ''
type MockSshClient = {
setNoDelay: ReturnType<typeof vi.fn>
_sock: Socket | undefined
lastExecCommand?: string
}
let clientInstances: MockSshClient[] = []
@ -18,6 +19,7 @@ vi.mock('ssh2', () => {
// to decide which log line to emit. A real Socket instance lets the test
// exercise the "enabled" branch instead of the "skipped (proxy socket)" branch.
_sock: Socket | undefined = new Socket()
lastExecCommand?: string
constructor() {
clientInstances.push(this)
}
@ -35,7 +37,10 @@ vi.mock('ssh2', () => {
}
end() {}
destroy() {}
exec() {}
exec(cmd: string, cb: (err: Error | undefined, channel: unknown) => void) {
this.lastExecCommand = cmd
cb(undefined, {})
}
sftp() {}
}
return { Client: MockSshClient }
@ -205,6 +210,17 @@ describe('SshConnection', () => {
expect(resolveWithSshG).toHaveBeenCalledWith('ssh-alias')
})
it('wraps exec commands in /bin/sh so non-POSIX login shells do not parse relay snippets', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
await conn.exec("cd '/tmp' && ('/usr/bin/node' -e 'console.log(1)' || echo MISSING)")
expect(clientInstances[0].lastExecCommand).toBe(
"exec /bin/sh -c 'cd '\\''/tmp'\\'' && ('\\''/usr/bin/node'\\'' -e '\\''console.log(1)'\\'' || echo MISSING)'"
)
})
})
describe('SshConnectionManager', () => {

View File

@ -18,6 +18,7 @@ import {
buildConnectConfig,
resolveEffectiveProxy,
spawnProxyCommand,
wrapRemoteCommandForPosixShell,
type SshConnectionCallbacks
} from './ssh-connection-utils'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
@ -70,7 +71,9 @@ export class SshConnection {
if (!this.client) {
throw new Error('Not connected')
}
return new Promise((res, rej) => this.client!.exec(cmd, (e, ch) => (e ? rej(e) : res(ch))))
return new Promise((res, rej) =>
this.client!.exec(wrapRemoteCommandForPosixShell(cmd), (e, ch) => (e ? rej(e) : res(ch)))
)
}
async sftp(): Promise<SFTPWrapper> {