@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.
- package/README.md +41 -10
- package/package.json +2 -2
- package/src/app/api/agents/[id]/route.ts +16 -0
- package/src/app/api/agents/route.ts +2 -0
- package/src/app/api/chats/[id]/route.ts +21 -1
- package/src/app/api/chats/route.ts +12 -1
- package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
- package/src/app/api/external-agents/[id]/route.ts +38 -6
- package/src/app/api/external-agents/route.ts +17 -1
- package/src/app/api/gateways/[id]/health/route.ts +8 -0
- package/src/app/api/gateways/[id]/route.ts +53 -1
- package/src/app/api/gateways/route.ts +53 -0
- package/src/app/api/openclaw/deploy/route.ts +240 -0
- package/src/cli/index.js +53 -0
- package/src/cli/index.test.js +102 -0
- package/src/cli/spec.js +79 -0
- package/src/components/agents/agent-sheet.tsx +97 -19
- package/src/components/auth/setup-wizard.tsx +111 -54
- package/src/components/gateways/gateway-sheet.tsx +202 -10
- package/src/components/openclaw/openclaw-deploy-panel.tsx +1208 -0
- package/src/components/providers/provider-list.tsx +321 -22
- package/src/lib/server/agent-runtime-config.ts +142 -7
- package/src/lib/server/agent-thread-session.ts +9 -1
- package/src/lib/server/chat-execution.ts +8 -2
- package/src/lib/server/heartbeat-service.ts +5 -1
- package/src/lib/server/openclaw-deploy.test.ts +75 -0
- package/src/lib/server/openclaw-deploy.ts +1384 -0
- package/src/lib/server/orchestrator.ts +9 -0
- package/src/lib/server/queue.ts +45 -2
- package/src/lib/setup-defaults.ts +2 -2
- package/src/lib/validation/schemas.ts +9 -0
- 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
|