feat(discovery): gateway bonjour + node pairing bridge

This commit is contained in:
Peter Steinberger
2025-12-13 03:47:27 +00:00
parent 163080b609
commit eace21dcae
18 changed files with 1780 additions and 29 deletions

View File

@@ -28,13 +28,6 @@ actor BridgeServer {
params.includePeerToPeer = true
let listener = try NWListener(using: params, on: .any)
let name = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
listener.service = NWListener.Service(
name: "\(name) (Clawdis)",
type: ClawdisBonjour.bridgeServiceType,
domain: ClawdisBonjour.bridgeServiceDomain,
txtRecord: nil)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }

View File

@@ -5,6 +5,7 @@ struct GeneralSettings: View {
@ObservedObject var state: AppState
@ObservedObject private var healthStore = HealthStore.shared
@ObservedObject private var gatewayManager = GatewayProcessManager.shared
@StateObject private var masterDiscovery = MasterDiscoveryModel()
@State private var isInstallingCLI = false
@State private var cliStatus: String?
@State private var cliInstalled = false
@@ -124,6 +125,18 @@ struct GeneralSettings: View {
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Menu {
if self.masterDiscovery.masters.isEmpty {
Button(self.masterDiscovery.statusText) {}.disabled(true)
} else {
ForEach(self.masterDiscovery.masters) { master in
Button(master.displayName) { self.applyDiscoveredMaster(master) }
}
}
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
}
.help("Discover Clawdis masters on your LAN")
Button {
Task { await self.testRemote() }
} label: {
@@ -188,6 +201,8 @@ struct GeneralSettings: View {
.lineLimit(1)
}
.transition(.opacity)
.onAppear { self.masterDiscovery.start() }
.onDisappear { self.masterDiscovery.stop() }
}
private var controlStatusLine: String {
@@ -562,6 +577,17 @@ extension GeneralSettings {
alert.addButton(withTitle: "OK")
alert.runModal()
}
private func applyDiscoveredMaster(_ master: MasterDiscoveryModel.DiscoveredMaster) {
let host = master.tailnetDns ?? master.lanHost
guard let host else { return }
let user = NSUserName()
var target = "\(user)@\(host)"
if master.sshPort != 22 {
target += ":\(master.sshPort)"
}
self.state.remoteTarget = target
}
}
private func healthAgeString(_ ms: Double?) -> String {

View File

@@ -0,0 +1,103 @@
import Foundation
import Network
@MainActor
final class MasterDiscoveryModel: ObservableObject {
struct DiscoveredMaster: Identifiable, Equatable {
var id: String { self.debugID }
var displayName: String
var lanHost: String?
var tailnetDns: String?
var sshPort: Int
var debugID: String
}
@Published var masters: [DiscoveredMaster] = []
@Published var statusText: String = "Idle"
private var browser: NWBrowser?
private static let serviceType = "_clawdis-master._tcp"
private static let serviceDomain = "local."
func start() {
if self.browser != nil { return }
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(for: .bonjour(type: Self.serviceType, domain: Self.serviceDomain), using: params)
browser.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self else { return }
switch state {
case .setup:
self.statusText = "Setup"
case .ready:
self.statusText = "Searching…"
case let .failed(err):
self.statusText = "Failed: \(err)"
case .cancelled:
self.statusText = "Stopped"
case let .waiting(err):
self.statusText = "Waiting: \(err)"
@unknown default:
self.statusText = "Unknown"
}
}
}
browser.browseResultsChangedHandler = { [weak self] results, _ in
Task { @MainActor in
guard let self else { return }
self.masters = results.compactMap { result -> DiscoveredMaster? in
guard case let .service(name, _, _, _) = result.endpoint else { return nil }
var lanHost: String?
var tailnetDns: String?
var sshPort = 22
if case let .bonjour(txt) = result.metadata {
let dict = txt.dictionary
if let value = dict["lanHost"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
lanHost = trimmed.isEmpty ? nil : trimmed
}
if let value = dict["tailnetDns"] {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
tailnetDns = trimmed.isEmpty ? nil : trimmed
}
if let value = dict["sshPort"],
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0
{
sshPort = parsed
}
}
return DiscoveredMaster(
displayName: name,
lanHost: lanHost,
tailnetDns: tailnetDns,
sshPort: sshPort,
debugID: Self.prettyEndpointDebugID(result.endpoint))
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
}
}
self.browser = browser
browser.start(queue: DispatchQueue(label: "com.steipete.clawdis.macos.master-discovery"))
}
func stop() {
self.browser?.cancel()
self.browser = nil
self.masters = []
self.statusText = "Stopped"
}
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
String(describing: endpoint)
}
}

View File

@@ -182,7 +182,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await HealthStore.shared.refresh(onDemand: true) }
Task { await PortGuardian.shared.sweep(mode: AppStateStore.shared.connectionMode) }
Task { await self.socketServer.start() }
Task { await BridgeServer.shared.start() }
self.scheduleFirstRunOnboardingIfNeeded()
// Developer/testing helper: auto-open WebChat when launched with --webchat
@@ -201,7 +200,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { await AgentRPC.shared.shutdown() }
Task { await GatewayConnection.shared.shutdown() }
Task { await self.socketServer.stop() }
Task { await BridgeServer.shared.stop() }
}
@MainActor

106
docs/discovery.md Normal file
View File

