diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd93da39..b023a40ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`. - CLI: set process titles to `clawdbot-` for clearer process listings. +- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware). - Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee. - Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows. diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index 79705aade..dbf29c000 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -341,6 +341,15 @@ final class AppState { return host } + private static func sanitizeSSHTarget(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.hasPrefix("ssh ") { + return trimmed.replacingOccurrences(of: "ssh ", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + return trimmed + } + private func startConfigWatcher() { let configUrl = ClawdbotConfigFile.url() self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in @@ -406,6 +415,7 @@ final class AppState { let connectionMode = self.connectionMode let remoteTarget = self.remoteTarget + let remoteIdentity = self.remoteIdentity let desiredMode: String? = switch connectionMode { case .local: "local" @@ -435,15 +445,46 @@ final class AppState { changed = true } - if connectionMode == .remote, let host = remoteHost { + if connectionMode == .remote { var remote = gateway["remote"] as? [String: Any] ?? [:] - let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) - let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" - let port = parsedExisting?.port ?? 18789 - let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" - if existingUrl != desiredUrl { - remote["url"] = desiredUrl + var remoteChanged = false + + if let host = remoteHost { + let existingUrl = (remote["url"] as? String)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl) + let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws" + let port = parsedExisting?.port ?? 18789 + let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)" + if existingUrl != desiredUrl { + remote["url"] = desiredUrl + remoteChanged = true + } + } + + let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget) + if !sanitizedTarget.isEmpty { + if (remote["sshTarget"] as? String) != sanitizedTarget { + remote["sshTarget"] = sanitizedTarget + remoteChanged = true + } + } else if remote["sshTarget"] != nil { + remote.removeValue(forKey: "sshTarget") + remoteChanged = true + } + + let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedIdentity.isEmpty { + if (remote["sshIdentity"] as? String) != trimmedIdentity { + remote["sshIdentity"] = trimmedIdentity + remoteChanged = true + } + } else if remote["sshIdentity"] != nil { + remote.removeValue(forKey: "sshIdentity") + remoteChanged = true + } + + if remoteChanged { gateway["remote"] = remote changed = true } diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index b4a5acb37..61c9e3b6f 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -11,6 +11,7 @@ const resolveGatewayPort = vi.fn(() => 18789); const discoverGatewayBeacons = vi.fn(async () => []); const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10"); const sshStop = vi.fn(async () => {}); +const resolveSshConfig = vi.fn(async () => null); const startSshPortForward = vi.fn(async () => ({ parsedTarget: { user: "me", host: "studio", port: 22 }, localPort: 18789, @@ -92,8 +93,16 @@ vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(), })); -vi.mock("../infra/ssh-tunnel.js", () => ({ - startSshPortForward: (opts: unknown) => startSshPortForward(opts), +vi.mock("../infra/ssh-tunnel.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startSshPortForward: (opts: unknown) => startSshPortForward(opts), + }; +}); + +vi.mock("../infra/ssh-config.js", () => ({ + resolveSshConfig: (opts: unknown) => resolveSshConfig(opts), })); vi.mock("../gateway/probe.js", () => ({ @@ -179,4 +188,122 @@ describe("gateway-status command", () => { const targets = parsed.targets as Array>; expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true); }); + + it("infers SSH target from gateway.remote.url and ssh config", async () => { + const runtimeLogs: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (_msg: string) => {}, + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + const originalUser = process.env.USER; + try { + process.env.USER = "steipete"; + loadConfig.mockReturnValueOnce({ + gateway: { + mode: "remote", + remote: { url: "ws://peters-mac-studio-1.sheep-coho.ts.net:18789", token: "rtok" }, + }, + }); + resolveSshConfig.mockResolvedValueOnce({ + user: "steipete", + host: "peters-mac-studio-1.sheep-coho.ts.net", + port: 2222, + identityFiles: ["/tmp/id_ed25519"], + }); + + startSshPortForward.mockClear(); + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000", json: true }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + expect(startSshPortForward).toHaveBeenCalledTimes(1); + const call = startSshPortForward.mock.calls[0]?.[0] as { + target: string; + identity?: string; + }; + expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222"); + expect(call.identity).toBe("/tmp/id_ed25519"); + } finally { + process.env.USER = originalUser; + } + }); + + it("falls back to host-only when USER is missing and ssh config is unavailable", async () => { + const runtimeLogs: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (_msg: string) => {}, + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + const originalUser = process.env.USER; + try { + process.env.USER = ""; + loadConfig.mockReturnValueOnce({ + gateway: { + mode: "remote", + remote: { url: "ws://studio.example:18789", token: "rtok" }, + }, + }); + resolveSshConfig.mockResolvedValueOnce(null); + + startSshPortForward.mockClear(); + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000", json: true }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + const call = startSshPortForward.mock.calls[0]?.[0] as { + target: string; + }; + expect(call.target).toBe("studio.example"); + } finally { + process.env.USER = originalUser; + } + }); + + it("keeps explicit SSH identity even when ssh config provides one", async () => { + const runtimeLogs: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (_msg: string) => {}, + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + loadConfig.mockReturnValueOnce({ + gateway: { + mode: "remote", + remote: { url: "ws://studio.example:18789", token: "rtok" }, + }, + }); + resolveSshConfig.mockResolvedValueOnce({ + user: "me", + host: "studio.example", + port: 22, + identityFiles: ["/tmp/id_from_config"], + }); + + startSshPortForward.mockClear(); + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000", json: true, sshIdentity: "/tmp/explicit_id" }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + const call = startSshPortForward.mock.calls[0]?.[0] as { + identity?: string; + }; + expect(call.identity).toBe("/tmp/explicit_id"); + }); }); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index fede2a836..bd73ee1bc 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -2,7 +2,8 @@ import { withProgress } from "../cli/progress.js"; import { loadConfig, resolveGatewayPort } from "../config/config.js"; import { probeGateway } from "../gateway/probe.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; -import { startSshPortForward } from "../infra/ssh-tunnel.js"; +import { resolveSshConfig } from "../infra/ssh-config.js"; +import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js"; import type { RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; @@ -47,13 +48,25 @@ export async function gatewayStatusCommand( }); let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget); - const sshIdentity = + let sshIdentity = sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity); const remotePort = resolveGatewayPort(cfg); let sshTunnelError: string | null = null; let sshTunnelStarted = false; + if (!sshTarget) { + sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url); + } + + if (sshTarget) { + const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs); + if (resolved) { + sshTarget = resolved.target; + if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity; + } + } + const { discovery, probed } = await withProgress( { label: "Inspecting gateways…", @@ -314,3 +327,53 @@ export async function gatewayStatusCommand( if (!ok) runtime.exit(1); } + +function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null { + if (typeof rawUrl !== "string") return null; + const trimmed = rawUrl.trim(); + if (!trimmed) return null; + let host: string | null = null; + try { + host = new URL(trimmed).hostname || null; + } catch { + return null; + } + if (!host) return null; + const user = process.env.USER?.trim() || ""; + return user ? `${user}@${host}` : host; +} + +function buildSshTarget(input: { user?: string; host?: string; port?: number }): string | null { + const host = input.host?.trim() ?? ""; + if (!host) return null; + const user = input.user?.trim() ?? ""; + const base = user ? `${user}@${host}` : host; + const port = input.port ?? 22; + if (port && port !== 22) return `${base}:${port}`; + return base; +} + +async function resolveSshTarget( + rawTarget: string, + identity: string | null, + overallTimeoutMs: number, +): Promise<{ target: string; identity?: string } | null> { + const parsed = parseSshTarget(rawTarget); + if (!parsed) return null; + const config = await resolveSshConfig(parsed, { + identity: identity ?? undefined, + timeoutMs: Math.min(800, overallTimeoutMs), + }); + if (!config) return { target: rawTarget, identity: identity ?? undefined }; + const target = buildSshTarget({ + user: config.user ?? parsed.user, + host: config.host ?? parsed.host, + port: config.port ?? parsed.port, + }); + if (!target) return { target: rawTarget, identity: identity ?? undefined }; + const identityFile = + identity ?? + config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ?? + undefined; + return { target, identity: identityFile }; +} diff --git a/src/infra/ssh-config.test.ts b/src/infra/ssh-config.test.ts new file mode 100644 index 000000000..8f3248e0c --- /dev/null +++ b/src/infra/ssh-config.test.ts @@ -0,0 +1,81 @@ +import { spawn } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => { + const spawn = vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { + stdout?: EventEmitter & { setEncoding?: (enc: string) => void }; + kill?: (signal?: string) => void; + }; + const stdout = new EventEmitter() as EventEmitter & { + setEncoding?: (enc: string) => void; + }; + stdout.setEncoding = vi.fn(); + child.stdout = stdout; + child.kill = vi.fn(); + process.nextTick(() => { + stdout.emit( + "data", + [ + "user steipete", + "hostname peters-mac-studio-1.sheep-coho.ts.net", + "port 2222", + "identityfile none", + "identityfile /tmp/id_ed25519", + "", + ].join("\n"), + ); + child.emit("exit", 0); + }); + return child; + }); + return { spawn }; +}); + +const spawnMock = vi.mocked(spawn); + +describe("ssh-config", () => { + it("parses ssh -G output", async () => { + const { parseSshConfigOutput } = await import("./ssh-config.js"); + const parsed = parseSshConfigOutput( + "user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n", + ); + expect(parsed.user).toBe("bob"); + expect(parsed.host).toBe("example.com"); + expect(parsed.port).toBe(2222); + expect(parsed.identityFiles).toEqual(["/tmp/id"]); + }); + + it("resolves ssh config via ssh -G", async () => { + const { resolveSshConfig } = await import("./ssh-config.js"); + const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 }); + expect(config?.user).toBe("steipete"); + expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net"); + expect(config?.port).toBe(2222); + expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]); + }); + + it("returns null when ssh -G fails", async () => { + spawnMock.mockImplementationOnce(() => { + const child = new EventEmitter() as EventEmitter & { + stdout?: EventEmitter & { setEncoding?: (enc: string) => void }; + kill?: (signal?: string) => void; + }; + const stdout = new EventEmitter() as EventEmitter & { + setEncoding?: (enc: string) => void; + }; + stdout.setEncoding = vi.fn(); + child.stdout = stdout; + child.kill = vi.fn(); + process.nextTick(() => { + child.emit("exit", 1); + }); + return child; + }); + + const { resolveSshConfig } = await import("./ssh-config.js"); + const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 }); + expect(config).toBeNull(); + }); +}); diff --git a/src/infra/ssh-config.ts b/src/infra/ssh-config.ts new file mode 100644 index 000000000..037405e8c --- /dev/null +++ b/src/infra/ssh-config.ts @@ -0,0 +1,95 @@ +import { spawn } from "node:child_process"; + +import type { SshParsedTarget } from "./ssh-tunnel.js"; + +export type SshResolvedConfig = { + user?: string; + host?: string; + port?: number; + identityFiles: string[]; +}; + +function parsePort(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return undefined; + return parsed; +} + +export function parseSshConfigOutput(output: string): SshResolvedConfig { + const result: SshResolvedConfig = { identityFiles: [] }; + const lines = output.split("\n"); + for (const raw of lines) { + const line = raw.trim(); + if (!line) continue; + const [key, ...rest] = line.split(/\s+/); + const value = rest.join(" ").trim(); + if (!key || !value) continue; + switch (key) { + case "user": + result.user = value; + break; + case "hostname": + result.host = value; + break; + case "port": + result.port = parsePort(value); + break; + case "identityfile": + if (value !== "none") result.identityFiles.push(value); + break; + default: + break; + } + } + return result; +} + +export async function resolveSshConfig( + target: SshParsedTarget, + opts: { identity?: string; timeoutMs?: number } = {}, +): Promise { + const sshPath = "/usr/bin/ssh"; + const args = ["-G"]; + if (target.port > 0 && target.port !== 22) { + args.push("-p", String(target.port)); + } + if (opts.identity?.trim()) { + args.push("-i", opts.identity.trim()); + } + const userHost = target.user ? `${target.user}@${target.host}` : target.host; + args.push(userHost); + + return await new Promise((resolve) => { + const child = spawn(sshPath, args, { + stdio: ["ignore", "pipe", "ignore"], + }); + let stdout = ""; + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (chunk) => { + stdout += String(chunk); + }); + + const timeoutMs = Math.max(200, opts.timeoutMs ?? 800); + const timer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } finally { + resolve(null); + } + }, timeoutMs); + + child.once("error", () => { + clearTimeout(timer); + resolve(null); + }); + child.once("exit", (code) => { + clearTimeout(timer); + if (code !== 0 || !stdout.trim()) { + resolve(null); + return; + } + resolve(parseSshConfigOutput(stdout)); + }); + }); +}