mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-23 13:28:12 +00:00
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
// Bridges TUI chat requests to gateway session APIs.
|
|
import { randomUUID } from "node:crypto";
|
|
import {
|
|
GATEWAY_CLIENT_CAPS,
|
|
GATEWAY_CLIENT_MODES,
|
|
GATEWAY_CLIENT_NAMES,
|
|
} from "../../packages/gateway-protocol/src/client-info.js";
|
|
import {
|
|
type HelloOk,
|
|
MIN_CLIENT_PROTOCOL_VERSION,
|
|
PROTOCOL_VERSION,
|
|
type CommandEntry,
|
|
type CommandsListParams,
|
|
type CommandsListResult,
|
|
type SessionsListParams,
|
|
type SessionsPatchResult,
|
|
type SessionsPatchParams,
|
|
} from "../../packages/gateway-protocol/src/index.js";
|
|
import { getRuntimeConfig } from "../config/config.js";
|
|
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
|
|
import { resolveGatewayInteractiveSurfaceAuth } from "../gateway/auth-surface-resolution.js";
|
|
import {
|
|
buildGatewayConnectionDetails,
|
|
ensureExplicitGatewayAuth,
|
|
resolveExplicitGatewayAuth,
|
|
} from "../gateway/call.js";
|
|
import { startGatewayClientWhenEventLoopReady } from "../gateway/client-start-readiness.js";
|
|
import { GatewayClient, GatewayClientRequestError } from "../gateway/client.js";
|
|
import { isLoopbackHost } from "../gateway/net.js";
|
|
import { formatErrorMessage } from "../infra/errors.js";
|
|
import { VERSION } from "../version.js";
|
|
import { TUI_SETUP_AUTH_SOURCE_CONFIG, TUI_SETUP_AUTH_SOURCE_ENV } from "./setup-launch-env.js";
|
|
import type {
|
|
ChatSendOptions,
|
|
TuiAgentsList,
|
|
TuiBackend,
|
|
TuiEvent,
|
|
TuiModelChoice,
|
|
TuiSessionList,
|
|
TuiSessionMutationResult,
|
|
} from "./tui-backend.js";
|
|
|
|
export type GatewayConnectionOptions = {
|
|
url?: string;
|
|
token?: string;
|
|
password?: string;
|
|
};
|
|
|
|
export type GatewayEvent = TuiEvent;
|
|
|
|
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
|
|
const STARTUP_CHAT_HISTORY_DEFAULT_RETRY_MS = 500;
|
|
const STARTUP_CHAT_HISTORY_MAX_RETRY_MS = 5_000;
|
|
|
|
type ResolvedGatewayConnection = {
|
|
url: string;
|
|
token?: string;
|
|
password?: string;
|
|
preauthHandshakeTimeoutMs?: number;
|
|
allowInsecureLocalOperatorUi: boolean;
|
|
};
|
|
|
|
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"),
|
|
);
|
|
}
|
|
|
|
function isRetryableStartupUnavailable(
|
|
err: unknown,
|
|
method: string,
|
|
): err is GatewayClientRequestError {
|
|
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 as { method?: unknown }).method;
|
|
return typeof detailMethod !== "string" || detailMethod === method;
|
|
}
|
|
|
|
function resolveStartupRetryDelayMs(err: GatewayClientRequestError): number {
|
|
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);
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
|
|
export type GatewaySessionList = TuiSessionList;
|
|
export type GatewayAgentsList = TuiAgentsList;
|
|
export type GatewayModelChoice = TuiModelChoice;
|
|
|
|
export class GatewayChatClient implements TuiBackend {
|
|
private client: GatewayClient;
|
|
private readyPromise: Promise<void>;
|
|
private resolveReady?: () => void;
|
|
readonly connection: ResolvedGatewayConnection;
|
|
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,
|
|
preauthHandshakeTimeoutMs: connection.preauthHandshakeTimeoutMs,
|
|
clientName: GATEWAY_CLIENT_NAMES.TUI,
|
|
clientDisplayName: "openclaw-tui",
|
|
clientVersion: VERSION,
|
|
platform: process.platform,
|
|
mode: GATEWAY_CLIENT_MODES.UI,
|
|
deviceIdentity: connection.allowInsecureLocalOperatorUi ? null : undefined,
|
|
caps: [GATEWAY_CLIENT_CAPS.TOOL_EVENTS],
|
|
instanceId: randomUUID(),
|
|
minProtocol: MIN_CLIENT_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) => {
|
|
// Reset so waitForReady() blocks again until the next successful reconnect.
|
|
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() {
|
|
void startGatewayClientWhenEventLoopReady(this.client, {
|
|
clientOptions: { preauthHandshakeTimeoutMs: this.connection.preauthHandshakeTimeoutMs },
|
|
})
|
|
.then((readiness) => {
|
|
if (!readiness.ready && !readiness.aborted) {
|
|
this.onDisconnected?.("gateway event loop readiness timeout");
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
this.onDisconnected?.(err instanceof Error ? err.message : String(err));
|
|
});
|
|
}
|
|
|
|
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,
|
|
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
|
...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
|
|
message: opts.message,
|
|
thinking: opts.thinking,
|
|
deliver: opts.deliver,
|
|
timeoutMs: opts.timeoutMs,
|
|
idempotencyKey: runId,
|
|
});
|
|
return { runId };
|
|
}
|
|
|
|
async abortChat(opts: { sessionKey: string; agentId?: string; runId: string }) {
|
|
return await this.client.request<{ ok: boolean; aborted: boolean }>("chat.abort", {
|
|
sessionKey: opts.sessionKey,
|
|
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
|
runId: opts.runId,
|
|
});
|
|
}
|
|
|
|
async loadHistory(opts: { sessionKey: string; agentId?: string; limit?: number }) {
|
|
const startedAt = Date.now();
|
|
for (;;) {
|
|
try {
|
|
return await this.client.request("chat.history", {
|
|
sessionKey: opts.sessionKey,
|
|
...(opts.agentId ? { agentId: opts.agentId } : {}),
|
|
limit: opts.limit,
|
|
});
|
|
} catch (err) {
|
|
const withinStartupRetryWindow =
|
|
Date.now() - startedAt < STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS;
|
|
if (withinStartupRetryWindow && isRetryableStartupUnavailable(err, "chat.history")) {
|
|
await sleep(resolveStartupRetryDelayMs(err));
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
async listSessions(opts?: SessionsListParams) {
|
|
return await this.client.request<GatewaySessionList>("sessions.list", opts ?? {});
|
|
}
|
|
|
|
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",
|
|
opts?: { agentId?: string },
|
|
): Promise<TuiSessionMutationResult> {
|
|
return await this.client.request<TuiSessionMutationResult>("sessions.reset", {
|
|
key,
|
|
...(opts?.agentId ? { agentId: opts.agentId } : {}),
|
|
...(reason ? { reason } : {}),
|
|
});
|
|
}
|
|
|
|
async getGatewayStatus() {
|
|
return await this.client.request("status");
|
|
}
|
|
|
|
async listModels(): Promise<GatewayModelChoice[]> {
|
|
const res = await this.client.request("models.list");
|
|
return Array.isArray(res?.models) ? res.models : [];
|
|
}
|
|
|
|
async listCommands(opts?: CommandsListParams): Promise<CommandEntry[]> {
|
|
const res = await this.client.request<CommandsListResult>("commands.list", opts ?? {});
|
|
return Array.isArray(res?.commands) ? res.commands : [];
|
|
}
|
|
}
|
|
|
|
export async function resolveGatewayConnection(
|
|
opts: GatewayConnectionOptions,
|
|
): Promise<ResolvedGatewayConnection> {
|
|
const config = getRuntimeConfig();
|
|
const env = process.env;
|
|
const gatewayAuthMode = config.gateway?.auth?.mode;
|
|
const isRemoteMode = config.gateway?.mode === "remote";
|
|
const preferConfiguredAuth = env[TUI_SETUP_AUTH_SOURCE_ENV] === TUI_SETUP_AUTH_SOURCE_CONFIG;
|
|
|
|
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;
|
|
const allowInsecureLocalOperatorUi = (() => {
|
|
if (config.gateway?.controlUi?.allowInsecureAuth !== true) {
|
|
return false;
|
|
}
|
|
try {
|
|
return isLoopbackHost(new URL(url).hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
if (urlOverride) {
|
|
return {
|
|
url,
|
|
token: explicitAuth.token,
|
|
password: explicitAuth.password,
|
|
preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs,
|
|
allowInsecureLocalOperatorUi,
|
|
};
|
|
}
|
|
|
|
if (isRemoteMode) {
|
|
const resolved = await resolveGatewayInteractiveSurfaceAuth({
|
|
config,
|
|
env,
|
|
explicitAuth,
|
|
surface: "remote",
|
|
});
|
|
if (resolved.failureReason) {
|
|
throwGatewayAuthResolutionError(resolved.failureReason);
|
|
}
|
|
return {
|
|
url,
|
|
token: resolved.token,
|
|
password: resolved.password,
|
|
preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs,
|
|
allowInsecureLocalOperatorUi: false,
|
|
};
|
|
}
|
|
|
|
if (gatewayAuthMode === "none" || gatewayAuthMode === "trusted-proxy") {
|
|
const resolved = await resolveGatewayInteractiveSurfaceAuth({
|
|
config,
|
|
env,
|
|
explicitAuth,
|
|
surface: "local",
|
|
});
|
|
return {
|
|
url,
|
|
token: resolved.token,
|
|
password: resolved.password,
|
|
preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs,
|
|
allowInsecureLocalOperatorUi,
|
|
};
|
|
}
|
|
|
|
try {
|
|
assertExplicitGatewayAuthModeWhenBothConfigured(config);
|
|
} catch (err) {
|
|
throwGatewayAuthResolutionError(formatErrorMessage(err));
|
|
}
|
|
|
|
const resolved = await resolveGatewayInteractiveSurfaceAuth({
|
|
config,
|
|
env,
|
|
explicitAuth,
|
|
suppressEnvAuthFallback: preferConfiguredAuth,
|
|
surface: "local",
|
|
});
|
|
if (resolved.failureReason) {
|
|
throwGatewayAuthResolutionError(resolved.failureReason);
|
|
}
|
|
return {
|
|
url,
|
|
token: resolved.token,
|
|
password: resolved.password,
|
|
preauthHandshakeTimeoutMs: config.gateway?.handshakeTimeoutMs,
|
|
allowInsecureLocalOperatorUi,
|
|
};
|
|
}
|