@lamalibre/create-lamaste 4.2.0 → 4.3.0

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.
Files changed (123) hide show
  1. package/package.json +2 -2
  2. package/scripts/lamaste-priv +205 -2
  3. package/src/index.js +4 -4
  4. package/src/lib/node-version.js +114 -0
  5. package/src/lib/relay-services.js +15 -1
  6. package/src/lib/service-config.js +113 -0
  7. package/src/tasks/gatekeeper.js +8 -3
  8. package/src/tasks/node.js +34 -75
  9. package/src/tasks/redeploy.js +36 -0
  10. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  11. package/vendor/docs/00-introduction/quickstart.md +5 -5
  12. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  13. package/vendor/docs/01-concepts/authentication.md +6 -3
  14. package/vendor/docs/01-concepts/authorization.md +29 -17
  15. package/vendor/docs/01-concepts/certificates.md +3 -3
  16. package/vendor/docs/01-concepts/mtls.md +2 -2
  17. package/vendor/docs/01-concepts/nginx-reverse-proxy.md +8 -3
  18. package/vendor/docs/01-concepts/security-model.md +98 -34
  19. package/vendor/docs/01-concepts/tickets.md +2 -2
  20. package/vendor/docs/01-concepts/tunneling.md +4 -4
  21. package/vendor/docs/02-guides/agent-setup.md +34 -16
  22. package/vendor/docs/02-guides/certificate-management.md +2 -2
  23. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  24. package/vendor/docs/02-guides/desktop-app-setup.md +10 -10
  25. package/vendor/docs/02-guides/installation.md +3 -3
  26. package/vendor/docs/02-guides/onboarding.md +2 -2
  27. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  28. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  29. package/vendor/docs/03-architecture/installer.md +4 -4
  30. package/vendor/docs/03-architecture/management-flow.md +1 -1
  31. package/vendor/docs/03-architecture/nginx-configuration.md +91 -26
  32. package/vendor/docs/03-architecture/overview.md +8 -8
  33. package/vendor/docs/03-architecture/panel-client.md +6 -6
  34. package/vendor/docs/03-architecture/panel-server.md +2 -2
  35. package/vendor/docs/03-architecture/state-management.md +2 -2
  36. package/vendor/docs/03-architecture/system-overview.md +1 -1
  37. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +407 -0
  38. package/vendor/docs/04-api-reference/app-host.md +360 -0
  39. package/vendor/docs/04-api-reference/audit-and-notifications.md +57 -0
  40. package/vendor/docs/04-api-reference/certificates.md +95 -38
  41. package/vendor/docs/04-api-reference/gatekeeper.md +5 -3
  42. package/vendor/docs/04-api-reference/lifecycle-hooks.md +333 -0
  43. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  44. package/vendor/docs/04-api-reference/overview.md +137 -111
  45. package/vendor/docs/04-api-reference/registry-and-oidc.md +7 -3
  46. package/vendor/docs/04-api-reference/tunnels.md +15 -13
  47. package/vendor/docs/05-operations/backup-and-restore.md +1 -1
  48. package/vendor/docs/05-operations/uninstalling.md +1 -1
  49. package/vendor/docs/05-operations/upgrades.md +25 -20
  50. package/vendor/docs/06-reference/config-files.md +190 -97
  51. package/vendor/docs/06-reference/glossary.md +3 -3
  52. package/vendor/docs/06-reference/installer-flags.md +13 -13
  53. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  54. package/vendor/docs/06-reference/troubleshooting.md +33 -8
  55. package/vendor/docs/_index.json +20 -0
  56. package/vendor/docs/gatekeeper-design.md +22 -19
  57. package/vendor/docs/identity-directory-design.md +57 -56
  58. package/vendor/docs/package.json +1 -1
  59. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  60. package/vendor/gatekeeper/dist/lib/types.d.ts +12 -0
  61. package/vendor/gatekeeper/dist/lib/types.d.ts.map +1 -1
  62. package/vendor/gatekeeper/dist/server/index.d.ts +2 -0
  63. package/vendor/gatekeeper/dist/server/index.d.ts.map +1 -1
  64. package/vendor/gatekeeper/dist/server/index.js +47 -8
  65. package/vendor/gatekeeper/dist/server/index.js.map +1 -1
  66. package/vendor/gatekeeper/dist/server/routes/authz.d.ts.map +1 -1
  67. package/vendor/gatekeeper/dist/server/routes/authz.js +233 -141
  68. package/vendor/gatekeeper/dist/server/routes/authz.js.map +1 -1
  69. package/vendor/gatekeeper/package.json +4 -3
  70. package/vendor/server/bin/lamaste-server.js +18 -1
  71. package/vendor/server/package.json +2 -2
  72. package/vendor/server/src/commands/audit.js +60 -0
  73. package/vendor/server/src/commands/plugins.js +963 -12
  74. package/vendor/server/src/commands/push-install.js +317 -0
  75. package/vendor/server/src/commands/registry-credential.js +2 -1
  76. package/vendor/server/src/commands/uninstall.js +28 -1
  77. package/vendor/server/src/ndjson.js +4 -2
  78. package/vendor/server/src/panel-api.js +32 -4
  79. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +1 -0
  80. package/vendor/server-ui/dist/assets/index-DBp3cmaU.js +84 -0
  81. package/vendor/server-ui/dist/index.html +2 -2
  82. package/vendor/serverd/package.json +2 -2
  83. package/vendor/serverd/src/index.js +56 -4
  84. package/vendor/serverd/src/lib/actor.js +24 -0
  85. package/vendor/serverd/src/lib/agent-jobs.js +330 -0
  86. package/vendor/serverd/src/lib/app-error.js +10 -0
  87. package/vendor/serverd/src/lib/audit.js +145 -0
  88. package/vendor/serverd/src/lib/hook-effects.js +123 -0
  89. package/vendor/serverd/src/lib/hook-pipelines.js +902 -0
  90. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  91. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  92. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  93. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  94. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  95. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  96. package/vendor/serverd/src/lib/mtls.js +89 -8
  97. package/vendor/serverd/src/lib/nginx.js +298 -44
  98. package/vendor/serverd/src/lib/notifications.js +107 -0
  99. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  100. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  101. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  102. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  103. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  104. package/vendor/serverd/src/lib/plugins.js +298 -68
  105. package/vendor/serverd/src/lib/push-install.js +88 -2
  106. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  107. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  108. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  109. package/vendor/serverd/src/lib/retained-data.js +99 -0
  110. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  111. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  112. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  113. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  114. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  115. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  116. package/vendor/serverd/src/routes/management/agents.js +262 -1
  117. package/vendor/serverd/src/routes/management/audit.js +81 -0
  118. package/vendor/serverd/src/routes/management/certs.js +1 -15
  119. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  120. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  121. package/vendor/serverd/src/routes/management.js +4 -0
  122. package/vendor/server-ui/dist/assets/index-B5Wp8lu-.js +0 -257
  123. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -11,22 +11,28 @@
