@swarmclawai/swarmclaw 1.9.2 → 1.9.4

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 +23 -3
  2. package/electron-dist/main.js +218 -0
  3. package/package.json +2 -2
  4. package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
  5. package/src/app/api/extensions/managed-resources/route.ts +116 -0
  6. package/src/app/api/gateways/[id]/environments/[environmentId]/route.ts +16 -0
  7. package/src/app/api/gateways/[id]/environments/route.ts +13 -0
  8. package/src/app/api/gateways/topology-route.test.ts +30 -0
  9. package/src/app/api/tasks/task-workspace-route.test.ts +4 -0
  10. package/src/cli/index.js +4 -0
  11. package/src/cli/spec.js +4 -0
  12. package/src/components/providers/provider-list.tsx +34 -1
  13. package/src/components/tasks/task-sheet.tsx +50 -0
  14. package/src/features/gateways/queries.ts +3 -0
  15. package/src/lib/server/extension-managed-resources.test.ts +159 -0
  16. package/src/lib/server/extension-managed-resources.ts +905 -0
  17. package/src/lib/server/extensions.ts +113 -2
  18. package/src/lib/server/gateways/gateway-profile-service.ts +2 -0
  19. package/src/lib/server/gateways/gateway-topology.test.ts +59 -3
  20. package/src/lib/server/gateways/gateway-topology.ts +129 -3
  21. package/src/lib/server/operations/operation-pulse.test.ts +29 -0
  22. package/src/lib/server/operations/operation-pulse.ts +9 -0
  23. package/src/lib/server/session-tools/extension-creator.ts +50 -0
  24. package/src/lib/server/tasks/task-execution-workspace.test.ts +14 -0
  25. package/src/lib/server/tasks/task-execution-workspace.ts +133 -6
  26. package/src/types/agent.ts +2 -0
  27. package/src/types/app-settings.ts +8 -0
  28. package/src/types/extension.ts +132 -0
  29. package/src/types/misc.ts +31 -0
  30. package/src/types/schedule.ts +3 -0
  31. package/src/types/task.ts +30 -0
  32. package/src/views/settings/extension-manager.tsx +157 -1
