diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index 02009c15e..5b2a40334 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -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 diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 29d89f071..74f5b3f59 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -8,6 +8,7 @@ let connectErrorMessage = '' type MockSshClient = { setNoDelay: ReturnType _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', () => { diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 4e8e88a42..6e97c5675 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -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 {