@lamalibre/create-lamaste 4.1.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 (126) 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 +99 -35
  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 +32 -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 +1067 -161
  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 +36 -6
  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/0005_plugin_hosting.sql +3 -0
  91. package/vendor/serverd/src/lib/migrations/state/0006_plugin_agent.sql +4 -0
  92. package/vendor/serverd/src/lib/migrations/state/0007_audit_and_notifications.sql +29 -0
  93. package/vendor/serverd/src/lib/migrations/state/0008_plugin_hooks.sql +70 -0
  94. package/vendor/serverd/src/lib/migrations/state/0009_plugin_agent_half.sql +53 -0
  95. package/vendor/serverd/src/lib/migrations/state/0010_plugin_routes_and_retained_data.sql +47 -0
  96. package/vendor/serverd/src/lib/migrations/state/0011_agent_job_signature.sql +14 -0
  97. package/vendor/serverd/src/lib/mtls.js +89 -8
  98. package/vendor/serverd/src/lib/nginx.js +298 -44
  99. package/vendor/serverd/src/lib/notifications.js +107 -0
  100. package/vendor/serverd/src/lib/plugin-hooks.js +560 -0
  101. package/vendor/serverd/src/lib/plugin-hosting.js +688 -0
  102. package/vendor/serverd/src/lib/plugin-lifecycle.js +603 -0
  103. package/vendor/serverd/src/lib/plugin-oidc.js +33 -16
  104. package/vendor/serverd/src/lib/plugin-operations.js +295 -0
  105. package/vendor/serverd/src/lib/plugin-packages.js +249 -0
  106. package/vendor/serverd/src/lib/plugins.js +306 -68
  107. package/vendor/serverd/src/lib/push-install.js +88 -2
  108. package/vendor/serverd/src/lib/relay-hooks.js +100 -0
  109. package/vendor/serverd/src/lib/relay-signing.js +43 -0
  110. package/vendor/serverd/src/lib/request-hooks.js +79 -0
  111. package/vendor/serverd/src/lib/retained-data.js +99 -0
  112. package/vendor/serverd/src/lib/trust-proxy.js +34 -0
  113. package/vendor/serverd/src/lib/tunnel-hooks.js +88 -0
  114. package/vendor/serverd/src/lib/vhost-reconcile.js +260 -0
  115. package/vendor/serverd/src/middleware/twofa-session.js +11 -1
  116. package/vendor/serverd/src/routes/agent-forwarder.js +37 -0
  117. package/vendor/serverd/src/routes/management/agent-panel-authz.js +97 -0
  118. package/vendor/serverd/src/routes/management/agents.js +262 -1
  119. package/vendor/serverd/src/routes/management/audit.js +81 -0
  120. package/vendor/serverd/src/routes/management/certs.js +1 -15
  121. package/vendor/serverd/src/routes/management/plugins.js +569 -54
  122. package/vendor/serverd/src/routes/management/storage.js +10 -2
  123. package/vendor/serverd/src/routes/management/tunnels.js +26 -0
  124. package/vendor/serverd/src/routes/management.js +4 -0
  125. package/vendor/server-ui/dist/assets/index-B0nWuUyL.js +0 -257
  126. package/vendor/server-ui/dist/assets/index-BqWWFlTU.css +0 -1
@@ -11,21 +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 {
16
+ DEFAULT_PLUGIN_HOSTING,
17
+ ManifestValidationError,
17
18
  RESERVED_API_PREFIXES,
18
19
  RESERVED_NAV_LABELS,
19
20
  derivePluginRoute,
21
+ hasAgentHalf,
22
+ pluginHostCapability,
20
23
  validateManifest as coreValidateManifest,
21
24
  } from '@lamalibre/lamaste';
22
- import { revokePluginCapabilitiesFromAgents } from '@lamalibre/lamaste/server';
23
25
  import { listRegisteredSubScopeNames } from './tickets.js';
24
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';
25
33
 
