@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,240 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import {
|
|
3
|
+
buildOpenClawDeployBundle,
|
|
4
|
+
deployOpenClawOverSsh,
|
|
5
|
+
getOpenClawLocalDeployStatus,
|
|
6
|
+
getOpenClawRemoteDeployStatus,
|
|
7
|
+
restartOpenClawLocalDeploy,
|
|
8
|
+
runOpenClawRemoteLifecycle,
|
|
9
|
+
startOpenClawLocalDeploy,
|
|
10
|
+
stopOpenClawLocalDeploy,
|
|
11
|
+
verifyOpenClawDeployment,
|
|
12
|
+
type OpenClawExposurePreset,
|
|
13
|
+
type OpenClawRemoteDeployProvider,
|
|
14
|
+
type OpenClawRemoteDeployTemplate,
|
|
15
|
+
type OpenClawSshConfig,
|
|
16
|
+
type OpenClawUseCaseTemplate,
|
|
17
|
+
} from '@/lib/server/openclaw-deploy'
|
|
18
|
+
|
|
19
|
+
export const dynamic = 'force-dynamic'
|
|
20
|
+
|
|
21
|
+
function parsePort(value: unknown): number | undefined {
|
|
22
|
+
const parsed = typeof value === 'number'
|
|
23
|
+
? value
|
|
24
|
+
: typeof value === 'string'
|
|
25
|
+
? Number.parseInt(value, 10)
|
|
26
|
+
: Number.NaN
|
|
27
|
+
return Number.isFinite(parsed) ? parsed : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseIntBounded(value: unknown, fallback: number, min: number, max: number): number {
|
|
31
|
+
const parsed = parsePort(value)
|
|
32
|
+
if (typeof parsed !== 'number') return fallback
|
|
33
|
+
return Math.max(min, Math.min(max, parsed))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseTemplate(value: unknown): OpenClawRemoteDeployTemplate | undefined {
|
|
37
|
+
if (value === 'docker' || value === 'render' || value === 'fly' || value === 'railway') {
|
|
38
|
+
return value
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseProvider(value: unknown): OpenClawRemoteDeployProvider | undefined {
|
|
44
|
+
if (
|
|
45
|
+
value === 'hetzner'
|
|
46
|
+
|| value === 'digitalocean'
|
|
47
|
+
|| value === 'vultr'
|
|
48
|
+
|| value === 'linode'
|
|
49
|
+
|| value === 'lightsail'
|
|
50
|
+
|| value === 'gcp'
|
|
51
|
+
|| value === 'azure'
|
|
52
|
+
|| value === 'oci'
|
|
53
|
+
|| value === 'generic'
|
|
54
|
+
) {
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseUseCase(value: unknown): OpenClawUseCaseTemplate | undefined {
|
|
61
|
+
if (
|
|
62
|
+
value === 'local-dev'
|
|
63
|
+
|| value === 'single-vps'
|
|
64
|
+
|| value === 'private-tailnet'
|
|
65
|
+
|| value === 'browser-heavy'
|
|
66
|
+
|| value === 'team-control'
|
|
67
|
+
) {
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseExposure(value: unknown): OpenClawExposurePreset | undefined {
|
|
74
|
+
if (
|
|
75
|
+
value === 'private-lan'
|
|
76
|
+
|| value === 'tailscale'
|
|
77
|
+
|| value === 'caddy'
|
|
78
|
+
|| value === 'nginx'
|
|
79
|
+
|| value === 'ssh-tunnel'
|
|
80
|
+
) {
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
return undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseSsh(value: unknown): Partial<OpenClawSshConfig> | null {
|
|
87
|
+
if (!value || typeof value !== 'object') return null
|
|
88
|
+
const ssh = value as Record<string, unknown>
|
|
89
|
+
return {
|
|
90
|
+
host: typeof ssh.host === 'string' ? ssh.host : '',
|
|
91
|
+
user: typeof ssh.user === 'string' ? ssh.user : null,
|
|
92
|
+
port: parsePort(ssh.port),
|
|
93
|
+
keyPath: typeof ssh.keyPath === 'string' ? ssh.keyPath : null,
|
|
94
|
+
targetDir: typeof ssh.targetDir === 'string' ? ssh.targetDir : null,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function GET() {
|
|
99
|
+
return NextResponse.json({
|
|
100
|
+
local: getOpenClawLocalDeployStatus(),
|
|
101
|
+
remote: getOpenClawRemoteDeployStatus(),
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function POST(req: Request) {
|
|
106
|
+
const body = await req.json().catch(() => ({}))
|
|
107
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (action === 'start-local') {
|
|
111
|
+
const result = await startOpenClawLocalDeploy({
|
|
112
|
+
port: parsePort(body.port),
|
|
113
|
+
token: typeof body.token === 'string' ? body.token : null,
|
|
114
|
+
})
|
|
115
|
+
return NextResponse.json({
|
|
116
|
+
ok: true,
|
|
117
|
+
local: result.local,
|
|
118
|
+
token: result.token,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (action === 'stop-local') {
|
|
123
|
+
return NextResponse.json({
|
|
124
|
+
ok: true,
|
|
125
|
+
local: stopOpenClawLocalDeploy(),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (action === 'restart-local') {
|
|
130
|
+
const result = await restartOpenClawLocalDeploy({
|
|
131
|
+
port: parsePort(body.port),
|
|
132
|
+
token: typeof body.token === 'string' ? body.token : null,
|
|
133
|
+
})
|
|
134
|
+
return NextResponse.json({
|
|
135
|
+
ok: true,
|
|
136
|
+
local: result.local,
|
|
137
|
+
token: result.token,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (action === 'bundle') {
|
|
142
|
+
const bundle = buildOpenClawDeployBundle({
|
|
143
|
+
template: parseTemplate(body.template),
|
|
144
|
+
target: typeof body.target === 'string' ? body.target : null,
|
|
145
|
+
token: typeof body.token === 'string' ? body.token : null,
|
|
146
|
+
scheme: body.scheme === 'http' ? 'http' : 'https',
|
|
147
|
+
port: parsePort(body.port),
|
|
148
|
+
provider: parseProvider(body.provider),
|
|
149
|
+
useCase: parseUseCase(body.useCase),
|
|
150
|
+
exposure: parseExposure(body.exposure),
|
|
151
|
+
})
|
|
152
|
+
return NextResponse.json({
|
|
153
|
+
ok: true,
|
|
154
|
+
bundle,
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (action === 'ssh-deploy') {
|
|
159
|
+
const result = await deployOpenClawOverSsh({
|
|
160
|
+
template: parseTemplate(body.template),
|
|
161
|
+
target: typeof body.target === 'string' ? body.target : null,
|
|
162
|
+
token: typeof body.token === 'string' ? body.token : null,
|
|
163
|
+
scheme: body.scheme === 'http' ? 'http' : 'https',
|
|
164
|
+
port: parsePort(body.port),
|
|
165
|
+
provider: parseProvider(body.provider),
|
|
166
|
+
useCase: parseUseCase(body.useCase),
|
|
167
|
+
exposure: parseExposure(body.exposure),
|
|
168
|
+
ssh: parseSsh(body.ssh),
|
|
169
|
+
})
|
|
170
|
+
return NextResponse.json({
|
|
171
|
+
ok: result.ok,
|
|
172
|
+
remote: getOpenClawRemoteDeployStatus(),
|
|
173
|
+
processId: result.processId || null,
|
|
174
|
+
token: result.token,
|
|
175
|
+
bundle: result.bundle,
|
|
176
|
+
summary: result.summary,
|
|
177
|
+
commandPreview: result.commandPreview,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
action === 'remote-start'
|
|
183
|
+
|| action === 'remote-stop'
|
|
184
|
+
|| action === 'remote-restart'
|
|
185
|
+
|| action === 'remote-upgrade'
|
|
186
|
+
|| action === 'remote-backup'
|
|
187
|
+
|| action === 'remote-restore'
|
|
188
|
+
|| action === 'remote-rotate-token'
|
|
189
|
+
) {
|
|
190
|
+
const actionMap = {
|
|
191
|
+
'remote-start': 'start',
|
|
192
|
+
'remote-stop': 'stop',
|
|
193
|
+
'remote-restart': 'restart',
|
|
194
|
+
'remote-upgrade': 'upgrade',
|
|
195
|
+
'remote-backup': 'backup',
|
|
196
|
+
'remote-restore': 'restore',
|
|
197
|
+
'remote-rotate-token': 'rotate-token',
|
|
198
|
+
} as const
|
|
199
|
+
const lifecycleAction = action as keyof typeof actionMap
|
|
200
|
+
const result = await runOpenClawRemoteLifecycle({
|
|
201
|
+
action: actionMap[lifecycleAction],
|
|
202
|
+
ssh: parseSsh(body.ssh),
|
|
203
|
+
token: typeof body.token === 'string' ? body.token : null,
|
|
204
|
+
backupPath: typeof body.backupPath === 'string' ? body.backupPath : null,
|
|
205
|
+
})
|
|
206
|
+
return NextResponse.json({
|
|
207
|
+
ok: result.ok,
|
|
208
|
+
remote: getOpenClawRemoteDeployStatus(),
|
|
209
|
+
processId: result.processId || null,
|
|
210
|
+
token: result.token,
|
|
211
|
+
summary: result.summary,
|
|
212
|
+
commandPreview: result.commandPreview,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (action === 'verify') {
|
|
217
|
+
const result = await verifyOpenClawDeployment({
|
|
218
|
+
endpoint: typeof body.endpoint === 'string' ? body.endpoint : null,
|
|
219
|
+
credentialId: typeof body.credentialId === 'string' ? body.credentialId : null,
|
|
220
|
+
token: typeof body.token === 'string' ? body.token : null,
|
|
221
|
+
model: typeof body.model === 'string' ? body.model : null,
|
|
222
|
+
timeoutMs: parseIntBounded(body.timeoutMs, 8000, 1000, 30000),
|
|
223
|
+
})
|
|
224
|
+
return NextResponse.json({
|
|
225
|
+
ok: result.ok,
|
|
226
|
+
verify: result,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return NextResponse.json({ ok: false, error: 'Unknown deploy action.' }, { status: 400 })
|
|
231
|
+
} catch (err: unknown) {
|
|
232
|
+
return NextResponse.json(
|
|
233
|
+
{
|
|
234
|
+
ok: false,
|
|
235
|
+
error: err instanceof Error ? err.message : 'OpenClaw deploy action failed.',
|
|
236
|
+
},
|
|
237
|
+
{ status: 500 },
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -310,6 +310,59 @@ const COMMAND_GROUPS = [
|
|
|
310
310
|
description: 'OpenClaw discovery, gateway control, and runtime APIs',
|
|
311
311
|
commands: [
|
|
312
312
|
cmd('discover', 'GET', '/openclaw/discover', 'Discover OpenClaw gateways'),
|
|
313
|
+
cmd('deploy-status', 'GET', '/openclaw/deploy', 'Get managed OpenClaw deploy status'),
|
|
314
|
+
cmd('deploy-local-start', 'POST', '/openclaw/deploy', 'Start a managed local OpenClaw deployment (use --data JSON for port/token overrides)', {
|
|
315
|
+
expectsJsonBody: true,
|
|
316
|
+
defaultBody: { action: 'start-local' },
|
|
317
|
+
}),
|
|
318
|
+
cmd('deploy-local-stop', 'POST', '/openclaw/deploy', 'Stop the managed local OpenClaw deployment', {
|
|
319
|
+
expectsJsonBody: true,
|
|
320
|
+
defaultBody: { action: 'stop-local' },
|
|
321
|
+
}),
|
|
322
|
+
cmd('deploy-local-restart', 'POST', '/openclaw/deploy', 'Restart the managed local OpenClaw deployment (use --data JSON for port/token overrides)', {
|
|
323
|
+
expectsJsonBody: true,
|
|
324
|
+
defaultBody: { action: 'restart-local' },
|
|
325
|
+
}),
|
|
326
|
+
cmd('deploy-bundle', 'POST', '/openclaw/deploy', 'Generate an OpenClaw remote deployment bundle (use --data JSON for template/target/token)', {
|
|
327
|
+
expectsJsonBody: true,
|
|
328
|
+
defaultBody: { action: 'bundle' },
|
|
329
|
+
}),
|
|
330
|
+
cmd('deploy-ssh', 'POST', '/openclaw/deploy', 'Push the official-image OpenClaw bundle to a remote host over SSH (use --data JSON for target/ssh/provider)', {
|
|
331
|
+
expectsJsonBody: true,
|
|
332
|
+
defaultBody: { action: 'ssh-deploy' },
|
|
333
|
+
}),
|
|
334
|
+
cmd('deploy-verify', 'POST', '/openclaw/deploy', 'Verify an OpenClaw endpoint/token pair (use --data JSON for endpoint/token)', {
|
|
335
|
+
expectsJsonBody: true,
|
|
336
|
+
defaultBody: { action: 'verify' },
|
|
337
|
+
}),
|
|
338
|
+
cmd('remote-start', 'POST', '/openclaw/deploy', 'Start a remote SSH-managed OpenClaw stack', {
|
|
339
|
+
expectsJsonBody: true,
|
|
340
|
+
defaultBody: { action: 'remote-start' },
|
|
341
|
+
}),
|
|
342
|
+
cmd('remote-stop', 'POST', '/openclaw/deploy', 'Stop a remote SSH-managed OpenClaw stack', {
|
|
343
|
+
expectsJsonBody: true,
|
|
344
|
+
defaultBody: { action: 'remote-stop' },
|
|
345
|
+
}),
|
|
346
|
+
cmd('remote-restart', 'POST', '/openclaw/deploy', 'Restart a remote SSH-managed OpenClaw stack', {
|
|
347
|
+
expectsJsonBody: true,
|
|
348
|
+
defaultBody: { action: 'remote-restart' },
|
|
349
|
+
}),
|
|
350
|
+
cmd('remote-upgrade', 'POST', '/openclaw/deploy', 'Upgrade a remote SSH-managed OpenClaw stack', {
|
|
351
|
+
expectsJsonBody: true,
|
|
352
|
+
defaultBody: { action: 'remote-upgrade' },
|
|
353
|
+
}),
|
|
354
|
+
cmd('remote-backup', 'POST', '/openclaw/deploy', 'Create a remote backup on an SSH-managed OpenClaw host', {
|
|
355
|
+
expectsJsonBody: true,
|
|
356
|
+
defaultBody: { action: 'remote-backup' },
|
|
357
|
+
}),
|
|
358
|
+
cmd('remote-restore', 'POST', '/openclaw/deploy', 'Restore a remote backup on an SSH-managed OpenClaw host', {
|
|
359
|
+
expectsJsonBody: true,
|
|
360
|
+
defaultBody: { action: 'remote-restore' },
|
|
361
|
+
}),
|
|
362
|
+
cmd('remote-rotate-token', 'POST', '/openclaw/deploy', 'Rotate the gateway token on an SSH-managed OpenClaw host', {
|
|
363
|
+
expectsJsonBody: true,
|
|
364
|
+
defaultBody: { action: 'remote-rotate-token' },
|
|
365
|
+
}),
|
|
313
366
|
cmd('directory', 'GET', '/openclaw/directory', 'List directory entries from running OpenClaw connectors'),
|
|
314
367
|
cmd('gateway-status', 'GET', '/openclaw/gateway', 'Check OpenClaw gateway connection status'),
|
|
315
368
|
cmd('gateway', 'POST', '/openclaw/gateway', 'Call OpenClaw gateway RPC/control action', { expectsJsonBody: true }),
|
package/src/cli/index.test.js
CHANGED
|
@@ -163,6 +163,108 @@ test('runCli sends authenticated request and emits compact JSON when --json is s
|
|
|
163
163
|
assert.equal(stderr.toString(), '')
|
|
164
164
|
})
|
|
165
165
|
|
|
166
|
+
test('openclaw deploy bundle command merges action with provided JSON body', async () => {
|
|
167
|
+
const stdout = makeWritable()
|
|
168
|
+
const stderr = makeWritable()
|
|
169
|
+
const calls = []
|
|
170
|
+
|
|
171
|
+
const fetchImpl = async (url, init) => {
|
|
172
|
+
calls.push({ url: String(url), init })
|
|
173
|
+
return jsonResponse({ ok: true, bundle: { template: 'docker' } })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const exitCode = await runCli(
|
|
177
|
+
['openclaw', 'deploy-bundle', '--data', '{"template":"docker","target":"openclaw.example.com"}', '--json'],
|
|
178
|
+
{
|
|
179
|
+
fetchImpl,
|
|
180
|
+
stdout,
|
|
181
|
+
stderr,
|
|
182
|
+
env: {},
|
|
183
|
+
cwd: process.cwd(),
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
assert.equal(exitCode, 0)
|
|
188
|
+
assert.equal(calls.length, 1)
|
|
189
|
+
assert.match(calls[0].url, /\/api\/openclaw\/deploy$/)
|
|
190
|
+
assert.equal(calls[0].init.method, 'POST')
|
|
191
|
+
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
|
192
|
+
action: 'bundle',
|
|
193
|
+
template: 'docker',
|
|
194
|
+
target: 'openclaw.example.com',
|
|
195
|
+
})
|
|
196
|
+
assert.equal(stdout.toString().trim(), '{"ok":true,"bundle":{"template":"docker"}}')
|
|
197
|
+
assert.equal(stderr.toString(), '')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
test('openclaw deploy ssh command merges action with provided JSON body', async () => {
|
|
201
|
+
const stdout = makeWritable()
|
|
202
|
+
const stderr = makeWritable()
|
|
203
|
+
const calls = []
|
|
204
|
+
|
|
205
|
+
const fetchImpl = async (url, init) => {
|
|
206
|
+
calls.push({ url: String(url), init })
|
|
207
|
+
return jsonResponse({ ok: true, processId: 'remote-1' })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const exitCode = await runCli(
|
|
211
|
+
['openclaw', 'deploy-ssh', '--data', '{"target":"openclaw.example.com","ssh":{"host":"1.2.3.4"}}', '--json'],
|
|
212
|
+
{
|
|
213
|
+
fetchImpl,
|
|
214
|
+
stdout,
|
|
215
|
+
stderr,
|
|
216
|
+
env: {},
|
|
217
|
+
cwd: process.cwd(),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
assert.equal(exitCode, 0)
|
|
222
|
+
assert.equal(calls.length, 1)
|
|
223
|
+
assert.match(calls[0].url, /\/api\/openclaw\/deploy$/)
|
|
224
|
+
assert.equal(calls[0].init.method, 'POST')
|
|
225
|
+
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
|
226
|
+
action: 'ssh-deploy',
|
|
227
|
+
target: 'openclaw.example.com',
|
|
228
|
+
ssh: { host: '1.2.3.4' },
|
|
229
|
+
})
|
|
230
|
+
assert.equal(stdout.toString().trim(), '{"ok":true,"processId":"remote-1"}')
|
|
231
|
+
assert.equal(stderr.toString(), '')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('openclaw remote restore command merges action with provided JSON body', async () => {
|
|
235
|
+
const stdout = makeWritable()
|
|
236
|
+
const stderr = makeWritable()
|
|
237
|
+
const calls = []
|
|
238
|
+
|
|
239
|
+
const fetchImpl = async (url, init) => {
|
|
240
|
+
calls.push({ url: String(url), init })
|
|
241
|
+
return jsonResponse({ ok: true, remote: { status: 'running' } })
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const exitCode = await runCli(
|
|
245
|
+
['openclaw', 'remote-restore', '--data', '{"backupPath":"/opt/openclaw/backups/latest.tgz","ssh":{"host":"1.2.3.4"}}', '--json'],
|
|
246
|
+
{
|
|
247
|
+
fetchImpl,
|
|
248
|
+
stdout,
|
|
249
|
+
stderr,
|
|
250
|
+
env: {},
|
|
251
|
+
cwd: process.cwd(),
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
assert.equal(exitCode, 0)
|
|
256
|
+
assert.equal(calls.length, 1)
|
|
257
|
+
assert.match(calls[0].url, /\/api\/openclaw\/deploy$/)
|
|
258
|
+
assert.equal(calls[0].init.method, 'POST')
|
|
259
|
+
assert.deepEqual(JSON.parse(String(calls[0].init.body)), {
|
|
260
|
+
action: 'remote-restore',
|
|
261
|
+
backupPath: '/opt/openclaw/backups/latest.tgz',
|
|
262
|
+
ssh: { host: '1.2.3.4' },
|
|
263
|
+
})
|
|
264
|
+
assert.equal(stdout.toString().trim(), '{"ok":true,"remote":{"status":"running"}}')
|
|
265
|
+
assert.equal(stderr.toString(), '')
|
|
266
|
+
})
|
|
267
|
+
|
|
166
268
|
test('runCli falls back to platform-api-key.txt when env key is missing', async () => {
|
|
167
269
|
const stdout = makeWritable()
|
|
168
270
|
const stderr = makeWritable()
|
package/src/cli/spec.js
CHANGED
|
@@ -211,6 +211,85 @@ const COMMAND_GROUPS = {
|
|
|
211
211
|
description: 'OpenClaw discovery, gateway control, and runtime APIs',
|
|
212
212
|
commands: {
|
|
213
213
|
discover: { description: 'Discover OpenClaw gateways', method: 'GET', path: '/openclaw/discover' },
|
|
214
|
+
'deploy-status': { description: 'Get managed OpenClaw deploy status', method: 'GET', path: '/openclaw/deploy' },
|
|
215
|
+
'deploy-local-start': {
|
|
216
|
+
description: 'Start a managed local OpenClaw deployment (use --data JSON for port/token overrides)',
|
|
217
|
+
method: 'POST',
|
|
218
|
+
path: '/openclaw/deploy',
|
|
219
|
+
staticBody: { action: 'start-local' },
|
|
220
|
+
},
|
|
221
|
+
'deploy-local-stop': {
|
|
222
|
+
description: 'Stop the managed local OpenClaw deployment',
|
|
223
|
+
method: 'POST',
|
|
224
|
+
path: '/openclaw/deploy',
|
|
225
|
+
staticBody: { action: 'stop-local' },
|
|
226
|
+
},
|
|
227
|
+
'deploy-local-restart': {
|
|
228
|
+
description: 'Restart the managed local OpenClaw deployment (use --data JSON for port/token overrides)',
|
|
229
|
+
method: 'POST',
|
|
230
|
+
path: '/openclaw/deploy',
|
|
231
|
+
staticBody: { action: 'restart-local' },
|
|
232
|
+
},
|
|
233
|
+
'deploy-bundle': {
|
|
234
|
+
description: 'Generate an OpenClaw remote deployment bundle (use --data JSON for template/target/token)',
|
|
235
|
+
method: 'POST',
|
|
236
|
+
path: '/openclaw/deploy',
|
|
237
|
+
staticBody: { action: 'bundle' },
|
|
238
|
+
},
|
|
239
|
+
'deploy-ssh': {
|
|
240
|
+
description: 'Push the official-image OpenClaw bundle to a remote host over SSH (use --data JSON for target/ssh/provider)',
|
|
241
|
+
method: 'POST',
|
|
242
|
+
path: '/openclaw/deploy',
|
|
243
|
+
staticBody: { action: 'ssh-deploy' },
|
|
244
|
+
},
|
|
245
|
+
'deploy-verify': {
|
|
246
|
+
description: 'Verify an OpenClaw endpoint/token pair (use --data JSON for endpoint/token)',
|
|
247
|
+
method: 'POST',
|
|
248
|
+
path: '/openclaw/deploy',
|
|
249
|
+
staticBody: { action: 'verify' },
|
|
250
|
+
},
|
|
251
|
+
'remote-start': {
|
|
252
|
+
description: 'Start a remote SSH-managed OpenClaw stack',
|
|
253
|
+
method: 'POST',
|
|
254
|
+
path: '/openclaw/deploy',
|
|
255
|
+
staticBody: { action: 'remote-start' },
|
|
256
|
+
},
|
|
257
|
+
'remote-stop': {
|
|
258
|
+
description: 'Stop a remote SSH-managed OpenClaw stack',
|
|
259
|
+
method: 'POST',
|
|
260
|
+
path: '/openclaw/deploy',
|
|
261
|
+
staticBody: { action: 'remote-stop' },
|
|
262
|
+
},
|
|
263
|
+
'remote-restart': {
|
|
264
|
+
description: 'Restart a remote SSH-managed OpenClaw stack',
|
|
265
|
+
method: 'POST',
|
|
266
|
+
path: '/openclaw/deploy',
|
|
267
|
+
staticBody: { action: 'remote-restart' },
|
|
268
|
+
},
|
|
269
|
+
'remote-upgrade': {
|
|
270
|
+
description: 'Upgrade a remote SSH-managed OpenClaw stack',
|
|
271
|
+
method: 'POST',
|
|
272
|
+
path: '/openclaw/deploy',
|
|
273
|
+
staticBody: { action: 'remote-upgrade' },
|
|
274
|
+
},
|
|
275
|
+
'remote-backup': {
|
|
276
|
+
description: 'Create a remote backup on an SSH-managed OpenClaw host',
|
|
277
|
+
method: 'POST',
|
|
278
|
+
path: '/openclaw/deploy',
|
|
279
|
+
staticBody: { action: 'remote-backup' },
|
|
280
|
+
},
|
|
281
|
+
'remote-restore': {
|
|
282
|
+
description: 'Restore a remote backup on an SSH-managed OpenClaw host',
|
|
283
|
+
method: 'POST',
|
|
284
|
+
path: '/openclaw/deploy',
|
|
285
|
+
staticBody: { action: 'remote-restore' },
|
|
286
|
+
},
|
|
287
|
+
'remote-rotate-token': {
|
|
288
|
+
description: 'Rotate the gateway token on an SSH-managed OpenClaw host',
|
|
289
|
+
method: 'POST',
|
|
290
|
+
path: '/openclaw/deploy',
|
|
291
|
+
staticBody: { action: 'remote-rotate-token' },
|
|
292
|
+
},
|
|
214
293
|
directory: { description: 'List directory entries from running OpenClaw connectors', method: 'GET', path: '/openclaw/directory' },
|
|
215
294
|
'gateway-status': { description: 'Check OpenClaw gateway connection status', method: 'GET', path: '/openclaw/gateway' },
|
|
216
295
|
gateway: { description: 'Call OpenClaw gateway RPC/control action', method: 'POST', path: '/openclaw/gateway' },
|
|
@@ -72,6 +72,24 @@ function parseIdentityList(value: string): string[] {
|
|
|
72
72
|
})
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
function formatGatewayTagList(value: string[] | null | undefined): string {
|
|
76
|
+
return Array.isArray(value) ? value.join(', ') : ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseGatewayTagList(value: string): string[] {
|
|
80
|
+
const seen = new Set<string>()
|
|
81
|
+
return value
|
|
82
|
+
.split(/[,\n]/)
|
|
83
|
+
.map((entry) => entry.trim())
|
|
84
|
+
.filter((entry) => {
|
|
85
|
+
if (!entry) return false
|
|
86
|
+
const key = entry.toLowerCase()
|
|
87
|
+
if (seen.has(key)) return false
|
|
88
|
+
seen.add(key)
|
|
89
|
+
return true
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
export function AgentSheet() {
|
|
76
94
|
const open = useAppStore((s) => s.agentSheetOpen)
|
|
77
95
|
const setOpen = useAppStore((s) => s.setAgentSheetOpen)
|
|
@@ -113,6 +131,8 @@ export function AgentSheet() {
|
|
|
113
131
|
const [credentialId, setCredentialId] = useState<string | null>(null)
|
|
114
132
|
const [apiEndpoint, setApiEndpoint] = useState<string | null>(null)
|
|
115
133
|
const [gatewayProfileId, setGatewayProfileId] = useState<string | null>(null)
|
|
134
|
+
const [preferredGatewayTagsText, setPreferredGatewayTagsText] = useState('')
|
|
135
|
+
const [preferredGatewayUseCase, setPreferredGatewayUseCase] = useState('')
|
|
116
136
|
const [routingStrategy, setRoutingStrategy] = useState<AgentRoutingStrategy>('single')
|
|
117
137
|
const [routingTargets, setRoutingTargets] = useState<AgentRoutingTarget[]>([])
|
|
118
138
|
const [platformAssignScope, setPlatformAssignScope] = useState<'self' | 'all'>('self')
|
|
@@ -220,6 +240,8 @@ export function AgentSheet() {
|
|
|
220
240
|
setCredentialId(editing.credentialId || null)
|
|
221
241
|
setApiEndpoint(editing.apiEndpoint || null)
|
|
222
242
|
setGatewayProfileId(editing.gatewayProfileId || null)
|
|
243
|
+
setPreferredGatewayTagsText(formatGatewayTagList(editing.preferredGatewayTags))
|
|
244
|
+
setPreferredGatewayUseCase(editing.preferredGatewayUseCase || '')
|
|
223
245
|
setRoutingStrategy(editing.routingStrategy || 'single')
|
|
224
246
|
setRoutingTargets(editing.routingTargets || [])
|
|
225
247
|
setPlatformAssignScope(editing.platformAssignScope || 'self')
|
|
@@ -287,6 +309,8 @@ export function AgentSheet() {
|
|
|
287
309
|
setCredentialId(null)
|
|
288
310
|
setApiEndpoint(null)
|
|
289
311
|
setGatewayProfileId(null)
|
|
312
|
+
setPreferredGatewayTagsText('')
|
|
313
|
+
setPreferredGatewayUseCase('')
|
|
290
314
|
setRoutingStrategy('single')
|
|
291
315
|
setRoutingTargets([])
|
|
292
316
|
setPlatformAssignScope('self')
|
|
@@ -422,6 +446,8 @@ export function AgentSheet() {
|
|
|
422
446
|
fallbackCredentialIds,
|
|
423
447
|
apiEndpoint,
|
|
424
448
|
gatewayProfileId,
|
|
449
|
+
preferredGatewayTags: parseGatewayTagList(preferredGatewayTagsText),
|
|
450
|
+
preferredGatewayUseCase: preferredGatewayUseCase || null,
|
|
425
451
|
priority: routingTargets.length + 1,
|
|
426
452
|
}
|
|
427
453
|
setRoutingTargets((current) => [...current, nextTarget])
|
|
@@ -463,9 +489,13 @@ export function AgentSheet() {
|
|
|
463
489
|
credentialId,
|
|
464
490
|
apiEndpoint: normalizedEndpoint,
|
|
465
491
|
gatewayProfileId,
|
|
492
|
+
preferredGatewayTags: parseGatewayTagList(preferredGatewayTagsText),
|
|
493
|
+
preferredGatewayUseCase: preferredGatewayUseCase || null,
|
|
466
494
|
routingStrategy,
|
|
467
495
|
routingTargets: routingTargets.map((target, index) => ({
|
|
468
496
|
...target,
|
|
497
|
+
preferredGatewayTags: parseGatewayTagList(formatGatewayTagList(target.preferredGatewayTags)),
|
|
498
|
+
preferredGatewayUseCase: target.preferredGatewayUseCase || null,
|
|
469
499
|
priority: typeof target.priority === 'number' ? target.priority : index + 1,
|
|
470
500
|
})),
|
|
471
501
|
subAgentIds: canDelegateToAgents ? subAgentIds : [],
|
|
@@ -1698,6 +1728,34 @@ export function AgentSheet() {
|
|
|
1698
1728
|
</div>
|
|
1699
1729
|
)}
|
|
1700
1730
|
|
|
1731
|
+
{(provider === 'openclaw' || routingTargets.some((target) => target.provider === 'openclaw') || openclawGatewayProfiles.length > 0) && (
|
|
1732
|
+
<div className="mb-8">
|
|
1733
|
+
<label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
1734
|
+
Gateway Preferences <HintTip text="When multiple OpenClaw gateways are available, prefer matching tags or deployment templates before falling back to the default route." />
|
|
1735
|
+
</label>
|
|
1736
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
1737
|
+
<input
|
|
1738
|
+
type="text"
|
|
1739
|
+
value={preferredGatewayTagsText}
|
|
1740
|
+
onChange={(e) => setPreferredGatewayTagsText(e.target.value)}
|
|
1741
|
+
placeholder="gpu, local, research"
|
|
1742
|
+
className={inputClass}
|
|
1743
|
+
/>
|
|
1744
|
+
<select value={preferredGatewayUseCase} onChange={(e) => setPreferredGatewayUseCase(e.target.value)} className={inputClass}>
|
|
1745
|
+
<option value="">Any OpenClaw template</option>
|
|
1746
|
+
<option value="local-dev">Local Dev</option>
|
|
1747
|
+
<option value="single-vps">Single VPS</option>
|
|
1748
|
+
<option value="private-tailnet">Private Tailnet</option>
|
|
1749
|
+
<option value="browser-heavy">Browser Heavy</option>
|
|
1750
|
+
<option value="team-control">Team Control</option>
|
|
1751
|
+
</select>
|
|
1752
|
+
</div>
|
|
1753
|
+
<p className="text-[11px] text-text-3/70 mt-2">
|
|
1754
|
+
These preferences bias scheduling toward matching OpenClaw control planes without hard-locking the agent to one gateway.
|
|
1755
|
+
</p>
|
|
1756
|
+
</div>
|
|
1757
|
+
)}
|
|
1758
|
+
|
|
1701
1759
|
<div className="mb-8">
|
|
1702
1760
|
<label className="flex items-center gap-2 font-display text-[12px] font-600 text-text-2 uppercase tracking-[0.08em] mb-2">
|
|
1703
1761
|
Model Routing <HintTip text="Route this agent through a provider/model pool instead of a single fixed model. The base provider remains the default when no route matches." />
|
|
@@ -1752,25 +1810,45 @@ export function AgentSheet() {
|
|
|
1752
1810
|
/>
|
|
1753
1811
|
</div>
|
|
1754
1812
|
{target.provider === 'openclaw' && openclawGatewayProfiles.length > 0 && (
|
|
1755
|
-
<
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1813
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
1814
|
+
<select
|
|
1815
|
+
value={target.gatewayProfileId || ''}
|
|
1816
|
+
onChange={(e) => {
|
|
1817
|
+
const nextId = e.target.value || null
|
|
1818
|
+
const gateway = openclawGatewayProfiles.find((item) => item.id === nextId)
|
|
1819
|
+
updateRoutingTarget(target.id, {
|
|
1820
|
+
gatewayProfileId: nextId,
|
|
1821
|
+
apiEndpoint: gateway?.endpoint || target.apiEndpoint || null,
|
|
1822
|
+
credentialId: gateway?.credentialId || target.credentialId || null,
|
|
1823
|
+
model: target.model || 'default',
|
|
1824
|
+
})
|
|
1825
|
+
}}
|
|
1826
|
+
className={inputClass}
|
|
1827
|
+
>
|
|
1828
|
+
<option value="">Custom OpenClaw endpoint</option>
|
|
1829
|
+
{openclawGatewayProfiles.map((gateway) => (
|
|
1830
|
+
<option key={gateway.id} value={gateway.id}>{gateway.name}</option>
|
|
1831
|
+
))}
|
|
1832
|
+
</select>
|
|
1833
|
+
<input
|
|
1834
|
+
value={formatGatewayTagList(target.preferredGatewayTags)}
|
|
1835
|
+
onChange={(e) => updateRoutingTarget(target.id, { preferredGatewayTags: parseGatewayTagList(e.target.value) })}
|
|
1836
|
+
placeholder="Prefer tags"
|
|
1837
|
+
className={inputClass}
|
|
1838
|
+
/>
|
|
1839
|
+
<select
|
|
1840
|
+
value={target.preferredGatewayUseCase || ''}
|
|
1841
|
+
onChange={(e) => updateRoutingTarget(target.id, { preferredGatewayUseCase: e.target.value || null })}
|
|
1842
|
+
className={inputClass}
|
|
1843
|
+
>
|
|
1844
|
+
<option value="">Any OpenClaw template</option>
|
|
1845
|
+
<option value="local-dev">Local Dev</option>
|
|
1846
|
+
<option value="single-vps">Single VPS</option>
|
|
1847
|
+
<option value="private-tailnet">Private Tailnet</option>
|
|
1848
|
+
<option value="browser-heavy">Browser Heavy</option>
|
|
1849
|
+
<option value="team-control">Team Control</option>
|
|
1850
|
+
</select>
|
|
1851
|
+
</div>
|
|
1774
1852
|
)}
|
|
1775
1853
|
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3">
|
|
1776
1854
|
<input
|