| import { randomUUID } from "node:crypto"; |
| import { loadConfig } from "../config/config.js"; |
| import { hasConfiguredSecretInput } from "../config/types.secrets.js"; |
| import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js"; |
| import { |
| buildGatewayConnectionDetails, |
| ensureExplicitGatewayAuth, |
| resolveExplicitGatewayAuth, |
| } from "../gateway/call.js"; |
| import { GatewayClient } from "../gateway/client.js"; |
| import { GATEWAY_CLIENT_CAPS } from "../gateway/protocol/client-info.js"; |
| import { |
| type HelloOk, |
| PROTOCOL_VERSION, |
| type SessionsListParams, |
| type SessionsPatchResult, |
| type SessionsPatchParams, |
| } from "../gateway/protocol/index.js"; |
| import { resolveConfiguredSecretInputString } from "../gateway/resolve-configured-secret-input-string.js"; |
| import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; |
| import { VERSION } from "../version.js"; |
| import type { ResponseUsageMode, SessionInfo, SessionScope } from "./tui-types.js"; |
|
|
| export type GatewayConnectionOptions = { |
| url?: string; |
| token?: string; |
| password?: string; |
| }; |
|
|
| export type ChatSendOptions = { |
| sessionKey: string; |
| message: string; |
| thinking?: string; |
| deliver?: boolean; |
| timeoutMs?: number; |
| runId?: string; |
| }; |
|
|
| export type GatewayEvent = { |
| event: string; |
| payload?: unknown; |
| seq?: number; |
| }; |
|
|
| type ResolvedGatewayConnection = { |
| url: string; |
| token?: string; |
| password?: string; |
| }; |
|
|
| function trimToUndefined(value: unknown): string | undefined { |
| if (typeof value !== "string") { |
| return undefined; |
| } |
| const trimmed = value.trim(); |
| return trimmed.length > 0 ? trimmed : undefined; |
| } |
|
|
| function throwGatewayAuthResolutionError(reason: string): never { |
| 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"), |
| ); |
| } |
|
|
| export type GatewaySessionList = { |
| ts: number; |
| path: string; |
| count: number; |
| defaults?: { |
| model?: string | null; |
| modelProvider?: string | null; |
| contextTokens?: number | null; |
| }; |
| sessions: Array< |
| Pick< |
| SessionInfo, |
| | "thinkingLevel" |
| | "fastMode" |
| | "verboseLevel" |
| | "reasoningLevel" |
| | "model" |
| | "contextTokens" |
| | "inputTokens" |
| | "outputTokens" |
| | "totalTokens" |
| | "modelProvider" |
| | "displayName" |
| > & { |
| key: string; |
| sessionId?: string; |
| updatedAt?: number | null; |
| fastMode?: boolean; |
| sendPolicy?: string; |
| responseUsage?: ResponseUsageMode; |
| label?: string; |
| provider?: string; |
| groupChannel?: string; |
| space?: string; |
| subject?: string; |
| chatType?: string; |
| lastProvider?: string; |
| lastTo?: string; |
| lastAccountId?: string; |
| derivedTitle?: string; |
| lastMessagePreview?: string; |
| } |
| >; |
| }; |
|
|
| export type GatewayAgentsList = { |
| defaultId: string; |
| mainKey: string; |
| scope: SessionScope; |
| agents: Array<{ |
| id: string; |
| name?: string; |
| }>; |
| }; |
|
|
| export type GatewayModelChoice = { |
| id: string; |
| name: string; |
| provider: string; |
| contextWindow?: number; |
| reasoning?: boolean; |
| }; |
|
|
| export class GatewayChatClient { |
| private client: GatewayClient; |
| private readyPromise: Promise<void>; |
| private resolveReady?: () => void; |
| readonly connection: { url: string; token?: string; password?: string }; |
| hello?: HelloOk; |
|
|
| onEvent?: (evt: GatewayEvent) => void; |
| onConnected?: () => void; |
| onDisconnected?: (reason: string) => void; |
| onGap?: (info: { expected: number; received: number }) => void; |
|
|
| constructor(connection: ResolvedGatewayConnection) { |
| this.connection = connection; |
|
|
| this.readyPromise = new Promise((resolve) => { |
| this.resolveReady = resolve; |
| }); |
|
|
| this.client = new GatewayClient({ |
| url: connection.url, |
| token: connection.token, |
| password: connection.password, |
| clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, |
| clientDisplayName: "openclaw-tui", |
| clientVersion: VERSION, |
| platform: process.platform, |
| mode: GATEWAY_CLIENT_MODES.UI, |
| caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS], |
| instanceId: randomUUID(), |
| minProtocol: PROTOCOL_VERSION, |
| maxProtocol: PROTOCOL_VERSION, |
| onHelloOk: (hello) => { |
| this.hello = hello; |
| this.resolveReady?.(); |
| this.onConnected?.(); |
| }, |
| onEvent: (evt) => { |
| this.onEvent?.({ |
| event: evt.event, |
| payload: evt.payload, |
| seq: evt.seq, |
| }); |
| }, |
| onClose: (_code, reason) => { |
| |
| this.readyPromise = new Promise((resolve) => { |
| this.resolveReady = resolve; |
| }); |
| this.onDisconnected?.(reason); |
| }, |
| onGap: (info) => { |
| this.onGap?.(info); |
| }, |
| }); |
| } |
|
|
| static async connect(opts: GatewayConnectionOptions): Promise<GatewayChatClient> { |
| const connection = await resolveGatewayConnection(opts); |
| return new GatewayChatClient(connection); |
| } |
|
|
| start() { |
| this.client.start(); |
| } |
|
|
| stop() { |
| this.client.stop(); |
| } |
|
|
| async waitForReady() { |
| await this.readyPromise; |
| } |
|
|
| async sendChat(opts: ChatSendOptions): Promise<{ runId: string }> { |
| const runId = opts.runId ?? randomUUID(); |
| await this.client.request("chat.send", { |
| sessionKey: opts.sessionKey, |
| message: opts.message, |
| thinking: opts.thinking, |
| deliver: opts.deliver, |
| timeoutMs: opts.timeoutMs, |
| idempotencyKey: runId, |
| }); |
| return { runId }; |
| } |
|
|
| async abortChat(opts: { sessionKey: string; runId: string }) { |
| return await this.client.request<{ ok: boolean; aborted: boolean }>("chat.abort", { |
| sessionKey: opts.sessionKey, |
| runId: opts.runId, |
| }); |
| } |
|
|
| async loadHistory(opts: { sessionKey: string; limit?: number }) { |
| return await this.client.request("chat.history", { |
| sessionKey: opts.sessionKey, |
| limit: opts.limit, |
| }); |
| } |
|
|
| async listSessions(opts?: SessionsListParams) { |
| return await this.client.request<GatewaySessionList>("sessions.list", { |
| limit: opts?.limit, |
| activeMinutes: opts?.activeMinutes, |
| includeGlobal: opts?.includeGlobal, |
| includeUnknown: opts?.includeUnknown, |
| includeDerivedTitles: opts?.includeDerivedTitles, |
| includeLastMessage: opts?.includeLastMessage, |
| agentId: opts?.agentId, |
| }); |
| } |
|
|
| async listAgents() { |
| return await this.client.request<GatewayAgentsList>("agents.list", {}); |
| } |
|
|
| async patchSession(opts: SessionsPatchParams): Promise<SessionsPatchResult> { |
| return await this.client.request<SessionsPatchResult>("sessions.patch", opts); |
| } |
|
|
| async resetSession(key: string, reason?: "new" | "reset") { |
| return await this.client.request("sessions.reset", { |
| key, |
| ...(reason ? { reason } : {}), |
| }); |
| } |
|
|
| async getStatus() { |
| return await this.client.request("status"); |
| } |
|
|
| async listModels(): Promise<GatewayModelChoice[]> { |
| const res = await this.client.request<{ models?: GatewayModelChoice[] }>("models.list"); |
| return Array.isArray(res?.models) ? res.models : []; |
| } |
| } |
|
|
| export async function resolveGatewayConnection( |
| opts: GatewayConnectionOptions, |
| ): Promise<ResolvedGatewayConnection> { |
| const config = loadConfig(); |
| const env = process.env; |
| const gatewayAuthMode = config.gateway?.auth?.mode; |
| const isRemoteMode = config.gateway?.mode === "remote"; |
| const remote = config.gateway?.remote; |
| const envToken = trimToUndefined(env.OPENCLAW_GATEWAY_TOKEN); |
| const envPassword = trimToUndefined(env.OPENCLAW_GATEWAY_PASSWORD); |
|
|
| const urlOverride = |
| typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; |
| const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); |
| ensureExplicitGatewayAuth({ |
| urlOverride, |
| urlOverrideSource: "cli", |
| explicitAuth, |
| errorHint: "Fix: pass --token or --password when using --url.", |
| }); |
| const url = buildGatewayConnectionDetails({ |
| config, |
| ...(urlOverride ? { url: urlOverride } : {}), |
| }).url; |
|
|
| if (urlOverride) { |
| return { |
| url, |
| token: explicitAuth.token, |
| password: explicitAuth.password, |
| }; |
| } |
|
|
| if (isRemoteMode) { |
| const remoteToken = explicitAuth.token |
| ? { value: explicitAuth.token } |
| : await resolveConfiguredSecretInputString({ |
| value: remote?.token, |
| path: "gateway.remote.token", |
| env, |
| config, |
| }); |
| const remotePassword = |
| explicitAuth.password || envPassword |
| ? { value: explicitAuth.password ?? envPassword } |
| : await resolveConfiguredSecretInputString({ |
| value: remote?.password, |
| path: "gateway.remote.password", |
| env, |
| config, |
| }); |
|
|
| const token = explicitAuth.token ?? remoteToken.value; |
| const password = explicitAuth.password ?? envPassword ?? remotePassword.value; |
| if (!token && !password) { |
| throwGatewayAuthResolutionError( |
| remoteToken.unresolvedRefReason ?? |
| remotePassword.unresolvedRefReason ?? |
| "Missing gateway auth credentials.", |
| ); |
| } |
| return { url, token, password }; |
| } |
|
|
| if (gatewayAuthMode === "none" || gatewayAuthMode === "trusted-proxy") { |
| return { |
| url, |
| token: explicitAuth.token ?? envToken, |
| password: explicitAuth.password ?? envPassword, |
| }; |
| } |
|
|
| try { |
| assertExplicitGatewayAuthModeWhenBothConfigured(config); |
| } catch (err) { |
| throwGatewayAuthResolutionError(err instanceof Error ? err.message : String(err)); |
| } |
|
|
| const defaults = config.secrets?.defaults; |
| const hasConfiguredToken = hasConfiguredSecretInput(config.gateway?.auth?.token, defaults); |
| const hasConfiguredPassword = hasConfiguredSecretInput(config.gateway?.auth?.password, defaults); |
| if (gatewayAuthMode === "password") { |
| const localPassword = |
| explicitAuth.password || envPassword |
| ? { value: explicitAuth.password ?? envPassword } |
| : await resolveConfiguredSecretInputString({ |
| value: config.gateway?.auth?.password, |
| path: "gateway.auth.password", |
| env, |
| config, |
| }); |
| const password = explicitAuth.password ?? envPassword ?? localPassword.value; |
| if (!password) { |
| throwGatewayAuthResolutionError( |
| localPassword.unresolvedRefReason ?? "Missing gateway auth password.", |
| ); |
| } |
| return { |
| url, |
| token: explicitAuth.token ?? envToken, |
| password, |
| }; |
| } |
|
|
| const resolveToken = async () => { |
| const localToken = explicitAuth.token |
| ? { value: explicitAuth.token } |
| : await resolveConfiguredSecretInputString({ |
| value: config.gateway?.auth?.token, |
| path: "gateway.auth.token", |
| env, |
| config, |
| }); |
| const token = explicitAuth.token ?? localToken.value ?? envToken; |
| if (!token) { |
| throwGatewayAuthResolutionError( |
| localToken.unresolvedRefReason ?? "Missing gateway auth token.", |
| ); |
| } |
| return token; |
| }; |
|
|
| if (gatewayAuthMode === "token") { |
| const token = await resolveToken(); |
| return { |
| url, |
| token, |
| password: explicitAuth.password ?? envPassword, |
| }; |
| } |
|
|
| const passwordCandidate = explicitAuth.password ?? envPassword; |
| const shouldUsePassword = |
| Boolean(passwordCandidate) || (hasConfiguredPassword && !hasConfiguredToken); |
|
|
| if (shouldUsePassword) { |
| const localPassword = passwordCandidate |
| ? { value: passwordCandidate } |
| : await resolveConfiguredSecretInputString({ |
| value: config.gateway?.auth?.password, |
| path: "gateway.auth.password", |
| env, |
| config, |
| }); |
| const password = explicitAuth.password ?? localPassword.value ?? envPassword; |
| if (!password) { |
| throwGatewayAuthResolutionError( |
| localPassword.unresolvedRefReason ?? "Missing gateway auth password.", |
| ); |
| } |
| return { |
| url, |
| token: explicitAuth.token ?? envToken, |
| password, |
| }; |
| } |
|
|
| const token = await resolveToken(); |
| return { |
| url, |
| token, |
| password: explicitAuth.password ?? envPassword, |
| }; |
| } |
|
|