@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
@@ -0,0 +1,1384 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { existsSync } from 'node:fs'
3
+ import { promises as fs } from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import {
7
+ getManagedProcess,
8
+ killManagedProcess,
9
+ removeManagedProcess,
10
+ startManagedProcess,
11
+ type ProcessStatus,
12
+ } from './process-manager'
13
+ import { normalizeOpenClawEndpoint, deriveOpenClawWsUrl } from '@/lib/openclaw-endpoint'
14
+ import { probeOpenClawHealth, type OpenClawHealthResult } from './openclaw-health'
15
+
16
+ export type OpenClawRemoteDeployTemplate = 'docker' | 'render' | 'fly' | 'railway'
17
+ export type OpenClawRemoteDeployProvider =
18
+ | 'hetzner'
19
+ | 'digitalocean'
20
+ | 'vultr'
21
+ | 'linode'
22
+ | 'lightsail'
23
+ | 'gcp'
24
+ | 'azure'
25
+ | 'oci'
26
+ | 'generic'
27
+ export type OpenClawUseCaseTemplate = 'local-dev' | 'single-vps' | 'private-tailnet' | 'browser-heavy' | 'team-control'
28
+ export type OpenClawExposurePreset = 'private-lan' | 'tailscale' | 'caddy' | 'nginx' | 'ssh-tunnel'
29
+
30
+ export interface OpenClawLocalDeployStatus {
31
+ running: boolean
32
+ processId: string | null
33
+ pid: number | null
34
+ port: number
35
+ endpoint: string
36
+ wsUrl: string
37
+ token: string | null
38
+ startedAt: number | null
39
+ tail: string
40
+ lastError: string | null
41
+ launchCommand: string
42
+ installCommand: string
43
+ }
44
+
45
+ export interface OpenClawRemoteDeployStatus {
46
+ active: boolean
47
+ processId: string | null
48
+ pid: number | null
49
+ action: string | null
50
+ target: string | null
51
+ startedAt: number | null
52
+ status: ProcessStatus | 'idle'
53
+ exitCode: number | null
54
+ tail: string
55
+ lastError: string | null
56
+ lastSummary: string | null
57
+ lastCommandPreview: string | null
58
+ lastBackupPath: string | null
59
+ }
60
+
61
+ export interface OpenClawDeployBundleFile {
62
+ name: string
63
+ language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
64
+ content: string
65
+ }
66
+
67
+ export interface OpenClawDeployBundle {
68
+ template: OpenClawRemoteDeployTemplate
69
+ provider: OpenClawRemoteDeployProvider
70
+ providerLabel: string
71
+ useCase: OpenClawUseCaseTemplate
72
+ exposure: OpenClawExposurePreset
73
+ title: string
74
+ summary: string
75
+ endpoint: string
76
+ wsUrl: string
77
+ token: string
78
+ runbook: string[]
79
+ files: OpenClawDeployBundleFile[]
80
+ }
81
+
82
+ export interface OpenClawSshConfig {
83
+ host: string
84
+ user?: string | null
85
+ port?: number | null
86
+ keyPath?: string | null
87
+ targetDir?: string | null
88
+ }
89
+
90
+ export interface OpenClawRemoteCommandResult {
91
+ ok: boolean
92
+ started: boolean
93
+ processId?: string | null
94
+ summary: string
95
+ commandPreview: string
96
+ token?: string
97
+ bundle?: OpenClawDeployBundle
98
+ }
99
+
100
+ interface LocalRuntimeState {
101
+ processId: string | null
102
+ port: number
103
+ endpoint: string
104
+ wsUrl: string
105
+ token: string | null
106
+ startedAt: number | null
107
+ lastError: string | null
108
+ }
109
+
110
+ interface RemoteRuntimeState {
111
+ processId: string | null
112
+ action: string | null
113
+ target: string | null
114
+ startedAt: number | null
115
+ lastError: string | null
116
+ lastSummary: string | null
117
+ lastCommandPreview: string | null
118
+ lastBackupPath: string | null
119
+ }
120
+
121
+ interface DeployRuntimeState {
122
+ local: LocalRuntimeState
123
+ remote: RemoteRuntimeState
124
+ }
125
+
126
+ interface RemoteProviderMeta {
127
+ id: OpenClawRemoteDeployProvider
128
+ label: string
129
+ shortLabel: string
130
+ bootstrapHint: string
131
+ summary: string
132
+ }
133
+
134
+ interface UseCaseMeta {
135
+ id: OpenClawUseCaseTemplate
136
+ label: string
137
+ summary: string
138
+ detail: string
139
+ defaultExposure: OpenClawExposurePreset
140
+ hostBind: string
141
+ nodeOptions: string | null
142
+ }
143
+
144
+ interface ExposureMeta {
145
+ id: OpenClawExposurePreset
146
+ label: string
147
+ summary: string
148
+ }
149
+
150
+ const DEFAULT_LOCAL_PORT = 18789
151
+ const DEFAULT_REMOTE_PORT = 18789
152
+ const GLOBAL_KEY = '__swarmclaw_openclaw_deploy__' as const
153
+
154
+ const REMOTE_PROVIDER_META: Record<OpenClawRemoteDeployProvider, RemoteProviderMeta> = {
155
+ hetzner: {
156
+ id: 'hetzner',
157
+ label: 'Hetzner Cloud',
158
+ shortLabel: 'Hetzner',
159
+ bootstrapHint: 'Paste cloud-init.yaml into the Cloud Config field when you create the server.',
160
+ summary: 'Cheap Ubuntu VPS with excellent fit for always-on OpenClaw control planes.',
161
+ },
162
+ digitalocean: {
163
+ id: 'digitalocean',
164
+ label: 'DigitalOcean Droplet',
165
+ shortLabel: 'DigitalOcean',
166
+ bootstrapHint: 'Paste cloud-init.yaml into the User Data field when you create the Droplet.',
167
+ summary: 'Simple Ubuntu VPS path with predictable pricing and easy DNS + volume add-ons.',
168
+ },
169
+ vultr: {
170
+ id: 'vultr',
171
+ label: 'Vultr Cloud Compute',
172
+ shortLabel: 'Vultr',
173
+ bootstrapHint: 'Paste cloud-init.yaml into User Data / Startup Script on the instance create screen.',
174
+ summary: 'Straightforward VPS deployment with broad region coverage.',
175
+ },
176
+ linode: {
177
+ id: 'linode',
178
+ label: 'Linode',
179
+ shortLabel: 'Linode',
180
+ bootstrapHint: 'Paste cloud-init.yaml into your instance User Data during provisioning.',
181
+ summary: 'Good fit for users who want an uncomplicated Linux VM with persistent disks.',
182
+ },
183
+ lightsail: {
184
+ id: 'lightsail',
185
+ label: 'AWS Lightsail',
186
+ shortLabel: 'Lightsail',
187
+ bootstrapHint: 'Paste cloud-init.yaml into the Launch script / user data area on instance creation.',
188
+ summary: 'AWS-backed VPS option for users who want a simpler path than full EC2.',
189
+ },
190
+ gcp: {
191
+ id: 'gcp',
192
+ label: 'Google Cloud',
193
+ shortLabel: 'GCP',
194
+ bootstrapHint: 'Use an Ubuntu or Debian VM and provide cloud-init.yaml as startup metadata or cloud-init user data.',
195
+ summary: 'Good option when you already use Google Cloud networking or IAM.',
196
+ },
197
+ azure: {
198
+ id: 'azure',
199
+ label: 'Azure',
200
+ shortLabel: 'Azure',
201
+ bootstrapHint: 'Paste cloud-init.yaml into Custom data / cloud-init when creating the VM.',
202
+ summary: 'Useful for teams already standardized on Azure subscriptions and networking.',
203
+ },
204
+ oci: {
205
+ id: 'oci',
206
+ label: 'Oracle Cloud',
207
+ shortLabel: 'OCI',
208
+ bootstrapHint: 'Paste cloud-init.yaml into cloud-init user data when creating the instance.',
209
+ summary: 'A practical low-cost VPS path if you already operate in Oracle Cloud.',
210
+ },
211
+ generic: {
212
+ id: 'generic',
213
+ label: 'Any Ubuntu VPS',
214
+ shortLabel: 'Generic VPS',
215
+ bootstrapHint: 'Use cloud-init.yaml on any Ubuntu 24.04 host with cloud-init, or copy bootstrap.sh after SSHing in.',
216
+ summary: 'Generic fallback for bare metal, homelab servers, and providers not listed above.',
217
+ },
218
+ }
219
+
220
+ const USE_CASE_META: Record<OpenClawUseCaseTemplate, UseCaseMeta> = {
221
+ 'local-dev': {
222
+ id: 'local-dev',
223
+ label: 'Local Dev',
224
+ summary: 'Local-first OpenClaw control plane for testing and personal machines.',
225
+ detail: 'Binds to loopback with safe defaults so a single developer can stand up OpenClaw quickly.',
226
+ defaultExposure: 'private-lan',
227
+ hostBind: '127.0.0.1',
228
+ nodeOptions: null,
229
+ },
230
+ 'single-vps': {
231
+ id: 'single-vps',
232
+ label: 'Single VPS',
233
+ summary: 'Balanced always-on control plane for one server and a small swarm.',
234
+ detail: 'Good default for Hetzner, DigitalOcean, Vultr, Linode, Lightsail, and generic Ubuntu VPS installs.',
235
+ defaultExposure: 'caddy',
236
+ hostBind: '0.0.0.0',
237
+ nodeOptions: null,
238
+ },
239
+ 'private-tailnet': {
240
+ id: 'private-tailnet',
241
+ label: 'Private Tailnet',
242
+ summary: 'Keep the gateway off the public internet and expose it only through a trusted tailnet.',
243
+ detail: 'Uses loopback binding and pairs well with Tailscale or an SSH tunnel.',
244
+ defaultExposure: 'tailscale',
245
+ hostBind: '127.0.0.1',
246
+ nodeOptions: null,
247
+ },
248
+ 'browser-heavy': {
249
+ id: 'browser-heavy',
250
+ label: 'Browser Heavy',
251
+ summary: 'Higher-memory defaults for browser tools and long-running automation nodes.',
252
+ detail: 'Raises Node memory limits and assumes a roomier VPS profile for browser-backed tasks.',
253
+ defaultExposure: 'caddy',
254
+ hostBind: '0.0.0.0',
255
+ nodeOptions: '--max-old-space-size=3072',
256
+ },
257
+ 'team-control': {
258
+ id: 'team-control',
259
+ label: 'Team Control',
260
+ summary: 'Shared control plane defaults for a trusted team with backups and cleaner exposure choices.',
261
+ detail: 'Prioritizes predictable exposure and easier operator handoff across a team.',
262
+ defaultExposure: 'caddy',
263
+ hostBind: '0.0.0.0',
264
+ nodeOptions: '--max-old-space-size=2048',
265
+ },
266
+ }
267
+
268
+ const EXPOSURE_META: Record<OpenClawExposurePreset, ExposureMeta> = {
269
+ 'private-lan': {
270
+ id: 'private-lan',
271
+ label: 'Private LAN',
272
+ summary: 'Expose only on your LAN or through provider firewall rules.',
273
+ },
274
+ tailscale: {
275
+ id: 'tailscale',
276
+ label: 'Tailscale',
277
+ summary: 'Keep OpenClaw on loopback and publish it only over your Tailscale tailnet.',
278
+ },
279
+ caddy: {
280
+ id: 'caddy',
281
+ label: 'Caddy',
282
+ summary: 'Run a bundled reverse proxy that can terminate HTTPS and proxy the gateway safely.',
283
+ },
284
+ nginx: {
285
+ id: 'nginx',
286
+ label: 'Nginx',
287
+ summary: 'Use an Nginx reverse proxy for teams that already manage TLS or edge certificates.',
288
+ },
289
+ 'ssh-tunnel': {
290
+ id: 'ssh-tunnel',
291
+ label: 'SSH Tunnel',
292
+ summary: 'Keep the gateway on loopback and access it through an SSH tunnel when needed.',
293
+ },
294
+ }
295
+
296
+ function getRuntimeState(): DeployRuntimeState {
297
+ const fallback: DeployRuntimeState = {
298
+ local: {
299
+ processId: null,
300
+ port: DEFAULT_LOCAL_PORT,
301
+ endpoint: normalizeOpenClawEndpoint(`http://127.0.0.1:${DEFAULT_LOCAL_PORT}`),
302
+ wsUrl: deriveOpenClawWsUrl(`http://127.0.0.1:${DEFAULT_LOCAL_PORT}`),
303
+ token: null,
304
+ startedAt: null,
305
+ lastError: null,
306
+ },
307
+ remote: {
308
+ processId: null,
309
+ action: null,
310
+ target: null,
311
+ startedAt: null,
312
+ lastError: null,
313
+ lastSummary: null,
314
+ lastCommandPreview: null,
315
+ lastBackupPath: null,
316
+ },
317
+ }
318
+ const globalState = globalThis as typeof globalThis & { [GLOBAL_KEY]?: DeployRuntimeState }
319
+ if (!globalState[GLOBAL_KEY]) {
320
+ globalState[GLOBAL_KEY] = fallback
321
+ }
322
+ return globalState[GLOBAL_KEY] || fallback
323
+ }
324
+
325
+ function shellEscape(value: string): string {
326
+ return `'${value.replace(/'/g, `'\\''`)}'`
327
+ }
328
+
329
+ function resolveBundledOpenClawBinary(): string {
330
+ const binName = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw'
331
+ const candidates = [
332
+ path.join(process.cwd(), 'node_modules', '.bin', binName),
333
+ path.join(process.cwd(), '.next', 'standalone', 'node_modules', '.bin', binName),
334
+ ]
335
+ for (const candidate of candidates) {
336
+ if (existsSync(candidate)) return candidate
337
+ }
338
+ return 'openclaw'
339
+ }
340
+
341
+ function buildLocalRunCommand(port: number, token?: string | null): string {
342
+ const parts = [
343
+ 'npx',
344
+ 'openclaw',
345
+ 'gateway',
346
+ 'run',
347
+ '--allow-unconfigured',
348
+ '--force',
349
+ '--bind',
350
+ 'loopback',
351
+ '--port',
352
+ String(port),
353
+ ]
354
+ if (token) {
355
+ parts.push('--auth', 'token', '--token', token)
356
+ }
357
+ return parts.join(' ')
358
+ }
359
+
360
+ function buildLocalInstallCommand(port: number, token?: string | null): string {
361
+ const parts = [
362
+ 'npx',
363
+ 'openclaw',
364
+ 'gateway',
365
+ 'install',
366
+ '--port',
367
+ String(port),
368
+ ]
369
+ if (token) parts.push('--token', token)
370
+ return `${parts.join(' ')} && npx openclaw gateway start`
371
+ }
372
+
373
+ function sanitizePort(value: unknown, fallback = DEFAULT_LOCAL_PORT): number {
374
+ const parsed = typeof value === 'number'
375
+ ? value
376
+ : typeof value === 'string'
377
+ ? Number.parseInt(value, 10)
378
+ : Number.NaN
379
+ if (!Number.isFinite(parsed)) return fallback
380
+ return Math.max(1024, Math.min(65535, Math.trunc(parsed)))
381
+ }
382
+
383
+ function normalizeToken(value: unknown): string | null {
384
+ return typeof value === 'string' && value.trim() ? value.trim() : null
385
+ }
386
+
387
+ function normalizeText(value: unknown): string | null {
388
+ return typeof value === 'string' && value.trim() ? value.trim() : null
389
+ }
390
+
391
+ function normalizeRemoteProvider(value: unknown): OpenClawRemoteDeployProvider {
392
+ if (
393
+ value === 'hetzner'
394
+ || value === 'digitalocean'
395
+ || value === 'vultr'
396
+ || value === 'linode'
397
+ || value === 'lightsail'
398
+ || value === 'gcp'
399
+ || value === 'azure'
400
+ || value === 'oci'
401
+ || value === 'generic'
402
+ ) {
403
+ return value
404
+ }
405
+ return 'hetzner'
406
+ }
407
+
408
+ function normalizeUseCase(value: unknown): OpenClawUseCaseTemplate {
409
+ if (
410
+ value === 'local-dev'
411
+ || value === 'single-vps'
412
+ || value === 'private-tailnet'
413
+ || value === 'browser-heavy'
414
+ || value === 'team-control'
415
+ ) {
416
+ return value
417
+ }
418
+ return 'single-vps'
419
+ }
420
+
421
+ function normalizeExposurePreset(value: unknown, fallback?: OpenClawUseCaseTemplate): OpenClawExposurePreset {
422
+ if (
423
+ value === 'private-lan'
424
+ || value === 'tailscale'
425
+ || value === 'caddy'
426
+ || value === 'nginx'
427
+ || value === 'ssh-tunnel'
428
+ ) {
429
+ return value
430
+ }
431
+ const useCase = fallback ? USE_CASE_META[fallback] : null
432
+ return useCase?.defaultExposure || 'private-lan'
433
+ }
434
+
435
+ function sanitizeSshConfig(input?: Partial<OpenClawSshConfig> | null): OpenClawSshConfig | null {
436
+ const host = typeof input?.host === 'string' && input.host.trim() ? input.host.trim() : ''
437
+ if (!host) return null
438
+ const port = sanitizePort(input?.port, 22)
439
+ return {
440
+ host,
441
+ user: typeof input?.user === 'string' && input.user.trim() ? input.user.trim() : 'root',
442
+ port,
443
+ keyPath: typeof input?.keyPath === 'string' && input.keyPath.trim() ? input.keyPath.trim() : null,
444
+ targetDir: typeof input?.targetDir === 'string' && input.targetDir.trim() ? input.targetDir.trim() : '/opt/openclaw',
445
+ }
446
+ }
447
+
448
+ function buildSshTarget(config: OpenClawSshConfig): string {
449
+ return `${config.user || 'root'}@${config.host}`
450
+ }
451
+
452
+ function buildSshArgs(config: OpenClawSshConfig, forScp = false): string[] {
453
+ const args: string[] = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new']
454
+ if (config.keyPath) args.push('-i', config.keyPath)
455
+ args.push(forScp ? '-P' : '-p', String(config.port || 22))
456
+ return args
457
+ }
458
+
459
+ async function materializeBundleFiles(bundle: OpenClawDeployBundle): Promise<{ dir: string; filePaths: string[] }> {
460
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'swarmclaw-openclaw-'))
461
+ const filePaths: string[] = []
462
+ for (const file of bundle.files) {
463
+ const filePath = path.join(dir, file.name)
464
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
465
+ await fs.writeFile(filePath, file.content, 'utf8')
466
+ filePaths.push(filePath)
467
+ }
468
+ return { dir, filePaths }
469
+ }
470
+
471
+ function updateRemoteRuntimeState(patch: Partial<RemoteRuntimeState>) {
472
+ Object.assign(getRuntimeState().remote, patch)
473
+ }
474
+
475
+ async function startRemoteCommand(params: {
476
+ action: string
477
+ target: string
478
+ command: string
479
+ summary: string
480
+ backupPath?: string | null
481
+ }): Promise<OpenClawRemoteCommandResult> {
482
+ const result = await startManagedProcess({
483
+ command: params.command,
484
+ cwd: process.cwd(),
485
+ background: true,
486
+ timeoutMs: 30 * 60_000,
487
+ })
488
+
489
+ if (result.status === 'completed' && (result.exitCode ?? 0) === 0) {
490
+ updateRemoteRuntimeState({
491
+ processId: null,
492
+ action: params.action,
493
+ target: params.target,
494
+ startedAt: Date.now(),
495
+ lastError: null,
496
+ lastSummary: params.summary,
497
+ lastCommandPreview: params.command,
498
+ lastBackupPath: params.backupPath || null,
499
+ })
500
+ return {
501
+ ok: true,
502
+ started: false,
503
+ processId: null,
504
+ summary: params.summary,
505
+ commandPreview: params.command,
506
+ }
507
+ }
508
+
509
+ if (result.status !== 'running') {
510
+ const message = result.output || result.tail || params.summary
511
+ updateRemoteRuntimeState({
512
+ processId: null,
513
+ action: params.action,
514
+ target: params.target,
515
+ startedAt: null,
516
+ lastError: message,
517
+ lastSummary: params.summary,
518
+ lastCommandPreview: params.command,
519
+ lastBackupPath: params.backupPath || null,
520
+ })
521
+ return {
522
+ ok: false,
523
+ started: false,
524
+ processId: null,
525
+ summary: message,
526
+ commandPreview: params.command,
527
+ }
528
+ }
529
+
530
+ updateRemoteRuntimeState({
531
+ processId: result.processId,
532
+ action: params.action,
533
+ target: params.target,
534
+ startedAt: Date.now(),
535
+ lastError: null,
536
+ lastSummary: params.summary,
537
+ lastCommandPreview: params.command,
538
+ lastBackupPath: params.backupPath || null,
539
+ })
540
+ return {
541
+ ok: true,
542
+ started: true,
543
+ processId: result.processId,
544
+ summary: params.summary,
545
+ commandPreview: params.command,
546
+ }
547
+ }
548
+
549
+ function wait(ms: number): Promise<void> {
550
+ return new Promise((resolve) => setTimeout(resolve, ms))
551
+ }
552
+
553
+ async function waitForLocalRuntime(processId: string, attempts = 12): Promise<void> {
554
+ for (let i = 0; i < attempts; i += 1) {
555
+ const process = getManagedProcess(processId)
556
+ if (!process || process.status !== 'running') break
557
+ if ((process.log || '').toLowerCase().includes('listening')) return
558
+ await wait(500)
559
+ }
560
+ }
561
+
562
+ function readTail(text: string, size = 1200): string {
563
+ if (!text) return ''
564
+ return text.length <= size ? text : text.slice(text.length - size)
565
+ }
566
+
567
+ function currentLocalStatus(): OpenClawLocalDeployStatus {
568
+ const state = getRuntimeState()
569
+ const processId = state.local.processId
570
+ const process = processId ? getManagedProcess(processId) : null
571
+ const running = !!process && process.status === 'running'
572
+
573
+ if (!running && processId && process && process.status !== 'running') {
574
+ state.local.lastError = readTail(process.log || '') || state.local.lastError
575
+ state.local.processId = null
576
+ state.local.startedAt = null
577
+ }
578
+
579
+ const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${state.local.port}`)
580
+ return {
581
+ running,
582
+ processId: running ? processId : null,
583
+ pid: running ? (process?.pid ?? null) : null,
584
+ port: state.local.port,
585
+ endpoint,
586
+ wsUrl: deriveOpenClawWsUrl(endpoint),
587
+ token: state.local.token || null,
588
+ startedAt: running ? state.local.startedAt : null,
589
+ tail: process ? readTail(process.log || '') : '',
590
+ lastError: running ? null : (state.local.lastError || null),
591
+ launchCommand: buildLocalRunCommand(state.local.port, state.local.token),
592
+ installCommand: buildLocalInstallCommand(state.local.port, state.local.token),
593
+ }
594
+ }
595
+
596
+ export function getOpenClawLocalDeployStatus(): OpenClawLocalDeployStatus {
597
+ return currentLocalStatus()
598
+ }
599
+
600
+ function currentRemoteStatus(): OpenClawRemoteDeployStatus {
601
+ const state = getRuntimeState()
602
+ const processId = state.remote.processId
603
+ const process = processId ? getManagedProcess(processId) : null
604
+ const active = !!process && process.status === 'running'
605
+
606
+ if (!active && processId && process && process.status !== 'running') {
607
+ state.remote.lastError = readTail(process.log || '') || state.remote.lastError
608
+ state.remote.processId = null
609
+ }
610
+
611
+ return {
612
+ active,
613
+ processId: active ? processId : null,
614
+ pid: active ? (process?.pid ?? null) : null,
615
+ action: state.remote.action || null,
616
+ target: state.remote.target || null,
617
+ startedAt: state.remote.startedAt || null,
618
+ status: process?.status || 'idle',
619
+ exitCode: process?.exitCode ?? null,
620
+ tail: process ? readTail(process.log || '') : '',
621
+ lastError: active ? null : (state.remote.lastError || null),
622
+ lastSummary: state.remote.lastSummary || null,
623
+ lastCommandPreview: state.remote.lastCommandPreview || null,
624
+ lastBackupPath: state.remote.lastBackupPath || null,
625
+ }
626
+ }
627
+
628
+ export function getOpenClawRemoteDeployStatus(): OpenClawRemoteDeployStatus {
629
+ return currentRemoteStatus()
630
+ }
631
+
632
+ export function generateOpenClawGatewayToken(): string {
633
+ return randomBytes(24).toString('base64url')
634
+ }
635
+
636
+ export async function startOpenClawLocalDeploy(input?: {
637
+ port?: number
638
+ token?: string | null
639
+ }): Promise<{ local: OpenClawLocalDeployStatus; token: string }> {
640
+ const state = getRuntimeState()
641
+ const current = currentLocalStatus()
642
+ if (current.running && current.processId) {
643
+ killManagedProcess(current.processId)
644
+ removeManagedProcess(current.processId)
645
+ }
646
+
647
+ const port = sanitizePort(input?.port, DEFAULT_LOCAL_PORT)
648
+ const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
649
+ const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${port}`)
650
+ const wsUrl = deriveOpenClawWsUrl(endpoint)
651
+ const binary = resolveBundledOpenClawBinary()
652
+ const args = [
653
+ binary,
654
+ 'gateway',
655
+ 'run',
656
+ '--allow-unconfigured',
657
+ '--force',
658
+ '--bind',
659
+ 'loopback',
660
+ '--port',
661
+ String(port),
662
+ '--auth',
663
+ 'token',
664
+ '--token',
665
+ token,
666
+ '--verbose',
667
+ ]
668
+
669
+ const result = await startManagedProcess({
670
+ command: args.map(shellEscape).join(' '),
671
+ cwd: process.cwd(),
672
+ background: true,
673
+ timeoutMs: 24 * 60 * 60_000,
674
+ })
675
+
676
+ if (result.status !== 'running') {
677
+ const message = result.output || result.tail || 'OpenClaw failed to start.'
678
+ state.local = {
679
+ processId: null,
680
+ port,
681
+ endpoint,
682
+ wsUrl,
683
+ token,
684
+ startedAt: null,
685
+ lastError: message,
686
+ }
687
+ throw new Error(message)
688
+ }
689
+
690
+ state.local = {
691
+ processId: result.processId,
692
+ port,
693
+ endpoint,
694
+ wsUrl,
695
+ token,
696
+ startedAt: Date.now(),
697
+ lastError: null,
698
+ }
699
+
700
+ await waitForLocalRuntime(result.processId)
701
+
702
+ return {
703
+ local: currentLocalStatus(),
704
+ token,
705
+ }
706
+ }
707
+
708
+ export function stopOpenClawLocalDeploy(): OpenClawLocalDeployStatus {
709
+ const state = getRuntimeState()
710
+ const processId = state.local.processId
711
+ if (processId) {
712
+ const process = getManagedProcess(processId)
713
+ if (process?.status === 'running') {
714
+ killManagedProcess(processId)
715
+ }
716
+ removeManagedProcess(processId)
717
+ }
718
+ state.local.processId = null
719
+ state.local.startedAt = null
720
+ return currentLocalStatus()
721
+ }
722
+
723
+ export async function restartOpenClawLocalDeploy(input?: {
724
+ port?: number
725
+ token?: string | null
726
+ }): Promise<{ local: OpenClawLocalDeployStatus; token: string }> {
727
+ const current = currentLocalStatus()
728
+ return startOpenClawLocalDeploy({
729
+ port: input?.port ?? current.port,
730
+ token: input?.token ?? current.token,
731
+ })
732
+ }
733
+
734
+ function ensureSchemeAndPort(raw: string, scheme: 'http' | 'https', port: number): string {
735
+ const trimmed = raw.trim()
736
+ if (!trimmed) {
737
+ return `${scheme}://127.0.0.1:${port}`
738
+ }
739
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
740
+ const defaultPort = scheme === 'https' ? 443 : port
741
+ const hasPort = /:\d+$/.test(trimmed)
742
+ const portSuffix = hasPort || defaultPort === 443 ? '' : `:${defaultPort}`
743
+ return `${scheme}://${trimmed}${portSuffix}`
744
+ }
745
+
746
+ function deriveRemoteDeploymentName(target: string): string {
747
+ const cleaned = target
748
+ .replace(/^https?:\/\//i, '')
749
+ .replace(/\/.*$/, '')
750
+ .replace(/:\d+$/, '')
751
+ .trim()
752
+ return cleaned || 'Remote OpenClaw'
753
+ }
754
+
755
+ function indentBlock(value: string, spaces: number): string {
756
+ const padding = ' '.repeat(spaces)
757
+ return value
758
+ .replace(/\r\n/g, '\n')
759
+ .split('\n')
760
+ .map((line) => `${padding}${line}`)
761
+ .join('\n')
762
+ }
763
+
764
+ interface DockerBundleOptions {
765
+ token: string
766
+ endpointHost: string
767
+ useCase: OpenClawUseCaseTemplate
768
+ exposure: OpenClawExposurePreset
769
+ }
770
+
771
+ function resolveHostBindAddress(useCase: OpenClawUseCaseTemplate, exposure: OpenClawExposurePreset): string {
772
+ if (exposure === 'tailscale' || exposure === 'ssh-tunnel' || exposure === 'caddy' || exposure === 'nginx') {
773
+ return '127.0.0.1'
774
+ }
775
+ return USE_CASE_META[useCase].hostBind
776
+ }
777
+
778
+ function buildDockerComposeFile(options: DockerBundleOptions): string {
779
+ return `services:
780
+ openclaw-gateway:
781
+ image: \${OPENCLAW_IMAGE:-openclaw:latest}
782
+ environment:
783
+ HOME: /home/node
784
+ TERM: xterm-256color
785
+ NODE_OPTIONS: \${OPENCLAW_NODE_OPTIONS:-}
786
+ OPENCLAW_GATEWAY_TOKEN: \${OPENCLAW_GATEWAY_TOKEN}
787
+ OPENCLAW_GATEWAY_BIND: \${OPENCLAW_GATEWAY_BIND:-lan}
788
+ volumes:
789
+ - \${OPENCLAW_CONFIG_DIR:-./.openclaw}:/home/node/.openclaw
790
+ - \${OPENCLAW_WORKSPACE_DIR:-./workspace}:/home/node/.openclaw/workspace
791
+ ports:
792
+ - "\${OPENCLAW_HOST_BIND:-${resolveHostBindAddress(options.useCase, options.exposure)}}:\${OPENCLAW_GATEWAY_PORT:-18789}:18789"
793
+ - "\${OPENCLAW_HOST_BIND:-${resolveHostBindAddress(options.useCase, options.exposure)}}:\${OPENCLAW_BRIDGE_PORT:-18790}:18790"
794
+ init: true
795
+ restart: unless-stopped
796
+ command:
797
+ [
798
+ "node",
799
+ "dist/index.js",
800
+ "gateway",
801
+ "--allow-unconfigured",
802
+ "--bind",
803
+ "\${OPENCLAW_GATEWAY_BIND:-lan}",
804
+ "--port",
805
+ "18789",
806
+ "--auth",
807
+ "token",
808
+ "--token",
809
+ "\${OPENCLAW_GATEWAY_TOKEN}",
810
+ ]
811
+ healthcheck:
812
+ test:
813
+ [
814
+ "CMD",
815
+ "node",
816
+ "-e",
817
+ "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
818
+ ]
819
+ interval: 30s
820
+ timeout: 5s
821
+ retries: 5
822
+ start_period: 20s
823
+ `
824
+ }
825
+
826
+ function buildDockerEnvFile(options: DockerBundleOptions): string {
827
+ return `OPENCLAW_IMAGE=openclaw:latest
828
+ OPENCLAW_GATEWAY_TOKEN=${options.token}
829
+ OPENCLAW_GATEWAY_BIND=lan
830
+ OPENCLAW_HOST_BIND=${resolveHostBindAddress(options.useCase, options.exposure)}
831
+ OPENCLAW_GATEWAY_PORT=18789
832
+ OPENCLAW_BRIDGE_PORT=18790
833
+ OPENCLAW_CONFIG_DIR=./.openclaw
834
+ OPENCLAW_WORKSPACE_DIR=./workspace
835
+ OPENCLAW_USE_CASE=${options.useCase}
836
+ OPENCLAW_EXPOSURE=${options.exposure}
837
+ OPENCLAW_NODE_OPTIONS=${USE_CASE_META[options.useCase].nodeOptions || ''}
838
+ `
839
+ }
840
+
841
+ function buildDockerBootstrapScript(options: DockerBundleOptions): string {
842
+ return `#!/usr/bin/env bash
843
+ set -euo pipefail
844
+
845
+ APP_DIR="\${OPENCLAW_APP_DIR:-$HOME/openclaw}"
846
+
847
+ mkdir -p "$APP_DIR"
848
+ cd "$APP_DIR"
849
+ mkdir -p .openclaw workspace backups
850
+
851
+ if ! command -v docker >/dev/null 2>&1; then
852
+ echo "Docker is required. On Ubuntu 24.04 you can install it with:"
853
+ echo " sudo apt-get update && sudo apt-get install -y docker.io docker-compose-plugin"
854
+ exit 1
855
+ fi
856
+
857
+ docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"
858
+ docker compose up -d
859
+ if [ -f docker-compose.proxy.yml ]; then
860
+ docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
861
+ fi
862
+ docker compose ps
863
+ echo "Use case: ${options.useCase}"
864
+ echo "Exposure preset: ${options.exposure}"
865
+ `
866
+ }
867
+
868
+ function buildCloudInitFile(options: DockerBundleOptions): string {
869
+ const envFile = buildDockerEnvFile(options)
870
+ const composeFile = buildDockerComposeFile(options)
871
+ const bootstrapFile = buildDockerBootstrapScript(options)
872
+ const extraFiles = buildExposureFiles(options)
873
+ .filter((file) => !['.env', 'docker-compose.yml', 'bootstrap.sh'].includes(file.name))
874
+ .map((file) => ` - path: /opt/openclaw/${file.name}
875
+ owner: root:root
876
+ permissions: "${file.name.endsWith('.sh') ? '0755' : '0644'}"
877
+ content: |
878
+ ${indentBlock(file.content, 6)}`)
879
+ .join('\n')
880
+ return `#cloud-config
881
+ package_update: true
882
+ package_upgrade: true
883
+ packages:
884
+ - ca-certificates
885
+ - curl
886
+ - docker.io
887
+ - docker-compose-plugin
888
+ write_files:
889
+ - path: /opt/openclaw/.env
890
+ owner: root:root
891
+ permissions: "0600"
892
+ content: |
893
+ ${indentBlock(envFile, 6)}
894
+ - path: /opt/openclaw/docker-compose.yml
895
+ owner: root:root
896
+ permissions: "0644"
897
+ content: |
898
+ ${indentBlock(composeFile, 6)}
899
+ - path: /opt/openclaw/bootstrap.sh
900
+ owner: root:root
901
+ permissions: "0755"
902
+ content: |
903
+ ${indentBlock(bootstrapFile, 6)}
904
+ ${extraFiles ? `${extraFiles}
905
+ ` : ''}runcmd:
906
+ - mkdir -p /opt/openclaw/.openclaw /opt/openclaw/workspace /opt/openclaw/backups
907
+ - systemctl enable --now docker
908
+ - bash -lc 'cd /opt/openclaw && docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"'
909
+ - bash -lc 'cd /opt/openclaw && docker compose up -d'
910
+ - bash -lc 'cd /opt/openclaw && if [ -f docker-compose.proxy.yml ]; then docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d; fi'
911
+ final_message: "OpenClaw gateway bootstrap complete. Run: sudo docker compose -f /opt/openclaw/docker-compose.yml ps"
912
+ `
913
+ }
914
+
915
+ function buildCaddyComposeFile(): string {
916
+ return `services:
917
+ caddy:
918
+ image: caddy:2
919
+ restart: unless-stopped
920
+ depends_on:
921
+ - openclaw-gateway
922
+ ports:
923
+ - "80:80"
924
+ - "443:443"
925
+ volumes:
926
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
927
+ - caddy_data:/data
928
+ - caddy_config:/config
929
+
930
+ volumes:
931
+ caddy_data:
932
+ caddy_config:
933
+ `
934
+ }
935
+
936
+ function buildCaddyfile(endpointHost: string): string {
937
+ return `${endpointHost} {
938
+ encode gzip
939
+ reverse_proxy openclaw-gateway:18789
940
+ }
941
+ `
942
+ }
943
+
944
+ function buildNginxComposeFile(): string {
945
+ return `services:
946
+ nginx:
947
+ image: nginx:1.27-alpine
948
+ restart: unless-stopped
949
+ depends_on:
950
+ - openclaw-gateway
951
+ ports:
952
+ - "80:80"
953
+ volumes:
954
+ - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
955
+ `
956
+ }
957
+
958
+ function buildNginxConfig(endpointHost: string): string {
959
+ return `server {
960
+ listen 80;
961
+ server_name ${endpointHost};
962
+
963
+ location / {
964
+ proxy_pass http://openclaw-gateway:18789;
965
+ proxy_http_version 1.1;
966
+ proxy_set_header Host $host;
967
+ proxy_set_header X-Forwarded-Proto $scheme;
968
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
969
+ proxy_set_header Upgrade $http_upgrade;
970
+ proxy_set_header Connection "upgrade";
971
+ }
972
+ }
973
+ `
974
+ }
975
+
976
+ function buildTailscaleServeScript(): string {
977
+ return `#!/usr/bin/env bash
978
+ set -euo pipefail
979
+
980
+ PORT="\${OPENCLAW_GATEWAY_PORT:-18789}"
981
+ if ! command -v tailscale >/dev/null 2>&1; then
982
+ echo "Install Tailscale first: https://tailscale.com/download"
983
+ exit 1
984
+ fi
985
+
986
+ sudo tailscale serve --bg --set-path=/ http://127.0.0.1:$PORT
987
+ tailscale status
988
+ `
989
+ }
990
+
991
+ function buildSshTunnelGuide(endpointHost: string): string {
992
+ return `Use an SSH tunnel instead of opening the gateway publicly.
993
+
994
+ Example:
995
+ ssh -N -L 18789:127.0.0.1:18789 user@${endpointHost}
996
+
997
+ Then point SwarmClaw at:
998
+ http://127.0.0.1:18789/v1
999
+ `
1000
+ }
1001
+
1002
+ function buildExposureFiles(options: DockerBundleOptions): OpenClawDeployBundleFile[] {
1003
+ if (options.exposure === 'caddy') {
1004
+ return [
1005
+ { name: 'docker-compose.proxy.yml', language: 'yaml', content: buildCaddyComposeFile() },
1006
+ { name: 'Caddyfile', language: 'text', content: buildCaddyfile(options.endpointHost) },
1007
+ ]
1008
+ }
1009
+ if (options.exposure === 'nginx') {
1010
+ return [
1011
+ { name: 'docker-compose.proxy.yml', language: 'yaml', content: buildNginxComposeFile() },
1012
+ { name: 'nginx.conf', language: 'text', content: buildNginxConfig(options.endpointHost) },
1013
+ ]
1014
+ }
1015
+ if (options.exposure === 'tailscale') {
1016
+ return [{ name: 'tailscale-serve.sh', language: 'bash', content: buildTailscaleServeScript() }]
1017
+ }
1018
+ if (options.exposure === 'ssh-tunnel') {
1019
+ return [{ name: 'ssh-tunnel.txt', language: 'text', content: buildSshTunnelGuide(options.endpointHost) }]
1020
+ }
1021
+ return []
1022
+ }
1023
+
1024
+ function buildRenderManifest(): string {
1025
+ return `services:
1026
+ - type: web
1027
+ name: openclaw
1028
+ runtime: docker
1029
+ plan: starter
1030
+ healthCheckPath: /health
1031
+ envVars:
1032
+ - key: PORT
1033
+ value: "8080"
1034
+ - key: SETUP_PASSWORD
1035
+ sync: false
1036
+ - key: OPENCLAW_STATE_DIR
1037
+ value: /data/.openclaw
1038
+ - key: OPENCLAW_WORKSPACE_DIR
1039
+ value: /data/workspace
1040
+ - key: OPENCLAW_GATEWAY_TOKEN
1041
+ sync: false
1042
+ disk:
1043
+ name: openclaw-data
1044
+ mountPath: /data
1045
+ sizeGB: 1
1046
+ `
1047
+ }
1048
+
1049
+ function buildFlyToml(): string {
1050
+ return `app = "openclaw"
1051
+ primary_region = "iad"
1052
+
1053
+ [build]
1054
+ dockerfile = "Dockerfile"
1055
+
1056
+ [env]
1057
+ NODE_ENV = "production"
1058
+ OPENCLAW_PREFER_PNPM = "1"
1059
+ OPENCLAW_STATE_DIR = "/data"
1060
+ NODE_OPTIONS = "--max-old-space-size=1536"
1061
+
1062
+ [processes]
1063
+ app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
1064
+
1065
+ [http_service]
1066
+ internal_port = 3000
1067
+ force_https = true
1068
+ auto_stop_machines = false
1069
+ auto_start_machines = true
1070
+ min_machines_running = 1
1071
+ processes = ["app"]
1072
+
1073
+ [[vm]]
1074
+ size = "shared-cpu-2x"
1075
+ memory = "2048mb"
1076
+
1077
+ [mounts]
1078
+ source = "openclaw_data"
1079
+ destination = "/data"
1080
+ `
1081
+ }
1082
+
1083
+ function buildRailwayEnvTemplate(token: string): string {
1084
+ return `OPENCLAW_GATEWAY_TOKEN=${token}
1085
+ OPENCLAW_STATE_DIR=/data/.openclaw
1086
+ OPENCLAW_WORKSPACE_DIR=/data/workspace
1087
+ PORT=8080
1088
+ `
1089
+ }
1090
+
1091
+ function buildRailwayConfig(): string {
1092
+ return `{
1093
+ "$schema": "https://railway.com/railway.schema.json",
1094
+ "deploy": {
1095
+ "healthcheckPath": "/healthz",
1096
+ "restartPolicyType": "ON_FAILURE",
1097
+ "restartPolicyMaxRetries": 10
1098
+ }
1099
+ }
1100
+ `
1101
+ }
1102
+
1103
+ function buildDockerRunbook(
1104
+ providerMeta: RemoteProviderMeta,
1105
+ endpoint: string,
1106
+ useCase: OpenClawUseCaseTemplate,
1107
+ exposure: OpenClawExposurePreset,
1108
+ ): string[] {
1109
+ const endpointHost = deriveRemoteDeploymentName(endpoint)
1110
+ return [
1111
+ `Provision a small Ubuntu 24.04 server on ${providerMeta.label}. ${providerMeta.bootstrapHint}`,
1112
+ `Use case preset: ${USE_CASE_META[useCase].label}. Exposure preset: ${EXPOSURE_META[exposure].label}.`,
1113
+ 'Let first boot finish, then confirm the service with: sudo docker compose -f /opt/openclaw/docker-compose.yml ps',
1114
+ exposure === 'tailscale'
1115
+ ? 'Run tailscale-serve.sh after the host joins your tailnet so OpenClaw stays private.'
1116
+ : exposure === 'caddy'
1117
+ ? 'Set your DNS name to this host and start the bundled Caddy proxy for HTTPS termination.'
1118
+ : exposure === 'nginx'
1119
+ ? 'Start the bundled Nginx proxy or bring your own TLS terminator in front of the gateway.'
1120
+ : exposure === 'ssh-tunnel'
1121
+ ? 'Do not expose the gateway publicly; use the generated SSH tunnel guide instead.'
1122
+ : `Point a DNS name, reverse proxy, or private network hostname at ${endpointHost} and keep the generated token private.`,
1123
+ 'Use the generated endpoint and token in SwarmClaw to save the gateway profile.',
1124
+ ]
1125
+ }
1126
+
1127
+ export function buildOpenClawDeployBundle(input?: {
1128
+ template?: OpenClawRemoteDeployTemplate
1129
+ target?: string | null
1130
+ token?: string | null
1131
+ scheme?: 'http' | 'https'
1132
+ port?: number
1133
+ provider?: OpenClawRemoteDeployProvider
1134
+ useCase?: OpenClawUseCaseTemplate
1135
+ exposure?: OpenClawExposurePreset
1136
+ }): OpenClawDeployBundle {
1137
+ const template = input?.template || 'docker'
1138
+ const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
1139
+ const scheme = input?.scheme === 'http' ? 'http' : 'https'
1140
+ const port = sanitizePort(input?.port, DEFAULT_REMOTE_PORT)
1141
+ const rawTarget = typeof input?.target === 'string' ? input.target.trim() : ''
1142
+ const endpoint = normalizeOpenClawEndpoint(ensureSchemeAndPort(rawTarget || 'openclaw.example.com', scheme, port))
1143
+ const wsUrl = deriveOpenClawWsUrl(endpoint)
1144
+ const provider = normalizeRemoteProvider(input?.provider)
1145
+ const providerMeta = REMOTE_PROVIDER_META[provider]
1146
+ const useCase = normalizeUseCase(input?.useCase)
1147
+ const exposure = normalizeExposurePreset(input?.exposure, useCase)
1148
+ const bundleOptions: DockerBundleOptions = {
1149
+ token,
1150
+ endpointHost: deriveRemoteDeploymentName(endpoint),
1151
+ useCase,
1152
+ exposure,
1153
+ }
1154
+
1155
+ if (template === 'render') {
1156
+ return {
1157
+ template,
1158
+ provider: 'generic',
1159
+ providerLabel: 'Render',
1160
+ useCase,
1161
+ exposure,
1162
+ title: 'Render OpenClaw Service',
1163
+ summary: 'Deploy the official OpenClaw repo as a Docker web service on Render, then point SwarmClaw at the public HTTPS URL.',
1164
+ endpoint,
1165
+ wsUrl,
1166
+ token,
1167
+ runbook: [
1168
+ 'Create a new Render Web Service from the official OpenClaw GitHub repo.',
1169
+ 'Add OPENCLAW_GATEWAY_TOKEN as a secret environment variable using the generated token below.',
1170
+ 'After the service is live, paste the HTTPS URL back into SwarmClaw and save this gateway.',
1171
+ ],
1172
+ files: [
1173
+ { name: 'render.yaml', language: 'yaml', content: buildRenderManifest() },
1174
+ { name: 'OPENCLAW_GATEWAY_TOKEN.txt', language: 'text', content: token },
1175
+ ],
1176
+ }
1177
+ }
1178
+
1179
+ if (template === 'fly') {
1180
+ return {
1181
+ template,
1182
+ provider: 'generic',
1183
+ providerLabel: 'Fly.io',
1184
+ useCase,
1185
+ exposure,
1186
+ title: 'Fly.io OpenClaw App',
1187
+ summary: 'Deploy the official OpenClaw repo on Fly.io for an always-on remote gateway with a persistent volume and HTTPS out of the box.',
1188
+ endpoint,
1189
+ wsUrl,
1190
+ token,
1191
+ runbook: [
1192
+ 'Deploy the official OpenClaw repo with this fly.toml.',
1193
+ 'Set OPENCLAW_GATEWAY_TOKEN as a Fly secret before first deploy.',
1194
+ 'Use the resulting HTTPS app URL as your SwarmClaw OpenClaw endpoint.',
1195
+ ],
1196
+ files: [
1197
+ { name: 'fly.toml', language: 'toml', content: buildFlyToml() },
1198
+ { name: 'OPENCLAW_GATEWAY_TOKEN.txt', language: 'text', content: token },
1199
+ ],
1200
+ }
1201
+ }
1202
+
1203
+ if (template === 'railway') {
1204
+ return {
1205
+ template,
1206
+ provider: 'generic',
1207
+ providerLabel: 'Railway',
1208
+ useCase,
1209
+ exposure,
1210
+ title: 'Railway OpenClaw Service',
1211
+ summary: 'Deploy the official OpenClaw repo on Railway using its Dockerfile, then attach a volume and set the generated gateway token.',
1212
+ endpoint,
1213
+ wsUrl,
1214
+ token,
1215
+ runbook: [
1216
+ 'Create a Railway project from the official OpenClaw GitHub repo so Railway builds the root Dockerfile automatically.',
1217
+ 'Attach a persistent volume at /data, then paste the generated variables below into the service variables editor.',
1218
+ 'After Railway deploys, use the public HTTPS URL as your SwarmClaw OpenClaw endpoint.',
1219
+ ],
1220
+ files: [
1221
+ { name: 'railway.json', language: 'text', content: buildRailwayConfig() },
1222
+ { name: 'railway.env', language: 'env', content: buildRailwayEnvTemplate(token) },
1223
+ ],
1224
+ }
1225
+ }
1226
+
1227
+ return {
1228
+ template: 'docker',
1229
+ provider,
1230
+ providerLabel: providerMeta.shortLabel,
1231
+ useCase,
1232
+ exposure,
1233
+ title: `${providerMeta.shortLabel} OpenClaw Smart Deploy`,
1234
+ summary: `${providerMeta.summary} ${USE_CASE_META[useCase].detail} This bundle only uses the official OpenClaw Docker image and gives you both manual Docker files and a cloud-init quickstart.`,
1235
+ endpoint,
1236
+ wsUrl,
1237
+ token,
1238
+ runbook: buildDockerRunbook(providerMeta, endpoint, useCase, exposure),
1239
+ files: [
1240
+ { name: 'cloud-init.yaml', language: 'yaml', content: buildCloudInitFile(bundleOptions) },
1241
+ { name: '.env', language: 'env', content: buildDockerEnvFile(bundleOptions) },
1242
+ { name: 'docker-compose.yml', language: 'yaml', content: buildDockerComposeFile(bundleOptions) },
1243
+ { name: 'bootstrap.sh', language: 'bash', content: buildDockerBootstrapScript(bundleOptions) },
1244
+ ...buildExposureFiles(bundleOptions),
1245
+ ],
1246
+ }
1247
+ }
1248
+
1249
+ function buildSshInvocation(config: OpenClawSshConfig, remoteCommand: string): string {
1250
+ return ['ssh', ...buildSshArgs(config), buildSshTarget(config), remoteCommand]
1251
+ .map(shellEscape)
1252
+ .join(' ')
1253
+ }
1254
+
1255
+ function buildScpInvocation(config: OpenClawSshConfig, filePaths: string[]): string {
1256
+ const destination = `${buildSshTarget(config)}:${config.targetDir || '/opt/openclaw'}/`
1257
+ return ['scp', ...buildSshArgs(config, true), ...filePaths, destination]
1258
+ .map(shellEscape)
1259
+ .join(' ')
1260
+ }
1261
+
1262
+ export async function verifyOpenClawDeployment(input?: {
1263
+ endpoint?: string | null
1264
+ credentialId?: string | null
1265
+ token?: string | null
1266
+ model?: string | null
1267
+ timeoutMs?: number
1268
+ }): Promise<OpenClawHealthResult> {
1269
+ return probeOpenClawHealth({
1270
+ endpoint: input?.endpoint || null,
1271
+ credentialId: input?.credentialId || null,
1272
+ token: input?.token || null,
1273
+ model: input?.model || null,
1274
+ timeoutMs: input?.timeoutMs,
1275
+ })
1276
+ }
1277
+
1278
+ export async function deployOpenClawBundleOverSsh(input?: {
1279
+ template?: OpenClawRemoteDeployTemplate
1280
+ target?: string | null
1281
+ token?: string | null
1282
+ scheme?: 'http' | 'https'
1283
+ port?: number
1284
+ provider?: OpenClawRemoteDeployProvider
1285
+ useCase?: OpenClawUseCaseTemplate
1286
+ exposure?: OpenClawExposurePreset
1287
+ ssh?: Partial<OpenClawSshConfig> | null
1288
+ }): Promise<OpenClawRemoteCommandResult> {
1289
+ const sshConfig = sanitizeSshConfig(input?.ssh)
1290
+ if (!sshConfig) throw new Error('SSH host is required for remote deploy.')
1291
+
1292
+ const bundle = buildOpenClawDeployBundle({
1293
+ template: input?.template,
1294
+ target: input?.target,
1295
+ token: input?.token,
1296
+ scheme: input?.scheme,
1297
+ port: input?.port,
1298
+ provider: input?.provider,
1299
+ useCase: input?.useCase,
1300
+ exposure: input?.exposure,
1301
+ })
1302
+ const materialized = await materializeBundleFiles(bundle)
1303
+ const remoteDir = sshConfig.targetDir || '/opt/openclaw'
1304
+ const mkdirCommand = buildSshInvocation(sshConfig, `mkdir -p ${shellEscape(remoteDir)}`)
1305
+ const scpCommand = buildScpInvocation(sshConfig, materialized.filePaths)
1306
+ const bootstrapCommand = buildSshInvocation(
1307
+ sshConfig,
1308
+ `cd ${shellEscape(remoteDir)} && chmod +x bootstrap.sh && OPENCLAW_APP_DIR=${shellEscape(remoteDir)} bash ./bootstrap.sh`,
1309
+ )
1310
+ const command = `${mkdirCommand} && ${scpCommand} && ${bootstrapCommand}`
1311
+ const result = await startRemoteCommand({
1312
+ action: 'ssh-deploy',
1313
+ target: sshConfig.host,
1314
+ command,
1315
+ summary: `Deploying OpenClaw to ${sshConfig.host} over SSH.`,
1316
+ })
1317
+ return {
1318
+ ...result,
1319
+ token: bundle.token,
1320
+ bundle,
1321
+ }
1322
+ }
1323
+
1324
+ export const deployOpenClawOverSsh = deployOpenClawBundleOverSsh
1325
+
1326
+ export async function runOpenClawRemoteLifecycleAction(input?: {
1327
+ action: 'start' | 'stop' | 'restart' | 'upgrade' | 'backup' | 'restore' | 'rotate-token'
1328
+ ssh?: Partial<OpenClawSshConfig> | null
1329
+ image?: string | null
1330
+ token?: string | null
1331
+ backupPath?: string | null
1332
+ }): Promise<OpenClawRemoteCommandResult> {
1333
+ const sshConfig = sanitizeSshConfig(input?.ssh)
1334
+ if (!sshConfig) throw new Error('SSH host is required for remote lifecycle actions.')
1335
+ const remoteDir = sshConfig.targetDir || '/opt/openclaw'
1336
+ const image = normalizeText(input?.image) || 'openclaw:latest'
1337
+ const action = input?.action || 'restart'
1338
+ let remoteCommand = ''
1339
+ let summary = ''
1340
+ let rotatedToken: string | undefined
1341
+ let backupPath: string | null = null
1342
+
1343
+ if (action === 'start') {
1344
+ remoteCommand = `cd ${shellEscape(remoteDir)} && docker compose up -d`
1345
+ summary = `Starting OpenClaw on ${sshConfig.host}.`
1346
+ } else if (action === 'stop') {
1347
+ remoteCommand = `cd ${shellEscape(remoteDir)} && docker compose down`
1348
+ summary = `Stopping OpenClaw on ${sshConfig.host}.`
1349
+ } else if (action === 'restart') {
1350
+ remoteCommand = `cd ${shellEscape(remoteDir)} && docker compose restart`
1351
+ summary = `Restarting OpenClaw on ${sshConfig.host}.`
1352
+ } else if (action === 'upgrade') {
1353
+ remoteCommand = `cd ${shellEscape(remoteDir)} && docker pull ${shellEscape(image)} && docker compose up -d`
1354
+ summary = `Pulling ${image} and recreating the OpenClaw stack on ${sshConfig.host}.`
1355
+ } else if (action === 'backup') {
1356
+ backupPath = path.posix.join(remoteDir, 'backups', `openclaw-backup-${Date.now()}.tgz`)
1357
+ remoteCommand = `cd ${shellEscape(remoteDir)} && mkdir -p backups && tar -czf ${shellEscape(backupPath)} .env docker-compose.yml .openclaw workspace && printf '%s\\n' ${shellEscape(backupPath)}`
1358
+ summary = `Creating an OpenClaw backup on ${sshConfig.host}.`
1359
+ } else if (action === 'restore') {
1360
+ backupPath = normalizeText(input?.backupPath) || null
1361
+ if (!backupPath) throw new Error('A remote backup path is required for restore.')
1362
+ remoteCommand = `cd ${shellEscape(remoteDir)} && tar -xzf ${shellEscape(backupPath)} -C ${shellEscape(remoteDir)} && docker compose up -d`
1363
+ summary = `Restoring OpenClaw from ${backupPath} on ${sshConfig.host}.`
1364
+ } else {
1365
+ rotatedToken = normalizeToken(input?.token) || generateOpenClawGatewayToken()
1366
+ remoteCommand = `cd ${shellEscape(remoteDir)} && sed -i.bak -e ${shellEscape(`s/^OPENCLAW_GATEWAY_TOKEN=.*/OPENCLAW_GATEWAY_TOKEN=${rotatedToken}/`)} .env && docker compose up -d && printf '%s\\n' ${shellEscape(rotatedToken)}`
1367
+ summary = `Rotating the OpenClaw gateway token on ${sshConfig.host}.`
1368
+ }
1369
+
1370
+ const command = buildSshInvocation(sshConfig, remoteCommand)
1371
+ const result = await startRemoteCommand({
1372
+ action,
1373
+ target: sshConfig.host,
1374
+ command,
1375
+ summary,
1376
+ backupPath,
1377
+ })
1378
+ return {
1379
+ ...result,
1380
+ token: rotatedToken,
1381
+ }
1382
+ }
1383
+
1384
+ export const runOpenClawRemoteLifecycle = runOpenClawRemoteLifecycleAction