feat: add openclaw pairing bridge
This commit is contained in:
parent
9a915ae080
commit
01181d4385
1
.gitignore
vendored
1
.gitignore
vendored
@ -18,6 +18,7 @@ public/_build/
|
||||
public/dl-index/
|
||||
.contentlayer/
|
||||
.dev-logs/
|
||||
.console-state/
|
||||
|
||||
# Contentlayer cache
|
||||
ui/docs/.contentlayer/
|
||||
|
||||
53
README.md
53
README.md
@ -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`
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
|
||||
@ -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()
|
||||
|
||||
184
src/server/openclaw/device-store.ts
Normal file
184
src/server/openclaw/device-store.ts
Normal 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')
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user