@swarmclawai/swarmclaw 0.7.5 → 0.7.7

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.
Files changed (32) hide show
  1. package/README.md +41 -10
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +12 -1
  7. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  8. package/src/app/api/external-agents/[id]/route.ts +38 -6
  9. package/src/app/api/external-agents/route.ts +17 -1
  10. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  11. package/src/app/api/gateways/[id]/route.ts +53 -1
  12. package/src/app/api/gateways/route.ts +53 -0
  13. package/src/app/api/openclaw/deploy/route.ts +240 -0
  14. package/src/cli/index.js +53 -0
  15. package/src/cli/index.test.js +102 -0
  16. package/src/cli/spec.js +79 -0
  17. package/src/components/agents/agent-sheet.tsx +97 -19
  18. package/src/components/auth/setup-wizard.tsx +111 -54
  19. package/src/components/gateways/gateway-sheet.tsx +202 -10
  20. package/src/components/openclaw/openclaw-deploy-panel.tsx +1208 -0
  21. package/src/components/providers/provider-list.tsx +321 -22
  22. package/src/lib/server/agent-runtime-config.ts +142 -7
  23. package/src/lib/server/agent-thread-session.ts +9 -1
  24. package/src/lib/server/chat-execution.ts +8 -2
  25. package/src/lib/server/heartbeat-service.ts +5 -1
  26. package/src/lib/server/openclaw-deploy.test.ts +75 -0
  27. package/src/lib/server/openclaw-deploy.ts +1384 -0
  28. package/src/lib/server/orchestrator.ts +9 -0
  29. package/src/lib/server/queue.ts +45 -2
  30. package/src/lib/setup-defaults.ts +2 -2
  31. package/src/lib/validation/schemas.ts +9 -0
  32. package/src/types/index.ts +65 -0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useMemo, useState } from 'react'
4
4
  import { api } from '@/lib/api-client'
5
+ import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
5
6
  import { useAppStore } from '@/stores/use-app-store'
6
7
  import type { ProviderType, Credential, GatewayProfile } from '@/types'
