@swarmclawai/swarmclaw 0.9.9 → 1.0.2

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 (44) hide show
  1. package/bin/doctor-cmd.js +149 -0
  2. package/bin/doctor-cmd.test.js +50 -0
  3. package/bin/install-root.js +194 -0
  4. package/bin/install-root.test.js +121 -0
  5. package/bin/server-cmd.js +90 -111
  6. package/bin/swarmclaw.js +83 -3
  7. package/bin/update-cmd.js +33 -20
  8. package/bin/update-cmd.test.js +1 -36
  9. package/bin/worker-cmd.js +23 -17
  10. package/next.config.ts +2 -0
  11. package/package.json +11 -10
  12. package/src/app/api/gateways/[id]/health/route.ts +2 -32
  13. package/src/app/api/gateways/health-route.test.ts +1 -1
  14. package/src/app/api/openclaw/dashboard-url/route.test.ts +166 -0
  15. package/src/app/api/openclaw/dashboard-url/route.ts +68 -0
  16. package/src/app/api/setup/check-provider/helpers.ts +28 -0
  17. package/src/app/api/setup/check-provider/route.test.ts +17 -1
  18. package/src/app/api/setup/check-provider/route.ts +29 -36
  19. package/src/app/api/tasks/import/github/helpers.ts +100 -0
  20. package/src/app/api/tasks/import/github/route.test.ts +1 -1
  21. package/src/app/api/tasks/import/github/route.ts +2 -92
  22. package/src/app/api/webhooks/[id]/helpers.ts +253 -0
  23. package/src/app/api/webhooks/[id]/route.ts +2 -243
  24. package/src/app/api/webhooks/route.test.ts +4 -2
  25. package/src/cli/binary.test.js +57 -0
  26. package/src/cli/index.js +14 -1
  27. package/src/cli/server-cmd.test.js +21 -20
  28. package/src/components/auth/setup-wizard/index.tsx +16 -0
  29. package/src/components/auth/setup-wizard/step-agents.tsx +34 -23
  30. package/src/components/auth/setup-wizard/step-connect.tsx +8 -0
  31. package/src/components/auth/setup-wizard/types.ts +2 -0
  32. package/src/components/auth/setup-wizard/utils.test.ts +79 -0
  33. package/src/components/chat/chat-header.tsx +45 -2
  34. package/src/lib/providers/openclaw-exports.test.ts +23 -0
  35. package/src/lib/providers/openclaw.ts +1 -1
  36. package/src/lib/server/data-dir.test.ts +35 -0
  37. package/src/lib/server/data-dir.ts +11 -0
  38. package/src/lib/server/openclaw/health.ts +30 -1
  39. package/src/lib/server/session-tools/file-send.test.ts +18 -2
  40. package/src/lib/server/session-tools/file.ts +11 -7
  41. package/src/lib/server/skills/skill-discovery.test.ts +34 -1
  42. package/src/lib/server/skills/skill-discovery.ts +9 -4
  43. package/src/lib/setup-defaults.test.ts +42 -0
  44. package/src/lib/setup-defaults.ts +1 -1
@@ -1,31 +1,11 @@
1
- import { genId } from '@/lib/id'
2
- import { timingSafeEqual } from 'node:crypto'
3
1
  import { NextResponse } from 'next/server'
4
- import { loadAgents, loadSessions, loadWebhooks, saveSessions, saveWebhooks, appendWebhookLog, upsertWebhookRetry } from '@/lib/server/storage'
5
- import { WORKSPACE_DIR } from '@/lib/server/data-dir'
6
- import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
7
- import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
8
- import { requestHeartbeatNow } from '@/lib/server/runtime/heartbeat-wake'
2
+ import { loadWebhooks, saveWebhooks } from '@/lib/server/storage'
9
3
  import { mutateItem, deleteItem, notFound, type CollectionOps } from '@/lib/server/collection-helpers'
10
- import type { WebhookRetryEntry } from '@/types'
11
- import { triggerWebhookWatchJobs } from '@/lib/server/runtime/watch-jobs'
12
- import { errorMessage } from '@/lib/shared-utils'
4
+ import { handleWebhookPost } from './helpers'
13
5
 
14
6
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
7
  const ops: CollectionOps<any> = { load: loadWebhooks, save: saveWebhooks }
16
8
 
