@lamalibre/create-lamaste 4.3.1 → 4.4.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 (72) hide show
  1. package/package.json +2 -2
  2. package/src/lib/service-config.js +6 -0
  3. package/vendor/docs/00-introduction/how-it-works.md +2 -2
  4. package/vendor/docs/00-introduction/quickstart.md +5 -5
  5. package/vendor/docs/00-introduction/what-is-lamaste.md +5 -5
  6. package/vendor/docs/01-concepts/certificates.md +3 -3
  7. package/vendor/docs/01-concepts/mtls.md +2 -2
  8. package/vendor/docs/01-concepts/security-model.md +38 -1
  9. package/vendor/docs/01-concepts/tickets.md +2 -2
  10. package/vendor/docs/01-concepts/tunneling.md +4 -4
  11. package/vendor/docs/02-guides/agent-setup.md +6 -6
  12. package/vendor/docs/02-guides/certificate-management.md +2 -2
  13. package/vendor/docs/02-guides/cloud-provisioning.md +3 -3
  14. package/vendor/docs/02-guides/desktop-app-setup.md +11 -11
  15. package/vendor/docs/02-guides/installation.md +3 -3
  16. package/vendor/docs/02-guides/onboarding.md +2 -2
  17. package/vendor/docs/02-guides/standalone-plugin-with-tickets.md +2 -2
  18. package/vendor/docs/03-architecture/installer-flow.md +2 -2
  19. package/vendor/docs/03-architecture/installer.md +4 -4
  20. package/vendor/docs/03-architecture/management-flow.md +1 -1
  21. package/vendor/docs/03-architecture/overview.md +5 -5
  22. package/vendor/docs/03-architecture/panel-client.md +7 -7
  23. package/vendor/docs/03-architecture/panel-server.md +2 -2
  24. package/vendor/docs/03-architecture/state-management.md +2 -2
  25. package/vendor/docs/03-architecture/system-overview.md +1 -1
  26. package/vendor/docs/04-api-reference/agent-hosted-plugins.md +393 -33
  27. package/vendor/docs/04-api-reference/app-host.md +87 -38
  28. package/vendor/docs/04-api-reference/audit-and-notifications.md +10 -10
  29. package/vendor/docs/04-api-reference/certificates.md +22 -19
  30. package/vendor/docs/04-api-reference/lifecycle-hooks.md +33 -11
  31. package/vendor/docs/04-api-reference/onboarding.md +1 -1
  32. package/vendor/docs/04-api-reference/overview.md +137 -135
  33. package/vendor/docs/04-api-reference/tunnels.md +14 -10
  34. package/vendor/docs/05-operations/backup-and-restore.md +2 -1
  35. package/vendor/docs/05-operations/uninstalling.md +1 -1
  36. package/vendor/docs/05-operations/upgrades.md +6 -6
  37. package/vendor/docs/06-reference/config-files.md +89 -84
  38. package/vendor/docs/06-reference/glossary.md +3 -3
  39. package/vendor/docs/06-reference/installer-flags.md +13 -13
  40. package/vendor/docs/06-reference/ports-and-services.md +1 -1
  41. package/vendor/docs/06-reference/troubleshooting.md +8 -8
  42. package/vendor/docs/gatekeeper-design.md +8 -8
  43. package/vendor/docs/identity-directory-design.md +2 -2
  44. package/vendor/docs/package.json +1 -1
  45. package/vendor/docs/todo/lib-cli-desktop-refactor.md +27 -27
  46. package/vendor/gatekeeper/package.json +2 -2
  47. package/vendor/server/bin/lamaste-server.js +4 -1
  48. package/vendor/server/package.json +4 -3
  49. package/vendor/server/src/commands/plugin-settings.js +541 -0
  50. package/vendor/server/src/commands/plugins.js +94 -27
  51. package/vendor/server/src/panel-api.js +36 -4
  52. package/vendor/server/src/plugin-approval.js +78 -0
  53. package/vendor/server/src/plugin-settings.js +699 -0
  54. package/vendor/server-ui/dist/assets/index-25wo_8av.css +1 -0
  55. package/vendor/server-ui/dist/assets/index-CESyyK8V.js +84 -0
  56. package/vendor/server-ui/dist/index.html +2 -2
  57. package/vendor/serverd/package.json +2 -2
  58. package/vendor/serverd/src/index.js +9 -0
  59. package/vendor/serverd/src/lib/agent-presence.js +106 -0
  60. package/vendor/serverd/src/lib/hook-pipelines.js +20 -4
  61. package/vendor/serverd/src/lib/migrations/state/0012_plugin_settings_and_presence.sql +62 -0
  62. package/vendor/serverd/src/lib/mtls.js +44 -4
  63. package/vendor/serverd/src/lib/plugin-hooks.js +120 -20
  64. package/vendor/serverd/src/lib/plugin-hosting.js +35 -14
  65. package/vendor/serverd/src/lib/plugin-lifecycle.js +267 -83
  66. package/vendor/serverd/src/lib/plugin-settings.js +1101 -0
  67. package/vendor/serverd/src/lib/plugins.js +73 -22
  68. package/vendor/serverd/src/lib/tunnel-hooks.js +11 -0
  69. package/vendor/serverd/src/routes/management/plugins.js +166 -19
  70. package/vendor/serverd/src/routes/management/tunnels.js +21 -1
  71. package/vendor/server-ui/dist/assets/index-BTlBt_cD.js +0 -84
  72. package/vendor/server-ui/dist/assets/index-Bc5NCMSv.css +0 -1