@@ -0,0 +1,905 @@
1
+ import fs from 'fs/promises'
2
+ import { constants as fsConstants } from 'fs'
3
+ import path from 'path'
4
+ import crypto from 'crypto'
5
+ import { getExtensionManager } from '@/lib/server/extensions'
6
+ import { loadAgents, saveAgentMany } from '@/lib/server/agents/agent-repository'
7
+ import { loadSchedules, upsertSchedules } from '@/lib/server/schedules/schedule-repository'
8
+ import { loadSettings, saveSettings } from '@/lib/server/settings/settings-repository'
9
+ import { logActivity } from '@/lib/server/activity/activity-log'
10
+ import { notify } from '@/lib/server/ws-hub'
11
+ import { WORKSPACE_DIR } from '@/lib/server/data-dir'
12
+ import type {
13
+ Agent,
14
+ AppSettings,
15
+ ExtensionManagedAgentDeclaration,
16
+ ExtensionManagedLocalFolderDeclaration,
17
+ ExtensionManagedResourceKind,
18
+ ExtensionManagedResourceMarker,
19
+ ExtensionManagedResourceRef,
20
+ ExtensionManagedResources,
21
+ ExtensionManagedScheduleDeclaration,
22
+ Schedule,
23
+ ScheduleStatus,
24
+ ScheduleType,
25
+ } from '@/types'
26
+
27
+ type ManagedExtensionEntry = ReturnType<ReturnType<typeof getExtensionManager>['getManagedResourceExtensions']>[number]
28
+
29
+ export interface ManagedResourceSummaryItem {
30
+ extensionId: string
31
+ extensionName: string
32
+ enabled: boolean
33
+ isBuiltin: boolean
34
+ source?: string
35
+ agents: Array<ManagedResourceDeclarationSummary<'agent'>>
36
+ schedules: Array<ManagedResourceDeclarationSummary<'schedule'>>
37
+ localFolders: Array<ManagedResourceDeclarationSummary<'local_folder'>>
38
+ gatewayPlatforms: Array<{
39
+ platformKey: string
40
+ displayName: string
41
+ description?: string | null
42
+ transport?: string
43
+ endpoint?: string | null
44
+ authMode?: string
45
+ setupCheckKey?: string | null
46
+ capabilities?: string[]
47
+ }>
48
+ setupChecks: Array<{
49
+ checkKey: string
50
+ displayName: string
51
+ description?: string | null
52
+ kind: string
53
+ target?: string | null
54
+ required: boolean
55
+ }>
56
+ }
57
+
58
+ export interface ManagedResourceDeclarationSummary<K extends ExtensionManagedResourceKind> {
59
+ resourceKind: K
60
+ resourceKey: string
61
+ displayName: string
62
+ status: 'declared' | 'resolved' | 'missing' | 'missing_ref' | 'unsupported_trigger'
63
+ resourceId: string | null
64
+ declarationHash: string
65
+ configured?: boolean
66
+ healthy?: boolean
67
+ problems?: string[]
68
+ }
69
+
70
+ export interface ManagedResourceSummary {
71
+ extensions: ManagedResourceSummaryItem[]
72
+ totals: {
73
+ extensions: number
74
+ agents: number
75
+ schedules: number
76
+ localFolders: number
77
+ gatewayPlatforms: number
78
+ setupChecks: number
79
+ resolvedAgents: number
80
+ resolvedSchedules: number
81
+ healthyLocalFolders: number
82
+ }
83
+ }
84
+
85
+ export interface ManagedResourceReconcileResult {
86
+ extensionId?: string
87
+ createdAgents: string[]
88
+ updatedAgents: string[]
89
+ createdSchedules: string[]
90
+ updatedSchedules: string[]
91
+ skipped: Array<{ resourceKind: 'agent' | 'schedule'; resourceKey: string; reason: string }>
92
+ }
93
+
94
+ export interface ExtensionLocalFolderProblem {
95
+ code: 'not_configured' | 'not_absolute' | 'missing' | 'not_directory' | 'not_readable' | 'not_writable' | 'missing_directory' | 'missing_file' | 'symlink_escape'
96
+ message: string
97
+ path?: string
98
+ }
99
+
100
+ export interface ExtensionLocalFolderStatus {
101
+ extensionId: string
102
+ folderKey: string
103
+ displayName: string
104
+ configured: boolean
105
+ path: string | null
106
+ realPath: string | null
107
+ access: 'read' | 'readWrite'
108
+ readable: boolean
109
+ writable: boolean
110
+ requiredDirectories: string[]
111
+ requiredFiles: string[]
112
+ missingDirectories: string[]
113
+ missingFiles: string[]
114
+ healthy: boolean
115
+ problems: ExtensionLocalFolderProblem[]
116
+ checkedAt: number
117
+ }
118
+
119
+ export interface ExtensionLocalFolderEntry {
120
+ path: string
121
+ name: string
122
+ kind: 'directory' | 'file'
123
+ size: number | null
124
+ modifiedAt: number
125
+ }
126
+
127
+ export interface ExtensionLocalFolderListing {
128
+ extensionId: string
129
+ folderKey: string
130
+ relativePath: string | null
131
+ entries: ExtensionLocalFolderEntry[]
132
+ truncated: boolean
133
+ }
134
+
135
+ type StoredLocalFolderConfig = ExtensionManagedLocalFolderDeclaration & {
136
+ path?: string | null
137
+ updatedAt?: number
138
+ }
139
+
140
+ function isRecord(value: unknown): value is Record<string, unknown> {
141
+ return !!value && typeof value === 'object' && !Array.isArray(value)
142
+ }
143
+
144
+ function text(value: unknown): string {
145
+ return typeof value === 'string' ? value.trim() : ''
146
+ }
147
+
148
+ function list(value: unknown): string[] {
149
+ return Array.isArray(value)
150
+ ? value.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
151
+ : []
152
+ }
153
+
154
+ function stableJson(value: unknown): string {
155
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`
156
+ if (!isRecord(value)) return JSON.stringify(value)
157
+ const entries = Object.keys(value)
158
+ .sort()
159
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
160
+ return `{${entries.join(',')}}`
161
+ }
162
+
163
+ function declarationHash(value: unknown): string {
164
+ return crypto.createHash('sha1').update(stableJson(value)).digest('hex')
165
+ }
166
+
167
+ function managedResourceId(extensionId: string, kind: 'agent' | 'schedule', key: string): string {
168
+ const hash = crypto.createHash('sha1').update(`${extensionId}:${kind}:${key}`).digest('hex').slice(0, 20)
169
+ return `managed_${kind}_${hash}`
170
+ }
171
+
172
+ function managedMarker(
173
+ extension: ManagedExtensionEntry,
174
+ resourceKind: ExtensionManagedResourceKind,
175
+ resourceKey: string,
176
+ hash: string,
177
+ ): ExtensionManagedResourceMarker {
178
+ return {
179
+ extensionId: extension.extensionId,
180
+ extensionName: extension.extensionName,
181
+ resourceKind,
182
+ resourceKey,
183
+ declarationHash: hash,
184
+ reconciledAt: Date.now(),
185
+ }
186
+ }
187
+
188
+ function getManagedAgentKey(declaration: ExtensionManagedAgentDeclaration): string {
189
+ return text(declaration.agentKey)
190
+ }
191
+
192
+ function getManagedScheduleKey(declaration: ExtensionManagedScheduleDeclaration): string {
193
+ return text(declaration.scheduleKey) || text(declaration.routineKey)
194
+ }
195
+
196
+ function getManagedScheduleTitle(declaration: ExtensionManagedScheduleDeclaration): string {
197
+ return text(declaration.displayName) || text(declaration.title) || getManagedScheduleKey(declaration)
198
+ }
199
+
200
+ function getManagedAgentDisplayName(declaration: ExtensionManagedAgentDeclaration): string {
201
+ return text(declaration.displayName) || getManagedAgentKey(declaration)
202
+ }
203
+
204
+ function normalizeCapabilities(value: ExtensionManagedAgentDeclaration['capabilities']): string[] {
205
+ if (Array.isArray(value)) return list(value)
206
+ const single = text(value)
207
+ return single ? [single] : []
208
+ }
209
+
210
+ function findManagedAgent(
211
+ agents: Record<string, Agent>,
212
+ extensionId: string,
213
+ agentKey: string,
214
+ ): Agent | null {
215
+ const stableId = managedResourceId(extensionId, 'agent', agentKey)
216
+ if (agents[stableId]) return agents[stableId]
217
+ return Object.values(agents).find((agent) =>
218
+ agent?.managedByExtension?.extensionId === extensionId
219
+ && agent.managedByExtension.resourceKind === 'agent'
220
+ && agent.managedByExtension.resourceKey === agentKey
221
+ ) || null
222
+ }
223
+
224
+ function findManagedSchedule(
225
+ schedules: Record<string, Schedule>,
226
+ extensionId: string,
227
+ scheduleKey: string,
228
+ ): Schedule | null {
229
+ const stableId = managedResourceId(extensionId, 'schedule', scheduleKey)
230
+ if (schedules[stableId]) return schedules[stableId]
231
+ return Object.values(schedules).find((schedule) =>
232
+ schedule?.managedByExtension?.extensionId === extensionId
233
+ && schedule.managedByExtension.resourceKind === 'schedule'
234
+ && schedule.managedByExtension.resourceKey === scheduleKey
235
+ ) || null
236
+ }
237
+
238
+ function agentRefKey(ref: ExtensionManagedResourceRef | null | undefined): string {
239
+ if (!ref || ref.resourceKind !== 'agent') return ''
240
+ return text(ref.resourceKey)
241
+ }
242
+
243
+ function resolveManagedScheduleAgentId(
244
+ declaration: ExtensionManagedScheduleDeclaration,
245
+ extension: ManagedExtensionEntry,
246
+ agents: Record<string, Agent>,
247
+ ): string | null {
248
+ const explicit = text(declaration.agentId)
249
+ if (explicit) return agents[explicit] ? explicit : null
250
+ const refKey = agentRefKey(declaration.agentRef) || agentRefKey(declaration.assigneeRef)
251
+ if (!refKey) return null
252
+ return findManagedAgent(agents, extension.extensionId, refKey)?.id || null
253
+ }
254
+
255
+ function scheduleTiming(declaration: ExtensionManagedScheduleDeclaration): {
256
+ ok: true
257
+ scheduleType: ScheduleType
258
+ cron?: string
259
+ intervalMs?: number
260
+ runAt?: number
261
+ timezone?: string | null
262
+ triggerEnabled?: boolean
263
+ } | { ok: false; reason: string } {
264
+ const scheduleTrigger = Array.isArray(declaration.triggers)
265
+ ? declaration.triggers.find((trigger) => trigger.kind === 'schedule' || text(trigger.cronExpression))
266
+ : null
267
+ const cron = text(declaration.cron) || text(scheduleTrigger?.cronExpression)
268
+ if (cron) {
269
+ return {
270
+ ok: true,
271
+ scheduleType: 'cron',
272
+ cron,
273
+ timezone: text(declaration.timezone) || text(scheduleTrigger?.timezone) || null,
274
+ triggerEnabled: scheduleTrigger?.enabled,
275
+ }
276
+ }
277
+ if (typeof declaration.intervalMs === 'number' && Number.isFinite(declaration.intervalMs) && declaration.intervalMs > 0) {
278
+ return {
279
+ ok: true,
280
+ scheduleType: 'interval',
281
+ intervalMs: Math.trunc(declaration.intervalMs),
282
+ timezone: text(declaration.timezone) || null,
283
+ triggerEnabled: scheduleTrigger?.enabled,
284
+ }
285
+ }
286
+ if (typeof declaration.runAt === 'number' && Number.isFinite(declaration.runAt) && declaration.runAt > 0) {
287
+ return {
288
+ ok: true,
289
+ scheduleType: 'once',
290
+ runAt: Math.trunc(declaration.runAt),
291
+ timezone: text(declaration.timezone) || null,
292
+ triggerEnabled: scheduleTrigger?.enabled,
293
+ }
294
+ }
295
+ return { ok: false, reason: 'missing_schedule_timing' }
296
+ }
297
+
298
+ function normalizeScheduleStatus(value: unknown, triggerEnabled?: boolean): ScheduleStatus {
299
+ if (value === 'active' || value === 'paused' || value === 'completed' || value === 'failed' || value === 'archived') {
300
+ return value
301
+ }
302
+ if (triggerEnabled === false) return 'paused'
303
+ return 'paused'
304
+ }
305
+
306
+ function buildManagedAgent(
307
+ existing: Agent | null,
308
+ extension: ManagedExtensionEntry,
309
+ declaration: ExtensionManagedAgentDeclaration,
310
+ ): Agent | null {
311
+ const agentKey = getManagedAgentKey(declaration)
312
+ const displayName = getManagedAgentDisplayName(declaration)
313
+ if (!agentKey || !displayName) return null
314
+ const hash = declarationHash(declaration)
315
+ const now = Date.now()
316
+ const id = existing?.id || managedResourceId(extension.extensionId, 'agent', agentKey)
317
+ const extensionIds = Array.from(new Set([...list(declaration.extensions), extension.extensionId]))
318
+ const prompt = text(declaration.systemPrompt) || text(declaration.instructions?.content)
319
+ return {
320
+ ...(existing || {}),
321
+ id,
322
+ name: displayName,
323
+ description: text(declaration.description) || existing?.description || `Managed by ${extension.extensionName}.`,
324
+ systemPrompt: prompt || existing?.systemPrompt || `You are ${displayName}. Follow the extension-managed instructions for ${extension.extensionName}.`,
325
+ provider: (text(declaration.provider) || existing?.provider || 'openai') as Agent['provider'],
326
+ model: text(declaration.model) || existing?.model || 'gpt-4o-mini',
327
+ apiEndpoint: declaration.apiEndpoint !== undefined ? declaration.apiEndpoint || null : existing?.apiEndpoint ?? null,
328
+ credentialId: declaration.credentialId !== undefined ? declaration.credentialId || null : existing?.credentialId ?? null,
329
+ fallbackCredentialIds: list(declaration.fallbackCredentialIds).length ? list(declaration.fallbackCredentialIds) : existing?.fallbackCredentialIds || [],
330
+ gatewayProfileId: declaration.gatewayProfileId !== undefined ? declaration.gatewayProfileId || null : existing?.gatewayProfileId ?? null,
331
+ preferredGatewayTags: list(declaration.preferredGatewayTags).length ? list(declaration.preferredGatewayTags) : existing?.preferredGatewayTags || [],
332
+ preferredGatewayUseCase: declaration.preferredGatewayUseCase !== undefined ? declaration.preferredGatewayUseCase || null : existing?.preferredGatewayUseCase ?? null,
333
+ capabilities: normalizeCapabilities(declaration.capabilities).length
334
+ ? normalizeCapabilities(declaration.capabilities)
335
+ : existing?.capabilities || [],
336
+ tools: list(declaration.tools).length ? list(declaration.tools) : existing?.tools,
337
+ extensions: extensionIds.length ? extensionIds : existing?.extensions || [],
338
+ skills: list(declaration.skills).length ? list(declaration.skills) : existing?.skills,
339
+ skillIds: list(declaration.skillIds).length ? list(declaration.skillIds) : existing?.skillIds || [],
340
+ mcpServerIds: list(declaration.mcpServerIds).length ? list(declaration.mcpServerIds) : existing?.mcpServerIds || [],
341
+ monthlyBudget: typeof declaration.monthlyBudget === 'number' ? declaration.monthlyBudget : existing?.monthlyBudget ?? null,
342
+ dailyBudget: typeof declaration.dailyBudget === 'number' ? declaration.dailyBudget : existing?.dailyBudget ?? null,
343
+ hourlyBudget: typeof declaration.hourlyBudget === 'number' ? declaration.hourlyBudget : existing?.hourlyBudget ?? null,
344
+ disabled: declaration.disabled !== undefined ? declaration.disabled === true : existing?.disabled === true,
345
+ heartbeatEnabled: declaration.heartbeatEnabled !== undefined ? declaration.heartbeatEnabled !== false : existing?.heartbeatEnabled ?? true,
346
+ planningMode: declaration.planningMode !== undefined ? declaration.planningMode : existing?.planningMode ?? null,
347
+ managedByExtension: managedMarker(extension, 'agent', agentKey, hash),
348
+ createdAt: existing?.createdAt || now,
349
+ updatedAt: now,
350
+ } as Agent
351
+ }
352
+
353
+ function buildManagedSchedule(
354
+ existing: Schedule | null,
355
+ extension: ManagedExtensionEntry,
356
+ declaration: ExtensionManagedScheduleDeclaration,
357
+ agents: Record<string, Agent>,
358
+ ): Schedule | { skipped: string } {
359
+ const scheduleKey = getManagedScheduleKey(declaration)
360
+ const title = getManagedScheduleTitle(declaration)
361
+ if (!scheduleKey || !title) return { skipped: 'invalid_schedule_declaration' }
362
+ const agentId = resolveManagedScheduleAgentId(declaration, extension, agents)
363
+ if (!agentId) return { skipped: 'missing_agent_ref' }
364
+ const timing = scheduleTiming(declaration)
365
+ if (!timing.ok) return { skipped: timing.reason }
366
+
367
+ const hash = declarationHash(declaration)
368
+ const now = Date.now()
369
+ const id = existing?.id || managedResourceId(extension.extensionId, 'schedule', scheduleKey)
370
+ const taskPrompt = text(declaration.taskPrompt) || text(declaration.description) || title
371
+ return {
372
+ ...(existing || {}),
373
+ id,
374
+ name: title,
375
+ agentId,
376
+ taskPrompt,
377
+ taskMode: declaration.taskMode || existing?.taskMode || (text(declaration.message) ? 'wake_only' : 'task'),
378
+ message: text(declaration.message) || existing?.message,
379
+ description: text(declaration.description) || existing?.description,
380
+ scheduleType: timing.scheduleType,
381
+ cron: timing.scheduleType === 'cron' ? timing.cron : undefined,
382
+ intervalMs: timing.scheduleType === 'interval' ? timing.intervalMs : undefined,
383
+ runAt: timing.scheduleType === 'once' ? timing.runAt : undefined,
384
+ timezone: timing.timezone ?? existing?.timezone ?? null,
385
+ status: existing?.status === 'archived'
386
+ ? 'archived'
387
+ : normalizeScheduleStatus(declaration.status, timing.triggerEnabled),
388
+ managedByExtension: managedMarker(extension, 'schedule', scheduleKey, hash),
389
+ createdAt: existing?.createdAt || now,
390
+ updatedAt: now,
391
+ } as Schedule
392
+ }
393
+
394
+ function configuredLocalFolders(extensionId: string): Record<string, StoredLocalFolderConfig> {
395
+ const settings = loadSettings()
396
+ const resources = settings.extensionManagedResources?.[extensionId]
397
+ return resources?.localFolders || {}
398
+ }
399
+
400
+ function declarationForLocalFolder(extensionId: string, folderKey: string): ExtensionManagedLocalFolderDeclaration | null {
401
+ const resources = getExtensionManager().getManagedResources(extensionId)
402
+ return resources?.localFolders?.find((folder) => text(folder.folderKey) === folderKey) || null
403
+ }
404
+
405
+ function mergeLocalFolderConfig(
406
+ declaration: ExtensionManagedLocalFolderDeclaration,
407
+ stored?: StoredLocalFolderConfig | null,
408
+ override?: Partial<StoredLocalFolderConfig> | null,
409
+ ): StoredLocalFolderConfig {
410
+ return {
411
+ ...declaration,
412
+ ...(stored || {}),
413
+ ...(override || {}),
414
+ access: declaration.access || override?.access || stored?.access || 'readWrite',
415
+ requiredDirectories: declaration.requiredDirectories || override?.requiredDirectories || stored?.requiredDirectories || [],
416
+ requiredFiles: declaration.requiredFiles || override?.requiredFiles || stored?.requiredFiles || [],
417
+ }
418
+ }
419
+
420
+ function problem(code: ExtensionLocalFolderProblem['code'], message: string, problemPath?: string): ExtensionLocalFolderProblem {
421
+ return { code, message, path: problemPath }
422
+ }
423
+
424
+ function assertLocalFolderKey(folderKey: string): void {
425
+ const trimmed = text(folderKey)
426
+ if (!trimmed || trimmed.length > 128) throw new Error('folderKey is required')
427
+ const first = trimmed.charCodeAt(0)
428
+ const startsOk = (first >= 97 && first <= 122) || (first >= 48 && first <= 57)
429
+ if (!startsOk) throw new Error('folderKey must start with a lowercase letter or digit')
430
+ for (const char of trimmed) {
431
+ const code = char.charCodeAt(0)
432
+ const ok = (code >= 97 && code <= 122)
433
+ || (code >= 48 && code <= 57)
434
+ || char === '.'
435
+ || char === '_'
436
+ || char === '-'
437
+ || char === ':'
438
+ if (!ok) throw new Error('folderKey contains unsupported characters')
439
+ }
440
+ }
441
+
442
+ function normalizeRelativePath(relativePath: string): string {
443
+ const raw = text(relativePath)
444
+ if (!raw || path.isAbsolute(raw) || raw.includes('\\')) {
445
+ throw new Error('Local folder relative paths must stay inside the configured root')
446
+ }
447
+ const segments = raw.split('/')
448
+ if (segments.some((segment) => !segment || segment === '.' || segment === '..')) {
449
+ throw new Error('Local folder relative paths must stay inside the configured root')
450
+ }
451
+ return raw
452
+ }
453
+
454
+ function normalizeOptionalRelativePath(relativePath: unknown): string | null {
455
+ const raw = text(relativePath)
456
+ return raw ? normalizeRelativePath(raw) : null
457
+ }
458
+
459
+ function isInsideRoot(rootRealPath: string, candidateRealPath: string): boolean {
460
+ const relative = path.relative(rootRealPath, candidateRealPath)
461
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
462
+ }
463
+
464
+ async function resolveInsideRoot(rootRealPath: string, relativePath: string): Promise<string> {
465
+ const normalized = normalizeRelativePath(relativePath)
466
+ const absolutePath = path.resolve(rootRealPath, normalized)
467
+ const relativeFromRoot = path.relative(rootRealPath, absolutePath)
468
+ if (relativeFromRoot.startsWith('..') || path.isAbsolute(relativeFromRoot)) {
469
+ throw new Error('Local folder path traversal is not allowed')
470
+ }
471
+ const realPath = await fs.realpath(absolutePath)
472
+ if (!isInsideRoot(rootRealPath, realPath)) {
473
+ throw new Error('Local folder symlink escape is not allowed')
474
+ }
475
+ return realPath
476
+ }
477
+
478
+ function normalizeMaxEntries(value: unknown): number {
479
+ const parsed = typeof value === 'number' ? value : Number(value)
480
+ if (!Number.isFinite(parsed)) return 1000
481
+ return Math.max(1, Math.min(5000, Math.trunc(parsed)))
482
+ }
483
+
484
+ async function checkRequiredPath(rootRealPath: string, relativePath: string, kind: 'directory' | 'file'): Promise<'ok' | 'missing' | 'escape' | 'wrong_kind'> {
485
+ try {
486
+ const realPath = await resolveInsideRoot(rootRealPath, relativePath)
487
+ const stat = await fs.stat(realPath)
488
+ if (kind === 'directory') return stat.isDirectory() ? 'ok' : 'wrong_kind'
489
+ return stat.isFile() ? 'ok' : 'wrong_kind'
490
+ } catch (err: unknown) {
491
+ const message = err instanceof Error ? err.message : ''
492
+ if (message.includes('escape') || message.includes('traversal')) return 'escape'
493
+ return 'missing'
494
+ }
495
+ }
496
+
497
+ export function listExtensionManagedResources(): ManagedResourceSummary {
498
+ const agents = loadAgents()
499
+ const schedules = loadSchedules()
500
+ const extensions = getExtensionManager().getManagedResourceExtensions()
501
+ const items: ManagedResourceSummaryItem[] = []
502
+
503
+ for (const extension of extensions) {
504
+ const resources: ExtensionManagedResources = extension.managedResources
505
+ const storedFolders = configuredLocalFolders(extension.extensionId)
506
+ const agentSummaries = (resources.agents || []).flatMap((declaration) => {
507
+ const resourceKey = getManagedAgentKey(declaration)
508
+ if (!resourceKey) return []
509
+ const resolved = findManagedAgent(agents, extension.extensionId, resourceKey)
510
+ return [{
511
+ resourceKind: 'agent' as const,
512
+ resourceKey,
513
+ displayName: getManagedAgentDisplayName(declaration),
514
+ status: resolved ? 'resolved' as const : 'missing' as const,
515
+ resourceId: resolved?.id || null,
516
+ declarationHash: declarationHash(declaration),
517
+ }]
518
+ })
519
+ const scheduleDeclarations = [...(resources.schedules || []), ...(resources.routines || [])]
520
+ const scheduleSummaries = scheduleDeclarations.flatMap((declaration) => {
521
+ const resourceKey = getManagedScheduleKey(declaration)
522
+ if (!resourceKey) return []
523
+ const resolved = findManagedSchedule(schedules, extension.extensionId, resourceKey)
524
+ const agentId = resolveManagedScheduleAgentId(declaration, extension, agents)
525
+ const timing = scheduleTiming(declaration)
526
+ return [{
527
+ resourceKind: 'schedule' as const,
528
+ resourceKey,
529
+ displayName: getManagedScheduleTitle(declaration),
530
+ status: resolved
531
+ ? 'resolved' as const
532
+ : !agentId
533
+ ? 'missing_ref' as const
534
+ : !timing.ok
535
+ ? 'unsupported_trigger' as const
536
+ : 'missing' as const,
537
+ resourceId: resolved?.id || null,
538
+ declarationHash: declarationHash(declaration),
539
+ }]
540
+ })
541
+ const folderSummaries = (resources.localFolders || []).flatMap((declaration) => {
542
+ const resourceKey = text(declaration.folderKey)
543
+ if (!resourceKey) return []
544
+ const stored = storedFolders[resourceKey]
545
+ const configured = !!stored?.path
546
+ return [{
547
+ resourceKind: 'local_folder' as const,
548
+ resourceKey,
549
+ displayName: text(declaration.displayName) || resourceKey,
550
+ status: configured ? 'declared' as const : 'missing' as const,
551
+ resourceId: null,
552
+ declarationHash: declarationHash(declaration),
553
+ configured,
554
+ healthy: false,
555
+ problems: configured ? [] : ['not_configured'],
556
+ }]
557
+ })
558
+
559
+ items.push({
560
+ extensionId: extension.extensionId,
561
+ extensionName: extension.extensionName,
562
+ enabled: extension.enabled,
563
+ isBuiltin: extension.isBuiltin,
564
+ source: extension.source,
565
+ agents: agentSummaries,
566
+ schedules: scheduleSummaries,
567
+ localFolders: folderSummaries,
568
+ gatewayPlatforms: resources.gatewayPlatforms || [],
569
+ setupChecks: (resources.setupChecks || []).map((check) => ({
570
+ ...check,
571
+ required: check.required !== false,
572
+ })),
573
+ })
574
+ }
575
+
576
+ return {
577
+ extensions: items,
578
+ totals: {
579
+ extensions: items.length,
580
+ agents: items.reduce((sum, item) => sum + item.agents.length, 0),
581
+ schedules: items.reduce((sum, item) => sum + item.schedules.length, 0),
582
+ localFolders: items.reduce((sum, item) => sum + item.localFolders.length, 0),
583
+ gatewayPlatforms: items.reduce((sum, item) => sum + item.gatewayPlatforms.length, 0),
584
+ setupChecks: items.reduce((sum, item) => sum + item.setupChecks.length, 0),
585
+ resolvedAgents: items.reduce((sum, item) => sum + item.agents.filter((agent) => agent.status === 'resolved').length, 0),
586
+ resolvedSchedules: items.reduce((sum, item) => sum + item.schedules.filter((schedule) => schedule.status === 'resolved').length, 0),
587
+ healthyLocalFolders: items.reduce((sum, item) => sum + item.localFolders.filter((folder) => folder.healthy).length, 0),
588
+ },
589
+ }
590
+ }
591
+
592
+ export function reconcileExtensionManagedResources(extensionId?: string | null): ManagedResourceReconcileResult {
593
+ const manager = getExtensionManager()
594
+ const candidates = manager.getManagedResourceExtensions()
595
+ .filter((entry) => !extensionId || entry.extensionId === extensionId)
596
+ if (extensionId && candidates.length === 0) {
597
+ throw new Error(`Extension has no managed resources: ${extensionId}`)
598
+ }
599
+
600
+ const result: ManagedResourceReconcileResult = {
601
+ extensionId: extensionId || undefined,
602
+ createdAgents: [],
603
+ updatedAgents: [],
604
+ createdSchedules: [],
605
+ updatedSchedules: [],
606
+ skipped: [],
607
+ }
608
+ const agents = loadAgents()
609
+ const schedules = loadSchedules()
610
+ const agentEntries: Array<[string, Agent]> = []
611
+ const scheduleEntries: Array<[string, Schedule]> = []
612
+
613
+ for (const extension of candidates) {
614
+ for (const declaration of extension.managedResources.agents || []) {
615
+ const resourceKey = getManagedAgentKey(declaration)
616
+ const existing = resourceKey ? findManagedAgent(agents, extension.extensionId, resourceKey) : null
617
+ const next = buildManagedAgent(existing, extension, declaration)
618
+ if (!next) {
619
+ result.skipped.push({ resourceKind: 'agent', resourceKey: resourceKey || 'unknown', reason: 'invalid_agent_declaration' })
620
+ continue
621
+ }
622
+ agents[next.id] = next
623
+ agentEntries.push([next.id, next])
624
+ ;(existing ? result.updatedAgents : result.createdAgents).push(next.id)
625
+ }
626
+ }
627
+
628
+ if (agentEntries.length > 0) {
629
+ saveAgentMany(agentEntries)
630
+ }
631
+
632
+ for (const extension of candidates) {
633
+ const declarations = [...(extension.managedResources.schedules || []), ...(extension.managedResources.routines || [])]
634
+ for (const declaration of declarations) {
635
+ const resourceKey = getManagedScheduleKey(declaration)
636
+ const existing = resourceKey ? findManagedSchedule(schedules, extension.extensionId, resourceKey) : null
637
+ const next = buildManagedSchedule(existing, extension, declaration, agents)
638
+ if ('skipped' in next) {
639
+ result.skipped.push({ resourceKind: 'schedule', resourceKey: resourceKey || 'unknown', reason: next.skipped })
640
+ continue
641
+ }
642
+ schedules[next.id] = next
643
+ scheduleEntries.push([next.id, next])
644
+ ;(existing ? result.updatedSchedules : result.createdSchedules).push(next.id)
645
+ }
646
+ }
647
+
648
+ if (scheduleEntries.length > 0) {
649
+ upsertSchedules(scheduleEntries)
650
+ }
651
+ if (agentEntries.length > 0 || scheduleEntries.length > 0) {
652
+ logActivity({
653
+ entityType: 'extension',
654
+ entityId: extensionId || 'managed-resources',
655
+ action: 'reconciled',
656
+ actor: 'user',
657
+ summary: `Extension managed resources reconciled (${agentEntries.length} agents, ${scheduleEntries.length} schedules)`,
658
+ detail: result as unknown as Record<string, unknown>,
659
+ })
660
+ notify('agents')
661
+ notify('schedules')
662
+ notify('extensions')
663
+ }
664
+
665
+ return result
666
+ }
667
+
668
+ export function setExtensionLocalFolderConfig(input: {
669
+ extensionId: string
670
+ folderKey: string
671
+ path: string
672
+ access?: 'read' | 'readWrite'
673
+ }): StoredLocalFolderConfig {
674
+ const extensionId = text(input.extensionId)
675
+ const folderKey = text(input.folderKey)
676
+ assertLocalFolderKey(folderKey)
677
+ const declaration = declarationForLocalFolder(extensionId, folderKey)
678
+ if (!declaration) throw new Error('Local folder key is not declared by this extension')
679
+ const configuredPath = text(input.path)
680
+ if (!configuredPath) throw new Error('path is required')
681
+ const settings = loadSettings() as AppSettings
682
+ const extensionResources = { ...(settings.extensionManagedResources || {}) }
683
+ const current = extensionResources[extensionId]?.localFolders || {}
684
+ const nextFolder = mergeLocalFolderConfig(declaration, current[folderKey], {
685
+ path: configuredPath,
686
+ access: input.access,
687
+ updatedAt: Date.now(),
688
+ })
689
+ extensionResources[extensionId] = {
690
+ ...(extensionResources[extensionId] || {}),
691
+ localFolders: {
692
+ ...current,
693
+ [folderKey]: nextFolder,
694
+ },
695
+ }
696
+ saveSettings({
697
+ ...settings,
698
+ extensionManagedResources: extensionResources,
699
+ })
700
+ notify('extensions')
701
+ return nextFolder
702
+ }
703
+
704
+ export async function inspectExtensionLocalFolder(input: {
705
+ extensionId: string
706
+ folderKey: string
707
+ overridePath?: string | null
708
+ }): Promise<ExtensionLocalFolderStatus> {
709
+ const extensionId = text(input.extensionId)
710
+ const folderKey = text(input.folderKey)
711
+ assertLocalFolderKey(folderKey)
712
+ const declaration = declarationForLocalFolder(extensionId, folderKey)
713
+ if (!declaration) throw new Error('Local folder key is not declared by this extension')
714
+ const stored = configuredLocalFolders(extensionId)[folderKey]
715
+ const config = mergeLocalFolderConfig(declaration, stored, input.overridePath ? { path: input.overridePath } : null)
716
+ const requiredDirectories = list(config.requiredDirectories).map(normalizeRelativePath)
717
+ const requiredFiles = list(config.requiredFiles).map(normalizeRelativePath)
718
+ const checkedAt = Date.now()
719
+ const access = config.access || 'readWrite'
720
+ const configuredPath = text(config.path)
721
+
722
+ if (!configuredPath) {
723
+ return {
724
+ extensionId,
725
+ folderKey,
726
+ displayName: text(config.displayName) || folderKey,
727
+ configured: false,
728
+ path: null,
729
+ realPath: null,
730
+ access,
731
+ readable: false,
732
+ writable: false,
733
+ requiredDirectories,
734
+ requiredFiles,
735
+ missingDirectories: requiredDirectories,
736
+ missingFiles: requiredFiles,
737
+ healthy: false,
738
+ problems: [problem('not_configured', 'No local folder path is configured.')],
739
+ checkedAt,
740
+ }
741
+ }
742
+
743
+ const resolvedPath = path.resolve(configuredPath)
744
+ const problems: ExtensionLocalFolderProblem[] = []
745
+ const missingDirectories: string[] = []
746
+ const missingFiles: string[] = []
747
+ let realPath: string | null = null
748
+ let readable = false
749
+ let writable = false
750
+
751
+ if (!path.isAbsolute(configuredPath)) {
752
+ problems.push(problem('not_absolute', 'Local folder path must be absolute.', configuredPath))
753
+ }
754
+
755
+ try {
756
+ const stat = await fs.stat(resolvedPath)
757
+ if (!stat.isDirectory()) {
758
+ problems.push(problem('not_directory', 'Configured local folder path is not a directory.', resolvedPath))
759
+ missingDirectories.push(...requiredDirectories)
760
+ missingFiles.push(...requiredFiles)
761
+ } else {
762
+ realPath = await fs.realpath(resolvedPath)
763
+ try {
764
+ await fs.access(realPath, fsConstants.R_OK)
765
+ readable = true
766
+ } catch {
767
+ problems.push(problem('not_readable', 'Configured local folder is not readable.', resolvedPath))
768
+ }
769
+ if (access === 'readWrite') {
770
+ try {
771
+ await fs.access(realPath, fsConstants.W_OK)
772
+ const probePath = path.join(realPath, `.swarmclaw-local-folder-probe-${process.pid}-${Date.now()}`)
773
+ await fs.writeFile(probePath, '')
774
+ await fs.rm(probePath, { force: true })
775
+ writable = true
776
+ } catch {
777
+ problems.push(problem('not_writable', 'Configured local folder is not writable.', resolvedPath))
778
+ }
779
+ }
780
+ for (const requiredDir of requiredDirectories) {
781
+ const status = await checkRequiredPath(realPath, requiredDir, 'directory')
782
+ if (status === 'missing' || status === 'wrong_kind') {
783
+ missingDirectories.push(requiredDir)
784
+ problems.push(problem('missing_directory', 'Required directory is missing.', requiredDir))
785
+ } else if (status === 'escape') {
786
+ problems.push(problem('symlink_escape', 'Required directory escapes the configured root.', requiredDir))
787
+ }
788
+ }
789
+ for (const requiredFile of requiredFiles) {
790
+ const status = await checkRequiredPath(realPath, requiredFile, 'file')
791
+ if (status === 'missing' || status === 'wrong_kind') {
792
+ missingFiles.push(requiredFile)
793
+ problems.push(problem('missing_file', 'Required file is missing.', requiredFile))
794
+ } else if (status === 'escape') {
795
+ problems.push(problem('symlink_escape', 'Required file escapes the configured root.', requiredFile))
796
+ }
797
+ }
798
+ }
799
+ } catch (err: unknown) {
800
+ const code = isRecord(err) && typeof err.code === 'string' ? err.code : ''
801
+ problems.push(problem(code === 'ENOENT' ? 'missing' : 'not_readable', 'Configured local folder cannot be inspected.', resolvedPath))
802
+ if (code === 'ENOENT') {
803
+ missingDirectories.push(...requiredDirectories)
804
+ missingFiles.push(...requiredFiles)
805
+ }
806
+ }
807
+
808
+ return {
809
+ extensionId,
810
+ folderKey,
811
+ displayName: text(config.displayName) || folderKey,
812
+ configured: true,
813
+ path: resolvedPath,
814
+ realPath,
815
+ access,
816
+ readable,
817
+ writable: access === 'read' ? false : writable,
818
+ requiredDirectories,
819
+ requiredFiles,
820
+ missingDirectories,
821
+ missingFiles,
822
+ healthy: problems.length === 0 && readable && (access === 'read' || writable),
823
+ problems,
824
+ checkedAt,
825
+ }
826
+ }
827
+
828
+ export async function listExtensionLocalFolderEntries(input: {
829
+ extensionId: string
830
+ folderKey: string
831
+ relativePath?: string | null
832
+ recursive?: boolean
833
+ maxEntries?: number
834
+ }): Promise<ExtensionLocalFolderListing> {
835
+ const status = await inspectExtensionLocalFolder({
836
+ extensionId: input.extensionId,
837
+ folderKey: input.folderKey,
838
+ })
839
+ if (!status.configured || !status.realPath || !status.readable) {
840
+ throw new Error('Local folder is not configured or readable')
841
+ }
842
+ if (!status.healthy) {
843
+ throw new Error('Local folder is not healthy')
844
+ }
845
+
846
+ const relativePath = normalizeOptionalRelativePath(input.relativePath)
847
+ const targetPath = relativePath
848
+ ? await resolveInsideRoot(status.realPath, relativePath)
849
+ : status.realPath
850
+ const targetStat = await fs.stat(targetPath)
851
+ if (!targetStat.isDirectory()) throw new Error('Local folder list target must be a directory')
852
+
853
+ const maxEntries = normalizeMaxEntries(input.maxEntries)
854
+ const entries: ExtensionLocalFolderEntry[] = []
855
+ let truncated = false
856
+
857
+ async function visit(directoryRealPath: string, directoryRelativePath: string | null): Promise<void> {
858
+ if (truncated) return
859
+ const dirents = await fs.readdir(directoryRealPath, { withFileTypes: true })
860
+ dirents.sort((left, right) => left.name.localeCompare(right.name))
861
+
862
+ for (const dirent of dirents) {
863
+ if (entries.length >= maxEntries) {
864
+ truncated = true
865
+ return
866
+ }
867
+ const childRelativePath = directoryRelativePath ? `${directoryRelativePath}/${dirent.name}` : dirent.name
868
+ let childRealPath: string
869
+ try {
870
+ childRealPath = await resolveInsideRoot(status.realPath!, childRelativePath)
871
+ } catch {
872
+ continue
873
+ }
874
+ const stat = await fs.stat(childRealPath).catch(() => null)
875
+ if (!stat) continue
876
+ const kind = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : null
877
+ if (!kind) continue
878
+ entries.push({
879
+ path: childRelativePath,
880
+ name: dirent.name,
881
+ kind,
882
+ size: kind === 'file' ? stat.size : null,
883
+ modifiedAt: stat.mtimeMs,
884
+ })
885
+ if (input.recursive && kind === 'directory') {
886
+ await visit(childRealPath, childRelativePath)
887
+ if (truncated) return
888
+ }
889
+ }
890
+ }
891
+
892
+ await visit(targetPath, relativePath)
893
+ return {
894
+ extensionId: text(input.extensionId),
895
+ folderKey: text(input.folderKey),
896
+ relativePath,
897
+ entries,
898
+ truncated,
899
+ }
900
+ }
901
+
902
+ export function defaultExtensionLocalFolderBasePath(extensionId: string): string {
903
+ const safe = crypto.createHash('sha1').update(extensionId).digest('hex').slice(0, 12)
904
+ return path.join(WORKSPACE_DIR, 'extension-folders', safe)
905
+ }