@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 +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/panel-api-routes.js +130 -0
- package/src/lib/panel-server.js +55 -0
- package/src/lib/panel-service.js +3 -1
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent plugin lifecycle management.
|
|
3
|
+
*
|
|
4
|
+
* Manages plugin installation, enable/disable, uninstall, and update for
|
|
5
|
+
* per-agent plugin registries. Plugins are installed into the agent's data
|
|
6
|
+
* directory and mounted on the agent panel server (port 9393) when enabled.
|
|
7
|
+
*
|
|
8
|
+
* Adapted from local-plugins.js — key differences:
|
|
9
|
+
* - All functions take `label` as first parameter (per-agent isolation)
|
|
10
|
+
* - Uses agentDataDir/agentPluginsFile/agentPluginsDir from platform.js
|
|
11
|
+
* - Validates modes.includes('agent') instead of 'local'
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile, writeFile, rename, open, mkdir, rm } from 'node:fs/promises';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { execa } from 'execa';
|
|
18
|
+
import { agentDataDir, agentPluginsFile, agentPluginsDir } from './platform.js';
|
|
19
|
+
|
|
20
|
+
// Reserved names that cannot be used as plugin names (matches panel-server constants).
|
|
21
|
+
const RESERVED_NAMES = [
|
|
22
|
+
'health', 'onboarding', 'invite', 'enroll', 'tunnels', 'sites', 'system',
|
|
23
|
+
'services', 'logs', 'users', 'certs', 'invitations', 'plugins', 'tickets',
|
|
24
|
+
'settings', 'identity', 'storage', 'agents',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// --- Promise-chain mutex (serialises registry modifications) ---
|
|
28
|
+
|
|
29
|
+
let lockTail = Promise.resolve();
|
|
30
|
+
|
|
31
|
+
function withLock(fn) {
|
|
32
|
+
const next = lockTail.then(fn, fn);
|
|
33
|
+
lockTail = next.catch(() => {});
|
|
34
|
+
return next;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- Registry read / write ---
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the agent plugin registry.
|
|
41
|
+
* @param {string} label - Agent label
|
|
42
|
+
* @returns {Promise<{plugins: Array}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function readAgentPluginRegistry(label) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(agentPluginsFile(label), 'utf-8');
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
return Array.isArray(parsed.plugins) ? parsed : { plugins: [] };
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code === 'ENOENT') {
|
|
51
|
+
return { plugins: [] };
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`Failed to read agent plugin registry: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Write the agent plugin registry atomically.
|
|
59
|
+
* @param {string} label - Agent label
|
|
60
|
+
* @param {object} data
|
|
61
|
+
*/
|
|
62
|
+
export async function writeAgentPluginRegistry(label, data) {
|
|
63
|
+
const filePath = agentPluginsFile(label);
|
|
64
|
+
const tmpPath = `${filePath}.tmp`;
|
|
65
|
+
|
|
66
|
+
await mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
67
|
+
|
|
68
|
+
const content = JSON.stringify(data, null, 2) + '\n';
|
|
69
|
+
await writeFile(tmpPath, content, { encoding: 'utf-8', mode: 0o600 });
|
|
70
|
+
|
|
71
|
+
const fd = await open(tmpPath, 'r');
|
|
72
|
+
await fd.sync();
|
|
73
|
+
await fd.close();
|
|
74
|
+
|
|
75
|
+
await rename(tmpPath, filePath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- Plugin lifecycle ---
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Install a plugin on the agent.
|
|
82
|
+
* @param {string} label - Agent label
|
|
83
|
+
* @param {string} packageName - Must be @lamalibre/ scoped
|
|
84
|
+
* @returns {Promise<object>} The new registry entry
|
|
85
|
+
*/
|
|
86
|
+
export function installAgentPlugin(label, packageName) {
|
|
87
|
+
return withLock(async () => {
|
|
88
|
+
if (!packageName.startsWith('@lamalibre/')) {
|
|
89
|
+
throw new Error('Only @lamalibre/ scoped packages are allowed');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Validate the portion after scope to prevent path traversal
|
|
93
|
+
const pkgName = packageName.slice('@lamalibre/'.length);
|
|
94
|
+
if (!/^[a-z0-9]([a-z0-9._-]*[a-z0-9])?$/.test(pkgName)) {
|
|
95
|
+
throw new Error('Invalid package name');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const registry = await readAgentPluginRegistry(label);
|
|
99
|
+
if (registry.plugins.find((p) => p.packageName === packageName)) {
|
|
100
|
+
throw new Error(`Plugin "${packageName}" is already installed`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Hard cap to prevent disk exhaustion
|
|
104
|
+
if (registry.plugins.length >= 20) {
|
|
105
|
+
throw new Error('Maximum of 20 agent plugins allowed');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Ensure agent dir has a package.json so npm installs locally
|
|
109
|
+
const dir = agentDataDir(label);
|
|
110
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
111
|
+
const pkgJsonPath = path.join(dir, 'package.json');
|
|
112
|
+
try {
|
|
113
|
+
await readFile(pkgJsonPath, 'utf-8');
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err.code === 'ENOENT') {
|
|
116
|
+
await writeFile(pkgJsonPath, '{"private":true}\n', { encoding: 'utf-8', mode: 0o600 });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Install via npm
|
|
121
|
+
await execa('npm', ['install', '--ignore-scripts', packageName], { cwd: dir, timeout: 120_000 });
|
|
122
|
+
|
|
123
|
+
// Read and validate manifest
|
|
124
|
+
let manifest;
|
|
125
|
+
try {
|
|
126
|
+
const require = createRequire(path.join(dir, '/'));
|
|
127
|
+
const manifestPath = require.resolve(`${packageName}/portlama-plugin.json`);
|
|
128
|
+
const manifestRaw = await readFile(manifestPath, 'utf-8');
|
|
129
|
+
manifest = JSON.parse(manifestRaw);
|
|
130
|
+
} catch {
|
|
131
|
+
// Clean up on manifest failure
|
|
132
|
+
await execa('npm', ['uninstall', packageName], { cwd: dir }).catch(() => {});
|
|
133
|
+
throw new Error(`No valid portlama-plugin.json found in "${packageName}"`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Validate name
|
|
137
|
+
if (!/^[a-z0-9-]+$/.test(manifest.name)) {
|
|
138
|
+
await execa('npm', ['uninstall', packageName], { cwd: dir }).catch(() => {});
|
|
139
|
+
throw new Error(`Invalid plugin name: "${manifest.name}"`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (RESERVED_NAMES.includes(manifest.name)) {
|
|
143
|
+
await execa('npm', ['uninstall', packageName], { cwd: dir }).catch(() => {});
|
|
144
|
+
throw new Error(`Plugin name "${manifest.name}" is reserved`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check for duplicate name (different package, same manifest name)
|
|
148
|
+
if (registry.plugins.find((p) => p.name === manifest.name)) {
|
|
149
|
+
await execa('npm', ['uninstall', packageName], { cwd: dir }).catch(() => {});
|
|
150
|
+
throw new Error(`A plugin named "${manifest.name}" is already installed`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate agent mode support
|
|
154
|
+
const modes = manifest.modes || ['server', 'agent'];
|
|
155
|
+
if (!modes.includes('agent')) {
|
|
156
|
+
await execa('npm', ['uninstall', packageName], { cwd: dir }).catch(() => {});
|
|
157
|
+
throw new Error(`Plugin "${manifest.name}" does not support agent mode`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Create plugin data directory
|
|
161
|
+
const pluginDir = path.join(agentPluginsDir(label), manifest.name);
|
|
162
|
+
await mkdir(pluginDir, { recursive: true, mode: 0o700 });
|
|
163
|
+
|
|
164
|
+
const entry = {
|
|
165
|
+
name: manifest.name,
|
|
166
|
+
displayName: manifest.displayName,
|
|
167
|
+
packageName,
|
|
168
|
+
version: manifest.version,
|
|
169
|
+
description: manifest.description || '',
|
|
170
|
+
capabilities: Array.isArray(manifest.capabilities) ? manifest.capabilities : [],
|
|
171
|
+
packages: manifest.packages || {},
|
|
172
|
+
panel: manifest.panel || {},
|
|
173
|
+
modes,
|
|
174
|
+
config: manifest.config || {},
|
|
175
|
+
status: 'disabled',
|
|
176
|
+
installedAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
registry.plugins.push(entry);
|
|
180
|
+
await writeAgentPluginRegistry(label, registry);
|
|
181
|
+
return entry;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Uninstall an agent plugin. Must be disabled first.
|
|
187
|
+
* @param {string} label - Agent label
|
|
188
|
+
* @param {string} name - Plugin name
|
|
189
|
+
*/
|
|
190
|
+
export function uninstallAgentPlugin(label, name) {
|
|
191
|
+
return withLock(async () => {
|
|
192
|
+
const registry = await readAgentPluginRegistry(label);
|
|
193
|
+
const index = registry.plugins.findIndex((p) => p.name === name);
|
|
194
|
+
if (index === -1) {
|
|
195
|
+
throw new Error(`Plugin "${name}" not found`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const plugin = registry.plugins[index];
|
|
199
|
+
if (plugin.status === 'enabled') {
|
|
200
|
+
throw new Error(`Plugin "${name}" must be disabled before uninstalling`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!plugin.packageName.startsWith('@lamalibre/')) {
|
|
204
|
+
throw new Error('Registry corruption: invalid package scope');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// npm uninstall
|
|
208
|
+
await execa('npm', ['uninstall', plugin.packageName], { cwd: agentDataDir(label) }).catch(() => {});
|
|
209
|
+
|
|
210
|
+
// Remove plugin data directory
|
|
211
|
+
const pluginDir = path.join(agentPluginsDir(label), plugin.name);
|
|
212
|
+
await rm(pluginDir, { recursive: true, force: true }).catch(() => {});
|
|
213
|
+
|
|
214
|
+
registry.plugins.splice(index, 1);
|
|
215
|
+
await writeAgentPluginRegistry(label, registry);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Enable an agent plugin.
|
|
221
|
+
* @param {string} label - Agent label
|
|
222
|
+
* @param {string} name - Plugin name
|
|
223
|
+
*/
|
|
224
|
+
export function enableAgentPlugin(label, name) {
|
|
225
|
+
return withLock(async () => {
|
|
226
|
+
const registry = await readAgentPluginRegistry(label);
|
|
227
|
+
const plugin = registry.plugins.find((p) => p.name === name);
|
|
228
|
+
if (!plugin) {
|
|
229
|
+
throw new Error(`Plugin "${name}" not found`);
|
|
230
|
+
}
|
|
231
|
+
if (plugin.status === 'enabled') return;
|
|
232
|
+
|
|
233
|
+
plugin.status = 'enabled';
|
|
234
|
+
plugin.enabledAt = new Date().toISOString();
|
|
235
|
+
await writeAgentPluginRegistry(label, registry);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Disable an agent plugin.
|
|
241
|
+
* @param {string} label - Agent label
|
|
242
|
+
* @param {string} name - Plugin name
|
|
243
|
+
*/
|
|
244
|
+
export function disableAgentPlugin(label, name) {
|
|
245
|
+
return withLock(async () => {
|
|
246
|
+
const registry = await readAgentPluginRegistry(label);
|
|
247
|
+
const plugin = registry.plugins.find((p) => p.name === name);
|
|
248
|
+
if (!plugin) {
|
|
249
|
+
throw new Error(`Plugin "${name}" not found`);
|
|
250
|
+
}
|
|
251
|
+
if (plugin.status === 'disabled') return;
|
|
252
|
+
|
|
253
|
+
plugin.status = 'disabled';
|
|
254
|
+
delete plugin.enabledAt;
|
|
255
|
+
await writeAgentPluginRegistry(label, registry);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Update an agent plugin to the latest version.
|
|
261
|
+
* @param {string} label - Agent label
|
|
262
|
+
* @param {string} nameOrPackage - Plugin name or package name
|
|
263
|
+
* @returns {Promise<object>} The updated registry entry
|
|
264
|
+
*/
|
|
265
|
+
export function updateAgentPlugin(label, nameOrPackage) {
|
|
266
|
+
return withLock(async () => {
|
|
267
|
+
const registry = await readAgentPluginRegistry(label);
|
|
268
|
+
const plugin = registry.plugins.find(
|
|
269
|
+
(p) => p.name === nameOrPackage || p.packageName === nameOrPackage,
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
if (!plugin) {
|
|
273
|
+
throw new Error(`Plugin "${nameOrPackage}" not found`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!plugin.packageName.startsWith('@lamalibre/')) {
|
|
277
|
+
throw new Error('Registry corruption: invalid package scope');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const dir = agentDataDir(label);
|
|
281
|
+
await execa('npm', ['install', '--ignore-scripts', plugin.packageName], { cwd: dir, timeout: 120_000 });
|
|
282
|
+
|
|
283
|
+
// Re-read manifest to capture the updated version
|
|
284
|
+
try {
|
|
285
|
+
const require = createRequire(path.join(dir, '/'));
|
|
286
|
+
const manifestPath = require.resolve(`${plugin.packageName}/portlama-plugin.json`);
|
|
287
|
+
const manifestRaw = await readFile(manifestPath, 'utf-8');
|
|
288
|
+
const manifest = JSON.parse(manifestRaw);
|
|
289
|
+
plugin.version = manifest.version || plugin.version;
|
|
290
|
+
if (manifest.capabilities) plugin.capabilities = manifest.capabilities;
|
|
291
|
+
if (manifest.panel) plugin.panel = manifest.panel;
|
|
292
|
+
if (manifest.packages) plugin.packages = manifest.packages;
|
|
293
|
+
if (manifest.description) plugin.description = manifest.description;
|
|
294
|
+
if (manifest.displayName) plugin.displayName = manifest.displayName;
|
|
295
|
+
} catch {
|
|
296
|
+
// Manifest may not exist — keep existing metadata
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
plugin.updatedAt = new Date().toISOString();
|
|
300
|
+
await writeAgentPluginRegistry(label, registry);
|
|
301
|
+
return plugin;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Read a plugin's panel.js bundle from the installed package.
|
|
307
|
+
* @param {string} label - Agent label
|
|
308
|
+
* @param {string} name - Plugin name
|
|
309
|
+
* @returns {Promise<string>} JavaScript source
|
|
310
|
+
*/
|
|
311
|
+
export async function checkAgentPluginUpdate(label, name) {
|
|
312
|
+
const registry = await readAgentPluginRegistry(label);
|
|
313
|
+
const plugin = registry.plugins.find((p) => p.name === name);
|
|
314
|
+
if (!plugin) {
|
|
315
|
+
throw new Error(`Plugin "${name}" not found`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const pkg = plugin.packageName;
|
|
319
|
+
if (!pkg || !pkg.startsWith('@lamalibre/')) {
|
|
320
|
+
throw new Error('Invalid package scope');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const { stdout } = await execa('npm', ['view', pkg, 'version', '--json'], {
|
|
324
|
+
cwd: agentDataDir(label),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const latestVersion = JSON.parse(stdout.trim());
|
|
328
|
+
const currentVersion = plugin.version;
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
name,
|
|
332
|
+
currentVersion,
|
|
333
|
+
latestVersion,
|
|
334
|
+
hasUpdate: latestVersion !== currentVersion,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function readAgentPluginBundle(label, name) {
|
|
339
|
+
const registry = await readAgentPluginRegistry(label);
|
|
340
|
+
const plugin = registry.plugins.find((p) => p.name === name);
|
|
341
|
+
if (!plugin) {
|
|
342
|
+
throw new Error(`Plugin "${name}" not found`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const serverPkg = plugin.packages?.server;
|
|
346
|
+
if (!serverPkg) {
|
|
347
|
+
throw new Error(`Plugin "${name}" has no server package with panel bundle`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Defense-in-depth: verify scope in case registry was tampered
|
|
351
|
+
if (!serverPkg.startsWith('@lamalibre/')) {
|
|
352
|
+
throw new Error('Server package scope violation');
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const require = createRequire(path.join(agentDataDir(label), '/'));
|
|
356
|
+
const panelPath = require.resolve(`${serverPkg}/panel.js`);
|
|
357
|
+
return readFile(panelPath, 'utf-8');
|
|
358
|
+
}
|
package/src/lib/keychain.js
CHANGED
|
@@ -4,23 +4,6 @@ import path from 'node:path';
|
|
|
4
4
|
import { execa } from 'execa';
|
|
5
5
|
import { AGENT_DIR } from './platform.js';
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Prompt for the macOS login Keychain password via a native OS dialog.
|
|
9
|
-
* Uses osascript to display a secure input dialog (hidden answer).
|
|
10
|
-
* Throws if the user cancels.
|
|
11
|
-
*
|
|
12
|
-
* @returns {Promise<string>}
|
|
13
|
-
*/
|
|
14
|
-
async function promptKeychainPassword() {
|
|
15
|
-
const { stdout } = await execa('osascript', [
|
|
16
|
-
'-e',
|
|
17
|
-
'display dialog "Portlama needs your macOS login password to store the agent certificate in your Keychain." default answer "" with hidden answer buttons {"Cancel", "OK"} default button "OK" with title "Portlama — Keychain Access" with icon caution',
|
|
18
|
-
'-e',
|
|
19
|
-
'text returned of result',
|
|
20
|
-
]);
|
|
21
|
-
return stdout.trim();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
7
|
/**
|
|
25
8
|
* Overwrite a file with random bytes, then unlink it.
|
|
26
9
|
* Provides defense-in-depth against key recovery from disk.
|
|
@@ -86,155 +69,3 @@ export async function generateKeypairAndCSR(label) {
|
|
|
86
69
|
}
|
|
87
70
|
}
|
|
88
71
|
|
|
89
|
-
/**
|
|
90
|
-
* Import a signed certificate and its private key into the macOS Keychain
|
|
91
|
-
* as a non-extractable identity.
|
|
92
|
-
*
|
|
93
|
-
* Creates a temporary P12 from the key+cert+CA, imports into Keychain with
|
|
94
|
-
* the -x flag (non-extractable), sets the key partition list for curl access,
|
|
95
|
-
* and securely deletes all temporary files.
|
|
96
|
-
*
|
|
97
|
-
* @param {string} keyPath - Path to the temporary private key PEM
|
|
98
|
-
* @param {string} certPem - PEM-encoded signed certificate
|
|
99
|
-
* @param {string} caCertPem - PEM-encoded CA certificate
|
|
100
|
-
* @param {string} label - Agent label
|
|
101
|
-
* @param {import('pino').Logger | Console} logger
|
|
102
|
-
* @returns {Promise<{ identity: string }>}
|
|
103
|
-
*/
|
|
104
|
-
export async function importIdentityToKeychain(keyPath, certPem, caCertPem, label, logger) {
|
|
105
|
-
const suffix = crypto.randomBytes(8).toString('hex');
|
|
106
|
-
const certPath = path.join(AGENT_DIR, `.tmp-cert-${suffix}.pem`);
|
|
107
|
-
const caPath = path.join(AGENT_DIR, `.tmp-ca-${suffix}.pem`);
|
|
108
|
-
const p12Path = path.join(AGENT_DIR, `.tmp-import-${suffix}.p12`);
|
|
109
|
-
const p12Password = crypto.randomBytes(16).toString('hex');
|
|
110
|
-
const identityName = `Portlama Agent (${label})`;
|
|
111
|
-
|
|
112
|
-
try {
|
|
113
|
-
// Write cert and CA to temp files
|
|
114
|
-
await writeFile(certPath, certPem, { mode: 0o600 });
|
|
115
|
-
await writeFile(caPath, caCertPem, { mode: 0o600 });
|
|
116
|
-
|
|
117
|
-
// Create temporary P12 from key + cert + CA
|
|
118
|
-
logger.info?.({ label }, 'Creating temporary P12 for Keychain import') ??
|
|
119
|
-
logger.log?.(`Creating temporary P12 for Keychain import: ${label}`);
|
|
120
|
-
await execa('openssl', [
|
|
121
|
-
'pkcs12',
|
|
122
|
-
'-export',
|
|
123
|
-
'-keypbe',
|
|
124
|
-
'PBE-SHA1-3DES',
|
|
125
|
-
'-certpbe',
|
|
126
|
-
'PBE-SHA1-3DES',
|
|
127
|
-
'-macalg',
|
|
128
|
-
'sha1',
|
|
129
|
-
'-out',
|
|
130
|
-
p12Path,
|
|
131
|
-
'-inkey',
|
|
132
|
-
keyPath,
|
|
133
|
-
'-in',
|
|
134
|
-
certPath,
|
|
135
|
-
'-certfile',
|
|
136
|
-
caPath,
|
|
137
|
-
'-name',
|
|
138
|
-
identityName,
|
|
139
|
-
'-passout',
|
|
140
|
-
`env:PORTLAMA_TMP_P12_PASS`,
|
|
141
|
-
], {
|
|
142
|
-
env: { ...process.env, PORTLAMA_TMP_P12_PASS: p12Password },
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// Import into Keychain with -x (non-extractable) and -T for curl access.
|
|
146
|
-
// Known limitation: `security import -P` passes the password as a CLI argument,
|
|
147
|
-
// visible in `ps aux`. The `security` command does not support stdin or env var
|
|
148
|
-
// password input. Mitigated by: the P12 is ephemeral (random password, temp file,
|
|
149
|
-
// deleted immediately after import), so the exposure window is seconds.
|
|
150
|
-
logger.info?.({ label }, 'Importing identity into Keychain (non-extractable)') ??
|
|
151
|
-
logger.log?.(`Importing identity into Keychain: ${label}`);
|
|
152
|
-
await execa('security', [
|
|
153
|
-
'import',
|
|
154
|
-
p12Path,
|
|
155
|
-
'-x', // non-extractable private key
|
|
156
|
-
'-T',
|
|
157
|
-
'/usr/bin/curl', // allow curl to use this identity
|
|
158
|
-
'-P',
|
|
159
|
-
p12Password,
|
|
160
|
-
]);
|
|
161
|
-
|
|
162
|
-
// Trust the Portlama CA so macOS considers the agent identity valid.
|
|
163
|
-
// Without this, `security find-identity -v -p ssl-client` marks the
|
|
164
|
-
// identity as CSSMERR_TP_NOT_TRUSTED and curl exits with code 58.
|
|
165
|
-
// `add-trusted-cert` is idempotent — safe to call on every import.
|
|
166
|
-
try {
|
|
167
|
-
await execa('security', [
|
|
168
|
-
'add-trusted-cert',
|
|
169
|
-
'-p',
|
|
170
|
-
'ssl', // trust for SSL/TLS
|
|
171
|
-
caPath,
|
|
172
|
-
]);
|
|
173
|
-
} catch (err) {
|
|
174
|
-
logger.warn?.({ err, label }, 'Could not set CA trust — curl may fail with exit 58') ??
|
|
175
|
-
logger.log?.(`Warning: Could not trust CA certificate for ${label}`);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Set the key partition list so curl can access the identity without prompts.
|
|
179
|
-
// Requires the login Keychain password — prompt via native macOS dialog.
|
|
180
|
-
const keychainPassword = await promptKeychainPassword();
|
|
181
|
-
try {
|
|
182
|
-
await execa('security', [
|
|
183
|
-
'set-key-partition-list',
|
|
184
|
-
'-S',
|
|
185
|
-
'apple-tool:,apple:', // apple-tool: for /usr/bin/curl, apple: for GUI apps
|
|
186
|
-
'-k',
|
|
187
|
-
keychainPassword,
|
|
188
|
-
'-l', // match by Label (friendly name from -name), not -D (Description)
|
|
189
|
-
identityName,
|
|
190
|
-
]);
|
|
191
|
-
} catch {
|
|
192
|
-
throw new Error(
|
|
193
|
-
'Could not authorize Keychain access. The macOS login password may be incorrect.',
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return { identity: identityName };
|
|
198
|
-
} finally {
|
|
199
|
-
// Securely delete all temp files
|
|
200
|
-
await secureDelete(keyPath);
|
|
201
|
-
await secureDelete(certPath);
|
|
202
|
-
await secureDelete(caPath);
|
|
203
|
-
await secureDelete(p12Path);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Check if a Keychain identity exists for the given agent label.
|
|
209
|
-
*
|
|
210
|
-
* @param {string} label - Agent label
|
|
211
|
-
* @returns {Promise<boolean>}
|
|
212
|
-
*/
|
|
213
|
-
export async function keychainIdentityExists(label) {
|
|
214
|
-
const identityName = `Portlama Agent (${label})`;
|
|
215
|
-
try {
|
|
216
|
-
const { stdout } = await execa('security', [
|
|
217
|
-
'find-identity',
|
|
218
|
-
'-v',
|
|
219
|
-
'-p',
|
|
220
|
-
'ssl-client',
|
|
221
|
-
]);
|
|
222
|
-
return stdout.includes(identityName);
|
|
223
|
-
} catch {
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Remove a Keychain identity for the given agent label.
|
|
230
|
-
*
|
|
231
|
-
* @param {string} label - Agent label
|
|
232
|
-
*/
|
|
233
|
-
export async function removeKeychainIdentity(label) {
|
|
234
|
-
const identityName = `Portlama Agent (${label})`;
|
|
235
|
-
await execa('security', [
|
|
236
|
-
'delete-identity',
|
|
237
|
-
'-c',
|
|
238
|
-
identityName,
|
|
239
|
-
]);
|
|
240
|
-
}
|
|
@@ -59,9 +59,14 @@ export async function startLocalPluginHost({ port = 9293 } = {}) {
|
|
|
59
59
|
|
|
60
60
|
const hostToken = await getOrCreateHostToken();
|
|
61
61
|
|
|
62
|
-
// Allow Tauri webview and localhost origins to call plugin APIs
|
|
62
|
+
// Allow Tauri webview and localhost origins to call plugin APIs.
|
|
63
|
+
// Restrict to known origins — never echo arbitrary origins with credentials.
|
|
63
64
|
await server.register(cors, {
|
|
64
|
-
origin:
|
|
65
|
+
origin: [
|
|
66
|
+
'tauri://localhost',
|
|
67
|
+
'https://tauri.localhost',
|
|
68
|
+
/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/,
|
|
69
|
+
],
|
|
65
70
|
credentials: true,
|
|
66
71
|
});
|
|
67
72
|
|
|
@@ -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
|
}
|