11
11
  import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
12
12
  import { createRequire } from 'node:module';
13
13
  import path from 'node:path';
14
- import { z } from 'zod';
15
14
  import { execa } from 'execa';
16
15
  import {
17
16
  DEFAULT_PLUGIN_HOSTING,
17
+ ManifestValidationError,
18
18
  RESERVED_API_PREFIXES,
19
19
  RESERVED_NAV_LABELS,
20
20
  derivePluginRoute,
21
+ hasAgentHalf,
22
+ pluginHostCapability,
21
23
  validateManifest as coreValidateManifest,
22
24
  } from '@lamalibre/lamaste';
23
- import { revokePluginCapabilitiesFromAgents } from '@lamalibre/lamaste/server';
24
25
  import { listRegisteredSubScopeNames } from './tickets.js';
25
26
  import { getStateDb } from './state-db.js';
27
+ import { revokePluginCapabilitiesFromAgents } from './mtls.js';
28
+ import {
29
+ bareLamalibrePackageName,
30
+ installedHookEntrySha256,
31
+ pinAgentHalf,
32
+ } from './plugin-packages.js';
26
33
 
27
34
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
28
35
  const PLUGINS_DIR = path.join(STATE_DIR, 'plugins');
29
- const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
30
36
 
31
37
  // Promise-chain mutex. SQLite's BEGIN IMMEDIATE serialises a single write
32
38
  // transaction, but install/uninstall are read-modify-write sequences that
