@rynx-ai/daemon 0.1.0 → 0.1.10-beta.2

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 (109) hide show
  1. package/dist/app-browser-host-supervisor.d.ts +97 -0
  2. package/dist/app-browser-host-supervisor.js +529 -0
  3. package/dist/browser-artifact-management.d.ts +20 -0
  4. package/dist/browser-artifact-management.js +61 -0
  5. package/dist/browser-cli-args.d.ts +20 -0
  6. package/dist/browser-cli-args.js +100 -0
  7. package/dist/browser-runner-session-context.d.ts +24 -0
  8. package/dist/browser-runner-session-context.js +113 -0
  9. package/dist/cdp-keyboard-input.d.ts +6 -0
  10. package/dist/cdp-keyboard-input.js +209 -0
  11. package/dist/channel-store.d.ts +0 -6
  12. package/dist/channel-store.js +2 -18
  13. package/dist/chrome-for-testing-release-resolver.d.ts +34 -0
  14. package/dist/chrome-for-testing-release-resolver.js +214 -0
  15. package/dist/chrome-for-testing-store.d.ts +85 -0
  16. package/dist/chrome-for-testing-store.js +836 -0
  17. package/dist/chrome-inspection-gateway.d.ts +58 -0
  18. package/dist/chrome-inspection-gateway.js +806 -0
  19. package/dist/chrome-inspection-manager.d.ts +94 -0
  20. package/dist/chrome-inspection-manager.js +573 -0
  21. package/dist/cli.js +478 -30
  22. package/dist/control-client.d.ts +122 -0
  23. package/dist/control-client.js +855 -0
  24. package/dist/control-deps.js +37 -6
  25. package/dist/control-endpoint.d.ts +14 -0
  26. package/dist/control-endpoint.js +124 -0
  27. package/dist/control-link-store.d.ts +16 -0
  28. package/dist/control-link-store.js +53 -0
  29. package/dist/daemon-build-status.d.ts +8 -0
  30. package/dist/daemon-build-status.js +18 -0
  31. package/dist/daemon-owner.d.ts +14 -0
  32. package/dist/daemon-owner.js +104 -0
  33. package/dist/daemon-server.d.ts +29 -2
  34. package/dist/daemon-server.js +1113 -26
  35. package/dist/db.js +196 -10
  36. package/dist/desktop-browser-host-client.d.ts +35 -0
  37. package/dist/desktop-browser-host-client.js +415 -0
  38. package/dist/direct-runtime-authenticator.d.ts +13 -0
  39. package/dist/direct-runtime-authenticator.js +132 -0
  40. package/dist/direct-runtime-config.d.ts +20 -0
  41. package/dist/direct-runtime-config.js +101 -0
  42. package/dist/embedded-browser-profile-cleanup.d.ts +58 -0
  43. package/dist/embedded-browser-profile-cleanup.js +338 -0
  44. package/dist/headless-browser-host.d.ts +14 -0
  45. package/dist/headless-browser-host.js +2173 -0
  46. package/dist/index-daemon.js +55 -5
  47. package/dist/installation-id.d.ts +33 -0
  48. package/dist/installation-id.js +692 -0
  49. package/dist/maintenance-management.d.ts +11 -0
  50. package/dist/maintenance-management.js +13 -0
  51. package/dist/migrations/cleanup-legacy-sessions.d.ts +0 -3
  52. package/dist/migrations/cleanup-legacy-sessions.js +10 -28
  53. package/dist/plugin-cli.d.ts +30 -0
  54. package/dist/plugin-cli.js +270 -0
  55. package/dist/plugin-host-rpc.d.ts +85 -0
  56. package/dist/plugin-host-rpc.js +878 -0
  57. package/dist/plugin-install-restart.d.ts +13 -0
  58. package/dist/plugin-install-restart.js +24 -0
  59. package/dist/plugin-installer.d.ts +71 -15
  60. package/dist/plugin-installer.js +521 -155
  61. package/dist/plugin-management-service.d.ts +58 -0
  62. package/dist/plugin-management-service.js +240 -0
  63. package/dist/plugin-package.d.ts +61 -0
  64. package/dist/plugin-package.js +732 -0
  65. package/dist/plugin-runtime-control.d.ts +2 -0
  66. package/dist/plugin-runtime-control.js +37 -0
  67. package/dist/plugin-store.d.ts +37 -8
  68. package/dist/plugin-store.js +80 -15
  69. package/dist/plugin-supervisor.d.ts +160 -0
  70. package/dist/plugin-supervisor.js +1143 -0
  71. package/dist/pm2.d.ts +3 -1
  72. package/dist/pm2.js +92 -18
  73. package/dist/provider-cli-service.d.ts +46 -0
  74. package/dist/provider-cli-service.js +510 -0
  75. package/dist/registry.d.ts +6 -19
  76. package/dist/registry.js +22 -87
  77. package/dist/remote-runtime-access-store.d.ts +62 -0
  78. package/dist/remote-runtime-access-store.js +393 -0
  79. package/dist/remote-runtime-admin.d.ts +35 -0
  80. package/dist/remote-runtime-admin.js +110 -0
  81. package/dist/remote-runtime-connection-manager.d.ts +103 -0
  82. package/dist/remote-runtime-connection-manager.js +1575 -0
  83. package/dist/remote-runtime-credential-store.d.ts +45 -0
  84. package/dist/remote-runtime-credential-store.js +639 -0
  85. package/dist/remote-runtime-target-control.d.ts +51 -0
  86. package/dist/remote-runtime-target-control.js +488 -0
  87. package/dist/remote-runtime-target-store.d.ts +56 -0
  88. package/dist/remote-runtime-target-store.js +326 -0
  89. package/dist/runtime-share-addresses.d.ts +21 -0
  90. package/dist/runtime-share-addresses.js +50 -0
  91. package/dist/session-browser-capability-store.d.ts +13 -0
  92. package/dist/session-browser-capability-store.js +58 -0
  93. package/dist/session-emulator-binding-store.d.ts +10 -0
  94. package/dist/session-emulator-binding-store.js +61 -0
  95. package/dist/session-launch-operations.d.ts +41 -0
  96. package/dist/session-launch-operations.js +124 -0
  97. package/dist/session-log-store.js +29 -0
  98. package/dist/session-meta-store.d.ts +3 -2
  99. package/dist/session-meta-store.js +59 -7
  100. package/dist/session-pending-message-store.d.ts +2 -0
  101. package/dist/session-pending-message-store.js +41 -0
  102. package/dist/session-resource-store.d.ts +32 -0
  103. package/dist/session-resource-store.js +700 -0
  104. package/dist/setup.d.ts +16 -0
  105. package/dist/setup.js +144 -5
  106. package/dist/skills-catalog.js +8 -4
  107. package/dist/update.d.ts +11 -2
  108. package/dist/update.js +0 -27
  109. package/package.json +23 -10
