refactor: remove bridge protocol

This commit is contained in:
Peter Steinberger
2026-01-19 04:50:07 +00:00
parent b347d5d9cc
commit 2f8206862a
118 changed files with 1560 additions and 8087 deletions
+79 -1
View File
@@ -425,7 +425,11 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
if (security === "deny") {
const approvals = resolveExecApprovals(defaults?.agentId);
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=node security=deny");
}
const boundNode = defaults?.node?.trim();
@@ -465,6 +469,79 @@ export function createExecTool(
if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
}
const resolution = resolveCommandResolution(params.command, workdir, env);
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
let approvedByAsk = false;
if (requiresAsk) {
const decisionResult = (await callGatewayTool("exec.approval.request", {}, {
command: params.command,
cwd: workdir,
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000,
})) as { decision?: string } | null;
const decision =
decisionResult && typeof decisionResult === "object"
? decisionResult.decision ?? null
: null;
if (decision === "deny") {
throw new Error("exec denied: user denied");
}
if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
throw new Error(
"exec denied: approval required (approval UI not available)",
);
}
approvedByAsk = true;
} else {
throw new Error("exec denied: approval required (approval UI not available)");
}
}
if (decision === "allow-once") {
approvedByAsk = true;
}
if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
}
}
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
throw new Error("exec denied: allowlist miss");
}
if (allowlistMatch) {
recordAllowlistUse(
approvals.file,
defaults?.agentId,
allowlistMatch,
params.command,
resolution?.resolvedPath,
);
}
const invokeParams: Record<string, unknown> = {
nodeId,
command: "system.run",
@@ -476,6 +553,7 @@ export function createExecTool(
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
},
idempotencyKey: crypto.randomUUID(),
};
+2 -2
View File
@@ -4,7 +4,7 @@ import {
resolveGatewayPort,
resolveStateDir,
} from "../../config/config.js";
import type { BridgeBindMode, GatewayControlUiConfig } from "../../config/types.js";
import type { GatewayBindMode, GatewayControlUiConfig } from "../../config/types.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import { findExtraGatewayServices } from "../../daemon/inspect.js";
@@ -33,7 +33,7 @@ type ConfigSummary = {
};
type GatewayStatusSummary = {
bindMode: BridgeBindMode;
bindMode: GatewayBindMode;
bindHost: string;
customBindHost?: string;
port: number;
+1 -1
View File
@@ -122,7 +122,7 @@ export function registerDnsCli(program: Command) {
console.log(
JSON.stringify(
{
bridge: { bind: "tailnet" },
gateway: { bind: "auto" },
discovery: { wideArea: { enabled: true } },
},
null,
-2
View File
@@ -146,7 +146,6 @@ describe("gateway-cli coverage", () => {
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
@@ -179,7 +178,6 @@ describe("gateway-cli coverage", () => {
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
-1
View File
@@ -46,7 +46,6 @@ export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBe
b.displayName ?? "",
host,
String(b.port ?? ""),
String(b.bridgePort ?? ""),
String(b.gatewayPort ?? ""),
].join("|");
if (seen.has(key)) continue;
+1 -1
View File
@@ -110,7 +110,7 @@ describe("gateway SIGTERM", () => {
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1",
// Avoid port collisions with other test processes that may also start a bridge server.
// Avoid port collisions with other test processes that may also start a gateway server.
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
CLAWDBOT_BRIDGE_PORT: "0",
},
+2 -2
View File
@@ -90,7 +90,7 @@ function resolveNodeDefaults(
if (opts.port !== undefined && portOverride === null) {
return { host, port: null };
}
const port = portOverride ?? config?.gateway?.port ?? 18790;
const port = portOverride ?? config?.gateway?.port ?? 18789;
return { host, port };
}
@@ -179,7 +179,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
await buildNodeInstallPlan({
env: process.env,
host,
port: port ?? 18790,
port: port ?? 18789,
tls,
tlsFingerprint: tlsFingerprint || undefined,
nodeId: opts.nodeId,
+9 -9
View File
@@ -30,17 +30,17 @@ export function registerNodeCli(program: Command) {
node
.command("start")
.description("Start the headless node host (foreground)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--tls", "Use TLS for the gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--node-id <id>", "Override node id")
.option("--display-name <name>", "Override node display name")
.action(async (opts) => {
const existing = await loadNodeHostConfig();
const host =
(opts.host as string | undefined)?.trim() || existing?.gateway?.host || "127.0.0.1";
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18789);
await runNodeHost({
gatewayHost: host,
gatewayPort: port,
@@ -63,11 +63,11 @@ export function registerNodeCli(program: Command) {
cmd
.command("install")
.description("Install the node service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--tls", "Use TLS for the gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--node-id <id>", "Override node id")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
+1 -1
View File
@@ -34,7 +34,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
.version(ctx.programVersion)
.option(
"--dev",
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (bridge/browser/canvas)",
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (browser/canvas)",
)
.option(
"--profile <name>",
+3 -3
View File
@@ -107,9 +107,9 @@ export function registerServiceCli(program: Command) {
node
.command("install")
.description("Install the node host service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--tls", "Use TLS for the Gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
+1 -2
View File
@@ -45,7 +45,6 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
valid: true,
config: {
gateway: { mode: "local" },
bridge: { enabled: true, port: 18790 },
},
issues: [],
legacyIssues: [],
@@ -73,7 +72,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
path: "/tmp/remote.json",
exists: true,
valid: true,
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
config: { gateway: { mode: "remote" } },
issues: [],
legacyIssues: [],
},
-6
View File
@@ -222,7 +222,6 @@ export async function gatewayStatusCommand(
host: b.host ?? null,
lanHost: b.lanHost ?? null,
tailnetDns: b.tailnetDns ?? null,
bridgePort: b.bridgePort ?? null,
gatewayPort: b.gatewayPort ?? null,
sshPort: b.sshPort ?? null,
wsUrl: (() => {
@@ -309,17 +308,12 @@ export async function gatewayStatusCommand(
}
if (p.configSummary) {
const c = p.configSummary;
const bridge =
c.bridge.enabled === false ? "disabled" : c.bridge.enabled === true ? "enabled" : "unknown";
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
: c.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
runtime.log(
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
);
runtime.log(` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`);
}
runtime.log("");
-11
View File
@@ -40,11 +40,6 @@ export type GatewayConfigSummary = {
remotePasswordConfigured: boolean;
tailscaleMode: string | null;
};
bridge: {
enabled: boolean | null;
bind: string | null;
port: number | null;
};
discovery: {
wideAreaEnabled: boolean | null;
};
@@ -191,7 +186,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
@@ -211,10 +205,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const remotePasswordConfigured =
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
const bridgeEnabled = typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
const bridgePort = parseIntOrNull(bridge.port);
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
return {
@@ -245,7 +235,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
remotePasswordConfigured,
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
},
bridge: { enabled: bridgeEnabled, bind: bridgeBind, port: bridgePort },
discovery: { wideAreaEnabled },
};
}
@@ -218,17 +218,14 @@ describe("legacy config detection", () => {
expect(res.config?.gateway?.auth?.mode).toBe("token");
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
});
it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => {
it("migrates gateway.bind from 'tailnet' to 'auto'", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
bridge: { bind: "tailnet" as const },
});
expect(res.changes).toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.changes).toContain("Migrated bridge.bind from 'tailnet' to 'auto'.");
expect(res.config?.gateway?.bind).toBe("auto");
expect(res.config?.bridge?.bind).toBe("auto");
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();
+1 -4
View File
@@ -145,7 +145,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
},
{
id: "bind-tailnet->auto",
describe: "Remap gateway/bridge bind 'tailnet' to 'auto'",
describe: "Remap gateway bind 'tailnet' to 'auto'",
apply: (raw, changes) => {
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
if (!obj) return;
@@ -158,9 +158,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
const gateway = getRecord(raw.gateway);
migrateBind(gateway, "gateway");
const bridge = getRecord(raw.bridge);
migrateBind(bridge, "bridge");
},
},
];
+1 -8
View File
@@ -4,13 +4,7 @@ import type { LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
import type { BrowserConfig } from "./types.browser.js";
import type { ChannelsConfig } from "./types.channels.js";
import type { CronConfig } from "./types.cron.js";
import type {
BridgeConfig,
CanvasHostConfig,
DiscoveryConfig,
GatewayConfig,
TalkConfig,
} from "./types.gateway.js";
import type { CanvasHostConfig, DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js";
import type { HooksConfig } from "./types.hooks.js";
import type {
AudioConfig,
@@ -81,7 +75,6 @@ export type ClawdbotConfig = {
channels?: ChannelsConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
talk?: TalkConfig;
+6 -21
View File
@@ -1,27 +1,13 @@
export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom";
export type BridgeConfig = {
enabled?: boolean;
port?: number;
/**
* Bind address policy for the node bridge server.
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (all interfaces, no fallback)
* - loopback: 127.0.0.1 (local-only)
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
*/
bind?: BridgeBindMode;
tls?: BridgeTlsConfig;
};
export type BridgeTlsConfig = {
/** Enable TLS for the node bridge server. */
export type GatewayTlsConfig = {
/** Enable TLS for the gateway server. */
enabled?: boolean;
/** Auto-generate a self-signed cert if cert/key are missing (default: true). */
autoGenerate?: boolean;
/** PEM certificate path for the bridge server. */
/** PEM certificate path for the gateway server. */
certPath?: string;
/** PEM private key path for the bridge server. */
/** PEM private key path for the gateway server. */
keyPath?: string;
/** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */
caPath?: string;
@@ -127,7 +113,6 @@ export type GatewayHttpConfig = {
endpoints?: GatewayHttpEndpointsConfig;
};
export type GatewayTlsConfig = BridgeTlsConfig;
export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
@@ -145,7 +130,7 @@ export type GatewayConfig = {
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
* Default: loopback (127.0.0.1).
*/
bind?: BridgeBindMode;
bind?: GatewayBindMode;
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
customBindHost?: string;
controlUi?: GatewayControlUiConfig;
+6 -21
View File
@@ -195,26 +195,6 @@ export const ClawdbotSchema = z
.strict()
.optional(),
channels: ChannelsSchema,
bridge: z
.object({
enabled: z.boolean().optional(),
port: z.number().int().positive().optional(),
bind: z
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.optional(),
tls: z
.object({
enabled: z.boolean().optional(),
autoGenerate: z.boolean().optional(),
certPath: z.string().optional(),
keyPath: z.string().optional(),
caPath: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
discovery: z
.object({
wideArea: z
@@ -251,7 +231,12 @@ export const ClawdbotSchema = z
port: z.number().int().positive().optional(),
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
bind: z
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.union([
z.literal("auto"),
z.literal("lan"),
z.literal("loopback"),
z.literal("custom"),
])
.optional(),
controlUi: z
.object({
+2 -2
View File
@@ -41,7 +41,7 @@ export type ChatAbortOps = {
) => { sessionKey: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
};
function broadcastChatAborted(
@@ -61,7 +61,7 @@ function broadcastChatAborted(
stopReason,
};
ops.broadcast("chat", payload);
ops.bridgeSendToSession(sessionKey, "chat", payload);
ops.nodeSendToSession(sessionKey, "chat", payload);
}
export function abortChatRunById(
+29 -2
View File
@@ -40,9 +40,13 @@ export type GatewayClientOptions = {
mode?: GatewayClientMode;
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
deviceIdentity?: DeviceIdentity;
minProtocol?: number;
maxProtocol?: number;
tlsFingerprint?: string;
onEvent?: (evt: EventFrame) => void;
onHelloOk?: (hello: HelloOk) => void;
onConnectError?: (err: Error) => void;
@@ -81,7 +85,21 @@ export class GatewayClient {
if (this.closed) return;
const url = this.opts.url ?? "ws://127.0.0.1:18789";
// Allow node screen snapshots and other large responses.
this.ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 });
const wsOptions: ConstructorParameters<typeof WebSocket>[1] = {
maxPayload: 25 * 1024 * 1024,
};
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
wsOptions.rejectUnauthorized = false;
wsOptions.checkServerIdentity = (_host, cert) => {
const fingerprint = normalizeFingerprint(
typeof cert?.fingerprint256 === "string" ? cert.fingerprint256 : "",
);
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
if (fingerprint && fingerprint === expected) return undefined;
return new Error("gateway tls fingerprint mismatch");
};
}
this.ws = new WebSocket(url, wsOptions);
this.ws.on("open", () => this.sendConnect());
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
@@ -149,7 +167,12 @@ export class GatewayClient {
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
instanceId: this.opts.instanceId,
},
caps: [],
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
permissions:
this.opts.permissions && typeof this.opts.permissions === "object"
? this.opts.permissions
: undefined,
auth,
role,
scopes,
@@ -270,3 +293,7 @@ export class GatewayClient {
return p;
}
}
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}
-1
View File
@@ -80,7 +80,6 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "plugins", kind: "restart" },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "bridge", kind: "restart" },
{ prefix: "discovery", kind: "restart" },
{ prefix: "canvasHost", kind: "restart" },
];
@@ -191,7 +191,7 @@ async function isPortFree(port: number): Promise<boolean> {
}
async function getFreeGatewayPort(): Promise<number> {
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
// Gateway uses derived ports (browser/canvas). Avoid flaky collisions by
// ensuring the common derived offsets are free too.
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();
+1 -1
View File
@@ -23,7 +23,7 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
* @returns The bind address to use (never null)
*/
export async function resolveGatewayBindHost(
bind: import("../config/config.js").BridgeBindMode | undefined,
bind: import("../config/config.js").GatewayBindMode | undefined,
customHost?: string,
): Promise<string> {
const mode = bind ?? "loopback";
+193
View File
@@ -0,0 +1,193 @@
import { randomUUID } from "node:crypto";
import type { GatewayWsClient } from "./server/ws-types.js";
export type NodeSession = {
nodeId: string;
connId: string;
client: GatewayWsClient;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
caps: string[];
commands: string[];
permissions?: Record<string, boolean>;
connectedAtMs: number;
};
type PendingInvoke = {
nodeId: string;
command: string;
resolve: (value: NodeInvokeResult) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export type NodeInvokeResult = {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
export class NodeRegistry {
private nodesById = new Map<string, NodeSession>();
private nodesByConn = new Map<string, string>();
private pendingInvokes = new Map<string, PendingInvoke>();
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
const connect = client.connect;
const nodeId = connect.device?.id ?? connect.client.id;
const caps = Array.isArray(connect.caps) ? connect.caps : [];
const commands = Array.isArray((connect as { commands?: string[] }).commands)
? (connect as { commands?: string[] }).commands ?? []
: [];
const permissions =
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
: undefined;
const session: NodeSession = {
nodeId,
connId: client.connId,
client,
displayName: connect.client.displayName,
platform: connect.client.platform,
version: connect.client.version,
coreVersion: (connect as { coreVersion?: string }).coreVersion,
uiVersion: (connect as { uiVersion?: string }).uiVersion,
deviceFamily: connect.client.deviceFamily,
modelIdentifier: connect.client.modelIdentifier,
remoteIp: opts.remoteIp,
caps,
commands,
permissions,
connectedAtMs: Date.now(),
};
this.nodesById.set(nodeId, session);
this.nodesByConn.set(client.connId, nodeId);
return session;
}
unregister(connId: string): string | null {
const nodeId = this.nodesByConn.get(connId);
if (!nodeId) return null;
this.nodesByConn.delete(connId);
this.nodesById.delete(nodeId);
for (const [id, pending] of this.pendingInvokes.entries()) {
if (pending.nodeId !== nodeId) continue;
clearTimeout(pending.timer);
pending.reject(new Error(`node disconnected (${pending.command})`));
this.pendingInvokes.delete(id);
}
return nodeId;
}
listConnected(): NodeSession[] {
return [...this.nodesById.values()];
}
get(nodeId: string): NodeSession | undefined {
return this.nodesById.get(nodeId);
}
async invoke(params: {
nodeId: string;
command: string;
params?: unknown;
timeoutMs?: number;
idempotencyKey?: string;
}): Promise<NodeInvokeResult> {
const node = this.nodesById.get(params.nodeId);
if (!node) {
return {
ok: false,
error: { code: "NOT_CONNECTED", message: "node not connected" },
};
}
const requestId = randomUUID();
const payload = {
id: requestId,
nodeId: params.nodeId,
command: params.command,
paramsJSON:
"params" in params && params.params !== undefined ? JSON.stringify(params.params) : null,
timeoutMs: params.timeoutMs,
idempotencyKey: params.idempotencyKey,
};
const ok = this.sendEvent(node, "node.invoke.request", payload);
if (!ok) {
return {
ok: false,
error: { code: "UNAVAILABLE", message: "failed to send invoke to node" },
};
}
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 30_000;
return await new Promise<NodeInvokeResult>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingInvokes.delete(requestId);
resolve({
ok: false,
error: { code: "TIMEOUT", message: "node invoke timed out" },
});
}, timeoutMs);
this.pendingInvokes.set(requestId, {
nodeId: params.nodeId,
command: params.command,
resolve,
reject,
timer,
});
});
}
handleInvokeResult(params: {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
}): boolean {
const pending = this.pendingInvokes.get(params.id);
if (!pending) return false;
clearTimeout(pending.timer);
this.pendingInvokes.delete(params.id);
pending.resolve({
ok: params.ok,
payload: params.payload,
payloadJSON: params.payloadJSON ?? null,
error: params.error ?? null,
});
return true;
}
sendEvent(nodeId: string, event: string, payload?: unknown): boolean {
const node = this.nodesById.get(nodeId);
if (!node) return false;
return this.sendEventToSession(node, event, payload);
}
private sendEvent(node: NodeSession, event: string, payload: unknown): boolean {
try {
node.client.socket.send(
JSON.stringify({
type: "event",
event,
payload,
}),
);
return true;
} catch {
return false;
}
}
private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean {
return this.sendEvent(node, event, payload);
}
}
+2
View File
@@ -5,6 +5,7 @@ export const GATEWAY_CLIENT_IDS = {
CLI: "cli",
GATEWAY_CLIENT: "gateway-client",
MACOS_APP: "clawdbot-macos",
NODE_HOST: "node-host",
TEST: "test",
FINGERPRINT: "fingerprint",
PROBE: "clawdbot-probe",
@@ -21,6 +22,7 @@ export const GATEWAY_CLIENT_MODES = {
CLI: "cli",
UI: "ui",
BACKEND: "backend",
NODE: "node",
PROBE: "probe",
TEST: "test",
} as const;
+10
View File
@@ -93,8 +93,12 @@ import {
ModelsListParamsSchema,
type NodeDescribeParams,
NodeDescribeParamsSchema,
type NodeEventParams,
NodeEventParamsSchema,
type NodeInvokeParams,
NodeInvokeParamsSchema,
type NodeInvokeResultParams,
NodeInvokeResultParamsSchema,
type NodeListParams,
NodeListParamsSchema,
type NodePairApproveParams,
@@ -207,6 +211,10 @@ export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRename
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams>(
NodeInvokeResultParamsSchema,
);
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
SessionsResolveParamsSchema,
@@ -422,6 +430,8 @@ export type {
NodePairVerifyParams,
NodeListParams,
NodeInvokeParams,
NodeInvokeResultParams,
NodeEventParams,
SessionsListParams,
SessionsResolveParams,
SessionsPatchParams,
+2
View File
@@ -35,6 +35,8 @@ export const ConnectParamsSchema = Type.Object(
{ additionalProperties: false },
),
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
commands: Type.Optional(Type.Array(NonEmptyString)),
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
role: Type.Optional(NonEmptyString),
scopes: Type.Optional(Type.Array(NonEmptyString)),
device: Type.Optional(
+41
View File
@@ -59,3 +59,44 @@ export const NodeInvokeParamsSchema = Type.Object(
},
{ additionalProperties: false },
);
export const NodeInvokeResultParamsSchema = Type.Object(
{
id: NonEmptyString,
nodeId: NonEmptyString,
ok: Type.Boolean(),
payload: Type.Optional(Type.Unknown()),
payloadJSON: Type.Optional(Type.String()),
error: Type.Optional(
Type.Object(
{
code: Type.Optional(NonEmptyString),
message: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
export const NodeEventParamsSchema = Type.Object(
{
event: NonEmptyString,
payload: Type.Optional(Type.Unknown()),
payloadJSON: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const NodeInvokeRequestEventSchema = Type.Object(
{
id: NonEmptyString,
nodeId: NonEmptyString,
command: NonEmptyString,
paramsJSON: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
idempotencyKey: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -85,7 +85,10 @@ import {
} from "./logs-chat.js";
import {
NodeDescribeParamsSchema,
NodeEventParamsSchema,
NodeInvokeParamsSchema,
NodeInvokeResultParamsSchema,
NodeInvokeRequestEventSchema,
NodeListParamsSchema,
NodePairApproveParamsSchema,
NodePairListParamsSchema,
@@ -140,6 +143,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeListParams: NodeListParamsSchema,
NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema,
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
NodeEventParams: NodeEventParamsSchema,
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
SessionsListParams: SessionsListParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema,
+4
View File
@@ -79,7 +79,9 @@ import type {
} from "./logs-chat.js";
import type {
NodeDescribeParamsSchema,
NodeEventParamsSchema,
NodeInvokeParamsSchema,
NodeInvokeResultParamsSchema,
NodeListParamsSchema,
NodePairApproveParamsSchema,
NodePairListParamsSchema,
@@ -131,6 +133,8 @@ export type NodeRenameParams = Static<typeof NodeRenameParamsSchema>;
export type NodeListParams = Static<typeof NodeListParamsSchema>;
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
-457
View File
@@ -1,457 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveThinkingDefault } from "../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { agentCommand } from "../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { isAcpSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "./chat-abort.js";
import { type ChatImageContent, parseMessageWithAttachments } from "./chat-attachments.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateChatAbortParams,
validateChatInjectParams,
validateChatHistoryParams,
validateChatSendParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "./server-constants.js";
import {
capArrayByJsonBytes,
loadSessionEntry,
readSessionMessages,
resolveSessionModelRef,
} from "./session-utils.js";
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
switch (method) {
case "chat.inject": {
if (!validateChatInjectParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
},
};
}
const p = params as {
sessionKey: string;
message: string;
label?: string;
};
const { storePath, entry } = loadSessionEntry(p.sessionKey);
const sessionId = entry?.sessionId;
if (!sessionId || !storePath) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "session not found" },
};
}
const transcriptPath = entry?.sessionFile
? entry.sessionFile
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
if (!fs.existsSync(transcriptPath)) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "transcript file not found" },
};
}
const now = Date.now();
const messageId = randomUUID().slice(0, 8);
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
const messageBody: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
const transcriptEntry = {
type: "message",
id: messageId,
timestamp: new Date(now).toISOString(),
message: messageBody,
};
try {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: {
code: ErrorCodes.UNAVAILABLE,
message: `failed to write transcript: ${errMessage}`,
},
};
}
const chatPayload = {
runId: `inject-${messageId}`,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message: transcriptEntry.message,
};
ctx.broadcast("chat", chatPayload);
ctx.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
return { ok: true, payloadJSON: JSON.stringify({ ok: true, messageId }) };
}
case "chat.history": {
if (!validateChatHistoryParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
},
};
}
const { sessionKey, limit } = params as {
sessionKey: string;
limit?: number;
};
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
const rawMessages =
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
const max = typeof limit === "number" ? limit : 200;
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(sliced, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agents?.defaults?.thinkingDefault;
if (configured) {
thinkingLevel = configured;
} else {
const { provider, model } = resolveSessionModelRef(cfg, entry);
const catalog = await ctx.loadGatewayModelCatalog();
thinkingLevel = resolveThinkingDefault({
cfg,
provider,
model,
catalog,
});
}
}
return {
ok: true,
payloadJSON: JSON.stringify({
sessionKey,
sessionId,
messages: capped,
thinkingLevel,
}),
};
}
case "chat.abort": {
if (!validateChatAbortParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`,
},
};
}
const { sessionKey, runId } = params as {
sessionKey: string;
runId?: string;
};
const ops = {
chatAbortControllers: ctx.chatAbortControllers,
chatRunBuffers: ctx.chatRunBuffers,
chatDeltaSentAt: ctx.chatDeltaSentAt,
chatAbortedRuns: ctx.chatAbortedRuns,
removeChatRun: ctx.removeChatRun,
agentRunSeq: ctx.agentRunSeq,
broadcast: ctx.broadcast,
bridgeSendToSession: ctx.bridgeSendToSession,
};
if (!runId) {
const res = abortChatRunsForSessionKey(ops, {
sessionKey,
stopReason: "rpc",
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.runIds,
}),
};
}
const active = ctx.chatAbortControllers.get(runId);
if (!active) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: false,
runIds: [],
}),
};
}
if (active.sessionKey !== sessionKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "runId does not match sessionKey",
},
};
}
const res = abortChatRunById(ops, {
runId,
sessionKey,
stopReason: "rpc",
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.aborted ? [runId] : [],
}),
};
}
case "chat.send": {
if (!validateChatSendParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`,
},
};
}
const p = params as {
sessionKey: string;
message: string;
thinking?: string;
deliver?: boolean;
attachments?: Array<{
type?: string;
mimeType?: string;
fileName?: string;
content?: unknown;
}>;
timeoutMs?: number;
idempotencyKey: string;
};
const stopCommand = isChatStopCommandText(p.message);
const normalizedAttachments =
p.attachments
?.map((a) => ({
type: typeof a?.type === "string" ? a.type : undefined,
mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined,
fileName: typeof a?.fileName === "string" ? a.fileName : undefined,
content:
typeof a?.content === "string"
? a.content
: ArrayBuffer.isView(a?.content)
? Buffer.from(
a.content.buffer,
a.content.byteOffset,
a.content.byteLength,
).toString("base64")
: undefined,
}))
.filter((a) => a.content) ?? [];
let parsedMessage = p.message;
let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) {
try {
const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, {
maxBytes: 5_000_000,
log: ctx.logBridge,
});
parsedMessage = parsed.message;
parsedImages = parsed.images;
} catch (err) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: String(err),
},
};
}
}
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
});
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const sessionEntry = mergeSessionEntry(entry, {
sessionId,
updatedAt: now,
});
const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
if (stopCommand) {
const res = abortChatRunsForSessionKey(
{
chatAbortControllers: ctx.chatAbortControllers,
chatRunBuffers: ctx.chatRunBuffers,
chatDeltaSentAt: ctx.chatDeltaSentAt,
chatAbortedRuns: ctx.chatAbortedRuns,
removeChatRun: ctx.removeChatRun,
agentRunSeq: ctx.agentRunSeq,
broadcast: ctx.broadcast,
bridgeSendToSession: ctx.bridgeSendToSession,
},
{ sessionKey: p.sessionKey, stopReason: "stop" },
);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.runIds,
}),
};
}
const cached = ctx.dedupe.get(`chat:${clientRunId}`);
if (cached) {
if (cached.ok) {
return { ok: true, payloadJSON: JSON.stringify(cached.payload) };
}
return {
ok: false,
error: cached.error ?? {
code: ErrorCodes.UNAVAILABLE,
message: "request failed",
},
};
}
const activeExisting = ctx.chatAbortControllers.get(clientRunId);
if (activeExisting) {
return {
ok: true,
payloadJSON: JSON.stringify({
runId: clientRunId,
status: "in_flight",
}),
};
}
try {
const abortController = new AbortController();
ctx.chatAbortControllers.set(clientRunId, {
controller: abortController,
sessionId,
sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
ctx.addChatRun(clientRunId, {
sessionKey: p.sessionKey,
clientRunId,
});
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = sessionEntry;
});
}
const ackPayload = {
runId: clientRunId,
status: "started" as const,
};
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
images: parsedImages.length > 0 ? parsedImages : undefined,
sessionId,
sessionKey: p.sessionKey,
runId: clientRunId,
thinking: p.thinking,
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: `node(${nodeId})`,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
ctx.deps,
)
.then(() => {
ctx.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
payload: { runId: clientRunId, status: "ok" as const },
});
})
.catch((err) => {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
ctx.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload: {
runId: clientRunId,
status: "error" as const,
summary: String(err),
},
error,
});
})
.finally(() => {
ctx.chatAbortControllers.delete(clientRunId);
});
return { ok: true, payloadJSON: JSON.stringify(ackPayload) };
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
const payload = {
runId: clientRunId,
status: "error" as const,
summary: String(err),
};
ctx.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload,
error,
});
return {
ok: false,
error: error ?? {
code: ErrorCodes.UNAVAILABLE,
message: String(err),
},
};
}
}
default:
return null;
}
};
-270
View File
@@ -1,270 +0,0 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
parseConfigJson5,
readConfigFileSnapshot,
resolveConfigSnapshotHash,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import { applyLegacyMigrations } from "../config/legacy.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { buildConfigSchema } from "../config/schema.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import {
ErrorCodes,
formatValidationErrors,
validateConfigGetParams,
validateConfigPatchParams,
validateConfigSchemaParams,
validateConfigSetParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
function resolveBaseHash(params: unknown): string | null {
const raw = (params as { baseHash?: unknown })?.baseHash;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed ? trimmed : null;
}
function requireConfigBaseHash(
params: unknown,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
): { ok: true } | { ok: false; error: { code: string; message: string } } {
if (!snapshot.exists) return { ok: true };
const snapshotHash = resolveConfigSnapshotHash(snapshot);
if (!snapshotHash) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config base hash unavailable; re-run config.get and retry",
},
};
}
const baseHash = resolveBaseHash(params);
if (!baseHash) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config base hash required; re-run config.get and retry",
},
};
}
if (baseHash !== snapshotHash) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config changed since last load; re-run config.get and retry",
},
};
}
return { ok: true };
}
export const handleConfigBridgeMethods: BridgeMethodHandler = async (
_ctx,
_nodeId,
method,
params,
) => {
switch (method) {
case "config.get": {
if (!validateConfigGetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
return { ok: true, payloadJSON: JSON.stringify(snapshot) };
}
case "config.schema": {
if (!validateConfigSchemaParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`,
},
};
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const pluginRegistry = loadClawdbotPlugins({
config: cfg,
workspaceDir,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
});
const schema = buildConfigSchema({
plugins: pluginRegistry.plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})),
channels: listChannelPlugins().map((entry) => ({
id: entry.id,
label: entry.meta.label,
description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})),
});
return { ok: true, payloadJSON: JSON.stringify(schema) };
}
case "config.set": {
if (!validateConfigSetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
const guard = requireConfigBaseHash(params, snapshot);
if (!guard.ok) {
return { ok: false, error: guard.error };
}
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config.set params: raw (string) required",
},
};
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: parsedRes.error,
},
};
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config",
details: { issues: validated.issues },
},
};
}
await writeConfigFile(validated.config);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
path: CONFIG_PATH_CLAWDBOT,
config: validated.config,
}),
};
}
case "config.patch": {
if (!validateConfigPatchParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
const guard = requireConfigBaseHash(params, snapshot);
if (!guard.ok) {
return { ok: false, error: guard.error };
}
if (!snapshot.valid) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config; fix before patching",
},
};
}
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config.patch params: raw (string) required",
},
};
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: parsedRes.error,
},
};
}
if (
!parsedRes.parsed ||
typeof parsedRes.parsed !== "object" ||
Array.isArray(parsedRes.parsed)
) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config.patch raw must be an object",
},
};
}
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
const migrated = applyLegacyMigrations(merged);
const resolved = migrated.next ?? merged;
const validated = validateConfigObject(resolved);
if (!validated.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config",
details: { issues: validated.issues },
},
};
}
await writeConfigFile(validated.config);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
path: CONFIG_PATH_CLAWDBOT,
config: validated.config,
}),
};
}
default:
return null;
}
};
@@ -1,437 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
resolveEmbeddedSessionLane,
waitForEmbeddedPiRunEnd,
} from "../agents/pi-embedded.js";
import { loadConfig } from "../config/config.js";
import {
resolveMainSessionKeyFromConfig,
snapshotSessionOrigin,
type SessionEntry,
updateSessionStore,
} from "../config/sessions.js";
import { clearCommandLane } from "../process/command-queue.js";
import {
ErrorCodes,
formatValidationErrors,
type SessionsCompactParams,
type SessionsDeleteParams,
type SessionsListParams,
type SessionsPatchParams,
type SessionsResetParams,
type SessionsResolveParams,
validateSessionsCompactParams,
validateSessionsDeleteParams,
validateSessionsListParams,
validateSessionsPatchParams,
validateSessionsResetParams,
validateSessionsResolveParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
import {
archiveFileOnDisk,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
resolveGatewaySessionStoreTarget,
resolveSessionTranscriptCandidates,
type SessionsPatchResult,
} from "./session-utils.js";
import { applySessionsPatchToStore } from "./sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js";
export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
ctx,
_nodeId,
method,
params,
) => {
switch (method) {
case "sessions.list": {
if (!validateSessionsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`,
},
};
}
const p = params as SessionsListParams;
const cfg = loadConfig();
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const result = listSessionsFromStore({
cfg,
storePath,
store,
opts: p,
});
return { ok: true, payloadJSON: JSON.stringify(result) };
}
case "sessions.resolve": {
if (!validateSessionsResolveParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
},
};
}
const p = params as SessionsResolveParams;
const cfg = loadConfig();
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
if (!resolved.ok) {
return {
ok: false,
error: {
code: resolved.error.code,
message: resolved.error.message,
details: resolved.error.details,
},
};
}
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, key: resolved.key }),
};
}
case "sessions.patch": {
if (!validateSessionsPatchParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`,
},
};
}
const p = params as SessionsPatchParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const applied = await updateSessionStore(storePath, async (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
return await applySessionsPatchToStore({
cfg,
store,
storeKey: primaryKey,
patch: p,
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
});
});
if (!applied.ok) {
return {
ok: false,
error: {
code: applied.error.code,
message: applied.error.message,
details: applied.error.details,
},
};
}
const payload: SessionsPatchResult = {
ok: true,
path: storePath,
key: target.canonicalKey,
entry: applied.entry,
};
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "sessions.reset": {
if (!validateSessionsResetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`,
},
};
}
const p = params as SessionsResetParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const next = await updateSessionStore(storePath, (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entry = store[primaryKey];
const now = Date.now();
const nextEntry: SessionEntry = {
sessionId: randomUUID(),
updatedAt: now,
systemSent: false,
abortedLastRun: false,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model,
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
origin: snapshotSessionOrigin(entry),
displayName: entry?.displayName,
chatType: entry?.chatType,
channel: entry?.channel,
subject: entry?.subject,
groupChannel: entry?.groupChannel,
space: entry?.space,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
};
store[primaryKey] = nextEntry;
return nextEntry;
});
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, key, entry: next }),
};
}
case "sessions.delete": {
if (!validateSessionsDeleteParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`,
},
};
}
const p = params as SessionsDeleteParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const mainKey = resolveMainSessionKeyFromConfig();
if (key === mainKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `Cannot delete the main session (${mainKey}).`,
},
};
}
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const { entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
clearCommandLane(resolveEmbeddedSessionLane(key));
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
abortEmbeddedPiRun(sessionId);
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
if (!ended) {
return {
ok: false,
error: {
code: ErrorCodes.UNAVAILABLE,
message: `Session ${key} is still active; try again in a moment.`,
},
};
}
}
const deletion = await updateSessionStore(storePath, (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entryToDelete = store[primaryKey];
const existed = Boolean(entryToDelete);
if (existed) delete store[primaryKey];
return { existed, entry: entryToDelete };
});
const existed = deletion.existed;
const archived: string[] = [];
if (deleteTranscript && sessionId) {
for (const candidate of resolveSessionTranscriptCandidates(
sessionId,
storePath,
entry?.sessionFile,
)) {
if (!fs.existsSync(candidate)) continue;
try {
archived.push(archiveFileOnDisk(candidate, "deleted"));
} catch {
// Best-effort; deleting the store entry is the main operation.
}
}
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
deleted: existed,
archived,
}),
};
}
case "sessions.compact": {
if (!validateSessionsCompactParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`,
},
};
}
const p = params as SessionsCompactParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const maxLines =
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
? Math.max(1, Math.floor(p.maxLines))
: 400;
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
// Resolve entry inside the lock, but compact outside to avoid holding it.
const compactTarget = await updateSessionStore(storePath, (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
return { entry: store[primaryKey], primaryKey };
});
const entry = compactTarget.entry;
const sessionId = entry?.sessionId;
if (!sessionId) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
reason: "no sessionId",
}),
};
}
const filePath = resolveSessionTranscriptCandidates(
sessionId,
storePath,
entry?.sessionFile,
).find((candidate) => fs.existsSync(candidate));
if (!filePath) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
reason: "no transcript",
}),
};
}
const raw = fs.readFileSync(filePath, "utf-8");
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= maxLines) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
kept: lines.length,
}),
};
}
const archived = archiveFileOnDisk(filePath, "bak");
const keptLines = lines.slice(-maxLines);
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
// Token counts no longer match; clear so status + UI reflect reality after the next turn.
await updateSessionStore(storePath, (store) => {
const entryToUpdate = store[compactTarget.primaryKey];
if (!entryToUpdate) return;
delete entryToUpdate.inputTokens;
delete entryToUpdate.outputTokens;
delete entryToUpdate.totalTokens;
entryToUpdate.updatedAt = Date.now();
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: true,
archived,
kept: keptLines.length,
}),
};
}
default:
return null;
}
};
@@ -1,94 +0,0 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { loadConfig } from "../config/config.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
import {
ErrorCodes,
formatValidationErrors,
validateModelsListParams,
validateTalkModeParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
import { HEALTH_REFRESH_INTERVAL_MS } from "./server-constants.js";
import { normalizeVoiceWakeTriggers } from "./server-utils.js";
export const handleSystemBridgeMethods: BridgeMethodHandler = async (
ctx,
_nodeId,
method,
params,
) => {
switch (method) {
case "voicewake.get": {
const cfg = await loadVoiceWakeConfig();
return {
ok: true,
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
};
}
case "voicewake.set": {
const triggers = normalizeVoiceWakeTriggers(params.triggers);
const cfg = await setVoiceWakeTriggers(triggers);
ctx.broadcastVoiceWakeChanged(cfg.triggers);
return {
ok: true,
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
};
}
case "health": {
const now = Date.now();
const cached = ctx.getHealthCache();
if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) {
return { ok: true, payloadJSON: JSON.stringify(cached) };
}
const snap = await ctx.refreshHealthSnapshot({ probe: false });
return { ok: true, payloadJSON: JSON.stringify(snap) };
}
case "talk.mode": {
if (!validateTalkModeParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
},
};
}
const payload = {
enabled: (params as { enabled: boolean }).enabled,
phase: (params as { phase?: string }).phase ?? null,
ts: Date.now(),
};
ctx.broadcast("talk.mode", payload, { dropIfSlow: true });
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "models.list": {
if (!validateModelsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
},
};
}
const models = await ctx.loadGatewayModelCatalog();
return { ok: true, payloadJSON: JSON.stringify({ models }) };
}
case "skills.bins": {
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
});
const bins = Array.from(
new Set(report.skills.flatMap((skill) => skill.requirements?.bins ?? []).filter(Boolean)),
);
return { ok: true, payloadJSON: JSON.stringify({ bins }) };
}
default:
return null;
}
};
-246
View File
@@ -1,246 +0,0 @@
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { startCanvasHost } from "../canvas-host/server.js";
import type { CliDeps } from "../cli/deps.js";
import type { HealthSummary } from "../commands/health.js";
import type { ClawdbotConfig } from "../config/config.js";
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { loadBridgeTlsRuntime } from "../infra/bridge/server/tls.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import { createBridgeHandlers } from "./server-bridge.js";
import {
type BridgeListConnectedFn,
type BridgeSendEventFn,
createBridgeSubscriptionManager,
} from "./server-bridge-subscriptions.js";
import type { ChatRunEntry } from "./server-chat.js";
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { startGatewayNodeBridge } from "./server-node-bridge.js";
import type { DedupeEntry } from "./server-shared.js";
export type GatewayBridgeRuntime = {
bridge: import("../infra/bridge/server.js").NodeBridgeServer | null;
bridgeHost: string | null;
bridgePort: number;
canvasHostServer: CanvasHostServer | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
bonjourStop: (() => Promise<void>) | null;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
broadcastVoiceWakeChanged: (triggers: string[]) => void;
};
export async function startGatewayBridgeRuntime(params: {
cfg: ClawdbotConfig;
port: number;
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
canvasHostEnabled: boolean;
canvasHost: CanvasHostHandler | null;
canvasRuntime: RuntimeEnv;
allowCanvasHostInTests?: boolean;
machineDisplayName: string;
deps: CliDeps;
broadcast: (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
dedupe: Map<string, DedupeEntry>;
agentRunSeq: Map<string, number>;
chatRunState: { abortedRuns: Map<string, number> };
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
removeChatRun: (
sessionId: string,
clientRunId: string,
sessionKey?: string,
) => ChatRunEntry | undefined;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
getHealthCache: () => HealthSummary | null;
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
logCanvas: { warn: (msg: string) => void };
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<GatewayBridgeRuntime> {
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
let bridgeEnabled = (() => {
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
})();
const bridgePort = (() => {
if (typeof params.cfg.bridge?.port === "number" && params.cfg.bridge.port > 0) {
return params.cfg.bridge.port;
}
if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) {
const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : deriveDefaultBridgePort(params.port);
}
return deriveDefaultBridgePort(params.port);
})();
const bridgeHost = (() => {
// Back-compat: allow an env var override when no bind policy is configured.
if (params.cfg.bridge?.bind === undefined) {
const env = process.env.CLAWDBOT_BRIDGE_HOST?.trim();
if (env) return env;
}
const bind = params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
if (bind === "loopback") return "127.0.0.1";
if (bind === "lan") return "0.0.0.0";
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const tailnetIPv6 = pickPrimaryTailnetIPv6();
if (bind === "auto") {
return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0";
}
if (bind === "custom") {
// For bridge, customBindHost is not currently supported on GatewayConfig.
// This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig.
return "0.0.0.0";
}
return "0.0.0.0";
})();
const bridgeTls = bridgeEnabled
? await loadBridgeTlsRuntime(params.cfg.bridge?.tls, params.logBridge)
: { enabled: false, required: false };
if (bridgeTls.required && !bridgeTls.enabled) {
params.logBridge.warn(bridgeTls.error ?? "bridge tls: failed to enable; bridge disabled");
bridgeEnabled = false;
}
const canvasHostPort = (() => {
if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
return deriveDefaultCanvasHostPort(params.port);
}
const configured = params.cfg.canvasHost?.port;
if (typeof configured === "number" && configured > 0) return configured;
return deriveDefaultCanvasHostPort(params.port);
})();
let canvasHostServer: CanvasHostServer | null = null;
if (params.canvasHostEnabled && bridgeEnabled && bridgeHost) {
try {
const started = await startCanvasHost({
runtime: params.canvasRuntime,
rootDir: params.cfg.canvasHost?.root,
port: canvasHostPort,
listenHost: bridgeHost,
allowInTests: params.allowCanvasHostInTests,
liveReload: params.cfg.canvasHost?.liveReload,
handler: params.canvasHost ?? undefined,
ownsHandler: params.canvasHost ? false : undefined,
});
if (started.port > 0) {
canvasHostServer = started;
}
} catch (err) {
params.logCanvas.warn(`failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`);
}
}
let bridge: NodeBridgeServer | null = null;
const bridgeSubscriptions = createBridgeSubscriptionManager();
const bridgeSubscribe = bridgeSubscriptions.subscribe;
const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe;
const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll;
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
bridge?.sendEvent(opts);
};
const bridgeListConnected: BridgeListConnectedFn = () => bridge?.listConnected() ?? [];
const bridgeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
bridgeSubscriptions.sendToSession(sessionKey, event, payload, bridgeSendEvent);
const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
const bridgeSendToAllConnected = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllConnected(event, payload, bridgeListConnected, bridgeSendEvent);
const broadcastVoiceWakeChanged = (triggers: string[]) => {
const payload = { triggers };
params.broadcast("voicewake.changed", payload, { dropIfSlow: true });
bridgeSendToAllConnected("voicewake.changed", payload);
};
const { handleBridgeRequest, handleBridgeEvent } = createBridgeHandlers({
deps: params.deps,
broadcast: params.broadcast,
bridgeSendToSession,
bridgeSubscribe,
bridgeUnsubscribe,
broadcastVoiceWakeChanged,
addChatRun: params.addChatRun,
removeChatRun: params.removeChatRun,
chatAbortControllers: params.chatAbortControllers,
chatAbortedRuns: params.chatRunState.abortedRuns,
chatRunBuffers: params.chatRunBuffers,
chatDeltaSentAt: params.chatDeltaSentAt,
dedupe: params.dedupe,
agentRunSeq: params.agentRunSeq,
getHealthCache: params.getHealthCache,
refreshHealthSnapshot: params.refreshGatewayHealthSnapshot,
loadGatewayModelCatalog: params.loadGatewayModelCatalog ?? loadGatewayModelCatalog,
logBridge: params.logBridge,
});
const canvasHostPortForBridge = canvasHostServer?.port;
const canvasHostHostForBridge =
canvasHostServer && bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
? bridgeHost
: undefined;
const bridgeRuntime = await startGatewayNodeBridge({
cfg: params.cfg,
bridgeEnabled,
bridgePort,
bridgeHost,
bridgeTls: bridgeTls.enabled ? bridgeTls : undefined,
machineDisplayName: params.machineDisplayName,
canvasHostPort: canvasHostPortForBridge,
canvasHostHost: canvasHostHostForBridge,
broadcast: params.broadcast,
bridgeUnsubscribeAll,
handleBridgeRequest,
handleBridgeEvent,
logBridge: params.logBridge,
});
bridge = bridgeRuntime.bridge;
const discovery = await startGatewayDiscovery({
machineDisplayName: params.machineDisplayName,
port: params.port,
gatewayTls: params.gatewayTls,
bridgePort: bridge?.port,
bridgeTls: bridgeTls.enabled
? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 }
: undefined,
canvasPort: canvasHostPortForBridge,
wideAreaDiscoveryEnabled,
logDiscovery: params.logDiscovery,
});
return {
bridge,
bridgeHost,
bridgePort,
canvasHostServer,
nodePresenceTimers: bridgeRuntime.nodePresenceTimers,
bonjourStop: discovery.bonjourStop,
bridgeSendToSession,
bridgeSendToAllSubscribed,
broadcastVoiceWakeChanged,
};
}
-62
View File
@@ -1,62 +0,0 @@
import { ErrorCodes } from "./protocol/index.js";
import { handleBridgeEvent as handleBridgeEventImpl } from "./server-bridge-events.js";
import { handleChatBridgeMethods } from "./server-bridge-methods-chat.js";
import { handleConfigBridgeMethods } from "./server-bridge-methods-config.js";
import { handleSessionsBridgeMethods } from "./server-bridge-methods-sessions.js";
import { handleSystemBridgeMethods } from "./server-bridge-methods-system.js";
import type {
BridgeEvent,
BridgeHandlersContext,
BridgeRequest,
BridgeResponse,
} from "./server-bridge-types.js";
export type { BridgeHandlersContext } from "./server-bridge-types.js";
export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const handleBridgeRequest = async (
nodeId: string,
req: BridgeRequest,
): Promise<BridgeResponse> => {
const method = req.method.trim();
const parseParams = (): Record<string, unknown> => {
const raw = typeof req.paramsJSON === "string" ? req.paramsJSON : "";
const trimmed = raw.trim();
if (!trimmed) return {};
const parsed = JSON.parse(trimmed) as unknown;
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
};
try {
const params = parseParams();
const response =
(await handleSystemBridgeMethods(ctx, nodeId, method, params)) ??
(await handleConfigBridgeMethods(ctx, nodeId, method, params)) ??
(await handleSessionsBridgeMethods(ctx, nodeId, method, params)) ??
(await handleChatBridgeMethods(ctx, nodeId, method, params));
if (response) return response;
return {
ok: false,
error: {
code: "FORBIDDEN",
message: "Method not allowed",
details: { method },
},
};
} catch (err) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: String(err) },
};
}
};
const handleBridgeEvent = async (nodeId: string, evt: BridgeEvent) => {
await handleBridgeEventImpl(ctx, nodeId, evt);
};
return { handleBridgeRequest, handleBridgeEvent };
}
+7 -7
View File
@@ -94,11 +94,11 @@ export type ChatEventBroadcast = (
opts?: { dropIfSlow?: boolean },
) => void;
export type BridgeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast;
bridgeSendToSession: BridgeSendToSession;
nodeSendToSession: NodeSendToSession;
agentRunSeq: Map<string, number>;
chatRunState: ChatRunState;
resolveSessionKeyForRun: (runId: string) => string | undefined;
@@ -107,7 +107,7 @@ export type AgentEventHandlerOptions = {
export function createAgentEventHandler({
broadcast,
bridgeSendToSession,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
@@ -131,7 +131,7 @@ export function createAgentEventHandler({
},
};
broadcast("chat", payload, { dropIfSlow: true });
bridgeSendToSession(sessionKey, "chat", payload);
nodeSendToSession(sessionKey, "chat", payload);
};
const emitChatFinal = (
@@ -159,7 +159,7 @@ export function createAgentEventHandler({
: undefined,
};
broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload);
nodeSendToSession(sessionKey, "chat", payload);
return;
}
const payload = {
@@ -170,7 +170,7 @@ export function createAgentEventHandler({
errorMessage: error ? formatForLog(error) : undefined,
};
broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload);
nodeSendToSession(sessionKey, "chat", payload);
};
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
@@ -222,7 +222,7 @@ export function createAgentEventHandler({
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (sessionKey) {
bridgeSendToSession(sessionKey, "agent", agentPayload);
nodeSendToSession(sessionKey, "agent", agentPayload);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
-9
View File
@@ -3,7 +3,6 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import type { PluginServicesHandle } from "../plugins/services.js";
export function createGatewayCloseHandler(params: {
@@ -11,7 +10,6 @@ export function createGatewayCloseHandler(params: {
tailscaleCleanup: (() => Promise<void>) | null;
canvasHost: CanvasHostHandler | null;
canvasHostServer: CanvasHostServer | null;
bridge: NodeBridgeServer | null;
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
pluginServices: PluginServicesHandle | null;
cron: { stop: () => void };
@@ -61,13 +59,6 @@ export function createGatewayCloseHandler(params: {
/* ignore */
}
}
if (params.bridge) {
try {
await params.bridge.close();
} catch {
/* ignore */
}
}
for (const plugin of listChannelPlugins()) {
await params.stopChannel(plugin.id);
}
+5 -11
View File
@@ -1,6 +1,6 @@
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaBridgeZone } from "../infra/widearea-dns.js";
import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaGatewayZone } from "../infra/widearea-dns.js";
import {
formatBonjourInstanceName,
resolveBonjourCliPath,
@@ -11,8 +11,6 @@ export async function startGatewayDiscovery(params: {
machineDisplayName: string;
port: number;
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
bridgePort?: number;
bridgeTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number;
wideAreaDiscoveryEnabled: boolean;
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
@@ -34,10 +32,7 @@ export async function startGatewayDiscovery(params: {
gatewayPort: params.port,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
bridgePort: params.bridgePort,
canvasPort: params.canvasPort,
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
sshPort,
tailnetDns,
cliPath: resolveBonjourCliPath(),
@@ -47,7 +42,7 @@ export async function startGatewayDiscovery(params: {
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
}
if (params.wideAreaDiscoveryEnabled && params.bridgePort) {
if (params.wideAreaDiscoveryEnabled) {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
if (!tailnetIPv4) {
params.logDiscovery.warn(
@@ -56,14 +51,13 @@ export async function startGatewayDiscovery(params: {
} else {
try {
const tailnetIPv6 = pickPrimaryTailnetIPv6();
const result = await writeWideAreaBridgeZone({
bridgePort: params.bridgePort,
const result = await writeWideAreaGatewayZone({
gatewayPort: params.port,
displayName: formatBonjourInstanceName(params.machineDisplayName),
tailnetIPv4,
tailnetIPv6: tailnetIPv6 ?? undefined,
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
tailnetDns,
sshPort,
cliPath: resolveBonjourCliPath(),
+5 -5
View File
@@ -20,7 +20,7 @@ export function startGatewayMaintenanceTimers(params: {
stateVersion?: { presence?: number; health?: number };
},
) => void;
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
getPresenceVersion: () => number;
getHealthVersion: () => number;
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
@@ -36,7 +36,7 @@ export function startGatewayMaintenanceTimers(params: {
sessionKey?: string,
) => ChatRunEntry | undefined;
agentRunSeq: Map<string, number>;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
}): {
tickInterval: ReturnType<typeof setInterval>;
healthInterval: ReturnType<typeof setInterval>;
@@ -49,14 +49,14 @@ export function startGatewayMaintenanceTimers(params: {
health: params.getHealthVersion(),
},
});
params.bridgeSendToAllSubscribed("health", snap);
params.nodeSendToAllSubscribed("health", snap);
});
// periodic keepalive
const tickInterval = setInterval(() => {
const payload = { ts: Date.now() };
params.broadcast("tick", payload, { dropIfSlow: true });
params.bridgeSendToAllSubscribed("tick", payload);
params.nodeSendToAllSubscribed("tick", payload);
}, TICK_INTERVAL_MS);
// periodic health refresh to keep cached snapshot warm
@@ -95,7 +95,7 @@ export function startGatewayMaintenanceTimers(params: {
removeChatRun: params.removeChatRun,
agentRunSeq: params.agentRunSeq,
broadcast: params.broadcast,
bridgeSendToSession: params.bridgeSendToSession,
nodeSendToSession: params.nodeSendToSession,
},
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
);
+3
View File
@@ -52,6 +52,8 @@ const BASE_METHODS = [
"node.list",
"node.describe",
"node.invoke",
"node.invoke.result",
"node.event",
"cron.list",
"cron.status",
"cron.add",
@@ -87,6 +89,7 @@ export const GATEWAY_EVENTS = [
"cron",
"node.pair.requested",
"node.pair.resolved",
"node.invoke.request",
"device.pair.requested",
"device.pair.resolved",
"voicewake.changed",
+5
View File
@@ -29,6 +29,7 @@ const APPROVALS_SCOPE = "operator.approvals";
const PAIRING_SCOPE = "operator.pairing";
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event"]);
const PAIRING_METHODS = new Set([
"node.pair.request",
"node.pair.list",
@@ -45,6 +46,10 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
if (!client?.connect) return null;
const role = client.connect.role ?? "operator";
const scopes = client.connect.scopes ?? [];
if (role === "node") {
if (NODE_ROLE_METHODS.has(method)) return null;
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}
if (role !== "operator") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}
+3 -3
View File
@@ -113,7 +113,7 @@ export const chatHandlers: GatewayRequestHandlers = {
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
nodeSendToSession: context.nodeSendToSession,
};
if (!runId) {
@@ -250,7 +250,7 @@ export const chatHandlers: GatewayRequestHandlers = {
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
nodeSendToSession: context.nodeSendToSession,
},
{ sessionKey: p.sessionKey, stopReason: "stop" },
);
@@ -451,7 +451,7 @@ export const chatHandlers: GatewayRequestHandlers = {
message: transcriptEntry.message,
};
context.broadcast("chat", chatPayload);
context.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
context.nodeSendToSession(p.sessionKey, "chat", chatPayload);
respond(true, { ok: true, messageId });
},
+5 -15
View File
@@ -167,11 +167,6 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId } = params as { nodeId: string };
const id = nodeId.trim();
if (!id) {
@@ -179,10 +174,10 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
const res = await context.nodeRegistry.invoke({
nodeId: id,
command: "system.execApprovals.get",
paramsJSON: "{}",
params: {},
});
if (!res.ok) {
respond(
@@ -194,7 +189,7 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
respond(true, payload, undefined);
});
},
@@ -210,11 +205,6 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId, file, baseHash } = params as {
nodeId: string;
file: ExecApprovalsFile;
@@ -226,10 +216,10 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
const res = await context.nodeRegistry.invoke({
nodeId: id,
command: "system.execApprovals.set",
paramsJSON: JSON.stringify({ file, baseHash }),
params: { file, baseHash },
});
if (!res.ok) {
respond(
+122 -23
View File
@@ -6,11 +6,14 @@ import {
requestNodePairing,
verifyNodeToken,
} from "../../infra/node-pairing.js";
import { listDevicePairing } from "../../infra/device-pairing.js";
import {
ErrorCodes,
errorShape,
validateNodeDescribeParams,
validateNodeEventParams,
validateNodeInvokeParams,
validateNodeInvokeResultParams,
validateNodeListParams,
validateNodePairApproveParams,
validateNodePairListParams,
@@ -201,9 +204,29 @@ export const nodeHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const list = await listNodePairing();
const pairedById = new Map(list.paired.map((n) => [n.nodeId, n]));
const connected = context.bridge?.listConnected?.() ?? [];
const list = await listDevicePairing();
const pairedById = new Map(
list.paired
.filter((entry) => entry.role === "node")
.map((entry) => [
entry.deviceId,
{
nodeId: entry.deviceId,
displayName: entry.displayName,
platform: entry.platform,
version: undefined,
coreVersion: undefined,
uiVersion: undefined,
deviceFamily: undefined,
modelIdentifier: undefined,
remoteIp: entry.remoteIp,
caps: [],
commands: [],
permissions: undefined,
},
]),
);
const connected = context.nodeRegistry.listConnected();
const connectedById = new Map(connected.map((n) => [n.nodeId, n]));
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
@@ -260,9 +283,9 @@ export const nodeHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const list = await listNodePairing();
const paired = list.paired.find((n) => n.nodeId === id);
const connected = context.bridge?.listConnected?.() ?? [];
const list = await listDevicePairing();
const paired = list.paired.find((n) => n.deviceId === id && n.role === "node");
const connected = context.nodeRegistry.listConnected();
const live = connected.find((n) => n.nodeId === id);
if (!paired && !live) {
@@ -270,8 +293,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
return;
}
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
const caps = uniqueSortedStrings([...(live?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? [])]);
respond(
true,
@@ -280,15 +303,15 @@ export const nodeHandlers: GatewayRequestHandlers = {
nodeId: id,
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
version: live?.version,
coreVersion: live?.coreVersion,
uiVersion: live?.uiVersion,
deviceFamily: live?.deviceFamily,
modelIdentifier: live?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,
caps,
commands,
permissions: live?.permissions ?? paired?.permissions,
permissions: live?.permissions,
paired: Boolean(paired),
connected: Boolean(live),
},
@@ -305,11 +328,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const p = params as {
nodeId: string;
command: string;
@@ -329,12 +347,12 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
await respondUnavailableOnThrow(respond, async () => {
const paramsJSON = "params" in p && p.params !== undefined ? JSON.stringify(p.params) : null;
const res = await bridge.invoke({
const res = await context.nodeRegistry.invoke({
nodeId,
command,
paramsJSON,
params: p.params,
timeoutMs: p.timeoutMs,
idempotencyKey: p.idempotencyKey,
});
if (!res.ok) {
respond(
@@ -346,7 +364,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
respond(
true,
{
@@ -360,4 +378,85 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
});
},
"node.invoke.result": async ({ params, respond, context }) => {
if (!validateNodeInvokeResultParams(params)) {
respondInvalidParams({
respond,
method: "node.invoke.result",
validator: validateNodeInvokeResultParams,
});
return;
}
const p = params as {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
const ok = context.nodeRegistry.handleInvokeResult({
id: p.id,
nodeId: p.nodeId,
ok: p.ok,
payload: p.payload,
payloadJSON: p.payloadJSON ?? null,
error: p.error ?? null,
});
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown invoke id"));
return;
}
respond(true, { ok: true }, undefined);
},
"node.event": async ({ params, respond, context }) => {
if (!validateNodeEventParams(params)) {
respondInvalidParams({
respond,
method: "node.event",
validator: validateNodeEventParams,
});
return;
}
const p = params as { event: string; payload?: unknown; payloadJSON?: string | null };
const payloadJSON =
typeof p.payloadJSON === "string"
? p.payloadJSON
: p.payload !== undefined
? JSON.stringify(p.payload)
: null;
await respondUnavailableOnThrow(respond, async () => {
const { handleNodeEvent } = await import("../server-node-events.js");
const nodeContext = {
deps: context.deps,
broadcast: context.broadcast,
nodeSendToSession: context.nodeSendToSession,
nodeSubscribe: context.nodeSubscribe,
nodeUnsubscribe: context.nodeUnsubscribe,
broadcastVoiceWakeChanged: context.broadcastVoiceWakeChanged,
addChatRun: context.addChatRun,
removeChatRun: context.removeChatRun,
chatAbortControllers: context.chatAbortControllers,
chatAbortedRuns: context.chatAbortedRuns,
chatRunBuffers: context.chatRunBuffers,
chatDeltaSentAt: context.chatDeltaSentAt,
dedupe: context.dedupe,
agentRunSeq: context.agentRunSeq,
getHealthCache: context.getHealthCache,
refreshHealthSnapshot: context.refreshHealthSnapshot,
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
logGateway: { warn: context.logGateway.warn },
};
await handleNodeEvent(
nodeContext,
"node",
{
type: "event",
event: p.event,
payloadJSON,
},
);
respond(true, { ok: true }, undefined);
});
},
};
+7 -3
View File
@@ -2,9 +2,9 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { createDefaultDeps } from "../../cli/deps.js";
import type { HealthSummary } from "../../commands/health.js";
import type { CronService } from "../../cron/service.js";
import type { startNodeBridgeServer } from "../../infra/bridge/server.js";
import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type { NodeRegistry } from "../node-registry.js";
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
import type { DedupeEntry } from "../server-shared.js";
@@ -39,9 +39,13 @@ export type GatewayRequestContext = {
stateVersion?: { presence?: number; health?: number };
},
) => void;
bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribeAll: (nodeId: string) => void;
hasConnectedMobileNode: () => boolean;
nodeRegistry: NodeRegistry;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatAbortedRuns: Map<string, number>;
+3 -5
View File
@@ -1,6 +1,4 @@
type BridgeLike = {
listConnected?: () => Array<{ platform?: string | null }>;
};
import type { NodeRegistry } from "./node-registry.js";
const isMobilePlatform = (platform: unknown): boolean => {
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
@@ -8,7 +6,7 @@ const isMobilePlatform = (platform: unknown): boolean => {
return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android");
};
export function hasConnectedMobileNode(bridge: BridgeLike | null): boolean {
const connected = bridge?.listConnected?.() ?? [];
export function hasConnectedMobileNode(registry: NodeRegistry): boolean {
const connected = registry.listConnected();
return connected.some((n) => isMobilePlatform(n.platform));
}
-202
View File
@@ -1,202 +0,0 @@
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { startNodeBridgeServer } from "../infra/bridge/server.js";
import type { BridgeTlsRuntime } from "../infra/bridge/server/tls.js";
import type { ClawdbotConfig } from "../config/config.js";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js";
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
import { isLoopbackAddress } from "./net.js";
import {
getHealthVersion,
getPresenceVersion,
incrementPresenceVersion,
} from "./server/health-state.js";
import type { BridgeEvent, BridgeRequest, BridgeResponse } from "./server-bridge-types.js";
export type GatewayNodeBridgeRuntime = {
bridge: NodeBridgeServer | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
};
export async function startGatewayNodeBridge(params: {
cfg: ClawdbotConfig;
bridgeEnabled: boolean;
bridgePort: number;
bridgeHost: string | null;
bridgeTls?: BridgeTlsRuntime;
machineDisplayName: string;
canvasHostPort?: number;
canvasHostHost?: string;
broadcast: (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
bridgeUnsubscribeAll: (nodeId: string) => void;
handleBridgeRequest: (nodeId: string, req: BridgeRequest) => Promise<BridgeResponse>;
handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise<void> | void;
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<GatewayNodeBridgeRuntime> {
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const formatVersionLabel = (raw: string): string => {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
};
const resolveNodeVersionLabel = (node: {
coreVersion?: string;
uiVersion?: string;
}): string | null => {
const core = node.coreVersion?.trim();
const ui = node.uiVersion?.trim();
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
};
const stopNodePresenceTimer = (nodeId: string) => {
const timer = nodePresenceTimers.get(nodeId);
if (timer) {
clearInterval(timer);
}
nodePresenceTimers.delete(nodeId);
};
const beaconNodePresence = (
node: {
nodeId: string;
displayName?: string;
remoteIp?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
},
reason: string,
) => {
const host = node.displayName?.trim() || node.nodeId;
const rawIp = node.remoteIp?.trim();
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
const version = resolveNodeVersionLabel(node) ?? node.version?.trim() ?? "unknown";
const platform = node.platform?.trim() || undefined;
const deviceFamily = node.deviceFamily?.trim() || undefined;
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`;
upsertPresence(node.nodeId, {
host,
ip,
version,
platform,
deviceFamily,
modelIdentifier,
mode: "remote",
reason,
lastInputSeconds: 0,
instanceId: node.nodeId,
text,
});
incrementPresenceVersion();
params.broadcast(
"presence",
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: {
presence: getPresenceVersion(),
health: getHealthVersion(),
},
},
);
};
const startNodePresenceTimer = (node: { nodeId: string }) => {
stopNodePresenceTimer(node.nodeId);
nodePresenceTimers.set(
node.nodeId,
setInterval(() => {
beaconNodePresence(node, "periodic");
}, 180_000),
);
};
if (params.bridgeEnabled && params.bridgePort > 0 && params.bridgeHost) {
try {
const started = await startNodeBridgeServer({
host: params.bridgeHost,
port: params.bridgePort,
tls: params.bridgeTls?.tlsOptions,
serverName: params.machineDisplayName,
canvasHostPort: params.canvasHostPort,
canvasHostHost: params.canvasHostHost,
onRequest: (nodeId, req) => params.handleBridgeRequest(nodeId, req),
onAuthenticated: async (node) => {
beaconNodePresence(node, "node-connected");
startNodePresenceTimer(node);
recordRemoteNodeInfo({
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
remoteIp: node.remoteIp,
});
bumpSkillsSnapshotVersion({ reason: "remote-node" });
await refreshRemoteNodeBins({
nodeId: node.nodeId,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
cfg: params.cfg,
});
try {
const cfg = await loadVoiceWakeConfig();
started.sendEvent({
nodeId: node.nodeId,
event: "voicewake.changed",
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
});
} catch {
// Best-effort only.
}
},
onDisconnected: (node) => {
params.bridgeUnsubscribeAll(node.nodeId);
stopNodePresenceTimer(node.nodeId);
beaconNodePresence(node, "node-disconnected");
},
onEvent: params.handleBridgeEvent,
onPairRequested: (request) => {
params.broadcast("node.pair.requested", request, {
dropIfSlow: true,
});
},
});
if (started.port > 0) {
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
params.logBridge.info(
`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`,
);
return { bridge: started, nodePresenceTimers };
}
} catch (err) {
params.logBridge.warn(`failed to start: ${String(err)}`);
}
} else if (params.bridgeEnabled && params.bridgePort > 0 && !params.bridgeHost) {
params.logBridge.warn(
"bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge",
);
}
return { bridge: null, nodePresenceTimers };
}
@@ -5,12 +5,12 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { ChatRunEntry } from "./server-chat.js";
import type { DedupeEntry } from "./server-shared.js";
export type BridgeHandlersContext = {
export type NodeEventContext = {
deps: CliDeps;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
bridgeSubscribe: (nodeId: string, sessionKey: string) => void;
bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
broadcastVoiceWakeChanged: (triggers: string[]) => void;
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
removeChatRun: (
@@ -27,32 +27,10 @@ export type BridgeHandlersContext = {
getHealthCache: () => HealthSummary | null;
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
logBridge: { warn: (msg: string) => void };
logGateway: { warn: (msg: string) => void };
};
export type BridgeRequest = {
id: string;
method: string;
paramsJSON?: string | null;
};
export type BridgeEvent = {
export type NodeEvent = {
event: string;
payloadJSON?: string | null;
};
export type BridgeResponse =
| { ok: true; payloadJSON?: string | null }
| {
ok: false;
error: { code: string; message: string; details?: unknown };
};
export type BridgeRequestParams = Record<string, unknown>;
export type BridgeMethodHandler = (
ctx: BridgeHandlersContext,
nodeId: string,
method: string,
params: BridgeRequestParams,
) => Promise<BridgeResponse | null>;
@@ -9,21 +9,21 @@ vi.mock("../infra/heartbeat-wake.js", () => ({
import { enqueueSystemEvent } from "../infra/system-events.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { handleBridgeEvent } from "./server-bridge-events.js";
import type { BridgeHandlersContext } from "./server-bridge-types.js";
import { handleNodeEvent } from "./server-node-events.js";
import type { NodeEventContext } from "./server-node-events-types.js";
import type { HealthSummary } from "../commands/health.js";
import type { CliDeps } from "../cli/deps.js";
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
function buildCtx(): BridgeHandlersContext {
function buildCtx(): NodeEventContext {
return {
deps: {} as CliDeps,
broadcast: () => {},
bridgeSendToSession: () => {},
bridgeSubscribe: () => {},
bridgeUnsubscribe: () => {},
nodeSendToSession: () => {},
nodeSubscribe: () => {},
nodeUnsubscribe: () => {},
broadcastVoiceWakeChanged: () => {},
addChatRun: () => {},
removeChatRun: () => undefined,
@@ -36,11 +36,11 @@ function buildCtx(): BridgeHandlersContext {
getHealthCache: () => null,
refreshHealthSnapshot: async () => ({}) as HealthSummary,
loadGatewayModelCatalog: async () => [],
logBridge: { warn: () => {} },
logGateway: { warn: () => {} },
};
}
describe("bridge exec events", () => {
describe("node exec events", () => {
beforeEach(() => {
enqueueSystemEventMock.mockReset();
requestHeartbeatNowMock.mockReset();
@@ -48,7 +48,7 @@ describe("bridge exec events", () => {
it("enqueues exec.started events", async () => {
const ctx = buildCtx();
await handleBridgeEvent(ctx, "node-1", {
await handleNodeEvent(ctx, "node-1", {
event: "exec.started",
payloadJSON: JSON.stringify({
sessionKey: "agent:main:main",
@@ -66,7 +66,7 @@ describe("bridge exec events", () => {
it("enqueues exec.finished events with output", async () => {
const ctx = buildCtx();
await handleBridgeEvent(ctx, "node-2", {
await handleNodeEvent(ctx, "node-2", {
event: "exec.finished",
payloadJSON: JSON.stringify({
runId: "run-2",
@@ -85,7 +85,7 @@ describe("bridge exec events", () => {
it("enqueues exec.denied events with reason", async () => {
const ctx = buildCtx();
await handleBridgeEvent(ctx, "node-3", {
await handleNodeEvent(ctx, "node-3", {
event: "exec.denied",
payloadJSON: JSON.stringify({
sessionKey: "agent:demo:main",
@@ -7,14 +7,14 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js";
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
export const handleBridgeEvent = async (
ctx: BridgeHandlersContext,
export const handleNodeEvent = async (
ctx: NodeEventContext,
nodeId: string,
evt: BridgeEvent,
evt: NodeEvent,
) => {
switch (evt.event) {
case "voice.transcript": {
@@ -72,7 +72,7 @@ export const handleBridgeEvent = async (
defaultRuntime,
ctx.deps,
).catch((err) => {
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
});
return;
}
@@ -140,7 +140,7 @@ export const handleBridgeEvent = async (
defaultRuntime,
ctx.deps,
).catch((err) => {
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
});
return;
}
@@ -156,7 +156,7 @@ export const handleBridgeEvent = async (
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
if (!sessionKey) return;
ctx.bridgeSubscribe(nodeId, sessionKey);
ctx.nodeSubscribe(nodeId, sessionKey);
return;
}
case "chat.unsubscribe": {
@@ -171,7 +171,7 @@ export const handleBridgeEvent = async (
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
if (!sessionKey) return;
ctx.bridgeUnsubscribe(nodeId, sessionKey);
ctx.nodeUnsubscribe(nodeId, sessionKey);
return;
}
case "exec.started":
@@ -1,9 +1,9 @@
import { describe, expect, test } from "vitest";
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
describe("bridge subscription manager", () => {
describe("node subscription manager", () => {
test("routes events to subscribed nodes", () => {
const manager = createBridgeSubscriptionManager();
const manager = createNodeSubscriptionManager();
const sent: Array<{
nodeId: string;
event: string;
@@ -22,7 +22,7 @@ describe("bridge subscription manager", () => {
});
test("unsubscribeAll clears session mappings", () => {
const manager = createBridgeSubscriptionManager();
const manager = createNodeSubscriptionManager();
const sent: string[] = [];
const sendEvent = (evt: { nodeId: string; event: string }) =>
sent.push(`${evt.nodeId}:${evt.event}`);
@@ -1,12 +1,12 @@
export type BridgeSendEventFn = (opts: {
export type NodeSendEventFn = (opts: {
nodeId: string;
event: string;
payloadJSON?: string | null;
}) => void;
export type BridgeListConnectedFn = () => Array<{ nodeId: string }>;
export type NodeListConnectedFn = () => Array<{ nodeId: string }>;
export type BridgeSubscriptionManager = {
export type NodeSubscriptionManager = {
subscribe: (nodeId: string, sessionKey: string) => void;
unsubscribe: (nodeId: string, sessionKey: string) => void;
unsubscribeAll: (nodeId: string) => void;
@@ -14,25 +14,25 @@ export type BridgeSubscriptionManager = {
sessionKey: string,
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => void;
sendToAllSubscribed: (
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => void;
sendToAllConnected: (
event: string,
payload: unknown,
listConnected?: BridgeListConnectedFn | null,
sendEvent?: BridgeSendEventFn | null,
listConnected?: NodeListConnectedFn | null,
sendEvent?: NodeSendEventFn | null,
) => void;
clear: () => void;
};
export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
const bridgeSessionSubscribers = new Map<string, Set<string>>();
export function createNodeSubscriptionManager(): NodeSubscriptionManager {
const nodeSubscriptions = new Map<string, Set<string>>();
const sessionSubscribers = new Map<string, Set<string>>();
const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null);
@@ -41,18 +41,18 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedNodeId || !normalizedSessionKey) return;
let nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
let nodeSet = nodeSubscriptions.get(normalizedNodeId);
if (!nodeSet) {
nodeSet = new Set<string>();
bridgeNodeSubscriptions.set(normalizedNodeId, nodeSet);
nodeSubscriptions.set(normalizedNodeId, nodeSet);
}
if (nodeSet.has(normalizedSessionKey)) return;
nodeSet.add(normalizedSessionKey);
let sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
let sessionSet = sessionSubscribers.get(normalizedSessionKey);
if (!sessionSet) {
sessionSet = new Set<string>();
bridgeSessionSubscribers.set(normalizedSessionKey, sessionSet);
sessionSubscribers.set(normalizedSessionKey, sessionSet);
}
sessionSet.add(normalizedNodeId);
};
@@ -62,36 +62,36 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedNodeId || !normalizedSessionKey) return;
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
nodeSet?.delete(normalizedSessionKey);
if (nodeSet?.size === 0) bridgeNodeSubscriptions.delete(normalizedNodeId);
if (nodeSet?.size === 0) nodeSubscriptions.delete(normalizedNodeId);
const sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
const sessionSet = sessionSubscribers.get(normalizedSessionKey);
sessionSet?.delete(normalizedNodeId);
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(normalizedSessionKey);
if (sessionSet?.size === 0) sessionSubscribers.delete(normalizedSessionKey);
};
const unsubscribeAll = (nodeId: string) => {
const normalizedNodeId = nodeId.trim();
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
if (!nodeSet) return;
for (const sessionKey of nodeSet) {
const sessionSet = bridgeSessionSubscribers.get(sessionKey);
const sessionSet = sessionSubscribers.get(sessionKey);
sessionSet?.delete(normalizedNodeId);
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(sessionKey);
if (sessionSet?.size === 0) sessionSubscribers.delete(sessionKey);
}
bridgeNodeSubscriptions.delete(normalizedNodeId);
nodeSubscriptions.delete(normalizedNodeId);
};
const sendToSession = (
sessionKey: string,
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey || !sendEvent) return;
const subs = bridgeSessionSubscribers.get(normalizedSessionKey);
const subs = sessionSubscribers.get(normalizedSessionKey);
if (!subs || subs.size === 0) return;
const payloadJSON = toPayloadJSON(payload);
@@ -103,11 +103,11 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const sendToAllSubscribed = (
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => {
if (!sendEvent) return;
const payloadJSON = toPayloadJSON(payload);
for (const nodeId of bridgeNodeSubscriptions.keys()) {
for (const nodeId of nodeSubscriptions.keys()) {
sendEvent({ nodeId, event, payloadJSON });
}
};
@@ -115,8 +115,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const sendToAllConnected = (
event: string,
payload: unknown,
listConnected?: BridgeListConnectedFn | null,
sendEvent?: BridgeSendEventFn | null,
listConnected?: NodeListConnectedFn | null,
sendEvent?: NodeSendEventFn | null,
) => {
if (!sendEvent || !listConnected) return;
const payloadJSON = toPayloadJSON(payload);
@@ -126,8 +126,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
};
const clear = () => {
bridgeNodeSubscriptions.clear();
bridgeSessionSubscribers.clear();
nodeSubscriptions.clear();
sessionSubscribers.clear();
};
return {
+2 -7
View File
@@ -1,9 +1,4 @@
import type {
BridgeBindMode,
GatewayAuthConfig,
GatewayTailscaleConfig,
loadConfig,
} from "../config/config.js";
import type { GatewayAuthConfig, GatewayBindMode, GatewayTailscaleConfig, loadConfig } from "../config/config.js";
import {
assertGatewayAuthConfigured,
type ResolvedGatewayAuth,
@@ -29,7 +24,7 @@ export type GatewayRuntimeConfig = {
export async function resolveGatewayRuntimeConfig(params: {
cfg: ReturnType<typeof loadConfig>;
port: number;
bind?: BridgeBindMode;
bind?: GatewayBindMode;
host?: string;
controlUiEnabled?: boolean;
openAiChatCompletionsEnabled?: boolean;
+2 -2
View File
@@ -9,7 +9,7 @@ export function attachGatewayWsHandlers(params: {
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
port: number;
bridgeHost?: string;
gatewayHost?: string;
canvasHostEnabled: boolean;
canvasHostServerPort?: number;
resolvedAuth: ResolvedGatewayAuth;
@@ -33,7 +33,7 @@ export function attachGatewayWsHandlers(params: {
wss: params.wss,
clients: params.clients,
port: params.port,
bridgeHost: params.bridgeHost,
gatewayHost: params.gatewayHost,
canvasHostEnabled: params.canvasHostEnabled,
canvasHostServerPort: params.canvasHostServerPort,
resolvedAuth: params.resolvedAuth,
+42 -48
View File
@@ -20,7 +20,7 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import {
primeRemoteSkillsCache,
refreshRemoteBinsForConnectedNodes,
setSkillsRemoteBridge,
setSkillsRemoteRegistry,
} from "../infra/skills-remote.js";
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
@@ -36,7 +36,7 @@ import {
incrementPresenceVersion,
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
@@ -48,12 +48,15 @@ import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
import { coreGatewayHandlers } from "./server-methods.js";
import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js";
import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { NodeRegistry } from "./node-registry.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
import { safeParseJson } from "./server-methods/nodes.helpers.js";
import { loadGatewayPlugins } from "./server-plugins.js";
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
import { createGatewayRuntimeState } from "./server-runtime-state.js";
import { hasConnectedMobileNode } from "./server-mobile-nodes.js";
import { resolveSessionKeyForRun } from "./server-session-key.js";
import { startGatewaySidecars } from "./server-startup.js";
import { logGatewayStartup } from "./server-startup-log.js";
@@ -68,7 +71,6 @@ ensureClawdbotCliOnPath();
const log = createSubsystemLogger("gateway");
const logCanvas = log.child("canvas");
const logBridge = log.child("bridge");
const logDiscovery = log.child("discovery");
const logTailscale = log.child("tailscale");
const logChannels = log.child("channels");
@@ -93,7 +95,7 @@ export type GatewayServerOptions = {
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
* - auto: prefer tailnet, else LAN
*/
bind?: import("../config/config.js").BridgeBindMode;
bind?: import("../config/config.js").GatewayBindMode;
/**
* Advanced override for the bind host, bypassing bind resolution.
* Prefer `bind` unless you really need a specific address.
@@ -135,7 +137,7 @@ export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
// Ensure all default port derivations (browser/bridge/canvas) see the actual runtime port.
// Ensure all default port derivations (browser/canvas) see the actual runtime port.
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
let configSnapshot = await readConfigFileSnapshot();
@@ -261,9 +263,24 @@ export async function startGatewayServer(
logPlugins,
});
let bonjourStop: (() => Promise<void>) | null = null;
let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null = null;
const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge);
const nodeRegistry = new NodeRegistry();
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const nodeSubscriptions = createNodeSubscriptionManager();
const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => {
const payload = safeParseJson(opts.payloadJSON ?? null);
nodeRegistry.sendEvent(opts.nodeId, opts.event, payload);
};
const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent);
const nodeSendToAllSubscribed = (event: string, payload: unknown) =>
nodeSubscriptions.sendToAllSubscribed(event, payload, nodeSendEvent);
const nodeSubscribe = nodeSubscriptions.subscribe;
const nodeUnsubscribe = nodeSubscriptions.unsubscribe;
const nodeUnsubscribeAll = nodeSubscriptions.unsubscribeAll;
const broadcastVoiceWakeChanged = (triggers: string[]) => {
broadcast("voicewake.changed", { triggers }, { dropIfSlow: true });
};
const hasMobileNodeConnected = () => hasConnectedMobileNode(nodeRegistry);
applyGatewayLaneConcurrency(cfgAtStart);
let cronState = buildGatewayCronService({
@@ -282,44 +299,18 @@ export async function startGatewayServer(
channelManager;
const machineDisplayName = await getMachineDisplayName();
const bridgeRuntime = await startGatewayBridgeRuntime({
cfg: cfgAtStart,
const discovery = await startGatewayDiscovery({
machineDisplayName,
port,
gatewayTls: gatewayTls.enabled
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
: undefined,
canvasHostEnabled,
canvasHost,
canvasRuntime,
allowCanvasHostInTests: opts.allowCanvasHostInTests,
machineDisplayName,
deps,
broadcast,
dedupe,
agentRunSeq,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
addChatRun,
removeChatRun,
chatAbortControllers,
getHealthCache,
refreshGatewayHealthSnapshot,
loadGatewayModelCatalog,
logBridge,
logCanvas,
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
logDiscovery,
});
bridge = bridgeRuntime.bridge;
const bridgeHost = bridgeRuntime.bridgeHost;
canvasHostServer = bridgeRuntime.canvasHostServer;
const nodePresenceTimers = bridgeRuntime.nodePresenceTimers;
bonjourStop = bridgeRuntime.bonjourStop;
const bridgeSendToSession = bridgeRuntime.bridgeSendToSession;
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
bonjourStop = discovery.bonjourStop;
setSkillsRemoteBridge(bridge);
setSkillsRemoteRegistry(nodeRegistry);
void primeRemoteSkillsCache();
registerSkillsChangeListener(() => {
const latest = loadConfig();
@@ -328,7 +319,7 @@ export async function startGatewayServer(
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
broadcast,
bridgeSendToAllSubscribed,
nodeSendToAllSubscribed,
getPresenceVersion,
getHealthVersion,
refreshGatewayHealthSnapshot,
@@ -340,13 +331,13 @@ export async function startGatewayServer(
chatDeltaSentAt,
removeChatRun,
agentRunSeq,
bridgeSendToSession,
nodeSendToSession,
});
const agentUnsub = onAgentEvent(
createAgentEventHandler({
broadcast,
bridgeSendToSession,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
@@ -369,7 +360,7 @@ export async function startGatewayServer(
wss,
clients,
port,
bridgeHost: bridgeHost ?? undefined,
gatewayHost: bindHost ?? undefined,
canvasHostEnabled: Boolean(canvasHost),
canvasHostServerPort: canvasHostServer?.port ?? undefined,
resolvedAuth,
@@ -395,9 +386,13 @@ export async function startGatewayServer(
incrementPresenceVersion,
getHealthVersion,
broadcast,
bridge,
bridgeSendToSession,
hasConnectedMobileNode,
nodeSendToSession,
nodeSendToAllSubscribed,
nodeSubscribe,
nodeUnsubscribe,
nodeUnsubscribeAll,
hasConnectedMobileNode: hasMobileNodeConnected,
nodeRegistry,
agentRunSeq,
chatAbortControllers,
chatAbortedRuns: chatRunState.abortedRuns,
@@ -491,7 +486,6 @@ export async function startGatewayServer(
tailscaleCleanup,
canvasHost,
canvasHostServer,
bridge,
stopChannel,
pluginServices,
cron,
+36 -61
View File
@@ -2,10 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
bridgeListConnected,
bridgeSendEvent,
bridgeStartCalls,
connectOk,
installGatewayTestHooks,
onceMessage,
@@ -13,6 +11,7 @@ import {
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES } from "../utils/message-channel.js";
installGatewayTestHooks();
@@ -116,42 +115,50 @@ describe("gateway server models + voicewake", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir);
bridgeSendEvent.mockClear();
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
const { server, ws } = await startServerWithClient();
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
const firstEventP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
nodeWs,
(o) => o.type === "event" && o.event === "voicewake.changed",
);
await connectOk(nodeWs, {
role: "node",
client: {
id: "n1",
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
});
await startCall?.onAuthenticated?.({ nodeId: "n1" });
const first = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(first?.payloadJSON).toBeTruthy();
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
triggers?: unknown;
};
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
bridgeSendEvent.mockClear();
const first = await firstEventP;
expect(first.event).toBe("voicewake.changed");
expect((first.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
"clawd",
"claude",
"computer",
]);
const broadcastP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
nodeWs,
(o) => o.type === "event" && o.event === "voicewake.changed",
);
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: ["clawd", "computer"],
});
expect(setRes.ok).toBe(true);
const broadcast = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(broadcast?.payloadJSON).toBeTruthy();
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
triggers?: unknown;
};
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
const broadcast = await broadcastP;
expect(broadcast.event).toBe("voicewake.changed");
expect((broadcast.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
"clawd",
"computer",
]);
nodeWs.close();
ws.close();
await server.close();
@@ -254,36 +261,4 @@ describe("gateway server models + voicewake", () => {
await server.close();
});
test("bridge RPC supports models.list and validates params", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const okRes = await startCall?.onRequest?.("n1", {
id: "1",
method: "models.list",
paramsJSON: "{}",
});
expect(okRes?.ok).toBe(true);
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
models?: unknown;
};
expect(Array.isArray(okPayload.models)).toBe(true);
const badRes = await startCall?.onRequest?.("n1", {
id: "2",
method: "models.list",
paramsJSON: JSON.stringify({ extra: true }),
});
expect(badRes?.ok).toBe(false);
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe("INVALID_REQUEST");
ws.close();
await server.close();
});
});
@@ -1,440 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
bridgeListConnected,
bridgeSendEvent,
bridgeStartCalls,
connectOk,
getFreePort,
installGatewayTestHooks,
onceMessage,
rpcReq,
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const _decodeWsData = (data: unknown): string => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf-8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
}
return "";
};
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
installGatewayTestHooks();
describe("gateway server node/bridge", () => {
test("node.list includes connected unpaired nodes with capabilities + commands", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const reqRes = await rpcReq<{
status?: string;
request?: { requestId?: string };
}>(ws, "node.pair.request", {
nodeId: "p1",
displayName: "Paired",
platform: "iPadOS",
version: "dev",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas"],
commands: ["canvas.eval"],
remoteIp: "10.0.0.10",
});
expect(reqRes.ok).toBe(true);
const requestId = reqRes.payload?.request?.requestId;
expect(typeof requestId).toBe("string");
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
expect(approveRes.ok).toBe(true);
bridgeListConnected.mockReturnValueOnce([
{
nodeId: "p1",
displayName: "Paired Live",
platform: "iPadOS",
version: "dev-live",
remoteIp: "10.0.0.11",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
commands: ["canvas.snapshot", "canvas.eval"],
},
{
nodeId: "u1",
displayName: "Unpaired Live",
platform: "Android",
version: "dev",
remoteIp: "10.0.0.12",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas"],
commands: ["canvas.eval"],
},
]);
const listRes = await rpcReq<{
nodes?: Array<{
nodeId: string;
paired?: boolean;
connected?: boolean;
caps?: string[];
commands?: string[];
displayName?: string;
remoteIp?: string;
}>;
}>(ws, "node.list", {});
expect(listRes.ok).toBe(true);
const nodes = listRes.payload?.nodes ?? [];
const pairedNode = nodes.find((n) => n.nodeId === "p1");
expect(pairedNode).toMatchObject({
nodeId: "p1",
paired: true,
connected: true,
displayName: "Paired Live",
remoteIp: "10.0.0.11",
});
expect(pairedNode?.caps?.slice().sort()).toEqual(["camera", "canvas"]);
expect(pairedNode?.commands?.slice().sort()).toEqual(["canvas.eval", "canvas.snapshot"]);
const unpairedNode = nodes.find((n) => n.nodeId === "u1");
expect(unpairedNode).toMatchObject({
nodeId: "u1",
paired: false,
connected: true,
displayName: "Unpaired Live",
});
expect(unpairedNode?.caps).toEqual(["canvas"]);
expect(unpairedNode?.commands).toEqual(["canvas.eval"]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("emits presence updates for bridge connect/disconnect", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const before = bridgeStartCalls.length;
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const bridgeCall = bridgeStartCalls[before];
expect(bridgeCall).toBeTruthy();
const waitPresenceReason = async (reason: string) => {
await onceMessage(
ws,
(o) => {
if (o.type !== "event" || o.event !== "presence") return false;
const payload = o.payload as { presence?: unknown } | null;
const list = payload?.presence;
if (!Array.isArray(list)) return false;
return list.some(
(p) =>
typeof p === "object" &&
p !== null &&
(p as { instanceId?: unknown }).instanceId === "node-1" &&
(p as { reason?: unknown }).reason === reason,
);
},
3000,
);
};
const presenceConnectedP = waitPresenceReason("node-connected");
await bridgeCall?.onAuthenticated?.({
nodeId: "node-1",
displayName: "Node",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceConnectedP;
const presenceDisconnectedP = waitPresenceReason("node-disconnected");
await bridgeCall?.onDisconnected?.({
nodeId: "node-1",
displayName: "Node",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceDisconnectedP;
} finally {
try {
ws.close();
} catch {
/* ignore */
}
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
}
} finally {
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("bridge RPC chat.history returns session messages", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
[
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "hi" }],
timestamp: Date.now(),
},
}),
].join("\n"),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const res = await bridgeCall?.onRequest?.("ios-node", {
id: "r1",
method: "chat.history",
paramsJSON: JSON.stringify({ sessionKey: "main" }),
});
expect(res?.ok).toBe(true);
const payload = JSON.parse(String((res as { payloadJSON?: string }).payloadJSON ?? "{}")) as {
sessionKey?: string;
sessionId?: string;
messages?: unknown[];
};
expect(payload.sessionKey).toBe("main");
expect(payload.sessionId).toBe("sess-main");
expect(Array.isArray(payload.messages)).toBe(true);
expect(payload.messages?.length).toBeGreaterThan(0);
await server.close();
});
test("bridge RPC sessions.list returns session rows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const res = await bridgeCall?.onRequest?.("ios-node", {
id: "r1",
method: "sessions.list",
paramsJSON: JSON.stringify({
includeGlobal: true,
includeUnknown: false,
limit: 50,
}),
});
expect(res?.ok).toBe(true);
const payload = JSON.parse(String((res as { payloadJSON?: string }).payloadJSON ?? "{}")) as {
sessions?: unknown[];
count?: number;
path?: string;
};
expect(Array.isArray(payload.sessions)).toBe(true);
expect(typeof payload.count).toBe("number");
expect(typeof payload.path).toBe("string");
const resolveRes = await bridgeCall?.onRequest?.("ios-node", {
id: "r2",
method: "sessions.resolve",
paramsJSON: JSON.stringify({ key: "main" }),
});
expect(resolveRes?.ok).toBe(true);
const resolvedPayload = JSON.parse(
String((resolveRes as { payloadJSON?: string }).payloadJSON ?? "{}"),
) as { key?: string };
expect(resolvedPayload.key).toBe("agent:main:main");
await server.close();
});
test("bridge chat events are pushed to subscribed nodes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
expect(bridgeCall?.onRequest).toBeDefined();
await bridgeCall?.onEvent?.("ios-node", {
event: "chat.subscribe",
payloadJSON: JSON.stringify({ sessionKey: "main" }),
});
bridgeSendEvent.mockClear();
const reqRes = await bridgeCall?.onRequest?.("ios-node", {
id: "s1",
method: "chat.send",
paramsJSON: JSON.stringify({
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-bridge-chat",
timeoutMs: 30_000,
}),
});
expect(reqRes?.ok).toBe(true);
emitAgentEvent({
runId: "sess-main",
seq: 1,
ts: Date.now(),
stream: "assistant",
data: { text: "hi from agent" },
});
emitAgentEvent({
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "lifecycle",
data: { phase: "end" },
});
await new Promise((r) => setTimeout(r, 25));
expect(bridgeSendEvent).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
event: "agent",
}),
);
expect(bridgeSendEvent).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
event: "chat",
}),
);
await server.close();
});
test("bridge chat.send forwards image attachments to agentCommand", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const reqRes = await bridgeCall?.onRequest?.("ios-node", {
id: "img-1",
method: "chat.send",
paramsJSON: JSON.stringify({
sessionKey: "main",
message: "see image",
idempotencyKey: "idem-bridge-img",
attachments: [
{
type: "image",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
},
],
}),
});
expect(reqRes?.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore, 8000);
const call = spy.mock.calls.at(-1)?.[0] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(call?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
await server.close();
});
});
@@ -1,228 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
agentCommand,
bridgeStartCalls,
connectOk,
getFreePort,
installGatewayTestHooks,
sessionStoreSaveDelayMs,
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const decodeWsData = (data: unknown): string => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf-8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
}
return "";
};
async function _waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
installGatewayTestHooks();
describe("gateway server node/bridge", () => {
test("bridge voice transcript defaults to main session", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
const spy = vi.mocked(agentCommand);
const beforeCalls = spy.mock.calls.length;
await bridgeCall?.onEvent?.("ios-node", {
event: "voice.transcript",
payloadJSON: JSON.stringify({ text: "hello" }),
});
expect(spy.mock.calls.length).toBe(beforeCalls + 1);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionId).toBe("sess-main");
expect(call.sessionKey).toBe("main");
expect(call.deliver).toBe(false);
expect(call.messageChannel).toBe("node");
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
string,
{ sessionId?: string } | undefined
>;
expect(stored["agent:main:main"]?.sessionId).toBe("sess-main");
expect(stored["node-ios-node"]).toBeUndefined();
await server.close();
});
test("bridge voice transcript triggers chat events for webchat clients", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
const isVoiceFinalChatEvent = (o: unknown) => {
if (!o || typeof o !== "object") return false;
const rec = o as Record<string, unknown>;
if (rec.type !== "event" || rec.event !== "chat") return false;
if (!rec.payload || typeof rec.payload !== "object") return false;
const payload = rec.payload as Record<string, unknown>;
const runId = typeof payload.runId === "string" ? payload.runId : "";
const state = typeof payload.state === "string" ? payload.state : "";
return runId.startsWith("voice-") && state === "final";
};
const finalChatP = new Promise<{
type: "event";
event: string;
payload?: unknown;
}>((resolve) => {
ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data));
if (isVoiceFinalChatEvent(obj)) {
resolve(obj as never);
}
});
});
await bridgeCall?.onEvent?.("ios-node", {
event: "voice.transcript",
payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }),
});
emitAgentEvent({
runId: "sess-main",
seq: 1,
ts: Date.now(),
stream: "assistant",
data: { text: "hi from agent" },
});
emitAgentEvent({
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "lifecycle",
data: { phase: "end" },
});
const evt = await finalChatP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
const message =
payload.message && typeof payload.message === "object"
? (payload.message as Record<string, unknown>)
: {};
expect(message.role).toBe("assistant");
ws.close();
await server.close();
});
test("bridge chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
sessionStoreSaveDelayMs.value = 120;
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const spy = vi.mocked(agentCommand);
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendP = bridgeCall?.onRequest?.("ios-node", {
id: "send-abort-save-bridge-1",
method: "chat.send",
paramsJSON: JSON.stringify({
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-save-bridge-1",
timeoutMs: 30_000,
}),
});
const abortRes = await bridgeCall?.onRequest?.("ios-node", {
id: "abort-save-bridge-1",
method: "chat.abort",
paramsJSON: JSON.stringify({
sessionKey: "main",
runId: "idem-abort-save-bridge-1",
}),
});
expect(abortRes?.ok).toBe(true);
const sendRes = await sendP;
expect(sendRes?.ok).toBe(true);
await server.close();
});
});
@@ -1,343 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
bridgeInvoke,
bridgeListConnected,
connectOk,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
const decodeWsData = (data: unknown): string => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf-8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
}
return "";
};
async function _waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
installGatewayTestHooks();
describe("gateway server node/bridge", () => {
test("supports gateway-owned node pairing methods and events", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const requestedP = new Promise<{
type: "event";
event: string;
payload?: unknown;
}>((resolve) => {
ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data)) as {
type?: string;
event?: string;
payload?: unknown;
};
if (obj.type === "event" && obj.event === "node.pair.requested") {
resolve(obj as never);
}
});
});
const res1 = await rpcReq(ws, "node.pair.request", {
nodeId: "n1",
displayName: "Node",
});
expect(res1.ok).toBe(true);
const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)?.request;
const requestId = typeof req1?.requestId === "string" ? req1.requestId : "";
expect(requestId.length).toBeGreaterThan(0);
const evt1 = await requestedP;
expect(evt1.event).toBe("node.pair.requested");
expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe(requestId);
const res2 = await rpcReq(ws, "node.pair.request", {
nodeId: "n1",
displayName: "Node",
});
expect(res2.ok).toBe(true);
await expect(
onceMessage(ws, (o) => o.type === "event" && o.event === "node.pair.requested", 200),
).rejects.toThrow();
const resolvedP = new Promise<{
type: "event";
event: string;
payload?: unknown;
}>((resolve) => {
ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data)) as {
type?: string;
event?: string;
payload?: unknown;
};
if (obj.type === "event" && obj.event === "node.pair.resolved") {
resolve(obj as never);
}
});
});
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
expect(approveRes.ok).toBe(true);
const tokenValue = (approveRes.payload as { node?: { token?: unknown } } | null)?.node?.token;
const token = typeof tokenValue === "string" ? tokenValue : "";
expect(token.length).toBeGreaterThan(0);
const evt2 = await resolvedP;
expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe(requestId);
expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe("approved");
const verifyRes = await rpcReq(ws, "node.pair.verify", {
nodeId: "n1",
token,
});
expect(verifyRes.ok).toBe(true);
expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true);
const listRes = await rpcReq(ws, "node.pair.list", {});
expect(listRes.ok).toBe(true);
const paired = (listRes.payload as { paired?: unknown } | null)?.paired;
expect(Array.isArray(paired)).toBe(true);
expect((paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1")).toBe(true);
ws.close();
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
test("routes node.invoke to the node bridge", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
bridgeInvoke.mockResolvedValueOnce({
type: "invoke-res",
id: "inv-1",
ok: true,
payloadJSON: JSON.stringify({ result: "4" }),
error: null,
});
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId: "ios-node",
command: "canvas.eval",
params: { javaScript: "2+2" },
timeoutMs: 123,
idempotencyKey: "idem-1",
});
expect(res.ok).toBe(true);
expect(bridgeInvoke).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
command: "canvas.eval",
paramsJSON: JSON.stringify({ javaScript: "2+2" }),
timeoutMs: 123,
}),
);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("routes camera.list invoke to the node bridge", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
bridgeInvoke.mockResolvedValueOnce({
type: "invoke-res",
id: "inv-2",
ok: true,
payloadJSON: JSON.stringify({ devices: [] }),
error: null,
});
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId: "ios-node",
command: "camera.list",
params: {},
idempotencyKey: "idem-2",
});
expect(res.ok).toBe(true);
expect(bridgeInvoke).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
command: "camera.list",
paramsJSON: JSON.stringify({}),
}),
);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.describe returns supported invoke commands for paired nodes", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const reqRes = await rpcReq<{
status?: string;
request?: { requestId?: string };
}>(ws, "node.pair.request", {
nodeId: "n1",
displayName: "iPad",
platform: "iPadOS",
version: "dev",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
remoteIp: "10.0.0.10",
});
expect(reqRes.ok).toBe(true);
const requestId = reqRes.payload?.request?.requestId;
expect(typeof requestId).toBe("string");
const approveRes = await rpcReq(ws, "node.pair.approve", {
requestId,
});
expect(approveRes.ok).toBe(true);
const describeRes = await rpcReq<{ commands?: string[] }>(ws, "node.describe", {
nodeId: "n1",
});
expect(describeRes.ok).toBe(true);
expect(describeRes.payload?.commands).toEqual([
"camera.snap",
"canvas.eval",
"canvas.snapshot",
]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.describe works for connected unpaired nodes (caps + commands)", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
bridgeListConnected.mockReturnValueOnce([
{
nodeId: "u1",
displayName: "Unpaired Live",
platform: "Android",
version: "dev-live",
remoteIp: "10.0.0.12",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas", "camera", "canvas"],
commands: ["canvas.eval", "camera.snap", "canvas.eval"],
},
]);
const describeRes = await rpcReq<{
paired?: boolean;
connected?: boolean;
caps?: string[];
commands?: string[];
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
}>(ws, "node.describe", { nodeId: "u1" });
expect(describeRes.ok).toBe(true);
expect(describeRes.payload).toMatchObject({
paired: false,
connected: true,
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
remoteIp: "10.0.0.12",
});
expect(describeRes.payload?.caps).toEqual(["camera", "canvas"]);
expect(describeRes.payload?.commands).toEqual(["camera.snap", "canvas.eval"]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
});
+6 -8
View File
@@ -1,14 +1,12 @@
import type { BridgeTlsConfig } from "../../config/types.gateway.js";
import type { GatewayTlsConfig } from "../../config/types.gateway.js";
import {
type BridgeTlsRuntime,
loadBridgeTlsRuntime,
} from "../../infra/bridge/server/tls.js";
export type GatewayTlsRuntime = BridgeTlsRuntime;
type GatewayTlsRuntime,
loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig,
} from "../../infra/tls/gateway.js";
export async function loadGatewayTlsRuntime(
cfg: BridgeTlsConfig | undefined,
cfg: GatewayTlsConfig | undefined,
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
): Promise<GatewayTlsRuntime> {
return await loadBridgeTlsRuntime(cfg, log);
return await loadGatewayTlsRuntimeConfig(cfg, log);
}
+10 -3
View File
@@ -22,7 +22,7 @@ export function attachGatewayWsConnectionHandler(params: {
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
port: number;
bridgeHost?: string;
gatewayHost?: string;
canvasHostEnabled: boolean;
canvasHostServerPort?: number;
resolvedAuth: ResolvedGatewayAuth;
@@ -46,7 +46,7 @@ export function attachGatewayWsConnectionHandler(params: {
wss,
clients,
port,
bridgeHost,
gatewayHost,
canvasHostEnabled,
canvasHostServerPort,
resolvedAuth,
@@ -76,7 +76,7 @@ export function attachGatewayWsConnectionHandler(params: {
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
const canvasHostOverride =
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::" ? bridgeHost : undefined;
gatewayHost && gatewayHost !== "0.0.0.0" && gatewayHost !== "::" ? gatewayHost : undefined;
const canvasHostUrl = resolveCanvasHostUrl({
canvasPort: canvasHostPortForWs,
hostOverride: canvasHostServerPort ? canvasHostOverride : undefined,
@@ -182,6 +182,13 @@ export function attachGatewayWsConnectionHandler(params: {
},
);
}
if (client?.connect?.role === "node") {
const context = buildRequestContext();
const nodeId = context.nodeRegistry.unregister(connId);
if (nodeId) {
context.nodeUnsubscribeAll(nodeId);
}
}
logWs("out", "close", {
connId,
code,
@@ -13,12 +13,15 @@ import {
requestDevicePairing,
updatePairedDeviceMetadata,
} from "../../../infra/device-pairing.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { upsertPresence } from "../../../infra/system-presence.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js";
import {
@@ -478,6 +481,38 @@ export function attachGatewayWsMessageHandler(params: {
};
setClient(nextClient);
setHandshakeState("connected");
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
recordRemoteNodeInfo({
nodeId: nodeSession.nodeId,
displayName: nodeSession.displayName,
platform: nodeSession.platform,
deviceFamily: nodeSession.deviceFamily,
commands: nodeSession.commands,
remoteIp: nodeSession.remoteIp,
});
void refreshRemoteNodeBins({
nodeId: nodeSession.nodeId,
platform: nodeSession.platform,
deviceFamily: nodeSession.deviceFamily,
commands: nodeSession.commands,
cfg: loadConfig(),
}).catch((err) =>
logGateway.warn(`remote bin probe failed for ${nodeSession.nodeId}: ${formatForLog(err)}`),
);
void loadVoiceWakeConfig()
.then((cfg) => {
context.nodeRegistry.sendEvent(nodeSession.nodeId, "voicewake.changed", {
triggers: cfg.triggers,
});
})
.catch((err) =>
logGateway.warn(
`voicewake snapshot failed for ${nodeSession.nodeId}: ${formatForLog(err)}`,
),
);
}
logWs("out", "hello-ok", {
connId,
-55
View File
@@ -13,34 +13,6 @@ import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export type BridgeClientInfo = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
};
export type BridgeStartOpts = {
onAuthenticated?: (node: BridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
onPairRequested?: (request: unknown) => Promise<void> | void;
onEvent?: (
nodeId: string,
evt: { event: string; payloadJSON?: string | null },
) => Promise<void> | void;
onRequest?: (
nodeId: string,
req: { id: string; method: string; paramsJSON?: string | null },
) => Promise<
| { ok: true; payloadJSON?: string | null }
| { ok: false; error: { code: string; message: string; details?: unknown } }
>;
};
type StubChannelOptions = {
id: ChannelPlugin["id"];
@@ -173,16 +145,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
});
const hoisted = vi.hoisted(() => ({
bridgeStartCalls: [] as BridgeStartOpts[],
bridgeInvoke: vi.fn(async () => ({
type: "invoke-res",
id: "1",
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
error: null,
})),
bridgeListConnected: vi.fn(() => [] as BridgeClientInfo[]),
bridgeSendEvent: vi.fn(),
testTailnetIPv4: { value: undefined as string | undefined },
piSdkMock: {
enabled: false,
@@ -232,10 +194,6 @@ export const setTestConfigRoot = (root: string) => {
process.env.CLAWDBOT_CONFIG_PATH = path.join(root, "clawdbot.json");
};
export const bridgeStartCalls = hoisted.bridgeStartCalls;
export const bridgeInvoke = hoisted.bridgeInvoke;
export const bridgeListConnected = hoisted.bridgeListConnected;
export const bridgeSendEvent = hoisted.bridgeSendEvent;
export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
@@ -282,19 +240,6 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
};
});
vi.mock("../infra/bridge/server.js", () => ({
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
bridgeStartCalls.push(opts);
return {
port: 18790,
close: async () => {},
listConnected: bridgeListConnected,
invoke: bridgeInvoke,
sendEvent: bridgeSendEvent,
};
}),
}));
vi.mock("../cron/isolated-agent.js", () => ({
runCronIsolatedAgentTurn: (...args: unknown[]) =>
(cronIsolatedRun as (...args: unknown[]) => unknown)(...args),
+10 -1
View File
@@ -247,6 +247,11 @@ export async function connectReq(
modelIdentifier?: string;
instanceId?: string;
};
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
},
): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto");
@@ -265,7 +270,11 @@ export async function connectReq(
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
},
caps: [],
caps: opts?.caps ?? [],
commands: opts?.commands ?? [],
permissions: opts?.permissions ?? undefined,
role: opts?.role,
scopes: opts?.scopes,
auth:
opts?.token || opts?.password
? {
+20 -22
View File
@@ -7,7 +7,7 @@ import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
describe("bonjour-discovery", () => {
it("discovers beacons on darwin across local + wide-area domains", async () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
const studioInstance = "Peters Mac Studio Bridge";
const studioInstance = "Peters Mac Studio Gateway";
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
@@ -17,8 +17,8 @@ describe("bonjour-discovery", () => {
if (domain === "local.") {
return {
stdout: [
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
"Add 2 3 local. _clawdbot-gateway._tcp. Peter\\226\\128\\153s Mac Studio Gateway",
"Add 2 3 local. _clawdbot-gateway._tcp. Laptop Gateway",
"",
].join("\n"),
stderr: "",
@@ -30,7 +30,7 @@ describe("bonjour-discovery", () => {
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
return {
stdout: [
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-gateway._tcp. Tailnet Gateway`,
"",
].join("\n"),
stderr: "",
@@ -46,27 +46,26 @@ describe("bonjour-discovery", () => {
const host =
instance === studioInstance
? "studio.local"
: instance === "Laptop Bridge"
: instance === "Laptop Gateway"
? "laptop.local"
: "tailnet.local";
const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
const tailnetDns = instance === "Tailnet Gateway" ? "studio.tailnet.ts.net" : "";
const displayName =
instance === studioInstance
? "Peters\\032Mac\\032Studio"
: instance.replace(" Bridge", "");
: instance.replace(" Gateway", "");
const txtParts = [
"txtvers=1",
`displayName=${displayName}`,
`lanHost=${host}`,
"gatewayPort=18789",
"bridgePort=18790",
"sshPort=22",
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
].filter((v): v is string => Boolean(v));
return {
stdout: [
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
`${instance}._clawdbot-gateway._tcp. can be reached at ${host}:18789`,
txtParts.join(" "),
"",
].join("\n"),
@@ -113,7 +112,7 @@ describe("bonjour-discovery", () => {
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
return {
stdout: ["Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge", ""].join("\n"),
stdout: ["Add 2 3 local. _clawdbot-gateway._tcp. Studio Gateway", ""].join("\n"),
stderr: "",
code: 0,
signal: null,
@@ -124,8 +123,8 @@ describe("bonjour-discovery", () => {
if (argv[0] === "dns-sd" && argv[1] === "-L") {
return {
stdout: [
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
"Studio Gateway._clawdbot-gateway._tcp. can be reached at studio.local:18789",
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 sshPort=22",
"",
].join("\n"),
stderr: "",
@@ -154,7 +153,7 @@ describe("bonjour-discovery", () => {
expect(beacons).toEqual([
expect.objectContaining({
domain: "local.",
instanceName: "Studio Bridge",
instanceName: "Studio Gateway",
displayName: "Peters Mac Studio",
txt: expect.objectContaining({
displayName: "Peters Mac Studio",
@@ -204,10 +203,10 @@ describe("bonjour-discovery", () => {
if (
server === "100.123.224.76" &&
qtype === "PTR" &&
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
qname === "_clawdbot-gateway._tcp.clawdbot.internal"
) {
return {
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
stdout: `studio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
@@ -218,10 +217,10 @@ describe("bonjour-discovery", () => {
if (
server === "100.123.224.76" &&
qtype === "SRV" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
) {
return {
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
stdout: `0 0 18789 studio.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
@@ -232,14 +231,13 @@ describe("bonjour-discovery", () => {
if (
server === "100.123.224.76" &&
qtype === "TXT" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
) {
return {
stdout: [
`"displayName=Studio"`,
`"transport=bridge"`,
`"bridgePort=18790"`,
`"gatewayPort=18789"`,
`"transport=gateway"`,
`"sshPort=22"`,
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
`"cliPath=/opt/homebrew/bin/clawdbot"`,
@@ -266,10 +264,10 @@ describe("bonjour-discovery", () => {
expect(beacons).toEqual([
expect.objectContaining({
domain: WIDE_AREA_DISCOVERY_DOMAIN,
instanceName: "studio-bridge",
instanceName: "studio-gateway",
displayName: "Studio",
host: "studio.clawdbot.internal",
port: 18790,
port: 18789,
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
gatewayPort: 18789,
sshPort: 22,
+25 -19
View File
@@ -9,11 +9,10 @@ export type GatewayBonjourBeacon = {
port?: number;
lanHost?: string;
tailnetDns?: string;
bridgePort?: number;
gatewayPort?: number;
sshPort?: number;
bridgeTls?: boolean;
bridgeTlsFingerprintSha256?: string;
gatewayTls?: boolean;
gatewayTlsFingerprintSha256?: string;
cliPath?: string;
txt?: Record<string, string>;
};
@@ -165,9 +164,9 @@ function parseDnsSdBrowse(stdout: string): string[] {
const instances = new Set<string>();
for (const raw of stdout.split("\n")) {
const line = raw.trim();
if (!line || !line.includes("_clawdbot-bridge._tcp")) continue;
if (!line || !line.includes("_clawdbot-gateway._tcp")) continue;
if (!line.includes("Add")) continue;
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
const match = line.match(/_clawdbot-gateway\._tcp\.?\s+(.+)$/);
if (match?.[1]) {
instances.add(decodeDnsSdEscapes(match[1].trim()));
}
@@ -205,14 +204,13 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
if (txt.lanHost) beacon.lanHost = txt.lanHost;
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
if (txt.cliPath) beacon.cliPath = txt.cliPath;
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
beacon.sshPort = parseIntOrNull(txt.sshPort);
if (txt.bridgeTls) {
const raw = txt.bridgeTls.trim().toLowerCase();
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
if (txt.gatewayTls) {
const raw = txt.gatewayTls.trim().toLowerCase();
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256;
if (txt.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
return beacon;
@@ -223,13 +221,13 @@ async function discoverViaDnsSd(
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], {
const browse = await run(["dns-sd", "-B", "_clawdbot-gateway._tcp", domain], {
timeoutMs,
});
const instances = parseDnsSdBrowse(browse.stdout);
const results: GatewayBonjourBeacon[] = [];
for (const instance of instances) {
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], {
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-gateway._tcp", domain], {
timeoutMs,
});
const parsed = parseDnsSdResolve(resolved.stdout, instance);
@@ -266,7 +264,7 @@ async function discoverWideAreaViaTailnetDns(
// Keep scans bounded: this is a fallback and should not block long.
ips = ips.slice(0, 40);
const probeName = `_clawdbot-bridge._tcp.${domain.replace(/\.$/, "")}`;
const probeName = `_clawdbot-gateway._tcp.${domain.replace(/\.$/, "")}`;
const concurrency = 6;
let nextIndex = 0;
@@ -310,7 +308,7 @@ async function discoverWideAreaViaTailnetDns(
if (budget <= 0) break;
const ptrName = ptr.trim().replace(/\.$/, "");
if (!ptrName) continue;
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
const instanceName = ptrName.replace(/\.?_clawdbot-gateway\._tcp\..*$/, "");
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
timeoutMs: Math.max(1, Math.min(350, budget)),
@@ -343,12 +341,16 @@ async function discoverWideAreaViaTailnetDns(
host: srvParsed.host,
port: srvParsed.port,
txt: Object.keys(txtMap).length ? txtMap : undefined,
bridgePort: parseIntOrNull(txtMap.bridgePort),
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
sshPort: parseIntOrNull(txtMap.sshPort),
tailnetDns: txtMap.tailnetDns || undefined,
cliPath: txtMap.cliPath || undefined,
};
if (txtMap.gatewayTls) {
const raw = txtMap.gatewayTls.trim().toLowerCase();
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txtMap.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txtMap.gatewayTlsSha256;
results.push(beacon);
}
@@ -363,9 +365,9 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
for (const raw of stdout.split("\n")) {
const line = raw.trimEnd();
if (!line) continue;
if (line.startsWith("=") && line.includes("_clawdbot-bridge._tcp")) {
if (line.startsWith("=") && line.includes("_clawdbot-gateway._tcp")) {
if (current) results.push(current);
const marker = " _clawdbot-bridge._tcp";
const marker = " _clawdbot-gateway._tcp";
const idx = line.indexOf(marker);
const left = idx >= 0 ? line.slice(0, idx).trim() : line;
const parts = left.split(/\s+/);
@@ -400,9 +402,13 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
if (txt.lanHost) current.lanHost = txt.lanHost;
if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns;
if (txt.cliPath) current.cliPath = txt.cliPath;
current.bridgePort = parseIntOrNull(txt.bridgePort);
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
current.sshPort = parseIntOrNull(txt.sshPort);
if (txt.gatewayTls) {
const raw = txt.gatewayTls.trim().toLowerCase();
current.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txt.gatewayTlsSha256) current.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
}
}
@@ -415,7 +421,7 @@ async function discoverViaAvahi(
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
const args = ["avahi-browse", "-rt", "_clawdbot-gateway._tcp"];
if (domain && domain !== "local.") {
// avahi-browse wants a plain domain (no trailing dot)
args.push("-d", domain.replace(/\.$/, ""));
+15 -21
View File
@@ -110,24 +110,23 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/clawdbot",
});
expect(createService).toHaveBeenCalledTimes(1);
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
expect(bridgeCall?.[0]?.port).toBe(18790);
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(gatewayCall?.[0]?.type).toBe("clawdbot-gateway");
expect(gatewayCall?.[0]?.port).toBe(18789);
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/clawdbot",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe("bridge");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
// We don't await `advertise()`, but it should still be called for each service.
expect(advertise).toHaveBeenCalledTimes(1);
@@ -166,7 +165,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
// 1 service × 2 listeners
@@ -209,7 +207,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
await started.stop();
@@ -248,7 +245,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
// initial advertise attempt happens immediately
@@ -295,7 +291,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
expect(advertise).toHaveBeenCalledTimes(1);
@@ -328,14 +323,13 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(gatewayCall?.[0]?.name).toBe("Mac (Clawdbot)");
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("Mac");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
await started.stop();
});
+18 -35
View File
@@ -17,10 +17,7 @@ export type GatewayBonjourAdvertiseOpts = {
sshPort?: number;
gatewayTlsEnabled?: boolean;
gatewayTlsFingerprintSha256?: string;
bridgePort?: number;
canvasPort?: number;
bridgeTlsEnabled?: boolean;
bridgeTlsFingerprintSha256?: string;
tailnetDns?: string;
cliPath?: string;
};
@@ -106,9 +103,6 @@ export async function startGatewayBonjourAdvertiser(
lanHost: `${hostname}.local`,
displayName,
};
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
txtBase.bridgePort = String(opts.bridgePort);
}
if (opts.gatewayTlsEnabled) {
txtBase.gatewayTls = "1";
if (opts.gatewayTlsFingerprintSha256) {
@@ -118,12 +112,6 @@ export async function startGatewayBonjourAdvertiser(
if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) {
txtBase.canvasPort = String(opts.canvasPort);
}
if (opts.bridgeTlsEnabled) {
txtBase.bridgeTls = "1";
if (opts.bridgeTlsFingerprintSha256) {
txtBase.bridgeTlsSha256 = opts.bridgeTlsFingerprintSha256;
}
}
if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) {
txtBase.tailnetDns = opts.tailnetDns.trim();
}
@@ -133,26 +121,23 @@ export async function startGatewayBonjourAdvertiser(
const services: Array<{ label: string; svc: BonjourService }> = [];
// Bridge beacon (used by macOS/iOS/Android nodes and the mac app onboarding flow).
if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) {
const bridge = responder.createService({
name: safeServiceName(instanceName),
type: "clawdbot-bridge",
protocol: Protocol.TCP,
port: opts.bridgePort,
domain: "local",
hostname,
txt: {
...txtBase,
sshPort: String(opts.sshPort ?? 22),
transport: "bridge",
},
});
services.push({
label: "bridge",
svc: bridge as unknown as BonjourService,
});
}
const gateway = responder.createService({
name: safeServiceName(instanceName),
type: "clawdbot-gateway",
protocol: Protocol.TCP,
port: opts.gatewayPort,
domain: "local",
hostname,
txt: {
...txtBase,
sshPort: String(opts.sshPort ?? 22),
transport: "gateway",
},
});
services.push({
label: "gateway",
svc: gateway as unknown as BonjourService,
});
let ciaoCancellationRejectionHandler: (() => void) | undefined;
if (services.length > 0) {
@@ -164,9 +149,7 @@ export async function startGatewayBonjourAdvertiser(
logDebug(
`bonjour: starting (hostname=${hostname}, instance=${JSON.stringify(
safeServiceName(instanceName),
)}, gatewayPort=${opts.gatewayPort}, bridgePort=${opts.bridgePort ?? 0}, sshPort=${
opts.sshPort ?? 22
})`,
)}, gatewayPort=${opts.gatewayPort}, sshPort=${opts.sshPort ?? 22})`,
);
for (const { label, svc } of services) {
@@ -1,420 +0,0 @@
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, vi } from "vitest";
import { pollUntil } from "../../../test/helpers/poll.js";
import { approveNodePairing, listNodePairing } from "../node-pairing.js";
import { configureNodeBridgeSocket, startNodeBridgeServer } from "./server.js";
const pairingTimeoutMs = process.platform === "win32" ? 8000 : 3000;
const suiteTimeoutMs = process.platform === "win32" ? 20000 : 10000;
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`);
}
async function waitForSocketConnect(socket: net.Socket) {
if (!socket.connecting) return;
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
}
describe("node bridge server", { timeout: suiteTimeoutMs }, () => {
let baseDir = "";
const pickNonLoopbackIPv4 = () => {
const ifaces = os.networkInterfaces();
for (const entries of Object.values(ifaces)) {
for (const info of entries ?? []) {
if (info.family === "IPv4" && info.internal === false) return info.address;
}
}
return null;
};
beforeAll(async () => {
process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS = "1";
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bridge-test-"));
});
afterAll(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
delete process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS;
});
it("enables keepalive on sockets", () => {
const socket = {
setNoDelay: vi.fn(),
setKeepAlive: vi.fn(),
};
configureNodeBridgeSocket(socket);
expect(socket.setNoDelay).toHaveBeenCalledWith(true);
expect(socket.setKeepAlive).toHaveBeenCalledWith(true, 15_000);
});
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("does not add a loopback listener when bind already includes loopback", async () => {
const loopback = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
});
expect(loopback.listeners).toHaveLength(1);
expect(loopback.listeners[0]?.host).toBe("127.0.0.1");
await loopback.close();
const wildcard = await startNodeBridgeServer({
host: "0.0.0.0",
port: 0,
pairingBaseDir: baseDir,
});
expect(wildcard.listeners).toHaveLength(1);
expect(wildcard.listeners[0]?.host).toBe("0.0.0.0");
await wildcard.close();
});
it("also listens on loopback when bound to a non-loopback host", async () => {
const host = pickNonLoopbackIPv4();
if (!host) return;
const server = await startNodeBridgeServer({
host,
port: 0,
pairingBaseDir: baseDir,
});
const hosts = server.listeners.map((l) => l.host).sort();
expect(hosts).toContain(host);
const hasLoopback = hosts.includes("127.0.0.1");
if (hasLoopback) {
const socket = net.connect({ host: "127.0.0.1", port: server.port });
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const readLine = createLineReader(socket);
sendLine(socket, { type: "hello", nodeId: "n-loopback" });
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 });
await waitForSocketConnect(socket);
const readLine = createLineReader(socket);
sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" });
// Approve the pending request from the gateway side.
const pending = await pollUntil(
async () => {
const list = await listNodePairing(baseDir);
return list.pending.find((p) => p.nodeId === "n2");
},
{ timeoutMs: pairingTimeoutMs },
);
expect(pending).toBeTruthy();
if (!pending) throw new Error("expected a pending request");
await approveNodePairing(pending.requestId, 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 });
await waitForSocketConnect(socket2);
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();
});
it("calls onPairRequested for newly created pending requests", async () => {
let requested: { nodeId?: string; requestId?: string } | null = null;
const server = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
onPairRequested: async (req) => {
requested = req;
},
});
const socket = net.connect({ host: "127.0.0.1", port: server.port });
await waitForSocketConnect(socket);
sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" });
await pollUntil(async () => requested, { timeoutMs: pairingTimeoutMs });
expect(requested?.nodeId).toBe("n3");
expect(typeof requested?.requestId).toBe("string");
socket.destroy();
await server.close();
});
it("handles req/res RPC after authentication", async () => {
let lastRequest: { nodeId?: string; id?: string; method?: string } | null = null;
const server = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
onRequest: async (nodeId, req) => {
lastRequest = { nodeId, id: req.id, method: req.method };
return { ok: true, payloadJSON: JSON.stringify({ ok: true }) };
},
});
const socket = net.connect({ host: "127.0.0.1", port: server.port });
await waitForSocketConnect(socket);
const readLine = createLineReader(socket);
sendLine(socket, {
type: "pair-request",
nodeId: "n3-rpc",
platform: "ios",
});
// Approve the pending request from the gateway side.
const pending = await pollUntil(
async () => {
const list = await listNodePairing(baseDir);
return list.pending.find((p) => p.nodeId === "n3-rpc");
},
{ timeoutMs: pairingTimeoutMs },
);
expect(pending).toBeTruthy();
if (!pending) throw new Error("expected a pending request");
await approveNodePairing(pending.requestId, baseDir);
const line1 = JSON.parse(await readLine()) as { type: string };
expect(line1.type).toBe("pair-ok");
const line2 = JSON.parse(await readLine()) as { type: string };
expect(line2.type).toBe("hello-ok");
sendLine(socket, { type: "req", id: "r1", method: "health" });
const res = JSON.parse(await readLine()) as {
type: string;
id?: string;
ok?: boolean;
payloadJSON?: string | null;
error?: unknown;
};
expect(res.type).toBe("res");
expect(res.id).toBe("r1");
expect(res.ok).toBe(true);
expect(res.payloadJSON).toBe(JSON.stringify({ ok: true }));
expect(res.error).toBeUndefined();
expect(lastRequest).toEqual({
nodeId: "n3-rpc",
id: "r1",
method: "health",
});
socket.destroy();
await server.close();
});
it("passes node metadata to onAuthenticated and onDisconnected", async () => {
let lastAuthed: {
nodeId?: string;
displayName?: string;
platform?: string;
version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
} | null = null;
let disconnected: {
nodeId?: string;
displayName?: string;
platform?: string;
version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
permissions?: Record<string, boolean>;
} | null = null;
let resolveDisconnected: (() => void) | null = null;
const disconnectedP = new Promise<void>((resolve) => {
resolveDisconnected = resolve;
});
let pendingRequest: {
requestId: string;
nodeId: string;
ts: number;
} | null = null;
const server = await startNodeBridgeServer({
host: "127.0.0.1",
port: 0,
pairingBaseDir: baseDir,
onAuthenticated: async (node) => {
lastAuthed = node;
},
onPairRequested: async (request) => {
pendingRequest = {
requestId: request.requestId,
nodeId: request.nodeId,
ts: request.ts,
};
},
onDisconnected: async (node) => {
disconnected = node;
resolveDisconnected?.();
},
});
const socket = net.connect({ host: "127.0.0.1", port: server.port });
await waitForSocketConnect(socket);
const readLine = createLineReader(socket);
sendLine(socket, {
type: "pair-request",
nodeId: "n4",
displayName: "Node",
platform: "ios",
version: "1.0",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
permissions: { screenRecording: true, notifications: false },
});
// Approve the pending request from the gateway side.
const pending = await pollUntil(async () => pendingRequest, { timeoutMs: pairingTimeoutMs });
expect(pending).toBeTruthy();
if (!pending) throw new Error("expected a pending request");
const approved = await approveNodePairing(pending.requestId, baseDir);
const token = approved?.node?.token ?? "";
expect(token.length).toBeGreaterThan(0);
const line1 = JSON.parse(await readLine()) as { type: string };
expect(line1.type).toBe("pair-ok");
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 });
await waitForSocketConnect(socket2);
const readLine2 = createLineReader(socket2);
sendLine(socket2, {
type: "hello",
nodeId: "n4",
token,
displayName: "Different name",
platform: "ios",
version: "2.0",
deviceFamily: "iPad",
modelIdentifier: "iPad99,1",
permissions: { screenRecording: false },
});
const line3 = JSON.parse(await readLine2()) as { type: string };
expect(line3.type).toBe("hello-ok");
await pollUntil(async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null), {
timeoutMs: pairingTimeoutMs,
});
expect(lastAuthed?.nodeId).toBe("n4");
// Prefer paired metadata over hello payload (token verifies the stored node record).
expect(lastAuthed?.displayName).toBe("Node");
expect(lastAuthed?.platform).toBe("ios");
expect(lastAuthed?.version).toBe("1.0");
expect(lastAuthed?.deviceFamily).toBe("iPad");
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
expect(lastAuthed?.permissions).toEqual({
screenRecording: false,
notifications: false,
});
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
socket2.destroy();
await disconnectedP;
expect(disconnected?.nodeId).toBe("n4");
expect(disconnected?.remoteIp?.includes("127.0.0.1")).toBe(true);
await server.close();
});
});
@@ -1,220 +0,0 @@
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 { pollUntil } from "../../../test/helpers/poll.js";
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`);
}
async function waitForSocketConnect(socket: net.Socket) {
if (!socket.connecting) return;
await new Promise<void>((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
}
describe("node bridge server", () => {
let baseDir = "";
const _pickNonLoopbackIPv4 = () => {
const ifaces = os.networkInterfaces();
for (const entries of Object.values(ifaces)) {
for (const info of entries ?? []) {
if (info.family === "IPv4" && info.internal === false) return info.address;
}
}
return null;
};
beforeAll(async () => {
process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS = "1";
baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bridge-test-"));
});
afterAll(async () => {
await fs.rm(baseDir, { recursive: true, force: true });
delete process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS;
});
it("supports invoke roundtrip to a connected node", 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 });
await waitForSocketConnect(socket);
const readLine = createLineReader(socket);
sendLine(socket, { type: "pair-request", nodeId: "n5", platform: "ios" });
// Approve the pending request from the gateway side.
const pending = await pollUntil(
async () => {
const list = await listNodePairing(baseDir);
return list.pending.find((p) => p.nodeId === "n5");
},
{ timeoutMs: 3000 },
);
expect(pending).toBeTruthy();
if (!pending) throw new Error("expected a pending request");
await approveNodePairing(pending.requestId, baseDir);
const pairOk = JSON.parse(await readLine()) as {
type: string;
token?: string;
};
expect(pairOk.type).toBe("pair-ok");
expect(typeof pairOk.token).toBe("string");
if (!pairOk.token) throw new Error("expected pair-ok token");
const token = pairOk.token;
const helloOk = JSON.parse(await readLine()) as { type: string };
expect(helloOk.type).toBe("hello-ok");
const responder = (async () => {
while (true) {
const frame = JSON.parse(await readLine()) as {
type: string;
id?: string;
command?: string;
};
if (frame.type !== "invoke") continue;
sendLine(socket, {
type: "invoke-res",
id: frame.id,
ok: true,
payloadJSON: JSON.stringify({ echo: frame.command }),
});
break;
}
})();
const res = await server.invoke({
nodeId: "n5",
command: "canvas.eval",
paramsJSON: JSON.stringify({ javaScript: "1+1" }),
timeoutMs: 3000,
});
expect(res.ok).toBe(true);
const payload = JSON.parse(String(res.payloadJSON ?? "null")) as {
echo?: string;
};
expect(payload.echo).toBe("canvas.eval");
await responder;
socket.destroy();
// Ensure invoke works only for connected nodes (hello with token on a new socket).
const socket2 = net.connect({ host: "127.0.0.1", port: server.port });
await waitForSocketConnect(socket2);
const readLine2 = createLineReader(socket2);
sendLine(socket2, { type: "hello", nodeId: "n5", token });
const hello2 = JSON.parse(await readLine2()) as { type: string };
expect(hello2.type).toBe("hello-ok");
socket2.destroy();
await server.close();
});
it("tracks connected node caps and hardware identifiers", 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 });
await waitForSocketConnect(socket);
const readLine = createLineReader(socket);
sendLine(socket, {
type: "pair-request",
nodeId: "n-caps",
displayName: "Node",
platform: "ios",
version: "1.0",
deviceFamily: "iPad",
modelIdentifier: "iPad14,5",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
permissions: { accessibility: true },
});
// Approve the pending request from the gateway side.
const pending = await pollUntil(
async () => {
const list = await listNodePairing(baseDir);
return list.pending.find((p) => p.nodeId === "n-caps");
},
{ timeoutMs: 3000 },
);
expect(pending).toBeTruthy();
if (!pending) throw new Error("expected a pending request");
await approveNodePairing(pending.requestId, baseDir);
const pairOk = JSON.parse(await readLine()) as { type: string };
expect(pairOk.type).toBe("pair-ok");
const helloOk = JSON.parse(await readLine()) as { type: string };
expect(helloOk.type).toBe("hello-ok");
const connected = server.listConnected();
const node = connected.find((n) => n.nodeId === "n-caps");
expect(node?.deviceFamily).toBe("iPad");
expect(node?.modelIdentifier).toBe("iPad14,5");
expect(node?.caps).toEqual(["canvas", "camera"]);
expect(node?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]);
expect(node?.permissions).toEqual({ accessibility: true });
const after = await listNodePairing(baseDir);
const paired = after.paired.find((p) => p.nodeId === "n-caps");
expect(paired?.caps).toEqual(["canvas", "camera"]);
expect(paired?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]);
expect(paired?.permissions).toEqual({ accessibility: true });
socket.destroy();
await server.close();
});
});
-10
View File
@@ -1,10 +0,0 @@
export { configureNodeBridgeSocket } from "./server/socket.js";
export { startNodeBridgeServer } from "./server/start.js";
export type {
BridgeEventFrame,
BridgeInvokeResponseFrame,
BridgeRPCRequestFrame,
NodeBridgeClientInfo,
NodeBridgeServer,
NodeBridgeServerOpts,
} from "./server/types.js";
-482
View File
@@ -1,482 +0,0 @@
import type net from "node:net";
import {
getPairedNode,
listNodePairing,
requestNodePairing,
updatePairedNodeMetadata,
verifyNodeToken,
} from "../../node-pairing.js";
import { encodeLine } from "./encode.js";
import { configureNodeBridgeSocket } from "./socket.js";
import type {
AnyBridgeFrame,
BridgeErrorFrame,
BridgeEventFrame,
BridgeHelloFrame,
BridgeHelloOkFrame,
BridgeInvokeResponseFrame,
BridgePairOkFrame,
BridgePairRequestFrame,
BridgePingFrame,
BridgePongFrame,
BridgeRPCRequestFrame,
BridgeRPCResponseFrame,
NodeBridgeClientInfo,
NodeBridgeServerOpts,
} from "./types.js";
type InvokeWaiter = {
resolve: (value: BridgeInvokeResponseFrame) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export type ConnectionState = {
socket: net.Socket;
nodeInfo: NodeBridgeClientInfo;
invokeWaiters: Map<string, InvokeWaiter>;
};
async function sleep(ms: number) {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
export function createNodeBridgeConnectionHandler(params: {
opts: NodeBridgeServerOpts;
connections: Map<string, ConnectionState>;
serverName: string;
buildCanvasHostUrl: (socket: net.Socket) => string | undefined;
}) {
const { opts, connections, serverName } = params;
return (socket: net.Socket) => {
configureNodeBridgeSocket(socket);
let buffer = "";
let isAuthenticated = false;
let nodeId: string | null = null;
let nodeInfo: NodeBridgeClientInfo | null = null;
const invokeWaiters = new Map<string, InvokeWaiter>();
const abort = new AbortController();
const stop = () => {
if (!abort.signal.aborted) abort.abort();
for (const [, waiter] of invokeWaiters) {
clearTimeout(waiter.timer);
waiter.reject(new Error("bridge connection closed"));
}
invokeWaiters.clear();
if (nodeId) {
const existing = connections.get(nodeId);
if (existing?.socket === socket) connections.delete(nodeId);
}
};
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 inferCaps = (frame: {
platform?: string;
deviceFamily?: string;
}): string[] | undefined => {
const platform = String(frame.platform ?? "")
.trim()
.toLowerCase();
const family = String(frame.deviceFamily ?? "")
.trim()
.toLowerCase();
if (platform.includes("ios") || platform.includes("ipados")) return ["canvas", "camera"];
if (platform.includes("android")) return ["canvas", "camera"];
if (family === "ipad" || family === "iphone" || family === "ios") return ["canvas", "camera"];
if (family === "android") return ["canvas", "camera"];
return undefined;
};
const normalizePermissions = (raw: unknown): Record<string, boolean> | undefined => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const entries = Object.entries(raw as Record<string, unknown>)
.map(([key, value]) => [String(key).trim(), value === true] as const)
.filter(([key]) => key.length > 0);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries);
};
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 || !verified.node) {
sendError("UNAUTHORIZED", "invalid token");
return;
}
const caps =
(Array.isArray(hello.caps)
? hello.caps.map((c) => String(c)).filter(Boolean)
: undefined) ??
verified.node.caps ??
inferCaps(hello);
const commands =
Array.isArray(hello.commands) && hello.commands.length > 0
? hello.commands.map((c) => String(c)).filter(Boolean)
: verified.node.commands;
const helloPermissions = normalizePermissions(hello.permissions);
const basePermissions = verified.node.permissions ?? {};
const permissions = helloPermissions
? { ...basePermissions, ...helloPermissions }
: verified.node.permissions;
isAuthenticated = true;
const existing = connections.get(nodeId);
if (existing?.socket && existing.socket !== socket) {
try {
existing.socket.destroy();
} catch {
/* ignore */
}
}
nodeInfo = {
nodeId,
displayName: verified.node.displayName ?? hello.displayName,
platform: verified.node.platform ?? hello.platform,
version: verified.node.version ?? hello.version,
coreVersion: verified.node.coreVersion ?? hello.coreVersion,
uiVersion: verified.node.uiVersion ?? hello.uiVersion,
deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily,
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
caps,
commands,
permissions,
remoteIp: remoteAddress,
};
await updatePairedNodeMetadata(
nodeId,
{
displayName: nodeInfo.displayName,
platform: nodeInfo.platform,
version: nodeInfo.version,
coreVersion: nodeInfo.coreVersion,
uiVersion: nodeInfo.uiVersion,
deviceFamily: nodeInfo.deviceFamily,
modelIdentifier: nodeInfo.modelIdentifier,
remoteIp: nodeInfo.remoteIp,
caps: nodeInfo.caps,
commands: nodeInfo.commands,
permissions: nodeInfo.permissions,
},
opts.pairingBaseDir,
);
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
send({
type: "hello-ok",
serverName,
canvasHostUrl: params.buildCanvasHostUrl(socket),
} satisfies BridgeHelloOkFrame);
await opts.onAuthenticated?.(nodeInfo);
};
const waitForApproval = async (request: {
requestId: string;
nodeId: string;
ts: number;
}): 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" };
// 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,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: Array.isArray(req.caps)
? req.caps.map((c) => String(c)).filter(Boolean)
: undefined,
commands: Array.isArray(req.commands)
? req.commands.map((c) => String(c)).filter(Boolean)
: undefined,
permissions:
req.permissions && typeof req.permissions === "object"
? (req.permissions as Record<string, boolean>)
: undefined,
remoteIp: remoteAddress,
silent: req.silent === true ? true : undefined,
},
opts.pairingBaseDir,
);
if (result.created) await opts.onPairRequested?.(result.request);
const wait = await waitForApproval({
requestId: result.request.requestId,
nodeId: result.request.nodeId,
ts: result.request.ts,
});
if (!wait.ok) {
sendError("UNAUTHORIZED", wait.reason);
return;
}
isAuthenticated = true;
const existing = connections.get(nodeId);
if (existing?.socket && existing.socket !== socket) {
try {
existing.socket.destroy();
} catch {
/* ignore */
}
}
nodeInfo = {
nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: Array.isArray(req.caps) ? req.caps.map((c) => String(c)).filter(Boolean) : undefined,
commands: Array.isArray(req.commands)
? req.commands.map((c) => String(c)).filter(Boolean)
: undefined,
permissions:
req.permissions && typeof req.permissions === "object"
? (req.permissions as Record<string, boolean>)
: undefined,
remoteIp: remoteAddress,
};
connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame);
send({
type: "hello-ok",
serverName,
canvasHostUrl: params.buildCanvasHostUrl(socket),
} satisfies BridgeHelloOkFrame);
await opts.onAuthenticated?.(nodeInfo);
};
const handleEvent = async (evt: BridgeEventFrame) => {
if (!isAuthenticated || !nodeId) {
sendError("UNAUTHORIZED", "not authenticated");
return;
}
await opts.onEvent?.(nodeId, evt);
};
const handleRequest = async (req: BridgeRPCRequestFrame) => {
if (!isAuthenticated || !nodeId) {
send({
type: "res",
id: String(req.id ?? ""),
ok: false,
error: { code: "UNAUTHORIZED", message: "not authenticated" },
} satisfies BridgeRPCResponseFrame);
return;
}
if (!opts.onRequest) {
send({
type: "res",
id: String(req.id ?? ""),
ok: false,
error: { code: "UNAVAILABLE", message: "RPC not supported" },
} satisfies BridgeRPCResponseFrame);
return;
}
const id = String(req.id ?? "");
const method = String(req.method ?? "");
if (!id || !method) {
send({
type: "res",
id: id || "invalid",
ok: false,
error: { code: "INVALID_REQUEST", message: "id and method required" },
} satisfies BridgeRPCResponseFrame);
return;
}
try {
const result = await opts.onRequest(nodeId, {
type: "req",
id,
method,
paramsJSON: req.paramsJSON ?? null,
});
if (result.ok) {
send({
type: "res",
id,
ok: true,
payloadJSON: result.payloadJSON ?? null,
} satisfies BridgeRPCResponseFrame);
} else {
send({
type: "res",
id,
ok: false,
error: result.error,
} satisfies BridgeRPCResponseFrame);
}
} catch (err) {
send({
type: "res",
id,
ok: false,
error: { code: "UNAVAILABLE", message: String(err) },
} satisfies BridgeRPCResponseFrame);
}
};
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 "req":
await handleRequest(frame as BridgeRPCRequestFrame);
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);
clearTimeout(waiter.timer);
waiter.resolve(res);
}
break;
}
case "invoke":
// Direction is gateway -> node only.
sendError("INVALID_REQUEST", "invoke not allowed from node");
break;
case "res":
// Direction is node -> gateway only.
sendError("INVALID_REQUEST", "res not allowed from node");
break;
case "pong":
// ignore
break;
default:
sendError("INVALID_REQUEST", "unknown type");
}
} catch (err) {
sendError("INVALID_REQUEST", String(err));
}
})();
}
});
socket.on("close", () => {
const info = nodeInfo;
stop();
if (info && isAuthenticated) void opts.onDisconnected?.(info);
});
socket.on("error", () => {
// close handler will run after close
});
};
}
-14
View File
@@ -1,14 +0,0 @@
import type { NodeBridgeServer } from "./types.js";
export function createDisabledNodeBridgeServer(): NodeBridgeServer {
return {
port: 0,
close: async () => {},
invoke: async () => {
throw new Error("bridge disabled in tests");
},
sendEvent: () => {},
listConnected: () => [],
listeners: [],
};
}
-5
View File
@@ -1,5 +0,0 @@
import type { AnyBridgeFrame } from "./types.js";
export function encodeLine(frame: AnyBridgeFrame) {
return `${JSON.stringify(frame)}\n`;
}
-11
View File
@@ -1,11 +0,0 @@
export function shouldAlsoListenOnLoopback(host: string | undefined) {
const h = String(host ?? "")
.trim()
.toLowerCase();
if (!h) return false; // default listen() already includes loopback
if (h === "0.0.0.0" || h === "::") return false; // already includes loopback
if (h === "localhost") return false;
if (h === "127.0.0.1" || h.startsWith("127.")) return false;
if (h === "::1") return false;
return true;
}
-7
View File
@@ -1,7 +0,0 @@
export function configureNodeBridgeSocket(socket: {
setNoDelay: (noDelay?: boolean) => void;
setKeepAlive: (enable?: boolean, initialDelay?: number) => void;
}) {
socket.setNoDelay(true);
socket.setKeepAlive(true, 15_000);
}
-170
View File
@@ -1,170 +0,0 @@
import { randomUUID } from "node:crypto";
import net from "node:net";
import os from "node:os";
import tls from "node:tls";
import { resolveCanvasHostUrl } from "../../canvas-host-url.js";
import { type ConnectionState, createNodeBridgeConnectionHandler } from "./connection.js";
import { createDisabledNodeBridgeServer } from "./disabled.js";
import { encodeLine } from "./encode.js";
import { shouldAlsoListenOnLoopback } from "./loopback.js";
import { isNodeBridgeTestEnv } from "./test-env.js";
import type {
BridgeEventFrame,
BridgeInvokeRequestFrame,
BridgeInvokeResponseFrame,
NodeBridgeServer,
NodeBridgeServerOpts,
} from "./types.js";
export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise<NodeBridgeServer> {
if (isNodeBridgeTestEnv() && process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1") {
return createDisabledNodeBridgeServer();
}
const serverName =
typeof opts.serverName === "string" && opts.serverName.trim()
? opts.serverName.trim()
: os.hostname();
const buildCanvasHostUrl = (socket: net.Socket) => {
return resolveCanvasHostUrl({
canvasPort: opts.canvasHostPort,
hostOverride: opts.canvasHostHost,
localAddress: socket.localAddress,
scheme: "http",
});
};
const connections = new Map<string, ConnectionState>();
const onConnection = createNodeBridgeConnectionHandler({
opts,
connections,
serverName,
buildCanvasHostUrl,
});
const loopbackHost = "127.0.0.1";
const listeners: Array<{ host: string; server: net.Server }> = [];
const createServer = () =>
opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection);
const primary = createServer();
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => reject(err);
primary.once("error", onError);
primary.listen(opts.port, opts.host, () => {
primary.off("error", onError);
resolve();
});
});
listeners.push({
host: String(opts.host ?? "").trim() || "(default)",
server: primary,
});
const address = primary.address();
const port = typeof address === "object" && address ? address.port : opts.port;
if (shouldAlsoListenOnLoopback(opts.host)) {
const loopback = createServer();
try {
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => reject(err);
loopback.once("error", onError);
loopback.listen(port, loopbackHost, () => {
loopback.off("error", onError);
resolve();
});
});
listeners.push({ host: loopbackHost, server: loopback });
} catch {
try {
loopback.close();
} catch {
/* ignore */
}
}
}
return {
port,
close: async () => {
for (const sock of connections.values()) {
try {
sock.socket.destroy();
} catch {
/* ignore */
}
}
connections.clear();
await Promise.all(
listeners.map(
(l) =>
new Promise<void>((resolve, reject) =>
l.server.close((err) => (err ? reject(err) : resolve())),
),
),
);
},
listConnected: () => [...connections.values()].map((c) => c.nodeInfo),
listeners: listeners.map((l) => ({ host: l.host, port })),
sendEvent: ({ nodeId, event, payloadJSON }) => {
const normalizedNodeId = String(nodeId ?? "").trim();
const normalizedEvent = String(event ?? "").trim();
if (!normalizedNodeId || !normalizedEvent) return;
const conn = connections.get(normalizedNodeId);
if (!conn) return;
try {
conn.socket.write(
encodeLine({
type: "event",
event: normalizedEvent,
payloadJSON: payloadJSON ?? null,
} satisfies BridgeEventFrame),
);
} catch {
// ignore
}
},
invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => {
const normalizedNodeId = String(nodeId ?? "").trim();
const normalizedCommand = String(command ?? "").trim();
if (!normalizedNodeId) throw new Error("INVALID_REQUEST: nodeId required");
if (!normalizedCommand) throw new Error("INVALID_REQUEST: command required");
const conn = connections.get(normalizedNodeId);
if (!conn) throw new Error(`UNAVAILABLE: node not connected (${normalizedNodeId})`);
const id = randomUUID();
const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000;
return await new Promise<BridgeInvokeResponseFrame>((resolve, reject) => {
const timer = setTimeout(
() => {
conn.invokeWaiters.delete(id);
reject(new Error("UNAVAILABLE: invoke timeout"));
},
Math.max(0, timeout),
);
conn.invokeWaiters.set(id, { resolve, reject, timer });
try {
conn.socket.write(
encodeLine({
type: "invoke",
id,
command: normalizedCommand,
paramsJSON: paramsJSON ?? null,
} satisfies BridgeInvokeRequestFrame),
);
} catch (err) {
conn.invokeWaiters.delete(id);
clearTimeout(timer);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
},
};
}
-3
View File
@@ -1,3 +0,0 @@
export function isNodeBridgeTestEnv() {
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
}
-149
View File
@@ -1,149 +0,0 @@
import type { TlsOptions } from "node:tls";
import type { NodePairingPendingRequest } from "../../node-pairing.js";
export type BridgeHelloFrame = {
type: "hello";
nodeId: string;
displayName?: string;
token?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
};
export type BridgePairRequestFrame = {
type: "pair-request";
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
remoteAddress?: string;
silent?: boolean;
};
export type BridgeEventFrame = {
type: "event";
event: string;
payloadJSON?: string | null;
};
export type BridgeRPCRequestFrame = {
type: "req";
id: string;
method: string;
paramsJSON?: string | null;
};
export type BridgeRPCResponseFrame = {
type: "res";
id: string;
ok: boolean;
payloadJSON?: string | null;
error?: { code: string; message: string; details?: unknown } | null;
};
export type BridgePingFrame = { type: "ping"; id: string };
export type BridgePongFrame = { type: "pong"; id: string };
export type BridgeInvokeRequestFrame = {
type: "invoke";
id: string;
command: string;
paramsJSON?: string | null;
};
export type BridgeInvokeResponseFrame = {
type: "invoke-res";
id: string;
ok: boolean;
payloadJSON?: string | null;
error?: { code: string; message: string } | null;
};
export type BridgeHelloOkFrame = {
type: "hello-ok";
serverName: string;
canvasHostUrl?: string;
};
export type BridgePairOkFrame = { type: "pair-ok"; token: string };
export type BridgeErrorFrame = { type: "error"; code: string; message: string };
export type AnyBridgeFrame =
| BridgeHelloFrame
| BridgePairRequestFrame
| BridgeEventFrame
| BridgeRPCRequestFrame
| BridgeRPCResponseFrame
| BridgePingFrame
| BridgePongFrame
| BridgeInvokeRequestFrame
| BridgeInvokeResponseFrame
| BridgeHelloOkFrame
| BridgePairOkFrame
| BridgeErrorFrame
| { type: string; [k: string]: unknown };
export type NodeBridgeServer = {
port: number;
close: () => Promise<void>;
invoke: (opts: {
nodeId: string;
command: string;
paramsJSON?: string | null;
timeoutMs?: number;
}) => Promise<BridgeInvokeResponseFrame>;
sendEvent: (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => void;
listConnected: () => NodeBridgeClientInfo[];
listeners: Array<{ host: string; port: number }>;
};
export type NodeBridgeClientInfo = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
};
export type NodeBridgeServerOpts = {
host: string;
port: number; // 0 = ephemeral
tls?: TlsOptions;
pairingBaseDir?: string;
canvasHostPort?: number;
canvasHostHost?: string;
onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise<void> | void;
onRequest?: (
nodeId: string,
req: BridgeRPCRequestFrame,
) => Promise<
| { ok: true; payloadJSON?: string | null }
| { ok: false; error: { code: string; message: string; details?: unknown } }
>;
onAuthenticated?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: NodeBridgeClientInfo) => Promise<void> | void;
onPairRequested?: (request: NodePairingPendingRequest) => Promise<void> | void;
serverName?: string;
};
+19 -23
View File
@@ -2,10 +2,10 @@ import type { SkillEligibilityContext, SkillEntry } from "../agents/skills.js";
import { loadWorkspaceSkillEntries } from "../agents/skills.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { NodeBridgeServer } from "./bridge/server.js";
import { listNodePairing, updatePairedNodeMetadata } from "./node-pairing.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import type { NodeRegistry } from "../gateway/node-registry.js";
type RemoteNodeRecord = {
nodeId: string;
@@ -19,7 +19,7 @@ type RemoteNodeRecord = {
const log = createSubsystemLogger("gateway/skills-remote");
const remoteNodes = new Map<string, RemoteNodeRecord>();
let remoteBridge: NodeBridgeServer | null = null;
let remoteRegistry: NodeRegistry | null = null;
function describeNode(nodeId: string): string {
const record = remoteNodes.get(nodeId);
@@ -55,22 +55,16 @@ function extractErrorMessage(err: unknown): string | undefined {
function logRemoteBinProbeFailure(nodeId: string, err: unknown) {
const message = extractErrorMessage(err);
const label = describeNode(nodeId);
if (message?.includes("UNAVAILABLE: node not connected")) {
if (message?.includes("node not connected")) {
log.info(
`remote bin probe skipped: node not connected (${label}); check nodes list/status for ${label}`,
);
return;
}
if (message?.includes("UNAVAILABLE: invoke timeout")) {
if (message?.includes("invoke timed out") || message?.includes("timeout")) {
log.warn(`remote bin probe timed out (${label}); check node connectivity for ${label}`);
return;
}
if (message?.includes("bridge connection closed")) {
log.warn(
`remote bin probe aborted: bridge connection closed (${label}); check nodes list/status for ${label}`,
);
return;
}
log.warn(`remote bin probe error (${label}): ${message ?? "unknown"}`);
}
@@ -117,8 +111,8 @@ function upsertNode(record: {
});
}
export function setSkillsRemoteBridge(bridge: NodeBridgeServer | null) {
remoteBridge = bridge;
export function setSkillsRemoteRegistry(registry: NodeRegistry | null) {
remoteRegistry = registry;
}
export async function primeRemoteSkillsCache() {
@@ -198,10 +192,12 @@ function buildBinProbeScript(bins: string[]): string {
return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`;
}
function parseBinProbePayload(payloadJSON: string | null | undefined): string[] {
if (!payloadJSON) return [];
function parseBinProbePayload(payloadJSON: string | null | undefined, payload?: unknown): string[] {
if (!payloadJSON && !payload) return [];
try {
const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown };
const parsed = payloadJSON
? (JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown })
: (payload as { stdout?: unknown; bins?: unknown });
if (Array.isArray(parsed.bins)) {
return parsed.bins.map((bin) => String(bin).trim()).filter(Boolean);
}
@@ -225,7 +221,7 @@ export async function refreshRemoteNodeBins(params: {
cfg: ClawdbotConfig;
timeoutMs?: number;
}) {
if (!remoteBridge) return;
if (!remoteRegistry) return;
if (!isMacPlatform(params.platform, params.deviceFamily)) return;
const canWhich = supportsSystemWhich(params.commands);
const canRun = supportsSystemRun(params.commands);
@@ -243,20 +239,20 @@ export async function refreshRemoteNodeBins(params: {
try {
const binsList = [...requiredBins];
const res = await remoteBridge.invoke(
const res = await remoteRegistry.invoke(
canWhich
? {
nodeId: params.nodeId,
command: "system.which",
paramsJSON: JSON.stringify({ bins: binsList }),
params: { bins: binsList },
timeoutMs: params.timeoutMs ?? 15_000,
}
: {
nodeId: params.nodeId,
command: "system.run",
paramsJSON: JSON.stringify({
params: {
command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)],
}),
},
timeoutMs: params.timeoutMs ?? 15_000,
},
);
@@ -264,7 +260,7 @@ export async function refreshRemoteNodeBins(params: {
logRemoteBinProbeFailure(params.nodeId, res.error?.message ?? "unknown");
return;
}
const bins = parseBinProbePayload(res.payloadJSON);
const bins = parseBinProbePayload(res.payloadJSON, res.payload);
recordRemoteNodeBins(params.nodeId, bins);
await updatePairedNodeMetadata(params.nodeId, { bins });
bumpSkillsSnapshotVersion({ reason: "remote-node" });
@@ -296,8 +292,8 @@ export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] |
}
export async function refreshRemoteBinsForConnectedNodes(cfg: ClawdbotConfig) {
if (!remoteBridge) return;
const connected = remoteBridge.listConnected();
if (!remoteRegistry) return;
const connected = remoteRegistry.listConnected();
for (const node of connected) {
await refreshRemoteNodeBins({
nodeId: node.nodeId,
@@ -5,12 +5,12 @@ import path from "node:path";
import tls from "node:tls";
import { promisify } from "node:util";
import type { BridgeTlsConfig } from "../../../config/types.gateway.js";
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../../utils.js";
import type { GatewayTlsConfig } from "../../config/types.gateway.js";
import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js";
const execFileAsync = promisify(execFile);
export type BridgeTlsRuntime = {
export type GatewayTlsRuntime = {
enabled: boolean;
required: boolean;
certPath?: string;
@@ -59,25 +59,25 @@ async function generateSelfSignedCert(params: {
"-out",
params.certPath,
"-subj",
"/CN=clawdbot-bridge",
"/CN=clawdbot-gateway",
]);
await fs.chmod(params.keyPath, 0o600).catch(() => {});
await fs.chmod(params.certPath, 0o600).catch(() => {});
params.log?.info?.(
`bridge tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
`gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`,
);
}
export async function loadBridgeTlsRuntime(
cfg: BridgeTlsConfig | undefined,
export async function loadGatewayTlsRuntime(
cfg: GatewayTlsConfig | undefined,
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
): Promise<BridgeTlsRuntime> {
): Promise<GatewayTlsRuntime> {
if (!cfg || cfg.enabled !== true) return { enabled: false, required: false };
const autoGenerate = cfg.autoGenerate !== false;
const baseDir = path.join(CONFIG_DIR, "bridge", "tls");
const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "bridge-cert.pem"));
const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "bridge-key.pem"));
const baseDir = path.join(CONFIG_DIR, "gateway", "tls");
const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "gateway-cert.pem"));
const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "gateway-key.pem"));
const caPath = cfg.caPath ? resolveUserPath(cfg.caPath) : undefined;
const hasCert = await fileExists(certPath);
@@ -92,7 +92,7 @@ export async function loadBridgeTlsRuntime(
required: true,
certPath,
keyPath,
error: `bridge tls: failed to generate cert (${String(err)})`,
error: `gateway tls: failed to generate cert (${String(err)})`,
};
}
}
@@ -103,7 +103,7 @@ export async function loadBridgeTlsRuntime(
required: true,
certPath,
keyPath,
error: "bridge tls: cert/key missing",
error: "gateway tls: cert/key missing",
};
}
@@ -121,7 +121,7 @@ export async function loadBridgeTlsRuntime(
certPath,
keyPath,
caPath,
error: "bridge tls: unable to compute certificate fingerprint",
error: "gateway tls: unable to compute certificate fingerprint",
};
}
@@ -146,7 +146,7 @@ export async function loadBridgeTlsRuntime(
certPath,
keyPath,
caPath,
error: `bridge tls: failed to load cert (${String(err)})`,
error: `gateway tls: failed to load cert (${String(err)})`,
};
}
}
+6 -8
View File
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest";
import { renderWideAreaBridgeZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
import { renderWideAreaGatewayZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
describe("wide-area DNS-SD zone rendering", () => {
it("renders a clawdbot.internal zone with bridge PTR/SRV/TXT records", () => {
const txt = renderWideAreaBridgeZoneText({
it("renders a clawdbot.internal zone with gateway PTR/SRV/TXT records", () => {
const txt = renderWideAreaGatewayZoneText({
serial: 2025121701,
bridgePort: 18790,
gatewayPort: 18789,
displayName: "Mac Studio (Clawdbot)",
tailnetIPv4: "100.123.224.76",
@@ -20,8 +19,8 @@ describe("wide-area DNS-SD zone rendering", () => {
expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`);
expect(txt).toContain(`studio-london IN A 100.123.224.76`);
expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`);
expect(txt).toContain(`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`);
expect(txt).toContain(`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`);
expect(txt).toContain(`_clawdbot-gateway._tcp IN PTR studio-london._clawdbot-gateway._tcp`);
expect(txt).toContain(`studio-london._clawdbot-gateway._tcp IN SRV 0 0 18789 studio-london`);
expect(txt).toContain(`displayName=Mac Studio (Clawdbot)`);
expect(txt).toContain(`gatewayPort=18789`);
expect(txt).toContain(`sshPort=22`);
@@ -29,9 +28,8 @@ describe("wide-area DNS-SD zone rendering", () => {
});
it("includes tailnetDns when provided", () => {
const txt = renderWideAreaBridgeZoneText({
const txt = renderWideAreaGatewayZoneText({
serial: 2025121701,
bridgePort: 18790,
gatewayPort: 18789,
displayName: "Mac Studio (Clawdbot)",
tailnetIPv4: "100.123.224.76",
+23 -25
View File
@@ -65,14 +65,13 @@ function computeContentHash(body: string): string {
return (h >>> 0).toString(16).padStart(8, "0");
}
export type WideAreaBridgeZoneOpts = {
bridgePort: number;
gatewayPort?: number;
export type WideAreaGatewayZoneOpts = {
gatewayPort: number;
displayName: string;
tailnetIPv4: string;
tailnetIPv6?: string;
bridgeTlsEnabled?: boolean;
bridgeTlsFingerprintSha256?: string;
gatewayTlsEnabled?: boolean;
gatewayTlsFingerprintSha256?: string;
instanceLabel?: string;
hostLabel?: string;
tailnetDns?: string;
@@ -80,23 +79,20 @@ export type WideAreaBridgeZoneOpts = {
cliPath?: string;
};
function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
function renderZone(opts: WideAreaGatewayZoneOpts & { serial: number }): string {
const hostname = os.hostname().split(".")[0] ?? "clawdbot";
const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdbot");
const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-bridge`, "clawdbot-bridge");
const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-gateway`, "clawdbot-gateway");
const txt = [
`displayName=${opts.displayName.trim() || hostname}`,
`transport=bridge`,
`bridgePort=${opts.bridgePort}`,
`transport=gateway`,
`gatewayPort=${opts.gatewayPort}`,
];
if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) {
txt.push(`gatewayPort=${opts.gatewayPort}`);
}
if (opts.bridgeTlsEnabled) {
txt.push(`bridgeTls=1`);
if (opts.bridgeTlsFingerprintSha256) {
txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`);
if (opts.gatewayTlsEnabled) {
txt.push(`gatewayTls=1`);
if (opts.gatewayTlsFingerprintSha256) {
txt.push(`gatewayTlsSha256=${opts.gatewayTlsFingerprintSha256}`);
}
}
if (opts.tailnetDns?.trim()) {
@@ -122,9 +118,11 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
records.push(`${hostLabel} IN AAAA ${opts.tailnetIPv6}`);
}
records.push(`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`);
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`);
records.push(`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`);
records.push(`_clawdbot-gateway._tcp IN PTR ${instanceLabel}._clawdbot-gateway._tcp`);
records.push(
`${instanceLabel}._clawdbot-gateway._tcp IN SRV 0 0 ${opts.gatewayPort} ${hostLabel}`,
);
records.push(`${instanceLabel}._clawdbot-gateway._tcp IN TXT ${txt.map(txtQuote).join(" ")}`);
const contentBody = `${records.join("\n")}\n`;
const hashBody = `${records
@@ -137,14 +135,14 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string {
return `; clawdbot-content-hash: ${contentHash}\n${contentBody}`;
}
export function renderWideAreaBridgeZoneText(
opts: WideAreaBridgeZoneOpts & { serial: number },
export function renderWideAreaGatewayZoneText(
opts: WideAreaGatewayZoneOpts & { serial: number },
): string {
return renderZone(opts);
}
export async function writeWideAreaBridgeZone(
opts: WideAreaBridgeZoneOpts,
export async function writeWideAreaGatewayZone(
opts: WideAreaGatewayZoneOpts,
): Promise<{ zonePath: string; changed: boolean }> {
const zonePath = getWideAreaZonePath();
await ensureDir(path.dirname(zonePath));
@@ -157,7 +155,7 @@ export async function writeWideAreaBridgeZone(
}
})();
const nextNoSerial = renderWideAreaBridgeZoneText({ ...opts, serial: 0 });
const nextNoSerial = renderWideAreaGatewayZoneText({ ...opts, serial: 0 });
const nextHash = extractContentHash(nextNoSerial);
const existingHash = existing ? extractContentHash(existing) : null;
@@ -167,7 +165,7 @@ export async function writeWideAreaBridgeZone(
const existingSerial = existing ? extractSerial(existing) : null;
const serial = nextSerial(existingSerial, new Date());
const next = renderWideAreaBridgeZoneText({ ...opts, serial });
const next = renderWideAreaGatewayZoneText({ ...opts, serial });
fs.writeFileSync(zonePath, next, "utf-8");
return { zonePath, changed: true };
}
-308
View File
@@ -1,308 +0,0 @@
import crypto from "node:crypto";
import net from "node:net";
import tls from "node:tls";
import type {
BridgeErrorFrame,
BridgeEventFrame,
BridgeHelloFrame,
BridgeHelloOkFrame,
BridgeInvokeRequestFrame,
BridgeInvokeResponseFrame,
BridgePairOkFrame,
BridgePairRequestFrame,
BridgePingFrame,
BridgePongFrame,
BridgeRPCRequestFrame,
BridgeRPCResponseFrame,
} from "../infra/bridge/server/types.js";
export type BridgeClientOptions = {
host: string;
port: number;
tls?: boolean;
tlsFingerprint?: string;
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
onInvoke?: (frame: BridgeInvokeRequestFrame) => void | Promise<void>;
onEvent?: (frame: BridgeEventFrame) => void | Promise<void>;
onPairToken?: (token: string) => void | Promise<void>;
onAuthReset?: () => void | Promise<void>;
onConnected?: (hello: BridgeHelloOkFrame) => void | Promise<void>;
onDisconnected?: (err?: Error) => void | Promise<void>;
log?: { info?: (msg: string) => void; warn?: (msg: string) => void };
};
type PendingRpc = {
resolve: (frame: BridgeRPCResponseFrame) => void;
reject: (err: Error) => void;
timer?: NodeJS.Timeout;
};
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}
function extractFingerprint(raw: tls.PeerCertificate | tls.DetailedPeerCertificate): string | null {
const value = "fingerprint256" in raw ? raw.fingerprint256 : undefined;
if (!value) return null;
return normalizeFingerprint(value);
}
export class BridgeClient {
private opts: BridgeClientOptions;
private socket: net.Socket | tls.TLSSocket | null = null;
private buffer = "";
private pendingRpc = new Map<string, PendingRpc>();
private connected = false;
private helloReady: Promise<void> | null = null;
private helloResolve: (() => void) | null = null;
private helloReject: ((err: Error) => void) | null = null;
constructor(opts: BridgeClientOptions) {
this.opts = opts;
}
async connect(): Promise<void> {
if (this.connected) return;
this.helloReady = new Promise<void>((resolve, reject) => {
this.helloResolve = resolve;
this.helloReject = reject;
});
const socket = this.opts.tls
? tls.connect({
host: this.opts.host,
port: this.opts.port,
rejectUnauthorized: false,
})
: net.connect({ host: this.opts.host, port: this.opts.port });
this.socket = socket;
socket.setNoDelay(true);
socket.on("connect", () => {
this.sendHello();
});
socket.on("error", (err: Error) => {
this.handleDisconnect(err);
});
socket.on("close", () => {
this.handleDisconnect();
});
socket.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString("utf8");
this.flush();
});
if (this.opts.tls && socket instanceof tls.TLSSocket && this.opts.tlsFingerprint) {
socket.once("secureConnect", () => {
const cert = socket.getPeerCertificate(true);
const fingerprint = cert ? extractFingerprint(cert) : null;
if (!fingerprint || fingerprint !== normalizeFingerprint(this.opts.tlsFingerprint ?? "")) {
const err = new Error("bridge tls fingerprint mismatch");
this.handleDisconnect(err);
socket.destroy(err);
}
});
}
await this.helloReady;
}
async close(): Promise<void> {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.connected = false;
this.pendingRpc.forEach((pending) => {
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error("bridge client closed"));
});
this.pendingRpc.clear();
}
async request(method: string, params: Record<string, unknown> | null = null, timeoutMs = 5000) {
const id = crypto.randomUUID();
const frame: BridgeRPCRequestFrame = {
type: "req",
id,
method,
paramsJSON: params ? JSON.stringify(params) : null,
};
const res = await new Promise<BridgeRPCResponseFrame>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRpc.delete(id);
reject(new Error(`bridge request timeout (${method})`));
}, timeoutMs);
this.pendingRpc.set(id, { resolve, reject, timer });
this.send(frame);
});
if (!res.ok) {
throw new Error(res.error?.message ?? "bridge request failed");
}
return res.payloadJSON ? JSON.parse(res.payloadJSON) : null;
}
sendEvent(event: string, payload?: unknown) {
const frame: BridgeEventFrame = {
type: "event",
event,
payloadJSON: payload ? JSON.stringify(payload) : null,
};
this.send(frame);
}
sendInvokeResponse(frame: BridgeInvokeResponseFrame) {
this.send(frame);
}
private sendHello() {
const hello: BridgeHelloFrame = {
type: "hello",
nodeId: this.opts.nodeId,
token: this.opts.token,
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
coreVersion: this.opts.coreVersion,
uiVersion: this.opts.uiVersion,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
commands: this.opts.commands,
permissions: this.opts.permissions,
};
this.send(hello);
}
private sendPairRequest() {
const req: BridgePairRequestFrame = {
type: "pair-request",
nodeId: this.opts.nodeId,
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
coreVersion: this.opts.coreVersion,
uiVersion: this.opts.uiVersion,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
commands: this.opts.commands,
permissions: this.opts.permissions,
};
this.send(req);
}
private send(frame: object) {
if (!this.socket) return;
this.socket.write(`${JSON.stringify(frame)}\n`);
}
private handleDisconnect(err?: Error) {
if (!this.connected && this.helloReject) {
this.helloReject(err ?? new Error("bridge connection failed"));
this.helloResolve = null;
this.helloReject = null;
}
if (!this.connected && !this.socket) return;
this.connected = false;
this.socket = null;
this.pendingRpc.forEach((pending) => {
if (pending.timer) clearTimeout(pending.timer);
pending.reject(err ?? new Error("bridge connection closed"));
});
this.pendingRpc.clear();
void this.opts.onDisconnected?.(err);
}
private flush() {
while (true) {
const idx = this.buffer.indexOf("\n");
if (idx === -1) break;
const line = this.buffer.slice(0, idx).trim();
this.buffer = this.buffer.slice(idx + 1);
if (!line) continue;
let frame: { type?: string; [key: string]: unknown };
try {
frame = JSON.parse(line) as { type?: string };
} catch {
continue;
}
this.handleFrame(frame as BridgeErrorFrame);
}
}
private handleFrame(frame: { type?: string; [key: string]: unknown }) {
const type = String(frame.type ?? "");
switch (type) {
case "hello-ok": {
this.connected = true;
this.helloResolve?.();
this.helloResolve = null;
this.helloReject = null;
void this.opts.onConnected?.(frame as BridgeHelloOkFrame);
return;
}
case "pair-ok": {
const token = String((frame as BridgePairOkFrame).token ?? "").trim();
if (token) {
this.opts.token = token;
void this.opts.onPairToken?.(token);
}
return;
}
case "error": {
const code = String((frame as BridgeErrorFrame).code ?? "");
if (code === "NOT_PAIRED" || code === "UNAUTHORIZED") {
this.opts.token = undefined;
void this.opts.onAuthReset?.();
this.sendPairRequest();
return;
}
this.handleDisconnect(new Error((frame as BridgeErrorFrame).message ?? "bridge error"));
return;
}
case "pong":
return;
case "ping": {
const ping = frame as BridgePingFrame;
const pong: BridgePongFrame = { type: "pong", id: String(ping.id ?? "") };
this.send(pong);
return;
}
case "event": {
void this.opts.onEvent?.(frame as BridgeEventFrame);
return;
}
case "res": {
const res = frame as BridgeRPCResponseFrame;
const pending = this.pendingRpc.get(res.id);
if (pending) {
if (pending.timer) clearTimeout(pending.timer);
this.pendingRpc.delete(res.id);
pending.resolve(res);
}
return;
}
case "invoke": {
void this.opts.onInvoke?.(frame as BridgeInvokeRequestFrame);
return;
}
case "invoke-res": {
return;
}
default:
return;
}
}
}
+178 -200
View File
@@ -4,13 +4,11 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
import {
addAllowlistEntry,
matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse,
requestExecApprovalViaSocket,
resolveCommandResolution,
resolveExecApprovals,
ensureExecApprovals,
@@ -26,10 +24,16 @@ import {
type ExecHostRunResult,
} from "../infra/exec-host.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadConfig } from "../config/config.js";
import { VERSION } from "../version.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { BridgeClient } from "./bridge-client.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
import { GatewayClient } from "../gateway/client.js";
type NodeHostRunOptions = {
gatewayHost: string;
@@ -49,6 +53,7 @@ type SystemRunParams = {
needsScreenRecording?: boolean | null;
agentId?: string | null;
sessionKey?: string | null;
approved?: boolean | null;
};
type SystemWhichParams = {
@@ -89,6 +94,15 @@ type ExecEventPayload = {
reason?: string;
};
type NodeInvokeRequestPayload = {
id: string;
nodeId: string;
command: string;
paramsJSON?: string | null;
timeoutMs?: number | null;
idempotencyKey?: string | null;
};
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
@@ -331,7 +345,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const nodeId = opts.nodeId?.trim() || config.nodeId;
if (nodeId !== config.nodeId) {
config.nodeId = nodeId;
config.token = undefined;
}
const displayName =
opts.displayName?.trim() || config.displayName || (await getMachineDisplayName());
@@ -339,37 +352,38 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const gateway: NodeHostGatewayConfig = {
host: opts.gatewayHost,
port: opts.gatewayPort,
tls: opts.gatewayTls === true,
tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false,
tlsFingerprint: opts.gatewayTlsFingerprint,
};
config.gateway = gateway;
await saveNodeHostConfig(config);
let disconnectResolve: (() => void) | null = null;
let disconnectSignal = false;
const waitForDisconnect = () =>
new Promise<void>((resolve) => {
if (disconnectSignal) {
disconnectSignal = false;
resolve();
return;
}
disconnectResolve = resolve;
});
const cfg = loadConfig();
const isRemoteMode = cfg.gateway?.mode === "remote";
const token =
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
(isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token);
const password =
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(isRemoteMode ? cfg.gateway?.remote?.password : cfg.gateway?.auth?.password);
const client = new BridgeClient({
host: gateway.host ?? "127.0.0.1",
port: gateway.port ?? 18790,
tls: gateway.tls,
tlsFingerprint: gateway.tlsFingerprint,
nodeId,
token: config.token,
displayName,
const host = gateway.host ?? "127.0.0.1";
const port = gateway.port ?? 18789;
const scheme = gateway.tls ? "wss" : "ws";
const url = `${scheme}://${host}:${port}`;
const client = new GatewayClient({
url,
token: token?.trim() || undefined,
password: password?.trim() || undefined,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,
clientVersion: VERSION,
platform: process.platform,
version: VERSION,
coreVersion: VERSION,
deviceFamily: os.platform(),
modelIdentifier: os.hostname(),
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: [
"system.run",
@@ -377,25 +391,23 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
"system.execApprovals.get",
"system.execApprovals.set",
],
onPairToken: async (token) => {
config.token = token;
await saveNodeHostConfig(config);
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
tlsFingerprint: gateway.tlsFingerprint,
onEvent: (evt) => {
if (evt.event !== "node.invoke.request") return;
const payload = coerceNodeInvokePayload(evt.payload);
if (!payload) return;
void handleInvoke(payload, client, skillBins);
},
onAuthReset: async () => {
if (!config.token) return;
config.token = undefined;
await saveNodeHostConfig(config);
onConnectError: (err) => {
// keep retrying (handled by GatewayClient)
// eslint-disable-next-line no-console
console.error(`node host gateway connect failed: ${err.message}`);
},
onInvoke: async (frame) => {
await handleInvoke(frame, client, skillBins);
},
onDisconnected: () => {
if (disconnectResolve) {
disconnectResolve();
disconnectResolve = null;
} else {
disconnectSignal = true;
}
onClose: (code, reason) => {
// eslint-disable-next-line no-console
console.error(`node host gateway closed (${code}): ${reason}`);
},
});
@@ -408,20 +420,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
return bins;
});
while (true) {
try {
await client.connect();
await waitForDisconnect();
} catch {
// ignore connect errors; retry
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
client.start();
await new Promise(() => {});
}
async function handleInvoke(
frame: BridgeInvokeRequestFrame,
client: BridgeClient,
frame: NodeInvokeRequestPayload,
client: GatewayClient,
skillBins: SkillBinsCache,
) {
const command = String(frame.command ?? "");
@@ -435,16 +440,12 @@ async function handleInvoke(
hash: snapshot.hash,
file: redactExecApprovals(snapshot.file),
};
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -482,16 +483,12 @@ async function handleInvoke(
hash: nextSnapshot.hash,
file: redactExecApprovals(nextSnapshot.file),
};
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -507,16 +504,12 @@ async function handleInvoke(
}
const env = sanitizeEnv(undefined);
const payload = await handleSystemWhich(params, env);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -525,9 +518,7 @@ async function handleInvoke(
}
if (command !== "system.run") {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "command not supported" },
});
@@ -538,9 +529,7 @@ async function handleInvoke(
try {
params = decodeParams<SystemRunParams>(frame.paramsJSON);
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -548,9 +537,7 @@ async function handleInvoke(
}
if (!Array.isArray(params.command) || params.command.length === 0) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: "command required" },
});
@@ -564,7 +551,6 @@ async function handleInvoke(
const approvals = resolveExecApprovals(agentId);
const security = approvals.agent.security;
const ask = approvals.agent.ask;
const askFallback = approvals.agent.askFallback;
const autoAllowSkills = approvals.agent.autoAllowSkills;
const sessionKey = params.sessionKey?.trim() || "node";
const runId = crypto.randomUUID();
@@ -591,7 +577,8 @@ async function handleInvoke(
};
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -601,9 +588,7 @@ async function handleInvoke(
reason: "companion-unavailable",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: {
code: "UNAVAILABLE",
@@ -615,7 +600,8 @@ async function handleInvoke(
if (!response.ok) {
const reason = response.error.reason ?? "approval-required";
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -625,9 +611,7 @@ async function handleInvoke(
reason,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: response.error.message },
});
@@ -636,7 +620,8 @@ async function handleInvoke(
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
@@ -649,9 +634,7 @@ async function handleInvoke(
output: combined,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(result),
});
@@ -659,7 +642,8 @@ async function handleInvoke(
}
if (security === "deny") {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -669,9 +653,7 @@ async function handleInvoke(
reason: "security=deny",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
});
@@ -682,99 +664,33 @@ async function handleInvoke(
ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
let approvedByAsk = false;
if (requiresAsk) {
const decision = await requestExecApprovalViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request: {
command: cmdText,
cwd: params.cwd ?? undefined,
const approvedByAsk = params.approved === true;
if (requiresAsk && !approvedByAsk) {
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
security,
ask,
agentId,
resolvedPath: resolution?.resolvedPath ?? null,
},
command: cmdText,
reason: "approval-required",
}),
);
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
if (decision === "deny") {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "user-denied",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied" },
});
return;
}
if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (allowlistMatch || skillAllow) {
approvedByAsk = true;
} else {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "approval-required",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
return;
}
} else {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "approval-required",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
return;
}
}
if (decision === "allow-once") {
approvedByAsk = true;
}
if (decision === "allow-always") {
approvedByAsk = true;
if (security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
}
return;
}
if (approvedByAsk && security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -784,9 +700,7 @@ async function handleInvoke(
reason: "allowlist-miss",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
});
@@ -798,7 +712,8 @@ async function handleInvoke(
}
if (params.needsScreenRecording === true) {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -808,16 +723,15 @@ async function handleInvoke(
reason: "permission:screenRecording",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
});
return;
}
client.sendEvent(
await sendNodeEvent(
client,
"exec.started",
buildExecEventPayload({
sessionKey,
@@ -842,7 +756,8 @@ async function handleInvoke(
}
}
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
@@ -856,9 +771,7 @@ async function handleInvoke(
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify({
exitCode: result.exitCode,
@@ -877,3 +790,68 @@ function decodeParams<T>(raw?: string | null): T {
}
return JSON.parse(raw) as T;
}
function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null {
if (!payload || typeof payload !== "object") return null;
const obj = payload as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : "";
const command = typeof obj.command === "string" ? obj.command.trim() : "";
if (!id || !nodeId || !command) return null;
const paramsJSON =
typeof obj.paramsJSON === "string"
? obj.paramsJSON
: obj.params !== undefined
? JSON.stringify(obj.params)
: null;
const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null;
const idempotencyKey =
typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null;
return {
id,
nodeId,
command,
paramsJSON,
timeoutMs,
idempotencyKey,
};
}
async function sendInvokeResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
},
) {
try {
await client.request("node.invoke.result", {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
payload: result.payload,
payloadJSON: result.payloadJSON ?? null,
error: result.error ?? null,
});
} catch {
// ignore: node invoke responses are best-effort
}
}
async function sendNodeEvent(
client: GatewayClient,
event: string,
payload: unknown,
) {
try {
await client.request("node.event", {
event,
payloadJSON: payload ? JSON.stringify(payload) : null,
});
} catch {
// ignore: node events are best-effort
}
}