@lamalibre/portlama-agent 1.0.21 → 1.0.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lamalibre/portlama-agent",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
4
  "description": "Tunnel agent for Portlama — manages Chisel tunnel client as a system service",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -24,9 +24,9 @@ const DEFAULT_PANEL_PORT = 9393;
24
24
 
25
25
  /**
26
26
  * @param {string[]} args
27
- * @param {{ label: string }} options
27
+ * @param {{ label: string, json?: boolean }} options
28
28
  */
29
- export async function runPanel(args, { label }) {
29
+ export async function runPanel(args, { label, json: globalJson = false }) {
30
30
  assertSupportedPlatform();
31
31
 
32
32
  const config = await loadAgentConfig(label);
@@ -35,9 +35,10 @@ export async function runPanel(args, { label }) {
35
35
  process.exit(1);
36
36
  }
37
37
 
38
- const isJson = args.includes('--json');
38
+ const isJson = globalJson || args.includes('--json');
39
39
  const isEnable = args.includes('--enable');
40
40
  const isDisable = args.includes('--disable');
41
+ const isLocalOnly = args.includes('--local-only');
41
42
  const isStatus = args.includes('--status') || (!isEnable && !isDisable);
42
43
 
43
44
  // Parse --port flag
@@ -51,24 +52,24 @@ export async function runPanel(args, { label }) {
51
52
  }
52
53
 
53
54
  if (isEnable) {
54
- await enablePanel(label, config, port, isJson);
55
+ await enablePanel(label, config, port, isJson, isLocalOnly);
55
56
  } else if (isDisable) {
56
- await disablePanel(label, config, isJson);
57
+ await disablePanel(label, config, isJson, isLocalOnly);
57
58
  } else if (isStatus) {
58
59
  await showStatus(label, config, isJson);
59
60
  }
60
61
  }
61
62
 
62
- async function enablePanel(label, config, port, isJson) {
63
+ async function enablePanel(label, config, port, isJson, localOnly = false) {
63
64
  // 1. Check if already enabled
64
65
  const loaded = await isPanelLoaded(label);
65
66
  if (loaded) {
66
67
  if (isJson) {
67
- console.log(JSON.stringify({ error: 'Panel service already running' }));
68
+ console.log(JSON.stringify({ ok: true, alreadyRunning: true, port: config.panelPort || port }));
68
69
  } else {
69
70
  console.log(chalk.yellow('Panel service is already running.'));
70
71
  }
71
- process.exit(1);
72
+ return;
72
73
  }
73
74
 
74
75
  // 2. Generate and write service config
@@ -78,7 +79,24 @@ async function enablePanel(label, config, port, isJson) {
78
79
  // 3. Start the panel service
79
80
  await loadPanelService(label);
80
81
 
81
- // 4. Create the tunnel on the panel server
82
+ // 4. Save panel config
83
+ config.panelPort = port;
84
+ config.panelEnabled = true;
85
+ config.updatedAt = new Date().toISOString();
86
+ await saveAgentConfig(label, config);
87
+
88
+ // 5. If local-only, skip tunnel exposure and chisel restart
89
+ if (localOnly) {
90
+ if (isJson) {
91
+ console.log(JSON.stringify({ ok: true, port }));
92
+ } else {
93
+ console.log(chalk.green('\nAgent panel started locally.'));
94
+ console.log(` Port: ${chalk.cyan(port)}`);
95
+ }
96
+ return;
97
+ }
98
+
99
+ // 6. Create the tunnel on the panel server
82
100
  let tunnel;
83
101
  try {
84
102
  const result = await exposePanelTunnel(config, port);
@@ -87,6 +105,9 @@ async function enablePanel(label, config, port, isJson) {
87
105
  // Rollback: stop the panel service we just started
88
106
  await unloadPanelService(label);
89
107
  await removePanelServiceConfig(label);
108
+ config.panelEnabled = false;
109
+ delete config.panelPort;
110
+ await saveAgentConfig(label, config);
90
111
  const msg = err instanceof Error ? err.message : 'Unknown error';
91
112
  if (isJson) {
92
113
  console.log(JSON.stringify({ error: msg }));
@@ -96,13 +117,7 @@ async function enablePanel(label, config, port, isJson) {
96
117
  process.exit(1);
97
118
  }
98
119
 
99
- // 5. Save panel config
100
- config.panelPort = port;
101
- config.panelEnabled = true;
102
- config.updatedAt = new Date().toISOString();
103
- await saveAgentConfig(label, config);
104
-
105
- // 6. Update the agent's chisel service (needs new tunnel mapping)
120
+ // 7. Update the agent's chisel service (needs new tunnel mapping)
106
121
  try {
107
122
  const { fetchAgentConfig } = await import('../lib/panel-api.js');
108
123
  const { generateServiceConfig, writeServiceConfigFile } =
@@ -132,14 +147,16 @@ async function enablePanel(label, config, port, isJson) {
132
147
  }
133
148
  }
134
149
 
135
- async function disablePanel(label, config, isJson) {
136
- // 1. Retract the tunnel
137
- try {
138
- await retractPanelTunnel(config);
139
- } catch (err) {
140
- if (!isJson) {
141
- const msg = err instanceof Error ? err.message : 'Unknown error';
142
- console.log(chalk.yellow(`Warning: Could not retract panel tunnel: ${msg}`));
150
+ async function disablePanel(label, config, isJson, localOnly = false) {
151
+ // 1. Retract the tunnel (skip in local-only mode)
152
+ if (!localOnly) {
153
+ try {
154
+ await retractPanelTunnel(config);
155
+ } catch (err) {
156
+ if (!isJson) {
157
+ const msg = err instanceof Error ? err.message : 'Unknown error';
158
+ console.log(chalk.yellow(`Warning: Could not retract panel tunnel: ${msg}`));
159
+ }
143
160
  }
144
161
  }
145
162
 
@@ -149,21 +166,23 @@ async function disablePanel(label, config, isJson) {
149
166
  // 3. Remove service config
150
167
  await removePanelServiceConfig(label);
151
168
 
152
- // 4. Update chisel (remove the panel tunnel mapping)
153
- try {
154
- const { fetchAgentConfig } = await import('../lib/panel-api.js');
155
- const { generateServiceConfig, writeServiceConfigFile } =
156
- await import('../lib/service-config.js');
157
- const { unloadAgent, loadAgent } = await import('../lib/service.js');
158
- const agentConfig = await fetchAgentConfig(config);
159
- const serviceContent = generateServiceConfig(agentConfig.chiselArgs, label);
160
- await writeServiceConfigFile(serviceContent, label);
161
- await unloadAgent(label);
162
- await loadAgent(label);
163
- } catch (err) {
164
- if (!isJson) {
165
- const msg = err instanceof Error ? err.message : 'Unknown error';
166
- console.log(chalk.yellow(`Warning: Could not restart chisel: ${msg}`));
169
+ // 4. Update chisel (remove the panel tunnel mapping) — skip in local-only mode
170
+ if (!localOnly) {
171
+ try {
172
+ const { fetchAgentConfig } = await import('../lib/panel-api.js');
173
+ const { generateServiceConfig, writeServiceConfigFile } =
174
+ await import('../lib/service-config.js');
175
+ const { unloadAgent, loadAgent } = await import('../lib/service.js');
176
+ const agentConfig = await fetchAgentConfig(config);
177
+ const serviceContent = generateServiceConfig(agentConfig.chiselArgs, label);
178
+ await writeServiceConfigFile(serviceContent, label);
179
+ await unloadAgent(label);
180
+ await loadAgent(label);
181
+ } catch (err) {
182
+ if (!isJson) {
183
+ const msg = err instanceof Error ? err.message : 'Unknown error';
184
+ console.log(chalk.yellow(`Warning: Could not restart chisel: ${msg}`));
185
+ }
167
186
  }
168
187
  }
169
188
 
@@ -176,7 +195,7 @@ async function disablePanel(label, config, isJson) {
176
195
  if (isJson) {
177
196
  console.log(JSON.stringify({ ok: true }));
178
197
  } else {
179
- console.log(chalk.green('Agent panel retracted.'));
198
+ console.log(chalk.green(localOnly ? 'Agent panel stopped.' : 'Agent panel retracted.'));
180
199
  }
181
200
  }
182
201
 
@@ -1,46 +1,12 @@
1
1
  import chalk from 'chalk';
2
- import { execa } from 'execa';
3
- import { readFile, writeFile, rename, open, mkdir, rm } from 'node:fs/promises';
4
- import { createRequire } from 'node:module';
5
- import path from 'node:path';
6
- import { assertSupportedPlatform, agentDataDir, agentPluginsFile, agentPluginsDir } from '../lib/platform.js';
2
+ import { assertSupportedPlatform } from '../lib/platform.js';
7
3
  import { validateLabel } from '../lib/registry.js';
8
-
9
- /**
10
- * Read the local plugin registry.
11
- * @param {string} label - Agent label
12
- */
13
- async function readLocalPlugins(label) {
14
- try {
15
- const raw = await readFile(agentPluginsFile(label), 'utf-8');
16
- const parsed = JSON.parse(raw);
17
- return Array.isArray(parsed.plugins) ? parsed : { plugins: [] };
18
- } catch (err) {
19
- if (err.code === 'ENOENT') {
20
- return { plugins: [] };
21
- }
22
- throw new Error(`Failed to read local plugin registry: ${err.message}`);
23
- }
24
- }
25
-
26
- /**
27
- * Write the local plugin registry atomically.
28
- * @param {string} label - Agent label
29
- * @param {object} data
30
- */
31
- async function writeLocalPlugins(label, data) {
32
- const pluginsFile = agentPluginsFile(label);
33
- const tmpPath = `${pluginsFile}.tmp`;
34
-
35
- const content = JSON.stringify(data, null, 2) + '\n';
36
- await writeFile(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
37
-
38
- const fd = await open(tmpPath, 'r');
39
- await fd.sync();
40
- await fd.close();
41
-
42
- await rename(tmpPath, pluginsFile);
43
- }
4
+ import {
5
+ readAgentPluginRegistry,
6
+ installAgentPlugin,
7
+ uninstallAgentPlugin,
8
+ updateAgentPlugin,
9
+ } from '../lib/agent-plugins.js';
44
10
 
45
11
  /**
46
12
  * Install a plugin locally on the agent.
@@ -48,71 +14,14 @@ async function writeLocalPlugins(label, data) {
48
14
  * @param {string} packageName
49
15
  */
50
16
  async function installLocal(label, packageName) {
51
- if (!packageName.startsWith('@lamalibre/')) {
52
- console.error(chalk.red(' Only @lamalibre/ scoped packages are allowed'));
53
- process.exit(1);
54
- }
55
-
56
- const registry = await readLocalPlugins(label);
57
- const existing = registry.plugins.find((p) => p.packageName === packageName);
58
- if (existing) {
59
- console.log(chalk.yellow(` Plugin "${packageName}" is already installed`));
60
- return;
61
- }
62
-
63
17
  console.log(chalk.cyan(` Installing ${packageName}...`));
64
-
65
- // Ensure agent dir has a package.json so npm installs locally
66
- const agentDir = agentDataDir(label);
67
- await mkdir(agentDir, { recursive: true, mode: 0o700 });
68
- const agentPkgJson = path.join(agentDir, 'package.json');
69
- try {
70
- await readFile(agentPkgJson, 'utf-8');
71
- } catch (err) {
72
- if (err.code === 'ENOENT') {
73
- await writeFile(agentPkgJson, '{"private":true}\n', { encoding: 'utf-8', mode: 0o600 });
74
- }
75
- }
76
-
77
18
  try {
78
- await execa('npm', ['install', '--ignore-scripts', packageName], { cwd: agentDir, stdio: 'inherit' });
19
+ const entry = await installAgentPlugin(label, packageName);
20
+ console.log(chalk.green(` Plugin "${entry.name}" installed`));
79
21
  } catch (err) {
80
- console.error(chalk.red(` Failed to install: ${err.message}`));
81
- process.exit(1);
82
- }
83
-
84
- // Read the plugin manifest
85
- let manifest;
86
- try {
87
- const require = createRequire(path.join(agentDir, '/'));
88
- const manifestPath = require.resolve(`${packageName}/portlama-plugin.json`);
89
- const manifestRaw = await readFile(manifestPath, 'utf-8');
90
- manifest = JSON.parse(manifestRaw);
91
- } catch {
92
- console.log(chalk.yellow(' Warning: No portlama-plugin.json found — registering with package name only'));
93
- manifest = { name: packageName.replace('@lamalibre/', ''), version: 'unknown' };
94
- }
95
-
96
- // Validate manifest name to prevent path traversal
97
- if (!/^[a-z0-9-]+$/.test(manifest.name)) {
98
- console.error(chalk.red(` Invalid plugin name: "${manifest.name}"`));
22
+ console.error(chalk.red(` ${err.message}`));
99
23
  process.exit(1);
100
24
  }
101
-
102
- // Create local plugin directory
103
- const pluginDir = path.join(agentPluginsDir(label), manifest.name);
104
- await mkdir(pluginDir, { recursive: true, mode: 0o700 });
105
-
106
- registry.plugins.push({
107
- name: manifest.name,
108
- packageName,
109
- version: manifest.version,
110
- installedAt: new Date().toISOString(),
111
- status: 'installed',
112
- });
113
-
114
- await writeLocalPlugins(label, registry);
115
- console.log(chalk.green(` Plugin "${manifest.name}" installed`));
116
25
  }
117
26
 
118
27
  /**
@@ -121,38 +30,24 @@ async function installLocal(label, packageName) {
121
30
  * @param {string} nameOrPackage
122
31
  */
123
32
  async function uninstallLocal(label, nameOrPackage) {
124
- const registry = await readLocalPlugins(label);
125
- const index = registry.plugins.findIndex(
33
+ // Resolve name from registry if a package name was given
34
+ const registry = await readAgentPluginRegistry(label);
35
+ const plugin = registry.plugins.find(
126
36
  (p) => p.name === nameOrPackage || p.packageName === nameOrPackage,
127
37
  );
128
-
129
- if (index === -1) {
38
+ if (!plugin) {
130
39
  console.error(chalk.red(` Plugin "${nameOrPackage}" not found`));
131
40
  process.exit(1);
132
41
  }
133
42
 
134
- const plugin = registry.plugins[index];
135
-
136
- if (!plugin.packageName.startsWith('@lamalibre/')) {
137
- console.error(chalk.red(' Registry corruption: invalid package scope'));
138
- process.exit(1);
139
- }
140
-
141
43
  console.log(chalk.cyan(` Uninstalling ${plugin.packageName}...`));
142
-
143
44
  try {
144
- await execa('npm', ['uninstall', plugin.packageName], { cwd: agentDataDir(label), stdio: 'inherit' });
45
+ await uninstallAgentPlugin(label, plugin.name);
46
+ console.log(chalk.green(` Plugin "${plugin.name}" uninstalled`));
145
47
  } catch (err) {
146
- console.log(chalk.yellow(` npm uninstall warning: ${err.message}`));
48
+ console.error(chalk.red(` ${err.message}`));
49
+ process.exit(1);
147
50
  }
148
-
149
- // Remove local plugin directory
150
- const pluginDir = path.join(agentPluginsDir(label), plugin.name);
151
- await rm(pluginDir, { recursive: true, force: true }).catch(() => {});
152
-
153
- registry.plugins.splice(index, 1);
154
- await writeLocalPlugins(label, registry);
155
- console.log(chalk.green(` Plugin "${plugin.name}" uninstalled`));
156
51
  }
157
52
 
158
53
  /**
@@ -161,45 +56,14 @@ async function uninstallLocal(label, nameOrPackage) {
161
56
  * @param {string} nameOrPackage
162
57
  */
163
58
  async function updateLocal(label, nameOrPackage) {
164
- const registry = await readLocalPlugins(label);
165
- const plugin = registry.plugins.find(
166
- (p) => p.name === nameOrPackage || p.packageName === nameOrPackage,
167
- );
168
-
169
- if (!plugin) {
170
- console.error(chalk.red(` Plugin "${nameOrPackage}" not found`));
171
- process.exit(1);
172
- }
173
-
174
- if (!plugin.packageName.startsWith('@lamalibre/')) {
175
- console.error(chalk.red(' Registry corruption: invalid package scope'));
176
- process.exit(1);
177
- }
178
-
179
- console.log(chalk.cyan(` Updating ${plugin.packageName}...`));
180
-
181
- const agentDir = agentDataDir(label);
59
+ console.log(chalk.cyan(` Updating ${nameOrPackage}...`));
182
60
  try {
183
- await execa('npm', ['install', '--ignore-scripts', plugin.packageName], { cwd: agentDir, stdio: 'inherit' });
61
+ const plugin = await updateAgentPlugin(label, nameOrPackage);
62
+ console.log(chalk.green(` Plugin "${plugin.name}" updated to v${plugin.version}`));
184
63
  } catch (err) {
185
- console.error(chalk.red(` Failed to update: ${err.message}`));
64
+ console.error(chalk.red(` ${err.message}`));
186
65
  process.exit(1);
187
66
  }
188
-
189
- // Re-read manifest to capture the updated version
190
- try {
191
- const require = createRequire(path.join(agentDir, '/'));
192
- const manifestPath = require.resolve(`${plugin.packageName}/portlama-plugin.json`);
193
- const manifestRaw = await readFile(manifestPath, 'utf-8');
194
- const manifest = JSON.parse(manifestRaw);
195
- plugin.version = manifest.version || plugin.version;
196
- } catch {
197
- // Manifest may not exist — keep existing version
198
- }
199
-
200
- plugin.updatedAt = new Date().toISOString();
201
- await writeLocalPlugins(label, registry);
202
- console.log(chalk.green(` Plugin "${plugin.name}" updated`));
203
67
  }
204
68
 
205
69
  /**
@@ -207,7 +71,7 @@ async function updateLocal(label, nameOrPackage) {
207
71
  * @param {string} label - Agent label
208
72
  */
209
73
  async function showStatus(label) {
210
- const registry = await readLocalPlugins(label);
74
+ const registry = await readAgentPluginRegistry(label);
211
75
  const b = chalk.bold;
212
76
  const c = chalk.cyan;
213
77
  const d = chalk.dim;
@@ -84,6 +84,36 @@ export async function runUpdate({ label }) {
84
84
  });
85
85
  },
86
86
  },
87
+ {
88
+ title: 'Reporting installed plugins',
89
+ task: async (_ctx, task) => {
90
+ const { readAgentPluginRegistry } = await import('../lib/agent-plugins.js');
91
+ const registry = await readAgentPluginRegistry(label);
92
+ const enabledPlugins = registry.plugins.filter((p) => p.status === 'enabled');
93
+ if (enabledPlugins.length === 0) {
94
+ task.skip('No enabled plugins');
95
+ return;
96
+ }
97
+ const pluginReport = enabledPlugins.map((p) => ({
98
+ name: p.name,
99
+ version: p.version,
100
+ capabilities: p.capabilities || [],
101
+ }));
102
+ try {
103
+ const { curlAuthenticatedJson } = await import('../lib/panel-api.js');
104
+ await curlAuthenticatedJson(config, [
105
+ '-X', 'POST',
106
+ '-H', 'Content-Type: application/json',
107
+ '-d', JSON.stringify({ plugins: pluginReport }),
108
+ `${config.panelUrl}/api/agents/plugins/report`,
109
+ ]);
110
+ task.output = `Reported ${enabledPlugins.length} plugin(s)`;
111
+ } catch {
112
+ task.skip('Server does not support plugin reporting yet');
113
+ }
114
+ },
115
+ rendererOptions: { persistentOutput: true },
116
+ },
87
117
  ],
88
118
  {
89
119
  renderer: 'default',
package/src/index.js CHANGED
@@ -163,7 +163,7 @@ export async function main() {
163
163
  const { runPanel } = await import('./commands/panel.js');
164
164
  const { resolveLabel } = await import('./lib/registry.js');
165
165
  const resolved = await resolveLabel(label);
166
- await runPanel(args.slice(1), { label: resolved });
166
+ await runPanel(args.slice(1), { label: resolved, json });
167
167
  break;
168
168
  }
169
169
  case 'list': {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Agent plugin router — mounts enabled plugin server routes and serves
3
+ * panel bundles on the agent panel server.
4
+ *
5
+ * Registered as a Fastify plugin under '/api/plugins' prefix in panel-server.js.
6
+ * mTLS validation is handled by the parent panel server (panel-server.js),
7
+ * so no additional auth guard is needed here.
8
+ *
9
+ * Combines patterns from:
10
+ * - plugin-router.js (server-side two-level encapsulation)
11
+ * - local-plugin-host.js (CJS/ESM plugin loading)
12
+ */
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import { createRequire } from 'node:module';
16
+ import path from 'node:path';
17
+ import { readAgentPluginRegistry } from './agent-plugins.js';
18
+ import { agentDataDir, agentPluginsDir } from './platform.js';
19
+
20
+ // Reserved prefixes that are never plugin route names.
21
+ const RESERVED_PREFIXES = new Set(['install']);
22
+
23
+ /**
24
+ * Fastify plugin that mounts agent plugin routes and panel bundles.
25
+ *
26
+ * @param {import('fastify').FastifyInstance} fastify
27
+ * @param {{ label: string }} opts
28
+ */
29
+ export default async function agentPluginRouter(fastify, { label }) {
30
+ const registry = await readAgentPluginRegistry(label);
31
+ const dir = agentDataDir(label);
32
+ const pluginsDir = agentPluginsDir(label);
33
+
34
+ for (const plugin of registry.plugins) {
35
+ if (plugin.status !== 'enabled') continue;
36
+
37
+ const pluginName = plugin.name;
38
+
39
+ if (plugin.packages?.server) {
40
+ // Defense-in-depth: verify scope at load time
41
+ if (!plugin.packages.server.startsWith('@lamalibre/')) {
42
+ fastify.log.error(
43
+ { plugin: pluginName },
44
+ 'Plugin server package scope violation — skipping',
45
+ );
46
+ continue;
47
+ }
48
+
49
+ try {
50
+ const require = createRequire(path.join(dir, '/'));
51
+ const modulePath = require.resolve(plugin.packages.server);
52
+ let serverModule;
53
+ try {
54
+ // Try require first (CJS packages)
55
+ serverModule = require(plugin.packages.server);
56
+ } catch {
57
+ // Fall back to dynamic import (ESM packages)
58
+ serverModule = await import(modulePath);
59
+ }
60
+
61
+ // Resolve the Fastify plugin function from the module.
62
+ let pluginFn = serverModule.default || serverModule;
63
+ if (typeof pluginFn !== 'function' && typeof serverModule.buildPlugin === 'function') {
64
+ pluginFn = serverModule.buildPlugin();
65
+ }
66
+
67
+ if (typeof pluginFn === 'function') {
68
+ const pluginDir = path.join(pluginsDir, pluginName) + '/';
69
+
70
+ // Two-level encapsulation: outer scope isolates the plugin code from
71
+ // the router's hooks, preventing plugins from overriding auth or
72
+ // adding hooks above their encapsulation boundary.
73
+ await fastify.register(async function pluginScope(outer) {
74
+ await outer.register(pluginFn, {
75
+ pluginDir,
76
+ logger: fastify.log,
77
+ });
78
+ }, { prefix: `/${pluginName}` });
79
+ fastify.log.info({ plugin: pluginName }, 'Agent plugin routes mounted');
80
+ }
81
+ } catch (err) {
82
+ fastify.log.error(
83
+ { plugin: pluginName, err: err.message },
84
+ 'Failed to mount agent plugin routes',
85
+ );
86
+ }
87
+ }
88
+
89
+ // Serve plugin panel bundle
90
+ if (plugin.packages?.server) {
91
+ const serverPkg = plugin.packages.server;
92
+ fastify.get(`/${pluginName}/panel.js`, async (_request, reply) => {
93
+ try {
94
+ if (!serverPkg.startsWith('@lamalibre/')) {
95
+ return reply.code(403).send({ error: 'Plugin server package scope violation' });
96
+ }
97
+ const require = createRequire(path.join(dir, '/'));
98
+ const panelPath = require.resolve(`${serverPkg}/panel.js`);
99
+ const content = await readFile(panelPath, 'utf-8');
100
+ return reply
101
+ .header('Content-Type', 'application/javascript')
102
+ .header('Cache-Control', 'public, max-age=3600')
103
+ .send(content);
104
+ } catch {
105
+ return reply.code(404).send({ error: 'Plugin panel bundle not found' });
106
+ }
107
+ });
108
+ }
109
+ }
110
+
111
+ // --- Disabled plugin catch-all ---
112
+
113
+ let cachedDisabledPlugins = new Set();
114
+ let cacheExpiry = 0;
115
+
116
+ async function getDisabledPlugins() {
117
+ const now = Date.now();
118
+ if (now < cacheExpiry) return cachedDisabledPlugins;
119
+
120
+ const currentRegistry = await readAgentPluginRegistry(label);
121
+ cachedDisabledPlugins = new Set(
122
+ currentRegistry.plugins.filter((p) => p.status !== 'enabled').map((p) => p.name),
123
+ );
124
+ cacheExpiry = now + 5000;
125
+ return cachedDisabledPlugins;
126
+ }
127
+
128
+ fastify.addHook('onRequest', async (request, reply) => {
129
+ // Match /<pluginName>/... under the /api/plugins prefix
130
+ const match = request.url.match(/^\/([a-z0-9-]+)(\/|$)/);
131
+ if (!match) return;
132
+
133
+ const name = match[1];
134
+ if (RESERVED_PREFIXES.has(name)) return;
135
+
136
+ const disabled = await getDisabledPlugins();
137
+ if (disabled.has(name)) {
138
+ return reply.code(503).send({ error: `Plugin "${name}" is disabled` });
139
+ }
140
+ });
141
+ }