import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' import type { ClientChannel } from 'ssh2' import { execCommand } from './ssh-relay-exec-command' type MockChannel = ClientChannel & { stdin: EventEmitter & { write: ReturnType } } function createMockChannel(): MockChannel { return Object.assign(new EventEmitter(), { stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }), stdin: Object.assign(new EventEmitter(), { write: vi.fn(() => true) }), close: vi.fn(), resume: vi.fn() }) as unknown as MockChannel } // execCommand only rejects with Error; narrow the caught reason (its resolve // type is string) and fail loudly if the command unexpectedly succeeds. async function execCommandRejection(promise: Promise): Promise { return promise.then( () => { throw new Error('expected execCommand to reject') }, (err: Error) => err ) } describe('execCommand', () => { const installMarkerToken = 'a'.repeat(32) const installMarkerPath = `/home/u/.install-lock/.sftp-namespace-${installMarkerToken}` it('waits for channel close before rejecting a timed-out remote command', async () => { vi.useFakeTimers() try { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'npm rebuild', { timeoutMs: 1_000 }) await vi.advanceTimersByTimeAsync(1_000) expect(channel.close).toHaveBeenCalledTimes(1) expect( await Promise.race([ commandPromise.then( () => 'settled', () => 'settled' ), Promise.resolve('pending') ]) ).toBe('pending') channel.emit('close', 0) await expect(commandPromise).rejects.toThrow('timed out after 1s') } finally { vi.useRealTimers() } }) it('rejects on command channel errors instead of emitting uncaught errors', async () => { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'uname -sm') await Promise.resolve() expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() expect(channel.close).toHaveBeenCalledOnce() channel.emit('close', 1) await expect(commandPromise).rejects.toThrow('remote host rebooted') expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) expect(channel.stderr.listenerCount('data')).toBe(0) }) it('includes stdout in nonzero-exit errors when stderr is empty', async () => { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'npm install node-pty 2>&1') await Promise.resolve() channel.emit('data', Buffer.from('gyp ERR! stack Error: not found: make\n')) channel.emit('close', 1) await expect(commandPromise).rejects.toThrow('gyp ERR! stack Error: not found: make') expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) expect(channel.stderr.listenerCount('data')).toBe(0) }) it('redacts install-owner marker tokens from command failures', async () => { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`) await Promise.resolve() channel.emit('data', Buffer.from(`touch failed for ${installMarkerPath}\n`)) channel.emit('close', 1) const error = await execCommandRejection(commandPromise) expect(error.message).toContain('.sftp-namespace-[redacted]') expect(error.message).not.toContain(installMarkerToken) }) it('redacts install-owner marker tokens from timeout errors', async () => { vi.useFakeTimers() try { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`, { timeoutMs: 1_000 }) await vi.advanceTimersByTimeAsync(1_000) channel.emit('close', 0) const error = await execCommandRejection(commandPromise) expect(error.message).toContain('.sftp-namespace-[redacted]') expect(error.message).not.toContain(installMarkerToken) } finally { vi.useRealTimers() } }) it('surfaces stdout alongside stderr on nonzero exit instead of masking it', async () => { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'npm install 2>&1') await Promise.resolve() // Why: the system-ssh transport routes the local OpenSSH client's own // diagnostics to channel.stderr while the remote command's merged output // arrives on stdout. stderr must not mask the real failure. channel.stderr.emit('data', Buffer.from("Warning: Permanently added 'host' (ED25519)\n")) channel.emit('data', Buffer.from("npm error g++: unrecognized '-std=gnu++20'\n")) channel.emit('close', 1) const error = await execCommandRejection(commandPromise) expect(error.message).toContain('Permanently added') expect(error.message).toContain('gnu++20') }) it('bounds command output while preserving the actionable tail', async () => { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'npm rebuild') await Promise.resolve() channel.emit('data', Buffer.from(`old-prefix-${'x'.repeat(1024 * 1024)}`)) channel.emit('data', Buffer.from('gyp ERR! tail diagnosis')) channel.emit('close', 1) const error = await execCommandRejection(commandPromise) expect(error.message).not.toContain('old-prefix') expect(error.message).toContain('gyp ERR! tail diagnosis') expect(error.message.length).toBeLessThan(1024 * 1024 + 200) }) it('cleans command channel listeners when a command times out', async () => { vi.useFakeTimers() try { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'sleep 60') await Promise.resolve() const rejection = expect(commandPromise).rejects.toThrow('timed out') await vi.advanceTimersByTimeAsync(30_000) expect(channel.close).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() expect(channel.resume).toHaveBeenCalledOnce() expect(channel.stderr.resume).toHaveBeenCalledOnce() expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow() expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow() channel.emit('data', Buffer.from('late stdout')) channel.stderr.emit('data', Buffer.from('late stderr')) expect(channel.listenerCount('error')).toBe(1) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('close')).toBe(1) expect(channel.stderr.listenerCount('error')).toBe(1) expect(channel.stderr.listenerCount('data')).toBe(0) channel.emit('close', 0) expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) } finally { vi.useRealTimers() } }) it('closes and rejects with AbortError when a command is aborted', async () => { const channel = createMockChannel() const controller = new AbortController() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'sleep 60', { signal: controller.signal }) await Promise.resolve() controller.abort() // Why: sshd frees the MaxSessions slot only when the channel finishes // closing, so abort must request close and settle from the 'close' event — // settling immediately lets the sequential fallback race the closing // channel and get refused. expect(channel.close).toHaveBeenCalledOnce() const settledEarly = await Promise.race([ commandPromise.then( () => 'settled', () => 'settled' ), Promise.resolve('pending') ]) expect(settledEarly).toBe('pending') channel.emit('close', 0) await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) expect(channel.stderr.listenerCount('data')).toBe(0) }) it('keeps system-SSH command termination unconfirmed after the local child closes', async () => { const channel = createMockChannel() const controller = new AbortController() const conn = { exec: vi.fn().mockResolvedValue(channel), usesSystemSshTransport: vi.fn().mockReturnValueOnce(true).mockReturnValue(false) } const commandPromise = execCommand(conn as never, 'npm install', { signal: controller.signal }) await Promise.resolve() controller.abort() channel.emit('close', 0) await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError', sshChannelCloseConfirmed: false }) }) it('tags an externally closed system-SSH command as unconfirmed', async () => { const channel = createMockChannel() as ClientChannel & { _closeRequested?: boolean } channel.close = vi.fn(() => { channel._closeRequested = true }) const conn = { exec: vi.fn().mockResolvedValue(channel), usesSystemSshTransport: vi.fn().mockReturnValue(true) } const commandPromise = execCommand(conn as never, 'npm install') await Promise.resolve() channel.close() channel.emit('close', 1) await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError', sshChannelCloseConfirmed: false }) }) it('handles aborts that happen while the SSH exec channel is still opening', async () => { const channel = createMockChannel() const controller = new AbortController() let resolveExec: (channel: ClientChannel) => void = () => {} const conn = { exec: vi.fn().mockReturnValue( new Promise((resolve) => { resolveExec = resolve }) ) } const commandPromise = execCommand(conn as never, 'sleep 60', { signal: controller.signal }) controller.abort() resolveExec(channel) await Promise.resolve() await Promise.resolve() expect(channel.close).toHaveBeenCalledOnce() // Nonzero exit from the killed command must still surface as AbortError. channel.emit('close', 1) await expect(commandPromise).rejects.toMatchObject({ name: 'AbortError' }) expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) expect(channel.stderr.listenerCount('data')).toBe(0) }) it('hands a zero-exit command stderr to onStderr instead of dropping it', async () => { // Why: probes fenced with `|| echo MISSING` always exit 0, so the resolve path used to be the // one place the failure reason was discarded. const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const captured: string[] = [] const commandPromise = execCommand(conn as never, "(node -e 'x' || echo MISSING)", { onStderr: (stderr) => captured.push(stderr) }) await Promise.resolve() channel.stderr.emit('data', Buffer.from('node: --bogus is not allowed in NODE_OPTIONS\n')) channel.emit('data', Buffer.from('MISSING\n')) channel.emit('close', 0) await expect(commandPromise).resolves.toBe('MISSING\n') expect(captured).toEqual(['node: --bogus is not allowed in NODE_OPTIONS\n']) // onStderr must not leak into the SSH exec options. expect(conn.exec).toHaveBeenCalledWith( expect.stringContaining("\n(node -e 'x' || echo MISSING)\n"), {} ) }) it('uses custom command timeouts without forwarding them to SSH exec', async () => { vi.useFakeTimers() try { const channel = createMockChannel() const conn = { exec: vi.fn().mockResolvedValue(channel) } const commandPromise = execCommand(conn as never, 'npm install', { wrapCommand: false, timeoutMs: 240_000 }) await Promise.resolve() expect(conn.exec).toHaveBeenCalledWith('npm install', { wrapCommand: false }) const rejection = expect(commandPromise).rejects.toThrow('timed out after 240s') await vi.advanceTimersByTimeAsync(240_000) expect(channel.close).toHaveBeenCalledOnce() await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() } finally { vi.useRealTimers() } }) })