refactor: remove bridge protocol

This commit is contained in:
Peter Steinberger
2026-01-19 04:50:07 +00:00
parent b347d5d9cc
commit 2f8206862a
118 changed files with 1560 additions and 8087 deletions

View File

@@ -1,308 +0,0 @@
import crypto from "node:crypto";
import net from "node:net";
import tls from "node:tls";
import type {
BridgeErrorFrame,
BridgeEventFrame,
BridgeHelloFrame,
BridgeHelloOkFrame,
BridgeInvokeRequestFrame,
BridgeInvokeResponseFrame,
BridgePairOkFrame,
BridgePairRequestFrame,
BridgePingFrame,
BridgePongFrame,
BridgeRPCRequestFrame,
BridgeRPCResponseFrame,
} from "../infra/bridge/server/types.js";
export type BridgeClientOptions = {
host: string;
port: number;
tls?: boolean;
tlsFingerprint?: string;
nodeId: string;
token?: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
onInvoke?: (frame: BridgeInvokeRequestFrame) => void | Promise<void>;
onEvent?: (frame: BridgeEventFrame) => void | Promise<void>;
onPairToken?: (token: string) => void | Promise<void>;
onAuthReset?: () => void | Promise<void>;
onConnected?: (hello: BridgeHelloOkFrame) => void | Promise<void>;
onDisconnected?: (err?: Error) => void | Promise<void>;
log?: { info?: (msg: string) => void; warn?: (msg: string) => void };
};
type PendingRpc = {
resolve: (frame: BridgeRPCResponseFrame) => void;
reject: (err: Error) => void;
timer?: NodeJS.Timeout;
};
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}
function extractFingerprint(raw: tls.PeerCertificate | tls.DetailedPeerCertificate): string | null {
const value = "fingerprint256" in raw ? raw.fingerprint256 : undefined;
if (!value) return null;
return normalizeFingerprint(value);
}
export class BridgeClient {
private opts: BridgeClientOptions;
private socket: net.Socket | tls.TLSSocket | null = null;
private buffer = "";
private pendingRpc = new Map<string, PendingRpc>();
private connected = false;
private helloReady: Promise<void> | null = null;
private helloResolve: (() => void) | null = null;
private helloReject: ((err: Error) => void) | null = null;
constructor(opts: BridgeClientOptions) {
this.opts = opts;
}
async connect(): Promise<void> {
if (this.connected) return;
this.helloReady = new Promise<void>((resolve, reject) => {
this.helloResolve = resolve;
this.helloReject = reject;
});
const socket = this.opts.tls
? tls.connect({
host: this.opts.host,
port: this.opts.port,
rejectUnauthorized: false,
})
: net.connect({ host: this.opts.host, port: this.opts.port });
this.socket = socket;
socket.setNoDelay(true);
socket.on("connect", () => {
this.sendHello();
});
socket.on("error", (err: Error) => {
this.handleDisconnect(err);
});
socket.on("close", () => {
this.handleDisconnect();
});
socket.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString("utf8");
this.flush();
});
if (this.opts.tls && socket instanceof tls.TLSSocket && this.opts.tlsFingerprint) {
socket.once("secureConnect", () => {
const cert = socket.getPeerCertificate(true);
const fingerprint = cert ? extractFingerprint(cert) : null;
if (!fingerprint || fingerprint !== normalizeFingerprint(this.opts.tlsFingerprint ?? "")) {
const err = new Error("bridge tls fingerprint mismatch");
this.handleDisconnect(err);
socket.destroy(err);
}
});
}
await this.helloReady;
}
async close(): Promise<void> {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
this.connected = false;
this.pendingRpc.forEach((pending) => {
if (pending.timer) clearTimeout(pending.timer);
pending.reject(new Error("bridge client closed"));
});
this.pendingRpc.clear();
}
async request(method: string, params: Record<string, unknown> | null = null, timeoutMs = 5000) {
const id = crypto.randomUUID();
const frame: BridgeRPCRequestFrame = {
type: "req",
id,
method,
paramsJSON: params ? JSON.stringify(params) : null,
};
const res = await new Promise<BridgeRPCResponseFrame>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingRpc.delete(id);
reject(new Error(`bridge request timeout (${method})`));
}, timeoutMs);
this.pendingRpc.set(id, { resolve, reject, timer });
this.send(frame);
});
if (!res.ok) {
throw new Error(res.error?.message ?? "bridge request failed");
}
return res.payloadJSON ? JSON.parse(res.payloadJSON) : null;
}
sendEvent(event: string, payload?: unknown) {
const frame: BridgeEventFrame = {
type: "event",
event,
payloadJSON: payload ? JSON.stringify(payload) : null,
};
this.send(frame);
}
sendInvokeResponse(frame: BridgeInvokeResponseFrame) {
this.send(frame);
}
private sendHello() {
const hello: BridgeHelloFrame = {
type: "hello",
nodeId: this.opts.nodeId,
token: this.opts.token,
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
coreVersion: this.opts.coreVersion,
uiVersion: this.opts.uiVersion,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
commands: this.opts.commands,
permissions: this.opts.permissions,
};
this.send(hello);
}
private sendPairRequest() {
const req: BridgePairRequestFrame = {
type: "pair-request",
nodeId: this.opts.nodeId,
displayName: this.opts.displayName,
platform: this.opts.platform,
version: this.opts.version,
coreVersion: this.opts.coreVersion,
uiVersion: this.opts.uiVersion,
deviceFamily: this.opts.deviceFamily,
modelIdentifier: this.opts.modelIdentifier,
caps: this.opts.caps,
commands: this.opts.commands,
permissions: this.opts.permissions,
};
this.send(req);
}
private send(frame: object) {
if (!this.socket) return;
this.socket.write(`${JSON.stringify(frame)}\n`);
}
private handleDisconnect(err?: Error) {
if (!this.connected && this.helloReject) {
this.helloReject(err ?? new Error("bridge connection failed"));
this.helloResolve = null;
this.helloReject = null;
}
if (!this.connected && !this.socket) return;
this.connected = false;
this.socket = null;
this.pendingRpc.forEach((pending) => {
if (pending.timer) clearTimeout(pending.timer);
pending.reject(err ?? new Error("bridge connection closed"));
});
this.pendingRpc.clear();
void this.opts.onDisconnected?.(err);
}
private flush() {
while (true) {
const idx = this.buffer.indexOf("\n");
if (idx === -1) break;
const line = this.buffer.slice(0, idx).trim();
this.buffer = this.buffer.slice(idx + 1);
if (!line) continue;
let frame: { type?: string; [key: string]: unknown };
try {
frame = JSON.parse(line) as { type?: string };
} catch {
continue;
}
this.handleFrame(frame as BridgeErrorFrame);
}
}
private handleFrame(frame: { type?: string; [key: string]: unknown }) {
const type = String(frame.type ?? "");
switch (type) {
case "hello-ok": {
this.connected = true;
this.helloResolve?.();
this.helloResolve = null;
this.helloReject = null;
void this.opts.onConnected?.(frame as BridgeHelloOkFrame);
return;
}
case "pair-ok": {
const token = String((frame as BridgePairOkFrame).token ?? "").trim();
if (token) {
this.opts.token = token;
void this.opts.onPairToken?.(token);
}
return;
}
case "error": {
const code = String((frame as BridgeErrorFrame).code ?? "");
if (code === "NOT_PAIRED" || code === "UNAUTHORIZED") {
this.opts.token = undefined;
void this.opts.onAuthReset?.();
this.sendPairRequest();
return;
}
this.handleDisconnect(new Error((frame as BridgeErrorFrame).message ?? "bridge error"));
return;
}
case "pong":
return;
case "ping": {
const ping = frame as BridgePingFrame;
const pong: BridgePongFrame = { type: "pong", id: String(ping.id ?? "") };
this.send(pong);
return;
}
case "event": {
void this.opts.onEvent?.(frame as BridgeEventFrame);
return;
}
case "res": {
const res = frame as BridgeRPCResponseFrame;
const pending = this.pendingRpc.get(res.id);
if (pending) {
if (pending.timer) clearTimeout(pending.timer);
this.pendingRpc.delete(res.id);
pending.resolve(res);
}
return;
}
case "invoke": {
void this.opts.onInvoke?.(frame as BridgeInvokeRequestFrame);
return;
}
case "invoke-res": {
return;
}
default:
return;
}
}
}

