@rynx-ai/daemon 0.1.9 → 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 (50) 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/chrome-for-testing-store.js +1 -1
  6. package/dist/chrome-inspection-gateway.d.ts +58 -0
  7. package/dist/chrome-inspection-gateway.js +806 -0
  8. package/dist/chrome-inspection-manager.d.ts +94 -0
  9. package/dist/chrome-inspection-manager.js +573 -0
  10. package/dist/cli.js +13 -2
  11. package/dist/control-client.d.ts +9 -0
  12. package/dist/control-client.js +63 -0
  13. package/dist/daemon-build-status.d.ts +8 -0
  14. package/dist/daemon-build-status.js +18 -0
  15. package/dist/daemon-server.d.ts +19 -2
  16. package/dist/daemon-server.js +615 -59
  17. package/dist/db.js +56 -0
  18. package/dist/desktop-browser-host-client.d.ts +2 -1
  19. package/dist/desktop-browser-host-client.js +3 -1
  20. package/dist/direct-runtime-authenticator.js +11 -6
  21. package/dist/headless-browser-host.js +18 -1
  22. package/dist/index-daemon.js +28 -1
  23. package/dist/maintenance-management.d.ts +11 -0
  24. package/dist/maintenance-management.js +13 -0
  25. package/dist/plugin-installer.d.ts +35 -0
  26. package/dist/plugin-installer.js +195 -94
  27. package/dist/plugin-management-service.d.ts +58 -0
  28. package/dist/plugin-management-service.js +240 -0
  29. package/dist/plugin-package.d.ts +26 -3
  30. package/dist/plugin-package.js +235 -56
  31. package/dist/pm2.js +37 -5
  32. package/dist/remote-runtime-access-store.d.ts +1 -0
  33. package/dist/remote-runtime-access-store.js +7 -0
  34. package/dist/remote-runtime-admin.d.ts +3 -0
  35. package/dist/remote-runtime-admin.js +3 -0
  36. package/dist/remote-runtime-connection-manager.d.ts +12 -1
  37. package/dist/remote-runtime-connection-manager.js +170 -4
  38. package/dist/remote-runtime-target-control.d.ts +5 -1
  39. package/dist/remote-runtime-target-control.js +62 -7
  40. package/dist/remote-runtime-target-store.d.ts +4 -1
  41. package/dist/remote-runtime-target-store.js +21 -2
  42. package/dist/session-log-store.js +29 -0
  43. package/dist/session-meta-store.js +3 -1
  44. package/dist/session-pending-message-store.d.ts +2 -0
  45. package/dist/session-pending-message-store.js +41 -0
  46. package/dist/session-resource-store.d.ts +32 -0
  47. package/dist/session-resource-store.js +700 -0
  48. package/dist/setup.d.ts +16 -0
  49. package/dist/setup.js +136 -2
  50. package/package.json +14 -9
package/dist/db.js CHANGED
@@ -91,6 +91,56 @@ function migrate(conn) {
91
91
  UNIQUE(session_id, position)
92
92
  );
93
93
  CREATE INDEX IF NOT EXISTS idx_session_items_session ON session_items(session_id, position);
