feat: add openclaw pairing bridge

This commit is contained in:
Haitao Pan 2026-03-12 12:51:12 +08:00
parent 9a915ae080
commit 01181d4385
6 changed files with 505 additions and 47 deletions

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ public/_build/
public/dl-index/
.contentlayer/
.dev-logs/
.console-state/
# Contentlayer cache
ui/docs/.contentlayer/

View File

@ -36,30 +36,64 @@ yarn dev
cp .env.example .env
```
AI 助手与集成页使用以下环境变量做服务端预填,不在前端 UI 中硬编码:
如果你的工作区同时包含 `openclaw-deploy-example`,建议参考 `../openclaw-deploy-example/.env` 填写 AI 助手联调配置,并同时查看 `docs/getting-started/installation.md`
- `OPENCLAW_GATEWAY_REMOTE_URL`
- `OPENCLAW_GATEWAY_TOKEN`
- `VAULT_SERVER_URL`
- `VAULT_NAMESPACE`
- `VAULT_TOKEN`
- `APISIX_AI_GATEWAY_URL`
- `AI_GATEWAY_ACCESS_TOKEN`
## 主要入口 (Key Routes)
建议参考 `/Users/shenlan/workspaces/cloud-neutral-toolkit/openclaw-deploy-example/.env` 填写,并同时查看 `docs/getting-started/installation.md`
- `/services`:服务导航页,保留现有控制台布局。
- `/services/openclaw`:原生 Next.js 的 OpenClaw 助手工作区。
- `/panel/api`:融合设置与集成页,用于配置和探测 OpenClaw Gateway、Vault Server、APISIX AI Gateway。
## AI 助手与集成能力 (Assistant & Integrations)
当前主页 AI 辅助功能已经基于本仓库原生实现,核心行为如下:
- 侧栏助手模式保留现有交互方式,但底层改为对接 OpenClaw gateway。
- 最大化助手页面统一收敛到 `/services/openclaw`,不再继续使用旧的 control UI 套壳。
- 页面截图通过 assistant chat 附件模式发送,而不是单独的浏览器控制壳。
- `/panel/api` 提供 OpenClaw、Vault、APISIX 三类集成的默认值预填与连通性探测。
- 网关地址与令牌从服务端环境变量读取,前端组件不硬编码敏感配置。
## 环境变量 (Environment Variables)
以下变量用于主页 AI 助手和集成页的服务端默认值预填:
| 变量 | 用途 |
|---|---|
| `OPENCLAW_GATEWAY_REMOTE_URL` | OpenClaw gateway 远端 WebSocket 地址 |
| `OPENCLAW_GATEWAY_TOKEN` | OpenClaw gateway 访问令牌 |
| `VAULT_SERVER_URL` | Vault 服务地址 |
| `VAULT_NAMESPACE` | Vault namespace可选 |
| `VAULT_TOKEN` | Vault 探测令牌 |
| `APISIX_AI_GATEWAY_URL` | APISIX AI Gateway 地址 |
| `AI_GATEWAY_ACCESS_TOKEN` | APISIX AI Gateway 探测令牌 |
更多说明见 `docs/getting-started/installation.md``.env.example`
## 核心特性 & 技术栈 (Features & Tech Stack)
核心特性:
* 统一控制面:汇聚 Cloud Neutral Toolkit 各微服务入口
* 原生 AI 助手工作区OpenClaw gateway 驱动的聊天、截图附件与会话体验
* 融合集成设置:在 `/panel/api` 统一管理 OpenClaw、Vault、APISIX AI Gateway
* 文档与内容系统Contentlayer 驱动的 docs/content pipeline
* 可扩展集成OIDC、Cloudflare Web Analytics 等
技术栈:
* Next.js + TypeScript
* Tailwind CSS + Radix UI
* Zustand
* Contentlayer
## 开发命令 (Useful Commands)
```bash
yarn dev
yarn build
yarn typecheck
./node_modules/.bin/eslint . --no-eslintrc --config .eslintrc.json --resolve-plugins-relative-to .
```
## 说明文档 (Docs)
入口:
@ -70,6 +104,7 @@ AI 助手与集成页使用以下环境变量做服务端预填,不在前端 U
* OIDC: `docs/integrations/oidc-auth.md`
* Cloudflare Web Analytics: `docs/integrations/cloudflare-web-analytics.md`
* Assistant / Integrations env setup: `docs/getting-started/installation.md`
* Chinese installation guide: `docs/zh/getting-started/installation.md`
其他:
* Agent rules: `AGENTS.md`

View File

@ -24,6 +24,39 @@ function jsonError(message: string, status = 400): Response {
return Response.json({ error: message }, { status })
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string {
if (!error) {
return 'Failed to probe OpenClaw gateway.'
}
const details = asRecord(error.details)
const detailCode = stringValue(details.code)
if (detailCode === 'PAIRING_REQUIRED') {
const requestId = stringValue(details.requestId)
const reason = stringValue(details.reason)
return [
'需要先在 OpenClaw 网关审批该设备配对请求。',
requestId ? `requestId: ${requestId}` : '',
client.deviceId ? `deviceId: ${client.deviceId}` : '',
reason ? `reason: ${reason}` : '',
]
.filter(Boolean)
.join(' ')
}
return error.message
}
async function probeOpenClaw(body: ProbeBody): Promise<Response> {
const config = resolveOpenClawGatewayConfig({
gatewayUrl: body.gatewayUrl,
@ -40,6 +73,7 @@ async function probeOpenClaw(body: ProbeBody): Promise<Response> {
await client.connect({
gatewayUrl: config.gatewayUrl,
gatewayToken: config.gatewayToken,
clientLabel: 'console.svc.plus Probe',
})
const [statusPayload, healthPayload] = await Promise.all([client.status(), client.health()])
@ -60,8 +94,10 @@ async function probeOpenClaw(body: ProbeBody): Promise<Response> {
target: 'openclaw',
gatewayUrl: config.gatewayUrl,
tokenSource: config.tokenSource,
error: gatewayError?.message ?? 'Failed to probe OpenClaw gateway.',
error: formatGatewayError(gatewayError, client),
code: gatewayError?.code,
details: gatewayError?.details ?? null,
deviceId: client.deviceId || undefined,
},
{ status: 502 },
)

View File

@ -34,16 +34,57 @@ type SendBody = {
attachments?: GatewayChatAttachmentPayload[]
}
function jsonError(message: string, status = 400, code?: string): Response {
function jsonError(
message: string,
status = 400,
code?: string,
details?: Record<string, unknown> | null,
deviceId?: string,
): Response {
return Response.json(
{
error: message,
code,
details: details ?? null,
...(deviceId ? { deviceId } : {}),
},
{ status },
)
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function stringValue(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
function formatGatewayError(error: OpenClawGatewayError | null, client: OpenClawGatewayClient): string {
if (!error) {
return 'Failed to connect to OpenClaw gateway.'
}
const details = asRecord(error.details)
const detailCode = stringValue(details.code)
if (detailCode === 'PAIRING_REQUIRED') {
const requestId = stringValue(details.requestId)
const reason = stringValue(details.reason)
return [
'需要先在 OpenClaw 网关审批该设备配对请求。',
requestId ? `requestId: ${requestId}` : '',
client.deviceId ? `deviceId: ${client.deviceId}` : '',
reason ? `reason: ${reason}` : '',
]
.filter(Boolean)
.join(' ')
}
return error.message
}
function resolveSessionKey(params: {
sessionKey?: string
agentId?: string
@ -107,9 +148,11 @@ async function handleBootstrap(body: BootstrapBody): Promise<Response> {
} catch (error) {
const gatewayError = error instanceof OpenClawGatewayError ? error : null
return jsonError(
gatewayError?.message ?? 'Failed to connect to OpenClaw gateway.',
formatGatewayError(gatewayError, client),
gatewayError?.code === 'OFFLINE' ? 503 : 502,
gatewayError?.code,
gatewayError?.details ?? null,
client.deviceId || undefined,
)
} finally {
await client.close()

View File

@ -0,0 +1,184 @@
import 'server-only'
import { createHash, createPrivateKey, generateKeyPairSync, sign as signPayload } from 'node:crypto'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
type StoredDeviceIdentity = {
deviceId: string
publicKeyBase64Url: string
privateKeyBase64Url: string
createdAtMs: number
}
const OPENCLAW_STATE_DIR = path.join(process.cwd(), '.console-state', 'openclaw')
const DEVICE_IDENTITY_FILE = path.join(OPENCLAW_STATE_DIR, 'gateway-device-identity.json')
let deviceIdentityPromise: Promise<StoredDeviceIdentity> | null = null
function asStoredDeviceIdentity(value: unknown): StoredDeviceIdentity | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
const entry = value as Record<string, unknown>
const deviceId = typeof entry.deviceId === 'string' ? entry.deviceId.trim() : ''
const publicKeyBase64Url =
typeof entry.publicKeyBase64Url === 'string' ? entry.publicKeyBase64Url.trim() : ''
const privateKeyBase64Url =
typeof entry.privateKeyBase64Url === 'string' ? entry.privateKeyBase64Url.trim() : ''
const createdAtMs =
typeof entry.createdAtMs === 'number' && Number.isFinite(entry.createdAtMs)
? entry.createdAtMs
: Date.now()
if (!deviceId || !publicKeyBase64Url || !privateKeyBase64Url) {
return null
}
return {
deviceId,
publicKeyBase64Url,
privateKeyBase64Url,
createdAtMs,
}
}
function normalizeMetadataForAuth(value?: string): string {
return value?.trim().toLowerCase() ?? ''
}
function deriveDeviceId(publicKeyBase64Url: string): string {
const publicKeyBytes = Buffer.from(publicKeyBase64Url, 'base64url')
return createHash('sha256').update(publicKeyBytes).digest('hex')
}
function deviceTokenFile(deviceId: string, role = 'operator'): string {
const safeRole = role.trim() || 'operator'
return path.join(OPENCLAW_STATE_DIR, `gateway-device-token.${deviceId}.${safeRole}.txt`)
}
async function ensureStateDirectory(): Promise<void> {
await mkdir(OPENCLAW_STATE_DIR, { recursive: true })
}
async function writeIdentity(identity: StoredDeviceIdentity): Promise<void> {
await ensureStateDirectory()
await writeFile(DEVICE_IDENTITY_FILE, `${JSON.stringify(identity, null, 2)}\n`, 'utf8')
}
export async function loadOrCreateOpenClawDeviceIdentity(): Promise<StoredDeviceIdentity> {
if (!deviceIdentityPromise) {
deviceIdentityPromise = (async () => {
try {
const raw = await readFile(DEVICE_IDENTITY_FILE, 'utf8')
const parsed = asStoredDeviceIdentity(JSON.parse(raw))
if (parsed) {
return parsed
}
} catch {
// Fall through to generating a new identity.
}
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const publicJwk = publicKey.export({ format: 'jwk' }) as { x?: string }
const privateJwk = privateKey.export({ format: 'jwk' }) as { d?: string }
if (!publicJwk.x || !privateJwk.d) {
throw new Error('Failed to generate OpenClaw device identity.')
}
const identity: StoredDeviceIdentity = {
deviceId: deriveDeviceId(publicJwk.x),
publicKeyBase64Url: publicJwk.x,
privateKeyBase64Url: privateJwk.d,
createdAtMs: Date.now(),
}
await writeIdentity(identity)
return identity
})()
}
try {
return await deviceIdentityPromise
} catch (error) {
deviceIdentityPromise = null
throw error
}
}
export async function loadOpenClawDeviceToken(params: {
deviceId: string
role?: string
}): Promise<string> {
try {
const value = await readFile(deviceTokenFile(params.deviceId, params.role), 'utf8')
return value.trim()
} catch {
return ''
}
}
export async function saveOpenClawDeviceToken(params: {
deviceId: string
role?: string
token: string
}): Promise<void> {
await ensureStateDirectory()
await writeFile(deviceTokenFile(params.deviceId, params.role), `${params.token.trim()}\n`, 'utf8')
}
export async function clearOpenClawDeviceToken(params: {
deviceId: string
role?: string
}): Promise<void> {
try {
await rm(deviceTokenFile(params.deviceId, params.role), { force: true })
} catch {
// Ignore missing files.
}
}
export function buildOpenClawDeviceAuthPayloadV3(params: {
deviceId: string
clientId: string
clientMode: string
role: string
scopes: string[]
signedAtMs: number
token: string
nonce: string
platform: string
deviceFamily: string
}): string {
return [
'v3',
params.deviceId,
params.clientId,
params.clientMode,
params.role,
params.scopes.join(','),
String(params.signedAtMs),
params.token,
params.nonce,
normalizeMetadataForAuth(params.platform),
normalizeMetadataForAuth(params.deviceFamily),
].join('|')
}
export function signOpenClawDevicePayload(params: {
identity: StoredDeviceIdentity
payload: string
}): string {
const privateKey = createPrivateKey({
format: 'jwk',
key: {
kty: 'OKP',
crv: 'Ed25519',
x: params.identity.publicKeyBase64Url,
d: params.identity.privateKeyBase64Url,
},
})
return signPayload(null, Buffer.from(params.payload, 'utf8'), privateKey).toString('base64url')
}

View File

@ -10,8 +10,25 @@ import {
type GatewayChatMessage,
type GatewaySessionSummary,
} from '@/lib/openclaw/types'
import {
buildOpenClawDeviceAuthPayloadV3,
clearOpenClawDeviceToken,
loadOpenClawDeviceToken,
loadOrCreateOpenClawDeviceIdentity,
saveOpenClawDeviceToken,
signOpenClawDevicePayload,
} from '@/server/openclaw/device-store'
const OPENCLAW_PROTOCOL_VERSION = 3
const OPENCLAW_CLIENT_IDS = {
assistant: 'webchat-ui',
} as const
const OPENCLAW_CLIENT_MODES = {
assistant: 'ui',
} as const
const OPENCLAW_CLIENT_PLATFORM = 'web'
const OPENCLAW_CLIENT_DEVICE_FAMILY = 'console'
const OPENCLAW_CLIENT_MODEL = 'nextjs'
const DEFAULT_OPERATOR_SCOPES = [
'operator.admin',
'operator.read',
@ -107,16 +124,20 @@ function resolveGatewayUrl(urlRaw: string): string {
export class OpenClawGatewayError extends Error {
readonly code?: string
readonly details?: Record<string, unknown>
constructor(message: string, code?: string) {
constructor(message: string, code?: string, details?: Record<string, unknown>) {
super(message)
this.name = 'OpenClawGatewayError'
this.code = code
this.details = details
}
}
export class OpenClawGatewayClient {
private socket: WebSocket | null = null
private currentDeviceId = ''
private connectChallengeNonce: string | null = null
private pending = new Map<string, PendingRequest>()
private listeners = new Set<(event: GatewayEventFrame) => void>()
private handleMessageRef = (event: MessageEvent) => {
@ -133,11 +154,17 @@ export class OpenClawGatewayClient {
gatewayUrl: string
gatewayToken: string
clientId?: string
clientMode?: string
clientLabel?: string
}): Promise<{ mainSessionKey: string }> {
}): Promise<{ mainSessionKey: string; deviceId: string }> {
const url = resolveGatewayUrl(params.gatewayUrl)
const socket = new WebSocket(url)
this.socket = socket
this.connectChallengeNonce = null
socket.addEventListener('message', this.handleMessageRef)
socket.addEventListener('close', this.handleCloseRef)
socket.addEventListener('error', this.handleErrorRef)
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
@ -163,45 +190,166 @@ export class OpenClawGatewayClient {
)
})
socket.addEventListener('message', this.handleMessageRef)
socket.addEventListener('close', this.handleCloseRef)
socket.addEventListener('error', this.handleErrorRef)
const clientId = params.clientId ?? OPENCLAW_CLIENT_IDS.assistant
const clientMode = params.clientMode ?? OPENCLAW_CLIENT_MODES.assistant
const identity = await loadOrCreateOpenClawDeviceIdentity()
this.currentDeviceId = identity.deviceId
const storedDeviceToken = await loadOpenClawDeviceToken({
deviceId: identity.deviceId,
role: 'operator',
})
const sharedGatewayToken = params.gatewayToken.trim()
const authToken = sharedGatewayToken || storedDeviceToken
const authDeviceToken = storedDeviceToken
const payload = asRecord(
await this.request('connect', {
minProtocol: OPENCLAW_PROTOCOL_VERSION,
maxProtocol: OPENCLAW_PROTOCOL_VERSION,
client: {
id: params.clientId ?? 'console-openclaw-proxy',
displayName: params.clientLabel ?? 'console.svc.plus Assistant',
version: '1.0.0',
platform: 'node',
mode: 'ui',
instanceId: `console-${randomUUID().slice(0, 8)}`,
},
locale: 'zh-CN',
userAgent: 'console.svc.plus/openclaw',
try {
const nonce = await this.waitForConnectChallenge(socket)
const signedAtMs = Date.now()
const signaturePayload = buildOpenClawDeviceAuthPayloadV3({
deviceId: identity.deviceId,
clientId,
clientMode,
role: 'operator',
scopes: DEFAULT_OPERATOR_SCOPES,
caps: ['tool-events'],
...(params.gatewayToken.trim()
? {
auth: {
token: params.gatewayToken.trim(),
},
}
: {}),
}, 12000),
)
scopes: [...DEFAULT_OPERATOR_SCOPES],
signedAtMs,
token: authToken,
nonce,
platform: OPENCLAW_CLIENT_PLATFORM,
deviceFamily: OPENCLAW_CLIENT_DEVICE_FAMILY,
})
const snapshot = asRecord(payload.snapshot)
const sessionDefaults = asRecord(snapshot.sessionDefaults)
const payload = asRecord(
await this.request('connect', {
minProtocol: OPENCLAW_PROTOCOL_VERSION,
maxProtocol: OPENCLAW_PROTOCOL_VERSION,
client: {
id: clientId,
displayName: params.clientLabel ?? 'console.svc.plus Assistant',
version: '1.0.0',
platform: OPENCLAW_CLIENT_PLATFORM,
deviceFamily: OPENCLAW_CLIENT_DEVICE_FAMILY,
modelIdentifier: OPENCLAW_CLIENT_MODEL,
mode: clientMode,
instanceId: `${clientId}-${identity.deviceId.slice(0, 8)}`,
},
locale: 'zh-CN',
userAgent: 'console.svc.plus/openclaw',
role: 'operator',
scopes: DEFAULT_OPERATOR_SCOPES,
caps: ['tool-events'],
commands: [],
permissions: {},
...((authToken || authDeviceToken)
? {
auth: {
...(authToken ? { token: authToken } : {}),
...(authDeviceToken ? { deviceToken: authDeviceToken } : {}),
},
}
: {}),
device: {
id: identity.deviceId,
publicKey: identity.publicKeyBase64Url,
signature: signOpenClawDevicePayload({
identity,
payload: signaturePayload,
}),
signedAt: signedAtMs,
nonce,
},
}, 12000),
)
return {
mainSessionKey: normalizeMainSessionKey(stringValue(sessionDefaults.mainSessionKey)),
const snapshot = asRecord(payload.snapshot)
const sessionDefaults = asRecord(snapshot.sessionDefaults)
const auth = asRecord(payload.auth)
const returnedDeviceToken = stringValue(auth.deviceToken)
if (returnedDeviceToken) {
await saveOpenClawDeviceToken({
deviceId: identity.deviceId,
role: stringValue(auth.role) ?? 'operator',
token: returnedDeviceToken,
})
}
return {
mainSessionKey: normalizeMainSessionKey(stringValue(sessionDefaults.mainSessionKey)),
deviceId: identity.deviceId,
}
} catch (error) {
const gatewayError = error instanceof OpenClawGatewayError ? error : null
const detailCode = stringValue(asRecord(gatewayError?.details).code)
if (detailCode === 'AUTH_DEVICE_TOKEN_MISMATCH' && !sharedGatewayToken && authDeviceToken) {
await clearOpenClawDeviceToken({
deviceId: identity.deviceId,
role: 'operator',
})
}
throw error
}
}
get deviceId(): string {
return this.currentDeviceId
}
private async waitForConnectChallenge(socket: WebSocket): Promise<string> {
if (this.connectChallengeNonce) {
return this.connectChallengeNonce
}
return await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup()
reject(new OpenClawGatewayError('Gateway connect challenge timeout', 'CHALLENGE_TIMEOUT'))
}, 4000)
const stopListening = this.onEvent((event) => {
if (event.event !== 'connect.challenge') {
return
}
const payload = asRecord(event.payload)
const nonce = stringValue(payload.nonce)
if (!nonce) {
cleanup()
reject(
new OpenClawGatewayError('Gateway connect challenge missing nonce', 'CHALLENGE_NONCE'),
)
return
}
this.connectChallengeNonce = nonce
cleanup()
resolve(nonce)
})
const onClose = () => {
cleanup()
reject(new OpenClawGatewayError('Gateway closed before connect challenge', 'SOCKET_CLOSED'))
}
const onError = () => {
cleanup()
reject(new OpenClawGatewayError('Gateway error before connect challenge', 'SOCKET_ERROR'))
}
const cleanup = () => {
clearTimeout(timeout)
stopListening()
socket.removeEventListener('close', onClose)
socket.removeEventListener('error', onError)
}
socket.addEventListener('close', onClose, { once: true })
socket.addEventListener('error', onError, { once: true })
})
}
onEvent(listener: (event: GatewayEventFrame) => void): () => void {
this.listeners.add(listener)
return () => {
@ -383,9 +531,18 @@ export class OpenClawGatewayClient {
const type = stringValue(payload.type)
if (type === 'event') {
const eventName = stringValue(payload.event) ?? ''
if (eventName === 'connect.challenge') {
const challengePayload = asRecord(payload.payload)
const nonce = stringValue(challengePayload.nonce)
if (nonce) {
this.connectChallengeNonce = nonce
}
}
const frame = {
type: 'event' as const,
event: stringValue(payload.event) ?? '',
event: eventName,
seq: numberValue(payload.seq),
payload: payload.payload,
}
@ -421,6 +578,7 @@ export class OpenClawGatewayClient {
new OpenClawGatewayError(
stringValue(error.message) ?? 'Gateway request failed',
stringValue(error.code),
asRecord(error.details),
),
)
return
@ -430,6 +588,7 @@ export class OpenClawGatewayClient {
}
private failPending(error: OpenClawGatewayError): void {
this.connectChallengeNonce = null
for (const [id, pending] of this.pending.entries()) {
clearTimeout(pending.timeout)
pending.reject(error)