control: seed events, add tests, update remote doc
This commit is contained in:
@@ -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.
|
||||
|
||||
95
src/infra/control-channel.test.ts
Normal file
95
src/infra/control-channel.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user