mirror of
https://github.com/Pythagora-io/gpt-pilot.git
synced 2026-05-09 03:00:20 -04:00
* fix(browser): prevent permanent timeout after stuck evaluate Thread AbortSignal from client-fetch through dispatcher to Playwright operations. When a timeout fires, force-disconnect the Playwright CDP connection to unblock the serialized command queue, allowing the next call to reconnect transparently. Key changes: - client-fetch.ts: proper AbortController with signal propagation - pw-session.ts: new forceDisconnectPlaywrightForTarget() - pw-tools-core.interactions.ts: accept signal, align inner timeout to outer-500ms, inject in-browser Promise.race for async evaluates - routes/dispatcher.ts + types.ts: propagate signal through dispatch - server.ts + bridge-server.ts: Express middleware creates AbortSignal from request lifecycle - client-actions-core.ts: add timeoutMs to evaluate type Fixes #10994 * fix(browser): v2 - force-disconnect via Connection.close() instead of browser.close() When page.evaluate() is stuck on a hung CDP transport, browser.close() also hangs because it tries to send a close command through the same stuck pipe. v2 fix: forceDisconnectPlaywrightForTarget now directly calls Playwright's internal Connection.close() which locally rejects all pending callbacks and emits 'disconnected' without touching the network. This instantly unblocks all stuck Playwright operations. closePlaywrightBrowserConnection (clean shutdown) now also has a 3s timeout fallback that drops to forceDropConnection if browser.close() hangs. Fixes permanent browser timeout after stuck evaluate. * fix(browser): v3 - fire-and-forget browser.close() instead of Connection.close() v2's forceDropConnection called browser._connection.close() which corrupts the entire Playwright instance because Connection is shared across all objects (BrowserType, Browser, Page, etc.). This prevented reconnection with cascading 'connectOverCDP: Force-disconnected' errors. v3 fix: forceDisconnectPlaywrightForTarget now: 1. Nulls cached connection immediately 2. Fire-and-forgets browser.close() (doesn't await — it may hang) 3. Next connectBrowser() creates a fresh connectOverCDP WebSocket Each connectOverCDP creates an independent WebSocket to the CDP endpoint, so the new connection is unaffected by the old one's pending close. The old browser.close() eventually resolves when the in-browser evaluate timeout fires, or the old connection gets GC'd. * fix(browser): v4 - clear connecting state and remove stale disconnect listeners The reconnect was failing because: 1. forceDisconnectPlaywrightForTarget nulled cached but not connecting, so subsequent calls could await a stale promise 2. The old browser's 'disconnected' event handler raced with new connections, nulling the fresh cached reference Fix: null both cached and connecting, and removeAllListeners on the old browser before fire-and-forget close. * fix(browser): v5 - use raw CDP Runtime.terminateExecution to kill stuck evaluate When forceDisconnectPlaywrightForTarget fires, open a raw WebSocket to the stuck page's CDP endpoint and send Runtime.terminateExecution. This kills running JS without navigating away or crashing the page. Also clear connecting state and remove stale disconnect listeners. * fix(browser): abort cancels stuck evaluate * Browser: always cleanup evaluate abort listener * Chore: remove Playwright debug scripts * Docs: add CDP evaluate refactor plan * Browser: refactor Playwright force-disconnect * Browser: abort stops evaluate promptly * Node host: extract withTimeout helper * Browser: remove disconnected listener safely * Changelog: note act:evaluate hang fix --------- Co-authored-by: Bob <bob@dutifulbob.com>
1289 lines
37 KiB
TypeScript
1289 lines
37 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import fsPromises from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
|
import { resolveBrowserConfig } from "../browser/config.js";
|
|
import {
|
|
createBrowserControlContext,
|
|
startBrowserControlServiceFromConfig,
|
|
} from "../browser/control-service.js";
|
|
import { createBrowserRouteDispatcher } from "../browser/routes/dispatcher.js";
|
|
import { loadConfig } from "../config/config.js";
|
|
import { GatewayClient } from "../gateway/client.js";
|
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
|
import {
|
|
addAllowlistEntry,
|
|
analyzeArgvCommand,
|
|
evaluateExecAllowlist,
|
|
evaluateShellAllowlist,
|
|
requiresExecApproval,
|
|
normalizeExecApprovals,
|
|
recordAllowlistUse,
|
|
resolveExecApprovals,
|
|
resolveSafeBins,
|
|
ensureExecApprovals,
|
|
readExecApprovalsSnapshot,
|
|
resolveExecApprovalsSocketPath,
|
|
saveExecApprovals,
|
|
type ExecAsk,
|
|
type ExecSecurity,
|
|
type ExecApprovalsFile,
|
|
type ExecAllowlistEntry,
|
|
type ExecCommandSegment,
|
|
} from "../infra/exec-approvals.js";
|
|
import {
|
|
requestExecHostViaSocket,
|
|
type ExecHostRequest,
|
|
type ExecHostResponse,
|
|
type ExecHostRunResult,
|
|
} from "../infra/exec-host.js";
|
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
|
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
|
import { detectMime } from "../media/mime.js";
|
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
|
import { VERSION } from "../version.js";
|
|
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
|
import { withTimeout } from "./with-timeout.js";
|
|
|
|
type NodeHostRunOptions = {
|
|
gatewayHost: string;
|
|
gatewayPort: number;
|
|
gatewayTls?: boolean;
|
|
gatewayTlsFingerprint?: string;
|
|
nodeId?: string;
|
|
displayName?: string;
|
|
};
|
|
|
|
type SystemRunParams = {
|
|
command: string[];
|
|
rawCommand?: string | null;
|
|
cwd?: string | null;
|
|
env?: Record<string, string>;
|
|
timeoutMs?: number | null;
|
|
needsScreenRecording?: boolean | null;
|
|
agentId?: string | null;
|
|
sessionKey?: string | null;
|
|
approved?: boolean | null;
|
|
approvalDecision?: string | null;
|
|
runId?: string | null;
|
|
};
|
|
|
|
type SystemWhichParams = {
|
|
bins: string[];
|
|
};
|
|
|
|
type BrowserProxyParams = {
|
|
method?: string;
|
|
path?: string;
|
|
query?: Record<string, string | number | boolean | null | undefined>;
|
|
body?: unknown;
|
|
timeoutMs?: number;
|
|
profile?: string;
|
|
};
|
|
|
|
type BrowserProxyFile = {
|
|
path: string;
|
|
base64: string;
|
|
mimeType?: string;
|
|
};
|
|
|
|
type BrowserProxyResult = {
|
|
result: unknown;
|
|
files?: BrowserProxyFile[];
|
|
};
|
|
|
|
type SystemExecApprovalsSetParams = {
|
|
file: ExecApprovalsFile;
|
|
baseHash?: string | null;
|
|
};
|
|
|
|
type ExecApprovalsSnapshot = {
|
|
path: string;
|
|
exists: boolean;
|
|
hash: string;
|
|
file: ExecApprovalsFile;
|
|
};
|
|
|
|
type RunResult = {
|
|
exitCode?: number;
|
|
timedOut: boolean;
|
|
success: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
error?: string | null;
|
|
truncated: boolean;
|
|
};
|
|
|
|
function resolveExecSecurity(value?: string): ExecSecurity {
|
|
return value === "deny" || value === "allowlist" || value === "full" ? value : "allowlist";
|
|
}
|
|
|
|
function isCmdExeInvocation(argv: string[]): boolean {
|
|
const token = argv[0]?.trim();
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
const base = path.win32.basename(token).toLowerCase();
|
|
return base === "cmd.exe" || base === "cmd";
|
|
}
|
|
|
|
function resolveExecAsk(value?: string): ExecAsk {
|
|
return value === "off" || value === "on-miss" || value === "always" ? value : "on-miss";
|
|
}
|
|
|
|
type ExecEventPayload = {
|
|
sessionKey: string;
|
|
runId: string;
|
|
host: string;
|
|
command?: string;
|
|
exitCode?: number;
|
|
timedOut?: boolean;
|
|
success?: boolean;
|
|
output?: string;
|
|
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;
|
|
const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
|
|
const execHostEnforced = process.env.OPENCLAW_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
|
|
const execHostFallbackAllowed =
|
|
process.env.OPENCLAW_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
|
|
|
const blockedEnvKeys = new Set([
|
|
"NODE_OPTIONS",
|
|
"PYTHONHOME",
|
|
"PYTHONPATH",
|
|
"PERL5LIB",
|
|
"PERL5OPT",
|
|
"RUBYOPT",
|
|
]);
|
|
|
|
const blockedEnvPrefixes = ["DYLD_", "LD_"];
|
|
|
|
class SkillBinsCache {
|
|
private bins = new Set<string>();
|
|
private lastRefresh = 0;
|
|
private readonly ttlMs = 90_000;
|
|
private readonly fetch: () => Promise<string[]>;
|
|
|
|
constructor(fetch: () => Promise<string[]>) {
|
|
this.fetch = fetch;
|
|
}
|
|
|
|
async current(force = false): Promise<Set<string>> {
|
|
if (force || Date.now() - this.lastRefresh > this.ttlMs) {
|
|
await this.refresh();
|
|
}
|
|
return this.bins;
|
|
}
|
|
|
|
private async refresh() {
|
|
try {
|
|
const bins = await this.fetch();
|
|
this.bins = new Set(bins);
|
|
this.lastRefresh = Date.now();
|
|
} catch {
|
|
if (!this.lastRefresh) {
|
|
this.bins = new Set();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function sanitizeEnv(
|
|
overrides?: Record<string, string> | null,
|
|
): Record<string, string> | undefined {
|
|
if (!overrides) {
|
|
return undefined;
|
|
}
|
|
const merged = { ...process.env } as Record<string, string>;
|
|
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
|
|
for (const [rawKey, value] of Object.entries(overrides)) {
|
|
const key = rawKey.trim();
|
|
if (!key) {
|
|
continue;
|
|
}
|
|
const upper = key.toUpperCase();
|
|
if (upper === "PATH") {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
if (!basePath || trimmed === basePath) {
|
|
merged[key] = trimmed;
|
|
continue;
|
|
}
|
|
const suffix = `${path.delimiter}${basePath}`;
|
|
if (trimmed.endsWith(suffix)) {
|
|
merged[key] = trimmed;
|
|
}
|
|
continue;
|
|
}
|
|
if (blockedEnvKeys.has(upper)) {
|
|
continue;
|
|
}
|
|
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) {
|
|
continue;
|
|
}
|
|
merged[key] = value;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function normalizeProfileAllowlist(raw?: string[]): string[] {
|
|
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
|
|
}
|
|
|
|
function resolveBrowserProxyConfig() {
|
|
const cfg = loadConfig();
|
|
const proxy = cfg.nodeHost?.browserProxy;
|
|
const allowProfiles = normalizeProfileAllowlist(proxy?.allowProfiles);
|
|
const enabled = proxy?.enabled !== false;
|
|
return { enabled, allowProfiles };
|
|
}
|
|
|
|
let browserControlReady: Promise<void> | null = null;
|
|
|
|
async function ensureBrowserControlService(): Promise<void> {
|
|
if (browserControlReady) {
|
|
return browserControlReady;
|
|
}
|
|
browserControlReady = (async () => {
|
|
const cfg = loadConfig();
|
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
|
if (!resolved.enabled) {
|
|
throw new Error("browser control disabled");
|
|
}
|
|
const started = await startBrowserControlServiceFromConfig();
|
|
if (!started) {
|
|
throw new Error("browser control disabled");
|
|
}
|
|
})();
|
|
return browserControlReady;
|
|
}
|
|
|
|
function isProfileAllowed(params: { allowProfiles: string[]; profile?: string | null }) {
|
|
const { allowProfiles, profile } = params;
|
|
if (!allowProfiles.length) {
|
|
return true;
|
|
}
|
|
if (!profile) {
|
|
return false;
|
|
}
|
|
return allowProfiles.includes(profile.trim());
|
|
}
|
|
|
|
function collectBrowserProxyPaths(payload: unknown): string[] {
|
|
const paths = new Set<string>();
|
|
const obj =
|
|
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : null;
|
|
if (!obj) {
|
|
return [];
|
|
}
|
|
if (typeof obj.path === "string" && obj.path.trim()) {
|
|
paths.add(obj.path.trim());
|
|
}
|
|
if (typeof obj.imagePath === "string" && obj.imagePath.trim()) {
|
|
paths.add(obj.imagePath.trim());
|
|
}
|
|
const download = obj.download;
|
|
if (download && typeof download === "object") {
|
|
const dlPath = (download as Record<string, unknown>).path;
|
|
if (typeof dlPath === "string" && dlPath.trim()) {
|
|
paths.add(dlPath.trim());
|
|
}
|
|
}
|
|
return [...paths];
|
|
}
|
|
|
|
async function readBrowserProxyFile(filePath: string): Promise<BrowserProxyFile | null> {
|
|
const stat = await fsPromises.stat(filePath).catch(() => null);
|
|
if (!stat || !stat.isFile()) {
|
|
return null;
|
|
}
|
|
if (stat.size > BROWSER_PROXY_MAX_FILE_BYTES) {
|
|
throw new Error(
|
|
`browser proxy file exceeds ${Math.round(BROWSER_PROXY_MAX_FILE_BYTES / (1024 * 1024))}MB`,
|
|
);
|
|
}
|
|
const buffer = await fsPromises.readFile(filePath);
|
|
const mimeType = await detectMime({ buffer, filePath });
|
|
return { path: filePath, base64: buffer.toString("base64"), mimeType };
|
|
}
|
|
|
|
function formatCommand(argv: string[]): string {
|
|
return argv
|
|
.map((arg) => {
|
|
const trimmed = arg.trim();
|
|
if (!trimmed) {
|
|
return '""';
|
|
}
|
|
const needsQuotes = /\s|"/.test(trimmed);
|
|
if (!needsQuotes) {
|
|
return trimmed;
|
|
}
|
|
return `"${trimmed.replace(/"/g, '\\"')}"`;
|
|
})
|
|
.join(" ");
|
|
}
|
|
|
|
function truncateOutput(raw: string, maxChars: number): { text: string; truncated: boolean } {
|
|
if (raw.length <= maxChars) {
|
|
return { text: raw, truncated: false };
|
|
}
|
|
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
|
|
}
|
|
|
|
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
|
const socketPath = file.socket?.path?.trim();
|
|
return {
|
|
...file,
|
|
socket: socketPath ? { path: socketPath } : undefined,
|
|
};
|
|
}
|
|
|
|
function requireExecApprovalsBaseHash(
|
|
params: SystemExecApprovalsSetParams,
|
|
snapshot: ExecApprovalsSnapshot,
|
|
) {
|
|
if (!snapshot.exists) {
|
|
return;
|
|
}
|
|
if (!snapshot.hash) {
|
|
throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry");
|
|
}
|
|
const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : "";
|
|
if (!baseHash) {
|
|
throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry");
|
|
}
|
|
if (baseHash !== snapshot.hash) {
|
|
throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry");
|
|
}
|
|
}
|
|
|
|
async function runCommand(
|
|
argv: string[],
|
|
cwd: string | undefined,
|
|
env: Record<string, string> | undefined,
|
|
timeoutMs: number | undefined,
|
|
): Promise<RunResult> {
|
|
return await new Promise((resolve) => {
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let outputLen = 0;
|
|
let truncated = false;
|
|
let timedOut = false;
|
|
let settled = false;
|
|
|
|
const child = spawn(argv[0], argv.slice(1), {
|
|
cwd,
|
|
env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
|
|
const onChunk = (chunk: Buffer, target: "stdout" | "stderr") => {
|
|
if (outputLen >= OUTPUT_CAP) {
|
|
truncated = true;
|
|
return;
|
|
}
|
|
const remaining = OUTPUT_CAP - outputLen;
|
|
const slice = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
const str = slice.toString("utf8");
|
|
outputLen += slice.length;
|
|
if (target === "stdout") {
|
|
stdout += str;
|
|
} else {
|
|
stderr += str;
|
|
}
|
|
if (chunk.length > remaining) {
|
|
truncated = true;
|
|
}
|
|
};
|
|
|
|
child.stdout?.on("data", (chunk) => onChunk(chunk as Buffer, "stdout"));
|
|
child.stderr?.on("data", (chunk) => onChunk(chunk as Buffer, "stderr"));
|
|
|
|
let timer: NodeJS.Timeout | undefined;
|
|
if (timeoutMs && timeoutMs > 0) {
|
|
timer = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, timeoutMs);
|
|
}
|
|
|
|
const finalize = (exitCode?: number, error?: string | null) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
resolve({
|
|
exitCode,
|
|
timedOut,
|
|
success: exitCode === 0 && !timedOut && !error,
|
|
stdout,
|
|
stderr,
|
|
error: error ?? null,
|
|
truncated,
|
|
});
|
|
};
|
|
|
|
child.on("error", (err) => {
|
|
finalize(undefined, err.message);
|
|
});
|
|
child.on("exit", (code) => {
|
|
finalize(code === null ? undefined : code, null);
|
|
});
|
|
});
|
|
}
|
|
|
|
function resolveEnvPath(env?: Record<string, string>): string[] {
|
|
const raw =
|
|
env?.PATH ??
|
|
(env as Record<string, string>)?.Path ??
|
|
process.env.PATH ??
|
|
process.env.Path ??
|
|
DEFAULT_NODE_PATH;
|
|
return raw.split(path.delimiter).filter(Boolean);
|
|
}
|
|
|
|
function ensureNodePathEnv(): string {
|
|
ensureOpenClawCliOnPath({ pathEnv: process.env.PATH ?? "" });
|
|
const current = process.env.PATH ?? "";
|
|
if (current.trim()) {
|
|
return current;
|
|
}
|
|
process.env.PATH = DEFAULT_NODE_PATH;
|
|
return DEFAULT_NODE_PATH;
|
|
}
|
|
|
|
function resolveExecutable(bin: string, env?: Record<string, string>) {
|
|
if (bin.includes("/") || bin.includes("\\")) {
|
|
return null;
|
|
}
|
|
const extensions =
|
|
process.platform === "win32"
|
|
? (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM")
|
|
.split(";")
|
|
.map((ext) => ext.toLowerCase())
|
|
: [""];
|
|
for (const dir of resolveEnvPath(env)) {
|
|
for (const ext of extensions) {
|
|
const candidate = path.join(dir, bin + ext);
|
|
if (fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function handleSystemWhich(params: SystemWhichParams, env?: Record<string, string>) {
|
|
const bins = params.bins.map((bin) => bin.trim()).filter(Boolean);
|
|
const found: Record<string, string> = {};
|
|
for (const bin of bins) {
|
|
const path = resolveExecutable(bin, env);
|
|
if (path) {
|
|
found[bin] = path;
|
|
}
|
|
}
|
|
return { bins: found };
|
|
}
|
|
|
|
function buildExecEventPayload(payload: ExecEventPayload): ExecEventPayload {
|
|
if (!payload.output) {
|
|
return payload;
|
|
}
|
|
const trimmed = payload.output.trim();
|
|
if (!trimmed) {
|
|
return payload;
|
|
}
|
|
const { text } = truncateOutput(trimmed, OUTPUT_EVENT_TAIL);
|
|
return { ...payload, output: text };
|
|
}
|
|
|
|
async function runViaMacAppExecHost(params: {
|
|
approvals: ReturnType<typeof resolveExecApprovals>;
|
|
request: ExecHostRequest;
|
|
}): Promise<ExecHostResponse | null> {
|
|
const { approvals, request } = params;
|
|
return await requestExecHostViaSocket({
|
|
socketPath: approvals.socketPath,
|
|
token: approvals.token,
|
|
request,
|
|
});
|
|
}
|
|
|
|
export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|
const config = await ensureNodeHostConfig();
|
|
const nodeId = opts.nodeId?.trim() || config.nodeId;
|
|
if (nodeId !== config.nodeId) {
|
|
config.nodeId = nodeId;
|
|
}
|
|
const displayName =
|
|
opts.displayName?.trim() || config.displayName || (await getMachineDisplayName());
|
|
config.displayName = displayName;
|
|
const gateway: NodeHostGatewayConfig = {
|
|
host: opts.gatewayHost,
|
|
port: opts.gatewayPort,
|
|
tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false,
|
|
tlsFingerprint: opts.gatewayTlsFingerprint,
|
|
};
|
|
config.gateway = gateway;
|
|
await saveNodeHostConfig(config);
|
|
|
|
const cfg = loadConfig();
|
|
const browserProxy = resolveBrowserProxyConfig();
|
|
const resolvedBrowser = resolveBrowserConfig(cfg.browser, cfg);
|
|
const browserProxyEnabled = browserProxy.enabled && resolvedBrowser.enabled;
|
|
const isRemoteMode = cfg.gateway?.mode === "remote";
|
|
const token =
|
|
process.env.OPENCLAW_GATEWAY_TOKEN?.trim() ||
|
|
(isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token);
|
|
const password =
|
|
process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() ||
|
|
(isRemoteMode ? cfg.gateway?.remote?.password : cfg.gateway?.auth?.password);
|
|
|
|
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 pathEnv = ensureNodePathEnv();
|
|
// eslint-disable-next-line no-console
|
|
console.log(`node host PATH: ${pathEnv}`);
|
|
|
|
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,
|
|
mode: GATEWAY_CLIENT_MODES.NODE,
|
|
role: "node",
|
|
scopes: [],
|
|
caps: ["system", ...(browserProxyEnabled ? ["browser"] : [])],
|
|
commands: [
|
|
"system.run",
|
|
"system.which",
|
|
"system.execApprovals.get",
|
|
"system.execApprovals.set",
|
|
...(browserProxyEnabled ? ["browser.proxy"] : []),
|
|
],
|
|
pathEnv,
|
|
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);
|
|
},
|
|
onConnectError: (err) => {
|
|
// keep retrying (handled by GatewayClient)
|
|
// eslint-disable-next-line no-console
|
|
console.error(`node host gateway connect failed: ${err.message}`);
|
|
},
|
|
onClose: (code, reason) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`node host gateway closed (${code}): ${reason}`);
|
|
},
|
|
});
|
|
|
|
const skillBins = new SkillBinsCache(async () => {
|
|
const res = await client.request<{ bins: Array<unknown> }>("skills.bins", {});
|
|
const bins = Array.isArray(res?.bins) ? res.bins.map((bin) => String(bin)) : [];
|
|
return bins;
|
|
});
|
|
|
|
client.start();
|
|
await new Promise(() => {});
|
|
}
|
|
|
|
async function handleInvoke(
|
|
frame: NodeInvokeRequestPayload,
|
|
client: GatewayClient,
|
|
skillBins: SkillBinsCache,
|
|
) {
|
|
const command = String(frame.command ?? "");
|
|
if (command === "system.execApprovals.get") {
|
|
try {
|
|
ensureExecApprovals();
|
|
const snapshot = readExecApprovalsSnapshot();
|
|
const payload: ExecApprovalsSnapshot = {
|
|
path: snapshot.path,
|
|
exists: snapshot.exists,
|
|
hash: snapshot.hash,
|
|
file: redactExecApprovals(snapshot.file),
|
|
};
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
const message = String(err);
|
|
const code = message.toLowerCase().includes("timed out") ? "TIMEOUT" : "INVALID_REQUEST";
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code, message },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === "system.execApprovals.set") {
|
|
try {
|
|
const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON);
|
|
if (!params.file || typeof params.file !== "object") {
|
|
throw new Error("INVALID_REQUEST: exec approvals file required");
|
|
}
|
|
ensureExecApprovals();
|
|
const snapshot = readExecApprovalsSnapshot();
|
|
requireExecApprovalsBaseHash(params, snapshot);
|
|
const normalized = normalizeExecApprovals(params.file);
|
|
const currentSocketPath = snapshot.file.socket?.path?.trim();
|
|
const currentToken = snapshot.file.socket?.token?.trim();
|
|
const socketPath =
|
|
normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
|
|
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
|
|
const next: ExecApprovalsFile = {
|
|
...normalized,
|
|
socket: {
|
|
path: socketPath,
|
|
token,
|
|
},
|
|
};
|
|
saveExecApprovals(next);
|
|
const nextSnapshot = readExecApprovalsSnapshot();
|
|
const payload: ExecApprovalsSnapshot = {
|
|
path: nextSnapshot.path,
|
|
exists: nextSnapshot.exists,
|
|
hash: nextSnapshot.hash,
|
|
file: redactExecApprovals(nextSnapshot.file),
|
|
};
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === "system.which") {
|
|
try {
|
|
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
|
|
if (!Array.isArray(params.bins)) {
|
|
throw new Error("INVALID_REQUEST: bins required");
|
|
}
|
|
const env = sanitizeEnv(undefined);
|
|
const payload = await handleSystemWhich(params, env);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command === "browser.proxy") {
|
|
try {
|
|
const params = decodeParams<BrowserProxyParams>(frame.paramsJSON);
|
|
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
|
|
if (!pathValue) {
|
|
throw new Error("INVALID_REQUEST: path required");
|
|
}
|
|
const proxyConfig = resolveBrowserProxyConfig();
|
|
if (!proxyConfig.enabled) {
|
|
throw new Error("UNAVAILABLE: node browser proxy disabled");
|
|
}
|
|
await ensureBrowserControlService();
|
|
const cfg = loadConfig();
|
|
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
|
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
|
|
const allowedProfiles = proxyConfig.allowProfiles;
|
|
if (allowedProfiles.length > 0) {
|
|
if (pathValue !== "/profiles") {
|
|
const profileToCheck = requestedProfile || resolved.defaultProfile;
|
|
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) {
|
|
throw new Error("INVALID_REQUEST: browser profile not allowed");
|
|
}
|
|
} else if (requestedProfile) {
|
|
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: requestedProfile })) {
|
|
throw new Error("INVALID_REQUEST: browser profile not allowed");
|
|
}
|
|
}
|
|
}
|
|
|
|
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
|
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
|
|
const body = params.body;
|
|
const query: Record<string, unknown> = {};
|
|
if (requestedProfile) {
|
|
query.profile = requestedProfile;
|
|
}
|
|
const rawQuery = params.query ?? {};
|
|
for (const [key, value] of Object.entries(rawQuery)) {
|
|
if (value === undefined || value === null) {
|
|
continue;
|
|
}
|
|
query[key] = typeof value === "string" ? value : String(value);
|
|
}
|
|
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
|
const response = await withTimeout(
|
|
(signal) =>
|
|
dispatcher.dispatch({
|
|
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
|
path,
|
|
query,
|
|
body,
|
|
signal,
|
|
}),
|
|
params.timeoutMs,
|
|
"browser proxy request",
|
|
);
|
|
if (response.status >= 400) {
|
|
const message =
|
|
response.body && typeof response.body === "object" && "error" in response.body
|
|
? String((response.body as { error?: unknown }).error)
|
|
: `HTTP ${response.status}`;
|
|
throw new Error(message);
|
|
}
|
|
const result = response.body;
|
|
if (allowedProfiles.length > 0 && path === "/profiles") {
|
|
const obj =
|
|
typeof result === "object" && result !== null ? (result as Record<string, unknown>) : {};
|
|
const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];
|
|
obj.profiles = profiles.filter((entry) => {
|
|
if (!entry || typeof entry !== "object") {
|
|
return false;
|
|
}
|
|
const name = (entry as Record<string, unknown>).name;
|
|
return typeof name === "string" && allowedProfiles.includes(name);
|
|
});
|
|
}
|
|
let files: BrowserProxyFile[] | undefined;
|
|
const paths = collectBrowserProxyPaths(result);
|
|
if (paths.length > 0) {
|
|
const loaded = await Promise.all(
|
|
paths.map(async (p) => {
|
|
try {
|
|
const file = await readBrowserProxyFile(p);
|
|
if (!file) {
|
|
throw new Error("file not found");
|
|
}
|
|
return file;
|
|
} catch (err) {
|
|
throw new Error(`browser proxy file read failed for ${p}: ${String(err)}`, {
|
|
cause: err,
|
|
});
|
|
}
|
|
}),
|
|
);
|
|
if (loaded.length > 0) {
|
|
files = loaded;
|
|
}
|
|
}
|
|
const payload: BrowserProxyResult = files ? { result, files } : { result };
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(payload),
|
|
});
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (command !== "system.run") {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "command not supported" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
let params: SystemRunParams;
|
|
try {
|
|
params = decodeParams<SystemRunParams>(frame.paramsJSON);
|
|
} catch (err) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(params.command) || params.command.length === 0) {
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "INVALID_REQUEST", message: "command required" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const argv = params.command.map((item) => String(item));
|
|
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
|
|
const cmdText = rawCommand || formatCommand(argv);
|
|
const agentId = params.agentId?.trim() || undefined;
|
|
const cfg = loadConfig();
|
|
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
|
const configuredSecurity = resolveExecSecurity(agentExec?.security ?? cfg.tools?.exec?.security);
|
|
const configuredAsk = resolveExecAsk(agentExec?.ask ?? cfg.tools?.exec?.ask);
|
|
const approvals = resolveExecApprovals(agentId, {
|
|
security: configuredSecurity,
|
|
ask: configuredAsk,
|
|
});
|
|
const security = approvals.agent.security;
|
|
const ask = approvals.agent.ask;
|
|
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
|
const sessionKey = params.sessionKey?.trim() || "node";
|
|
const runId = params.runId?.trim() || crypto.randomUUID();
|
|
const env = sanitizeEnv(params.env ?? undefined);
|
|
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
|
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
|
let analysisOk = false;
|
|
let allowlistMatches: ExecAllowlistEntry[] = [];
|
|
let allowlistSatisfied = false;
|
|
let segments: ExecCommandSegment[] = [];
|
|
if (rawCommand) {
|
|
const allowlistEval = evaluateShellAllowlist({
|
|
command: rawCommand,
|
|
allowlist: approvals.allowlist,
|
|
safeBins,
|
|
cwd: params.cwd ?? undefined,
|
|
env,
|
|
skillBins: bins,
|
|
autoAllowSkills,
|
|
platform: process.platform,
|
|
});
|
|
analysisOk = allowlistEval.analysisOk;
|
|
allowlistMatches = allowlistEval.allowlistMatches;
|
|
allowlistSatisfied =
|
|
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
|
segments = allowlistEval.segments;
|
|
} else {
|
|
const analysis = analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
|
|
const allowlistEval = evaluateExecAllowlist({
|
|
analysis,
|
|
allowlist: approvals.allowlist,
|
|
safeBins,
|
|
cwd: params.cwd ?? undefined,
|
|
skillBins: bins,
|
|
autoAllowSkills,
|
|
});
|
|
analysisOk = analysis.ok;
|
|
allowlistMatches = allowlistEval.allowlistMatches;
|
|
allowlistSatisfied =
|
|
security === "allowlist" && analysisOk ? allowlistEval.allowlistSatisfied : false;
|
|
segments = analysis.segments;
|
|
}
|
|
const isWindows = process.platform === "win32";
|
|
const cmdInvocation = rawCommand
|
|
? isCmdExeInvocation(segments[0]?.argv ?? [])
|
|
: isCmdExeInvocation(argv);
|
|
if (security === "allowlist" && isWindows && cmdInvocation) {
|
|
analysisOk = false;
|
|
allowlistSatisfied = false;
|
|
}
|
|
|
|
const useMacAppExec = process.platform === "darwin";
|
|
if (useMacAppExec) {
|
|
const approvalDecision =
|
|
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
|
? params.approvalDecision
|
|
: null;
|
|
const execRequest: ExecHostRequest = {
|
|
command: argv,
|
|
rawCommand: rawCommand || null,
|
|
cwd: params.cwd ?? null,
|
|
env: params.env ?? null,
|
|
timeoutMs: params.timeoutMs ?? null,
|
|
needsScreenRecording: params.needsScreenRecording ?? null,
|
|
agentId: agentId ?? null,
|
|
sessionKey: sessionKey ?? null,
|
|
approvalDecision,
|
|
};
|
|
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
|
|
if (!response) {
|
|
if (execHostEnforced || !execHostFallbackAllowed) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "companion-unavailable",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: {
|
|
code: "UNAVAILABLE",
|
|
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
} else if (!response.ok) {
|
|
const reason = response.error.reason ?? "approval-required";
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason,
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: response.error.message },
|
|
});
|
|
return;
|
|
} else {
|
|
const result: ExecHostRunResult = response.payload;
|
|
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.finished",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
output: combined,
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify(result),
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (security === "deny") {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "security=deny",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const requiresAsk = requiresExecApproval({
|
|
ask,
|
|
security,
|
|
analysisOk,
|
|
allowlistSatisfied,
|
|
});
|
|
|
|
const approvalDecision =
|
|
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
|
? params.approvalDecision
|
|
: null;
|
|
const approvedByAsk = approvalDecision !== null || params.approved === true;
|
|
if (requiresAsk && !approvedByAsk) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "approval-required",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" },
|
|
});
|
|
return;
|
|
}
|
|
if (approvalDecision === "allow-always" && security === "allowlist") {
|
|
if (analysisOk) {
|
|
for (const segment of segments) {
|
|
const pattern = segment.resolution?.resolvedPath ?? "";
|
|
if (pattern) {
|
|
addAllowlistEntry(approvals.file, agentId, pattern);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (security === "allowlist" && (!analysisOk || !allowlistSatisfied) && !approvedByAsk) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "allowlist-miss",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (allowlistMatches.length > 0) {
|
|
const seen = new Set<string>();
|
|
for (const match of allowlistMatches) {
|
|
if (!match?.pattern || seen.has(match.pattern)) {
|
|
continue;
|
|
}
|
|
seen.add(match.pattern);
|
|
recordAllowlistUse(
|
|
approvals.file,
|
|
agentId,
|
|
match,
|
|
cmdText,
|
|
segments[0]?.resolution?.resolvedPath,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (params.needsScreenRecording === true) {
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.denied",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
reason: "permission:screenRecording",
|
|
}),
|
|
);
|
|
await sendInvokeResult(client, frame, {
|
|
ok: false,
|
|
error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
let execArgv = argv;
|
|
if (
|
|
security === "allowlist" &&
|
|
isWindows &&
|
|
!approvedByAsk &&
|
|
rawCommand &&
|
|
analysisOk &&
|
|
allowlistSatisfied &&
|
|
segments.length === 1 &&
|
|
segments[0]?.argv.length > 0
|
|
) {
|
|
// Avoid cmd.exe in allowlist mode on Windows; run the parsed argv directly.
|
|
execArgv = segments[0].argv;
|
|
}
|
|
|
|
const result = await runCommand(
|
|
execArgv,
|
|
params.cwd?.trim() || undefined,
|
|
env,
|
|
params.timeoutMs ?? undefined,
|
|
);
|
|
if (result.truncated) {
|
|
const suffix = "... (truncated)";
|
|
if (result.stderr.trim().length > 0) {
|
|
result.stderr = `${result.stderr}\n${suffix}`;
|
|
} else {
|
|
result.stdout = `${result.stdout}\n${suffix}`;
|
|
}
|
|
}
|
|
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
|
await sendNodeEvent(
|
|
client,
|
|
"exec.finished",
|
|
buildExecEventPayload({
|
|
sessionKey,
|
|
runId,
|
|
host: "node",
|
|
command: cmdText,
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
output: combined,
|
|
}),
|
|
);
|
|
|
|
await sendInvokeResult(client, frame, {
|
|
ok: true,
|
|
payloadJSON: JSON.stringify({
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
success: result.success,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
error: result.error ?? null,
|
|
}),
|
|
});
|
|
}
|
|
|
|
function decodeParams<T>(raw?: string | null): T {
|
|
if (!raw) {
|
|
throw new Error("INVALID_REQUEST: paramsJSON required");
|
|
}
|
|
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", buildNodeInvokeResultParams(frame, result));
|
|
} catch {
|
|
// ignore: node invoke responses are best-effort
|
|
}
|
|
}
|
|
|
|
export function buildNodeInvokeResultParams(
|
|
frame: NodeInvokeRequestPayload,
|
|
result: {
|
|
ok: boolean;
|
|
payload?: unknown;
|
|
payloadJSON?: string | null;
|
|
error?: { code?: string; message?: string } | null;
|
|
},
|
|
): {
|
|
id: string;
|
|
nodeId: string;
|
|
ok: boolean;
|
|
payload?: unknown;
|
|
payloadJSON?: string;
|
|
error?: { code?: string; message?: string };
|
|
} {
|
|
const params: {
|
|
id: string;
|
|
nodeId: string;
|
|
ok: boolean;
|
|
payload?: unknown;
|
|
payloadJSON?: string;
|
|
error?: { code?: string; message?: string };
|
|
} = {
|
|
id: frame.id,
|
|
nodeId: frame.nodeId,
|
|
ok: result.ok,
|
|
};
|
|
if (result.payload !== undefined) {
|
|
params.payload = result.payload;
|
|
}
|
|
if (typeof result.payloadJSON === "string") {
|
|
params.payloadJSON = result.payloadJSON;
|
|
}
|
|
if (result.error) {
|
|
params.error = result.error;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|