@agentsquared/cli 1.0.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,740 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+
6
+ import { WebSocket } from 'ws'
7
+ import { runOpenClawCli } from './cli.mjs'
8
+
9
+ const PROTOCOL_VERSION = 3
10
+ const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:18789'
11
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15000
12
+ const DEFAULT_REQUEST_TIMEOUT_MS = 180000
13
+ const DEFAULT_CLIENT_ID = 'gateway-client'
14
+ const DEFAULT_CLIENT_MODE = 'backend'
15
+ const DEFAULT_DEVICE_FAMILY = 'agentsquared'
16
+ const DEFAULT_ROLE = 'operator'
17
+ const DEFAULT_SCOPES = [
18
+ 'operator.read',
19
+ 'operator.write',
20
+ 'operator.admin',
21
+ 'operator.approvals',
22
+ 'operator.pairing'
23
+ ]
24
+
25
+ function clean(value) {
26
+ return `${value ?? ''}`.trim()
27
+ }
28
+
29
+ function base64UrlEncode(buffer) {
30
+ return Buffer.from(buffer)
31
+ .toString('base64')
32
+ .replaceAll('+', '-')
33
+ .replaceAll('/', '_')
34
+ .replace(/=+$/g, '')
35
+ }
36
+
37
+ function randomId() {
38
+ if (typeof crypto.randomUUID === 'function') {
39
+ return crypto.randomUUID()
40
+ }
41
+ return `${Date.now()}_${Math.random().toString(16).slice(2)}`
42
+ }
43
+
44
+ function ensureDir(filePath) {
45
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
46
+ }
47
+
48
+ function readJson(filePath) {
49
+ try {
50
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'))
51
+ } catch {
52
+ return null
53
+ }
54
+ }
55
+
56
+ function writeJson(filePath, value) {
57
+ ensureDir(filePath)
58
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 })
59
+ try {
60
+ fs.chmodSync(filePath, 0o600)
61
+ } catch {
62
+ // best-effort on non-posix filesystems
63
+ }
64
+ }
65
+
66
+ function ed25519PublicKeyRaw(publicKeyPem) {
67
+ const key = crypto.createPublicKey(publicKeyPem)
68
+ const spki = key.export({ type: 'spki', format: 'der' })
69
+ const prefix = Buffer.from('302a300506032b6570032100', 'hex')
70
+ if (spki.length === prefix.length + 32 && spki.subarray(0, prefix.length).equals(prefix)) {
71
+ return spki.subarray(prefix.length)
72
+ }
73
+ return spki
74
+ }
75
+
76
+ function fingerprintPublicKey(publicKeyPem) {
77
+ return crypto.createHash('sha256').update(ed25519PublicKeyRaw(publicKeyPem)).digest('hex')
78
+ }
79
+
80
+ function loadOrCreateDeviceIdentity(filePath) {
81
+ const existing = readJson(filePath)
82
+ if (
83
+ existing?.version === 1
84
+ && clean(existing.deviceId)
85
+ && clean(existing.publicKeyPem)
86
+ && clean(existing.privateKeyPem)
87
+ ) {
88
+ return {
89
+ deviceId: clean(existing.deviceId),
90
+ publicKeyPem: existing.publicKeyPem,
91
+ privateKeyPem: existing.privateKeyPem
92
+ }
93
+ }
94
+
95
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
96
+ const identity = {
97
+ version: 1,
98
+ deviceId: '',
99
+ publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
100
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
101
+ createdAtMs: Date.now()
102
+ }
103
+ identity.deviceId = fingerprintPublicKey(identity.publicKeyPem)
104
+ writeJson(filePath, identity)
105
+ return {
106
+ deviceId: identity.deviceId,
107
+ publicKeyPem: identity.publicKeyPem,
108
+ privateKeyPem: identity.privateKeyPem
109
+ }
110
+ }
111
+
112
+ function signDevicePayload(privateKeyPem, payload) {
113
+ const key = crypto.createPrivateKey(privateKeyPem)
114
+ const signature = crypto.sign(null, Buffer.from(payload, 'utf8'), key)
115
+ return base64UrlEncode(signature)
116
+ }
117
+
118
+ function publicKeyRawBase64UrlFromPem(publicKeyPem) {
119
+ return base64UrlEncode(ed25519PublicKeyRaw(publicKeyPem))
120
+ }
121
+
122
+ function buildDeviceAuthPayloadV3({
123
+ deviceId,
124
+ clientId,
125
+ clientMode,
126
+ role,
127
+ scopes,
128
+ signedAtMs,
129
+ token,
130
+ nonce,
131
+ platform,
132
+ deviceFamily
133
+ }) {
134
+ return [
135
+ 'v3',
136
+ clean(deviceId),
137
+ clean(clientId),
138
+ clean(clientMode),
139
+ clean(role),
140
+ (Array.isArray(scopes) ? scopes : []).map((scope) => clean(scope)).filter(Boolean).join(','),
141
+ String(signedAtMs),
142
+ clean(token),
143
+ clean(nonce),
144
+ clean(platform),
145
+ clean(deviceFamily)
146
+ ].join('|')
147
+ }
148
+
149
+ function authStorePath(stateDir) {
150
+ return path.join(stateDir, 'openclaw-device-auth.json')
151
+ }
152
+
153
+ function identityPath(stateDir) {
154
+ return path.join(stateDir, 'openclaw-device.json')
155
+ }
156
+
157
+ function loadStoredDeviceToken(stateDir, { deviceId, role }) {
158
+ const store = readJson(authStorePath(stateDir))
159
+ if (store?.version !== 1 || clean(store?.deviceId) !== clean(deviceId)) {
160
+ return null
161
+ }
162
+ const entry = store.tokens?.[clean(role)]
163
+ if (!entry || typeof entry !== 'object') {
164
+ return null
165
+ }
166
+ return {
167
+ token: clean(entry.token),
168
+ scopes: Array.isArray(entry.scopes) ? entry.scopes.map((scope) => clean(scope)).filter(Boolean) : []
169
+ }
170
+ }
171
+
172
+ function storeDeviceToken(stateDir, {
173
+ deviceId,
174
+ role,
175
+ token,
176
+ scopes = []
177
+ }) {
178
+ const filePath = authStorePath(stateDir)
179
+ const existing = readJson(filePath)
180
+ const next = existing?.version === 1 && clean(existing.deviceId) === clean(deviceId)
181
+ ? existing
182
+ : {
183
+ version: 1,
184
+ deviceId: clean(deviceId),
185
+ tokens: {}
186
+ }
187
+ next.tokens = next.tokens && typeof next.tokens === 'object' ? next.tokens : {}
188
+ next.tokens[clean(role)] = {
189
+ token: clean(token),
190
+ scopes: Array.isArray(scopes) ? scopes.map((scope) => clean(scope)).filter(Boolean) : [],
191
+ updatedAtMs: Date.now()
192
+ }
193
+ writeJson(filePath, next)
194
+ }
195
+
196
+ function clearDeviceToken(stateDir, {
197
+ deviceId,
198
+ role
199
+ }) {
200
+ const filePath = authStorePath(stateDir)
201
+ const existing = readJson(filePath)
202
+ if (existing?.version !== 1 || clean(existing.deviceId) !== clean(deviceId)) {
203
+ return
204
+ }
205
+ if (!existing.tokens || typeof existing.tokens !== 'object') {
206
+ return
207
+ }
208
+ delete existing.tokens[clean(role)]
209
+ writeJson(filePath, existing)
210
+ }
211
+
212
+ function resolveDefaultConfigPath() {
213
+ return path.join(os.homedir(), '.openclaw', 'openclaw.json')
214
+ }
215
+
216
+ function resolveConfiguredGatewayUrl(config = null) {
217
+ const gateway = config?.gateway
218
+ if (!gateway || typeof gateway !== 'object') {
219
+ return ''
220
+ }
221
+ const configuredPort = Number.parseInt(`${gateway.port ?? ''}`, 10)
222
+ if (!Number.isFinite(configuredPort) || configuredPort <= 0) {
223
+ return ''
224
+ }
225
+ const loopbackUrl = new URL(`ws://127.0.0.1:${configuredPort}`)
226
+ const configuredPath = clean(gateway.path)
227
+ if (configuredPath && configuredPath !== '/') {
228
+ loopbackUrl.pathname = configuredPath.startsWith('/') ? configuredPath : `/${configuredPath}`
229
+ }
230
+ return loopbackUrl.toString()
231
+ }
232
+
233
+ function readGatewayAuthFromConfig(configPath) {
234
+ const config = readJson(configPath)
235
+ const auth = config?.gateway?.auth
236
+ if (!auth || typeof auth !== 'object') {
237
+ return { mode: '', token: '', password: '' }
238
+ }
239
+ return {
240
+ mode: clean(auth.mode),
241
+ token: typeof auth.token === 'string' ? auth.token : '',
242
+ password: typeof auth.password === 'string' ? auth.password : ''
243
+ }
244
+ }
245
+
246
+ function readGatewayBootstrapConfig(configPath) {
247
+ const resolvedConfigPath = clean(configPath) || resolveDefaultConfigPath()
248
+ const config = readJson(resolvedConfigPath)
249
+ const auth = config?.gateway?.auth
250
+ const authMode = auth && typeof auth === 'object' ? clean(auth.mode) : ''
251
+ return {
252
+ configPath: resolvedConfigPath,
253
+ config,
254
+ gatewayUrl: resolveConfiguredGatewayUrl(config),
255
+ authMode,
256
+ gatewayToken: auth && typeof auth === 'object' && typeof auth.token === 'string' ? auth.token : '',
257
+ gatewayPassword: auth && typeof auth === 'object' && typeof auth.password === 'string' ? auth.password : ''
258
+ }
259
+ }
260
+
261
+ function isLoopbackHost(hostname) {
262
+ const normalized = clean(hostname).toLowerCase()
263
+ return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1' || normalized === '[::1]'
264
+ }
265
+
266
+ function normalizeGatewayProtocol(rawProtocol) {
267
+ const protocol = clean(rawProtocol).toLowerCase()
268
+ if (protocol === 'ws:' || protocol === 'wss:') {
269
+ return protocol
270
+ }
271
+ if (protocol === 'http:') {
272
+ return 'ws:'
273
+ }
274
+ if (protocol === 'https:') {
275
+ return 'wss:'
276
+ }
277
+ return 'ws:'
278
+ }
279
+
280
+ function parseGatewayUrl(rawGatewayUrl) {
281
+ const value = clean(rawGatewayUrl)
282
+ if (!value) {
283
+ return null
284
+ }
285
+ let parsed
286
+ try {
287
+ parsed = new URL(value)
288
+ } catch {
289
+ throw new Error(`OpenClaw gateway URL was invalid: ${value}`)
290
+ }
291
+ return {
292
+ raw: value,
293
+ protocol: normalizeGatewayProtocol(parsed.protocol),
294
+ hostname: parsed.hostname,
295
+ port: parsed.port,
296
+ pathname: parsed.pathname || '',
297
+ search: parsed.search || ''
298
+ }
299
+ }
300
+
301
+ function resolveLoopbackGatewayUrl({
302
+ explicitGatewayUrl = '',
303
+ discoveredGatewayUrl = ''
304
+ } = {}) {
305
+ const explicit = parseGatewayUrl(explicitGatewayUrl)
306
+ if (explicit) {
307
+ if (!isLoopbackHost(explicit.hostname)) {
308
+ throw new Error('OpenClaw host mode requires a local loopback gateway URL. Remote or tailnet OpenClaw Gateway URLs are not supported for AgentSquared onboarding or gateway startup.')
309
+ }
310
+ return explicit.raw
311
+ }
312
+
313
+ const discovered = parseGatewayUrl(discoveredGatewayUrl)
314
+ if (!discovered) {
315
+ return DEFAULT_GATEWAY_URL
316
+ }
317
+ const port = clean(discovered.port)
318
+ if (!port) {
319
+ throw new Error('OpenClaw gateway status did not report a local port. AgentSquared can only connect to a loopback OpenClaw Gateway.')
320
+ }
321
+ const loopbackUrl = new URL(`${discovered.protocol}//127.0.0.1:${port}`)
322
+ if (clean(discovered.pathname) && discovered.pathname !== '/') {
323
+ loopbackUrl.pathname = discovered.pathname
324
+ }
325
+ if (clean(discovered.search)) {
326
+ loopbackUrl.search = discovered.search
327
+ }
328
+ return loopbackUrl.toString()
329
+ }
330
+
331
+ const runProcess = runOpenClawCli
332
+
333
+ export async function resolveOpenClawGatewayBootstrap({
334
+ configPath = '',
335
+ gatewayUrl = '',
336
+ gatewayToken = '',
337
+ gatewayPassword = ''
338
+ } = {}) {
339
+ const configBootstrap = readGatewayBootstrapConfig(clean(configPath) || resolveDefaultConfigPath())
340
+ const authFromConfig = {
341
+ mode: clean(configBootstrap.authMode),
342
+ token: clean(configBootstrap.gatewayToken),
343
+ password: clean(configBootstrap.gatewayPassword)
344
+ }
345
+ const resolvedGatewayUrl = resolveLoopbackGatewayUrl({
346
+ explicitGatewayUrl: gatewayUrl,
347
+ discoveredGatewayUrl: configBootstrap.gatewayUrl
348
+ })
349
+ return {
350
+ gatewayUrl: resolvedGatewayUrl,
351
+ gatewayToken: clean(gatewayToken) || clean(process.env.OPENCLAW_GATEWAY_TOKEN) || authFromConfig.token,
352
+ gatewayPassword: clean(gatewayPassword) || clean(process.env.OPENCLAW_GATEWAY_PASSWORD) || authFromConfig.password,
353
+ authMode: authFromConfig.mode,
354
+ configPath: configBootstrap.configPath,
355
+ config: configBootstrap.config
356
+ }
357
+ }
358
+
359
+ function isGatewayRequestError(error, code) {
360
+ return clean(error?.detailCode || error?.details?.code).toUpperCase() === clean(code).toUpperCase()
361
+ }
362
+
363
+ function toRequestError(error) {
364
+ const details = error?.details && typeof error.details === 'object' ? error.details : {}
365
+ const requestId = clean(details.requestId)
366
+ const detailCode = clean(details.code)
367
+ const reason = clean(error?.message) || 'gateway request failed'
368
+ const next = new Error(reason)
369
+ next.details = details
370
+ next.detailCode = detailCode
371
+ next.requestId = requestId
372
+ return next
373
+ }
374
+
375
+ async function approveLatestPairing({
376
+ command = 'openclaw',
377
+ cwd = '',
378
+ gatewayUrl = '',
379
+ gatewayToken = '',
380
+ gatewayPassword = ''
381
+ } = {}) {
382
+ const args = ['devices', 'approve', '--latest', '--json']
383
+ if (clean(gatewayUrl)) {
384
+ args.push('--url', clean(gatewayUrl))
385
+ if (clean(gatewayToken)) {
386
+ args.push('--token', clean(gatewayToken))
387
+ }
388
+ if (clean(gatewayPassword)) {
389
+ args.push('--password', clean(gatewayPassword))
390
+ }
391
+ }
392
+ const result = await runProcess(command, args, {
393
+ cwd,
394
+ timeoutMs: 20000
395
+ })
396
+ return result.stdout ? (parseOpenClawJson(result.stdout) || parseJson(result.stdout, 'OpenClaw devices approve response')) : {}
397
+ }
398
+
399
+ class OpenClawGatewayWsSession {
400
+ constructor({
401
+ url,
402
+ gatewayToken = '',
403
+ gatewayPassword = '',
404
+ stateDir,
405
+ clientId = DEFAULT_CLIENT_ID,
406
+ clientVersion = 'agentsquared',
407
+ clientMode = DEFAULT_CLIENT_MODE,
408
+ role = DEFAULT_ROLE,
409
+ scopes = DEFAULT_SCOPES,
410
+ connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS,
411
+ requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
412
+ deviceFamily = DEFAULT_DEVICE_FAMILY
413
+ }) {
414
+ this.url = clean(url) || DEFAULT_GATEWAY_URL
415
+ this.gatewayToken = clean(gatewayToken)
416
+ this.gatewayPassword = clean(gatewayPassword)
417
+ this.stateDir = stateDir
418
+ this.clientId = clean(clientId) || DEFAULT_CLIENT_ID
419
+ this.clientVersion = clean(clientVersion) || 'agentsquared'
420
+ this.clientMode = clean(clientMode) || DEFAULT_CLIENT_MODE
421
+ this.role = clean(role) || DEFAULT_ROLE
422
+ this.scopes = Array.isArray(scopes) ? scopes.map((scope) => clean(scope)).filter(Boolean) : [...DEFAULT_SCOPES]
423
+ this.connectTimeoutMs = Math.max(1000, connectTimeoutMs)
424
+ this.requestTimeoutMs = Math.max(1000, requestTimeoutMs)
425
+ this.deviceFamily = clean(deviceFamily) || DEFAULT_DEVICE_FAMILY
426
+ this.identity = loadOrCreateDeviceIdentity(identityPath(stateDir))
427
+ this.ws = null
428
+ this.pending = new Map()
429
+ this.connected = false
430
+ this.connectionPromise = null
431
+ this.connectChallengeNonce = ''
432
+ this.connectChallengeError = null
433
+ this.connectChallengeResolve = null
434
+ this.connectChallengeReject = null
435
+ }
436
+
437
+ async connect() {
438
+ if (this.connected && this.ws?.readyState === WebSocket.OPEN) {
439
+ return
440
+ }
441
+ if (this.connectionPromise) {
442
+ return this.connectionPromise
443
+ }
444
+ this.connectionPromise = this.#connectInternal()
445
+ try {
446
+ await this.connectionPromise
447
+ } finally {
448
+ this.connectionPromise = null
449
+ }
450
+ }
451
+
452
+ async #connectInternal() {
453
+ const ws = new WebSocket(this.url)
454
+ this.ws = ws
455
+ ws.on('message', (chunk) => this.#handleMessage(chunk.toString()))
456
+ ws.on('close', (code, reasonBuffer) => {
457
+ this.connected = false
458
+ const reason = Buffer.isBuffer(reasonBuffer) ? reasonBuffer.toString('utf8') : `${reasonBuffer ?? ''}`
459
+ if (this.pending.size === 0) {
460
+ return
461
+ }
462
+ const error = new Error(`OpenClaw gateway closed (${code}): ${clean(reason) || 'no close reason'}`)
463
+ for (const [id, pending] of this.pending.entries()) {
464
+ clearTimeout(pending.timer)
465
+ pending.reject(error)
466
+ this.pending.delete(id)
467
+ }
468
+ })
469
+
470
+ await new Promise((resolve, reject) => {
471
+ const timer = setTimeout(() => {
472
+ reject(new Error(`OpenClaw gateway open timed out after ${this.connectTimeoutMs}ms`))
473
+ }, this.connectTimeoutMs)
474
+ ws.once('open', () => {
475
+ clearTimeout(timer)
476
+ resolve(true)
477
+ })
478
+ ws.once('error', (error) => {
479
+ clearTimeout(timer)
480
+ reject(error)
481
+ })
482
+ })
483
+
484
+ const nonce = await this.#waitForConnectChallenge()
485
+ const connectId = randomId()
486
+ const connectParams = this.#buildConnectParams(nonce)
487
+ const hello = await this.#sendRequest(connectId, 'connect', connectParams, this.connectTimeoutMs)
488
+ this.connected = true
489
+ const issuedDeviceToken = clean(hello?.auth?.deviceToken)
490
+ if (issuedDeviceToken) {
491
+ storeDeviceToken(this.stateDir, {
492
+ deviceId: this.identity.deviceId,
493
+ role: clean(hello?.auth?.role) || this.role,
494
+ token: issuedDeviceToken,
495
+ scopes: Array.isArray(hello?.auth?.scopes) ? hello.auth.scopes : this.scopes
496
+ })
497
+ }
498
+ }
499
+
500
+ async #waitForConnectChallenge() {
501
+ if (clean(this.connectChallengeNonce)) {
502
+ const nonce = this.connectChallengeNonce
503
+ this.connectChallengeNonce = ''
504
+ return nonce
505
+ }
506
+ if (this.connectChallengeError) {
507
+ const error = this.connectChallengeError
508
+ this.connectChallengeError = null
509
+ throw error
510
+ }
511
+ return new Promise((resolve, reject) => {
512
+ const timer = setTimeout(() => {
513
+ reject(new Error(`OpenClaw connect challenge timed out after ${this.connectTimeoutMs}ms`))
514
+ }, this.connectTimeoutMs)
515
+ this.connectChallengeResolve = (nonce) => {
516
+ clearTimeout(timer)
517
+ resolve(nonce)
518
+ }
519
+ this.connectChallengeReject = (error) => {
520
+ clearTimeout(timer)
521
+ reject(error)
522
+ }
523
+ })
524
+ }
525
+
526
+ #selectAuth() {
527
+ const storedToken = loadStoredDeviceToken(this.stateDir, {
528
+ deviceId: this.identity.deviceId,
529
+ role: this.role
530
+ })
531
+ if (clean(storedToken?.token) && !this.gatewayPassword) {
532
+ return {
533
+ authToken: this.gatewayToken || clean(storedToken.token),
534
+ authDeviceToken: this.gatewayToken ? clean(storedToken.token) : '',
535
+ signatureToken: this.gatewayToken || clean(storedToken.token),
536
+ usingStoredDeviceToken: true
537
+ }
538
+ }
539
+ return {
540
+ authToken: this.gatewayToken,
541
+ authDeviceToken: '',
542
+ authPassword: this.gatewayPassword,
543
+ signatureToken: this.gatewayToken,
544
+ usingStoredDeviceToken: false
545
+ }
546
+ }
547
+
548
+ #buildConnectParams(nonce) {
549
+ const selected = this.#selectAuth()
550
+ const signedAtMs = Date.now()
551
+ const payload = buildDeviceAuthPayloadV3({
552
+ deviceId: this.identity.deviceId,
553
+ clientId: this.clientId,
554
+ clientMode: this.clientMode,
555
+ role: this.role,
556
+ scopes: this.scopes,
557
+ signedAtMs,
558
+ token: selected.signatureToken || null,
559
+ nonce,
560
+ platform: process.platform,
561
+ deviceFamily: this.deviceFamily
562
+ })
563
+ return {
564
+ minProtocol: PROTOCOL_VERSION,
565
+ maxProtocol: PROTOCOL_VERSION,
566
+ client: {
567
+ id: this.clientId,
568
+ version: this.clientVersion,
569
+ platform: process.platform,
570
+ deviceFamily: this.deviceFamily,
571
+ mode: this.clientMode
572
+ },
573
+ role: this.role,
574
+ scopes: this.scopes,
575
+ caps: [],
576
+ commands: [],
577
+ auth: (selected.authToken || selected.authDeviceToken || selected.authPassword)
578
+ ? {
579
+ token: selected.authToken || undefined,
580
+ deviceToken: selected.authDeviceToken || undefined,
581
+ password: selected.authPassword || undefined
582
+ }
583
+ : undefined,
584
+ device: {
585
+ id: this.identity.deviceId,
586
+ publicKey: publicKeyRawBase64UrlFromPem(this.identity.publicKeyPem),
587
+ signature: signDevicePayload(this.identity.privateKeyPem, payload),
588
+ signedAt: signedAtMs,
589
+ nonce
590
+ }
591
+ }
592
+ }
593
+
594
+ #handleMessage(raw) {
595
+ let parsed
596
+ try {
597
+ parsed = JSON.parse(raw)
598
+ } catch {
599
+ return
600
+ }
601
+ if (parsed?.type === 'event' && parsed.event === 'connect.challenge') {
602
+ const nonce = clean(parsed?.payload?.nonce)
603
+ if (!nonce) {
604
+ const error = new Error('OpenClaw gateway connect challenge was missing a nonce.')
605
+ if (this.connectChallengeReject) {
606
+ this.connectChallengeReject(error)
607
+ } else {
608
+ this.connectChallengeError = error
609
+ }
610
+ return
611
+ }
612
+ if (this.connectChallengeResolve) {
613
+ this.connectChallengeResolve(nonce)
614
+ this.connectChallengeResolve = null
615
+ this.connectChallengeReject = null
616
+ } else {
617
+ this.connectChallengeNonce = nonce
618
+ }
619
+ return
620
+ }
621
+ if (parsed?.type === 'res' && clean(parsed.id)) {
622
+ const pending = this.pending.get(clean(parsed.id))
623
+ if (!pending) {
624
+ return
625
+ }
626
+ clearTimeout(pending.timer)
627
+ this.pending.delete(clean(parsed.id))
628
+ if (parsed.ok) {
629
+ pending.resolve(parsed.payload ?? null)
630
+ return
631
+ }
632
+ pending.reject(toRequestError(parsed.error ?? { message: 'OpenClaw request failed' }))
633
+ }
634
+ }
635
+
636
+ async #sendRequest(id, method, params, timeoutMs) {
637
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
638
+ throw new Error('OpenClaw gateway socket is not open.')
639
+ }
640
+ const result = new Promise((resolve, reject) => {
641
+ const timer = setTimeout(() => {
642
+ this.pending.delete(id)
643
+ reject(new Error(`OpenClaw ${method} timed out after ${timeoutMs}ms`))
644
+ }, timeoutMs)
645
+ this.pending.set(id, { resolve, reject, timer })
646
+ this.ws.send(JSON.stringify({
647
+ type: 'req',
648
+ id,
649
+ method,
650
+ params
651
+ }))
652
+ })
653
+ return result
654
+ }
655
+
656
+ async request(method, params = {}, timeoutMs = this.requestTimeoutMs) {
657
+ await this.connect()
658
+ return this.#sendRequest(randomId(), clean(method), params, timeoutMs)
659
+ }
660
+
661
+ async close() {
662
+ const ws = this.ws
663
+ this.ws = null
664
+ this.connected = false
665
+ if (!ws) {
666
+ return
667
+ }
668
+ await new Promise((resolve) => {
669
+ if (ws.readyState === WebSocket.CLOSED) {
670
+ resolve(true)
671
+ return
672
+ }
673
+ const timer = setTimeout(() => {
674
+ try {
675
+ ws.terminate()
676
+ } catch {
677
+ // ignore
678
+ }
679
+ resolve(true)
680
+ }, 500)
681
+ ws.once('close', () => {
682
+ clearTimeout(timer)
683
+ resolve(true)
684
+ })
685
+ try {
686
+ ws.close(1000, 'normal closure')
687
+ } catch {
688
+ clearTimeout(timer)
689
+ resolve(true)
690
+ }
691
+ })
692
+ }
693
+ }
694
+
695
+ export async function withOpenClawGatewayClient(options, fn) {
696
+ const bootstrap = await resolveOpenClawGatewayBootstrap(options)
697
+ const stateDir = clean(options?.stateDir) || path.join(os.homedir(), '.openclaw', 'workspace', 'AgentSquared', 'default', 'runtime')
698
+ const clientOptions = {
699
+ url: clean(bootstrap.gatewayUrl) || DEFAULT_GATEWAY_URL,
700
+ gatewayToken: clean(bootstrap.gatewayToken),
701
+ gatewayPassword: clean(bootstrap.gatewayPassword),
702
+ stateDir,
703
+ connectTimeoutMs: options?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
704
+ requestTimeoutMs: options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
705
+ }
706
+
707
+ const tryConnect = async () => {
708
+ const client = new OpenClawGatewayWsSession(clientOptions)
709
+ await client.connect()
710
+ return client
711
+ }
712
+
713
+ let client
714
+ try {
715
+ client = await tryConnect()
716
+ } catch (error) {
717
+ const pairingStrategy = clean(options?.pairingStrategy || 'auto').toLowerCase() || 'auto'
718
+ if (pairingStrategy === 'none' || !isGatewayRequestError(error, 'PAIRING_REQUIRED')) {
719
+ throw error
720
+ }
721
+ await approveLatestPairing({
722
+ command: options?.command,
723
+ cwd: options?.cwd,
724
+ gatewayUrl: clean(bootstrap.gatewayUrl),
725
+ gatewayToken: clean(bootstrap.gatewayToken),
726
+ gatewayPassword: clean(bootstrap.gatewayPassword)
727
+ })
728
+ client = await tryConnect()
729
+ }
730
+
731
+ try {
732
+ return await fn(client, {
733
+ gatewayUrl: clean(bootstrap.gatewayUrl),
734
+ configPath: clean(bootstrap.configPath),
735
+ authMode: clean(bootstrap.authMode)
736
+ })
737
+ } finally {
738
+ await client.close()
739
+ }
740
+ }