diff --git a/CHANGELOG.md b/CHANGELOG.md index 58654420d..65e094dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ - Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete - CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete - CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete +- Gateway/CLI: add `clawdbot gateway --dev/--reset` to auto-create a dev config/workspace with a robot identity (no BOOTSTRAP.md). — thanks @steipete ## 2026.1.8 diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 0e80a7c73..87f799c2a 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -39,6 +39,8 @@ Notes: - `--password `: password override (also sets `CLAWDBOT_GATEWAY_PASSWORD` for the process). - `--tailscale `: expose the Gateway via Tailscale. - `--tailscale-reset-on-exit`: reset Tailscale serve/funnel config on shutdown. +- `--dev`: create a dev config + workspace if missing (skips BOOTSTRAP.md). +- `--reset`: recreate the dev config (requires `--dev`). - `--force`: kill any existing listener on the selected port before starting. - `--verbose`: verbose logs. - `--claude-cli-logs`: only show claude-cli logs in the console (and enable its stdout/stderr). @@ -82,6 +84,25 @@ clawdbot gateway status clawdbot gateway status --json ``` +#### Remote over SSH (Mac app parity) + +The macOS app “Remote over SSH” mode uses a local port-forward so the remote gateway (which may be bound to loopback only) becomes reachable at `ws://127.0.0.1:`. + +CLI equivalent: + +```bash +clawdbot gateway status --ssh steipete@peters-mac-studio-1 +``` + +Options: +- `--ssh `: `user@host` or `user@host:port` (port defaults to `22`). +- `--ssh-identity `: identity file. +- `--ssh-auto`: pick the first discovered bridge host as SSH target (LAN/WAB only). + +Config (optional, used as defaults): +- `gateway.remote.sshTarget` +- `gateway.remote.sshIdentity` + ### `gateway call ` Low-level RPC helper. diff --git a/docs/cli/index.md b/docs/cli/index.md index 26e8dae8a..594965be7 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -409,6 +409,8 @@ Options: - `--tailscale ` - `--tailscale-reset-on-exit` - `--allow-unconfigured` +- `--dev` +- `--reset` - `--force` (kill existing listener on port) - `--verbose` - `--ws-log ` diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 82541fa60..ef0b2a376 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -1,13 +1,18 @@ import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { Command } from "commander"; +import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { gatewayStatusCommand } from "../commands/gateway-status.js"; +import { moveToTrash } from "../commands/onboard-helpers.js"; import { CONFIG_PATH_CLAWDBOT, type GatewayAuthMode, loadConfig, readConfigFileSnapshot, resolveGatewayPort, + writeConfigFile, } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, @@ -34,6 +39,7 @@ import { } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; +import { resolveUserPath } from "../utils.js"; import { forceFreePortAndWait } from "./ports.js"; import { withProgress } from "./progress.js"; @@ -62,6 +68,8 @@ type GatewayRunOpts = { compact?: boolean; rawStream?: boolean; rawStreamPath?: unknown; + dev?: boolean; + reset?: boolean; }; type GatewayRunParams = { @@ -69,6 +77,33 @@ type GatewayRunParams = { }; const gatewayLog = createSubsystemLogger("gateway"); +const DEV_IDENTITY_NAME = "Clawdbot Dev"; +const DEV_IDENTITY_THEME = "helpful debug droid"; +const DEV_IDENTITY_EMOJI = "🤖"; +const DEV_AGENT_WORKSPACE_SUFFIX = "dev"; +const DEV_AGENTS_TEMPLATE = `# AGENTS.md - Clawdbot Dev Workspace + +Default dev workspace for clawdbot gateway --dev. + +- Keep replies concise and direct. +- Prefer observable debugging steps and logs. +- Avoid destructive actions unless asked. +`; +const DEV_SOUL_TEMPLATE = `# SOUL.md - Dev Persona + +Helpful robotic debugging assistant. + +- Concise, structured answers. +- Ask for missing context before guessing. +- Prefer reproducible steps and logs. +`; +const DEV_IDENTITY_TEMPLATE = `# IDENTITY.md - Agent Identity + +- Name: ${DEV_IDENTITY_NAME} +- Creature: debug droid +- Vibe: ${DEV_IDENTITY_THEME} +- Emoji: ${DEV_IDENTITY_EMOJI} +`; type GatewayRunSignalAction = "stop" | "restart"; @@ -93,6 +128,72 @@ const toOptionString = (value: unknown): string | undefined => { return undefined; }; +const resolveDevWorkspaceDir = ( + env: NodeJS.ProcessEnv = process.env, +): string => { + const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir); + return `${baseDir}-${DEV_AGENT_WORKSPACE_SUFFIX}`; +}; + +async function writeFileIfMissing(filePath: string, content: string) { + try { + await fs.promises.writeFile(filePath, content, { + encoding: "utf-8", + flag: "wx", + }); + } catch (err) { + const anyErr = err as { code?: string }; + if (anyErr.code !== "EEXIST") throw err; + } +} + +async function ensureDevWorkspace(dir: string) { + const resolvedDir = resolveUserPath(dir); + await fs.promises.mkdir(resolvedDir, { recursive: true }); + await writeFileIfMissing( + path.join(resolvedDir, "AGENTS.md"), + DEV_AGENTS_TEMPLATE, + ); + await writeFileIfMissing( + path.join(resolvedDir, "SOUL.md"), + DEV_SOUL_TEMPLATE, + ); + await writeFileIfMissing( + path.join(resolvedDir, "IDENTITY.md"), + DEV_IDENTITY_TEMPLATE, + ); +} + +async function ensureDevGatewayConfig(opts: { reset?: boolean }) { + const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT); + if (opts.reset && configExists) { + await moveToTrash(CONFIG_PATH_CLAWDBOT, defaultRuntime); + } + + const shouldWrite = opts.reset || !configExists; + if (!shouldWrite) return; + + const workspace = resolveDevWorkspaceDir(); + await writeConfigFile({ + gateway: { + mode: "local", + bind: "loopback", + }, + agent: { + workspace, + skipBootstrap: true, + }, + identity: { + name: DEV_IDENTITY_NAME, + theme: DEV_IDENTITY_THEME, + emoji: DEV_IDENTITY_EMOJI, + }, + }); + await ensureDevWorkspace(workspace); + defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`); + defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`); +} + type GatewayDiscoverOpts = { timeout?: string; json?: boolean; @@ -403,6 +504,11 @@ async function runGatewayCommand( opts: GatewayRunOpts, params: GatewayRunParams = {}, ) { + if (opts.reset && !opts.dev) { + defaultRuntime.error("Use --reset with --dev."); + defaultRuntime.exit(1); + return; + } if (params.legacyTokenEnv) { const legacyToken = process.env.CLAWDIS_GATEWAY_TOKEN; if (legacyToken && !process.env.CLAWDBOT_GATEWAY_TOKEN) { @@ -439,6 +545,10 @@ async function runGatewayCommand( process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath; } + if (opts.dev) { + await ensureDevGatewayConfig({ reset: Boolean(opts.reset) }); + } + const cfg = loadConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { @@ -692,6 +802,12 @@ function addGatewayRunCommand( "Allow gateway start without gateway.mode=local in config", false, ) + .option( + "--dev", + "Create a dev config + workspace if missing (no BOOTSTRAP.md)", + false, + ) + .option("--reset", "Recreate dev config (requires --dev)", false) .option( "--force", "Kill any existing listener on the target port before starting", @@ -825,6 +941,16 @@ export function registerGatewayCli(program: Command) { "--url ", "Explicit Gateway WebSocket URL (still probes localhost)", ) + .option( + "--ssh ", + "SSH target for remote gateway tunnel (user@host or user@host:port)", + ) + .option("--ssh-identity ", "SSH identity file path") + .option( + "--ssh-auto", + "Try to derive an SSH target from Bonjour discovery", + false, + ) .option("--token ", "Gateway token (applies to all probes)") .option("--password ", "Gateway password (applies to all probes)") .option("--timeout ", "Overall probe budget in ms", "3000") diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 64d536f98..6e375553a 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -10,6 +10,15 @@ const loadConfig = vi.fn(() => ({ const resolveGatewayPort = vi.fn(() => 18789); const discoverGatewayBeacons = vi.fn(async () => []); const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10"); +const sshStop = vi.fn(async () => {}); +const startSshPortForward = vi.fn(async () => ({ + parsedTarget: { user: "me", host: "studio", port: 22 }, + localPort: 18789, + remotePort: 18789, + pid: 123, + stderr: [], + stop: sshStop, +})); const probeGateway = vi.fn(async ({ url }: { url: string }) => { if (url.includes("127.0.0.1")) { return { @@ -71,6 +80,10 @@ vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(), })); +vi.mock("../infra/ssh-tunnel.js", () => ({ + startSshPortForward: (opts: unknown) => startSshPortForward(opts), +})); + vi.mock("../gateway/probe.js", () => ({ probeGateway: (opts: unknown) => probeGateway(opts), })); @@ -128,4 +141,36 @@ describe("gateway-status command", () => { expect(targets[0]?.health).toBeTruthy(); expect(targets[0]?.summary).toBeTruthy(); }); + + it("supports SSH tunnel targets", async () => { + const runtimeLogs: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (_msg: string) => {}, + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + startSshPortForward.mockClear(); + sshStop.mockClear(); + probeGateway.mockClear(); + + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000", json: true, ssh: "me@studio" }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + expect(startSshPortForward).toHaveBeenCalledTimes(1); + expect(probeGateway).toHaveBeenCalled(); + expect(sshStop).toHaveBeenCalledTimes(1); + + const parsed = JSON.parse(runtimeLogs.join("\n")) as Record< + string, + unknown + >; + const targets = parsed.targets as Array>; + expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); + }); }); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index daa41ca39..592ee0e53 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -3,17 +3,25 @@ import { loadConfig, resolveGatewayPort } from "../config/config.js"; import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js"; import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; +import { startSshPortForward } from "../infra/ssh-tunnel.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import type { RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; -type TargetKind = "explicit" | "configRemote" | "localLoopback"; +type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel"; type GatewayStatusTarget = { id: string; kind: TargetKind; url: string; active: boolean; + tunnel?: { + kind: "ssh"; + target: string; + localPort: number; + remotePort: number; + pid: number | null; + }; }; type GatewayConfigSummary = { @@ -121,9 +129,17 @@ function resolveTargets( function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { if (kind === "localLoopback") return Math.min(800, overallMs); + if (kind === "sshTunnel") return Math.min(2000, overallMs); return Math.min(1500, overallMs); } +function sanitizeSshTarget(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + return trimmed.replace(/^ssh\s+/, ""); +} + function resolveAuthForTarget( cfg: ClawdbotConfig, target: GatewayStatusTarget, @@ -292,11 +308,13 @@ function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) { const kindLabel = target.kind === "localLoopback" ? "Local loopback" - : target.kind === "configRemote" - ? target.active - ? "Remote (configured)" - : "Remote (configured, inactive)" - : "URL (explicit)"; + : target.kind === "sshTunnel" + ? "Remote over SSH" + : target.kind === "configRemote" + ? target.active + ? "Remote (configured)" + : "Remote (configured, inactive)" + : "URL (explicit)"; return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`; } @@ -319,6 +337,9 @@ export async function gatewayStatusCommand( password?: string; timeout?: unknown; json?: boolean; + ssh?: string; + sshIdentity?: string; + sshAuto?: boolean; }, runtime: RuntimeEnv, ) { @@ -327,7 +348,7 @@ export async function gatewayStatusCommand( const rich = isRich() && opts.json !== true; const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000); - const targets = resolveTargets(cfg, opts.url); + const baseTargets = resolveTargets(cfg, opts.url); const network = buildNetworkHints(cfg); const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs); @@ -335,19 +356,16 @@ export async function gatewayStatusCommand( timeoutMs: discoveryTimeoutMs, }); - const probePromises = targets.map(async (target) => { - const auth = resolveAuthForTarget(cfg, target, { - token: typeof opts.token === "string" ? opts.token : undefined, - password: typeof opts.password === "string" ? opts.password : undefined, - }); - const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); - const probe = await probeGateway({ url: target.url, auth, timeoutMs }); - const configSummary = probe.configSnapshot - ? extractConfigSummary(probe.configSnapshot) - : null; - const self = pickGatewaySelfPresence(probe.presence); - return { target, probe, configSummary, self }; - }); + let sshTarget = + sanitizeSshTarget(opts.ssh) ?? + sanitizeSshTarget(cfg.gateway?.remote?.sshTarget); + const sshIdentity = + sanitizeSshTarget(opts.sshIdentity) ?? + sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity); + const remotePort = resolveGatewayPort(cfg); + + let sshTunnelError: string | null = null; + let sshTunnelStarted = false; const { discovery, probed } = await withProgress( { @@ -356,15 +374,111 @@ export async function gatewayStatusCommand( enabled: opts.json !== true, }, async () => { - const [discoveryRes, probesRes] = await Promise.allSettled([ - discoveryPromise, - Promise.all(probePromises), - ]); - return { - discovery: - discoveryRes.status === "fulfilled" ? discoveryRes.value : [], - probed: probesRes.status === "fulfilled" ? probesRes.value : [], + const tryStartTunnel = async () => { + if (!sshTarget) return null; + try { + const tunnel = await startSshPortForward({ + target: sshTarget, + identity: sshIdentity ?? undefined, + localPortPreferred: remotePort, + remotePort, + timeoutMs: Math.min(1500, overallTimeoutMs), + }); + sshTunnelStarted = true; + return tunnel; + } catch (err) { + sshTunnelError = err instanceof Error ? err.message : String(err); + return null; + } }; + + const discoveryTask = discoveryPromise.catch(() => []); + const tunnelTask = sshTarget ? tryStartTunnel() : Promise.resolve(null); + + const [discovery, tunnelFirst] = await Promise.all([ + discoveryTask, + tunnelTask, + ]); + + if (!sshTarget && opts.sshAuto) { + const user = process.env.USER?.trim() || ""; + const candidates = discovery + .map((b) => { + const host = b.tailnetDns || b.lanHost || b.host; + if (!host?.trim()) return null; + const sshPort = + typeof b.sshPort === "number" && b.sshPort > 0 ? b.sshPort : 22; + const base = user ? `${user}@${host.trim()}` : host.trim(); + return sshPort !== 22 ? `${base}:${sshPort}` : base; + }) + .filter((x): x is string => Boolean(x)); + if (candidates.length > 0) sshTarget = candidates[0] ?? null; + } + + const tunnel = + tunnelFirst || + (sshTarget && !sshTunnelStarted && !sshTunnelError + ? await tryStartTunnel() + : null); + + const tunnelTarget: GatewayStatusTarget | null = tunnel + ? { + id: "sshTunnel", + kind: "sshTunnel", + url: `ws://127.0.0.1:${tunnel.localPort}`, + active: true, + tunnel: { + kind: "ssh", + target: sshTarget ?? "", + localPort: tunnel.localPort, + remotePort, + pid: tunnel.pid, + }, + } + : null; + + const targets: GatewayStatusTarget[] = tunnelTarget + ? [ + tunnelTarget, + ...baseTargets.filter((t) => t.url !== tunnelTarget.url), + ] + : baseTargets; + + try { + const probed = await Promise.all( + targets.map(async (target) => { + const auth = resolveAuthForTarget(cfg, target, { + token: typeof opts.token === "string" ? opts.token : undefined, + password: + typeof opts.password === "string" ? opts.password : undefined, + }); + const timeoutMs = resolveProbeBudgetMs( + overallTimeoutMs, + target.kind, + ); + const probe = await probeGateway({ + url: target.url, + auth, + timeoutMs, + }); + const configSummary = probe.configSnapshot + ? extractConfigSummary(probe.configSnapshot) + : null; + const self = pickGatewaySelfPresence(probe.presence); + return { target, probe, configSummary, self }; + }), + ); + + return { discovery, probed }; + } finally { + if (tunnel) { + try { + await tunnel.stop(); + } catch { + // best-effort + } + } + } }, ); @@ -373,6 +487,7 @@ export async function gatewayStatusCommand( const multipleGateways = reachable.length > 1; const primary = reachable.find((p) => p.target.kind === "explicit") ?? + reachable.find((p) => p.target.kind === "sshTunnel") ?? reachable.find((p) => p.target.kind === "configRemote") ?? reachable.find((p) => p.target.kind === "localLoopback") ?? null; @@ -382,6 +497,14 @@ export async function gatewayStatusCommand( message: string; targetIds?: string[]; }> = []; + if (sshTarget && !sshTunnelStarted) { + warnings.push({ + code: "ssh_tunnel_failed", + message: sshTunnelError + ? `SSH tunnel failed: ${String(sshTunnelError)}` + : "SSH tunnel failed to start; falling back to direct probes.", + }); + } if (multipleGateways) { warnings.push({ code: "multiple_gateways", @@ -427,6 +550,7 @@ export async function gatewayStatusCommand( kind: p.target.kind, url: p.target.url, active: p.target.active, + tunnel: p.target.tunnel ?? null, connect: { ok: p.probe.ok, latencyMs: p.probe.connectLatencyMs, @@ -486,6 +610,11 @@ export async function gatewayStatusCommand( for (const p of probed) { runtime.log(renderTargetHeader(p.target, rich)); runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`); + if (p.target.tunnel?.kind === "ssh") { + runtime.log( + ` ${colorize(rich, theme.muted, "ssh")}: ${colorize(rich, theme.command, p.target.tunnel.target)}`, + ); + } if (p.probe.ok && p.self) { const host = p.self.host ?? "unknown"; const ip = p.self.ip ? ` (${p.self.ip})` : ""; diff --git a/src/config/types.ts b/src/config/types.ts index b99312195..1ccc17df6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -875,6 +875,13 @@ export type GatewayTailscaleConfig = { export type GatewayRemoteConfig = { /** Remote Gateway WebSocket URL (ws:// or wss://). */ url?: string; + /** + * Remote gateway over SSH, forwarding the gateway port to localhost. + * Format: "user@host" or "user@host:port" (port defaults to 22). + */ + sshTarget?: string; + /** Optional SSH identity file path. */ + sshIdentity?: string; /** Token for remote auth (when the gateway requires token auth). */ token?: string; /** Password for remote auth (when the gateway requires password auth). */ diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts new file mode 100644 index 000000000..5b459c0b9 --- /dev/null +++ b/src/infra/ssh-tunnel.ts @@ -0,0 +1,202 @@ +import { spawn } from "node:child_process"; +import net from "node:net"; + +import { ensurePortAvailable } from "./ports.js"; + +export type SshParsedTarget = { + user?: string; + host: string; + port: number; +}; + +export type SshTunnel = { + parsedTarget: SshParsedTarget; + localPort: number; + remotePort: number; + pid: number | null; + stderr: string[]; + stop: () => Promise; +}; + +function isErrno(err: unknown): err is NodeJS.ErrnoException { + return Boolean(err && typeof err === "object" && "code" in err); +} + +export function parseSshTarget(raw: string): SshParsedTarget | null { + const trimmed = raw.trim().replace(/^ssh\s+/, ""); + if (!trimmed) return null; + + const [userPart, hostPart] = trimmed.includes("@") + ? ((): [string | undefined, string] => { + const idx = trimmed.indexOf("@"); + const user = trimmed.slice(0, idx).trim(); + const host = trimmed.slice(idx + 1).trim(); + return [user || undefined, host]; + })() + : [undefined, trimmed]; + + const colonIdx = hostPart.lastIndexOf(":"); + if (colonIdx > 0 && colonIdx < hostPart.length - 1) { + const host = hostPart.slice(0, colonIdx).trim(); + const portRaw = hostPart.slice(colonIdx + 1).trim(); + const port = Number.parseInt(portRaw, 10); + if (!host || !Number.isFinite(port) || port <= 0) return null; + return { user: userPart, host, port }; + } + + if (!hostPart) return null; + return { user: userPart, host: hostPart, port: 22 }; +} + +async function pickEphemeralPort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + server.close(() => { + if (!addr || typeof addr === "string") { + reject(new Error("failed to allocate a local port")); + return; + } + resolve(addr.port); + }); + }); + }); +} + +async function canConnectLocal(port: number): Promise { + return await new Promise((resolve) => { + const socket = net.connect({ host: "127.0.0.1", port }); + const done = (ok: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(ok); + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.setTimeout(250, () => done(false)); + }); +} + +async function waitForLocalListener( + port: number, + timeoutMs: number, +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await canConnectLocal(port)) return; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`ssh tunnel did not start listening on localhost:${port}`); +} + +export async function startSshPortForward(opts: { + target: string; + identity?: string; + localPortPreferred: number; + remotePort: number; + timeoutMs: number; +}): Promise { + const parsed = parseSshTarget(opts.target); + if (!parsed) throw new Error(`invalid SSH target: ${opts.target}`); + + let localPort = opts.localPortPreferred; + try { + await ensurePortAvailable(localPort); + } catch (err) { + if (isErrno(err) && err.code === "EADDRINUSE") { + localPort = await pickEphemeralPort(); + } else { + throw err; + } + } + + const userHost = parsed.user ? `${parsed.user}@${parsed.host}` : parsed.host; + const args = [ + "-N", + "-L", + `${localPort}:127.0.0.1:${opts.remotePort}`, + "-p", + String(parsed.port), + "-o", + "ExitOnForwardFailure=yes", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=accept-new", + "-o", + "UpdateHostKeys=yes", + "-o", + "ConnectTimeout=5", + "-o", + "ServerAliveInterval=15", + "-o", + "ServerAliveCountMax=3", + ]; + if (opts.identity?.trim()) { + args.push("-i", opts.identity.trim()); + } + args.push(userHost); + + const stderr: string[] = []; + const child = spawn("/usr/bin/ssh", args, { + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk) => { + const lines = String(chunk) + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + stderr.push(...lines); + }); + + const stop = async () => { + if (child.killed) return; + child.kill("SIGTERM"); + await new Promise((resolve) => { + const t = setTimeout(() => { + try { + child.kill("SIGKILL"); + } finally { + resolve(); + } + }, 1500); + child.once("exit", () => { + clearTimeout(t); + resolve(); + }); + }); + }; + + try { + await Promise.race([ + waitForLocalListener(localPort, Math.max(250, opts.timeoutMs)), + new Promise((_, reject) => { + child.once("exit", (code, signal) => { + reject( + new Error( + `ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`, + ), + ); + }); + }), + ]); + } catch (err) { + await stop(); + const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : ""; + throw new Error( + `${err instanceof Error ? err.message : String(err)}${suffix}`, + ); + } + + return { + parsedTarget: parsed, + localPort, + remotePort: opts.remotePort, + pid: typeof child.pid === "number" ? child.pid : null, + stderr, + stop, + }; +}