import { describe, expect, test } from 'bun:test'; import { type RemoteTransport, resolveRemote, runRemoteClient } from '@celilo/core'; const argv = (rest: string[]) => ['bun', 'celilo', ...rest]; describe('resolveRemote', () => { test('leading --remote ', () => { expect(resolveRemote(argv(['--remote', 'host', 'module', 'list']), undefined)).toEqual({ dest: 'host', commandArgv: ['module', 'list'], }); }); test('leading --remote=', () => { expect(resolveRemote(argv(['--remote=user@host', 'status']), undefined)).toEqual({ dest: 'user@host', commandArgv: ['status'], }); }); test('CELILO_REMOTE env', () => { expect(resolveRemote(argv(['module', 'list']), 'envhost')).toEqual({ dest: 'envhost', commandArgv: ['module', 'list'], }); }); test('leading flag beats env', () => { expect(resolveRemote(argv(['--remote', 'flaghost', 'status']), 'envhost')?.dest).toBe( 'flaghost', ); }); test('local invocation → null', () => { expect(resolveRemote(argv(['module', 'list']), undefined)).toBeNull(); }); test('--remote with no dest → null', () => { expect(resolveRemote(argv(['--remote']), undefined)).toBeNull(); }); }); test('renders a forwarded interview and sends the answer back', async () => { const writes: string[] = []; const encoder = new TextEncoder(); let controller!: ReadableStreamDefaultController; const stdout = new ReadableStream({ start(c) { controller = c; }, }); const push = (obj: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`)); const transport: RemoteTransport = { stdin: { write(chunk: string) { writes.push(chunk); // The command's answer arrived — finish the command. if (chunk.includes('"answer"')) { push({ type: 'result', success: true, exitCode: 0 }); controller.close(); } }, }, stdout, kill() {}, exited: Promise.resolve(0), }; // Server script: greet, then ask one interview. push({ type: 'ready', protocolVersion: 1 }); push({ type: 'interview', id: 'q1', kind: 'text', message: 'Hostname?' }); const seen: Array<{ id: string }> = []; const outcome = await runRemoteClient('ignored', ['module', 'deploy', 'site'], { openTransport: () => transport, out: { write() {} }, renderInterview: async (iv) => { seen.push(iv); return 'myhost'; }, }); expect(outcome).toEqual({ status: 'result', exitCode: 0 }); expect(seen).toHaveLength(1); expect(seen[0].id).toBe('q1'); expect(writes.some((w) => w.includes('"command"'))).toBe(true); const answer = writes.find((w) => w.includes('"answer"')); expect(answer).toBeDefined(); expect(JSON.parse(answer as string)).toEqual({ type: 'answer', id: 'q1', value: 'myhost' }); }); /** * Regression for the fabricated "operator declined". * * The default renderer prompts on the terminal. The renderer it replaced read * keypresses off stdin whether or not stdin was a terminal, so driven over the * MCP (stdin = the JSON-RPC stream) the next newline submitted the prompt at * its `initialValue` — the question's `defaultValue` — and a breaking update * nobody saw came back as a considered "no". With no terminal the client must * say it cannot answer. * * And it must say so as `unanswerable`, NOT as an `answer` of any shape: an * `answer` is what consumes the query and destroys a question nobody decided * (celilo#609). The server parks and replies `blocked`. */ test('no TTY and no renderer → sends unanswerable and returns blocked, never an answer', async () => { const writes: string[] = []; const encoder = new TextEncoder(); let controller!: ReadableStreamDefaultController; const stdout = new ReadableStream({ start(c) { controller = c; }, }); const push = (obj: unknown) => controller.enqueue(encoder.encode(`${JSON.stringify(obj)}\n`)); const transport: RemoteTransport = { stdin: { write(chunk: string) { writes.push(chunk); // What a #609 server does with "I can't decide": park, don't resolve. if (chunk.includes('"unanswerable"')) { push({ type: 'blocked', sessionId: 'sess-1', eventId: '42', question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?', key: 'module-upgrade:iptables.apply_breaking', }); controller.close(); } }, }, stdout, kill() {}, exited: Promise.resolve(1), }; push({ type: 'ready', protocolVersion: 1 }); push({ type: 'interview', id: 'q1', scope: 'module-upgrade:iptables', key: 'apply_breaking', kind: 'confirm', message: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?', defaultValue: 'false', }); // No `renderInterview` — exactly what runRemoteCapture used to do. bun test // runs with a piped stdin, i.e. the MCP server's situation. expect(process.stdin.isTTY).toBeFalsy(); const outcome = await runRemoteClient('ignored', ['module', 'update'], { openTransport: () => transport, out: { write() {} }, }); // Never an answer — that would consume the query. expect(writes.find((w) => w.includes('"answer"'))).toBeUndefined(); const unanswerable = writes.find((w) => w.includes('"unanswerable"')); expect(unanswerable).toBeDefined(); const parsed = JSON.parse(unanswerable as string) as { id: string; reason: string }; expect(parsed.id).toBe('q1'); expect(parsed.reason).toContain('module-upgrade:iptables.apply_breaking'); // And the caller is told where it stands rather than being handed a decline. expect(outcome).toEqual({ status: 'blocked', sessionId: 'sess-1', eventId: '42', question: 'Apply breaking update for iptables (1.0.2+9 → 2.0.0+1)?', key: 'module-upgrade:iptables.apply_breaking', }); });