@tokagent/tokagentos 2.0.29 → 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.
@@ -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
+ }