@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
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Plugin lifecycle operations that span hosting and hooks:
3
+ *
4
+ * - **Uninstall** of a hosted plugin: the uninstall pipeline first (its
5
+ * hooks, tunnel, the agent half Lamaste installed, secret, capability),
6
+ * then the package — one operation.
7
+ * - **Update** of an agent-hosted plugin in place: the new version is
8
+ * fetched and read without installing it, and the agent half it brings is
9
+ * pinned (its tarball's integrity, from the registry); when what it
10
+ * brings — relay hooks, agent half — differs from what was approved it
11
+ * waits for an administrator's approval (or the plugin's auto-approve)
12
+ * and the installed version stays live; then the install pipeline runs
13
+ * with `phase: "upgrade"` around installing it — the new agent half
14
+ * first, then the relay package — and a failure restores the previous
15
+ * version on both sides.
16
+ */
17
+
18
+ import {
19
+ ManifestValidationError,
20
+ agentHalfPreview,
21
+ derivePluginRoute,
22
+ hasAgentHalf,
23
+ hookEntrySha256,
24
+ relayHookSetNeedsEntry,
25
+ relayHookSetOf,
26
+ relayHookSetPreview,
27
+ validateManifest,
28
+ } from '@lamalibre/lamaste';
29
+ import { pluginHostingPhase } from '@lamalibre/lamaste/server';
30
+ import { httpError } from './app-error.js';
31
+ import { setPluginCapabilities } from './mtls.js';
32
+ import {
33
+ findPlugin,
34
+ getPluginCapabilities,
35
+ manifestPolicyError,
36
+ replacePluginPackage,
37
+ uninstallPlugin,
38
+ writePluginRow,
39
+ } from './plugins.js';
40
+ import { pinAgentHalf, readRegistryPackage } from './plugin-packages.js';
41
+ import {
42
+ installRunnerModule,
43
+ installedAgentHalf,
44
+ installedApproval,
45
+ installedRelayHooks,
46
+ pendingUpdateView,
47
+ readHookState,
48
+ recordApprovedSet,
49
+ removeRunner,
50
+ restoreHookState,
51
+ restoreRunnerModule,
52
+ setPendingUpdate,
53
+ } from './plugin-hooks.js';
54
+ import {
55
+ StageFailure,
56
+ isPluginOperationRunning,
57
+ startPluginOperation,
58
+ } from './plugin-operations.js';
59
+ import {
60
+ assertAgentEligible,
61
+ assertAgentHalfPush,
62
+ isActiveAgent,
63
+ isPluginHosted,
64
+ logAgentHalfPush,
65
+ ownHosting,
66
+ prepareHostingRemoval,
67
+ retainedDataOf,
68
+ teardownHosting,
69
+ } from './plugin-hosting.js';
70
+ import { pipelineContext, runUpgradePipeline } from './hook-pipelines.js';
71
+ import { getConfig } from './config.js';
72
+ import { recordAudit, recordAuditBestEffort } from './audit.js';
73
+ import { notifyAdminsBestEffort } from './notifications.js';
74
+
75
+ /** Replaces the OIDC client sync (tests: there is no Authelia to write to). */
76
+ let oidcSyncOverride = null;
77
+
78
+ /** @param {((logger: object) => Promise<{ createdSecrets?: Array<{ id: string, secret: string }> } | null>) | null} sync */
79
+ export function setOidcSyncForTests(sync) {
80
+ oidcSyncOverride = sync;
81
+ }
82
+
83
+ /**
84
+ * Bring Authelia's OIDC clients in line with the installed plugins (best
85
+ * effort: logged, never thrown). Returns the secrets of the confidential
86
+ * clients the sync created (`[{ id, secret }]`) — shown to the admin once.
87
+ *
88
+ * @returns {Promise<Array<{ id: string, secret: string }>>}
89
+ */
90
+ async function syncOidcBestEffort(logger) {
91
+ try {
92
+ const syncOidcClients = oidcSyncOverride ?? (await import('./plugin-oidc.js')).syncOidcClients;
93
+ const result = await syncOidcClients(logger);
94
+ return (result?.createdSecrets ?? []).map(({ id, secret }) => ({ id, secret }));
95
+ } catch (err) {
96
+ logger?.warn({ err: err.message }, 'Failed to sync plugin OIDC clients to Authelia');
97
+ return [];
98
+ }
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Uninstall
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Uninstall plugin `name`. A plugin that is not hosted is removed at once
107
+ * (`{ removed }`); a hosted one by an operation (`{ operation }`). `purge`
108
+ * (a hosted plugin whose agent half Lamaste installed) removes the agent
109
+ * half's data directory and service account too; `sourceIp` is the
110
+ * admin's, for the push-install gates of removing that agent half.
111
+ */
112
+ export async function uninstallPluginWithHosting(
113
+ name,
114
+ { actor, logger, force = false, purge = false, sourceIp },
115
+ ) {
116
+ const plugin = await findPlugin(name);
117
+ if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
118
+ if (plugin.status === 'enabled') {
119
+ throw httpError(`Plugin "${name}" must be disabled before uninstalling`, 400);
120
+ }
121
+ if (isPluginOperationRunning(name)) {
122
+ throw httpError(`Plugin "${name}" has an operation in progress — wait for it to finish`, 409);
123
+ }
124
+ // Only the plugin's own hosting: never one another plugin holds on its route.
125
+ const record = hasAgentHalf(plugin) ? await ownHosting(plugin) : null;
126
+ if (!record) {
127
+ if (purge) {
128
+ const retained = await retainedDataOf(name);
129
+ throw httpError(
130
+ retained.length > 0
131
+ ? `Plugin "${name}" is not hosted — the data agents keep of it (${retained.map((r) => r.agentLabel).join(', ')}) is purged per agent: Purge under Retained data on the Plugins page, or lamaste-server plugins purge-data ${name} --agent <label>`
132
+ : `Plugin "${name}" is not hosted and no agent keeps data of it — nothing to purge`,
133
+ 400,
134
+ );
135
+ }
136
+ const removed = await uninstallPlugin(name, logger);
137
+ setPluginCapabilities(await getPluginCapabilities());
138
+ await recordAuditBestEffort({ actor, action: 'plugin.uninstalled', plugin: name }, logger);
139
+ return { removed };
140
+ }
141
+ const push = await prepareHostingRemoval(plugin, record, { purge, sourceIp });
142
+ const { operation } = await startPluginOperation({
143
+ plugin: name,
144
+ kind: 'uninstall',
145
+ actor,
146
+ params: { force, ...(purge ? { purge } : {}) },
147
+ logger,
148
+ run: async (op) => {
149
+ await teardownHosting(op, plugin, { force, purge, logger });
150
+ if (await isPluginHosted(name)) {
151
+ throw new StageFailure('The hosting could not be removed — the plugin stays installed');
152
+ }
153
+ await op.step('remove the package', () => uninstallPlugin(name, logger));
154
+ setPluginCapabilities(await getPluginCapabilities());
155
+ await recordAuditBestEffort(
156
+ { actor, action: 'plugin.uninstalled', plugin: name, details: { operation: op.id } },
157
+ logger,
158
+ );
159
+ return { status: 'succeeded' };
160
+ },
161
+ });
162
+ await logAgentHalfPush(push, operation.id, logger);
163
+ return { operation };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Update
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Why auto-approve does not cover a new version's agent half, compared
172
+ * with the installed one (`before`): the agent's helper installs it, and
173
+ * its system packages, as root, so an agent half where there was none,
174
+ * another agent package, added system packages or another service spec
175
+ * wait for an administrator. A new version and tarball of the same agent
176
+ * half — what every release brings — do not. Empty when auto-approve
177
+ * covers it.
178
+ *
179
+ * @param {import('@lamalibre/lamaste').AgentHalfSpec | null} before
180
+ * @param {import('@lamalibre/lamaste').AgentHalfSpec | null} after
181
+ * @returns {string[]}
182
+ */
183
+ export function autoApproveWithheld(before, after) {
184
+ if (!after) return [];
185
+ if (!before) return ['it brings an agent half Lamaste installs'];
186
+ const reasons = [];
187
+ if (after.package !== before.package) {
188
+ reasons.push(`its agent package changes (${before.package} → ${after.package})`);
189
+ }
190
+ const added = after.systemPackages.filter((p) => !before.systemPackages.includes(p));
191
+ if (added.length > 0) reasons.push(`it adds system packages (${added.join(', ')})`);
192
+ if (after.service.entry !== before.service.entry) {
193
+ reasons.push(`its service entry changes (${before.service.entry} → ${after.service.entry})`);
194
+ }
195
+ 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
+ );
199
+ }
200
+ return reasons;
201
+ }
202
+
203
+ /**
204
+ * What a staged version brings, read without installing it: its manifest,
205
+ * its relay hook set and module, the agent half pinned for it, and the
206
+ * approval that covers them (`installedApproval` over the staged entry).
207
+ *
208
+ * @param {object} plugin the installed registry entry
209
+ * @param {string} requestedVersion
210
+ * @param {object | null} record the plugin's hosting, if any
211
+ */
212
+ async function stageVersion(plugin, requestedVersion, record) {
213
+ const staged = await readRegistryPackage(plugin.packageName, requestedVersion);
214
+ let manifest;
215
+ try {
216
+ manifest = validateManifest(staged.manifestRaw);
217
+ } catch (err) {
218
+ if (err instanceof ManifestValidationError) {
219
+ throw httpError(
220
+ `Invalid plugin manifest in ${staged.version}: ${err.issues.map((i) => i.message).join(', ')}`,
221
+ 400,
222
+ );
223
+ }
224
+ throw err;
225
+ }
226
+ if (manifest.name !== plugin.name) {
227
+ throw httpError(
228
+ `Version ${staged.version} is plugin "${manifest.name}", not "${plugin.name}"`,
229
+ 400,
230
+ );
231
+ }
232
+ if (manifest.version !== staged.version) {
233
+ throw httpError(
234
+ `Version ${staged.version} declares version ${manifest.version} in its manifest`,
235
+ 400,
236
+ );
237
+ }
238
+ if (manifest.hosting !== 'agent') {
239
+ throw httpError('Version changes the plugin to a server plugin — reinstall it instead', 400);
240
+ }
241
+ const policy = await manifestPolicyError(manifest);
242
+ if (policy) throw httpError(policy.message, policy.statusCode);
243
+ if (hasAgentHalf(plugin) && !hasAgentHalf(manifest) && record) {
244
+ throw httpError(
245
+ 'Version drops the agent half of a hosted plugin — clear its hosting first',
246
+ 400,
247
+ );
248
+ }
249
+
250
+ // The agent half's tarball is pinned now: approval binds it, and the
251
+ // agent installs only a tarball that matches.
252
+ const agentHalfPin = await pinAgentHalf(manifest);
253
+ if (record) {
254
+ // Who installs the agent half cannot change under a hosting: Lamaste
255
+ // would install a second copy beside the hand-installed one, or leave
256
+ // its own behind unmanaged.
257
+ if (agentHalfPin && !record.agentHalf) {
258
+ throw httpError(
259
+ `Version ${staged.version} has Lamaste install the agent half, which runs on agent "${record.agentLabel}" installed by hand — clear its hosting first`,
260
+ 400,
261
+ );
262
+ }
263
+ if (!agentHalfPin && record.agentHalf) {
264
+ throw httpError(
265
+ `Version ${staged.version} no longer has an agent half Lamaste installs — clear its hosting first`,
266
+ 400,
267
+ );
268
+ }
269
+ }
270
+
271
+ const relaySet = relayHookSetOf(manifest);
272
+ let moduleBytes = null;
273
+ let entrySha256 = null;
274
+ if (relaySet && relayHookSetNeedsEntry(relaySet) && relaySet.entry !== null) {
275
+ moduleBytes = staged.readHookModule(relaySet.entry);
276
+ if (!moduleBytes) {
277
+ throw httpError(
278
+ `Version ${staged.version} has no relay hook module at "${relaySet.entry}"`,
279
+ 400,
280
+ );
281
+ }
282
+ entrySha256 = hookEntrySha256(moduleBytes);
283
+ }
284
+ // The staged version as a registry entry, so it is judged exactly as the
285
+ // installed one will be.
286
+ const entry = {
287
+ ...plugin,
288
+ version: staged.version,
289
+ hooks: manifest.hooks,
290
+ hooksEntrySha256: entrySha256,
291
+ agent: manifest.agent,
292
+ agentHalfPin,
293
+ };
294
+ const approval = installedApproval(entry);
295
+ return {
296
+ version: staged.version,
297
+ manifest,
298
+ entry,
299
+ relaySet,
300
+ moduleBytes,
301
+ entrySha256,
302
+ agentHalfPin,
303
+ agentHalf: approval.agentHalf,
304
+ // The relay hook set's own digest — what the runner holds.
305
+ relayDigest: approval.relay?.digest ?? null,
306
+ approvalRequired: approval.required,
307
+ // The approval digest (relay hooks and agent half), or null when nothing needs approving.
308
+ digest: approval.digest,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * Update plugin `name` to `requestedVersion` (an exact version or a
314
+ * dist-tag; default `latest`). Returns `{ upToDate }`, `{ pendingUpdate }`
315
+ * (what it brings awaits approval — shaped as the hook status's
316
+ * `pendingUpdate`) or `{ operation }`.
317
+ *
318
+ * `approvedDigest` is set when an administrator approves the pending
319
+ * update: the version is fetched again and must still bring exactly that.
320
+ * A hosted plugin whose agent half Lamaste installs is updated on its agent
321
+ * too: the agent must be able to host the new version and the push-install
322
+ * gates must allow `update` (`sourceIp`: the admin's) — checked when the
323
+ * update starts, not while it waits for approval.
324
+ */
325
+ export async function updatePlugin(
326
+ name,
327
+ requestedVersion,
328
+ { actor, certSerial, logger, approvedDigest = null, sourceIp },
329
+ ) {
330
+ const plugin = await findPlugin(name);
331
+ if (!plugin) throw httpError(`Plugin "${name}" not found`, 404);
332
+ if (plugin.hosting !== 'agent') {
333
+ throw httpError(
334
+ 'Updating in place is for agent-hosted plugins; a server plugin is reinstalled (its code runs in the panel)',
335
+ 400,
336
+ );
337
+ }
338
+ if (isPluginOperationRunning(name)) {
339
+ throw httpError(`Plugin "${name}" has an operation in progress — wait for it to finish`, 409);
340
+ }
341
+
342
+ const record = await ownHosting(plugin);
343
+ const staged = await stageVersion(plugin, requestedVersion ?? 'latest', record);
344
+ if (staged.version === plugin.version) return { upToDate: true, version: plugin.version };
345
+ if (
346
+ record &&
347
+ pluginHostingPhase(record) !== 'active' &&
348
+ (staged.agentHalf || installedAgentHalf(plugin))
349
+ ) {
350
+ throw httpError(
351
+ `Plugin "${name}" is ${pluginHostingPhase(record)} on agent "${record.agentLabel}" — finish removing its hosting before updating`,
352
+ 409,
353
+ );
354
+ }
355
+
356
+ const state = await readHookState(name);
357
+ let approvedBy = null;
358
+ 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
+ }
427
+ }
428
+ }
429
+
430
+ // The agent half goes onto the hosting agent: a push the agent must be
431
+ // able to take, and the gates must allow.
432
+ const hosted = record !== null && pluginHostingPhase(record) === 'active';
433
+ let push = null;
434
+ if (hosted && staged.agentHalf) {
435
+ if (!(await isActiveAgent(record.agentLabel))) {
436
+ throw httpError(
437
+ `Agent "${record.agentLabel}" hosting "${name}" is no longer enrolled — remove the plugin's hosting before updating it`,
438
+ 409,
439
+ );
440
+ }
441
+ await assertAgentEligible(
442
+ record.agentLabel,
443
+ staged.manifest.agent?.requires,
444
+ staged.agentHalf,
445
+ plugin.name,
446
+ );
447
+ push = await assertAgentHalfPush(
448
+ record.agentLabel,
449
+ sourceIp,
450
+ 'update',
451
+ staged.agentHalf.package,
452
+ );
453
+ }
454
+ if (autoApproved) {
455
+ await recordAudit({
456
+ actor: 'auto-approve',
457
+ action: 'hooks.approved',
458
+ plugin: name,
459
+ details: {
460
+ digest: staged.digest,
461
+ version: staged.version,
462
+ for: 'update',
463
+ requestedBy: actor,
464
+ },
465
+ });
466
+ }
467
+
468
+ const { operation } = await startPluginOperation({
469
+ plugin: name,
470
+ kind: 'update',
471
+ actor,
472
+ params: { from: plugin.version, to: staged.version },
473
+ logger,
474
+ run: (op) => applyUpdate(op, { plugin, staged, approvedBy, actor, logger }),
475
+ });
476
+ await logAgentHalfPush(push, operation.id, logger);
477
+ return { operation };
478
+ }
479
+
480
+ async function applyUpdate(op, { plugin, staged, approvedBy, actor, logger }) {
481
+ const name = plugin.name;
482
+ const oldRelay = installedRelayHooks(plugin);
483
+ const priorHookState = await readHookState(name);
484
+ const record = await ownHosting(plugin);
485
+ const hosted = record !== null && pluginHostingPhase(record) === 'active';
486
+ const config = getConfig();
487
+ const nextEntry = staged.entry;
488
+ const ctx = pipelineContext({
489
+ plugin: nextEntry,
490
+ agentLabel: record?.agentLabel ?? null,
491
+ domain: config.domain,
492
+ email: config.email,
493
+ logger,
494
+ });
495
+ ctx.version = staged.version;
496
+ ctx.previousVersion = plugin.version;
497
+
498
+ // The new version's relay stages run from its module: it goes into the
499
+ // runner first (approved above); a failure points the runner back.
500
+ if (staged.relaySet) {
501
+ await op.step('install the new relay hook module', () =>
502
+ installRunnerModule(name, {
503
+ digest: staged.relayDigest,
504
+ entrySha256: staged.entrySha256,
505
+ moduleBytes: staged.moduleBytes,
506
+ }),
507
+ );
508
+ }
509
+
510
+ // Secrets of confidential OIDC clients the new version adds: shown once,
511
+ // and only when the update stays (a revert deletes them again).
512
+ let createdSecrets = [];
513
+ const result = await runUpgradePipeline(op, ctx, {
514
+ hosted,
515
+ apply: async () => {
516
+ const installed = await replacePluginPackage(
517
+ name,
518
+ plugin.packageName,
519
+ staged.version,
520
+ logger,
521
+ );
522
+ const installedEntry = {
523
+ ...nextEntry,
524
+ hooks: installed.manifest.hooks,
525
+ hooksEntrySha256: installed.hooksEntrySha256,
526
+ agent: installed.manifest.agent,
527
+ };
528
+ let approval;
529
+ try {
530
+ approval = installedApproval(installedEntry);
531
+ } catch {
532
+ approval = null;
533
+ }
534
+ if (!approval || approval.digest !== staged.digest) {
535
+ throw new StageFailure('The installed package differs from the version that was reviewed');
536
+ }
537
+ await writePluginRow(
538
+ name,
539
+ installed.manifest,
540
+ installed.hooksEntrySha256,
541
+ staged.agentHalfPin,
542
+ );
543
+ if (approval.required) {
544
+ await recordApprovedSet(installedEntry, approval.digest, staged.version, approvedBy);
545
+ } else await setPendingUpdate(name, null);
546
+ setPluginCapabilities(await getPluginCapabilities());
547
+ createdSecrets = await syncOidcBestEffort(logger);
548
+ },
549
+ revert: async ({ applied }) => {
550
+ if (applied) {
551
+ const restored = await replacePluginPackage(
552
+ name,
553
+ plugin.packageName,
554
+ plugin.version,
555
+ logger,
556
+ );
557
+ await writePluginRow(
558
+ name,
559
+ restored.manifest,
560
+ restored.hooksEntrySha256,
561
+ plugin.agentHalfPin ?? null,
562
+ );
563
+ await restoreHookState(name, priorHookState);
564
+ setPluginCapabilities(await getPluginCapabilities());
565
+ await syncOidcBestEffort(logger);
566
+ }
567
+ if (staged.relaySet) {
568
+ if (oldRelay) {
569
+ await restoreRunnerModule(name, {
570
+ digest: oldRelay.digest,
571
+ entrySha256: oldRelay.entrySha256,
572
+ });
573
+ } else await removeRunner(name);
574
+ }
575
+ },
576
+ });
577
+
578
+ if (result.status === 'succeeded') op.revealOidcClientSecrets(createdSecrets);
579
+
580
+ // Hooks the new version no longer has: the old runner goes once the new
581
+ // version is in place.
582
+ if (result.status === 'succeeded' && oldRelay && !staged.relaySet) {
583
+ await removeRunner(name).catch((err) =>
584
+ op.warn(`Could not remove the old hook runner: ${err.message}`),
585
+ );
586
+ }
587
+ await recordAuditBestEffort(
588
+ {
589
+ actor,
590
+ action: result.status === 'succeeded' ? 'plugin.updated' : 'plugin.update.reverted',
591
+ plugin: name,
592
+ details: {
593
+ from: plugin.version,
594
+ to: staged.version,
595
+ approvedBy,
596
+ hosted,
597
+ operation: op.id,
598
+ },
599
+ },
600
+ logger,
601
+ );
602
+ return result;
603
+ }