@swarmclawai/swarmclaw 0.7.1 → 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 +155 -150
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +26 -0
- package/src/app/api/agents/[id]/thread/route.ts +37 -9
- package/src/app/api/agents/route.ts +13 -2
- package/src/app/api/auth/route.ts +76 -7
- package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
- package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
- package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
- package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
- package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
- package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
- package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
- package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
- package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
- package/src/app/api/{sessions → chats}/route.ts +21 -7
- 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 +6 -26
- package/src/app/api/plugins/settings/route.ts +40 -0
- 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/usage/route.ts +30 -0
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +9 -2
- package/src/cli/index.js +39 -33
- package/src/cli/index.ts +43 -49
- package/src/cli/spec.js +29 -27
- package/src/components/agents/agent-card.tsx +16 -13
- package/src/components/agents/agent-chat-list.tsx +104 -4
- package/src/components/agents/agent-list.tsx +54 -22
- package/src/components/agents/agent-sheet.tsx +209 -18
- package/src/components/agents/cron-job-form.tsx +3 -3
- package/src/components/agents/inspector-panel.tsx +110 -50
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +5 -38
- package/src/components/chat/chat-area.tsx +39 -27
- package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
- package/src/components/chat/chat-header.tsx +299 -314
- package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
- package/src/components/chat/chat-tool-toggles.tsx +26 -17
- package/src/components/chat/checkpoint-timeline.tsx +4 -4
- package/src/components/chat/message-bubble.tsx +4 -1
- package/src/components/chat/message-list.tsx +5 -3
- package/src/components/chat/session-debug-panel.tsx +1 -1
- package/src/components/chat/tool-request-banner.tsx +3 -3
- package/src/components/chatrooms/agent-hover-card.tsx +3 -3
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
- 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 +218 -1
- package/src/components/home/home-view.tsx +129 -5
- package/src/components/layout/app-layout.tsx +392 -182
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/plugins/plugin-list.tsx +487 -254
- package/src/components/plugins/plugin-sheet.tsx +236 -13
- package/src/components/projects/project-detail.tsx +183 -0
- package/src/components/settings/gateway-connection-panel.tsx +1 -1
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/command-palette.tsx +111 -25
- 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 +78 -1
- package/src/components/shared/settings/section-orchestrator.tsx +3 -3
- package/src/components/shared/settings/section-providers.tsx +1 -1
- 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 +244 -56
- 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 +147 -1
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +8 -8
- 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/chat.ts +1 -1
- package/src/lib/{sessions.ts → chats.ts} +28 -18
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/providers/claude-cli.ts +1 -1
- 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/capability-router.ts +10 -8
- 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 +285 -165
- 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 +48 -8
- 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 +948 -112
- 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/cost.ts +34 -1
- package/src/lib/server/daemon-state.ts +61 -3
- 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 +14 -40
- 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 +28 -1103
- 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 +20 -9
- package/src/lib/server/orchestrator.ts +7 -7
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +207 -0
- package/src/lib/server/plugins.ts +927 -66
- package/src/lib/server/provider-health.ts +38 -6
- package/src/lib/server/queue.ts +13 -28
- 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 -82
- package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
- package/src/lib/server/session-tools/calendar.ts +366 -0
- package/src/lib/server/session-tools/canvas.ts +1 -1
- package/src/lib/server/session-tools/chatroom.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +114 -10
- package/src/lib/server/session-tools/context.ts +21 -5
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +74 -28
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +497 -24
- package/src/lib/server/session-tools/discovery.ts +24 -6
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/edit_file.ts +4 -2
- package/src/lib/server/session-tools/email.ts +320 -0
- 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 +241 -25
- package/src/lib/server/session-tools/git.ts +1 -1
- package/src/lib/server/session-tools/http.ts +1 -1
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +380 -0
- package/src/lib/server/session-tools/index.ts +130 -50
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/memory.ts +172 -3
- package/src/lib/server/session-tools/monitor.ts +151 -8
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
- package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +148 -7
- package/src/lib/server/session-tools/plugin-creator.ts +89 -26
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +301 -0
- package/src/lib/server/session-tools/sample-ui.ts +1 -1
- package/src/lib/server/session-tools/sandbox.ts +4 -2
- package/src/lib/server/session-tools/schedule.ts +24 -12
- package/src/lib/server/session-tools/session-info.ts +43 -7
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
- package/src/lib/server/session-tools/shell.ts +5 -2
- package/src/lib/server/session-tools/subagent.ts +194 -28
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +42 -12
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +926 -91
- package/src/lib/server/storage.ts +255 -16
- package/src/lib/server/stream-agent-chat.ts +116 -268
- 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 +66 -18
- package/src/lib/server/tool-capability-policy.test.ts +9 -9
- package/src/lib/server/tool-capability-policy.ts +38 -27
- 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/tool-definitions.ts +4 -0
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +10 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +5 -11
- package/src/stores/use-chat-store.ts +38 -9
- package/src/types/index.ts +352 -47
- package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
- package/src/components/sessions/new-session-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -24
- package/src/lib/server/session-run-manager.test.ts +0 -23
- /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
|
@@ -1,15 +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'
|
|
8
|
+
import { canonicalizePluginId, expandPluginIds, getPluginAliases } from './tool-aliases'
|
|
6
9
|
import { log } from './logger'
|
|
7
10
|
import { createNotification } from './create-notification'
|
|
8
11
|
import { notify } from './ws-hub'
|
|
12
|
+
import { decryptKey, encryptKey, loadSettings, saveSettings } from './storage'
|
|
9
13
|
|
|
10
14
|
const PLUGINS_DIR = path.join(DATA_DIR, 'plugins')
|
|
15
|
+
const PLUGIN_WORKSPACES_DIR = path.join(PLUGINS_DIR, '.workspaces')
|
|
11
16
|
const PLUGINS_CONFIG = path.join(DATA_DIR, 'plugins.json')
|
|
12
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
|
|
13
21
|
const MAX_CONSECUTIVE_PLUGIN_FAILURES = (() => {
|
|
14
22
|
const raw = Number.parseInt(process.env.SWARMCLAW_PLUGIN_FAILURE_THRESHOLD || '3', 10)
|
|
15
23
|
if (!Number.isFinite(raw)) return 3
|
|
@@ -23,6 +31,55 @@ interface PluginFailureRecord {
|
|
|
23
31
|
lastFailedAt: number
|
|
24
32
|
}
|
|
25
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
|
+
|
|
26
83
|
interface PluginLogger {
|
|
27
84
|
info: (msg: string, m?: unknown) => void
|
|
28
85
|
warn: (msg: string, m?: unknown) => void
|
|
@@ -67,10 +124,165 @@ interface OpenClawPluginApi {
|
|
|
67
124
|
runtime: Record<string, unknown>
|
|
68
125
|
}
|
|
69
126
|
|
|
127
|
+
export interface HookExecutionOptions {
|
|
128
|
+
enabledIds?: string[]
|
|
129
|
+
includeAllWhenEmpty?: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
70
132
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
71
133
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
72
134
|
}
|
|
73
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
|
+
|
|
74
286
|
function coerceTools(rawTools: unknown): PluginToolDef[] {
|
|
75
287
|
if (Array.isArray(rawTools)) {
|
|
76
288
|
const tools: PluginToolDef[] = []
|
|
@@ -127,6 +339,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
127
339
|
name: raw.name as string,
|
|
128
340
|
version: (raw.version as string) || '0.0.1',
|
|
129
341
|
description: (raw.description as string) || '',
|
|
342
|
+
author: typeof raw.author === 'string' ? raw.author : undefined,
|
|
343
|
+
openclaw: raw.openclaw === true,
|
|
130
344
|
hooks,
|
|
131
345
|
tools: coerceTools(raw.tools),
|
|
132
346
|
ui: isRecord(raw.ui) ? (raw.ui as PluginUIExtension) : undefined,
|
|
@@ -160,6 +374,7 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
160
374
|
'message:inbound': 'transformInboundMessage',
|
|
161
375
|
'message:outbound': 'transformOutboundMessage',
|
|
162
376
|
'command:new': 'beforeAgentStart',
|
|
377
|
+
'agent:context': 'getAgentContext',
|
|
163
378
|
}
|
|
164
379
|
|
|
165
380
|
const pluginLogger: PluginLogger = {
|
|
@@ -212,6 +427,8 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
212
427
|
name: pluginName,
|
|
213
428
|
version: pluginVersion,
|
|
214
429
|
description: pluginDesc || `OpenClaw plugin (v${pluginVersion})`,
|
|
430
|
+
author: typeof raw.author === 'string' ? raw.author : undefined,
|
|
431
|
+
openclaw: true,
|
|
215
432
|
hooks,
|
|
216
433
|
tools,
|
|
217
434
|
}
|
|
@@ -251,6 +468,7 @@ function normalizePlugin(mod: unknown): Plugin | null {
|
|
|
251
468
|
name: oc.name,
|
|
252
469
|
version: oc.version,
|
|
253
470
|
description: `OpenClaw plugin (v${oc.version || '0.0.0'})`,
|
|
471
|
+
openclaw: true,
|
|
254
472
|
hooks,
|
|
255
473
|
tools,
|
|
256
474
|
}
|
|
@@ -279,13 +497,202 @@ class PluginManager {
|
|
|
279
497
|
private plugins: Map<string, LoadedPlugin> = new Map()
|
|
280
498
|
private builtins: Map<string, Plugin> = new Map()
|
|
281
499
|
private loaded = false
|
|
500
|
+
private watcher: fs.FSWatcher | null = null
|
|
282
501
|
|
|
283
502
|
registerBuiltin(id: string, plugin: Plugin) {
|
|
284
|
-
this.
|
|
503
|
+
const canonicalId = this.canonicalPluginId(id)
|
|
504
|
+
this.builtins.set(canonicalId, plugin)
|
|
285
505
|
// Builtins can be imported/registered after first load, so force re-evaluation.
|
|
286
506
|
this.loaded = false
|
|
287
507
|
}
|
|
288
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
|
+
|
|
289
696
|
private readFailureState(): Record<string, PluginFailureRecord> {
|
|
290
697
|
try {
|
|
291
698
|
const parsed = JSON.parse(fs.readFileSync(PLUGIN_FAILURES, 'utf8')) as Record<string, PluginFailureRecord>
|
|
@@ -306,17 +713,21 @@ class PluginManager {
|
|
|
306
713
|
|
|
307
714
|
private clearFailureState(id: string): void {
|
|
308
715
|
const state = this.readFailureState()
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
311
723
|
this.writeFailureState(state)
|
|
312
724
|
}
|
|
313
725
|
|
|
314
726
|
private autoDisableExternalPlugin(id: string, reason: string, failure: PluginFailureRecord): void {
|
|
315
|
-
const config = this.loadConfig()
|
|
316
|
-
if (config[id]?.enabled === false) return
|
|
317
|
-
config[id] = { ...config[id], enabled: false }
|
|
318
727
|
try {
|
|
319
|
-
|
|
728
|
+
const current = this.readConfigEntry(id)
|
|
729
|
+
if (current?.enabled === false) return
|
|
730
|
+
this.updateConfigEntry(id, { ...(current || {}), enabled: false })
|
|
320
731
|
} catch (err: unknown) {
|
|
321
732
|
log.error('plugins', 'Failed to write plugins config while auto-disabling plugin', {
|
|
322
733
|
pluginId: id,
|
|
@@ -351,14 +762,15 @@ class PluginManager {
|
|
|
351
762
|
private markPluginFailure(id: string, stage: string, err: unknown, disableEligible: boolean): void {
|
|
352
763
|
const errorText = err instanceof Error ? err.message : String(err)
|
|
353
764
|
const state = this.readFailureState()
|
|
354
|
-
const
|
|
765
|
+
const failureKey = this.canonicalPluginId(id)
|
|
766
|
+
const nextCount = (state[failureKey]?.count || 0) + 1
|
|
355
767
|
const record: PluginFailureRecord = {
|
|
356
768
|
count: nextCount,
|
|
357
769
|
lastError: errorText,
|
|
358
770
|
lastStage: stage,
|
|
359
771
|
lastFailedAt: Date.now(),
|
|
360
772
|
}
|
|
361
|
-
state[
|
|
773
|
+
state[failureKey] = record
|
|
362
774
|
this.writeFailureState(state)
|
|
363
775
|
|
|
364
776
|
log.warn('plugins', 'Plugin failure recorded', {
|
|
@@ -369,8 +781,12 @@ class PluginManager {
|
|
|
369
781
|
error: errorText,
|
|
370
782
|
})
|
|
371
783
|
|
|
372
|
-
if (
|
|
373
|
-
|
|
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)
|
|
374
790
|
}
|
|
375
791
|
}
|
|
376
792
|
|
|
@@ -385,16 +801,27 @@ class PluginManager {
|
|
|
385
801
|
load() {
|
|
386
802
|
if (this.loaded) return
|
|
387
803
|
this.plugins.clear()
|
|
804
|
+
this.ensurePluginWatcher()
|
|
388
805
|
|
|
389
806
|
const config = this.loadConfig()
|
|
390
807
|
|
|
391
808
|
// 1. Load Built-ins
|
|
392
809
|
for (const [id, p] of this.builtins.entries()) {
|
|
393
|
-
const
|
|
810
|
+
const explicitConfig = this.readConfigEntry(id, config)
|
|
811
|
+
const isEnabled = explicitConfig != null ? explicitConfig.enabled !== false : p.enabledByDefault !== false
|
|
394
812
|
if (isEnabled) {
|
|
395
813
|
this.plugins.set(id, {
|
|
396
814
|
id,
|
|
397
|
-
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
|
+
},
|
|
398
825
|
hooks: p.hooks || {},
|
|
399
826
|
tools: p.tools || [],
|
|
400
827
|
ui: p.ui,
|
|
@@ -408,7 +835,7 @@ class PluginManager {
|
|
|
408
835
|
|
|
409
836
|
// 2. Load External
|
|
410
837
|
try {
|
|
411
|
-
|
|
838
|
+
this.ensurePluginDirs()
|
|
412
839
|
const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
|
|
413
840
|
|
|
414
841
|
let dynamicRequire: NodeRequire | null = null
|
|
@@ -423,11 +850,12 @@ class PluginManager {
|
|
|
423
850
|
if (dynamicRequire) {
|
|
424
851
|
for (const file of files) {
|
|
425
852
|
try {
|
|
426
|
-
const
|
|
853
|
+
const explicitConfig = this.readConfigEntry(file, config)
|
|
854
|
+
const isEnabled = explicitConfig?.enabled !== false
|
|
427
855
|
if (!isEnabled) continue
|
|
428
856
|
|
|
429
857
|
const fullPath = path.join(PLUGINS_DIR, file)
|
|
430
|
-
|
|
858
|
+
this.clearPluginRequireCache(dynamicRequire, file)
|
|
431
859
|
const plugin = normalizePlugin(dynamicRequire(fullPath))
|
|
432
860
|
if (!plugin) {
|
|
433
861
|
this.markPluginFailure(file, 'load.normalize', 'Plugin format unsupported or activate() failed', true)
|
|
@@ -436,7 +864,16 @@ class PluginManager {
|
|
|
436
864
|
|
|
437
865
|
this.plugins.set(file, {
|
|
438
866
|
id: file,
|
|
439
|
-
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
|
+
},
|
|
440
877
|
hooks: plugin.hooks || {},
|
|
441
878
|
tools: plugin.tools || [],
|
|
442
879
|
ui: plugin.ui,
|
|
@@ -461,7 +898,7 @@ class PluginManager {
|
|
|
461
898
|
getTools(enabledIds: string[]): Array<{ pluginId: string; tool: PluginToolDef }> {
|
|
462
899
|
this.load()
|
|
463
900
|
const all: Array<{ pluginId: string; tool: PluginToolDef }> = []
|
|
464
|
-
const ids = new Set(enabledIds)
|
|
901
|
+
const ids = new Set(expandPluginIds(enabledIds))
|
|
465
902
|
for (const [id, p] of this.plugins.entries()) {
|
|
466
903
|
if (ids.has(id)) {
|
|
467
904
|
const tools = Array.isArray(p.tools) ? p.tools : []
|
|
@@ -523,13 +960,17 @@ class PluginManager {
|
|
|
523
960
|
return allUI
|
|
524
961
|
}
|
|
525
962
|
|
|
526
|
-
|
|
963
|
+
listPluginIds(): string[] {
|
|
527
964
|
this.load()
|
|
528
|
-
|
|
529
|
-
|
|
965
|
+
return Array.from(this.plugins.keys())
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, options?: HookExecutionOptions) {
|
|
969
|
+
this.load()
|
|
970
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
530
971
|
|
|
531
972
|
for (const [id, p] of this.plugins.entries()) {
|
|
532
|
-
if (filterIds && !filterIds.has(id)) continue
|
|
973
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
533
974
|
const hook = p.hooks[hookName]
|
|
534
975
|
if (hook) {
|
|
535
976
|
try {
|
|
@@ -548,22 +989,54 @@ class PluginManager {
|
|
|
548
989
|
}
|
|
549
990
|
}
|
|
550
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
|
+
|
|
551
1024
|
async transformText(
|
|
552
1025
|
hookName: 'transformInboundMessage' | 'transformOutboundMessage',
|
|
553
1026
|
params: { session: Session; text: string },
|
|
554
|
-
|
|
1027
|
+
options?: HookExecutionOptions,
|
|
555
1028
|
): Promise<string> {
|
|
556
1029
|
this.load()
|
|
557
|
-
const filterIds = enabledIds
|
|
1030
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
558
1031
|
let currentText = params.text
|
|
559
1032
|
|
|
560
1033
|
for (const [id, p] of this.plugins.entries()) {
|
|
561
|
-
if (filterIds && !filterIds.has(id)) continue
|
|
1034
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
562
1035
|
const hook = p.hooks[hookName]
|
|
563
1036
|
if (hook) {
|
|
564
1037
|
try {
|
|
565
|
-
const result = await (hook as (ctx: typeof params) => Promise<string> | string)(params)
|
|
566
|
-
currentText = result
|
|
1038
|
+
const result = await (hook as (ctx: typeof params) => Promise<string> | string)({ ...params, text: currentText })
|
|
1039
|
+
if (typeof result === 'string') currentText = result
|
|
567
1040
|
this.markPluginSuccess(id)
|
|
568
1041
|
} catch (err: unknown) {
|
|
569
1042
|
log.error('plugins', 'Plugin transform hook failed', {
|
|
@@ -579,6 +1052,246 @@ class PluginManager {
|
|
|
579
1052
|
return currentText
|
|
580
1053
|
}
|
|
581
1054
|
|
|
1055
|
+
async collectAgentContext(session: import('@/types').Session, enabledPlugins: string[], message: string, history: import('@/types').Message[]): Promise<string[]> {
|
|
1056
|
+
this.load()
|
|
1057
|
+
const enabledSet = new Set(expandPluginIds(enabledPlugins))
|
|
1058
|
+
const parts: string[] = []
|
|
1059
|
+
|
|
1060
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1061
|
+
if (!enabledSet.has(id)) continue
|
|
1062
|
+
const hook = p.hooks.getAgentContext
|
|
1063
|
+
if (!hook) continue
|
|
1064
|
+
try {
|
|
1065
|
+
const result = await hook({ session, enabledPlugins, message, history })
|
|
1066
|
+
if (typeof result === 'string' && result.trim()) {
|
|
1067
|
+
parts.push(result)
|
|
1068
|
+
this.markPluginSuccess(id)
|
|
1069
|
+
}
|
|
1070
|
+
} catch (err: unknown) {
|
|
1071
|
+
log.error('plugins', 'getAgentContext hook failed', {
|
|
1072
|
+
pluginId: id,
|
|
1073
|
+
pluginName: p.meta.name,
|
|
1074
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1075
|
+
})
|
|
1076
|
+
this.markPluginFailure(id, 'hook.getAgentContext', err, true)
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return parts
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** Collect capability descriptions from all enabled plugins for system prompt */
|
|
1084
|
+
collectCapabilityDescriptions(enabledPlugins: string[]): string[] {
|
|
1085
|
+
this.load()
|
|
1086
|
+
const enabledSet = new Set(expandPluginIds(enabledPlugins))
|
|
1087
|
+
const lines: string[] = []
|
|
1088
|
+
|
|
1089
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1090
|
+
if (!enabledSet.has(id)) continue
|
|
1091
|
+
const hook = p.hooks.getCapabilityDescription
|
|
1092
|
+
if (!hook) continue
|
|
1093
|
+
try {
|
|
1094
|
+
const result = hook()
|
|
1095
|
+
if (typeof result === 'string' && result.trim()) {
|
|
1096
|
+
lines.push(`- ${result}`)
|
|
1097
|
+
}
|
|
1098
|
+
} catch (err: unknown) {
|
|
1099
|
+
log.error('plugins', 'getCapabilityDescription hook failed', { pluginId: id, error: err instanceof Error ? err.message : String(err) })
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return lines
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Collect operating guidance from all enabled plugins */
|
|
1107
|
+
collectOperatingGuidance(enabledPlugins: string[]): string[] {
|
|
1108
|
+
this.load()
|
|
1109
|
+
const enabledSet = new Set(expandPluginIds(enabledPlugins))
|
|
1110
|
+
const lines: string[] = []
|
|
1111
|
+
|
|
1112
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1113
|
+
if (!enabledSet.has(id)) continue
|
|
1114
|
+
const hook = p.hooks.getOperatingGuidance
|
|
1115
|
+
if (!hook) continue
|
|
1116
|
+
try {
|
|
1117
|
+
const result = hook()
|
|
1118
|
+
if (result === null || result === undefined) continue
|
|
1119
|
+
if (typeof result === 'string' && result.trim()) {
|
|
1120
|
+
lines.push(result)
|
|
1121
|
+
} else if (Array.isArray(result)) {
|
|
1122
|
+
for (const line of result) {
|
|
1123
|
+
if (typeof line === 'string' && line.trim()) lines.push(line)
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
} catch (err: unknown) {
|
|
1127
|
+
log.error('plugins', 'getOperatingGuidance hook failed', { pluginId: id, error: err instanceof Error ? err.message : String(err) })
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
return lines
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** Collect all settings fields declared by enabled plugins */
|
|
1135
|
+
collectSettingsFields(enabledPlugins: string[]): Array<{ pluginId: string; pluginName: string; fields: import('@/types').PluginSettingsField[] }> {
|
|
1136
|
+
this.load()
|
|
1137
|
+
const enabledSet = new Set(expandPluginIds(enabledPlugins))
|
|
1138
|
+
const result: Array<{ pluginId: string; pluginName: string; fields: import('@/types').PluginSettingsField[] }> = []
|
|
1139
|
+
|
|
1140
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1141
|
+
if (!enabledSet.has(id)) continue
|
|
1142
|
+
const fields = p.ui?.settingsFields
|
|
1143
|
+
if (fields?.length) {
|
|
1144
|
+
result.push({ pluginId: id, pluginName: p.meta.name, fields })
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return result
|
|
1149
|
+
}
|
|
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
|
+
|
|
582
1295
|
recordExternalToolFailure(pluginId: string, toolName: string, err: unknown): void {
|
|
583
1296
|
this.markPluginFailure(pluginId, `tool.${toolName}`, err, true)
|
|
584
1297
|
}
|
|
@@ -588,8 +1301,11 @@ class PluginManager {
|
|
|
588
1301
|
}
|
|
589
1302
|
|
|
590
1303
|
isEnabled(filename: string): boolean {
|
|
591
|
-
const
|
|
592
|
-
return
|
|
1304
|
+
const explicit = this.readConfigEntry(filename)
|
|
1305
|
+
if (explicit != null) return explicit.enabled !== false
|
|
1306
|
+
const builtin = this.builtins.get(this.canonicalPluginId(filename))
|
|
1307
|
+
if (builtin) return builtin.enabledByDefault !== false
|
|
1308
|
+
return true
|
|
593
1309
|
}
|
|
594
1310
|
|
|
595
1311
|
listPlugins(): PluginMeta[] {
|
|
@@ -599,34 +1315,40 @@ class PluginManager {
|
|
|
599
1315
|
const failures = this.readFailureState()
|
|
600
1316
|
const metas: PluginMeta[] = []
|
|
601
1317
|
|
|
602
|
-
const describeCapabilities = (loaded?: LoadedPlugin, fallback?: Plugin): Pick<PluginMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount'> => {
|
|
1318
|
+
const describeCapabilities = (loaded?: LoadedPlugin, fallback?: Plugin): Pick<PluginMeta, 'toolCount' | 'hookCount' | 'hasUI' | 'providerCount' | 'connectorCount' | 'settingsFields'> => {
|
|
603
1319
|
const tools = loaded?.tools || fallback?.tools || []
|
|
604
1320
|
const hooks = loaded?.hooks || fallback?.hooks || {}
|
|
605
1321
|
const providers = loaded?.providers || fallback?.providers || []
|
|
606
1322
|
const connectors = loaded?.connectors || fallback?.connectors || []
|
|
607
1323
|
const hasUi = !!(loaded?.ui || fallback?.ui)
|
|
1324
|
+
const settingsFields = loaded?.ui?.settingsFields || fallback?.ui?.settingsFields
|
|
608
1325
|
return {
|
|
609
1326
|
toolCount: Array.isArray(tools) ? tools.length : 0,
|
|
610
1327
|
hookCount: Object.values(hooks || {}).filter((fn) => typeof fn === 'function').length,
|
|
611
1328
|
hasUI: hasUi,
|
|
612
1329
|
providerCount: Array.isArray(providers) ? providers.length : 0,
|
|
613
1330
|
connectorCount: Array.isArray(connectors) ? connectors.length : 0,
|
|
1331
|
+
settingsFields: settingsFields?.length ? settingsFields : undefined,
|
|
614
1332
|
}
|
|
615
1333
|
}
|
|
616
1334
|
|
|
617
1335
|
// Add all builtins
|
|
618
1336
|
for (const [id, p] of this.builtins.entries()) {
|
|
619
1337
|
const loaded = this.plugins.get(id)
|
|
620
|
-
const
|
|
621
|
-
const
|
|
1338
|
+
const explicitCfg = this.readConfigEntry(id, config)
|
|
1339
|
+
const enabled = explicitCfg != null ? explicitCfg.enabled !== false : p.enabledByDefault !== false
|
|
1340
|
+
const failure = failures[this.canonicalPluginId(id)]
|
|
622
1341
|
const caps = describeCapabilities(loaded, p)
|
|
623
1342
|
metas.push({
|
|
624
1343
|
name: p.name,
|
|
625
1344
|
description: p.description || '',
|
|
626
1345
|
filename: id,
|
|
627
1346
|
enabled,
|
|
1347
|
+
isBuiltin: true,
|
|
1348
|
+
author: p.author || 'SwarmClaw',
|
|
628
1349
|
version: (p as { version?: string }).version || loaded?.meta.version || '1.0.0',
|
|
629
1350
|
source: loaded?.meta.source || 'local',
|
|
1351
|
+
openclaw: p.openclaw === true,
|
|
630
1352
|
failureCount: failure?.count,
|
|
631
1353
|
lastFailureAt: failure?.lastFailedAt,
|
|
632
1354
|
lastFailureStage: failure?.lastStage,
|
|
@@ -642,21 +1364,33 @@ class PluginManager {
|
|
|
642
1364
|
for (const f of files) {
|
|
643
1365
|
if (!metas.find(m => m.filename === f)) {
|
|
644
1366
|
const loaded = this.plugins.get(f)
|
|
645
|
-
const
|
|
1367
|
+
const explicitCfg = this.readConfigEntry(f, config)
|
|
1368
|
+
const enabled = explicitCfg?.enabled !== false
|
|
646
1369
|
const failure = failures[f]
|
|
647
1370
|
const caps = describeCapabilities(loaded)
|
|
1371
|
+
const dependencyInfo = this.getDependencyInfo(f, explicitCfg)
|
|
648
1372
|
metas.push({
|
|
649
1373
|
name: loaded?.meta.name || f.replace(/\.(js|mjs)$/, ''),
|
|
650
1374
|
filename: f,
|
|
651
1375
|
enabled,
|
|
1376
|
+
isBuiltin: false,
|
|
1377
|
+
author: loaded?.meta.author,
|
|
652
1378
|
version: loaded?.meta.version || '0.0.1',
|
|
653
|
-
source: loaded?.meta.source || 'marketplace',
|
|
654
|
-
|
|
1379
|
+
source: loaded?.meta.source || (explicitCfg?.sourceUrl ? 'marketplace' : 'local'),
|
|
1380
|
+
openclaw: loaded?.meta.openclaw,
|
|
1381
|
+
createdByAgentId: explicitCfg?.createdByAgentId || null,
|
|
655
1382
|
failureCount: failure?.count,
|
|
656
1383
|
lastFailureAt: failure?.lastFailedAt,
|
|
657
1384
|
lastFailureStage: failure?.lastStage,
|
|
658
1385
|
lastFailureError: failure?.lastError,
|
|
659
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,
|
|
660
1394
|
...caps,
|
|
661
1395
|
})
|
|
662
1396
|
}
|
|
@@ -670,52 +1404,178 @@ class PluginManager {
|
|
|
670
1404
|
}
|
|
671
1405
|
}
|
|
672
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
|
+
|
|
673
1513
|
setEnabled(filename: string, enabled: boolean) {
|
|
674
|
-
const
|
|
675
|
-
|
|
676
|
-
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
1514
|
+
const current = this.readConfigEntry(filename)
|
|
1515
|
+
this.updateConfigEntry(filename, { ...(current || {}), enabled })
|
|
677
1516
|
if (enabled) this.clearFailureState(filename)
|
|
678
1517
|
this.reload()
|
|
679
1518
|
}
|
|
680
1519
|
|
|
681
1520
|
deletePlugin(filename: string): boolean {
|
|
682
1521
|
// Only allow deleting external plugins, not builtins
|
|
683
|
-
if (this.builtins.has(filename)) return false
|
|
1522
|
+
if (this.builtins.has(this.canonicalPluginId(filename))) return false
|
|
684
1523
|
const fullPath = path.join(PLUGINS_DIR, filename)
|
|
685
1524
|
if (!fs.existsSync(fullPath)) return false
|
|
686
1525
|
fs.unlinkSync(fullPath)
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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)
|
|
691
1534
|
this.clearFailureState(filename)
|
|
692
1535
|
this.reload()
|
|
693
1536
|
return true
|
|
694
1537
|
}
|
|
695
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
|
+
|
|
696
1560
|
async updatePlugin(id: string) {
|
|
697
1561
|
this.load()
|
|
698
1562
|
const p = this.plugins.get(id)
|
|
699
1563
|
if (!p) throw new Error('Plugin not found')
|
|
1564
|
+
if (p.isBuiltin) throw new Error('Built-in plugins are updated via application releases')
|
|
700
1565
|
|
|
701
1566
|
log.info('plugins', 'Updating plugin', { pluginId: id, pluginName: p.meta.name })
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if (!
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
content = content.replace(`version: "${current}"`, `version: "${next}"`)
|
|
715
|
-
fs.writeFileSync(fullPath, content, 'utf8')
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
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
|
+
})
|
|
719
1579
|
|
|
720
1580
|
this.reload()
|
|
721
1581
|
return true
|
|
@@ -723,7 +1583,9 @@ class PluginManager {
|
|
|
723
1583
|
|
|
724
1584
|
async updateAllPlugins() {
|
|
725
1585
|
this.load()
|
|
726
|
-
const ids = Array.from(this.plugins.
|
|
1586
|
+
const ids = Array.from(this.plugins.entries())
|
|
1587
|
+
.filter(([, plugin]) => !plugin.isBuiltin)
|
|
1588
|
+
.map(([id]) => id)
|
|
727
1589
|
for (const id of ids) {
|
|
728
1590
|
try {
|
|
729
1591
|
await this.updatePlugin(id)
|
|
@@ -733,12 +1595,11 @@ class PluginManager {
|
|
|
733
1595
|
}
|
|
734
1596
|
|
|
735
1597
|
setMeta(filename: string, meta: Record<string, unknown>) {
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
1598
|
+
const current = this.readConfigEntry(filename)
|
|
1599
|
+
this.updateConfigEntry(filename, { ...(current || {}), ...(meta as PluginConfigEntry) })
|
|
739
1600
|
}
|
|
740
1601
|
|
|
741
|
-
private loadConfig(): Record<string,
|
|
1602
|
+
private loadConfig(): Record<string, PluginConfigEntry> {
|
|
742
1603
|
try { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8')) } catch { return {} }
|
|
743
1604
|
}
|
|
744
1605
|
|