@@ -0,0 +1,106 @@
---
summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding the master gateway"
read_when:
- Implementing or changing Bonjour discovery/advertising
- Adjusting remote connection modes (direct vs SSH)
- Designing bridge + pairing for remote nodes
---
# Discovery & transports
Clawdis has two distinct problems that look similar on the surface:
1) **Operator remote control**: the macOS menu bar app controlling a “master” gateway running elsewhere.
2) **Node pairing**: Iris/iOS (and future nodes) finding a gateway and pairing securely.
The design goal is to keep all network discovery/advertising in the **Node Gateway** (`clawd` / `clawdis gateway`) and keep clients (mac app, iOS) as consumers.
## Terms
- **Master gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs providers.
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
## Why we keep both “direct” and SSH
- **Direct bridge** is the best UX on the same network and within a tailnet:
- auto-discovery on LAN via Bonjour
- pairing tokens + ACLs owned by the gateway
- no shell access required; protocol surface can stay tight and auditable
- **SSH** remains the universal fallback:
- works anywhere you have SSH access (even across unrelated networks)
- survives multicast/mDNS issues
- requires no new inbound ports besides SSH
## Discovery inputs (how clients learn where the master is)
### 1) Bonjour / mDNS (LAN only)
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
Target direction:
- The **gateway** advertises itself (and/or its bridge) via Bonjour.
- Clients browse and show a “pick a master” list, then store the chosen endpoint.
#### Current implementation
- Service types:
- `_clawdis-master._tcp` (gateway “master” beacon)
- `_clawdis-bridge._tcp` (optional; bridge transport beacon)
- TXT keys (non-secret):
- `role=master`
- `lanHost=<hostname>.local`
- `sshPort=22` (or whatever is advertised)
- `gatewayPort=18789` (loopback WS port; informational)
- `bridgePort=18790` (when bridge is enabled)
- `tailnetDns=<magicdns>` (optional hint)
Disable/override:
- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising.
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bind/port.
### 2) Tailnet (cross-network)
For London/Vienna style setups, Bonjour wont help. The recommended “direct” target is:
- Tailscale MagicDNS name (preferred) or a stable tailnet IP.
If the gateway can detect it is running under Tailscale, it can publish `tailnetDns` as an optional hint for clients.
### 3) Manual / SSH target
When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port.
See `docs/remote.md`.
## Transport selection (client policy)
Recommended client behavior:
1) If a paired direct endpoint is configured and reachable, use it.
2) Else, if Bonjour finds a master on LAN, offer a one-tap “Use this master” choice and save it as the direct endpoint.
3) Else, if a tailnet DNS/IP is configured, try direct.
4) Else, fall back to SSH.
## Pairing + auth (direct transport)
The gateway is the source of truth for node/client admission.
- Pairing requests are created/approved/rejected in the gateway (see `docs/gateway/pairing.md`).
- The bridge enforces:
- auth (token / keypair)
- scopes/ACLs (bridge is not a raw proxy to every gateway method)
- rate limits
## Where the code lives (target architecture)
- Node gateway:
- advertises discovery beacons (Bonjour)
- owns pairing storage + decisions
- runs the bridge listener (direct transport)
- macOS app:
- UI for picking a master, showing pairing prompts, and troubleshooting
- SSH tunneling only for the fallback path
- iOS node:
- browses Bonjour (LAN) as a convenience only
- uses direct transport + pairing to connect to the gateway

View File

@@ -17,7 +17,7 @@ This enables:
## Concepts
- **Pending request**: a node asked to join; requires explicit approve/reject.
- **Paired node**: node is allowed; gateway returns an auth token for subsequent connects.
- **Bridge**: LAN transport that forwards between node ↔ gateway. The bridge does not decide membership.
- **Bridge**: direct transport endpoint owned by the gateway. The bridge does not decide membership.
## API surface (gateway protocol)
These are conceptual method names; wire them into `src/gateway/protocol/schema.ts` and regenerate Swift types.
@@ -46,20 +46,24 @@ These are conceptual method names; wire them into `src/gateway/protocol/schema.t
- Creates (or returns) a pending request.
- Params: node metadata (same shape as `node.pair.requested` payload, minus `requestId`/`ts`).
- Result:
- `requestId`
- `status` ("pending" | "alreadyPaired")
- If already paired: may include `token` directly to allow fast path.
- `status` ("pending")
- `created` (boolean) — whether this call created the pending request
- `request` (pending request object), including `isRepair` when the node was already paired
- Security: **never returns an existing token**. If a paired node “lost” its token, it must be approved again (token rotation).
- `node.pair.list`
- Returns:
- `pending[]` (pending requests)
- `paired[]` (paired node records)
- `node.pair.approve`
- Params: `{ requestId }`
- Result: `{ nodeId, token }`
- Result: `{ requestId, node: { nodeId, token, ... } }`
- Must be idempotent (first decision wins).
- `node.pair.reject`
- Params: `{ requestId }`
- Result: `{ nodeId }`
- Result: `{ requestId, nodeId }`
- `node.pair.verify`
- Params: `{ nodeId, token }`
- Result: `{ ok: boolean, node?: { nodeId, ... } }`
## CLI flows
CLI must be able to fully operate without any GUI:
@@ -80,10 +84,9 @@ Notes:
- Pending entries should have a TTL (e.g. 5 minutes) and expire automatically.
## Bridge integration
The macOS Bridge is responsible for:
- Surfacing the pairing request to the gateway (`node.pair.request`).
- Waiting for the decision (`node.pair.approve`/`reject`) and completing the on-wire pairing handshake to the node.
- Enforcing ACLs on what the node can call, even after paired.
Target direction:
- The gateway runs the bridge listener (LAN/tailnet-facing) and advertises discovery beacons (Bonjour).
- The bridge is transport only; it forwards/scopes requests and enforces ACLs, but pairing decisions are made by the gateway.
The macOS UI (Swift) can:
- Subscribe to `node.pair.requested`, show an alert, and call `node.pair.approve` or `node.pair.reject`.
@@ -91,5 +94,4 @@ The macOS UI (Swift) can:
## Implementation note
If the bridge is only provided by the macOS app, then “no Swift app running” cannot work end-to-end.
To support headless pairing, also add a `clawdis bridge` CLI mode that provides the Bonjour bridge service and forwards to the local gateway.
The long-term goal is to move bridge hosting + Bonjour advertising into the Node gateway so headless pairing works by default.

View File

@@ -31,10 +31,10 @@ Non-goals (v1):
- macOS “Canvas” exists today, but is **mac-only** and controlled via mac app IPC (`clawdis-mac canvas ...`) rather than the Gateway protocol (`docs/mac/canvas.md`).
- Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder``AgentRPC`).
## Recommended topology (B): macOS Bridge + loopback Gateway
Keep the Node gateway loopback-only; expose a dedicated **macOS bridge** to the LAN.
## Recommended topology (B): Gateway-owned Bridge + loopback Gateway
Keep the Node gateway loopback-only; expose a dedicated **gateway-owned bridge** to the LAN/tailnet.
**iOS App** ⇄ (TLS + pairing) ⇄ **macOS Bridge** ⇄ (loopback) ⇄ **Gateway WS** (`ws://127.0.0.1:18789`)
**iOS App** ⇄ (TLS + pairing) ⇄ **Bridge (in gateway)** ⇄ (loopback) ⇄ **Gateway WS** (`ws://127.0.0.1:18789`)
Why:
- Preserves current threat model: Gateway remains local-only.
@@ -71,6 +71,11 @@ Desired behavior:
See `docs/gateway/pairing.md` for the API/events and storage.
CLI (headless approvals):
- `clawdis nodes pending`
- `clawdis nodes approve <requestId>`
- `clawdis nodes reject <requestId>`
### Authorization / scope control (bridge-side ACL)
The bridge must not be a raw proxy to every gateway method.
@@ -183,8 +188,8 @@ open ClawdisNode.xcodeproj
- Keep current Canvas root (already implemented):
- `~/Library/Application Support/Clawdis/canvas/<session>/...`
- Bridge state:
- `~/Library/Application Support/Clawdis/bridge/paired-nodes.json`
- `~/Library/Application Support/Clawdis/bridge/keys/...`
- No local pairing store (pairing is gateway-owned).
- Any local bridge-only state should remain private under Application Support.
### Gateway (node)
- Pairing (source of truth):

