@swarmclawai/swarmclaw 0.2.0 → 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.
package/README.md CHANGED
@@ -206,7 +206,7 @@ To connect an agent to an OpenClaw gateway:
206
206
  2. Toggle **OpenClaw Gateway** ON
207
207
  3. Enter the gateway URL (e.g. `http://192.168.1.50:18789` or `https://my-vps:18789`)
208
208
  4. Add a gateway token if authentication is enabled on the remote gateway
209
- 5. Click **Test & Save** to verify the connection
209
+ 5. Click **Connect** approve the device in your gateway's dashboard if prompted, then **Retry Connection**
210
210
 
211
211
  Each agent can point to a **different** OpenClaw gateway — one local, several remote. This is how you manage a **swarm of OpenClaws** from a single dashboard.
212
212
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
5
5
  "license": "MIT",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
@@ -1,8 +1,6 @@
1
1
  import { NextResponse } from 'next/server'
2
- import { WebSocket } from 'ws'
3
- import { randomUUID } from 'crypto'
4
2
  import { loadCredentials, decryptKey } from '@/lib/server/storage'
5
- import { buildOpenClawConnectParams, getDeviceId } from '@/lib/providers/openclaw'
3
+ import { getDeviceId, wsConnect } from '@/lib/providers/openclaw'
6
4
 
7
5
  type SetupProvider =
8
6
  | 'openai'
@@ -159,103 +157,16 @@ async function checkOpenClaw(apiKey: string, endpointRaw: string): Promise<{ ok:
159
157
  const token = apiKey || undefined
160
158
  const deviceId = getDeviceId()
161
159
 
162
- // Always connect with device auth the gateway may accept token-only for
163
- // the connect handshake but still require device identity for agent ops.
164
- const result = await attemptOpenClawConnect(wsUrl, token, true)
160
+ const result = await wsConnect(wsUrl, token, true, 10_000)
161
+ // Close the WebSocket immediately we only care about the handshake result
162
+ if (result.ws) try { result.ws.close() } catch {}
163
+
165
164
  if (result.ok) {
166
165
  return { ok: true, message: 'Connected to OpenClaw gateway.', normalizedEndpoint, deviceId }
167
166
  }
168
- if (result.errorCode === 'PAIRING_REQUIRED') {
169
- return {
170
- ok: false,
171
- errorCode: 'PAIRING_REQUIRED',
172
- message: 'Device needs approval on your gateway.',
173
- normalizedEndpoint,
174
- deviceId,
175
- }
176
- }
177
- if (result.errorCode === 'DEVICE_AUTH_INVALID') {
178
- return {
179
- ok: false,
180
- errorCode: 'DEVICE_AUTH_INVALID',
181
- message: 'Device signature rejected. Your gateway may be running a different protocol version.',
182
- normalizedEndpoint,
183
- deviceId,
184
- }
185
- }
186
167
  return { ok: false, message: result.message, normalizedEndpoint, deviceId, errorCode: result.errorCode }
187
168
  }
188
169
 