@@ -1,186 +1,552 @@
1
1
  /**
2
- * Plugin installation — from npm or a local directory.
2
+ * Transactional plugin installation.
3
3
  *
4
- * No trust mechanism: installing runs the plugin's code in the daemon process.
5
- * The user owns that risk.
6
- *
7
- * - **npm**: `npm install <spec>` into `~/.rynx/plugins`, then record it.
8
- * - **local**: register an absolute directory and load it in place (no copy;
9
- * supports local development / offline). The directory must be a
10
- * ready package (built, deps installed, exporting `plugin`).
4
+ * Packages are first materialized in a host-owned staging directory. The
5
+ * installer reads only static JSON metadata, validates every declared entry,
6
+ * hashes the package, and atomically renames the validated tree into an
7
+ * immutable installation path. Plugin code is never imported here.
8
+ */
9
+ import { randomUUID } from "node:crypto";
10
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
11
+ import { basename, dirname, join, resolve } from "node:path";
12
+ import { PLUGIN_ID_PATTERN, pluginCliInvocationLeasePid, readPluginRuntimeLeases, removePluginRuntimeLease, } from "@rynx-ai/plugin-sdk";
13
+ import { rynxHome } from "@rynx-ai/core";
14
+ import { getPlugin, isRunnablePluginRecord, removePlugin, upsertPlugin, } from "./plugin-store.js";
15
+ import { detectPluginSource, inspectPluginPackage, materializePluginPackage, normalizePluginSpec, rebuildPluginPackage, } from "./plugin-package.js";
16
+ import { pluginsDir } from "./registry.js";
17
+ import { db } from "./db.js";
18
+ const INSTALLATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
19
+ const STAGING_TRANSACTION_PATTERN = /^install-[A-Za-z0-9]{6}$/;
20
+ const STAGING_OWNER_FILE = ".rynx-staging-owner.json";
21
+ const PROMOTION_MARKER_SUFFIX = ".rynx-promoted";
22
+ const MAX_HOST_MARKER_BYTES = 4 * 1024;
23
+ const DEFAULT_STAGING_GC_AGE_MS = 24 * 60 * 60 * 1_000;
24
+ /**
25
+ * Install or replace one arbitrary npm/local/git/URL plugin package.
11
26
  *
12
- * After recording, the channel-type catalog is rebuilt so a freshly-installed
13
- * plugin's types are usable without a daemon restart.
27
+ * A failed validation, materialization, atomic move, or registry update leaves
28
+ * the previously registered installation untouched.
14
29
  */
