@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
@@ -0,0 +1,100 @@
1
+ import { dedup } from '@/lib/shared-utils'
2
+
3
+ type GitHubIssueLabel = string | { name?: string | null }
4
+
5
+ export interface GitHubIssueRecord {
6
+ id: number | string
7
+ number: number
8
+ title: string
9
+ body?: string | null
10
+ state?: string | null
11
+ html_url?: string | null
12
+ labels?: GitHubIssueLabel[]
13
+ assignee?: { login?: string | null } | null
14
+ user?: { login?: string | null } | null
15
+ pull_request?: unknown
16
+ }
17
+
18
+ export interface ParsedRepo {
19
+ owner: string
20
+ repo: string
21
+ fullName: string
22
+ }
23
+
24
+ const BODY_CHAR_LIMIT = 12_000
25
+
26
+ function normalizeLabelName(label: GitHubIssueLabel): string {
27
+ if (typeof label === 'string') return label.trim()
28
+ return String(label?.name || '').trim()
29
+ }
30
+
31
+ function normalizeTag(value: string): string {
32
+ return value.trim().replace(/\s+/g, ' ').slice(0, 60)
33
+ }
34
+
35
+ export function parseGitHubRepoInput(input: string): ParsedRepo | null {
36
+ const trimmed = input.trim().replace(/\.git$/i, '')
37
+ if (!trimmed) return null
38
+
39
+ if (/^https?:\/\//i.test(trimmed)) {
40
+ try {
41
+ const url = new URL(trimmed)
42
+ if (!/github\.com$/i.test(url.hostname)) return null
43
+ const parts = url.pathname.split('/').filter(Boolean)
44
+ if (parts.length < 2) return null
45
+ const owner = parts[0]
46
+ const repo = parts[1].replace(/\.git$/i, '')
47
+ if (!owner || !repo) return null
48
+ return { owner, repo, fullName: `${owner}/${repo}` }
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ const compact = trimmed.replace(/^github\.com\//i, '')
55
+ const parts = compact.split('/').filter(Boolean)
56
+ if (parts.length < 2) return null
57
+ const owner = parts[0]
58
+ const repo = parts[1].replace(/\.git$/i, '')
59
+ if (!owner || !repo) return null
60
+ return { owner, repo, fullName: `${owner}/${repo}` }
61
+ }
62
+
63
+ export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
64
+ const title = issue.title?.trim() || `Issue ${issue.number}`
65
+ return `[${repoFullName}#${issue.number}] ${title}`
66
+ }
67
+
68
+ export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
69
+ const labels = (issue.labels || [])
70
+ .map(normalizeLabelName)
71
+ .filter(Boolean)
72
+ const header = [
73
+ `Imported from GitHub issue ${repoFullName}#${issue.number}`,
74
+ issue.html_url ? `URL: ${issue.html_url}` : '',
75
+ issue.state ? `State: ${issue.state}` : '',
76
+ labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
77
+ issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
78
+ issue.user?.login ? `Opened by: ${issue.user.login}` : '',
79
+ ]
80
+ .filter(Boolean)
81
+ .join('\n')
82
+
83
+ const rawBody = String(issue.body || '').trim()
84
+ if (!rawBody) return header
85
+
86
+ const body = rawBody.length > BODY_CHAR_LIMIT
87
+ ? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
88
+ : rawBody
89
+
90
+ return `${header}\n\n${body}`
91
+ }
92
+
93
+ export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
94
+ const raw = [
95
+ 'github',
96
+ repoFullName,
97
+ ...(issue.labels || []).map(normalizeLabelName),
98
+ ]
99
+ return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
100
+ }
@@ -5,7 +5,7 @@ import {
5
5
  buildGitHubIssueTaskTags,
6
6
  buildGitHubIssueTaskTitle,
7
7
  parseGitHubRepoInput,
8
- } from './route'
8
+ } from './helpers'
9
9
 