@@ -48,7 +48,12 @@ import {
48
48
  import { httpError } from './app-error.js';
49
49
  import { getConfig } from './config.js';
50
50
  import { readTunnels } from './state.js';
51
- import { loadAgentRegistry, saveAgentRegistry, withRegistryLock } from './mtls.js';
51
+ import {
52
+ getAgentHostFacts,
53
+ loadAgentRegistry,
54
+ saveAgentRegistry,
55
+ withRegistryLock,
56
+ } from './mtls.js';
52
57
  import { startPluginOperation } from './plugin-operations.js';
53
58
  import {
54
59
  pipelineContext,
@@ -62,6 +67,8 @@ import { relaySigner } from './relay-signing.js';
62
67
  import { findRetainedData, forgetRetainedData, listRetainedData } from './retained-data.js';
63
68
  import { recordAudit } from './audit.js';
64
69
  import { onHealthTransition } from './tunnel-hooks.js';
70
+ import { agentLastSeenAt, agentPresenceMap } from './agent-presence.js';
71
+ import { refreshPluginSettingsBestEffort } from './plugin-settings.js';
65
72
 
66
73
  const STATE_DIR = process.env.LAMALIBRE_LAMASTE_STATE_DIR || '/etc/lamalibre/lamaste';
67
74
 
@@ -145,12 +152,6 @@ export async function isActiveAgent(label) {
145
152
  // Pushing an agent half: eligibility and the push-install gates
146
153
  // ---------------------------------------------------------------------------
147
154
 
148
- /** The host facts agent `label` last reported, or null. */
149
- async function agentHostFacts(label) {
150
- const registry = await loadAgentRegistry();
151
- return registry.agents.find((a) => a.label === label && !a.revoked)?.hostFacts ?? null;
152
- }
153
-
154
155
  /**
155
156
  * What this relay brings to eligibility: the id of the key it signs app
156
157
  * jobs with — null when the key is unusable (it signs nothing then).
@@ -173,7 +174,7 @@ export async function assertAgentEligible(label, requires, agentHalf, pluginName
173
174
  const { eligible, reasons } = hostEligibility(
174
175
  requires,
175
176
  agentHalf,
176
- await agentHostFacts(label),
177
+ await getAgentHostFacts(label),
177
178
  await relayEligibility(),
178
179
  );
179
180
  if (!eligible) {
@@ -336,7 +337,13 @@ export async function hostPlugin(name, agentLabel, { actor, logger, sourceIp })
336
337
  actor,
337
338
  params: { agentLabel },
338
339
  logger,
339
- run: (op) => runInstallPipeline(op, ctx),
340
+ run: async (op) => {
341
+ const result = await runInstallPipeline(op, ctx);
342
+ // The agent half starts without settings — or with those it kept on
343
+ // this agent from an earlier hosting: the relay's cache follows it.
344
+ if (result.status === 'succeeded') await refreshPluginSettingsBestEffort(plugin.name, logger);
345
+ return result;
346
+ },
340
347
  });
341
348
  await logAgentHalfPush(push, operation.id, logger);
342
349
  return operation;
@@ -398,28 +405,42 @@ export async function isPluginHosted(name) {
398
405
  }
399
406
 
400
407
  /**
408
+ * The plugin's hosting as the panel shows it, with when its agent last
409
+ * synced (`agentLastSeenAt`, null when it never did since the panel knew).
410
+ *
401
411
  * @param {string} name
402
- * @returns {Promise<import('@lamalibre/lamaste/server').PluginHostingView | null>}
412
+ * @returns {Promise<(import('@lamalibre/lamaste/server').PluginHostingView & { agentLastSeenAt: string | null }) | null>}
403
413
  */
404
414
  export async function getHostingView(name) {
405
415
  const record = await ownHostingOf(name);
406
- return record ? withLiveHealth(pluginHostingView(record)) : null;
416
+ if (!record) return null;
417
+ return {
418
+ ...withLiveHealth(pluginHostingView(record)),
419
+ agentLastSeenAt: await agentLastSeenAt(record.agentLabel),
420
+ };
407
421
  }
408
422
 
409
423
  /**
410
424
  * `agentHosting` for every plugin of a list — null for plugins that are not
411
- * hosted — and `retainedData`: what agents keep of the plugin's agent half
412
- * after it was removed without purge.
425
+ * hosted; with `agentLastSeenAt` as in {@link getHostingView} — and
426
+ * `retainedData`: what agents keep of the plugin's agent half after it was
427
+ * removed without purge.
413
428
  */
414
429
  export async function attachHostings(plugins) {
415
430
  const store = await readPluginHostings(STATE_DIR);
416
431
  const retained = await listRetainedData();
432
+ const lastSeen = await agentPresenceMap();
417
433
  return plugins.map((p) => {
418
434
  const record = p.route ? store[p.route] : undefined;
419
435
  const own = record && pluginHostingOwner(record) === p.name ? record : null;
420
436
  return {
421
437
  ...p,
422
- agentHosting: own ? withLiveHealth(pluginHostingView(own)) : null,
438
+ agentHosting: own
439
+ ? {
440
+ ...withLiveHealth(pluginHostingView(own)),
441
+ agentLastSeenAt: lastSeen.get(own.agentLabel) ?? null,
442
+ }
443
+ : null,
423
444
  retainedData: retained.filter((r) => r.plugin === p.name),
424
445
  };
425
446
  });
@@ -12,7 +12,13 @@
12
12
  * and the installed version stays live; then the install pipeline runs
13
13
  * with `phase: "upgrade"` around installing it — the new agent half
14
14
  * first, then the relay package — and a failure restores the previous
15
- * version on both sides.
15
+ * version on both sides. Settings are outside the approval digest: the
16
+ * preview lists how the settings schema changes (`pendingUpdate.settings`)
17
+ * and the relay re-reads the agent half's settings after the update — but
18
+ * a version that turns a secret setting into a plain one always waits for
19
+ * an administrator's approval, its digest approved or not, and whatever
20
+ * auto-approve says (`pendingUpdate.approvalRequired` says why an update
21
+ * waits).
16
22
  */
17
23
 
18
24
  import {
@@ -24,6 +30,7 @@ import {
24
30
  relayHookSetNeedsEntry,
25
31
  relayHookSetOf,
26
32
  relayHookSetPreview,
33
+ settingsSchemaDiff,
27
34
  validateManifest,
28
35
  } from '@lamalibre/lamaste';
29
36
  import { pluginHostingPhase } from '@lamalibre/lamaste/server';
@@ -39,6 +46,9 @@ import {
39
46
  } from './plugins.js';
40
47
  import { pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
41
48
  import {
49
+ DIGEST_UNAPPROVED,
50
+ SETTINGS_SECRET_TO_PLAIN,
51
+ digestUnapprovedReason,
42
52
  installRunnerModule,
43
53
  installedAgentHalf,
44
54
  installedApproval,
@@ -71,6 +81,7 @@ import { pipelineContext, runUpgradePipeline } from './hook-pipelines.js';
71
81
  import { getConfig } from './config.js';
72
82
  import { recordAudit, recordAuditBestEffort } from './audit.js';
73
83
  import { notifyAdminsBestEffort } from './notifications.js';
84
+ import { refreshPluginSettingsBestEffort, settingsBlockOf } from './plugin-settings.js';
74
85
 
75
86
  /** Replaces the OIDC client sync (tests: there is no Authelia to write to). */
76
87
  let oidcSyncOverride = null;
@@ -181,25 +192,124 @@ export async function uninstallPluginWithHosting(
181
192
  * @returns {string[]}
182
193
  */
183
194
  export function autoApproveWithheld(before, after) {
195
+ return agentHalfWithheld(before, after).map((reason) => reason.message);
196
+ }
197
+
198
+ /**
199
+ * {@link autoApproveWithheld}, each reason with its stable code (what the
200
+ * audit entry records beside the sentence): `agent-half.added`,
201
+ * `agent-half.package`, `agent-half.system-packages`, `agent-half.entry`,
202
+ * `agent-half.memory-max`.
203
+ *
204
+ * @returns {Array<{ code: string, message: string }>}
205
+ */
206
+ function agentHalfWithheld(before, after) {
184
207
  if (!after) return [];
185
- if (!before) return ['it brings an agent half Lamaste installs'];
208
+ if (!before) {
209
+ return [{ code: 'agent-half.added', message: 'it brings an agent half Lamaste installs' }];
210
+ }
186
211
  const reasons = [];
187
212
  if (after.package !== before.package) {
188
- reasons.push(`its agent package changes (${before.package} → ${after.package})`);
213
+ reasons.push({
214
+ code: 'agent-half.package',
215
+ message: `its agent package changes (${before.package} → ${after.package})`,
216
+ });
189
217
  }
190
218
  const added = after.systemPackages.filter((p) => !before.systemPackages.includes(p));
191
- if (added.length > 0) reasons.push(`it adds system packages (${added.join(', ')})`);
219
+ if (added.length > 0) {
220
+ reasons.push({
221
+ code: 'agent-half.system-packages',
222
+ message: `it adds system packages (${added.join(', ')})`,
223
+ });
224
+ }
192
225
  if (after.service.entry !== before.service.entry) {
193
- reasons.push(`its service entry changes (${before.service.entry} → ${after.service.entry})`);
226
+ reasons.push({
227
+ code: 'agent-half.entry',
228
+ message: `its service entry changes (${before.service.entry} → ${after.service.entry})`,
229
+ });
194
230
  }
195
231
  if (after.service.memoryMax !== before.service.memoryMax) {
196
- reasons.push(
197
- `its service memory limit changes (${before.service.memoryMax ?? 'none'} → ${after.service.memoryMax ?? 'none'})`,
198
- );
232
+ reasons.push({
233
+ code: 'agent-half.memory-max',
234
+ message: `its service memory limit changes (${before.service.memoryMax ?? 'none'} → ${after.service.memoryMax ?? 'none'})`,
235
+ });
199
236
  }
200
237
  return reasons;
201
238
  }
202
239
 
240
+ /**
241
+ * Why a version waits for an administrator because of how it changes the
242
+ * settings schema: fields that are secrets now and plain values after it
243
+ * (`settings.secret-to-plain`). Their secrets are dropped on both sides,
244
+ * never moved — but whatever an administrator types into them next is kept
245
+ * on the relay and shown in clear, which a plugin release must not decide
246
+ * on its own. Settings are outside the approval digest, so this is a gate
247
+ * of its own: such an update waits whether or not its digest is approved,
248
+ * and auto-approve never covers it.
249
+ *
250
+ * @param {import('@lamalibre/lamaste').SettingsSchemaDiff | null} diff
251
+ * @returns {Array<{ code: string, reason: string }>}
252
+ */
253
+ export function settingsApprovalRequired(diff) {
254
+ const keys = diff?.secretToPlain ?? [];
255
+ if (keys.length === 0) return [];
256
+ return [
257
+ {
258
+ code: SETTINGS_SECRET_TO_PLAIN,
259
+ reason: `it turns secret settings into plain ones (${keys.join(', ')}) — what is typed into them next is kept on the relay and shown in clear`,
260
+ },
261
+ ];
262
+ }
263
+
264
+ /**
265
+ * Why a staged version waits for an administrator's approval (`[{ code,
266
+ * reason }]`; empty: it needs none and runs at once) — what the approval of
267
+ * the pending update covers. Independent of auto-approve, which may stand
268
+ * in for an administrator on the first reason alone:
269
+ *
270
+ * - `digest.unapproved` — what it brings (relay hooks, the agent half
271
+ * Lamaste installs) is not what was approved;
272
+ * - `settings.secret-to-plain` — {@link settingsApprovalRequired}.
273
+ *
274
+ * @param {{ approvalRequired: boolean, digest: string | null, relaySet: object | null, agentHalf: object | null }} staged
275
+ * @param {{ approvedDigest: string | null } | null} state
276
+ * @param {import('@lamalibre/lamaste').SettingsSchemaDiff | null} settings
277
+ * @returns {Array<{ code: string, reason: string }>}
278
+ */
279
+ function approvalRequiredFor(staged, state, settings) {
280
+ const digestApproved = !staged.approvalRequired || state?.approvedDigest === staged.digest;
281
+ return [
282
+ ...(digestApproved
283
+ ? []
284
+ : [digestUnapprovedReason({ relay: staged.relaySet, agentHalf: staged.agentHalf })]),
285
+ ...settingsApprovalRequired(settings),
286
+ ];
287
+ }
288
+
289
+ /**
290
+ * Check that what an administrator approved (`approvedUpdate`: the pending
291
+ * update as it was shown — its version, its digest, the secret settings it
292
+ * turns plain) covers the version as it was fetched again: the same
293
+ * version and digest, and no secret turned plain that was not approved.
294
+ */
295
+ function assertApprovalCovers(staged, settings, approvedUpdate) {
296
+ if (approvedUpdate.version !== staged.version || approvedUpdate.digest !== staged.digest) {
297
+ throw httpError(
298
+ `Version ${staged.version} no longer brings what was approved — review it again`,
299
+ 409,
300
+ );
301
+ }
302
+ const unapproved = (settings?.secretToPlain ?? []).filter(
303
+ (key) => !approvedUpdate.secretToPlain.includes(key),
304
+ );
305
+ if (unapproved.length > 0) {
306
+ throw httpError(
307
+ `Version ${staged.version} now turns secret settings into plain ones that were not approved (${unapproved.join(', ')}) — review it again`,
308
+ 409,
309
+ );
310
+ }
311
+ }
312
+
203
313
  /**
204
314
  * What a staged version brings, read without installing it: its manifest,
205
315
  * its relay hook set and module, the agent half pinned for it, and the
@@ -290,6 +400,7 @@ async function stageVersion(plugin, requestedVersion, record) {
290
400
  hooksEntrySha256: entrySha256,
291
401
  agent: manifest.agent,
292
402
  agentHalfPin,
403
+ settings: manifest.settings,
293
404
  };
294
405
  const approval = installedApproval(entry);
295
406
  return {
@@ -309,14 +420,37 @@ async function stageVersion(plugin, requestedVersion, record) {
309
420
  };
310
421
  }
311
422
 
423
+ /**
424
+ * How a staged version changes the settings schema, for the update's
425
+ * preview (core's typed `settingsSchemaDiff`): fields added and removed
426
+ * with their types, retyped ones `from` → `to` (a retyped field's value is
427
+ * dropped on both sides, never converted), the fields the plugin will need
428
+ * filled, and the secrets that become plain values (`secretToPlain`, which
429
+ * makes the update wait for an administrator) — null when neither version
430
+ * declares settings.
431
+ *
432
+ * @param {object} plugin the installed registry entry
433
+ * @param {object} manifest the staged version's manifest
434
+ */
435
+ function settingsPreview(plugin, manifest) {
436
+ const installed = settingsBlockOf(plugin);
437
+ const staged = settingsBlockOf(manifest);
438
+ if (!installed && !staged) return null;
439
+ return settingsSchemaDiff(installed, staged);
440
+ }
441
+
312
442
  /**
313
443
  * Update plugin `name` to `requestedVersion` (an exact version or a
314
444
  * dist-tag; default `latest`). Returns `{ upToDate }`, `{ pendingUpdate }`
315
- * (what it brings awaits approval — shaped as the hook status's
445
+ * (it waits for an administrator's approval — shaped as the hook status's
316
446
  * `pendingUpdate`) or `{ operation }`.
317
447
  *
318
- * `approvedDigest` is set when an administrator approves the pending
319
- * update: the version is fetched again and must still bring exactly that.
448
+ * An update waits when what it brings is not what was approved — unless
449
+ * auto-approve covers it — and whenever it turns a secret setting into a
450
+ * plain one (see {@link approvalRequiredFor}). `approvedUpdate` is set when
451
+ * an administrator approves the pending update (`{ version, digest,
452
+ * secretToPlain }`, as it was shown): the version is fetched again and must
453
+ * still bring exactly that digest, and turn no other secret plain.
320
454
  * A hosted plugin whose agent half Lamaste installs is updated on its agent
321
455
  * too: the agent must be able to host the new version and the push-install
322
456
  * gates must allow `update` (`sourceIp`: the admin's) — checked when the
@@ -325,7 +459,7 @@ async function stageVersion(plugin, requestedVersion, record) {
325
459
  export async function updatePlugin(
326
460
  name,
327
461
  requestedVersion,
328
- { actor, certSerial, logger, approvedDigest = null, sourceIp },
462
+ { actor, certSerial, logger, approvedUpdate = null, sourceIp },
329
463
  ) {
330
464
  const plugin = await findPlugin(name);
331
465
  if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
@@ -354,77 +488,43 @@ export async function updatePlugin(
354
488
  }
355
489
 
356
490
  const state = await readHookState(name);
491
+ const settings = settingsPreview(plugin, staged.manifest);
492
+ const approvalRequired = approvalRequiredFor(staged, state, settings);
357
493
  let approvedBy = null;
358
494
  let autoApproved = false;
359
- if (staged.approvalRequired) {
360
- if (state?.approvedDigest === staged.digest) {
361
- approvedBy = state.approvedBy ?? 'unchanged';
362
- } else if (approvedDigest !== null) {
363
- if (approvedDigest !== staged.digest) {
364
- throw httpError(
365
- `Version ${staged.version} no longer brings what was approved — review it again`,
366
- 409,
367
- );
368
- }
369
- approvedBy = actor;
370
- } else {
371
- // Auto-approve covers a new version of the same agent half, never one
372
- // that changes what runs as root on the agent.
373
- const withheld = state?.autoApprove
374
- ? autoApproveWithheld(installedAgentHalf(plugin), staged.agentHalf)
375
- : [];
376
- if (state?.autoApprove && withheld.length === 0) {
377
- approvedBy = 'auto-approve';
378
- autoApproved = true;
379
- } else {
380
- const pendingUpdate = {
381
- version: staged.version,
382
- digest: staged.digest,
383
- stagedAt: new Date().toISOString(),
384
- preview: staged.relaySet
385
- ? relayHookSetPreview(staged.relaySet, staged.entrySha256)
386
- : null,
387
- agentHalf: staged.agentHalf
388
- ? agentHalfPreview(derivePluginRoute(plugin.name), staged.agentHalf)
389
- : null,
390
- ...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
391
- };
392
- await setPendingUpdate(name, pendingUpdate);
393
- if (withheld.length > 0) {
394
- await recordAudit({
395
- actor: 'auto-approve',
396
- action: 'hooks.auto-approve.withheld',
397
- plugin: name,
398
- details: {
399
- digest: staged.digest,
400
- version: staged.version,
401
- reasons: withheld,
402
- requestedBy: actor,
403
- },
404
- });
405
- }
406
- await recordAudit({
407
- actor,
408
- action: 'plugin.update.awaiting-approval',
409
- plugin: name,
410
- details: { from: plugin.version, to: staged.version, digest: staged.digest, certSerial },
411
- });
412
- await notifyAdminsBestEffort(
413
- {
414
- source: 'lamaste',
415
- level: 'warning',
416
- message:
417
- withheld.length > 0
418
- ? `${name} ${staged.version} waits for your approval — auto-approve does not cover it: ${withheld.join('; ')}.`
419
- : staged.agentHalf
420
- ? `${name} ${staged.version} brings a new agent half${staged.relaySet ? ' and relay hooks' : ''} — it waits for your approval.`
421
- : `${name} ${staged.version} brings a changed relay hook set — it waits for your approval.`,
422
- },
423
- logger,
424
- );
425
- return { pendingUpdate: pendingUpdateView(pendingUpdate) };
426
- }
495
+ if (approvedUpdate !== null) {
496
+ assertApprovalCovers(staged, settings, approvedUpdate);
497
+ approvedBy = actor;
498
+ } else if (approvalRequired.length > 0) {
499
+ // Auto-approve stands in for an administrator on a digest that changed,
500
+ // when the agent half changes nothing that runs as root on the agent —
501
+ // and on nothing else: every other reason (a secret setting turned
502
+ // plain) withholds it.
503
+ const digestChanged = approvalRequired.some((reason) => reason.code === DIGEST_UNAPPROVED);
504
+ const withheldReasons = state?.autoApprove
505
+ ? [
506
+ ...(digestChanged ? agentHalfWithheld(installedAgentHalf(plugin), staged.agentHalf) : []),
507
+ ...approvalRequired
508
+ .filter((reason) => reason.code !== DIGEST_UNAPPROVED)
509
+ .map(({ code, reason }) => ({ code, message: reason })),
510
+ ]
511
+ : [];
512
+ if (!state?.autoApprove || withheldReasons.length > 0) {
513
+ return awaitApproval({
514
+ plugin,
515
+ staged,
516
+ settings,
517
+ approvalRequired,
518
+ withheldReasons,
519
+ actor,
520
+ certSerial,
521
+ logger,
522
+ });
427
523
  }
524
+ approvedBy = 'auto-approve';
525
+ autoApproved = true;
526
+ } else if (staged.approvalRequired) {
527
+ approvedBy = state.approvedBy ?? 'unchanged';
428
528
  }
429
529
 
430
530
  // The agent half goes onto the hosting agent: a push the agent must be
@@ -451,16 +551,19 @@ export async function updatePlugin(
451
551
  staged.agentHalf.package,
452
552
  );
453
553
  }
454
- if (autoApproved) {
554
+ // The approval takes effect now that the update starts under it: what it
555
+ // covered (`codes`), by an administrator or by auto-approve.
556
+ if (autoApproved || approvedUpdate !== null) {
455
557
  await recordAudit({
456
- actor: 'auto-approve',
558
+ actor: autoApproved ? 'auto-approve' : actor,
457
559
  action: 'hooks.approved',
458
560
  plugin: name,
459
561
  details: {
460
562
  digest: staged.digest,
461
563
  version: staged.version,
462
564
  for: 'update',
463
- requestedBy: actor,
565
+ codes: approvalRequired.map((reason) => reason.code),
566
+ ...(autoApproved ? { requestedBy: actor } : { certSerial }),
464
567
  },
465
568
  });
466
569
  }
@@ -477,6 +580,83 @@ export async function updatePlugin(
477
580
  return { operation };
478
581
  }
479
582
 
583
+ /**
584
+ * Stage `staged` as the plugin's pending update, waiting for an
585
+ * administrator: stored with why it waits (`approvalRequired`) and why
586
+ * auto-approve, when on, did not cover it (`withheldReasons`), audited and
587
+ * notified. The installed version stays live.
588
+ *
589
+ * @returns {Promise<{ pendingUpdate: object }>}
590
+ */
591
+ async function awaitApproval({
592
+ plugin,
593
+ staged,
594
+ settings,
595
+ approvalRequired,
596
+ withheldReasons,
597
+ actor,
598
+ certSerial,
599
+ logger,
600
+ }) {
601
+ const name = plugin.name;
602
+ const withheld = withheldReasons.map((reason) => reason.message);
603
+ const pendingUpdate = {
604
+ version: staged.version,
605
+ digest: staged.digest,
606
+ stagedAt: new Date().toISOString(),
607
+ preview: staged.relaySet ? relayHookSetPreview(staged.relaySet, staged.entrySha256) : null,
608
+ agentHalf: staged.agentHalf
609
+ ? agentHalfPreview(derivePluginRoute(plugin.name), staged.agentHalf)
610
+ : null,
611
+ settings,
612
+ approvalRequired,
613
+ ...(withheld.length > 0 ? { autoApproveWithheld: withheld } : {}),
614
+ };
615
+ await setPendingUpdate(name, pendingUpdate);
616
+ if (withheld.length > 0) {
617
+ await recordAudit({
618
+ actor: 'auto-approve',
619
+ action: 'hooks.auto-approve.withheld',
620
+ plugin: name,
621
+ details: {
622
+ digest: staged.digest,
623
+ version: staged.version,
624
+ reasons: withheld,
625
+ codes: withheldReasons.map((reason) => reason.code),
626
+ requestedBy: actor,
627
+ },
628
+ });
629
+ }
630
+ await recordAudit({
631
+ actor,
632
+ action: 'plugin.update.awaiting-approval',
633
+ plugin: name,
634
+ details: {
635
+ from: plugin.version,
636
+ to: staged.version,
637
+ digest: staged.digest,
638
+ codes: approvalRequired.map((reason) => reason.code),
639
+ certSerial,
640
+ },
641
+ });
642
+ // What auto-approve withheld beyond the reasons themselves (an agent half
643
+ // that runs more as root); a secret turned plain is one of the reasons.
644
+ const notCovered = withheldReasons
645
+ .filter((withheldReason) => !approvalRequired.some((r) => r.code === withheldReason.code))
646
+ .map((reason) => reason.message);
647
+ await notifyAdminsBestEffort(
648
+ {
649
+ source: 'lamaste',
650
+ level: 'warning',
651
+ message:
652
+ `${name} ${staged.version} waits for your approval: ${approvalRequired.map((r) => r.reason).join('; ')}.` +
653
+ (notCovered.length > 0 ? ` Auto-approve does not cover it: ${notCovered.join('; ')}.` : ''),
654
+ },
655
+ logger,
656
+ );
657
+ return { pendingUpdate: pendingUpdateView(pendingUpdate) };
658
+ }
659
+
480
660
  async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
481
661
  const name = plugin.name;
482
662
  const oldRelay = installedRelayHooks(plugin);
@@ -577,6 +757,10 @@ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
577
757
 
578
758
  if (result.status === 'succeeded') op.revealOidcClientSecrets(createdSecrets);
579
759
 
760
+ // The agent half may have dropped values the new version retyped or no
761
+ // longer knows (and keeps that after a revert): the relay re-reads them.
762
+ if (hosted) await refreshPluginSettingsBestEffort(name, logger);
763
+
580
764
  // Hooks the new version no longer has: the old runner goes once the new
581
765
  // version is in place.
582
766
  if (result.status === 'succeeded' && oldRelay && !staged.relaySet) {