@swarmclawai/swarmclaw 0.7.4 → 0.7.6
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 +32 -9
- package/package.json +2 -2
- package/src/app/api/agents/[id]/thread/route.ts +4 -89
- package/src/app/api/openclaw/deploy/route.ts +101 -0
- package/src/cli/index.js +13 -0
- package/src/cli/index.test.js +34 -0
- package/src/cli/spec.js +19 -0
- package/src/components/auth/setup-wizard.tsx +36 -52
- package/src/components/gateways/gateway-sheet.tsx +63 -3
- package/src/components/openclaw/openclaw-deploy-panel.tsx +626 -0
- package/src/components/providers/provider-list.tsx +103 -8
- package/src/lib/server/agent-thread-session.test.ts +85 -0
- package/src/lib/server/agent-thread-session.ts +123 -0
- package/src/lib/server/data-dir.test.ts +56 -0
- package/src/lib/server/data-dir.ts +15 -9
- package/src/lib/server/heartbeat-service.ts +18 -5
- package/src/lib/server/heartbeat-wake.ts +6 -2
- package/src/lib/server/openclaw-deploy.test.ts +67 -0
- package/src/lib/server/openclaw-deploy.ts +724 -0
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
getManagedProcess,
|
|
6
|
+
killManagedProcess,
|
|
7
|
+
removeManagedProcess,
|
|
8
|
+
startManagedProcess,
|
|
9
|
+
} from './process-manager'
|
|
10
|
+
import { normalizeOpenClawEndpoint, deriveOpenClawWsUrl } from '@/lib/openclaw-endpoint'
|
|
11
|
+
|
|
12
|
+
export type OpenClawRemoteDeployTemplate = 'docker' | 'render' | 'fly' | 'railway'
|
|
13
|
+
export type OpenClawRemoteDeployProvider =
|
|
14
|
+
| 'hetzner'
|
|
15
|
+
| 'digitalocean'
|
|
16
|
+
| 'vultr'
|
|
17
|
+
| 'linode'
|
|
18
|
+
| 'lightsail'
|
|
19
|
+
| 'gcp'
|
|
20
|
+
| 'azure'
|
|
21
|
+
| 'oci'
|
|
22
|
+
| 'generic'
|
|
23
|
+
|
|
24
|
+
export interface OpenClawLocalDeployStatus {
|
|
25
|
+
running: boolean
|
|
26
|
+
processId: string | null
|
|
27
|
+
pid: number | null
|
|
28
|
+
port: number
|
|
29
|
+
endpoint: string
|
|
30
|
+
wsUrl: string
|
|
31
|
+
token: string | null
|
|
32
|
+
startedAt: number | null
|
|
33
|
+
tail: string
|
|
34
|
+
lastError: string | null
|
|
35
|
+
launchCommand: string
|
|
36
|
+
installCommand: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OpenClawDeployBundleFile {
|
|
40
|
+
name: string
|
|
41
|
+
language: 'bash' | 'yaml' | 'env' | 'toml' | 'text'
|
|
42
|
+
content: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface OpenClawDeployBundle {
|
|
46
|
+
template: OpenClawRemoteDeployTemplate
|
|
47
|
+
provider: OpenClawRemoteDeployProvider
|
|
48
|
+
providerLabel: string
|
|
49
|
+
title: string
|
|
50
|
+
summary: string
|
|
51
|
+
endpoint: string
|
|
52
|
+
wsUrl: string
|
|
53
|
+
token: string
|
|
54
|
+
runbook: string[]
|
|
55
|
+
files: OpenClawDeployBundleFile[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface LocalRuntimeState {
|
|
59
|
+
processId: string | null
|
|
60
|
+
port: number
|
|
61
|
+
endpoint: string
|
|
62
|
+
wsUrl: string
|
|
63
|
+
token: string | null
|
|
64
|
+
startedAt: number | null
|
|
65
|
+
lastError: string | null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface DeployRuntimeState {
|
|
69
|
+
local: LocalRuntimeState
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface RemoteProviderMeta {
|
|
73
|
+
id: OpenClawRemoteDeployProvider
|
|
74
|
+
label: string
|
|
75
|
+
shortLabel: string
|
|
76
|
+
bootstrapHint: string
|
|
77
|
+
summary: string
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const DEFAULT_LOCAL_PORT = 18789
|
|
81
|
+
const DEFAULT_REMOTE_PORT = 18789
|
|
82
|
+
const GLOBAL_KEY = '__swarmclaw_openclaw_deploy__' as const
|
|
83
|
+
|
|
84
|
+
const REMOTE_PROVIDER_META: Record<OpenClawRemoteDeployProvider, RemoteProviderMeta> = {
|
|
85
|
+
hetzner: {
|
|
86
|
+
id: 'hetzner',
|
|
87
|
+
label: 'Hetzner Cloud',
|
|
88
|
+
shortLabel: 'Hetzner',
|
|
89
|
+
bootstrapHint: 'Paste cloud-init.yaml into the Cloud Config field when you create the server.',
|
|
90
|
+
summary: 'Cheap Ubuntu VPS with excellent fit for always-on OpenClaw control planes.',
|
|
91
|
+
},
|
|
92
|
+
digitalocean: {
|
|
93
|
+
id: 'digitalocean',
|
|
94
|
+
label: 'DigitalOcean Droplet',
|
|
95
|
+
shortLabel: 'DigitalOcean',
|
|
96
|
+
bootstrapHint: 'Paste cloud-init.yaml into the User Data field when you create the Droplet.',
|
|
97
|
+
summary: 'Simple Ubuntu VPS path with predictable pricing and easy DNS + volume add-ons.',
|
|
98
|
+
},
|
|
99
|
+
vultr: {
|
|
100
|
+
id: 'vultr',
|
|
101
|
+
label: 'Vultr Cloud Compute',
|
|
102
|
+
shortLabel: 'Vultr',
|
|
103
|
+
bootstrapHint: 'Paste cloud-init.yaml into User Data / Startup Script on the instance create screen.',
|
|
104
|
+
summary: 'Straightforward VPS deployment with broad region coverage.',
|
|
105
|
+
},
|
|
106
|
+
linode: {
|
|
107
|
+
id: 'linode',
|
|
108
|
+
label: 'Linode',
|
|
109
|
+
shortLabel: 'Linode',
|
|
110
|
+
bootstrapHint: 'Paste cloud-init.yaml into your instance User Data during provisioning.',
|
|
111
|
+
summary: 'Good fit for users who want an uncomplicated Linux VM with persistent disks.',
|
|
112
|
+
},
|
|
113
|
+
lightsail: {
|
|
114
|
+
id: 'lightsail',
|
|
115
|
+
label: 'AWS Lightsail',
|
|
116
|
+
shortLabel: 'Lightsail',
|
|
117
|
+
bootstrapHint: 'Paste cloud-init.yaml into the Launch script / user data area on instance creation.',
|
|
118
|
+
summary: 'AWS-backed VPS option for users who want a simpler path than full EC2.',
|
|
119
|
+
},
|
|
120
|
+
gcp: {
|
|
121
|
+
id: 'gcp',
|
|
122
|
+
label: 'Google Cloud',
|
|
123
|
+
shortLabel: 'GCP',
|
|
124
|
+
bootstrapHint: 'Use an Ubuntu or Debian VM and provide cloud-init.yaml as startup metadata or cloud-init user data.',
|
|
125
|
+
summary: 'Good option when you already use Google Cloud networking or IAM.',
|
|
126
|
+
},
|
|
127
|
+
azure: {
|
|
128
|
+
id: 'azure',
|
|
129
|
+
label: 'Azure',
|
|
130
|
+
shortLabel: 'Azure',
|
|
131
|
+
bootstrapHint: 'Paste cloud-init.yaml into Custom data / cloud-init when creating the VM.',
|
|
132
|
+
summary: 'Useful for teams already standardized on Azure subscriptions and networking.',
|
|
133
|
+
},
|
|
134
|
+
oci: {
|
|
135
|
+
id: 'oci',
|
|
136
|
+
label: 'Oracle Cloud',
|
|
137
|
+
shortLabel: 'OCI',
|
|
138
|
+
bootstrapHint: 'Paste cloud-init.yaml into cloud-init user data when creating the instance.',
|
|
139
|
+
summary: 'A practical low-cost VPS path if you already operate in Oracle Cloud.',
|
|
140
|
+
},
|
|
141
|
+
generic: {
|
|
142
|
+
id: 'generic',
|
|
143
|
+
label: 'Any Ubuntu VPS',
|
|
144
|
+
shortLabel: 'Generic VPS',
|
|
145
|
+
bootstrapHint: 'Use cloud-init.yaml on any Ubuntu 24.04 host with cloud-init, or copy bootstrap.sh after SSHing in.',
|
|
146
|
+
summary: 'Generic fallback for bare metal, homelab servers, and providers not listed above.',
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getRuntimeState(): DeployRuntimeState {
|
|
151
|
+
const fallback: DeployRuntimeState = {
|
|
152
|
+
local: {
|
|
153
|
+
processId: null,
|
|
154
|
+
port: DEFAULT_LOCAL_PORT,
|
|
155
|
+
endpoint: normalizeOpenClawEndpoint(`http://127.0.0.1:${DEFAULT_LOCAL_PORT}`),
|
|
156
|
+
wsUrl: deriveOpenClawWsUrl(`http://127.0.0.1:${DEFAULT_LOCAL_PORT}`),
|
|
157
|
+
token: null,
|
|
158
|
+
startedAt: null,
|
|
159
|
+
lastError: null,
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
const globalState = globalThis as typeof globalThis & { [GLOBAL_KEY]?: DeployRuntimeState }
|
|
163
|
+
if (!globalState[GLOBAL_KEY]) {
|
|
164
|
+
globalState[GLOBAL_KEY] = fallback
|
|
165
|
+
}
|
|
166
|
+
return globalState[GLOBAL_KEY] || fallback
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function shellEscape(value: string): string {
|
|
170
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function resolveBundledOpenClawBinary(): string {
|
|
174
|
+
const binName = process.platform === 'win32' ? 'openclaw.cmd' : 'openclaw'
|
|
175
|
+
const candidates = [
|
|
176
|
+
path.join(process.cwd(), 'node_modules', '.bin', binName),
|
|
177
|
+
path.join(process.cwd(), '.next', 'standalone', 'node_modules', '.bin', binName),
|
|
178
|
+
]
|
|
179
|
+
for (const candidate of candidates) {
|
|
180
|
+
if (existsSync(candidate)) return candidate
|
|
181
|
+
}
|
|
182
|
+
return 'openclaw'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildLocalRunCommand(port: number, token?: string | null): string {
|
|
186
|
+
const parts = [
|
|
187
|
+
'npx',
|
|
188
|
+
'openclaw',
|
|
189
|
+
'gateway',
|
|
190
|
+
'run',
|
|
191
|
+
'--allow-unconfigured',
|
|
192
|
+
'--force',
|
|
193
|
+
'--bind',
|
|
194
|
+
'loopback',
|
|
195
|
+
'--port',
|
|
196
|
+
String(port),
|
|
197
|
+
]
|
|
198
|
+
if (token) {
|
|
199
|
+
parts.push('--auth', 'token', '--token', token)
|
|
200
|
+
}
|
|
201
|
+
return parts.join(' ')
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildLocalInstallCommand(port: number, token?: string | null): string {
|
|
205
|
+
const parts = [
|
|
206
|
+
'npx',
|
|
207
|
+
'openclaw',
|
|
208
|
+
'gateway',
|
|
209
|
+
'install',
|
|
210
|
+
'--port',
|
|
211
|
+
String(port),
|
|
212
|
+
]
|
|
213
|
+
if (token) parts.push('--token', token)
|
|
214
|
+
return `${parts.join(' ')} && npx openclaw gateway start`
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sanitizePort(value: unknown, fallback = DEFAULT_LOCAL_PORT): number {
|
|
218
|
+
const parsed = typeof value === 'number'
|
|
219
|
+
? value
|
|
220
|
+
: typeof value === 'string'
|
|
221
|
+
? Number.parseInt(value, 10)
|
|
222
|
+
: Number.NaN
|
|
223
|
+
if (!Number.isFinite(parsed)) return fallback
|
|
224
|
+
return Math.max(1024, Math.min(65535, Math.trunc(parsed)))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function normalizeToken(value: unknown): string | null {
|
|
228
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function normalizeRemoteProvider(value: unknown): OpenClawRemoteDeployProvider {
|
|
232
|
+
if (
|
|
233
|
+
value === 'hetzner'
|
|
234
|
+
|| value === 'digitalocean'
|
|
235
|
+
|| value === 'vultr'
|
|
236
|
+
|| value === 'linode'
|
|
237
|
+
|| value === 'lightsail'
|
|
238
|
+
|| value === 'gcp'
|
|
239
|
+
|| value === 'azure'
|
|
240
|
+
|| value === 'oci'
|
|
241
|
+
|| value === 'generic'
|
|
242
|
+
) {
|
|
243
|
+
return value
|
|
244
|
+
}
|
|
245
|
+
return 'hetzner'
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function wait(ms: number): Promise<void> {
|
|
249
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function waitForLocalRuntime(processId: string, attempts = 12): Promise<void> {
|
|
253
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
254
|
+
const process = getManagedProcess(processId)
|
|
255
|
+
if (!process || process.status !== 'running') break
|
|
256
|
+
if ((process.log || '').toLowerCase().includes('listening')) return
|
|
257
|
+
await wait(500)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readTail(text: string, size = 1200): string {
|
|
262
|
+
if (!text) return ''
|
|
263
|
+
return text.length <= size ? text : text.slice(text.length - size)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function currentLocalStatus(): OpenClawLocalDeployStatus {
|
|
267
|
+
const state = getRuntimeState()
|
|
268
|
+
const processId = state.local.processId
|
|
269
|
+
const process = processId ? getManagedProcess(processId) : null
|
|
270
|
+
const running = !!process && process.status === 'running'
|
|
271
|
+
|
|
272
|
+
if (!running && processId && process && process.status !== 'running') {
|
|
273
|
+
state.local.lastError = readTail(process.log || '') || state.local.lastError
|
|
274
|
+
state.local.processId = null
|
|
275
|
+
state.local.startedAt = null
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${state.local.port}`)
|
|
279
|
+
return {
|
|
280
|
+
running,
|
|
281
|
+
processId: running ? processId : null,
|
|
282
|
+
pid: running ? (process?.pid ?? null) : null,
|
|
283
|
+
port: state.local.port,
|
|
284
|
+
endpoint,
|
|
285
|
+
wsUrl: deriveOpenClawWsUrl(endpoint),
|
|
286
|
+
token: state.local.token || null,
|
|
287
|
+
startedAt: running ? state.local.startedAt : null,
|
|
288
|
+
tail: process ? readTail(process.log || '') : '',
|
|
289
|
+
lastError: running ? null : (state.local.lastError || null),
|
|
290
|
+
launchCommand: buildLocalRunCommand(state.local.port, state.local.token),
|
|
291
|
+
installCommand: buildLocalInstallCommand(state.local.port, state.local.token),
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function getOpenClawLocalDeployStatus(): OpenClawLocalDeployStatus {
|
|
296
|
+
return currentLocalStatus()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function generateOpenClawGatewayToken(): string {
|
|
300
|
+
return randomBytes(24).toString('base64url')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function startOpenClawLocalDeploy(input?: {
|
|
304
|
+
port?: number
|
|
305
|
+
token?: string | null
|
|
306
|
+
}): Promise<{ local: OpenClawLocalDeployStatus; token: string }> {
|
|
307
|
+
const state = getRuntimeState()
|
|
308
|
+
const current = currentLocalStatus()
|
|
309
|
+
if (current.running && current.processId) {
|
|
310
|
+
killManagedProcess(current.processId)
|
|
311
|
+
removeManagedProcess(current.processId)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const port = sanitizePort(input?.port, DEFAULT_LOCAL_PORT)
|
|
315
|
+
const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
|
|
316
|
+
const endpoint = normalizeOpenClawEndpoint(`http://127.0.0.1:${port}`)
|
|
317
|
+
const wsUrl = deriveOpenClawWsUrl(endpoint)
|
|
318
|
+
const binary = resolveBundledOpenClawBinary()
|
|
319
|
+
const args = [
|
|
320
|
+
binary,
|
|
321
|
+
'gateway',
|
|
322
|
+
'run',
|
|
323
|
+
'--allow-unconfigured',
|
|
324
|
+
'--force',
|
|
325
|
+
'--bind',
|
|
326
|
+
'loopback',
|
|
327
|
+
'--port',
|
|
328
|
+
String(port),
|
|
329
|
+
'--auth',
|
|
330
|
+
'token',
|
|
331
|
+
'--token',
|
|
332
|
+
token,
|
|
333
|
+
'--verbose',
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
const result = await startManagedProcess({
|
|
337
|
+
command: args.map(shellEscape).join(' '),
|
|
338
|
+
cwd: process.cwd(),
|
|
339
|
+
background: true,
|
|
340
|
+
timeoutMs: 24 * 60 * 60_000,
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
if (result.status !== 'running') {
|
|
344
|
+
const message = result.output || result.tail || 'OpenClaw failed to start.'
|
|
345
|
+
state.local = {
|
|
346
|
+
processId: null,
|
|
347
|
+
port,
|
|
348
|
+
endpoint,
|
|
349
|
+
wsUrl,
|
|
350
|
+
token,
|
|
351
|
+
startedAt: null,
|
|
352
|
+
lastError: message,
|
|
353
|
+
}
|
|
354
|
+
throw new Error(message)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
state.local = {
|
|
358
|
+
processId: result.processId,
|
|
359
|
+
port,
|
|
360
|
+
endpoint,
|
|
361
|
+
wsUrl,
|
|
362
|
+
token,
|
|
363
|
+
startedAt: Date.now(),
|
|
364
|
+
lastError: null,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
await waitForLocalRuntime(result.processId)
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
local: currentLocalStatus(),
|
|
371
|
+
token,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function stopOpenClawLocalDeploy(): OpenClawLocalDeployStatus {
|
|
376
|
+
const state = getRuntimeState()
|
|
377
|
+
const processId = state.local.processId
|
|
378
|
+
if (processId) {
|
|
379
|
+
const process = getManagedProcess(processId)
|
|
380
|
+
if (process?.status === 'running') {
|
|
381
|
+
killManagedProcess(processId)
|
|
382
|
+
}
|
|
383
|
+
removeManagedProcess(processId)
|
|
384
|
+
}
|
|
385
|
+
state.local.processId = null
|
|
386
|
+
state.local.startedAt = null
|
|
387
|
+
return currentLocalStatus()
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function ensureSchemeAndPort(raw: string, scheme: 'http' | 'https', port: number): string {
|
|
391
|
+
const trimmed = raw.trim()
|
|
392
|
+
if (!trimmed) {
|
|
393
|
+
return `${scheme}://127.0.0.1:${port}`
|
|
394
|
+
}
|
|
395
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
|
|
396
|
+
const defaultPort = scheme === 'https' ? 443 : port
|
|
397
|
+
const hasPort = /:\d+$/.test(trimmed)
|
|
398
|
+
const portSuffix = hasPort || defaultPort === 443 ? '' : `:${defaultPort}`
|
|
399
|
+
return `${scheme}://${trimmed}${portSuffix}`
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function deriveRemoteDeploymentName(target: string): string {
|
|
403
|
+
const cleaned = target
|
|
404
|
+
.replace(/^https?:\/\//i, '')
|
|
405
|
+
.replace(/\/.*$/, '')
|
|
406
|
+
.replace(/:\d+$/, '')
|
|
407
|
+
.trim()
|
|
408
|
+
return cleaned || 'Remote OpenClaw'
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function indentBlock(value: string, spaces: number): string {
|
|
412
|
+
const padding = ' '.repeat(spaces)
|
|
413
|
+
return value
|
|
414
|
+
.replace(/\r\n/g, '\n')
|
|
415
|
+
.split('\n')
|
|
416
|
+
.map((line) => `${padding}${line}`)
|
|
417
|
+
.join('\n')
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function buildDockerComposeFile(): string {
|
|
421
|
+
return `services:
|
|
422
|
+
openclaw-gateway:
|
|
423
|
+
image: \${OPENCLAW_IMAGE:-openclaw:latest}
|
|
424
|
+
environment:
|
|
425
|
+
HOME: /home/node
|
|
426
|
+
TERM: xterm-256color
|
|
427
|
+
OPENCLAW_GATEWAY_TOKEN: \${OPENCLAW_GATEWAY_TOKEN}
|
|
428
|
+
OPENCLAW_GATEWAY_BIND: \${OPENCLAW_GATEWAY_BIND:-lan}
|
|
429
|
+
volumes:
|
|
430
|
+
- \${OPENCLAW_CONFIG_DIR:-./.openclaw}:/home/node/.openclaw
|
|
431
|
+
- \${OPENCLAW_WORKSPACE_DIR:-./workspace}:/home/node/.openclaw/workspace
|
|
432
|
+
ports:
|
|
433
|
+
- "\${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
|
434
|
+
- "\${OPENCLAW_BRIDGE_PORT:-18790}:18790"
|
|
435
|
+
init: true
|
|
436
|
+
restart: unless-stopped
|
|
437
|
+
command:
|
|
438
|
+
[
|
|
439
|
+
"node",
|
|
440
|
+
"dist/index.js",
|
|
441
|
+
"gateway",
|
|
442
|
+
"--allow-unconfigured",
|
|
443
|
+
"--bind",
|
|
444
|
+
"\${OPENCLAW_GATEWAY_BIND:-lan}",
|
|
445
|
+
"--port",
|
|
446
|
+
"18789",
|
|
447
|
+
"--auth",
|
|
448
|
+
"token",
|
|
449
|
+
"--token",
|
|
450
|
+
"\${OPENCLAW_GATEWAY_TOKEN}",
|
|
451
|
+
]
|
|
452
|
+
healthcheck:
|
|
453
|
+
test:
|
|
454
|
+
[
|
|
455
|
+
"CMD",
|
|
456
|
+
"node",
|
|
457
|
+
"-e",
|
|
458
|
+
"fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
459
|
+
]
|
|
460
|
+
interval: 30s
|
|
461
|
+
timeout: 5s
|
|
462
|
+
retries: 5
|
|
463
|
+
start_period: 20s
|
|
464
|
+
`
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function buildDockerEnvFile(token: string): string {
|
|
468
|
+
return `OPENCLAW_IMAGE=openclaw:latest
|
|
469
|
+
OPENCLAW_GATEWAY_TOKEN=${token}
|
|
470
|
+
OPENCLAW_GATEWAY_BIND=lan
|
|
471
|
+
OPENCLAW_GATEWAY_PORT=18789
|
|
472
|
+
OPENCLAW_BRIDGE_PORT=18790
|
|
473
|
+
OPENCLAW_CONFIG_DIR=./.openclaw
|
|
474
|
+
OPENCLAW_WORKSPACE_DIR=./workspace
|
|
475
|
+
`
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function buildDockerBootstrapScript(): string {
|
|
479
|
+
return `#!/usr/bin/env bash
|
|
480
|
+
set -euo pipefail
|
|
481
|
+
|
|
482
|
+
APP_DIR="\${OPENCLAW_APP_DIR:-$HOME/openclaw}"
|
|
483
|
+
|
|
484
|
+
mkdir -p "$APP_DIR"
|
|
485
|
+
cd "$APP_DIR"
|
|
486
|
+
mkdir -p .openclaw workspace
|
|
487
|
+
|
|
488
|
+
if ! command -v docker >/dev/null 2>&1; then
|
|
489
|
+
echo "Docker is required. On Ubuntu 24.04 you can install it with:"
|
|
490
|
+
echo " sudo apt-get update && sudo apt-get install -y docker.io docker-compose-plugin"
|
|
491
|
+
exit 1
|
|
492
|
+
fi
|
|
493
|
+
|
|
494
|
+
docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"
|
|
495
|
+
docker compose up -d
|
|
496
|
+
docker compose ps
|
|
497
|
+
`
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function buildCloudInitFile(token: string): string {
|
|
501
|
+
const envFile = buildDockerEnvFile(token)
|
|
502
|
+
const composeFile = buildDockerComposeFile()
|
|
503
|
+
return `#cloud-config
|
|
504
|
+
package_update: true
|
|
505
|
+
package_upgrade: true
|
|
506
|
+
packages:
|
|
507
|
+
- ca-certificates
|
|
508
|
+
- curl
|
|
509
|
+
- docker.io
|
|
510
|
+
- docker-compose-plugin
|
|
511
|
+
write_files:
|
|
512
|
+
- path: /opt/openclaw/.env
|
|
513
|
+
owner: root:root
|
|
514
|
+
permissions: "0600"
|
|
515
|
+
content: |
|
|
516
|
+
${indentBlock(envFile, 6)}
|
|
517
|
+
- path: /opt/openclaw/docker-compose.yml
|
|
518
|
+
owner: root:root
|
|
519
|
+
permissions: "0644"
|
|
520
|
+
content: |
|
|
521
|
+
${indentBlock(composeFile, 6)}
|
|
522
|
+
runcmd:
|
|
523
|
+
- mkdir -p /opt/openclaw/.openclaw /opt/openclaw/workspace
|
|
524
|
+
- systemctl enable --now docker
|
|
525
|
+
- bash -lc 'cd /opt/openclaw && docker pull "\${OPENCLAW_IMAGE:-openclaw:latest}"'
|
|
526
|
+
- bash -lc 'cd /opt/openclaw && docker compose up -d'
|
|
527
|
+
final_message: "OpenClaw gateway bootstrap complete. Run: sudo docker compose -f /opt/openclaw/docker-compose.yml ps"
|
|
528
|
+
`
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function buildRenderManifest(): string {
|
|
532
|
+
return `services:
|
|
533
|
+
- type: web
|
|
534
|
+
name: openclaw
|
|
535
|
+
runtime: docker
|
|
536
|
+
plan: starter
|
|
537
|
+
healthCheckPath: /health
|
|
538
|
+
envVars:
|
|
539
|
+
- key: PORT
|
|
540
|
+
value: "8080"
|
|
541
|
+
- key: SETUP_PASSWORD
|
|
542
|
+
sync: false
|
|
543
|
+
- key: OPENCLAW_STATE_DIR
|
|
544
|
+
value: /data/.openclaw
|
|
545
|
+
- key: OPENCLAW_WORKSPACE_DIR
|
|
546
|
+
value: /data/workspace
|
|
547
|
+
- key: OPENCLAW_GATEWAY_TOKEN
|
|
548
|
+
sync: false
|
|
549
|
+
disk:
|
|
550
|
+
name: openclaw-data
|
|
551
|
+
mountPath: /data
|
|
552
|
+
sizeGB: 1
|
|
553
|
+
`
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function buildFlyToml(): string {
|
|
557
|
+
return `app = "openclaw"
|
|
558
|
+
primary_region = "iad"
|
|
559
|
+
|
|
560
|
+
[build]
|
|
561
|
+
dockerfile = "Dockerfile"
|
|
562
|
+
|
|
563
|
+
[env]
|
|
564
|
+
NODE_ENV = "production"
|
|
565
|
+
OPENCLAW_PREFER_PNPM = "1"
|
|
566
|
+
OPENCLAW_STATE_DIR = "/data"
|
|
567
|
+
NODE_OPTIONS = "--max-old-space-size=1536"
|
|
568
|
+
|
|
569
|
+
[processes]
|
|
570
|
+
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
|
|
571
|
+
|
|
572
|
+
[http_service]
|
|
573
|
+
internal_port = 3000
|
|
574
|
+
force_https = true
|
|
575
|
+
auto_stop_machines = false
|
|
576
|
+
auto_start_machines = true
|
|
577
|
+
min_machines_running = 1
|
|
578
|
+
processes = ["app"]
|
|
579
|
+
|
|
580
|
+
[[vm]]
|
|
581
|
+
size = "shared-cpu-2x"
|
|
582
|
+
memory = "2048mb"
|
|
583
|
+
|
|
584
|
+
[mounts]
|
|
585
|
+
source = "openclaw_data"
|
|
586
|
+
destination = "/data"
|
|
587
|
+
`
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function buildRailwayEnvTemplate(token: string): string {
|
|
591
|
+
return `OPENCLAW_GATEWAY_TOKEN=${token}
|
|
592
|
+
OPENCLAW_STATE_DIR=/data/.openclaw
|
|
593
|
+
OPENCLAW_WORKSPACE_DIR=/data/workspace
|
|
594
|
+
PORT=8080
|
|
595
|
+
`
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function buildRailwayConfig(): string {
|
|
599
|
+
return `{
|
|
600
|
+
"$schema": "https://railway.com/railway.schema.json",
|
|
601
|
+
"deploy": {
|
|
602
|
+
"healthcheckPath": "/healthz",
|
|
603
|
+
"restartPolicyType": "ON_FAILURE",
|
|
604
|
+
"restartPolicyMaxRetries": 10
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
`
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function buildDockerRunbook(
|
|
611
|
+
providerMeta: RemoteProviderMeta,
|
|
612
|
+
endpoint: string,
|
|
613
|
+
): string[] {
|
|
614
|
+
const endpointHost = deriveRemoteDeploymentName(endpoint)
|
|
615
|
+
return [
|
|
616
|
+
`Provision a small Ubuntu 24.04 server on ${providerMeta.label}. ${providerMeta.bootstrapHint}`,
|
|
617
|
+
'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.`,
|
|
619
|
+
'Use the generated endpoint and token in SwarmClaw to save the gateway profile.',
|
|
620
|
+
]
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function buildOpenClawDeployBundle(input?: {
|
|
624
|
+
template?: OpenClawRemoteDeployTemplate
|
|
625
|
+
target?: string | null
|
|
626
|
+
token?: string | null
|
|
627
|
+
scheme?: 'http' | 'https'
|
|
628
|
+
port?: number
|
|
629
|
+
provider?: OpenClawRemoteDeployProvider
|
|
630
|
+
}): OpenClawDeployBundle {
|
|
631
|
+
const template = input?.template || 'docker'
|
|
632
|
+
const token = normalizeToken(input?.token) || generateOpenClawGatewayToken()
|
|
633
|
+
const scheme = input?.scheme === 'http' ? 'http' : 'https'
|
|
634
|
+
const port = sanitizePort(input?.port, DEFAULT_REMOTE_PORT)
|
|
635
|
+
const rawTarget = typeof input?.target === 'string' ? input.target.trim() : ''
|
|
636
|
+
const endpoint = normalizeOpenClawEndpoint(ensureSchemeAndPort(rawTarget || 'openclaw.example.com', scheme, port))
|
|
637
|
+
const wsUrl = deriveOpenClawWsUrl(endpoint)
|
|
638
|
+
const provider = normalizeRemoteProvider(input?.provider)
|
|
639
|
+
const providerMeta = REMOTE_PROVIDER_META[provider]
|
|
640
|
+
|
|
641
|
+
if (template === 'render') {
|
|
642
|
+
return {
|
|
643
|
+
template,
|
|
644
|
+
provider: 'generic',
|
|
645
|
+
providerLabel: 'Render',
|
|
646
|
+
title: 'Render OpenClaw Service',
|
|
647
|
+
summary: 'Deploy the official OpenClaw repo as a Docker web service on Render, then point SwarmClaw at the public HTTPS URL.',
|
|
648
|
+
endpoint,
|
|
649
|
+
wsUrl,
|
|
650
|
+
token,
|
|
651
|
+
runbook: [
|
|
652
|
+
'Create a new Render Web Service from the official OpenClaw GitHub repo.',
|
|
653
|
+
'Add OPENCLAW_GATEWAY_TOKEN as a secret environment variable using the generated token below.',
|
|
654
|
+
'After the service is live, paste the HTTPS URL back into SwarmClaw and save this gateway.',
|
|
655
|
+
],
|
|
656
|
+
files: [
|
|
657
|
+
{ name: 'render.yaml', language: 'yaml', content: buildRenderManifest() },
|
|
658
|
+
{ name: 'OPENCLAW_GATEWAY_TOKEN.txt', language: 'text', content: token },
|
|
659
|
+
],
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (template === 'fly') {
|
|
664
|
+
return {
|
|
665
|
+
template,
|
|
666
|
+
provider: 'generic',
|
|
667
|
+
providerLabel: 'Fly.io',
|
|
668
|
+
title: 'Fly.io OpenClaw App',
|
|
669
|
+
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
|
+
endpoint,
|
|
671
|
+
wsUrl,
|
|
672
|
+
token,
|
|
673
|
+
runbook: [
|
|
674
|
+
'Deploy the official OpenClaw repo with this fly.toml.',
|
|
675
|
+
'Set OPENCLAW_GATEWAY_TOKEN as a Fly secret before first deploy.',
|
|
676
|
+
'Use the resulting HTTPS app URL as your SwarmClaw OpenClaw endpoint.',
|
|
677
|
+
],
|
|
678
|
+
files: [
|
|
679
|
+
{ name: 'fly.toml', language: 'toml', content: buildFlyToml() },
|
|
680
|
+
{ name: 'OPENCLAW_GATEWAY_TOKEN.txt', language: 'text', content: token },
|
|
681
|
+
],
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (template === 'railway') {
|
|
686
|
+
return {
|
|
687
|
+
template,
|
|
688
|
+
provider: 'generic',
|
|
689
|
+
providerLabel: 'Railway',
|
|
690
|
+
title: 'Railway OpenClaw Service',
|
|
691
|
+
summary: 'Deploy the official OpenClaw repo on Railway using its Dockerfile, then attach a volume and set the generated gateway token.',
|
|
692
|
+
endpoint,
|
|
693
|
+
wsUrl,
|
|
694
|
+
token,
|
|
695
|
+
runbook: [
|
|
696
|
+
'Create a Railway project from the official OpenClaw GitHub repo so Railway builds the root Dockerfile automatically.',
|
|
697
|
+
'Attach a persistent volume at /data, then paste the generated variables below into the service variables editor.',
|
|
698
|
+
'After Railway deploys, use the public HTTPS URL as your SwarmClaw OpenClaw endpoint.',
|
|
699
|
+
],
|
|
700
|
+
files: [
|
|
701
|
+
{ name: 'railway.json', language: 'text', content: buildRailwayConfig() },
|
|
702
|
+
{ name: 'railway.env', language: 'env', content: buildRailwayEnvTemplate(token) },
|
|
703
|
+
],
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
template: 'docker',
|
|
709
|
+
provider,
|
|
710
|
+
providerLabel: providerMeta.shortLabel,
|
|
711
|
+
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.`,
|
|
713
|
+
endpoint,
|
|
714
|
+
wsUrl,
|
|
715
|
+
token,
|
|
716
|
+
runbook: buildDockerRunbook(providerMeta, endpoint),
|
|
717
|
+
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() },
|
|
722
|
+
],
|
|
723
|
+
}
|
|
724
|
+
}
|