diff --git a/docs/remote.md b/docs/remote.md index 195e3f8be..ba724d8eb 100644 --- a/docs/remote.md +++ b/docs/remote.md @@ -13,7 +13,7 @@ This repo supports “remote over SSH” by keeping a single relay (the master) 1) Establish SSH tunnel. 2) Open TCP socket to the local forwarded port. 3) Send `ping` to verify connectivity. -4) Issue `health` and `last-heartbeat` requests to seed UI. +4) Issue `health`, `status`, and `last-heartbeat` requests to seed UI. 5) Listen for `event` frames (heartbeat updates, relay status). ## Heartbeats @@ -25,9 +25,13 @@ This repo supports “remote over SSH” by keeping a single relay (the master) - The menu app skips SSH and connects directly to `127.0.0.1:18789` with the same protocol. ## Failure handling -- If the tunnel drops, the client reconnects and re-issues `ping`, `health`, and `last-heartbeat` to refresh state. +- If the tunnel drops, the client reconnects and re-issues `ping`, `health`, and `last-heartbeat` to refresh state (the mac app shows “Control channel disconnected”). - If the control port is unavailable (older relay), the app can optionally fall back to the legacy CLI path, but the goal is to rely solely on the control channel. +## Test Remote (in the mac app) +1) SSH reachability check (`ssh -o BatchMode=yes … echo ok`). +2) If SSH succeeds, the app opens the control tunnel and issues a `health` request; success marks the remote as ready. + ## Security - Control server listens only on localhost. - SSH tunneling reuses existing keys/agent; no additional auth is added by the control server. diff --git a/src/infra/control-channel.test.ts b/src/infra/control-channel.test.ts new file mode 100644 index 000000000..f5bd310b3 --- /dev/null +++ b/src/infra/control-channel.test.ts @@ -0,0 +1,95 @@ +import crypto from "node:crypto"; +import net from "node:net"; + +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { startControlChannel } from "./control-channel.js"; +import { emitHeartbeatEvent } from "./heartbeat-events.js"; + +// Mock health/status to avoid hitting real services +vi.mock("../commands/health.js", () => ({ + getHealthSnapshot: vi.fn(async () => ({ + ts: Date.now(), + durationMs: 10, + web: { + linked: true, + authAgeMs: 1000, + connect: { ok: true, status: 200, error: null, elapsedMs: 5 }, + }, + heartbeatSeconds: 60, + sessions: { path: "/tmp/sessions.json", count: 1, recent: [] }, + ipc: { path: "/tmp/clawdis.sock", exists: true }, + })), +})); + +vi.mock("../commands/status.js", () => ({ + getStatusSummary: vi.fn(async () => ({ + web: { linked: true, authAgeMs: 1000 }, + heartbeatSeconds: 60, + sessions: { path: "/tmp/sessions.json", count: 1, recent: [] }, + })), +})); + +describe("control channel", () => { + let server: Awaited>; + let client: net.Socket; + + beforeAll(async () => { + server = await startControlChannel({}, { port: 19999 }); + client = net.createConnection({ host: "127.0.0.1", port: 19999 }); + }); + + afterAll(async () => { + client.destroy(); + await server.close(); + }); + + const sendRequest = (method: string, params?: unknown) => + new Promise>((resolve, reject) => { + const id = crypto.randomUUID(); + const frame = { type: "request", id, method, params }; + client.write(`${JSON.stringify(frame)}\n`); + const onData = (chunk: Buffer) => { + const line = chunk.toString("utf8").trim(); + const parsed = JSON.parse(line) as { id?: string }; + if (parsed.id === id) { + client.off("data", onData); + resolve(parsed as Record); + } + }; + client.on("data", onData); + client.on("error", reject); + }); + + it("responds to ping", async () => { + const res = await sendRequest("ping"); + expect(res.ok).toBe(true); + }); + + it("returns health snapshot", async () => { + const res = await sendRequest("health"); + expect(res.ok).toBe(true); + const payload = res.payload as { web?: { linked?: boolean } }; + expect(payload.web?.linked).toBe(true); + }); + + it("emits heartbeat events", async () => { + const evtPromise = new Promise>((resolve) => { + const handler = (chunk: Buffer) => { + const lines = chunk.toString("utf8").trim().split(/\n/); + for (const line of lines) { + const parsed = JSON.parse(line) as { type?: string; event?: string }; + if (parsed.type === "event" && parsed.event === "heartbeat") { + client.off("data", handler); + resolve(parsed as Record); + } + } + }; + client.on("data", handler); + }); + + emitHeartbeatEvent({ status: "sent", to: "+1", preview: "hi" }); + const evt = await evtPromise; + expect(evt.event).toBe("heartbeat"); + }); +}); diff --git a/src/infra/control-channel.ts b/src/infra/control-channel.ts index b05f52627..1981d4345 100644 --- a/src/infra/control-channel.ts +++ b/src/infra/control-channel.ts @@ -54,6 +54,17 @@ export async function startControlChannel( const server = net.createServer((socket) => { socket.setEncoding("utf8"); clients.add(socket); + + // Seed relay status + last heartbeat for new clients. + write(socket, { + type: "event", + event: "relay-status", + payload: { state: "running" }, + }); + const last = getLastHeartbeatEvent(); + if (last) + write(socket, { type: "event", event: "heartbeat", payload: last }); + let buffer = ""; socket.on("data", (chunk) => {