17
- type WebhookPostDeps = {
18
- enqueueRun: typeof enqueueSessionRun
19
- enqueueEvent: typeof enqueueSystemEvent
20
- requestHeartbeat: typeof requestHeartbeatNow
21
- }
22
-
23
- const defaultWebhookPostDeps: WebhookPostDeps = {
24
- enqueueRun: enqueueSessionRun,
25
- enqueueEvent: enqueueSystemEvent,
26
- requestHeartbeat: requestHeartbeatNow,
27
- }
28
-
29
9
  function normalizeEvents(value: unknown): string[] {
30
10
  if (!Array.isArray(value)) return []
31
11
  return value
@@ -34,12 +14,6 @@ function normalizeEvents(value: unknown): string[] {
34
14
  .filter(Boolean)
35
15
  }
36
16
 
37
- function eventMatches(registered: string[], incoming: string): boolean {
38
- if (registered.length === 0) return true
39
- if (registered.includes('*')) return true
40
- return registered.includes(incoming)
41
- }
42
-
43
17
  export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
44
18
  const { id } = await params
45
19
  const webhooks = loadWebhooks()
@@ -71,221 +45,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
71
45
  return NextResponse.json({ ok: true })
72
46
  }
73
47
 
74
- export async function handleWebhookPost(
75
- req: Request,
76
- id: string,
77
- deps: WebhookPostDeps = defaultWebhookPostDeps,
78
- ) {
79
- const webhooks = loadWebhooks()
80
- const webhook = webhooks[id]
81
- if (!webhook) return notFound('Webhook not found')
82
- if (webhook.isEnabled === false) {
83
- appendWebhookLog(genId(8), {
84
- id: genId(8), webhookId: id, event: 'unknown',
85
- payload: '', status: 'error', error: 'Webhook is disabled', timestamp: Date.now(),
86
- })
87
- return NextResponse.json({ error: 'Webhook is disabled' }, { status: 409 })
88
- }
89
-
90
- const secret = typeof webhook.secret === 'string' ? webhook.secret.trim() : ''
91
- if (secret) {
92
- const url = new URL(req.url)
93
- const provided = req.headers.get('x-webhook-secret') || url.searchParams.get('secret') || ''
94
- const secretBuf = Buffer.from(secret)
95
- const providedBuf = Buffer.from(provided)
96
- // timingSafeEqual requires equal lengths; compare against secretBuf if lengths differ
97
- const compareBuf = providedBuf.length === secretBuf.length ? providedBuf : secretBuf
98
- const isInvalid = providedBuf.length !== secretBuf.length || !timingSafeEqual(secretBuf, compareBuf)
99
- if (isInvalid) {
100
- appendWebhookLog(genId(8), {
101
- id: genId(8), webhookId: id, event: 'unknown',
102
- payload: '', status: 'error', error: 'Invalid webhook secret', timestamp: Date.now(),
103
- })
104
- return NextResponse.json({ error: 'Invalid webhook secret' }, { status: 401 })
105
- }
106
- }
107
-
108
- let payload: unknown = null
109
- let rawBody = ''
110
- const contentType = req.headers.get('content-type') || ''
111
- if (contentType.includes('application/json')) {
112
- try {
113
- payload = await req.json()
114
- rawBody = JSON.stringify(payload)
115
- } catch {
116
- payload = {}
117
- rawBody = '{}'
118
- }
119
- } else {
120
- rawBody = await req.text()
121
- try {
122
- payload = JSON.parse(rawBody)
123
- } catch {
124
- payload = { raw: rawBody }
125
- }
126
- }
127
-
128
- const url = new URL(req.url)
129
- const incomingEvent = String(
130
- (payload as Record<string, unknown> | null)?.type
131
- || (payload as Record<string, unknown> | null)?.event
132
- || req.headers.get('x-event-type')
133
- || url.searchParams.get('event')
134
- || 'unknown',
135
- )
136
- const registeredEvents = normalizeEvents(webhook.events)
137
- if (!eventMatches(registeredEvents, incomingEvent)) {
138
- return NextResponse.json({
139
- ok: true,
140
- ignored: true,
141
- reason: 'Event does not match webhook filters',
142
- event: incomingEvent,
143
- })
144
- }
145
-
146
- triggerWebhookWatchJobs({
147
- webhookId: id,
148
- event: incomingEvent,
149
- payloadPreview: rawBody,
150
- })
151
-
152
- const agents = loadAgents()
153
- const agent = webhook.agentId ? agents[webhook.agentId] : null
154
- if (!agent) {
155
- appendWebhookLog(genId(8), {
156
- id: genId(8), webhookId: id, event: incomingEvent,
157
- payload: (rawBody || '').slice(0, 2000), status: 'error', error: 'Webhook agent is not configured or missing', timestamp: Date.now(),
158
- })
159
- return NextResponse.json({ error: 'Webhook agent is not configured or missing' }, { status: 400 })
160
- }
161
-
162
- const sessions = loadSessions()
163
- const sessionName = `webhook:${id}`
164
- let session = Object.values(sessions).find((s: unknown) => {
165
- const rec = s as Record<string, unknown>
166
- return rec.name === sessionName && rec.agentId === agent.id
167
- }) as Record<string, unknown> | undefined
168
- if (!session) {
169
- const sessionId = genId()
170
- const now = Date.now()
171
- session = {
172
- id: sessionId,
173
- name: sessionName,
174
- cwd: WORKSPACE_DIR,
175
- user: 'system',
176
- provider: agent.provider || 'claude-cli',
177
- model: agent.model || '',
178
- credentialId: agent.credentialId || null,
179
- apiEndpoint: agent.apiEndpoint || null,
180
- claudeSessionId: null,
181
- codexThreadId: null,
182
- opencodeSessionId: null,
183
- delegateResumeIds: {
184
- claudeCode: null,
185
- codex: null,
186
- opencode: null,
187
- },
188
- messages: [],
189
- createdAt: now,
190
- lastActiveAt: now,
191
- sessionType: 'human',
192
- agentId: agent.id,
193
- parentSessionId: null,
194
- tools: agent.tools || [],
195
- heartbeatEnabled: agent.heartbeatEnabled ?? false,
196
- heartbeatIntervalSec: agent.heartbeatIntervalSec ?? null,
197
- }
198
- sessions[session.id as string] = session
199
- saveSessions(sessions)
200
- }
201
-
202
- const sid = session.id as string
203
- const payloadPreview = (rawBody || '').slice(0, 12_000)
204
- const prompt = [
205
- 'Webhook event received.',
206
- `Webhook ID: ${id}`,
207
- `Webhook Name: ${webhook.name || id}`,
208
- `Source: ${webhook.source || 'custom'}`,
209
- `Event: ${incomingEvent}`,
210
- `Received At: ${new Date().toISOString()}`,
211
- '',
212
- 'Payload:',
213
- payloadPreview || '(empty payload)',
214
- '',
215
- 'Handle this event now. If this requires notifying the user, use configured connector tools.',
216
- ].join('\n')
217
-
218
- try {
219
- const run = deps.enqueueRun({
220
- sessionId: sid,
221
- message: prompt,
222
- source: 'webhook',
223
- internal: false,
224
- mode: 'followup',
225
- })
226
-
227
- // Enqueue system event + heartbeat wake
228
- deps.enqueueEvent(sid, `Webhook received: ${webhook.name || id} (${incomingEvent})`)
229
- if (webhook.agentId) {
230
- deps.requestHeartbeat({
231
- agentId: webhook.agentId,
232
- eventId: `webhook:${id}:${incomingEvent}:${Date.now()}`,
233
- reason: 'webhook',
234
- source: `webhook:${id}`,
235
- resumeMessage: `Webhook received: ${webhook.name || id} (${incomingEvent})`,
236
- detail: payloadPreview || '(empty payload)',
237
- })
238
- }
239
-
240
- appendWebhookLog(genId(8), {
241
- id: genId(8), webhookId: id, event: incomingEvent,
242
- payload: (rawBody || '').slice(0, 2000), status: 'success',
243
- sessionId: sid, runId: run.runId, timestamp: Date.now(),
244
- })
245
-
246
- return NextResponse.json({
247
- ok: true,
248
- webhookId: id,
249
- event: incomingEvent,
250
- sessionId: sid,
251
- runId: run.runId,
252
- })
253
- } catch (err: unknown) {
254
- const errorMsg = errorMessage(err)
255
-
256
- // Enqueue for retry with exponential backoff
257
- const retryId = genId()
258
- const now = Date.now()
259
- const retryEntry: WebhookRetryEntry = {
260
- id: retryId,
261
- webhookId: id,
262
- event: incomingEvent,
263
- payload: (rawBody || '').slice(0, 12_000),
264
- attempts: 1,
265
- maxAttempts: 3,
266
- nextRetryAt: now + 30_000,
267
- deadLettered: false,
268
- createdAt: now,
269
- }
270
- upsertWebhookRetry(retryId, retryEntry)
271
-
272
- appendWebhookLog(genId(8), {
273
- id: genId(8), webhookId: id, event: incomingEvent,
274
- payload: (rawBody || '').slice(0, 2000), status: 'error',
275
- error: `Dispatch failed, queued for retry: ${errorMsg}`, timestamp: Date.now(),
276
- })
277
-
278
- return NextResponse.json({
279
- ok: true,
280
- webhookId: id,
281
- event: incomingEvent,
282
- retryQueued: true,
283
- retryId,
284
- error: errorMsg,
285
- })
286
- }
287
- }
288
-
289
48
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
290
49
  const { id } = await params