189
- function attemptOpenClawConnect(
190
- wsUrl: string,
191
- token: string | undefined,
192
- useDeviceAuth: boolean,
193
- ): Promise<{ ok: boolean; message: string; errorCode?: string }> {
194
- return new Promise((resolve) => {
195
- let settled = false
196
- const done = (result: { ok: boolean; message: string; errorCode?: string }) => {
197
- if (settled) return
198
- settled = true
199
- clearTimeout(timer)
200
- try { ws.close() } catch {}
201
- resolve(result)
202
- }
203
-
204
- const timer = setTimeout(() => {
205
- done({ ok: false, message: 'Connection timed out. Verify the gateway URL and network access.' })
206
- }, 10_000)
207
-
208
- const ws = new WebSocket(wsUrl)
209
- let connectId: string | null = null
210
-
211
- ws.on('message', (data) => {
212
- try {
213
- const msg = JSON.parse(data.toString())
214
- if (msg.event === 'connect.challenge') {
215
- connectId = randomUUID()
216
- ws.send(JSON.stringify({
217
- type: 'req',
218
- id: connectId,
219
- method: 'connect',
220
- params: buildOpenClawConnectParams(token, msg.payload?.nonce, { useDeviceAuth }),
221
- }))
222
- return
223
- }
224
- if (msg.type === 'res' && msg.id === connectId) {
225
- if (msg.ok) {
226
- done({ ok: true, message: 'Connected.' })
227
- } else {
228
- const message = msg.error?.message || 'Gateway rejected the connection.'
229
- // Extract error code from structured field or infer from message text
230
- let errorCode = (msg.error?.details?.code ?? msg.error?.code) as string | undefined
231
- if (!errorCode) {
232
- const m = message.toLowerCase()
233
- if (m.includes('pairing') || m.includes('not paired') || m.includes('pending approval')) errorCode = 'PAIRING_REQUIRED'
234
- else if (m.includes('signature') || m.includes('device') || m.includes('identity')) errorCode = 'DEVICE_AUTH_INVALID'
235
- }
236
- done({ ok: false, message, errorCode })
237
- }
238
- return
239
- }
240
- } catch {
241
- done({ ok: false, message: 'Unexpected response from gateway.' })
242
- }
243
- })
244
-
245
- ws.on('error', (err) => {
246
- done({ ok: false, message: `Connection failed: ${err.message}` })
247
- })
248
-
249
- ws.on('close', (code, reason) => {
250
- if (code === 1008) {
251
- done({ ok: false, message: `Unauthorized: ${reason?.toString() || 'invalid token'}` })
252
- } else {
253
- done({ ok: false, message: `Connection closed (${code})` })
254
- }
255
- })
256
- })
257
- }
258
-
259
170
  export async function POST(req: Request) {
260
171
  const body = parseBody(await req.json().catch(() => ({})))
261
172
  const provider = clean(body.provider) as SetupProvider
@@ -0,0 +1,11 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { getDeviceId } from '@/lib/providers/openclaw'
3
+
4
+ export async function GET() {
5
+ try {
6
+ const deviceId = getDeviceId()
7
+ return NextResponse.json({ deviceId })
8
+ } catch (err: any) {
9
+ return NextResponse.json({ deviceId: null, error: err?.message }, { status: 500 })
10
+ }
11
+ }
@@ -101,6 +101,8 @@ export function AgentSheet() {
101
101
  const [testMessage, setTestMessage] = useState('')
102
102
  const [testErrorCode, setTestErrorCode] = useState<string | null>(null)
103
103
  const [testDeviceId, setTestDeviceId] = useState<string | null>(null)
104
+ const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
105
+ const [configCopied, setConfigCopied] = useState(false)
104
106
 
105
107
  const soulFileRef = useRef<HTMLInputElement>(null)
106
108
  const promptFileRef = useRef<HTMLInputElement>(null)
@@ -234,6 +236,16 @@ export function AgentSheet() {
234
236
  // eslint-disable-next-line react-hooks/exhaustive-deps
235
237
  }, [mcpServerIds.join(',')])
236
238
 
239
+ // Fetch OpenClaw device ID when toggle is enabled
240
+ useEffect(() => {
241
+ if (!openclawEnabled) return
242
+ let cancelled = false
243
+ api<{ deviceId: string }>('GET', '/setup/openclaw-device').then((res) => {
244
+ if (!cancelled && res.deviceId) setOpenclawDeviceId(res.deviceId)
245
+ }).catch(() => {})
246
+ return () => { cancelled = true }
247
+ }, [openclawEnabled])
248
+
237
249
  const handleGenerate = async () => {
238
250
  if (!aiPrompt.trim()) return
239
251
  setGenerating(true)
@@ -380,8 +392,10 @@ export function AgentSheet() {
380
392
  if (needsTest) {
381
393
  const passed = await handleTestConnection()
382
394
  if (!passed) return
383
- // Brief pause so the user can see the success state on the button
384
- await new Promise((r) => setTimeout(r, 1500))
395
+ if (!openclawEnabled) {
396
+ // Brief pause so the user can see the success state on the button
397
+ await new Promise((r) => setTimeout(r, 1500))
398
+ }
385
399
  }
386
400
  setSaving(true)
387
401
  await handleSave()
@@ -400,15 +414,44 @@ export function AgentSheet() {
400
414
 
401
415
  return (
402
416
  <BottomSheet open={open} onClose={onClose} wide>
403
- <div className="mb-10">
404
- <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
405
- {editing ? 'Edit Agent' : 'New Agent'}
406
- </h2>
407
- <p className="text-[14px] text-text-3">Define an AI agent or orchestrator</p>
417
+ <div className="mb-10 flex items-start justify-between">
418
+ <div>
419
+ <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
420
+ {editing ? 'Edit Agent' : 'New Agent'}
421
+ </h2>
422
+ <p className="text-[14px] text-text-3">Define an AI agent or orchestrator</p>
423
+ </div>
424
+ <div className="flex items-center gap-3 mt-1.5">
425
+ <label className="text-[11px] font-600 text-text-3 uppercase tracking-[0.08em]">OpenClaw</label>
426
+ <button
427
+ type="button"
428
+ onClick={() => {
429
+ if (!openclawEnabled) {
430
+ setOpenclawEnabled(true)
431
+ setProvider('openclaw')
432
+ setModel('default')
433
+ if (!apiEndpoint) setApiEndpoint('http://localhost:18789')
434
+ } else {
435
+ setOpenclawEnabled(false)
436
+ const first = providers[0]?.id || 'claude-cli'
437
+ setProvider(first)
438
+ setModel('')
439
+ setApiEndpoint(null)
440
+ setCredentialId(null)
441
+ setTestStatus('idle')
442
+ setTestMessage('')
443
+ setTestErrorCode(null)
444
+ }
445
+ }}
446
+ className={`relative w-11 h-6 rounded-full transition-colors duration-200 cursor-pointer border-none ${openclawEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
447
+ >
448
+ <span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition-transform duration-200 ${openclawEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
449
+ </button>
450
+ </div>
408
451
  </div>
409
452
 
410
453
  {/* AI Generation */}
411
- {!editing && <AiGenBlock
454
+ {!editing && !openclawEnabled && <AiGenBlock
412
455
  aiPrompt={aiPrompt} setAiPrompt={setAiPrompt}
413
456
  generating={generating} generated={generated} genError={genError}
414
457
  onGenerate={handleGenerate} appSettings={appSettings}
@@ -425,8 +468,8 @@ export function AgentSheet() {
425
468
  <input type="text" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What does this agent do?" className={inputClass} style={{ fontFamily: 'inherit' }} />
426
469
  </div>
427
470
 
428
- {/* Capabilities */}
429
- <div className="mb-8">
471
+ {/* Capabilities — hidden for OpenClaw (gateway manages its own capabilities) */}
472
+ {!openclawEnabled && <div className="mb-8">
430
473
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">
431
474
  Capabilities <span className="normal-case tracking-normal font-normal text-text-3">(for agent delegation)</span>
432
475
  </label>
@@ -467,7 +510,7 @@ export function AgentSheet() {
467
510
  />
468
511
  </div>
469
512
  <p className="text-[11px] text-text-3/70 mt-1.5">Press Enter or comma to add. Other agents see these when deciding delegation.</p>
470
- </div>
513
+ </div>}
471
514
 
472
515
  {provider !== 'openclaw' && (
473
516
  <div className="mb-8">
@@ -508,35 +551,13 @@ export function AgentSheet() {
508
551
  </div>
509
552
  )}
510
553
 
511
- {/* OpenClaw Gateway Toggle */}
512
- <div className="mb-8">
513
- <div className="flex items-center justify-between">
514
- <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em]">OpenClaw Gateway</label>
515
- <button
516
- type="button"
517
- onClick={() => {
518
- if (!openclawEnabled) {
519
- setOpenclawEnabled(true)
520
- setProvider('openclaw')
521
- setModel('default')
522
- if (!apiEndpoint) setApiEndpoint('http://localhost:18789')
523
- } else {
524
- setOpenclawEnabled(false)
525
- setProvider('claude-cli')
526
- setModel('')
527
- setApiEndpoint(null)
528
- setCredentialId(null)
529
- }
530
- }}
531
- className={`relative w-11 h-6 rounded-full transition-colors duration-200 cursor-pointer border-none ${openclawEnabled ? 'bg-accent-bright' : 'bg-white/[0.12]'}`}
532
- >
533
- <span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white transition-transform duration-200 ${openclawEnabled ? 'translate-x-5' : 'translate-x-0'}`} />
534
- </button>
535
- </div>
536
- {openclawEnabled && (
537
- <div className="mt-4 space-y-4">
554
+ {/* OpenClaw Gateway Fields */}
555
+ {openclawEnabled && (
556
+ <div className="mb-8 space-y-5">
557
+ {/* Connection fields */}
558
+ <div className="space-y-4">
538
559
  <div>
539
- <label className="block text-[12px] text-text-3 mb-2">Gateway URL</label>
560
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Gateway URL</label>
540
561
  <input
541
562
  type="text"
542
563
  value={apiEndpoint || ''}
@@ -547,7 +568,7 @@ export function AgentSheet() {
547
568
  />
548
569
  </div>
549
570
  <div>
550
- <label className="block text-[12px] text-text-3 mb-2">Gateway Token</label>
571
+ <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">Gateway Token</label>
551
572
  {openclawCredentials.length > 0 && !addingKey ? (
552
573
  <div className="flex gap-2">
553
574
  <select value={credentialId || ''} onChange={(e) => {
@@ -559,7 +580,7 @@ export function AgentSheet() {
559
580
  setCredentialId(e.target.value || null)
560
581
  }
561
582
  }} className={`${inputClass} appearance-none cursor-pointer flex-1`} style={{ fontFamily: 'inherit' }}>
562
- <option value="">Select a token...</option>
583
+ <option value="">No token (auth disabled)</option>
563
584
  {openclawCredentials.map((c) => (
564
585
  <option key={c.id} value={c.id}>{c.name}</option>
565
586
  ))}
@@ -574,12 +595,12 @@ export function AgentSheet() {
574
595
  </button>
575
596
  </div>
576
597
  ) : (
577
- <div className="space-y-3 p-4 rounded-[12px] border border-accent-bright/15 bg-accent-soft/20">
598
+ <div className="space-y-3 p-4 rounded-[12px] border border-accent-bright/15 bg-accent-soft/10">
578
599
  <input
579
600
  type="text"
580
601
  value={newKeyName}
581
602
  onChange={(e) => setNewKeyName(e.target.value)}
582
- placeholder="Token name (optional)"
603
+ placeholder="Label (e.g. Local gateway)"
583
604
  className={inputClass}
584
605
  style={{ fontFamily: 'inherit' }}
585
606
  />
@@ -619,85 +640,108 @@ export function AgentSheet() {
619
640
  </div>
620
641
  )}
621
642
  </div>
622
- <p className="text-[11px] text-text-3/70">Enter the URL and token for your local or remote OpenClaw gateway.</p>
623
- {/* Insecure connection warning */}
624
- {(() => {
625
- const url = (apiEndpoint || '').trim().toLowerCase()
626
- const isRemote = url && !/localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/i.test(url)
627
- const isSecure = /^(https|wss):\/\//i.test(url)
628
- if (isRemote && !isSecure) return (
629
- <div className="mt-3 p-3 rounded-[10px] bg-[#fbbf24]/10 border border-[#fbbf24]/30">
630
- <p className="text-[11px] text-[#fbbf24] font-500 leading-[1.5]">
631
- This connection is not encrypted. Credentials and chat data could be intercepted on the network.
632
- For production use, put your gateway behind HTTPS (e.g. Caddy, nginx) or use an SSH tunnel.
633
- </p>
634
- </div>
635
- )
636
- return null
637
- })()}
638
- {/* OpenClaw troubleshooting — shown on any test failure */}
639
- {testStatus === 'fail' && openclawEnabled && (
640
- <div className="mt-3 p-4 rounded-[12px] bg-accent-soft/30 border border-accent-bright/20">
641
- {testErrorCode === 'PAIRING_REQUIRED' ? (
642
- <div className="space-y-2">
643
- <p className="text-[12px] text-accent-bright font-600 mb-1">Device Pairing Required</p>
644
- <p className="text-[11px] text-text-3 leading-[1.6]">
645
- Your gateway needs to approve this SwarmClaw instance before it can connect. Follow these steps:
646
- </p>
647
- <ol className="text-[11px] text-text-3 leading-[1.8] list-decimal list-inside space-y-1">
648
- <li>Open your OpenClaw control UI at <span className="text-accent-bright font-500">{apiEndpoint || 'http://localhost:18789'}</span></li>
649
- <li>Go to <span className="text-text-2 font-500">Devices</span></li>
650
- <li>Find and approve the pending device {testDeviceId ? <span className="text-text-2 font-mono text-[10px]">({testDeviceId.slice(0, 12)}...)</span> : null}</li>
651
- <li>Come back here and click <span className="text-text-2 font-500">Test &amp; Save</span> again</li>
652
- </ol>
653
- </div>
654
- ) : testErrorCode === 'DEVICE_AUTH_INVALID' ? (
655
- <div className="space-y-2">
656
- <p className="text-[12px] text-accent-bright font-600 mb-1">Device Authentication Failed</p>
657
- <p className="text-[11px] text-text-3 leading-[1.6]">
658
- The gateway rejected this device&apos;s signature. This usually means it needs to be paired first, or there&apos;s a protocol mismatch.
659
- </p>
660
- <p className="text-[11px] text-text-3 font-500 mt-2 mb-1">Try these steps:</p>
661
- <ol className="text-[11px] text-text-3 leading-[1.8] list-decimal list-inside space-y-1">
662
- <li>Open your OpenClaw control UI at <span className="text-accent-bright font-500">{apiEndpoint || 'http://localhost:18789'}</span></li>
663
- <li>Go to <span className="text-text-2 font-500">Devices</span> and look for a pending device request</li>
664
- <li>If the device is listed, approve it and click <span className="text-text-2 font-500">Test &amp; Save</span> again</li>
665
- <li>If not listed, update your gateway to the latest version and restart it</li>
666
- </ol>
667
- </div>
668
- ) : (
669
- <div className="space-y-2">
670
- <p className="text-[12px] text-accent-bright font-600 mb-1">Connection Failed</p>
671
- <p className="text-[11px] text-text-3 leading-[1.6]">
672
- Could not connect to the OpenClaw gateway. Check the following:
673
- </p>
674
- <ul className="text-[11px] text-text-3 leading-[1.8] list-disc list-inside space-y-1">
675
- <li>The gateway is running and reachable at the URL above</li>
676
- <li>The gateway token matches exactly (if required)</li>
677
- <li>No firewall is blocking the connection</li>
678
- </ul>
679
- </div>
680
- )}
681
- {testDeviceId && (
682
- <div className="mt-3 pt-3 border-t border-white/[0.06]">
683
- <p className="text-[10px] text-text-3/60">
684
- SwarmClaw Device ID: <span className="font-mono text-text-3/80 select-all">{testDeviceId}</span>
685
- </p>
686
- </div>
687
- )}
688
- </div>
689
- )}
690
- {/* Device ID info — shown after any successful OpenClaw test */}
691
- {testStatus === 'pass' && openclawEnabled && testDeviceId && (
692
- <div className="mt-3 px-3 py-2 rounded-[10px] bg-emerald-500/[0.06] border border-emerald-500/15">
693
- <p className="text-[10px] text-text-3/60">
694
- Device ID: <span className="font-mono text-emerald-400/70 select-all">{testDeviceId.slice(0, 16)}...</span>
643
+ </div>
644
+
645
+ {/* Insecure connection warning */}
646
+ {(() => {
647
+ const url = (apiEndpoint || '').trim().toLowerCase()
648
+ const isRemote = url && !/localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]/i.test(url)
649
+ const isSecure = /^(https|wss):\/\//i.test(url)
650
+ if (isRemote && !isSecure) return (
651
+ <div className="px-3 py-2.5 rounded-[10px] bg-[#fbbf24]/[0.06] border border-[#fbbf24]/20">
652
+ <p className="text-[13px] text-[#fbbf24] leading-[1.5]">
653
+ Unencrypted connection. Use HTTPS or an SSH tunnel for production.
695
654
  </p>
696
655
  </div>
697
- )}
698
- </div>
699
- )}
700
- </div>
656
+ )
657
+ return null
658
+ })()}
659
+
660
+ {/* Status feedback — single unified block */}
661
+ {testStatus === 'pass' && (
662
+ <div className="p-4 rounded-[12px] bg-emerald-500/[0.06] border border-emerald-500/15 space-y-2">
663
+ <div className="flex items-center gap-2">
664
+ <span className="w-2 h-2 rounded-full bg-emerald-400" />
665
+ <p className="text-[14px] text-emerald-400 font-600">Connected</p>
666
+ </div>
667
+ <p className="text-[13px] text-text-2/80 leading-[1.6]">Gateway is reachable and this device is paired. Tools and models are managed by the OpenClaw instance.</p>
668
+ </div>
669
+ )}
670
+ {testStatus === 'fail' && (
671
+ <div className="p-4 rounded-[12px] border space-y-3"
672
+ style={{
673
+ background: testErrorCode === 'PAIRING_REQUIRED' ? 'rgba(34,197,94,0.04)' : 'rgba(var(--accent-bright-rgb,120,100,255),0.06)',
674
+ borderColor: testErrorCode === 'PAIRING_REQUIRED' ? 'rgba(34,197,94,0.2)' : 'rgba(var(--accent-bright-rgb,120,100,255),0.15)',
675
+ }}
676
+ >
677
+ {testErrorCode === 'PAIRING_REQUIRED' ? (<>
678
+ <div className="flex items-center gap-2">
679
+ <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
680
+ <p className="text-[14px] text-[#22c55e] font-600">Awaiting Approval</p>
681
+ </div>
682
+ <p className="text-[13px] text-text-2/80 leading-[1.6]">
683
+ This device is pending approval on your gateway. Go to <span className="text-text-2 font-500">Nodes</span>, approve the device{(testDeviceId || openclawDeviceId) ? <> (<code className="text-[12px] font-mono text-text-2/70">{(testDeviceId || openclawDeviceId)!.slice(0, 12)}...</code>)</> : null}, then click <span className="text-text-2 font-500">Retry Connection</span>.
684
+ </p>
685
+ <a
686
+ href={(() => { const ep = (apiEndpoint || 'http://localhost:18789').replace(/\/+$/, ''); return /^https?:\/\//i.test(ep) ? ep : `http://${ep}` })()}
687
+ target="_blank"
688
+ rel="noopener noreferrer"
689
+ className="inline-flex items-center gap-1.5 mt-2 px-4 py-2 rounded-[10px] bg-white/[0.06] border border-white/[0.1] text-[13px] text-text-2 font-500 hover:bg-white/[0.1] transition-colors"
690
+ >
691
+ Approve in Dashboard →
692
+ </a>
693
+ </>) : testErrorCode === 'DEVICE_AUTH_INVALID' ? (<>
694
+ <p className="text-[14px] text-accent-bright font-600">Device Not Paired</p>
695
+ <p className="text-[13px] text-text-2/80 leading-[1.6]">
696
+ The gateway doesn&apos;t recognize this device. Go to <span className="text-text-2 font-500">Nodes</span>, and add or approve this device{(testDeviceId || openclawDeviceId) ? <> (<code className="text-[12px] font-mono text-text-2/70">{(testDeviceId || openclawDeviceId)!.slice(0, 12)}...</code>)</> : null}.
697
+ </p>
698
+ <a
699
+ href={(() => { const ep = (apiEndpoint || 'http://localhost:18789').replace(/\/+$/, ''); return /^https?:\/\//i.test(ep) ? ep : `http://${ep}` })()}
700
+ target="_blank"
701
+ rel="noopener noreferrer"
702
+ className="inline-flex items-center gap-1.5 mt-2 px-4 py-2 rounded-[10px] bg-white/[0.06] border border-white/[0.1] text-[13px] text-text-2 font-500 hover:bg-white/[0.1] transition-colors"
703
+ >
704
+ Approve in Dashboard →
705
+ </a>
706
+ </>) : testErrorCode === 'AUTH_TOKEN_MISSING' ? (<>
707
+ <p className="text-[14px] text-accent-bright font-600">Token Required</p>
708
+ <p className="text-[13px] text-text-2/80 leading-[1.6]">
709
+ This gateway requires an auth token. Add one above and try again.
710
+ </p>
711
+ </>) : testErrorCode === 'AUTH_TOKEN_INVALID' ? (<>
712
+ <p className="text-[14px] text-accent-bright font-600">Invalid Token</p>
713
+ <p className="text-[13px] text-text-2/80 leading-[1.6]">
714
+ The gateway rejected this token. Check that it matches the one configured on your OpenClaw instance.
715
+ </p>
716
+ </>) : (<>
717
+ <p className="text-[14px] text-accent-bright font-600">Connection Failed</p>
718
+ <p className="text-[13px] text-text-2/80 leading-[1.6]">
719
+ {testMessage || 'Could not reach the gateway. Check the URL, token, and that the gateway is running.'}
720
+ </p>
721
+ </>)}
722
+ {/* Device ID footer — always shown on failure for debugging */}
723
+ {(testDeviceId || openclawDeviceId) && testErrorCode !== 'AUTH_TOKEN_MISSING' && testErrorCode !== 'AUTH_TOKEN_INVALID' && (
724
+ <div className="pt-2 border-t border-white/[0.04]">
725
+ <p className="text-[12px] text-text-3/70 flex items-center gap-1.5">
726
+ Device <code className="font-mono text-text-2/70 select-all">{(testDeviceId || openclawDeviceId)}</code>
727
+ <button
728
+ type="button"
729
+ onClick={() => {
730
+ navigator.clipboard.writeText((testDeviceId || openclawDeviceId)!)
731
+ setConfigCopied(true)
732
+ setTimeout(() => setConfigCopied(false), 2000)
733
+ }}
734
+ className="text-[12px] text-text-3/60 hover:text-text-3/80 transition-colors cursor-pointer bg-transparent border-none"
735
+ >
736
+ {configCopied ? 'copied' : 'copy'}
737
+ </button>
738
+ </p>
739
+ </div>
740
+ )}
741
+ </div>
742
+ )}
743
+ </div>
744
+ )}
701
745
 
702
746
  {!openclawEnabled && <div className="mb-8">
703
747
  <label className="block font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-3">Provider</label>
@@ -883,7 +927,7 @@ export function AgentSheet() {
883
927
  </label>
884
928
  <input type="text" value={apiEndpoint || ''} onChange={(e) => setApiEndpoint(e.target.value || null)} placeholder={currentProvider.defaultEndpoint || 'http://localhost:11434'} className={`${inputClass} font-mono text-[14px]`} />
885
929
  {provider === 'openclaw' && (
886
- <p className="text-[11px] text-text-3/60 mt-2">The /v1 endpoint of your remote OpenClaw instance</p>
930
+ <p className="text-[13px] text-text-3/70 mt-2">The URL of your OpenClaw gateway</p>
887
931
  )}
888
932
  </div>
889
933
  )}
@@ -952,17 +996,15 @@ export function AgentSheet() {
952
996
  </div>
953
997
  )}
954
998
 
955
- {/* Native capability provider note */}
956
- {hasNativeCapabilities && (
999
+ {/* Native capability provider note — not shown for OpenClaw (covered in connection status) */}
1000
+ {hasNativeCapabilities && !openclawEnabled && (
957
1001
  <div className="mb-8 p-4 rounded-[14px] bg-white/[0.02] border border-white/[0.06]">
958
1002
  <p className="text-[13px] text-text-3">
959
- {provider === 'openclaw'
960
- ? 'OpenClaw manages tools/platform capabilities in the remote OpenClaw instance — no local tool toggles are applied here.'
961
- : provider === 'claude-cli'
962
- ? 'Claude CLI uses its own built-in capabilities — no additional local tool/platform configuration is needed.'
963
- : provider === 'codex-cli'
964
- ? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'
965
- : 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'}
1003
+ {provider === 'claude-cli'
1004
+ ? 'Claude CLI uses its own built-in capabilities — no additional local tool/platform configuration is needed.'
1005
+ : provider === 'codex-cli'
1006
+ ? 'OpenAI Codex CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'
1007
+ : 'OpenCode CLI uses its own built-in tools (shell, files, etc.) — no additional local tool configuration needed.'}
966
1008
  </p>
967
1009
  </div>
968
1010
  )}
@@ -1171,13 +1213,13 @@ export function AgentSheet() {
1171
1213
  </div>
1172
1214
  )}
1173
1215
 
1174
- {/* Test connection result */}
1175
- {testStatus === 'fail' && (
1216
+ {/* Test connection result (hidden for OpenClaw — inline status block handles it) */}
1217
+ {!openclawEnabled && testStatus === 'fail' && (
1176
1218
  <div className="mb-4 p-3 rounded-[12px] bg-red-500/[0.08] border border-red-500/20">
1177
1219
  <p className="text-[13px] text-red-400">{testMessage || 'Connection test failed'}</p>
1178
1220
  </div>
1179
1221
  )}
1180
- {testStatus === 'pass' && (
1222
+ {!openclawEnabled && testStatus === 'pass' && (
1181
1223
  <div className="mb-4 p-3 rounded-[12px] bg-emerald-500/[0.08] border border-emerald-500/20">
1182
1224
  <p className="text-[13px] text-emerald-400">{testMessage || 'Connected successfully'}</p>
1183
1225
  </div>
@@ -1207,12 +1249,18 @@ export function AgentSheet() {
1207
1249
  </button>
1208
1250
  <button
1209
1251
  onClick={handleTestAndSave}
1210
- disabled={!name.trim() || providerNeedsKey || testStatus === 'testing' || testStatus === 'pass' || saving}
1252
+ disabled={!name.trim() || providerNeedsKey || testStatus === 'testing' || saving || (!openclawEnabled && testStatus === 'pass')}
1211
1253
  className={`flex-1 py-3.5 rounded-[14px] border-none text-white text-[15px] font-600 cursor-pointer active:scale-[0.97] disabled:opacity-60 transition-all hover:brightness-110
1212
1254
  ${testStatus === 'pass' ? 'bg-emerald-600 shadow-[0_4px_20px_rgba(16,185,129,0.25)]' : 'bg-[#6366F1] shadow-[0_4px_20px_rgba(99,102,241,0.25)]'}`}
1213
1255
  style={{ fontFamily: 'inherit' }}
1214
1256
  >
1215
- {testStatus === 'testing' ? 'Testing...' : testStatus === 'pass' ? (saving ? 'Saving...' : 'Connected!') : needsTest ? 'Test & Save' : editing ? 'Save' : 'Create'}
1257
+ {openclawEnabled
1258
+ ? (testStatus === 'testing' ? 'Connecting...'
1259
+ : testStatus === 'pass' ? (saving ? 'Saving...' : 'Save')
1260
+ : testStatus === 'fail' && testErrorCode === 'PAIRING_REQUIRED' ? 'Retry Connection'
1261
+ : testStatus === 'fail' ? 'Retry'
1262
+ : 'Connect')
1263
+ : (testStatus === 'testing' ? 'Testing...' : testStatus === 'pass' ? (saving ? 'Saving...' : 'Connected!') : needsTest ? 'Test & Save' : editing ? 'Save' : 'Create')}
1216
1264
  </button>
1217
1265
  </div>
1218
1266
  </BottomSheet>
@@ -65,7 +65,8 @@ const PROVIDERS: Record<string, BuiltinProviderConfig> = {
65
65
  id: 'openclaw',
66
66
  name: 'OpenClaw',
67
67
  models: ['default'],
68
- requiresApiKey: true,
68
+ requiresApiKey: false,
69
+ optionalApiKey: true,
69
70
  requiresEndpoint: true,
70
71
  defaultEndpoint: 'http://localhost:18789',
71
72
  handler: { streamChat: streamOpenClawChat },
@@ -30,23 +30,50 @@ interface DeviceIdentity {
30
30
  privateKeyPem: string
31
31
  }
32
32
 
33
- function getIdentityPath(): string {
33
+ /** Resolve the openclaw CLI's state directory (~/.openclaw by default). */
34
+ function resolveCliStateDir(): string {
35
+ const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim()
36
+ if (override) return path.resolve(override.replace(/^~/, process.env.HOME || ''))
37
+ const home = process.env.HOME || process.env.USERPROFILE || ''
38
+ // Check new path first, then legacy
39
+ const newDir = path.join(home, '.openclaw')
40
+ if (fs.existsSync(newDir)) return newDir
41
+ const legacyDir = path.join(home, '.clawdbot')
42
+ if (fs.existsSync(legacyDir)) return legacyDir
43
+ return newDir
44
+ }
45
+
46
+ function getSwarmClawIdentityPath(): string {
34
47
  const dataDir = path.join(process.cwd(), 'data')
35
48
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true })
36
49
  return path.join(dataDir, 'openclaw-device.json')
37
50
  }
38
51
 
39
- function loadOrCreateDeviceIdentity(): DeviceIdentity {
40
- const filePath = getIdentityPath()
52
+ function tryLoadIdentityFile(filePath: string): DeviceIdentity | null {
41
53
  try {
42
- if (fs.existsSync(filePath)) {
43
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
44
- if (parsed?.deviceId && parsed?.publicKeyPem && parsed?.privateKeyPem) {
45
- return parsed
46
- }
54
+ if (!fs.existsSync(filePath)) return null
55
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'))
56
+ if (parsed?.publicKeyPem && parsed?.privateKeyPem) {
57
+ // Re-derive deviceId from public key (matches CLI behavior)
58
+ const derivedId = fingerprintPublicKey(parsed.publicKeyPem)
59
+ return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem }
47
60
  }
48
61
  } catch {}
62
+ return null
63
+ }
49
64
 
65
+ function loadOrCreateDeviceIdentity(): DeviceIdentity {
66
+ // 1. Prefer the openclaw CLI's identity — it's likely already paired with the gateway
67
+ const cliIdentityPath = path.join(resolveCliStateDir(), 'identity', 'device.json')
68
+ const cliIdentity = tryLoadIdentityFile(cliIdentityPath)
69
+ if (cliIdentity) return cliIdentity
70
+
71
+ // 2. Fall back to SwarmClaw's own identity
72
+ const swarmClawPath = getSwarmClawIdentityPath()
73
+ const existing = tryLoadIdentityFile(swarmClawPath)
74
+ if (existing) return existing
75
+
76
+ // 3. Generate a new identity
50
77
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
51
78
  const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }) as string
52
79
  const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
@@ -55,7 +82,7 @@ function loadOrCreateDeviceIdentity(): DeviceIdentity {
55
82
  publicKeyPem,
56
83
  privateKeyPem,
57
84
  }
58
- fs.writeFileSync(filePath, JSON.stringify({ version: 1, ...identity }, null, 2) + '\n', { mode: 0o600 })
85
+ fs.writeFileSync(swarmClawPath, JSON.stringify({ version: 1, ...identity }, null, 2) + '\n', { mode: 0o600 })
59
86
  return identity
60
87
  }
61
88
 
@@ -67,7 +94,7 @@ export function getDeviceId(): string {
67
94
  // --- Protocol helpers ---
68
95
 
69
96
  function normalizeWsUrl(raw: string): string {
70
- let url = raw.replace(/\/+$/, '').replace(/\/v1$/i, '')
97
+ let url = raw.replace(/\/+$/, '')
71
98
  if (!/^(https?|wss?):\/\//i.test(url)) url = `http://${url}`
72
99
  url = url.replace(/^ws:/i, 'http:').replace(/^wss:/i, 'https:')
73
100
  return url.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:')
@@ -92,7 +119,7 @@ export function buildOpenClawConnectParams(
92
119
  const scopes = ['operator.admin']
93
120
 
94
121
  const params: Record<string, unknown> = {
95
- minProtocol: 1,
122
+ minProtocol: 3,
96
123
  maxProtocol: 3,
97
124
  auth: token ? { token } : undefined,
98
125
  client: {
@@ -134,7 +161,7 @@ export function buildOpenClawConnectParams(
134
161
 
135
162
  // --- Gateway connection ---
136
163
 
137
- interface ConnectResult {
164
+ export interface ConnectResult {
138
165
  ok: boolean
139
166
  message: string
140
167
  errorCode?: string
@@ -145,7 +172,7 @@ interface ConnectResult {
145
172
  * Open a WebSocket and complete the connect handshake.
146
173
  * Resolves with { ok, ws } on success or { ok: false, message, errorCode } on failure.
147
174
  */
148
- function wsConnect(
175
+ export function wsConnect(
149
176
  wsUrl: string,
150
177
  token: string | undefined,
151
178
  useDeviceAuth: boolean,
@@ -185,11 +212,16 @@ function wsConnect(
185
212
  if (msg.ok) {
186
213
  done({ ok: true, message: 'Connected.', ws })
187
214
  } else {
188
- done({
189
- ok: false,
190
- message: msg.error?.message || 'Gateway connect failed.',
191
- errorCode: msg.error?.details?.code as string | undefined,
192
- })
215
+ const message = msg.error?.message || 'Gateway connect failed.'
216
+ let errorCode = (msg.error?.details?.code ?? msg.error?.code) as string | undefined
217
+ if (!errorCode) {
218
+ const m = message.toLowerCase()
219
+ if (m.includes('pairing') || m.includes('not paired') || m.includes('pending approval')) errorCode = 'PAIRING_REQUIRED'
220
+ else if (m.includes('signature') || m.includes('device auth')) errorCode = 'DEVICE_AUTH_INVALID'
221
+ else if (m.includes('token missing') || m.includes('token required')) errorCode = 'AUTH_TOKEN_MISSING'
222
+ else if (m.includes('unauthorized') || m.includes('invalid token')) errorCode = 'AUTH_TOKEN_INVALID'
223
+ }
224
+ done({ ok: false, message, errorCode })
193
225
  }
194
226
  }
195
227
  } catch {
@@ -202,8 +234,15 @@ function wsConnect(
202
234
  })
203
235
 
204
236
  ws.on('close', (code, reason) => {
237
+ const reasonStr = reason?.toString() || ''
205
238
  if (code === 1008) {
206
- done({ ok: false, message: `Unauthorized: ${reason?.toString() || 'invalid token'}` })
239
+ const m = reasonStr.toLowerCase()
240
+ let errorCode: string | undefined
241
+ if (m.includes('pairing') || m.includes('not paired') || m.includes('pending approval')) errorCode = 'PAIRING_REQUIRED'
242
+ else if (m.includes('signature') || m.includes('device auth') || m.includes('device identity') || m.includes('device nonce')) errorCode = 'DEVICE_AUTH_INVALID'
243
+ else if (m.includes('token missing') || m.includes('token required')) errorCode = 'AUTH_TOKEN_MISSING'
244
+ else if (m.includes('unauthorized') || m.includes('invalid token')) errorCode = 'AUTH_TOKEN_INVALID'
245
+ done({ ok: false, message: reasonStr || 'Unauthorized', errorCode })
207
246
  } else {
208
247
  done({ ok: false, message: `Connection closed unexpectedly (${code})` })
209
248
  }
@@ -228,14 +267,14 @@ async function connectToGateway(
228
267
 
229
268
  // --- Provider ---
230
269
 
231
- export function streamOpenClawChat({ session, message, imagePath, write, active }: StreamChatOptions): Promise<string> {
270
+ export function streamOpenClawChat({ session, message, imagePath, apiKey, write, active }: StreamChatOptions): Promise<string> {
232
271
  let prompt = message
233
272
  if (imagePath) {
234
273
  prompt = `[The user has shared an image at: ${imagePath}]\n\n${message}`
235
274
  }
236
275
 
237
276
  const wsUrl = session.apiEndpoint ? normalizeWsUrl(session.apiEndpoint) : 'ws://127.0.0.1:18789'
238
- const token = session.apiKey || undefined
277
+ const token = apiKey || session.apiKey || undefined
239
278
 
240
279
  return new Promise((resolve) => {
241
280
  let fullResponse = ''