@dcrays/dcgchat-test 0.2.33 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ // Gateway connection handler - connects to local OpenClaw gateway
2
+ import { WebSocket } from 'ws'
3
+ import crypto from 'crypto'
4
+ import { deriveDeviceIdFromPublicKey, publicKeyRawBase64UrlFromPem, buildDeviceAuthPayloadV3, signDevicePayload } from './security.js'
5
+ import { dcgLogger } from '../utils/log.js'
6
+ import { sendDcgchatCron, updateCronJobSessionKey } from '../cron.js'
7
+
8
+ export interface GatewayEvent {
9
+ type: string
10
+ payload?: Record<string, unknown>
11
+ seq?: number
12
+ }
13
+
14
+ export interface GatewayHelloOk {
15
+ server: {
16
+ connId: string
17
+ }
18
+ features: {
19
+ methods: string[]
20
+ events: string[]
21
+ }
22
+ policy?: {
23
+ tickIntervalMs?: number
24
+ }
25
+ auth?: {
26
+ deviceToken?: string
27
+ role?: string
28
+ scopes?: string[]
29
+ }
30
+ }
31
+
32
+ export interface GatewayResponse {
33
+ type: 'res'
34
+ id: string
35
+ ok: boolean
36
+ /** 多数 RPC 成功时的返回值 */
37
+ result?: unknown
38
+ /** connect 及部分响应使用 payload(见 docs/gateway/protocol.md) */
39
+ payload?: unknown
40
+ error?: {
41
+ code: string
42
+ message: string
43
+ details?: Record<string, unknown>
44
+ }
45
+ }
46
+
47
+ export interface GatewayEventFrame {
48
+ type: 'event'
49
+ event: string
50
+ payload?: Record<string, unknown>
51
+ seq?: number
52
+ }
53
+
54
+ export interface GatewayHelloOkFrame {
55
+ type: 'hello-ok'
56
+ server: {
57
+ connId: string
58
+ }
59
+ features: {
60
+ methods: string[]
61
+ events: string[]
62
+ }
63
+ policy?: {
64
+ tickIntervalMs?: number
65
+ }
66
+ }
67
+
68
+ // Union of all possible gateway messages
69
+ export type GatewayMessage = GatewayEventFrame | GatewayResponse | GatewayHelloOkFrame
70
+
71
+ export interface GatewayConfig {
72
+ url: string
73
+ token: string
74
+ role: string
75
+ scopes: string[]
76
+ reconnectInterval?: number
77
+ maxReconnectAttempts?: number
78
+ }
79
+
80
+ /**
81
+ * Gateway connection handler
82
+ */
83
+ export class GatewayConnection {
84
+ private ws: WebSocket | null = null
85
+ private config: Required<GatewayConfig>
86
+ private deviceId: string
87
+ private privateKeyPem: string
88
+ private publicKeyB64Url: string
89
+ private connected: boolean = false
90
+ private connId: string | null = null
91
+ /** 服务端 connect.challenge 提供的 nonce,须与签名载荷一致 */
92
+ private connectChallengeNonce: string | null = null
93
+ private connectSent: boolean = false
94
+ private messageHandlers: Map<string, (response: GatewayResponse) => void> = new Map()
95
+ private eventHandlers: Set<(event: GatewayEvent) => void> = new Set()
96
+
97
+ constructor(config: GatewayConfig) {
98
+ this.config = {
99
+ url: config.url,
100
+ token: config.token,
101
+ role: config.role || 'operator',
102
+ scopes: config.scopes || ['operator.admin'],
103
+ reconnectInterval: config.reconnectInterval || 5000,
104
+ maxReconnectAttempts: config.maxReconnectAttempts || 10
105
+ }
106
+
107
+ const identity = this.loadOrCreateDeviceIdentity()
108
+ // 必须与公钥指纹一致(deriveDeviceIdFromPublicKey),不可用随机 UUID
109
+ this.deviceId = identity.deviceId
110
+ this.privateKeyPem = identity.privateKeyPem
111
+ this.publicKeyB64Url = identity.publicKeyB64Url
112
+ }
113
+
114
+ private loadOrCreateDeviceIdentity() {
115
+ const fs = require('fs')
116
+ const path = require('path')
117
+ const stateDir = path.join(process.cwd(), '.state')
118
+ const deviceFile = path.join(stateDir, 'device.json')
119
+
120
+ // Try to load existing identity
121
+ if (fs.existsSync(deviceFile)) {
122
+ const stored = JSON.parse(fs.readFileSync(deviceFile, 'utf8'))
123
+ if (stored.deviceId && stored.publicKeyPem && stored.privateKeyPem) {
124
+ const derivedId = deriveDeviceIdFromPublicKey(stored.publicKeyPem)
125
+ const deviceId = derivedId !== stored.deviceId ? derivedId : stored.deviceId
126
+ if (derivedId !== stored.deviceId) {
127
+ try {
128
+ fs.writeFileSync(deviceFile, JSON.stringify({ ...stored, deviceId: derivedId }, null, 2))
129
+ } catch {
130
+ /* keep in-memory fixed id only */
131
+ }
132
+ }
133
+ return {
134
+ deviceId,
135
+ publicKeyPem: stored.publicKeyPem,
136
+ privateKeyPem: stored.privateKeyPem,
137
+ publicKeyB64Url: publicKeyRawBase64UrlFromPem(stored.publicKeyPem)
138
+ }
139
+ }
140
+ }
141
+
142
+ // Create new identity
143
+ const keyPair = crypto.generateKeyPairSync('ed25519')
144
+ const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString()
145
+ const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
146
+ const deviceId = deriveDeviceIdFromPublicKey(publicKeyPem)
147
+ const publicKeyB64Url = publicKeyRawBase64UrlFromPem(publicKeyPem)
148
+
149
+ // Ensure directory exists
150
+ if (!fs.existsSync(stateDir)) {
151
+ fs.mkdirSync(stateDir, { recursive: true })
152
+ }
153
+
154
+ // Save identity
155
+ fs.writeFileSync(
156
+ deviceFile,
157
+ JSON.stringify(
158
+ {
159
+ version: 1,
160
+ deviceId,
161
+ publicKeyPem,
162
+ privateKeyPem,
163
+ createdAtMs: Date.now()
164
+ },
165
+ null,
166
+ 2
167
+ )
168
+ )
169
+ fs.chmodSync(deviceFile, 0o600)
170
+
171
+ return { deviceId, publicKeyPem, privateKeyPem, publicKeyB64Url }
172
+ }
173
+
174
+ /**
175
+ * Connect to the gateway
176
+ */
177
+ async connect(): Promise<GatewayHelloOk> {
178
+ return new Promise((resolve, reject) => {
179
+ this.connectChallengeNonce = null
180
+ this.connectSent = false
181
+ this.ws = new WebSocket(this.config.url)
182
+
183
+ let handshakeSettled = false
184
+ const finishHandshake = (fn: () => void) => {
185
+ if (handshakeSettled) return
186
+ handshakeSettled = true
187
+ clearTimeout(timeout)
188
+ fn()
189
+ }
190
+
191
+ const timeout = setTimeout(() => {
192
+ finishHandshake(() => reject(new Error('Gateway connection timeout')))
193
+ }, 15000)
194
+
195
+ this.ws.on('open', () => {
196
+ dcgLogger('Gateway connection opened(等待 connect.challenge)')
197
+ })
198
+
199
+ this.ws.on('message', (data) => {
200
+ try {
201
+ const msg = JSON.parse(data.toString())
202
+ this.handleMessage(
203
+ msg,
204
+ (hello) => finishHandshake(() => resolve(hello)),
205
+ (err) => finishHandshake(() => reject(err)),
206
+ timeout
207
+ )
208
+ } catch (err) {
209
+ dcgLogger(`[Gateway] 解析消息失败: ${err}`, 'error')
210
+ }
211
+ })
212
+
213
+ this.ws.on('close', () => {
214
+ this.connected = false
215
+ finishHandshake(() => reject(new Error('Gateway 在握手完成前关闭了连接')))
216
+ })
217
+
218
+ this.ws.on('error', (err) => {
219
+ console.log('🚀 ~ GatewayConnection ~ connect ~ err:', err)
220
+ finishHandshake(() => reject(err))
221
+ })
222
+ })
223
+ }
224
+
225
+ /**
226
+ * Send initial connect request
227
+ */
228
+ private sendConnect(): void {
229
+ if (this.connectSent) return
230
+ const nonce = this.connectChallengeNonce?.trim() ?? ''
231
+ if (!nonce) return
232
+
233
+ this.connectSent = true
234
+ const signedAtMs = Date.now()
235
+ const platform = process.platform
236
+
237
+ const payload = buildDeviceAuthPayloadV3({
238
+ deviceId: this.deviceId,
239
+ clientId: 'gateway-client',
240
+ clientMode: 'backend',
241
+ role: this.config.role,
242
+ scopes: this.config.scopes,
243
+ signedAtMs,
244
+ token: this.config.token,
245
+ nonce,
246
+ platform,
247
+ deviceFamily: ''
248
+ })
249
+
250
+ const signature = signDevicePayload(this.privateKeyPem, payload)
251
+
252
+ this.ws?.send(
253
+ JSON.stringify({
254
+ type: 'req',
255
+ id: '1',
256
+ method: 'connect',
257
+ params: {
258
+ minProtocol: 3,
259
+ maxProtocol: 3,
260
+ client: {
261
+ id: 'gateway-client',
262
+ version: '1.0.0',
263
+ platform,
264
+ mode: 'backend'
265
+ },
266
+ auth: { token: this.config.token },
267
+ role: this.config.role,
268
+ scopes: this.config.scopes,
269
+ device: {
270
+ id: this.deviceId,
271
+ publicKey: this.publicKeyB64Url,
272
+ signature,
273
+ signedAt: signedAtMs,
274
+ nonce
275
+ }
276
+ }
277
+ })
278
+ )
279
+ }
280
+
281
+ /**
282
+ * Handle incoming messages
283
+ */
284
+ private mapHelloPayloadToHelloOk(payload: Record<string, unknown>): GatewayHelloOk {
285
+ const serverRaw = payload.server as Record<string, unknown> | undefined
286
+ const featuresRaw = payload.features as Record<string, unknown> | undefined
287
+ return {
288
+ server: { connId: typeof serverRaw?.connId === 'string' ? serverRaw.connId : '' },
289
+ features: {
290
+ methods: Array.isArray(featuresRaw?.methods) ? (featuresRaw.methods as string[]) : [],
291
+ events: Array.isArray(featuresRaw?.events) ? (featuresRaw.events as string[]) : []
292
+ },
293
+ policy: payload.policy as GatewayHelloOk['policy'],
294
+ auth: payload.auth as GatewayHelloOk['auth']
295
+ }
296
+ }
297
+
298
+ private handleMessage(
299
+ msg: Record<string, any>,
300
+ resolveHello: (helloOk: GatewayHelloOk) => void,
301
+ rejectHello: (err: Error) => void,
302
+ timeout: NodeJS.Timeout
303
+ ): void {
304
+ const msgType = msg.type as string | undefined
305
+
306
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
307
+ const payload = msg.payload as Record<string, unknown> | undefined
308
+ const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
309
+ if (!nonce) {
310
+ rejectHello(new Error('connect.challenge 缺少 nonce'))
311
+ return
312
+ }
313
+ this.connectChallengeNonce = nonce
314
+ this.sendConnect()
315
+ return
316
+ }
317
+
318
+ // 协议 v3:握手成功为 type:"res" + payload.type:"hello-ok"(见 openclaw docs/gateway/protocol.md)
319
+ if (msg.type === 'res' && !this.connected) {
320
+ const ok = msg.ok === true
321
+ const inner = msg.payload as Record<string, unknown> | undefined
322
+ if (ok && inner && inner.type === 'hello-ok') {
323
+ this.connected = true
324
+ const serverRaw = inner.server as Record<string, unknown> | undefined
325
+ this.connId = typeof serverRaw?.connId === 'string' ? serverRaw.connId : null
326
+ clearTimeout(timeout)
327
+ dcgLogger(`[Gateway] 已连接 connId=${this.connId ?? '(none)'}`)
328
+ resolveHello(this.mapHelloPayloadToHelloOk(inner))
329
+ return
330
+ }
331
+ if (msg.ok === false) {
332
+ const errObj = msg.error as Record<string, unknown> | undefined
333
+ const message = (typeof errObj?.message === 'string' && errObj.message) || 'Gateway connect 被拒绝'
334
+ clearTimeout(timeout)
335
+ rejectHello(new Error(message))
336
+ return
337
+ }
338
+ }
339
+
340
+ // 旧式或其它实现:顶层 hello-ok
341
+ if (msgType === 'hello-ok' || (!msgType && msg.server)) {
342
+ this.connected = true
343
+ this.connId = ((msg.server as Record<string, unknown>)?.connId as string) || null
344
+ clearTimeout(timeout)
345
+ dcgLogger(`[Gateway] 已连接 connId=${this.connId ?? '(none)'}`)
346
+ resolveHello(msg as unknown as GatewayHelloOk)
347
+ return
348
+ }
349
+ if (msg.type === 'res') {
350
+ const handler = this.messageHandlers.get(msg.id as string)
351
+ if (handler) {
352
+ this.messageHandlers.delete(msg.id as string)
353
+ handler(msg as unknown as GatewayResponse)
354
+ }
355
+ return
356
+ }
357
+
358
+ if (msg.type === 'event') {
359
+ if (msg.event === 'cron') {
360
+ dcgLogger(`[Gateway] 收到事件: ${JSON.stringify(msg)}`)
361
+ if (msg.payload?.action === 'added') {
362
+ updateCronJobSessionKey(msg.payload?.jobId as string)
363
+ }
364
+ if (msg.payload?.action === 'updated') {
365
+ sendDcgchatCron()
366
+ }
367
+ }
368
+ const event: GatewayEvent = {
369
+ type: msg.event as string,
370
+ payload: msg.payload as Record<string, unknown> | undefined,
371
+ seq: msg.seq as number | undefined
372
+ }
373
+ this.eventHandlers.forEach((h) => h(event))
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Call a gateway method
379
+ */
380
+ async callMethod<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
381
+ const id = crypto.randomUUID()
382
+
383
+ return new Promise((resolve, reject) => {
384
+ if (!this.connected || !this.ws) {
385
+ reject(new Error('Not connected to gateway'))
386
+ return
387
+ }
388
+
389
+ const timeout = setTimeout(() => {
390
+ this.messageHandlers.delete(id)
391
+ reject(new Error('Method call timeout'))
392
+ }, 30000)
393
+
394
+ this.messageHandlers.set(id, (response) => {
395
+ clearTimeout(timeout)
396
+ if (response.ok) {
397
+ const body = response.result !== undefined ? response.result : (response as GatewayResponse).payload
398
+ resolve(body as T)
399
+ } else {
400
+ reject(new Error(response.error?.message || 'Method call failed'))
401
+ }
402
+ })
403
+
404
+ this.ws.send(
405
+ JSON.stringify({
406
+ type: 'req',
407
+ id,
408
+ method,
409
+ params
410
+ })
411
+ )
412
+ })
413
+ }
414
+
415
+ /**
416
+ * Register event handler
417
+ */
418
+ onEvent(handler: (event: GatewayEvent) => void): () => void {
419
+ this.eventHandlers.add(handler)
420
+ return () => this.eventHandlers.delete(handler)
421
+ }
422
+
423
+ /**
424
+ * Close connection
425
+ */
426
+ close(): void {
427
+ this.ws?.close(1000, 'Plugin stopped')
428
+ this.connected = false
429
+ }
430
+
431
+ /**
432
+ * Check if connected
433
+ */
434
+ isConnected(): boolean {
435
+ return this.connected
436
+ }
437
+
438
+ /**
439
+ * Get connection ID
440
+ */
441
+ getConnId(): string | null {
442
+ return this.connId
443
+ }
444
+
445
+ /**
446
+ * Get the WebSocket instance (for external use)
447
+ */
448
+ getWebSocket(): WebSocket | null {
449
+ return this.ws
450
+ }
451
+
452
+ /**
453
+ * Ping the gateway
454
+ */
455
+ ping(): void {
456
+ this.ws?.ping()
457
+ }
458
+ }
@@ -0,0 +1,101 @@
1
+ // Security utilities for the tunnel plugin
2
+ import crypto from 'crypto'
3
+ import fs from 'fs'
4
+ import jwt from 'jsonwebtoken'
5
+ import { z } from 'zod'
6
+
7
+ // ED25519 SubjectPublicKeyInfo prefix (must match openclaw gateway `ED25519_SPKI_PREFIX`)
8
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
9
+
10
+ /**
11
+ * Raw 32-byte Ed25519 public key bytes from PEM (same rules as openclaw `derivePublicKeyRaw`).
12
+ */
13
+ export function derivePublicKeyRawFromPem(publicKeyPem: string): Buffer {
14
+ const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) as Buffer
15
+ if (
16
+ spki.length === ED25519_SPKI_PREFIX.length + 32 &&
17
+ spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
18
+ ) {
19
+ return spki.subarray(ED25519_SPKI_PREFIX.length)
20
+ }
21
+ return spki
22
+ }
23
+
24
+ /**
25
+ * Base64url encode (no padding)
26
+ */
27
+ export function base64UrlEncode(buffer: Buffer): string {
28
+ return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
29
+ }
30
+
31
+ /** Wire-format device public key (base64url of raw 32 bytes), matches gateway expectations. */
32
+ export function publicKeyRawBase64UrlFromPem(publicKeyPem: string): string {
33
+ return base64UrlEncode(derivePublicKeyRawFromPem(publicKeyPem))
34
+ }
35
+
36
+ /**
37
+ * Derive device ID from ED25519 public key
38
+ * Device ID = sha256(rawPublicKey)
39
+ */
40
+ export function deriveDeviceIdFromPublicKey(publicKeyPem: string): string {
41
+ const rawPublicKey = derivePublicKeyRawFromPem(publicKeyPem)
42
+ return crypto.createHash('sha256').update(rawPublicKey).digest('hex')
43
+ }
44
+
45
+ /**
46
+ * Sign device payload
47
+ */
48
+ export function signDevicePayload(privateKeyPem: string, payload: string, encoding: 'base64' | 'base64url' = 'base64url'): string {
49
+ const key = crypto.createPrivateKey(privateKeyPem)
50
+ const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key)
51
+ return encoding === 'base64url' ? base64UrlEncode(sig) : sig.toString('base64')
52
+ }
53
+ function normalizeTrimmedMetadata(value: unknown): string {
54
+ if (typeof value !== 'string') return ''
55
+ const trimmed = value.trim()
56
+ return trimmed ? trimmed : ''
57
+ }
58
+
59
+ function toLowerAscii(input: string): string {
60
+ return input.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32))
61
+ }
62
+
63
+ function normalizeDeviceMetadataForAuth(value: unknown): string {
64
+ const trimmed = normalizeTrimmedMetadata(value)
65
+ if (!trimmed) return ''
66
+ return toLowerAscii(trimmed)
67
+ }
68
+
69
+ /**
70
+ * Device authentication payload v3(与 openclaw `buildDeviceAuthPayloadV3` 一致)
71
+ */
72
+ export function buildDeviceAuthPayloadV3(params: {
73
+ deviceId: string
74
+ clientId: string
75
+ clientMode: string
76
+ role: string
77
+ scopes: string[]
78
+ signedAtMs: number
79
+ token: string
80
+ nonce: string
81
+ platform?: string
82
+ deviceFamily?: string
83
+ }): string {
84
+ const scopes = params.scopes.join(',')
85
+ const token = params.token ?? ''
86
+ const platform = normalizeDeviceMetadataForAuth(params.platform ?? '')
87
+ const deviceFamily = normalizeDeviceMetadataForAuth(params.deviceFamily ?? '')
88
+ return [
89
+ 'v3',
90
+ params.deviceId,
91
+ params.clientId,
92
+ params.clientMode,
93
+ params.role,
94
+ scopes,
95
+ String(params.signedAtMs),
96
+ token,
97
+ params.nonce,
98
+ platform,
99
+ deviceFamily
100
+ ].join('|')
101
+ }