/** * Unit tests for the SSH Console tools (SshConsoleEnsure / Send / Snapshot). * * Strategy: stub the full SshSubsystem rather than booting repos — these * tests focus on the orchestration logic (find-or-open, deny check, * snapshot routing). The 12-step preflight is already covered by the * SshExec tests in ssh.test.ts. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { executeTool, TOOL_DEFS, unescapeAiInput } from './ssh-console.js'; import { setSshSubsystem, type SshSubsystem } from './ssh.js'; import type { ToolContext } from './core.js'; describe('unescapeAiInput', () => { it('passes real LF through unchanged', () => { expect(unescapeAiInput('ls -la\n')).toBe('ls -la\n'); }); it('converts literal 2-char "\\\\n" to real LF', () => { // source '\\n' = 2 chars (backslash + n); should become real LF. expect(unescapeAiInput('ls -la\\n')).toBe('ls -la\n'); }); it('converts literal 2-char "\\\\r" to real CR', () => { expect(unescapeAiInput('uptime\\r')).toBe('uptime\r'); }); it('converts literal "\\\\t" and "\\\\0"', () => { expect(unescapeAiInput('a\\tb')).toBe('a\tb'); expect(unescapeAiInput('a\\0b')).toBe('a\0b'); }); it('converts \\xHH hex escapes', () => { expect(unescapeAiInput('\\x03')).toBe('\x03'); // Ctrl-C expect(unescapeAiInput('\\x1b:q!\\n')).toBe('\x1b:q!\n'); // Esc + vim exit }); it('handles double-backslash correctly', () => { // source '\\\\' = 2 chars (\\); should become single backslash. expect(unescapeAiInput('a\\\\b')).toBe('a\\b'); }); it('preserves unknown escapes literally', () => { expect(unescapeAiInput('foo\\q')).toBe('foo\\q'); }); it('does not touch a string with no backslashes', () => { expect(unescapeAiInput('hello world')).toBe('hello world'); expect(unescapeAiInput('')).toBe(''); }); }); function mkConn(overrides: Partial<{ commandDenyPatterns: string | null; commandAllowPatterns: string | null; }> = {}) { return { id: 'conn-1', ownerId: 'u1', label: 'test', host: 'localhost', port: 22, username: 'me', privateKeyEnc: Buffer.alloc(0), passphraseEnc: null, keyVersion: 1, keyFingerprint: 'fp-key', hostKeyType: 'ssh-ed25519', hostKeyB64: 'aaa', hostKeyFingerprint: 'fp', hostKeyRecordedAt: '2026-01-01', hostKeyVerifiedAt: '2026-01-01', hostKeyPending: false, hostKeyPendingB64: null, hostKeyPendingFingerprint: null, hostKeyPendingToken: null, hostKeyPendingSource: null, commandDenyPatterns: overrides.commandDenyPatterns ?? null, commandAllowPatterns: overrides.commandAllowPatterns ?? null, remotePathPrefix: '/', allowRemoteUnrestricted: true, allowPrivateAddresses: true, enabled: true, disabledByAdmin: false, disabledByAdminReason: null, disabledByAdminAt: null, disabledByAdminUserId: null, createdAt: '2026-01-01', updatedAt: '2026-01-01', }; } function mkStubSubsystem() { const audit = { beginAndComplete: vi.fn().mockReturnValue(1), begin: vi.fn().mockReturnValue(1), complete: vi.fn(), listAuditRows: vi.fn(), pruneOlderThan: vi.fn(), promotePendingToAborted: vi.fn(), }; const registry = { get: vi.fn().mockReturnValue(null), register: vi.fn(), enforceCap: vi.fn().mockReturnValue([]), closeForTask: vi.fn().mockResolvedValue(undefined), listAll: vi.fn().mockReturnValue([]), listForConnection: vi.fn().mockReturnValue([]), sweep: vi.fn(), startSweepTimer: vi.fn(), stopSweepTimer: vi.fn(), shutdown: vi.fn(), }; const connectionRepo = { resolveConnection: vi.fn().mockReturnValue(mkConn()), }; const abuseRepo = { isLocked: vi.fn().mockReturnValue({ locked: false }), checkAndRecordFailure: vi.fn(), recordSuccess: vi.fn(), }; const accessResolver = { resolveAccess: vi.fn().mockReturnValue({ allowed: true }) }; const channel = { write: vi.fn(), end: vi.fn(), setWindow: vi.fn(), on: vi.fn(), }; const client = { end: vi.fn() }; const openShellChannel = vi.fn().mockResolvedValue({ channel, client, hostFingerprint: 'SHA256:fake', }); const sub = { connectionRepo, auditRepo: audit, abuseRepo, accessResolver, sessionRegistry: registry, openShellChannel, getUserAccess: () => ({ isAdmin: false, orgIds: [] }), decryptKeyMaterial: () => Buffer.alloc(0), decryptPassphrase: () => null, sshExec: vi.fn(), sshUpload: vi.fn(), sshDownload: vi.fn(), maintenance: { isActive: () => false, snapshot: () => ({ active: false }), enter: () => {}, exit: () => {} } as SshSubsystem['maintenance'], config: { enabled: true, allowPrivateAddresses: true, callTimeoutSeconds: 30, maxOutputBytes: 1024, maxUploadSizeMb: 10, maxDownloadSizeMb: 10, auditRetentionDays: 90, adminBypassesGrants: true, abuseWindowMinutes: 10, abuseFailureThreshold: 5, abuseLockMinutes: 30, console: { enabled: true, idleTimeoutSeconds: 60, maxSessionDurationSeconds: 600, scrollbackBytes: 4096, maxSessionsPerConnection: 3, maxInputBytesPerSend: 1024, autoInjectScreenLines: 24, defaultCols: 80, defaultRows: 24, }, }, } as unknown as SshSubsystem; return { sub, audit, registry, openShellChannel, connectionRepo, channel }; } function mkCtx(overrides: Partial = {}): ToolContext { return { workspacePath: '/tmp', editAllowed: true, taskId: 'task-1', userId: 'u1', ownerId: 'u1', jobId: 'j1', pieceName: 'p', allowedSshConnections: ['*'], ...overrides, }; } describe('SshConsoleEnsure', () => { beforeEach(() => setSshSubsystem(null)); it('is registered in TOOL_DEFS', () => { expect(TOOL_DEFS.SshConsoleEnsure).toBeDefined(); expect(TOOL_DEFS.SshConsoleSend).toBeDefined(); expect(TOOL_DEFS.SshConsoleSnapshot).toBeDefined(); }); it('opens new session when none exists', async () => { const { sub, registry, openShellChannel } = mkStubSubsystem(); setSshSubsystem(sub); const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-1' }, mkCtx()); expect(res?.isError).toBe(false); expect(openShellChannel).toHaveBeenCalled(); expect(registry.register).toHaveBeenCalled(); }); it('reuses existing session for same task + connection', async () => { const { sub, registry, openShellChannel } = mkStubSubsystem(); const existing = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, }; registry.get.mockReturnValue(existing); setSshSubsystem(sub); const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-1' }, mkCtx()); expect(res?.isError).toBe(false); expect(openShellChannel).not.toHaveBeenCalled(); }); it('rejects mismatching connection_id by default and surfaces the active id', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-OLD', isClosed: false, startedAt: Date.now() - 60_000, lastActivityAt: Date.now() - 5_000, }); setSshSubsystem(sub); const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-NEW' }, mkCtx()); expect(res?.isError).toBe(true); expect(res?.output).toContain('conn-OLD'); expect(res?.output).toContain('force_replace'); expect(registry.closeForTask).not.toHaveBeenCalled(); }); it('closes old session and opens new when force_replace=true', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-OLD', isClosed: false, startedAt: Date.now() - 60_000, lastActivityAt: Date.now() - 5_000, }); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleEnsure', { connection_id: 'conn-NEW', force_replace: true }, mkCtx(), ); expect(registry.closeForTask).toHaveBeenCalledWith('task-1', 'connection_change'); expect(res?.isError).toBe(false); }); it('rejects when console.enabled is false', async () => { const { sub } = mkStubSubsystem(); sub.config.console.enabled = false; setSshSubsystem(sub); const res = await executeTool('SshConsoleEnsure', { connection_id: 'conn-1' }, mkCtx()); expect(res?.isError).toBe(true); }); }); describe('SshConsoleSend', () => { beforeEach(() => setSshSubsystem(null)); it('writes input to session and returns screen snapshot', async () => { const { sub, registry } = mkStubSubsystem(); const writes: Buffer[] = []; const fakeSession = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, write: (b: Buffer) => writes.push(b), snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ls\n', cursor: { x: 0, y: 1 }, }), totalOutputBytes: 100, }; registry.get.mockReturnValue(fakeSession); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSend', { connection_id: 'conn-1', input: 'ls\n', wait_ms: 50 }, mkCtx(), ); expect(res?.isError).toBe(false); expect(writes[0]!.toString()).toBe('ls\n'); const parsed = JSON.parse(res!.output); expect(parsed.bytes_sent).toBe(3); expect(parsed.screen_after).toContain('prompt'); expect(parsed.warning).toBeUndefined(); }); it('auto-appends \\n when input is printable without line terminator', async () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue(mkConn()); const fakeSession = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, write: vi.fn(), snapshotScreen: () => ({ cols: 80, rows: 24, text: 'prompt$ ', cursor: { x: 0, y: 0 } }), totalOutputBytes: 0, }; registry.get.mockReturnValue(fakeSession); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSend', { connection_id: 'conn-1', input: 'ls -la' }, // no newline mkCtx(), ); expect(res?.isError).toBe(false); const parsed = JSON.parse(res!.output); expect(parsed.auto_newline_appended).toBe(true); expect(parsed.warning).toBeUndefined(); expect(parsed.bytes_sent).toBe(7); // 'ls -la' (6) + '\n' (1) // Verify the bytes actually written to PTY include the appended newline expect(fakeSession.write).toHaveBeenCalledTimes(1); const writtenBuf = fakeSession.write.mock.calls[0][0] as Buffer; expect(writtenBuf.toString('utf8')).toBe('ls -la\n'); }); it('does NOT auto-append newline for control bytes (Ctrl-C / Ctrl-D / Esc / Tab)', async () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue(mkConn()); const fakeSession = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, write: vi.fn(), snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }), totalOutputBytes: 0, }; registry.get.mockReturnValue(fakeSession); setSshSubsystem(sub); for (const input of ['\x03', '\x04', '\x1b:q!', '\t']) { const res = await executeTool('SshConsoleSend', { connection_id: 'conn-1', input }, mkCtx()); const parsed = JSON.parse(res!.output); expect(parsed.auto_newline_appended, `for ${JSON.stringify(input)}`).toBeFalsy(); } }); it('rejects when deny-list line hit', async () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue( mkConn({ commandDenyPatterns: '^rm -rf /\\b' }), ); const fakeSession = { localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, write: vi.fn(), snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 }, }), totalOutputBytes: 0, }; registry.get.mockReturnValue(fakeSession); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSend', { connection_id: 'conn-1', input: 'rm -rf /\n' }, mkCtx(), ); expect(res?.isError).toBe(true); expect(fakeSession.write).not.toHaveBeenCalled(); }); it('rejects input over max_input_bytes_per_send', async () => { const { sub, registry } = mkStubSubsystem(); sub.config.console.maxInputBytesPerSend = 4; registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, write: vi.fn(), snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 }, }), totalOutputBytes: 0, } as any); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSend', { connection_id: 'conn-1', input: '12345' }, mkCtx(), ); expect(res?.isError).toBe(true); }); it('omitting connection_id uses the active session', async () => { const { sub, registry, connectionRepo } = mkStubSubsystem(); connectionRepo.resolveConnection.mockReturnValue(mkConn()); const writes: Buffer[] = []; registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, write: (b: Buffer) => writes.push(b), snapshotScreen: () => ({ cols: 80, rows: 24, text: 'ok', cursor: { x: 0, y: 0 } }), totalOutputBytes: 0, } as any); setSshSubsystem(sub); const res = await executeTool('SshConsoleSend', { input: 'whoami\n' }, mkCtx()); expect(res?.isError).toBe(false); expect(writes[0]!.toString()).toBe('whoami\n'); }); it('rejects mismatching connection_id and surfaces the active id', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-ACTIVE', cols: 80, rows: 24, isClosed: false, write: vi.fn(), snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }), totalOutputBytes: 0, } as any); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSend', { connection_id: 'conn-WRONG', input: 'ls\n' }, mkCtx(), ); expect(res?.isError).toBe(true); expect(res?.output).toContain('conn-ACTIVE'); expect(res?.output).toContain('force_replace'); }); it('errors when no active session and no connection_id provided', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue(null); setSshSubsystem(sub); const res = await executeTool('SshConsoleSend', { input: 'ls\n' }, mkCtx()); expect(res?.isError).toBe(true); expect(res?.output).toContain('SshListConnections'); }); }); describe('SshConsoleSnapshot', () => { beforeEach(() => setSshSubsystem(null)); it('returns screen when kind=screen', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, snapshotScreen: () => ({ cols: 80, rows: 24, text: 'screen', cursor: { x: 1, y: 2 } }), snapshotScrollback: () => ({ text: 'scroll', byteCount: 6, truncated: false }), } as any); setSshSubsystem(sub); const res = await executeTool('SshConsoleSnapshot', { connection_id: 'conn-1' }, mkCtx()); expect(res?.isError).toBe(false); const data = JSON.parse(res!.output); expect(data.kind).toBe('screen'); expect(data.text).toBe('screen'); expect(data.cursor).toEqual({ x: 1, y: 2 }); }); it('returns scrollback when kind=scrollback', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }), snapshotScrollback: (_opts: { maxBytes: number }) => ({ text: 'tail', byteCount: 9999, truncated: true, }), } as any); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSnapshot', { connection_id: 'conn-1', kind: 'scrollback', max_bytes: 4 }, mkCtx(), ); expect(res?.isError).toBe(false); const data = JSON.parse(res!.output); expect(data.kind).toBe('scrollback'); expect(data.text).toBe('tail'); expect(data.truncated).toBe(true); }); it('returns error when no active session', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue(null); setSshSubsystem(sub); const res = await executeTool('SshConsoleSnapshot', { connection_id: 'conn-1' }, mkCtx()); expect(res?.isError).toBe(true); expect(res?.output).toContain('no live session'); }); it('omitting connection_id uses the active session', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-1', cols: 80, rows: 24, isClosed: false, snapshotScreen: () => ({ cols: 80, rows: 24, text: 'auto', cursor: { x: 0, y: 0 } }), snapshotScrollback: () => ({ text: '', byteCount: 0, truncated: false }), } as any); setSshSubsystem(sub); const res = await executeTool('SshConsoleSnapshot', {}, mkCtx()); expect(res?.isError).toBe(false); const data = JSON.parse(res!.output); expect(data.text).toBe('auto'); }); it('rejects mismatching connection_id and surfaces the active id', async () => { const { sub, registry } = mkStubSubsystem(); registry.get.mockReturnValue({ localTaskId: 'task-1', connectionId: 'conn-ACTIVE', cols: 80, rows: 24, isClosed: false, snapshotScreen: () => ({ cols: 80, rows: 24, text: '', cursor: { x: 0, y: 0 } }), } as any); setSshSubsystem(sub); const res = await executeTool( 'SshConsoleSnapshot', { connection_id: 'conn-WRONG' }, mkCtx(), ); expect(res?.isError).toBe(true); expect(res?.output).toContain('conn-ACTIVE'); }); });