@swarmclawai/swarmclaw 0.7.5 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +41 -10
  2. package/package.json +2 -2
  3. package/src/app/api/agents/[id]/route.ts +16 -0
  4. package/src/app/api/agents/route.ts +2 -0
  5. package/src/app/api/chats/[id]/route.ts +21 -1
  6. package/src/app/api/chats/route.ts +12 -1
  7. package/src/app/api/external-agents/[id]/heartbeat/route.ts +3 -0
  8. package/src/app/api/external-agents/[id]/route.ts +38 -6
  9. package/src/app/api/external-agents/route.ts +17 -1
  10. package/src/app/api/gateways/[id]/health/route.ts +8 -0
  11. package/src/app/api/gateways/[id]/route.ts +53 -1
  12. package/src/app/api/gateways/route.ts +53 -0
  13. package/src/app/api/openclaw/deploy/route.ts +240 -0
  14. package/src/cli/index.js +53 -0
  15. package/src/cli/index.test.js +102 -0
  16. package/src/cli/spec.js +79 -0
  17. package/src/components/agents/agent-sheet.tsx +97 -19
  18. package/src/components/auth/setup-wizard.tsx +111 -54
  19. package/src/components/gateways/gateway-sheet.tsx +202 -10
  20. package/src/components/openclaw/openclaw-deploy-panel.tsx +1208 -0
  21. package/src/components/providers/provider-list.tsx +321 -22
  22. package/src/lib/server/agent-runtime-config.ts +142 -7
  23. package/src/lib/server/agent-thread-session.ts +9 -1
  24. package/src/lib/server/chat-execution.ts +8 -2
  25. package/src/lib/server/heartbeat-service.ts +5 -1
  26. package/src/lib/server/openclaw-deploy.test.ts +75 -0
  27. package/src/lib/server/openclaw-deploy.ts +1384 -0
  28. package/src/lib/server/orchestrator.ts +9 -0
  29. package/src/lib/server/queue.ts +45 -2
  30. package/src/lib/setup-defaults.ts +2 -2
  31. package/src/lib/validation/schemas.ts +9 -0
  32. package/src/types/index.ts +65 -0
@@ -0,0 +1,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 }),
@@ -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
- <select
1756
- value={target.gatewayProfileId || ''}
1757
- onChange={(e) => {
1758
- const nextId = e.target.value || null
1759
- const gateway = openclawGatewayProfiles.find((item) => item.id === nextId)
1760
- updateRoutingTarget(target.id, {
1761
- gatewayProfileId: nextId,
1762
- apiEndpoint: gateway?.endpoint || target.apiEndpoint || null,
1763
- credentialId: gateway?.credentialId || target.credentialId || null,
1764
- model: target.model || 'default',
1765
- })
1766
- }}
1767
- className={inputClass}
1768
- >
1769
- <option value="">Custom OpenClaw endpoint</option>
1770
- {openclawGatewayProfiles.map((gateway) => (
1771
- <option key={gateway.id} value={gateway.id}>{gateway.name}</option>
1772
- ))}
1773
- </select>
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