10
10
  test('parseGitHubRepoInput accepts repo slugs and GitHub URLs', () => {
11
11
  assert.deepEqual(parseGitHubRepoInput('swarmclawai/swarmclaw'), {
@@ -6,10 +6,10 @@ import { formatZodError } from '@/lib/validation/schemas'
6
6
  import { loadSettings, loadTasks, logActivity, upsertStoredItems } from '@/lib/server/storage'
7
7
  import { notify } from '@/lib/server/ws-hub'
8
8
  import type { BoardTask } from '@/types'
9
- import { dedup } from '@/lib/shared-utils'
9
+ import { parseGitHubRepoInput, buildGitHubIssueTaskTitle, buildGitHubIssueTaskDescription, buildGitHubIssueTaskTags } from './helpers'
10
+ import type { GitHubIssueRecord } from './helpers'
10
11
 
11
12
  const MAX_IMPORT_LIMIT = 200
12
- const BODY_CHAR_LIMIT = 12_000
13
13
 
14
14
  const GitHubIssueImportSchema = z.object({
15
15
  repo: z.string().trim().min(1, 'Repository is required'),
@@ -23,25 +23,6 @@ const GitHubIssueImportSchema = z.object({
23
23
 
24
24
  type GitHubIssueLabel = string | { name?: string | null }
25
25
 
26
- interface GitHubIssueRecord {
27
- id: number | string
28
- number: number
29
- title: string
30
- body?: string | null
31
- state?: string | null
32
- html_url?: string | null
33
- labels?: GitHubIssueLabel[]
34
- assignee?: { login?: string | null } | null
35
- user?: { login?: string | null } | null
36
- pull_request?: unknown
37
- }
38
-
39
- interface ParsedRepo {
40
- owner: string
41
- repo: string
42
- fullName: string
43
- }
44
-
45
26
  function getGitHubToken(explicitToken: string): string {
46
27
  return explicitToken.trim()
47
28
  || process.env.GITHUB_TOKEN
@@ -55,10 +36,6 @@ function normalizeLabelName(label: GitHubIssueLabel): string {
55
36
  return String(label?.name || '').trim()
56
37
  }
57
38
 
58
- function normalizeTag(value: string): string {
59
- return value.trim().replace(/\s+/g, ' ').slice(0, 60)
60
- }
61
-
62
39
  function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
63
40
  return {
64
41
  taskId,
@@ -68,73 +45,6 @@ function toIssueSummary(issue: GitHubIssueRecord, taskId?: string) {
68
45
  }
69
46
  }
70
47
 
71
- export function parseGitHubRepoInput(input: string): ParsedRepo | null {
72
- const trimmed = input.trim().replace(/\.git$/i, '')
73
- if (!trimmed) return null
74
-
75
- if (/^https?:\/\//i.test(trimmed)) {
76
- try {
77
- const url = new URL(trimmed)
78
- if (!/github\.com$/i.test(url.hostname)) return null
79
- const parts = url.pathname.split('/').filter(Boolean)
80
- if (parts.length < 2) return null
81
- const owner = parts[0]
82
- const repo = parts[1].replace(/\.git$/i, '')
83
- if (!owner || !repo) return null
84
- return { owner, repo, fullName: `${owner}/${repo}` }
85
- } catch {
86
- return null
87
- }
88
- }
89
-
90
- const compact = trimmed.replace(/^github\.com\//i, '')
91
- const parts = compact.split('/').filter(Boolean)
92
- if (parts.length < 2) return null
93
- const owner = parts[0]
94
- const repo = parts[1].replace(/\.git$/i, '')
95
- if (!owner || !repo) return null
96
- return { owner, repo, fullName: `${owner}/${repo}` }
97
- }
98
-
99
- export function buildGitHubIssueTaskTitle(issue: GitHubIssueRecord, repoFullName: string): string {
100
- const title = issue.title?.trim() || `Issue ${issue.number}`
101
- return `[${repoFullName}#${issue.number}] ${title}`
102
- }
103
-
104
- export function buildGitHubIssueTaskDescription(issue: GitHubIssueRecord, repoFullName: string): string {
105
- const labels = (issue.labels || [])
106
- .map(normalizeLabelName)
107
- .filter(Boolean)
108
- const header = [
109
- `Imported from GitHub issue ${repoFullName}#${issue.number}`,
110
- issue.html_url ? `URL: ${issue.html_url}` : '',
111
- issue.state ? `State: ${issue.state}` : '',
112
- labels.length > 0 ? `Labels: ${labels.join(', ')}` : '',
113
- issue.assignee?.login ? `Assignee: ${issue.assignee.login}` : '',
114
- issue.user?.login ? `Opened by: ${issue.user.login}` : '',
115
- ]
116
- .filter(Boolean)
117
- .join('\n')
118
-
119
- const rawBody = String(issue.body || '').trim()
120
- if (!rawBody) return header
121
-
122
- const body = rawBody.length > BODY_CHAR_LIMIT
123
- ? `${rawBody.slice(0, BODY_CHAR_LIMIT).trimEnd()}\n\n[Truncated during import]`
124
- : rawBody
125
-
126
- return `${header}\n\n${body}`
127
- }
128
-
129
- export function buildGitHubIssueTaskTags(issue: GitHubIssueRecord, repoFullName: string): string[] {
130
- const raw = [
131
- 'github',
132
- repoFullName,
133
- ...(issue.labels || []).map(normalizeLabelName),
134
- ]
135
- return dedup(raw.map(normalizeTag).filter(Boolean)).slice(0, 8)
136
- }
137
-
138
48
  function findExistingImportedTask(
139
49
  tasks: Record<string, BoardTask>,
140
50
  repoFullName: string,
@@ -0,0 +1,253 @@
1
+ import { genId } from '@/lib/id'
2
+ import { NextResponse } from 'next/server'
3
+ import { loadAgents, loadSessions, saveSessions, loadWebhooks, appendWebhookLog, upsertWebhookRetry } from '@/lib/server/storage'
4
+ import { WORKSPACE_DIR } from '@/lib/server/data-dir'
5
+ import { enqueueSessionRun } from '@/lib/server/runtime/session-run-manager'
6
+ import { enqueueSystemEvent } from '@/lib/server/runtime/system-events'
7
+ import { requestHeartbeatNow } from '@/lib/server/runtime/heartbeat-wake'
8
+ import { notFound } from '@/lib/server/collection-helpers'
9
+ import type { WebhookRetryEntry } from '@/types'
10
+ import { triggerWebhookWatchJobs } from '@/lib/server/runtime/watch-jobs'
11
+ import { errorMessage } from '@/lib/shared-utils'
12
+
13
+ export type WebhookPostDeps = {
14
+ enqueueRun: typeof enqueueSessionRun
15
+ enqueueEvent: typeof enqueueSystemEvent
16
+ requestHeartbeat: typeof requestHeartbeatNow
17
+ }
18
+
19
+ export const defaultWebhookPostDeps: WebhookPostDeps = {
20
+ enqueueRun: enqueueSessionRun,
21
+ enqueueEvent: enqueueSystemEvent,
22
+ requestHeartbeat: requestHeartbeatNow,
23
+ }
24
+
25
+ function normalizeEvents(value: unknown): string[] {
26
+ if (!Array.isArray(value)) return []
27
+ return value
28
+ .filter((v): v is string => typeof v === 'string')
29
+ .map((v) => v.trim())
30
+ .filter(Boolean)
31
+ }
32
+
33
+ function eventMatches(registered: string[], incoming: string): boolean {
34
+ if (registered.length === 0) return true
35
+ if (registered.includes('*')) return true
36
+ return registered.includes(incoming)
37
+ }
38
+
39
+ export async function handleWebhookPost(
40
+ req: Request,
41
+ id: string,
42
+ deps: WebhookPostDeps = defaultWebhookPostDeps,
43
+ ) {
44
+ const webhooks = loadWebhooks()
45
+ const webhook = webhooks[id]
46
+ if (!webhook) return notFound('Webhook not found')
47
+ if (webhook.isEnabled === false) {
48
+ appendWebhookLog(genId(8), {
49
+ id: genId(8), webhookId: id, event: 'unknown',
50
+ payload: '', status: 'error', error: 'Webhook is disabled', timestamp: Date.now(),
51
+ })
52
+ return NextResponse.json({ error: 'Webhook is disabled' }, { status: 409 })
53
+ }
54
+
55
+ const { timingSafeEqual } = await import('node:crypto')
56
+ const secret = typeof webhook.secret === 'string' ? webhook.secret.trim() : ''
57
+ if (secret) {
58
+ const url = new URL(req.url)
59
+ const provided = req.headers.get('x-webhook-secret') || url.searchParams.get('secret') || ''
60
+ const secretBuf = Buffer.from(secret)
61
+ const providedBuf = Buffer.from(provided)
62
+ // timingSafeEqual requires equal lengths; compare against secretBuf if lengths differ
63
+ const compareBuf = providedBuf.length === secretBuf.length ? providedBuf : secretBuf
64
+ const isInvalid = providedBuf.length !== secretBuf.length || !timingSafeEqual(secretBuf, compareBuf)
65
+ if (isInvalid) {
66
+ appendWebhookLog(genId(8), {
67
+ id: genId(8), webhookId: id, event: 'unknown',
68
+ payload: '', status: 'error', error: 'Invalid webhook secret', timestamp: Date.now(),
69
+ })
70
+ return NextResponse.json({ error: 'Invalid webhook secret' }, { status: 401 })
71
+ }
72
+ }
73
+
74
+ let payload: unknown = null
75
+ let rawBody = ''
76
+ const contentType = req.headers.get('content-type') || ''
77
+ if (contentType.includes('application/json')) {
78
+ try {
79
+ payload = await req.json()
80
+ rawBody = JSON.stringify(payload)
81
+ } catch {
82
+ payload = {}
83
+ rawBody = '{}'
84
+ }
85
+ } else {
86
+ rawBody = await req.text()
87
+ try {
88
+ payload = JSON.parse(rawBody)
89
+ } catch {
90
+ payload = { raw: rawBody }
91
+ }
92
+ }
93
+
94
+ const url = new URL(req.url)
95
+ const incomingEvent = String(
96
+ (payload as Record<string, unknown> | null)?.type
97
+ || (payload as Record<string, unknown> | null)?.event
98
+ || req.headers.get('x-event-type')
99
+ || url.searchParams.get('event')
100
+ || 'unknown',
101
+ )
102
+ const registeredEvents = normalizeEvents(webhook.events)
103
+ if (!eventMatches(registeredEvents, incomingEvent)) {
104
+ return NextResponse.json({
105
+ ok: true,
106
+ ignored: true,
107
+ reason: 'Event does not match webhook filters',
108
+ event: incomingEvent,
109
+ })
110
+ }
111
+
112
+ triggerWebhookWatchJobs({
113
+ webhookId: id,
114
+ event: incomingEvent,
115
+ payloadPreview: rawBody,
116
+ })
117
+
118
+ const agents = loadAgents()
119
+ const agent = webhook.agentId ? agents[webhook.agentId] : null
120
+ if (!agent) {
121
+ appendWebhookLog(genId(8), {
122
+ id: genId(8), webhookId: id, event: incomingEvent,
123
+ payload: (rawBody || '').slice(0, 2000), status: 'error', error: 'Webhook agent is not configured or missing', timestamp: Date.now(),
124
+ })
125
+ return NextResponse.json({ error: 'Webhook agent is not configured or missing' }, { status: 400 })
126
+ }
127
+
128
+ const sessions = loadSessions()
129
+ const sessionName = `webhook:${id}`
130
+ let session = Object.values(sessions).find((s: unknown) => {
131
+ const rec = s as Record<string, unknown>
132
+ return rec.name === sessionName && rec.agentId === agent.id
133
+ }) as Record<string, unknown> | undefined
134
+ if (!session) {
135
+ const sessionId = genId()
136
+ const now = Date.now()
137
+ session = {
138
+ id: sessionId,
139
+ name: sessionName,
140
+ cwd: WORKSPACE_DIR,
141
+ user: 'system',
142
+ provider: agent.provider || 'claude-cli',
143
+ model: agent.model || '',
144
+ credentialId: agent.credentialId || null,
145
+ apiEndpoint: agent.apiEndpoint || null,
146
+ claudeSessionId: null,
147
+ codexThreadId: null,
148
+ opencodeSessionId: null,
149
+ delegateResumeIds: {
150
+ claudeCode: null,
151
+ codex: null,
152
+ opencode: null,
153
+ },
154
+ messages: [],
155
+ createdAt: now,
156
+ lastActiveAt: now,
157
+ sessionType: 'human',
158
+ agentId: agent.id,
159
+ parentSessionId: null,
160
+ tools: agent.tools || [],
161
+ heartbeatEnabled: agent.heartbeatEnabled ?? false,
162
+ heartbeatIntervalSec: agent.heartbeatIntervalSec ?? null,
163
+ }
164
+ sessions[session.id as string] = session
165
+ saveSessions(sessions)
166
+ }
167
+
168
+ const sid = session.id as string
169
+ const payloadPreview = (rawBody || '').slice(0, 12_000)
170
+ const prompt = [
171
+ 'Webhook event received.',
172
+ `Webhook ID: ${id}`,
173
+ `Webhook Name: ${webhook.name || id}`,
174
+ `Source: ${webhook.source || 'custom'}`,
175
+ `Event: ${incomingEvent}`,
176
+ `Received At: ${new Date().toISOString()}`,
177
+ '',
178
+ 'Payload:',
179
+ payloadPreview || '(empty payload)',
180
+ '',
181
+ 'Handle this event now. If this requires notifying the user, use configured connector tools.',
182
+ ].join('\n')
183
+
184
+ try {
185
+ const run = deps.enqueueRun({
186
+ sessionId: sid,
187
+ message: prompt,
188
+ source: 'webhook',
189
+ internal: false,
190
+ mode: 'followup',
191
+ })
192
+
193
+ // Enqueue system event + heartbeat wake
194
+ deps.enqueueEvent(sid, `Webhook received: ${webhook.name || id} (${incomingEvent})`)
195
+ if (webhook.agentId) {
196
+ deps.requestHeartbeat({
197
+ agentId: webhook.agentId,
198
+ eventId: `webhook:${id}:${incomingEvent}:${Date.now()}`,
199
+ reason: 'webhook',
200
+ source: `webhook:${id}`,
201
+ resumeMessage: `Webhook received: ${webhook.name || id} (${incomingEvent})`,
202
+ detail: payloadPreview || '(empty payload)',
203
+ })
204
+ }
205
+
206
+ appendWebhookLog(genId(8), {
207
+ id: genId(8), webhookId: id, event: incomingEvent,
208
+ payload: (rawBody || '').slice(0, 2000), status: 'success',
209
+ sessionId: sid, runId: run.runId, timestamp: Date.now(),
210
+ })
211
+
212
+ return NextResponse.json({
213
+ ok: true,
214
+ webhookId: id,
215
+ event: incomingEvent,
216
+ sessionId: sid,
217
+ runId: run.runId,
218
+ })
219
+ } catch (err: unknown) {
220
+ const errorMsg = errorMessage(err)
221
+
222
+ // Enqueue for retry with exponential backoff
223
+ const retryId = genId()
224
+ const now = Date.now()
225
+ const retryEntry: WebhookRetryEntry = {
226
+ id: retryId,
227
+ webhookId: id,
228
+ event: incomingEvent,
229
+ payload: (rawBody || '').slice(0, 12_000),
230
+ attempts: 1,
231
+ maxAttempts: 3,
232
+ nextRetryAt: now + 30_000,
233
+ deadLettered: false,
234
+ createdAt: now,
235
+ }
236
+ upsertWebhookRetry(retryId, retryEntry)
237
+
238
+ appendWebhookLog(genId(8), {
239
+ id: genId(8), webhookId: id, event: incomingEvent,
240
+ payload: (rawBody || '').slice(0, 2000), status: 'error',
241
+ error: `Dispatch failed, queued for retry: ${errorMsg}`, timestamp: Date.now(),
242
+ })
243
+
244
+ return NextResponse.json({
245
+ ok: true,
246
+ webhookId: id,
247
+ event: incomingEvent,
248
+ retryQueued: true,
249
+ retryId,
250
+ error: errorMsg,
251
+ })
252
+ }
253
+ }