94
+ CREATE TABLE IF NOT EXISTS session_resources (
95
+ id TEXT PRIMARY KEY,
96
+ session_id TEXT NOT NULL,
97
+ client_upload_id TEXT NOT NULL,
98
+ upload_id TEXT NOT NULL UNIQUE,
99
+ filename TEXT NOT NULL,
100
+ media_type TEXT NOT NULL CHECK(media_type IN ('image/png', 'image/jpeg', 'image/webp')),
101
+ byte_length INTEGER NOT NULL,
102
+ uploaded_bytes INTEGER NOT NULL DEFAULT 0,
103
+ sha256 TEXT,
104
+ width INTEGER,
105
+ height INTEGER,
106
+ state TEXT NOT NULL DEFAULT 'uploading'
107
+ CHECK(state IN ('uploading', 'staged', 'committed')),
108
+ message_item_id TEXT,
109
+ created_at INTEGER NOT NULL,
110
+ committed_at INTEGER,
111
+ UNIQUE(session_id, client_upload_id)
112
+ );
113
+ CREATE INDEX IF NOT EXISTS idx_session_resources_session
114
+ ON session_resources(session_id, created_at);
115
+ CREATE INDEX IF NOT EXISTS idx_session_resources_message
116
+ ON session_resources(message_item_id);
117
+ CREATE TABLE IF NOT EXISTS session_message_operations (
118
+ session_id TEXT NOT NULL,
119
+ client_message_id TEXT NOT NULL,
120
+ request_hash TEXT NOT NULL,
121
+ state TEXT NOT NULL DEFAULT 'prepared'
122
+ CHECK(state IN (
123
+ 'prepared',
124
+ 'injecting',
125
+ 'injected',
126
+ 'mirrored',
127
+ 'outcome_unknown',
128
+ 'failed_not_started'
129
+ )),
130
+ error TEXT,
131
+ created_at INTEGER NOT NULL,
132
+ updated_at INTEGER NOT NULL,
133
+ PRIMARY KEY(session_id, client_message_id)
134
+ );
135
+ CREATE TABLE IF NOT EXISTS session_message_operation_resources (
136
+ session_id TEXT NOT NULL,
137
+ client_message_id TEXT NOT NULL,
138
+ resource_id TEXT NOT NULL UNIQUE REFERENCES session_resources(id) ON DELETE CASCADE,
139
+ PRIMARY KEY(session_id, client_message_id, resource_id),
140
+ FOREIGN KEY(session_id, client_message_id)
141
+ REFERENCES session_message_operations(session_id, client_message_id)
142
+ ON DELETE CASCADE
143
+ );
94
144
  CREATE TABLE IF NOT EXISTS sessions (
95
145
  id TEXT PRIMARY KEY,
96
146
  provider TEXT,
@@ -103,6 +153,12 @@ function migrate(conn) {
103
153
  created_at TEXT NOT NULL,
104
154
  updated_at TEXT
105
155
  );
156
+ CREATE TABLE IF NOT EXISTS session_pending_messages (
157
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
158
+ message TEXT NOT NULL,
159
+ state TEXT NOT NULL DEFAULT 'queued' CHECK(state IN ('queued', 'outcome_unknown')),
160
+ created_at TEXT NOT NULL
161
+ );
106
162
  CREATE TABLE IF NOT EXISTS session_emulator_bindings (
107
163
  session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
108
164
  binding_id TEXT NOT NULL UNIQUE,
@@ -1,9 +1,10 @@
1
- import { type DesktopBrowserHostCommandFrame, type DesktopBrowserHostErrorCode, type DesktopBrowserHostEvent, type DesktopBrowserHostLeaseFrame, type DesktopBrowserHostSurfaceCapability, type DesktopBrowserHostSurfaceFrame } from "@rynx-ai/protocol/desktop-browser-host";
1
+ import { type DesktopBrowserHostCommandFrame, type DesktopBrowserHostErrorCode, type DesktopBrowserHostEvent, type DesktopBrowserHostLeaseFrame, type DesktopBrowserHostPresentation, type DesktopBrowserHostSurfaceCapability, type DesktopBrowserHostSurfaceFrame } from "@rynx-ai/protocol/desktop-browser-host";
2
2
  export interface ResidentDesktopBrowserHostConnectOptions {
3
3
  hostInstanceId: string;
4
4
  /** Advertise only handlers that are installed in this main-process Host. */
5
5
  capabilities: {
6
6
  semanticPageBinding: boolean;
7
+ presentation: DesktopBrowserHostPresentation;
7
8
  surface: DesktopBrowserHostSurfaceCapability;
8
9
  };
9
10
  signal?: AbortSignal;
@@ -12,6 +12,7 @@ export async function connectResidentDesktopBrowserHost(options) {
12
12
  hostInstanceId: options.hostInstanceId,
13
13
  capabilities: {
14
14
  semanticPageBinding: options.capabilities.semanticPageBinding,
15
+ presentation: options.capabilities.presentation,
15
16
  surface: options.capabilities.surface,
16
17
  },
17
18
  });
@@ -384,7 +385,8 @@ function rawDataBytes(data) {
384
385
  }
385
386
  function isMutatingCommand(command) {
386
387
  return command.command.method !== "browser.snapshot" &&
387
- command.command.method !== "browser.cdp-endpoint";
388
+ command.command.method !== "browser.cdp-endpoint" &&
389
+ command.command.method !== "page.cdp-endpoint";
388
390
  }
389
391
  async function abortableTimeout(promise, timeoutMs, signal) {
390
392
  signal?.throwIfAborted();
@@ -1,5 +1,5 @@
1
1
  import { createHash, createPublicKey, randomBytes, randomUUID, verify, } from "node:crypto";
2
- import { DIRECT_RUNTIME_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL, encodeClientAuthenticateCanonical, encodeClientAuthenticateProofPayload, encodeServerHelloCanonical, parseServerAccepted, parseServerHello, } from "@rynx-ai/protocol/direct-runtime";
2
+ import { DIRECT_RUNTIME_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL, DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL, encodeClientAuthenticateCanonical, encodeClientAuthenticateProofPayload, encodeServerHelloCanonical, parseServerAccepted, parseServerHello, } from "@rynx-ai/protocol/direct-runtime";
3
3
  /** Daemon-side proof verification shared by Direct control and Browser Surface bindings. */
4
4
  export class DaemonDirectRuntimeAuthenticator {
5
5
  identity;
@@ -38,8 +38,8 @@ export class DaemonDirectRuntimeAuthenticator {
38
38
  if (clientAuthenticate.role !== role || serverHello.role !== role) {
39
39
  throw new Error("unsupported Direct Runtime channel role");
40
40
  }
41
- if (role === "browser-surface" && clientAuthenticate.mode !== "connect") {
42
- throw new Error("Browser Surface channels require an existing Direct Runtime grant");
41
+ if (role !== "control" && clientAuthenticate.mode !== "connect") {
42
+ throw new Error(`${role === "browser-surface" ? "Browser Surface" : "Browser Inspect"} channels require an existing Direct Runtime grant`);
43
43
  }
44
44
  if (clientAuthenticate.daemonId !== serverHello.daemonId ||
45
45
  clientAuthenticate.daemonInstanceId !== serverHello.daemonInstanceId ||
@@ -103,9 +103,14 @@ export class DaemonDirectRuntimeAuthenticator {
103
103
  }
104
104
  }
105
105
  function bindingProtocol(role) {
106
- return role === "control"
107
- ? DIRECT_RUNTIME_BINDING_PROTOCOL
108
- : DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL;
106
+ switch (role) {
107
+ case "control":
108
+ return DIRECT_RUNTIME_BINDING_PROTOCOL;
109
+ case "browser-surface":
110
+ return DIRECT_RUNTIME_BROWSER_SURFACE_BINDING_PROTOCOL;
111
+ case "browser-inspect":
112
+ return DIRECT_RUNTIME_BROWSER_INSPECT_BINDING_PROTOCOL;
113
+ }
109
114
  }
110
115
  function withoutSignature(value) {
111
116
  const { signature: _signature, ...fields } = value;
@@ -31,6 +31,7 @@ const MAX_SURFACE_QUALITY = 100;
31
31
  const MIN_SURFACE_FALLBACK_INTERVAL_MS = 200;
32
32
  const MIN_SURFACE_SCREENCAST_IDLE_MS = 250;
33
33
  const DEVTOOLS_BROWSER_PATH = /^\/devtools\/browser\/[A-Za-z0-9._~-]+$/;
34
+ const DEVTOOLS_TARGET_ID_PATTERN = /^[A-Za-z0-9._~-]+$/;
34
35
  const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/;
35
36
  const CONTROL_CHARACTER_GLOBAL_PATTERN = /[\u0000-\u001f\u007f]/g;
36
37
  export function createHeadlessBrowserHost(options = {}) {
@@ -241,7 +242,23 @@ class HeadlessBrowserHandle {
241
242
  }
242
243
  async getCdpEndpoint() {
243
244
  this.ensureAvailable();
244
- return { endpoint: this.host.endpoint, engineVersion: this.host.engineVersion };
245
+ return {
246
+ endpoint: this.host.endpoint,
247
+ engineVersion: this.host.engineVersion,
248
+ ...(this.activeTargetId ? { pageTargetId: this.activeTargetId } : {}),
249
+ };
250
+ }
251
+ async getPageCdpEndpoint(hostPageId) {
252
+ this.ensureAvailable();
253
+ const target = this.requireTarget(hostPageId);
254
+ if (!DEVTOOLS_TARGET_ID_PATTERN.test(target.targetId)) {
255
+ throw new SessionBrowserHostError("unavailable", "Headless Browser Page cannot be exposed for inspection");
256
+ }
257
+ const browserEndpoint = new URL(this.host.endpoint);
258
+ return {
259
+ endpoint: `${browserEndpoint.protocol}//${browserEndpoint.host}/devtools/page/${target.targetId}`,
260
+ engineVersion: this.host.engineVersion,
261
+ };
245
262
  }
246
263
  openSurface(input) {
247
264
  return this.exclusive(async () => {
@@ -11,13 +11,28 @@ import { startRynxDaemonServer } from "./daemon-server.js";
11
11
  import { installStdioEpipeGuard } from "./stdio-epipe-guard.js";
12
12
  installStdioEpipeGuard();
13
13
  async function main() {
14
- const server = await startRynxDaemonServer();
14
+ const followsApp = process.env.RYNX_DAEMON_LIFECYCLE === "follow-app";
15
+ let parentDisconnected = followsApp && !process.connected;
16
+ const recordParentDisconnect = () => {
17
+ parentDisconnected = true;
18
+ };
19
+ if (followsApp)
20
+ process.once("disconnect", recordParentDisconnect);
21
+ let server;
22
+ try {
23
+ server = await startRynxDaemonServer();
24
+ }
25
+ catch (error) {
26
+ process.off("disconnect", recordParentDisconnect);
27
+ throw error;
28
+ }
15
29
  let shutdownPromise;
16
30
  const shutdown = (signal) => {
17
31
  if (shutdownPromise)
18
32
  return shutdownPromise;
19
33
  process.off("SIGINT", onSigint);
20
34
  process.off("SIGTERM", onSigterm);
35
+ process.off("disconnect", onDisconnect);
21
36
  shutdownPromise = (async () => {
22
37
  console.log(JSON.stringify({ level: "info", type: "shutdown", signal }));
23
38
  await server.shutdown();
@@ -34,8 +49,20 @@ async function main() {
34
49
  const onSigterm = () => {
35
50
  void shutdown("SIGTERM");
36
51
  };
52
+ const onDisconnect = () => {
53
+ if (process.env.RYNX_DAEMON_LIFECYCLE === "follow-app") {
54
+ void shutdown("APP_DISCONNECT");
55
+ }
56
+ };
37
57
  process.once("SIGINT", onSigint);
38
58
  process.once("SIGTERM", onSigterm);
59
+ if (followsApp) {
60
+ process.once("disconnect", onDisconnect);
61
+ process.off("disconnect", recordParentDisconnect);
62
+ if (parentDisconnected || !process.connected) {
63
+ void shutdown("APP_DISCONNECT");
64
+ }
65
+ }
39
66
  }
40
67
  main().catch((err) => {
41
68
  console.error(`Fatal error: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
@@ -0,0 +1,11 @@
1
+ import type { DaemonCleanupSessionsInput, DaemonCleanupSessionsResult } from "@rynx-ai/protocol/control";
2
+ import { type CleanupOptions } from "./migrations/cleanup-legacy-sessions.js";
3
+ export interface MaintenanceManagementService {
4
+ cleanupSessions(input: DaemonCleanupSessionsInput): DaemonCleanupSessionsResult;
5
+ }
6
+ export interface MaintenanceManagementServiceOptions {
7
+ codexStorePath?: string;
8
+ cleanup?: (options: CleanupOptions) => DaemonCleanupSessionsResult;
9
+ }
10
+ /** Daemon-owned maintenance mutations kept behind authenticated local RPC. */
11
+ export declare function createMaintenanceManagementService(options: MaintenanceManagementServiceOptions): MaintenanceManagementService;
@@ -0,0 +1,13 @@
1
+ import { cleanupLegacySessions, } from "./migrations/cleanup-legacy-sessions.js";
2
+ /** Daemon-owned maintenance mutations kept behind authenticated local RPC. */
3
+ export function createMaintenanceManagementService(options) {
4
+ const cleanup = options.cleanup ?? cleanupLegacySessions;
5
+ return {
6
+ cleanupSessions: (input) => cleanup({
7
+ ...(options.codexStorePath
8
+ ? { codexStorePath: options.codexStorePath }
9
+ : {}),
10
+ dryRun: input.dryRun ?? false,
11
+ }),
12
+ };
13
+ }
@@ -21,6 +21,36 @@ export interface InstallPluginOptions {
21
21
  expectedId?: string;
22
22
  /** Explicitly allow package/dependency lifecycle build scripts in staging. */
23
23
  allowScripts?: boolean;
24
+ /** Pre-build package digest explicitly approved before any scripts execute. */
25
+ expectedDigest?: string;
26
+ }
27
+ export interface PreparePluginInstallationOptions {
28
+ signal?: AbortSignal;
29
+ /** Dependency injection for offline tests and alternate package fetchers. */
30
+ materializer?: PluginPackageMaterializer;
31
+ /** Require the inspected manifest to retain this id (used by update). */
32
+ expectedId?: string;
33
+ /** Explicitly allow package/dependency lifecycle build scripts in staging. */
34
+ allowScripts?: boolean;
35
+ }
36
+ export interface CommitPreparedPluginInstallationOptions {
37
+ grants: readonly PluginCapability[];
38
+ expectedDigest: string;
39
+ signal?: AbortSignal;
40
+ }
41
+ /**
42
+ * One daemon-owned, already materialized artifact. The handle is process-local
43
+ * and one-shot: commit or discard consumes it.
44
+ */
45
+ export interface PreparedPluginInstallation {
46
+ readonly name: string;
47
+ readonly source: PluginSource;
48
+ readonly version?: string;
49
+ readonly manifest: PluginManifestV1;
50
+ readonly integrity: string;
51
+ readonly existing?: PluginRecord;
52
+ commit(options: CommitPreparedPluginInstallationOptions): Promise<InstallResult>;
53
+ discard(): void;
24
54
  }
25
55
  /**
26
56
  * Install or replace one arbitrary npm/local/git/URL plugin package.
@@ -29,6 +59,11 @@ export interface InstallPluginOptions {
29
59
  * the previously registered installation untouched.
30
60
  */
31
61
  export declare function installPlugin(spec: string, onProgress?: (line: string) => void, opts?: InstallPluginOptions): Promise<InstallResult>;
62
+ /**
63
+ * Materialize, build (only when explicitly allowed), inspect, and hash one
64
+ * plugin into host-owned staging without mutating the registry.
65
+ */
66
+ export declare function preparePluginInstallation(spec: string, onProgress?: (line: string) => void, opts?: PreparePluginInstallationOptions): Promise<PreparedPluginInstallation>;
32
67
  /**
33
68
  * Remove the registry row but retain the immutable package until the next
34
69
  * daemon boot. A currently running supervisor may still be executing it.
@@ -28,6 +28,67 @@ const DEFAULT_STAGING_GC_AGE_MS = 24 * 60 * 60 * 1_000;
28
28
  * the previously registered installation untouched.
29
29
  */
30
30
  export async function installPlugin(spec, onProgress = () => { }, opts = {}) {
31
+ const prepared = await preparePluginInstallation(spec, onProgress, opts);
32
+ try {
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
+ });
82
+ }
83
+ finally {
84
+ prepared.discard();
85
+ }
86
+ }
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 = {}) {
31
92
  if (!spec.trim())
32
93
  throw new Error("plugin spec must not be empty");
33
94
  const progress = safeProgress(onProgress);
@@ -45,6 +106,7 @@ export async function installPlugin(spec, onProgress = () => { }, opts = {}) {
45
106
  const stagedPackage = join(transactionDir, "package");
46
107
  let finalPath;
47
108
  let registryUpdated = false;
109
+ let consumed = false;
48
110
  try {
49
111
  await (opts.materializer ?? materializePluginPackage)({
50
112
  spec: storedSpec,
@@ -53,123 +115,152 @@ export async function installPlugin(spec, onProgress = () => { }, opts = {}) {
53
115
  onProgress: progress,
54
116
  signal: opts.signal,
55
117
  });
56
- let inspected = inspectPluginPackage(stagedPackage, { requireEntries: false });
57
- if (inspected.manifest.requiresBuildScripts && !opts.allowScripts) {
58
- throw new Error(`plugin "${inspected.manifest.id}" requires package build scripts; ` +
59
- "review the package and re-run with --allow-scripts");
60
- }
118
+ let inspected = await inspectPluginPackage(stagedPackage, {
119
+ requireEntries: false,
120
+ signal: opts.signal,
121
+ });
61
122
  if (opts.allowScripts && !inspected.manifest.requiresBuildScripts) {
62
123
  throw new Error(`plugin "${inspected.manifest.id}" does not declare requiresBuildScripts; ` +
63
124
  "refusing unnecessary --allow-scripts approval");
64
125
  }
65
- if (inspected.manifest.requiresBuildScripts) {
66
- const manifestBeforeBuild = JSON.stringify(inspected.manifest);
67
- const versionBeforeBuild = inspected.version;
68
- progress(`running explicitly approved build scripts for plugin "${inspected.manifest.id}"`);
69
- await rebuildPluginPackage(stagedPackage, progress, opts.signal);
70
- const built = inspectPluginPackage(stagedPackage);
71
- if (JSON.stringify(built.manifest) !== manifestBeforeBuild ||
72
- built.version !== versionBeforeBuild) {
73
- throw new Error("plugin build scripts changed static package identity or manifest metadata");
74
- }
75
- inspected = built;
76
- }
77
- else {
78
- inspected = inspectPluginPackage(stagedPackage);
126
+ const requiresBuildScripts = Boolean(inspected.manifest.requiresBuildScripts);
127
+ if (!requiresBuildScripts) {
128
+ inspected = await inspectPluginPackage(stagedPackage, { signal: opts.signal });
79
129
  }
80
130
  const name = inspected.manifest.id;
81
131
  if (opts.expectedId && name !== opts.expectedId) {
82
132
  throw new Error(`plugin update id mismatch: expected "${opts.expectedId}", received "${name}"`);
83
133
  }
84
134
  const existing = getPlugin(name);
85
- if (existing && opts.onConflict && !(await opts.onConflict(existing))) {
86
- return {
87
- name,
88
- source,
89
- ...(inspected.version ? { version: inspected.version } : {}),
90
- skipped: true,
91
- };
92
- }
93
- if (opts.signal?.aborted)
94
- throw new Error("plugin installation aborted");
95
- const requested = new Set(inspected.manifest.requestedCapabilities);
96
- if (!opts.approveCapabilities &&
97
- opts.grants === undefined &&
98
- !existing &&
99
- requested.size > 0) {
100
- throw new Error(`plugin "${name}" requests host capabilities; explicit grants or approval are required`);
101
- }
102
- const grants = opts.approveCapabilities
103
- ? await opts.approveCapabilities(inspected.manifest, existing)
104
- : opts.grants ??
105
- (existing
106
- ? existing.grants.filter((capability) => requested.has(capability))
107
- : []);
108
- for (const grant of grants) {
109
- if (!requested.has(grant)) {
110
- throw new Error(`plugin capability was not requested: ${grant}`);
111
- }
112
- }
113
- db().transaction(() => {
114
- const current = getPlugin(name);
115
- if (!samePluginGeneration(existing, current)) {
116
- throw new Error(`plugin "${name}" changed concurrently; retry the installation`);
117
- }
118
- const leases = activeRuntimeLeases(name, storage.installationsRoot);
119
- if (leases.length > 0) {
120
- throw new Error(`plugin "${name}" has active runtime lease(s): ${leases
121
- .map((lease) => lease.leaseId)
122
- .join(", ")}; stop its managed services or CLI commands before updating`);
135
+ const cleanup = () => {
136
+ if (!registryUpdated && finalPath) {
137
+ removeOwnedInstallation(storage.installationsRoot, basename(dirname(finalPath)), finalPath);
123
138
  }
124
- const idRoot = ensureDirectDirectory(storage.installationsRoot, name, "plugin id directory");
125
- const installationId = randomUUID();
126
- finalPath = join(idRoot, installationId);
127
- const marker = promotionMarkerPath(idRoot, installationId);
128
- writePromotionMarker(marker);
129
139
  try {
130
- renameSync(stagedPackage, finalPath);
140
+ rmSync(transactionDir, { recursive: true, force: true });
131
141
  }
132
- catch (error) {
133
- rmSync(marker, { force: true });
134
- finalPath = undefined;
135
- throw error;
142
+ catch {
143
+ /* orphaned staging is inactive and can be collected later */
136
144
  }
137
- progress(`staged validated plugin "${name}" → ${finalPath}`);
138
- upsertPlugin({
139
- name,
140
- spec: storedSpec,
141
- source,
142
- version: inspected.version,
143
- installPath: finalPath,
144
- manifest: inspected.manifest,
145
- integrity: inspected.integrity,
146
- grants,
147
- enabled: current?.enabled,
148
- });
149
- }).immediate();
150
- registryUpdated = true;
151
- if (existing?.installPath && existing.installPath !== finalPath) {
152
- progress(`retained superseded installation until the daemon switches versions`);
153
- }
154
- progress(`installed plugin "${name}"${inspected.version ? `@${inspected.version}` : ""}`);
145
+ };
146
+ const preparedManifest = cloneManifest(inspected.manifest);
147
+ const preparedExisting = existing ? clonePluginRecord(existing) : undefined;
155
148
  return {
156
149
  name,
157
150
  source,
158
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
+ },
159
256
  };
160
257
  }
161
- finally {
162
- // Once registered, finalPath is the active immutable installation and must
163
- // outlive this transaction. Everything else under staging is disposable.
258
+ catch (error) {
164
259
  if (!registryUpdated && finalPath) {
165
260
  removeOwnedInstallation(storage.installationsRoot, basename(dirname(finalPath)), finalPath);
166
261
  }
167
- try {
168
- rmSync(transactionDir, { recursive: true, force: true });
169
- }
170
- catch {
171
- /* orphaned staging is inactive and can be collected later */
172
- }
262
+ rmSync(transactionDir, { recursive: true, force: true });
263
+ throw error;
173
264
  }
174
265
  }
175
266
  /**
@@ -312,6 +403,16 @@ function samePluginGeneration(before, current) {
312
403
  return before === current;
313
404
  return JSON.stringify(before) === JSON.stringify(current);
314
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
+ }
315
416
  function writeStagingOwner(transactionDir) {
316
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" });
317
418
  }