@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.
- package/dist/app-browser-host-supervisor.d.ts +97 -0
- package/dist/app-browser-host-supervisor.js +529 -0
- package/dist/browser-artifact-management.d.ts +20 -0
- package/dist/browser-artifact-management.js +61 -0
- package/dist/chrome-for-testing-store.js +1 -1
- package/dist/chrome-inspection-gateway.d.ts +58 -0
- package/dist/chrome-inspection-gateway.js +806 -0
- package/dist/chrome-inspection-manager.d.ts +94 -0
- package/dist/chrome-inspection-manager.js +573 -0
- package/dist/cli.js +13 -2
- package/dist/control-client.d.ts +9 -0
- package/dist/control-client.js +63 -0
- package/dist/daemon-build-status.d.ts +8 -0
- package/dist/daemon-build-status.js +18 -0
- package/dist/daemon-server.d.ts +19 -2
- package/dist/daemon-server.js +615 -59
- package/dist/db.js +56 -0
- package/dist/desktop-browser-host-client.d.ts +2 -1
- package/dist/desktop-browser-host-client.js +3 -1
- package/dist/direct-runtime-authenticator.js +11 -6
- package/dist/headless-browser-host.js +18 -1
- package/dist/index-daemon.js +28 -1
- package/dist/maintenance-management.d.ts +11 -0
- package/dist/maintenance-management.js +13 -0
- package/dist/plugin-installer.d.ts +35 -0
- package/dist/plugin-installer.js +195 -94
- package/dist/plugin-management-service.d.ts +58 -0
- package/dist/plugin-management-service.js +240 -0
- package/dist/plugin-package.d.ts +26 -3
- package/dist/plugin-package.js +235 -56
- package/dist/pm2.js +37 -5
- package/dist/remote-runtime-access-store.d.ts +1 -0
- package/dist/remote-runtime-access-store.js +7 -0
- package/dist/remote-runtime-admin.d.ts +3 -0
- package/dist/remote-runtime-admin.js +3 -0
- package/dist/remote-runtime-connection-manager.d.ts +12 -1
- package/dist/remote-runtime-connection-manager.js +170 -4
- package/dist/remote-runtime-target-control.d.ts +5 -1
- package/dist/remote-runtime-target-control.js +62 -7
- package/dist/remote-runtime-target-store.d.ts +4 -1
- package/dist/remote-runtime-target-store.js +21 -2
- package/dist/session-log-store.js +29 -0
- package/dist/session-meta-store.js +3 -1
- package/dist/session-pending-message-store.d.ts +2 -0
- package/dist/session-pending-message-store.js +41 -0
- package/dist/session-resource-store.d.ts +32 -0
- package/dist/session-resource-store.js +700 -0
- package/dist/setup.d.ts +16 -0
- package/dist/setup.js +136 -2
- 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
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 {
|
|
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 () => {
|
package/dist/index-daemon.js
CHANGED
|
@@ -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
|
|
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.
|
package/dist/plugin-installer.js
CHANGED
|
@@ -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, {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
140
|
+
rmSync(transactionDir, { recursive: true, force: true });
|
|
131
141
|
}
|
|
132
|
-
catch
|
|
133
|
-
|
|
134
|
-
finalPath = undefined;
|
|
135
|
-
throw error;
|
|
142
|
+
catch {
|
|
143
|
+
/* orphaned staging is inactive and can be collected later */
|
|
136
144
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
}
|