@swarmclawai/swarmclaw 0.7.6 → 0.7.8

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 (86) hide show
  1. package/README.md +19 -10
  2. package/package.json +1 -1
  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 +13 -1
  7. package/src/app/api/connectors/[id]/route.ts +20 -2
  8. package/src/app/api/connectors/route.ts +12 -8
  9. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  10. package/src/app/api/external-agents/[id]/route.ts +38 -6
  11. package/src/app/api/external-agents/route.ts +17 -1
  12. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  13. package/src/app/api/gateways/[id]/route.ts +53 -1
  14. package/src/app/api/gateways/route.ts +53 -0
  15. package/src/app/api/openclaw/deploy/route.ts +139 -0
  16. package/src/app/api/projects/[id]/route.ts +6 -2
  17. package/src/app/api/projects/route.ts +4 -3
  18. package/src/app/api/secrets/[id]/route.ts +1 -0
  19. package/src/app/api/secrets/route.ts +2 -1
  20. package/src/app/api/settings/route.ts +2 -0
  21. package/src/cli/index.js +40 -0
  22. package/src/cli/index.test.js +68 -0
  23. package/src/cli/spec.js +60 -0
  24. package/src/components/agents/agent-sheet.tsx +281 -33
  25. package/src/components/auth/setup-wizard.tsx +75 -2
  26. package/src/components/chat/chat-area.tsx +36 -19
  27. package/src/components/chat/chat-header.tsx +4 -0
  28. package/src/components/chat/delegation-banner.test.ts +14 -1
  29. package/src/components/chat/delegation-banner.tsx +1 -1
  30. package/src/components/gateways/gateway-sheet.tsx +140 -8
  31. package/src/components/layout/app-layout.tsx +40 -23
  32. package/src/components/openclaw/openclaw-deploy-panel.tsx +591 -9
  33. package/src/components/projects/project-detail.tsx +217 -0
  34. package/src/components/projects/project-sheet.tsx +176 -4
  35. package/src/components/providers/provider-list.tsx +221 -17
  36. package/src/components/shared/settings/section-capability-policy.tsx +38 -0
  37. package/src/components/shared/settings/section-voice.tsx +11 -3
  38. package/src/components/tasks/approvals-panel.tsx +177 -18
  39. package/src/components/tasks/task-board.tsx +137 -23
  40. package/src/components/tasks/task-card.tsx +29 -0
  41. package/src/components/tasks/task-sheet.tsx +16 -4
  42. package/src/lib/server/agent-runtime-config.ts +142 -7
  43. package/src/lib/server/agent-thread-session.ts +9 -1
  44. package/src/lib/server/capability-router.test.ts +22 -0
  45. package/src/lib/server/capability-router.ts +54 -18
  46. package/src/lib/server/chat-execution.ts +33 -3
  47. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  48. package/src/lib/server/connectors/manager.ts +99 -74
  49. package/src/lib/server/daemon-state.ts +83 -46
  50. package/src/lib/server/elevenlabs.test.ts +59 -1
  51. package/src/lib/server/heartbeat-service.ts +5 -1
  52. package/src/lib/server/main-agent-loop.test.ts +260 -0
  53. package/src/lib/server/main-agent-loop.ts +559 -14
  54. package/src/lib/server/openclaw-deploy.test.ts +8 -0
  55. package/src/lib/server/openclaw-deploy.ts +679 -19
  56. package/src/lib/server/orchestrator-lg.ts +1 -0
  57. package/src/lib/server/orchestrator.ts +11 -0
  58. package/src/lib/server/plugins.ts +6 -1
  59. package/src/lib/server/project-context.ts +162 -0
  60. package/src/lib/server/project-utils.ts +150 -0
  61. package/src/lib/server/queue-followups.test.ts +147 -2
  62. package/src/lib/server/queue.ts +278 -8
  63. package/src/lib/server/session-run-manager.ts +31 -0
  64. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  65. package/src/lib/server/session-tools/connector.ts +26 -1
  66. package/src/lib/server/session-tools/context.ts +5 -0
  67. package/src/lib/server/session-tools/crud.ts +265 -76
  68. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  69. package/src/lib/server/session-tools/delegate.ts +38 -2
  70. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  71. package/src/lib/server/session-tools/memory.ts +14 -2
  72. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  73. package/src/lib/server/session-tools/platform.ts +60 -19
  74. package/src/lib/server/session-tools/web-inputs.test.ts +17 -0
  75. package/src/lib/server/session-tools/web.ts +153 -6
  76. package/src/lib/server/stream-agent-chat.test.ts +27 -2
  77. package/src/lib/server/stream-agent-chat.ts +104 -30
  78. package/src/lib/server/tool-aliases.ts +2 -0
  79. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  80. package/src/lib/server/tool-capability-policy.ts +29 -1
  81. package/src/lib/server/tool-planning.test.ts +44 -0
  82. package/src/lib/server/tool-planning.ts +269 -0
  83. package/src/lib/setup-defaults.ts +2 -2
  84. package/src/lib/tool-definitions.ts +2 -1
  85. package/src/lib/validation/schemas.ts +9 -0
  86. package/src/types/index.ts +104 -0