291
50
  return handleWebhookPost(req, id)
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
2
2
  import test, { afterEach } from 'node:test'
3
3
 
4
4
  import { GET as getWebhookHistory } from './[id]/history/route'
5
- import { handleWebhookPost } from './[id]/route'
5
+ import { handleWebhookPost } from './[id]/helpers'
6
6
  import {
7
7
  loadAgents,
8
8
  loadSessions,
@@ -126,7 +126,9 @@ test('handleWebhookPost creates a session, records success history, and triggers
126
126
  assert.match(String(calls.runs[0].message), /Event: build\.completed/)
127
127
 
128
128
  assert.deepEqual(calls.events, [[sessionId, 'Webhook received: Webhook Smoke (build.completed)']])
129
- assert.deepEqual(calls.heartbeats, [{ agentId: 'agent-webhook-smoke', reason: 'webhook' }])
129
+ assert.equal(calls.heartbeats.length, 1)
130
+ assert.equal(calls.heartbeats[0].agentId, 'agent-webhook-smoke')
131
+ assert.equal(calls.heartbeats[0].reason, 'webhook')
130
132
 
131
133
  const logEntries = Object.values(loadWebhookLogs()) as Array<Record<string, unknown>>
132
134
  const successEntry = logEntries.find((entry) => entry.webhookId === webhookId && entry.status === 'success')
@@ -119,6 +119,51 @@ test('binary server help exits successfully', () => {
119
119
  assert.match(result.stdout, /Usage: swarmclaw server/i)
120
120
  })
121
121
 
122
+ test('binary run alias routes to server help', () => {
123
+ const result = runBinary(['run', '--help'])
124
+ assert.equal(result.status, 0, result.stderr)
125
+ assert.match(result.stdout, /Usage: swarmclaw server/i)
126
+ })
127
+
128
+ test('binary help command shows root help', () => {
129
+ const result = runBinary(['help'])
130
+ assert.equal(result.status, 0, result.stderr)
131
+ assert.match(result.stdout, /SwarmClaw CLI/i)
132
+ assert.match(result.stdout, /swarmclaw help \[command\]/i)
133
+ })
134
+
135
+ test('binary help command shows command help for run alias', () => {
136
+ const result = runBinary(['help', 'run'])
137
+ assert.equal(result.status, 0, result.stderr)
138
+ assert.match(result.stdout, /Usage: swarmclaw server/i)
139
+ })
140
+
141
+ test('binary help command shows command help for doctor alias', () => {
142
+ const result = runBinary(['help', 'doctor'])
143
+ assert.equal(result.status, 0, result.stderr)
144
+ assert.match(result.stdout, /Usage: swarmclaw doctor/i)
145
+ })
146
+
147
+ test('binary status alias routes to local server status', () => {
148
+ const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-binary-status-'))
149
+ const result = runBinary(['status'], {
150
+ env: {
151
+ SWARMCLAW_HOME: homeDir,
152
+ },
153
+ })
154
+
155
+ assert.equal(result.status, 0, result.stderr)
156
+ assert.match(result.stdout, /Server: not running/i)
157
+
158
+ fs.rmSync(homeDir, { recursive: true, force: true })
159
+ })
160
+
161
+ test('binary doctor help exits successfully', () => {
162
+ const result = runBinary(['doctor', '--help'])
163
+ assert.equal(result.status, 0, result.stderr)
164
+ assert.match(result.stdout, /Usage: swarmclaw doctor/i)
165
+ })
166
+
122
167
  test('binary update help exits successfully', () => {
123
168
  const result = runBinary(['update', '--help'])
124
169
  assert.equal(result.status, 0, result.stderr)
@@ -131,6 +176,18 @@ test('binary version output matches package version', () => {
131
176
  assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
132
177
  })
133
178
 
179
+ test('binary bare version alias output matches package version', () => {
180
+ const result = runBinary(['version'])
181
+ assert.equal(result.status, 0, result.stderr)
182
+ assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
183
+ })
184
+
185
+ test('binary -v alias output matches package version', () => {
186
+ const result = runBinary(['-v'])
187
+ assert.equal(result.status, 0, result.stderr)
188
+ assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
189
+ })
190
+
134
191
  test('legacy TS launcher falls back to tsx import when strip-types is unavailable', () => {
135
192
  const cliPath = path.join(APP_ROOT, 'src', 'cli', 'index.ts')
136
193
  const args = buildLegacyTsCliArgs(cliPath, ['runs', 'list'], {
package/src/cli/index.js CHANGED
@@ -412,6 +412,7 @@ const COMMAND_GROUPS = [
412
412
  cmd('skills-install', 'POST', '/openclaw/skills/install', 'Install OpenClaw skill dependencies', { expectsJsonBody: true }),
413
413
  cmd('skills-remove', 'POST', '/openclaw/skills/remove', 'Remove OpenClaw skill', { expectsJsonBody: true }),
414
414
  cmd('sync', 'POST', '/openclaw/sync', 'Run OpenClaw sync action', { expectsJsonBody: true }),
415
+ cmd('dashboard-url', 'GET', '/openclaw/dashboard-url', 'Get tokenized OpenClaw dashboard URL for an agent'),
415
416
  cmd('doctor', 'GET', '/openclaw/doctor', 'Run OpenClaw doctor check (read-only)'),
416
417
  cmd('doctor-fix', 'POST', '/openclaw/doctor', 'Run OpenClaw doctor with auto-fix', { expectsJsonBody: true }),
417
418
  ],
@@ -1182,6 +1183,9 @@ function renderGeneralHelp() {
1182
1183
  'SwarmClaw CLI',
1183
1184
  '',
1184
1185
  'Usage:',
1186
+ ' swarmclaw',
1187
+ ' swarmclaw help [command]',
1188
+ ' swarmclaw run|start|stop|status|doctor|update|version',
1185
1189
  ' swarmclaw <group> <command> [args] [options]',
1186
1190
  '',
1187
1191
  'Global options:',
@@ -1198,6 +1202,15 @@ function renderGeneralHelp() {
1198
1202
  ' --help Show help',
1199
1203
  ' --version Show package version',
1200
1204
  '',
1205
+ 'Top-level commands:',
1206
+ ' run, start Start the SwarmClaw server',
1207
+ ' stop Stop the detached SwarmClaw server',
1208
+ ' status Show local server status',
1209
+ ' doctor Show local install/build diagnostics',
1210
+ ' help Show root or command help',
1211
+ ' update Update this SwarmClaw installation',
1212
+ ' version Show package version',
1213
+ '',
1201
1214
  'Groups:',
1202
1215
  ]
1203
1216
 
@@ -1209,7 +1222,7 @@ function renderGeneralHelp() {
1209
1222
  }
1210
1223
  }
1211
1224
 
1212
- lines.push('', 'Use "swarmclaw <group> --help" for group commands.')
1225
+ lines.push('', 'Use "swarmclaw help <command>" or "swarmclaw <group> --help" for more detail.')
1213
1226
  return lines.join('\n')
1214
1227
  }
1215
1228
 
@@ -19,41 +19,42 @@ function loadServerCmdForHome(homeDir) {
19
19
  return loaded
20
20
  }
21
21
 
22
- test('needsBuild returns true when no build marker exists', () => {
22
+ test('needsBuild returns true when standalone output is missing from the package root', () => {
23
23
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
24
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
24
25
  const serverCmd = loadServerCmdForHome(homeDir)
25
- assert.equal(serverCmd.needsBuild(false), true)
26
+
27
+ assert.equal(serverCmd.needsBuild(false, { pkgRoot }), true)
28
+
26
29
  fs.rmSync(homeDir, { recursive: true, force: true })
30
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
27
31
  })
28
32
 
29
- test('needsBuild returns false when build marker version matches and standalone server exists', () => {
33
+ test('needsBuild returns false when standalone server exists in the package root', () => {
30
34
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
35
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
31
36
  const serverCmd = loadServerCmdForHome(homeDir)
32
37
 
33
- fs.mkdirSync(path.join(homeDir, '.next', 'standalone'), { recursive: true })
34
- fs.writeFileSync(path.join(homeDir, '.next', 'standalone', 'server.js'), 'console.log("ok")\n', 'utf8')
35
- fs.writeFileSync(
36
- path.join(homeDir, '.built'),
37
- JSON.stringify({ builtAt: new Date().toISOString(), version: serverCmd.getVersion() }),
38
- 'utf8',
39
- )
38
+ fs.mkdirSync(path.join(pkgRoot, '.next', 'standalone'), { recursive: true })
39
+ fs.writeFileSync(path.join(pkgRoot, '.next', 'standalone', 'server.js'), 'console.log("ok")\n', 'utf8')
40
+
41
+ assert.equal(serverCmd.needsBuild(false, { pkgRoot }), false)
40
42
 
41
- assert.equal(serverCmd.needsBuild(false), false)
42
43
  fs.rmSync(homeDir, { recursive: true, force: true })
44
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
43
45
  })
44
46
 
45
- test('needsBuild returns true when build marker version is stale', () => {
47
+ test('findStandaloneServer recursively resolves nested standalone server paths', () => {
46
48
  const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-home-'))
49
+ const pkgRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-server-pkg-'))
47
50
  const serverCmd = loadServerCmdForHome(homeDir)
48
51
 
49
- fs.mkdirSync(path.join(homeDir, '.next', 'standalone'), { recursive: true })
50
- fs.writeFileSync(path.join(homeDir, '.next', 'standalone', 'server.js'), 'console.log("ok")\n', 'utf8')
51
- fs.writeFileSync(
52
- path.join(homeDir, '.built'),
53
- JSON.stringify({ builtAt: new Date().toISOString(), version: '0.0.0-test' }),
54
- 'utf8',
55
- )
52
+ const nestedServer = path.join(pkgRoot, '.next', 'standalone', 'Users', 'wayde', 'Dev', 'swarmclaw', 'server.js')
53
+ fs.mkdirSync(path.dirname(nestedServer), { recursive: true })
54
+ fs.writeFileSync(nestedServer, 'console.log("ok")\n', 'utf8')
55
+
56
+ assert.equal(serverCmd.findStandaloneServer({ pkgRoot }), nestedServer)
56
57
 
57
- assert.equal(serverCmd.needsBuild(false), true)
58
58
  fs.rmSync(homeDir, { recursive: true, force: true })
59
+ fs.rmSync(pkgRoot, { recursive: true, force: true })
59
60
  })
@@ -339,6 +339,22 @@ export function SetupWizard({ onComplete }: SetupWizardProps) {
339
339
  agentId = (await api<{ id: string }>('POST', '/agents', payload)).id
340
340
  }
341
341
 
342
+ // Push soul and identity files to the OpenClaw gateway (non-fatal)
343
+ if (draft.provider === 'openclaw') {
344
+ try {
345
+ if (draft.soul.trim()) {
346
+ await api('PUT', '/openclaw/agent-files', { agentId, filename: 'SOUL.md', content: draft.soul.trim() })
347
+ }
348
+ const identityLines = [`# ${draft.name.trim()}`, '']
349
+ if (draft.description.trim()) identityLines.push(draft.description.trim())
350
+ identityLines.push('')
351
+ if (draft.capabilities.length) identityLines.push(`- Capabilities: ${draft.capabilities.join(', ')}`)
352
+ await api('PUT', '/openclaw/agent-files', { agentId, filename: 'IDENTITY.md', content: identityLines.join('\n') })
353
+ } catch {
354
+ // Gateway file sync is best-effort during setup
355
+ }
356
+ }
357
+
342
358
  created.push({
343
359
  id: agentId,
344
360
  name: draft.name.trim(),
@@ -422,34 +422,45 @@ export function StepAgents({
422
422
  focus:border-accent-bright/30 focus:shadow-[0_0_30px_rgba(99,102,241,0.1)]"
423
423
  />
424
424
  </div>
425
- <div>
426
- <label className="block text-[12px] text-text-3 font-500 mb-1.5 ml-1">Model</label>
427
- <ModelCombobox
428
- value={draft.model}
429
- provider={matchedProvider}
430
- endpointOverride={draft.apiEndpoint}
431
- onChange={(model) => onUpdateDraft(draft.id, { model })}
432
- modelLibraryUrl={matchedProvider ? SETUP_PROVIDERS.find((sp) => sp.id === matchedProvider.provider)?.modelLibraryUrl : null}
433
- />
434
- </div>
435
- <div>
436
- <label className="block text-[12px] text-text-3 font-500 mb-1.5 ml-1">Mode</label>
437
- <select
438
- value={draft.platformAssignScope}
439
- onChange={(e) => onUpdateDraft(draft.id, { platformAssignScope: e.target.value as 'self' | 'all' })}
440
- className="w-full px-4 py-3 rounded-[12px] border border-white/[0.08] bg-bg
441
- text-text text-[14px] outline-none transition-all duration-200
442
- focus:border-accent-bright/30 focus:shadow-[0_0_30px_rgba(99,102,241,0.1)]"
443
- >
444
- <option value="self">Focused agent</option>
445
- <option value="all">Delegating orchestrator</option>
446
- </select>
447
- </div>
425
+ {matchedProvider?.provider === 'openclaw' ? (
426
+ <div className="md:col-span-2">
427
+ <label className="block text-[12px] text-text-3 font-500 mb-1.5 ml-1">Model</label>
428
+ <div className="flex items-center gap-3 px-4 py-3 rounded-[12px] border border-white/[0.08] bg-bg">
429
+ <span className="text-[13px] text-text-3">Configured on the OpenClaw gateway.</span>
430
+ {matchedProvider.dashboardUrl && (
431
+ <a
432
+ href={matchedProvider.dashboardUrl}
433
+ target="_blank"
434
+ rel="noopener noreferrer"
435
+ className="text-[13px] text-accent-bright hover:underline whitespace-nowrap"
436
+ >
437
+ Open Dashboard
438
+ </a>
439
+ )}
440
+ </div>
441
+ </div>
442
+ ) : (
443
+ <div className="md:col-span-2">
444
+ <label className="block text-[12px] text-text-3 font-500 mb-1.5 ml-1">Model</label>
445
+ <ModelCombobox
446
+ value={draft.model}
447
+ provider={matchedProvider}
448
+ endpointOverride={draft.apiEndpoint}
449
+ onChange={(model) => onUpdateDraft(draft.id, { model })}
450
+ modelLibraryUrl={matchedProvider ? SETUP_PROVIDERS.find((sp) => sp.id === matchedProvider.provider)?.modelLibraryUrl : null}
451
+ />
452
+ </div>
453
+ )}
448
454
  <div className="md:col-span-2">
449
455
  <SoulPicker
450
456
  value={draft.soul}
451
457
  onChange={(soul) => onUpdateDraft(draft.id, { soul })}
452
458
  />
459
+ {matchedProvider?.provider === 'openclaw' && (
460
+ <p className="mt-1.5 ml-1 text-[11px] text-text-3/70">
461
+ Synced to the gateway as SOUL.md on save.
462
+ </p>
463
+ )}
453
464
  </div>
454
465
  </div>
455
466
 
@@ -200,6 +200,13 @@ export function StepConnect({
200
200
  resolvedProvider = customConfig.id as typeof provider
201
201
  }
202
202
 
203
+ // Build a tokenized dashboard URL for OpenClaw so step-agents can link to it
204
+ let dashboardUrl: string | null = null
205
+ if (provider === 'openclaw') {
206
+ const base = resolveOpenClawDashboardUrl(endpoint.trim() || selectedProvider.defaultEndpoint)
207
+ dashboardUrl = apiKey.trim() ? `${base}?token=${encodeURIComponent(apiKey.trim())}` : base
208
+ }
209
+
203
210
  const configured: ConfiguredProvider = {
204
211
  id: crypto.randomUUID(),
205
212
  provider: resolvedProvider,
@@ -212,6 +219,7 @@ export function StepConnect({
212
219
  tags: providerTags,
213
220
  deployment: providerDeployment,
214
221
  verified: checkState === 'ok',
222
+ dashboardUrl,
215
223
  }
216
224
 
217
225
  onSaveProvider(configured)
@@ -44,6 +44,8 @@ export interface ConfiguredProvider {
44
44
  tags?: string[]
45
45
  deployment?: GatewayProfile['deployment'] | null
46
46
  verified?: boolean
47
+ /** Pre-built dashboard URL (with token if available). Only set for OpenClaw. */
48
+ dashboardUrl?: string | null
47
49
  }
48
50
 
49
51
  export interface StarterDraftAgent {