26
34
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
27
35
  const PLUGINS_DIR = path.join(STATE_DIR, 'plugins');
28
- const PKI_DIR = process.env.LAMALIBRE_LAMASTE_PKI_DIR || '/etc/lamalibre/lamaste/pki';
29
36
 
30
37
  // Promise-chain mutex. SQLite's BEGIN IMMEDIATE serialises a single write
31
38
  // transaction, but install/uninstall are read-modify-write sequences that
@@ -56,12 +63,14 @@ async function getStmts() {
56
63
 
57
64
  selectAll: db.prepare('SELECT * FROM plugins'),
58
65
  selectByName: db.prepare('SELECT * FROM plugins WHERE name = ?'),
66
+ selectByRoute: db.prepare('SELECT * FROM plugins WHERE route = ?'),
59
67
  selectByPackageName: db.prepare('SELECT * FROM plugins WHERE package_name = ?'),
60
68
  insert: db.prepare(`
61
69
  INSERT INTO plugins
62
- (name, display_name, package_name, version, description, capabilities,
63
- packages, panel, config, modes, status, installed_at, enabled_at)
64
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
65
74
  `),
66
75
  deleteByName: db.prepare('DELETE FROM plugins WHERE name = ?'),
67
76
  deleteAll: db.prepare('DELETE FROM plugins'),
@@ -86,14 +95,23 @@ async function getStmts() {
86
95
  // JSON-encoded TEXT in storage. They round-trip through JSON.parse/stringify
87
96
  // so callers see the same shape the JSON-backed module returned — including
88
97
  // `undefined` for optional fields (mirroring the JSON object layout where
89
- // missing keys are absent rather than null).
98
+ // missing keys are absent rather than null). `hosting` is plain TEXT
99
+ // ('server' | 'agent'; rows from before 4.2 default to 'server'): an
100
+ // agent-hosted plugin is installed (npm, manifest) like any other, but the
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.
90
106
 
91
107
  function rowToPlugin(row) {
92
108
  const entry = {
93
109
  name: row.name,
110
+ route: row.route ?? null,
94
111
  packageName: row.package_name,
95
112
  version: row.version,
96
113
  capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
114
+ hosting: row.hosting,
97
115
  status: row.status,
98
116
  installedAt: row.installed_at,
99
117
  };
@@ -103,6 +121,10 @@ function rowToPlugin(row) {
103
121
  if (row.panel != null) entry.panel = JSON.parse(row.panel);
104
122
  if (row.config != null) entry.config = JSON.parse(row.config);
105
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);
106
128
  if (row.enabled_at != null) entry.enabledAt = row.enabled_at;
107
129
  return entry;
108
130
  }
@@ -146,6 +168,7 @@ export async function writePlugins(data) {
146
168
  for (const p of list) {
147
169
  s.insert.run(
148
170
  p.name,
171
+ p.route === undefined ? derivePluginRoute(p.name) : p.route,
149
172
  nullableString(p.displayName),
150
173
  p.packageName,
151
174
  p.version,
@@ -155,6 +178,11 @@ export async function writePlugins(data) {
155
178
  nullableJson(p.panel),
156
179
  nullableJson(p.config),
157
180
  nullableJson(p.modes),
181
+ p.hosting ?? DEFAULT_PLUGIN_HOSTING,
182
+ nullableJson(p.agent),
183
+ nullableJson(p.hooks),
184
+ nullableString(p.hooksEntrySha256),
185
+ nullableJson(p.agentHalfPin),
158
186
  p.status,
159
187
  p.installedAt,
160
188
  nullableString(p.enabledAt),
@@ -173,7 +201,7 @@ export async function writePlugins(data) {
173
201
  *
174
202
  * @param {object} raw - The raw manifest object
175
203
  * @returns {object} The validated and parsed manifest
176
- * @throws {z.ZodError} If validation fails
204
+ * @throws {ManifestValidationError} If validation fails
177
205
  */
178
206
  export function validateManifest(raw) {
179
207
  return coreValidateManifest(raw);
@@ -230,10 +258,74 @@ async function syncOidcClientsBestEffort(logger) {
230
258
  }
231
259
  }
232
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
+
233
318
  export function installPlugin(packageName, logger) {
234
319
  return withPluginLock(async () => {
235
320
  // Validate package scope
236
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(() => {});
237
329
 
238
330
  const s = await getStmts();
239
331
 
@@ -256,15 +348,18 @@ export function installPlugin(packageName, logger) {
256
348
  }
257
349
 
258
350
  // Install the npm package
259
- logger.info({ packageName }, 'Installing plugin package');
351
+ logger.info({ packageName: installSpec }, 'Installing plugin package');
260
352
  try {
261
- await execa('npm', ['install', '--ignore-scripts', packageName], {
353
+ await execa('npm', ['install', '--ignore-scripts', installSpec], {
262
354
  cwd: STATE_DIR,
263
355
  timeout: 120000,
264
356
  });
265
357
  } catch (err) {
266
- logger.error({ packageName, stderr: err.stderr, err: err.message }, 'npm install failed');
267
- 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 });
268
363
  }
269
364
 
270
365
  // Read the plugin manifest from the installed package
@@ -277,70 +372,58 @@ export function installPlugin(packageName, logger) {
277
372
  } catch (err) {
278
373
  // Clean up the installed package if manifest is missing/invalid
279
374
  logger.warn({ packageName, err: err.message }, 'Invalid or missing manifest, uninstalling');
280
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
375
+ await discardPackage();
281
376
 
282
- if (err instanceof z.ZodError) {
283
- throw Object.assign(
284
- new Error(`Invalid plugin manifest: ${err.errors.map((e) => e.message).join(', ')}`),
285
- { statusCode: 400 },
286
- );
377
+ if (err instanceof ManifestValidationError) {
378
+ throw Object.assign(new Error(err.message), { statusCode: 400 });
287
379
  }
288
380
  throw Object.assign(new Error(`Plugin manifest not found in "${packageName}"`), {
289
381
  statusCode: 400,
290
382
  });
291
383
  }
292
384
 
293
- // Reject names that collide with core API route prefixes
294
- if (RESERVED_API_PREFIXES.includes(manifest.name)) {
295
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
296
- throw Object.assign(new Error(`Plugin name "${manifest.name}" is reserved`), {
297
- statusCode: 400,
298
- });
385
+ const policyError = await manifestPolicyError(manifest);
386
+ if (policyError) {
387
+ await discardPackage();
388
+ throw Object.assign(new Error(policyError.message), { statusCode: policyError.statusCode });
299
389
  }
300
390
 
301
- // Reject displayName that matches reserved navigation labels
302
- if (manifest.displayName) {
303
- if (RESERVED_NAV_LABELS.includes(manifest.displayName.toLowerCase())) {
304
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
305
- throw Object.assign(
306
- new Error(
307
- `Plugin display name "${manifest.displayName}" conflicts with a core navigation label`,
308
- ),
309
- { statusCode: 400 },
310
- );
311
- }
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 });
312
400
  }
313
401
 
314
- // Validate apiPrefix matches plugin name if specified
315
- if (manifest.panel?.apiPrefix && manifest.panel.apiPrefix !== `/api/${manifest.name}`) {
316
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
317
- throw Object.assign(
318
- new Error(
319
- `Plugin apiPrefix must be "/api/${manifest.name}" but got "${manifest.panel.apiPrefix}"`,
320
- ),
321
- { statusCode: 400 },
322
- );
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 });
323
411
  }
324
412
 
325
- // Cross-namespace collision: any capability declared by the plugin must
326
- // not match a sub-scope name already owned by a registered ticket scope.
327
- // Schema-level namespacing puts both sides in `plugin:*`; this check is
328
- // a flat set intersection. The reverse direction (registering a scope
329
- // that collides with an installed plugin's cap) is enforced in
330
- // `registerScope`.
331
- if (Array.isArray(manifest.capabilities) && manifest.capabilities.length > 0) {
332
- const subScopeNames = new Set(await listRegisteredSubScopeNames());
333
- const collision = manifest.capabilities.find((c) => subScopeNames.has(c));
334
- if (collision) {
335
- await execa('npm', ['uninstall', packageName], { cwd: STATE_DIR }).catch(() => {});
336
- throw Object.assign(
337
- new Error(
338
- `Plugin capability "${collision}" collides with an existing ticket scope sub-scope name. ` +
339
- 'Pick a different action name or unregister the conflicting ticket scope first.',
340
- ),
341
- { statusCode: 409 },
342
- );
343
- }
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);
344
427
  }
345
428
 
346
429
  // Create the plugin state directory
@@ -350,6 +433,7 @@ export function installPlugin(packageName, logger) {
350
433
  // Build the entry, then persist via INSERT
351
434
  const entry = {
352
435
  name: manifest.name,
436
+ route,
353
437
  displayName: manifest.displayName,
354
438
  packageName,
355
439
  version: manifest.version,
@@ -359,6 +443,11 @@ export function installPlugin(packageName, logger) {
359
443
  panel: manifest.panel,
360
444
  config: manifest.config,
361
445
  modes: manifest.modes,
446
+ hosting: manifest.hosting,
447
+ agent: manifest.agent,
448
+ hooks: manifest.hooks,
449
+ hooksEntrySha256,
450
+ agentHalfPin,
362
451
  status: 'disabled',
363
452
  installedAt: new Date().toISOString(),
364
453
  };
@@ -367,6 +456,7 @@ export function installPlugin(packageName, logger) {
367
456
  try {
368
457
  s.insert.run(
369
458
  entry.name,
459
+ entry.route,
370
460
  nullableString(entry.displayName),
371
461
  entry.packageName,
372
462
  entry.version,
@@ -376,6 +466,11 @@ export function installPlugin(packageName, logger) {
376
466
  nullableJson(entry.panel),
377
467
  nullableJson(entry.config),
378
468
  nullableJson(entry.modes),
469
+ entry.hosting,
470
+ nullableJson(entry.agent),
471
+ nullableJson(entry.hooks),
472
+ nullableString(entry.hooksEntrySha256),
473
+ nullableJson(entry.agentHalfPin),
379
474
  entry.status,
380
475
  entry.installedAt,
381
476
  null,
@@ -416,6 +511,24 @@ export function uninstallPlugin(name, logger) {
416
511
  });
417
512
  }
418
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
+
419
532
  // Uninstall the npm package
420
533
  logger.info({ name, packageName: plugin.packageName }, 'Uninstalling plugin package');
421
534
  await execa('npm', ['uninstall', plugin.packageName], {
@@ -442,12 +555,24 @@ export function uninstallPlugin(name, logger) {
442
555
 
443
556
  // Sweep this plugin's capabilities off every agent so re-installing the
444
557
  // plugin later does NOT silently re-grant. Failure here is non-fatal
445
- // (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.
446
561
  try {
447
- const route = derivePluginRoute(plugin.name);
448
- const diffs = await revokePluginCapabilitiesFromAgents(PKI_DIR, route);
449
- if (diffs.length > 0) {
450
- 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
+ }
451
576
  }
452
577
  } catch (err) {
453
578
  logger.warn(
@@ -462,6 +587,83 @@ export function uninstallPlugin(name, logger) {
462
587
  });
463
588
  }
464
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
+
465
667
  /**
466
668
  * Enable a plugin.
467
669
  *
@@ -557,6 +759,42 @@ export async function getPluginCapabilities() {
557
759
  const parsed = JSON.parse(row.capabilities);
558
760
  if (Array.isArray(parsed)) caps.push(...parsed);
559
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
+ }
560
768
  }
561
769
  return [...new Set(caps)];
562
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
+ }