7
8
  import {
@@ -53,6 +54,9 @@ interface ConfiguredProvider {
53
54
  endpoint: string | null
54
55
  defaultModel: string
55
56
  gatewayProfileId: string | null
57
+ notes?: string | null
58
+ tags?: string[]
59
+ deployment?: GatewayProfile['deployment'] | null
56
60
  }
57
61
 
58
62
  interface StarterDraftAgent {
@@ -87,6 +91,20 @@ const CONNECTOR_ICONS = [
87
91
  { name: 'Telegram', icon: 'T' },
88
92
  { name: 'WhatsApp', icon: 'W' },
89
93
  ]
94
+ const OPENCLAW_USE_CASE_LABELS: Record<NonNullable<NonNullable<GatewayProfile['deployment']>['useCase']>, string> = {
95
+ 'local-dev': 'Local Dev',
96
+ 'single-vps': 'Single VPS',
97
+ 'private-tailnet': 'Private Tailnet',
98
+ 'browser-heavy': 'Browser Heavy',
99
+ 'team-control': 'Team Control',
100
+ }
101
+ const OPENCLAW_EXPOSURE_LABELS: Record<NonNullable<NonNullable<GatewayProfile['deployment']>['exposure']>, string> = {
102
+ 'private-lan': 'Private LAN',
103
+ tailscale: 'Tailscale',
104
+ caddy: 'Caddy',
105
+ nginx: 'Nginx',
106
+ 'ssh-tunnel': 'SSH Tunnel',
107
+ }
90
108
 
91
109
  function stepIndex(step: SetupStep): number {
92
110
  if (step === 'connect') return STEP_ORDER.indexOf('providers')
@@ -142,12 +160,6 @@ function isLocalOpenClawEndpoint(value: string | null | undefined): boolean {
142
160
  return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '0.0.0.0'
143
161
  }
144
162
 
145
- function resolveOpenClawPort(value: string | null | undefined): number {
146
- const parsed = parseProviderUrl(value)
147
- const port = parsed ? Number(parsed.port) : NaN
148
- return Number.isFinite(port) && port > 0 ? port : 18789
149
- }
150
-
151
163
  function resolveOpenClawDashboardUrl(value: string | null | undefined): string {
152
164
  const parsed = parseProviderUrl(value)
153
165
  if (!parsed) return 'http://localhost:18789'
@@ -229,6 +241,12 @@ function ConfiguredProviderChips({ providers }: { providers: ConfiguredProvider[
229
241
  {cp.provider === 'openclaw' && formatEndpointHost(cp.endpoint)
230
242
  ? `· ${formatEndpointHost(cp.endpoint)}`
231
243
  : ''}
244
+ {cp.provider === 'openclaw' && cp.deployment?.useCase
245
+ ? ` · ${OPENCLAW_USE_CASE_LABELS[cp.deployment.useCase]}`
246
+ : ''}
247
+ {cp.provider === 'openclaw' && cp.deployment?.exposure
248
+ ? ` · ${OPENCLAW_EXPOSURE_LABELS[cp.deployment.exposure]}`
249
+ : ''}
232
250
  {cp.defaultModel ? ` · ${cp.defaultModel}` : ''}
233
251
  </span>
234
252
  </span>
@@ -337,12 +355,14 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
337
355
  const [endpoint, setEndpoint] = useState('')
338
356
  const [apiKey, setApiKey] = useState('')
339
357
  const [credentialId, setCredentialId] = useState<string | null>(null)
358
+ const [providerNotes, setProviderNotes] = useState('')
359
+ const [providerTags, setProviderTags] = useState<string[]>([])
360
+ const [providerDeployment, setProviderDeployment] = useState<GatewayProfile['deployment'] | null>(null)
340
361
  const [checkState, setCheckState] = useState<CheckState>('idle')
341
362
  const [checkMessage, setCheckMessage] = useState('')
342
363
  const [checkErrorCode, setCheckErrorCode] = useState<string | null>(null)
343
364
  const [openclawDeviceId, setOpenclawDeviceId] = useState<string | null>(null)
344
365
  const [providerSuggestedModel, setProviderSuggestedModel] = useState('')
345
- const [commandCopyState, setCommandCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
346
366
 
347
367
  const [doctorState, setDoctorState] = useState<'idle' | 'checking' | 'done' | 'error'>('idle')
348
368
  const [doctorError, setDoctorError] = useState('')
@@ -381,9 +401,6 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
381
401
  ? resolveOpenClawDashboardUrl(openClawEndpointValue)
382
402
  : null
383
403
  const openClawLocal = provider === 'openclaw' ? isLocalOpenClawEndpoint(openClawEndpointValue) : false
384
- const openClawPort = provider === 'openclaw' ? resolveOpenClawPort(openClawEndpointValue) : 18789
385
- const openClawLocalCommand = `npx openclaw gateway run --bind loopback --port ${openClawPort} --verbose`
386
- const openClawLocalCommandPnpm = `pnpm openclaw gateway run --bind loopback --port ${openClawPort} --verbose`
387
404
 
388
405
  const resetProviderForm = () => {
389
406
  setProvider(null)
@@ -391,12 +408,14 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
391
408
  setEndpoint('')
392
409
  setApiKey('')
393
410
  setCredentialId(null)
411
+ setProviderNotes('')
412
+ setProviderTags([])
413
+ setProviderDeployment(null)
394
414
  setCheckState('idle')
395
415
  setCheckMessage('')
396
416
  setCheckErrorCode(null)
397
417
  setOpenclawDeviceId(null)
398
418
  setProviderSuggestedModel('')
399
- setCommandCopyState('idle')
400
419
  setError('')
401
420
  }
402
421
 
@@ -442,16 +461,57 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
442
461
  setEndpoint(meta?.defaultEndpoint || '')
443
462
  setApiKey('')
444
463
  setCredentialId(null)
464
+ setProviderNotes('')
465
+ setProviderTags([])
466
+ setProviderDeployment(null)
445
467
  setCheckState('idle')
446
468
  setCheckMessage('')
447
469
  setCheckErrorCode(null)
448
470
  setOpenclawDeviceId(null)
449
471
  setProviderSuggestedModel(getDefaultModelForProvider(nextProvider))
450
- setCommandCopyState('idle')
451
472
  setError('')
452
473
  setStep('connect')
453
474
  }
454
475
 
476
+ const applyOpenClawDeployPatch = (patch: {
477
+ endpoint?: string
478
+ token?: string
479
+ name?: string
480
+ notes?: string
481
+ deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null
482
+ }) => {
483
+ if (patch.endpoint) {
484
+ setEndpoint(patch.endpoint)
485
+ }
486
+ if (patch.token) {
487
+ setApiKey(patch.token)
488
+ setCredentialId(null)
489
+ }
490
+ if (patch.name && (!providerLabel.trim() || providerLabel.trim() === (selectedProvider?.name || ''))) {
491
+ setProviderLabel(patch.name)
492
+ }
493
+ if (patch.notes) {
494
+ setProviderNotes(patch.notes)
495
+ }
496
+ if (patch.deployment) {
497
+ const nextDeployment = patch.deployment as GatewayProfile['deployment']
498
+ setProviderDeployment((current) => ({
499
+ ...(current || {}),
500
+ ...(nextDeployment || {}),
501
+ }))
502
+ setProviderTags((current) => Array.from(new Set([
503
+ ...current,
504
+ 'onboarding',
505
+ ...(nextDeployment?.useCase ? [nextDeployment.useCase] : []),
506
+ ...(nextDeployment?.exposure ? [nextDeployment.exposure] : []),
507
+ ])))
508
+ }
509
+ setCheckState('idle')
510
+ setCheckMessage('')
511
+ setCheckErrorCode(null)
512
+ setError('')
513
+ }
514
+
455
515
  const runConnectionCheck = async (): Promise<boolean> => {
456
516
  if (!provider || !selectedProvider) return false
457
517
  if (requiresKey && !apiKey.trim()) {
@@ -542,6 +602,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
542
602
  endpoint: supportsEndpoint ? (endpoint.trim() || selectedProvider.defaultEndpoint || null) : null,
543
603
  defaultModel: providerSuggestedModel || getDefaultModelForProvider(provider),
544
604
  gatewayProfileId: null,
605
+ notes: providerNotes.trim() || null,
606
+ tags: providerTags,
607
+ deployment: providerDeployment,
545
608
  }
546
609
 
547
610
  const nextConfigured = [...configuredProviders, configuredProvider]
@@ -603,17 +666,6 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
603
666
  }))
604
667
  }
605
668
 
606
- const copyOpenClawLocalCommand = async () => {
607
- try {
608
- await navigator.clipboard.writeText(openClawLocalCommand)
609
- setCommandCopyState('copied')
610
- window.setTimeout(() => setCommandCopyState('idle'), 1200)
611
- } catch {
612
- setCommandCopyState('failed')
613
- window.setTimeout(() => setCommandCopyState('idle'), 1800)
614
- }
615
- }
616
-
617
669
  const createAgentsAndFinish = async () => {
618
670
  const enabledDrafts = draftAgents.filter((draft) => draft.enabled)
619
671
  if (enabledDrafts.some((draft) => !draft.provider)) {
@@ -646,8 +698,13 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
646
698
  name: configuredProvider.name,
647
699
  endpoint: normalizedEndpoint,
648
700
  credentialId: configuredProvider.credentialId || null,
649
- tags: ['onboarding'],
650
- notes: `Created during setup for ${configuredProvider.name}.`,
701
+ tags: Array.from(new Set([
702
+ 'onboarding',
703
+ ...(configuredProvider.tags || []),
704
+ ])),
705
+ notes: configuredProvider.notes || `Created during setup for ${configuredProvider.name}.`,
706
+ deployment: configuredProvider.deployment || null,
707
+ status: configuredProvider.deployment?.lastVerifiedOk ? 'healthy' : 'pending',
651
708
  isDefault: shouldCreateDefault,
652
709
  })
653
710
  gatewayProfileIdsByProviderConfig.set(configuredProvider.id, createdGateway.id)
@@ -1063,6 +1120,16 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1063
1120
 
1064
1121
  {provider === 'openclaw' && (
1065
1122
  <div className="rounded-[14px] border border-white/[0.08] bg-surface p-4 space-y-4">
1123
+ <OpenClawDeployPanel
1124
+ compact
1125
+ endpoint={openClawEndpointValue}
1126
+ token={apiKey}
1127
+ suggestedName={providerLabel || selectedProvider.name}
1128
+ title="Smart Deploy OpenClaw"
1129
+ description="Launch the bundled official OpenClaw gateway locally, or generate an official-image VPS bundle for major providers without relying on third-party deployment services."
1130
+ onApply={applyOpenClawDeployPatch}
1131
+ />
1132
+
1066
1133
  <div className="grid gap-3 md:grid-cols-2">
1067
1134
  <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-3">
1068
1135
  <div className="text-[12px] uppercase tracking-[0.08em] text-text-3 mb-2">Remote gateway</div>
@@ -1075,39 +1142,20 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1075
1142
  <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1076
1143
  If you only have a WebSocket gateway URL, you can still paste it here. SwarmClaw will normalize it for agent chat.
1077
1144
  </p>
1145
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1146
+ Safer remote defaults: use <code className="text-text-2">private-tailnet</code> with <code className="text-text-2">tailscale</code> or <code className="text-text-2">ssh-tunnel</code> unless you intentionally want public HTTPS ingress.
1147
+ </p>
1078
1148
  </div>
1079
1149
  <div className="rounded-[12px] border border-white/[0.06] bg-bg px-4 py-3">
1080
- <div className="text-[12px] uppercase tracking-[0.08em] text-text-3 mb-2">Run locally</div>
1150
+ <div className="text-[12px] uppercase tracking-[0.08em] text-text-3 mb-2">Safe defaults</div>
1081
1151
  <p className="text-[13px] text-text-2 leading-relaxed">
1082
- Use this when SwarmClaw and OpenClaw are on the same host. <code className="text-text-2">localhost</code> always refers to the SwarmClaw host.
1152
+ Smart Deploy generates a gateway token for you, defaults to the standard OpenClaw ports, and prefills this setup form automatically.
1083
1153
  </p>
1084
- <div className="mt-3 rounded-[10px] border border-white/[0.06] bg-surface px-3 py-2">
1085
- <code className="block overflow-x-auto whitespace-nowrap text-[12px] text-text-2">
1086
- {openClawLocalCommand}
1087
- </code>
1088
- </div>
1089
- <div className="mt-2 flex items-center gap-2">
1090
- <button
1091
- type="button"
1092
- onClick={copyOpenClawLocalCommand}
1093
- className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-white/[0.03] text-[12px] text-text cursor-pointer hover:bg-white/[0.06] transition-all duration-200"
1094
- >
1095
- {commandCopyState === 'copied'
1096
- ? 'Copied'
1097
- : commandCopyState === 'failed'
1098
- ? 'Copy failed'
1099
- : 'Copy command'}
1100
- </button>
1101
- <button
1102
- type="button"
1103
- onClick={() => { setEndpoint(selectedProvider.defaultEndpoint || 'http://localhost:18789/v1'); setCheckState('idle'); setCheckMessage(''); setCheckErrorCode(null) }}
1104
- className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-white/[0.03] text-[12px] text-text cursor-pointer hover:bg-white/[0.06] transition-all duration-200"
1105
- >
1106
- Use local default
1107
- </button>
1108
- </div>
1109
- <p className="mt-2 text-[11px] text-text-3">
1110
- In a source checkout, use <code className="text-text-2">{openClawLocalCommandPnpm}</code>.
1154
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1155
+ Local quickstart uses the bundled official OpenClaw CLI. Remote quickstart uses the official OpenClaw Docker image or the official repo for managed hosts.
1156
+ </p>
1157
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1158
+ Choose <code className="text-text-2">local-dev</code> for one-machine setup, <code className="text-text-2">single-vps</code> for most hosted installs, or <code className="text-text-2">private-tailnet</code> when the gateway should stay private.
1111
1159
  </p>
1112
1160
  </div>
1113
1161
  </div>
@@ -1120,6 +1168,9 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1120
1168
  <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1121
1169
  Current target: <span className="text-text-2">{openClawEndpointHost || 'localhost:18789'}</span>{openClawLocal ? ' · local route' : ' · remote route'}
1122
1170
  </p>
1171
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">
1172
+ Use <code className="text-text-2">caddy</code> or <code className="text-text-2">nginx</code> only when you intentionally want HTTPS/public ingress managed on the gateway side.
1173
+ </p>
1123
1174
  </div>
1124
1175
  </div>
1125
1176
  )}
@@ -1330,6 +1381,12 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
1330
1381
  {configuredProvider.provider === 'openclaw' && formatEndpointHost(configuredProvider.endpoint)
1331
1382
  ? ` · ${formatEndpointHost(configuredProvider.endpoint)}`
1332
1383
  : ''}
1384
+ {configuredProvider.provider === 'openclaw' && configuredProvider.deployment?.useCase
1385
+ ? ` · ${OPENCLAW_USE_CASE_LABELS[configuredProvider.deployment.useCase]}`
1386
+ : ''}
1387
+ {configuredProvider.provider === 'openclaw' && configuredProvider.deployment?.exposure
1388
+ ? ` · ${OPENCLAW_EXPOSURE_LABELS[configuredProvider.deployment.exposure]}`
1389
+ : ''}
1333
1390
  {configuredProvider.defaultModel ? ` · ${configuredProvider.defaultModel}` : ''}
1334
1391
  </option>
1335
1392
  ))}
@@ -1,11 +1,19 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useState } from 'react'
3
+ import { useEffect, useRef, useState } from 'react'
4
4
  import { BottomSheet } from '@/components/shared/bottom-sheet'
