import { randomUUID } from "node:crypto"; import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig, resolveConfigPath, resolveGatewayPort, resolveStateDir, } from "../config/config.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { GatewayClient } from "./client.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; export type CallGatewayOptions = { url?: string; token?: string; password?: string; method: string; params?: unknown; expectFinal?: boolean; timeoutMs?: number; clientName?: string; clientVersion?: string; platform?: string; mode?: string; instanceId?: string; minProtocol?: number; maxProtocol?: number; /** * Overrides the config path shown in connection error details. * Does not affect config loading; callers still control auth via opts.token/password/env/config. */ configPath?: string; }; export type GatewayConnectionDetails = { url: string; urlSource: string; bindDetail?: string; remoteFallbackNote?: string; message: string; }; export function buildGatewayConnectionDetails( options: { config?: ClawdbotConfig; url?: string; configPath?: string } = {}, ): GatewayConnectionDetails { const config = options.config ?? loadConfig(); const configPath = options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); const isRemoteMode = config.gateway?.mode === "remote"; const remote = isRemoteMode ? config.gateway?.remote : undefined; const localPort = resolveGatewayPort(config); const tailnetIPv4 = pickPrimaryTailnetIPv4(); const bindMode = config.gateway?.bind ?? "loopback"; const preferTailnet = bindMode === "tailnet" || (bindMode === "auto" && !!tailnetIPv4); const localUrl = preferTailnet && tailnetIPv4 ? `ws://${tailnetIPv4}:${localPort}` : `ws://127.0.0.1:${localPort}`; const urlOverride = typeof options.url === "string" && options.url.trim().length > 0 ? options.url.trim() : undefined; const remoteUrl = typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; const url = urlOverride || remoteUrl || localUrl; const urlSource = urlOverride ? "cli --url" : remoteUrl ? "config gateway.remote.url" : preferTailnet && tailnetIPv4 ? `local tailnet ${tailnetIPv4}` : "local loopback"; const remoteFallbackNote = isRemoteMode && !urlOverride && !remoteUrl ? "Note: gateway.mode=remote but gateway.remote.url is missing; using local URL." : undefined; const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined; const message = [ `Gateway target: ${url}`, `Source: ${urlSource}`, `Config: ${configPath}`, bindDetail, remoteFallbackNote, ] .filter(Boolean) .join("\n"); return { url, urlSource, bindDetail, remoteFallbackNote, message, }; } export async function callGateway( opts: CallGatewayOptions, ): Promise { const timeoutMs = opts.timeoutMs ?? 10_000; const config = loadConfig(); const isRemoteMode = config.gateway?.mode === "remote"; const remote = isRemoteMode ? config.gateway?.remote : undefined; const authToken = config.gateway?.auth?.token; const authPassword = config.gateway?.auth?.password; const connectionDetails = buildGatewayConnectionDetails({ config, url: opts.url, ...(opts.configPath ? { configPath: opts.configPath } : {}), }); const url = connectionDetails.url; const token = (typeof opts.token === "string" && opts.token.trim().length > 0 ? opts.token.trim() : undefined) || (isRemoteMode ? typeof remote?.token === "string" && remote.token.trim().length > 0 ? remote.token.trim() : undefined : process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || (typeof authToken === "string" && authToken.trim().length > 0 ? authToken.trim() : undefined)); const password = (typeof opts.password === "string" && opts.password.trim().length > 0 ? opts.password.trim() : undefined) || process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || (isRemoteMode ? typeof remote?.password === "string" && remote.password.trim().length > 0 ? remote.password.trim() : undefined : typeof authPassword === "string" && authPassword.trim().length > 0 ? authPassword.trim() : undefined); const formatCloseError = (code: number, reason: string) => { const reasonText = reason?.trim() || "no close reason"; const hint = code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : ""; const suffix = hint ? ` ${hint}` : ""; return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`; }; const formatTimeoutError = () => `gateway timeout after ${timeoutMs}ms\n${connectionDetails.message}`; return await new Promise((resolve, reject) => { let settled = false; let ignoreClose = false; const stop = (err?: Error, value?: T) => { if (settled) return; settled = true; clearTimeout(timer); if (err) reject(err); else resolve(value as T); }; const client = new GatewayClient({ url, token, password, instanceId: opts.instanceId ?? randomUUID(), clientName: opts.clientName ?? "cli", clientVersion: opts.clientVersion ?? "dev", platform: opts.platform, mode: opts.mode ?? "cli", minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async () => { try { const result = await client.request(opts.method, opts.params, { expectFinal: opts.expectFinal, }); ignoreClose = true; stop(undefined, result); client.stop(); } catch (err) { ignoreClose = true; client.stop(); stop(err as Error); } }, onClose: (code, reason) => { if (settled || ignoreClose) return; ignoreClose = true; client.stop(); stop(new Error(formatCloseError(code, reason))); }, }); const timer = setTimeout(() => { ignoreClose = true; client.stop(); stop(new Error(formatTimeoutError())); }, timeoutMs); client.start(); }); } export function randomIdempotencyKey() { return randomUUID(); }