diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 4abc3246..43c4b80d 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 59c1af0e..79c3c4a3 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 ad7c34f8..2a9747dc 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 00000000..734bbee2 --- /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 00000000..0e814218 --- /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 00000000..76755fc5 --- /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 00000000..04af2f73 --- /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 a80c3059..f6b5d69d 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 00000000..26ec67d3 --- /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 96696db2..2407f083 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -35,6 +35,7 @@ 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 Migration0024 from "./Migrations/024_OpenclawGatewayConfig.ts"; import { Effect } from "effect"; /** @@ -71,6 +72,7 @@ const loader = Migrator.fromRecord({ "21_ProjectionPendingUserInputs": Migration0021, "22_DecisionWorkspace": Migration0022, "23_ProjectionPendingUserInputsBackfill": Migration0023, + "24_OpenclawGatewayConfig": Migration0024, }); /** diff --git a/apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts b/apps/server/src/persistence/Migrations/024_OpenclawGatewayConfig.ts new file mode 100644 index 00000000..a09c92b8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/024_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 2b26065c..678bb4ea 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 00000000..f28374cf --- /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 00000000..f2d21b1a --- /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 19f5dd84..06c56fa1 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -16,6 +16,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 +34,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 +607,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 13f9345d..1f6ec9a8 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 2892939c..0551d027 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.test.ts @@ -2,6 +2,7 @@ import { ProjectId, SmeConversationId } from "@okcode/contracts"; import { Effect, Layer, Option, Queue, Stream } from "effect"; import { describe, expect, it } from "vitest"; +import { OpenclawGatewayConfig } from "../../persistence/Services/OpenclawGatewayConfig.ts"; import { SmeKnowledgeDocumentRepository, type SmeKnowledgeDocumentRepositoryShape, @@ -184,6 +185,40 @@ function makeProviderService() { return { service, startedSessions, sentTurns }; } +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, + }), + }; +} + describe("SmeChatServiceLive", () => { it("routes Claude conversations through the provider runtime and stores the reply", async () => { const projectId = ProjectId.makeUnsafe("project-1"); @@ -223,6 +258,7 @@ describe("SmeChatServiceLive", () => { Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), + Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); @@ -337,6 +373,7 @@ describe("SmeChatServiceLive", () => { Layer.succeed(SmeConversationRepository, makeConversationRepository([conversationRow])), ), Layer.provideMerge(Layer.succeed(SmeMessageRepository, messageRepo)), + Layer.provideMerge(Layer.succeed(OpenclawGatewayConfig, makeOpenclawGatewayConfig())), Layer.provideMerge(Layer.succeed(ProviderService, providerService.service)), ); diff --git a/apps/server/src/sme/Layers/SmeChatServiceLive.ts b/apps/server/src/sme/Layers/SmeChatServiceLive.ts index c02f9c73..ac59229b 100644 --- a/apps/server/src/sme/Layers/SmeChatServiceLive.ts +++ b/apps/server/src/sme/Layers/SmeChatServiceLive.ts @@ -20,6 +20,7 @@ import { import { DateTime, Effect, Layer, Option, Random, Ref } from "effect"; import crypto from "node:crypto"; +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"; @@ -107,6 +108,7 @@ const makeSmeChatService = () => const documentRepo = yield* SmeKnowledgeDocumentRepository; const conversationRepo = yield* SmeConversationRepository; const messageRepo = yield* SmeMessageRepository; + const openclawGatewayConfig = yield* OpenclawGatewayConfig; const providerService = yield* ProviderService; const providerHealth = yield* ProviderHealth; @@ -174,12 +176,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 } : {}), }); } }); diff --git a/apps/server/src/sme/authValidation.ts b/apps/server/src/sme/authValidation.ts index a5efdf75..3dfc18f9 100644 --- a/apps/server/src/sme/authValidation.ts +++ b/apps/server/src/sme/authValidation.ts @@ -49,7 +49,7 @@ 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 { @@ -106,6 +106,8 @@ export function validateClaudeSetup(input: { }; } +export const validateClaudeSetup = validateAnthropicSetup; + async function readCodexConfigModelProvider( providerOptions?: CodexAppServerStartSessionInput["providerOptions"], ): Promise { @@ -334,12 +336,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 +351,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 +399,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/backends/anthropic.ts b/apps/server/src/sme/backends/anthropic.ts new file mode 100644 index 00000000..419e851f --- /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/wsServer.ts b/apps/server/src/wsServer.ts index b44b3ae5..14cb7eb2 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; @@ -1690,6 +1702,22 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const tokens = tokenManager.list(); 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 @@ -1720,7 +1748,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 37daea8a..74259a92 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 6d0be949..ec5b7d76 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 3519926a..cc3b1451 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/themes.css b/apps/web/src/themes.css index 16412bd1..4a548867 100644 --- a/apps/web/src/themes.css +++ b/apps/web/src/themes.css @@ -63,64 +63,123 @@ --warning-foreground: #f1a10d; } -/* ─── Solar Witch ─── magical, cozy, ritualistic ─── */ +/* ─── Solar Witch ─── mystical, radiant, high-contrast sunset magic ─── */ :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); + --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(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; + --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: #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; + --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; + --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) ─── */ diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 1087d8da..efa8351a 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -396,6 +396,11 @@ export function createWsNativeApi(): NativeApi { saveProjectEnvironmentVariables: (input) => 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 4cebaec8..bd399622 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 8e7efd10..64c5f68a 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 c433820a..1ebaa476 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 cd4b4a9d..fc5131e5 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(