@lamalibre/portlama-agent 1.0.21 → 1.0.23
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/src/commands/panel.js +59 -40
- package/src/commands/plugin.js +23 -159
- package/src/commands/update.js +30 -0
- package/src/index.js +1 -1
- package/src/lib/agent-plugin-router.js +141 -0
- package/src/lib/agent-plugins.js +358 -0
- package/src/lib/keychain.js +0 -169
- package/src/lib/local-plugin-host.js +7 -2
- package/src/lib/local-plugins.js +2 -1
- package/src/lib/panel-api-routes.js +130 -0
- package/src/lib/panel-server.js +63 -2
- package/src/lib/panel-service.js +3 -1
|
@@ -17,6 +17,17 @@ import {
|
|
|
17
17
|
retractPanelTunnel,
|
|
18
18
|
fetchPanelTunnelStatus,
|
|
19
19
|
} from './panel-api.js';
|
|
20
|
+
import {
|
|
21
|
+
readAgentPluginRegistry,
|
|
22
|
+
installAgentPlugin,
|
|
23
|
+
uninstallAgentPlugin,
|
|
24
|
+
enableAgentPlugin,
|
|
25
|
+
disableAgentPlugin,
|
|
26
|
+
updateAgentPlugin,
|
|
27
|
+
checkAgentPluginUpdate,
|
|
28
|
+
readAgentPluginBundle,
|
|
29
|
+
} from './agent-plugins.js';
|
|
30
|
+
import { unloadPanelService, loadPanelService } from './panel-service.js';
|
|
20
31
|
|
|
21
32
|
// UUID regex for validating :id params before proxying to panel server
|
|
22
33
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
@@ -257,4 +268,123 @@ export default async function panelApiRoutes(fastify, opts) {
|
|
|
257
268
|
await unloadAgent(label);
|
|
258
269
|
return { ok: true, message: 'Agent stopped. Run portlama-agent uninstall for full removal.' };
|
|
259
270
|
});
|
|
271
|
+
|
|
272
|
+
// --- Plugins ---
|
|
273
|
+
|
|
274
|
+
const PLUGIN_NAME_RE = /^[a-z0-9-]+$/;
|
|
275
|
+
|
|
276
|
+
fastify.get('/plugins', async () => {
|
|
277
|
+
return readAgentPluginRegistry(label);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
fastify.post('/plugins/install', async (request, reply) => {
|
|
281
|
+
const { packageName } = request.body || {};
|
|
282
|
+
if (!packageName || typeof packageName !== 'string') {
|
|
283
|
+
return reply.code(400).send({ error: 'packageName is required' });
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
const entry = await installAgentPlugin(label, packageName);
|
|
287
|
+
return { ok: true, plugin: entry };
|
|
288
|
+
} catch (err) {
|
|
289
|
+
return reply.code(400).send({ error: err.message });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
fastify.post('/plugins/:name/enable', async (request, reply) => {
|
|
294
|
+
const { name } = request.params;
|
|
295
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
296
|
+
return reply.code(400).send({ error: 'Invalid plugin name' });
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await enableAgentPlugin(label, name);
|
|
300
|
+
// Restart panel service to mount newly enabled plugin routes.
|
|
301
|
+
// The response is sent before the process exits — launchd/systemd
|
|
302
|
+
// will restart the process with the updated registry.
|
|
303
|
+
setTimeout(async () => {
|
|
304
|
+
try {
|
|
305
|
+
await unloadPanelService(label);
|
|
306
|
+
await loadPanelService(label);
|
|
307
|
+
} catch (err) {
|
|
308
|
+
fastify.log.error({ err: err.message }, 'Failed to restart panel service');
|
|
309
|
+
}
|
|
310
|
+
}, 500);
|
|
311
|
+
return { ok: true, name, status: 'enabled', restarting: true };
|
|
312
|
+
} catch (err) {
|
|
313
|
+
return reply.code(400).send({ error: err.message });
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
fastify.post('/plugins/:name/disable', async (request, reply) => {
|
|
318
|
+
const { name } = request.params;
|
|
319
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
320
|
+
return reply.code(400).send({ error: 'Invalid plugin name' });
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
await disableAgentPlugin(label, name);
|
|
324
|
+
// Restart panel service to unmount disabled plugin routes.
|
|
325
|
+
// Use setTimeout to flush the response before the process exits.
|
|
326
|
+
setTimeout(async () => {
|
|
327
|
+
try {
|
|
328
|
+
await unloadPanelService(label);
|
|
329
|
+
await loadPanelService(label);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
fastify.log.error({ err: err.message }, 'Failed to restart panel service after disable');
|
|
332
|
+
}
|
|
333
|
+
}, 500);
|
|
334
|
+
return { ok: true, name, status: 'disabled', restarting: true };
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return reply.code(400).send({ error: err.message });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
fastify.delete('/plugins/:name', async (request, reply) => {
|
|
341
|
+
const { name } = request.params;
|
|
342
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
343
|
+
return reply.code(400).send({ error: 'Invalid plugin name' });
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await uninstallAgentPlugin(label, name);
|
|
347
|
+
return { ok: true, name };
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return reply.code(400).send({ error: err.message });
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
fastify.post('/plugins/:name/update', async (request, reply) => {
|
|
354
|
+
const { name } = request.params;
|
|
355
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
356
|
+
return reply.code(400).send({ error: 'Invalid plugin name' });
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const plugin = await updateAgentPlugin(label, name);
|
|
360
|
+
return { ok: true, plugin };
|
|
361
|
+
} catch (err) {
|
|
362
|
+
return reply.code(400).send({ error: err.message });
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
fastify.get('/plugins/:name/check-update', async (request, reply) => {
|
|
367
|
+
const { name } = request.params;
|
|
368
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
369
|
+
return reply.code(400).send({ error: 'Invalid plugin name' });
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
return await checkAgentPluginUpdate(label, name);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
return reply.code(400).send({ error: err.message });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
fastify.get('/plugins/:name/bundle', async (request, reply) => {
|
|
379
|
+
const { name } = request.params;
|
|
380
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
381
|
+
return reply.code(400).send({ error: 'Invalid plugin name' });
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
const source = await readAgentPluginBundle(label, name);
|
|
385
|
+
return { source };
|
|
386
|
+
} catch (err) {
|
|
387
|
+
return reply.code(404).send({ error: err.message });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
260
390
|
}
|
package/src/lib/panel-server.js
CHANGED
|
@@ -11,11 +11,14 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import Fastify from 'fastify';
|
|
14
|
+
import cors from '@fastify/cors';
|
|
14
15
|
import rateLimit from '@fastify/rate-limit';
|
|
15
16
|
import fastifyStatic from '@fastify/static';
|
|
16
17
|
import path from 'node:path';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
import panelApiRoutes from './panel-api-routes.js';
|
|
20
|
+
import agentPluginRouter from './agent-plugin-router.js';
|
|
21
|
+
import { readAgentPluginBundle } from './agent-plugins.js';
|
|
19
22
|
|
|
20
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
24
|
|
|
@@ -33,6 +36,18 @@ export async function startPanelServer(label, { port = 9393 } = {}) {
|
|
|
33
36
|
},
|
|
34
37
|
});
|
|
35
38
|
|
|
39
|
+
// Allow Tauri webview and localhost origins to call plugin APIs.
|
|
40
|
+
// credentials: true is required because plugin microfrontends use
|
|
41
|
+
// fetch(..., { credentials: 'include' }) for session cookies.
|
|
42
|
+
await server.register(cors, {
|
|
43
|
+
origin: [
|
|
44
|
+
'tauri://localhost',
|
|
45
|
+
'https://tauri.localhost',
|
|
46
|
+
/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/,
|
|
47
|
+
],
|
|
48
|
+
credentials: true,
|
|
49
|
+
});
|
|
50
|
+
|
|
36
51
|
await server.register(rateLimit, {
|
|
37
52
|
max: 100,
|
|
38
53
|
timeWindow: '1 minute',
|
|
@@ -52,11 +67,30 @@ export async function startPanelServer(label, { port = 9393 } = {}) {
|
|
|
52
67
|
// Allow health check without auth
|
|
53
68
|
if (request.url === '/api/health') return;
|
|
54
69
|
|
|
55
|
-
//
|
|
56
|
-
if (
|
|
70
|
+
// Plugin bundles are intentionally public (loaded via <script> tag)
|
|
71
|
+
if (request.url.startsWith('/plugin-bundles/')) return;
|
|
72
|
+
|
|
73
|
+
// Auth is required for /api/* routes AND plugin server routes (/<pluginName>/api/...).
|
|
74
|
+
// Static assets (SPA files) are served by fastify-static and don't need auth.
|
|
75
|
+
const needsAuth = request.url.startsWith('/api') ||
|
|
76
|
+
/^\/[a-z0-9-]+\/api\//.test(request.url);
|
|
77
|
+
if (!needsAuth) return;
|
|
57
78
|
|
|
58
79
|
const verify = request.headers['x-ssl-client-verify'];
|
|
59
80
|
if (verify !== 'SUCCESS') {
|
|
81
|
+
// Allow localhost browser requests (desktop app plugin microfrontends).
|
|
82
|
+
// The server binds 127.0.0.1 only, so only local processes can reach it.
|
|
83
|
+
// The Origin header is browser-enforced and cannot be forged by web pages.
|
|
84
|
+
const origin = request.headers.origin || '';
|
|
85
|
+
const isLocalOrigin =
|
|
86
|
+
/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin) ||
|
|
87
|
+
origin === 'tauri://localhost' ||
|
|
88
|
+
origin === 'https://tauri.localhost';
|
|
89
|
+
if (isLocalOrigin) {
|
|
90
|
+
request.certCN = `agent:${label}`;
|
|
91
|
+
request.certRole = 'agent';
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
60
94
|
return reply.code(403).send({ error: 'Valid mTLS certificate required' });
|
|
61
95
|
}
|
|
62
96
|
|
|
@@ -82,6 +116,33 @@ export async function startPanelServer(label, { port = 9393 } = {}) {
|
|
|
82
116
|
// --- REST API routes ---
|
|
83
117
|
await server.register(panelApiRoutes, { prefix: '/api', label });
|
|
84
118
|
|
|
119
|
+
// --- Agent plugin routes ---
|
|
120
|
+
// Mounts enabled plugin server routes at /<name>/... (root level),
|
|
121
|
+
// matching the local plugin host pattern that plugins expect.
|
|
122
|
+
// Plugins construct URLs as ${panelUrl}/${pluginName}/api/${pluginName}/...
|
|
123
|
+
await server.register(agentPluginRouter, { label });
|
|
124
|
+
|
|
125
|
+
// --- Public plugin bundle endpoint (outside /api — no mTLS required) ---
|
|
126
|
+
// Desktop app loads bundles via <script> tag to bypass Tauri IPC JSON size limits.
|
|
127
|
+
// Script tags are not subject to CORS, so cross-origin loading works.
|
|
128
|
+
const PLUGIN_NAME_RE = /^[a-z0-9-]+$/;
|
|
129
|
+
server.get('/plugin-bundles/:name/panel.js', async (request, reply) => {
|
|
130
|
+
const { name } = request.params;
|
|
131
|
+
if (!PLUGIN_NAME_RE.test(name)) {
|
|
132
|
+
reply.type('application/javascript');
|
|
133
|
+
return reply.code(400).send('// invalid plugin name');
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const source = await readAgentPluginBundle(label, name);
|
|
137
|
+
reply.type('application/javascript');
|
|
138
|
+
reply.header('Cache-Control', 'public, max-age=3600');
|
|
139
|
+
return source;
|
|
140
|
+
} catch {
|
|
141
|
+
reply.type('application/javascript');
|
|
142
|
+
return reply.code(404).send(`// plugin bundle not found: ${name}`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
85
146
|
// --- Static SPA files ---
|
|
86
147
|
const staticRoot = path.resolve(__dirname, '..', 'panel-dist');
|
|
87
148
|
try {
|
package/src/lib/panel-service.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
panelLogFile,
|
|
20
20
|
panelErrorLogFile,
|
|
21
21
|
agentLogsDir,
|
|
22
|
+
agentDataDir,
|
|
22
23
|
} from './platform.js';
|
|
23
24
|
|
|
24
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -218,6 +219,7 @@ function generatePanelSystemdUnit(label, port) {
|
|
|
218
219
|
const logFile = panelLogFile(label);
|
|
219
220
|
const errorLogFile = panelErrorLogFile(label);
|
|
220
221
|
const logsDir = agentLogsDir(label);
|
|
222
|
+
const dataDir = agentDataDir(label);
|
|
221
223
|
|
|
222
224
|
const execStart = [nodePath, entryPath, '--label', label, '--port', String(port)]
|
|
223
225
|
.map(systemdQuote)
|
|
@@ -238,7 +240,7 @@ StandardError=append:${errorLogFile}
|
|
|
238
240
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
239
241
|
NoNewPrivileges=true
|
|
240
242
|
ProtectSystem=strict
|
|
241
|
-
ReadWritePaths=${logsDir}
|
|
243
|
+
ReadWritePaths=${logsDir} ${dataDir}
|
|
242
244
|
|
|
243
245
|
[Install]
|
|
244
246
|
WantedBy=multi-user.target
|