@swarmclawai/swarmclaw 1.9.2 → 1.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/electron-dist/main.js +218 -0
- package/package.json +2 -2
- package/src/app/api/extensions/managed-resources/route.test.ts +117 -0
- package/src/app/api/extensions/managed-resources/route.ts +116 -0
- package/src/cli/index.js +2 -0
- package/src/cli/spec.js +2 -0
- package/src/lib/server/extension-managed-resources.test.ts +159 -0
- package/src/lib/server/extension-managed-resources.ts +905 -0
- package/src/lib/server/extensions.ts +113 -2
- package/src/lib/server/session-tools/extension-creator.ts +50 -0
- package/src/types/agent.ts +2 -0
- package/src/types/app-settings.ts +8 -0
- package/src/types/extension.ts +132 -0
- package/src/types/schedule.ts +3 -0
- 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
|
+
}
|