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.
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.

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) => {
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) => {