/** * SSH helper — spawns commands on the host via SSH. * * BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi). * They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the * Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53). */ import { spawn, type ChildProcess } from 'node:child_process'; export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53'; export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop'; /** Common SSH args — strict host checking disabled for container-to-host trust. */ const SSH_BASE_ARGS = [ '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'LogLevel=ERROR', '-o', 'BatchMode=yes', ]; export interface SshExecResult { exitCode: number; stdout: string; stderr: string; } /** * Execute a command on the host via SSH, collecting all output. * Returns when the remote process exits. */ export async function sshExec( command: string, opts?: { signal?: AbortSignal; timeoutMs?: number }, ): Promise { return new Promise((resolve, reject) => { const child = spawn('ssh', [ ...SSH_BASE_ARGS, `${SSH_USER}@${SSH_HOST}`, command, ], { stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; let killed = false; child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); const cleanup = () => { if (!killed) { killed = true; child.kill('SIGTERM'); } }; // Abort signal if (opts?.signal) { if (opts.signal.aborted) { cleanup(); reject(new Error('SSH exec aborted before start')); return; } opts.signal.addEventListener('abort', cleanup, { once: true }); } // Timeout let timer: ReturnType | undefined; if (opts?.timeoutMs) { timer = setTimeout(() => { cleanup(); reject(new Error(`SSH exec timed out after ${opts.timeoutMs}ms`)); }, opts.timeoutMs); } child.on('close', (code) => { if (timer) clearTimeout(timer); if (opts?.signal) opts.signal.removeEventListener('abort', cleanup); resolve({ exitCode: code ?? 1, stdout, stderr }); }); child.on('error', (err) => { if (timer) clearTimeout(timer); if (opts?.signal) opts.signal.removeEventListener('abort', cleanup); reject(err); }); // Close stdin immediately — we're not sending input via sshExec child.stdin!.end(); }); } /** * Spawn an SSH child process with a command on the host. * Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY). */ export function sshSpawn(command: string): ChildProcess { return spawn('ssh', [ ...SSH_BASE_ARGS, `${SSH_USER}@${SSH_HOST}`, command, ], { stdio: ['pipe', 'pipe', 'pipe'], }); } /** * Spawn an SSH child process that pipes stdin through. * Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`). */ export function sshSpawnWithStdin(command: string, input: string): ChildProcess { const child = spawn('ssh', [ ...SSH_BASE_ARGS, `${SSH_USER}@${SSH_HOST}`, command, ], { stdio: ['pipe', 'pipe', 'pipe'], }); // Write the input and close stdin child.stdin!.write(input); child.stdin!.end(); return child; }