@swarmclawai/swarmclaw 0.7.2 → 0.7.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 +81 -22
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +26 -0
- package/src/app/api/agents/[id]/thread/route.ts +36 -7
- package/src/app/api/agents/route.ts +12 -1
- package/src/app/api/auth/route.ts +76 -7
- package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
- package/src/app/api/chats/[id]/browser/route.ts +5 -1
- package/src/app/api/chats/[id]/chat/route.ts +7 -3
- package/src/app/api/chats/[id]/main-loop/route.ts +7 -88
- package/src/app/api/chats/[id]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +18 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/route.ts +16 -0
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
- package/src/app/api/connectors/doctor/route.ts +13 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/memory/maintenance/route.ts +11 -1
- package/src/app/api/openclaw/agent-files/route.ts +27 -4
- package/src/app/api/openclaw/skills/route.ts +11 -3
- package/src/app/api/plugins/dependencies/route.ts +24 -0
- package/src/app/api/plugins/install/route.ts +15 -92
- package/src/app/api/plugins/route.ts +3 -26
- package/src/app/api/plugins/settings/route.ts +17 -12
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/settings/route.ts +49 -7
- package/src/app/api/tasks/[id]/route.ts +15 -6
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +9 -2
- package/src/cli/index.js +4 -0
- package/src/cli/index.ts +3 -10
- package/src/components/agents/agent-card.tsx +15 -12
- package/src/components/agents/agent-chat-list.tsx +101 -1
- package/src/components/agents/agent-list.tsx +46 -9
- package/src/components/agents/agent-sheet.tsx +207 -16
- package/src/components/agents/inspector-panel.tsx +108 -48
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/chat/chat-area.tsx +29 -13
- package/src/components/chat/chat-card.tsx +4 -20
- package/src/components/chat/chat-header.tsx +255 -353
- package/src/components/chat/chat-list.tsx +7 -9
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +3 -1
- package/src/components/chatrooms/chatroom-view.tsx +347 -205
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/layout/app-layout.tsx +383 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/plugins/plugin-list.tsx +15 -3
- package/src/components/plugins/plugin-sheet.tsx +118 -9
- package/src/components/projects/project-detail.tsx +183 -0
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/settings/plugin-manager.tsx +20 -4
- package/src/components/shared/settings/section-capability-policy.tsx +105 -0
- package/src/components/shared/settings/section-heartbeat.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +3 -3
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
- package/src/components/shared/settings/section-secrets.tsx +6 -6
- package/src/components/shared/settings/section-user-preferences.tsx +1 -1
- package/src/components/shared/settings/section-voice.tsx +5 -1
- package/src/components/shared/settings/section-web-search.tsx +10 -2
- package/src/components/shared/settings/settings-page.tsx +245 -46
- package/src/components/tasks/approvals-panel.tsx +205 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +7 -7
- package/src/lib/auth.ts +17 -0
- package/src/lib/chat-streaming-state.test.ts +108 -0
- package/src/lib/chat-streaming-state.ts +108 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +205 -0
- package/src/lib/server/approvals.ts +483 -75
- package/src/lib/server/autonomy-runtime.test.ts +341 -0
- package/src/lib/server/browser-state.test.ts +118 -0
- package/src/lib/server/browser-state.ts +123 -0
- package/src/lib/server/build-llm.test.ts +36 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
- package/src/lib/server/chat-execution.ts +250 -61
- package/src/lib/server/chatroom-health.test.ts +26 -0
- package/src/lib/server/chatroom-health.ts +2 -3
- package/src/lib/server/chatroom-helpers.test.ts +67 -2
- package/src/lib/server/chatroom-helpers.ts +45 -5
- package/src/lib/server/connectors/discord.ts +175 -11
- package/src/lib/server/connectors/doctor.test.ts +80 -0
- package/src/lib/server/connectors/doctor.ts +116 -0
- package/src/lib/server/connectors/manager.ts +946 -110
- package/src/lib/server/connectors/policy.test.ts +222 -0
- package/src/lib/server/connectors/policy.ts +452 -0
- package/src/lib/server/connectors/slack.ts +188 -9
- package/src/lib/server/connectors/telegram.ts +65 -15
- package/src/lib/server/connectors/thread-context.test.ts +44 -0
- package/src/lib/server/connectors/thread-context.ts +72 -0
- package/src/lib/server/connectors/types.ts +41 -11
- package/src/lib/server/daemon-state.ts +59 -1
- package/src/lib/server/data-dir.ts +13 -0
- package/src/lib/server/delegation-jobs.test.ts +140 -0
- package/src/lib/server/delegation-jobs.ts +248 -0
- package/src/lib/server/document-utils.test.ts +47 -0
- package/src/lib/server/document-utils.ts +397 -0
- package/src/lib/server/heartbeat-service.ts +13 -39
- package/src/lib/server/heartbeat-source.test.ts +22 -0
- package/src/lib/server/heartbeat-source.ts +7 -0
- package/src/lib/server/identity-continuity.test.ts +77 -0
- package/src/lib/server/identity-continuity.ts +127 -0
- package/src/lib/server/mailbox-utils.ts +347 -0
- package/src/lib/server/main-agent-loop.ts +27 -967
- package/src/lib/server/memory-db.ts +4 -6
- package/src/lib/server/memory-tiers.ts +40 -0
- package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
- package/src/lib/server/openclaw-agent-resolver.ts +128 -0
- package/src/lib/server/openclaw-exec-config.ts +5 -6
- package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
- package/src/lib/server/openclaw-skills-normalize.ts +136 -0
- package/src/lib/server/openclaw-sync.ts +3 -2
- package/src/lib/server/orchestrator-lg.ts +17 -6
- package/src/lib/server/orchestrator.ts +2 -2
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +207 -0
- package/src/lib/server/plugins.ts +822 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/queue.ts +3 -20
- package/src/lib/server/scheduler.ts +2 -0
- package/src/lib/server/session-archive-memory.test.ts +85 -0
- package/src/lib/server/session-archive-memory.ts +230 -0
- package/src/lib/server/session-mailbox.ts +8 -18
- package/src/lib/server/session-reset-policy.test.ts +99 -0
- package/src/lib/server/session-reset-policy.ts +311 -0
- package/src/lib/server/session-run-manager.ts +33 -80
- package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
- package/src/lib/server/session-tools/calendar.ts +2 -12
- package/src/lib/server/session-tools/connector.ts +109 -8
- package/src/lib/server/session-tools/context.ts +14 -2
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +70 -32
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +406 -20
- package/src/lib/server/session-tools/discovery.ts +22 -4
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/email.ts +1 -3
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +237 -24
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +1 -3
- package/src/lib/server/session-tools/index.ts +56 -1
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +150 -7
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +142 -4
- package/src/lib/server/session-tools/plugin-creator.ts +86 -23
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +1 -3
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +36 -3
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
- package/src/lib/server/session-tools/subagent.ts +193 -27
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +13 -10
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +896 -100
- package/src/lib/server/storage.ts +226 -7
- package/src/lib/server/stream-agent-chat.ts +46 -21
- package/src/lib/server/structured-extract.test.ts +72 -0
- package/src/lib/server/structured-extract.ts +373 -0
- package/src/lib/server/task-mention.test.ts +16 -2
- package/src/lib/server/task-mention.ts +61 -10
- package/src/lib/server/tool-aliases.ts +44 -7
- package/src/lib/server/tool-capability-policy.ts +6 -0
- package/src/lib/server/tool-retry.ts +2 -0
- package/src/lib/server/watch-jobs.test.ts +173 -0
- package/src/lib/server/watch-jobs.ts +532 -0
- package/src/lib/server/ws-hub.ts +5 -3
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +7 -0
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +0 -6
- package/src/stores/use-chat-store.ts +31 -2
- package/src/types/index.ts +287 -44
- package/src/components/chat/new-chat-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -17
- package/src/lib/server/session-run-manager.test.ts +0 -26
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
|
+
import crypto from 'crypto'
|
|
3
4
|
import { createRequire } from 'module'
|
|
4
|
-
import
|
|
5
|
+
import { spawn } from 'child_process'
|
|
6
|
+
import type { Plugin, PluginHooks, PluginMeta, PluginToolDef, PluginUIExtension, PluginProviderExtension, PluginConnectorExtension, Session, PluginPackageManager, PluginDependencyInstallStatus } from '@/types'
|
|
5
7
|
import { DATA_DIR } from './data-dir'
|
|
6
|
-
import { expandPluginIds } from './tool-aliases'
|
|
8
|
+
import { canonicalizePluginId, expandPluginIds, getPluginAliases } from './tool-aliases'
|
|
7
9
|
import { log } from './logger'
|
|
8
10
|
import { createNotification } from './create-notification'
|
|
9
11
|
import { notify } from './ws-hub'
|
|
12
|
+
import { decryptKey, encryptKey, loadSettings, saveSettings } from './storage'
|
|
10
13
|
|
|
11
14
|
const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
|
|
15
|
+
const PLUGIN_WORKSPACES_DIR = path.join(PLUGINS_DIR, '.workspaces')
|
|
12
16
|
const PLUGINS_CONFIG = path.join(DATA_DIR, 'plugins.json')
|
|
13
17
|
const PLUGIN_FAILURES = path.join(DATA_DIR, 'plugin-failures.json')
|
|
18
|
+
const MAX_EXTERNAL_PLUGIN_BYTES = 1024 * 1024
|
|
19
|
+
const SUPPORTED_PLUGIN_PACKAGE_MANAGERS: PluginPackageManager[] = ['npm', 'pnpm', 'yarn', 'bun']
|
|
20
|
+
const PACKAGE_INSTALL_TIMEOUT_MS = 5 * 60 * 1000
|
|
14
21
|
const MAX_CONSECUTIVE_PLUGIN_FAILURES = (() => {
|
|
15
22
|
const raw = Number.parseInt(process.env.SWARMCLAW_PLUGIN_FAILURE_THRESHOLD || '3', 10)
|
|
16
23
|
if (!Number.isFinite(raw)) return 3
|
|
@@ -24,6 +31,55 @@ interface PluginFailureRecord {
|
|
|
24
31
|
lastFailedAt: number
|
|
25
32
|
}
|
|
26
33
|
|
|
34
|
+
interface PluginConfigEntry {
|
|
35
|
+
enabled?: boolean
|
|
36
|
+
createdByAgentId?: string
|
|
37
|
+
sourceUrl?: string
|
|
38
|
+
sourceHash?: string
|
|
39
|
+
installedAt?: number
|
|
40
|
+
updatedAt?: number
|
|
41
|
+
packageManager?: PluginPackageManager
|
|
42
|
+
dependencyInstallStatus?: PluginDependencyInstallStatus
|
|
43
|
+
dependencyInstallError?: string
|
|
44
|
+
dependencyInstalledAt?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface InstalledPluginSource {
|
|
48
|
+
filename: string
|
|
49
|
+
sourceUrl: string
|
|
50
|
+
sourceHash: string
|
|
51
|
+
contentType?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PluginSourceDownload {
|
|
55
|
+
code: string
|
|
56
|
+
contentType: string
|
|
57
|
+
normalizedUrl: string
|
|
58
|
+
hash: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface PluginDependencyInfo {
|
|
62
|
+
hasManifest: boolean
|
|
63
|
+
dependencyCount: number
|
|
64
|
+
devDependencyCount: number
|
|
65
|
+
packageManager?: PluginPackageManager
|
|
66
|
+
installStatus: PluginDependencyInstallStatus
|
|
67
|
+
installError?: string
|
|
68
|
+
installedAt?: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface UpsertPluginOptions {
|
|
72
|
+
packageJson?: unknown
|
|
73
|
+
packageManager?: string | null
|
|
74
|
+
installDependencies?: boolean
|
|
75
|
+
meta?: Record<string, unknown>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PluginSecretSettingValue {
|
|
79
|
+
__pluginSecret: true
|
|
80
|
+
encrypted: string
|
|
81
|
+
}
|
|
82
|
+
|
|
27
83
|
interface PluginLogger {
|
|
28
84
|
info: (msg: string, m?: unknown) => void
|
|
29
85
|
warn: (msg: string, m?: unknown) => void
|
|
@@ -68,10 +124,165 @@ interface OpenClawPluginApi {
|
|
|
68
124
|
runtime: Record<string, unknown>
|
|
69
125
|
}
|
|
70
126
|
|
|
127
|
+
export interface HookExecutionOptions {
|
|
128
|
+
enabledIds?: string[]
|
|
129
|
+
includeAllWhenEmpty?: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
71
132
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
72
133
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
73
134
|
}
|
|
74
135
|
|
|
136
|
+
function isPluginSecretSettingValue(value: unknown): value is PluginSecretSettingValue {
|
|
137
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
138
|
+
const rec = value as Record<string, unknown>
|
|
139
|
+
return rec.__pluginSecret === true && typeof rec.encrypted === 'string'
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hashPluginSource(content: string): string {
|
|
143
|
+
return crypto.createHash('sha256').update(content).digest('hex')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizePluginPackageManager(raw: unknown): PluginPackageManager | null {
|
|
147
|
+
const text = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
|
|
148
|
+
if (!text) return null
|
|
149
|
+
const normalized = text.split('@')[0] as PluginPackageManager
|
|
150
|
+
return SUPPORTED_PLUGIN_PACKAGE_MANAGERS.includes(normalized) ? normalized : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function pluginWorkspaceKey(filename: string): string {
|
|
154
|
+
return path.basename(filename).replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function trimProcessOutput(output: string): string {
|
|
158
|
+
return output.trim().slice(-4000)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizePluginManifest(
|
|
162
|
+
rawManifest: unknown,
|
|
163
|
+
filename: string,
|
|
164
|
+
packageManager?: PluginPackageManager | null,
|
|
165
|
+
): Record<string, unknown> {
|
|
166
|
+
const parsed = typeof rawManifest === 'string'
|
|
167
|
+
? JSON.parse(rawManifest) as unknown
|
|
168
|
+
: rawManifest
|
|
169
|
+
if (!isRecord(parsed)) throw new Error('Plugin package.json must be a JSON object')
|
|
170
|
+
|
|
171
|
+
const manifest = { ...parsed } as Record<string, unknown>
|
|
172
|
+
if (typeof manifest.name !== 'string' || !manifest.name.trim()) {
|
|
173
|
+
manifest.name = path.basename(filename, path.extname(filename)).replace(/[^a-zA-Z0-9._-]/g, '-')
|
|
174
|
+
}
|
|
175
|
+
if (manifest.private === undefined) manifest.private = true
|
|
176
|
+
if (packageManager && typeof manifest.packageManager !== 'string') {
|
|
177
|
+
manifest.packageManager = packageManager
|
|
178
|
+
}
|
|
179
|
+
return manifest
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function countManifestDependencies(manifest: Record<string, unknown> | null): {
|
|
183
|
+
dependencyCount: number
|
|
184
|
+
devDependencyCount: number
|
|
185
|
+
} {
|
|
186
|
+
const dependencies = isRecord(manifest?.dependencies) ? Object.keys(manifest.dependencies).length : 0
|
|
187
|
+
const devDependencies = isRecord(manifest?.devDependencies) ? Object.keys(manifest.devDependencies).length : 0
|
|
188
|
+
return {
|
|
189
|
+
dependencyCount: dependencies,
|
|
190
|
+
devDependencyCount: devDependencies,
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getInstallCommand(packageManager: PluginPackageManager): { command: string; args: string[] } {
|
|
195
|
+
switch (packageManager) {
|
|
196
|
+
case 'pnpm':
|
|
197
|
+
return { command: 'pnpm', args: ['install', '--ignore-scripts', '--config.ignore-workspace=true'] }
|
|
198
|
+
case 'yarn':
|
|
199
|
+
return { command: 'yarn', args: ['install', '--ignore-scripts'] }
|
|
200
|
+
case 'bun':
|
|
201
|
+
return { command: 'bun', args: ['install', '--ignore-scripts'] }
|
|
202
|
+
case 'npm':
|
|
203
|
+
default:
|
|
204
|
+
return { command: 'npm', args: ['install', '--ignore-scripts', '--no-audit', '--no-fund'] }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function toRawPluginUrl(url: string): string {
|
|
209
|
+
if (url.includes('github.com') && url.includes('/blob/')) {
|
|
210
|
+
return url.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/')
|
|
211
|
+
}
|
|
212
|
+
if (url.includes('gist.github.com')) {
|
|
213
|
+
return url.endsWith('/raw') ? url : `${url}/raw`
|
|
214
|
+
}
|
|
215
|
+
return url
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function normalizeMarketplacePluginUrl(url: string): string {
|
|
219
|
+
const trimmed = typeof url === 'string' ? url.trim() : ''
|
|
220
|
+
if (!trimmed) return trimmed
|
|
221
|
+
|
|
222
|
+
let normalized = trimmed
|
|
223
|
+
.replace('github.com/swarmclawai/plugins/', 'github.com/swarmclawai/swarmforge/')
|
|
224
|
+
.replace('raw.githubusercontent.com/swarmclawai/plugins/', 'raw.githubusercontent.com/swarmclawai/swarmforge/')
|
|
225
|
+
|
|
226
|
+
normalized = toRawPluginUrl(normalized)
|
|
227
|
+
|
|
228
|
+
return normalized
|
|
229
|
+
.replace('/swarmclawai/swarmforge/master/', '/swarmclawai/swarmforge/main/')
|
|
230
|
+
.replace('/swarmclawai/plugins/master/', '/swarmclawai/swarmforge/main/')
|
|
231
|
+
.replace('/swarmclawai/plugins/main/', '/swarmclawai/swarmforge/main/')
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function sanitizePluginFilename(filename: string): string {
|
|
235
|
+
const trimmed = typeof filename === 'string' ? filename.trim() : ''
|
|
236
|
+
if (!trimmed) throw new Error('Filename is required')
|
|
237
|
+
if (!trimmed.endsWith('.js') && !trimmed.endsWith('.mjs')) {
|
|
238
|
+
throw new Error('Filename must end in .js or .mjs')
|
|
239
|
+
}
|
|
240
|
+
const sanitized = path.basename(trimmed)
|
|
241
|
+
if (sanitized !== trimmed || trimmed.includes('..')) {
|
|
242
|
+
throw new Error('Invalid filename')
|
|
243
|
+
}
|
|
244
|
+
return sanitized
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function downloadPluginSource(url: string): Promise<PluginSourceDownload> {
|
|
248
|
+
const normalizedUrl = normalizeMarketplacePluginUrl(url)
|
|
249
|
+
if (!normalizedUrl || !normalizedUrl.startsWith('https://')) {
|
|
250
|
+
throw new Error('URL must be a valid HTTPS URL')
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const res = await fetch(normalizedUrl, { signal: AbortSignal.timeout(15_000) })
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
throw new Error(`Download failed (HTTP ${res.status}) from ${normalizedUrl}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const contentType = res.headers.get('content-type') || ''
|
|
259
|
+
const lengthHeader = res.headers.get('content-length')
|
|
260
|
+
const declaredSize = lengthHeader ? Number.parseInt(lengthHeader, 10) : Number.NaN
|
|
261
|
+
if (Number.isFinite(declaredSize) && declaredSize > MAX_EXTERNAL_PLUGIN_BYTES) {
|
|
262
|
+
throw new Error(`Plugin file is too large (${declaredSize} bytes)`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let code = await res.text()
|
|
266
|
+
if (Buffer.byteLength(code, 'utf8') > MAX_EXTERNAL_PLUGIN_BYTES) {
|
|
267
|
+
throw new Error(`Plugin file exceeds ${MAX_EXTERNAL_PLUGIN_BYTES} bytes`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (contentType.includes('text/html') && code.includes('<!DOCTYPE')) {
|
|
271
|
+
throw new Error('URL returned an HTML page instead of JavaScript. Use a raw/direct link to the plugin file.')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Compatibility: modern Node exposes global fetch.
|
|
275
|
+
code = code.replace(/const\s+fetch\s*=\s*require\(['"]node-fetch['"]\);?/g, '// node-fetch stripped for compatibility')
|
|
276
|
+
code = code.replace(/import\s+fetch\s+from\s+['"]node-fetch['"];?/g, '// node-fetch stripped for compatibility')
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
code,
|
|
280
|
+
contentType,
|
|
281
|
+
normalizedUrl,
|
|
282
|
+
hash: hashPluginSource(code),
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
75
286
|
function coerceTools(rawTools: unknown): PluginToolDef[] {
|
|
76
287
|
if (Array.isArray(rawTools)) {
|
|
77
288
|
const tools: PluginToolDef[] = []
|
|
@@ -128,6 +339,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
128
339
|
name: raw.name as string,
|
|
129
340
|
version: (raw.version as string) || '0.0.1',
|
|
130
341
|
description: (raw.description as string) || '',
|
|
342
|
+
author: typeof raw.author === 'string' ? raw.author : undefined,
|
|
343
|
+
openclaw: raw.openclaw === true,
|
|
131
344
|
hooks,
|
|
132
345
|
tools: coerceTools(raw.tools),
|
|
133
346
|
ui: isRecord(raw.ui) ? (raw.ui as PluginUIExtension) : undefined,
|
|
@@ -214,6 +427,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
214
427
|
name: pluginName,
|
|
215
428
|
version: pluginVersion,
|
|
216
429
|
description: pluginDesc || `OpenClaw plugin (v${pluginVersion})`,
|
|
430
|
+
author: typeof raw.author === 'string' ? raw.author : undefined,
|
|
431
|
+
openclaw: true,
|
|
217
432
|
hooks,
|
|
218
433
|
tools,
|
|
219
434
|
}
|
|
@@ -253,6 +468,7 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
253
468
|
name: oc.name,
|
|
254
469
|
version: oc.version,
|
|
255
470
|
description: `OpenClaw plugin (v${oc.version || '0.0.0'})`,
|
|
471
|
+
openclaw: true,
|
|
256
472
|
hooks,
|
|
257
473
|
tools,
|
|
258
474
|
}
|
|
@@ -281,13 +497,202 @@ class PluginManager {
|
|
|
281
497
|
private plugins: Map<string, LoadedPlugin> = new Map()
|
|
282
498
|
private builtins: Map<string, Plugin> = new Map()
|
|
283
499
|
private loaded = false
|
|
500
|
+
private watcher: fs.FSWatcher | null = null
|
|
284
501
|
|
|
285
502
|
registerBuiltin(id: string, plugin: Plugin) {
|
|
286
|
-
this.
|
|
503
|
+
const canonicalId = this.canonicalPluginId(id)
|
|
504
|
+
this.builtins.set(canonicalId, plugin)
|
|
287
505
|
// Builtins can be imported/registered after first load, so force re-evaluation.
|
|
288
506
|
this.loaded = false
|
|
289
507
|
}
|
|
290
508
|
|
|
509
|
+
private ensurePluginWatcher(): void {
|
|
510
|
+
if (this.watcher) return
|
|
511
|
+
try {
|
|
512
|
+
this.ensurePluginDirs()
|
|
513
|
+
this.watcher = fs.watch(PLUGINS_DIR, (_eventType, filename) => {
|
|
514
|
+
if (!filename || (!filename.endsWith('.js') && !filename.endsWith('.mjs'))) return
|
|
515
|
+
this.loaded = false
|
|
516
|
+
notify('plugins')
|
|
517
|
+
})
|
|
518
|
+
this.watcher.unref?.()
|
|
519
|
+
} catch (err: unknown) {
|
|
520
|
+
log.warn('plugins', 'Failed to watch plugins directory', {
|
|
521
|
+
error: err instanceof Error ? err.message : String(err),
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private isExternalPluginFilename(id: string): boolean {
|
|
527
|
+
return id.endsWith('.js') || id.endsWith('.mjs')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private ensurePluginDirs(): void {
|
|
531
|
+
if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
|
|
532
|
+
if (!fs.existsSync(PLUGIN_WORKSPACES_DIR)) fs.mkdirSync(PLUGIN_WORKSPACES_DIR, { recursive: true })
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private getWorkspaceDir(filename: string): string {
|
|
536
|
+
return path.join(PLUGIN_WORKSPACES_DIR, pluginWorkspaceKey(filename))
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
private getWorkspaceEntryPath(filename: string): string {
|
|
540
|
+
return path.join(this.getWorkspaceDir(filename), 'index.js')
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private getWorkspaceManifestPath(filename: string): string {
|
|
544
|
+
return path.join(this.getWorkspaceDir(filename), 'package.json')
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private hasWorkspace(filename: string): boolean {
|
|
548
|
+
return fs.existsSync(this.getWorkspaceEntryPath(filename))
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private readWorkspaceManifest(filename: string): Record<string, unknown> | null {
|
|
552
|
+
const manifestPath = this.getWorkspaceManifestPath(filename)
|
|
553
|
+
try {
|
|
554
|
+
if (!fs.existsSync(manifestPath)) return null
|
|
555
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>
|
|
556
|
+
} catch {
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private getDependencyInfo(filename: string, explicitConfig?: PluginConfigEntry | null): PluginDependencyInfo {
|
|
562
|
+
const manifest = this.readWorkspaceManifest(filename)
|
|
563
|
+
const counts = countManifestDependencies(manifest)
|
|
564
|
+
return {
|
|
565
|
+
hasManifest: !!manifest,
|
|
566
|
+
dependencyCount: counts.dependencyCount,
|
|
567
|
+
devDependencyCount: counts.devDependencyCount,
|
|
568
|
+
packageManager:
|
|
569
|
+
normalizePluginPackageManager(explicitConfig?.packageManager)
|
|
570
|
+
|| normalizePluginPackageManager(manifest?.packageManager)
|
|
571
|
+
|| undefined,
|
|
572
|
+
installStatus: explicitConfig?.dependencyInstallStatus || (manifest ? 'ready' : 'none'),
|
|
573
|
+
installError: explicitConfig?.dependencyInstallError,
|
|
574
|
+
installedAt: explicitConfig?.dependencyInstalledAt,
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private writeWorkspaceShim(filename: string): void {
|
|
579
|
+
const relEntry = `./.workspaces/${pluginWorkspaceKey(filename)}/index.js`
|
|
580
|
+
const shim = `// Auto-generated plugin workspace shim. Edit the managed source file instead.\nmodule.exports = require(${JSON.stringify(relEntry)})\n`
|
|
581
|
+
fs.writeFileSync(path.join(PLUGINS_DIR, filename), shim, 'utf8')
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private clearPluginRequireCache(dynamicRequire: NodeRequire, filename: string): void {
|
|
585
|
+
const rootPath = path.join(PLUGINS_DIR, filename)
|
|
586
|
+
delete dynamicRequire.cache[rootPath]
|
|
587
|
+
const workspaceDir = this.getWorkspaceDir(filename)
|
|
588
|
+
for (const cacheKey of Object.keys(dynamicRequire.cache)) {
|
|
589
|
+
if (cacheKey.startsWith(`${workspaceDir}${path.sep}`)) {
|
|
590
|
+
delete dynamicRequire.cache[cacheKey]
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private resolvePluginSourcePath(filename: string): string {
|
|
596
|
+
return this.hasWorkspace(filename)
|
|
597
|
+
? this.getWorkspaceEntryPath(filename)
|
|
598
|
+
: path.join(PLUGINS_DIR, filename)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private async runDependencyInstall(packageManager: PluginPackageManager, cwd: string): Promise<void> {
|
|
602
|
+
const { command, args } = getInstallCommand(packageManager)
|
|
603
|
+
|
|
604
|
+
await new Promise<void>((resolve, reject) => {
|
|
605
|
+
const child = spawn(command, args, {
|
|
606
|
+
cwd,
|
|
607
|
+
env: { ...process.env },
|
|
608
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
609
|
+
})
|
|
610
|
+
|
|
611
|
+
let stderr = ''
|
|
612
|
+
let stdout = ''
|
|
613
|
+
const timer = setTimeout(() => {
|
|
614
|
+
child.kill('SIGTERM')
|
|
615
|
+
reject(new Error(`${command} install timed out after ${Math.round(PACKAGE_INSTALL_TIMEOUT_MS / 1000)}s`))
|
|
616
|
+
}, PACKAGE_INSTALL_TIMEOUT_MS)
|
|
617
|
+
|
|
618
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
619
|
+
stdout = trimProcessOutput(`${stdout}${chunk.toString()}`)
|
|
620
|
+
})
|
|
621
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
622
|
+
stderr = trimProcessOutput(`${stderr}${chunk.toString()}`)
|
|
623
|
+
})
|
|
624
|
+
child.on('error', (err) => {
|
|
625
|
+
clearTimeout(timer)
|
|
626
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
627
|
+
reject(new Error(`${command} is not installed on this machine`))
|
|
628
|
+
return
|
|
629
|
+
}
|
|
630
|
+
reject(err)
|
|
631
|
+
})
|
|
632
|
+
child.on('close', (code) => {
|
|
633
|
+
clearTimeout(timer)
|
|
634
|
+
if (code === 0) {
|
|
635
|
+
resolve()
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
reject(new Error(trimProcessOutput(`${stderr}\n${stdout}`) || `${command} install exited ${code}`))
|
|
639
|
+
})
|
|
640
|
+
})
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private canonicalPluginId(id: string): string {
|
|
644
|
+
const trimmed = typeof id === 'string' ? id.trim() : ''
|
|
645
|
+
if (!trimmed) return ''
|
|
646
|
+
if (this.isExternalPluginFilename(trimmed)) return path.basename(trimmed)
|
|
647
|
+
return canonicalizePluginId(trimmed)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
private configIdsFor(id: string): string[] {
|
|
651
|
+
const canonicalId = this.canonicalPluginId(id)
|
|
652
|
+
if (!canonicalId) return []
|
|
653
|
+
if (this.isExternalPluginFilename(canonicalId)) return [canonicalId]
|
|
654
|
+
const aliases = getPluginAliases(canonicalId)
|
|
655
|
+
const ids = new Set<string>([canonicalId, ...aliases])
|
|
656
|
+
return Array.from(ids)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private readConfigEntry(id: string, config?: Record<string, PluginConfigEntry>): PluginConfigEntry | null {
|
|
660
|
+
const cfg = config || this.loadConfig()
|
|
661
|
+
let merged: PluginConfigEntry | null = null
|
|
662
|
+
for (const key of this.configIdsFor(id)) {
|
|
663
|
+
const entry = cfg[key]
|
|
664
|
+
if (!entry) continue
|
|
665
|
+
merged = { ...(merged || {}), ...entry }
|
|
666
|
+
if (key === this.canonicalPluginId(id)) break
|
|
667
|
+
}
|
|
668
|
+
return merged
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private writeConfig(config: Record<string, PluginConfigEntry>): void {
|
|
672
|
+
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private updateConfigEntry(id: string, patch: PluginConfigEntry | null): void {
|
|
676
|
+
const canonicalId = this.canonicalPluginId(id)
|
|
677
|
+
const config = this.loadConfig()
|
|
678
|
+
for (const key of this.configIdsFor(canonicalId)) {
|
|
679
|
+
if (key !== canonicalId) delete config[key]
|
|
680
|
+
}
|
|
681
|
+
if (patch) {
|
|
682
|
+
config[canonicalId] = { ...(config[canonicalId] || {}), ...patch }
|
|
683
|
+
} else {
|
|
684
|
+
delete config[canonicalId]
|
|
685
|
+
}
|
|
686
|
+
this.writeConfig(config)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private resolveEnabledFilter(enabledIds?: string[], includeAllWhenEmpty = false): Set<string> | null {
|
|
690
|
+
if (!Array.isArray(enabledIds) || enabledIds.length === 0) {
|
|
691
|
+
return includeAllWhenEmpty ? null : new Set<string>()
|
|
692
|
+
}
|
|
693
|
+
return new Set(expandPluginIds(enabledIds))
|
|
694
|
+
}
|
|
695
|
+
|
|
291
696
|
private readFailureState(): Record<string, PluginFailureRecord> {
|
|
292
697
|
try {
|
|
293
698
|
const parsed = JSON.parse(fs.readFileSync(PLUGIN_FAILURES, 'utf8')) as Record<string, PluginFailureRecord>
|
|
@@ -308,17 +713,21 @@ class PluginManager {
|
|
|
308
713
|
|
|
309
714
|
private clearFailureState(id: string): void {
|
|
310
715
|
const state = this.readFailureState()
|
|
311
|
-
|
|
312
|
-
|
|
716
|
+
let changed = false
|
|
717
|
+
for (const key of this.configIdsFor(id)) {
|
|
718
|
+
if (!state[key]) continue
|
|
719
|
+
delete state[key]
|
|
720
|
+
changed = true
|
|
721
|
+
}
|
|
722
|
+
if (!changed) return
|
|
313
723
|
this.writeFailureState(state)
|
|
314
724
|
}
|
|
315
725
|
|
|
316
726
|
private autoDisableExternalPlugin(id: string, reason: string, failure: PluginFailureRecord): void {
|
|
317
|
-
const config = this.loadConfig()
|
|
318
|
-
if (config[id]?.enabled === false) return
|
|
319
|
-
config[id] = { ...config[id], enabled: false }
|
|
320
727
|
try {
|
|
321
|
-
|
|
728
|
+
const current = this.readConfigEntry(id)
|
|
729
|
+
if (current?.enabled === false) return
|
|
730
|
+
this.updateConfigEntry(id, { ...(current || {}), enabled: false })
|
|
322
731
|
} catch (err: unknown) {
|
|
323
732
|
log.error('plugins', 'Failed to write plugins config while auto-disabling plugin', {
|
|
324
733
|
pluginId: id,
|
|
@@ -353,14 +762,15 @@ class PluginManager {
|
|
|
353
762
|
private markPluginFailure(id: string, stage: string, err: unknown, disableEligible: boolean): void {
|
|
354
763
|
const errorText = err instanceof Error ? err.message : String(err)
|
|
355
764
|
const state = this.readFailureState()
|
|
356
|
-
const
|
|
765
|
+
const failureKey = this.canonicalPluginId(id)
|
|
766
|
+
const nextCount = (state[failureKey]?.count || 0) + 1
|
|
357
767
|
const record: PluginFailureRecord = {
|
|
358
768
|
count: nextCount,
|
|
359
769
|
lastError: errorText,
|
|
360
770
|
lastStage: stage,
|
|
361
771
|
lastFailedAt: Date.now(),
|
|
362
772
|
}
|
|
363
|
-
state[
|
|
773
|
+
state[failureKey] = record
|
|
364
774
|
this.writeFailureState(state)
|
|
365
775
|
|
|
366
776
|
log.warn('plugins', 'Plugin failure recorded', {
|
|
@@ -371,8 +781,12 @@ class PluginManager {
|
|
|
371
781
|
error: errorText,
|
|
372
782
|
})
|
|
373
783
|
|
|
374
|
-
if (
|
|
375
|
-
|
|
784
|
+
if (
|
|
785
|
+
disableEligible
|
|
786
|
+
&& nextCount >= MAX_CONSECUTIVE_PLUGIN_FAILURES
|
|
787
|
+
&& !this.builtins.has(failureKey)
|
|
788
|
+
) {
|
|
789
|
+
this.autoDisableExternalPlugin(failureKey, `Plugin failure at ${stage}`, record)
|
|
376
790
|
}
|
|
377
791
|
}
|
|
378
792
|
|
|
@@ -387,17 +801,27 @@ class PluginManager {
|
|
|
387
801
|
load() {
|
|
388
802
|
if (this.loaded) return
|
|
389
803
|
this.plugins.clear()
|
|
804
|
+
this.ensurePluginWatcher()
|
|
390
805
|
|
|
391
806
|
const config = this.loadConfig()
|
|
392
807
|
|
|
393
808
|
// 1. Load Built-ins
|
|
394
809
|
for (const [id, p] of this.builtins.entries()) {
|
|
395
|
-
const explicitConfig = config
|
|
810
|
+
const explicitConfig = this.readConfigEntry(id, config)
|
|
396
811
|
const isEnabled = explicitConfig != null ? explicitConfig.enabled !== false : p.enabledByDefault !== false
|
|
397
812
|
if (isEnabled) {
|
|
398
813
|
this.plugins.set(id, {
|
|
399
814
|
id,
|
|
400
|
-
meta: {
|
|
815
|
+
meta: {
|
|
816
|
+
name: p.name,
|
|
817
|
+
description: p.description || '',
|
|
818
|
+
filename: id,
|
|
819
|
+
enabled: true,
|
|
820
|
+
author: p.author || 'SwarmClaw',
|
|
821
|
+
version: p.version || '1.0.0',
|
|
822
|
+
source: 'local',
|
|
823
|
+
openclaw: p.openclaw === true,
|
|
824
|
+
},
|
|
401
825
|
hooks: p.hooks || {},
|
|
402
826
|
tools: p.tools || [],
|
|
403
827
|
ui: p.ui,
|
|
@@ -411,7 +835,7 @@ class PluginManager {
|
|
|
411
835
|
|
|
412
836
|
// 2. Load External
|
|
413
837
|
try {
|
|
414
|
-
|
|
838
|
+
this.ensurePluginDirs()
|
|
415
839
|
const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
|
|
416
840
|
|
|
417
841
|
let dynamicRequire: NodeRequire | null = null
|
|
@@ -426,11 +850,12 @@ class PluginManager {
|
|
|
426
850
|
if (dynamicRequire) {
|
|
427
851
|
for (const file of files) {
|
|
428
852
|
try {
|
|
429
|
-
const
|
|
853
|
+
const explicitConfig = this.readConfigEntry(file, config)
|
|
854
|
+
const isEnabled = explicitConfig?.enabled !== false
|
|
430
855
|
if (!isEnabled) continue
|
|
431
856
|
|
|
432
857
|
const fullPath = path.join(PLUGINS_DIR, file)
|
|
433
|
-
|
|
858
|
+
this.clearPluginRequireCache(dynamicRequire, file)
|
|
434
859
|
const plugin = normalizePlugin(dynamicRequire(fullPath))
|
|
435
860
|
if (!plugin) {
|
|
436
861
|
this.markPluginFailure(file, 'load.normalize', 'Plugin format unsupported or activate() failed', true)
|
|
@@ -439,7 +864,16 @@ class PluginManager {
|
|
|
439
864
|
|
|
440
865
|
this.plugins.set(file, {
|
|
441
866
|
id: file,
|
|
442
|
-
meta: {
|
|
867
|
+
meta: {
|
|
868
|
+
name: plugin.name,
|
|
869
|
+
description: plugin.description || '',
|
|
870
|
+
filename: file,
|
|
871
|
+
enabled: true,
|
|
872
|
+
author: plugin.author,
|
|
873
|
+
version: plugin.version || '0.0.1',
|
|
874
|
+
source: explicitConfig?.sourceUrl ? 'marketplace' : 'local',
|
|
875
|
+
openclaw: plugin.openclaw === true,
|
|
876
|
+
},
|
|
443
877
|
hooks: plugin.hooks || {},
|
|
444
878
|
tools: plugin.tools || [],
|
|
445
879
|
ui: plugin.ui,
|
|
@@ -464,7 +898,7 @@ class PluginManager {
|
|
|
464
898
|
getTools(enabledIds: string[]): Array<{ pluginId: string; tool: PluginToolDef }> {
|
|
465
899
|
this.load()
|
|
466
900
|
const all: Array<{ pluginId: string; tool: PluginToolDef }> = []
|
|
467
|
-
const ids = new Set(enabledIds)
|
|
901
|
+
const ids = new Set(expandPluginIds(enabledIds))
|
|
468
902
|
for (const [id, p] of this.plugins.entries()) {
|
|
469
903
|
if (ids.has(id)) {
|
|
470
904
|
const tools = Array.isArray(p.tools) ? p.tools : []
|
|
@@ -526,13 +960,17 @@ class PluginManager {
|
|
|
526
960
|
return allUI
|
|
527
961
|
}
|
|
528
962
|
|
|
529
|
-
|
|
963
|
+
listPluginIds(): string[] {
|
|
964
|
+
this.load()
|
|
965
|
+
return Array.from(this.plugins.keys())
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, options?: HookExecutionOptions) {
|
|
530
969
|
this.load()
|
|
531
|
-
|
|
532
|
-
const filterIds = enabledIds.length > 0 ? new Set(enabledIds) : null
|
|
970
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
533
971
|
|
|
534
972
|
for (const [id, p] of this.plugins.entries()) {
|
|
535
|
-
if (filterIds && !filterIds.has(id)) continue
|
|
973
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
536
974
|
const hook = p.hooks[hookName]
|
|
537
975
|
if (hook) {
|
|
538
976
|
try {
|
|
@@ -551,22 +989,54 @@ class PluginManager {
|
|
|
551
989
|
}
|
|
552
990
|
}
|
|
553
991
|
|
|
992
|
+
async runBeforeToolExec(
|
|
993
|
+
params: { toolName: string; input: Record<string, unknown> | null },
|
|
994
|
+
options?: HookExecutionOptions,
|
|
995
|
+
): Promise<Record<string, unknown> | null> {
|
|
996
|
+
this.load()
|
|
997
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
998
|
+
let currentInput = params.input
|
|
999
|
+
|
|
1000
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1001
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1002
|
+
const hook = p.hooks.beforeToolExec
|
|
1003
|
+
if (!hook) continue
|
|
1004
|
+
try {
|
|
1005
|
+
const result = await hook({ toolName: params.toolName, input: currentInput })
|
|
1006
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
1007
|
+
currentInput = result as Record<string, unknown>
|
|
1008
|
+
}
|
|
1009
|
+
this.markPluginSuccess(id)
|
|
1010
|
+
} catch (err: unknown) {
|
|
1011
|
+
log.error('plugins', 'beforeToolExec hook failed', {
|
|
1012
|
+
pluginId: id,
|
|
1013
|
+
pluginName: p.meta.name,
|
|
1014
|
+
toolName: params.toolName,
|
|
1015
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1016
|
+
})
|
|
1017
|
+
this.markPluginFailure(id, 'hook.beforeToolExec', err, true)
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return currentInput
|
|
1022
|
+
}
|
|
1023
|
+
|
|
554
1024
|
async transformText(
|
|
555
1025
|
hookName: 'transformInboundMessage' | 'transformOutboundMessage',
|
|
556
1026
|
params: { session: Session; text: string },
|
|
557
|
-
|
|
1027
|
+
options?: HookExecutionOptions,
|
|
558
1028
|
): Promise<string> {
|
|
559
1029
|
this.load()
|
|
560
|
-
const filterIds = enabledIds
|
|
1030
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
561
1031
|
let currentText = params.text
|
|
562
1032
|
|
|
563
1033
|
for (const [id, p] of this.plugins.entries()) {
|
|
564
|
-
if (filterIds && !filterIds.has(id)) continue
|
|
1034
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
565
1035
|
const hook = p.hooks[hookName]
|
|
566
1036
|
if (hook) {
|
|
567
1037
|
try {
|
|
568
|
-
const result = await (hook as (ctx: typeof params) => Promise<string> | string)(params)
|
|
569
|
-
currentText = result
|
|
1038
|
+
const result = await (hook as (ctx: typeof params) => Promise<string> | string)({ ...params, text: currentText })
|
|
1039
|
+
if (typeof result === 'string') currentText = result
|
|
570
1040
|
this.markPluginSuccess(id)
|
|
571
1041
|
} catch (err: unknown) {
|
|
572
1042
|
log.error('plugins', 'Plugin transform hook failed', {
|
|
@@ -584,7 +1054,7 @@ class PluginManager {
|
|
|
584
1054
|
|
|
585
1055
|
async collectAgentContext(session: import('@/types').Session, enabledPlugins: string[], message: string, history: import('@/types').Message[]): Promise<string[]> {
|
|
586
1056
|
this.load()
|
|
587
|
-
const enabledSet = new Set(enabledPlugins)
|
|
1057
|
+
const enabledSet = new Set(expandPluginIds(enabledPlugins))
|
|
588
1058
|
const parts: string[] = []
|
|
589
1059
|
|
|
590
1060
|
for (const [id, p] of this.plugins.entries()) {
|
|
@@ -678,6 +1148,150 @@ class PluginManager {
|
|
|
678
1148
|
return result
|
|
679
1149
|
}
|
|
680
1150
|
|
|
1151
|
+
getSettingsFields(pluginId: string): import('@/types').PluginSettingsField[] {
|
|
1152
|
+
this.load()
|
|
1153
|
+
const candidateIds = expandPluginIds([pluginId])
|
|
1154
|
+
for (const id of candidateIds) {
|
|
1155
|
+
const plugin = this.plugins.get(id) || (this.builtins.has(id) ? {
|
|
1156
|
+
ui: this.builtins.get(id)?.ui,
|
|
1157
|
+
} as LoadedPlugin : null)
|
|
1158
|
+
const fields = plugin?.ui?.settingsFields
|
|
1159
|
+
if (fields?.length) return fields
|
|
1160
|
+
}
|
|
1161
|
+
return []
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
getPluginSettings(pluginId: string): Record<string, unknown> {
|
|
1165
|
+
const settings = loadSettings()
|
|
1166
|
+
const allSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
|
|
1167
|
+
const result: Record<string, unknown> = {}
|
|
1168
|
+
|
|
1169
|
+
for (const key of this.configIdsFor(pluginId)) {
|
|
1170
|
+
const values = allSettings[key]
|
|
1171
|
+
if (!values || typeof values !== 'object') continue
|
|
1172
|
+
for (const [fieldKey, fieldValue] of Object.entries(values)) {
|
|
1173
|
+
if (isPluginSecretSettingValue(fieldValue)) {
|
|
1174
|
+
try {
|
|
1175
|
+
result[fieldKey] = decryptKey(fieldValue.encrypted)
|
|
1176
|
+
} catch {
|
|
1177
|
+
result[fieldKey] = ''
|
|
1178
|
+
}
|
|
1179
|
+
continue
|
|
1180
|
+
}
|
|
1181
|
+
result[fieldKey] = fieldValue
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
for (const field of this.getSettingsFields(pluginId)) {
|
|
1186
|
+
if (result[field.key] === undefined && field.defaultValue !== undefined) {
|
|
1187
|
+
result[field.key] = field.defaultValue
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return result
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
getPublicPluginSettings(pluginId: string): { values: Record<string, unknown>; configuredSecretFields: string[] } {
|
|
1195
|
+
const values = this.getPluginSettings(pluginId)
|
|
1196
|
+
const configuredSecretFields: string[] = []
|
|
1197
|
+
|
|
1198
|
+
for (const field of this.getSettingsFields(pluginId)) {
|
|
1199
|
+
if (field.type !== 'secret') continue
|
|
1200
|
+
const current = values[field.key]
|
|
1201
|
+
if (typeof current === 'string' && current.trim()) {
|
|
1202
|
+
configuredSecretFields.push(field.key)
|
|
1203
|
+
}
|
|
1204
|
+
values[field.key] = ''
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
return { values, configuredSecretFields }
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
setPluginSettings(pluginId: string, values: Record<string, unknown>): Record<string, unknown> {
|
|
1211
|
+
const fields = this.getSettingsFields(pluginId)
|
|
1212
|
+
if (fields.length === 0 && Object.keys(values || {}).length > 0) {
|
|
1213
|
+
throw new Error(`Plugin "${pluginId}" does not declare configurable settings`)
|
|
1214
|
+
}
|
|
1215
|
+
const fieldMap = new Map(fields.map((field) => [field.key, field]))
|
|
1216
|
+
const nextValues: Record<string, unknown> = {}
|
|
1217
|
+
|
|
1218
|
+
for (const [key, rawValue] of Object.entries(values || {})) {
|
|
1219
|
+
const field = fieldMap.get(key)
|
|
1220
|
+
if (!field) continue
|
|
1221
|
+
if (rawValue === undefined) continue
|
|
1222
|
+
if (field.type === 'boolean') {
|
|
1223
|
+
nextValues[key] = rawValue === true || rawValue === 'true' || rawValue === 1 || rawValue === '1'
|
|
1224
|
+
continue
|
|
1225
|
+
}
|
|
1226
|
+
if (field.type === 'number') {
|
|
1227
|
+
const parsed = typeof rawValue === 'number' ? rawValue : Number(rawValue)
|
|
1228
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid number for setting "${key}"`)
|
|
1229
|
+
nextValues[key] = parsed
|
|
1230
|
+
continue
|
|
1231
|
+
}
|
|
1232
|
+
const text = typeof rawValue === 'string' ? rawValue : String(rawValue ?? '')
|
|
1233
|
+
if (field.required && !text.trim()) throw new Error(`Setting "${key}" is required`)
|
|
1234
|
+
if (field.type === 'select' && field.options?.length) {
|
|
1235
|
+
const allowed = new Set(field.options.map((option) => option.value))
|
|
1236
|
+
if (!allowed.has(text)) throw new Error(`Invalid value for setting "${key}"`)
|
|
1237
|
+
}
|
|
1238
|
+
if (field.type === 'secret') {
|
|
1239
|
+
nextValues[key] = text.trim()
|
|
1240
|
+
} else {
|
|
1241
|
+
nextValues[key] = text
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
const currentSettings = loadSettings()
|
|
1246
|
+
const pluginSettings = (currentSettings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
|
|
1247
|
+
const canonicalId = this.canonicalPluginId(pluginId)
|
|
1248
|
+
const existingStored: Record<string, unknown> = {}
|
|
1249
|
+
for (const alias of this.configIdsFor(canonicalId)) {
|
|
1250
|
+
const existing = pluginSettings[alias]
|
|
1251
|
+
if (!existing || typeof existing !== 'object') continue
|
|
1252
|
+
Object.assign(existingStored, existing)
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
for (const field of fields) {
|
|
1256
|
+
if (!field.required) continue
|
|
1257
|
+
if (
|
|
1258
|
+
nextValues[field.key] === undefined
|
|
1259
|
+
&& existingStored[field.key] === undefined
|
|
1260
|
+
&& field.defaultValue === undefined
|
|
1261
|
+
) {
|
|
1262
|
+
throw new Error(`Setting "${field.key}" is required`)
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const stored: Record<string, unknown> = {}
|
|
1267
|
+
|
|
1268
|
+
for (const field of fields) {
|
|
1269
|
+
if (nextValues[field.key] === undefined) {
|
|
1270
|
+
if (existingStored[field.key] !== undefined) {
|
|
1271
|
+
stored[field.key] = existingStored[field.key]
|
|
1272
|
+
}
|
|
1273
|
+
continue
|
|
1274
|
+
}
|
|
1275
|
+
if (field.type === 'secret') {
|
|
1276
|
+
stored[field.key] = {
|
|
1277
|
+
__pluginSecret: true,
|
|
1278
|
+
encrypted: encryptKey(String(nextValues[field.key] ?? '')),
|
|
1279
|
+
} satisfies PluginSecretSettingValue
|
|
1280
|
+
} else {
|
|
1281
|
+
stored[field.key] = nextValues[field.key]
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
for (const alias of this.configIdsFor(canonicalId)) {
|
|
1286
|
+
delete pluginSettings[alias]
|
|
1287
|
+
}
|
|
1288
|
+
pluginSettings[canonicalId] = stored
|
|
1289
|
+
currentSettings.pluginSettings = pluginSettings
|
|
1290
|
+
saveSettings(currentSettings)
|
|
1291
|
+
|
|
1292
|
+
return this.getPublicPluginSettings(canonicalId).values
|
|
1293
|
+
}
|
|
1294
|
+
|
|
681
1295
|
recordExternalToolFailure(pluginId: string, toolName: string, err: unknown): void {
|
|
682
1296
|
this.markPluginFailure(pluginId, `tool.${toolName}`, err, true)
|
|
683
1297
|
}
|
|
@@ -687,10 +1301,9 @@ class PluginManager {
|
|
|
687
1301
|
}
|
|
688
1302
|
|
|
689
1303
|
isEnabled(filename: string): boolean {
|
|
690
|
-
const
|
|
691
|
-
const explicit = config[filename]
|
|
1304
|
+
const explicit = this.readConfigEntry(filename)
|
|
692
1305
|
if (explicit != null) return explicit.enabled !== false
|
|
693
|
-
const builtin = this.builtins.get(filename)
|
|
1306
|
+
const builtin = this.builtins.get(this.canonicalPluginId(filename))
|
|
694
1307
|
if (builtin) return builtin.enabledByDefault !== false
|
|
695
1308
|
return true
|
|
696
1309
|
}
|
|
@@ -722,18 +1335,20 @@ class PluginManager {
|
|
|
722
1335
|
// Add all builtins
|
|
723
1336
|
for (const [id, p] of this.builtins.entries()) {
|
|
724
1337
|
const loaded = this.plugins.get(id)
|
|
725
|
-
const explicitCfg = config
|
|
1338
|
+
const explicitCfg = this.readConfigEntry(id, config)
|
|
726
1339
|
const enabled = explicitCfg != null ? explicitCfg.enabled !== false : p.enabledByDefault !== false
|
|
727
|
-
const failure = failures[id]
|
|
1340
|
+
const failure = failures[this.canonicalPluginId(id)]
|
|
728
1341
|
const caps = describeCapabilities(loaded, p)
|
|
729
1342
|
metas.push({
|
|
730
1343
|
name: p.name,
|
|
731
1344
|
description: p.description || '',
|
|
732
1345
|
filename: id,
|
|
733
1346
|
enabled,
|
|
734
|
-
|
|
1347
|
+
isBuiltin: true,
|
|
1348
|
+
author: p.author || 'SwarmClaw',
|
|
735
1349
|
version: (p as { version?: string }).version || loaded?.meta.version || '1.0.0',
|
|
736
1350
|
source: loaded?.meta.source || 'local',
|
|
1351
|
+
openclaw: p.openclaw === true,
|
|
737
1352
|
failureCount: failure?.count,
|
|
738
1353
|
lastFailureAt: failure?.lastFailedAt,
|
|
739
1354
|
lastFailureStage: failure?.lastStage,
|
|
@@ -749,22 +1364,33 @@ class PluginManager {
|
|
|
749
1364
|
for (const f of files) {
|
|
750
1365
|
if (!metas.find(m => m.filename === f)) {
|
|
751
1366
|
const loaded = this.plugins.get(f)
|
|
752
|
-
const
|
|
1367
|
+
const explicitCfg = this.readConfigEntry(f, config)
|
|
1368
|
+
const enabled = explicitCfg?.enabled !== false
|
|
753
1369
|
const failure = failures[f]
|
|
754
1370
|
const caps = describeCapabilities(loaded)
|
|
1371
|
+
const dependencyInfo = this.getDependencyInfo(f, explicitCfg)
|
|
755
1372
|
metas.push({
|
|
756
1373
|
name: loaded?.meta.name || f.replace(/\.(js|mjs)$/, ''),
|
|
757
1374
|
filename: f,
|
|
758
1375
|
enabled,
|
|
1376
|
+
isBuiltin: false,
|
|
759
1377
|
author: loaded?.meta.author,
|
|
760
1378
|
version: loaded?.meta.version || '0.0.1',
|
|
761
|
-
source: loaded?.meta.source || 'marketplace',
|
|
762
|
-
|
|
1379
|
+
source: loaded?.meta.source || (explicitCfg?.sourceUrl ? 'marketplace' : 'local'),
|
|
1380
|
+
openclaw: loaded?.meta.openclaw,
|
|
1381
|
+
createdByAgentId: explicitCfg?.createdByAgentId || null,
|
|
763
1382
|
failureCount: failure?.count,
|
|
764
1383
|
lastFailureAt: failure?.lastFailedAt,
|
|
765
1384
|
lastFailureStage: failure?.lastStage,
|
|
766
1385
|
lastFailureError: failure?.lastError,
|
|
767
1386
|
autoDisabled: !enabled && !!failure && failure.count >= MAX_CONSECUTIVE_PLUGIN_FAILURES,
|
|
1387
|
+
hasDependencyManifest: dependencyInfo.hasManifest,
|
|
1388
|
+
dependencyCount: dependencyInfo.dependencyCount,
|
|
1389
|
+
devDependencyCount: dependencyInfo.devDependencyCount,
|
|
1390
|
+
packageManager: dependencyInfo.packageManager,
|
|
1391
|
+
dependencyInstallStatus: dependencyInfo.installStatus,
|
|
1392
|
+
dependencyInstallError: dependencyInfo.installError,
|
|
1393
|
+
dependencyInstalledAt: dependencyInfo.installedAt,
|
|
768
1394
|
...caps,
|
|
769
1395
|
})
|
|
770
1396
|
}
|
|
@@ -778,52 +1404,178 @@ class PluginManager {
|
|
|
778
1404
|
}
|
|
779
1405
|
}
|
|
780
1406
|
|
|
1407
|
+
readPluginSource(filename: string): string {
|
|
1408
|
+
const fullPath = this.resolvePluginSourcePath(filename)
|
|
1409
|
+
if (!fs.existsSync(fullPath)) throw new Error(`Plugin not found: ${filename}`)
|
|
1410
|
+
return fs.readFileSync(fullPath, 'utf8')
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
async savePluginSource(filename: string, code: string, options?: UpsertPluginOptions): Promise<void> {
|
|
1414
|
+
const sanitizedFilename = sanitizePluginFilename(filename)
|
|
1415
|
+
this.ensurePluginDirs()
|
|
1416
|
+
|
|
1417
|
+
const shouldUseWorkspace = this.hasWorkspace(sanitizedFilename) || options?.packageJson !== undefined
|
|
1418
|
+
const sourcePath = shouldUseWorkspace
|
|
1419
|
+
? this.getWorkspaceEntryPath(sanitizedFilename)
|
|
1420
|
+
: path.join(PLUGINS_DIR, sanitizedFilename)
|
|
1421
|
+
|
|
1422
|
+
if (shouldUseWorkspace) {
|
|
1423
|
+
fs.mkdirSync(this.getWorkspaceDir(sanitizedFilename), { recursive: true })
|
|
1424
|
+
fs.writeFileSync(sourcePath, code, 'utf8')
|
|
1425
|
+
this.writeWorkspaceShim(sanitizedFilename)
|
|
1426
|
+
} else {
|
|
1427
|
+
fs.writeFileSync(sourcePath, code, 'utf8')
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const normalizedPackageManager = normalizePluginPackageManager(options?.packageManager)
|
|
1431
|
+
|
|
1432
|
+
if (options?.packageJson !== undefined) {
|
|
1433
|
+
if (!shouldUseWorkspace) {
|
|
1434
|
+
throw new Error('Plugin workspace is required for package.json support')
|
|
1435
|
+
}
|
|
1436
|
+
const manifest = normalizePluginManifest(options.packageJson, sanitizedFilename, normalizedPackageManager)
|
|
1437
|
+
fs.writeFileSync(this.getWorkspaceManifestPath(sanitizedFilename), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
1438
|
+
this.setMeta(sanitizedFilename, {
|
|
1439
|
+
...(options?.meta || {}),
|
|
1440
|
+
packageManager: normalizedPackageManager || normalizePluginPackageManager(manifest.packageManager) || undefined,
|
|
1441
|
+
dependencyInstallStatus: 'ready',
|
|
1442
|
+
dependencyInstallError: undefined,
|
|
1443
|
+
dependencyInstalledAt: undefined,
|
|
1444
|
+
})
|
|
1445
|
+
} else if (options?.meta && Object.keys(options.meta).length > 0) {
|
|
1446
|
+
this.setMeta(sanitizedFilename, options.meta)
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (options?.installDependencies) {
|
|
1450
|
+
await this.installPluginDependencies(sanitizedFilename, {
|
|
1451
|
+
packageManager: normalizedPackageManager || undefined,
|
|
1452
|
+
})
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
this.reload()
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async installPluginDependencies(filename: string, options?: { packageManager?: PluginPackageManager }): Promise<PluginDependencyInfo> {
|
|
1459
|
+
const sanitizedFilename = sanitizePluginFilename(filename)
|
|
1460
|
+
const fullPath = path.join(PLUGINS_DIR, sanitizedFilename)
|
|
1461
|
+
if (!fs.existsSync(fullPath) && !this.hasWorkspace(sanitizedFilename)) {
|
|
1462
|
+
throw new Error(`Plugin not found: ${sanitizedFilename}`)
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
this.ensurePluginDirs()
|
|
1466
|
+
const workspaceDir = this.getWorkspaceDir(sanitizedFilename)
|
|
1467
|
+
const sourcePath = this.resolvePluginSourcePath(sanitizedFilename)
|
|
1468
|
+
const currentCode = fs.existsSync(sourcePath) ? fs.readFileSync(sourcePath, 'utf8') : ''
|
|
1469
|
+
|
|
1470
|
+
if (!this.hasWorkspace(sanitizedFilename)) {
|
|
1471
|
+
fs.mkdirSync(workspaceDir, { recursive: true })
|
|
1472
|
+
fs.writeFileSync(this.getWorkspaceEntryPath(sanitizedFilename), currentCode, 'utf8')
|
|
1473
|
+
this.writeWorkspaceShim(sanitizedFilename)
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const manifest = this.readWorkspaceManifest(sanitizedFilename)
|
|
1477
|
+
if (!manifest) throw new Error(`Plugin "${sanitizedFilename}" does not have a package.json manifest`)
|
|
1478
|
+
|
|
1479
|
+
const packageManager = options?.packageManager
|
|
1480
|
+
|| normalizePluginPackageManager(this.readConfigEntry(sanitizedFilename)?.packageManager)
|
|
1481
|
+
|| normalizePluginPackageManager(manifest.packageManager)
|
|
1482
|
+
|| 'npm'
|
|
1483
|
+
|
|
1484
|
+
this.setMeta(sanitizedFilename, {
|
|
1485
|
+
packageManager,
|
|
1486
|
+
dependencyInstallStatus: 'installing',
|
|
1487
|
+
dependencyInstallError: undefined,
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
try {
|
|
1491
|
+
await this.runDependencyInstall(packageManager, workspaceDir)
|
|
1492
|
+
this.setMeta(sanitizedFilename, {
|
|
1493
|
+
packageManager,
|
|
1494
|
+
dependencyInstallStatus: 'installed',
|
|
1495
|
+
dependencyInstallError: undefined,
|
|
1496
|
+
dependencyInstalledAt: Date.now(),
|
|
1497
|
+
})
|
|
1498
|
+
} catch (err: unknown) {
|
|
1499
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1500
|
+
this.setMeta(sanitizedFilename, {
|
|
1501
|
+
packageManager,
|
|
1502
|
+
dependencyInstallStatus: 'error',
|
|
1503
|
+
dependencyInstallError: message,
|
|
1504
|
+
})
|
|
1505
|
+
throw new Error(message)
|
|
1506
|
+
} finally {
|
|
1507
|
+
this.reload()
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
return this.getDependencyInfo(sanitizedFilename, this.readConfigEntry(sanitizedFilename))
|
|
1511
|
+
}
|
|
1512
|
+
|
|
781
1513
|
setEnabled(filename: string, enabled: boolean) {
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
1514
|
+
const current = this.readConfigEntry(filename)
|
|
1515
|
+
this.updateConfigEntry(filename, { ...(current || {}), enabled })
|
|
785
1516
|
if (enabled) this.clearFailureState(filename)
|
|
786
1517
|
this.reload()
|
|
787
1518
|
}
|
|
788
1519
|
|
|
789
1520
|
deletePlugin(filename: string): boolean {
|
|
790
1521
|
// Only allow deleting external plugins, not builtins
|
|
791
|
-
if (this.builtins.has(filename)) return false
|
|
1522
|
+
if (this.builtins.has(this.canonicalPluginId(filename))) return false
|
|
792
1523
|
const fullPath = path.join(PLUGINS_DIR, filename)
|
|
793
1524
|
if (!fs.existsSync(fullPath)) return false
|
|
794
1525
|
fs.unlinkSync(fullPath)
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1526
|
+
const workspaceDir = this.getWorkspaceDir(filename)
|
|
1527
|
+
if (fs.existsSync(workspaceDir)) fs.rmSync(workspaceDir, { recursive: true, force: true })
|
|
1528
|
+
this.updateConfigEntry(filename, null)
|
|
1529
|
+
const settings = loadSettings()
|
|
1530
|
+
const pluginSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
|
|
1531
|
+
for (const key of this.configIdsFor(filename)) delete pluginSettings[key]
|
|
1532
|
+
settings.pluginSettings = pluginSettings
|
|
1533
|
+
saveSettings(settings)
|
|
799
1534
|
this.clearFailureState(filename)
|
|
800
1535
|
this.reload()
|
|
801
1536
|
return true
|
|
802
1537
|
}
|
|
803
1538
|
|
|
1539
|
+
async installPluginFromUrl(url: string, filename: string, meta?: Record<string, unknown>): Promise<InstalledPluginSource> {
|
|
1540
|
+
const sanitizedFilename = sanitizePluginFilename(filename)
|
|
1541
|
+
const download = await downloadPluginSource(url)
|
|
1542
|
+
await this.savePluginSource(sanitizedFilename, download.code, {
|
|
1543
|
+
meta: {
|
|
1544
|
+
...(meta || {}),
|
|
1545
|
+
sourceUrl: download.normalizedUrl,
|
|
1546
|
+
sourceHash: download.hash,
|
|
1547
|
+
installedAt: Date.now(),
|
|
1548
|
+
updatedAt: Date.now(),
|
|
1549
|
+
},
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
return {
|
|
1553
|
+
filename: sanitizedFilename,
|
|
1554
|
+
sourceUrl: download.normalizedUrl,
|
|
1555
|
+
sourceHash: download.hash,
|
|
1556
|
+
contentType: download.contentType,
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
804
1560
|
async updatePlugin(id: string) {
|
|
805
1561
|
this.load()
|
|
806
1562
|
const p = this.plugins.get(id)
|
|
807
1563
|
if (!p) throw new Error('Plugin not found')
|
|
1564
|
+
if (p.isBuiltin) throw new Error('Built-in plugins are updated via application releases')
|
|
808
1565
|
|
|
809
1566
|
log.info('plugins', 'Updating plugin', { pluginId: id, pluginName: p.meta.name })
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (!
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
content = content.replace(`version: "${current}"`, `version: "${next}"`)
|
|
823
|
-
fs.writeFileSync(fullPath, content, 'utf8')
|
|
824
|
-
}
|
|
825
|
-
}
|
|
826
|
-
}
|
|
1567
|
+
const current = this.readConfigEntry(id)
|
|
1568
|
+
const sourceUrl = current?.sourceUrl?.trim()
|
|
1569
|
+
if (!sourceUrl) throw new Error(`Plugin "${id}" has no recorded source URL and cannot be updated automatically`)
|
|
1570
|
+
|
|
1571
|
+
const download = await downloadPluginSource(sourceUrl)
|
|
1572
|
+
const fullPath = path.join(PLUGINS_DIR, id)
|
|
1573
|
+
fs.writeFileSync(fullPath, download.code, 'utf8')
|
|
1574
|
+
this.setMeta(id, {
|
|
1575
|
+
sourceUrl: download.normalizedUrl,
|
|
1576
|
+
sourceHash: download.hash,
|
|
1577
|
+
updatedAt: Date.now(),
|
|
1578
|
+
})
|
|
827
1579
|
|
|
828
1580
|
this.reload()
|
|
829
1581
|
return true
|
|
@@ -831,7 +1583,9 @@ class PluginManager {
|
|
|
831
1583
|
|
|
832
1584
|
async updateAllPlugins() {
|
|
833
1585
|
this.load()
|
|
834
|
-
const ids = Array.from(this.plugins.
|
|
1586
|
+
const ids = Array.from(this.plugins.entries())
|
|
1587
|
+
.filter(([, plugin]) => !plugin.isBuiltin)
|
|
1588
|
+
.map(([id]) => id)
|
|
835
1589
|
for (const id of ids) {
|
|
836
1590
|
try {
|
|
837
1591
|
await this.updatePlugin(id)
|
|
@@ -841,12 +1595,11 @@ class PluginManager {
|
|
|
841
1595
|
}
|
|
842
1596
|
|
|
843
1597
|
setMeta(filename: string, meta: Record<string, unknown>) {
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
1598
|
+
const current = this.readConfigEntry(filename)
|
|
1599
|
+
this.updateConfigEntry(filename, { ...(current || {}), ...(meta as PluginConfigEntry) })
|
|
847
1600
|
}
|
|
848
1601
|
|
|
849
|
-
private loadConfig(): Record<string,
|
|
1602
|
+
private loadConfig(): Record<string, PluginConfigEntry> {
|
|
850
1603
|
try { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8')) } catch { return {} }
|
|
851
1604
|
}
|
|
852
1605
|
|