mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 21:01:43 +03:00
refactor: remove bridge protocol
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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
@@ -122,7 +122,7 @@ export function registerDnsCli(program: Command) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
bridge: { bind: "tailnet" },
|
||||
gateway: { bind: "auto" },
|
||||
discovery: { wideArea: { enabled: true } },
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
+4
-4
@@ -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}`);
|
||||
+30
-30
@@ -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 {
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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 = "Peter’s Mac Studio Bridge";
|
||||
const studioInstance = "Peter’s 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
|
||||
? "Peter’s\\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: "Peter’s Mac Studio",
|
||||
txt: expect.objectContaining({
|
||||
displayName: "Peter’s 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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { AnyBridgeFrame } from "./types.js";
|
||||
|
||||
export function encodeLine(frame: AnyBridgeFrame) {
|
||||
return `${JSON.stringify(frame)}\n`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function isNodeBridgeTestEnv() {
|
||||
return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST);
|
||||
}
|
||||
@@ -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
@@ -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)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user