15
- import { spawn } from "node:child_process";
16
- import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
- import { homedir } from "node:os";
18
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- import { getPlugin, removePlugin, upsertPlugin, } from "./plugin-store.js";
21
- import { importPluginManifest, pluginPkgDir, pluginsDir } from "./registry.js";
22
- /** A spec is "local" if it's a path/file: URL or an existing directory. */
23
- function detectSource(spec) {
24
- if (spec.startsWith("file:"))
25
- return "local";
26
- if (spec.startsWith("~") || spec.startsWith("./") || spec.startsWith("../") || isAbsolute(spec)) {
27
- return "local";
28
- }
30
+ export async function installPlugin(spec, onProgress = () => { }, opts = {}) {
31
+ const prepared = await preparePluginInstallation(spec, onProgress, opts);
29
32
  try {
30
- if (existsSync(spec) && statSync(spec).isDirectory())
31
- return "local";
33
+ if (opts.expectedDigest !== undefined &&
34
+ !/^sha256-[A-Za-z0-9+/]{43}={0,2}$/.test(opts.expectedDigest)) {
35
+ throw new Error("expected plugin digest is invalid");
36
+ }
37
+ if (opts.expectedDigest !== undefined &&
38
+ opts.expectedDigest !== prepared.integrity) {
39
+ throw new Error(`prepared plugin digest ${prepared.integrity} does not match ` +
40
+ `expected digest ${opts.expectedDigest}`);
41
+ }
42
+ if (prepared.manifest.requiresBuildScripts) {
43
+ if (!opts.allowScripts) {
44
+ throw new Error(`plugin "${prepared.name}" requires package build scripts; review digest ` +
45
+ `${prepared.integrity} and re-run with --allow-scripts ` +
46
+ `--expect-digest ${prepared.integrity}`);
47
+ }
48
+ if (opts.expectedDigest === undefined) {
49
+ throw new Error(`build-script approval must be bound to the prepared artifact; ` +
50
+ `re-run with --expect-digest ${prepared.integrity}`);
51
+ }
52
+ }
53
+ const existing = prepared.existing;
54
+ if (existing && opts.onConflict && !(await opts.onConflict(existing))) {
55
+ return {
56
+ name: prepared.name,
57
+ source: prepared.source,
58
+ ...(prepared.version ? { version: prepared.version } : {}),
59
+ skipped: true,
60
+ };
61
+ }
62
+ if (opts.signal?.aborted)
63
+ throw new Error("plugin installation aborted");
64
+ const requested = new Set(prepared.manifest.requestedCapabilities);
65
+ if (!opts.approveCapabilities &&
66
+ opts.grants === undefined &&
67
+ !existing &&
68
+ requested.size > 0) {
69
+ throw new Error(`plugin "${prepared.name}" requests host capabilities; explicit grants or approval are required`);
70
+ }
71
+ const grants = opts.approveCapabilities
72
+ ? await opts.approveCapabilities(prepared.manifest, existing)
73
+ : opts.grants ??
74
+ (existing
75
+ ? existing.grants.filter((capability) => requested.has(capability))
76
+ : []);
77
+ return await prepared.commit({
78
+ grants,
79
+ expectedDigest: opts.expectedDigest ?? prepared.integrity,
80
+ signal: opts.signal,
81
+ });
32
82
  }
33
- catch {
34
- /* not a path */
83
+ finally {
84
+ prepared.discard();
35
85
  }
36
- return "npm";
37
86
  }
38
- function toLocalDir(spec) {
39
- let p = spec.startsWith("file:") ? fileURLToPath(spec) : spec;
40
- if (p === "~" || p.startsWith("~/"))
41
- p = join(homedir(), p.slice(1));
42
- return resolve(p);
87
+ /**
88
+ * Materialize, build (only when explicitly allowed), inspect, and hash one
89
+ * plugin into host-owned staging without mutating the registry.
90
+ */
91
+ export async function preparePluginInstallation(spec, onProgress = () => { }, opts = {}) {
92
+ if (!spec.trim())
93
+ throw new Error("plugin spec must not be empty");
94
+ const progress = safeProgress(onProgress);
95
+ const source = detectPluginSource(spec);
96
+ const storedSpec = normalizePluginSpec(spec, source);
97
+ const storage = ensureStorageRoots();
98
+ const transactionDir = mkdtempSync(join(storage.stagingRoot, "install-"));
99
+ try {
100
+ writeStagingOwner(transactionDir);
101
+ }
102
+ catch (error) {
103
+ rmSync(transactionDir, { recursive: true, force: true });
104
+ throw error;
105
+ }
106
+ const stagedPackage = join(transactionDir, "package");
107
+ let finalPath;
108
+ let registryUpdated = false;
109
+ let consumed = false;
110
+ try {
111
+ await (opts.materializer ?? materializePluginPackage)({
112
+ spec: storedSpec,
113
+ source,
114
+ destination: stagedPackage,
115
+ onProgress: progress,
116
+ signal: opts.signal,
117
+ });
118
+ let inspected = await inspectPluginPackage(stagedPackage, {
119
+ requireEntries: false,
120
+ signal: opts.signal,
121
+ });
122
+ if (opts.allowScripts && !inspected.manifest.requiresBuildScripts) {
123
+ throw new Error(`plugin "${inspected.manifest.id}" does not declare requiresBuildScripts; ` +
124
+ "refusing unnecessary --allow-scripts approval");
125
+ }
126
+ const requiresBuildScripts = Boolean(inspected.manifest.requiresBuildScripts);
127
+ if (!requiresBuildScripts) {
128
+ inspected = await inspectPluginPackage(stagedPackage, { signal: opts.signal });
129
+ }
130
+ const name = inspected.manifest.id;
131
+ if (opts.expectedId && name !== opts.expectedId) {
132
+ throw new Error(`plugin update id mismatch: expected "${opts.expectedId}", received "${name}"`);
133
+ }
134
+ const existing = getPlugin(name);
135
+ const cleanup = () => {
136
+ if (!registryUpdated && finalPath) {
137
+ removeOwnedInstallation(storage.installationsRoot, basename(dirname(finalPath)), finalPath);
138
+ }
139
+ try {
140
+ rmSync(transactionDir, { recursive: true, force: true });
141
+ }
142
+ catch {
143
+ /* orphaned staging is inactive and can be collected later */
144
+ }
145
+ };
146
+ const preparedManifest = cloneManifest(inspected.manifest);
147
+ const preparedExisting = existing ? clonePluginRecord(existing) : undefined;
148
+ return {
149
+ name,
150
+ source,
151
+ ...(inspected.version ? { version: inspected.version } : {}),
152
+ manifest: cloneManifest(preparedManifest),
153
+ integrity: inspected.integrity,
154
+ ...(preparedExisting ? { existing: clonePluginRecord(preparedExisting) } : {}),
155
+ commit: async ({ grants, expectedDigest, signal }) => {
156
+ if (consumed)
157
+ throw new Error("prepared plugin installation was already consumed");
158
+ consumed = true;
159
+ try {
160
+ if (signal?.aborted)
161
+ throw new Error("plugin installation aborted");
162
+ if (expectedDigest !== inspected.integrity) {
163
+ throw new Error("prepared plugin digest does not match expected digest");
164
+ }
165
+ const rechecked = await inspectPluginPackage(stagedPackage, {
166
+ ...(requiresBuildScripts ? { requireEntries: false } : {}),
167
+ signal,
168
+ });
169
+ if (rechecked.integrity !== inspected.integrity ||
170
+ rechecked.version !== inspected.version ||
171
+ JSON.stringify(rechecked.manifest) !== JSON.stringify(preparedManifest)) {
172
+ throw new Error("prepared plugin artifact changed after approval");
173
+ }
174
+ let promoted = rechecked;
175
+ if (requiresBuildScripts) {
176
+ if (!opts.allowScripts) {
177
+ throw new Error(`plugin "${name}" requires package build scripts; ` +
178
+ `approve digest ${inspected.integrity} before running scripts`);
179
+ }
180
+ progress(`running digest-approved build scripts for plugin "${name}"`);
181
+ await rebuildPluginPackage(stagedPackage, progress, signal);
182
+ const built = await inspectPluginPackage(stagedPackage, { signal });
183
+ if (JSON.stringify(built.manifest) !== JSON.stringify(preparedManifest) ||
184
+ built.version !== inspected.version) {
185
+ throw new Error("plugin build scripts changed static package identity or manifest metadata");
186
+ }
187
+ promoted = built;
188
+ }
189
+ const requested = new Set(preparedManifest.requestedCapabilities);
190
+ for (const grant of grants) {
191
+ if (!requested.has(grant)) {
192
+ throw new Error(`plugin capability was not requested: ${grant}`);
193
+ }
194
+ }
195
+ if (new Set(grants).size !== grants.length) {
196
+ throw new Error("plugin grants must be unique");
197
+ }
198
+ db().transaction(() => {
199
+ const current = getPlugin(name);
200
+ if (!samePluginGeneration(existing, current)) {
201
+ throw new Error(`plugin "${name}" changed concurrently; retry the installation`);
202
+ }
203
+ const leases = activeRuntimeLeases(name, storage.installationsRoot);
204
+ if (leases.length > 0) {
205
+ throw new Error(`plugin "${name}" has active runtime lease(s): ${leases
206
+ .map((lease) => lease.leaseId)
207
+ .join(", ")}; stop its managed services or CLI commands before updating`);
208
+ }
209
+ const idRoot = ensureDirectDirectory(storage.installationsRoot, name, "plugin id directory");
210
+ const installationId = randomUUID();
211
+ finalPath = join(idRoot, installationId);
212
+ const marker = promotionMarkerPath(idRoot, installationId);
213
+ writePromotionMarker(marker);
214
+ try {
215
+ renameSync(stagedPackage, finalPath);
216
+ }
217
+ catch (error) {
218
+ rmSync(marker, { force: true });
219
+ finalPath = undefined;
220
+ throw error;
221
+ }
222
+ progress(`staged validated plugin "${name}" → ${finalPath}`);
223
+ upsertPlugin({
224
+ name,
225
+ spec: storedSpec,
226
+ source,
227
+ version: promoted.version,
228
+ installPath: finalPath,
229
+ manifest: preparedManifest,
230
+ integrity: promoted.integrity,
231
+ grants,
232
+ enabled: current?.enabled,
233
+ });
234
+ }).immediate();
235
+ registryUpdated = true;
236
+ if (existing?.installPath && existing.installPath !== finalPath) {
237
+ progress("retained superseded installation until the daemon switches versions");
238
+ }
239
+ progress(`installed plugin "${name}"${promoted.version ? `@${promoted.version}` : ""}`);
240
+ return {
241
+ name,
242
+ source,
243
+ ...(promoted.version ? { version: promoted.version } : {}),
244
+ };
245
+ }
246
+ finally {
247
+ cleanup();
248
+ }
249
+ },
250
+ discard: () => {
251
+ if (consumed)
252
+ return;
253
+ consumed = true;
254
+ cleanup();
255
+ },
256
+ };
257
+ }
258
+ catch (error) {
259
+ if (!registryUpdated && finalPath) {
260
+ removeOwnedInstallation(storage.installationsRoot, basename(dirname(finalPath)), finalPath);
261
+ }
262
+ rmSync(transactionDir, { recursive: true, force: true });
263
+ throw error;
264
+ }
43
265
  }
44
- /** Package name from an npm spec, dropping any version range (`x@^1` → `x`). */
45
- function npmPackageName(spec) {
46
- const at = spec.lastIndexOf("@");
47
- return at > 0 ? spec.slice(0, at) : spec;
266
+ /**
267
+ * Remove the registry row but retain the immutable package until the next
268
+ * daemon boot. A currently running supervisor may still be executing it.
269
+ */
270
+ export async function uninstallPlugin(name, onProgress = () => { }) {
271
+ const progress = safeProgress(onProgress);
272
+ let record;
273
+ const removed = db().transaction(() => {
274
+ record = getPlugin(name);
275
+ if (!record)
276
+ return false;
277
+ const leases = activeRuntimeLeases(name, ensureStorageRoots().installationsRoot);
278
+ if (leases.length > 0) {
279
+ throw new Error(`plugin "${name}" has active runtime lease(s): ${leases
280
+ .map((lease) => lease.leaseId)
281
+ .join(", ")}; stop its managed services or CLI commands before uninstalling`);
282
+ }
283
+ return removePlugin(name);
284
+ }).immediate();
285
+ if (!removed || !record)
286
+ return false;
287
+ if (record.installPath)
288
+ progress(`retained active package until the daemon stops using it`);
289
+ progress(`uninstalled plugin "${name}"`);
290
+ return true;
48
291
  }
49
292
  /**
50
- * Install (or re-install) a plugin from an npm spec or a local directory.
51
- * - **local**: copy the package (minus `node_modules`) into `~/.rynx/plugins/<name>`
52
- * and load it there. The plugin must be self-contained — it bundles its deps at
53
- * build time (incl. the unpublished workspace `@rynx-ai/core`) and declares a
54
- * `rynx.plugin` entry; the daemon installs nothing.
55
- * - **npm**: `npm install` into `~/.rynx/plugins` (deps resolved by npm).
293
+ * Delete inactive immutable versions at a daemon-start safe point.
56
294
  *
57
- * `onConflict` is consulted when a plugin of the same `name` already exists; if it
58
- * returns false the install is aborted with `{ skipped: true }` (nothing changes).
59
- * `onProgress` streams human-readable lines.
295
+ * A short grace window prevents a concurrently materializing install (renamed
296
+ * just before its registry transaction) from being mistaken for an orphan.
60
297
  */
61
- export async function installPlugin(spec, onProgress = () => { }, opts = {}) {
62
- const source = detectSource(spec);
63
- let pkgDir;
64
- let storedSpec;
65
- if (source === "local") {
66
- const srcDir = toLocalDir(spec);
67
- if (!existsSync(join(srcDir, "package.json")))
68
- throw new Error(`no package.json at ${srcDir}`);
69
- const name = (await importPluginManifest(srcDir))?.name;
70
- if (!name)
71
- throw new Error(`package at ${srcDir} has no valid \`plugin\` export`);
72
- if (await declined(name, opts.onConflict))
73
- return { name, source, skipped: true };
74
- const dest = join(pluginsDir(), name);
75
- ensurePluginsRoot(pluginsDir());
76
- rmSync(dest, { recursive: true, force: true });
77
- // A self-contained plugin bundles its deps, so node_modules is never copied.
78
- cpSync(srcDir, dest, { recursive: true, filter: (p) => basename(p) !== "node_modules" });
79
- onProgress(`copied local plugin "${name}" → ${dest}`);
80
- pkgDir = dest;
81
- storedSpec = dest;
82
- }
83
- else {
84
- const dir = pluginsDir();
85
- ensurePluginsRoot(dir);
86
- onProgress(`npm install ${spec}`);
87
- await runNpm(["install", spec, "--prefix", dir, "--no-audit", "--no-fund", "--save"], onProgress, opts.signal);
88
- storedSpec = npmPackageName(spec);
89
- pkgDir = pluginPkgDir({ source, spec: storedSpec });
90
- const name = (await importPluginManifest(pkgDir))?.name;
91
- if (!name)
92
- throw new Error(`package at ${pkgDir} has no valid \`plugin\` export`);
93
- if (await declined(name, opts.onConflict))
94
- return { name, source, skipped: true };
95
- }
96
- const manifest = await importPluginManifest(pkgDir);
97
- if (!manifest || typeof manifest.name !== "string" || !manifest.name) {
98
- throw new Error(`package at ${pkgDir} has no valid \`plugin\` export`);
99
- }
100
- const version = readVersion(pkgDir);
101
- upsertPlugin({ name: manifest.name, spec: storedSpec, source, version });
102
- onProgress(`installed plugin "${manifest.name}"${version ? `@${version}` : ""}`);
103
- return { name: manifest.name, source, version };
104
- }
105
- /** A same-named plugin already exists and `onConflict` says don't overwrite. */
106
- async function declined(name, onConflict) {
107
- if (!onConflict)
108
- return false;
109
- const existing = getPlugin(name);
110
- if (!existing)
111
- return false;
112
- return !(await onConflict(existing));
298
+ export function pruneInactivePluginInstallations({ minAgeMs = 60_000, stagingMinAgeMs = DEFAULT_STAGING_GC_AGE_MS, now = Date.now(), } = {}) {
299
+ const { stagingRoot, installationsRoot } = ensureStorageRoots();
300
+ const minimumAge = Math.max(0, minAgeMs);
301
+ const removed = [];
302
+ pruneStagingTransactions(stagingRoot, Math.max(0, stagingMinAgeMs), now, removed);
303
+ for (const pluginId of readdirSync(installationsRoot)) {
304
+ if (!PLUGIN_ID_PATTERN.test(pluginId))
305
+ continue;
306
+ db().transaction(() => {
307
+ const active = new Set();
308
+ const current = getPlugin(pluginId);
309
+ if (current && isRunnablePluginRecord(current))
310
+ active.add(resolve(current.installPath));
311
+ for (const lease of activeRuntimeLeases(pluginId, installationsRoot)) {
312
+ active.add(resolve(lease.installPath));
313
+ }
314
+ const idRoot = join(installationsRoot, pluginId);
315
+ let idStat;
316
+ try {
317
+ idStat = lstatSync(idRoot);
318
+ }
319
+ catch {
320
+ return;
321
+ }
322
+ if (!idStat.isDirectory() || idStat.isSymbolicLink())
323
+ return;
324
+ const entries = readdirSync(idRoot);
325
+ for (const installationId of entries) {
326
+ if (!INSTALLATION_ID_PATTERN.test(installationId))
327
+ continue;
328
+ const candidate = join(idRoot, installationId);
329
+ if (active.has(resolve(candidate)))
330
+ continue;
331
+ let candidateStat;
332
+ try {
333
+ candidateStat = lstatSync(candidate);
334
+ }
335
+ catch {
336
+ continue;
337
+ }
338
+ if (!candidateStat.isDirectory() ||
339
+ candidateStat.isSymbolicLink() ||
340
+ now - promotionTimestampMs(idRoot, installationId, candidateStat.ctimeMs) < minimumAge) {
341
+ continue;
342
+ }
343
+ if (removeOwnedInstallation(installationsRoot, pluginId, candidate)) {
344
+ removed.push(candidate);
345
+ }
346
+ }
347
+ for (const entry of entries) {
348
+ const installationId = promotionMarkerInstallationId(entry);
349
+ if (!installationId || existsSync(join(idRoot, installationId)))
350
+ continue;
351
+ const marker = join(idRoot, entry);
352
+ try {
353
+ const stat = lstatSync(marker);
354
+ if (stat.isFile() &&
355
+ !stat.isSymbolicLink() &&
356
+ now - stat.mtimeMs >= minimumAge) {
357
+ rmSync(marker, { force: true });
358
+ }
359
+ }
360
+ catch {
361
+ // A concurrently removed orphan marker needs no further work.
362
+ }
363
+ }
364
+ }).immediate();
365
+ }
366
+ return removed;
113
367
  }
114
- /** Uninstall a plugin: drop its record, npm-uninstall if applicable, refresh catalog. */
115
- export async function uninstallPlugin(name, onProgress = () => { }) {
116
- const rec = getPlugin(name);
117
- if (!rec)
118
- return false;
119
- if (rec.source === "npm") {
368
+ function activeRuntimeLeases(pluginId, installationsRoot) {
369
+ const dataDir = join(rynxHome(), "plugin-data", pluginId);
370
+ return readPluginRuntimeLeases(dataDir).filter((lease) => {
371
+ if (lease.pluginId !== pluginId)
372
+ return false;
373
+ const invocationPid = pluginCliInvocationLeasePid(lease.leaseId);
374
+ if (invocationPid !== undefined && !isProcessAlive(invocationPid)) {
375
+ removePluginRuntimeLease(dataDir, lease.leaseId);
376
+ return false;
377
+ }
378
+ const candidate = resolve(lease.installPath);
379
+ if (dirname(candidate) !== join(installationsRoot, pluginId) ||
380
+ !INSTALLATION_ID_PATTERN.test(basename(candidate))) {
381
+ return false;
382
+ }
120
383
  try {
121
- await runNpm(["uninstall", rec.spec, "--prefix", pluginsDir(), "--no-audit", "--no-fund"], onProgress);
384
+ const stat = lstatSync(candidate);
385
+ return stat.isDirectory() && !stat.isSymbolicLink() && realpathSync(candidate) === candidate;
122
386
  }
123
- catch (error) {
124
- onProgress(`npm uninstall warning: ${messageOf(error)}`);
387
+ catch {
388
+ return false;
125
389
  }
390
+ });
391
+ }
392
+ function isProcessAlive(pid) {
393
+ try {
394
+ process.kill(pid, 0);
395
+ return true;
126
396
  }
127
- else {
128
- // local: remove the copied package dir — but only if it lives under our plugins
129
- // root (older records may point at an in-place source tree; never delete that).
130
- const dir = pluginPkgDir(rec);
131
- if (dirname(dir) === pluginsDir()) {
132
- try {
133
- rmSync(dir, { recursive: true, force: true });
134
- onProgress(`removed ${dir}`);
135
- }
136
- catch (error) {
137
- onProgress(`remove warning: ${messageOf(error)}`);
397
+ catch (error) {
398
+ return error.code === "EPERM";
399
+ }
400
+ }
401
+ function samePluginGeneration(before, current) {
402
+ if (!before || !current)
403
+ return before === current;
404
+ return JSON.stringify(before) === JSON.stringify(current);
405
+ }
406
+ function cloneManifest(manifest) {
407
+ return JSON.parse(JSON.stringify(manifest));
408
+ }
409
+ function clonePluginRecord(record) {
410
+ return {
411
+ ...record,
412
+ ...(record.manifest ? { manifest: cloneManifest(record.manifest) } : {}),
413
+ grants: [...record.grants],
414
+ };
415
+ }
416
+ function writeStagingOwner(transactionDir) {
417
+ writeFileSync(join(transactionDir, STAGING_OWNER_FILE), `${JSON.stringify({ schemaVersion: 1, pid: process.pid, createdAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
418
+ }
419
+ function pruneStagingTransactions(stagingRoot, minAgeMs, now, removed) {
420
+ for (const entry of readdirSync(stagingRoot)) {
421
+ if (!STAGING_TRANSACTION_PATTERN.test(entry))
422
+ continue;
423
+ const candidate = join(stagingRoot, entry);
424
+ try {
425
+ const stat = lstatSync(candidate);
426
+ if (!stat.isDirectory() ||
427
+ stat.isSymbolicLink() ||
428
+ dirname(realpathSync(candidate)) !== realpathSync(stagingRoot)) {
429
+ continue;
138
430
  }
431
+ const owner = readStagingOwner(candidate);
432
+ if (owner?.pid && isProcessAlive(owner.pid))
433
+ continue;
434
+ const createdAt = owner?.createdAtMs ?? Math.max(stat.ctimeMs, stat.mtimeMs);
435
+ if (now - createdAt < minAgeMs)
436
+ continue;
437
+ rmSync(candidate, { recursive: true, force: true });
438
+ removed.push(candidate);
439
+ }
440
+ catch {
441
+ // Invalid or concurrently removed staging data is never followed.
139
442
  }
140
443
  }
141
- removePlugin(name);
142
- onProgress(`uninstalled plugin "${name}"`);
143
- return true;
144
444
  }
145
- function ensurePluginsRoot(dir) {
146
- mkdirSync(dir, { recursive: true });
147
- const pkg = join(dir, "package.json");
148
- if (!existsSync(pkg)) {
149
- writeFileSync(pkg, `${JSON.stringify({ name: "rynx-plugins", private: true, version: "0.0.0" }, null, 2)}\n`);
445
+ function readStagingOwner(transactionDir) {
446
+ const file = join(transactionDir, STAGING_OWNER_FILE);
447
+ try {
448
+ const stat = lstatSync(file);
449
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_HOST_MARKER_BYTES)
450
+ return undefined;
451
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
452
+ if (parsed.schemaVersion !== 1 ||
453
+ !Number.isSafeInteger(parsed.pid) ||
454
+ Number(parsed.pid) <= 0 ||
455
+ typeof parsed.createdAt !== "string" ||
456
+ !Number.isFinite(Date.parse(parsed.createdAt))) {
457
+ return undefined;
458
+ }
459
+ return { pid: Number(parsed.pid), createdAtMs: stat.mtimeMs };
460
+ }
461
+ catch {
462
+ return undefined;
150
463
  }
151
464
  }
152
- function readVersion(pkgDir) {
465
+ function promotionMarkerPath(idRoot, installationId) {
466
+ return join(idRoot, `${installationId}${PROMOTION_MARKER_SUFFIX}`);
467
+ }
468
+ function writePromotionMarker(marker) {
469
+ writeFileSync(marker, `${JSON.stringify({ schemaVersion: 1, promotedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
470
+ }
471
+ function promotionTimestampMs(idRoot, installationId, conservativeFallback) {
153
472
  try {
154
- return JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf8"))
155
- .version;
473
+ const stat = lstatSync(promotionMarkerPath(idRoot, installationId));
474
+ if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= MAX_HOST_MARKER_BYTES) {
475
+ return stat.mtimeMs;
476
+ }
156
477
  }
157
478
  catch {
158
- return undefined;
479
+ // Installations predating promotion markers fall back to rename-sensitive ctime.
159
480
  }
481
+ return conservativeFallback;
160
482
  }
161
- function runNpm(args, onProgress, signal) {
162
- return new Promise((resolveDone, reject) => {
163
- const child = spawn("npm", args, { stdio: ["ignore", "pipe", "pipe"] });
164
- const abort = () => child.kill();
165
- signal?.addEventListener("abort", abort, { once: true });
166
- const onData = (buf) => {
167
- for (const line of buf.toString().split(/\r?\n/)) {
168
- if (line.trim())
169
- onProgress(line);
170
- }
171
- };
172
- child.stdout?.on("data", onData);
173
- child.stderr?.on("data", onData);
174
- child.on("error", reject);
175
- child.on("close", (code) => {
176
- signal?.removeEventListener("abort", abort);
177
- if (code === 0)
178
- resolveDone();
179
- else
180
- reject(new Error(`npm exited with code ${code ?? "null"}`));
181
- });
182
- });
483
+ function promotionMarkerInstallationId(entry) {
484
+ const match = /^([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.rynx-promoted$/i.exec(entry);
485
+ return match?.[1];
486
+ }
487
+ function ensureStorageRoots() {
488
+ const configuredRoot = resolve(pluginsDir());
489
+ mkdirSync(configuredRoot, { recursive: true, mode: 0o700 });
490
+ const root = realpathSync(configuredRoot);
491
+ return {
492
+ stagingRoot: ensureDirectDirectory(root, "staging", "plugin staging directory"),
493
+ installationsRoot: ensureDirectDirectory(root, "installed", "plugin installations directory"),
494
+ };
495
+ }
496
+ /** Create/validate one non-symlink direct child of a canonical parent. */
497
+ function ensureDirectDirectory(parent, child, label) {
498
+ const path = join(parent, child);
499
+ if (!existsSync(path))
500
+ mkdirSync(path, { mode: 0o700 });
501
+ const stat = lstatSync(path);
502
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
503
+ throw new Error(`${label} must be a host-owned directory, not a symlink`);
504
+ }
505
+ const real = realpathSync(path);
506
+ if (dirname(real) !== realpathSync(parent)) {
507
+ throw new Error(`${label} resolves outside its host-owned parent`);
508
+ }
509
+ return real;
510
+ }
511
+ /**
512
+ * Delete only `<installations>/<validated-id>/<host UUID>`.
513
+ * Legacy paths, source directories, traversal strings, and symlinks are kept.
514
+ */
515
+ function removeOwnedInstallation(installationsRoot, pluginId, installPath) {
516
+ try {
517
+ if (!PLUGIN_ID_PATTERN.test(pluginId))
518
+ return false;
519
+ const root = realpathSync(installationsRoot);
520
+ const idRoot = join(root, pluginId);
521
+ const candidate = resolve(installPath);
522
+ if (dirname(candidate) !== idRoot || !INSTALLATION_ID_PATTERN.test(basename(candidate))) {
523
+ return false;
524
+ }
525
+ const idStat = lstatSync(idRoot);
526
+ const candidateStat = lstatSync(candidate);
527
+ if (!idStat.isDirectory() ||
528
+ idStat.isSymbolicLink() ||
529
+ !candidateStat.isDirectory() ||
530
+ candidateStat.isSymbolicLink()) {
531
+ return false;
532
+ }
533
+ if (realpathSync(idRoot) !== idRoot || realpathSync(candidate) !== candidate)
534
+ return false;
535
+ rmSync(candidate, { recursive: true, force: true });
536
+ rmSync(promotionMarkerPath(idRoot, basename(candidate)), { force: true });
537
+ return true;
538
+ }
539
+ catch {
540
+ return false;
541
+ }
183
542
  }
184
- function messageOf(error) {
185
- return error instanceof Error ? error.message : String(error);
543
+ function safeProgress(callback) {
544
+ return (line) => {
545
+ try {
546
+ callback(line);
547
+ }
548
+ catch {
549
+ /* progress reporting must never change installation state */
550
+ }
551
+ };
186
552
  }