View File

@@ -4,13 +4,11 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
import {
addAllowlistEntry,
matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse,
requestExecApprovalViaSocket,
resolveCommandResolution,
resolveExecApprovals,
ensureExecApprovals,
@@ -26,10 +24,16 @@ import {
type ExecHostRunResult,
} from "../infra/exec-host.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadConfig } from "../config/config.js";
import { VERSION } from "../version.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { BridgeClient } from "./bridge-client.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
import { GatewayClient } from "../gateway/client.js";
type NodeHostRunOptions = {
gatewayHost: string;
@@ -49,6 +53,7 @@ type SystemRunParams = {
needsScreenRecording?: boolean | null;
agentId?: string | null;
sessionKey?: string | null;
approved?: boolean | null;
};
type SystemWhichParams = {
@@ -89,6 +94,15 @@ type ExecEventPayload = {
reason?: string;
};
type NodeInvokeRequestPayload = {
id: string;
nodeId: string;
command: string;
paramsJSON?: string | null;
timeoutMs?: number | null;
idempotencyKey?: string | null;
};
const OUTPUT_CAP = 200_000;
const OUTPUT_EVENT_TAIL = 20_000;
@@ -331,7 +345,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const nodeId = opts.nodeId?.trim() || config.nodeId;
if (nodeId !== config.nodeId) {
config.nodeId = nodeId;
config.token = undefined;
}
const displayName =
opts.displayName?.trim() || config.displayName || (await getMachineDisplayName());
@@ -339,37 +352,38 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
const gateway: NodeHostGatewayConfig = {
host: opts.gatewayHost,
port: opts.gatewayPort,
tls: opts.gatewayTls === true,
tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false,
tlsFingerprint: opts.gatewayTlsFingerprint,
};
config.gateway = gateway;
await saveNodeHostConfig(config);
let disconnectResolve: (() => void) | null = null;
let disconnectSignal = false;
const waitForDisconnect = () =>
new Promise<void>((resolve) => {
if (disconnectSignal) {
disconnectSignal = false;
resolve();
return;
}
disconnectResolve = resolve;
});
const cfg = loadConfig();
const isRemoteMode = cfg.gateway?.mode === "remote";
const token =
process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
(isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token);
const password =
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(isRemoteMode ? cfg.gateway?.remote?.password : cfg.gateway?.auth?.password);
const client = new BridgeClient({
host: gateway.host ?? "127.0.0.1",
port: gateway.port ?? 18790,
tls: gateway.tls,
tlsFingerprint: gateway.tlsFingerprint,
nodeId,
token: config.token,
displayName,
const host = gateway.host ?? "127.0.0.1";
const port = gateway.port ?? 18789;
const scheme = gateway.tls ? "wss" : "ws";
const url = `${scheme}://${host}:${port}`;
const client = new GatewayClient({
url,
token: token?.trim() || undefined,
password: password?.trim() || undefined,
instanceId: nodeId,
clientName: GATEWAY_CLIENT_NAMES.NODE_HOST,
clientDisplayName: displayName,
clientVersion: VERSION,
platform: process.platform,
version: VERSION,
coreVersion: VERSION,
deviceFamily: os.platform(),
modelIdentifier: os.hostname(),
mode: GATEWAY_CLIENT_MODES.NODE,
role: "node",
scopes: [],
caps: ["system"],
commands: [
"system.run",
@@ -377,25 +391,23 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
"system.execApprovals.get",
"system.execApprovals.set",
],
onPairToken: async (token) => {
config.token = token;
await saveNodeHostConfig(config);
permissions: undefined,
deviceIdentity: loadOrCreateDeviceIdentity(),
tlsFingerprint: gateway.tlsFingerprint,
onEvent: (evt) => {
if (evt.event !== "node.invoke.request") return;
const payload = coerceNodeInvokePayload(evt.payload);
if (!payload) return;
void handleInvoke(payload, client, skillBins);
},
onAuthReset: async () => {
if (!config.token) return;
config.token = undefined;
await saveNodeHostConfig(config);
onConnectError: (err) => {
// keep retrying (handled by GatewayClient)
// eslint-disable-next-line no-console
console.error(`node host gateway connect failed: ${err.message}`);
},
onInvoke: async (frame) => {
await handleInvoke(frame, client, skillBins);
},
onDisconnected: () => {
if (disconnectResolve) {
disconnectResolve();
disconnectResolve = null;
} else {
disconnectSignal = true;
}
onClose: (code, reason) => {
// eslint-disable-next-line no-console
console.error(`node host gateway closed (${code}): ${reason}`);
},
});
@@ -408,20 +420,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
return bins;
});
while (true) {
try {
await client.connect();
await waitForDisconnect();
} catch {
// ignore connect errors; retry
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
client.start();
await new Promise(() => {});
}
async function handleInvoke(
frame: BridgeInvokeRequestFrame,
client: BridgeClient,
frame: NodeInvokeRequestPayload,
client: GatewayClient,
skillBins: SkillBinsCache,
) {
const command = String(frame.command ?? "");
@@ -435,16 +440,12 @@ async function handleInvoke(
hash: snapshot.hash,
file: redactExecApprovals(snapshot.file),
};
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -482,16 +483,12 @@ async function handleInvoke(
hash: nextSnapshot.hash,
file: redactExecApprovals(nextSnapshot.file),
};
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -507,16 +504,12 @@ async function handleInvoke(
}
const env = sanitizeEnv(undefined);
const payload = await handleSystemWhich(params, env);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -525,9 +518,7 @@ async function handleInvoke(
}
if (command !== "system.run") {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "command not supported" },
});
@@ -538,9 +529,7 @@ async function handleInvoke(
try {
params = decodeParams<SystemRunParams>(frame.paramsJSON);
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
@@ -548,9 +537,7 @@ async function handleInvoke(
}
if (!Array.isArray(params.command) || params.command.length === 0) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "INVALID_REQUEST", message: "command required" },
});
@@ -564,7 +551,6 @@ async function handleInvoke(
const approvals = resolveExecApprovals(agentId);
const security = approvals.agent.security;
const ask = approvals.agent.ask;
const askFallback = approvals.agent.askFallback;
const autoAllowSkills = approvals.agent.autoAllowSkills;
const sessionKey = params.sessionKey?.trim() || "node";
const runId = crypto.randomUUID();
@@ -591,7 +577,8 @@ async function handleInvoke(
};
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
if (!response) {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -601,9 +588,7 @@ async function handleInvoke(
reason: "companion-unavailable",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: {
code: "UNAVAILABLE",
@@ -615,7 +600,8 @@ async function handleInvoke(
if (!response.ok) {
const reason = response.error.reason ?? "approval-required";
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -625,9 +611,7 @@ async function handleInvoke(
reason,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: response.error.message },
});
@@ -636,7 +620,8 @@ async function handleInvoke(
const result: ExecHostRunResult = response.payload;
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
@@ -649,9 +634,7 @@ async function handleInvoke(
output: combined,
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify(result),
});
@@ -659,7 +642,8 @@ async function handleInvoke(
}
if (security === "deny") {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -669,9 +653,7 @@ async function handleInvoke(
reason: "security=deny",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
});
@@ -682,99 +664,33 @@ async function handleInvoke(
ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
let approvedByAsk = false;
if (requiresAsk) {
const decision = await requestExecApprovalViaSocket({
socketPath: approvals.socketPath,
token: approvals.token,
request: {
command: cmdText,
cwd: params.cwd ?? undefined,
const approvedByAsk = params.approved === true;
if (requiresAsk && !approvedByAsk) {
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
security,
ask,
agentId,
resolvedPath: resolution?.resolvedPath ?? null,
},
command: cmdText,
reason: "approval-required",
}),
);
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
if (decision === "deny") {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "user-denied",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied" },
});
return;
}
if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (allowlistMatch || skillAllow) {
approvedByAsk = true;
} else {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "approval-required",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
return;
}
} else {
client.sendEvent(
"exec.denied",
buildExecEventPayload({
sessionKey,
runId,
host: "node",
command: cmdText,
reason: "approval-required",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
});
return;
}
}
if (decision === "allow-once") {
approvedByAsk = true;
}
if (decision === "allow-always") {
approvedByAsk = true;
if (security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
}
return;
}
if (approvedByAsk && security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
}
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -784,9 +700,7 @@ async function handleInvoke(
reason: "allowlist-miss",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
});
@@ -798,7 +712,8 @@ async function handleInvoke(
}
if (params.needsScreenRecording === true) {
client.sendEvent(
await sendNodeEvent(
client,
"exec.denied",
buildExecEventPayload({
sessionKey,
@@ -808,16 +723,15 @@ async function handleInvoke(
reason: "permission:screenRecording",
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: false,
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
});
return;
}
client.sendEvent(
await sendNodeEvent(
client,
"exec.started",
buildExecEventPayload({
sessionKey,
@@ -842,7 +756,8 @@ async function handleInvoke(
}
}
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
client.sendEvent(
await sendNodeEvent(
client,
"exec.finished",
buildExecEventPayload({
sessionKey,
@@ -856,9 +771,7 @@ async function handleInvoke(
}),
);
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
await sendInvokeResult(client, frame, {
ok: true,
payloadJSON: JSON.stringify({
exitCode: result.exitCode,
@@ -877,3 +790,68 @@ function decodeParams<T>(raw?: string | null): T {
}
return JSON.parse(raw) as T;
}
function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null {
if (!payload || typeof payload !== "object") return null;
const obj = payload as Record<string, unknown>;
const id = typeof obj.id === "string" ? obj.id.trim() : "";
const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : "";
const command = typeof obj.command === "string" ? obj.command.trim() : "";
if (!id || !nodeId || !command) return null;
const paramsJSON =
typeof obj.paramsJSON === "string"
? obj.paramsJSON
: obj.params !== undefined
? JSON.stringify(obj.params)
: null;
const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null;
const idempotencyKey =
typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null;
return {
id,
nodeId,
command,
paramsJSON,
timeoutMs,
idempotencyKey,
};
}
async function sendInvokeResult(
client: GatewayClient,
frame: NodeInvokeRequestPayload,
result: {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
},
) {
try {
await client.request("node.invoke.result", {
id: frame.id,
nodeId: frame.nodeId,
ok: result.ok,
payload: result.payload,
payloadJSON: result.payloadJSON ?? null,
error: result.error ?? null,
});
} catch {
// ignore: node invoke responses are best-effort
}
}
async function sendNodeEvent(
client: GatewayClient,
event: string,
payload: unknown,
) {
try {
await client.request("node.event", {
event,
payloadJSON: payload ? JSON.stringify(payload) : null,
});
} catch {
// ignore: node events are best-effort
}
}