From 2ca3c3738cfd5f990700f64503b0be7430dadad9 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 03:32:09 -0500 Subject: [PATCH 1/7] Add OpenClaw gateway auth and device token persistence - Add gateway client handshake with signed device identity - Persist encrypted gateway config, device keys, and tokens - Thread OpenClaw auth state through server and UI --- apps/server/src/index.ts | 34 + apps/server/src/main.test.ts | 32 + apps/server/src/main.ts | 2 + apps/server/src/openclaw/GatewayClient.ts | 553 +++++ apps/server/src/openclaw/deviceAuth.ts | 82 + apps/server/src/openclaw/protocol.ts | 155 ++ apps/server/src/openclaw/sessionIdentity.ts | 34 + apps/server/src/openclawGatewayTest.ts | 2 +- .../Layers/OpenclawGatewayConfig.ts | 482 +++++ apps/server/src/persistence/Migrations.ts | 8 +- .../Migrations/021_OpenclawGatewayConfig.ts | 23 + .../Services/EnvironmentVariables.ts | 88 +- .../Services/OpenclawGatewayConfig.ts | 72 + apps/server/src/persistence/vault.ts | 92 + .../src/provider/Layers/ProviderHealth.ts | 225 +- apps/server/src/serverLayers.ts | 8 +- .../src/sme/Layers/SmeChatServiceLive.test.ts | 323 +-- .../src/sme/Layers/SmeChatServiceLive.ts | 126 +- apps/server/src/sme/authValidation.ts | 177 +- apps/server/src/sme/promptBuilder.ts | 14 + apps/server/src/wsServer.ts | 49 +- apps/web/src/appSettings.ts | 17 +- apps/web/src/lib/serverReactQuery.ts | 12 + apps/web/src/routes/__root.tsx | 2 +- apps/web/src/routes/_chat.settings.tsx | 1907 ++++++++--------- apps/web/src/wsNativeApi.ts | 5 + packages/contracts/src/ipc.ts | 14 +- packages/contracts/src/providerRuntime.ts | 2 + packages/contracts/src/server.ts | 29 +- packages/contracts/src/ws.ts | 11 + 30 files changed, 3195 insertions(+), 1385 deletions(-) create mode 100644 apps/server/src/openclaw/GatewayClient.ts create mode 100644 apps/server/src/openclaw/deviceAuth.ts create mode 100644 apps/server/src/openclaw/protocol.ts create mode 100644 apps/server/src/openclaw/sessionIdentity.ts create mode 100644 apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts create mode 100644 apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts create mode 100644 apps/server/src/persistence/Services/OpenclawGatewayConfig.ts create mode 100644 apps/server/src/persistence/vault.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4abc32460..43c4b80db 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,11 +10,45 @@ import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; import { NetService } from "@okcode/shared/Net"; import { FetchHttpClient } from "effect/unstable/http"; +import { OpenclawGatewayConfig } from "./persistence/Services/OpenclawGatewayConfig"; const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), Layer.provideMerge(ServerLive), Layer.provideMerge(OpenLive), + Layer.provideMerge( + Layer.succeed(OpenclawGatewayConfig, { + getSummary: () => + Effect.succeed({ + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }), + getStored: () => Effect.succeed(null), + save: () => Effect.die("unexpected openclaw save"), + resolveForConnect: () => Effect.succeed(null), + saveDeviceToken: () => Effect.void, + clearDeviceToken: () => Effect.void, + resetDeviceState: () => + Effect.succeed({ + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }), + }), + ), Layer.provideMerge(NetService.layer), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(FetchHttpClient.layer), diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 59c1af0ee..79c3c4a3d 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -15,6 +15,7 @@ import { NetService } from "@okcode/shared/Net"; import { CliConfig, okcodeCli, type CliConfigShape } from "./main"; import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; +import { OpenclawGatewayConfig } from "./persistence/Services/OpenclawGatewayConfig"; import { Server, type ServerShape } from "./wsServer"; const start = vi.fn(() => undefined); @@ -54,6 +55,37 @@ const testLayer = Layer.mergeAll( start: serverStart, stopSignal: Effect.void, } satisfies ServerShape), + Layer.succeed(OpenclawGatewayConfig, { + getSummary: () => + Effect.succeed({ + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }), + getStored: () => Effect.succeed(null), + save: () => Effect.die("unexpected openclaw save"), + resolveForConnect: () => Effect.succeed(null), + saveDeviceToken: () => Effect.void, + clearDeviceToken: () => Effect.void, + resetDeviceState: () => + Effect.succeed({ + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }), + }), Layer.succeed(Open, { openBrowser: (_target: string) => Effect.void, openInEditor: () => Effect.void, diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index ad7c34f8c..2a9747dc8 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -20,6 +20,7 @@ import { import { fixPath, resolveBaseDir } from "./os-jank"; import { Open } from "./open"; import * as SqlitePersistence from "./persistence/Layers/Sqlite"; +import { OpenclawGatewayConfigLive } from "./persistence/Layers/OpenclawGatewayConfig"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { Server } from "./wsServer"; @@ -194,6 +195,7 @@ const LayerLive = (input: CliInput) => Layer.empty.pipe( Layer.provideMerge(makeServerRuntimeServicesLayer()), Layer.provideMerge(makeServerProviderLayer()), + Layer.provideMerge(OpenclawGatewayConfigLive), Layer.provideMerge(ProviderHealthLive), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), diff --git a/apps/server/src/openclaw/GatewayClient.ts b/apps/server/src/openclaw/GatewayClient.ts new file mode 100644 index 000000000..734bbee23 --- /dev/null +++ b/apps/server/src/openclaw/GatewayClient.ts @@ -0,0 +1,553 @@ +import NodeWebSocket from "ws"; + +import type { OpenclawDeviceIdentity } from "./deviceAuth.ts"; +import { signOpenclawDeviceChallenge } from "./deviceAuth.ts"; +import { + assertRequiredMethods, + extractHelloMethods, + extractHelloPayload, + formatGatewayError, + OPENCLAW_OPERATOR_SCOPES, + OPENCLAW_PROTOCOL_VERSION, + parseGatewayError, + parseGatewayFrame, + readString, + type GatewayFrame, + type OpenclawHelloAuth, + type OpenclawHelloPayload, + type ParsedGatewayError, +} from "./protocol.ts"; + +const WS_CONNECT_TIMEOUT_MS = 10_000; +const REQUEST_TIMEOUT_MS = 30_000; + +export interface OpenclawGatewayClientOptions { + readonly url: string; + readonly identity: OpenclawDeviceIdentity; + readonly sharedSecret?: string; + readonly deviceToken?: string; + readonly deviceTokenRole?: string; + readonly deviceTokenScopes?: ReadonlyArray; + readonly clientId: string; + readonly clientVersion: string; + readonly clientPlatform: string; + readonly clientMode: string; + readonly locale: string; + readonly userAgent: string; + readonly role?: string; + readonly scopes?: ReadonlyArray; + readonly requiredMethods?: ReadonlyArray; +} + +export interface OpenclawGatewayConnectResult { + readonly hello: OpenclawHelloPayload | undefined; + readonly auth: OpenclawHelloAuth | undefined; + readonly methods: Set; + readonly usedStoredDeviceToken: boolean; +} + +interface PendingRequest { + readonly method: string; + readonly resolve: (payload: unknown) => void; + readonly reject: (error: unknown) => void; + readonly timeout: ReturnType; +} + +interface PendingEventWaiter { + readonly eventName: string; + readonly resolve: (payload: Record | undefined) => void; + readonly reject: (error: unknown) => void; + readonly timeout: ReturnType; +} + +export class OpenclawGatewayClientError extends Error { + readonly gatewayError: ParsedGatewayError | undefined; + readonly socketCloseCode: number | undefined; + readonly socketCloseReason: string | undefined; + + constructor( + message: string, + options?: { + readonly gatewayError?: ParsedGatewayError; + readonly socketCloseCode?: number; + readonly socketCloseReason?: string; + readonly cause?: unknown; + }, + ) { + super(message, options?.cause !== undefined ? { cause: options.cause } : undefined); + this.name = "OpenclawGatewayClientError"; + this.gatewayError = options?.gatewayError; + this.socketCloseCode = options?.socketCloseCode; + this.socketCloseReason = options?.socketCloseReason; + } +} + +function uniqueScopes(scopes: ReadonlyArray | undefined): string[] { + const values = new Set(); + for (const scope of scopes ?? []) { + const trimmed = scope.trim(); + if (trimmed.length > 0) { + values.add(trimmed); + } + } + return [...values]; +} + +function closeDetail(code: number | undefined, reason: string | undefined): string { + if (code === undefined) { + return ""; + } + return reason && reason.length > 0 ? ` (code ${code}: ${reason})` : ` (code ${code})`; +} + +function clientErrorOptions(input: { + readonly gatewayError: ParsedGatewayError | undefined; + readonly socketCloseCode: number | undefined; + readonly socketCloseReason: string | undefined; + readonly cause: unknown; +}) { + return { + ...(input.gatewayError !== undefined ? { gatewayError: input.gatewayError } : {}), + ...(input.socketCloseCode !== undefined ? { socketCloseCode: input.socketCloseCode } : {}), + ...(input.socketCloseReason !== undefined + ? { socketCloseReason: input.socketCloseReason } + : {}), + ...(input.cause !== undefined ? { cause: input.cause } : {}), + }; +} + +export class OpenclawGatewayClient { + static async connect(options: OpenclawGatewayClientOptions): Promise<{ + client: OpenclawGatewayClient; + connect: OpenclawGatewayConnectResult; + }> { + const client = new OpenclawGatewayClient(options); + try { + const connectResult = await client.connectInternal(); + return { client, connect: connectResult }; + } catch (error) { + await client.close(); + throw error; + } + } + + private readonly options: OpenclawGatewayClientOptions; + private ws: NodeWebSocket | null = null; + private nextRequestId = 1; + private closed = false; + private closeCode: number | undefined = undefined; + private closeReason: string | undefined = undefined; + private readonly pendingRequests = new Map(); + private readonly pendingEventWaiters = new Set(); + private readonly bufferedEvents: GatewayFrame[] = []; + private readonly eventListeners = new Set<(event: GatewayFrame) => void>(); + private readonly closeListeners = new Set<(error?: OpenclawGatewayClientError) => void>(); + + readonly methods = new Set(); + hello: OpenclawHelloPayload | undefined = undefined; + auth: OpenclawHelloAuth | undefined = undefined; + + private constructor(options: OpenclawGatewayClientOptions) { + this.options = options; + } + + onEvent(listener: (event: GatewayFrame) => void): () => void { + this.eventListeners.add(listener); + return () => { + this.eventListeners.delete(listener); + }; + } + + onClose(listener: (error?: OpenclawGatewayClientError) => void): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + async request(method: string, params?: Record, timeoutMs = REQUEST_TIMEOUT_MS) { + const socket = this.ws; + if (!socket || socket.readyState !== NodeWebSocket.OPEN) { + throw new OpenclawGatewayClientError(`WebSocket is not open for request '${method}'.`, { + ...clientErrorOptions({ + gatewayError: undefined, + socketCloseCode: this.closeCode, + socketCloseReason: this.closeReason, + cause: undefined, + }), + }); + } + + const id = String(this.nextRequestId++); + const payload = JSON.stringify({ + type: "req", + id, + method, + ...(params !== undefined ? { params } : {}), + }); + + return await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingRequests.delete(id); + reject( + new OpenclawGatewayClientError( + `Gateway request '${method}' timed out after ${timeoutMs}ms.`, + clientErrorOptions({ + gatewayError: undefined, + socketCloseCode: this.closeCode, + socketCloseReason: this.closeReason, + cause: undefined, + }), + ), + ); + }, timeoutMs); + + this.pendingRequests.set(id, { + method, + resolve, + reject, + timeout, + }); + + try { + socket.send(payload); + } catch (cause) { + clearTimeout(timeout); + this.pendingRequests.delete(id); + reject( + new OpenclawGatewayClientError(`Failed to send gateway request '${method}'.`, { + ...clientErrorOptions({ + gatewayError: undefined, + cause, + socketCloseCode: this.closeCode, + socketCloseReason: this.closeReason, + }), + }), + ); + } + }); + } + + async waitForEvent(eventName: string, timeoutMs = REQUEST_TIMEOUT_MS) { + const bufferedIndex = this.bufferedEvents.findIndex( + (event) => event.type === "event" && event.event === eventName, + ); + if (bufferedIndex >= 0) { + const [event] = this.bufferedEvents.splice(bufferedIndex, 1); + if (event) { + return this.framePayload(event); + } + } + + return await new Promise | undefined>((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingEventWaiters.delete(waiter); + reject( + new OpenclawGatewayClientError( + `Gateway event '${eventName}' timed out after ${timeoutMs}ms.`, + clientErrorOptions({ + gatewayError: undefined, + socketCloseCode: this.closeCode, + socketCloseReason: this.closeReason, + cause: undefined, + }), + ), + ); + }, timeoutMs); + + const waiter: PendingEventWaiter = { + eventName, + resolve: (payload) => { + clearTimeout(timeout); + resolve(payload); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + timeout, + }; + this.pendingEventWaiters.add(waiter); + }); + } + + async close(): Promise { + this.closed = true; + const socket = this.ws; + this.ws = null; + if (!socket) { + return; + } + if (socket.readyState === NodeWebSocket.CLOSED || socket.readyState === NodeWebSocket.CLOSING) { + return; + } + await new Promise((resolve) => { + socket.once("close", () => resolve()); + socket.close(); + }); + } + + private async connectInternal(): Promise { + const canUseStoredDeviceToken = + typeof this.options.deviceToken === "string" && this.options.deviceToken.length > 0; + + try { + return await this.performConnectAttempt("shared"); + } catch (error) { + const parsedError = + error instanceof OpenclawGatewayClientError ? error.gatewayError : undefined; + const shouldRetryWithDeviceToken = + canUseStoredDeviceToken && + parsedError?.canRetryWithDeviceToken === true && + this.options.sharedSecret !== undefined; + if (!shouldRetryWithDeviceToken) { + throw error; + } + + await this.closeCurrentSocket(); + return await this.performConnectAttempt("deviceToken"); + } + } + + private async performConnectAttempt( + authMode: "shared" | "deviceToken", + ): Promise { + await this.openSocket(); + const challenge = await this.waitForEvent("connect.challenge"); + const nonce = readString(challenge?.nonce); + if (!nonce) { + throw new OpenclawGatewayClientError("Gateway challenge did not include a nonce."); + } + + const signedAt = + typeof challenge?.ts === "number" && Number.isFinite(challenge.ts) + ? challenge.ts + : Date.now(); + const role = this.options.role ?? "operator"; + const scopes = + authMode === "deviceToken" && uniqueScopes(this.options.deviceTokenScopes).length > 0 + ? uniqueScopes(this.options.deviceTokenScopes) + : uniqueScopes(this.options.scopes ?? OPENCLAW_OPERATOR_SCOPES); + const authToken = + authMode === "deviceToken" + ? (this.options.deviceToken ?? "") + : (this.options.sharedSecret ?? ""); + const signedDevice = signOpenclawDeviceChallenge(this.options.identity, { + clientId: this.options.clientId, + clientMode: this.options.clientMode, + role, + scopes, + token: authToken, + nonce, + signedAt, + }); + + const helloPayload = await this.request("connect", { + minProtocol: OPENCLAW_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_PROTOCOL_VERSION, + client: { + id: this.options.clientId, + version: this.options.clientVersion, + platform: this.options.clientPlatform, + mode: this.options.clientMode, + }, + role, + scopes, + caps: [], + commands: [], + permissions: {}, + ...(authMode === "shared" && authToken.length > 0 ? { auth: { token: authToken } } : {}), + ...(authMode === "deviceToken" && authToken.length > 0 + ? { auth: { deviceToken: authToken } } + : {}), + locale: this.options.locale, + userAgent: this.options.userAgent, + device: signedDevice, + }); + + const hello = extractHelloPayload(helloPayload); + const methods = extractHelloMethods(hello); + if (this.options.requiredMethods && this.options.requiredMethods.length > 0) { + assertRequiredMethods(methods, this.options.requiredMethods); + } + + this.hello = hello; + this.auth = hello?.auth; + this.methods.clear(); + for (const method of methods) { + this.methods.add(method); + } + + return { + hello, + auth: hello?.auth, + methods, + usedStoredDeviceToken: authMode === "deviceToken", + }; + } + + private framePayload(frame: GatewayFrame): Record | undefined { + return typeof frame.payload === "object" && frame.payload !== null + ? (frame.payload as Record) + : undefined; + } + + private async openSocket(): Promise { + await this.closeCurrentSocket(); + this.closeCode = undefined; + this.closeReason = undefined; + this.closed = false; + + this.ws = await new Promise((resolve, reject) => { + const socket = new NodeWebSocket(this.options.url); + const timeout = setTimeout(() => { + socket.close(); + reject( + new OpenclawGatewayClientError( + `WebSocket connection to ${this.options.url} timed out after ${WS_CONNECT_TIMEOUT_MS}ms.`, + ), + ); + }, WS_CONNECT_TIMEOUT_MS); + + socket.on("open", () => { + clearTimeout(timeout); + resolve(socket); + }); + socket.on("error", (cause) => { + clearTimeout(timeout); + reject( + new OpenclawGatewayClientError( + `WebSocket connection to ${this.options.url} failed: ${cause instanceof Error ? cause.message : String(cause)}`, + clientErrorOptions({ + gatewayError: undefined, + socketCloseCode: undefined, + socketCloseReason: undefined, + cause, + }), + ), + ); + }); + this.attachSocketHandlers(socket); + }); + } + + private attachSocketHandlers(socket: NodeWebSocket) { + socket.on("message", (data) => { + const frame = parseGatewayFrame(data); + if (!frame) { + return; + } + + if (frame.type === "res" && frame.id !== undefined && frame.id !== null) { + const pending = this.pendingRequests.get(String(frame.id)); + if (!pending) { + return; + } + clearTimeout(pending.timeout); + this.pendingRequests.delete(String(frame.id)); + if (frame.ok === true) { + pending.resolve(frame.payload); + return; + } + const gatewayError = parseGatewayError(frame.error); + pending.reject( + new OpenclawGatewayClientError( + formatGatewayError(gatewayError), + clientErrorOptions({ + gatewayError, + socketCloseCode: this.closeCode, + socketCloseReason: this.closeReason, + cause: undefined, + }), + ), + ); + return; + } + + if (frame.type === "event" && typeof frame.event === "string") { + let matchedWaiter = false; + for (const waiter of [...this.pendingEventWaiters]) { + if (waiter.eventName === frame.event) { + matchedWaiter = true; + this.pendingEventWaiters.delete(waiter); + waiter.resolve(this.framePayload(frame)); + } + } + if (!matchedWaiter) { + this.bufferedEvents.push(frame); + } + } + + for (const listener of this.eventListeners) { + listener(frame); + } + }); + + socket.on("close", (code, reasonBuffer) => { + this.closeCode = code; + const reason = reasonBuffer.toString("utf8"); + this.closeReason = reason.length > 0 ? reason : undefined; + const error = + this.closed || (code === 1000 && !this.closeReason) + ? undefined + : new OpenclawGatewayClientError( + `WebSocket closed before the gateway exchange completed${closeDetail(code, this.closeReason)}.`, + clientErrorOptions({ + gatewayError: undefined, + socketCloseCode: code, + socketCloseReason: this.closeReason, + cause: undefined, + }), + ); + + for (const [, pending] of this.pendingRequests) { + clearTimeout(pending.timeout); + pending.reject( + error ?? + new OpenclawGatewayClientError(`Gateway request '${pending.method}' was interrupted.`), + ); + } + this.pendingRequests.clear(); + + for (const waiter of this.pendingEventWaiters) { + clearTimeout(waiter.timeout); + waiter.reject( + error ?? + new OpenclawGatewayClientError( + `Gateway event '${waiter.eventName}' was interrupted.`, + clientErrorOptions({ + gatewayError: undefined, + socketCloseCode: code, + socketCloseReason: this.closeReason, + cause: undefined, + }), + ), + ); + } + this.pendingEventWaiters.clear(); + + for (const listener of this.closeListeners) { + listener(error); + } + }); + } + + private async closeCurrentSocket() { + if (!this.ws) { + return; + } + const socket = this.ws; + this.ws = null; + await new Promise((resolve) => { + if ( + socket.readyState === NodeWebSocket.CLOSED || + socket.readyState === NodeWebSocket.CLOSING + ) { + resolve(); + return; + } + socket.once("close", () => resolve()); + socket.close(); + }); + } +} diff --git a/apps/server/src/openclaw/deviceAuth.ts b/apps/server/src/openclaw/deviceAuth.ts new file mode 100644 index 000000000..0e8142188 --- /dev/null +++ b/apps/server/src/openclaw/deviceAuth.ts @@ -0,0 +1,82 @@ +import { createHash, createPrivateKey, generateKeyPairSync, sign } from "node:crypto"; + +export interface OpenclawDeviceIdentity { + readonly deviceId: string; + readonly deviceFingerprint: string; + readonly publicKey: string; + readonly privateKeyPem: string; +} + +export interface OpenclawSignedDeviceIdentity { + readonly id: string; + readonly publicKey: string; + readonly signature: string; + readonly signedAt: number; + readonly nonce: string; +} + +export interface OpenclawDeviceSigningParams { + readonly clientId: string; + readonly clientMode: string; + readonly role: string; + readonly scopes: ReadonlyArray; + readonly token: string; + readonly nonce: string; + readonly signedAt: number; +} + +function toBase64Url(buffer: Buffer): string { + return buffer.toString("base64url"); +} + +function decodeBase64Url(value: string): Buffer { + return Buffer.from(value, "base64url"); +} + +export function generateOpenclawDeviceIdentity(): OpenclawDeviceIdentity { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const publicJwk = publicKey.export({ format: "jwk" }); + if (typeof publicJwk.x !== "string") { + throw new Error("Failed to export OpenClaw device public key."); + } + + const rawPublicKey = decodeBase64Url(publicJwk.x); + const fingerprint = createHash("sha256").update(rawPublicKey).digest("hex"); + + return { + deviceId: fingerprint, + deviceFingerprint: fingerprint, + publicKey: toBase64Url(rawPublicKey), + privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(), + }; +} + +export function signOpenclawDeviceChallenge( + identity: OpenclawDeviceIdentity, + params: OpenclawDeviceSigningParams, +): OpenclawSignedDeviceIdentity { + const payload = [ + "v2", + identity.deviceId, + params.clientId, + params.clientMode, + params.role, + [...params.scopes].join(","), + String(params.signedAt), + params.token, + params.nonce, + ].join("|"); + + const signature = sign( + null, + Buffer.from(payload, "utf8"), + createPrivateKey(identity.privateKeyPem), + ); + return { + id: identity.deviceId, + publicKey: identity.publicKey, + signature: toBase64Url(signature), + signedAt: params.signedAt, + nonce: params.nonce, + }; +} diff --git a/apps/server/src/openclaw/protocol.ts b/apps/server/src/openclaw/protocol.ts new file mode 100644 index 000000000..76755fc54 --- /dev/null +++ b/apps/server/src/openclaw/protocol.ts @@ -0,0 +1,155 @@ +import type NodeWebSocket from "ws"; + +export const OPENCLAW_PROTOCOL_VERSION = 3; +export const OPENCLAW_OPERATOR_SCOPES = ["operator.read", "operator.write"] as const; + +export type GatewayFrame = { + type?: unknown; + id?: unknown; + ok?: unknown; + method?: unknown; + event?: unknown; + payload?: unknown; + error?: { + code?: unknown; + message?: unknown; + details?: unknown; + }; +}; + +export interface ParsedGatewayError { + readonly message: string; + readonly code: string | undefined; + readonly detailCode: string | undefined; + readonly detailReason: string | undefined; + readonly recommendedNextStep: string | undefined; + readonly canRetryWithDeviceToken: boolean | undefined; +} + +export interface OpenclawHelloAuth { + readonly deviceToken: string | undefined; + readonly role: string | undefined; + readonly scopes: ReadonlyArray; +} + +export interface OpenclawHelloPayload { + readonly type: string | undefined; + readonly protocol: number | undefined; + readonly auth: OpenclawHelloAuth | undefined; + readonly features: + | { + readonly methods: ReadonlyArray | undefined; + } + | undefined; +} + +export function bufferToString(data: NodeWebSocket.Data): string { + if (typeof data === "string") return data; + if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8"); + if (Array.isArray(data)) return Buffer.concat(data).toString("utf8"); + return data.toString("utf8"); +} + +export function parseGatewayFrame(data: NodeWebSocket.Data): GatewayFrame | null { + try { + const parsed = JSON.parse(bufferToString(data)); + if (typeof parsed === "object" && parsed !== null) { + return parsed as GatewayFrame; + } + } catch { + // Ignore non-JSON frames. + } + return null; +} + +export function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function readBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function parseGatewayError(error: GatewayFrame["error"]): ParsedGatewayError { + const details = + typeof error?.details === "object" && error.details !== null + ? (error.details as Record) + : undefined; + return { + message: readString(error?.message) ?? "Gateway request failed.", + code: + typeof error?.code === "string" || typeof error?.code === "number" + ? String(error.code) + : undefined, + detailCode: readString(details?.code), + detailReason: readString(details?.reason), + recommendedNextStep: readString(details?.recommendedNextStep), + canRetryWithDeviceToken: readBoolean(details?.canRetryWithDeviceToken), + }; +} + +export function formatGatewayError(error: ParsedGatewayError): string { + const details = [ + error.code ? `code ${error.code}` : null, + error.detailCode ? `detail ${error.detailCode}` : null, + error.detailReason ? `reason ${error.detailReason}` : null, + error.recommendedNextStep ? `next ${error.recommendedNextStep}` : null, + error.canRetryWithDeviceToken ? "device-token retry available" : null, + ].filter((detail): detail is string => detail !== null); + return details.length > 0 ? `${error.message} (${details.join(", ")})` : error.message; +} + +export function extractHelloPayload(payload: unknown): OpenclawHelloPayload | undefined { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return undefined; + } + + const record = payload as Record; + const authRecord = + record.auth && typeof record.auth === "object" && !Array.isArray(record.auth) + ? (record.auth as Record) + : undefined; + const featuresRecord = + record.features && typeof record.features === "object" && !Array.isArray(record.features) + ? (record.features as Record) + : undefined; + const methods = + Array.isArray(featuresRecord?.methods) && + featuresRecord?.methods.every((item) => typeof item === "string") + ? (featuresRecord.methods as string[]) + : undefined; + + const type = readString(record.type); + const protocol = typeof record.protocol === "number" ? record.protocol : undefined; + const deviceToken = readString(authRecord?.deviceToken); + const role = readString(authRecord?.role); + + return { + type, + protocol, + auth: authRecord + ? { + deviceToken, + role, + scopes: Array.isArray(authRecord.scopes) + ? authRecord.scopes.filter((scope): scope is string => typeof scope === "string") + : [], + } + : undefined, + features: methods ? { methods } : undefined, + }; +} + +export function extractHelloMethods(hello: OpenclawHelloPayload | undefined): Set { + return new Set(hello?.features?.methods ?? []); +} + +export function assertRequiredMethods( + methods: Set, + requiredMethods: ReadonlyArray, +): void { + const missing = requiredMethods.filter((method) => !methods.has(method)); + if (missing.length > 0) { + throw new Error(`Gateway is missing required methods: ${missing.join(", ")}`); + } +} diff --git a/apps/server/src/openclaw/sessionIdentity.ts b/apps/server/src/openclaw/sessionIdentity.ts new file mode 100644 index 000000000..04af2f73b --- /dev/null +++ b/apps/server/src/openclaw/sessionIdentity.ts @@ -0,0 +1,34 @@ +export type OpenclawSessionIdentityKind = "sessionKey" | "key" | "sessionId" | "id"; + +export interface OpenclawSessionIdentity { + readonly kind: OpenclawSessionIdentityKind; + readonly value: string; +} + +const SESSION_IDENTITY_FIELDS: readonly OpenclawSessionIdentityKind[] = [ + "sessionKey", + "key", + "sessionId", + "id", +]; + +export function normalizeOpenclawSessionIdentity( + value: unknown, +): OpenclawSessionIdentity | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return { kind: "sessionKey", value: value.trim() }; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + for (const field of SESSION_IDENTITY_FIELDS) { + const candidate = record[field]; + if (typeof candidate === "string" && candidate.trim().length > 0) { + return { kind: field, value: candidate.trim() }; + } + } + + return undefined; +} diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index a80c30597..f6b5d69da 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -489,7 +489,7 @@ export async function runOpenclawGatewayTest( try { const urlStart = Date.now(); - const gatewayUrl = input.gatewayUrl.trim(); + const gatewayUrl = input.gatewayUrl?.trim() ?? ""; const sharedSecret = input.password?.trim() || undefined; if (!gatewayUrl) { pushStep("URL validation", "fail", Date.now() - urlStart, "Gateway URL is empty."); diff --git a/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts b/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts new file mode 100644 index 000000000..26ec67d32 --- /dev/null +++ b/apps/server/src/persistence/Layers/OpenclawGatewayConfig.ts @@ -0,0 +1,482 @@ +import type { + OpenclawGatewayConfigSummary, + ResetOpenclawGatewayDeviceStateInput, + SaveOpenclawGatewayConfigInput, +} from "@okcode/contracts"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Option, Schema } from "effect"; +import path from "node:path"; + +import { ServerConfig } from "../../config.ts"; +import { generateOpenclawDeviceIdentity } from "../../openclaw/deviceAuth.ts"; +import { + PersistenceCryptoError, + toPersistenceCryptoError, + toPersistenceDecodeError, + toPersistenceSqlError, +} from "../Errors.ts"; +import { + OpenclawGatewayConfig, + type OpenclawGatewayConfigError, + type OpenclawGatewayStoredConfig, + type ResolveOpenclawGatewayConfigInput, + type SaveOpenclawDeviceTokenInput, +} from "../Services/OpenclawGatewayConfig.ts"; +import { decodeVaultPayload, encodeVaultPayload, readOrCreateVaultKey } from "../vault.ts"; + +const OPENCLAW_CONFIG_ID = "default"; + +const OpenclawGatewayConfigRow = Schema.Struct({ + configId: Schema.String, + gatewayUrl: Schema.String, + encryptedSharedSecret: Schema.NullOr(Schema.String), + deviceId: Schema.String, + devicePublicKey: Schema.String, + deviceFingerprint: Schema.String, + encryptedDevicePrivateKey: Schema.String, + encryptedDeviceToken: Schema.NullOr(Schema.String), + deviceTokenRole: Schema.NullOr(Schema.String), + deviceTokenScopesJson: Schema.String, + createdAt: Schema.String, + updatedAt: Schema.String, +}); + +const GetOpenclawGatewayConfigRequest = Schema.Struct({ + configId: Schema.String, +}); + +function emptySummary(): OpenclawGatewayConfigSummary { + return { + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }; +} + +function normalizeScopes(scopes: ReadonlyArray | undefined): string[] { + const unique = new Set(); + for (const scope of scopes ?? []) { + const trimmed = scope.trim(); + if (trimmed.length > 0) { + unique.add(trimmed); + } + } + return [...unique].sort((left, right) => left.localeCompare(right)); +} + +function fromGeneratedIdentity(identity: ReturnType) { + return { + deviceId: identity.deviceId, + devicePublicKey: identity.publicKey, + deviceFingerprint: identity.deviceFingerprint, + devicePrivateKeyPem: identity.privateKeyPem, + }; +} + +function makeStoredConfig(input: { + readonly gatewayUrl: string; + readonly sharedSecret: string | undefined; + readonly deviceId: string; + readonly devicePublicKey: string; + readonly deviceFingerprint: string; + readonly devicePrivateKeyPem: string; + readonly deviceToken: string | undefined; + readonly deviceTokenRole: string | undefined; + readonly deviceTokenScopes: ReadonlyArray; + readonly updatedAt: string; +}): OpenclawGatewayStoredConfig { + return { + gatewayUrl: input.gatewayUrl, + sharedSecret: input.sharedSecret, + deviceId: input.deviceId, + devicePublicKey: input.devicePublicKey, + deviceFingerprint: input.deviceFingerprint, + devicePrivateKeyPem: input.devicePrivateKeyPem, + deviceToken: input.deviceToken, + deviceTokenRole: input.deviceTokenRole, + deviceTokenScopes: normalizeScopes(input.deviceTokenScopes), + updatedAt: input.updatedAt, + }; +} + +function toSummary(config: OpenclawGatewayStoredConfig | null): OpenclawGatewayConfigSummary { + if (!config) { + return emptySummary(); + } + return { + gatewayUrl: config.gatewayUrl, + hasSharedSecret: Boolean(config.sharedSecret), + deviceId: config.deviceId, + devicePublicKey: config.devicePublicKey, + deviceFingerprint: config.deviceFingerprint, + hasDeviceToken: Boolean(config.deviceToken), + deviceTokenRole: config.deviceTokenRole ?? null, + deviceTokenScopes: [...config.deviceTokenScopes], + updatedAt: config.updatedAt, + }; +} + +function toOpenclawGatewayConfigError( + operation: string, + cause: unknown, +): OpenclawGatewayConfigError { + if (Schema.is(PersistenceCryptoError)(cause)) { + return cause; + } + if (Schema.isSchemaError(cause)) { + return toPersistenceDecodeError(operation)(cause); + } + if (cause instanceof Error) { + return new PersistenceCryptoError({ + operation, + detail: cause.message.length > 0 ? cause.message : `Failed to execute ${operation}`, + cause, + }); + } + return toPersistenceCryptoError(operation)(cause); +} + +export const OpenclawGatewayConfigLive = Layer.effect( + OpenclawGatewayConfig, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const { stateDir } = yield* ServerConfig; + const secretKeyPath = path.join(stateDir, "openclaw-vault.key"); + let secretKeyPromise: Promise | null = null; + + const getSecretKey = () => { + if (!secretKeyPromise) { + secretKeyPromise = readOrCreateVaultKey(secretKeyPath).catch((error) => { + secretKeyPromise = null; + throw error; + }); + } + return secretKeyPromise; + }; + + const findRow = SqlSchema.findOneOption({ + Request: GetOpenclawGatewayConfigRequest, + Result: OpenclawGatewayConfigRow, + execute: ({ configId }) => + sql` + SELECT + config_id AS "configId", + gateway_url AS "gatewayUrl", + encrypted_shared_secret AS "encryptedSharedSecret", + device_id AS "deviceId", + device_public_key AS "devicePublicKey", + device_fingerprint AS "deviceFingerprint", + encrypted_device_private_key AS "encryptedDevicePrivateKey", + encrypted_device_token AS "encryptedDeviceToken", + device_token_role AS "deviceTokenRole", + device_token_scopes_json AS "deviceTokenScopesJson", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM openclaw_gateway_config + WHERE config_id = ${configId} + `, + }); + + const upsertRow = SqlSchema.void({ + Request: OpenclawGatewayConfigRow, + execute: (row) => + sql` + INSERT INTO openclaw_gateway_config ( + config_id, + gateway_url, + encrypted_shared_secret, + device_id, + device_public_key, + device_fingerprint, + encrypted_device_private_key, + encrypted_device_token, + device_token_role, + device_token_scopes_json, + created_at, + updated_at + ) VALUES ( + ${row.configId}, + ${row.gatewayUrl}, + ${row.encryptedSharedSecret}, + ${row.deviceId}, + ${row.devicePublicKey}, + ${row.deviceFingerprint}, + ${row.encryptedDevicePrivateKey}, + ${row.encryptedDeviceToken}, + ${row.deviceTokenRole}, + ${row.deviceTokenScopesJson}, + ${row.createdAt}, + ${row.updatedAt} + ) + ON CONFLICT (config_id) + DO UPDATE SET + gateway_url = excluded.gateway_url, + encrypted_shared_secret = excluded.encrypted_shared_secret, + device_id = excluded.device_id, + device_public_key = excluded.device_public_key, + device_fingerprint = excluded.device_fingerprint, + encrypted_device_private_key = excluded.encrypted_device_private_key, + encrypted_device_token = excluded.encrypted_device_token, + device_token_role = excluded.device_token_role, + device_token_scopes_json = excluded.device_token_scopes_json, + updated_at = excluded.updated_at + `, + }); + + const decodeRow = (row: typeof OpenclawGatewayConfigRow.Type) => + Effect.tryPromise({ + try: async () => { + const key = await getSecretKey(); + const deviceTokenScopes = normalizeScopes( + JSON.parse(row.deviceTokenScopesJson) as ReadonlyArray, + ); + const sharedSecret = + row.encryptedSharedSecret !== null + ? decodeVaultPayload({ + key, + aad: ["openclaw", "shared-secret", row.gatewayUrl], + encryptedValue: row.encryptedSharedSecret, + }) + : undefined; + const devicePrivateKeyPem = decodeVaultPayload({ + key, + aad: ["openclaw", "device-private-key", row.deviceId], + encryptedValue: row.encryptedDevicePrivateKey, + }); + const deviceToken = + row.encryptedDeviceToken !== null + ? decodeVaultPayload({ + key, + aad: ["openclaw", "device-token", row.deviceId, row.deviceTokenRole ?? ""], + encryptedValue: row.encryptedDeviceToken, + }) + : undefined; + + return { + gatewayUrl: row.gatewayUrl, + sharedSecret, + deviceId: row.deviceId, + devicePublicKey: row.devicePublicKey, + deviceFingerprint: row.deviceFingerprint, + devicePrivateKeyPem, + deviceToken, + deviceTokenRole: row.deviceTokenRole ?? undefined, + deviceTokenScopes, + updatedAt: row.updatedAt, + } satisfies OpenclawGatewayStoredConfig; + }, + catch: (cause) => toOpenclawGatewayConfigError("OpenclawGatewayConfig.decodeRow", cause), + }); + + const writeConfig = (config: OpenclawGatewayStoredConfig) => + Effect.gen(function* () { + const key = yield* Effect.tryPromise({ + try: () => getSecretKey(), + catch: (cause) => + toOpenclawGatewayConfigError("OpenclawGatewayConfig.writeConfig:key", cause), + }); + const now = new Date().toISOString(); + const row = { + configId: OPENCLAW_CONFIG_ID, + gatewayUrl: config.gatewayUrl, + encryptedSharedSecret: + config.sharedSecret !== undefined + ? encodeVaultPayload({ + key, + aad: ["openclaw", "shared-secret", config.gatewayUrl], + value: config.sharedSecret, + }) + : null, + deviceId: config.deviceId, + devicePublicKey: config.devicePublicKey, + deviceFingerprint: config.deviceFingerprint, + encryptedDevicePrivateKey: encodeVaultPayload({ + key, + aad: ["openclaw", "device-private-key", config.deviceId], + value: config.devicePrivateKeyPem, + }), + encryptedDeviceToken: + config.deviceToken !== undefined + ? encodeVaultPayload({ + key, + aad: ["openclaw", "device-token", config.deviceId, config.deviceTokenRole ?? ""], + value: config.deviceToken, + }) + : null, + deviceTokenRole: config.deviceTokenRole ?? null, + deviceTokenScopesJson: JSON.stringify(normalizeScopes(config.deviceTokenScopes)), + createdAt: now, + updatedAt: now, + }; + yield* upsertRow(row).pipe( + Effect.mapError(toPersistenceSqlError("OpenclawGatewayConfig.writeConfig:query")), + ); + }); + + const getStored = () => + findRow({ configId: OPENCLAW_CONFIG_ID }).pipe( + Effect.mapError(toPersistenceSqlError("OpenclawGatewayConfig.getStored:query")), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(null), + onSome: (row) => decodeRow(row), + }), + ), + ); + + const save = (input: SaveOpenclawGatewayConfigInput) => + Effect.gen(function* () { + const existing = yield* getStored(); + const sharedSecret = input.clearSharedSecret + ? undefined + : input.sharedSecret?.trim() !== undefined && input.sharedSecret.trim().length > 0 + ? input.sharedSecret.trim() + : existing?.sharedSecret; + const generatedIdentity = fromGeneratedIdentity(generateOpenclawDeviceIdentity()); + const identity = existing ?? { + ...generatedIdentity, + deviceToken: undefined, + deviceTokenRole: undefined, + deviceTokenScopes: [], + updatedAt: new Date().toISOString(), + gatewayUrl: input.gatewayUrl, + sharedSecret, + }; + const nextConfig = makeStoredConfig({ + gatewayUrl: input.gatewayUrl, + sharedSecret, + deviceId: identity.deviceId, + devicePublicKey: identity.devicePublicKey, + deviceFingerprint: identity.deviceFingerprint, + devicePrivateKeyPem: identity.devicePrivateKeyPem, + deviceToken: identity.deviceToken, + deviceTokenRole: identity.deviceTokenRole, + deviceTokenScopes: identity.deviceTokenScopes, + updatedAt: new Date().toISOString(), + }); + yield* writeConfig(nextConfig); + return toSummary(nextConfig); + }); + + const saveDeviceToken = (input: SaveOpenclawDeviceTokenInput) => + Effect.gen(function* () { + const existing = yield* getStored(); + if (!existing) { + return; + } + yield* writeConfig( + makeStoredConfig({ + ...existing, + deviceToken: input.deviceToken, + deviceTokenRole: input.role ?? existing.deviceTokenRole, + deviceTokenScopes: input.scopes ?? existing.deviceTokenScopes, + updatedAt: new Date().toISOString(), + }), + ); + }); + + const clearDeviceToken = () => + Effect.gen(function* () { + const existing = yield* getStored(); + if (!existing) { + return; + } + yield* writeConfig( + makeStoredConfig({ + ...existing, + deviceToken: undefined, + deviceTokenRole: undefined, + deviceTokenScopes: [], + updatedAt: new Date().toISOString(), + }), + ); + }); + + const resetDeviceState = (input?: ResetOpenclawGatewayDeviceStateInput) => + Effect.gen(function* () { + const existing = yield* getStored(); + if (!existing) { + return emptySummary(); + } + const regenerateIdentity = input?.regenerateIdentity ?? true; + const nextIdentity = regenerateIdentity + ? fromGeneratedIdentity(generateOpenclawDeviceIdentity()) + : existing; + const nextConfig = makeStoredConfig({ + gatewayUrl: existing.gatewayUrl, + sharedSecret: existing.sharedSecret, + deviceId: nextIdentity.deviceId, + devicePublicKey: nextIdentity.devicePublicKey, + deviceFingerprint: nextIdentity.deviceFingerprint, + devicePrivateKeyPem: nextIdentity.devicePrivateKeyPem, + deviceToken: undefined, + deviceTokenRole: undefined, + deviceTokenScopes: [], + updatedAt: new Date().toISOString(), + }); + yield* writeConfig(nextConfig); + return toSummary(nextConfig); + }); + + const resolveForConnect = (input?: ResolveOpenclawGatewayConfigInput) => + Effect.gen(function* () { + const existing = yield* getStored(); + if (!existing) { + const gatewayUrl = input?.gatewayUrl?.trim(); + if (!gatewayUrl) { + return null; + } + if (!input?.allowEphemeralIdentity) { + return null; + } + const identity = fromGeneratedIdentity(generateOpenclawDeviceIdentity()); + const sharedSecret = + input.sharedSecret?.trim() && input.sharedSecret.trim().length > 0 + ? input.sharedSecret.trim() + : undefined; + return makeStoredConfig({ + gatewayUrl, + sharedSecret, + deviceId: identity.deviceId, + devicePublicKey: identity.devicePublicKey, + deviceFingerprint: identity.deviceFingerprint, + devicePrivateKeyPem: identity.devicePrivateKeyPem, + deviceToken: undefined, + deviceTokenRole: undefined, + deviceTokenScopes: [], + updatedAt: new Date().toISOString(), + }); + } + + const gatewayUrl = input?.gatewayUrl?.trim() || existing.gatewayUrl; + const sharedSecret = + input?.sharedSecret?.trim() && input.sharedSecret.trim().length > 0 + ? input.sharedSecret.trim() + : existing.sharedSecret; + return makeStoredConfig({ + ...existing, + gatewayUrl, + sharedSecret, + }); + }); + + const getSummary = () => getStored().pipe(Effect.map(toSummary)); + + return { + getSummary, + getStored, + save, + resolveForConnect, + saveDeviceToken, + clearDeviceToken, + resetDeviceState, + }; + }), +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 96696db2c..ad7a15009 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,9 +32,7 @@ import Migration0017 from "./Migrations/017_EnvironmentVariables.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts"; import Migration0019 from "./Migrations/019_SmeKnowledgeBase.ts"; import Migration0020 from "./Migrations/020_SmeConversationProviderAuth.ts"; -import Migration0021 from "./Migrations/021_ProjectionPendingUserInputs.ts"; -import Migration0022 from "./Migrations/022_DecisionWorkspace.ts"; -import Migration0023 from "./Migrations/023_ProjectionPendingUserInputsBackfill.ts"; +import Migration0021 from "./Migrations/021_OpenclawGatewayConfig.ts"; import { Effect } from "effect"; /** @@ -68,9 +66,7 @@ const loader = Migrator.fromRecord({ "18_ProjectionThreadsGithubRef": Migration0018, "19_SmeKnowledgeBase": Migration0019, "20_SmeConversationProviderAuth": Migration0020, - "21_ProjectionPendingUserInputs": Migration0021, - "22_DecisionWorkspace": Migration0022, - "23_ProjectionPendingUserInputsBackfill": Migration0023, + "21_OpenclawGatewayConfig": Migration0021, }); /** diff --git a/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts b/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts new file mode 100644 index 000000000..a09c92b8f --- /dev/null +++ b/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts @@ -0,0 +1,23 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS openclaw_gateway_config ( + config_id TEXT PRIMARY KEY, + gateway_url TEXT NOT NULL, + encrypted_shared_secret TEXT NULL, + device_id TEXT NOT NULL, + device_public_key TEXT NOT NULL, + device_fingerprint TEXT NOT NULL, + encrypted_device_private_key TEXT NOT NULL, + encrypted_device_token TEXT NULL, + device_token_role TEXT NULL, + device_token_scopes_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Services/EnvironmentVariables.ts b/apps/server/src/persistence/Services/EnvironmentVariables.ts index 2b26065ce..678bb4eae 100644 --- a/apps/server/src/persistence/Services/EnvironmentVariables.ts +++ b/apps/server/src/persistence/Services/EnvironmentVariables.ts @@ -7,8 +7,6 @@ * * @module EnvironmentVariables */ -import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import { @@ -32,6 +30,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError, } from "../Errors.ts"; +import { decodeVaultPayload, encodeVaultPayload, readOrCreateVaultKey } from "../vault.ts"; export interface EnvironmentVariablesShape { readonly getGlobal: () => Effect.Effect< @@ -62,10 +61,6 @@ export type EnvironmentVariablesError = | PersistenceDecodeError | PersistenceCryptoError; -const SECRET_PAYLOAD_VERSION = "v1"; -const SECRET_KEY_BYTES = 32; -const SECRET_IV_BYTES = 12; - const GlobalEnvironmentVariableRow = Schema.Struct({ key: Schema.String, encryptedValue: Schema.String, @@ -111,18 +106,11 @@ function encodeSecretPayload(input: { readonly envKey: string; readonly value: string; }): string { - const iv = randomBytes(SECRET_IV_BYTES); - const cipher = createCipheriv("aes-256-gcm", input.key, iv); - cipher.setAAD(Buffer.from([input.scope, input.projectId ?? "", input.envKey].join("\0"), "utf8")); - - const ciphertext = Buffer.concat([cipher.update(input.value, "utf8"), cipher.final()]); - const authTag = cipher.getAuthTag(); - return [ - SECRET_PAYLOAD_VERSION, - iv.toString("base64"), - authTag.toString("base64"), - ciphertext.toString("base64"), - ].join(":"); + return encodeVaultPayload({ + key: input.key, + aad: [input.scope, input.projectId ?? "", input.envKey], + value: input.value, + }); } function decodeSecretPayload(input: { @@ -132,63 +120,11 @@ function decodeSecretPayload(input: { readonly envKey: string; readonly encryptedValue: string; }): string { - const parts = input.encryptedValue.split(":"); - if (parts.length !== 4 || parts[0] !== SECRET_PAYLOAD_VERSION) { - throw new Error("Unsupported secret payload version."); - } - - const [, ivRaw, authTagRaw, ciphertextRaw] = parts; - const iv = Buffer.from(ivRaw ?? "", "base64"); - const authTag = Buffer.from(authTagRaw ?? "", "base64"); - const ciphertext = Buffer.from(ciphertextRaw ?? "", "base64"); - if (iv.byteLength !== SECRET_IV_BYTES || authTag.byteLength !== 16) { - throw new Error("Invalid encrypted payload."); - } - - const decipher = createDecipheriv("aes-256-gcm", input.key, iv); - decipher.setAAD( - Buffer.from([input.scope, input.projectId ?? "", input.envKey].join("\0"), "utf8"), - ); - decipher.setAuthTag(authTag); - return `${decipher.update(ciphertext, undefined, "utf8")}${decipher.final("utf8")}`; -} - -async function readOrCreateSecretKey(secretKeyPath: string): Promise { - try { - const existing = await fs.readFile(secretKeyPath, "utf8"); - const decoded = Buffer.from(existing.trim(), "base64"); - if (decoded.byteLength !== SECRET_KEY_BYTES) { - throw new Error("Invalid vault key length."); - } - return decoded; - } catch (error) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - if (code !== "ENOENT") { - throw error; - } - - await fs.mkdir(path.dirname(secretKeyPath), { recursive: true }); - const key = randomBytes(SECRET_KEY_BYTES); - try { - await fs.writeFile(secretKeyPath, `${key.toString("base64")}\n`, { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - return key; - } catch (writeError) { - const writeCode = (writeError as NodeJS.ErrnoException | undefined)?.code; - if (writeCode === "EEXIST") { - const existing = await fs.readFile(secretKeyPath, "utf8"); - const decoded = Buffer.from(existing.trim(), "base64"); - if (decoded.byteLength !== SECRET_KEY_BYTES) { - throw new Error("Invalid vault key length.", { cause: writeError }); - } - return decoded; - } - throw writeError; - } - } + return decodeVaultPayload({ + key: input.key, + aad: [input.scope, input.projectId ?? "", input.envKey], + encryptedValue: input.encryptedValue, + }); } function toEnvironmentError(operation: string, error: unknown): EnvironmentVariablesError { @@ -221,7 +157,7 @@ export const EnvironmentVariablesLive = Layer.effect( const getSecretKey = () => { if (!secretKeyPromise) { - secretKeyPromise = readOrCreateSecretKey(secretKeyPath).catch((error) => { + secretKeyPromise = readOrCreateVaultKey(secretKeyPath).catch((error) => { secretKeyPromise = null; throw error; }); diff --git a/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts b/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts new file mode 100644 index 000000000..f28374cfc --- /dev/null +++ b/apps/server/src/persistence/Services/OpenclawGatewayConfig.ts @@ -0,0 +1,72 @@ +import type { + OpenclawGatewayConfigSummary, + ResetOpenclawGatewayDeviceStateInput, + SaveOpenclawGatewayConfigInput, +} from "@okcode/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { + PersistenceCryptoError, + PersistenceDecodeError, + PersistenceSqlError, +} from "../Errors.ts"; + +export type OpenclawGatewayConfigError = + | PersistenceSqlError + | PersistenceDecodeError + | PersistenceCryptoError; + +export interface OpenclawGatewayStoredConfig { + readonly gatewayUrl: string; + readonly sharedSecret: string | undefined; + readonly deviceId: string; + readonly devicePublicKey: string; + readonly deviceFingerprint: string; + readonly devicePrivateKeyPem: string; + readonly deviceToken: string | undefined; + readonly deviceTokenRole: string | undefined; + readonly deviceTokenScopes: ReadonlyArray; + readonly updatedAt: string; +} + +export interface ResolveOpenclawGatewayConfigInput { + readonly gatewayUrl?: string; + readonly sharedSecret?: string; + readonly allowEphemeralIdentity?: boolean; +} + +export interface SaveOpenclawDeviceTokenInput { + readonly deviceToken: string; + readonly role?: string; + readonly scopes?: ReadonlyArray; +} + +export interface OpenclawGatewayConfigShape { + readonly getSummary: () => Effect.Effect< + OpenclawGatewayConfigSummary, + OpenclawGatewayConfigError + >; + readonly getStored: () => Effect.Effect< + OpenclawGatewayStoredConfig | null, + OpenclawGatewayConfigError + >; + readonly save: ( + input: SaveOpenclawGatewayConfigInput, + ) => Effect.Effect; + readonly resolveForConnect: ( + input?: ResolveOpenclawGatewayConfigInput, + ) => Effect.Effect; + readonly saveDeviceToken: ( + input: SaveOpenclawDeviceTokenInput, + ) => Effect.Effect; + readonly clearDeviceToken: () => Effect.Effect; + readonly resetDeviceState: ( + input?: ResetOpenclawGatewayDeviceStateInput, + ) => Effect.Effect; +} + +export class OpenclawGatewayConfig extends ServiceMap.Service< + OpenclawGatewayConfig, + OpenclawGatewayConfigShape +>()("okcode/persistence/Services/OpenclawGatewayConfig") {} diff --git a/apps/server/src/persistence/vault.ts b/apps/server/src/persistence/vault.ts new file mode 100644 index 000000000..f2d21b1a4 --- /dev/null +++ b/apps/server/src/persistence/vault.ts @@ -0,0 +1,92 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const VAULT_PAYLOAD_VERSION = "v1"; +export const VAULT_KEY_BYTES = 32; +export const VAULT_IV_BYTES = 12; + +export interface EncodeVaultPayloadInput { + readonly key: Buffer; + readonly aad: ReadonlyArray; + readonly value: string; +} + +export interface DecodeVaultPayloadInput { + readonly key: Buffer; + readonly aad: ReadonlyArray; + readonly encryptedValue: string; +} + +export function encodeVaultPayload(input: EncodeVaultPayloadInput): string { + const iv = randomBytes(VAULT_IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", input.key, iv); + cipher.setAAD(Buffer.from(input.aad.join("\0"), "utf8")); + + const ciphertext = Buffer.concat([cipher.update(input.value, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return [ + VAULT_PAYLOAD_VERSION, + iv.toString("base64"), + authTag.toString("base64"), + ciphertext.toString("base64"), + ].join(":"); +} + +export function decodeVaultPayload(input: DecodeVaultPayloadInput): string { + const parts = input.encryptedValue.split(":"); + if (parts.length !== 4 || parts[0] !== VAULT_PAYLOAD_VERSION) { + throw new Error("Unsupported secret payload version."); + } + + const [, ivRaw, authTagRaw, ciphertextRaw] = parts; + const iv = Buffer.from(ivRaw ?? "", "base64"); + const authTag = Buffer.from(authTagRaw ?? "", "base64"); + const ciphertext = Buffer.from(ciphertextRaw ?? "", "base64"); + if (iv.byteLength !== VAULT_IV_BYTES || authTag.byteLength !== 16) { + throw new Error("Invalid encrypted payload."); + } + + const decipher = createDecipheriv("aes-256-gcm", input.key, iv); + decipher.setAAD(Buffer.from(input.aad.join("\0"), "utf8")); + decipher.setAuthTag(authTag); + return `${decipher.update(ciphertext, undefined, "utf8")}${decipher.final("utf8")}`; +} + +export async function readOrCreateVaultKey(secretKeyPath: string): Promise { + try { + const existing = await fs.readFile(secretKeyPath, "utf8"); + const decoded = Buffer.from(existing.trim(), "base64"); + if (decoded.byteLength !== VAULT_KEY_BYTES) { + throw new Error("Invalid vault key length."); + } + return decoded; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code !== "ENOENT") { + throw error; + } + + await fs.mkdir(path.dirname(secretKeyPath), { recursive: true }); + const key = randomBytes(VAULT_KEY_BYTES); + try { + await fs.writeFile(secretKeyPath, `${key.toString("base64")}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + return key; + } catch (writeError) { + const writeCode = (writeError as NodeJS.ErrnoException | undefined)?.code; + if (writeCode === "EEXIST") { + const existing = await fs.readFile(secretKeyPath, "utf8"); + const decoded = Buffer.from(existing.trim(), "base64"); + if (decoded.byteLength !== VAULT_KEY_BYTES) { + throw new Error("Invalid vault key length.", { cause: writeError }); + } + return decoded; + } + throw writeError; + } + } +} diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 19f5dd845..3cbfb84ae 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -1,7 +1,8 @@ /** * ProviderHealthLive - Startup-time provider health checks. * - * Performs provider readiness probes on demand for `server.getConfig`. + * Performs one-time provider readiness probes when the server starts and + * keeps the resulting snapshot in memory for `server.getConfig`. * * Uses effect's ChildProcessSpawner to run CLI probes natively. * @@ -16,6 +17,9 @@ import type { import { Array, Data, Effect, FileSystem, Layer, Option, Path, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { serverBuildInfo } from "../../buildInfo.ts"; +import { OpenclawGatewayClient, OpenclawGatewayClientError } from "../../openclaw/GatewayClient.ts"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { formatCodexCliUpgradeMessage, isCodexCliVersionSupported, @@ -31,6 +35,14 @@ class OpenClawHealthProbeError extends Data.TaggedError("OpenClawHealthProbeErro cause: unknown; }> {} +const OPENCLAW_HEALTH_REQUIRED_METHODS = [ + "sessions.create", + "sessions.get", + "sessions.send", + "sessions.abort", + "sessions.messages.subscribe", +] as const; + // ── Pure helpers ──────────────────────────────────────────────────── export interface CommandResult { @@ -596,104 +608,169 @@ export const checkClaudeProviderStatus: Effect.Effect< const OPENCLAW_PROVIDER = "openclaw" as const; -const checkOpenClawProviderStatus: Effect.Effect = Effect.gen( - function* () { - const checkedAt = new Date().toISOString(); - const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL; +const checkOpenClawProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + OpenclawGatewayConfig +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + const gatewayConfig = yield* OpenclawGatewayConfig; + const resolvedConfigResult = yield* gatewayConfig.resolveForConnect().pipe( + Effect.match({ + onSuccess: (resolvedConfig) => ({ ok: true as const, resolvedConfig }), + onFailure: (cause) => ({ ok: false as const, cause }), + }), + ); - if (!gatewayUrl) { - return { - provider: OPENCLAW_PROVIDER, - status: "warning" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: - "OpenClaw gateway URL is not configured. Set OPENCLAW_GATEWAY_URL or configure in settings.", - } satisfies ServerProviderStatus; - } + if (!resolvedConfigResult.ok) { + const reason = + resolvedConfigResult.cause instanceof Error + ? resolvedConfigResult.cause.message + : String(resolvedConfigResult.cause); - // Derive HTTP health URL from the gateway URL (replace ws:// with http://). - const healthUrl = gatewayUrl - .replace(/^ws:\/\//, "http://") - .replace(/^wss:\/\//, "https://") - .replace(/\/$/, "") - .concat("/health"); - - const probeResult = yield* Effect.tryPromise({ - try: async () => { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS); - try { - const response = await fetch(healthUrl, { - signal: controller.signal, - }); - return { ok: response.ok, status: response.status }; - } finally { - clearTimeout(timeout); - } - }, - catch: (cause) => new OpenClawHealthProbeError({ cause }), - }).pipe(Effect.result); + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: `OpenClaw gateway configuration could not be read. ${reason}`, + } satisfies ServerProviderStatus; + } - if (Result.isFailure(probeResult)) { - return { - provider: OPENCLAW_PROVIDER, - status: "warning" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: `Cannot reach OpenClaw gateway at ${gatewayUrl}. Check the URL and ensure the gateway is running.`, - } satisfies ServerProviderStatus; - } + const resolvedConfig = resolvedConfigResult.resolvedConfig; - const probe = probeResult.success; - if (!probe.ok) { - return { - provider: OPENCLAW_PROVIDER, - status: "warning" as const, - available: false, - authStatus: "unknown" as const, - checkedAt, - message: `OpenClaw gateway at ${gatewayUrl} returned HTTP ${probe.status}.`, - } satisfies ServerProviderStatus; - } + if (!resolvedConfig) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + checkedAt, + message: "OpenClaw gateway URL is not configured. Save it in Settings to enable OpenClaw.", + } satisfies ServerProviderStatus; + } + const connectResult = yield* Effect.tryPromise({ + try: async () => { + const connection = await OpenclawGatewayClient.connect({ + url: resolvedConfig.gatewayUrl, + identity: { + deviceId: resolvedConfig.deviceId, + deviceFingerprint: resolvedConfig.deviceFingerprint, + publicKey: resolvedConfig.devicePublicKey, + privateKeyPem: resolvedConfig.devicePrivateKeyPem, + }, + ...(resolvedConfig.sharedSecret ? { sharedSecret: resolvedConfig.sharedSecret } : {}), + ...(resolvedConfig.deviceToken ? { deviceToken: resolvedConfig.deviceToken } : {}), + ...(resolvedConfig.deviceTokenRole + ? { deviceTokenRole: resolvedConfig.deviceTokenRole } + : {}), + ...(resolvedConfig.deviceTokenScopes.length > 0 + ? { deviceTokenScopes: resolvedConfig.deviceTokenScopes } + : {}), + clientId: "okcode", + clientVersion: serverBuildInfo.version, + clientPlatform: + process.platform === "darwin" + ? "macos" + : process.platform === "win32" + ? "windows" + : process.platform, + clientMode: "operator", + locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", + userAgent: `okcode/${serverBuildInfo.version}`, + role: "operator", + scopes: ["operator.read", "operator.write"], + requiredMethods: OPENCLAW_HEALTH_REQUIRED_METHODS, + }); + try { + const deviceToken = connection.connect.auth?.deviceToken; + if (deviceToken && deviceToken !== resolvedConfig.deviceToken) { + await Effect.runPromise( + gatewayConfig.saveDeviceToken({ + deviceToken, + ...(connection.connect.auth?.role ? { role: connection.connect.auth.role } : {}), + ...(connection.connect.auth?.scopes.length + ? { scopes: connection.connect.auth.scopes } + : {}), + }), + ); + } + } finally { + await connection.client.close(); + } + return connection.connect; + }, + catch: (cause) => new OpenClawHealthProbeError({ cause }), + }).pipe(Effect.result); + + if (Result.isSuccess(connectResult)) { return { provider: OPENCLAW_PROVIDER, status: "ready" as const, available: true, - authStatus: "unknown" as const, + authStatus: "authenticated" as const, checkedAt, } satisfies ServerProviderStatus; - }, -); + } + + const cause = connectResult.failure.cause; + if (cause instanceof OpenClawHealthProbeError) { + const error = cause.cause; + if (error instanceof OpenclawGatewayClientError) { + const detailCode = error.gatewayError?.detailCode; + const gatewayMessage = error.gatewayError?.message ?? error.message; + if ( + detailCode === "PAIRING_REQUIRED" || + detailCode === "AUTH_TOKEN_MISSING" || + detailCode === "AUTH_TOKEN_MISMATCH" || + detailCode === "AUTH_DEVICE_TOKEN_MISMATCH" || + detailCode?.startsWith("DEVICE_AUTH_") + ) { + return { + provider: OPENCLAW_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unauthenticated" as const, + checkedAt, + message: gatewayMessage, + } satisfies ServerProviderStatus; + } + } + } + + return { + provider: OPENCLAW_PROVIDER, + status: "warning" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: `Cannot complete the OpenClaw gateway handshake at ${resolvedConfig.gatewayUrl}. Check connectivity, proxying, and pairing/device auth state.`, + } satisfies ServerProviderStatus; +}); // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const openclawGatewayConfig = yield* OpenclawGatewayConfig; return { getStatuses: Effect.all( - [ - checkCodexProviderStatus.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ), - checkClaudeProviderStatus.pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - ), - checkOpenClawProviderStatus, - ], + [checkCodexProviderStatus, checkClaudeProviderStatus, checkOpenClawProviderStatus], { concurrency: "unbounded", }, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(OpenclawGatewayConfig, openclawGatewayConfig), ), } satisfies ProviderHealthShape; }), diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 13f9345de..1f6ec9a8b 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -22,12 +22,14 @@ import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeOpenClawAdapterLive } from "./provider/Layers/OpenClawAdapter"; +import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; import { ProviderService } from "./provider/Services/ProviderService"; import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; import { EnvironmentVariablesLive } from "./persistence/Services/EnvironmentVariables"; +import { OpenclawGatewayConfigLive } from "./persistence/Layers/OpenclawGatewayConfig"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { TerminalRuntimeEnvResolverLive } from "./terminal/Layers/RuntimeEnvResolver"; @@ -100,7 +102,7 @@ export function makeServerProviderLayer(): Layer.Layer< ); const openclawAdapterLayer = makeOpenClawAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, - ); + ).pipe(Layer.provideMerge(OpenclawGatewayConfigLive)); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), @@ -130,6 +132,7 @@ export function makeServerRuntimeServicesLayer() { const runtimeServicesLayer = Layer.empty.pipe( Layer.provideMerge(EnvironmentVariablesLive), + Layer.provideMerge(OpenclawGatewayConfigLive), Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), Layer.provideMerge(OrchestrationProjectionOverviewQueryLive), Layer.provideMerge(OrchestrationProjectionThreadDetailQueryLive), @@ -175,6 +178,8 @@ export function makeServerRuntimeServicesLayer() { const smeChatLayer = SmeChatServiceLive.pipe( Layer.provideMerge(EnvironmentVariablesLive), + Layer.provideMerge(OpenclawGatewayConfigLive), + Layer.provideMerge(ProviderHealthLive.pipe(Layer.provideMerge(OpenclawGatewayConfigLive))), Layer.provide(SmeKnowledgeDocumentRepositoryLive), Layer.provide(SmeConversationRepositoryLive), Layer.provide(SmeMessageRepositoryLive), @@ -190,6 +195,7 @@ export function makeServerRuntimeServicesLayer() { TerminalRuntimeEnvResolverLive, KeybindingsLive, SkillServiceLive, + OpenclawGatewayConfigLive, smeChatLayer, ).pipe(Layer.provideMerge(NodeServices.layer)); } diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index 2892939cd..7e0fef604 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -1,7 +1,12 @@ -import { ProjectId, SmeConversationId } from "@okcode/contracts"; -import { Effect, Layer, Option, Queue, Stream } from "effect"; -import { describe, expect, it } from "vitest"; +import { ProjectId, SmeConversationId, type EnvironmentVariableEntry } from "@okcode/contracts"; +import { Effect, Layer, Option, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + EnvironmentVariables, + type EnvironmentVariablesShape, +} from "../../persistence/Services/EnvironmentVariables.ts"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeKnowledgeDocumentRepository, type SmeKnowledgeDocumentRepositoryShape, @@ -28,6 +33,70 @@ import { import { SmeChatService } from "../Services/SmeChatService.ts"; import { makeSmeChatServiceLive } from "./SmeChatServiceLive.ts"; +const originalAnthropicEnv = { + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, + ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, +}; + +afterEach(() => { + restoreAnthropicEnv(); +}); + +function restoreAnthropicEnv() { + for (const [key, value] of Object.entries(originalAnthropicEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function setAnthropicEnv(input: { + readonly apiKey?: string; + readonly authToken?: string; + readonly baseURL?: string; +}) { + if (input.apiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = input.apiKey; + } + + if (input.authToken === undefined) { + delete process.env.ANTHROPIC_AUTH_TOKEN; + } else { + process.env.ANTHROPIC_AUTH_TOKEN = input.authToken; + } + + if (input.baseURL === undefined) { + delete process.env.ANTHROPIC_BASE_URL; + } else { + process.env.ANTHROPIC_BASE_URL = input.baseURL; + } +} + +function toEntries(record: Record): EnvironmentVariableEntry[] { + return Object.entries(record).map(([key, value]) => ({ key, value })); +} + +function makeEnvironmentVariables(persistedEnv: Record): EnvironmentVariablesShape { + const entries = toEntries(persistedEnv); + + return { + getGlobal: () => Effect.succeed({ entries }), + saveGlobal: (input) => Effect.succeed({ entries: input.entries }), + getProject: (input) => Effect.succeed({ projectId: input.projectId, entries }), + saveProject: (input) => + Effect.succeed({ + projectId: input.projectId, + entries: input.entries, + }), + resolveEnvironment: () => Effect.succeed(persistedEnv), + }; +} + function makeDocumentRepository( rows: ReadonlyArray = [], ): SmeKnowledgeDocumentRepositoryShape { @@ -94,83 +163,10 @@ function makeMessageRepository() { return { repository, rowsByConversation }; } -function makeProviderHealth( - statuses: Array<{ - readonly provider: "codex" | "claudeAgent" | "openclaw"; - readonly status: "ready" | "warning" | "error"; - readonly available: boolean; - readonly authStatus: "authenticated" | "unauthenticated" | "unknown"; - readonly checkedAt: string; - readonly message?: string; - }>, -): ProviderHealthShape { +function makeProviderService(): ProviderServiceShape { return { - getStatuses: Effect.succeed(statuses), - }; -} - -function makeProviderService() { - const runtimeEvents = Effect.runSync(Queue.unbounded()); - const startedSessions: Array = []; - const sentTurns: Array = []; - - const service: ProviderServiceShape = { - startSession: (threadId, input) => - Effect.sync(() => { - startedSessions.push({ threadId, input }); - return { - provider: input.provider ?? "claudeAgent", - status: "ready", - runtimeMode: input.runtimeMode, - threadId, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - } as never; - }), - sendTurn: (input) => - Effect.gen(function* () { - sentTurns.push(input); - const turnId = "turn-1" as never; - yield* Queue.offer(runtimeEvents, { - eventId: "evt-1" as never, - provider: "claudeAgent", - threadId: input.threadId, - turnId, - createdAt: "2026-01-01T00:00:00.000Z", - type: "content.delta", - payload: { - streamKind: "assistant_text", - delta: "Hello", - }, - } as never); - yield* Queue.offer(runtimeEvents, { - eventId: "evt-2" as never, - provider: "claudeAgent", - threadId: input.threadId, - turnId, - createdAt: "2026-01-01T00:00:00.000Z", - type: "content.delta", - payload: { - streamKind: "assistant_text", - delta: " world", - }, - } as never); - yield* Queue.offer(runtimeEvents, { - eventId: "evt-3" as never, - provider: "claudeAgent", - threadId: input.threadId, - turnId, - createdAt: "2026-01-01T00:00:00.000Z", - type: "turn.completed", - payload: { - state: "completed", - }, - } as never); - return { - threadId: input.threadId, - turnId, - } as never; - }), + startSession: () => Effect.die("unexpected provider startSession"), + sendTurn: () => Effect.die("unexpected provider sendTurn"), interruptTurn: () => Effect.void, respondToRequest: () => Effect.void, respondToUserInput: () => Effect.void, @@ -178,14 +174,58 @@ function makeProviderService() { listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.die("unexpected provider getCapabilities"), rollbackConversation: () => Effect.void, - streamEvents: Stream.fromQueue(runtimeEvents), + streamEvents: Stream.empty, }; +} + +function makeOpenclawGatewayConfig() { + return { + getSummary: () => + Effect.succeed({ + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }), + getStored: () => Effect.succeed(null), + save: () => Effect.die("unexpected openclaw save"), + resolveForConnect: () => Effect.succeed(null), + saveDeviceToken: () => Effect.void, + clearDeviceToken: () => Effect.void, + resetDeviceState: () => + Effect.succeed({ + gatewayUrl: null, + hasSharedSecret: false, + deviceId: null, + devicePublicKey: null, + deviceFingerprint: null, + hasDeviceToken: false, + deviceTokenRole: null, + deviceTokenScopes: [], + updatedAt: null, + }), + }; +} - return { service, startedSessions, sentTurns }; +function makeProviderHealth(): ProviderHealthShape { + return { + getStatuses: Effect.succeed([]), + }; } describe("SmeChatServiceLive", () => { - it("routes Claude conversations through the provider runtime and stores the reply", async () => { + it("uses persisted Anthropic credentials for a successful send and stores the final reply", async () => { + setAnthropicEnv({ + apiKey: "process-key-that-should-not-win", + authToken: "process-token-that-should-not-win", + baseURL: "https://process-base.example", + }); + const projectId = ProjectId.makeUnsafe("project-1"); const conversationId = SmeConversationId.makeUnsafe("conversation-1"); const conversationRow: SmeConversationRow = { @@ -193,37 +233,51 @@ describe("SmeChatServiceLive", () => { projectId, title: "Architecture Q&A", provider: "claudeAgent", - authMethod: "auto", + authMethod: "apiKey", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; + const persistedEnv = { + ANTHROPIC_API_KEY: "project-api-key", + ANTHROPIC_BASE_URL: "https://project-base.example", + }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const providerService = makeProviderService(); + const capturedClientOptions: Array = []; + const capturedRequests: Array = []; + + const createClient = vi.fn((options: unknown) => { + capturedClientOptions.push(options); + return { + messages: { + stream: async function* (request: unknown) { + capturedRequests.push(request); + yield { + type: "content_block_delta", + delta: { type: "text_delta", text: "Hello" }, + }; + yield { + type: "content_block_delta", + delta: { type: "text_delta", text: " world" }, + }; + }, + }, + } as never; + }); - const layer = makeSmeChatServiceLive().pipe( + const layer = makeSmeChatServiceLive({ createClient }).pipe( Layer.provideMerge( - Layer.succeed( - ProviderHealth, - makeProviderHealth([ - { - provider: "claudeAgent", - status: "ready", - available: true, - authStatus: "authenticated", - checkedAt: "2026-01-01T00:00:00.000Z", - message: "Claude Code CLI is ready.", - }, - ]), - ), + Layer.succeed(EnvironmentVariables, makeEnvironmentVariables(persistedEnv)), ), Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), Layer.provideMerge( Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), - Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), + Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), + Layer.provideMerge(Layer.succeed(ProviderHealth, makeProviderHealth())), + Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), ); const events: Array = []; @@ -234,13 +288,6 @@ describe("SmeChatServiceLive", () => { { conversationId, text: "What changed in the latest design?", - providerOptions: { - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - permissionMode: "plan", - maxThinkingTokens: 12_000, - }, - }, }, (event) => { events.push(event); @@ -249,21 +296,22 @@ describe("SmeChatServiceLive", () => { }).pipe(Effect.provide(layer)), ); - expect(providerService.startedSessions).toHaveLength(1); - expect(providerService.sentTurns).toHaveLength(1); - expect((providerService.startedSessions[0] as any).input.providerOptions).toEqual({ - claudeAgent: { - binaryPath: "/usr/local/bin/claude", - permissionMode: "plan", - maxThinkingTokens: 12_000, + expect(createClient).toHaveBeenCalledTimes(1); + expect(capturedClientOptions).toEqual([ + { + apiKey: "project-api-key", + authToken: null, + baseURL: "https://project-base.example", }, - }); - expect(providerService.sentTurns[0] as any).toEqual( - expect.objectContaining({ + ]); + expect(capturedRequests).toEqual([ + { model: "claude-sonnet-4-6", - input: expect.stringContaining("knowledgeable subject matter expert assistant"), - }), - ); + max_tokens: 8192, + system: expect.stringContaining("knowledgeable subject matter expert assistant"), + messages: [{ role: "user", content: "What changed in the latest design?" }], + }, + ]); expect(events).toEqual([ { type: "sme.message.delta", @@ -299,7 +347,9 @@ describe("SmeChatServiceLive", () => { ]); }); - it("fails before sending when Claude Code CLI is unavailable", async () => { + it("fails before persisting messages when no Anthropic credentials are available", async () => { + setAnthropicEnv({}); + const projectId = ProjectId.makeUnsafe("project-2"); const conversationId = SmeConversationId.makeUnsafe("conversation-2"); const conversationRow: SmeConversationRow = { @@ -307,37 +357,25 @@ describe("SmeChatServiceLive", () => { projectId, title: "Docs sync", provider: "claudeAgent", - authMethod: "auto", + authMethod: "apiKey", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const providerService = makeProviderService(); + const createClient = vi.fn(); - const layer = makeSmeChatServiceLive().pipe( - Layer.provideMerge( - Layer.succeed( - ProviderHealth, - makeProviderHealth([ - { - provider: "claudeAgent", - status: "error", - available: false, - authStatus: "unknown", - checkedAt: "2026-01-01T00:00:00.000Z", - message: "Claude Code CLI (`claude`) is not installed or not on PATH.", - }, - ]), - ), - ), + const layer = makeSmeChatServiceLive({ createClient }).pipe( + Layer.provideMerge(Layer.succeed(EnvironmentVariables, makeEnvironmentVariables({}))), Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), Layer.provideMerge( Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), - Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), + Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), + Layer.provideMerge(Layer.succeed(ProviderHealth, makeProviderHealth())), + Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), ); await expect( @@ -350,12 +388,9 @@ describe("SmeChatServiceLive", () => { }); }).pipe(Effect.provide(layer)), ), - ).rejects.toThrow( - "SmeChatError in sendMessage:validate: Claude Code CLI (`claude`) is not installed or not on PATH.", - ); + ).rejects.toThrow("SmeChatError in sendMessage:validate: Anthropic API key is missing."); - expect(providerService.startedSessions).toHaveLength(0); - expect(providerService.sentTurns).toHaveLength(0); + expect(createClient).not.toHaveBeenCalled(); expect(rowsByConversation.get(conversationId)).toEqual([ expect.objectContaining({ role: "user", diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index c02f9c734..833d6a2f8 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -6,6 +6,7 @@ * * @module SmeChatServiceLive */ +import Anthropic from "@anthropic-ai/sdk"; import type { SmeAuthMethod, SmeConversation, @@ -20,25 +21,38 @@ import { import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; +import { EnvironmentVariables } from "../../persistence/Services/EnvironmentVariables.ts"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; -import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProviderHealth } from "../../provider/Services/ProviderHealth.ts"; +import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { isValidSmeAuthMethod, - validateClaudeSetup, + validateAnthropicSetup, validateCodexSetup, validateOpenClawSetup, } from "../authValidation.ts"; +import { sendSmeViaAnthropic, type ResolvedAnthropicClientOptions } from "../backends/anthropic.ts"; import { sendSmeViaProviderRuntime } from "../backends/providerRuntime.ts"; -import { buildSmeCompiledPrompt } from "../promptBuilder.ts"; +import { + buildSmeAnthropicMessages, + buildSmeCompiledPrompt, + buildSmeSystemPrompt, +} from "../promptBuilder.ts"; import { SmeChatError, SmeChatService, type SmeChatServiceShape, } from "../Services/SmeChatService.ts"; +type AnthropicMessagesClient = Pick; + +export interface SmeChatServiceLiveOptions { + readonly createClient?: (options: ResolvedAnthropicClientOptions) => AnthropicMessagesClient; +} + type ActiveRequest = { readonly interrupt: Effect.Effect; }; @@ -102,13 +116,19 @@ function toMessage(message: { }; } -const makeSmeChatService = () => +const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => Effect.gen(function* () { const documentRepo = yield* SmeKnowledgeDocumentRepository; const conversationRepo = yield* SmeConversationRepository; const messageRepo = yield* SmeMessageRepository; - const providerService = yield* ProviderService; + const environmentVariables = yield* EnvironmentVariables; + const openclawGatewayConfig = yield* OpenclawGatewayConfig; const providerHealth = yield* ProviderHealth; + const providerService = yield* ProviderService; + const createClient = + options.createClient ?? + ((clientOptions: ResolvedAnthropicClientOptions): AnthropicMessagesClient => + new Anthropic(clientOptions)); const activeRequests = yield* Ref.make(new Map()); @@ -147,15 +167,18 @@ const makeSmeChatService = () => switch (conversation.provider) { case "claudeAgent": { - const providerStatus = (yield* providerHealth.getStatuses).find( - (status) => status.provider === "claudeAgent", - ); - return validateClaudeSetup({ + const persistedEnv = yield* environmentVariables + .resolveEnvironment({ + projectId: conversation.projectId, + }) + .pipe(Effect.mapError((e) => new SmeChatError("validateSetup", e.message))); + return validateAnthropicSetup({ authMethod: conversation.authMethod as Extract< SmeAuthMethod, "auto" | "apiKey" | "authToken" >, - providerStatus, + persistedEnv, + processEnv: process.env, }); } @@ -174,12 +197,21 @@ const makeSmeChatService = () => }); case "openclaw": + const openclawSummary = yield* openclawGatewayConfig + .getSummary() + .pipe(Effect.mapError((e) => new SmeChatError("validateSetup", e.message))); + const openclawStatus = (yield* providerHealth.getStatuses).find( + (status) => status.provider === "openclaw", + ); return validateOpenClawSetup({ authMethod: conversation.authMethod as Extract< SmeAuthMethod, "auto" | "password" | "none" >, - providerOptions, + gatewayUrl: openclawSummary.gatewayUrl, + hasSharedSecret: openclawSummary.hasSharedSecret, + hasDeviceToken: openclawSummary.hasDeviceToken, + ...(openclawStatus ? { providerStatus: openclawStatus } : {}), }); } }); @@ -425,28 +457,73 @@ const makeSmeChatService = () => return yield* Effect.fail(new SmeChatError("sendMessage:validate", validation.message)); } + const systemPrompt = buildSmeSystemPrompt(docs); const promptHistory = existingMessages.map((message) => ({ role: message.role, text: message.text, })); + const anthropicMessages = buildSmeAnthropicMessages({ + history: promptHistory, + userText: input.text, + }); const compiledPrompt = buildSmeCompiledPrompt({ docs, history: promptHistory, userText: input.text, }); - const sendEffect = sendSmeViaProviderRuntime({ - providerService, - provider: conv.provider, - conversationId: input.conversationId, - assistantMessageId, - model: conv.model, - compiledPrompt, - ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), - ...(onEvent ? { onEvent } : {}), - setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), - clearInterruptEffect: clearInterrupt(input.conversationId), - }); + const sendEffect = + conv.provider === "claudeAgent" + ? Effect.gen(function* () { + const persistedEnv = yield* environmentVariables + .resolveEnvironment({ + projectId: conv.projectId, + }) + .pipe(Effect.mapError((e) => new SmeChatError("sendMessage:env", e.message))); + const anthropicSetup = validateAnthropicSetup({ + authMethod: conv.authMethod as Extract< + SmeAuthMethod, + "auto" | "apiKey" | "authToken" + >, + persistedEnv, + processEnv: process.env, + }); + if (!anthropicSetup.ok || !anthropicSetup.clientOptions) { + return yield* Effect.fail( + new SmeChatError("sendMessage:validate", anthropicSetup.message), + ); + } + + const controller = new AbortController(); + yield* setInterrupt( + input.conversationId, + Effect.sync(() => { + controller.abort(); + }), + ); + return yield* sendSmeViaAnthropic({ + client: createClient(anthropicSetup.clientOptions), + conversationId: input.conversationId, + assistantMessageId, + model: conv.model, + systemPrompt, + messages: anthropicMessages, + ...(onEvent ? { onEvent } : {}), + abortSignal: controller.signal, + }).pipe(Effect.ensuring(clearInterrupt(input.conversationId))); + }) + : sendSmeViaProviderRuntime({ + providerService, + provider: conv.provider, + conversationId: input.conversationId, + assistantMessageId, + model: conv.model, + compiledPrompt, + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + ...(onEvent ? { onEvent } : {}), + setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), + clearInterruptEffect: clearInterrupt(input.conversationId), + }); const responseText = yield* sendEffect.pipe( Effect.mapError((cause) => @@ -513,6 +590,7 @@ const makeSmeChatService = () => } satisfies SmeChatServiceShape; }); -export const makeSmeChatServiceLive = () => Layer.effect(SmeChatService, makeSmeChatService()); +export const makeSmeChatServiceLive = (options: SmeChatServiceLiveOptions = {}) => + Layer.effect(SmeChatService, makeSmeChatService(options)); export const SmeChatServiceLive = makeSmeChatServiceLive(); diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index a5efdf75d..dc917e882 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -4,6 +4,7 @@ import { type ProviderKind, type ServerProviderStatus, } from "@okcode/contracts"; +import { compactNodeProcessEnv } from "@okcode/shared/environment"; import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; @@ -15,6 +16,7 @@ import { readCodexAccountSnapshot, type CodexAppServerStartSessionInput, } from "../codexAppServerManager.ts"; +import type { ResolvedAnthropicClientOptions } from "./backends/anthropic.ts"; const OPENAI_MODEL_PROVIDERS = new Set(["openai"]); @@ -23,6 +25,41 @@ function normalizeOptionalValue(value: string | undefined | null): string | null return trimmed && trimmed.length > 0 ? trimmed : null; } +function pickAnthropicCredential( + env: Record, + authMethod: Extract, +): { + apiKey: string | null; + authToken: string | null; + resolvedAuthMethod: "apiKey" | "authToken"; +} | null { + const apiKey = normalizeOptionalValue(env.ANTHROPIC_API_KEY); + const authToken = normalizeOptionalValue(env.ANTHROPIC_AUTH_TOKEN); + + if (authMethod === "apiKey") { + return apiKey ? { apiKey, authToken: null, resolvedAuthMethod: "apiKey" } : null; + } + if (authMethod === "authToken") { + return authToken ? { apiKey: null, authToken, resolvedAuthMethod: "authToken" } : null; + } + if (apiKey) { + return { apiKey, authToken: null, resolvedAuthMethod: "apiKey" }; + } + if (authToken) { + return { apiKey: null, authToken, resolvedAuthMethod: "authToken" }; + } + return null; +} + +function anthropicBaseUrl(persistedEnv: Record, processEnv?: NodeJS.ProcessEnv) { + const processEnvRecord = compactNodeProcessEnv(processEnv ?? process.env); + return ( + normalizeOptionalValue(persistedEnv.ANTHROPIC_BASE_URL) ?? + normalizeOptionalValue(processEnvRecord.ANTHROPIC_BASE_URL) ?? + undefined + ); +} + export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAuthMethod[] { switch (provider) { case "claudeAgent": @@ -37,7 +74,7 @@ export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAu export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": - return "auto"; + return "apiKey"; case "codex": return "chatgpt"; case "openclaw": @@ -49,63 +86,63 @@ export function isValidSmeAuthMethod(provider: ProviderKind, authMethod: SmeAuth return getAllowedSmeAuthMethods(provider).includes(authMethod); } -export function validateClaudeSetup(input: { +export function validateAnthropicSetup(input: { readonly authMethod: Extract; - readonly providerStatus?: ServerProviderStatus | null | undefined; -}): SmeValidateSetupResult { - const providerStatus = input.providerStatus; - if (!providerStatus) { - return { - ok: false, - severity: "error", - message: "Claude Code CLI status is unavailable.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", - }; - } - - if (!providerStatus.available || providerStatus.status === "error") { - return { - ok: false, - severity: "error", - message: - providerStatus.message ?? "Claude Code CLI is not installed or not available on PATH.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", - }; - } - - if (providerStatus.authStatus === "unauthenticated") { + readonly persistedEnv: Record; + readonly processEnv?: NodeJS.ProcessEnv; +}): SmeValidateSetupResult & { readonly clientOptions?: ResolvedAnthropicClientOptions } { + const processEnvRecord = compactNodeProcessEnv(input.processEnv ?? process.env); + const merged = { ...processEnvRecord, ...input.persistedEnv }; + const credential = pickAnthropicCredential(merged, input.authMethod); + if (!credential) { + if (input.authMethod === "authToken") { + return { + ok: false, + severity: "error", + message: + "Anthropic auth token is missing. Set ANTHROPIC_AUTH_TOKEN in project or global environment variables.", + resolvedAuthMethod: "authToken", + }; + } + if (input.authMethod === "apiKey") { + return { + ok: false, + severity: "error", + message: + "Anthropic API key is missing. Set ANTHROPIC_API_KEY in project or global environment variables.", + resolvedAuthMethod: "apiKey", + }; + } return { ok: false, severity: "error", message: - providerStatus.message ?? - "Claude Code CLI is not authenticated. Run `claude auth login` and try again.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", - }; - } - - if (providerStatus.status === "warning") { - return { - ok: true, - severity: "warning", - message: providerStatus.message ?? "Claude Code CLI is available but needs verification.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + "SME Chat requires ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN. Add one in Settings > Environment Variables.", + resolvedAuthMethod: "auto", }; } return { ok: true, severity: "ready", - message: providerStatus.message ?? "Claude Code CLI is ready.", - resolvedAuthMethod: input.authMethod, - resolvedAccountType: "unknown", + message: + credential.resolvedAuthMethod === "apiKey" + ? "Anthropic API key is configured." + : "Anthropic auth token is configured.", + resolvedAuthMethod: credential.resolvedAuthMethod, + clientOptions: (() => { + const baseURL = anthropicBaseUrl(input.persistedEnv, input.processEnv); + return { + apiKey: credential.apiKey, + authToken: credential.authToken, + ...(baseURL ? { baseURL } : {}), + }; + })(), }; } +export const validateClaudeSetup = validateAnthropicSetup; + async function readCodexConfigModelProvider( providerOptions?: CodexAppServerStartSessionInput["providerOptions"], ): Promise { @@ -334,12 +371,12 @@ export async function validateCodexSetup(input: { export function validateOpenClawSetup(input: { readonly authMethod: Extract; - readonly providerOptions?: CodexAppServerStartSessionInput["providerOptions"]; + readonly gatewayUrl: string | null; + readonly hasSharedSecret: boolean; + readonly hasDeviceToken: boolean; + readonly providerStatus?: ServerProviderStatus; }): SmeValidateSetupResult { - const gatewayUrl = normalizeOptionalValue(input.providerOptions?.openclaw?.gatewayUrl); - const password = normalizeOptionalValue(input.providerOptions?.openclaw?.password); - - if (!gatewayUrl) { + if (!input.gatewayUrl) { return { ok: false, severity: "error", @@ -349,13 +386,45 @@ export function validateOpenClawSetup(input: { } const resolvedAuthMethod = - input.authMethod === "auto" ? (password ? "password" : "none") : input.authMethod; + input.authMethod === "auto" ? (input.hasSharedSecret ? "password" : "none") : input.authMethod; + + if (resolvedAuthMethod === "password" && !input.hasSharedSecret) { + return { + ok: false, + severity: "error", + message: "OpenClaw shared-secret auth is selected, but no shared secret is configured.", + resolvedAuthMethod, + }; + } - if (resolvedAuthMethod === "password" && !password) { + if (input.providerStatus?.authStatus === "unauthenticated") { return { ok: false, severity: "error", - message: "OpenClaw password auth is selected, but no gateway password is configured.", + message: + input.providerStatus.message ?? + "OpenClaw is configured, but pairing or device authentication is not complete.", + resolvedAuthMethod, + }; + } + + if (input.providerStatus?.status === "warning") { + return { + ok: false, + severity: "warning", + message: + input.providerStatus.message ?? + "OpenClaw gateway health could not be verified. Test the gateway in Settings.", + resolvedAuthMethod, + }; + } + + if (!input.hasDeviceToken) { + return { + ok: false, + severity: "warning", + message: + "OpenClaw gateway settings are saved, but no device token is cached yet. Test the gateway in Settings and approve the device if prompted.", resolvedAuthMethod, }; } @@ -365,8 +434,8 @@ export function validateOpenClawSetup(input: { severity: "ready", message: resolvedAuthMethod === "password" - ? "OpenClaw gateway URL and password are configured." - : "OpenClaw gateway URL is configured.", + ? "OpenClaw gateway, shared secret, and device pairing are configured." + : "OpenClaw gateway and device pairing are configured.", resolvedAuthMethod, }; } diff --git a/apps/server/src/sme/promptBuilder.ts b/apps/server/src/sme/promptBuilder.ts index 531f8dc0c..320e942cd 100644 --- a/apps/server/src/sme/promptBuilder.ts +++ b/apps/server/src/sme/promptBuilder.ts @@ -65,3 +65,17 @@ export function buildSmeCompiledPrompt(input: { .filter((section) => section.length > 0) .join("\n\n"); } + +export function buildSmeAnthropicMessages(input: { + readonly history: ReadonlyArray<{ readonly role: string; readonly text: string }>; + readonly userText: string; +}): Array<{ role: "user" | "assistant"; content: string }> { + const apiMessages: Array<{ role: "user" | "assistant"; content: string }> = []; + for (const message of input.history) { + if (message.role === "user" || message.role === "assistant") { + apiMessages.push({ role: message.role, content: message.text }); + } + } + apiMessages.push({ role: "user", content: input.userText }); + return apiMessages; +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index b44b3ae57..57243202f 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -96,6 +96,7 @@ import { PrReview } from "./prReview/Services/PrReview.ts"; import { GitHub } from "./github/Services/GitHub.ts"; import { GitActionExecutionError } from "./git/Errors.ts"; import { EnvironmentVariables } from "./persistence/Services/EnvironmentVariables.ts"; +import { OpenclawGatewayConfig } from "./persistence/Services/OpenclawGatewayConfig.ts"; import { SkillService } from "./skills/SkillService.ts"; import { SmeChatService } from "./sme/Services/SmeChatService.ts"; import { TokenManager } from "./tokenManager.ts"; @@ -318,7 +319,8 @@ export type ServerRuntimeServices = | SkillService | SmeChatService | Open - | EnvironmentVariables; + | EnvironmentVariables + | OpenclawGatewayConfig; export class ServerLifecycleError extends Schema.TaggedErrorClass()( "ServerLifecycleError", @@ -363,6 +365,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const providerHealth = yield* ProviderHealth; + const openclawGatewayConfig = yield* OpenclawGatewayConfig; const git = yield* GitCore; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -879,6 +882,15 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), ).pipe(Effect.forkIn(subscriptionsScope)); + const publishServerConfigUpdated = () => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindingsManager.loadConfigState; + yield* pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: keybindingsConfig.issues, + providers: providerStatuses, + }); + }); + yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); yield* readiness.markOrchestrationSubscriptionsReady; @@ -1691,6 +1703,23 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { tokens }; } + case WS_METHODS.serverGetOpenclawGatewayConfig: + return yield* openclawGatewayConfig.getSummary(); + + case WS_METHODS.serverSaveOpenclawGatewayConfig: { + const body = stripRequestTag(request.body); + const summary = yield* openclawGatewayConfig.save(body); + yield* publishServerConfigUpdated(); + return summary; + } + + case WS_METHODS.serverResetOpenclawGatewayDeviceState: { + const body = stripRequestTag(request.body); + const summary = yield* openclawGatewayConfig.resetDeviceState(body); + yield* publishServerConfigUpdated(); + return summary; + } + // ── Companion pairing (placeholder) ───────────────────────────── // These handlers are wired for type-exhaustiveness but return // stub responses until the full companion session manager is built. @@ -1720,7 +1749,23 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< // ── OpenClaw gateway test ──────────────────────────────────────── case WS_METHODS.serverTestOpenclawGateway: { const body = stripRequestTag(request.body); - return yield* testOpenclawGateway(body); + const resolvedConfig = yield* openclawGatewayConfig.resolveForConnect({ + ...(body.gatewayUrl ? { gatewayUrl: body.gatewayUrl } : {}), + ...(body.password ? { sharedSecret: body.password } : {}), + allowEphemeralIdentity: body.gatewayUrl !== undefined, + }); + if (!resolvedConfig) { + return yield* new RouteRequestError({ + message: + "OpenClaw gateway URL is not configured. Save it in Settings or provide a test override.", + }); + } + const result = yield* testOpenclawGateway({ + gatewayUrl: resolvedConfig.gatewayUrl, + password: body.password ?? resolvedConfig.sharedSecret, + }); + yield* publishServerConfigUpdated(); + return result; } // ── Connection health ─────────────────────────────────────────── diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 37daea8a9..74259a92e 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -345,14 +345,7 @@ export function getCustomModelOptionsByProvider( } export function getProviderStartOptions( - settings: Pick< - AppSettings, - | "claudeBinaryPath" - | "codexBinaryPath" - | "codexHomePath" - | "openclawGatewayUrl" - | "openclawPassword" - >, + settings: Pick, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { ...(settings.codexBinaryPath || settings.codexHomePath @@ -370,14 +363,6 @@ export function getProviderStartOptions( }, } : {}), - ...(settings.openclawGatewayUrl || settings.openclawPassword - ? { - openclaw: { - ...(settings.openclawGatewayUrl ? { gatewayUrl: settings.openclawGatewayUrl } : {}), - ...(settings.openclawPassword ? { password: settings.openclawPassword } : {}), - }, - } - : {}), }; return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; diff --git a/apps/web/src/lib/serverReactQuery.ts b/apps/web/src/lib/serverReactQuery.ts index 6d0be949c..ec5b7d768 100644 --- a/apps/web/src/lib/serverReactQuery.ts +++ b/apps/web/src/lib/serverReactQuery.ts @@ -4,6 +4,7 @@ import { ensureNativeApi } from "~/nativeApi"; export const serverQueryKeys = { all: ["server"] as const, config: () => ["server", "config"] as const, + openclawGatewayConfig: () => ["server", "openclawGatewayConfig"] as const, update: () => ["server", "update"] as const, }; @@ -31,3 +32,14 @@ export function serverUpdateQueryOptions() { retry: false, }); } + +export function openclawGatewayConfigQueryOptions() { + return queryOptions({ + queryKey: serverQueryKeys.openclawGatewayConfig(), + queryFn: async () => { + const api = ensureNativeApi(); + return api.server.getOpenclawGatewayConfig(); + }, + staleTime: Infinity, + }); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3519926aa..cc3b14515 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -328,7 +328,7 @@ function EventRouter() { // don't produce duplicate toasts. let subscribed = false; const unsubServerConfigUpdated = onServerConfigUpdated((payload) => { - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); if (!subscribed) return; const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b1f821ac2..15557ad65 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -4,16 +4,12 @@ import { CheckCircle2Icon, ChevronDownIcon, CpuIcon, - GlobeIcon, GitBranchIcon, ImportIcon, - KeyboardIcon, Loader2Icon, PaletteIcon, PlusIcon, - RefreshCwIcon, RotateCcwIcon, - ShieldCheckIcon, SkipForwardIcon, SmartphoneIcon, Undo2Icon, @@ -26,11 +22,8 @@ import { type ReactNode, useCallback, useEffect, useState } from "react"; import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; import { type BuildMetadata, - type KeybindingCommand, - type KeybindingRule, type ProjectId, type ProviderKind, - type ServerProviderStatus, DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@okcode/contracts"; import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; @@ -63,7 +56,6 @@ import { APP_BUILD_INFO } from "../branding"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; -import { HotkeysSettingsSection } from "../components/settings/HotkeysSettingsSection"; import { Input } from "../components/ui/input"; import { Select, @@ -102,34 +94,20 @@ import { setStoredRadiusOverride, type CustomThemeData, } from "../lib/customTheme"; -import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; import { - getSelectableThreadProviders, - isProviderReadyForThreadSelection, -} from "../lib/providerAvailability"; -import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; + openclawGatewayConfigQueryOptions, + serverConfigQueryOptions, + serverQueryKeys, +} from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; -import { - getProviderLabel as getProviderStatusLabelName, - getProviderStatusDescription, - getProviderStatusHeading, -} from "../components/chat/providerStatusPresentation"; // --------------------------------------------------------------------------- // Settings navigation sections // --------------------------------------------------------------------------- -type SettingsSectionId = - | "general" - | "authentication" - | "hotkeys" - | "environment" - | "git" - | "models" - | "mobile" - | "advanced"; +type SettingsSectionId = "general" | "environment" | "git" | "models" | "mobile" | "advanced"; interface SettingsNavItem { id: SettingsSectionId; @@ -141,12 +119,6 @@ interface SettingsNavItem { function useSettingsNavItems(): SettingsNavItem[] { return [ { id: "general", label: "General", icon: }, - { - id: "authentication", - label: "Authentication", - icon: , - }, - { id: "hotkeys", label: "Hotkeys", icon: }, { id: "environment", label: "Environment", icon: }, { id: "git", label: "Git", icon: }, { id: "models", label: "Models", icon: }, @@ -345,9 +317,9 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ }, { provider: "claudeAgent", - title: "Claude Code", + title: "Anthropic", binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude Code binary path", + binaryPlaceholder: "Claude binary path", binaryDescription: ( <> Leave blank to use claude from your PATH. Authentication uses{" "} @@ -357,147 +329,6 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ }, ]; -const PROVIDER_AUTH_GUIDES: Record< - ProviderKind, - { - installCmd?: string; - authCmd?: string; - verifyCmd?: string; - note: string; - } -> = { - codex: { - installCmd: "npm install -g @openai/codex", - authCmd: "codex login", - verifyCmd: "codex login status", - note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", - }, - claudeAgent: { - installCmd: "npm install -g @anthropic-ai/claude-code", - authCmd: "claude auth login", - verifyCmd: "claude auth status", - note: "Claude Code must be installed and signed in before it appears in the thread picker.", - }, - openclaw: { - verifyCmd: "Test Connection", - note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", - }, -}; - -function getAuthenticationBadgeCopy(input: { - status: ServerProviderStatus | null; - provider: ProviderKind; - openclawGatewayUrl: string; -}): { - tone: "success" | "warning" | "error"; - label: string; -} { - if ( - isProviderReadyForThreadSelection({ - provider: input.provider, - statuses: input.status ? [input.status] : [], - openclawGatewayUrl: input.openclawGatewayUrl, - }) - ) { - return { tone: "success", label: "Available in thread picker" }; - } - - if (input.status?.authStatus === "unauthenticated") { - return { tone: "error", label: "Sign-in required" }; - } - - if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { - return { tone: "warning", label: "Gateway not configured" }; - } - - if (input.status?.available === false || input.status?.status === "error") { - return { tone: "error", label: "Unavailable" }; - } - - return { tone: "warning", label: "Needs verification" }; -} - -function AuthenticationStatusCard({ - provider, - status, - openclawGatewayUrl, -}: { - provider: ProviderKind; - status: ServerProviderStatus | null; - openclawGatewayUrl: string; -}) { - const guide = PROVIDER_AUTH_GUIDES[provider]; - const badge = getAuthenticationBadgeCopy({ status, provider, openclawGatewayUrl }); - const badgeClassName = - badge.tone === "success" - ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" - : badge.tone === "error" - ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" - : "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"; - const heading = - status !== null - ? getProviderStatusHeading(status) - : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 - ? "OpenClaw gateway is configured locally" - : `${getProviderStatusLabelName(provider)} needs configuration`; - const description = - status !== null - ? getProviderStatusDescription(status) - : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 - ? "OpenClaw is configured in local settings. Use Test Connection below to verify the gateway before starting a thread." - : guide.note; - - return ( -
-
-
-
-

- {getProviderStatusLabelName(provider)} -

- - {badge.label} - -
-

{heading}

-

{description}

-
- {status?.checkedAt ? ( - - Checked {new Date(status.checkedAt).toLocaleString()} - - ) : null} -
- -
-
-
Install
- - {guide.installCmd ?? "Configured in-app"} - -
-
-
Authenticate
- - {guide.authCmd ?? "Use gateway password"} - -
-
-
Verify
- {guide.verifyCmd ?? "N/A"} -
-
- -

{guide.note}

-
- ); -} - function SettingsSection({ title, description, @@ -760,6 +591,7 @@ function SettingsRouteView() { const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const openclawGatewayConfigQuery = useQuery(openclawGatewayConfigQueryOptions()); const queryClient = useQueryClient(); const trimmedBrowserPreviewStartPageUrl = settings.browserPreviewStartPageUrl.trim(); const browserPreviewStartPageValidation = @@ -770,7 +602,6 @@ function SettingsRouteView() { settings.browserPreviewStartPageUrl, ); const projects = useStore((state) => state.projects); - const threads = useStore((state) => state.threads); const [selectedProjectId, setSelectedProjectId] = useState( () => projects[0]?.id ?? null, ); @@ -808,6 +639,12 @@ function SettingsRouteView() { null, ); const [openclawTestLoading, setOpenclawTestLoading] = useState(false); + const [openclawGatewayDraft, setOpenclawGatewayDraft] = useState(null); + const [openclawSharedSecretDraft, setOpenclawSharedSecretDraft] = useState(""); + const [openclawSaveLoading, setOpenclawSaveLoading] = useState(false); + const [openclawResetLoading, setOpenclawResetLoading] = useState<"token" | "identity" | null>( + null, + ); const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = useCopyToClipboard(); @@ -817,15 +654,6 @@ function SettingsRouteView() { const selectedProjectEnvironmentVariablesQuery = useQuery( projectEnvironmentVariablesQueryOptions(activeProjectId), ); - const activeProjectPreviewThreadId = - activeProjectId === null - ? null - : (threads - .filter((thread) => thread.projectId === activeProjectId) - .toSorted((a, b) => - (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), - ) - .at(0)?.id ?? null); useEffect(() => { if (projects.length === 0) { @@ -845,11 +673,6 @@ function SettingsRouteView() { const claudeBinaryPath = settings.claudeBinaryPath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; - const providerStatuses = serverConfigQuery.data?.providers ?? []; - const selectableProviders = getSelectableThreadProviders({ - statuses: providerStatuses, - openclawGatewayUrl: settings.openclawGatewayUrl, - }); const gitTextGenerationModelOptions = getAppModelOptions( "codex", @@ -887,9 +710,16 @@ function SettingsRouteView() { settings.claudeBinaryPath !== defaults.claudeBinaryPath || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; + const savedOpenclawGatewayUrl = openclawGatewayConfigQuery.data?.gatewayUrl ?? ""; + const savedOpenclawHasSharedSecret = openclawGatewayConfigQuery.data?.hasSharedSecret ?? false; + const effectiveOpenclawGatewayUrl = openclawGatewayDraft ?? savedOpenclawGatewayUrl; const isOpenClawSettingsDirty = - settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || - settings.openclawPassword !== defaults.openclawPassword; + (openclawGatewayDraft !== null && openclawGatewayDraft !== savedOpenclawGatewayUrl) || + openclawSharedSecretDraft.length > 0; + const canImportLegacyOpenclawSettings = + openclawGatewayConfigQuery.isSuccess && + !savedOpenclawGatewayUrl && + Boolean(settings.openclawGatewayUrl || settings.openclawPassword); const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), @@ -908,12 +738,6 @@ function SettingsRouteView() { ...(settings.showAuthFailuresAsErrors !== defaults.showAuthFailuresAsErrors ? ["Auth failure errors"] : []), - ...(settings.showNotificationDetails !== defaults.showNotificationDetails - ? ["Notification details"] - : []), - ...(settings.includeDiagnosticsTipsInCopy !== defaults.includeDiagnosticsTipsInCopy - ? ["Diagnostics copy tips"] - : []), ...(settings.openLinksExternally !== defaults.openLinksExternally ? ["Open links externally"] : []), @@ -949,33 +773,11 @@ function SettingsRouteView() { ...(settings.backgroundImageOpacity !== defaults.backgroundImageOpacity ? ["Background opacity"] : []), - ...(settings.sidebarOpacity !== defaults.sidebarOpacity ? ["Sidebar opacity"] : []), - ...(settings.sidebarProjectRowHeight !== defaults.sidebarProjectRowHeight - ? ["Project height"] - : []), - ...(settings.sidebarThreadRowHeight !== defaults.sidebarThreadRowHeight - ? ["Thread height"] - : []), - ...(settings.sidebarFontSize !== defaults.sidebarFontSize ? ["Sidebar font size"] : []), - ...(settings.sidebarSpacing !== defaults.sidebarSpacing ? ["Sidebar spacing"] : []), ...(radiusOverride !== null ? ["Border radius"] : []), ...(fontOverride ? ["Font family"] : []), - ...(fontSizeOverride !== null ? ["Code font size"] : []), + ...(fontSizeOverride !== null ? ["Font size"] : []), ]; - const openTweakcn = useCallback(() => { - void openUrlInAppBrowser({ - url: "https://tweakcn.com", - projectId: activeProjectId, - threadId: activeProjectPreviewThreadId, - popOut: true, - nativeApi: readNativeApi(), - }).catch(() => { - const nativeApi = ensureNativeApi(); - return nativeApi.shell.openExternal("https://tweakcn.com"); - }); - }, [activeProjectId, activeProjectPreviewThreadId]); - const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -999,19 +801,6 @@ function SettingsRouteView() { }); }, [availableEditors, keybindingsConfigPath]); - const replaceKeybindingRules = useCallback( - async (command: KeybindingCommand, rules: readonly KeybindingRule[]) => { - const api = ensureNativeApi(); - await api.server.replaceKeybindingRules({ command, rules: [...rules] }); - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - }, - [queryClient], - ); - - const refreshProviderStatuses = useCallback(async () => { - await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); - }, [queryClient]); - const saveGlobalEnvironmentVariables = useCallback( async (entries: ReadonlyArray<{ key: string; value: string }>) => { const api = ensureNativeApi(); @@ -1044,9 +833,11 @@ function SettingsRouteView() { setOpenclawTestResult(null); try { const api = ensureNativeApi(); + const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); + const sharedSecret = openclawSharedSecretDraft.trim(); const result = await api.server.testOpenclawGateway({ - gatewayUrl: settings.openclawGatewayUrl, - password: settings.openclawPassword || undefined, + ...(gatewayUrl ? { gatewayUrl } : {}), + ...(sharedSecret ? { password: sharedSecret } : {}), }); setOpenclawTestResult(result); } catch (err) { @@ -1059,13 +850,111 @@ function SettingsRouteView() { } finally { setOpenclawTestLoading(false); } - }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); + }, [effectiveOpenclawGatewayUrl, openclawSharedSecretDraft, openclawTestLoading]); const handleCopyOpenclawDebugReport = useCallback(() => { if (!openclawTestResult) return; copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); }, [copyOpenclawDebugReport, openclawTestResult]); + const saveOpenclawGatewayConfig = useCallback(async () => { + if (openclawSaveLoading) return; + const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); + if (!gatewayUrl) { + throw new Error("Gateway URL is required."); + } + setOpenclawSaveLoading(true); + try { + const api = ensureNativeApi(); + const sharedSecret = openclawSharedSecretDraft.trim(); + const summary = await api.server.saveOpenclawGatewayConfig({ + gatewayUrl, + ...(sharedSecret ? { sharedSecret } : {}), + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setOpenclawGatewayDraft(null); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + } finally { + setOpenclawSaveLoading(false); + } + }, [effectiveOpenclawGatewayUrl, openclawSaveLoading, openclawSharedSecretDraft, queryClient]); + + const clearSavedOpenclawSharedSecret = useCallback(async () => { + const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); + if (!gatewayUrl) { + throw new Error("Gateway URL is required."); + } + setOpenclawSaveLoading(true); + try { + const api = ensureNativeApi(); + const summary = await api.server.saveOpenclawGatewayConfig({ + gatewayUrl, + clearSharedSecret: true, + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + } finally { + setOpenclawSaveLoading(false); + } + }, [effectiveOpenclawGatewayUrl, queryClient]); + + const resetOpenclawDeviceState = useCallback( + async (regenerateIdentity: boolean) => { + if (openclawResetLoading) return; + setOpenclawResetLoading(regenerateIdentity ? "identity" : "token"); + try { + const api = ensureNativeApi(); + const summary = await api.server.resetOpenclawGatewayDeviceState({ + regenerateIdentity, + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + setOpenclawTestResult(null); + } finally { + setOpenclawResetLoading(null); + } + }, + [openclawResetLoading, queryClient], + ); + + const importLegacyOpenclawSettings = useCallback(async () => { + const gatewayUrl = settings.openclawGatewayUrl.trim(); + if (!gatewayUrl) { + throw new Error("Legacy OpenClaw settings do not contain a gateway URL."); + } + setOpenclawSaveLoading(true); + try { + const api = ensureNativeApi(); + const sharedSecret = settings.openclawPassword.trim(); + const summary = await api.server.saveOpenclawGatewayConfig({ + gatewayUrl, + ...(sharedSecret ? { sharedSecret } : {}), + }); + queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); + void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + updateSettings({ + openclawGatewayUrl: defaults.openclawGatewayUrl, + openclawPassword: defaults.openclawPassword, + }); + setOpenclawGatewayDraft(null); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + } finally { + setOpenclawSaveLoading(false); + } + }, [ + defaults.openclawGatewayUrl, + defaults.openclawPassword, + queryClient, + settings.openclawGatewayUrl, + settings.openclawPassword, + updateSettings, + ]); + const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -1344,23 +1233,6 @@ function SettingsRouteView() { ))} - - - - - } - /> - - Open tweakcn in the in-app browser - - { clearFontSizeOverride(); setFontSizeOverrideState(null); @@ -1445,7 +1317,7 @@ function SettingsRouteView() { setStoredFontSizeOverride(value); }} className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Code font size" + aria-label="Font size" /> {fontSizeOverride ?? 12}px @@ -1564,150 +1436,6 @@ function SettingsRouteView() { } /> - - updateSettings({ - sidebarProjectRowHeight: DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, - }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ - sidebarProjectRowHeight: Number(e.target.value), - }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Project height" - /> - - {settings.sidebarProjectRowHeight}px - -
- } - /> - - - updateSettings({ - sidebarThreadRowHeight: DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, - }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ - sidebarThreadRowHeight: Number(e.target.value), - }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Thread height" - /> - - {settings.sidebarThreadRowHeight}px - -
- } - /> - - - updateSettings({ sidebarFontSize: DEFAULT_SIDEBAR_FONT_SIZE }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ sidebarFontSize: Number(e.target.value) }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar font size" - /> - - {settings.sidebarFontSize}px - -
- } - /> - - - updateSettings({ sidebarSpacing: DEFAULT_SIDEBAR_SPACING }) - } - /> - ) : null - } - control={ -
- { - updateSettings({ sidebarSpacing: Number(e.target.value) }); - }} - className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Sidebar spacing" - /> - - {settings.sidebarSpacing}px - -
- } - /> - - - updateSettings({ - showNotificationDetails: defaults.showNotificationDetails, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - showNotificationDetails: Boolean(checked), - }) - } - aria-label="Show notification details by default" - /> - } - /> - - - updateSettings({ - includeDiagnosticsTipsInCopy: defaults.includeDiagnosticsTipsInCopy, - }) - } - /> - ) : null - } - control={ - - updateSettings({ - includeDiagnosticsTipsInCopy: Boolean(checked), - }) - } - aria-label="Include diagnostics tips in copied text" - /> - } - /> - )} - {activeSection === "authentication" && ( + {activeSection === "environment" && ( void refreshProviderStatuses()} - > - - Refresh status - - } - > - -
- {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( - status.provider === provider) ?? - null - } - openclawGatewayUrl={settings.openclawGatewayUrl} - /> - ))} -
-
- - { - updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - openclaw: false, - }); - }} - /> - ) : null - } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- - - {providerSettings.homePathKey ? ( - - ) : null} -
-
-
-
-
- ); - })} -
-
-
- - 0 - ? `Configured for ${settings.openclawGatewayUrl}` - : "Not configured" - } - resetAction={ - isOpenClawSettingsDirty ? ( - - updateSettings({ - openclawGatewayUrl: defaults.openclawGatewayUrl, - openclawPassword: defaults.openclawPassword, - }) - } - /> - ) : null - } - > -
- - - -
- -
- - {openclawTestResult ? ( -
-
- {openclawTestResult.success ? ( - - ) : ( - - )} - - {openclawTestResult.success - ? "Connection successful" - : "Connection failed"} - - - {openclawTestResult.totalDurationMs}ms total - - -
- - {openclawTestResult.steps.length > 0 ? ( -
- {openclawTestResult.steps.map((step) => ( -
- {step.status === "pass" ? ( - - ) : null} - {step.status === "fail" ? ( - - ) : null} - {step.status === "skip" ? ( - - ) : null} -
-
- - {step.name} - - - {step.durationMs}ms - -
- {step.detail ? ( - - {step.detail} - - ) : null} -
-
- ))} -
- ) : null} - - {openclawTestResult.error && - !openclawTestResult.steps.some((step) => step.status === "fail") ? ( -
- {openclawTestResult.error} -
- ) : null} -
- ) : null} -
-
-
- )} - - {activeSection === "hotkeys" && ( - - )} - - {activeSection === "environment" && ( - )} - {activeSection === "models" && ( + {activeSection === "models" && ( + + + updateSettings({ + textGenerationModel: defaults.textGenerationModel, + }) + } + /> + ) : null + } + control={ + + } + /> + + 0 ? ( + { + updateSettings({ + customCodexModels: defaults.customCodexModels, + customClaudeModels: defaults.customClaudeModels, + }); + setCustomModelErrorByProvider({}); + setShowAllCustomModels(false); + }} + /> + ) : null + } + > +
+
+ + { + const value = event.target.value; + setCustomModelInputByProvider((existing) => ({ + ...existing, + [selectedCustomModelProvider]: value, + })); + if (selectedCustomModelError) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [selectedCustomModelProvider]: null, + })); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(selectedCustomModelProvider); + }} + placeholder={selectedCustomModelProviderSettings.example} + spellCheck={false} + /> + +
+ + {selectedCustomModelError ? ( +

+ {selectedCustomModelError} +

+ ) : null} + + {totalCustomModels > 0 ? ( +
+
+ {visibleCustomModelRows.map((row) => ( +
+ + {row.providerTitle} + + + {row.slug} + + +
+ ))} +
+ + {savedCustomModelRows.length > 5 ? ( + + ) : null} +
+ ) : null} +
+
+
+ )} + + {activeSection === "mobile" && !isMobileShell && ( + + +
+ +
+
+
+ )} + + {activeSection === "advanced" && ( + label="provider installs" + onClick={() => { updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } + claudeBinaryPath: defaults.claudeBinaryPath, + codexBinaryPath: defaults.codexBinaryPath, + codexHomePath: defaults.codexHomePath, + }); + setOpenInstallProviders({ + codex: false, + claudeAgent: false, + openclaw: false, + }); + }} /> ) : null } - control={ - - } - /> + > +
+
+ {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { + const isOpen = openInstallProviders[providerSettings.provider]; + const isDirty = + providerSettings.provider === "codex" + ? settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath + : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + const binaryPathValue = + providerSettings.binaryPathKey === "claudeBinaryPath" + ? claudeBinaryPath + : codexBinaryPath; + + return ( + + setOpenInstallProviders((existing) => ({ + ...existing, + [providerSettings.provider]: open, + })) + } + > +
+ + + +
+
+ + + {providerSettings.homePathKey ? ( + + ) : null} +
+
+
+
+
+ ); + })} +
+
+
0 ? ( + isOpenClawSettingsDirty ? ( { + setOpenclawGatewayDraft(null); + setOpenclawSharedSecretDraft(""); + setOpenclawTestResult(null); + }} + /> + ) : null + } + > +
+ {canImportLegacyOpenclawSettings ? ( +
+
+ + Legacy browser-local OpenClaw settings were found. Import them to + the server to make them active. + + +
+
+ ) : null} + + + +
+
+ Saved URL:{" "} + + {openclawGatewayConfigQuery.data?.gatewayUrl ?? "Not saved"} + +
+
+ Saved shared secret:{" "} + + {savedOpenclawHasSharedSecret ? "Configured" : "Not configured"} + +
+
+ Device fingerprint:{" "} + + {openclawGatewayConfigQuery.data?.deviceFingerprint ?? "Not created"} + +
+
+ Cached device token:{" "} + + {openclawGatewayConfigQuery.data?.hasDeviceToken + ? "Present" + : "Not cached"} + +
+
+ +
+ + + ) : null} +
- {selectedCustomModelError ? ( -

- {selectedCustomModelError} -

- ) : null} + {/* Debug / Results Panel */} + {openclawTestResult && ( +
+ {/* Overall status header */} +
+ {openclawTestResult.success ? ( + + ) : ( + + )} + + {openclawTestResult.success + ? "Connection successful" + : "Connection failed"} + + + {openclawTestResult.totalDurationMs}ms total + + +
- {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - + {step.status === "pass" && ( + + )} + {step.status === "fail" && ( + + )} + {step.status === "skip" && ( + + )} +
+
+ + {step.name} + + + {step.durationMs}ms + +
+ {step.detail && ( + + {step.detail} + + )} +
+
+ ))} +
+ )} + + {/* Server info */} + {openclawTestResult.serverInfo && ( +
+ + Server Info + +
+ {openclawTestResult.serverInfo.version && ( +
+ Version:{" "} + + {openclawTestResult.serverInfo.version} + +
+ )} + {openclawTestResult.serverInfo.sessionId && ( +
+ Session:{" "} + + {openclawTestResult.serverInfo.sessionId} + +
+ )}
- ))} -
+
+ )} - {savedCustomModelRows.length > 5 ? ( - - ) : null} + {openclawTestResult.diagnostics && ( +
+ + Debugging Context + +
+ {openclawTestResult.diagnostics.normalizedUrl && ( +
+ Endpoint:{" "} + + {openclawTestResult.diagnostics.normalizedUrl} + +
+ )} + {openclawTestResult.diagnostics.hostKind && ( +
+ Host type:{" "} + + {describeOpenclawGatewayHostKind( + openclawTestResult.diagnostics.hostKind, + )} + +
+ )} + {openclawTestResult.diagnostics.resolvedAddresses.length > 0 && ( +
+ Resolved:{" "} + + {openclawTestResult.diagnostics.resolvedAddresses.join( + ", ", + )} + +
+ )} + {describeOpenclawGatewayHealthStatus(openclawTestResult) && ( +
+ Health probe:{" "} + + {describeOpenclawGatewayHealthStatus(openclawTestResult)} + + {openclawTestResult.diagnostics.healthUrl && ( + <> + {" "} + at{" "} + + {openclawTestResult.diagnostics.healthUrl} + + + )} +
+ )} + {openclawTestResult.diagnostics.socketCloseCode !== undefined && ( +
+ Socket close:{" "} + + {openclawTestResult.diagnostics.socketCloseCode} + {openclawTestResult.diagnostics.socketCloseReason + ? ` (${openclawTestResult.diagnostics.socketCloseReason})` + : ""} + +
+ )} + {openclawTestResult.diagnostics.socketError && ( +
+ Socket error:{" "} + + {openclawTestResult.diagnostics.socketError} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorCode && ( +
+ Gateway error code:{" "} + + {openclawTestResult.diagnostics.gatewayErrorCode} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorDetailCode && ( +
+ Gateway detail code:{" "} + + {openclawTestResult.diagnostics.gatewayErrorDetailCode} + +
+ )} + {openclawTestResult.diagnostics.gatewayErrorDetailReason && ( +
+ Gateway detail reason:{" "} + + {openclawTestResult.diagnostics.gatewayErrorDetailReason} + +
+ )} + {openclawTestResult.diagnostics.gatewayRecommendedNextStep && ( +
+ Gateway next step:{" "} + + {openclawTestResult.diagnostics.gatewayRecommendedNextStep} + +
+ )} + {openclawTestResult.diagnostics.gatewayCanRetryWithDeviceToken !== + undefined && ( +
+ Device-token retry available:{" "} + + {openclawTestResult.diagnostics + .gatewayCanRetryWithDeviceToken + ? "Yes" + : "No"} + +
+ )} + {openclawTestResult.diagnostics.observedNotifications.length > + 0 && ( +
+ Gateway events:{" "} + + {openclawTestResult.diagnostics.observedNotifications.join( + ", ", + )} + +
+ )} +
+
+ )} + + {openclawTestResult.diagnostics && + openclawTestResult.diagnostics.hints.length > 0 && ( +
+ + Troubleshooting + +
    + {openclawTestResult.diagnostics.hints.map((hint) => ( +
  • + + {hint} +
  • + ))} +
+
+ )} + + {/* Error summary */} + {openclawTestResult.error && + !openclawTestResult.steps.some((s) => s.status === "fail") && ( +
+ {openclawTestResult.error} +
+ )}
- ) : null} + )}
-
- )} - {activeSection === "mobile" && !isMobileShell && ( - -
- -
-
-
- )} + title="Keybindings" + description="Open the persisted `keybindings.json` file to edit advanced bindings directly." + status={ + <> + + {keybindingsConfigPath ?? "Resolving keybindings path..."} + + {openKeybindingsError ? ( + + {openKeybindingsError} + + ) : ( + Opens in your preferred editor. + )} + + } + control={ + + } + /> - {activeSection === "advanced" && ( - transport.request(WS_METHODS.serverSaveProjectEnvironmentVariables, input), upsertKeybinding: (input) => transport.request(WS_METHODS.serverUpsertKeybinding, input), + getOpenclawGatewayConfig: () => transport.request(WS_METHODS.serverGetOpenclawGatewayConfig), + saveOpenclawGatewayConfig: (input) => + transport.request(WS_METHODS.serverSaveOpenclawGatewayConfig, input), + resetOpenclawGatewayDeviceState: (input) => + transport.request(WS_METHODS.serverResetOpenclawGatewayDeviceState, input), replaceKeybindingRules: (input) => transport.request(WS_METHODS.serverReplaceKeybindingRules, input), testOpenclawGateway: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4cebaec84..bd399622a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -71,7 +71,6 @@ import type { GitHubPostCommentInput, GitHubPostCommentResult, } from "./github"; -import type { ServerConfig, TestOpenclawGatewayInput, TestOpenclawGatewayResult } from "./server"; import type { GlobalEnvironmentVariablesResult, ProjectEnvironmentVariablesInput, @@ -90,11 +89,17 @@ import type { TerminalWriteInput, } from "./terminal"; import type { + OpenclawGatewayConfigSummary, + ResetOpenclawGatewayDeviceStateInput, + SaveOpenclawGatewayConfigInput, + ServerConfig, ServerReplaceKeybindingRulesInput, ServerReplaceKeybindingRulesResult, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, ServerUpdateInfo, + TestOpenclawGatewayInput, + TestOpenclawGatewayResult, } from "./server"; import type { SkillListInput, @@ -478,6 +483,13 @@ export interface NativeApi { input: SaveProjectEnvironmentVariablesInput, ) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + getOpenclawGatewayConfig: () => Promise; + saveOpenclawGatewayConfig: ( + input: SaveOpenclawGatewayConfigInput, + ) => Promise; + resetOpenclawGatewayDeviceState: ( + input?: ResetOpenclawGatewayDeviceStateInput, + ) => Promise; replaceKeybindingRules: ( input: ServerReplaceKeybindingRulesInput, ) => Promise; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 8e7efd10f..64c5f68a7 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -25,6 +25,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.permission", "codex.sdk.thread-event", "openclaw.gateway.notification", + "openclaw.gateway.event", + "openclaw.gateway.response", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index c433820a0..1ebaa4765 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -149,6 +149,33 @@ export const ListTokensResult = Schema.Struct({ }); export type ListTokensResult = typeof ListTokensResult.Type; +// ── OpenClaw Gateway Config ───────────────────────────────────────── + +export const OpenclawGatewayConfigSummary = Schema.Struct({ + gatewayUrl: Schema.NullOr(TrimmedNonEmptyString), + hasSharedSecret: Schema.Boolean, + deviceId: Schema.NullOr(TrimmedNonEmptyString), + devicePublicKey: Schema.NullOr(TrimmedNonEmptyString), + deviceFingerprint: Schema.NullOr(TrimmedNonEmptyString), + hasDeviceToken: Schema.Boolean, + deviceTokenRole: Schema.NullOr(TrimmedNonEmptyString), + deviceTokenScopes: Schema.Array(TrimmedNonEmptyString), + updatedAt: Schema.NullOr(IsoDateTime), +}); +export type OpenclawGatewayConfigSummary = typeof OpenclawGatewayConfigSummary.Type; + +export const SaveOpenclawGatewayConfigInput = Schema.Struct({ + gatewayUrl: TrimmedNonEmptyString, + sharedSecret: Schema.optional(Schema.String), + clearSharedSecret: Schema.optional(Schema.Boolean), +}); +export type SaveOpenclawGatewayConfigInput = typeof SaveOpenclawGatewayConfigInput.Type; + +export const ResetOpenclawGatewayDeviceStateInput = Schema.Struct({ + regenerateIdentity: Schema.optional(Schema.Boolean), +}); +export type ResetOpenclawGatewayDeviceStateInput = typeof ResetOpenclawGatewayDeviceStateInput.Type; + // ── Companion Pairing (new model) ────────────────────────────────── // The companion pairing model replaces the single-token deep-link flow // with endpoint-aware bundles and device-scoped sessions. The legacy @@ -239,7 +266,7 @@ export type RevokePairedDeviceResult = typeof RevokePairedDeviceResult.Type; // ── OpenClaw Gateway Test ─────────────────────────────────────────── export const TestOpenclawGatewayInput = Schema.Struct({ - gatewayUrl: Schema.String, + gatewayUrl: Schema.optional(Schema.String), password: Schema.optional(Schema.String), }); export type TestOpenclawGatewayInput = typeof TestOpenclawGatewayInput.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index cd4b4a9d5..fc5131e52 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -84,8 +84,10 @@ import { ExchangeCompanionBootstrapInput, GenerateCompanionPairingBundleInput, GeneratePairingLinkInput, + ResetOpenclawGatewayDeviceStateInput, RevokePairedDeviceInput, RevokeTokenInput, + SaveOpenclawGatewayConfigInput, ServerConfigUpdatedPayload, ServerReplaceKeybindingRulesInput, TestOpenclawGatewayInput, @@ -218,6 +220,9 @@ export const WS_METHODS = { serverRotateToken: "server.rotateToken", serverRevokeToken: "server.revokeToken", serverListTokens: "server.listTokens", + serverGetOpenclawGatewayConfig: "server.getOpenclawGatewayConfig", + serverSaveOpenclawGatewayConfig: "server.saveOpenclawGatewayConfig", + serverResetOpenclawGatewayDeviceState: "server.resetOpenclawGatewayDeviceState", // Companion pairing serverGenerateCompanionPairingBundle: "server.generateCompanionPairingBundle", @@ -400,6 +405,12 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.serverRotateToken, Schema.Struct({})), tagRequestBody(WS_METHODS.serverRevokeToken, RevokeTokenInput), tagRequestBody(WS_METHODS.serverListTokens, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverGetOpenclawGatewayConfig, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverSaveOpenclawGatewayConfig, SaveOpenclawGatewayConfigInput), + tagRequestBody( + WS_METHODS.serverResetOpenclawGatewayDeviceState, + ResetOpenclawGatewayDeviceStateInput, + ), // Companion pairing tagRequestBody( From de9e802fca72ba9da67aa2d50743b6df902ba687 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 10 Apr 2026 03:37:44 -0500 Subject: [PATCH 2/7] Wire in Openclaw gateway config and drop Solar Witch theme - Add `OpenclawGatewayConfigLive` to the server runtime layer - Remove the Solar Witch theme definitions from the web styles --- apps/web/src/themes.css | 60 ----------------------------------------- 1 file changed, 60 deletions(-) diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css index 16412bd16..2710de9a7 100644 --- a/apps/web/src/themes.css +++ b/apps/web/src/themes.css @@ -63,66 +63,6 @@ --warning-foreground: #f1a10d; } -/* ─── Solar Witch ─── magical, cozy, ritualistic ─── */ - -:root.theme-solar-witch { - color-scheme: light; - --background: #faf5ee; - --foreground: #2d2118; - --card: #f5efe4; - --card-foreground: #2d2118; - --popover: #f5efe4; - --popover-foreground: #2d2118; - --primary: oklch(0.62 0.18 55); - --primary-foreground: #ffffff; - --secondary: rgba(160, 100, 40, 0.06); - --secondary-foreground: #2d2118; - --muted: rgba(160, 100, 40, 0.06); - --muted-foreground: #8a7560; - --accent: rgba(160, 100, 40, 0.08); - --accent-foreground: #2d2118; - --destructive: #e5484d; - --destructive-foreground: #cd2b31; - --border: rgba(160, 100, 40, 0.1); - --input: rgba(160, 100, 40, 0.12); - --ring: oklch(0.62 0.18 55); - --info: #b47a2b; - --info-foreground: #96631e; - --success: #46a758; - --success-foreground: #2d8a3e; - --warning: #e5a836; - --warning-foreground: #ad7a18; -} - -:root.theme-solar-witch.dark { - color-scheme: dark; - --background: #120e0a; - --foreground: #f0e6d6; - --card: #1a140e; - --card-foreground: #f0e6d6; - --popover: #1a140e; - --popover-foreground: #f0e6d6; - --primary: oklch(0.72 0.17 60); - --primary-foreground: #120e0a; - --secondary: rgba(220, 170, 100, 0.06); - --secondary-foreground: #f0e6d6; - --muted: rgba(220, 170, 100, 0.06); - --muted-foreground: #9a8a78; - --accent: rgba(220, 170, 100, 0.08); - --accent-foreground: #f0e6d6; - --destructive: #ff6369; - --destructive-foreground: #ff9592; - --border: rgba(220, 170, 100, 0.08); - --input: rgba(220, 170, 100, 0.1); - --ring: oklch(0.72 0.17 60); - --info: #e8a860; - --info-foreground: #f0c088; - --success: #4cc38a; - --success-foreground: #3dd68c; - --warning: #ffb224; - --warning-foreground: #f1a10d; -} - /* ─── Carbon ─── stark, modern, performance-focused (Vercel-inspired) ─── */ :root.theme-carbon { From a4d7b29cc0a20987946150bb6bf120aef307c622 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:06:56 +0000 Subject: [PATCH 3/7] Apply reviewer feedback: fix connect handshake, config error handling, Solar Witch theme, and legacy import banner gating Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/9615af9c-7cb2-409b-93c1-541b906a4a67 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --- apps/server/src/openclawGatewayTest.ts | 280 +++++++++++++++++++++++++ apps/web/src/themes.css | 60 ++++++ 2 files changed, 340 insertions(+) diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index f6b5d69da..3b044495d 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -487,6 +487,262 @@ export async function runOpenclawGatewayTest( }; }; +<<<<<<< HEAD +======= + let parsedUrlForHints: URL | null = null; + const testDeviceIdentity = generateOpenclawDeviceIdentity(); + + const waitForGatewayEvent = ( + socket: NodeWebSocket, + eventName: string, + ): Promise | undefined> => + new Promise((resolve, reject) => { + const bufferedIndex = earlyGatewayEvents.findIndex( + (message) => message.type === "event" && message.event === eventName, + ); + if (bufferedIndex >= 0) { + const [message] = earlyGatewayEvents.splice(bufferedIndex, 1); + resolve( + typeof message?.payload === "object" && message.payload !== null + ? (message.payload as Record) + : undefined, + ); + return; + } + + let settled = false; + let timeout: ReturnType | undefined; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + } + socket.off("message", onMessage); + socket.off("close", onClose); + socket.off("error", onError); + }; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + + const onMessage = (data: NodeWebSocket.Data) => { + const message = parseGatewayEnvelope(data); + if (!message) { + return; + } + if (message.type === "event" && typeof message.event === "string") { + pushUnique(diagnostics.observedNotifications, message.event); + if (message.event === eventName) { + settle(() => + resolve( + typeof message.payload === "object" && message.payload !== null + ? (message.payload as Record) + : undefined, + ), + ); + } + } + }; + + const onClose = (code: number, reasonBuffer: Buffer) => { + diagnostics.socketCloseCode = code; + const reason = reasonBuffer.toString("utf8"); + if (reason.length > 0) { + diagnostics.socketCloseReason = reason; + } + const closeDetail = formatSocketClose(code, reason); + settle(() => + reject( + new Error( + `WebSocket closed before gateway event '${eventName}' arrived${ + closeDetail ? ` (${closeDetail})` : "" + }.`, + ), + ), + ); + }; + + const onError = (cause: Error) => { + diagnostics.socketError = toMessage(cause, "WebSocket error."); + settle(() => + reject( + new Error( + `WebSocket error while waiting for gateway event '${eventName}': ${diagnostics.socketError}`, + ), + ), + ); + }; + + socket.on("message", onMessage); + socket.on("close", onClose); + socket.on("error", onError); + + timeout = setTimeout(() => { + settle(() => + reject(new Error(buildTimeoutDetail(`Gateway event '${eventName}'`, diagnostics))), + ); + }, OPENCLAW_TEST_RPC_TIMEOUT_MS); + }); + + const sendGatewayRequest = ( + socket: NodeWebSocket, + method: string, + params?: Record, + ): Promise<{ payload?: unknown; error?: ParsedGatewayError }> => + new Promise((resolve, reject) => { + const id = String(rpcId++); + let settled = false; + let timeout: ReturnType | undefined; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + } + socket.off("message", onMessage); + socket.off("close", onClose); + socket.off("error", onError); + }; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + cleanup(); + callback(); + }; + + const onMessage = (data: NodeWebSocket.Data) => { + const message = parseGatewayEnvelope(data); + if (!message) { + return; + } + if (message.type === "event" && typeof message.event === "string") { + pushUnique(diagnostics.observedNotifications, message.event); + return; + } + if (message.type === "res" && message.id === id) { + if (message.ok === true) { + recordGatewayError(diagnostics, undefined); + settle(() => + resolve( + message.payload !== undefined + ? { payload: message.payload } + : { payload: undefined }, + ), + ); + return; + } + + const parsedError = parseGatewayError(message.error); + recordGatewayError(diagnostics, parsedError); + settle(() => resolve({ error: parsedError })); + } + }; + + const onClose = (code: number, reasonBuffer: Buffer) => { + diagnostics.socketCloseCode = code; + const reason = reasonBuffer.toString("utf8"); + if (reason.length > 0) { + diagnostics.socketCloseReason = reason; + } + const closeDetail = formatSocketClose(code, reason); + settle(() => + reject( + new Error( + `WebSocket closed before gateway request '${method}' completed${ + closeDetail ? ` (${closeDetail})` : "" + }.`, + ), + ), + ); + }; + + const onError = (cause: Error) => { + diagnostics.socketError = toMessage(cause, "WebSocket error."); + settle(() => + reject( + new Error( + `WebSocket error during gateway request '${method}': ${diagnostics.socketError}`, + ), + ), + ); + }; + + socket.on("message", onMessage); + socket.on("close", onClose); + socket.on("error", onError); + + timeout = setTimeout(() => { + settle(() => + reject(new Error(buildTimeoutDetail(`Gateway request '${method}'`, diagnostics))), + ); + }, OPENCLAW_TEST_RPC_TIMEOUT_MS); + + try { + socket.send( + JSON.stringify({ + type: "req", + id, + method, + ...(params !== undefined ? { params } : {}), + }), + ); + } catch (cause) { + diagnostics.socketError = toMessage(cause, "WebSocket send failed."); + settle(() => reject(cause instanceof Error ? cause : new Error(diagnostics.socketError))); + } + }); + + const buildConnectParams = ( + sharedSecret: string | undefined, + challenge: Record | undefined, + ): Record => { + const nonce = + typeof challenge?.nonce === "string" && challenge.nonce.length > 0 ? challenge.nonce : ""; + const signedAt = + typeof challenge?.ts === "number" && Number.isFinite(challenge.ts) + ? challenge.ts + : Date.now(); + const authToken = sharedSecret ?? ""; + const signedDevice = signOpenclawDeviceChallenge(testDeviceIdentity, { + clientId: "okcode", + clientMode: "operator", + role: "operator", + scopes: [...OPENCLAW_OPERATOR_SCOPES], + token: authToken, + nonce, + signedAt, + }); + return { + minProtocol: OPENCLAW_PROTOCOL_VERSION, + maxProtocol: OPENCLAW_PROTOCOL_VERSION, + client: { + id: "okcode", + version: serverBuildInfo.version, + platform: + process.platform === "darwin" + ? "macos" + : process.platform === "win32" + ? "windows" + : process.platform, + mode: "operator", + }, + role: "operator", + scopes: [...OPENCLAW_OPERATOR_SCOPES], + caps: [], + commands: [], + permissions: {}, + locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", + userAgent: `okcode/${serverBuildInfo.version}`, + ...(authToken.length > 0 ? { auth: { token: authToken } } : {}), + device: signedDevice, + }; + }; + +>>>>>>> 6de978d0 (Apply reviewer feedback: fix connect handshake, config error handling, Solar Witch theme, and legacy import banner gating) try { const urlStart = Date.now(); const gatewayUrl = input.gatewayUrl?.trim() ?? ""; @@ -600,7 +856,31 @@ export async function runOpenclawGatewayTest( applyHealthProbe(await healthPromise); const handshakeStart = Date.now(); +<<<<<<< HEAD pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); +======= + try { + const challenge = await waitForGatewayEvent(ws, "connect.challenge"); + captureEarlyGatewayEvents = false; + earlyGatewayEvents.length = 0; + const connectResult = await sendGatewayRequest( + ws, + "connect", + buildConnectParams(sharedSecret, challenge), + ); + if (connectResult.error) { + const detail = formatGatewayError(connectResult.error); + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); + } + pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); + } catch (cause) { + const detail = toMessage(cause, "Gateway handshake failed."); + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); + } + +>>>>>>> 6de978d0 (Apply reviewer feedback: fix connect handshake, config error handling, Solar Witch theme, and legacy import banner gating) return finalize(true); } finally { try { diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css index 2710de9a7..1752bf3d5 100644 --- a/apps/web/src/themes.css +++ b/apps/web/src/themes.css @@ -63,6 +63,66 @@ --warning-foreground: #f1a10d; } +/* ─── Solar Witch ─── mystical, radiant, high-contrast sunset magic ─── */ + +:root.theme-solar-witch { + color-scheme: light; + --background: #fff7ed; + --foreground: #3b0764; + --card: #ffedd5; + --card-foreground: #3b0764; + --popover: #fff7ed; + --popover-foreground: #3b0764; + --primary: oklch(0.68 0.23 35); + --primary-foreground: #ffffff; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #7c2d12; + --muted: rgba(245, 158, 11, 0.12); + --muted-foreground: #9a3412; + --accent: rgba(236, 72, 153, 0.12); + --accent-foreground: #831843; + --destructive: #dc2626; + --destructive-foreground: #b91c1c; + --border: rgba(234, 88, 12, 0.18); + --input: rgba(234, 88, 12, 0.14); + --ring: oklch(0.68 0.23 35); + --info: #7c3aed; + --info-foreground: #6d28d9; + --success: #16a34a; + --success-foreground: #15803d; + --warning: #f59e0b; + --warning-foreground: #b45309; +} + +:root.theme-solar-witch.dark { + color-scheme: dark; + --background: #140b1f; + --foreground: #f8e8ff; + --card: #1f102b; + --card-foreground: #f8e8ff; + --popover: #1f102b; + --popover-foreground: #f8e8ff; + --primary: oklch(0.74 0.21 55); + --primary-foreground: #1a1022; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #ffd7a3; + --muted: rgba(236, 72, 153, 0.1); + --muted-foreground: #d8b4fe; + --accent: rgba(168, 85, 247, 0.14); + --accent-foreground: #f3e8ff; + --destructive: #fb7185; + --destructive-foreground: #fda4af; + --border: rgba(251, 146, 60, 0.14); + --input: rgba(251, 146, 60, 0.18); + --ring: oklch(0.74 0.21 55); + --info: #a78bfa; + --info-foreground: #c4b5fd; + --success: #4ade80; + --success-foreground: #86efac; + --warning: #fbbf24; + --warning-foreground: #fcd34d; +} + /* ─── Carbon ─── stark, modern, performance-focused (Vercel-inspired) ─── */ :root.theme-carbon { From 79c0ff9f8d89712e756c18c20cb673fc2ee42165 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:09:19 +0000 Subject: [PATCH 4/7] Add nonce validation in test handshake for clearer error reporting Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/9615af9c-7cb2-409b-93c1-541b906a4a67 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --- apps/server/src/openclawGatewayTest.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index 3b044495d..98aeb9111 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -863,6 +863,11 @@ export async function runOpenclawGatewayTest( const challenge = await waitForGatewayEvent(ws, "connect.challenge"); captureEarlyGatewayEvents = false; earlyGatewayEvents.length = 0; + if (typeof challenge?.nonce !== "string" || challenge.nonce.length === 0) { + const detail = "Gateway challenge did not include a nonce."; + pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); + return finalize(false, detail, "Gateway handshake"); + } const connectResult = await sendGatewayRequest( ws, "connect", From 4f71ce73fa552548ed3e3b1acee2e4ac4baa2990 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:59:03 -0500 Subject: [PATCH 5/7] Update DESIGN.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- DESIGN.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a83e69c56..97988926f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -93,16 +93,15 @@ Colors are referenced through CSS custom properties, never hardcoded hex values. ### Themes -Six premium themes, each with light and dark variants: - -| Theme | Vibe | -| --------------------- | ------------------------------------- | -| **Iridescent Void** | Futuristic, expensive, slightly alien | -| **Solar Witch** | Magical, cozy, ritualistic | -| **Carbon** | Stark, modern, performance-focused | -| **Vapor** | Refined, fluid, purposeful | -| **Cotton Candy** | Sweet, dreamy, pink and blue | -| **Cathedral Circuit** | Sacred machine, techno-gothic | +Five premium themes, each with light and dark variants: + +| Theme | Vibe | +|-------|------| +| **Iridescent Void** | Futuristic, expensive, slightly alien | +| **Carbon** | Stark, modern, performance-focused | +| **Vapor** | Refined, fluid, purposeful | +| **Cotton Candy** | Sweet, dreamy, pink and blue | +| **Cathedral Circuit** | Sacred machine, techno-gothic | All themes define the same set of CSS custom properties. Components must use semantic tokens (`bg-accent`, `text-muted-foreground`) — never theme-specific values. From 707086bbbb85bf765cb71e6c46ef3b216ac73fae Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:59:12 -0500 Subject: [PATCH 6/7] Update apps/web/src/themes.css Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- apps/web/src/themes.css | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/apps/web/src/themes.css b/apps/web/src/themes.css index 1752bf3d5..4a5488674 100644 --- a/apps/web/src/themes.css +++ b/apps/web/src/themes.css @@ -94,6 +94,65 @@ --warning-foreground: #b45309; } +:root.theme-solar-witch.dark { + color-scheme: dark; + --background: #140b1f; + --foreground: #f8e8ff; + --card: #1f102b; + --card-foreground: #f8e8ff; + --popover: #1f102b; + --popover-foreground: #f8e8ff; + --primary: oklch(0.74 0.21 55); + --primary-foreground: #1a1022; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #ffd7a3; + --muted: rgba(236, 72, 153, 0.1); + --muted-foreground: #d8b4fe; + --accent: rgba(168, 85, 247, 0.14); + --accent-foreground: #f3e8ff; + --destructive: #fb7185; + --destructive-foreground: #fda4af; + --border: rgba(251, 146, 60, 0.14); + --input: rgba(251, 146, 60, 0.18); + --ring: oklch(0.74 0.21 55); + --info: #a78bfa; + --info-foreground: #c4b5fd; + --success: #4ade80; + --success-foreground: #86efac; + --warning: #fbbf24; + --warning-foreground: #fcd34d; +} +/* ─── Solar Witch ─── mystical, radiant, high-contrast sunset magic ─── */ + +:root.theme-solar-witch { + color-scheme: light; + --background: #fff7ed; + --foreground: #3b0764; + --card: #ffedd5; + --card-foreground: #3b0764; + --popover: #fff7ed; + --popover-foreground: #3b0764; + --primary: oklch(0.68 0.23 35); + --primary-foreground: #ffffff; + --secondary: rgba(251, 146, 60, 0.12); + --secondary-foreground: #7c2d12; + --muted: rgba(245, 158, 11, 0.12); + --muted-foreground: #9a3412; + --accent: rgba(236, 72, 153, 0.12); + --accent-foreground: #831843; + --destructive: #dc2626; + --destructive-foreground: #b91c1c; + --border: rgba(234, 88, 12, 0.18); + --input: rgba(234, 88, 12, 0.14); + --ring: oklch(0.68 0.23 35); + --info: #7c3aed; + --info-foreground: #6d28d9; + --success: #16a34a; + --success-foreground: #15803d; + --warning: #f59e0b; + --warning-foreground: #b45309; +} + :root.theme-solar-witch.dark { color-scheme: dark; --background: #140b1f; From 2918b9dd661863eb474b304dc01f76fc9d15d31b Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 12 Apr 2026 21:10:13 -0500 Subject: [PATCH 7/7] Route Claude chats through provider runtime - Remove Anthropic env-based chat handling - Validate Claude against provider health and new provider options - Update migrations and theme docs --- DESIGN.md | 19 +- apps/server/src/openclawGatewayTest.ts | 285 --- apps/server/src/persistence/Migrations.ts | 10 +- ...Config.ts => 024_OpenclawGatewayConfig.ts} | 0 .../src/provider/Layers/ProviderHealth.ts | 3 +- .../src/sme/Layers/SmeChatServiceLive.test.ts | 288 +-- .../src/sme/Layers/SmeChatServiceLive.ts | 113 +- apps/server/src/sme/authValidation.ts | 121 +- apps/server/src/sme/backends/anthropic.ts | 56 + apps/server/src/sme/promptBuilder.ts | 14 - apps/server/src/wsServer.ts | 1 - apps/web/src/routes/_chat.settings.tsx | 2089 +++++++++-------- 12 files changed, 1361 insertions(+), 1638 deletions(-) rename apps/server/src/persistence/Migrations/{021_OpenclawGatewayConfig.ts => 024_OpenclawGatewayConfig.ts} (100%) create mode 100644 apps/server/src/sme/backends/anthropic.ts diff --git a/DESIGN.md b/DESIGN.md index 97988926f..a83e69c56 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -93,15 +93,16 @@ Colors are referenced through CSS custom properties, never hardcoded hex values. ### Themes -Five premium themes, each with light and dark variants: - -| Theme | Vibe | -|-------|------| -| **Iridescent Void** | Futuristic, expensive, slightly alien | -| **Carbon** | Stark, modern, performance-focused | -| **Vapor** | Refined, fluid, purposeful | -| **Cotton Candy** | Sweet, dreamy, pink and blue | -| **Cathedral Circuit** | Sacred machine, techno-gothic | +Six premium themes, each with light and dark variants: + +| Theme | Vibe | +| --------------------- | ------------------------------------- | +| **Iridescent Void** | Futuristic, expensive, slightly alien | +| **Solar Witch** | Magical, cozy, ritualistic | +| **Carbon** | Stark, modern, performance-focused | +| **Vapor** | Refined, fluid, purposeful | +| **Cotton Candy** | Sweet, dreamy, pink and blue | +| **Cathedral Circuit** | Sacred machine, techno-gothic | All themes define the same set of CSS custom properties. Components must use semantic tokens (`bg-accent`, `text-muted-foreground`) — never theme-specific values. diff --git a/apps/server/src/openclawGatewayTest.ts b/apps/server/src/openclawGatewayTest.ts index 98aeb9111..f6b5d69da 100644 --- a/apps/server/src/openclawGatewayTest.ts +++ b/apps/server/src/openclawGatewayTest.ts @@ -487,262 +487,6 @@ export async function runOpenclawGatewayTest( }; }; -<<<<<<< HEAD -======= - let parsedUrlForHints: URL | null = null; - const testDeviceIdentity = generateOpenclawDeviceIdentity(); - - const waitForGatewayEvent = ( - socket: NodeWebSocket, - eventName: string, - ): Promise | undefined> => - new Promise((resolve, reject) => { - const bufferedIndex = earlyGatewayEvents.findIndex( - (message) => message.type === "event" && message.event === eventName, - ); - if (bufferedIndex >= 0) { - const [message] = earlyGatewayEvents.splice(bufferedIndex, 1); - resolve( - typeof message?.payload === "object" && message.payload !== null - ? (message.payload as Record) - : undefined, - ); - return; - } - - let settled = false; - let timeout: ReturnType | undefined; - - const cleanup = () => { - if (timeout) { - clearTimeout(timeout); - } - socket.off("message", onMessage); - socket.off("close", onClose); - socket.off("error", onError); - }; - - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - cleanup(); - callback(); - }; - - const onMessage = (data: NodeWebSocket.Data) => { - const message = parseGatewayEnvelope(data); - if (!message) { - return; - } - if (message.type === "event" && typeof message.event === "string") { - pushUnique(diagnostics.observedNotifications, message.event); - if (message.event === eventName) { - settle(() => - resolve( - typeof message.payload === "object" && message.payload !== null - ? (message.payload as Record) - : undefined, - ), - ); - } - } - }; - - const onClose = (code: number, reasonBuffer: Buffer) => { - diagnostics.socketCloseCode = code; - const reason = reasonBuffer.toString("utf8"); - if (reason.length > 0) { - diagnostics.socketCloseReason = reason; - } - const closeDetail = formatSocketClose(code, reason); - settle(() => - reject( - new Error( - `WebSocket closed before gateway event '${eventName}' arrived${ - closeDetail ? ` (${closeDetail})` : "" - }.`, - ), - ), - ); - }; - - const onError = (cause: Error) => { - diagnostics.socketError = toMessage(cause, "WebSocket error."); - settle(() => - reject( - new Error( - `WebSocket error while waiting for gateway event '${eventName}': ${diagnostics.socketError}`, - ), - ), - ); - }; - - socket.on("message", onMessage); - socket.on("close", onClose); - socket.on("error", onError); - - timeout = setTimeout(() => { - settle(() => - reject(new Error(buildTimeoutDetail(`Gateway event '${eventName}'`, diagnostics))), - ); - }, OPENCLAW_TEST_RPC_TIMEOUT_MS); - }); - - const sendGatewayRequest = ( - socket: NodeWebSocket, - method: string, - params?: Record, - ): Promise<{ payload?: unknown; error?: ParsedGatewayError }> => - new Promise((resolve, reject) => { - const id = String(rpcId++); - let settled = false; - let timeout: ReturnType | undefined; - - const cleanup = () => { - if (timeout) { - clearTimeout(timeout); - } - socket.off("message", onMessage); - socket.off("close", onClose); - socket.off("error", onError); - }; - - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - cleanup(); - callback(); - }; - - const onMessage = (data: NodeWebSocket.Data) => { - const message = parseGatewayEnvelope(data); - if (!message) { - return; - } - if (message.type === "event" && typeof message.event === "string") { - pushUnique(diagnostics.observedNotifications, message.event); - return; - } - if (message.type === "res" && message.id === id) { - if (message.ok === true) { - recordGatewayError(diagnostics, undefined); - settle(() => - resolve( - message.payload !== undefined - ? { payload: message.payload } - : { payload: undefined }, - ), - ); - return; - } - - const parsedError = parseGatewayError(message.error); - recordGatewayError(diagnostics, parsedError); - settle(() => resolve({ error: parsedError })); - } - }; - - const onClose = (code: number, reasonBuffer: Buffer) => { - diagnostics.socketCloseCode = code; - const reason = reasonBuffer.toString("utf8"); - if (reason.length > 0) { - diagnostics.socketCloseReason = reason; - } - const closeDetail = formatSocketClose(code, reason); - settle(() => - reject( - new Error( - `WebSocket closed before gateway request '${method}' completed${ - closeDetail ? ` (${closeDetail})` : "" - }.`, - ), - ), - ); - }; - - const onError = (cause: Error) => { - diagnostics.socketError = toMessage(cause, "WebSocket error."); - settle(() => - reject( - new Error( - `WebSocket error during gateway request '${method}': ${diagnostics.socketError}`, - ), - ), - ); - }; - - socket.on("message", onMessage); - socket.on("close", onClose); - socket.on("error", onError); - - timeout = setTimeout(() => { - settle(() => - reject(new Error(buildTimeoutDetail(`Gateway request '${method}'`, diagnostics))), - ); - }, OPENCLAW_TEST_RPC_TIMEOUT_MS); - - try { - socket.send( - JSON.stringify({ - type: "req", - id, - method, - ...(params !== undefined ? { params } : {}), - }), - ); - } catch (cause) { - diagnostics.socketError = toMessage(cause, "WebSocket send failed."); - settle(() => reject(cause instanceof Error ? cause : new Error(diagnostics.socketError))); - } - }); - - const buildConnectParams = ( - sharedSecret: string | undefined, - challenge: Record | undefined, - ): Record => { - const nonce = - typeof challenge?.nonce === "string" && challenge.nonce.length > 0 ? challenge.nonce : ""; - const signedAt = - typeof challenge?.ts === "number" && Number.isFinite(challenge.ts) - ? challenge.ts - : Date.now(); - const authToken = sharedSecret ?? ""; - const signedDevice = signOpenclawDeviceChallenge(testDeviceIdentity, { - clientId: "okcode", - clientMode: "operator", - role: "operator", - scopes: [...OPENCLAW_OPERATOR_SCOPES], - token: authToken, - nonce, - signedAt, - }); - return { - minProtocol: OPENCLAW_PROTOCOL_VERSION, - maxProtocol: OPENCLAW_PROTOCOL_VERSION, - client: { - id: "okcode", - version: serverBuildInfo.version, - platform: - process.platform === "darwin" - ? "macos" - : process.platform === "win32" - ? "windows" - : process.platform, - mode: "operator", - }, - role: "operator", - scopes: [...OPENCLAW_OPERATOR_SCOPES], - caps: [], - commands: [], - permissions: {}, - locale: Intl.DateTimeFormat().resolvedOptions().locale || "en-US", - userAgent: `okcode/${serverBuildInfo.version}`, - ...(authToken.length > 0 ? { auth: { token: authToken } } : {}), - device: signedDevice, - }; - }; - ->>>>>>> 6de978d0 (Apply reviewer feedback: fix connect handshake, config error handling, Solar Witch theme, and legacy import banner gating) try { const urlStart = Date.now(); const gatewayUrl = input.gatewayUrl?.trim() ?? ""; @@ -856,36 +600,7 @@ export async function runOpenclawGatewayTest( applyHealthProbe(await healthPromise); const handshakeStart = Date.now(); -<<<<<<< HEAD pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); -======= - try { - const challenge = await waitForGatewayEvent(ws, "connect.challenge"); - captureEarlyGatewayEvents = false; - earlyGatewayEvents.length = 0; - if (typeof challenge?.nonce !== "string" || challenge.nonce.length === 0) { - const detail = "Gateway challenge did not include a nonce."; - pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); - return finalize(false, detail, "Gateway handshake"); - } - const connectResult = await sendGatewayRequest( - ws, - "connect", - buildConnectParams(sharedSecret, challenge), - ); - if (connectResult.error) { - const detail = formatGatewayError(connectResult.error); - pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); - return finalize(false, detail, "Gateway handshake"); - } - pushStep("Gateway handshake", "pass", Date.now() - handshakeStart, "Connected."); - } catch (cause) { - const detail = toMessage(cause, "Gateway handshake failed."); - pushStep("Gateway handshake", "fail", Date.now() - handshakeStart, detail); - return finalize(false, detail, "Gateway handshake"); - } - ->>>>>>> 6de978d0 (Apply reviewer feedback: fix connect handshake, config error handling, Solar Witch theme, and legacy import banner gating) return finalize(true); } finally { try { diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ad7a15009..2407f083a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -32,7 +32,10 @@ import Migration0017 from "./Migrations/017_EnvironmentVariables.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsGithubRef.ts"; import Migration0019 from "./Migrations/019_SmeKnowledgeBase.ts"; import Migration0020 from "./Migrations/020_SmeConversationProviderAuth.ts"; -import Migration0021 from "./Migrations/021_OpenclawGatewayConfig.ts"; +import Migration0021 from "./Migrations/021_ProjectionPendingUserInputs.ts"; +import Migration0022 from "./Migrations/022_DecisionWorkspace.ts"; +import Migration0023 from "./Migrations/023_ProjectionPendingUserInputsBackfill.ts"; +import Migration0024 from "./Migrations/024_OpenclawGatewayConfig.ts"; import { Effect } from "effect"; /** @@ -66,7 +69,10 @@ const loader = Migrator.fromRecord({ "18_ProjectionThreadsGithubRef": Migration0018, "19_SmeKnowledgeBase": Migration0019, "20_SmeConversationProviderAuth": Migration0020, - "21_OpenclawGatewayConfig": Migration0021, + "21_ProjectionPendingUserInputs": Migration0021, + "22_DecisionWorkspace": Migration0022, + "23_ProjectionPendingUserInputsBackfill": Migration0023, + "24_OpenclawGatewayConfig": Migration0024, }); /** diff --git a/apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts b/apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts similarity index 100% rename from apps/server/src/persistence/Migrations/021_OpenclawGatewayConfig.ts rename to apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 3cbfb84ae..06c56fa1a 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -1,8 +1,7 @@ /** * ProviderHealthLive - Startup-time provider health checks. * - * Performs one-time provider readiness probes when the server starts and - * keeps the resulting snapshot in memory for `server.getConfig`. + * Performs provider readiness probes on demand for `server.getConfig`. * * Uses effect's ChildProcessSpawner to run CLI probes natively. * diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts index 7e0fef604..0551d0270 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -1,11 +1,7 @@ -import { ProjectId, SmeConversationId, type EnvironmentVariableEntry } from "@okcode/contracts"; -import { Effect, Layer, Option, Stream } from "effect"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProjectId, SmeConversationId } from "@okcode/contracts"; +import { Effect, Layer, Option, Queue, Stream } from "effect"; +import { describe, expect, it } from "vitest"; -import { - EnvironmentVariables, - type EnvironmentVariablesShape, -} from "../../persistence/Services/EnvironmentVariables.ts"; import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeKnowledgeDocumentRepository, @@ -33,70 +29,6 @@ import { import { SmeChatService } from "../Services/SmeChatService.ts"; import { makeSmeChatServiceLive } from "./SmeChatServiceLive.ts"; -const originalAnthropicEnv = { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN, - ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, -}; - -afterEach(() => { - restoreAnthropicEnv(); -}); - -function restoreAnthropicEnv() { - for (const [key, value] of Object.entries(originalAnthropicEnv)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } -} - -function setAnthropicEnv(input: { - readonly apiKey?: string; - readonly authToken?: string; - readonly baseURL?: string; -}) { - if (input.apiKey === undefined) { - delete process.env.ANTHROPIC_API_KEY; - } else { - process.env.ANTHROPIC_API_KEY = input.apiKey; - } - - if (input.authToken === undefined) { - delete process.env.ANTHROPIC_AUTH_TOKEN; - } else { - process.env.ANTHROPIC_AUTH_TOKEN = input.authToken; - } - - if (input.baseURL === undefined) { - delete process.env.ANTHROPIC_BASE_URL; - } else { - process.env.ANTHROPIC_BASE_URL = input.baseURL; - } -} - -function toEntries(record: Record): EnvironmentVariableEntry[] { - return Object.entries(record).map(([key, value]) => ({ key, value })); -} - -function makeEnvironmentVariables(persistedEnv: Record): EnvironmentVariablesShape { - const entries = toEntries(persistedEnv); - - return { - getGlobal: () => Effect.succeed({ entries }), - saveGlobal: (input) => Effect.succeed({ entries: input.entries }), - getProject: (input) => Effect.succeed({ projectId: input.projectId, entries }), - saveProject: (input) => - Effect.succeed({ - projectId: input.projectId, - entries: input.entries, - }), - resolveEnvironment: () => Effect.succeed(persistedEnv), - }; -} - function makeDocumentRepository( rows: ReadonlyArray = [], ): SmeKnowledgeDocumentRepositoryShape { @@ -163,10 +95,83 @@ function makeMessageRepository() { return { repository, rowsByConversation }; } -function makeProviderService(): ProviderServiceShape { +function makeProviderHealth( + statuses: Array<{ + readonly provider: "codex" | "claudeAgent" | "openclaw"; + readonly status: "ready" | "warning" | "error"; + readonly available: boolean; + readonly authStatus: "authenticated" | "unauthenticated" | "unknown"; + readonly checkedAt: string; + readonly message?: string; + }>, +): ProviderHealthShape { return { - startSession: () => Effect.die("unexpected provider startSession"), - sendTurn: () => Effect.die("unexpected provider sendTurn"), + getStatuses: Effect.succeed(statuses), + }; +} + +function makeProviderService() { + const runtimeEvents = Effect.runSync(Queue.unbounded()); + const startedSessions: Array = []; + const sentTurns: Array = []; + + const service: ProviderServiceShape = { + startSession: (threadId, input) => + Effect.sync(() => { + startedSessions.push({ threadId, input }); + return { + provider: input.provider ?? "claudeAgent", + status: "ready", + runtimeMode: input.runtimeMode, + threadId, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } as never; + }), + sendTurn: (input) => + Effect.gen(function* () { + sentTurns.push(input); + const turnId = "turn-1" as never; + yield* Queue.offer(runtimeEvents, { + eventId: "evt-1" as never, + provider: "claudeAgent", + threadId: input.threadId, + turnId, + createdAt: "2026-01-01T00:00:00.000Z", + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: "Hello", + }, + } as never); + yield* Queue.offer(runtimeEvents, { + eventId: "evt-2" as never, + provider: "claudeAgent", + threadId: input.threadId, + turnId, + createdAt: "2026-01-01T00:00:00.000Z", + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: " world", + }, + } as never); + yield* Queue.offer(runtimeEvents, { + eventId: "evt-3" as never, + provider: "claudeAgent", + threadId: input.threadId, + turnId, + createdAt: "2026-01-01T00:00:00.000Z", + type: "turn.completed", + payload: { + state: "completed", + }, + } as never); + return { + threadId: input.threadId, + turnId, + } as never; + }), interruptTurn: () => Effect.void, respondToRequest: () => Effect.void, respondToUserInput: () => Effect.void, @@ -174,8 +179,10 @@ function makeProviderService(): ProviderServiceShape { listSessions: () => Effect.succeed([]), getCapabilities: () => Effect.die("unexpected provider getCapabilities"), rollbackConversation: () => Effect.void, - streamEvents: Stream.empty, + streamEvents: Stream.fromQueue(runtimeEvents), }; + + return { service, startedSessions, sentTurns }; } function makeOpenclawGatewayConfig() { @@ -212,20 +219,8 @@ function makeOpenclawGatewayConfig() { }; } -function makeProviderHealth(): ProviderHealthShape { - return { - getStatuses: Effect.succeed([]), - }; -} - describe("SmeChatServiceLive", () => { - it("uses persisted Anthropic credentials for a successful send and stores the final reply", async () => { - setAnthropicEnv({ - apiKey: "process-key-that-should-not-win", - authToken: "process-token-that-should-not-win", - baseURL: "https://process-base.example", - }); - + it("routes Claude conversations through the provider runtime and stores the reply", async () => { const projectId = ProjectId.makeUnsafe("project-1"); const conversationId = SmeConversationId.makeUnsafe("conversation-1"); const conversationRow: SmeConversationRow = { @@ -233,42 +228,30 @@ describe("SmeChatServiceLive", () => { projectId, title: "Architecture Q&A", provider: "claudeAgent", - authMethod: "apiKey", + authMethod: "auto", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; - const persistedEnv = { - ANTHROPIC_API_KEY: "project-api-key", - ANTHROPIC_BASE_URL: "https://project-base.example", - }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const capturedClientOptions: Array = []; - const capturedRequests: Array = []; - - const createClient = vi.fn((options: unknown) => { - capturedClientOptions.push(options); - return { - messages: { - stream: async function* (request: unknown) { - capturedRequests.push(request); - yield { - type: "content_block_delta", - delta: { type: "text_delta", text: "Hello" }, - }; - yield { - type: "content_block_delta", - delta: { type: "text_delta", text: " world" }, - }; - }, - }, - } as never; - }); + const providerService = makeProviderService(); - const layer = makeSmeChatServiceLive({ createClient }).pipe( + const layer = makeSmeChatServiceLive().pipe( Layer.provideMerge( - Layer.succeed(EnvironmentVariables, makeEnvironmentVariables(persistedEnv)), + Layer.succeed( + ProviderHealth, + makeProviderHealth([ + { + provider: "claudeAgent", + status: "ready", + available: true, + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + message: "Claude Code CLI is ready.", + }, + ]), + ), ), Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), Layer.provideMerge( @@ -276,8 +259,7 @@ describe("SmeChatServiceLive", () => { ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), - Layer.provideMerge(Layer.succeed(ProviderHealth, makeProviderHealth())), - Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), + Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); const events: Array = []; @@ -288,6 +270,13 @@ describe("SmeChatServiceLive", () => { { conversationId, text: "What changed in the latest design?", + providerOptions: { + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, + }, + }, }, (event) => { events.push(event); @@ -296,22 +285,21 @@ describe("SmeChatServiceLive", () => { }).pipe(Effect.provide(layer)), ); - expect(createClient).toHaveBeenCalledTimes(1); - expect(capturedClientOptions).toEqual([ - { - apiKey: "project-api-key", - authToken: null, - baseURL: "https://project-base.example", + expect(providerService.startedSessions).toHaveLength(1); + expect(providerService.sentTurns).toHaveLength(1); + expect((providerService.startedSessions[0] as any).input.providerOptions).toEqual({ + claudeAgent: { + binaryPath: "/usr/local/bin/claude", + permissionMode: "plan", + maxThinkingTokens: 12_000, }, - ]); - expect(capturedRequests).toEqual([ - { + }); + expect(providerService.sentTurns[0] as any).toEqual( + expect.objectContaining({ model: "claude-sonnet-4-6", - max_tokens: 8192, - system: expect.stringContaining("knowledgeable subject matter expert assistant"), - messages: [{ role: "user", content: "What changed in the latest design?" }], - }, - ]); + input: expect.stringContaining("knowledgeable subject matter expert assistant"), + }), + ); expect(events).toEqual([ { type: "sme.message.delta", @@ -347,9 +335,7 @@ describe("SmeChatServiceLive", () => { ]); }); - it("fails before persisting messages when no Anthropic credentials are available", async () => { - setAnthropicEnv({}); - + it("fails before sending when Claude Code CLI is unavailable", async () => { const projectId = ProjectId.makeUnsafe("project-2"); const conversationId = SmeConversationId.makeUnsafe("conversation-2"); const conversationRow: SmeConversationRow = { @@ -357,25 +343,38 @@ describe("SmeChatServiceLive", () => { projectId, title: "Docs sync", provider: "claudeAgent", - authMethod: "apiKey", + authMethod: "auto", model: "claude-sonnet-4-6", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }; const { repository: messageRepo, rowsByConversation } = makeMessageRepository(); - const createClient = vi.fn(); + const providerService = makeProviderService(); - const layer = makeSmeChatServiceLive({ createClient }).pipe( - Layer.provideMerge(Layer.succeed(EnvironmentVariables, makeEnvironmentVariables({}))), + const layer = makeSmeChatServiceLive().pipe( + Layer.provideMerge( + Layer.succeed( + ProviderHealth, + makeProviderHealth([ + { + provider: "claudeAgent", + status: "error", + available: false, + authStatus: "unknown", + checkedAt: "2026-01-01T00:00:00.000Z", + message: "Claude Code CLI (`claude`) is not installed or not on PATH.", + }, + ]), + ), + ), Layer.provideMerge(Layer.succeed(SmeKnowledgeDocumentRepository, makeDocumentRepository())), Layer.provideMerge( Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), - Layer.provideMerge(Layer.succeed(ProviderHealth, makeProviderHealth())), - Layer.provideMerge(Layer.succeed(ProviderService, makeProviderService())), + Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); await expect( @@ -388,9 +387,12 @@ describe("SmeChatServiceLive", () => { }); }).pipe(Effect.provide(layer)), ), - ).rejects.toThrow("SmeChatError in sendMessage:validate: Anthropic API key is missing."); + ).rejects.toThrow( + "SmeChatError in sendMessage:validate: Claude Code CLI (`claude`) is not installed or not on PATH.", + ); - expect(createClient).not.toHaveBeenCalled(); + expect(providerService.startedSessions).toHaveLength(0); + expect(providerService.sentTurns).toHaveLength(0); expect(rowsByConversation.get(conversationId)).toEqual([ expect.objectContaining({ role: "user", diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index 833d6a2f8..ac59229be 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -6,7 +6,6 @@ * * @module SmeChatServiceLive */ -import Anthropic from "@anthropic-ai/sdk"; import type { SmeAuthMethod, SmeConversation, @@ -21,38 +20,26 @@ import { import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; -import { EnvironmentVariables } from "../../persistence/Services/EnvironmentVariables.ts"; import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeConversationRepository } from "../../persistence/Services/SmeConversations.ts"; import { SmeKnowledgeDocumentRepository } from "../../persistence/Services/SmeKnowledgeDocuments.ts"; import { SmeMessageRepository } from "../../persistence/Services/SmeMessages.ts"; -import { ProviderHealth } from "../../provider/Services/ProviderHealth.ts"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import { ProviderHealth } from "../../provider/Services/ProviderHealth.ts"; import { isValidSmeAuthMethod, - validateAnthropicSetup, + validateClaudeSetup, validateCodexSetup, validateOpenClawSetup, } from "../authValidation.ts"; -import { sendSmeViaAnthropic, type ResolvedAnthropicClientOptions } from "../backends/anthropic.ts"; import { sendSmeViaProviderRuntime } from "../backends/providerRuntime.ts"; -import { - buildSmeAnthropicMessages, - buildSmeCompiledPrompt, - buildSmeSystemPrompt, -} from "../promptBuilder.ts"; +import { buildSmeCompiledPrompt } from "../promptBuilder.ts"; import { SmeChatError, SmeChatService, type SmeChatServiceShape, } from "../Services/SmeChatService.ts"; -type AnthropicMessagesClient = Pick; - -export interface SmeChatServiceLiveOptions { - readonly createClient?: (options: ResolvedAnthropicClientOptions) => AnthropicMessagesClient; -} - type ActiveRequest = { readonly interrupt: Effect.Effect; }; @@ -116,19 +103,14 @@ function toMessage(message: { }; } -const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => +const makeSmeChatService = () => Effect.gen(function* () { const documentRepo = yield* SmeKnowledgeDocumentRepository; const conversationRepo = yield* SmeConversationRepository; const messageRepo = yield* SmeMessageRepository; - const environmentVariables = yield* EnvironmentVariables; const openclawGatewayConfig = yield* OpenclawGatewayConfig; - const providerHealth = yield* ProviderHealth; const providerService = yield* ProviderService; - const createClient = - options.createClient ?? - ((clientOptions: ResolvedAnthropicClientOptions): AnthropicMessagesClient => - new Anthropic(clientOptions)); + const providerHealth = yield* ProviderHealth; const activeRequests = yield* Ref.make(new Map()); @@ -167,18 +149,15 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => switch (conversation.provider) { case "claudeAgent": { - const persistedEnv = yield* environmentVariables - .resolveEnvironment({ - projectId: conversation.projectId, - }) - .pipe(Effect.mapError((e) => new SmeChatError("validateSetup", e.message))); - return validateAnthropicSetup({ + const providerStatus = (yield* providerHealth.getStatuses).find( + (status) => status.provider === "claudeAgent", + ); + return validateClaudeSetup({ authMethod: conversation.authMethod as Extract< SmeAuthMethod, "auto" | "apiKey" | "authToken" >, - persistedEnv, - processEnv: process.env, + providerStatus, }); } @@ -457,73 +436,28 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => return yield* Effect.fail(new SmeChatError("sendMessage:validate", validation.message)); } - const systemPrompt = buildSmeSystemPrompt(docs); const promptHistory = existingMessages.map((message) => ({ role: message.role, text: message.text, })); - const anthropicMessages = buildSmeAnthropicMessages({ - history: promptHistory, - userText: input.text, - }); const compiledPrompt = buildSmeCompiledPrompt({ docs, history: promptHistory, userText: input.text, }); - const sendEffect = - conv.provider === "claudeAgent" - ? Effect.gen(function* () { - const persistedEnv = yield* environmentVariables - .resolveEnvironment({ - projectId: conv.projectId, - }) - .pipe(Effect.mapError((e) => new SmeChatError("sendMessage:env", e.message))); - const anthropicSetup = validateAnthropicSetup({ - authMethod: conv.authMethod as Extract< - SmeAuthMethod, - "auto" | "apiKey" | "authToken" - >, - persistedEnv, - processEnv: process.env, - }); - if (!anthropicSetup.ok || !anthropicSetup.clientOptions) { - return yield* Effect.fail( - new SmeChatError("sendMessage:validate", anthropicSetup.message), - ); - } - - const controller = new AbortController(); - yield* setInterrupt( - input.conversationId, - Effect.sync(() => { - controller.abort(); - }), - ); - return yield* sendSmeViaAnthropic({ - client: createClient(anthropicSetup.clientOptions), - conversationId: input.conversationId, - assistantMessageId, - model: conv.model, - systemPrompt, - messages: anthropicMessages, - ...(onEvent ? { onEvent } : {}), - abortSignal: controller.signal, - }).pipe(Effect.ensuring(clearInterrupt(input.conversationId))); - }) - : sendSmeViaProviderRuntime({ - providerService, - provider: conv.provider, - conversationId: input.conversationId, - assistantMessageId, - model: conv.model, - compiledPrompt, - ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), - ...(onEvent ? { onEvent } : {}), - setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), - clearInterruptEffect: clearInterrupt(input.conversationId), - }); + const sendEffect = sendSmeViaProviderRuntime({ + providerService, + provider: conv.provider, + conversationId: input.conversationId, + assistantMessageId, + model: conv.model, + compiledPrompt, + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), + ...(onEvent ? { onEvent } : {}), + setInterruptEffect: (interrupt) => setInterrupt(input.conversationId, interrupt), + clearInterruptEffect: clearInterrupt(input.conversationId), + }); const responseText = yield* sendEffect.pipe( Effect.mapError((cause) => @@ -590,7 +524,6 @@ const makeSmeChatService = (options: SmeChatServiceLiveOptions = {}) => } satisfies SmeChatServiceShape; }); -export const makeSmeChatServiceLive = (options: SmeChatServiceLiveOptions = {}) => - Layer.effect(SmeChatService, makeSmeChatService(options)); +export const makeSmeChatServiceLive = () => Layer.effect(SmeChatService, makeSmeChatService()); export const SmeChatServiceLive = makeSmeChatServiceLive(); diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index dc917e882..3dfc18f99 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -4,7 +4,6 @@ import { type ProviderKind, type ServerProviderStatus, } from "@okcode/contracts"; -import { compactNodeProcessEnv } from "@okcode/shared/environment"; import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; @@ -16,7 +15,6 @@ import { readCodexAccountSnapshot, type CodexAppServerStartSessionInput, } from "../codexAppServerManager.ts"; -import type { ResolvedAnthropicClientOptions } from "./backends/anthropic.ts"; const OPENAI_MODEL_PROVIDERS = new Set(["openai"]); @@ -25,41 +23,6 @@ function normalizeOptionalValue(value: string | undefined | null): string | null return trimmed && trimmed.length > 0 ? trimmed : null; } -function pickAnthropicCredential( - env: Record, - authMethod: Extract, -): { - apiKey: string | null; - authToken: string | null; - resolvedAuthMethod: "apiKey" | "authToken"; -} | null { - const apiKey = normalizeOptionalValue(env.ANTHROPIC_API_KEY); - const authToken = normalizeOptionalValue(env.ANTHROPIC_AUTH_TOKEN); - - if (authMethod === "apiKey") { - return apiKey ? { apiKey, authToken: null, resolvedAuthMethod: "apiKey" } : null; - } - if (authMethod === "authToken") { - return authToken ? { apiKey: null, authToken, resolvedAuthMethod: "authToken" } : null; - } - if (apiKey) { - return { apiKey, authToken: null, resolvedAuthMethod: "apiKey" }; - } - if (authToken) { - return { apiKey: null, authToken, resolvedAuthMethod: "authToken" }; - } - return null; -} - -function anthropicBaseUrl(persistedEnv: Record, processEnv?: NodeJS.ProcessEnv) { - const processEnvRecord = compactNodeProcessEnv(processEnv ?? process.env); - return ( - normalizeOptionalValue(persistedEnv.ANTHROPIC_BASE_URL) ?? - normalizeOptionalValue(processEnvRecord.ANTHROPIC_BASE_URL) ?? - undefined - ); -} - export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAuthMethod[] { switch (provider) { case "claudeAgent": @@ -74,7 +37,7 @@ export function getAllowedSmeAuthMethods(provider: ProviderKind): readonly SmeAu export function getDefaultSmeAuthMethod(provider: ProviderKind): SmeAuthMethod { switch (provider) { case "claudeAgent": - return "apiKey"; + return "auto"; case "codex": return "chatgpt"; case "openclaw": @@ -88,56 +51,58 @@ export function isValidSmeAuthMethod(provider: ProviderKind, authMethod: SmeAuth export function validateAnthropicSetup(input: { readonly authMethod: Extract; - readonly persistedEnv: Record; - readonly processEnv?: NodeJS.ProcessEnv; -}): SmeValidateSetupResult & { readonly clientOptions?: ResolvedAnthropicClientOptions } { - const processEnvRecord = compactNodeProcessEnv(input.processEnv ?? process.env); - const merged = { ...processEnvRecord, ...input.persistedEnv }; - const credential = pickAnthropicCredential(merged, input.authMethod); - if (!credential) { - if (input.authMethod === "authToken") { - return { - ok: false, - severity: "error", - message: - "Anthropic auth token is missing. Set ANTHROPIC_AUTH_TOKEN in project or global environment variables.", - resolvedAuthMethod: "authToken", - }; - } - if (input.authMethod === "apiKey") { - return { - ok: false, - severity: "error", - message: - "Anthropic API key is missing. Set ANTHROPIC_API_KEY in project or global environment variables.", - resolvedAuthMethod: "apiKey", - }; - } + readonly providerStatus?: ServerProviderStatus | null | undefined; +}): SmeValidateSetupResult { + const providerStatus = input.providerStatus; + if (!providerStatus) { + return { + ok: false, + severity: "error", + message: "Claude Code CLI status is unavailable.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }; + } + + if (!providerStatus.available || providerStatus.status === "error") { return { ok: false, severity: "error", message: - "SME Chat requires ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN. Add one in Settings > Environment Variables.", - resolvedAuthMethod: "auto", + providerStatus.message ?? "Claude Code CLI is not installed or not available on PATH.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }; + } + + if (providerStatus.authStatus === "unauthenticated") { + return { + ok: false, + severity: "error", + message: + providerStatus.message ?? + "Claude Code CLI is not authenticated. Run `claude auth login` and try again.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", + }; + } + + if (providerStatus.status === "warning") { + return { + ok: true, + severity: "warning", + message: providerStatus.message ?? "Claude Code CLI is available but needs verification.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", }; } return { ok: true, severity: "ready", - message: - credential.resolvedAuthMethod === "apiKey" - ? "Anthropic API key is configured." - : "Anthropic auth token is configured.", - resolvedAuthMethod: credential.resolvedAuthMethod, - clientOptions: (() => { - const baseURL = anthropicBaseUrl(input.persistedEnv, input.processEnv); - return { - apiKey: credential.apiKey, - authToken: credential.authToken, - ...(baseURL ? { baseURL } : {}), - }; - })(), + message: providerStatus.message ?? "Claude Code CLI is ready.", + resolvedAuthMethod: input.authMethod, + resolvedAccountType: "unknown", }; } diff --git a/apps/server/src/sme/backends/anthropic.ts b/apps/server/src/sme/backends/anthropic.ts new file mode 100644 index 000000000..419e851f4 --- /dev/null +++ b/apps/server/src/sme/backends/anthropic.ts @@ -0,0 +1,56 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { SmeMessageEvent } from "@okcode/contracts"; +import { Effect } from "effect"; + +import { SmeChatError } from "../Services/SmeChatService.ts"; + +type AnthropicMessagesClient = Pick; + +export interface ResolvedAnthropicClientOptions { + readonly apiKey: string | null; + readonly authToken: string | null; + readonly baseURL?: string; +} + +export interface SendSmeViaAnthropicInput { + readonly client: AnthropicMessagesClient; + readonly conversationId: string; + readonly assistantMessageId: string; + readonly model: string; + readonly systemPrompt: string; + readonly messages: Array<{ role: "user" | "assistant"; content: string }>; + readonly onEvent?: ((event: SmeMessageEvent) => void) | undefined; + readonly abortSignal?: AbortSignal | undefined; +} + +export function sendSmeViaAnthropic(input: SendSmeViaAnthropicInput) { + return Effect.tryPromise({ + try: async () => { + let result = ""; + const stream = input.client.messages.stream( + { + model: input.model, + max_tokens: 8192, + system: input.systemPrompt, + messages: input.messages, + }, + input.abortSignal ? { signal: input.abortSignal } : undefined, + ); + + for await (const event of stream) { + if (event.type === "content_block_delta" && event.delta.type === "text_delta") { + result += event.delta.text; + input.onEvent?.({ + type: "sme.message.delta", + conversationId: input.conversationId as never, + messageId: input.assistantMessageId as never, + text: event.delta.text, + }); + } + } + + return result; + }, + catch: (cause) => new SmeChatError("sendMessage:anthropic", String(cause), cause), + }); +} diff --git a/apps/server/src/sme/promptBuilder.ts b/apps/server/src/sme/promptBuilder.ts index 320e942cd..531f8dc0c 100644 --- a/apps/server/src/sme/promptBuilder.ts +++ b/apps/server/src/sme/promptBuilder.ts @@ -65,17 +65,3 @@ export function buildSmeCompiledPrompt(input: { .filter((section) => section.length > 0) .join("\n\n"); } - -export function buildSmeAnthropicMessages(input: { - readonly history: ReadonlyArray<{ readonly role: string; readonly text: string }>; - readonly userText: string; -}): Array<{ role: "user" | "assistant"; content: string }> { - const apiMessages: Array<{ role: "user" | "assistant"; content: string }> = []; - for (const message of input.history) { - if (message.role === "user" || message.role === "assistant") { - apiMessages.push({ role: message.role, content: message.text }); - } - } - apiMessages.push({ role: "user", content: input.userText }); - return apiMessages; -} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 57243202f..14cb7eb24 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1702,7 +1702,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const tokens = tokenManager.list(); return { tokens }; } - case WS_METHODS.serverGetOpenclawGatewayConfig: return yield* openclawGatewayConfig.getSummary(); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 15557ad65..b1f821ac2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -4,12 +4,16 @@ import { CheckCircle2Icon, ChevronDownIcon, CpuIcon, + GlobeIcon, GitBranchIcon, ImportIcon, + KeyboardIcon, Loader2Icon, PaletteIcon, PlusIcon, + RefreshCwIcon, RotateCcwIcon, + ShieldCheckIcon, SkipForwardIcon, SmartphoneIcon, Undo2Icon, @@ -22,8 +26,11 @@ import { type ReactNode, useCallback, useEffect, useState } from "react"; import type { TestOpenclawGatewayHostKind, TestOpenclawGatewayResult } from "@okcode/contracts"; import { type BuildMetadata, + type KeybindingCommand, + type KeybindingRule, type ProjectId, type ProviderKind, + type ServerProviderStatus, DEFAULT_GIT_TEXT_GENERATION_MODEL, } from "@okcode/contracts"; import { getModelOptions, normalizeModelSlug } from "@okcode/shared/model"; @@ -56,6 +63,7 @@ import { APP_BUILD_INFO } from "../branding"; import { Button } from "../components/ui/button"; import { Collapsible, CollapsibleContent } from "../components/ui/collapsible"; import { EnvironmentVariablesEditor } from "../components/EnvironmentVariablesEditor"; +import { HotkeysSettingsSection } from "../components/settings/HotkeysSettingsSection"; import { Input } from "../components/ui/input"; import { Select, @@ -94,20 +102,34 @@ import { setStoredRadiusOverride, type CustomThemeData, } from "../lib/customTheme"; +import { openUrlInAppBrowser } from "../lib/openUrlInAppBrowser"; import { - openclawGatewayConfigQueryOptions, - serverConfigQueryOptions, - serverQueryKeys, -} from "../lib/serverReactQuery"; + getSelectableThreadProviders, + isProviderReadyForThreadSelection, +} from "../lib/providerAvailability"; +import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; import { PairingLink } from "../components/mobile/PairingLink"; +import { + getProviderLabel as getProviderStatusLabelName, + getProviderStatusDescription, + getProviderStatusHeading, +} from "../components/chat/providerStatusPresentation"; // --------------------------------------------------------------------------- // Settings navigation sections // --------------------------------------------------------------------------- -type SettingsSectionId = "general" | "environment" | "git" | "models" | "mobile" | "advanced"; +type SettingsSectionId = + | "general" + | "authentication" + | "hotkeys" + | "environment" + | "git" + | "models" + | "mobile" + | "advanced"; interface SettingsNavItem { id: SettingsSectionId; @@ -119,6 +141,12 @@ interface SettingsNavItem { function useSettingsNavItems(): SettingsNavItem[] { return [ { id: "general", label: "General", icon: }, + { + id: "authentication", + label: "Authentication", + icon: , + }, + { id: "hotkeys", label: "Hotkeys", icon: }, { id: "environment", label: "Environment", icon: }, { id: "git", label: "Git", icon: }, { id: "models", label: "Models", icon: }, @@ -317,9 +345,9 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ }, { provider: "claudeAgent", - title: "Anthropic", + title: "Claude Code", binaryPathKey: "claudeBinaryPath", - binaryPlaceholder: "Claude binary path", + binaryPlaceholder: "Claude Code binary path", binaryDescription: ( <> Leave blank to use claude from your PATH. Authentication uses{" "} @@ -329,6 +357,147 @@ const INSTALL_PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ }, ]; +const PROVIDER_AUTH_GUIDES: Record< + ProviderKind, + { + installCmd?: string; + authCmd?: string; + verifyCmd?: string; + note: string; + } +> = { + codex: { + installCmd: "npm install -g @openai/codex", + authCmd: "codex login", + verifyCmd: "codex login status", + note: "Codex stays available in thread creation when the CLI is ready and its auth is either confirmed or delegated to a custom model provider.", + }, + claudeAgent: { + installCmd: "npm install -g @anthropic-ai/claude-code", + authCmd: "claude auth login", + verifyCmd: "claude auth status", + note: "Claude Code must be installed and signed in before it appears in the thread picker.", + }, + openclaw: { + verifyCmd: "Test Connection", + note: "OpenClaw uses the gateway URL and password below rather than a local CLI login. A configured gateway unlocks it for new-thread selection.", + }, +}; + +function getAuthenticationBadgeCopy(input: { + status: ServerProviderStatus | null; + provider: ProviderKind; + openclawGatewayUrl: string; +}): { + tone: "success" | "warning" | "error"; + label: string; +} { + if ( + isProviderReadyForThreadSelection({ + provider: input.provider, + statuses: input.status ? [input.status] : [], + openclawGatewayUrl: input.openclawGatewayUrl, + }) + ) { + return { tone: "success", label: "Available in thread picker" }; + } + + if (input.status?.authStatus === "unauthenticated") { + return { tone: "error", label: "Sign-in required" }; + } + + if (input.provider === "openclaw" && input.openclawGatewayUrl.trim().length === 0) { + return { tone: "warning", label: "Gateway not configured" }; + } + + if (input.status?.available === false || input.status?.status === "error") { + return { tone: "error", label: "Unavailable" }; + } + + return { tone: "warning", label: "Needs verification" }; +} + +function AuthenticationStatusCard({ + provider, + status, + openclawGatewayUrl, +}: { + provider: ProviderKind; + status: ServerProviderStatus | null; + openclawGatewayUrl: string; +}) { + const guide = PROVIDER_AUTH_GUIDES[provider]; + const badge = getAuthenticationBadgeCopy({ status, provider, openclawGatewayUrl }); + const badgeClassName = + badge.tone === "success" + ? "border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" + : badge.tone === "error" + ? "border-red-500/25 bg-red-500/10 text-red-700 dark:text-red-300" + : "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + const heading = + status !== null + ? getProviderStatusHeading(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw gateway is configured locally" + : `${getProviderStatusLabelName(provider)} needs configuration`; + const description = + status !== null + ? getProviderStatusDescription(status) + : provider === "openclaw" && openclawGatewayUrl.trim().length > 0 + ? "OpenClaw is configured in local settings. Use Test Connection below to verify the gateway before starting a thread." + : guide.note; + + return ( +
+
+
+
+

+ {getProviderStatusLabelName(provider)} +

+ + {badge.label} + +
+

{heading}

+

{description}

+
+ {status?.checkedAt ? ( + + Checked {new Date(status.checkedAt).toLocaleString()} + + ) : null} +
+ +
+
+
Install
+ + {guide.installCmd ?? "Configured in-app"} + +
+
+
Authenticate
+ + {guide.authCmd ?? "Use gateway password"} + +
+
+
Verify
+ {guide.verifyCmd ?? "N/A"} +
+
+ +

{guide.note}

+
+ ); +} + function SettingsSection({ title, description, @@ -591,7 +760,6 @@ function SettingsRouteView() { const { theme, setTheme, colorTheme, setColorTheme, fontFamily, setFontFamily } = useTheme(); const { settings, defaults, updateSettings, resetSettings } = useAppSettings(); const serverConfigQuery = useQuery(serverConfigQueryOptions()); - const openclawGatewayConfigQuery = useQuery(openclawGatewayConfigQueryOptions()); const queryClient = useQueryClient(); const trimmedBrowserPreviewStartPageUrl = settings.browserPreviewStartPageUrl.trim(); const browserPreviewStartPageValidation = @@ -602,6 +770,7 @@ function SettingsRouteView() { settings.browserPreviewStartPageUrl, ); const projects = useStore((state) => state.projects); + const threads = useStore((state) => state.threads); const [selectedProjectId, setSelectedProjectId] = useState( () => projects[0]?.id ?? null, ); @@ -639,12 +808,6 @@ function SettingsRouteView() { null, ); const [openclawTestLoading, setOpenclawTestLoading] = useState(false); - const [openclawGatewayDraft, setOpenclawGatewayDraft] = useState(null); - const [openclawSharedSecretDraft, setOpenclawSharedSecretDraft] = useState(""); - const [openclawSaveLoading, setOpenclawSaveLoading] = useState(false); - const [openclawResetLoading, setOpenclawResetLoading] = useState<"token" | "identity" | null>( - null, - ); const { copyToClipboard: copyOpenclawDebugReport, isCopied: openclawDebugReportCopied } = useCopyToClipboard(); @@ -654,6 +817,15 @@ function SettingsRouteView() { const selectedProjectEnvironmentVariablesQuery = useQuery( projectEnvironmentVariablesQueryOptions(activeProjectId), ); + const activeProjectPreviewThreadId = + activeProjectId === null + ? null + : (threads + .filter((thread) => thread.projectId === activeProjectId) + .toSorted((a, b) => + (b.updatedAt ?? b.createdAt).localeCompare(a.updatedAt ?? a.createdAt), + ) + .at(0)?.id ?? null); useEffect(() => { if (projects.length === 0) { @@ -673,6 +845,11 @@ function SettingsRouteView() { const claudeBinaryPath = settings.claudeBinaryPath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const providerStatuses = serverConfigQuery.data?.providers ?? []; + const selectableProviders = getSelectableThreadProviders({ + statuses: providerStatuses, + openclawGatewayUrl: settings.openclawGatewayUrl, + }); const gitTextGenerationModelOptions = getAppModelOptions( "codex", @@ -710,16 +887,9 @@ function SettingsRouteView() { settings.claudeBinaryPath !== defaults.claudeBinaryPath || settings.codexBinaryPath !== defaults.codexBinaryPath || settings.codexHomePath !== defaults.codexHomePath; - const savedOpenclawGatewayUrl = openclawGatewayConfigQuery.data?.gatewayUrl ?? ""; - const savedOpenclawHasSharedSecret = openclawGatewayConfigQuery.data?.hasSharedSecret ?? false; - const effectiveOpenclawGatewayUrl = openclawGatewayDraft ?? savedOpenclawGatewayUrl; const isOpenClawSettingsDirty = - (openclawGatewayDraft !== null && openclawGatewayDraft !== savedOpenclawGatewayUrl) || - openclawSharedSecretDraft.length > 0; - const canImportLegacyOpenclawSettings = - openclawGatewayConfigQuery.isSuccess && - !savedOpenclawGatewayUrl && - Boolean(settings.openclawGatewayUrl || settings.openclawPassword); + settings.openclawGatewayUrl !== defaults.openclawGatewayUrl || + settings.openclawPassword !== defaults.openclawPassword; const changedSettingLabels = [ ...(theme !== "system" ? ["Theme"] : []), ...(colorTheme !== DEFAULT_COLOR_THEME ? ["Color theme"] : []), @@ -738,6 +908,12 @@ function SettingsRouteView() { ...(settings.showAuthFailuresAsErrors !== defaults.showAuthFailuresAsErrors ? ["Auth failure errors"] : []), + ...(settings.showNotificationDetails !== defaults.showNotificationDetails + ? ["Notification details"] + : []), + ...(settings.includeDiagnosticsTipsInCopy !== defaults.includeDiagnosticsTipsInCopy + ? ["Diagnostics copy tips"] + : []), ...(settings.openLinksExternally !== defaults.openLinksExternally ? ["Open links externally"] : []), @@ -773,11 +949,33 @@ function SettingsRouteView() { ...(settings.backgroundImageOpacity !== defaults.backgroundImageOpacity ? ["Background opacity"] : []), + ...(settings.sidebarOpacity !== defaults.sidebarOpacity ? ["Sidebar opacity"] : []), + ...(settings.sidebarProjectRowHeight !== defaults.sidebarProjectRowHeight + ? ["Project height"] + : []), + ...(settings.sidebarThreadRowHeight !== defaults.sidebarThreadRowHeight + ? ["Thread height"] + : []), + ...(settings.sidebarFontSize !== defaults.sidebarFontSize ? ["Sidebar font size"] : []), + ...(settings.sidebarSpacing !== defaults.sidebarSpacing ? ["Sidebar spacing"] : []), ...(radiusOverride !== null ? ["Border radius"] : []), ...(fontOverride ? ["Font family"] : []), - ...(fontSizeOverride !== null ? ["Font size"] : []), + ...(fontSizeOverride !== null ? ["Code font size"] : []), ]; + const openTweakcn = useCallback(() => { + void openUrlInAppBrowser({ + url: "https://tweakcn.com", + projectId: activeProjectId, + threadId: activeProjectPreviewThreadId, + popOut: true, + nativeApi: readNativeApi(), + }).catch(() => { + const nativeApi = ensureNativeApi(); + return nativeApi.shell.openExternal("https://tweakcn.com"); + }); + }, [activeProjectId, activeProjectPreviewThreadId]); + const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -801,6 +999,19 @@ function SettingsRouteView() { }); }, [availableEditors, keybindingsConfigPath]); + const replaceKeybindingRules = useCallback( + async (command: KeybindingCommand, rules: readonly KeybindingRule[]) => { + const api = ensureNativeApi(); + await api.server.replaceKeybindingRules({ command, rules: [...rules] }); + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); + }, + [queryClient], + ); + + const refreshProviderStatuses = useCallback(async () => { + await queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() }); + }, [queryClient]); + const saveGlobalEnvironmentVariables = useCallback( async (entries: ReadonlyArray<{ key: string; value: string }>) => { const api = ensureNativeApi(); @@ -833,11 +1044,9 @@ function SettingsRouteView() { setOpenclawTestResult(null); try { const api = ensureNativeApi(); - const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); - const sharedSecret = openclawSharedSecretDraft.trim(); const result = await api.server.testOpenclawGateway({ - ...(gatewayUrl ? { gatewayUrl } : {}), - ...(sharedSecret ? { password: sharedSecret } : {}), + gatewayUrl: settings.openclawGatewayUrl, + password: settings.openclawPassword || undefined, }); setOpenclawTestResult(result); } catch (err) { @@ -850,111 +1059,13 @@ function SettingsRouteView() { } finally { setOpenclawTestLoading(false); } - }, [effectiveOpenclawGatewayUrl, openclawSharedSecretDraft, openclawTestLoading]); + }, [openclawTestLoading, settings.openclawGatewayUrl, settings.openclawPassword]); const handleCopyOpenclawDebugReport = useCallback(() => { if (!openclawTestResult) return; copyOpenclawDebugReport(formatOpenclawGatewayDebugReport(openclawTestResult), undefined); }, [copyOpenclawDebugReport, openclawTestResult]); - const saveOpenclawGatewayConfig = useCallback(async () => { - if (openclawSaveLoading) return; - const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); - if (!gatewayUrl) { - throw new Error("Gateway URL is required."); - } - setOpenclawSaveLoading(true); - try { - const api = ensureNativeApi(); - const sharedSecret = openclawSharedSecretDraft.trim(); - const summary = await api.server.saveOpenclawGatewayConfig({ - gatewayUrl, - ...(sharedSecret ? { sharedSecret } : {}), - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - setOpenclawGatewayDraft(null); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - } finally { - setOpenclawSaveLoading(false); - } - }, [effectiveOpenclawGatewayUrl, openclawSaveLoading, openclawSharedSecretDraft, queryClient]); - - const clearSavedOpenclawSharedSecret = useCallback(async () => { - const gatewayUrl = effectiveOpenclawGatewayUrl.trim(); - if (!gatewayUrl) { - throw new Error("Gateway URL is required."); - } - setOpenclawSaveLoading(true); - try { - const api = ensureNativeApi(); - const summary = await api.server.saveOpenclawGatewayConfig({ - gatewayUrl, - clearSharedSecret: true, - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - } finally { - setOpenclawSaveLoading(false); - } - }, [effectiveOpenclawGatewayUrl, queryClient]); - - const resetOpenclawDeviceState = useCallback( - async (regenerateIdentity: boolean) => { - if (openclawResetLoading) return; - setOpenclawResetLoading(regenerateIdentity ? "identity" : "token"); - try { - const api = ensureNativeApi(); - const summary = await api.server.resetOpenclawGatewayDeviceState({ - regenerateIdentity, - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - setOpenclawTestResult(null); - } finally { - setOpenclawResetLoading(null); - } - }, - [openclawResetLoading, queryClient], - ); - - const importLegacyOpenclawSettings = useCallback(async () => { - const gatewayUrl = settings.openclawGatewayUrl.trim(); - if (!gatewayUrl) { - throw new Error("Legacy OpenClaw settings do not contain a gateway URL."); - } - setOpenclawSaveLoading(true); - try { - const api = ensureNativeApi(); - const sharedSecret = settings.openclawPassword.trim(); - const summary = await api.server.saveOpenclawGatewayConfig({ - gatewayUrl, - ...(sharedSecret ? { sharedSecret } : {}), - }); - queryClient.setQueryData(serverQueryKeys.openclawGatewayConfig(), summary); - void queryClient.invalidateQueries({ queryKey: serverQueryKeys.all }); - updateSettings({ - openclawGatewayUrl: defaults.openclawGatewayUrl, - openclawPassword: defaults.openclawPassword, - }); - setOpenclawGatewayDraft(null); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - } finally { - setOpenclawSaveLoading(false); - } - }, [ - defaults.openclawGatewayUrl, - defaults.openclawPassword, - queryClient, - settings.openclawGatewayUrl, - settings.openclawPassword, - updateSettings, - ]); - const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -1233,6 +1344,23 @@ function SettingsRouteView() { ))} + + + + + } + /> + + Open tweakcn in the in-app browser + + { clearFontSizeOverride(); setFontSizeOverrideState(null); @@ -1317,7 +1445,7 @@ function SettingsRouteView() { setStoredFontSizeOverride(value); }} className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" - aria-label="Font size" + aria-label="Code font size" /> {fontSizeOverride ?? 12}px @@ -1436,6 +1564,150 @@ function SettingsRouteView() { } /> + + updateSettings({ + sidebarProjectRowHeight: DEFAULT_SIDEBAR_PROJECT_ROW_HEIGHT, + }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ + sidebarProjectRowHeight: Number(e.target.value), + }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Project height" + /> + + {settings.sidebarProjectRowHeight}px + +
+ } + /> + + + updateSettings({ + sidebarThreadRowHeight: DEFAULT_SIDEBAR_THREAD_ROW_HEIGHT, + }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ + sidebarThreadRowHeight: Number(e.target.value), + }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Thread height" + /> + + {settings.sidebarThreadRowHeight}px + +
+ } + /> + + + updateSettings({ sidebarFontSize: DEFAULT_SIDEBAR_FONT_SIZE }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ sidebarFontSize: Number(e.target.value) }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Sidebar font size" + /> + + {settings.sidebarFontSize}px + +
+ } + /> + + + updateSettings({ sidebarSpacing: DEFAULT_SIDEBAR_SPACING }) + } + /> + ) : null + } + control={ +
+ { + updateSettings({ sidebarSpacing: Number(e.target.value) }); + }} + className="h-1.5 w-24 cursor-pointer appearance-none rounded-full bg-muted accent-foreground sm:w-28" + aria-label="Sidebar spacing" + /> + + {settings.sidebarSpacing}px + +
+ } + /> + + + updateSettings({ + showNotificationDetails: defaults.showNotificationDetails, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + showNotificationDetails: Boolean(checked), + }) + } + aria-label="Show notification details by default" + /> + } + /> + + + updateSettings({ + includeDiagnosticsTipsInCopy: defaults.includeDiagnosticsTipsInCopy, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + includeDiagnosticsTipsInCopy: Boolean(checked), + }) + } + aria-label="Include diagnostics tips in copied text" + /> + } + /> + )} - {activeSection === "environment" && ( + {activeSection === "authentication" && ( void refreshProviderStatuses()} + > + + Refresh status + + } > - Failed to load saved variables:{" "} - {getErrorMessage(globalEnvironmentVariablesQuery.error)} -
- ) : globalEnvironmentVariablesQuery.isFetching ? ( - Loading saved variables... - ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( - - {globalEnvironmentVariablesQuery.data.entries.length} saved variables - - ) : ( - No global variables saved yet. - ) - } + title="Thread picker availability" + description="These checks decide which providers show up in the thread composer before a provider is locked in." + status={`${selectableProviders.length} provider${selectableProviders.length === 1 ? "" : "s"} currently selectable`} > - +
+ {(["codex", "claudeAgent", "openclaw"] as const).map((provider) => ( + status.provider === provider) ?? + null + } + openclawGatewayUrl={settings.openclawGatewayUrl} + /> + ))} +
- {selectedProject.name} · {selectedProject.cwd} -
- ) : ( - Open a project to edit project variables. - ) - } - control={ - projects.length > 0 ? ( - - ) : ( - - No projects available. - - ) + /> + ) : null } > - +
+
+ {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { + const isOpen = openInstallProviders[providerSettings.provider]; + const isDirty = + providerSettings.provider === "codex" + ? settings.codexBinaryPath !== defaults.codexBinaryPath || + settings.codexHomePath !== defaults.codexHomePath + : settings.claudeBinaryPath !== defaults.claudeBinaryPath; + const binaryPathValue = + providerSettings.binaryPathKey === "claudeBinaryPath" + ? claudeBinaryPath + : codexBinaryPath; + + return ( + + setOpenInstallProviders((existing) => ({ + ...existing, + [providerSettings.provider]: open, + })) + } + > +
+ + + +
+
+ + + {providerSettings.homePathKey ? ( + + ) : null} +
+
+
+
+
+ ); + })} +
+
- - )} - {activeSection === "git" && ( - 0 + ? `Configured for ${settings.openclawGatewayUrl}` + : "Not configured" + } resetAction={ - settings.rebaseBeforeCommit !== defaults.rebaseBeforeCommit ? ( + isOpenClawSettingsDirty ? ( updateSettings({ - rebaseBeforeCommit: defaults.rebaseBeforeCommit, + openclawGatewayUrl: defaults.openclawGatewayUrl, + openclawPassword: defaults.openclawPassword, }) } /> ) : null } - control={ - - updateSettings({ - rebaseBeforeCommit: Boolean(checked), - }) - } - aria-label="Rebase onto the default branch before committing" - /> - } - /> - - )} - - {activeSection === "models" && ( - - - updateSettings({ - textGenerationModel: defaults.textGenerationModel, - }) - } - /> - ) : null - } - control={ - - } - /> - - 0 ? ( - { - updateSettings({ - customCodexModels: defaults.customCodexModels, - customClaudeModels: defaults.customClaudeModels, - }); - setCustomModelErrorByProvider({}); - setShowAllCustomModels(false); - }} - /> - ) : null - } > -
-
- +
+ + + +
- {selectedCustomModelError ? ( -

- {selectedCustomModelError} -

- ) : null} + {openclawTestResult ? ( +
+
+ {openclawTestResult.success ? ( + + ) : ( + + )} + + {openclawTestResult.success + ? "Connection successful" + : "Connection failed"} + + + {openclawTestResult.totalDurationMs}ms total + + +
- {totalCustomModels > 0 ? ( -
-
- {visibleCustomModelRows.map((row) => ( -
- - {row.providerTitle} - - - {row.slug} - - -
- ))} -
+ {step.status === "pass" ? ( + + ) : null} + {step.status === "fail" ? ( + + ) : null} + {step.status === "skip" ? ( + + ) : null} +
+
+ + {step.name} + + + {step.durationMs}ms + +
+ {step.detail ? ( + + {step.detail} + + ) : null} +
+
+ ))} +
+ ) : null} - {savedCustomModelRows.length > 5 ? ( - + {openclawTestResult.error && + !openclawTestResult.steps.some((step) => step.status === "fail") ? ( +
+ {openclawTestResult.error} +
) : null}
) : null} @@ -2425,650 +2765,371 @@ function SettingsRouteView() { )} - {activeSection === "mobile" && !isMobileShell && ( - + )} + + {activeSection === "environment" && ( + + Failed to load saved variables:{" "} + {getErrorMessage(globalEnvironmentVariablesQuery.error)} + + ) : globalEnvironmentVariablesQuery.isFetching ? ( + Loading saved variables... + ) : globalEnvironmentVariablesQuery.data?.entries.length ? ( + + {globalEnvironmentVariablesQuery.data.entries.length} saved variables + + ) : ( + No global variables saved yet. + ) + } > -
- -
+ +
+ + + {selectedProject.name} · {selectedProject.cwd} + + ) : ( + Open a project to edit project variables. + ) + } + control={ + projects.length > 0 ? ( + + ) : ( + + No projects available. + + ) + } + > +
)} - {activeSection === "advanced" && ( + {activeSection === "git" && ( { + label="rebase before commit" + onClick={() => updateSettings({ - claudeBinaryPath: defaults.claudeBinaryPath, - codexBinaryPath: defaults.codexBinaryPath, - codexHomePath: defaults.codexHomePath, - }); - setOpenInstallProviders({ - codex: false, - claudeAgent: false, - openclaw: false, - }); - }} + rebaseBeforeCommit: defaults.rebaseBeforeCommit, + }) + } /> ) : null } - > -
-
- {INSTALL_PROVIDER_SETTINGS.map((providerSettings) => { - const isOpen = openInstallProviders[providerSettings.provider]; - const isDirty = - providerSettings.provider === "codex" - ? settings.codexBinaryPath !== defaults.codexBinaryPath || - settings.codexHomePath !== defaults.codexHomePath - : settings.claudeBinaryPath !== defaults.claudeBinaryPath; - const binaryPathValue = - providerSettings.binaryPathKey === "claudeBinaryPath" - ? claudeBinaryPath - : codexBinaryPath; - - return ( - - setOpenInstallProviders((existing) => ({ - ...existing, - [providerSettings.provider]: open, - })) - } - > -
- - - -
-
- - - {providerSettings.homePathKey ? ( - - ) : null} -
-
-
-
-
- ); - })} -
-
-
+ control={ + + updateSettings({ + rebaseBeforeCommit: Boolean(checked), + }) + } + aria-label="Rebase onto the default branch before committing" + /> + } + /> +
+ )} + {activeSection === "models" && ( + { - setOpenclawGatewayDraft(null); - setOpenclawSharedSecretDraft(""); - setOpenclawTestResult(null); - }} + label="git writing model" + onClick={() => + updateSettings({ + textGenerationModel: defaults.textGenerationModel, + }) + } /> ) : null } - > -
- {canImportLegacyOpenclawSettings ? ( -
-
- - Legacy browser-local OpenClaw settings were found. Import them to - the server to make them active. - - -
-
- ) : null} - - - -
-
- Saved URL:{" "} - - {openclawGatewayConfigQuery.data?.gatewayUrl ?? "Not saved"} - -
-
- Saved shared secret:{" "} - - {savedOpenclawHasSharedSecret ? "Configured" : "Not configured"} - -
-
- Device fingerprint:{" "} - - {openclawGatewayConfigQuery.data?.deviceFingerprint ?? "Not created"} - -
-
- Cached device token:{" "} - - {openclawGatewayConfigQuery.data?.hasDeviceToken - ? "Present" - : "Not cached"} - -
-
- -
- + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(selectedCustomModelProvider); + }} + placeholder={selectedCustomModelProviderSettings.example} + spellCheck={false} + />
- {/* Debug / Results Panel */} - {openclawTestResult && ( -
- {/* Overall status header */} -
- {openclawTestResult.success ? ( - - ) : ( - - )} - - {openclawTestResult.success - ? "Connection successful" - : "Connection failed"} - - - {openclawTestResult.totalDurationMs}ms total - - -
- - {/* Step-by-step results */} - {openclawTestResult.steps.length > 0 && ( -
- {openclawTestResult.steps.map((step) => ( -
- {step.status === "pass" && ( - - )} - {step.status === "fail" && ( - - )} - {step.status === "skip" && ( - - )} -
-
- - {step.name} - - - {step.durationMs}ms - -
- {step.detail && ( - - {step.detail} - - )} -
-
- ))} -
- )} - - {/* Server info */} - {openclawTestResult.serverInfo && ( -
- - Server Info - -
- {openclawTestResult.serverInfo.version && ( -
- Version:{" "} - - {openclawTestResult.serverInfo.version} - -
- )} - {openclawTestResult.serverInfo.sessionId && ( -
- Session:{" "} - - {openclawTestResult.serverInfo.sessionId} - -
- )} -
-
- )} - - {openclawTestResult.diagnostics && ( -
- - Debugging Context - -
- {openclawTestResult.diagnostics.normalizedUrl && ( -
- Endpoint:{" "} - - {openclawTestResult.diagnostics.normalizedUrl} - -
- )} - {openclawTestResult.diagnostics.hostKind && ( -
- Host type:{" "} - - {describeOpenclawGatewayHostKind( - openclawTestResult.diagnostics.hostKind, - )} - -
- )} - {openclawTestResult.diagnostics.resolvedAddresses.length > 0 && ( -
- Resolved:{" "} - - {openclawTestResult.diagnostics.resolvedAddresses.join( - ", ", - )} - -
- )} - {describeOpenclawGatewayHealthStatus(openclawTestResult) && ( -
- Health probe:{" "} - - {describeOpenclawGatewayHealthStatus(openclawTestResult)} - - {openclawTestResult.diagnostics.healthUrl && ( - <> - {" "} - at{" "} - - {openclawTestResult.diagnostics.healthUrl} - - - )} -
- )} - {openclawTestResult.diagnostics.socketCloseCode !== undefined && ( -
- Socket close:{" "} - - {openclawTestResult.diagnostics.socketCloseCode} - {openclawTestResult.diagnostics.socketCloseReason - ? ` (${openclawTestResult.diagnostics.socketCloseReason})` - : ""} - -
- )} - {openclawTestResult.diagnostics.socketError && ( -
- Socket error:{" "} - - {openclawTestResult.diagnostics.socketError} - -
- )} - {openclawTestResult.diagnostics.gatewayErrorCode && ( -
- Gateway error code:{" "} - - {openclawTestResult.diagnostics.gatewayErrorCode} - -
- )} - {openclawTestResult.diagnostics.gatewayErrorDetailCode && ( -
- Gateway detail code:{" "} - - {openclawTestResult.diagnostics.gatewayErrorDetailCode} - -
- )} - {openclawTestResult.diagnostics.gatewayErrorDetailReason && ( -
- Gateway detail reason:{" "} - - {openclawTestResult.diagnostics.gatewayErrorDetailReason} - -
- )} - {openclawTestResult.diagnostics.gatewayRecommendedNextStep && ( -
- Gateway next step:{" "} - - {openclawTestResult.diagnostics.gatewayRecommendedNextStep} - -
- )} - {openclawTestResult.diagnostics.gatewayCanRetryWithDeviceToken !== - undefined && ( -
- Device-token retry available:{" "} - - {openclawTestResult.diagnostics - .gatewayCanRetryWithDeviceToken - ? "Yes" - : "No"} - -
- )} - {openclawTestResult.diagnostics.observedNotifications.length > - 0 && ( -
- Gateway events:{" "} - - {openclawTestResult.diagnostics.observedNotifications.join( - ", ", - )} - -
- )} -
-
- )} + {selectedCustomModelError ? ( +

+ {selectedCustomModelError} +

+ ) : null} - {openclawTestResult.diagnostics && - openclawTestResult.diagnostics.hints.length > 0 && ( -
- - Troubleshooting + {totalCustomModels > 0 ? ( +
+
+ {visibleCustomModelRows.map((row) => ( +
+ + {row.providerTitle} -
    - {openclawTestResult.diagnostics.hints.map((hint) => ( -
  • - - {hint} -
  • - ))} -
+ + {row.slug} + +
- )} + ))} +
- {/* Error summary */} - {openclawTestResult.error && - !openclawTestResult.steps.some((s) => s.status === "fail") && ( -
- {openclawTestResult.error} -
- )} + {savedCustomModelRows.length > 5 ? ( + + ) : null}
- )} + ) : null}
+ + )} + {activeSection === "mobile" && !isMobileShell && ( + - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - - {openKeybindingsError} - - ) : ( - Opens in your preferred editor. - )} - - } - control={ - - } - /> + title="Pair mobile device" + description="Copy this pairing link and open it in the OK Code mobile app to pair your phone." + > +
+ +
+
+
+ )} + {activeSection === "advanced" && ( +