@tokagent/tokagentos 2.0.24 → 2.0.30
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/package.json +1 -1
- package/scaffold-patches/packages/agent/src/api/plugin-routes.ts +1889 -0
- package/scaffold-patches/packages/agent/src/api/server.ts +4509 -0
- package/scaffold-patches/packages/agent/src/api/trigger-routes.ts +942 -0
- package/scaffold-patches/packages/agent/src/runtime/core-plugins.ts +4 -0
- package/scaffold-patches/packages/agent/src/triggers/runtime.ts +955 -0
- package/scaffold-patches/packages/app-core/src/api/automations-compat-routes.ts +924 -0
- package/scaffold-patches/packages/app-core/src/api/client-agent.ts +2755 -0
- package/scaffold-patches/packages/app-core/src/components/pages/AutomationsView.tsx +446 -26
- package/scaffold-patches/packages/app-core/src/components/pages/SettingsView.tsx +155 -0
- package/scaffold-patches/packages/shared/src/onboarding-presets.characters.ts +16 -16
- package/templates/fullstack-app/package.json +9 -5
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/package.json +1 -1
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/__tests__/routes/estimate-routes.test.ts +5 -2
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/app.js +896 -19
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/index.html +280 -94
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/dashboard/style.css +969 -235
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/keys-routes.ts +170 -0
- package/templates/fullstack-app/plugins/plugin-tokagent-billing/src/routes/messages-proxy-routes.ts +114 -3
- package/templates/fullstack-app/plugins/plugin-web-fetch/build.ts +35 -0
- package/templates/fullstack-app/plugins/plugin-web-fetch/package.json +37 -0
- package/templates/fullstack-app/plugins/plugin-web-fetch/src/index.ts +471 -0
- package/templates/fullstack-app/plugins/plugin-web-fetch/tsconfig.json +20 -0
- package/templates/fullstack-app/scripts/ensure-plugin-builds.mjs +1 -0
- package/templates/fullstack-app/scripts/verify-llm-plugins.mjs +122 -0
- package/templates-manifest.json +1 -1
|
@@ -0,0 +1,1889 @@
|
|
|
1
|
+
import type http from "node:http";
|
|
2
|
+
import type { AgentRuntime } from "@elizaos/core";
|
|
3
|
+
import { logger } from "@elizaos/core";
|
|
4
|
+
import {
|
|
5
|
+
isElizaSettingsDebugEnabled,
|
|
6
|
+
sanitizeForSettingsDebug,
|
|
7
|
+
settingsDebugCloudSummary,
|
|
8
|
+
} from "@elizaos/shared";
|
|
9
|
+
import type { ElizaConfig } from "../config/config.js";
|
|
10
|
+
import { loadElizaConfig, saveElizaConfig } from "../config/config.js";
|
|
11
|
+
import {
|
|
12
|
+
getPluginWidgets,
|
|
13
|
+
type PluginWidgetDeclarationServer,
|
|
14
|
+
} from "../config/plugin-widgets.js";
|
|
15
|
+
import { resolveDefaultAgentWorkspaceDir } from "../providers/workspace.js";
|
|
16
|
+
import {
|
|
17
|
+
type AdvancedCapabilityPluginId,
|
|
18
|
+
applyAdvancedCapabilitiesConfig,
|
|
19
|
+
isAdvancedCapabilityPluginId,
|
|
20
|
+
resolveAdvancedCapabilitiesEnabled,
|
|
21
|
+
} from "../runtime/advanced-capabilities-config.js";
|
|
22
|
+
import {
|
|
23
|
+
CORE_PLUGINS,
|
|
24
|
+
OPTIONAL_CORE_PLUGINS,
|
|
25
|
+
} from "../runtime/core-plugins.js";
|
|
26
|
+
import type { ResolvedPlugin } from "../runtime/plugin-types.js";
|
|
27
|
+
import type {
|
|
28
|
+
CoreManagerLike,
|
|
29
|
+
InstallProgressLike,
|
|
30
|
+
PluginManagerLike,
|
|
31
|
+
} from "../services/plugin-manager-types.js";
|
|
32
|
+
import nodeFs from "node:fs/promises";
|
|
33
|
+
import nodePath from "node:path";
|
|
34
|
+
import type { ReadJsonBodyOptions } from "./http-helpers.js";
|
|
35
|
+
import { applyPluginRuntimeMutation } from "./plugin-runtime-apply.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Atomic write of a single key=value pair into the project's `.env` file
|
|
39
|
+
* (the one in `process.cwd()` — the project root from which `bun run dev`
|
|
40
|
+
* was launched, where other API keys like OPENROUTER_API_KEY also live).
|
|
41
|
+
*
|
|
42
|
+
* Behavior:
|
|
43
|
+
* - If `.env` doesn't exist → create it.
|
|
44
|
+
* - If the key already appears (active line) → replace that line.
|
|
45
|
+
* - If the key appears commented (`# KEY=...`) → uncomment and set.
|
|
46
|
+
* - Otherwise → append `KEY=value\n`.
|
|
47
|
+
*
|
|
48
|
+
* Atomicity via tmp-file + rename. Permissions are tightened to 0600
|
|
49
|
+
* because `.env` typically holds secrets.
|
|
50
|
+
*/
|
|
51
|
+
async function writeProjectEnvVar(key: string, value: string): Promise<string> {
|
|
52
|
+
const envPath = nodePath.join(process.cwd(), ".env");
|
|
53
|
+
let existing = "";
|
|
54
|
+
try {
|
|
55
|
+
existing = await nodeFs.readFile(envPath, "utf8");
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") throw err;
|
|
58
|
+
// File doesn't exist — start with an empty string.
|
|
59
|
+
}
|
|
60
|
+
const line = `${key}=${value}`;
|
|
61
|
+
const activeRe = new RegExp(`^${key}=.*$`, "m");
|
|
62
|
+
const commentedRe = new RegExp(`^#\\s*${key}=.*$`, "m");
|
|
63
|
+
let next: string;
|
|
64
|
+
if (activeRe.test(existing)) {
|
|
65
|
+
next = existing.replace(activeRe, line);
|
|
66
|
+
} else if (commentedRe.test(existing)) {
|
|
67
|
+
next = existing.replace(commentedRe, line);
|
|
68
|
+
} else if (existing.length === 0) {
|
|
69
|
+
next = `${line}\n`;
|
|
70
|
+
} else {
|
|
71
|
+
const sep = existing.endsWith("\n") ? "" : "\n";
|
|
72
|
+
next = `${existing}${sep}${line}\n`;
|
|
73
|
+
}
|
|
74
|
+
const tmpPath = `${envPath}.tmp.${process.pid}`;
|
|
75
|
+
await nodeFs.writeFile(tmpPath, next, { mode: 0o600 });
|
|
76
|
+
await nodeFs.rename(tmpPath, envPath);
|
|
77
|
+
try {
|
|
78
|
+
await nodeFs.chmod(envPath, 0o600);
|
|
79
|
+
} catch {
|
|
80
|
+
// Best-effort permission tighten — non-fatal on filesystems that don't
|
|
81
|
+
// support chmod (rare on macOS/Linux but possible on Windows).
|
|
82
|
+
}
|
|
83
|
+
return envPath;
|
|
84
|
+
}
|
|
85
|
+
import {
|
|
86
|
+
type PluginParamInfo,
|
|
87
|
+
validatePluginConfig,
|
|
88
|
+
} from "./plugin-validation.js";
|
|
89
|
+
|
|
90
|
+
/** Workspace packages use `@elizaos/plugin-*` or `@elizaos/app-*` — normalize list/toggle ids. */
|
|
91
|
+
function optionalPluginListId(npmName: string): string {
|
|
92
|
+
if (npmName.startsWith("@elizaos/app-")) {
|
|
93
|
+
return npmName.slice("@elizaos/".length);
|
|
94
|
+
}
|
|
95
|
+
return npmName.replace("@elizaos/plugin-", "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ADVANCED_CAPABILITY_SERVICE_BY_PLUGIN_ID: Partial<
|
|
99
|
+
Record<AdvancedCapabilityPluginId, string>
|
|
100
|
+
> = {
|
|
101
|
+
experience: "EXPERIENCE",
|
|
102
|
+
form: "FORM",
|
|
103
|
+
personality: "CHARACTER_MANAGEMENT",
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Types — kept lean to avoid circular deps with server.ts
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
interface PluginParamDef {
|
|
111
|
+
key: string;
|
|
112
|
+
type: string;
|
|
113
|
+
description: string;
|
|
114
|
+
required: boolean;
|
|
115
|
+
sensitive: boolean;
|
|
116
|
+
default?: string;
|
|
117
|
+
options?: string[];
|
|
118
|
+
currentValue: string | null;
|
|
119
|
+
isSet: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface PluginEntry {
|
|
123
|
+
id: string;
|
|
124
|
+
name: string;
|
|
125
|
+
description: string;
|
|
126
|
+
tags: string[];
|
|
127
|
+
enabled: boolean;
|
|
128
|
+
configured: boolean;
|
|
129
|
+
envKey: string | null;
|
|
130
|
+
category:
|
|
131
|
+
| "ai-provider"
|
|
132
|
+
| "connector"
|
|
133
|
+
| "streaming"
|
|
134
|
+
| "database"
|
|
135
|
+
| "app"
|
|
136
|
+
| "feature";
|
|
137
|
+
source: "bundled" | "store";
|
|
138
|
+
configKeys: string[];
|
|
139
|
+
parameters: PluginParamDef[];
|
|
140
|
+
validationErrors: Array<{ field: string; message: string }>;
|
|
141
|
+
validationWarnings: Array<{ field: string; message: string }>;
|
|
142
|
+
npmName?: string;
|
|
143
|
+
version?: string;
|
|
144
|
+
releaseStream?: "latest" | "alpha";
|
|
145
|
+
requestedVersion?: string;
|
|
146
|
+
latestVersion?: string | null;
|
|
147
|
+
alphaVersion?: string | null;
|
|
148
|
+
pluginDeps?: string[];
|
|
149
|
+
isActive?: boolean;
|
|
150
|
+
loadError?: string;
|
|
151
|
+
configUiHints?: Record<string, Record<string, unknown>>;
|
|
152
|
+
icon?: string | null;
|
|
153
|
+
homepage?: string;
|
|
154
|
+
repository?: string;
|
|
155
|
+
setupGuideUrl?: string;
|
|
156
|
+
autoEnabled?: boolean;
|
|
157
|
+
managementMode?: "standard" | "core-optional";
|
|
158
|
+
capabilityStatus?:
|
|
159
|
+
| "loaded"
|
|
160
|
+
| "auto-enabled"
|
|
161
|
+
| "blocked"
|
|
162
|
+
| "missing-prerequisites"
|
|
163
|
+
| "disabled";
|
|
164
|
+
capabilityReason?: string | null;
|
|
165
|
+
prerequisites?: Array<{ label: string; met: boolean }>;
|
|
166
|
+
/** Widget declarations for this plugin (rendered by the UI widget system). */
|
|
167
|
+
widgets?: PluginWidgetDeclarationServer[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type PluginHealthProbeResult = {
|
|
171
|
+
ok?: boolean;
|
|
172
|
+
message?: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
function getPluginHealthProbe(
|
|
176
|
+
plugin: unknown,
|
|
177
|
+
): (() => PluginHealthProbeResult | Promise<PluginHealthProbeResult>) | null {
|
|
178
|
+
if (!plugin || typeof plugin !== "object") {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const record = plugin as Record<string, unknown>;
|
|
182
|
+
for (const key of ["health", "healthCheck", "testConnection", "test"]) {
|
|
183
|
+
const candidate = record[key];
|
|
184
|
+
if (typeof candidate === "function") {
|
|
185
|
+
return candidate as () =>
|
|
186
|
+
| PluginHealthProbeResult
|
|
187
|
+
| Promise<PluginHealthProbeResult>;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface SecretEntry {
|
|
194
|
+
key: string;
|
|
195
|
+
description: string;
|
|
196
|
+
category: string;
|
|
197
|
+
sensitive: boolean;
|
|
198
|
+
required: boolean;
|
|
199
|
+
isSet: boolean;
|
|
200
|
+
maskedValue: string | null;
|
|
201
|
+
usedBy: Array<{ pluginId: string; pluginName: string; enabled: boolean }>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type CoreToggleDriftFlag = "entries_vs_allowlist" | "entries_vs_compat";
|
|
205
|
+
|
|
206
|
+
interface CoreToggleDriftDiagnostic {
|
|
207
|
+
pluginId: string;
|
|
208
|
+
npmName: string;
|
|
209
|
+
enabled_allowlist: boolean;
|
|
210
|
+
enabled_entries: boolean | null;
|
|
211
|
+
enabled_compat: boolean | null;
|
|
212
|
+
drift_flags: CoreToggleDriftFlag[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface PluginRouteContext {
|
|
216
|
+
req: http.IncomingMessage;
|
|
217
|
+
res: http.ServerResponse;
|
|
218
|
+
method: string;
|
|
219
|
+
pathname: string;
|
|
220
|
+
url: URL;
|
|
221
|
+
state: {
|
|
222
|
+
runtime: AgentRuntime | null;
|
|
223
|
+
config: ElizaConfig;
|
|
224
|
+
plugins: PluginEntry[];
|
|
225
|
+
broadcastWs: ((data: object) => void) | null;
|
|
226
|
+
};
|
|
227
|
+
// Helpers from server.ts
|
|
228
|
+
json: (res: http.ServerResponse, data: unknown, status?: number) => void;
|
|
229
|
+
error: (res: http.ServerResponse, message: string, status?: number) => void;
|
|
230
|
+
readJsonBody: <T extends object>(
|
|
231
|
+
req: http.IncomingMessage,
|
|
232
|
+
res: http.ServerResponse,
|
|
233
|
+
options?: ReadJsonBodyOptions,
|
|
234
|
+
) => Promise<T | null>;
|
|
235
|
+
scheduleRuntimeRestart: (reason: string) => void;
|
|
236
|
+
restartRuntime?: (reason: string) => Promise<boolean>;
|
|
237
|
+
// Server.ts internal helpers
|
|
238
|
+
BLOCKED_ENV_KEYS: Set<string>;
|
|
239
|
+
discoverInstalledPlugins: (
|
|
240
|
+
config: ElizaConfig,
|
|
241
|
+
bundledIds: Set<string>,
|
|
242
|
+
) => PluginEntry[];
|
|
243
|
+
maskValue: (value: string) => string;
|
|
244
|
+
aggregateSecrets: (plugins: PluginEntry[]) => SecretEntry[];
|
|
245
|
+
readProviderCache: (
|
|
246
|
+
providerId: string,
|
|
247
|
+
) => { models: Array<{ id: string; name: string; category: string }> } | null;
|
|
248
|
+
paramKeyToCategory: (paramKey: string) => string;
|
|
249
|
+
buildPluginEvmDiagnosticEntry: (opts: {
|
|
250
|
+
config: ElizaConfig;
|
|
251
|
+
runtime: AgentRuntime | null;
|
|
252
|
+
}) => PluginEntry;
|
|
253
|
+
EVM_PLUGIN_PACKAGE: string;
|
|
254
|
+
applyWhatsAppQrOverride: (
|
|
255
|
+
plugins: PluginEntry[],
|
|
256
|
+
workspaceDir: string,
|
|
257
|
+
) => void;
|
|
258
|
+
applySignalQrOverride: (
|
|
259
|
+
plugins: PluginEntry[],
|
|
260
|
+
workspaceDir: string,
|
|
261
|
+
signalAuthExists: (dir: string) => boolean,
|
|
262
|
+
) => void;
|
|
263
|
+
signalAuthExists: (dir: string) => boolean;
|
|
264
|
+
resolvePluginConfigMutationRejections: (
|
|
265
|
+
parameters: PluginParamDef[],
|
|
266
|
+
configObj: Record<string, string>,
|
|
267
|
+
) => Array<{ field: string; message: string }>;
|
|
268
|
+
requirePluginManager: (runtime: AgentRuntime | null) => PluginManagerLike;
|
|
269
|
+
requireCoreManager: (runtime: AgentRuntime | null) => CoreManagerLike;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const pluginsListInFlight = new WeakMap<
|
|
273
|
+
PluginRouteContext["state"],
|
|
274
|
+
Promise<PluginEntry[]>
|
|
275
|
+
>();
|
|
276
|
+
|
|
277
|
+
function readCompatEnabledFromConfig(
|
|
278
|
+
config: ElizaConfig,
|
|
279
|
+
pluginId: string,
|
|
280
|
+
): boolean | null {
|
|
281
|
+
const asRecord = (value: unknown): Record<string, unknown> | null => {
|
|
282
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
return value as Record<string, unknown>;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const container =
|
|
289
|
+
asRecord(config.connectors)?.[pluginId] ??
|
|
290
|
+
asRecord(config.streaming)?.[pluginId];
|
|
291
|
+
const value = asRecord(container)?.enabled;
|
|
292
|
+
return typeof value === "boolean" ? value : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildCoreToggleDiagnostics(
|
|
296
|
+
config: ElizaConfig,
|
|
297
|
+
npmName: string,
|
|
298
|
+
): CoreToggleDriftDiagnostic | null {
|
|
299
|
+
const pluginId = optionalPluginListId(npmName);
|
|
300
|
+
const isOptional = (OPTIONAL_CORE_PLUGINS as readonly string[]).includes(
|
|
301
|
+
npmName,
|
|
302
|
+
);
|
|
303
|
+
if (!isOptional) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const allowList = new Set(config.plugins?.allow ?? []);
|
|
307
|
+
const enabledAllowList = allowList.has(npmName) || allowList.has(pluginId);
|
|
308
|
+
const entryEnabledRaw = config.plugins?.entries?.[pluginId]?.enabled;
|
|
309
|
+
const enabledEntries =
|
|
310
|
+
typeof entryEnabledRaw === "boolean" ? entryEnabledRaw : null;
|
|
311
|
+
const enabledCompat = readCompatEnabledFromConfig(config, pluginId);
|
|
312
|
+
const driftFlags: CoreToggleDriftFlag[] = [];
|
|
313
|
+
|
|
314
|
+
if (enabledEntries !== null && enabledEntries !== enabledAllowList) {
|
|
315
|
+
driftFlags.push("entries_vs_allowlist");
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
enabledEntries !== null &&
|
|
319
|
+
enabledCompat !== null &&
|
|
320
|
+
enabledEntries !== enabledCompat
|
|
321
|
+
) {
|
|
322
|
+
driftFlags.push("entries_vs_compat");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
pluginId,
|
|
327
|
+
npmName,
|
|
328
|
+
enabled_allowlist: enabledAllowList,
|
|
329
|
+
enabled_entries: enabledEntries,
|
|
330
|
+
enabled_compat: enabledCompat,
|
|
331
|
+
drift_flags: driftFlags,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// Route handler
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Handle plugin management routes (/api/plugins/*, /api/secrets, /api/core/*).
|
|
341
|
+
* Returns `true` if the request was handled.
|
|
342
|
+
*/
|
|
343
|
+
export async function handlePluginRoutes(
|
|
344
|
+
ctx: PluginRouteContext,
|
|
345
|
+
): Promise<boolean> {
|
|
346
|
+
const {
|
|
347
|
+
req,
|
|
348
|
+
res,
|
|
349
|
+
method,
|
|
350
|
+
pathname,
|
|
351
|
+
state,
|
|
352
|
+
json,
|
|
353
|
+
error,
|
|
354
|
+
readJsonBody,
|
|
355
|
+
scheduleRuntimeRestart,
|
|
356
|
+
restartRuntime,
|
|
357
|
+
BLOCKED_ENV_KEYS,
|
|
358
|
+
discoverInstalledPlugins,
|
|
359
|
+
maskValue,
|
|
360
|
+
aggregateSecrets,
|
|
361
|
+
readProviderCache,
|
|
362
|
+
paramKeyToCategory,
|
|
363
|
+
buildPluginEvmDiagnosticEntry,
|
|
364
|
+
EVM_PLUGIN_PACKAGE,
|
|
365
|
+
applyWhatsAppQrOverride,
|
|
366
|
+
applySignalQrOverride,
|
|
367
|
+
signalAuthExists,
|
|
368
|
+
resolvePluginConfigMutationRejections,
|
|
369
|
+
requirePluginManager,
|
|
370
|
+
requireCoreManager,
|
|
371
|
+
} = ctx;
|
|
372
|
+
|
|
373
|
+
const buildPluginsListSnapshot = async (): Promise<PluginEntry[]> => {
|
|
374
|
+
let freshConfig: ElizaConfig;
|
|
375
|
+
try {
|
|
376
|
+
freshConfig = loadElizaConfig();
|
|
377
|
+
} catch {
|
|
378
|
+
freshConfig = state.config;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const bundledIds = new Set(state.plugins.map((p) => p.id));
|
|
382
|
+
const installedEntries = discoverInstalledPlugins(freshConfig, bundledIds);
|
|
383
|
+
const allPlugins: PluginEntry[] = [...state.plugins, ...installedEntries];
|
|
384
|
+
let installedMetadataByName = new Map<
|
|
385
|
+
string,
|
|
386
|
+
{
|
|
387
|
+
version?: string;
|
|
388
|
+
releaseStream?: "latest" | "alpha";
|
|
389
|
+
requestedVersion?: string;
|
|
390
|
+
latestVersion?: string | null;
|
|
391
|
+
alphaVersion?: string | null;
|
|
392
|
+
}
|
|
393
|
+
>();
|
|
394
|
+
try {
|
|
395
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
396
|
+
const installed = await pluginManager.listInstalledPlugins();
|
|
397
|
+
installedMetadataByName = new Map(
|
|
398
|
+
installed.map((plugin) => [
|
|
399
|
+
plugin.name,
|
|
400
|
+
{
|
|
401
|
+
version: plugin.version,
|
|
402
|
+
releaseStream: plugin.releaseStream,
|
|
403
|
+
requestedVersion: plugin.requestedVersion,
|
|
404
|
+
latestVersion: plugin.latestVersion,
|
|
405
|
+
alphaVersion: plugin.alphaVersion,
|
|
406
|
+
},
|
|
407
|
+
]),
|
|
408
|
+
);
|
|
409
|
+
} catch {
|
|
410
|
+
// Keep the plugin list working even when the plugin-manager service is unavailable.
|
|
411
|
+
}
|
|
412
|
+
const evmDiagnostic = buildPluginEvmDiagnosticEntry({
|
|
413
|
+
config: state.config,
|
|
414
|
+
runtime: state.runtime,
|
|
415
|
+
});
|
|
416
|
+
const existingEvmPlugin = allPlugins.find(
|
|
417
|
+
(plugin) => plugin.id === "evm" || plugin.npmName === EVM_PLUGIN_PACKAGE,
|
|
418
|
+
);
|
|
419
|
+
if (existingEvmPlugin) {
|
|
420
|
+
existingEvmPlugin.autoEnabled = evmDiagnostic.autoEnabled;
|
|
421
|
+
existingEvmPlugin.managementMode = "core-optional";
|
|
422
|
+
existingEvmPlugin.capabilityStatus = evmDiagnostic.capabilityStatus;
|
|
423
|
+
existingEvmPlugin.capabilityReason = evmDiagnostic.capabilityReason;
|
|
424
|
+
existingEvmPlugin.prerequisites = evmDiagnostic.prerequisites;
|
|
425
|
+
existingEvmPlugin.setupGuideUrl =
|
|
426
|
+
existingEvmPlugin.setupGuideUrl ?? evmDiagnostic.setupGuideUrl;
|
|
427
|
+
existingEvmPlugin.tags = Array.from(
|
|
428
|
+
new Set([...(existingEvmPlugin.tags ?? []), ...evmDiagnostic.tags]),
|
|
429
|
+
);
|
|
430
|
+
} else {
|
|
431
|
+
allPlugins.push(evmDiagnostic);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const configEntries = (
|
|
435
|
+
freshConfig.plugins as Record<string, unknown> | undefined
|
|
436
|
+
)?.entries as Record<string, { enabled?: boolean }> | undefined;
|
|
437
|
+
const advancedCapabilitiesEnabled =
|
|
438
|
+
resolveAdvancedCapabilitiesEnabled(freshConfig);
|
|
439
|
+
const loadedNames = state.runtime
|
|
440
|
+
? state.runtime.plugins.map((p) => p.name)
|
|
441
|
+
: [];
|
|
442
|
+
for (const plugin of allPlugins) {
|
|
443
|
+
const installedMetadata =
|
|
444
|
+
(plugin.npmName ? installedMetadataByName.get(plugin.npmName) : null) ??
|
|
445
|
+
installedMetadataByName.get(plugin.name);
|
|
446
|
+
if (installedMetadata) {
|
|
447
|
+
plugin.version = installedMetadata.version ?? plugin.version;
|
|
448
|
+
plugin.releaseStream =
|
|
449
|
+
installedMetadata.releaseStream ?? plugin.releaseStream;
|
|
450
|
+
plugin.requestedVersion =
|
|
451
|
+
installedMetadata.requestedVersion ?? plugin.requestedVersion;
|
|
452
|
+
plugin.latestVersion =
|
|
453
|
+
installedMetadata.latestVersion ?? plugin.latestVersion ?? null;
|
|
454
|
+
plugin.alphaVersion =
|
|
455
|
+
installedMetadata.alphaVersion ?? plugin.alphaVersion ?? null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (isAdvancedCapabilityPluginId(plugin.id)) {
|
|
459
|
+
const serviceType = ADVANCED_CAPABILITY_SERVICE_BY_PLUGIN_ID[plugin.id];
|
|
460
|
+
plugin.enabled = advancedCapabilitiesEnabled;
|
|
461
|
+
plugin.isActive = advancedCapabilitiesEnabled
|
|
462
|
+
? serviceType
|
|
463
|
+
? Boolean(state.runtime?.getService(serviceType))
|
|
464
|
+
: Boolean(state.runtime)
|
|
465
|
+
: false;
|
|
466
|
+
plugin.autoEnabled = advancedCapabilitiesEnabled;
|
|
467
|
+
plugin.loadError = undefined;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const suffix = `plugin-${plugin.id}`;
|
|
472
|
+
const packageName = `@elizaos/plugin-${plugin.id}`;
|
|
473
|
+
const npmPkgName = plugin.npmName;
|
|
474
|
+
const isLoaded =
|
|
475
|
+
loadedNames.length > 0 &&
|
|
476
|
+
loadedNames.some((name) => {
|
|
477
|
+
return (
|
|
478
|
+
name === plugin.id ||
|
|
479
|
+
name === suffix ||
|
|
480
|
+
name === packageName ||
|
|
481
|
+
(npmPkgName != null && name === npmPkgName) ||
|
|
482
|
+
name.endsWith(`/${suffix}`) ||
|
|
483
|
+
name.includes(plugin.id)
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
plugin.isActive = isLoaded;
|
|
487
|
+
const configEntry = configEntries?.[plugin.id];
|
|
488
|
+
if (configEntry && typeof configEntry.enabled === "boolean") {
|
|
489
|
+
plugin.enabled = configEntry.enabled;
|
|
490
|
+
} else {
|
|
491
|
+
plugin.enabled = isLoaded;
|
|
492
|
+
}
|
|
493
|
+
plugin.loadError = undefined;
|
|
494
|
+
if (plugin.enabled && !isLoaded && state.runtime) {
|
|
495
|
+
const installs = freshConfig.plugins?.installs as
|
|
496
|
+
| Record<string, unknown>
|
|
497
|
+
| undefined;
|
|
498
|
+
const packageName = `@elizaos/plugin-${plugin.id}`;
|
|
499
|
+
const hasInstallRecord =
|
|
500
|
+
installs?.[packageName] || installs?.[plugin.id];
|
|
501
|
+
if (hasInstallRecord) {
|
|
502
|
+
plugin.loadError =
|
|
503
|
+
"Plugin installed but failed to load — the package may be missing compiled files.";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (plugin.id === "evm" || plugin.npmName === EVM_PLUGIN_PACKAGE) {
|
|
507
|
+
plugin.enabled = evmDiagnostic.enabled;
|
|
508
|
+
plugin.isActive = evmDiagnostic.isActive;
|
|
509
|
+
plugin.autoEnabled = evmDiagnostic.autoEnabled;
|
|
510
|
+
plugin.managementMode = "core-optional";
|
|
511
|
+
plugin.capabilityStatus = evmDiagnostic.capabilityStatus;
|
|
512
|
+
plugin.capabilityReason = evmDiagnostic.capabilityReason;
|
|
513
|
+
plugin.prerequisites = evmDiagnostic.prerequisites;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
for (const plugin of allPlugins) {
|
|
518
|
+
for (const param of plugin.parameters) {
|
|
519
|
+
const envValue = process.env[param.key];
|
|
520
|
+
param.isSet = Boolean(envValue?.trim());
|
|
521
|
+
param.currentValue = param.isSet
|
|
522
|
+
? param.sensitive
|
|
523
|
+
? maskValue(envValue ?? "")
|
|
524
|
+
: (envValue ?? "")
|
|
525
|
+
: null;
|
|
526
|
+
}
|
|
527
|
+
const paramInfos: PluginParamInfo[] = plugin.parameters.map((p) => ({
|
|
528
|
+
key: p.key,
|
|
529
|
+
required: p.required,
|
|
530
|
+
sensitive: p.sensitive,
|
|
531
|
+
type: p.type,
|
|
532
|
+
description: p.description,
|
|
533
|
+
default: p.default,
|
|
534
|
+
}));
|
|
535
|
+
const validation = validatePluginConfig(
|
|
536
|
+
plugin.id,
|
|
537
|
+
plugin.category,
|
|
538
|
+
plugin.envKey,
|
|
539
|
+
plugin.configKeys,
|
|
540
|
+
undefined,
|
|
541
|
+
paramInfos,
|
|
542
|
+
);
|
|
543
|
+
plugin.validationErrors = validation.errors;
|
|
544
|
+
plugin.validationWarnings = validation.warnings;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
applyWhatsAppQrOverride(allPlugins, resolveDefaultAgentWorkspaceDir());
|
|
548
|
+
applySignalQrOverride(
|
|
549
|
+
allPlugins,
|
|
550
|
+
resolveDefaultAgentWorkspaceDir(),
|
|
551
|
+
signalAuthExists,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
for (const plugin of allPlugins) {
|
|
555
|
+
const providerModels = readProviderCache(plugin.id)?.models ?? [];
|
|
556
|
+
|
|
557
|
+
for (const param of plugin.parameters) {
|
|
558
|
+
if (!param.key.toUpperCase().includes("MODEL")) continue;
|
|
559
|
+
|
|
560
|
+
const expectedCat = paramKeyToCategory(param.key);
|
|
561
|
+
const filtered = providerModels.filter(
|
|
562
|
+
(m) => m.category === expectedCat,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
if (!plugin.configUiHints) plugin.configUiHints = {};
|
|
566
|
+
plugin.configUiHints[param.key] = {
|
|
567
|
+
...plugin.configUiHints[param.key],
|
|
568
|
+
type: "select",
|
|
569
|
+
options: filtered.map((m) => ({
|
|
570
|
+
value: m.id,
|
|
571
|
+
label: m.name !== m.id ? `${m.name} (${m.id})` : m.id,
|
|
572
|
+
})),
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Attach widget declarations from the static plugin widget map.
|
|
578
|
+
for (const plugin of allPlugins) {
|
|
579
|
+
const widgets = getPluginWidgets(plugin.id);
|
|
580
|
+
if (widgets.length > 0) {
|
|
581
|
+
plugin.widgets = widgets;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
return allPlugins;
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const resolvePluginsSnapshot = async (
|
|
589
|
+
config: ElizaConfig,
|
|
590
|
+
): Promise<ResolvedPlugin[]> => {
|
|
591
|
+
const { resolvePlugins } = await import("../runtime/plugin-resolver.js");
|
|
592
|
+
return await resolvePlugins(config, { quiet: true });
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const resolvePluginsSnapshotSafe = async (
|
|
596
|
+
config: ElizaConfig,
|
|
597
|
+
reason: string,
|
|
598
|
+
): Promise<ResolvedPlugin[] | undefined> => {
|
|
599
|
+
try {
|
|
600
|
+
return await resolvePluginsSnapshot(config);
|
|
601
|
+
} catch (err) {
|
|
602
|
+
logger.warn(
|
|
603
|
+
`[plugin-routes] Failed to resolve plugin snapshot for ${reason}: ${err instanceof Error ? err.message : String(err)}`,
|
|
604
|
+
);
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const npmNamePattern =
|
|
610
|
+
/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
611
|
+
|
|
612
|
+
const validateRegistryPluginPackageName = (
|
|
613
|
+
pluginName: string,
|
|
614
|
+
): string | null => {
|
|
615
|
+
const trimmedName = pluginName.trim();
|
|
616
|
+
if (!trimmedName) {
|
|
617
|
+
return "Request body must include 'name' (plugin package name)";
|
|
618
|
+
}
|
|
619
|
+
if (!npmNamePattern.test(trimmedName)) {
|
|
620
|
+
return "Invalid plugin name format";
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
// ── GET /api/plugins ────────────────────────────────────────────────────
|
|
626
|
+
if (method === "GET" && pathname === "/api/plugins") {
|
|
627
|
+
let inFlight = pluginsListInFlight.get(state);
|
|
628
|
+
if (!inFlight) {
|
|
629
|
+
inFlight = buildPluginsListSnapshot();
|
|
630
|
+
pluginsListInFlight.set(state, inFlight);
|
|
631
|
+
}
|
|
632
|
+
const allPlugins = await inFlight.finally(() => {
|
|
633
|
+
pluginsListInFlight.delete(state);
|
|
634
|
+
});
|
|
635
|
+
json(res, { plugins: allPlugins });
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── PUT /api/plugins/:id ────────────────────────────────────────────────
|
|
640
|
+
if (method === "PUT" && pathname.startsWith("/api/plugins/")) {
|
|
641
|
+
const pluginId = pathname.slice("/api/plugins/".length);
|
|
642
|
+
const body = await readJsonBody<{
|
|
643
|
+
enabled?: boolean;
|
|
644
|
+
config?: Record<string, string>;
|
|
645
|
+
}>(req, res);
|
|
646
|
+
if (!body) return true;
|
|
647
|
+
|
|
648
|
+
if (isElizaSettingsDebugEnabled()) {
|
|
649
|
+
logger.debug(
|
|
650
|
+
`[eliza][settings][api] PUT /api/plugins/${pluginId} body=${JSON.stringify(
|
|
651
|
+
sanitizeForSettingsDebug({
|
|
652
|
+
enabled: body.enabled,
|
|
653
|
+
configKeys: body.config ? Object.keys(body.config).sort() : [],
|
|
654
|
+
config: body.config ?? {},
|
|
655
|
+
}),
|
|
656
|
+
)}`,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Search both bundled plugins AND store-installed plugins
|
|
661
|
+
let plugin = state.plugins.find((p) => p.id === pluginId);
|
|
662
|
+
if (!plugin) {
|
|
663
|
+
// Check store-installed plugins from config
|
|
664
|
+
let freshCfg: ElizaConfig;
|
|
665
|
+
try {
|
|
666
|
+
freshCfg = loadElizaConfig();
|
|
667
|
+
} catch {
|
|
668
|
+
freshCfg = state.config;
|
|
669
|
+
}
|
|
670
|
+
const bundledIds = new Set(state.plugins.map((p) => p.id));
|
|
671
|
+
const installed = discoverInstalledPlugins(freshCfg, bundledIds);
|
|
672
|
+
const found = installed.find((p) => p.id === pluginId);
|
|
673
|
+
if (found) {
|
|
674
|
+
// Temporarily add to state.plugins so toggle logic works the same way
|
|
675
|
+
state.plugins.push(found);
|
|
676
|
+
plugin = found;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (!plugin) {
|
|
680
|
+
error(res, `Plugin "${pluginId}" not found`, 404);
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const previousConfig = structuredClone(state.config);
|
|
685
|
+
const previousResolvedPlugins = state.runtime
|
|
686
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin update")
|
|
687
|
+
: undefined;
|
|
688
|
+
|
|
689
|
+
if (body.enabled !== undefined) {
|
|
690
|
+
plugin.enabled = body.enabled;
|
|
691
|
+
}
|
|
692
|
+
if (body.config) {
|
|
693
|
+
const configRejections = resolvePluginConfigMutationRejections(
|
|
694
|
+
plugin.parameters,
|
|
695
|
+
body.config,
|
|
696
|
+
);
|
|
697
|
+
if (configRejections.length > 0) {
|
|
698
|
+
json(
|
|
699
|
+
res,
|
|
700
|
+
{ ok: false, plugin, validationErrors: configRejections },
|
|
701
|
+
422,
|
|
702
|
+
);
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Only validate the fields actually being submitted — not all required
|
|
707
|
+
// fields. Users may save partial config (e.g. just the API key) from
|
|
708
|
+
// the Settings page; blocking the save because OTHER required fields
|
|
709
|
+
// aren't set yet is counterproductive.
|
|
710
|
+
const configObj = body.config;
|
|
711
|
+
const submittedParamInfos: PluginParamInfo[] = plugin.parameters
|
|
712
|
+
.filter((p) => p.key in configObj)
|
|
713
|
+
.map((p) => ({
|
|
714
|
+
key: p.key,
|
|
715
|
+
required: p.required,
|
|
716
|
+
sensitive: p.sensitive,
|
|
717
|
+
type: p.type,
|
|
718
|
+
description: p.description,
|
|
719
|
+
default: p.default,
|
|
720
|
+
}));
|
|
721
|
+
const configValidation = validatePluginConfig(
|
|
722
|
+
pluginId,
|
|
723
|
+
plugin.category,
|
|
724
|
+
plugin.envKey,
|
|
725
|
+
plugin.configKeys,
|
|
726
|
+
body.config,
|
|
727
|
+
submittedParamInfos,
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
if (!configValidation.valid) {
|
|
731
|
+
json(
|
|
732
|
+
res,
|
|
733
|
+
{ ok: false, plugin, validationErrors: configValidation.errors },
|
|
734
|
+
422,
|
|
735
|
+
);
|
|
736
|
+
return true;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const allowedParamKeys = new Set(plugin.parameters.map((p) => p.key));
|
|
740
|
+
|
|
741
|
+
// Persist config values to state.config.env so they survive restarts
|
|
742
|
+
if (!state.config.env) {
|
|
743
|
+
state.config.env = {};
|
|
744
|
+
}
|
|
745
|
+
for (const [key, value] of Object.entries(body.config)) {
|
|
746
|
+
if (
|
|
747
|
+
allowedParamKeys.has(key) &&
|
|
748
|
+
!BLOCKED_ENV_KEYS.has(key.toUpperCase()) &&
|
|
749
|
+
typeof value === "string" &&
|
|
750
|
+
value.trim()
|
|
751
|
+
) {
|
|
752
|
+
process.env[key] = value;
|
|
753
|
+
(state.config.env as Record<string, unknown>)[key] = value;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
plugin.configured = true;
|
|
757
|
+
|
|
758
|
+
// Save config even when only config values changed (no enable toggle)
|
|
759
|
+
if (body.enabled === undefined) {
|
|
760
|
+
try {
|
|
761
|
+
saveElizaConfig(state.config);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
logger.warn(
|
|
764
|
+
`[eliza-api] Failed to save config: ${err instanceof Error ? err.message : err}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// Refresh validation
|
|
771
|
+
const refreshParamInfos: PluginParamInfo[] = plugin.parameters.map((p) => ({
|
|
772
|
+
key: p.key,
|
|
773
|
+
required: p.required,
|
|
774
|
+
sensitive: p.sensitive,
|
|
775
|
+
type: p.type,
|
|
776
|
+
description: p.description,
|
|
777
|
+
default: p.default,
|
|
778
|
+
}));
|
|
779
|
+
const updated = validatePluginConfig(
|
|
780
|
+
pluginId,
|
|
781
|
+
plugin.category,
|
|
782
|
+
plugin.envKey,
|
|
783
|
+
plugin.configKeys,
|
|
784
|
+
undefined,
|
|
785
|
+
refreshParamInfos,
|
|
786
|
+
);
|
|
787
|
+
plugin.validationErrors = updated.errors;
|
|
788
|
+
plugin.validationWarnings = updated.warnings;
|
|
789
|
+
|
|
790
|
+
// Update config.plugins.entries so the runtime loads/skips this plugin
|
|
791
|
+
if (body.enabled !== undefined) {
|
|
792
|
+
if (isAdvancedCapabilityPluginId(pluginId)) {
|
|
793
|
+
applyAdvancedCapabilitiesConfig(state.config, body.enabled);
|
|
794
|
+
for (const candidate of state.plugins) {
|
|
795
|
+
if (isAdvancedCapabilityPluginId(candidate.id)) {
|
|
796
|
+
candidate.enabled = body.enabled;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
logger.info(
|
|
801
|
+
`[eliza-api] ${body.enabled ? "Enabled" : "Disabled"} advanced capabilities via plugin alias: ${pluginId}`,
|
|
802
|
+
);
|
|
803
|
+
} else {
|
|
804
|
+
const packageName = `@elizaos/plugin-${pluginId}`;
|
|
805
|
+
|
|
806
|
+
if (!state.config.plugins) {
|
|
807
|
+
state.config.plugins = {};
|
|
808
|
+
}
|
|
809
|
+
if (!state.config.plugins.entries) {
|
|
810
|
+
(state.config.plugins as Record<string, unknown>).entries = {};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const entries = (state.config.plugins as Record<string, unknown>)
|
|
814
|
+
.entries as Record<string, Record<string, unknown>>;
|
|
815
|
+
entries[pluginId] = { enabled: body.enabled };
|
|
816
|
+
|
|
817
|
+
// Keep plugins.allow aligned with entries[pluginId].enabled so the
|
|
818
|
+
// enable-state drift check in buildCoreToggleDiagnostics() stays clean.
|
|
819
|
+
state.config.plugins.allow = state.config.plugins.allow ?? [];
|
|
820
|
+
const allow = state.config.plugins.allow;
|
|
821
|
+
if (body.enabled) {
|
|
822
|
+
if (!allow.includes(pluginId) && !allow.includes(packageName)) {
|
|
823
|
+
allow.push(pluginId);
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
state.config.plugins.allow = allow.filter(
|
|
827
|
+
(p: string) => p !== pluginId && p !== packageName,
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
logger.info(
|
|
832
|
+
`[eliza-api] ${body.enabled ? "Enabled" : "Disabled"} plugin: ${packageName}`,
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Persist capability toggle state in config.features so the runtime
|
|
837
|
+
// can gate related behaviour (e.g. disabling image description when
|
|
838
|
+
// vision is toggled off).
|
|
839
|
+
const CAPABILITY_FEATURE_IDS = new Set([
|
|
840
|
+
"vision",
|
|
841
|
+
"browser",
|
|
842
|
+
"computeruse",
|
|
843
|
+
"coding-agent",
|
|
844
|
+
]);
|
|
845
|
+
if (CAPABILITY_FEATURE_IDS.has(pluginId)) {
|
|
846
|
+
if (!state.config.features) {
|
|
847
|
+
state.config.features = {};
|
|
848
|
+
}
|
|
849
|
+
state.config.features[pluginId] = body.enabled;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Save updated config
|
|
853
|
+
try {
|
|
854
|
+
saveElizaConfig(state.config);
|
|
855
|
+
} catch (err) {
|
|
856
|
+
logger.warn(
|
|
857
|
+
`[eliza-api] Failed to save config: ${err instanceof Error ? err.message : err}`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
863
|
+
runtime: state.runtime,
|
|
864
|
+
previousConfig,
|
|
865
|
+
nextConfig: state.config,
|
|
866
|
+
previousResolvedPlugins,
|
|
867
|
+
changedPluginId: pluginId,
|
|
868
|
+
changedPluginPackage: plugin.npmName,
|
|
869
|
+
config: body.config,
|
|
870
|
+
expectRuntimeGraphChange: body.enabled !== undefined,
|
|
871
|
+
reason:
|
|
872
|
+
body.enabled !== undefined
|
|
873
|
+
? `Plugin toggle: ${pluginId}`
|
|
874
|
+
: `Plugin config updated: ${pluginId}`,
|
|
875
|
+
restartRuntime,
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
if (runtimeApply.requiresRestart) {
|
|
879
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
if (isElizaSettingsDebugEnabled()) {
|
|
883
|
+
const cloud = (state.config as Record<string, unknown>).cloud as
|
|
884
|
+
| Record<string, unknown>
|
|
885
|
+
| undefined;
|
|
886
|
+
logger.debug(
|
|
887
|
+
`[eliza][settings][api] PUT /api/plugins/${pluginId} → done configured=${plugin.configured} enabled=${plugin.enabled} cloud=${JSON.stringify(settingsDebugCloudSummary(cloud))}`,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
json(res, {
|
|
892
|
+
ok: true,
|
|
893
|
+
plugin,
|
|
894
|
+
applied: runtimeApply.mode,
|
|
895
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
896
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
897
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
898
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
899
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
900
|
+
});
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── GET /api/secrets ─────────────────────────────────────────────────
|
|
905
|
+
if (method === "GET" && pathname === "/api/secrets") {
|
|
906
|
+
// Merge bundled + installed plugins for full parameter coverage
|
|
907
|
+
const bundledIds = new Set(state.plugins.map((p) => p.id));
|
|
908
|
+
const installedEntries = discoverInstalledPlugins(state.config, bundledIds);
|
|
909
|
+
const allPlugins: PluginEntry[] = [...state.plugins, ...installedEntries];
|
|
910
|
+
|
|
911
|
+
// Sync enabled status from runtime (same logic as GET /api/plugins)
|
|
912
|
+
if (state.runtime) {
|
|
913
|
+
const loadedNames = state.runtime.plugins.map((p) => p.name);
|
|
914
|
+
for (const plugin of allPlugins) {
|
|
915
|
+
const suffix = `plugin-${plugin.id}`;
|
|
916
|
+
const packageName = `@elizaos/plugin-${plugin.id}`;
|
|
917
|
+
plugin.enabled = loadedNames.some(
|
|
918
|
+
(name) =>
|
|
919
|
+
name === plugin.id ||
|
|
920
|
+
name === suffix ||
|
|
921
|
+
name === packageName ||
|
|
922
|
+
name.endsWith(`/${suffix}`) ||
|
|
923
|
+
name.includes(plugin.id),
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const secrets = aggregateSecrets(allPlugins);
|
|
929
|
+
json(res, { secrets });
|
|
930
|
+
return true;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ── PUT /api/secrets ─────────────────────────────────────────────────
|
|
934
|
+
if (method === "PUT" && pathname === "/api/secrets") {
|
|
935
|
+
const body = await readJsonBody<{ secrets: Record<string, string> }>(
|
|
936
|
+
req,
|
|
937
|
+
res,
|
|
938
|
+
);
|
|
939
|
+
if (!body) return true;
|
|
940
|
+
if (!body.secrets || typeof body.secrets !== "object") {
|
|
941
|
+
error(res, "Missing or invalid 'secrets' object", 400);
|
|
942
|
+
return true;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Build allowlist from all plugin-declared sensitive params
|
|
946
|
+
const bundledIds = new Set(state.plugins.map((p) => p.id));
|
|
947
|
+
const installedEntries = discoverInstalledPlugins(state.config, bundledIds);
|
|
948
|
+
const allPlugins: PluginEntry[] = [...state.plugins, ...installedEntries];
|
|
949
|
+
const allowedKeys = new Set<string>();
|
|
950
|
+
for (const plugin of allPlugins) {
|
|
951
|
+
for (const param of plugin.parameters) {
|
|
952
|
+
if (param.sensitive) allowedKeys.add(param.key);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const updatedKeys: string[] = [];
|
|
957
|
+
for (const [key, value] of Object.entries(body.secrets)) {
|
|
958
|
+
if (typeof value !== "string" || !value.trim()) continue;
|
|
959
|
+
if (!allowedKeys.has(key)) continue;
|
|
960
|
+
if (BLOCKED_ENV_KEYS.has(key.toUpperCase())) continue;
|
|
961
|
+
process.env[key] = value;
|
|
962
|
+
updatedKeys.push(key);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// Mark affected plugins as configured
|
|
966
|
+
for (const plugin of allPlugins) {
|
|
967
|
+
const pluginKeys = new Set(plugin.parameters.map((p) => p.key));
|
|
968
|
+
if (updatedKeys.some((k) => pluginKeys.has(k))) {
|
|
969
|
+
plugin.configured = true;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
json(res, { ok: true, updated: updatedKeys });
|
|
974
|
+
return true;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// ── PUT /api/integrations/tavily-key ─────────────────────────────────
|
|
978
|
+
// Dedicated endpoint for the Tavily search API key used by the WEB_SEARCH
|
|
979
|
+
// action (@tokagent/plugin-web-fetch). Bypasses the plugin-discovery
|
|
980
|
+
// allowlist used by /api/secrets and /api/plugins/:id because the
|
|
981
|
+
// web-fetch plugin is a workspace plugin not in the bundled manifest —
|
|
982
|
+
// it loads at runtime via TOKAGENT_PLUGINS, but its agentConfig.pluginParameters
|
|
983
|
+
// are never seen by the discovery layer. Writes TAVILY_API_KEY to
|
|
984
|
+
// config.env (survives restarts) AND to process.env (live for the
|
|
985
|
+
// current process), then schedules a runtime restart so any cached
|
|
986
|
+
// settings refresh.
|
|
987
|
+
if (method === "PUT" && pathname === "/api/integrations/tavily-key") {
|
|
988
|
+
const body = await readJsonBody<{ apiKey?: unknown }>(req, res);
|
|
989
|
+
if (!body) return true;
|
|
990
|
+
const raw = typeof body.apiKey === "string" ? body.apiKey.trim() : "";
|
|
991
|
+
if (!raw) {
|
|
992
|
+
error(res, "Missing 'apiKey' (non-empty string)", 400);
|
|
993
|
+
return true;
|
|
994
|
+
}
|
|
995
|
+
// Tavily keys start with `tvly-` per their docs; reject obvious typos
|
|
996
|
+
// early so the user gets a clear error before we persist garbage.
|
|
997
|
+
if (!/^tvly-[A-Za-z0-9_-]{8,}$/.test(raw)) {
|
|
998
|
+
error(
|
|
999
|
+
res,
|
|
1000
|
+
"TAVILY_API_KEY must start with 'tvly-' followed by 8+ alphanumeric characters. Get a free key at https://app.tavily.com/sign-in.",
|
|
1001
|
+
400,
|
|
1002
|
+
);
|
|
1003
|
+
return true;
|
|
1004
|
+
}
|
|
1005
|
+
let writtenPath: string;
|
|
1006
|
+
try {
|
|
1007
|
+
writtenPath = await writeProjectEnvVar("TAVILY_API_KEY", raw);
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
error(
|
|
1010
|
+
res,
|
|
1011
|
+
`Failed to write TAVILY_API_KEY to project .env: ${err instanceof Error ? err.message : String(err)}`,
|
|
1012
|
+
500,
|
|
1013
|
+
);
|
|
1014
|
+
return true;
|
|
1015
|
+
}
|
|
1016
|
+
// Make it live immediately for the running process so the next
|
|
1017
|
+
// WEB_SEARCH call picks it up without waiting for restart.
|
|
1018
|
+
process.env.TAVILY_API_KEY = raw;
|
|
1019
|
+
logger.info(
|
|
1020
|
+
`[integrations] TAVILY_API_KEY written to ${writtenPath} (process.env updated, restart scheduled)`,
|
|
1021
|
+
);
|
|
1022
|
+
|
|
1023
|
+
// Respond BEFORE scheduling the restart. `restartRuntime` tears down
|
|
1024
|
+
// the HTTP server mid-request — awaiting it here causes the client to
|
|
1025
|
+
// time out waiting for a response that never arrives. Fire-and-forget
|
|
1026
|
+
// the restart so the response lands first.
|
|
1027
|
+
const restartScheduled = typeof scheduleRuntimeRestart === "function";
|
|
1028
|
+
json(res, { ok: true, restartScheduled });
|
|
1029
|
+
if (restartScheduled) {
|
|
1030
|
+
// Defer to the next tick so the response is fully flushed before
|
|
1031
|
+
// the restart kicks in.
|
|
1032
|
+
setImmediate(() => {
|
|
1033
|
+
try {
|
|
1034
|
+
scheduleRuntimeRestart("tavily-key-updated");
|
|
1035
|
+
} catch (err) {
|
|
1036
|
+
logger.warn(
|
|
1037
|
+
`[integrations] scheduleRuntimeRestart failed: ${err instanceof Error ? err.message : err}`,
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
return true;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// ── POST /api/plugins/:id/test ────────────────────────────────────────
|
|
1046
|
+
// Test a plugin's connection / configuration validity.
|
|
1047
|
+
const pluginTestMatch =
|
|
1048
|
+
method === "POST" && pathname.match(/^\/api\/plugins\/([^/]+)\/test$/);
|
|
1049
|
+
if (pluginTestMatch) {
|
|
1050
|
+
const pluginId = decodeURIComponent(pluginTestMatch[1]);
|
|
1051
|
+
const startMs = Date.now();
|
|
1052
|
+
|
|
1053
|
+
try {
|
|
1054
|
+
// Find the plugin in the runtime
|
|
1055
|
+
const allPlugins = state.runtime?.plugins ?? [];
|
|
1056
|
+
const normalizePluginId = (value: string): string => {
|
|
1057
|
+
const scopedPackage = value.match(/^@[^/]+\/plugin-(.+)$/);
|
|
1058
|
+
if (scopedPackage) {
|
|
1059
|
+
return scopedPackage[1] ?? value;
|
|
1060
|
+
}
|
|
1061
|
+
return value.replace(/^@[^/]+\//, "").replace(/^plugin-/, "");
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
const normalizedPluginId = normalizePluginId(pluginId);
|
|
1065
|
+
|
|
1066
|
+
const plugin = allPlugins.find((p: { id?: string; name?: string }) => {
|
|
1067
|
+
const runtimeName = p.name ?? "";
|
|
1068
|
+
const runtimeId = normalizePluginId(runtimeName);
|
|
1069
|
+
return (
|
|
1070
|
+
p.id === pluginId ||
|
|
1071
|
+
p.name === pluginId ||
|
|
1072
|
+
runtimeId === pluginId ||
|
|
1073
|
+
runtimeId === normalizedPluginId
|
|
1074
|
+
);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
if (!plugin) {
|
|
1078
|
+
json(
|
|
1079
|
+
res,
|
|
1080
|
+
{
|
|
1081
|
+
success: false,
|
|
1082
|
+
pluginId,
|
|
1083
|
+
error: "Plugin not found or not loaded",
|
|
1084
|
+
durationMs: Date.now() - startMs,
|
|
1085
|
+
},
|
|
1086
|
+
404,
|
|
1087
|
+
);
|
|
1088
|
+
return true;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
// Check if plugin exposes a test/health method
|
|
1092
|
+
const testFn = getPluginHealthProbe(plugin);
|
|
1093
|
+
if (typeof testFn === "function") {
|
|
1094
|
+
const result = await testFn();
|
|
1095
|
+
json(res, {
|
|
1096
|
+
success: result.ok !== false,
|
|
1097
|
+
pluginId,
|
|
1098
|
+
message:
|
|
1099
|
+
result.message ??
|
|
1100
|
+
(result.ok !== false
|
|
1101
|
+
? "Connection successful"
|
|
1102
|
+
: "Connection failed"),
|
|
1103
|
+
durationMs: Date.now() - startMs,
|
|
1104
|
+
});
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// No test function — return a basic "plugin is loaded" status
|
|
1109
|
+
json(res, {
|
|
1110
|
+
success: true,
|
|
1111
|
+
pluginId,
|
|
1112
|
+
message: "Plugin is loaded and active (no custom test available)",
|
|
1113
|
+
durationMs: Date.now() - startMs,
|
|
1114
|
+
});
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
json(
|
|
1117
|
+
res,
|
|
1118
|
+
{
|
|
1119
|
+
success: false,
|
|
1120
|
+
pluginId,
|
|
1121
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1122
|
+
durationMs: Date.now() - startMs,
|
|
1123
|
+
},
|
|
1124
|
+
500,
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// ── POST /api/plugins/install ───────────────────────────────────────────
|
|
1131
|
+
// Install a plugin from the registry and restart the agent.
|
|
1132
|
+
if (method === "POST" && pathname === "/api/plugins/install") {
|
|
1133
|
+
const body = await readJsonBody<{
|
|
1134
|
+
name: string;
|
|
1135
|
+
autoRestart?: boolean;
|
|
1136
|
+
stream?: "latest" | "alpha";
|
|
1137
|
+
version?: string;
|
|
1138
|
+
}>(req, res);
|
|
1139
|
+
if (!body) return true;
|
|
1140
|
+
const pluginName = body.name?.trim();
|
|
1141
|
+
|
|
1142
|
+
if (!pluginName) {
|
|
1143
|
+
error(res, "Request body must include 'name' (plugin package name)", 400);
|
|
1144
|
+
return true;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const installValidationError =
|
|
1148
|
+
validateRegistryPluginPackageName(pluginName);
|
|
1149
|
+
if (installValidationError) {
|
|
1150
|
+
error(res, installValidationError, 400);
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
try {
|
|
1155
|
+
const previousConfig = structuredClone(state.config);
|
|
1156
|
+
const previousResolvedPlugins = state.runtime
|
|
1157
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin install")
|
|
1158
|
+
: undefined;
|
|
1159
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1160
|
+
const result = await pluginManager.installPlugin(
|
|
1161
|
+
pluginName,
|
|
1162
|
+
(progress: InstallProgressLike) => {
|
|
1163
|
+
logger.info(`[install] ${progress.phase}: ${progress.message}`);
|
|
1164
|
+
state.broadcastWs?.({
|
|
1165
|
+
type: "install-progress",
|
|
1166
|
+
pluginName: progress.pluginName,
|
|
1167
|
+
phase: progress.phase,
|
|
1168
|
+
message: progress.message,
|
|
1169
|
+
});
|
|
1170
|
+
},
|
|
1171
|
+
{
|
|
1172
|
+
releaseStream: body.stream,
|
|
1173
|
+
version: body.version,
|
|
1174
|
+
},
|
|
1175
|
+
);
|
|
1176
|
+
|
|
1177
|
+
if (!result.success) {
|
|
1178
|
+
json(res, { ok: false, error: result.error }, 422);
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Auto-enable the newly installed plugin so the runtime loads it after restart.
|
|
1183
|
+
const installedId = (result.pluginName ?? pluginName)
|
|
1184
|
+
.replace(/^@[^/]+\/plugin-/, "")
|
|
1185
|
+
.replace(/^@[^/]+\//, "")
|
|
1186
|
+
.replace(/^plugin-/, "");
|
|
1187
|
+
if (!state.config.plugins) {
|
|
1188
|
+
state.config.plugins = {};
|
|
1189
|
+
}
|
|
1190
|
+
if (!state.config.plugins.entries) {
|
|
1191
|
+
(state.config.plugins as Record<string, unknown>).entries = {};
|
|
1192
|
+
}
|
|
1193
|
+
const pluginEntries = (state.config.plugins as Record<string, unknown>)
|
|
1194
|
+
.entries as Record<string, Record<string, unknown>>;
|
|
1195
|
+
pluginEntries[installedId] = { enabled: true };
|
|
1196
|
+
|
|
1197
|
+
// Record the install path so plugin-resolver can find the package.
|
|
1198
|
+
// Without this, the downloaded package in ~/.eliza/plugins/installed/
|
|
1199
|
+
// is invisible to the runtime loader.
|
|
1200
|
+
if (result.installPath) {
|
|
1201
|
+
if (
|
|
1202
|
+
!(state.config.plugins as Record<string, unknown>).installs ||
|
|
1203
|
+
typeof (state.config.plugins as Record<string, unknown>).installs !==
|
|
1204
|
+
"object"
|
|
1205
|
+
) {
|
|
1206
|
+
(state.config.plugins as Record<string, unknown>).installs = {};
|
|
1207
|
+
}
|
|
1208
|
+
const installs = (state.config.plugins as Record<string, unknown>)
|
|
1209
|
+
.installs as Record<string, Record<string, unknown>>;
|
|
1210
|
+
installs[result.pluginName ?? pluginName] = {
|
|
1211
|
+
source: "npm",
|
|
1212
|
+
requestedVersion: result.requestedVersion,
|
|
1213
|
+
releaseStream: result.releaseStream,
|
|
1214
|
+
installPath: result.installPath,
|
|
1215
|
+
version: result.version ?? "unknown",
|
|
1216
|
+
installedAt: new Date().toISOString(),
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
try {
|
|
1221
|
+
saveElizaConfig(state.config);
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
logger.warn(
|
|
1224
|
+
`[eliza-api] Failed to save config after install: ${err instanceof Error ? err.message : err}`,
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1229
|
+
runtime: state.runtime,
|
|
1230
|
+
previousConfig,
|
|
1231
|
+
nextConfig: state.config,
|
|
1232
|
+
previousResolvedPlugins,
|
|
1233
|
+
changedPluginId: installedId,
|
|
1234
|
+
changedPluginPackage: result.pluginName,
|
|
1235
|
+
expectRuntimeGraphChange: true,
|
|
1236
|
+
reason: `Plugin ${result.pluginName} installed`,
|
|
1237
|
+
restartRuntime,
|
|
1238
|
+
});
|
|
1239
|
+
|
|
1240
|
+
if (runtimeApply.requiresRestart && body.autoRestart !== false) {
|
|
1241
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
json(res, {
|
|
1245
|
+
ok: true,
|
|
1246
|
+
pluginName: result.pluginName,
|
|
1247
|
+
plugin: {
|
|
1248
|
+
name: result.pluginName,
|
|
1249
|
+
version: result.version,
|
|
1250
|
+
installPath: result.installPath,
|
|
1251
|
+
},
|
|
1252
|
+
applied: runtimeApply.mode,
|
|
1253
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1254
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1255
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1256
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1257
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1258
|
+
releaseStream: result.releaseStream,
|
|
1259
|
+
requestedVersion: result.requestedVersion,
|
|
1260
|
+
latestVersion: result.latestVersion,
|
|
1261
|
+
alphaVersion: result.alphaVersion,
|
|
1262
|
+
message: runtimeApply.requiresRestart
|
|
1263
|
+
? `${result.pluginName} installed. Restart required to activate.`
|
|
1264
|
+
: `${result.pluginName} installed.`,
|
|
1265
|
+
});
|
|
1266
|
+
} catch (err) {
|
|
1267
|
+
error(
|
|
1268
|
+
res,
|
|
1269
|
+
`Install failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1270
|
+
500,
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
return true;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ── POST /api/plugins/update ────────────────────────────────────────────
|
|
1277
|
+
if (method === "POST" && pathname === "/api/plugins/update") {
|
|
1278
|
+
const body = await readJsonBody<{
|
|
1279
|
+
name: string;
|
|
1280
|
+
autoRestart?: boolean;
|
|
1281
|
+
stream?: "latest" | "alpha";
|
|
1282
|
+
version?: string;
|
|
1283
|
+
}>(req, res);
|
|
1284
|
+
if (!body) return true;
|
|
1285
|
+
const pluginName = body.name?.trim();
|
|
1286
|
+
|
|
1287
|
+
const updateValidationError = validateRegistryPluginPackageName(
|
|
1288
|
+
pluginName ?? "",
|
|
1289
|
+
);
|
|
1290
|
+
if (updateValidationError) {
|
|
1291
|
+
error(res, updateValidationError, 400);
|
|
1292
|
+
return true;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
try {
|
|
1296
|
+
const previousConfig = structuredClone(state.config);
|
|
1297
|
+
const previousResolvedPlugins = state.runtime
|
|
1298
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin update")
|
|
1299
|
+
: undefined;
|
|
1300
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1301
|
+
const updatePlugin =
|
|
1302
|
+
typeof pluginManager.updatePlugin === "function"
|
|
1303
|
+
? pluginManager.updatePlugin.bind(pluginManager)
|
|
1304
|
+
: pluginManager.installPlugin.bind(pluginManager);
|
|
1305
|
+
const result = await updatePlugin(
|
|
1306
|
+
pluginName,
|
|
1307
|
+
(progress: InstallProgressLike) => {
|
|
1308
|
+
logger.info(`[update] ${progress.phase}: ${progress.message}`);
|
|
1309
|
+
state.broadcastWs?.({
|
|
1310
|
+
type: "install-progress",
|
|
1311
|
+
pluginName: progress.pluginName,
|
|
1312
|
+
phase: progress.phase,
|
|
1313
|
+
message: progress.message,
|
|
1314
|
+
});
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
releaseStream: body.stream,
|
|
1318
|
+
version: body.version,
|
|
1319
|
+
},
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
if (!result.success) {
|
|
1323
|
+
json(res, { ok: false, error: result.error }, 422);
|
|
1324
|
+
return true;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (!state.config.plugins) {
|
|
1328
|
+
state.config.plugins = {};
|
|
1329
|
+
}
|
|
1330
|
+
if (!state.config.plugins.entries) {
|
|
1331
|
+
state.config.plugins.entries = {};
|
|
1332
|
+
}
|
|
1333
|
+
const updatedId = (result.pluginName ?? pluginName)
|
|
1334
|
+
.replace(/^@[^/]+\/plugin-/, "")
|
|
1335
|
+
.replace(/^@[^/]+\//, "")
|
|
1336
|
+
.replace(/^plugin-/, "");
|
|
1337
|
+
state.config.plugins.entries[updatedId] = { enabled: true };
|
|
1338
|
+
state.config.plugins.installs = state.config.plugins.installs ?? {};
|
|
1339
|
+
state.config.plugins.installs[result.pluginName ?? pluginName] = {
|
|
1340
|
+
source: "npm",
|
|
1341
|
+
requestedVersion: result.requestedVersion,
|
|
1342
|
+
releaseStream: result.releaseStream,
|
|
1343
|
+
installPath: result.installPath,
|
|
1344
|
+
version: result.version ?? "unknown",
|
|
1345
|
+
installedAt: new Date().toISOString(),
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
try {
|
|
1349
|
+
saveElizaConfig(state.config);
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
logger.warn(
|
|
1352
|
+
`[eliza-api] Failed to save config after update: ${err instanceof Error ? err.message : err}`,
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1357
|
+
runtime: state.runtime,
|
|
1358
|
+
previousConfig,
|
|
1359
|
+
nextConfig: state.config,
|
|
1360
|
+
previousResolvedPlugins,
|
|
1361
|
+
changedPluginId: updatedId,
|
|
1362
|
+
changedPluginPackage: result.pluginName,
|
|
1363
|
+
forceReloadPackages: [result.pluginName],
|
|
1364
|
+
expectRuntimeGraphChange: true,
|
|
1365
|
+
reason: `Plugin ${result.pluginName} updated`,
|
|
1366
|
+
restartRuntime,
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
if (runtimeApply.requiresRestart && body.autoRestart !== false) {
|
|
1370
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
json(res, {
|
|
1374
|
+
ok: true,
|
|
1375
|
+
pluginName: result.pluginName,
|
|
1376
|
+
plugin: {
|
|
1377
|
+
name: result.pluginName,
|
|
1378
|
+
version: result.version,
|
|
1379
|
+
installPath: result.installPath,
|
|
1380
|
+
},
|
|
1381
|
+
applied: runtimeApply.mode,
|
|
1382
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1383
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1384
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1385
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1386
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1387
|
+
releaseStream: result.releaseStream,
|
|
1388
|
+
requestedVersion: result.requestedVersion,
|
|
1389
|
+
latestVersion: result.latestVersion,
|
|
1390
|
+
alphaVersion: result.alphaVersion,
|
|
1391
|
+
message: runtimeApply.requiresRestart
|
|
1392
|
+
? `${result.pluginName} updated. Restart required to activate.`
|
|
1393
|
+
: `${result.pluginName} updated.`,
|
|
1394
|
+
});
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
error(
|
|
1397
|
+
res,
|
|
1398
|
+
`Update failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1399
|
+
500,
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
return true;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// ── POST /api/plugins/uninstall ─────────────────────────────────────────
|
|
1406
|
+
if (method === "POST" && pathname === "/api/plugins/uninstall") {
|
|
1407
|
+
const body = await readJsonBody<{ name: string; autoRestart?: boolean }>(
|
|
1408
|
+
req,
|
|
1409
|
+
res,
|
|
1410
|
+
);
|
|
1411
|
+
if (!body) return true;
|
|
1412
|
+
const pluginName = body.name?.trim();
|
|
1413
|
+
|
|
1414
|
+
const uninstallValidationError = validateRegistryPluginPackageName(
|
|
1415
|
+
pluginName ?? "",
|
|
1416
|
+
);
|
|
1417
|
+
if (uninstallValidationError) {
|
|
1418
|
+
error(res, uninstallValidationError, 400);
|
|
1419
|
+
return true;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
try {
|
|
1423
|
+
const previousConfig = structuredClone(state.config);
|
|
1424
|
+
const previousResolvedPlugins = state.runtime
|
|
1425
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin uninstall")
|
|
1426
|
+
: undefined;
|
|
1427
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1428
|
+
const result = await pluginManager.uninstallPlugin(pluginName);
|
|
1429
|
+
|
|
1430
|
+
if (!result.success) {
|
|
1431
|
+
json(res, { ok: false, error: result.error }, 422);
|
|
1432
|
+
return true;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const removedId = (result.pluginName ?? pluginName)
|
|
1436
|
+
.replace(/^@[^/]+\/plugin-/, "")
|
|
1437
|
+
.replace(/^@[^/]+\//, "")
|
|
1438
|
+
.replace(/^plugin-/, "");
|
|
1439
|
+
const installs = state.config.plugins?.installs;
|
|
1440
|
+
if (installs && typeof installs === "object") {
|
|
1441
|
+
delete installs[result.pluginName ?? pluginName];
|
|
1442
|
+
}
|
|
1443
|
+
const entries = state.config.plugins?.entries;
|
|
1444
|
+
if (entries && typeof entries === "object") {
|
|
1445
|
+
delete entries[removedId];
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
try {
|
|
1449
|
+
saveElizaConfig(state.config);
|
|
1450
|
+
} catch (err) {
|
|
1451
|
+
logger.warn(
|
|
1452
|
+
`[eliza-api] Failed to save config after uninstall: ${err instanceof Error ? err.message : err}`,
|
|
1453
|
+
);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1457
|
+
runtime: state.runtime,
|
|
1458
|
+
previousConfig,
|
|
1459
|
+
nextConfig: state.config,
|
|
1460
|
+
previousResolvedPlugins,
|
|
1461
|
+
changedPluginId: removedId,
|
|
1462
|
+
changedPluginPackage: result.pluginName,
|
|
1463
|
+
expectRuntimeGraphChange: true,
|
|
1464
|
+
reason: `Plugin ${pluginName} uninstalled`,
|
|
1465
|
+
restartRuntime,
|
|
1466
|
+
});
|
|
1467
|
+
|
|
1468
|
+
if (runtimeApply.requiresRestart && body.autoRestart !== false) {
|
|
1469
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
json(res, {
|
|
1473
|
+
ok: true,
|
|
1474
|
+
pluginName: result.pluginName,
|
|
1475
|
+
applied: runtimeApply.mode,
|
|
1476
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1477
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1478
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1479
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1480
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1481
|
+
message: runtimeApply.requiresRestart
|
|
1482
|
+
? `${pluginName} uninstalled. Restart required.`
|
|
1483
|
+
: `${pluginName} uninstalled.`,
|
|
1484
|
+
});
|
|
1485
|
+
} catch (err) {
|
|
1486
|
+
error(
|
|
1487
|
+
res,
|
|
1488
|
+
`Uninstall failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1489
|
+
500,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
return true;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ── POST /api/plugins/:id/eject ─────────────────────────────────────────
|
|
1496
|
+
if (method === "POST" && pathname.match(/^\/api\/plugins\/[^/]+\/eject$/)) {
|
|
1497
|
+
const pluginName = decodeURIComponent(
|
|
1498
|
+
pathname.slice("/api/plugins/".length, pathname.length - "/eject".length),
|
|
1499
|
+
);
|
|
1500
|
+
try {
|
|
1501
|
+
const previousConfig = structuredClone(state.config);
|
|
1502
|
+
const previousResolvedPlugins = state.runtime
|
|
1503
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin eject")
|
|
1504
|
+
: undefined;
|
|
1505
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1506
|
+
// Ensure the method exists on the service (it should)
|
|
1507
|
+
if (typeof pluginManager.ejectPlugin !== "function") {
|
|
1508
|
+
throw new Error("Plugin manager does not support ejecting plugins");
|
|
1509
|
+
}
|
|
1510
|
+
const result = await pluginManager.ejectPlugin(pluginName);
|
|
1511
|
+
if (!result.success) {
|
|
1512
|
+
json(res, { ok: false, error: result.error }, 422);
|
|
1513
|
+
return true;
|
|
1514
|
+
}
|
|
1515
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1516
|
+
runtime: state.runtime,
|
|
1517
|
+
previousConfig,
|
|
1518
|
+
nextConfig: state.config,
|
|
1519
|
+
previousResolvedPlugins,
|
|
1520
|
+
changedPluginId: pluginName,
|
|
1521
|
+
changedPluginPackage: result.pluginName,
|
|
1522
|
+
forceReloadPackages: [result.pluginName],
|
|
1523
|
+
expectRuntimeGraphChange: true,
|
|
1524
|
+
reason: `Plugin ${pluginName} ejected`,
|
|
1525
|
+
restartRuntime,
|
|
1526
|
+
});
|
|
1527
|
+
if (runtimeApply.requiresRestart) {
|
|
1528
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1529
|
+
}
|
|
1530
|
+
json(res, {
|
|
1531
|
+
ok: true,
|
|
1532
|
+
pluginName: result.pluginName,
|
|
1533
|
+
applied: runtimeApply.mode,
|
|
1534
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1535
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1536
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1537
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1538
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1539
|
+
message: `${pluginName} ejected to local source.`,
|
|
1540
|
+
});
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
error(
|
|
1543
|
+
res,
|
|
1544
|
+
`Eject failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1545
|
+
500,
|
|
1546
|
+
);
|
|
1547
|
+
}
|
|
1548
|
+
return true;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ── POST /api/plugins/:id/sync ──────────────────────────────────────────
|
|
1552
|
+
if (method === "POST" && pathname.match(/^\/api\/plugins\/[^/]+\/sync$/)) {
|
|
1553
|
+
const pluginName = decodeURIComponent(
|
|
1554
|
+
pathname.slice("/api/plugins/".length, pathname.length - "/sync".length),
|
|
1555
|
+
);
|
|
1556
|
+
try {
|
|
1557
|
+
const previousConfig = structuredClone(state.config);
|
|
1558
|
+
const previousResolvedPlugins = state.runtime
|
|
1559
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin sync")
|
|
1560
|
+
: undefined;
|
|
1561
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1562
|
+
if (typeof pluginManager.syncPlugin !== "function") {
|
|
1563
|
+
throw new Error("Plugin manager does not support syncing plugins");
|
|
1564
|
+
}
|
|
1565
|
+
const result = await pluginManager.syncPlugin(pluginName);
|
|
1566
|
+
if (!result.success) {
|
|
1567
|
+
json(res, { ok: false, error: result.error }, 422);
|
|
1568
|
+
return true;
|
|
1569
|
+
}
|
|
1570
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1571
|
+
runtime: state.runtime,
|
|
1572
|
+
previousConfig,
|
|
1573
|
+
nextConfig: state.config,
|
|
1574
|
+
previousResolvedPlugins,
|
|
1575
|
+
changedPluginId: pluginName,
|
|
1576
|
+
changedPluginPackage: result.pluginName,
|
|
1577
|
+
forceReloadPackages: result.requiresRestart ? [result.pluginName] : [],
|
|
1578
|
+
expectRuntimeGraphChange: true,
|
|
1579
|
+
reason: `Plugin ${pluginName} synced`,
|
|
1580
|
+
restartRuntime,
|
|
1581
|
+
});
|
|
1582
|
+
if (runtimeApply.requiresRestart) {
|
|
1583
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1584
|
+
}
|
|
1585
|
+
json(res, {
|
|
1586
|
+
ok: true,
|
|
1587
|
+
pluginName: result.pluginName,
|
|
1588
|
+
applied: runtimeApply.mode,
|
|
1589
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1590
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1591
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1592
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1593
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1594
|
+
message: `${pluginName} synced with upstream.`,
|
|
1595
|
+
});
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
error(
|
|
1598
|
+
res,
|
|
1599
|
+
`Sync failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1600
|
+
500,
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
return true;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
// ── POST /api/plugins/:id/reinject ──────────────────────────────────────
|
|
1607
|
+
if (
|
|
1608
|
+
method === "POST" &&
|
|
1609
|
+
pathname.match(/^\/api\/plugins\/[^/]+\/reinject$/)
|
|
1610
|
+
) {
|
|
1611
|
+
const pluginName = decodeURIComponent(
|
|
1612
|
+
pathname.slice(
|
|
1613
|
+
"/api/plugins/".length,
|
|
1614
|
+
pathname.length - "/reinject".length,
|
|
1615
|
+
),
|
|
1616
|
+
);
|
|
1617
|
+
try {
|
|
1618
|
+
const previousConfig = structuredClone(state.config);
|
|
1619
|
+
const previousResolvedPlugins = state.runtime
|
|
1620
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "plugin reinject")
|
|
1621
|
+
: undefined;
|
|
1622
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1623
|
+
if (typeof pluginManager.reinjectPlugin !== "function") {
|
|
1624
|
+
throw new Error("Plugin manager does not support reinjecting plugins");
|
|
1625
|
+
}
|
|
1626
|
+
const result = await pluginManager.reinjectPlugin(pluginName);
|
|
1627
|
+
if (!result.success) {
|
|
1628
|
+
json(res, { ok: false, error: result.error }, 422);
|
|
1629
|
+
return true;
|
|
1630
|
+
}
|
|
1631
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1632
|
+
runtime: state.runtime,
|
|
1633
|
+
previousConfig,
|
|
1634
|
+
nextConfig: state.config,
|
|
1635
|
+
previousResolvedPlugins,
|
|
1636
|
+
changedPluginId: pluginName,
|
|
1637
|
+
changedPluginPackage: result.pluginName,
|
|
1638
|
+
forceReloadPackages: [result.pluginName],
|
|
1639
|
+
expectRuntimeGraphChange: true,
|
|
1640
|
+
reason: `Plugin ${pluginName} reinjected`,
|
|
1641
|
+
restartRuntime,
|
|
1642
|
+
});
|
|
1643
|
+
if (runtimeApply.requiresRestart) {
|
|
1644
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1645
|
+
}
|
|
1646
|
+
json(res, {
|
|
1647
|
+
ok: true,
|
|
1648
|
+
pluginName: result.pluginName,
|
|
1649
|
+
applied: runtimeApply.mode,
|
|
1650
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1651
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1652
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1653
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1654
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1655
|
+
message: `${pluginName} restored to registry version.`,
|
|
1656
|
+
});
|
|
1657
|
+
} catch (err) {
|
|
1658
|
+
error(
|
|
1659
|
+
res,
|
|
1660
|
+
`Reinject failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1661
|
+
500,
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
return true;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// ── GET /api/plugins/installed ──────────────────────────────────────────
|
|
1668
|
+
// List plugins that were installed from the registry at runtime.
|
|
1669
|
+
if (method === "GET" && pathname === "/api/plugins/installed") {
|
|
1670
|
+
try {
|
|
1671
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1672
|
+
const installed = await pluginManager.listInstalledPlugins();
|
|
1673
|
+
json(res, { count: installed.length, plugins: installed });
|
|
1674
|
+
} catch (err) {
|
|
1675
|
+
error(
|
|
1676
|
+
res,
|
|
1677
|
+
`Failed to list installed plugins: ${err instanceof Error ? err.message : String(err)}`,
|
|
1678
|
+
500,
|
|
1679
|
+
);
|
|
1680
|
+
}
|
|
1681
|
+
return true;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// ── GET /api/plugins/ejected ────────────────────────────────────────────
|
|
1685
|
+
// List plugins ejected to local source checkouts with upstream metadata.
|
|
1686
|
+
if (method === "GET" && pathname === "/api/plugins/ejected") {
|
|
1687
|
+
try {
|
|
1688
|
+
const pluginManager = requirePluginManager(state.runtime);
|
|
1689
|
+
if (typeof pluginManager.listEjectedPlugins !== "function") {
|
|
1690
|
+
throw new Error(
|
|
1691
|
+
"Plugin manager does not support listing ejected plugins",
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
const plugins = await pluginManager.listEjectedPlugins();
|
|
1695
|
+
json(res, { count: plugins.length, plugins });
|
|
1696
|
+
} catch (err) {
|
|
1697
|
+
error(
|
|
1698
|
+
res,
|
|
1699
|
+
`Failed to list ejected plugins: ${err instanceof Error ? err.message : String(err)}`,
|
|
1700
|
+
500,
|
|
1701
|
+
);
|
|
1702
|
+
}
|
|
1703
|
+
return true;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// ── GET /api/core/status ────────────────────────────────────────────────
|
|
1707
|
+
// Returns whether @elizaos/core is ejected or resolved from npm.
|
|
1708
|
+
if (method === "GET" && pathname === "/api/core/status") {
|
|
1709
|
+
try {
|
|
1710
|
+
const coreManager = requireCoreManager(state.runtime);
|
|
1711
|
+
const coreStatus = await coreManager.getCoreStatus();
|
|
1712
|
+
json(res, coreStatus);
|
|
1713
|
+
} catch (err) {
|
|
1714
|
+
error(
|
|
1715
|
+
res,
|
|
1716
|
+
`Failed to get core status: ${err instanceof Error ? err.message : String(err)}`,
|
|
1717
|
+
500,
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
return true;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ── GET /api/plugins/core ────────────────────────────────────────────
|
|
1724
|
+
// Returns all core and optional core plugins with their loaded/running status.
|
|
1725
|
+
if (method === "GET" && pathname === "/api/plugins/core") {
|
|
1726
|
+
// Build a set of loaded plugin names for robust matching.
|
|
1727
|
+
// Plugin internal names vary wildly (e.g. "local-ai" for plugin-local-embedding,
|
|
1728
|
+
// "eliza-coder" for plugin-code), so we check loaded names against multiple
|
|
1729
|
+
// derived forms of the npm package name.
|
|
1730
|
+
const loadedNames: Set<string> = state.runtime
|
|
1731
|
+
? new Set(state.runtime.plugins.map((p: { name: string }) => p.name))
|
|
1732
|
+
: new Set<string>();
|
|
1733
|
+
|
|
1734
|
+
const isLoaded = (npmName: string): boolean => {
|
|
1735
|
+
if (loadedNames.has(npmName)) return true;
|
|
1736
|
+
// @elizaos/plugin-foo -> plugin-foo
|
|
1737
|
+
const withoutScope = npmName.replace("@elizaos/", "");
|
|
1738
|
+
if (loadedNames.has(withoutScope)) return true;
|
|
1739
|
+
// plugin-foo -> foo
|
|
1740
|
+
const shortId = withoutScope.replace("plugin-", "");
|
|
1741
|
+
if (loadedNames.has(shortId)) return true;
|
|
1742
|
+
// Check if ANY loaded name contains the short id or vice versa
|
|
1743
|
+
for (const n of loadedNames) {
|
|
1744
|
+
if (n.includes(shortId) || shortId.includes(n)) return true;
|
|
1745
|
+
}
|
|
1746
|
+
return false;
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
// Check which optional plugins are currently in the allow list
|
|
1750
|
+
const allowList = new Set(state.config.plugins?.allow ?? []);
|
|
1751
|
+
|
|
1752
|
+
const makeEntry = (npm: string, isCore: boolean) => {
|
|
1753
|
+
const id = optionalPluginListId(npm);
|
|
1754
|
+
return {
|
|
1755
|
+
npmName: npm,
|
|
1756
|
+
id,
|
|
1757
|
+
name: id
|
|
1758
|
+
.split("-")
|
|
1759
|
+
.map((w: string) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
1760
|
+
.join(" "),
|
|
1761
|
+
isCore,
|
|
1762
|
+
loaded: isLoaded(npm),
|
|
1763
|
+
enabled: isCore || allowList.has(npm) || allowList.has(id),
|
|
1764
|
+
};
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
const coreList = CORE_PLUGINS.map((npm: string) => makeEntry(npm, true));
|
|
1768
|
+
const optionalList = OPTIONAL_CORE_PLUGINS.map((npm: string) =>
|
|
1769
|
+
makeEntry(npm, false),
|
|
1770
|
+
);
|
|
1771
|
+
|
|
1772
|
+
json(res, { core: coreList, optional: optionalList });
|
|
1773
|
+
return true;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// ── POST /api/plugins/core/toggle ─────────────────────────────────────
|
|
1777
|
+
// Enable or disable an optional core plugin by updating the allow list.
|
|
1778
|
+
if (method === "POST" && pathname === "/api/plugins/core/toggle") {
|
|
1779
|
+
const body = await readJsonBody<{ npmName: string; enabled: boolean }>(
|
|
1780
|
+
req,
|
|
1781
|
+
res,
|
|
1782
|
+
);
|
|
1783
|
+
if (!body?.npmName) return true;
|
|
1784
|
+
|
|
1785
|
+
// Only allow toggling optional plugins, not core
|
|
1786
|
+
const isCorePlugin = (CORE_PLUGINS as readonly string[]).includes(
|
|
1787
|
+
body.npmName,
|
|
1788
|
+
);
|
|
1789
|
+
if (isCorePlugin) {
|
|
1790
|
+
error(res, "Core plugins cannot be disabled");
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
const isOptional = (OPTIONAL_CORE_PLUGINS as readonly string[]).includes(
|
|
1794
|
+
body.npmName,
|
|
1795
|
+
);
|
|
1796
|
+
if (!isOptional) {
|
|
1797
|
+
error(res, "Unknown optional plugin");
|
|
1798
|
+
return true;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const previousConfig = structuredClone(state.config);
|
|
1802
|
+
const previousResolvedPlugins = state.runtime
|
|
1803
|
+
? await resolvePluginsSnapshotSafe(previousConfig, "core plugin toggle")
|
|
1804
|
+
: undefined;
|
|
1805
|
+
|
|
1806
|
+
// Update the allow list in config
|
|
1807
|
+
state.config.plugins = state.config.plugins ?? {};
|
|
1808
|
+
state.config.plugins.allow = state.config.plugins.allow ?? [];
|
|
1809
|
+
const allow = state.config.plugins.allow;
|
|
1810
|
+
const shortId = optionalPluginListId(body.npmName);
|
|
1811
|
+
|
|
1812
|
+
if (body.enabled) {
|
|
1813
|
+
if (!allow.includes(body.npmName) && !allow.includes(shortId)) {
|
|
1814
|
+
allow.push(body.npmName);
|
|
1815
|
+
}
|
|
1816
|
+
} else {
|
|
1817
|
+
state.config.plugins.allow = allow.filter(
|
|
1818
|
+
(p: string) => p !== body.npmName && p !== shortId,
|
|
1819
|
+
);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
// Keep plugins.entries.enabled aligned with the toggle so optional baked-in
|
|
1823
|
+
// plugins that use strict `enabled === true` entry rules stay consistent.
|
|
1824
|
+
const pluginsRoot = state.config.plugins as Record<string, unknown>;
|
|
1825
|
+
const prevEntries =
|
|
1826
|
+
(pluginsRoot.entries as
|
|
1827
|
+
| Record<string, { enabled?: boolean; [k: string]: unknown }>
|
|
1828
|
+
| undefined) ?? {};
|
|
1829
|
+
pluginsRoot.entries = {
|
|
1830
|
+
...prevEntries,
|
|
1831
|
+
[shortId]: {
|
|
1832
|
+
...prevEntries[shortId],
|
|
1833
|
+
enabled: body.enabled,
|
|
1834
|
+
},
|
|
1835
|
+
};
|
|
1836
|
+
|
|
1837
|
+
try {
|
|
1838
|
+
saveElizaConfig(state.config);
|
|
1839
|
+
} catch (err) {
|
|
1840
|
+
logger.warn(
|
|
1841
|
+
`[api] Config save failed: ${err instanceof Error ? err.message : err}`,
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const runtimeApply = await applyPluginRuntimeMutation({
|
|
1846
|
+
runtime: state.runtime,
|
|
1847
|
+
previousConfig,
|
|
1848
|
+
nextConfig: state.config,
|
|
1849
|
+
previousResolvedPlugins,
|
|
1850
|
+
changedPluginId: shortId,
|
|
1851
|
+
changedPluginPackage: body.npmName,
|
|
1852
|
+
expectRuntimeGraphChange: true,
|
|
1853
|
+
reason: `Plugin ${shortId} ${body.enabled ? "enabled" : "disabled"}`,
|
|
1854
|
+
restartRuntime,
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
if (runtimeApply.requiresRestart) {
|
|
1858
|
+
scheduleRuntimeRestart(runtimeApply.reason);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
json(res, {
|
|
1862
|
+
ok: true,
|
|
1863
|
+
applied: runtimeApply.mode,
|
|
1864
|
+
requiresRestart: runtimeApply.requiresRestart,
|
|
1865
|
+
restartedRuntime: runtimeApply.restartedRuntime,
|
|
1866
|
+
loadedPackages: runtimeApply.loadedPackages,
|
|
1867
|
+
unloadedPackages: runtimeApply.unloadedPackages,
|
|
1868
|
+
reloadedPackages: runtimeApply.reloadedPackages,
|
|
1869
|
+
diagnostics: (() => {
|
|
1870
|
+
const diagnostic = buildCoreToggleDiagnostics(
|
|
1871
|
+
state.config,
|
|
1872
|
+
body.npmName,
|
|
1873
|
+
);
|
|
1874
|
+
return diagnostic && diagnostic.drift_flags.length > 0
|
|
1875
|
+
? {
|
|
1876
|
+
withDrift: true,
|
|
1877
|
+
plugin: diagnostic,
|
|
1878
|
+
}
|
|
1879
|
+
: undefined;
|
|
1880
|
+
})(),
|
|
1881
|
+
message: runtimeApply.requiresRestart
|
|
1882
|
+
? `${shortId} ${body.enabled ? "enabled" : "disabled"}. Restart required.`
|
|
1883
|
+
: `${shortId} ${body.enabled ? "enabled" : "disabled"}.`,
|
|
1884
|
+
});
|
|
1885
|
+
return true;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
return false;
|
|
1889
|
+
}
|