@swarmclawai/swarmclaw 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -50
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +43 -0
- package/src/app/api/agents/[id]/thread/route.ts +39 -8
- package/src/app/api/agents/route.ts +35 -2
- package/src/app/api/auth/route.ts +77 -8
- package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- 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]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +30 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +23 -1
- 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/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- 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/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +12 -4
- 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/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +55 -17
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +16 -6
- package/src/app/api/tasks/bulk/route.ts +3 -3
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +135 -17
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +38 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +21 -12
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- 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 +456 -23
- package/src/components/agents/inspector-panel.tsx +110 -49
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +70 -27
- package/src/components/chat/chat-card.tsx +6 -21
- package/src/components/chat/chat-header.tsx +263 -366
- package/src/components/chat/chat-list.tsx +62 -26
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +145 -19
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +422 -209
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +385 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- 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 +189 -1
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- 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 +88 -6
- package/src/components/shared/settings/section-orchestrator.tsx +6 -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 +248 -47
- package/src/components/tasks/approvals-panel.tsx +211 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- 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/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +264 -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 +44 -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 +219 -0
- package/src/lib/server/chat-execution.ts +402 -125
- 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 +74 -2
- package/src/lib/server/chatroom-helpers.ts +144 -11
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- 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 +994 -130
- 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 +189 -10
- 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 +62 -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/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +23 -43
- 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 +31 -964
- 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 +6 -5
- package/src/lib/server/openclaw-gateway.ts +123 -36
- 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 +18 -8
- package/src/lib/server/orchestrator.ts +5 -4
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +215 -0
- package/src/lib/server/plugins.ts +832 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +4 -21
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- 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 +128 -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 +96 -34
- 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-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +40 -12
- 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 +98 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +243 -24
- package/src/lib/server/session-tools/http.ts +9 -3
- 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 +87 -2
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +162 -12
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- 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 +95 -25
- 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/sandbox.ts +51 -92
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +58 -4
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +195 -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 +947 -108
- package/src/lib/server/storage.ts +255 -10
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +185 -25
- 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 -11
- package/src/lib/server/tool-aliases.ts +80 -12
- package/src/lib/server/tool-capability-policy.ts +7 -1
- 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/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +62 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +43 -7
- package/src/stores/use-chat-store.ts +31 -2
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +470 -44
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
- 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,212 @@ 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
|
+
const 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
|
+
watcher.on('error', (err: unknown) => {
|
|
519
|
+
log.warn('plugins', 'Plugin watcher disabled after runtime watch failure', {
|
|
520
|
+
error: err instanceof Error ? err.message : String(err),
|
|
521
|
+
})
|
|
522
|
+
if (this.watcher === watcher) {
|
|
523
|
+
try { watcher.close() } catch { /* ignore */ }
|
|
524
|
+
this.watcher = null
|
|
525
|
+
}
|
|
526
|
+
})
|
|
527
|
+
watcher.unref?.()
|
|
528
|
+
this.watcher = watcher
|
|
529
|
+
} catch (err: unknown) {
|
|
530
|
+
log.warn('plugins', 'Failed to watch plugins directory', {
|
|
531
|
+
error: err instanceof Error ? err.message : String(err),
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
private isExternalPluginFilename(id: string): boolean {
|
|
537
|
+
return id.endsWith('.js') || id.endsWith('.mjs')
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private ensurePluginDirs(): void {
|
|
541
|
+
if (!fs.existsSync(PLUGINS_DIR)) fs.mkdirSync(PLUGINS_DIR, { recursive: true })
|
|
542
|
+
if (!fs.existsSync(PLUGIN_WORKSPACES_DIR)) fs.mkdirSync(PLUGIN_WORKSPACES_DIR, { recursive: true })
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private getWorkspaceDir(filename: string): string {
|
|
546
|
+
return path.join(PLUGIN_WORKSPACES_DIR, pluginWorkspaceKey(filename))
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private getWorkspaceEntryPath(filename: string): string {
|
|
550
|
+
return path.join(this.getWorkspaceDir(filename), 'index.js')
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private getWorkspaceManifestPath(filename: string): string {
|
|
554
|
+
return path.join(this.getWorkspaceDir(filename), 'package.json')
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
private hasWorkspace(filename: string): boolean {
|
|
558
|
+
return fs.existsSync(this.getWorkspaceEntryPath(filename))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private readWorkspaceManifest(filename: string): Record<string, unknown> | null {
|
|
562
|
+
const manifestPath = this.getWorkspaceManifestPath(filename)
|
|
563
|
+
try {
|
|
564
|
+
if (!fs.existsSync(manifestPath)) return null
|
|
565
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record<string, unknown>
|
|
566
|
+
} catch {
|
|
567
|
+
return null
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private getDependencyInfo(filename: string, explicitConfig?: PluginConfigEntry | null): PluginDependencyInfo {
|
|
572
|
+
const manifest = this.readWorkspaceManifest(filename)
|
|
573
|
+
const counts = countManifestDependencies(manifest)
|
|
574
|
+
return {
|
|
575
|
+
hasManifest: !!manifest,
|
|
576
|
+
dependencyCount: counts.dependencyCount,
|
|
577
|
+
devDependencyCount: counts.devDependencyCount,
|
|
578
|
+
packageManager:
|
|
579
|
+
normalizePluginPackageManager(explicitConfig?.packageManager)
|
|
580
|
+
|| normalizePluginPackageManager(manifest?.packageManager)
|
|
581
|
+
|| undefined,
|
|
582
|
+
installStatus: explicitConfig?.dependencyInstallStatus || (manifest ? 'ready' : 'none'),
|
|
583
|
+
installError: explicitConfig?.dependencyInstallError,
|
|
584
|
+
installedAt: explicitConfig?.dependencyInstalledAt,
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
private writeWorkspaceShim(filename: string): void {
|
|
589
|
+
const relEntry = `./.workspaces/${pluginWorkspaceKey(filename)}/index.js`
|
|
590
|
+
const shim = `// Auto-generated plugin workspace shim. Edit the managed source file instead.\nmodule.exports = require(${JSON.stringify(relEntry)})\n`
|
|
591
|
+
fs.writeFileSync(path.join(PLUGINS_DIR, filename), shim, 'utf8')
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private clearPluginRequireCache(dynamicRequire: NodeRequire, filename: string): void {
|
|
595
|
+
const rootPath = path.join(PLUGINS_DIR, filename)
|
|
596
|
+
delete dynamicRequire.cache[rootPath]
|
|
597
|
+
const workspaceDir = this.getWorkspaceDir(filename)
|
|
598
|
+
for (const cacheKey of Object.keys(dynamicRequire.cache)) {
|
|
599
|
+
if (cacheKey.startsWith(`${workspaceDir}${path.sep}`)) {
|
|
600
|
+
delete dynamicRequire.cache[cacheKey]
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private resolvePluginSourcePath(filename: string): string {
|
|
606
|
+
return this.hasWorkspace(filename)
|
|
607
|
+
? this.getWorkspaceEntryPath(filename)
|
|
608
|
+
: path.join(PLUGINS_DIR, filename)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private async runDependencyInstall(packageManager: PluginPackageManager, cwd: string): Promise<void> {
|
|
612
|
+
const { command, args } = getInstallCommand(packageManager)
|
|
613
|
+
|
|
614
|
+
await new Promise<void>((resolve, reject) => {
|
|
615
|
+
const child = spawn(command, args, {
|
|
616
|
+
cwd,
|
|
617
|
+
env: { ...process.env },
|
|
618
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
let stderr = ''
|
|
622
|
+
let stdout = ''
|
|
623
|
+
const timer = setTimeout(() => {
|
|
624
|
+
child.kill('SIGTERM')
|
|
625
|
+
reject(new Error(`${command} install timed out after ${Math.round(PACKAGE_INSTALL_TIMEOUT_MS / 1000)}s`))
|
|
626
|
+
}, PACKAGE_INSTALL_TIMEOUT_MS)
|
|
627
|
+
|
|
628
|
+
child.stdout?.on('data', (chunk: Buffer | string) => {
|
|
629
|
+
stdout = trimProcessOutput(`${stdout}${chunk.toString()}`)
|
|
630
|
+
})
|
|
631
|
+
child.stderr?.on('data', (chunk: Buffer | string) => {
|
|
632
|
+
stderr = trimProcessOutput(`${stderr}${chunk.toString()}`)
|
|
633
|
+
})
|
|
634
|
+
child.on('error', (err) => {
|
|
635
|
+
clearTimeout(timer)
|
|
636
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
637
|
+
reject(new Error(`${command} is not installed on this machine`))
|
|
638
|
+
return
|
|
639
|
+
}
|
|
640
|
+
reject(err)
|
|
641
|
+
})
|
|
642
|
+
child.on('close', (code) => {
|
|
643
|
+
clearTimeout(timer)
|
|
644
|
+
if (code === 0) {
|
|
645
|
+
resolve()
|
|
646
|
+
return
|
|
647
|
+
}
|
|
648
|
+
reject(new Error(trimProcessOutput(`${stderr}\n${stdout}`) || `${command} install exited ${code}`))
|
|
649
|
+
})
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private canonicalPluginId(id: string): string {
|
|
654
|
+
const trimmed = typeof id === 'string' ? id.trim() : ''
|
|
655
|
+
if (!trimmed) return ''
|
|
656
|
+
if (this.isExternalPluginFilename(trimmed)) return path.basename(trimmed)
|
|
657
|
+
return canonicalizePluginId(trimmed)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private configIdsFor(id: string): string[] {
|
|
661
|
+
const canonicalId = this.canonicalPluginId(id)
|
|
662
|
+
if (!canonicalId) return []
|
|
663
|
+
if (this.isExternalPluginFilename(canonicalId)) return [canonicalId]
|
|
664
|
+
const aliases = getPluginAliases(canonicalId)
|
|
665
|
+
const ids = new Set<string>([canonicalId, ...aliases])
|
|
666
|
+
return Array.from(ids)
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
private readConfigEntry(id: string, config?: Record<string, PluginConfigEntry>): PluginConfigEntry | null {
|
|
670
|
+
const cfg = config || this.loadConfig()
|
|
671
|
+
let merged: PluginConfigEntry | null = null
|
|
672
|
+
for (const key of this.configIdsFor(id)) {
|
|
673
|
+
const entry = cfg[key]
|
|
674
|
+
if (!entry) continue
|
|
675
|
+
merged = { ...(merged || {}), ...entry }
|
|
676
|
+
if (key === this.canonicalPluginId(id)) break
|
|
677
|
+
}
|
|
678
|
+
return merged
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
private writeConfig(config: Record<string, PluginConfigEntry>): void {
|
|
682
|
+
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
private updateConfigEntry(id: string, patch: PluginConfigEntry | null): void {
|
|
686
|
+
const canonicalId = this.canonicalPluginId(id)
|
|
687
|
+
const config = this.loadConfig()
|
|
688
|
+
for (const key of this.configIdsFor(canonicalId)) {
|
|
689
|
+
if (key !== canonicalId) delete config[key]
|
|
690
|
+
}
|
|
691
|
+
if (patch) {
|
|
692
|
+
config[canonicalId] = { ...(config[canonicalId] || {}), ...patch }
|
|
693
|
+
} else {
|
|
694
|
+
delete config[canonicalId]
|
|
695
|
+
}
|
|
696
|
+
this.writeConfig(config)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private resolveEnabledFilter(enabledIds?: string[], includeAllWhenEmpty = false): Set<string> | null {
|
|
700
|
+
if (!Array.isArray(enabledIds) || enabledIds.length === 0) {
|
|
701
|
+
return includeAllWhenEmpty ? null : new Set<string>()
|
|
702
|
+
}
|
|
703
|
+
return new Set(expandPluginIds(enabledIds))
|
|
704
|
+
}
|
|
705
|
+
|
|
291
706
|
private readFailureState(): Record<string, PluginFailureRecord> {
|
|
292
707
|
try {
|
|
293
708
|
const parsed = JSON.parse(fs.readFileSync(PLUGIN_FAILURES, 'utf8')) as Record<string, PluginFailureRecord>
|
|
@@ -308,17 +723,21 @@ class PluginManager {
|
|
|
308
723
|
|
|
309
724
|
private clearFailureState(id: string): void {
|
|
310
725
|
const state = this.readFailureState()
|
|
311
|
-
|
|
312
|
-
|
|
726
|
+
let changed = false
|
|
727
|
+
for (const key of this.configIdsFor(id)) {
|
|
728
|
+
if (!state[key]) continue
|
|
729
|
+
delete state[key]
|
|
730
|
+
changed = true
|
|
731
|
+
}
|
|
732
|
+
if (!changed) return
|
|
313
733
|
this.writeFailureState(state)
|
|
314
734
|
}
|
|
315
735
|
|
|
316
736
|
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
737
|
try {
|
|
321
|
-
|
|
738
|
+
const current = this.readConfigEntry(id)
|
|
739
|
+
if (current?.enabled === false) return
|
|
740
|
+
this.updateConfigEntry(id, { ...(current || {}), enabled: false })
|
|
322
741
|
} catch (err: unknown) {
|
|
323
742
|
log.error('plugins', 'Failed to write plugins config while auto-disabling plugin', {
|
|
324
743
|
pluginId: id,
|
|
@@ -353,14 +772,15 @@ class PluginManager {
|
|
|
353
772
|
private markPluginFailure(id: string, stage: string, err: unknown, disableEligible: boolean): void {
|
|
354
773
|
const errorText = err instanceof Error ? err.message : String(err)
|
|
355
774
|
const state = this.readFailureState()
|
|
356
|
-
const
|
|
775
|
+
const failureKey = this.canonicalPluginId(id)
|
|
776
|
+
const nextCount = (state[failureKey]?.count || 0) + 1
|
|
357
777
|
const record: PluginFailureRecord = {
|
|
358
778
|
count: nextCount,
|
|
359
779
|
lastError: errorText,
|
|
360
780
|
lastStage: stage,
|
|
361
781
|
lastFailedAt: Date.now(),
|
|
362
782
|
}
|
|
363
|
-
state[
|
|
783
|
+
state[failureKey] = record
|
|
364
784
|
this.writeFailureState(state)
|
|
365
785
|
|
|
366
786
|
log.warn('plugins', 'Plugin failure recorded', {
|
|
@@ -371,8 +791,12 @@ class PluginManager {
|
|
|
371
791
|
error: errorText,
|
|
372
792
|
})
|
|
373
793
|
|
|
374
|
-
if (
|
|
375
|
-
|
|
794
|
+
if (
|
|
795
|
+
disableEligible
|
|
796
|
+
&& nextCount >= MAX_CONSECUTIVE_PLUGIN_FAILURES
|
|
797
|
+
&& !this.builtins.has(failureKey)
|
|
798
|
+
) {
|
|
799
|
+
this.autoDisableExternalPlugin(failureKey, `Plugin failure at ${stage}`, record)
|
|
376
800
|
}
|
|
377
801
|
}
|
|
378
802
|
|
|
@@ -387,17 +811,27 @@ class PluginManager {
|
|
|
387
811
|
load() {
|
|
388
812
|
if (this.loaded) return
|
|
389
813
|
this.plugins.clear()
|
|
814
|
+
this.ensurePluginWatcher()
|
|
390
815
|
|
|
391
816
|
const config = this.loadConfig()
|
|
392
817
|
|
|
393
818
|
// 1. Load Built-ins
|
|
394
819
|
for (const [id, p] of this.builtins.entries()) {
|
|
395
|
-
const explicitConfig = config
|
|
820
|
+
const explicitConfig = this.readConfigEntry(id, config)
|
|
396
821
|
const isEnabled = explicitConfig != null ? explicitConfig.enabled !== false : p.enabledByDefault !== false
|
|
397
822
|
if (isEnabled) {
|
|
398
823
|
this.plugins.set(id, {
|
|
399
824
|
id,
|
|
400
|
-
meta: {
|
|
825
|
+
meta: {
|
|
826
|
+
name: p.name,
|
|
827
|
+
description: p.description || '',
|
|
828
|
+
filename: id,
|
|
829
|
+
enabled: true,
|
|
830
|
+
author: p.author || 'SwarmClaw',
|
|
831
|
+
version: p.version || '1.0.0',
|
|
832
|
+
source: 'local',
|
|
833
|
+
openclaw: p.openclaw === true,
|
|
834
|
+
},
|
|
401
835
|
hooks: p.hooks || {},
|
|
402
836
|
tools: p.tools || [],
|
|
403
837
|
ui: p.ui,
|
|
@@ -411,7 +845,7 @@ class PluginManager {
|
|
|
411
845
|
|
|
412
846
|
// 2. Load External
|
|
413
847
|
try {
|
|
414
|
-
|
|
848
|
+
this.ensurePluginDirs()
|
|
415
849
|
const files = fs.readdirSync(PLUGINS_DIR).filter(f => f.endsWith('.js') || f.endsWith('.mjs'))
|
|
416
850
|
|
|
417
851
|
let dynamicRequire: NodeRequire | null = null
|
|
@@ -426,11 +860,12 @@ class PluginManager {
|
|
|
426
860
|
if (dynamicRequire) {
|
|
427
861
|
for (const file of files) {
|
|
428
862
|
try {
|
|
429
|
-
const
|
|
863
|
+
const explicitConfig = this.readConfigEntry(file, config)
|
|
864
|
+
const isEnabled = explicitConfig?.enabled !== false
|
|
430
865
|
if (!isEnabled) continue
|
|
431
866
|
|
|
432
867
|
const fullPath = path.join(PLUGINS_DIR, file)
|
|
433
|
-
|
|
868
|
+
this.clearPluginRequireCache(dynamicRequire, file)
|
|
434
869
|
const plugin = normalizePlugin(dynamicRequire(fullPath))
|
|
435
870
|
if (!plugin) {
|
|
436
871
|
this.markPluginFailure(file, 'load.normalize', 'Plugin format unsupported or activate() failed', true)
|
|
@@ -439,7 +874,16 @@ class PluginManager {
|
|
|
439
874
|
|
|
440
875
|
this.plugins.set(file, {
|
|
441
876
|
id: file,
|
|
442
|
-
meta: {
|
|
877
|
+
meta: {
|
|
878
|
+
name: plugin.name,
|
|
879
|
+
description: plugin.description || '',
|
|
880
|
+
filename: file,
|
|
881
|
+
enabled: true,
|
|
882
|
+
author: plugin.author,
|
|
883
|
+
version: plugin.version || '0.0.1',
|
|
884
|
+
source: explicitConfig?.sourceUrl ? 'marketplace' : 'local',
|
|
885
|
+
openclaw: plugin.openclaw === true,
|
|
886
|
+
},
|
|
443
887
|
hooks: plugin.hooks || {},
|
|
444
888
|
tools: plugin.tools || [],
|
|
445
889
|
ui: plugin.ui,
|
|
@@ -464,7 +908,7 @@ class PluginManager {
|
|
|
464
908
|
getTools(enabledIds: string[]): Array<{ pluginId: string; tool: PluginToolDef }> {
|
|
465
909
|
this.load()
|
|
466
910
|
const all: Array<{ pluginId: string; tool: PluginToolDef }> = []
|
|
467
|
-
const ids = new Set(enabledIds)
|
|
911
|
+
const ids = new Set(expandPluginIds(enabledIds))
|
|
468
912
|
for (const [id, p] of this.plugins.entries()) {
|
|
469
913
|
if (ids.has(id)) {
|
|
470
914
|
const tools = Array.isArray(p.tools) ? p.tools : []
|
|
@@ -526,13 +970,17 @@ class PluginManager {
|
|
|
526
970
|
return allUI
|
|
527
971
|
}
|
|
528
972
|
|
|
529
|
-
|
|
973
|
+
listPluginIds(): string[] {
|
|
530
974
|
this.load()
|
|
531
|
-
|
|
532
|
-
|
|
975
|
+
return Array.from(this.plugins.keys())
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async runHook<K extends keyof PluginHooks>(hookName: K, ctx: HookContext<K>, options?: HookExecutionOptions) {
|
|
979
|
+
this.load()
|
|
980
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
533
981
|
|
|
534
982
|
for (const [id, p] of this.plugins.entries()) {
|
|
535
|
-
if (filterIds && !filterIds.has(id)) continue
|
|
983
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
536
984
|
const hook = p.hooks[hookName]
|
|
537
985
|
if (hook) {
|
|
538
986
|
try {
|
|
@@ -551,22 +999,54 @@ class PluginManager {
|
|
|
551
999
|
}
|
|
552
1000
|
}
|
|
553
1001
|
|
|
1002
|
+
async runBeforeToolExec(
|
|
1003
|
+
params: { toolName: string; input: Record<string, unknown> | null },
|
|
1004
|
+
options?: HookExecutionOptions,
|
|
1005
|
+
): Promise<Record<string, unknown> | null> {
|
|
1006
|
+
this.load()
|
|
1007
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
1008
|
+
let currentInput = params.input
|
|
1009
|
+
|
|
1010
|
+
for (const [id, p] of this.plugins.entries()) {
|
|
1011
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
1012
|
+
const hook = p.hooks.beforeToolExec
|
|
1013
|
+
if (!hook) continue
|
|
1014
|
+
try {
|
|
1015
|
+
const result = await hook({ toolName: params.toolName, input: currentInput })
|
|
1016
|
+
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
|
1017
|
+
currentInput = result as Record<string, unknown>
|
|
1018
|
+
}
|
|
1019
|
+
this.markPluginSuccess(id)
|
|
1020
|
+
} catch (err: unknown) {
|
|
1021
|
+
log.error('plugins', 'beforeToolExec hook failed', {
|
|
1022
|
+
pluginId: id,
|
|
1023
|
+
pluginName: p.meta.name,
|
|
1024
|
+
toolName: params.toolName,
|
|
1025
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1026
|
+
})
|
|
1027
|
+
this.markPluginFailure(id, 'hook.beforeToolExec', err, true)
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
return currentInput
|
|
1032
|
+
}
|
|
1033
|
+
|
|
554
1034
|
async transformText(
|
|
555
1035
|
hookName: 'transformInboundMessage' | 'transformOutboundMessage',
|
|
556
1036
|
params: { session: Session; text: string },
|
|
557
|
-
|
|
1037
|
+
options?: HookExecutionOptions,
|
|
558
1038
|
): Promise<string> {
|
|
559
1039
|
this.load()
|
|
560
|
-
const filterIds = enabledIds
|
|
1040
|
+
const filterIds = this.resolveEnabledFilter(options?.enabledIds, options?.includeAllWhenEmpty === true)
|
|
561
1041
|
let currentText = params.text
|
|
562
1042
|
|
|
563
1043
|
for (const [id, p] of this.plugins.entries()) {
|
|
564
|
-
if (filterIds && !filterIds.has(id)) continue
|
|
1044
|
+
if (filterIds !== null && !filterIds.has(id)) continue
|
|
565
1045
|
const hook = p.hooks[hookName]
|
|
566
1046
|
if (hook) {
|
|
567
1047
|
try {
|
|
568
|
-
const result = await (hook as (ctx: typeof params) => Promise<string> | string)(params)
|
|
569
|
-
currentText = result
|
|
1048
|
+
const result = await (hook as (ctx: typeof params) => Promise<string> | string)({ ...params, text: currentText })
|
|
1049
|
+
if (typeof result === 'string') currentText = result
|
|
570
1050
|
this.markPluginSuccess(id)
|
|
571
1051
|
} catch (err: unknown) {
|
|
572
1052
|
log.error('plugins', 'Plugin transform hook failed', {
|
|
@@ -584,7 +1064,7 @@ class PluginManager {
|
|
|
584
1064
|
|
|
585
1065
|
async collectAgentContext(session: import('@/types').Session, enabledPlugins: string[], message: string, history: import('@/types').Message[]): Promise<string[]> {
|
|
586
1066
|
this.load()
|
|
587
|
-
const enabledSet = new Set(enabledPlugins)
|
|
1067
|
+
const enabledSet = new Set(expandPluginIds(enabledPlugins))
|
|
588
1068
|
const parts: string[] = []
|
|
589
1069
|
|
|
590
1070
|
for (const [id, p] of this.plugins.entries()) {
|
|
@@ -678,6 +1158,150 @@ class PluginManager {
|
|
|
678
1158
|
return result
|
|
679
1159
|
}
|
|
680
1160
|
|
|
1161
|
+
getSettingsFields(pluginId: string): import('@/types').PluginSettingsField[] {
|
|
1162
|
+
this.load()
|
|
1163
|
+
const candidateIds = expandPluginIds([pluginId])
|
|
1164
|
+
for (const id of candidateIds) {
|
|
1165
|
+
const plugin = this.plugins.get(id) || (this.builtins.has(id) ? {
|
|
1166
|
+
ui: this.builtins.get(id)?.ui,
|
|
1167
|
+
} as LoadedPlugin : null)
|
|
1168
|
+
const fields = plugin?.ui?.settingsFields
|
|
1169
|
+
if (fields?.length) return fields
|
|
1170
|
+
}
|
|
1171
|
+
return []
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
getPluginSettings(pluginId: string): Record<string, unknown> {
|
|
1175
|
+
const settings = loadSettings()
|
|
1176
|
+
const allSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
|
|
1177
|
+
const result: Record<string, unknown> = {}
|
|
1178
|
+
|
|
1179
|
+
for (const key of this.configIdsFor(pluginId)) {
|
|
1180
|
+
const values = allSettings[key]
|
|
1181
|
+
if (!values || typeof values !== 'object') continue
|
|
1182
|
+
for (const [fieldKey, fieldValue] of Object.entries(values)) {
|
|
1183
|
+
if (isPluginSecretSettingValue(fieldValue)) {
|
|
1184
|
+
try {
|
|
1185
|
+
result[fieldKey] = decryptKey(fieldValue.encrypted)
|
|
1186
|
+
} catch {
|
|
1187
|
+
result[fieldKey] = ''
|
|
1188
|
+
}
|
|
1189
|
+
continue
|
|
1190
|
+
}
|
|
1191
|
+
result[fieldKey] = fieldValue
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
for (const field of this.getSettingsFields(pluginId)) {
|
|
1196
|
+
if (result[field.key] === undefined && field.defaultValue !== undefined) {
|
|
1197
|
+
result[field.key] = field.defaultValue
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return result
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
getPublicPluginSettings(pluginId: string): { values: Record<string, unknown>; configuredSecretFields: string[] } {
|
|
1205
|
+
const values = this.getPluginSettings(pluginId)
|
|
1206
|
+
const configuredSecretFields: string[] = []
|
|
1207
|
+
|
|
1208
|
+
for (const field of this.getSettingsFields(pluginId)) {
|
|
1209
|
+
if (field.type !== 'secret') continue
|
|
1210
|
+
const current = values[field.key]
|
|
1211
|
+
if (typeof current === 'string' && current.trim()) {
|
|
1212
|
+
configuredSecretFields.push(field.key)
|
|
1213
|
+
}
|
|
1214
|
+
values[field.key] = ''
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return { values, configuredSecretFields }
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
setPluginSettings(pluginId: string, values: Record<string, unknown>): Record<string, unknown> {
|
|
1221
|
+
const fields = this.getSettingsFields(pluginId)
|
|
1222
|
+
if (fields.length === 0 && Object.keys(values || {}).length > 0) {
|
|
1223
|
+
throw new Error(`Plugin "${pluginId}" does not declare configurable settings`)
|
|
1224
|
+
}
|
|
1225
|
+
const fieldMap = new Map(fields.map((field) => [field.key, field]))
|
|
1226
|
+
const nextValues: Record<string, unknown> = {}
|
|
1227
|
+
|
|
1228
|
+
for (const [key, rawValue] of Object.entries(values || {})) {
|
|
1229
|
+
const field = fieldMap.get(key)
|
|
1230
|
+
if (!field) continue
|
|
1231
|
+
if (rawValue === undefined) continue
|
|
1232
|
+
if (field.type === 'boolean') {
|
|
1233
|
+
nextValues[key] = rawValue === true || rawValue === 'true' || rawValue === 1 || rawValue === '1'
|
|
1234
|
+
continue
|
|
1235
|
+
}
|
|
1236
|
+
if (field.type === 'number') {
|
|
1237
|
+
const parsed = typeof rawValue === 'number' ? rawValue : Number(rawValue)
|
|
1238
|
+
if (!Number.isFinite(parsed)) throw new Error(`Invalid number for setting "${key}"`)
|
|
1239
|
+
nextValues[key] = parsed
|
|
1240
|
+
continue
|
|
1241
|
+
}
|
|
1242
|
+
const text = typeof rawValue === 'string' ? rawValue : String(rawValue ?? '')
|
|
1243
|
+
if (field.required && !text.trim()) throw new Error(`Setting "${key}" is required`)
|
|
1244
|
+
if (field.type === 'select' && field.options?.length) {
|
|
1245
|
+
const allowed = new Set(field.options.map((option) => option.value))
|
|
1246
|
+
if (!allowed.has(text)) throw new Error(`Invalid value for setting "${key}"`)
|
|
1247
|
+
}
|
|
1248
|
+
if (field.type === 'secret') {
|
|
1249
|
+
nextValues[key] = text.trim()
|
|
1250
|
+
} else {
|
|
1251
|
+
nextValues[key] = text
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const currentSettings = loadSettings()
|
|
1256
|
+
const pluginSettings = (currentSettings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
|
|
1257
|
+
const canonicalId = this.canonicalPluginId(pluginId)
|
|
1258
|
+
const existingStored: Record<string, unknown> = {}
|
|
1259
|
+
for (const alias of this.configIdsFor(canonicalId)) {
|
|
1260
|
+
const existing = pluginSettings[alias]
|
|
1261
|
+
if (!existing || typeof existing !== 'object') continue
|
|
1262
|
+
Object.assign(existingStored, existing)
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
for (const field of fields) {
|
|
1266
|
+
if (!field.required) continue
|
|
1267
|
+
if (
|
|
1268
|
+
nextValues[field.key] === undefined
|
|
1269
|
+
&& existingStored[field.key] === undefined
|
|
1270
|
+
&& field.defaultValue === undefined
|
|
1271
|
+
) {
|
|
1272
|
+
throw new Error(`Setting "${field.key}" is required`)
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const stored: Record<string, unknown> = {}
|
|
1277
|
+
|
|
1278
|
+
for (const field of fields) {
|
|
1279
|
+
if (nextValues[field.key] === undefined) {
|
|
1280
|
+
if (existingStored[field.key] !== undefined) {
|
|
1281
|
+
stored[field.key] = existingStored[field.key]
|
|
1282
|
+
}
|
|
1283
|
+
continue
|
|
1284
|
+
}
|
|
1285
|
+
if (field.type === 'secret') {
|
|
1286
|
+
stored[field.key] = {
|
|
1287
|
+
__pluginSecret: true,
|
|
1288
|
+
encrypted: encryptKey(String(nextValues[field.key] ?? '')),
|
|
1289
|
+
} satisfies PluginSecretSettingValue
|
|
1290
|
+
} else {
|
|
1291
|
+
stored[field.key] = nextValues[field.key]
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
for (const alias of this.configIdsFor(canonicalId)) {
|
|
1296
|
+
delete pluginSettings[alias]
|
|
1297
|
+
}
|
|
1298
|
+
pluginSettings[canonicalId] = stored
|
|
1299
|
+
currentSettings.pluginSettings = pluginSettings
|
|
1300
|
+
saveSettings(currentSettings)
|
|
1301
|
+
|
|
1302
|
+
return this.getPublicPluginSettings(canonicalId).values
|
|
1303
|
+
}
|
|
1304
|
+
|
|
681
1305
|
recordExternalToolFailure(pluginId: string, toolName: string, err: unknown): void {
|
|
682
1306
|
this.markPluginFailure(pluginId, `tool.${toolName}`, err, true)
|
|
683
1307
|
}
|
|
@@ -687,10 +1311,9 @@ class PluginManager {
|
|
|
687
1311
|
}
|
|
688
1312
|
|
|
689
1313
|
isEnabled(filename: string): boolean {
|
|
690
|
-
const
|
|
691
|
-
const explicit = config[filename]
|
|
1314
|
+
const explicit = this.readConfigEntry(filename)
|
|
692
1315
|
if (explicit != null) return explicit.enabled !== false
|
|
693
|
-
const builtin = this.builtins.get(filename)
|
|
1316
|
+
const builtin = this.builtins.get(this.canonicalPluginId(filename))
|
|
694
1317
|
if (builtin) return builtin.enabledByDefault !== false
|
|
695
1318
|
return true
|
|
696
1319
|
}
|
|
@@ -722,18 +1345,20 @@ class PluginManager {
|
|
|
722
1345
|
// Add all builtins
|
|
723
1346
|
for (const [id, p] of this.builtins.entries()) {
|
|
724
1347
|
const loaded = this.plugins.get(id)
|
|
725
|
-
const explicitCfg = config
|
|
1348
|
+
const explicitCfg = this.readConfigEntry(id, config)
|
|
726
1349
|
const enabled = explicitCfg != null ? explicitCfg.enabled !== false : p.enabledByDefault !== false
|
|
727
|
-
const failure = failures[id]
|
|
1350
|
+
const failure = failures[this.canonicalPluginId(id)]
|
|
728
1351
|
const caps = describeCapabilities(loaded, p)
|
|
729
1352
|
metas.push({
|
|
730
1353
|
name: p.name,
|
|
731
1354
|
description: p.description || '',
|
|
732
1355
|
filename: id,
|
|
733
1356
|
enabled,
|
|
734
|
-
|
|
1357
|
+
isBuiltin: true,
|
|
1358
|
+
author: p.author || 'SwarmClaw',
|
|
735
1359
|
version: (p as { version?: string }).version || loaded?.meta.version || '1.0.0',
|
|
736
1360
|
source: loaded?.meta.source || 'local',
|
|
1361
|
+
openclaw: p.openclaw === true,
|
|
737
1362
|
failureCount: failure?.count,
|
|
738
1363
|
lastFailureAt: failure?.lastFailedAt,
|
|
739
1364
|
lastFailureStage: failure?.lastStage,
|
|
@@ -749,22 +1374,33 @@ class PluginManager {
|
|
|
749
1374
|
for (const f of files) {
|
|
750
1375
|
if (!metas.find(m => m.filename === f)) {
|
|
751
1376
|
const loaded = this.plugins.get(f)
|
|
752
|
-
const
|
|
1377
|
+
const explicitCfg = this.readConfigEntry(f, config)
|
|
1378
|
+
const enabled = explicitCfg?.enabled !== false
|
|
753
1379
|
const failure = failures[f]
|
|
754
1380
|
const caps = describeCapabilities(loaded)
|
|
1381
|
+
const dependencyInfo = this.getDependencyInfo(f, explicitCfg)
|
|
755
1382
|
metas.push({
|
|
756
1383
|
name: loaded?.meta.name || f.replace(/\.(js|mjs)$/, ''),
|
|
757
1384
|
filename: f,
|
|
758
1385
|
enabled,
|
|
1386
|
+
isBuiltin: false,
|
|
759
1387
|
author: loaded?.meta.author,
|
|
760
1388
|
version: loaded?.meta.version || '0.0.1',
|
|
761
|
-
source: loaded?.meta.source || 'marketplace',
|
|
762
|
-
|
|
1389
|
+
source: loaded?.meta.source || (explicitCfg?.sourceUrl ? 'marketplace' : 'local'),
|
|
1390
|
+
openclaw: loaded?.meta.openclaw,
|
|
1391
|
+
createdByAgentId: explicitCfg?.createdByAgentId || null,
|
|
763
1392
|
failureCount: failure?.count,
|
|
764
1393
|
lastFailureAt: failure?.lastFailedAt,
|
|
765
1394
|
lastFailureStage: failure?.lastStage,
|
|
766
1395
|
lastFailureError: failure?.lastError,
|
|
767
1396
|
autoDisabled: !enabled && !!failure && failure.count >= MAX_CONSECUTIVE_PLUGIN_FAILURES,
|
|
1397
|
+
hasDependencyManifest: dependencyInfo.hasManifest,
|
|
1398
|
+
dependencyCount: dependencyInfo.dependencyCount,
|
|
1399
|
+
devDependencyCount: dependencyInfo.devDependencyCount,
|
|
1400
|
+
packageManager: dependencyInfo.packageManager,
|
|
1401
|
+
dependencyInstallStatus: dependencyInfo.installStatus,
|
|
1402
|
+
dependencyInstallError: dependencyInfo.installError,
|
|
1403
|
+
dependencyInstalledAt: dependencyInfo.installedAt,
|
|
768
1404
|
...caps,
|
|
769
1405
|
})
|
|
770
1406
|
}
|
|
@@ -778,52 +1414,178 @@ class PluginManager {
|
|
|
778
1414
|
}
|
|
779
1415
|
}
|
|
780
1416
|
|
|
1417
|
+
readPluginSource(filename: string): string {
|
|
1418
|
+
const fullPath = this.resolvePluginSourcePath(filename)
|
|
1419
|
+
if (!fs.existsSync(fullPath)) throw new Error(`Plugin not found: ${filename}`)
|
|
1420
|
+
return fs.readFileSync(fullPath, 'utf8')
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
async savePluginSource(filename: string, code: string, options?: UpsertPluginOptions): Promise<void> {
|
|
1424
|
+
const sanitizedFilename = sanitizePluginFilename(filename)
|
|
1425
|
+
this.ensurePluginDirs()
|
|
1426
|
+
|
|
1427
|
+
const shouldUseWorkspace = this.hasWorkspace(sanitizedFilename) || options?.packageJson !== undefined
|
|
1428
|
+
const sourcePath = shouldUseWorkspace
|
|
1429
|
+
? this.getWorkspaceEntryPath(sanitizedFilename)
|
|
1430
|
+
: path.join(PLUGINS_DIR, sanitizedFilename)
|
|
1431
|
+
|
|
1432
|
+
if (shouldUseWorkspace) {
|
|
1433
|
+
fs.mkdirSync(this.getWorkspaceDir(sanitizedFilename), { recursive: true })
|
|
1434
|
+
fs.writeFileSync(sourcePath, code, 'utf8')
|
|
1435
|
+
this.writeWorkspaceShim(sanitizedFilename)
|
|
1436
|
+
} else {
|
|
1437
|
+
fs.writeFileSync(sourcePath, code, 'utf8')
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
const normalizedPackageManager = normalizePluginPackageManager(options?.packageManager)
|
|
1441
|
+
|
|
1442
|
+
if (options?.packageJson !== undefined) {
|
|
1443
|
+
if (!shouldUseWorkspace) {
|
|
1444
|
+
throw new Error('Plugin workspace is required for package.json support')
|
|
1445
|
+
}
|
|
1446
|
+
const manifest = normalizePluginManifest(options.packageJson, sanitizedFilename, normalizedPackageManager)
|
|
1447
|
+
fs.writeFileSync(this.getWorkspaceManifestPath(sanitizedFilename), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
|
1448
|
+
this.setMeta(sanitizedFilename, {
|
|
1449
|
+
...(options?.meta || {}),
|
|
1450
|
+
packageManager: normalizedPackageManager || normalizePluginPackageManager(manifest.packageManager) || undefined,
|
|
1451
|
+
dependencyInstallStatus: 'ready',
|
|
1452
|
+
dependencyInstallError: undefined,
|
|
1453
|
+
dependencyInstalledAt: undefined,
|
|
1454
|
+
})
|
|
1455
|
+
} else if (options?.meta && Object.keys(options.meta).length > 0) {
|
|
1456
|
+
this.setMeta(sanitizedFilename, options.meta)
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (options?.installDependencies) {
|
|
1460
|
+
await this.installPluginDependencies(sanitizedFilename, {
|
|
1461
|
+
packageManager: normalizedPackageManager || undefined,
|
|
1462
|
+
})
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
this.reload()
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async installPluginDependencies(filename: string, options?: { packageManager?: PluginPackageManager }): Promise<PluginDependencyInfo> {
|
|
1469
|
+
const sanitizedFilename = sanitizePluginFilename(filename)
|
|
1470
|
+
const fullPath = path.join(PLUGINS_DIR, sanitizedFilename)
|
|
1471
|
+
if (!fs.existsSync(fullPath) && !this.hasWorkspace(sanitizedFilename)) {
|
|
1472
|
+
throw new Error(`Plugin not found: ${sanitizedFilename}`)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
this.ensurePluginDirs()
|
|
1476
|
+
const workspaceDir = this.getWorkspaceDir(sanitizedFilename)
|
|
1477
|
+
const sourcePath = this.resolvePluginSourcePath(sanitizedFilename)
|
|
1478
|
+
const currentCode = fs.existsSync(sourcePath) ? fs.readFileSync(sourcePath, 'utf8') : ''
|
|
1479
|
+
|
|
1480
|
+
if (!this.hasWorkspace(sanitizedFilename)) {
|
|
1481
|
+
fs.mkdirSync(workspaceDir, { recursive: true })
|
|
1482
|
+
fs.writeFileSync(this.getWorkspaceEntryPath(sanitizedFilename), currentCode, 'utf8')
|
|
1483
|
+
this.writeWorkspaceShim(sanitizedFilename)
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const manifest = this.readWorkspaceManifest(sanitizedFilename)
|
|
1487
|
+
if (!manifest) throw new Error(`Plugin "${sanitizedFilename}" does not have a package.json manifest`)
|
|
1488
|
+
|
|
1489
|
+
const packageManager = options?.packageManager
|
|
1490
|
+
|| normalizePluginPackageManager(this.readConfigEntry(sanitizedFilename)?.packageManager)
|
|
1491
|
+
|| normalizePluginPackageManager(manifest.packageManager)
|
|
1492
|
+
|| 'npm'
|
|
1493
|
+
|
|
1494
|
+
this.setMeta(sanitizedFilename, {
|
|
1495
|
+
packageManager,
|
|
1496
|
+
dependencyInstallStatus: 'installing',
|
|
1497
|
+
dependencyInstallError: undefined,
|
|
1498
|
+
})
|
|
1499
|
+
|
|
1500
|
+
try {
|
|
1501
|
+
await this.runDependencyInstall(packageManager, workspaceDir)
|
|
1502
|
+
this.setMeta(sanitizedFilename, {
|
|
1503
|
+
packageManager,
|
|
1504
|
+
dependencyInstallStatus: 'installed',
|
|
1505
|
+
dependencyInstallError: undefined,
|
|
1506
|
+
dependencyInstalledAt: Date.now(),
|
|
1507
|
+
})
|
|
1508
|
+
} catch (err: unknown) {
|
|
1509
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1510
|
+
this.setMeta(sanitizedFilename, {
|
|
1511
|
+
packageManager,
|
|
1512
|
+
dependencyInstallStatus: 'error',
|
|
1513
|
+
dependencyInstallError: message,
|
|
1514
|
+
})
|
|
1515
|
+
throw new Error(message)
|
|
1516
|
+
} finally {
|
|
1517
|
+
this.reload()
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
return this.getDependencyInfo(sanitizedFilename, this.readConfigEntry(sanitizedFilename))
|
|
1521
|
+
}
|
|
1522
|
+
|
|
781
1523
|
setEnabled(filename: string, enabled: boolean) {
|
|
782
|
-
const
|
|
783
|
-
|
|
784
|
-
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
1524
|
+
const current = this.readConfigEntry(filename)
|
|
1525
|
+
this.updateConfigEntry(filename, { ...(current || {}), enabled })
|
|
785
1526
|
if (enabled) this.clearFailureState(filename)
|
|
786
1527
|
this.reload()
|
|
787
1528
|
}
|
|
788
1529
|
|
|
789
1530
|
deletePlugin(filename: string): boolean {
|
|
790
1531
|
// Only allow deleting external plugins, not builtins
|
|
791
|
-
if (this.builtins.has(filename)) return false
|
|
1532
|
+
if (this.builtins.has(this.canonicalPluginId(filename))) return false
|
|
792
1533
|
const fullPath = path.join(PLUGINS_DIR, filename)
|
|
793
1534
|
if (!fs.existsSync(fullPath)) return false
|
|
794
1535
|
fs.unlinkSync(fullPath)
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1536
|
+
const workspaceDir = this.getWorkspaceDir(filename)
|
|
1537
|
+
if (fs.existsSync(workspaceDir)) fs.rmSync(workspaceDir, { recursive: true, force: true })
|
|
1538
|
+
this.updateConfigEntry(filename, null)
|
|
1539
|
+
const settings = loadSettings()
|
|
1540
|
+
const pluginSettings = (settings.pluginSettings as Record<string, Record<string, unknown>> | undefined) ?? {}
|
|
1541
|
+
for (const key of this.configIdsFor(filename)) delete pluginSettings[key]
|
|
1542
|
+
settings.pluginSettings = pluginSettings
|
|
1543
|
+
saveSettings(settings)
|
|
799
1544
|
this.clearFailureState(filename)
|
|
800
1545
|
this.reload()
|
|
801
1546
|
return true
|
|
802
1547
|
}
|
|
803
1548
|
|
|
1549
|
+
async installPluginFromUrl(url: string, filename: string, meta?: Record<string, unknown>): Promise<InstalledPluginSource> {
|
|
1550
|
+
const sanitizedFilename = sanitizePluginFilename(filename)
|
|
1551
|
+
const download = await downloadPluginSource(url)
|
|
1552
|
+
await this.savePluginSource(sanitizedFilename, download.code, {
|
|
1553
|
+
meta: {
|
|
1554
|
+
...(meta || {}),
|
|
1555
|
+
sourceUrl: download.normalizedUrl,
|
|
1556
|
+
sourceHash: download.hash,
|
|
1557
|
+
installedAt: Date.now(),
|
|
1558
|
+
updatedAt: Date.now(),
|
|
1559
|
+
},
|
|
1560
|
+
})
|
|
1561
|
+
|
|
1562
|
+
return {
|
|
1563
|
+
filename: sanitizedFilename,
|
|
1564
|
+
sourceUrl: download.normalizedUrl,
|
|
1565
|
+
sourceHash: download.hash,
|
|
1566
|
+
contentType: download.contentType,
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
804
1570
|
async updatePlugin(id: string) {
|
|
805
1571
|
this.load()
|
|
806
1572
|
const p = this.plugins.get(id)
|
|
807
1573
|
if (!p) throw new Error('Plugin not found')
|
|
1574
|
+
if (p.isBuiltin) throw new Error('Built-in plugins are updated via application releases')
|
|
808
1575
|
|
|
809
1576
|
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
|
-
}
|
|
1577
|
+
const current = this.readConfigEntry(id)
|
|
1578
|
+
const sourceUrl = current?.sourceUrl?.trim()
|
|
1579
|
+
if (!sourceUrl) throw new Error(`Plugin "${id}" has no recorded source URL and cannot be updated automatically`)
|
|
1580
|
+
|
|
1581
|
+
const download = await downloadPluginSource(sourceUrl)
|
|
1582
|
+
const fullPath = path.join(PLUGINS_DIR, id)
|
|
1583
|
+
fs.writeFileSync(fullPath, download.code, 'utf8')
|
|
1584
|
+
this.setMeta(id, {
|
|
1585
|
+
sourceUrl: download.normalizedUrl,
|
|
1586
|
+
sourceHash: download.hash,
|
|
1587
|
+
updatedAt: Date.now(),
|
|
1588
|
+
})
|
|
827
1589
|
|
|
828
1590
|
this.reload()
|
|
829
1591
|
return true
|
|
@@ -831,7 +1593,9 @@ class PluginManager {
|
|
|
831
1593
|
|
|
832
1594
|
async updateAllPlugins() {
|
|
833
1595
|
this.load()
|
|
834
|
-
const ids = Array.from(this.plugins.
|
|
1596
|
+
const ids = Array.from(this.plugins.entries())
|
|
1597
|
+
.filter(([, plugin]) => !plugin.isBuiltin)
|
|
1598
|
+
.map(([id]) => id)
|
|
835
1599
|
for (const id of ids) {
|
|
836
1600
|
try {
|
|
837
1601
|
await this.updatePlugin(id)
|
|
@@ -841,12 +1605,11 @@ class PluginManager {
|
|
|
841
1605
|
}
|
|
842
1606
|
|
|
843
1607
|
setMeta(filename: string, meta: Record<string, unknown>) {
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(config, null, 2))
|
|
1608
|
+
const current = this.readConfigEntry(filename)
|
|
1609
|
+
this.updateConfigEntry(filename, { ...(current || {}), ...(meta as PluginConfigEntry) })
|
|
847
1610
|
}
|
|
848
1611
|
|
|
849
|
-
private loadConfig(): Record<string,
|
|
1612
|
+
private loadConfig(): Record<string, PluginConfigEntry> {
|
|
850
1613
|
try { return JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8')) } catch { return {} }
|
|
851
1614
|
}
|
|
852
1615
|
|