UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

583 lines (582 loc) • 23 kB
import { l as normalizeOptionalString } from "./string-coerce-1VuiLaaR.mjs"; import { t as createDeferredCore } from "./deferred-D0La5CRk.mjs"; import { r as asNullableRecord } from "./record-coerce-TIsquoVR.mjs"; import { h as sleep } from "./utils-DgZn2dLu.mjs"; import { o as racePromiseWithAbortSignal } from "./src-D1BHGNTj.mjs"; import "./abort-signal-QqAmLWTl.mjs"; import { t as formatErrorMessage } from "./errors-O33D01XW.mjs"; import { t as VERSION } from "./version-BJGPJZu6.mjs"; import { M as parseAgentSessionKey } from "./session-key-C09ow4h0.mjs"; import { r as getRuntimeConfig } from "./io.runtime-BsUXSsD4.mjs"; import "./config-DLycWFvB.mjs"; import { s as readActiveGatewayLockPort } from "./gateway-lock-B6mKYs2x.mjs"; import { i as GATEWAY_CLIENT_NAMES, r as GATEWAY_CLIENT_MODES, t as GATEWAY_CLIENT_CAPS } from "./client-info-C8RatpKO.mjs"; import { a as roleScopesAllow } from "./operator-scope-compat-Ci6GBcmU.mjs"; import { t as GATEWAY_SERVER_CAPS } from "./server-capabilities-BTjjxElt.mjs"; import "./version-CwNT1gaY.mjs"; import { n as loadDeviceIdentityIfPresent } from "./device-identity-KmxGQO-v.mjs"; import { t as ConnectErrorDetailCodes, u as readConnectErrorDetailCode } from "./connect-error-details-q_NaMExM.mjs"; import { s as loadOriginDeviceToken } from "./device-auth-store-BEiXAvyd.mjs"; import { t as buildGatewayConnectionDetails } from "./call-B7pNY6_o.mjs"; import { t as startGatewayClientWhenEventLoopReady } from "./readiness-kkd804Uc.mjs"; import { r as resolveExplicitGatewayAuth } from "./credentials-lHQ7_oVm.mjs"; import { i as resolveGatewayDeviceAuthRoute } from "./connection-details-BcuL3xPn.mjs"; import { i as resolveGatewayUrlOverride, r as resolveGatewayClientBootstrap } from "./client-bootstrap-c5z3TAjg.mjs"; import { a as isRetryableGatewayStartupUnavailableError } from "./startup-unavailable-D0-EeFjq.mjs"; import { t as GatewayClient } from "./client-Bd9s4LXT.mjs"; import { t as GatewayClientRequestError } from "./request-error-Clu0Uyey.mjs"; import { n as normalizeEdgeAuthHeadersConfig, r as resolveEdgeAuthHeaders, t as gatewayEdgeAuthValueForTarget } from "./edge-auth-BDTIY9rT.mjs"; import { t as assertExplicitGatewayAuthModeWhenBothConfigured } from "./auth-mode-policy-DzNSJi11.mjs"; import { t as mergeChatStreamMessage } from "./chat-stream-message-Cxmp5iwS.mjs"; import { n as isListedTuiSession } from "./tui-session-list-policy-GTQ8VnY7.mjs"; import { randomUUID } from "node:crypto"; //#region packages/gateway-client/src/chat-stream-projection.ts /** Connection-owned chat baselines, independent of the selected local view. */ var GatewayChatStreamProjection = class { constructor() { this.runs = /* @__PURE__ */ new Map(); } project(event) { if (event.event !== "chat") return { event, missingBaseline: false }; const payload = asNullableRecord(event.payload); if (!payload || typeof payload.runId !== "string" || typeof payload.sessionKey !== "string") return { event, missingBaseline: false }; if (payload.state === "delta") { const previous = this.runs.get(payload.runId); const message = mergeChatStreamMessage(previous?.sessionKey === payload.sessionKey && previous.agentId === payload.agentId ? previous.message : void 0, payload); if (message !== void 0) this.runs.set(payload.runId, { sessionKey: payload.sessionKey, agentId: payload.agentId, message }); return { event: { ...event, payload: { ...payload, message } }, missingBaseline: message === void 0 }; } if (payload.state === "final" || payload.state === "error" || payload.state === "aborted") this.runs.delete(payload.runId); return { event, missingBaseline: false }; } retire(isRetired) { for (const [runId, scope] of this.runs) if (isRetired(scope)) this.runs.delete(runId); } clear() { this.runs.clear(); } }; //#endregion //#region src/tui/gateway-chat-models.ts function refreshTuiGatewayModelCatalog(params) { const { catalogs, client, agentId, published, onChanged } = params; let entry = catalogs.get(agentId); if (!entry) { entry = {}; catalogs.set(agentId, entry); } if (entry.pending) return entry.pending; const owner = entry; const current = () => catalogs.get(agentId) === owner && owner.pending === pending; const pending = client.request("models.list", { ...agentId ? { agentId } : {}, ...published ? { includeDetails: true } : {} }).then((res) => { if (!current()) return catalogs.get(agentId)?.models ?? []; const models = Array.isArray(res?.models) ? res.models : []; owner.models = published ? models : models.map(({ available: _available, unavailableReason: _reason, ...model }) => model); onChanged?.(agentId); return owner.models; }).catch((error) => { if (!current()) return catalogs.get(agentId)?.models ?? []; throw error; }).finally(() => { if (current()) owner.pending = void 0; }); owner.pending = pending; return pending; } //#endregion //#region src/tui/gateway-chat.ts const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 6e4; const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500; const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5e3; function throwGatewayAuthResolutionError(reason) { throw new Error([ reason, "Fix: set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD, pass --token/--password,", "or resolve the configured secret provider for this credential." ].join("\n")); } function isRetryableStartupUnavailable(err, method) { if (!(err instanceof GatewayClientRequestError)) return false; if (err.gatewayCode !== "UNAVAILABLE" || !err.retryable) return false; const details = err.details; if (!details || typeof details !== "object") return true; const detailMethod = details.method; return typeof detailMethod !== "string" || detailMethod === method; } function resolveStartupRetryDelayMs(err) { const retryAfterMs = typeof err.retryAfterMs === "number" ? err.retryAfterMs : STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS; return Math.min(Math.max(retryAfterMs, 100), STARTUP_CHAT_HISTORY_MAX_RETRY_MS); } async function hasStoredOriginDeviceAuth(deviceAuthScope) { try { const identity = loadDeviceIdentityIfPresent(); return Boolean(identity && (await loadOriginDeviceToken({ gatewayScope: deviceAuthScope, deviceId: identity.deviceId, role: "operator" }))?.token); } catch { return false; } } function isLegacyParameterError(err, method, parameter) { if (!(err instanceof GatewayClientRequestError) || err.gatewayCode !== "INVALID_REQUEST") return false; const message = err.message.toLowerCase(); return message.includes(`invalid ${method} params`) && message.includes(parameter); } var GatewayChatClient = class GatewayChatClient { constructor(connection) { this.chatStream = new GatewayChatStreamProjection(); this.historyLifetime = new AbortController(); this.ready = createDeferredCore(); this.modelCatalogs = /* @__PURE__ */ new Map(); this.connection = connection; this.client = new GatewayClient({ url: connection.url, ...connection.deviceAuthScope ? { deviceAuthScope: connection.deviceAuthScope } : {}, ...connection.sshTunnel ? { sshTunnel: connection.sshTunnel } : {}, token: connection.token, password: connection.password, edgeAuthHeaders: connection.edgeAuthHeaders, tlsFingerprint: connection.tlsFingerprint, preauthHandshakeTimeoutMs: connection.preauthHandshakeTimeoutMs, clientName: GATEWAY_CLIENT_NAMES.TUI, clientDisplayName: "openclaw-tui", clientVersion: VERSION, mode: GATEWAY_CLIENT_MODES.UI, scopes: [ "operator.admin", "operator.read", "operator.write", "operator.approvals" ], caps: [ GATEWAY_CLIENT_CAPS.AGENT_KIND, GATEWAY_CLIENT_CAPS.PLUGIN_APPROVALS, GATEWAY_CLIENT_CAPS.TASK_SUGGESTIONS, GATEWAY_CLIENT_CAPS.TOOL_EVENTS, GATEWAY_CLIENT_CAPS.ULTRAFAST ], instanceId: randomUUID(), minProtocol: 4, maxProtocol: 4, notifyOnStartupRetry: true, onHelloOk: (hello) => { this.pendingConnectError = void 0; this.hello = hello; this.ready.resolve(); this.onConnected?.(); }, onEvent: (evt) => { this.refreshModelsForEvent(evt); const projected = this.chatStream.project({ event: evt.event, payload: evt.payload, seq: evt.seq }); this.onEvent?.(projected.event); }, onClose: (_code, reason) => { this.chatStream.clear(); this.modelCatalogs.clear(); this.ready = createDeferredCore(); if (this.pendingConnectError && this.onConnectError) { this.pendingConnectError = void 0; return; } this.onDisconnected?.(reason); }, onConnectError: (error) => this.notifyConnectError(error), onGap: (info) => { this.onGap?.(info); } }); } static async connect(opts) { const connection = await resolveGatewayConnection(opts); return new GatewayChatClient(connection); } /** Connect to a target already selected and authenticated by a preceding Gateway probe. */ static async connectBound(opts) { return new GatewayChatClient(await resolveBoundGatewayConnection(opts)); } start() { startGatewayClientWhenEventLoopReady(this.client, { clientOptions: { preauthHandshakeTimeoutMs: this.connection.preauthHandshakeTimeoutMs } }).then((readiness) => { if (!readiness.ready && !readiness.aborted) this.notifyUnclosedConnectError(/* @__PURE__ */ new Error("gateway event loop readiness timeout")); }).catch((err) => { this.notifyUnclosedConnectError(err instanceof Error ? err : new Error(String(err))); }); } notifyConnectError(error) { if (this.pendingConnectError) return; if (isRetryableGatewayStartupUnavailableError(error)) return; if (this.connection.deviceAuthScope && readConnectErrorDetailCode(error.details) === ConnectErrorDetailCodes.PAIRING_REQUIRED && !error.message.includes("Pairing request sent.")) error.message = [error.message, "Pairing request sent. Approve it in that gateway's Control UI (Settings -> Devices), or run `openclaw devices approve --latest` on the gateway host, then retry."].join("\n"); this.pendingConnectError = error; this.onConnectError?.(error); } notifyUnclosedConnectError(error) { const hasStructuredHandler = Boolean(this.onConnectError); this.notifyConnectError(error); if (!hasStructuredHandler) this.onDisconnected?.(error.message); } stop() { this.chatStream.clear(); this.historyLifetime.abort(); this.modelCatalogs.clear(); return this.client.stopAndWait(); } async subscribeSessionEvents() { return await this.client.request("sessions.subscribe", {}); } async waitForReady() { await this.ready.promise; } async sendChat(opts) { const runId = opts.runId ?? randomUUID(); const response = await this.client.request("chat.send", { sessionKey: opts.sessionKey, ...opts.agentId ? { agentId: opts.agentId } : {}, ...opts.sessionId ? { sessionId: opts.sessionId } : {}, message: opts.message, thinking: opts.thinking, deliver: opts.deliver, timeoutMs: opts.timeoutMs, idempotencyKey: runId }); const acceptedRunId = normalizeOptionalString(response?.runId) ?? runId; const status = normalizeOptionalString(response?.status); return status ? { runId: acceptedRunId, status } : { runId: acceptedRunId }; } async abortChat(opts) { const params = { sessionKey: opts.sessionKey, ...opts.agentId ? { agentId: opts.agentId } : {}, ...opts.runId ? { runId: opts.runId } : {} }; try { return await this.client.request("chat.abort", opts.runId ? params : { ...params, preserveSideRuns: true }); } catch (err) { if (opts.runId || !isLegacyParameterError(err, "chat.abort", "preservesideruns")) throw err; return await this.client.request("chat.abort", params); } } async loadHistory(opts) { const deadline = Date.now() + STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS; for (;;) { this.historyLifetime.signal.throwIfAborted(); try { return await this.client.request("chat.history", { sessionKey: opts.sessionKey, ...opts.agentId ? { agentId: opts.agentId } : {}, limit: opts.limit }); } catch (err) { if (Date.now() >= deadline || !isRetryableStartupUnavailable(err, "chat.history")) throw err; await sleep(resolveStartupRetryDelayMs(err), this.historyLifetime.signal); } } } async loadImage(opts) { const { loadGatewayImage } = await import("./gateway-image-loader-CssUqAhS.mjs"); const signal = AbortSignal.any([opts.signal, this.historyLifetime.signal]); const credentials = [this.hello?.auth.deviceToken, ...this.hello?.auth.method === "password" ? [this.connection.password, this.connection.token] : [this.connection.token, this.connection.password]].filter((value) => Boolean(value)); return await loadGatewayImage({ request: { ...opts, signal }, connection: this.connection, credentials: [...new Set(credentials)], readMediaBasePath: async (requestSignal) => { return (await this.client.request("config.get", {}, { signal: requestSignal })).runtimeConfig.gateway?.controlUi?.basePath ?? ""; }, downloadArtifact: (artifactId, requestSignal) => this.client.request("artifacts.download", { sessionKey: opts.sessionKey, ...opts.agentId ? { agentId: opts.agentId } : {}, artifactId }, { signal: requestSignal }) }); } async listSessions(opts) { return await this.client.request("sessions.list", opts ?? {}); } async resolveSession(opts) { return await this.client.request("sessions.resolve", opts); } async describeSession(opts) { const agentId = opts.agentId ?? parseAgentSessionKey(opts.sessionKey)?.agentId; const signal = this.historyLifetime.signal; for (;;) { signal.throwIfAborted(); const connection = this.ready; const hello = this.hello; const isCurrentConnection = () => connection === this.ready && hello === this.hello; try { const [description, listing] = await Promise.all([this.client.request("sessions.describe", { key: opts.sessionKey, ...opts.agentId ? { agentId: opts.agentId } : {} }, { signal }), this.client.request("sessions.list", { agentId, limit: 1 }, { signal })]); signal.throwIfAborted(); if (isCurrentConnection()) { const session = description.session; return { session: session && isListedTuiSession(session) ? session : null, defaults: listing.defaults }; } } catch (error) { if (signal.aborted || isCurrentConnection()) throw error; } await racePromiseWithAbortSignal(this.ready.promise, signal); } } async listAgents() { const result = await this.client.request("agents.list", {}); if (!this.modelCatalogs.has(result.defaultId)) this.listModels({ agentId: result.defaultId }).catch(() => {}); return result; } async patchSession(opts) { return await this.client.request("sessions.patch", opts); } async createSession(opts) { const params = { ...opts, emitCommandHooks: Boolean(opts.parentSessionKey) }; try { return await this.client.request("sessions.create", params); } catch (err) { if (opts.succeedsParent === void 0 || !isLegacyParameterError(err, "sessions.create", "succeedsparent")) throw err; const { succeedsParent: _succeedsParent, ...legacyParams } = params; if (!opts.succeedsParent) { const { parentSessionKey: _parentSessionKey, emitCommandHooks: _emitCommandHooks, ...parallelParams } = legacyParams; return await this.client.request("sessions.create", parallelParams); } return await this.client.request("sessions.create", legacyParams); } } async resetSession(key, reason, opts) { return await this.client.request("sessions.reset", { key, ...opts?.agentId ? { agentId: opts.agentId } : {}, ...reason ? { reason } : {} }); } async getGatewayStatus() { return await this.client.request("status"); } getKnownModels(opts) { return this.modelCatalogs.get(opts?.agentId)?.models; } listModels(opts) { return refreshTuiGatewayModelCatalog({ catalogs: this.modelCatalogs, client: this.client, agentId: opts?.agentId, published: this.hello?.features.capabilities?.includes(GATEWAY_SERVER_CAPS.PUBLISHED_MODEL_CATALOG) === true, onChanged: this.onModelsChanged }); } refreshModelsForEvent(event) { const payload = asNullableRecord(event.payload); const clear = event.event === "chat.metadata.changed" && payload?.modelSelectionChanged === true; const refresh = event.event === "config.changed" || event.event === "chat.metadata.changed" && payload?.modelCatalogChanged !== false; const scope = event.event === "models.snapshot" ? asNullableRecord(payload?.scope) : null; if (!clear && !refresh && !scope) return; for (const [agentId, entry] of this.modelCatalogs) { if (scope && (scope.agentId !== agentId || scope.sessionKey || scope.authProfileId)) continue; entry.pending = void 0; if (clear) { entry.models = void 0; this.onModelsChanged?.(agentId); } this.listModels({ agentId }).catch(() => {}); } } async listCommands(opts) { const res = await this.client.request("commands.list", opts ?? {}); return Array.isArray(res?.commands) ? res.commands : []; } async listPluginApprovals() { return await this.client.request("plugin.approval.list", {}); } async listQuestions() { return await this.client.request("question.list", {}); } async getQuestion(id) { return await this.client.request("question.get", { id }); } async resolveQuestion(params) { return await this.client.request("question.resolve", params); } async resolvePluginApproval(id, decision) { return await this.client.request("plugin.approval.resolve", { id, decision }); } getTaskSuggestionActionCapabilities() { const auth = this.hello?.auth; const methods = this.hello?.features?.methods; const allows = (method, scope) => Array.isArray(methods) && methods.includes(method) && Boolean(auth && roleScopesAllow({ role: auth.role, requestedScopes: [scope], allowedScopes: auth.scopes })); return { canAccept: allows("taskSuggestions.accept", "operator.admin"), canDismiss: allows("taskSuggestions.dismiss", "operator.write") }; } async listTaskSuggestions() { if (this.hello?.features?.methods?.includes("taskSuggestions.list") !== true) return []; const actions = this.getTaskSuggestionActionCapabilities(); if (!actions.canAccept && !actions.canDismiss) return []; return (await this.client.request("taskSuggestions.list", {})).suggestions; } async acceptTaskSuggestion(taskId) { return await this.client.request("taskSuggestions.accept", { taskId, mode: "local" }); } async dismissTaskSuggestion(taskId) { return await this.client.request("taskSuggestions.dismiss", { taskId }); } }; /** * Preserve a pre-probed Gateway route across an in-process handoff. This path * deliberately ignores global config and Gateway env overrides, including * credentials, while still applying the normal remote URL safety policy. */ async function resolveBoundGatewayConnection(opts) { const url = buildGatewayConnectionDetails({ config: opts.config, url: opts.url, ignoreEnvUrlOverride: true }).url; const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); const edgeAuthConfig = normalizeEdgeAuthHeadersConfig(gatewayEdgeAuthValueForTarget({ config: opts.config, targetUrl: url })); const edgeAuthHeaders = await resolveEdgeAuthHeaders({ config: opts.config, value: edgeAuthConfig, targetUrl: url, env: process.env }); const { deviceAuthScope, sshTunnel } = resolveGatewayDeviceAuthRoute({ config: opts.config, url, remote: true, configuredRemote: opts.configuredRemote, tlsFingerprint: opts.tlsFingerprint }); return { url, deviceAuthScope, ...sshTunnel ? { sshTunnel } : {}, token: explicitAuth.token, password: explicitAuth.password, ...edgeAuthHeaders ? { edgeAuthHeaders } : {}, ...opts.tlsFingerprint ? { tlsFingerprint: opts.tlsFingerprint } : {} }; } async function resolveGatewayConnection(opts) { const config = getRuntimeConfig(); const env = process.env; const gatewayAuthMode = config.gateway?.auth?.mode; const isRemoteMode = config.gateway?.mode === "remote"; const urlOverride = resolveGatewayUrlOverride({ gatewayUrl: opts.url, env }); const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); const hasExplicitGatewayTarget = Boolean(urlOverride.url || env.OPENCLAW_GATEWAY_PORT?.trim() || isRemoteMode); const resumeMayMatchLocalTarget = opts.allowConfiguredAuthForExactTarget === true && urlOverride.source === "cli" && !isRemoteMode && !env.OPENCLAW_GATEWAY_PORT?.trim(); const activeLocalGatewayPort = !hasExplicitGatewayTarget || resumeMayMatchLocalTarget ? await readActiveGatewayLockPort() : void 0; if (!urlOverride.source && gatewayAuthMode !== "none" && gatewayAuthMode !== "trusted-proxy" && !isRemoteMode) try { assertExplicitGatewayAuthModeWhenBothConfigured(config); } catch (err) { throwGatewayAuthResolutionError(formatErrorMessage(err)); } const bootstrap = await resolveGatewayClientBootstrap({ config, gatewayUrl: urlOverride.source === "cli" ? urlOverride.url : void 0, explicitAuth, env, authPolicy: "interactive", allowConfiguredAuthForExactTarget: opts.allowConfiguredAuthForExactTarget, suppressEnvAuthFallback: opts.suppressEnvAuthFallback, ...activeLocalGatewayPort ? { localPortOverride: activeLocalGatewayPort } : {}, explicitTlsFingerprint: opts.tlsFingerprint, allowStoredOriginAuth: hasStoredOriginDeviceAuth, overrideAuthErrorHint: "Fix: pass --token or --password once to request pairing, approve it in that gateway's Control UI (Settings -> Devices), then retry with the same credential so OpenClaw can store the device token.", buildConnectionDetails: buildGatewayConnectionDetails }); const hasStoredOriginAuth = Boolean(bootstrap.deviceAuthScope && await hasStoredOriginDeviceAuth(bootstrap.deviceAuthScope)); const missingSharedAuth = bootstrap.authFailureReason === "Missing gateway auth credentials." || bootstrap.authFailureReason === "Missing gateway auth token." || bootstrap.authFailureReason === "Missing gateway auth password."; if (bootstrap.authFailureReason && (!missingSharedAuth || !hasStoredOriginAuth)) throwGatewayAuthResolutionError(bootstrap.authFailureReason); const edgeAuthConfig = normalizeEdgeAuthHeadersConfig(gatewayEdgeAuthValueForTarget({ config, targetUrl: bootstrap.url })); const edgeAuthHeaders = await resolveEdgeAuthHeaders({ config, value: edgeAuthConfig, targetUrl: bootstrap.url, env }); return { url: bootstrap.url, deviceAuthScope: bootstrap.deviceAuthScope, ...bootstrap.sshTunnel ? { sshTunnel: bootstrap.sshTunnel } : {}, token: bootstrap.auth.token, password: bootstrap.auth.password, ...edgeAuthHeaders ? { edgeAuthHeaders } : {}, ...bootstrap.tlsFingerprint ? { tlsFingerprint: bootstrap.tlsFingerprint } : {} }; } //#endregion export { GatewayChatClient };