Files
openclaw/src/tui/gateway-chat.ts
2026-03-12 23:31:31 +00:00

434 lines
12 KiB
TypeScript

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) => {
// 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() {
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,
};
}