@@ -57,12 +63,14 @@ async function getStmts() {
57
63
 
58
64
  selectAll: db.prepare('SELECT * FROM plugins'),
59
65
  selectByName: db.prepare('SELECT * FROM plugins WHERE name = ?'),
66
+ selectByRoute: db.prepare('SELECT * FROM plugins WHERE route = ?'),
60
67
  selectByPackageName: db.prepare('SELECT * FROM plugins WHERE package_name = ?'),
61
68
  insert: db.prepare(`
62
69
  INSERT INTO plugins
63
- (name, display_name, package_name, version, description, capabilities,
64
- packages, panel, config, modes, hosting, status, installed_at, enabled_at)
65
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
70
+ (name, route, display_name, package_name, version, description, capabilities,
71
+ packages, panel, config, modes, hosting, agent, hooks, hooks_entry_sha256,
72
+ agent_half_pin, status, installed_at, enabled_at)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
66
74
  `),
67
75
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
68
76
  deleteAll: db.prepare('DELETE FROM plugins'),
@@ -90,11 +98,16 @@ async function getStmts() {
90
98
  // missing keys are absent rather than null). `hosting` is plain TEXT
91
99
  // ('server' | 'agent'; rows from before 4.2 default to 'server'): an
92
100
  // agent-hosted plugin is installed (npm, manifest) like any other, but the
93
- // plugin host never imports it — see `pluginHostPlugin`.
101
+ // plugin host never imports it — see `pluginHostPlugin`. `route` is the
102
+ // plugin's route (`derivePluginRoute(name)`), unique among installed
103
+ // plugins; null only for a plugin installed before routes were unique that
104
+ // shares its route with an older one (migration 0010) — such a plugin is
105
+ // never hosted and its uninstall leaves the route's capabilities alone.
94
106
 
95
107
  function rowToPlugin(row) {
96
108
  const entry = {
97
109
  name: row.name,
110
+ route: row.route ?? null,
98
111
  packageName: row.package_name,
99
112
  version: row.version,
100
113
  capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
@@ -108,6 +121,10 @@ function rowToPlugin(row) {
108
121
  if (row.panel != null) entry.panel = JSON.parse(row.panel);
109
122
  if (row.config != null) entry.config = JSON.parse(row.config);
110
123
  if (row.modes != null) entry.modes = JSON.parse(row.modes);
124
+ if (row.agent != null) entry.agent = JSON.parse(row.agent);
125
+ if (row.hooks != null) entry.hooks = JSON.parse(row.hooks);
126
+ if (row.hooks_entry_sha256 != null) entry.hooksEntrySha256 = row.hooks_entry_sha256;
127
+ if (row.agent_half_pin != null) entry.agentHalfPin = JSON.parse(row.agent_half_pin);
111
128
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
112
129
  return entry;
113
130
  }
@@ -151,6 +168,7 @@ export async function writePlugins(data) {
151
168
  for (const p of list) {
152
169
  s.insert.run(
153
170
  p.name,
171
+ p.route === undefined ? derivePluginRoute(p.name) : p.route,
154
172
  nullableString(p.displayName),
155
173
  p.packageName,
156
174
  p.version,
@@ -161,6 +179,10 @@ export async function writePlugins(data) {
161
179
  nullableJson(p.config),
162
180
  nullableJson(p.modes),
163
181
  p.hosting ?? DEFAULT_PLUGIN_HOSTING,
182
+ nullableJson(p.agent),
183
+ nullableJson(p.hooks),
184
+ nullableString(p.hooksEntrySha256),
185
+ nullableJson(p.agentHalfPin),
164
186
  p.status,
165
187
  p.installedAt,
166
188
  nullableString(p.enabledAt),
@@ -179,7 +201,7 @@ export async function writePlugins(data) {
179
201
  *
180
202
  * @param {object} raw - The raw manifest object
181
203
  * @returns {object} The validated and parsed manifest
182
- * @throws {z.ZodError} If validation fails
204
+ * @throws {ManifestValidationError} If validation fails
183
205
  */
184
206
  export function validateManifest(raw) {
185
207
  return coreValidateManifest(raw);
@@ -236,10 +258,74 @@ async function syncOidcClientsBestEffort(logger) {
236
258
  }
237
259
  }
238
260
 
261
+ /**
262
+ * The panel's rules for a manifest beyond its schema — checked at install
263
+ * and again for an update. Returns `{ message, statusCode }` or null.
264
+ */
265
+ export async function manifestPolicyError(manifest) {
266
+ // Names that collide with core API route prefixes
267
+ if (RESERVED_API_PREFIXES.includes(manifest.name)) {
268
+ return { message: `Plugin name "${manifest.name}" is reserved`, statusCode: 400 };
269
+ }
270
+ // A displayName that matches a core navigation label
271
+ if (manifest.displayName && RESERVED_NAV_LABELS.includes(manifest.displayName.toLowerCase())) {
272
+ return {
273
+ message: `Plugin display name "${manifest.displayName}" conflicts with a core navigation label`,
274
+ statusCode: 400,
275
+ };
276
+ }
277
+ // A plugin with an agent half is named by its route: the route keys its
278
+ // hosting, its internal tunnel, its agent half's account and directories
279
+ // on the agent and its forwarded API (`/api/<route>`), while its hook
280
+ // state, operations and audit entries go by name — the two must be one.
281
+ if (hasAgentHalf(manifest)) {
282
+ const route = derivePluginRoute(manifest.name);
283
+ if (route !== manifest.name) {
284
+ return {
285
+ message: `A plugin with an agent half is named by its route: "${manifest.name}" would be served as "${route}" — name it "${route}" (its packages keep their names)`,
286
+ statusCode: 400,
287
+ };
288
+ }
289
+ }
290
+ // apiPrefix must match the plugin name
291
+ if (manifest.panel?.apiPrefix && manifest.panel.apiPrefix !== `/api/${manifest.name}`) {
292
+ return {
293
+ message: `Plugin apiPrefix must be "/api/${manifest.name}" but got "${manifest.panel.apiPrefix}"`,
294
+ statusCode: 400,
295
+ };
296
+ }
297
+ // Cross-namespace collision: any capability declared by the plugin must
298
+ // not match a sub-scope name already owned by a registered ticket scope.
299
+ // Schema-level namespacing puts both sides in `plugin:*`; this check is
300
+ // a flat set intersection. The reverse direction (registering a scope
301
+ // that collides with an installed plugin's cap) is enforced in
302
+ // `registerScope`.
303
+ if (Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0) {
304
+ const subScopeNames = new Set(await listRegisteredSubScopeNames());
305
+ const collision = manifest.capabilities.find((c) => subScopeNames.has(c));
306
+ if (collision) {
307
+ return {
308
+ message:
309
+ `Plugin capability "${collision}" collides with an existing ticket scope sub-scope name. ` +
310
+ 'Pick a different action name or unregister the conflicting ticket scope first.',
311
+ statusCode: 409,
312
+ };
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+
239
318
  export function installPlugin(packageName, logger) {
240
319
  return withPluginLock(async () => {
241
320
  // Validate package scope
242
321
  assertSafeLamalibrePackage(packageName);
322
+ // `@lamalibre/x@1.2.3` installs that version; the registry knows the
323
+ // package by its bare name (the manifest is resolved by it, too).
324
+ const installSpec = packageName;
325
+ packageName = bareLamalibrePackageName(installSpec);
326
+ // Remove the package npm installed when it is refused below.
327
+ const discardPackage = () =>
328
+ execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
243
329
 
244
330
  const s = await getStmts();
245
331
 
@@ -262,15 +348,18 @@ export function installPlugin(packageName, logger) {
262
348
  }
263
349
 
264
350
  // Install the npm package
265
- logger.info({ packageName }, 'Installing plugin package');
351
+ logger.info({ packageName: installSpec }, 'Installing plugin package');
266
352
  try {
267
- await execa('npm', ['install', '--ignore-scripts', packageName], {
353
+ await execa('npm', ['install', '--ignore-scripts', installSpec], {
268
354
  cwd: STATE_DIR,
269
355
  timeout: 120000,
270
356
  });
271
357
  } catch (err) {
272
- logger.error({ packageName, stderr: err.stderr, err: err.message }, 'npm install failed');
273
- throw Object.assign(new Error(`Failed to install "${packageName}"`), { statusCode: 500 });
358
+ logger.error(
359
+ { packageName: installSpec, stderr: err.stderr, err: err.message },
360
+ 'npm install failed',
361
+ );
362
+ throw Object.assign(new Error(`Failed to install "${installSpec}"`), { statusCode: 500 });
274
363
  }
275
364
 
276
365
  // Read the plugin manifest from the installed package
@@ -283,70 +372,58 @@ export function installPlugin(packageName, logger) {
283
372
  } catch (err) {
284
373
  // Clean up the installed package if manifest is missing/invalid
285
374
  logger.warn({ packageName, err: err.message }, 'Invalid or missing manifest, uninstalling');
286
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
375
+ await discardPackage();
287
376
 
288
- if (err instanceof z.ZodError) {
289
- throw Object.assign(
290
- new Error(`Invalid plugin manifest: ${err.errors.map((e) => e.message).join(', ')}`),
291
- { statusCode: 400 },
292
- );
377
+ if (err instanceof ManifestValidationError) {
378
+ throw Object.assign(new Error(err.message), { statusCode: 400 });
293
379
  }
294
380
  throw Object.assign(new Error(`Plugin manifest not found in "${packageName}"`), {
295
381
  statusCode: 400,
296
382
  });
297
383
  }
298
384
 
299
- // Reject names that collide with core API route prefixes
300
- if (RESERVED_API_PREFIXES.includes(manifest.name)) {
301
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
302
- throw Object.assign(new Error(`Plugin name "${manifest.name}" is reserved`), {
303
- statusCode: 400,
304
- });
385
+ const policyError = await manifestPolicyError(manifest);
386
+ if (policyError) {
387
+ await discardPackage();
388
+ throw Object.assign(new Error(policyError.message), { statusCode: policyError.statusCode });
305
389
  }
306
390
 
307
- // Reject displayName that matches reserved navigation labels
308
- if (manifest.displayName) {
309
- if (RESERVED_NAV_LABELS.includes(manifest.displayName.toLowerCase())) {
310
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
311
- throw Object.assign(
312
- new Error(
313
- `Plugin display name "${manifest.displayName}" conflicts with a core navigation label`,
314
- ),
315
- { statusCode: 400 },
316
- );
317
- }
391
+ // A plugin with relay hooks of its own: its module must be in the
392
+ // package; its SHA-256 goes into the hook set's digest, which an admin
393
+ // approves before Lamaste runs it.
394
+ let hooksEntrySha256 = null;
395
+ try {
396
+ hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
397
+ } catch (err) {
398
+ await discardPackage();
399
+ throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
318
400
  }
319
401
 
320
- // Validate apiPrefix matches plugin name if specified
321
- if (manifest.panel?.apiPrefix && manifest.panel.apiPrefix !== `/api/${manifest.name}`) {
322
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
323
- throw Object.assign(
324
- new Error(
325
- `Plugin apiPrefix must be "/api/${manifest.name}" but got "${manifest.panel.apiPrefix}"`,
326
- ),
327
- { statusCode: 400 },
328
- );
402
+ // An agent half Lamaste installs: its tarball's integrity is pinned now,
403
+ // from the registry this install used — the approval covers it and the
404
+ // agent installs only a tarball that matches it.
405
+ let agentHalfPin = null;
406
+ try {
407
+ agentHalfPin = await pinAgentHalf(manifest);
408
+ } catch (err) {
409
+ await discardPackage();
410
+ throw Object.assign(new Error(err.message), { statusCode: err.statusCode || 400 });
329
411
  }
330
412
 
331
- // Cross-namespace collision: any capability declared by the plugin must
332
- // not match a sub-scope name already owned by a registered ticket scope.
333
- // Schema-level namespacing puts both sides in `plugin:*`; this check is
334
- // a flat set intersection. The reverse direction (registering a scope
335
- // that collides with an installed plugin's cap) is enforced in
336
- // `registerScope`.
337
- if (Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0) {
338
- const subScopeNames = new Set(await listRegisteredSubScopeNames());
339
- const collision = manifest.capabilities.find((c) => subScopeNames.has(c));
340
- if (collision) {
341
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
342
- throw Object.assign(
343
- new Error(
344
- `Plugin capability "${collision}" collides with an existing ticket scope sub-scope name. ` +
345
- 'Pick a different action name or unregister the conflicting ticket scope first.',
346
- ),
347
- { statusCode: 409 },
348
- );
349
- }
413
+ // A name another installed package already uses would fail the INSERT
414
+ // below and leave this package behind.
415
+ if (s.selectByName.get(manifest.name)) {
416
+ await discardPackage();
417
+ throw Object.assign(new Error(`A plugin named "${manifest.name}" is already installed`), {
418
+ statusCode: 409,
419
+ });
420
+ }
421
+ // So would its route (unique index): one installed plugin per route.
422
+ const route = derivePluginRoute(manifest.name);
423
+ const routeOwner = s.selectByRoute.get(route);
424
+ if (routeOwner) {
425
+ await discardPackage();
426
+ throw routeTakenError(manifest.name, route, routeOwner.name);
350
427
  }
351
428
 
352
429
  // Create the plugin state directory
@@ -356,6 +433,7 @@ export function installPlugin(packageName, logger) {
356
433
  // Build the entry, then persist via INSERT
357
434
  const entry = {
358
435
  name: manifest.name,
436
+ route,
359
437
  displayName: manifest.displayName,
360
438
  packageName,
361
439
  version: manifest.version,
@@ -366,6 +444,10 @@ export function installPlugin(packageName, logger) {
366
444
  config: manifest.config,
367
445
  modes: manifest.modes,
368
446
  hosting: manifest.hosting,
447
+ agent: manifest.agent,
448
+ hooks: manifest.hooks,
449
+ hooksEntrySha256,
450
+ agentHalfPin,
369
451
  status: 'disabled',
370
452
  installedAt: new Date().toISOString(),
371
453
  };
@@ -374,6 +456,7 @@ export function installPlugin(packageName, logger) {
374
456
  try {
375
457
  s.insert.run(
376
458
  entry.name,
459
+ entry.route,
377
460
  nullableString(entry.displayName),
378
461
  entry.packageName,
379
462
  entry.version,
@@ -384,6 +467,10 @@ export function installPlugin(packageName, logger) {
384
467
  nullableJson(entry.config),
385
468
  nullableJson(entry.modes),
386
469
  entry.hosting,
470
+ nullableJson(entry.agent),
471
+ nullableJson(entry.hooks),
472
+ nullableString(entry.hooksEntrySha256),
473
+ nullableJson(entry.agentHalfPin),
387
474
  entry.status,
388
475
  entry.installedAt,
389
476
  null,
@@ -424,6 +511,24 @@ export function uninstallPlugin(name, logger) {
424
511
  });
425
512
  }
426
513
 
514
+ // A hosted plugin is uninstalled by an operation that runs its
515
+ // uninstall pipeline first (plugin-lifecycle.js); by the time it gets
516
+ // here the hosting is gone.
517
+ if (hasAgentHalf(plugin)) {
518
+ const { isPluginHosted } = await import('./plugin-hosting.js');
519
+ if (await isPluginHosted(name)) {
520
+ throw Object.assign(
521
+ new Error(`Plugin "${name}" is hosted on an agent — its hosting is removed first`),
522
+ { statusCode: 409 },
523
+ );
524
+ }
525
+ }
526
+
527
+ // Its relay hook runner and approval go with it: a later install of the
528
+ // plugin is approved afresh.
529
+ const { removeRelayHooks } = await import('./plugin-hooks.js');
530
+ await removeRelayHooks(name, logger);
531
+
427
532
  // Uninstall the npm package
428
533
  logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
429
534
  await execa('npm', ['uninstall', plugin.packageName], {
@@ -450,12 +555,24 @@ export function uninstallPlugin(name, logger) {
450
555
 
451
556
  // Sweep this plugin's capabilities off every agent so re-installing the
452
557
  // plugin later does NOT silently re-grant. Failure here is non-fatal
453
- // (the plugin is already gone), but logged loudly.
558
+ // (the plugin is already gone), but logged loudly. A plugin without a
559
+ // route of its own shares its namespace with the plugin that holds the
560
+ // route: the capabilities are that plugin's, and stay.
454
561
  try {
455
- const route = derivePluginRoute(plugin.name);
456
- const diffs = await revokePluginCapabilitiesFromAgents(PKI_DIR, route);
457
- if (diffs.length > 0) {
458
- logger.info({ name, route, diffs }, 'Revoked plugin capabilities from agents on uninstall');
562
+ const route = plugin.route;
563
+ if (route === null) {
564
+ logger.warn(
565
+ { name, route: derivePluginRoute(plugin.name) },
566
+ 'Plugin shares its route with another installed plugin — its capabilities are left in place',
567
+ );
568
+ } else {
569
+ const diffs = await revokePluginCapabilitiesFromAgents(route);
570
+ if (diffs.length > 0) {
571
+ logger.info(
572
+ { name, route, diffs },
573
+ 'Revoked plugin capabilities from agents on uninstall',
574
+ );
575
+ }
459
576
  }
460
577
  } catch (err) {
461
578
  logger.warn(
@@ -470,6 +587,83 @@ export function uninstallPlugin(name, logger) {
470
587
  });
471
588
  }
472
589
 
590
+ // --- In-place update (agent-hosted plugins) ---
591
+
592
+ const updateRowSql = `
593
+ UPDATE plugins SET
594
+ display_name = ?, version = ?, description = ?, capabilities = ?, packages = ?, panel = ?,
595
+ config = ?, modes = ?, hosting = ?, agent = ?, hooks = ?, hooks_entry_sha256 = ?,
596
+ agent_half_pin = ?
597
+ WHERE name = ?`;
598
+
599
+ /**
600
+ * Rewrite an installed plugin's row from a manifest (an update, or its
601
+ * reversal) with the agent half pinned for that version (null when Lamaste
602
+ * installs none). Status and install time stay.
603
+ */
604
+ export async function writePluginRow(name, manifest, hooksEntrySha256, agentHalfPin) {
605
+ const s = await getStmts();
606
+ s.db
607
+ .prepare(updateRowSql)
608
+ .run(
609
+ nullableString(manifest.displayName),
610
+ manifest.version,
611
+ nullableString(manifest.description),
612
+ JSON.stringify(manifest.capabilities ?? []),
613
+ nullableJson(manifest.packages),
614
+ nullableJson(manifest.panel),
615
+ nullableJson(manifest.config),
616
+ nullableJson(manifest.modes),
617
+ manifest.hosting ?? DEFAULT_PLUGIN_HOSTING,
618
+ nullableJson(manifest.agent),
619
+ nullableJson(manifest.hooks),
620
+ nullableString(hooksEntrySha256),
621
+ nullableJson(agentHalfPin),
622
+ name,
623
+ );
624
+ }
625
+
626
+ /**
627
+ * Install `<packageName>@<version>` over the installed copy and read it
628
+ * back: the validated manifest and its relay entry module's SHA-256. The
629
+ * row is not touched — the caller compares the result with what was
630
+ * reviewed and then writes it. Refuses a package whose manifest names
631
+ * another plugin.
632
+ */
633
+ export function replacePluginPackage(name, packageName, version, logger) {
634
+ return withPluginLock(async () => {
635
+ const bare = bareLamalibrePackageName(packageName);
636
+ const spec = `${bare}@${version}`;
637
+ assertSafeLamalibrePackage(spec);
638
+ logger?.info({ name, spec }, 'Installing plugin package version');
639
+ try {
640
+ await execa('npm', ['install', '--ignore-scripts', spec], {
641
+ cwd: STATE_DIR,
642
+ timeout: 120000,
643
+ });
644
+ } catch (err) {
645
+ logger?.error({ spec, stderr: err.stderr, err: err.message }, 'npm install failed');
646
+ throw Object.assign(new Error(`Failed to install "${spec}"`), { statusCode: 502 });
647
+ }
648
+ const require = createRequire(`${STATE_DIR}/`);
649
+ const manifestPath = require.resolve(`${bare}/lamaste-plugin.json`);
650
+ const manifest = validateManifest(JSON.parse(await readFile(manifestPath, 'utf-8')));
651
+ if (manifest.name !== name) {
652
+ throw Object.assign(new Error(`${spec} is plugin "${manifest.name}", not "${name}"`), {
653
+ statusCode: 400,
654
+ });
655
+ }
656
+ if (manifest.version !== version) {
657
+ throw Object.assign(
658
+ new Error(`${spec} installed version ${manifest.version} according to its manifest`),
659
+ { statusCode: 400 },
660
+ );
661
+ }
662
+ const hooksEntrySha256 = await installedHookEntrySha256(packageName, manifest);
663
+ return { manifest, hooksEntrySha256 };
664
+ });
665
+ }
666
+
473
667
  /**
474
668
  * Enable a plugin.
475
669
  *
@@ -565,6 +759,42 @@ export async function getPluginCapabilities() {
565
759
  const parsed = JSON.parse(row.capabilities);
566
760
  if (Array.isArray(parsed)) caps.push(...parsed);
567
761
  }
762
+ // `plugin:<route>:host` is never declared: Lamaste contributes it for
763
+ // every installed agent-hosted plugin with an agent half, enabled or
764
+ // not — hosting is an administrator's binding and outlives a disable.
765
+ if (row.hosting === 'agent' && row.agent != null && row.route != null) {
766
+ caps.push(pluginHostCapability(row.route));
767
+ }
568
768
  }
569
769
  return [...new Set(caps)];
570
770
  }
771
+
772
+ /**
773
+ * One installed plugin by name, or null.
774
+ * @param {string} name
775
+ */
776
+ export async function findPlugin(name) {
777
+ const s = await getStmts();
778
+ const row = s.selectByName.get(name);
779
+ return row ? rowToPlugin(row) : null;
780
+ }
781
+
782
+ /**
783
+ * The installed plugin that holds route `route`, or null.
784
+ * @param {string} route
785
+ */
786
+ export async function findPluginByRoute(route) {
787
+ const s = await getStmts();
788
+ const row = s.selectByRoute.get(route);
789
+ return row ? rowToPlugin(row) : null;
790
+ }
791
+
792
+ /** 409 `route_taken`: another installed plugin holds the route. */
793
+ function routeTakenError(name, route, owner) {
794
+ return Object.assign(
795
+ new Error(
796
+ `Plugin "${name}" would use route "${route}", which installed plugin "${owner}" holds — a route (the name without "-server" or "-agent") names one plugin's API, capabilities, hosting and agent half`,
797
+ ),
798
+ { statusCode: 409, code: 'route_taken', route, owner },
799
+ );
800
+ }
@@ -23,6 +23,16 @@ function pushInstallConfigPath() {
23
23
  return path.join(STATE_DIR, 'push-install-config.json');
24
24
  }
25
25
 
26
+ /** Actions a push-install policy can allow. */
27
+ export const PUSH_INSTALL_ACTIONS = ['install', 'update', 'uninstall', 'check-prerequisites'];
28
+
29
+ /**
30
+ * How long an agent's push-install window may be opened for, in minutes —
31
+ * enforced when one is opened and reported with the config, so clients
32
+ * offer the same bounds.
33
+ */
34
+ export const PUSH_INSTALL_WINDOW_MINUTES = Object.freeze({ min: 5, max: 480 });
35
+
26
36
  const DEFAULT_POLICY = {
27
37
  id: 'default',
28
38
  name: 'Default',
@@ -30,9 +40,33 @@ const DEFAULT_POLICY = {
30
40
  allowedIps: [],
31
41
  deniedIps: [],
32
42
  allowedPlugins: [],
33
- allowedActions: ['install', 'update', 'check-prerequisites'],
43
+ allowedActions: ['install', 'update', 'uninstall', 'check-prerequisites'],
34
44
  };
35
45
 
46
+ /**
47
+ * The default policy's actions before `uninstall` joined them (4.3: removing
48
+ * an agent half Lamaste installed is a push to the agent too).
49
+ */
50
+ const DEFAULT_ACTIONS_BEFORE_UNINSTALL = ['install', 'update', 'check-prerequisites'];
51
+
52
+ /**
53
+ * A stored default policy whose actions were never edited gets `uninstall`
54
+ * with the default it was written from — the administrator chose nothing
55
+ * that excludes it. A policy that was edited, or any other policy, stays
56
+ * as stored.
57
+ */
58
+ function withCurrentDefaults(policy) {
59
+ if (
60
+ policy?.id === DEFAULT_POLICY.id &&
61
+ Array.isArray(policy.allowedActions) &&
62
+ policy.allowedActions.length === DEFAULT_ACTIONS_BEFORE_UNINSTALL.length &&
63
+ DEFAULT_ACTIONS_BEFORE_UNINSTALL.every((a, i) => policy.allowedActions[i] === a)
64
+ ) {
65
+ return { ...policy, allowedActions: [...DEFAULT_POLICY.allowedActions] };
66
+ }
67
+ return policy;
68
+ }
69
+
36
70
  const DEFAULT_PUSH_INSTALL_CONFIG = {
37
71
  enabled: false,
38
72
  policies: [structuredClone(DEFAULT_POLICY)],
@@ -51,7 +85,7 @@ export async function readPushInstallConfig() {
51
85
  return {
52
86
  enabled: parsed.enabled ?? false,
53
87
  policies: Array.isArray(parsed.policies)
54
- ? parsed.policies
88
+ ? parsed.policies.map(withCurrentDefaults)
55
89
  : [structuredClone(DEFAULT_POLICY)],
56
90
  defaultPolicy: parsed.defaultPolicy || 'default',
57
91
  };
@@ -388,3 +422,55 @@ export async function validatePushInstallAccess(label, sourceIp) {
388
422
 
389
423
  return { ok: true, agent, config, policy };
390
424
  }
425
+
426
+ /**
427
+ * Whether `policy` allows `action` on `packageName`. An empty list allows
428
+ * everything (as the policy editor documents). Returns the refusal, or null.
429
+ *
430
+ * @param {{ allowedActions?: string[], allowedPlugins?: string[] }} policy
431
+ * @param {string} action
432
+ * @param {string | null} packageName
433
+ * @returns {{ error: string, statusCode: number } | null}
434
+ */
435
+ export function pushInstallPolicyRefusal(policy, action, packageName) {
436
+ if (
437
+ Array.isArray(policy.allowedActions) &&
438
+ policy.allowedActions.length > 0 &&
439
+ !policy.allowedActions.includes(action)
440
+ ) {
441
+ return { error: `Action "${action}" is not allowed by the assigned policy`, statusCode: 403 };
442
+ }
443
+ if (
444
+ packageName &&
445
+ Array.isArray(policy.allowedPlugins) &&
446
+ policy.allowedPlugins.length > 0 &&
447
+ !policy.allowedPlugins.includes(packageName)
448
+ ) {
449
+ return {
450
+ error: `Package "${packageName}" is not allowed by the assigned policy`,
451
+ statusCode: 403,
452
+ };
453
+ }
454
+ return null;
455
+ }
456
+
457
+ /**
458
+ * The push-install gates for one push to agent `label` from `sourceIp`:
459
+ * {@link validatePushInstallAccess} (global switch, the agent's window,
460
+ * the source IP) and the assigned policy's actions and packages. Hosting,
461
+ * updating and removing an agent half Lamaste installs are pushes: the
462
+ * relay has the agent install, switch or remove code as root.
463
+ *
464
+ * @param {string} label
465
+ * @param {string} sourceIp
466
+ * @param {'install' | 'update' | 'uninstall'} action
467
+ * @param {string} packageName the agent half's package
468
+ * @returns {Promise<{ ok: true, policy: object } | { ok: false, error: string, statusCode: number }>}
469
+ */
470
+ export async function checkPushInstallAction(label, sourceIp, action, packageName) {
471
+ const access = await validatePushInstallAccess(label, sourceIp);
472
+ if (!access.ok) return access;
473
+ const refusal = pushInstallPolicyRefusal(access.policy, action, packageName);
474
+ if (refusal) return { ok: false, ...refusal };
475
+ return { ok: true, policy: access.policy };
476
+ }