"use strict"; const assert = require("node:assert/strict"); const fs = require("node:fs"); const fsp = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); const test = require("node:test"); const { createPluginHostService } = require("./hostService.cjs"); function createOptions(root, configureRpcRegistry) { return { app: { getAppPath: () => process.cwd(), getPath: () => root, getVersion: () => "0.0.0", }, electron: {}, configureRpcRegistry, }; } function transportContext() { return { signal: new AbortController().signal, logger: { write: async () => {} }, }; } test("host RPC registry configuration is complete before runtime initialization", (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const service = createPluginHostService(createOptions(root, (registry) => { registry.registerRequest("custom.test", () => null); })); context.after(() => service.database.close()); const routes = service.rpcRegistry.createRoutes({ pluginId: "com.example.service", pluginVersion: "1.0.0", runtimeId: "runtime-1", runtimeKind: "browser", }); assert.equal(typeof routes.requestHandlers["custom.test"], "function"); assert.equal(typeof service.terminalProviderService.provide, "function"); }); test("async host RPC registry configuration fails before a service can start", (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); assert.throws( () => createPluginHostService(createOptions(root, async () => {})), /must be synchronous/, ); }); test("host service forwards the transport quota guard to the runtime supervisor", (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); let guarded; const runtimeMessageGuard = (identity, message) => { guarded = { identity, message }; }; const service = createPluginHostService({ ...createOptions(root), runtimeMessageGuard, }); context.after(() => service.database.close()); const identity = { runtimeId: "runtime-1" }; const message = { jsonrpc: "2.0" }; service.runtimeSupervisor.runtimeMessageGuard(identity, message); assert.deepEqual(guarded, { identity, message }); }); test("host service seeds the latest contribution environment before runtime activation", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const service = createPluginHostService(createOptions(root)); context.after(() => service.manager.shutdown()); const environment = { locale: "zh-CN", theme: "dark", reducedMotion: true, highContrast: false }; await service.contributionService.setEnvironment(environment); assert.deepEqual(service.runtimeSupervisor.getInitialEnvironment(), environment); }); test("runtime cleanup revokes leases before awaiting companion teardown", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const service = createPluginHostService(createOptions(root)); context.after(() => service.manager.shutdown()); const events = []; let releaseCompanions; service.leaseStore.revokeRuntime = (runtimeId) => { events.push(`revoke:${runtimeId}`); }; service.companionSupervisor.releaseRuntime = async (runtimeId) => { events.push(`release:${runtimeId}`); await new Promise((resolve) => { releaseCompanions = resolve; }); }; let settled = false; const cleanup = service.runtimeSupervisor.runtimeCleanup({ runtimeId: "runtime-1" }) .finally(() => { settled = true; }); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(events, ["revoke:runtime-1", "release:runtime-1"]); assert.equal(settled, false); releaseCompanions(); await cleanup; assert.equal(settled, true); }); test("runtime trust placement precedes required and advanced permission prompts", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const events = []; const service = createPluginHostService({ ...createOptions(root), resolveRuntimeKind: async () => { events.push("placement"); return "utility"; }, requestPermissionDecision: async (request) => { events.push(`permission:${request.permission}`); return { requestId: request.requestId, decision: "allow", scope: "application" }; }, }); context.after(() => service.manager.shutdown()); const manifest = { manifestVersion: 1, id: "com.example.advanced", name: "advanced", version: "1.0.0", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { node: "index.js" }, permissions: { required: ["runtime.advanced", "storage"] }, }; assert.equal(await service.runtimeSupervisor.resolveRuntimeKind({ plugin: { id: manifest.id, activeVersion: manifest.version, manifest }, availableKinds: ["utility"], securityPrincipal: "verified:test-publisher-key", signal: new AbortController().signal, }), "utility"); assert.deepEqual(events, [ "placement", "permission:storage", "permission:runtime.advanced", ]); }); test("first-party placement selects the utility runtime for companion manifests", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const requested = []; const service = createPluginHostService({ ...createOptions(root), requestPermissionDecision: async (request) => { requested.push(request.permission); return { requestId: request.requestId, decision: "allow", scope: "application" }; }, }); context.after(() => service.manager.shutdown()); const manifest = { manifestVersion: 1, id: "com.example.companion-placement", name: "companion-placement", version: "1.0.0", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "browser.js", node: "node.js" }, permissions: { required: [ "runtime.advanced", { permission: "companion.execute", resources: ["com.example.companion-placement.helper"], }, ], }, companionExecutables: [{ id: "com.example.companion-placement.helper", variants: [{ path: "bin/helper", platforms: [`${process.platform}-${process.arch}`], sha256: "0".repeat(64), }], }], }; assert.equal(await service.runtimeSupervisor.resolveRuntimeKind({ plugin: { id: manifest.id, activeVersion: manifest.version, manifest }, availableKinds: ["browser", "utility"], securityPrincipal: "unsigned-package:companion-placement", signal: new AbortController().signal, }), "utility"); assert.deepEqual(requested, ["companion.execute", "runtime.advanced"]); }); test("first-party placement selects utility for dual-entrypoint terminal interceptors", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const requested = []; const service = createPluginHostService({ ...createOptions(root), requestPermissionDecision: async (request) => { requested.push(request); return { requestId: request.requestId, decision: "allow", scope: "application" }; }, }); context.after(() => service.manager.shutdown()); const manifest = { manifestVersion: 1, id: "com.example.interceptor-placement", name: "interceptor-placement", version: "1.0.0", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "browser.js", node: "node.js" }, permissions: { required: ["runtime.advanced", "provider.terminal", "terminal.intercept.input"], }, contributes: { providers: [{ id: "com.example.interceptor-placement.input", label: "Input filter", kind: "terminal.interceptor.input", }], }, }; assert.equal(await service.runtimeSupervisor.resolveRuntimeKind({ plugin: { id: manifest.id, activeVersion: manifest.version, manifest }, availableKinds: ["browser", "utility"], securityPrincipal: "unsigned-package:interceptor-placement", signal: new AbortController().signal, }), "utility"); assert.deepEqual(requested.map((request) => request.permission), [ "provider.terminal", "terminal.intercept.input", "runtime.advanced", ]); assert.match( requested.find((request) => request.permission === "terminal.intercept.input")?.reason ?? "", /arbitrary no-echo input may not be detectable/u, ); }); test("secure host methods fail closed without an approver while public logging remains available", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const service = createPluginHostService(createOptions(root)); context.after(() => service.manager.shutdown()); const routes = service.rpcRegistry.createRoutes({ pluginId: "com.example.service", pluginVersion: "1.0.0", runtimeId: "runtime-1", runtimeKind: "browser", manifest: { id: "com.example.service", name: "service", publisher: "example", permissions: { required: ["storage"] }, }, }); await assert.rejects( routes.requestHandlers["storage.set"]({ key: "answer", value: 42 }, transportContext()), /not granted/, ); await routes.notificationHandlers["log.write"]({ level: "info", message: "hello" }, transportContext()); assert.equal(service.database.getValue("com.example.service", "answer"), undefined); }); test("approved host capabilities reuse application grants through the registry", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); let prompts = 0; const service = createPluginHostService({ ...createOptions(root), requestPermissionDecision: async (request) => { prompts += 1; return { requestId: request.requestId, decision: "allow", scope: "application" }; }, }); context.after(() => service.manager.shutdown()); const pluginManifest = { manifestVersion: 1, id: "com.example.service", name: "service", version: "1.0.0", publisher: "example", engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" }, permissions: { required: ["storage"] }, }; service.database.installVersion({ pluginId: pluginManifest.id, version: pluginManifest.version, manifest: pluginManifest, archiveSha256: "a".repeat(64), packageRelativePath: "com.example.service/1.0.0/package", }); const routes = service.rpcRegistry.createRoutes({ pluginId: "com.example.service", pluginVersion: "1.0.0", runtimeId: "runtime-1", runtimeKind: "browser", manifest: pluginManifest, }); await routes.requestHandlers["storage.set"]({ key: "answer", value: 42 }, transportContext()); assert.deepEqual( await routes.requestHandlers["storage.get"]({ key: "answer" }, transportContext()), { found: true, value: 42 }, ); assert.equal(prompts, 1); }); test("host service wires a handle-bound directory adapter without changing the RPC seam", async (context) => { const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-"))); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const directoryPath = path.join(root, "enumerate"); await fsp.mkdir(directoryPath); await fsp.writeFile(path.join(directoryPath, "entry.txt"), "entry"); const service = createPluginHostService({ ...createOptions(root), requestPermissionDecision: async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once", }), openPluginDirectoryHandle: async (directoryPath) => { const stats = await fsp.stat(directoryPath); const directory = await fsp.opendir(directoryPath); return { stat: async () => stats, read: () => directory.read(), close: async () => { try { await directory.close(); } catch (error) { if (error?.code !== "ERR_DIR_CLOSED") throw error; } }, }; }, }); context.after(() => service.manager.shutdown()); const pluginManifest = { id: "com.example.directory", name: "directory", version: "1.0.0", publisher: "example", permissions: { optional: [{ permission: "filesystem.read", resources: [directoryPath] }] }, }; const routes = service.rpcRegistry.createRoutes({ pluginId: pluginManifest.id, pluginVersion: pluginManifest.version, runtimeId: "runtime-1", runtimeKind: "browser", manifest: pluginManifest, }); assert.deepEqual( await routes.requestHandlers["filesystem.readDirectory"]( { path: directoryPath }, transportContext(), ), { entries: [{ name: "entry.txt", kind: "file" }] }, ); }); test("custom host methods without an explicit authorization classification are denied", async (context) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-host-service-")); context.after(() => fs.rmSync(root, { recursive: true, force: true })); const service = createPluginHostService(createOptions(root, (registry) => { registry.registerRequest("custom.unclassified", () => ({ value: true })); })); context.after(() => service.manager.shutdown()); const routes = service.rpcRegistry.createRoutes({ pluginId: "com.example.service", pluginVersion: "1.0.0", runtimeId: "runtime-1", runtimeKind: "browser", manifest: { permissions: { optional: ["settings.read"] } }, }); await assert.rejects( routes.requestHandlers["custom.unclassified"]({}, transportContext()), /no authorization policy/, ); });