@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.
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/a2_cli.mjs +1576 -0
- package/adapters/index.mjs +79 -0
- package/adapters/openclaw/adapter.mjs +1020 -0
- package/adapters/openclaw/cli.mjs +89 -0
- package/adapters/openclaw/detect.mjs +259 -0
- package/adapters/openclaw/helpers.mjs +827 -0
- package/adapters/openclaw/ws_client.mjs +740 -0
- package/bin/a2-cli.js +8 -0
- package/lib/conversation/policy.mjs +122 -0
- package/lib/conversation/store.mjs +223 -0
- package/lib/conversation/templates.mjs +419 -0
- package/lib/gateway/api.mjs +28 -0
- package/lib/gateway/inbox.mjs +344 -0
- package/lib/gateway/lifecycle.mjs +602 -0
- package/lib/gateway/runtime_state.mjs +388 -0
- package/lib/gateway/server.mjs +883 -0
- package/lib/gateway/state.mjs +175 -0
- package/lib/routing/agent_router.mjs +511 -0
- package/lib/runtime/executor.mjs +380 -0
- package/lib/runtime/keys.mjs +85 -0
- package/lib/runtime/report.mjs +302 -0
- package/lib/runtime/safety.mjs +72 -0
- package/lib/shared/paths.mjs +155 -0
- package/lib/shared/primitives.mjs +43 -0
- package/lib/transport/http_json.mjs +96 -0
- package/lib/transport/libp2p.mjs +397 -0
- package/lib/transport/peer_session.mjs +857 -0
- package/lib/transport/relay_http.mjs +110 -0
- package/package.json +53 -0
|
@@ -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
|
+
}
|