control: seed events, add tests, update remote doc

This commit is contained in:
Peter Steinberger
2025-12-08 22:03:46 +01:00
parent 9c54e48194
commit e38bdd0d2d
3 changed files with 112 additions and 2 deletions

View File

@@ -13,7 +13,7 @@ This repo supports “remote over SSH” by keeping a single relay (the master)
1) Establish SSH tunnel. 1) Establish SSH tunnel.
2) Open TCP socket to the local forwarded port. 2) Open TCP socket to the local forwarded port.
3) Send `ping` to verify connectivity. 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). 5) Listen for `event` frames (heartbeat updates, relay status).
## Heartbeats ## 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. - The menu app skips SSH and connects directly to `127.0.0.1:18789` with the same protocol.
## Failure handling ## 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. - 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 ## Security
- Control server listens only on localhost. - Control server listens only on localhost.
- SSH tunneling reuses existing keys/agent; no additional auth is added by the control server. - SSH tunneling reuses existing keys/agent; no additional auth is added by the control server.

View File

@@ -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<ReturnType<typeof startControlChannel>>;
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<Record<string, unknown>>((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<string, unknown>);
}
};
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<Record<string, unknown>>((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<string, unknown>);
}
}
};
client.on("data", handler);
});
emitHeartbeatEvent({ status: "sent", to: "+1", preview: "hi" });
const evt = await evtPromise;
expect(evt.event).toBe("heartbeat");
});
});

View File

@@ -54,6 +54,17 @@ export async function startControlChannel(
const server = net.createServer((socket) => { const server = net.createServer((socket) => {
socket.setEncoding("utf8"); socket.setEncoding("utf8");
clients.add(socket); 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 = ""; let buffer = "";
socket.on("data", (chunk) => { socket.on("data", (chunk) => {