@@ -1,13 +1,17 @@
1
1
  import { randomBytes } from 'node:crypto'
2
2
  import { existsSync } from 'node:fs'
3
+ import { promises as fs } from 'node:fs'
4
+ import os from 'node:os'
3
5
  import path from 'node:path'
4
6
  import {
5
7
  getManagedProcess,
6
8
  killManagedProcess,
7
9
  removeManagedProcess,
8
10
  startManagedProcess,
11
+ type ProcessStatus,
9
12
  } from './process-manager'
10
13
  import { normalizeOpenClawEndpoint, deriveOpenClawWsUrl } from '@/lib/openclaw-endpoint'
14
+ import { probeOpenClawHealth, type OpenClawHealthResult } from './openclaw-health'
11
15
 
12
16
  export type OpenClawRemoteDeployTemplate = 'docker' | 'render' | 'fly' | 'railway'
13
17
  export type OpenClawRemoteDeployProvider =
@@ -20,6 +24,8 @@ export type OpenClawRemoteDeployProvider =
20
24
  | 'azure'
21
25
  | 'oci'
22
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'
23
29
 
24
30
  export interface OpenClawLocalDeployStatus {
25
31
  running: boolean
@@ -36,6 +42,22 @@ export interface OpenClawLocalDeployStatus {
36
42
  installCommand: string
37
43
  }
38
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
+
39
61
  export interface OpenClawDeployBundleFile {
40
62
  name: string
41
63
  language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
@@ -46,6 +68,8 @@ export interface OpenClawDeployBundle {
46
68
  template: OpenClawRemoteDeployTemplate
47
69
  provider: OpenClawRemoteDeployProvider
48
70
  providerLabel: string
71
+ useCase: OpenClawUseCaseTemplate
72
+ exposure: OpenClawExposurePreset
49
73
  title: string
50
74
  summary: string
51
75
  endpoint: string
@@ -55,6 +79,24 @@ export interface OpenClawDeployBundle {
55
79
  files: OpenClawDeployBundleFile[]
56
80
  }
57
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
+
58
100
  interface LocalRuntimeState {
59
101
  processId: string | null
60
102
  port: number
@@ -65,8 +107,20 @@ interface LocalRuntimeState {
65
107
  lastError: string | null
66
108
  }
67
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
+
68
121
  interface DeployRuntimeState {
69
122
  local: LocalRuntimeState
123
+ remote: RemoteRuntimeState
70
124
  }
71
125
 
72
126
  interface RemoteProviderMeta {
@@ -77,6 +131,22 @@ interface RemoteProviderMeta {
77
131
  summary: string
78
132
  }
79
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
+
80
150
  const DEFAULT_LOCAL_PORT = 18789
81
151
  const DEFAULT_REMOTE_PORT = 18789
82
152
  const GLOBAL_KEY = '__swarmclaw_openclaw_deploy__' as const
@@ -147,6 +217,82 @@ const REMOTE_PROVIDER_META: Record<OpenClawRemoteDeployProvider, RemoteProviderM
147
217
  },
148
218
  }
149
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
+
150
296
  function getRuntimeState(): DeployRuntimeState {
151
297
  const fallback: DeployRuntimeState = {
152
298
  local: {
@@ -158,6 +304,16 @@ function getRuntimeState(): DeployRuntimeState {
158
304
  startedAt: null,
159
305
  lastError: null,
160
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
+ },
161
317
  }
162
318
  const globalState = globalThis as typeof globalThis & { [GLOBAL_KEY]?: DeployRuntimeState }
163
319
  if (!globalState[GLOBAL_KEY]) {
@@ -228,6 +384,10 @@ function normalizeToken(value: unknown): string | null {
228
384
  return typeof value === 'string' && value.trim() ? value.trim() : null
229
385
  }
230
386
 
387
+ function normalizeText(value: unknown): string | null {
388
+ return typeof value === 'string' && value.trim() ? value.trim() : null
389
+ }
390
+
231
391
  function normalizeRemoteProvider(value: unknown): OpenClawRemoteDeployProvider {
232
392
  if (
233
393
  value === 'hetzner'
@@ -245,6 +405,147 @@ function normalizeRemoteProvider(value: unknown): OpenClawRemoteDeployProvider {
245
405
  return 'hetzner'
246
406
  }
247
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
+
248
549
  function wait(ms: number): Promise<void> {
249
550
  return new Promise((resolve) => setTimeout(resolve, ms))
250
551
  }
@@ -296,6 +597,38 @@ export function getOpenClawLocalDeployStatus(): OpenClawLocalDeployStatus {
296
597
  return currentLocalStatus()
297
598
  }
298
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
+
299
632
  export function generateOpenClawGatewayToken(): string {
300
633
  return randomBytes(24).toString('base64url')
301
634
  }
@@ -387,6 +720,17 @@ export function stopOpenClawLocalDeploy(): OpenClawLocalDeployStatus {
387
720
  return currentLocalStatus()
388
721
  }
389
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
+
390
734
  function ensureSchemeAndPort(raw: string, scheme: 'http' | 'https', port: number): string {
391
735
  const trimmed = raw.trim()
392
736
  if (!trimmed) {
@@ -417,21 +761,36 @@ function indentBlock(value: string, spaces: number): string {
417
761
  .join('\n')
418
762
  }
419
763
 
420
- function buildDockerComposeFile(): string {
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 {
421
779
  return `services:
422
780
  openclaw-gateway:
423
781
  image: \${OPENCLAW_IMAGE:-openclaw:latest}
424
782
  environment:
425
783
  HOME: /home/node
426
784
  TERM: xterm-256color
785
+ NODE_OPTIONS: \${OPENCLAW_NODE_OPTIONS:-}
427
786
  OPENCLAW_GATEWAY_TOKEN: \${OPENCLAW_GATEWAY_TOKEN}
428
787
  OPENCLAW_GATEWAY_BIND: \${OPENCLAW_GATEWAY_BIND:-lan}
429
788
  volumes:
430
789
  - \${OPENCLAW_CONFIG_DIR:-./.openclaw}:/home/node/.openclaw
431
790
  - \${OPENCLAW_WORKSPACE_DIR:-./workspace}:/home/node/.openclaw/workspace
432
791
  ports:
433
- - "\${OPENCLAW_GATEWAY_PORT:-18789}:18789"
434
- - "\${OPENCLAW_BRIDGE_PORT:-18790}:18790"
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"
435
794
  init: true
436
795
  restart: unless-stopped
437
796
  command:
@@ -464,18 +823,22 @@ function buildDockerComposeFile(): string {
464
823
  `
465
824
  }
466
825
 
467
- function buildDockerEnvFile(token: string): string {
826
+ function buildDockerEnvFile(options: DockerBundleOptions): string {
468
827
  return `OPENCLAW_IMAGE=openclaw:latest
469
- OPENCLAW_GATEWAY_TOKEN=${token}
828
+ OPENCLAW_GATEWAY_TOKEN=${options.token}
470
829
  OPENCLAW_GATEWAY_BIND=lan
830
+ OPENCLAW_HOST_BIND=${resolveHostBindAddress(options.useCase, options.exposure)}
471
831
  OPENCLAW_GATEWAY_PORT=18789
472
832
  OPENCLAW_BRIDGE_PORT=18790
473
833
  OPENCLAW_CONFIG_DIR=./.openclaw
474
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 || ''}
475
838
  `
476
839
  }
477
840
 
478
- function buildDockerBootstrapScript(): string {
841
+ function buildDockerBootstrapScript(options: DockerBundleOptions): string {
479
842
  return `#!/usr/bin/env bash
480
843
  set -euo pipefail
481
844
 
@@ -483,7 +846,7 @@ APP_DIR="\${OPENCLAW_APP_DIR:-$HOME/openclaw}"
483
846
 
484
847
  mkdir -p "$APP_DIR"
485
848
  cd "$APP_DIR"
486
- mkdir -p .openclaw workspace
849
+ mkdir -p .openclaw workspace backups
487
850
 
488
851
  if ! command -v docker >/dev/null 2>&1; then
489
852
  echo "Docker is required. On Ubuntu 24.04 you can install it with:"
@@ -493,13 +856,27 @@ fi
493
856
 
494
857
  docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"
495
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
496
862
  docker compose ps
863
+ echo "Use case: ${options.useCase}"
864
+ echo "Exposure preset: ${options.exposure}"
497
865
  `
498
866
  }
499
867
 
500
- function buildCloudInitFile(token: string): string {
501
- const envFile = buildDockerEnvFile(token)
502
- const composeFile = buildDockerComposeFile()
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')
503
880
  return `#cloud-config
504
881
  package_update: true
505
882
  package_upgrade: true
@@ -519,15 +896,131 @@ ${indentBlock(envFile, 6)}
519
896
  permissions: "0644"
520
897
  content: |
521
898
  ${indentBlock(composeFile, 6)}
522
- runcmd:
523
- - mkdir -p /opt/openclaw/.openclaw /opt/openclaw/workspace
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
524
907
  - systemctl enable --now docker
525
908
  - bash -lc 'cd /opt/openclaw && docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"'
526
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'
527
911
  final_message: "OpenClaw gateway bootstrap complete. Run: sudo docker compose -f /opt/openclaw/docker-compose.yml ps"
528
912
  `
529
913
  }
530
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
+
531
1024
  function buildRenderManifest(): string {
532
1025
  return `services:
533
1026
  - type: web
@@ -610,12 +1103,23 @@ function buildRailwayConfig(): string {
610
1103
  function buildDockerRunbook(
611
1104
  providerMeta: RemoteProviderMeta,
612
1105
  endpoint: string,
1106
+ useCase: OpenClawUseCaseTemplate,
1107
+ exposure: OpenClawExposurePreset,
613
1108
  ): string[] {
614
1109
  const endpointHost = deriveRemoteDeploymentName(endpoint)
615
1110
  return [
616
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}.`,
617
1113
  'Let first boot finish, then confirm the service with: sudo docker compose -f /opt/openclaw/docker-compose.yml ps',
618
- `Point a DNS name, reverse proxy, or Tailscale hostname at ${endpointHost} and keep the generated token private.`,
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.`,
619
1123
  'Use the generated endpoint and token in SwarmClaw to save the gateway profile.',
620
1124
  ]
621
1125
  }
@@ -627,6 +1131,8 @@ export function buildOpenClawDeployBundle(input?: {
627
1131
  scheme?: 'http' | 'https'
628
1132
  port?: number
629
1133
  provider?: OpenClawRemoteDeployProvider
1134
+ useCase?: OpenClawUseCaseTemplate
1135
+ exposure?: OpenClawExposurePreset
630
1136
  }): OpenClawDeployBundle {
631
1137
  const template = input?.template || 'docker'
632
1138
  const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
@@ -637,12 +1143,22 @@ export function buildOpenClawDeployBundle(input?: {
637
1143
  const wsUrl = deriveOpenClawWsUrl(endpoint)
638
1144
  const provider = normalizeRemoteProvider(input?.provider)
639
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
+ }
640
1154
 
641
1155
  if (template === 'render') {
642
1156
  return {
643
1157
  template,
644
1158
  provider: 'generic',
645
1159
  providerLabel: 'Render',
1160
+ useCase,
1161
+ exposure,
646
1162
  title: 'Render OpenClaw Service',
647
1163
  summary: 'Deploy the official OpenClaw repo as a Docker web service on Render, then point SwarmClaw at the public HTTPS URL.',
648
1164
  endpoint,
@@ -665,6 +1181,8 @@ export function buildOpenClawDeployBundle(input?: {
665
1181
  template,
666
1182
  provider: 'generic',
667
1183
  providerLabel: 'Fly.io',
1184
+ useCase,
1185
+ exposure,
668
1186
  title: 'Fly.io OpenClaw App',
669
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.',
670
1188
  endpoint,
@@ -687,6 +1205,8 @@ export function buildOpenClawDeployBundle(input?: {
687
1205
  template,
688
1206
  provider: 'generic',
689
1207
  providerLabel: 'Railway',
1208
+ useCase,
1209
+ exposure,
690
1210
  title: 'Railway OpenClaw Service',
691
1211
  summary: 'Deploy the official OpenClaw repo on Railway using its Dockerfile, then attach a volume and set the generated gateway token.',
692
1212
  endpoint,
@@ -708,17 +1228,157 @@ export function buildOpenClawDeployBundle(input?: {
708
1228
  template: 'docker',
709
1229
  provider,
710
1230
  providerLabel: providerMeta.shortLabel,
1231
+ useCase,
1232
+ exposure,
711
1233
  title: `${providerMeta.shortLabel} OpenClaw Smart Deploy`,
712
- summary: `${providerMeta.summary} This bundle only uses the official OpenClaw Docker image and gives you both manual Docker files and a cloud-init quickstart.`,
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.`,
713
1235
  endpoint,
714
1236
  wsUrl,
715
1237
  token,
716
- runbook: buildDockerRunbook(providerMeta, endpoint),
1238
+ runbook: buildDockerRunbook(providerMeta, endpoint, useCase, exposure),
717
1239
  files: [
718
- { name: 'cloud-init.yaml', language: 'yaml', content: buildCloudInitFile(token) },
719
- { name: '.env', language: 'env', content: buildDockerEnvFile(token) },
720
- { name: 'docker-compose.yml', language: 'yaml', content: buildDockerComposeFile() },
721
- { name: 'bootstrap.sh', language: 'bash', content: buildDockerBootstrapScript() },
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),
722
1245
  ],
723
1246
  }
724
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