View File

@@ -7,6 +7,9 @@ read_when:
This repo supports “remote over SSH” by keeping a single gateway (the master) running on a host (e.g., your Mac Studio) and connecting one or more macOS menu bar clients to it. The menu app no longer shells out to `pnpm clawdis …`; it talks to the gateway over a persistent control channel that is tunneled through SSH.
Remote mode is the SSH fallback transport. As Clawdis adds a direct “bridge” transport for LAN/tailnet setups, SSH remains supported for universal reach.
See `docs/discovery.md` for how clients choose between direct vs SSH.
## Topology
- Master: runs the gateway + control server on `127.0.0.1:18789` (in-process TCP server).
- Clients: when “Remote over SSH” is selected, the app opens one SSH tunnel:

View File

@@ -35,6 +35,7 @@
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-ai": "^0.18.0",
"@mariozechner/pi-coding-agent": "^0.18.0",
"@sinclair/typebox": "^0.34.41",

210
src/cli/nodes-cli.ts Normal file
View File

@@ -0,0 +1,210 @@
import type { Command } from "commander";
import { callGateway } from "../gateway/call.js";
import { defaultRuntime } from "../runtime.js";
type NodesRpcOpts = {
url?: string;
token?: string;
timeout?: string;
json?: boolean;
};
type PendingRequest = {
requestId: string;
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
};
type PairedNode = {
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
createdAtMs?: number;
approvedAtMs?: number;
};
type PairingList = {
pending: PendingRequest[];
paired: PairedNode[];
};
const nodesCallOpts = (cmd: Command) =>
cmd
.option("--url <url>", "Gateway WebSocket URL", "ws://127.0.0.1:18789")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--json", "Output JSON", false);
const callGatewayCli = async (
method: string,
opts: NodesRpcOpts,
params?: unknown,
) =>
callGateway({
url: opts.url,
token: opts.token,
method,
params,
timeoutMs: Number(opts.timeout ?? 10_000),
clientName: "cli",
mode: "cli",
});
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
function parsePairingList(value: unknown): PairingList {
const obj =
typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: {};
const pending = Array.isArray(obj.pending)
? (obj.pending as PendingRequest[])
: [];
const paired = Array.isArray(obj.paired) ? (obj.paired as PairedNode[]) : [];
return { pending, paired };
}
export function registerNodesCli(program: Command) {
const nodes = program
.command("nodes")
.description("Manage gateway-owned node pairing");
nodesCallOpts(
nodes
.command("list")
.description("List pending and paired nodes")
.action(async (opts: NodesRpcOpts) => {
try {
const result = (await callGatewayCli(
"node.pair.list",
opts,
{},
)) as unknown;
if (opts.json) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { pending, paired } = parsePairingList(result);
defaultRuntime.log(
`Pending: ${pending.length} · Paired: ${paired.length}`,
);
if (pending.length > 0) {
defaultRuntime.log("\nPending:");
for (const r of pending) {
const name = r.displayName || r.nodeId;
const repair = r.isRepair ? " (repair)" : "";
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
const age =
typeof r.ts === "number"
? ` · ${formatAge(Date.now() - r.ts)} ago`
: "";
defaultRuntime.log(
`- ${r.requestId}: ${name}${repair}${ip}${age}`,
);
}
}
if (paired.length > 0) {
defaultRuntime.log("\nPaired:");
for (const n of paired) {
const name = n.displayName || n.nodeId;
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
defaultRuntime.log(`- ${n.nodeId}: ${name}${ip}`);
}
}
} catch (err) {
defaultRuntime.error(`nodes list failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
nodes
.command("pending")
.description("List pending pairing requests")
.action(async (opts: NodesRpcOpts) => {
try {
const result = (await callGatewayCli(
"node.pair.list",
opts,
{},
)) as unknown;
const { pending } = parsePairingList(result);
if (opts.json) {
defaultRuntime.log(JSON.stringify(pending, null, 2));
return;
}
if (pending.length === 0) {
defaultRuntime.log("No pending pairing requests.");
return;
}
for (const r of pending) {
const name = r.displayName || r.nodeId;
const repair = r.isRepair ? " (repair)" : "";
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
const age =
typeof r.ts === "number"
? ` · ${formatAge(Date.now() - r.ts)} ago`
: "";
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
}
} catch (err) {
defaultRuntime.error(`nodes pending failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
nodes
.command("approve")
.description("Approve a pending pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: NodesRpcOpts) => {
try {
const result = await callGatewayCli("node.pair.approve", opts, {
requestId,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`nodes approve failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
nodesCallOpts(
nodes
.command("reject")
.description("Reject a pending pairing request")
.argument("<requestId>", "Pending request id")
.action(async (requestId: string, opts: NodesRpcOpts) => {
try {
const result = await callGatewayCli("node.pair.reject", opts, {
requestId,
});
defaultRuntime.log(JSON.stringify(result, null, 2));
} catch (err) {
defaultRuntime.error(`nodes reject failed: ${String(err)}`);
defaultRuntime.exit(1);
}
}),
);
}

View File

@@ -13,6 +13,7 @@ import { startWebChatServer } from "../webchat/server.js";
import { registerCronCli } from "./cron-cli.js";
import { createDefaultDeps } from "./deps.js";
import { registerGatewayCli } from "./gateway-cli.js";
import { registerNodesCli } from "./nodes-cli.js";
import { forceFreePort } from "./ports.js";
export { forceFreePort };
@@ -209,6 +210,7 @@ Examples:
});
registerGatewayCli(program);
registerNodesCli(program);
registerCronCli(program);
program
.command("status")

View File

@@ -36,6 +36,16 @@ import {
GatewayFrameSchema,
type HelloOk,
HelloOkSchema,
type NodePairApproveParams,
NodePairApproveParamsSchema,
type NodePairListParams,
NodePairListParamsSchema,
type NodePairRejectParams,
NodePairRejectParamsSchema,
type NodePairRequestParams,
NodePairRequestParamsSchema,
type NodePairVerifyParams,
NodePairVerifyParamsSchema,
PROTOCOL_VERSION,
type PresenceEntry,
PresenceEntrySchema,
@@ -74,6 +84,21 @@ export const validateRequestFrame =
export const validateSendParams = ajv.compile(SendParamsSchema);
export const validateAgentParams = ajv.compile(AgentParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema,
);
export const validateNodePairListParams = ajv.compile<NodePairListParams>(
NodePairListParamsSchema,
);
export const validateNodePairApproveParams = ajv.compile<NodePairApproveParams>(
NodePairApproveParamsSchema,
);
export const validateNodePairRejectParams = ajv.compile<NodePairRejectParams>(
NodePairRejectParamsSchema,
);
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
NodePairVerifyParamsSchema,
);
export const validateCronListParams =
ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
@@ -118,6 +143,11 @@ export {
SendParamsSchema,
AgentParamsSchema,
WakeParamsSchema,
NodePairRequestParamsSchema,
NodePairListParamsSchema,
NodePairApproveParamsSchema,
NodePairRejectParamsSchema,
NodePairVerifyParamsSchema,
CronJobSchema,
CronListParamsSchema,
CronStatusParamsSchema,
@@ -152,6 +182,11 @@ export type {
TickEvent,
ShutdownEvent,
WakeParams,
NodePairRequestParams,
NodePairListParams,
NodePairApproveParams,
NodePairRejectParams,
NodePairVerifyParams,
CronJob,
CronListParams,
CronStatusParams,

View File

@@ -211,6 +211,37 @@ export const WakeParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const NodePairRequestParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
displayName: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString),
remoteIp: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
export const NodePairListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const NodePairApproveParamsSchema = Type.Object(
{ requestId: NonEmptyString },
{ additionalProperties: false },
);
export const NodePairRejectParamsSchema = Type.Object(
{ requestId: NonEmptyString },
{ additionalProperties: false },
);
export const NodePairVerifyParamsSchema = Type.Object(
{ nodeId: NonEmptyString, token: NonEmptyString },
{ additionalProperties: false },
);
export const CronScheduleSchema = Type.Union([
Type.Object(
{
@@ -441,6 +472,11 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SendParams: SendParamsSchema,
AgentParams: AgentParamsSchema,
WakeParams: WakeParamsSchema,
NodePairRequestParams: NodePairRequestParamsSchema,
NodePairListParams: NodePairListParamsSchema,
NodePairApproveParams: NodePairApproveParamsSchema,
NodePairRejectParams: NodePairRejectParamsSchema,
NodePairVerifyParams: NodePairVerifyParamsSchema,
CronJob: CronJobSchema,
CronListParams: CronListParamsSchema,
CronStatusParams: CronStatusParamsSchema,
@@ -471,6 +507,11 @@ export type ErrorShape = Static<typeof ErrorShapeSchema>;
export type StateVersion = Static<typeof StateVersionSchema>;
export type AgentEvent = Static<typeof AgentEventSchema>;
export type WakeParams = Static<typeof WakeParamsSchema>;
export type NodePairRequestParams = Static<typeof NodePairRequestParamsSchema>;
export type NodePairListParams = Static<typeof NodePairListParamsSchema>;
export type NodePairApproveParams = Static<typeof NodePairApproveParamsSchema>;
export type NodePairRejectParams = Static<typeof NodePairRejectParamsSchema>;
export type NodePairVerifyParams = Static<typeof NodePairVerifyParamsSchema>;
export type CronJob = Static<typeof CronJobSchema>;
export type CronListParams = Static<typeof CronListParamsSchema>;
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;

View File

@@ -30,11 +30,20 @@ import { resolveCronStorePath } from "../cron/store.js";
import type { CronJobCreate, CronJobPatch } from "../cron/types.js";
import { isVerbose } from "../globals.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
import { startNodeBridgeServer } from "../infra/bridge/server.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import {
getLastHeartbeatEvent,
onHeartbeatEvent,
} from "../infra/heartbeat-events.js";
import {
approveNodePairing,
listNodePairing,
rejectNodePairing,
requestNodePairing,
verifyNodeToken,
} from "../infra/node-pairing.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import {
listSystemPresence,
@@ -78,6 +87,11 @@ import {
validateCronRunsParams,
validateCronStatusParams,
validateCronUpdateParams,
validateNodePairApproveParams,
validateNodePairListParams,
validateNodePairRejectParams,
validateNodePairRequestParams,
validateNodePairVerifyParams,
validateRequestFrame,
validateSendParams,
validateWakeParams,
@@ -96,6 +110,11 @@ const METHODS = [
"last-heartbeat",
"set-heartbeats",
"wake",
"node.pair.request",
"node.pair.list",
"node.pair.approve",
"node.pair.reject",
"node.pair.verify",
"cron.list",
"cron.status",
"cron.add",
@@ -121,6 +140,8 @@ const EVENTS = [
"health",
"heartbeat",
"cron",
"node.pair.requested",
"node.pair.resolved",
];
export type GatewayServer = {
@@ -319,6 +340,8 @@ export async function startGatewayServer(
): Promise<GatewayServer> {
const host = "127.0.0.1";
const httpServer: HttpServer = createHttpServer();
let bonjourStop: (() => Promise<void>) | null = null;
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: NodeJS.ErrnoException) => {
@@ -362,7 +385,7 @@ export async function startGatewayServer(
module: "cron",
storePath: cronStorePath,
});
const cronDeps = createDefaultDeps();
const deps = createDefaultDeps();
const cronEnabled =
process.env.CLAWDIS_SKIP_CRON !== "1" && cfgAtStart.cron?.enabled === true;
const cron = new CronService({
@@ -374,7 +397,7 @@ export async function startGatewayServer(
const cfg = loadConfig();
return await runCronIsolatedAgentTurn({
cfg,
deps: cronDeps,
deps,
job,
message,
sessionKey: `cron:${job.id}`,
@@ -496,6 +519,176 @@ export async function startGatewayServer(
}
};
const bridgeHost = process.env.CLAWDIS_BRIDGE_HOST ?? "0.0.0.0";
const bridgePort =
process.env.CLAWDIS_BRIDGE_PORT !== undefined
? Number.parseInt(process.env.CLAWDIS_BRIDGE_PORT, 10)
: 18790;
const bridgeEnabled = process.env.CLAWDIS_BRIDGE_ENABLED !== "0";
const handleBridgeEvent = async (
nodeId: string,
evt: { event: string; payloadJSON?: string | null },
) => {
switch (evt.event) {
case "voice.transcript": {
if (!evt.payloadJSON) return;
let payload: unknown;
try {
payload = JSON.parse(evt.payloadJSON) as unknown;
} catch {
return;
}
const obj =
typeof payload === "object" && payload !== null
? (payload as Record<string, unknown>)
: {};
const text = typeof obj.text === "string" ? obj.text.trim() : "";
if (!text) return;
if (text.length > 20_000) return;
const sessionKeyRaw =
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
const sessionKey =
sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
const { storePath, store, entry } = loadSessionEntry(sessionKey);
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
store[sessionKey] = {
sessionId,
updatedAt: now,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
};
if (storePath) {
await saveSessionStore(storePath, store);
}
void agentCommand(
{
message: text,
sessionId,
thinking: "low",
deliver: false,
surface: "Iris",
},
defaultRuntime,
deps,
).catch((err) => {
logWarn(`bridge: agent failed node=${nodeId}: ${formatForLog(err)}`);
});
return;
}
case "agent.request": {
if (!evt.payloadJSON) return;
type AgentDeepLink = {
message?: string;
sessionKey?: string | null;
thinking?: string | null;
deliver?: boolean;
to?: string | null;
channel?: string | null;
timeoutSeconds?: number | null;
key?: string | null;
};
let link: AgentDeepLink | null = null;
try {
link = JSON.parse(evt.payloadJSON) as AgentDeepLink;
} catch {
return;
}
const message = (link?.message ?? "").trim();
if (!message) return;
if (message.length > 20_000) return;
const channelRaw =
typeof link?.channel === "string" ? link.channel.trim() : "";
const channel = channelRaw.toLowerCase();
const provider =
channel === "whatsapp" || channel === "telegram"
? channel
: undefined;
const to =
typeof link?.to === "string" && link.to.trim()
? link.to.trim()
: undefined;
const deliver = Boolean(link?.deliver) && Boolean(provider);
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
const sessionKey =
sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
const { storePath, store, entry } = loadSessionEntry(sessionKey);
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
store[sessionKey] = {
sessionId,
updatedAt: now,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
systemSent: entry?.systemSent,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
};
if (storePath) {
await saveSessionStore(storePath, store);
}
void agentCommand(
{
message,
sessionId,
thinking: link?.thinking ?? undefined,
deliver,
to,
provider,
timeout:
typeof link?.timeoutSeconds === "number"
? link.timeoutSeconds.toString()
: undefined,
surface: "Iris",
},
defaultRuntime,
deps,
).catch((err) => {
logWarn(`bridge: agent failed node=${nodeId}: ${formatForLog(err)}`);
});
return;
}
default:
return;
}
};
if (bridgeEnabled && bridgePort > 0) {
try {
const started = await startNodeBridgeServer({
host: bridgeHost,
port: bridgePort,
onEvent: handleBridgeEvent,
});
if (started.port > 0) {
bridge = started;
defaultRuntime.log(
`bridge listening on tcp://${bridgeHost}:${bridge.port} (Iris)`,
);
}
} catch (err) {
logWarn(`gateway: bridge failed to start: ${String(err)}`);
}
}
try {
const bonjour = await startGatewayBonjourAdvertiser({
gatewayPort: port,
bridgePort: bridge?.port,
});
bonjourStop = bonjour.stop;
} catch (err) {
logWarn(`gateway: bonjour advertising failed: ${String(err)}`);
}
broadcastHealthUpdate = (snap: HealthSummary) => {
broadcast("health", snap, {
stateVersion: { presence: presenceVersion, health: healthVersion },
@@ -606,7 +799,6 @@ export async function startGatewayServer(
let client: Client | null = null;
let closed = false;
const connId = randomUUID();
const deps = createDefaultDeps();
const remoteAddr = (
socket as WebSocket & { _socket?: { remoteAddress?: string } }
)._socket?.remoteAddress;
@@ -1338,6 +1530,191 @@ export async function startGatewayServer(
respond(true, { ok: true }, undefined);
break;
}
case "node.pair.request": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodePairRequestParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid node.pair.request params: ${formatValidationErrors(validateNodePairRequestParams.errors)}`,
),
);
break;
}
const p = params as {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
};
try {
const result = await requestNodePairing({
nodeId: p.nodeId,
displayName: p.displayName,
platform: p.platform,
version: p.version,
remoteIp: p.remoteIp,
});
if (result.status === "pending" && result.created) {
broadcast("node.pair.requested", result.request, {
dropIfSlow: true,
});
}
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "node.pair.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodePairListParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid node.pair.list params: ${formatValidationErrors(validateNodePairListParams.errors)}`,
),
);
break;
}
try {
const list = await listNodePairing();
respond(true, list, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "node.pair.approve": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodePairApproveParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid node.pair.approve params: ${formatValidationErrors(validateNodePairApproveParams.errors)}`,
),
);
break;
}
const { requestId } = params as { requestId: string };
try {
const approved = await approveNodePairing(requestId);
if (!approved) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"),
);
break;
}
broadcast(
"node.pair.resolved",
{
requestId,
nodeId: approved.node.nodeId,
decision: "approved",
ts: Date.now(),
},
{ dropIfSlow: true },
);
respond(true, approved, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "node.pair.reject": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodePairRejectParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid node.pair.reject params: ${formatValidationErrors(validateNodePairRejectParams.errors)}`,
),
);
break;
}
const { requestId } = params as { requestId: string };
try {
const rejected = await rejectNodePairing(requestId);
if (!rejected) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"),
);
break;
}
broadcast(
"node.pair.resolved",
{
requestId,
nodeId: rejected.nodeId,
decision: "rejected",
ts: Date.now(),
},
{ dropIfSlow: true },
);
respond(true, rejected, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "node.pair.verify": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateNodePairVerifyParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid node.pair.verify params: ${formatValidationErrors(validateNodePairVerifyParams.errors)}`,
),
);
break;
}
const { nodeId, token } = params as {
nodeId: string;
token: string;
};
try {
const result = await verifyNodeToken(nodeId, token);
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
}
break;
}
case "send": {
const p = (req.params ?? {}) as Record<string, unknown>;
if (!validateSendParams(p)) {
@@ -1682,6 +2059,20 @@ export async function startGatewayServer(
return {
close: async () => {
if (bonjourStop) {
try {
await bonjourStop();
} catch {
/* ignore */
}
}
if (bridge) {
try {
await bridge.close();
} catch {
/* ignore */
}
}
providerAbort.abort();
cron.stop();
broadcast("shutdown", {

112
src/infra/bonjour.ts Normal file
View File

@@ -0,0 +1,112 @@
import os from "node:os";
import { type CiaoService, getResponder, Protocol } from "@homebridge/ciao";
export type GatewayBonjourAdvertiser = {
stop: () => Promise<void>;
};
export type GatewayBonjourAdvertiseOpts = {
instanceName?: string;
gatewayPort: number;
sshPort?: number;
bridgePort?: number;
tailnetDns?: string;
};
function isDisabledByEnv() {
if (process.env.CLAWDIS_DISABLE_BONJOUR === "1") return true;
if (process.env.NODE_ENV === "test") return true;
if (process.env.VITEST) return true;
return false;
}
function safeServiceName(name: string) {
const trimmed = name.trim();
return trimmed.length > 0 ? trimmed : "Clawdis";
}
export async function startGatewayBonjourAdvertiser(
opts: GatewayBonjourAdvertiseOpts,
): Promise<GatewayBonjourAdvertiser> {
if (isDisabledByEnv()) {
return { stop: async () => {} };
}
const responder = getResponder();
const hostname = os.hostname().replace(/\.local$/i, "");
const instanceName =
typeof opts.instanceName === "string" && opts.instanceName.trim()
? opts.instanceName.trim()
: `${hostname} (Clawdis)`;
const txtBase: Record<string, string> = {
role: "master",
gatewayPort: String(opts.gatewayPort),
lanHost: `${hostname}.local`,
};
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
txtBase.bridgePort = String(opts.bridgePort);
}
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
txtBase.tailnetDns = opts.tailnetDns.trim();
}
const services: CiaoService[] = [];
// Master beacon: used for discovery (auto-fill SSH/direct targets).
// We advertise a TCP service so clients can resolve the host; the port itself is informational.
const master = responder.createService({
name: safeServiceName(instanceName),
type: "clawdis-master",
protocol: Protocol.TCP,
port: opts.sshPort ?? 22,
txt: {
...txtBase,
sshPort: String(opts.sshPort ?? 22),
},
});
services.push(master);
// Optional bridge beacon (same type used by Iris/iOS today).
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
const bridge = responder.createService({
name: safeServiceName(instanceName),
type: "clawdis-bridge",
protocol: Protocol.TCP,
port: opts.bridgePort,
txt: {
...txtBase,
transport: "bridge",
},
});
services.push(bridge);
}
// Do not block gateway startup on mDNS probing/announce. Advertising can take
// multiple seconds depending on network state; the gateway should come up even
// if Bonjour is slow or fails.
for (const svc of services) {
void svc.advertise().catch(() => {
/* ignore */
});
}
return {
stop: async () => {
for (const svc of services) {
try {
await svc.destroy();
} catch {
/* ignore */
}
}
try {
await responder.shutdown();
} catch {
/* ignore */
}
},
};
}

View File

@@ -0,0 +1,129 @@
import fs from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { approveNodePairing, listNodePairing } from "../node-pairing.js";
import { startNodeBridgeServer } from "./server.js";
function createLineReader(socket: net.Socket) {
let buffer = "";
const pending: Array<(line: string) => void> = [];
const flush = () => {
while (pending.length > 0) {
const idx = buffer.indexOf("\n");
if (idx === -1) return;
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
const resolve = pending.shift();
resolve?.(line);
}
};
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
flush();
});
const readLine = async () => {
flush();
const idx = buffer.indexOf("\n");
if (idx !== -1) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
return line;
}
return await new Promise<string>((resolve) => pending.push(resolve));
};
return readLine;
}
function sendLine(socket: net.Socket, obj: unknown) {
socket.write(`${JSON.stringify(obj)}\n`);
}
describe("node bridge server", () => {
let baseDir = "";
beforeAll(async () => {
process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS = "1";
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-bridge-test-"));
});
afterAll(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
delete process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS;
});
it("rejects hello when not paired", async () => {
const server = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
});
const socket = net.connect({ host: "127.0.0.1", port: server.port });
const readLine = createLineReader(socket);
sendLine(socket, { type: "hello", nodeId: "n1" });
const line = await readLine();
const msg = JSON.parse(line) as { type: string; code?: string };
expect(msg.type).toBe("error");
expect(msg.code).toBe("NOT_PAIRED");
socket.destroy();
await server.close();
});
it("pairs after approval and then accepts hello", async () => {
const server = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
});
const socket = net.connect({ host: "127.0.0.1", port: server.port });
const readLine = createLineReader(socket);
sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" });
// Approve the pending request from the gateway side.
let reqId: string | undefined;
for (let i = 0; i < 40; i += 1) {
const list = await listNodePairing(baseDir);
const req = list.pending.find((p) => p.nodeId === "n2");
if (req) {
reqId = req.requestId;
break;
}
await new Promise((r) => setTimeout(r, 25));
}
expect(reqId).toBeTruthy();
if (!reqId) throw new Error("expected a pending requestId");
await approveNodePairing(reqId, baseDir);
const line1 = JSON.parse(await readLine()) as {
type: string;
token?: string;
};
expect(line1.type).toBe("pair-ok");
expect(typeof line1.token).toBe("string");
if (!line1.token) throw new Error("expected pair-ok token");
const token = line1.token;
const line2 = JSON.parse(await readLine()) as { type: string };
expect(line2.type).toBe("hello-ok");
socket.destroy();
const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
const readLine2 = createLineReader(socket2);
sendLine(socket2, { type: "hello", nodeId: "n2", token });
const line3 = JSON.parse(await readLine2()) as { type: string };
expect(line3.type).toBe("hello-ok");
socket2.destroy();
await server.close();
});
});

356
src/infra/bridge/server.ts Normal file
View File

@@ -0,0 +1,356 @@
import net from "node:net";
import os from "node:os";
import {
getPairedNode,
listNodePairing,
requestNodePairing,
verifyNodeToken,
} from "../node-pairing.js";
type BridgeHelloFrame = {
type: "hello";
nodeId: string;
displayName?: string;
token?: string;
platform?: string;
version?: string;
};
type BridgePairRequestFrame = {
type: "pair-request";
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteAddress?: string;
};
type BridgeEventFrame = {
type: "event";
event: string;
payloadJSON?: string | null;
};
type BridgePingFrame = { type: "ping"; id: string };
type BridgePongFrame = { type: "pong"; id: string };
type BridgeInvokeResponseFrame = {
type: "invoke-res";
id: string;
ok: boolean;
payloadJSON?: string | null;
error?: { code: string; message: string } | null;
};
type BridgeHelloOkFrame = { type: "hello-ok"; serverName: string };
type BridgePairOkFrame = { type: "pair-ok"; token: string };
type BridgeErrorFrame = { type: "error"; code: string; message: string };
type AnyBridgeFrame =
| BridgeHelloFrame
| BridgePairRequestFrame
| BridgeEventFrame
| BridgePingFrame
| BridgePongFrame
| BridgeInvokeResponseFrame
| BridgeHelloOkFrame
| BridgePairOkFrame
| BridgeErrorFrame
| { type: string; [k: string]: unknown };
export type NodeBridgeServer = {
port: number;
close: () => Promise<void>;
};
export type NodeBridgeServerOpts = {
host: string;
port: number; // 0 = ephemeral
pairingBaseDir?: string;
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
onAuthenticated?: (nodeId: string) => Promise<void> | void;
onDisconnected?: (nodeId: string) => Promise<void> | void;
serverName?: string;
};
function isTestEnv() {
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
}
function encodeLine(frame: AnyBridgeFrame) {
return `${JSON.stringify(frame)}\n`;
}
async function sleep(ms: number) {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
export async function startNodeBridgeServer(
opts: NodeBridgeServerOpts,
): Promise<NodeBridgeServer> {
if (isTestEnv() && process.env.CLAWDIS_ENABLE_BRIDGE_IN_TESTS !== "1") {
return {
port: 0,
close: async () => {},
};
}
const serverName =
typeof opts.serverName === "string" && opts.serverName.trim()
? opts.serverName.trim()
: os.hostname();
const connections = new Map<string, net.Socket>();
const server = net.createServer((socket) => {
socket.setNoDelay(true);
let buffer = "";
let isAuthenticated = false;
let nodeId: string | null = null;
const invokeWaiters = new Map<
string,
{
resolve: (value: BridgeInvokeResponseFrame) => void;
reject: (err: Error) => void;
}
>();
const abort = new AbortController();
const stop = () => {
if (!abort.signal.aborted) abort.abort();
if (nodeId) connections.delete(nodeId);
for (const [, waiter] of invokeWaiters) {
waiter.reject(new Error("bridge connection closed"));
}
invokeWaiters.clear();
};
const send = (frame: AnyBridgeFrame) => {
try {
socket.write(encodeLine(frame));
} catch {
// ignore
}
};
const sendError = (code: string, message: string) => {
send({ type: "error", code, message } satisfies BridgeErrorFrame);
};
const remoteAddress = (() => {
const addr = socket.remoteAddress?.trim();
return addr && addr.length > 0 ? addr : undefined;
})();
const handleHello = async (hello: BridgeHelloFrame) => {
nodeId = String(hello.nodeId ?? "").trim();
if (!nodeId) {
sendError("INVALID_REQUEST", "nodeId required");
return;
}
const token = typeof hello.token === "string" ? hello.token.trim() : "";
if (!token) {
const paired = await getPairedNode(nodeId, opts.pairingBaseDir);
sendError(paired ? "UNAUTHORIZED" : "NOT_PAIRED", "pairing required");
return;
}
const verified = await verifyNodeToken(
nodeId,
token,
opts.pairingBaseDir,
);
if (!verified.ok) {
sendError("UNAUTHORIZED", "invalid token");
return;
}
isAuthenticated = true;
connections.set(nodeId, socket);
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
await opts.onAuthenticated?.(nodeId);
};
const waitForApproval = async (request: {
requestId: string;
nodeId: string;
ts: number;
isRepair?: boolean;
}): Promise<
{ ok: true; token: string } | { ok: false; reason: string }
> => {
const deadline = Date.now() + 5 * 60 * 1000;
while (!abort.signal.aborted && Date.now() < deadline) {
const list = await listNodePairing(opts.pairingBaseDir);
const stillPending = list.pending.some(
(p) => p.requestId === request.requestId,
);
if (stillPending) {
await sleep(250);
continue;
}
const paired = await getPairedNode(request.nodeId, opts.pairingBaseDir);
if (!paired) return { ok: false, reason: "pairing rejected" };
// For a repair, ensure this approval happened after the request was created.
if (paired.approvedAtMs < request.ts) {
return { ok: false, reason: "pairing rejected" };
}
return { ok: true, token: paired.token };
}
return {
ok: false,
reason: abort.signal.aborted ? "disconnected" : "pairing expired",
};
};
const handlePairRequest = async (req: BridgePairRequestFrame) => {
nodeId = String(req.nodeId ?? "").trim();
if (!nodeId) {
sendError("INVALID_REQUEST", "nodeId required");
return;
}
const result = await requestNodePairing(
{
nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteIp: remoteAddress,
},
opts.pairingBaseDir,
);
const wait = await waitForApproval(result.request);
if (!wait.ok) {
sendError("UNAUTHORIZED", wait.reason);
return;
}
isAuthenticated = true;
connections.set(nodeId, socket);
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
send({ type: "hello-ok", serverName } satisfies BridgeHelloOkFrame);
await opts.onAuthenticated?.(nodeId);
};
const handleEvent = async (evt: BridgeEventFrame) => {
if (!isAuthenticated || !nodeId) {
sendError("UNAUTHORIZED", "not authenticated");
return;
}
await opts.onEvent?.(nodeId, evt);
};
socket.on("data", (chunk) => {
buffer += chunk.toString("utf8");
while (true) {
const idx = buffer.indexOf("\n");
if (idx === -1) break;
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
const trimmed = line.trim();
if (!trimmed) continue;
void (async () => {
let frame: AnyBridgeFrame;
try {
frame = JSON.parse(trimmed) as AnyBridgeFrame;
} catch (err) {
sendError("INVALID_REQUEST", String(err));
return;
}
const type = typeof frame.type === "string" ? frame.type : "";
try {
switch (type) {
case "hello":
await handleHello(frame as BridgeHelloFrame);
break;
case "pair-request":
await handlePairRequest(frame as BridgePairRequestFrame);
break;
case "event":
await handleEvent(frame as BridgeEventFrame);
break;
case "ping": {
if (!isAuthenticated) {
sendError("UNAUTHORIZED", "not authenticated");
break;
}
const ping = frame as BridgePingFrame;
send({
type: "pong",
id: String(ping.id ?? ""),
} satisfies BridgePongFrame);
break;
}
case "invoke-res": {
if (!isAuthenticated) {
sendError("UNAUTHORIZED", "not authenticated");
break;
}
const res = frame as BridgeInvokeResponseFrame;
const waiter = invokeWaiters.get(res.id);
if (waiter) {
invokeWaiters.delete(res.id);
waiter.resolve(res);
}
break;
}
case "pong":
// ignore
break;
default:
sendError("INVALID_REQUEST", "unknown type");
}
} catch (err) {
sendError("INVALID_REQUEST", String(err));
}
})();
}
});
socket.on("close", () => {
const id = nodeId;
stop();
if (id && isAuthenticated) void opts.onDisconnected?.(id);
});
socket.on("error", () => {
// close handler will run after close
});
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(opts.port, opts.host, () => resolve());
});
const address = server.address();
const port =
typeof address === "object" && address ? address.port : opts.port;
return {
port,
close: async () => {
for (const sock of connections.values()) {
try {
sock.destroy();
} catch {
/* ignore */
}
}
connections.clear();
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
},
};
}

238
src/infra/node-pairing.ts Normal file
View File

@@ -0,0 +1,238 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export type NodePairingPendingRequest = {
requestId: string;
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
isRepair?: boolean;
ts: number;
};
export type NodePairingPairedNode = {
nodeId: string;
token: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
createdAtMs: number;
approvedAtMs: number;
};
export type NodePairingList = {
pending: NodePairingPendingRequest[];
paired: NodePairingPairedNode[];
};
type NodePairingStateFile = {
pendingById: Record<string, NodePairingPendingRequest>;
pairedByNodeId: Record<string, NodePairingPairedNode>;
};
const PENDING_TTL_MS = 5 * 60 * 1000;
function defaultBaseDir() {
return path.join(os.homedir(), ".clawdis");
}
function resolvePaths(baseDir?: string) {
const root = baseDir ?? defaultBaseDir();
const dir = path.join(root, "nodes");
return {
dir,
pendingPath: path.join(dir, "pending.json"),
pairedPath: path.join(dir, "paired.json"),
};
}
async function readJSON<T>(filePath: string): Promise<T | null> {
try {
const raw = await fs.readFile(filePath, "utf8");
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function writeJSONAtomic(filePath: string, value: unknown) {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const tmp = `${filePath}.${randomUUID()}.tmp`;
await fs.writeFile(tmp, JSON.stringify(value, null, 2), "utf8");
await fs.rename(tmp, filePath);
}
function pruneExpiredPending(
pendingById: Record<string, NodePairingPendingRequest>,
nowMs: number,
) {
for (const [id, req] of Object.entries(pendingById)) {
if (nowMs - req.ts > PENDING_TTL_MS) {
delete pendingById[id];
}
}
}
let lock: Promise<void> = Promise.resolve();
async function withLock<T>(fn: () => Promise<T>): Promise<T> {
const prev = lock;
let release: (() => void) | undefined;
lock = new Promise<void>((resolve) => {
release = resolve;
});
await prev;
try {
return await fn();
} finally {
release?.();
}
}
async function loadState(baseDir?: string): Promise<NodePairingStateFile> {
const { pendingPath, pairedPath } = resolvePaths(baseDir);
const [pending, paired] = await Promise.all([
readJSON<Record<string, NodePairingPendingRequest>>(pendingPath),
readJSON<Record<string, NodePairingPairedNode>>(pairedPath),
]);
const state: NodePairingStateFile = {
pendingById: pending ?? {},
pairedByNodeId: paired ?? {},
};
pruneExpiredPending(state.pendingById, Date.now());
return state;
}
async function persistState(state: NodePairingStateFile, baseDir?: string) {
const { pendingPath, pairedPath } = resolvePaths(baseDir);
await Promise.all([
writeJSONAtomic(pendingPath, state.pendingById),
writeJSONAtomic(pairedPath, state.pairedByNodeId),
]);
}
function normalizeNodeId(nodeId: string) {
return nodeId.trim();
}
function newToken() {
return randomUUID().replaceAll("-", "");
}
export async function listNodePairing(
baseDir?: string,
): Promise<NodePairingList> {
const state = await loadState(baseDir);
const pending = Object.values(state.pendingById).sort((a, b) => b.ts - a.ts);
const paired = Object.values(state.pairedByNodeId).sort(
(a, b) => b.approvedAtMs - a.approvedAtMs,
);
return { pending, paired };
}
export async function getPairedNode(
nodeId: string,
baseDir?: string,
): Promise<NodePairingPairedNode | null> {
const state = await loadState(baseDir);
return state.pairedByNodeId[normalizeNodeId(nodeId)] ?? null;
}
export async function requestNodePairing(
req: Omit<NodePairingPendingRequest, "requestId" | "ts" | "isRepair">,
baseDir?: string,
): Promise<{
status: "pending";
request: NodePairingPendingRequest;
created: boolean;
}> {
return await withLock(async () => {
const state = await loadState(baseDir);
const nodeId = normalizeNodeId(req.nodeId);
if (!nodeId) {
throw new Error("nodeId required");
}
const existing = Object.values(state.pendingById).find(
(p) => p.nodeId === nodeId,
);
if (existing) {
return { status: "pending", request: existing, created: false };
}
const isRepair = Boolean(state.pairedByNodeId[nodeId]);
const request: NodePairingPendingRequest = {
requestId: randomUUID(),
nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
remoteIp: req.remoteIp,
isRepair,
ts: Date.now(),
};
state.pendingById[request.requestId] = request;
await persistState(state, baseDir);
return { status: "pending", request, created: true };
});
}
export async function approveNodePairing(
requestId: string,
baseDir?: string,
): Promise<{ requestId: string; node: NodePairingPairedNode } | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) return null;
const now = Date.now();
const existing = state.pairedByNodeId[pending.nodeId];
const node: NodePairingPairedNode = {
nodeId: pending.nodeId,
token: newToken(),
displayName: pending.displayName,
platform: pending.platform,
version: pending.version,
remoteIp: pending.remoteIp,
createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now,
};
delete state.pendingById[requestId];
state.pairedByNodeId[pending.nodeId] = node;
await persistState(state, baseDir);
return { requestId, node };
});
}
export async function rejectNodePairing(
requestId: string,
baseDir?: string,
): Promise<{ requestId: string; nodeId: string } | null> {
return await withLock(async () => {
const state = await loadState(baseDir);
const pending = state.pendingById[requestId];
if (!pending) return null;
delete state.pendingById[requestId];
await persistState(state, baseDir);
return { requestId, nodeId: pending.nodeId };
});
}
export async function verifyNodeToken(
nodeId: string,
token: string,
baseDir?: string,
): Promise<{ ok: boolean; node?: NodePairingPairedNode }> {
const state = await loadState(baseDir);
const normalized = normalizeNodeId(nodeId);
const node = state.pairedByNodeId[normalized];
if (!node) return { ok: false };
return node.token === token ? { ok: true, node } : { ok: false };
}