5
+ import { OpenClawDeployPanel } from '@/components/openclaw/openclaw-deploy-panel'
5
6
  import { useAppStore } from '@/stores/use-app-store'
6
7
  import { api } from '@/lib/api-client'
7
8
  import { toast } from 'sonner'
8
- import type { OpenClawDevicePairRequest, OpenClawNode, OpenClawNodePairRequest, OpenClawPairedDevice } from '@/types'
9
+ import type {
10
+ Credential,
11
+ OpenClawDevicePairRequest,
12
+ OpenClawNode,
13
+ OpenClawNodePairRequest,
14
+ OpenClawPairedDevice,
15
+ GatewayProfile,
16
+ } from '@/types'
9
17
 
10
18
  interface DiscoveryResult {
11
19
  host: string
@@ -30,6 +38,17 @@ interface PairingListResult<T> {
30
38
  paired?: OpenClawPairedDevice[]
31
39
  }
32
40
 
41
+ interface GatewayImportShape {
42
+ name?: string
43
+ endpoint?: string
44
+ credentialId?: string | null
45
+ token?: string | null
46
+ notes?: string | null
47
+ tags?: string[]
48
+ isDefault?: boolean
49
+ deployment?: GatewayProfile['deployment']
50
+ }
51
+
33
52
  export function GatewaySheet() {
34
53
  const open = useAppStore((s) => s.gatewaySheetOpen)
35
54
  const setOpen = useAppStore((s) => s.setGatewaySheetOpen)
@@ -46,6 +65,7 @@ export function GatewaySheet() {
46
65
  const [name, setName] = useState('')
47
66
  const [endpoint, setEndpoint] = useState('http://localhost:18789')
48
67
  const [credentialId, setCredentialId] = useState<string | null>(null)
68
+ const [tokenDraft, setTokenDraft] = useState('')
49
69
  const [notes, setNotes] = useState('')
50
70
  const [tags, setTags] = useState('')
51
71
  const [isDefault, setIsDefault] = useState(false)
@@ -65,6 +85,8 @@ export function GatewaySheet() {
65
85
  const [invokeParamsText, setInvokeParamsText] = useState('{}')
66
86
  const [invokeResult, setInvokeResult] = useState('')
67
87
  const [invoking, setInvoking] = useState(false)
88
+ const [deployment, setDeployment] = useState<GatewayProfile['deployment'] | null>(null)
89
+ const importFileRef = useRef<HTMLInputElement>(null)
68
90
 
69
91
  useEffect(() => {
70
92
  if (!open) return
@@ -81,17 +103,21 @@ export function GatewaySheet() {
81
103
  setName(editing.name)
82
104
  setEndpoint(editing.endpoint)
83
105
  setCredentialId(editing.credentialId || null)
106
+ setTokenDraft('')
84
107
  setNotes(editing.notes || '')
85
108
  setTags((editing.tags || []).join(', '))
86
109
  setIsDefault(editing.isDefault === true)
110
+ setDeployment(editing.deployment || null)
87
111
  return
88
112
  }
89
113
  setName('')
90
114
  setEndpoint('http://localhost:18789')
91
115
  setCredentialId(null)
116
+ setTokenDraft('')
92
117
  setNotes('')
93
118
  setTags('')
94
119
  setIsDefault(gatewayProfiles.length === 0)
120
+ setDeployment(null)
95
121
  setNodes([])
96
122
  setNodePairings([])
97
123
  setDevicePairings([])
@@ -133,10 +159,18 @@ export function GatewaySheet() {
133
159
  setNodePairings(nextNodePairings)
134
160
  setDevicePairings(nextDevicePairings)
135
161
  setPairedDevices(nextPairedDevices)
162
+ const nextStats: NonNullable<GatewayProfile['stats']> = {
163
+ nodeCount: nextNodes.length,
164
+ connectedNodeCount: nextNodes.filter((node) => node.connected).length,
165
+ pendingNodePairings: nextNodePairings.length,
166
+ pairedDeviceCount: nextPairedDevices.length,
167
+ pendingDevicePairings: nextDevicePairings.length,
168
+ }
136
169
  if (nextNodes[0]) {
137
170
  setInvokeNodeId((current) => current || nextNodes[0].nodeId)
138
171
  setInvokeCommand((current) => current || nextNodes[0].commands?.[0] || '')
139
172
  }
173
+ void api('PUT', `/gateways/${profileId}`, { stats: nextStats }).catch(() => {})
140
174
  } catch (err: unknown) {
141
175
  setNodesError(err instanceof Error ? err.message : 'Failed to load nodes for this gateway.')
142
176
  } finally {
@@ -157,12 +191,22 @@ export function GatewaySheet() {
157
191
  const handleSave = async () => {
158
192
  setSaving(true)
159
193
  try {
194
+ let nextCredentialId = credentialId
195
+ if (tokenDraft.trim()) {
196
+ const created = await api<Credential>('POST', '/credentials', {
197
+ provider: 'openclaw',
198
+ name: `${name.trim() || 'OpenClaw Gateway'} token`,
199
+ apiKey: tokenDraft.trim(),
200
+ })
201
+ nextCredentialId = created.id
202
+ }
160
203
  const payload = {
161
204
  name: name.trim() || 'OpenClaw Gateway',
162
205
  endpoint: endpoint.trim() || 'http://localhost:18789',
163
- credentialId: credentialId || null,
206
+ credentialId: nextCredentialId || null,
164
207
  notes: notes.trim() || null,
165
208
  tags: tags.split(',').map((item) => item.trim()).filter(Boolean),
209
+ deployment,
166
210
  isDefault,
167
211
  }
168
212
  if (editing) {
@@ -172,7 +216,7 @@ export function GatewaySheet() {
172
216
  await api('POST', '/gateways', payload)
173
217
  toast.success('Gateway added')
174
218
  }
175
- await loadGatewayProfiles()
219
+ await Promise.all([loadGatewayProfiles(), loadCredentials()])
176
220
  onClose()
177
221
  } catch (err: unknown) {
178
222
  toast.error(err instanceof Error ? err.message : 'Failed to save gateway')
@@ -188,6 +232,7 @@ export function GatewaySheet() {
188
232
  const params = new URLSearchParams()
189
233
  params.set('endpoint', endpoint.trim() || 'http://localhost:18789')
190
234
  if (credentialId) params.set('credentialId', credentialId)
235
+ if (tokenDraft.trim()) params.set('token', tokenDraft.trim())
191
236
  const result = await api<{ ok: boolean; models: string[]; error?: string; hint?: string }>('GET', `/providers/openclaw/health?${params.toString()}`)
192
237
  if (result.ok) {
193
238
  setCheckMessage(`Connected. ${result.models?.length ? `${result.models.length} model${result.models.length === 1 ? '' : 's'} visible.` : 'Gateway responded normally.'}`)
@@ -278,15 +323,112 @@ export function GatewaySheet() {
278
323
 
279
324
  const inputClass = 'w-full px-4 py-3.5 rounded-[14px] border border-white/[0.08] bg-surface text-text text-[15px] outline-none transition-all duration-200 placeholder:text-text-3/50 focus-glow'
280
325
 
326
+ const applyDeployPatch = (patch: { endpoint?: string; token?: string; name?: string; notes?: string; deployment?: GatewayProfile['deployment'] | Record<string, unknown> | null }) => {
327
+ if (patch.endpoint) {
328
+ setEndpoint(patch.endpoint)
329
+ setCheckMessage('')
330
+ }
331
+ if (patch.token) {
332
+ setTokenDraft(patch.token)
333
+ setCredentialId(null)
334
+ }
335
+ if (patch.name && !name.trim()) {
336
+ setName(patch.name)
337
+ }
338
+ if (patch.notes && !notes.trim()) {
339
+ setNotes(patch.notes)
340
+ }
341
+ if (patch.deployment) {
342
+ setDeployment((current) => ({
343
+ ...(current || {}),
344
+ ...(patch.deployment as GatewayProfile['deployment']),
345
+ }))
346
+ }
347
+ }
348
+
349
+ const handleExportGateway = () => {
350
+ const payload: GatewayImportShape = {
351
+ name: name.trim() || 'OpenClaw Gateway',
352
+ endpoint: endpoint.trim() || 'http://localhost:18789',
353
+ credentialId: credentialId || null,
354
+ token: tokenDraft.trim() || null,
355
+ notes: notes.trim() || null,
356
+ tags: tags.split(',').map((item) => item.trim()).filter(Boolean),
357
+ isDefault,
358
+ deployment,
359
+ }
360
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })
361
+ const url = URL.createObjectURL(blob)
362
+ const link = document.createElement('a')
363
+ link.href = url
364
+ link.download = `${(payload.name || 'openclaw-gateway').replace(/[^a-zA-Z0-9_-]/g, '_')}.gateway.json`
365
+ link.click()
366
+ URL.revokeObjectURL(url)
367
+ toast.success('Gateway config exported')
368
+ }
369
+
370
+ const handleImportGateway = (event: React.ChangeEvent<HTMLInputElement>) => {
371
+ const file = event.target.files?.[0]
372
+ if (!file) return
373
+ const reader = new FileReader()
374
+ reader.onload = (loadEvent) => {
375
+ try {
376
+ const parsed = JSON.parse(String(loadEvent.target?.result || '{}')) as GatewayImportShape
377
+ setName(typeof parsed.name === 'string' ? parsed.name : '')
378
+ setEndpoint(typeof parsed.endpoint === 'string' ? parsed.endpoint : 'http://localhost:18789')
379
+ setCredentialId(typeof parsed.credentialId === 'string' ? parsed.credentialId : null)
380
+ setTokenDraft(typeof parsed.token === 'string' ? parsed.token : '')
381
+ setNotes(typeof parsed.notes === 'string' ? parsed.notes : '')
382
+ setTags(Array.isArray(parsed.tags) ? parsed.tags.join(', ') : '')
383
+ setIsDefault(parsed.isDefault === true)
384
+ setDeployment(parsed.deployment || null)
385
+ setCheckMessage('')
386
+ toast.success('Gateway config imported into this form')
387
+ } catch {
388
+ toast.error('Invalid gateway JSON')
389
+ } finally {
390
+ event.target.value = ''
391
+ }
392
+ }
393
+ reader.readAsText(file)
394
+ }
395
+
281
396
  return (
282
397
  <BottomSheet open={open} onClose={onClose} wide>
398
+ <input
399
+ ref={importFileRef}
400
+ type="file"
401
+ accept="application/json,.json"
402
+ onChange={handleImportGateway}
403
+ className="hidden"
404
+ />
283
405
  <div className="mb-10">
284
- <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
285
- {editing ? 'Edit Gateway' : 'New Gateway'}
286
- </h2>
287
- <p className="text-[14px] text-text-3">
288
- First-class OpenClaw gateway profiles for local or remote control planes.
289
- </p>
406
+ <div className="flex flex-wrap items-start justify-between gap-3">
407
+ <div>
408
+ <h2 className="font-display text-[28px] font-700 tracking-[-0.03em] mb-2">
409
+ {editing ? 'Edit Gateway' : 'New Gateway'}
410
+ </h2>
411
+ <p className="text-[14px] text-text-3">
412
+ First-class OpenClaw gateway profiles for local or remote control planes.
413
+ </p>
414
+ </div>
415
+ <div className="flex flex-wrap gap-2">
416
+ <button
417
+ type="button"
418
+ onClick={() => importFileRef.current?.click()}
419
+ className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-2 text-[12px] font-600 hover:bg-white/[0.04] transition-all cursor-pointer"
420
+ >
421
+ Import JSON
422
+ </button>
423
+ <button
424
+ type="button"
425
+ onClick={handleExportGateway}
426
+ className="px-3 py-2 rounded-[10px] border border-white/[0.08] bg-transparent text-text-2 text-[12px] font-600 hover:bg-white/[0.04] transition-all cursor-pointer"
427
+ >
428
+ Export JSON
429
+ </button>
430
+ </div>
431
+ </div>
290
432
  </div>
291
433
 
292
434
  <div className="mb-6">
@@ -309,6 +451,44 @@ export function GatewaySheet() {
309
451
  <p className="text-[11px] text-text-3/60 mt-2">Remote HTTPS URLs and local loopback endpoints are both supported.</p>
310
452
  </div>
311
453
 
454
+ <div className="mb-6">
455
+ <OpenClawDeployPanel
456
+ endpoint={endpoint}
457
+ token={tokenDraft}
458
+ deployment={deployment}
459
+ suggestedName={name || null}
460
+ title="Deploy OpenClaw From SwarmClaw"
461
+ description="Use official OpenClaw sources only. Start it on this host, or generate a pre-configured remote bundle for VPS and hosted deployments."
462
+ onApply={applyDeployPatch}
463
+ />
464
+ </div>
465
+
466
+ {deployment && (
467
+ <div className="mb-6 rounded-[16px] border border-white/[0.06] bg-white/[0.02] p-4">
468
+ <div className="text-[12px] font-700 uppercase tracking-[0.08em] text-text-3/70 mb-2">Deploy metadata</div>
469
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-[12px] text-text-3/75">
470
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface px-3 py-3">
471
+ <div className="uppercase tracking-[0.08em] text-text-3/55">Method</div>
472
+ <div className="mt-1 text-text-2">{deployment.method || 'manual'}</div>
473
+ </div>
474
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface px-3 py-3">
475
+ <div className="uppercase tracking-[0.08em] text-text-3/55">Use case</div>
476
+ <div className="mt-1 text-text-2">{deployment.useCase || 'general'}</div>
477
+ </div>
478
+ <div className="rounded-[12px] border border-white/[0.06] bg-surface px-3 py-3">
479
+ <div className="uppercase tracking-[0.08em] text-text-3/55">Exposure</div>
480
+ <div className="mt-1 text-text-2">{deployment.exposure || 'manual'}</div>
481
+ </div>
482
+ </div>
483
+ {deployment.lastDeploySummary && (
484
+ <p className="mt-3 text-[12px] text-text-3 leading-relaxed">{deployment.lastDeploySummary}</p>
485
+ )}
486
+ {deployment.lastVerifiedMessage && (
487
+ <p className="mt-2 text-[12px] text-text-3 leading-relaxed">{deployment.lastVerifiedMessage}</p>
488
+ )}
489
+ </div>
490
+ )}
491
+
312
492
  {discoveries.length > 0 && (
313
493
  <div className="mb-6">
314
494
  <div className="text-[12px] text-text-3/70 mb-2">Detected healthy gateways</div>
@@ -342,6 +522,18 @@ export function GatewaySheet() {
342
522
  <option key={item.id} value={item.id}>{item.name}</option>
343
523
  ))}
344
524
  </select>
525
+ <input
526
+ value={tokenDraft}
527
+ onChange={(e) => {
528
+ setTokenDraft(e.target.value)
529
+ if (e.target.value) setCredentialId(null)
530
+ }}
531
+ placeholder="Or paste/generate a new gateway token"
532
+ className={`${inputClass} mt-3 font-mono text-[13px]`}
533
+ />
534
+ <p className="mt-2 text-[11px] text-text-3/60">
535
+ A pasted token is stored as a new encrypted OpenClaw credential when you save this gateway.
536
+ </p>
345
537
  </div>
346
538
 
347
539
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">