From 71803a267d7e9971db5b3264252048947f8cca5c Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:49:49 +0800 Subject: [PATCH] feat(runtime): add plugin platform foundation Generated-by: Codex --- .../src/__tests__/plugin-platform.test.ts | 649 ++++++++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/operations.ts | 3 + .../src/protocol/plugin-platform.ts | 616 +++++++++++ .../src/server/execution-composition.ts | 19 + .../src/server/extension-bundle.ts | 264 +++++ .../src/server/extension-package-manifest.ts | 306 ++++++ packages/runtime-host/src/server/index.ts | 38 + .../src/server/operation-dispatcher.ts | 5 + .../src/server/plugin-composition-store.ts | 329 ++++++ .../src/server/plugin-package-loader.ts | 149 +++ .../src/server/plugin-package-store.ts | 404 ++++++++ .../src/server/plugin-platform-coordinator.ts | 207 ++++ .../src/server/plugin-platform.ts | 966 ++++++++++++++++++ .../plugin-composition-loader.test.ts | 205 +++- .../runtime/src/plugin-composition-loader.ts | 275 ++++- packages/runtime/src/plugin-runtime.ts | 280 +++++ 17 files changed, 4711 insertions(+), 8 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/plugin-platform.test.ts create mode 100644 packages/runtime-host/src/protocol/plugin-platform.ts create mode 100644 packages/runtime-host/src/server/extension-bundle.ts create mode 100644 packages/runtime-host/src/server/extension-package-manifest.ts create mode 100644 packages/runtime-host/src/server/plugin-composition-store.ts create mode 100644 packages/runtime-host/src/server/plugin-package-loader.ts create mode 100644 packages/runtime-host/src/server/plugin-package-store.ts create mode 100644 packages/runtime-host/src/server/plugin-platform-coordinator.ts create mode 100644 packages/runtime-host/src/server/plugin-platform.ts diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts new file mode 100644 index 0000000000..f5d72fb32f --- /dev/null +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -0,0 +1,649 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { decodeRequestFrame, decodeResponseFrame } from '../protocol/index.js'; +import { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, +} from '../server/plugin-composition-store.js'; +import { HostPluginPlatformCoordinator } from '../server/plugin-platform-coordinator.js'; +import { PluginPackageStore } from '../server/plugin-package-store.js'; +import { HostPluginPlatform } from '../server/plugin-platform.js'; + +test('Plugin Platform installs, activates, persists, and recovers a generic package', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-platform-')); + try { + const source = await writeFixturePackage(root, 'fixture-package', 'first'); + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + + assert.deepEqual(await platform.installPackage(source), { extensionId: 'fixture-package' }); + await platform.apply({ + baseGeneration: 0, + operations: [ + { + type: 'insert', + rootId: 'profile', + entry: { id: 'fixture-entry', packageId: 'fixture-package' }, + }, + ], + }); + const published = platform.runtimeSnapshot('profile'); + assert.equal(published.entries[0]?.entryId, 'fixture-entry'); + assert.deepEqual(published.entries[0]?.contributions, [ + { id: 'first', kind: 'foundation-test' }, + ]); + const bundle = join(root, 'fixture-package.maka-extension'); + await platform.packages.export('fixture-package', bundle); + const imported = new HostPluginPlatform(join(root, 'import-control')); + await imported.recover(); + assert.deepEqual(await imported.installPackage(bundle), { extensionId: 'fixture-package' }); + await imported.close(); + await platform.close(); + + const recovered = new HostPluginPlatform(join(root, 'control')); + await recovered.recover(); + assert.equal(recovered.inspect('profile')[0]?.status, 'active'); + assert.equal(recovered.desiredSnapshot().generation, 1); + assert.equal(recovered.runtimeSnapshot('profile').digest, published.digest); + await recovered.close(); + + const generationRoot = join(root, 'control', 'plugin-generations-v1'); + assert.deepEqual(await readdir(generationRoot).catch(() => []), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin Platform coordinator keeps package and composition operations generic', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-protocol-')); + try { + const source = await writeFixturePackage(root, 'protocol-package', 'generic'); + const platform = new HostPluginPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + + const installed = await coordinator.handlers['plugin.package.install']( + { sourcePath: source }, + null as never, + ); + assert.deepEqual(installed, { + ok: true, + result: { extensionId: 'protocol-package' }, + }); + const applied = await coordinator.handlers['plugin.composition.apply']( + { + operations: [ + { + type: 'insert', + entry: { id: 'protocol-entry', packageId: 'protocol-package' }, + }, + ], + }, + null as never, + ); + assert.equal(applied.ok, true); + const queried = await coordinator.handlers['plugin.platform.query']( + { rootId: 'profile' }, + null as never, + ); + assert.equal(queried.ok, true); + if (queried.ok) { + assert.deepEqual( + queried.result.packages.map(({ extensionId }) => extensionId), + ['protocol-package'], + ); + assert.equal(queried.result.runtime?.entries[0]?.entryId, 'protocol-entry'); + } + assert.deepEqual( + await coordinator.handlers['plugin.package.reload']( + { extensionId: 'protocol-package' }, + null as never, + ), + { ok: true, result: {} }, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('failed package replacement restores both stored bytes and live Runtime package', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-rollback-')); + try { + const source = await writeFixturePackage(root, 'rollback-package', 'stable'); + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage(source); + await platform.apply({ + operations: [ + { + type: 'insert', + entry: { id: 'rollback-entry', packageId: 'rollback-package' }, + }, + ], + }); + const before = platform.runtimeSnapshot('profile'); + const invalid = await writeFixturePackage(root, 'rollback-package', 'replacement', { + runtimePackageId: 'wrong-package', + directorySuffix: 'invalid', + }); + + await assert.rejects(() => platform.installPackage(invalid), /does not match manifest/u); + assert.equal(platform.runtimeSnapshot('profile').digest, before.digest); + assert.equal( + (await platform.packages.load('rollback-package')).manifest.id, + 'rollback-package', + ); + await platform.close(); + + const recovered = new HostPluginPlatform(join(root, 'control')); + await recovered.recover(); + assert.equal(recovered.runtimeSnapshot('profile').digest, before.digest); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin Platform protocol rejects open and malformed generic composition shapes', () => { + assert.equal( + decodeRequestFrame({ + requestId: 'plugin-reload', + operation: 'plugin.package.reload', + input: { extensionId: 'fixture-package' }, + }).operation, + 'plugin.package.reload', + ); + assert.deepEqual( + decodeRequestFrame({ + requestId: 'plugin-request', + operation: 'plugin.composition.apply', + input: { + baseGeneration: 4, + operations: [ + { + type: 'insert', + rootId: 'session:one', + entry: { + id: 'fixture-entry', + packageId: 'fixture-package', + config: { enabled: true }, + intercept: { policy: { nested: true } }, + }, + }, + ], + }, + }).operation, + 'plugin.composition.apply', + ); + assert.throws(() => + decodeRequestFrame({ + requestId: 'plugin-request', + operation: 'plugin.package.install', + input: { sourcePath: '/tmp/package', unexpected: true }, + }), + ); + assert.throws(() => + decodeResponseFrame({ + requestId: 'plugin-request', + operation: 'plugin.platform.query', + ok: true, + result: { + packages: [], + desired: { + schemaVersion: 1, + generation: 0, + roots: { profile: [], desktopUi: [], sessions: {} }, + }, + inspections: [], + failures: [], + runtime: { + schemaVersion: 1, + rootId: 'profile', + digest: 'not-a-digest', + entries: [], + }, + }, + }), + ); +}); + +test('failed desired-state persistence leaves Runtime composition unchanged', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-persistence-')); + try { + const control = join(root, 'control'); + const store = new FailingCompositionStore(control); + const source = await writeFixturePackage(root, 'persistent-package', 'stable'); + const platform = new HostPluginPlatform(control, { store }); + await platform.recover(); + await platform.installPackage(source); + await platform.apply({ + operations: [ + { + type: 'insert', + entry: { id: 'persistent-entry', packageId: 'persistent-package' }, + }, + ], + }); + const before = platform.composition.snapshot(); + store.fail = true; + + await assert.rejects( + () => + platform.apply({ + baseGeneration: before.generation, + operations: [{ type: 'update', entryId: 'persistent-entry', patch: { disabled: true } }], + }), + /Runtime state was not changed/u, + ); + assert.deepEqual(platform.composition.snapshot(), before); + assert.equal(platform.inspect('profile')[0]?.status, 'active'); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('recovery loads installed packages that do not yet have an Entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-unused-package-')); + try { + const control = join(root, 'control'); + const source = await writeFixturePackage(root, 'unused-package', 'available'); + const initial = new HostPluginPlatform(control); + await initial.recover(); + await initial.installPackage(source); + await initial.close(); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + await recovered.apply({ + operations: [{ type: 'insert', entry: { id: 'later-entry', packageId: 'unused-package' } }], + }); + assert.equal(recovered.inspect('profile')[0]?.status, 'active'); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('immutable package generation is owned by package lifetime across repeated Entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-generation-owner-')); + try { + const control = join(root, 'control'); + const platform = new HostPluginPlatform(control); + await platform.recover(); + await platform.installPackage(await writeFixturePackage(root, 'shared-package', 'shared')); + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'shared-one', packageId: 'shared-package' } }, + { type: 'insert', entry: { id: 'shared-two', packageId: 'shared-package' } }, + ], + }); + const generations = join(control, 'plugin-generations-v1'); + assert.equal((await readdir(generations)).length, 1); + + await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-one' }] }); + assert.equal((await readdir(generations)).length, 1); + assert.equal(platform.composition.inspect('shared-two').status, 'active'); + + await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-two' }] }); + await platform.uninstallPackage('shared-package'); + assert.deepEqual(await readdir(generations).catch(() => []), []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('unknown desired-state commit outcome fences mutation without inventing a rollback', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-unknown-commit-')); + try { + const control = join(root, 'control'); + const store = new UnknownCommitCompositionStore(control); + const platform = new HostPluginPlatform(control, { store }); + await platform.recover(); + store.fail = true; + + await assert.rejects( + () => platform.apply({ operations: [{ type: 'insert', entry: { id: 'uncertain-entry' } }] }), + /commit outcome is unknown/u, + ); + assert.deepEqual(platform.composition.snapshot().roots.profile, []); + await assert.rejects( + () => platform.apply({ operations: [{ type: 'remove', entryId: 'uncertain-entry' }] }), + /fenced/u, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('desired state commits before Runtime convergence and exposes divergence', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-divergence-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'failing-package', 'failing', { throwOnApply: true }), + ); + + await assert.rejects( + () => + platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'desired-failure', packageId: 'failing-package' } }, + ], + }), + /desired Plugin composition was committed/iu, + ); + assert.equal(platform.desiredSnapshot().roots.profile[0]?.id, 'desired-failure'); + assert.deepEqual(platform.composition.snapshot().roots.profile, []); + const queried = await coordinator.handlers['plugin.platform.query']({}, null as never); + assert.equal(queried.ok, true); + if (queried.ok) { + assert.equal(queried.result.desired.roots.profile[0]?.id, 'desired-failure'); + assert.deepEqual(queried.result.inspections, []); + assert.equal(queried.result.failures[0]?.entryId, 'desired-failure'); + } + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('recovery is fail-open for Host and isolates a broken desired Entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-partial-recovery-')); + try { + const control = join(root, 'control'); + const initial = new HostPluginPlatform(control); + await initial.recover(); + await initial.installPackage(await writeFixturePackage(root, 'healthy-package', 'healthy')); + await initial.close(); + await new HostPluginCompositionStore(control).replace({ + schemaVersion: 1, + generation: 5, + roots: { + profile: [ + { id: 'healthy-entry', packageId: 'healthy-package', disabled: false, config: {} }, + { id: 'broken-entry', packageId: 'missing-package', disabled: false, config: {} }, + ], + desktopUi: [], + sessions: {}, + }, + }); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal(recovered.inspect('profile')[0]?.id, 'healthy-entry'); + assert.equal(recovered.desiredSnapshot().generation, 5); + assert.deepEqual( + recovered.desiredSnapshot().roots.profile.map(({ id }) => id), + ['healthy-entry', 'broken-entry'], + ); + assert.equal( + recovered.failures().some(({ entryId }) => entryId === 'broken-entry'), + true, + ); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('corrupt Plugin authority fails closed locally without failing Host recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-corrupt-authority-')); + try { + const control = join(root, 'control'); + await mkdir(control, { recursive: true }); + await writeFile(join(control, 'plugin-composition-v2.json'), '{not-json'); + const platform = new HostPluginPlatform(control); + const coordinator = new HostPluginPlatformCoordinator(platform); + + await platform.recover(); + const queried = await coordinator.handlers['plugin.platform.query']({}, null as never); + assert.equal(queried.ok, false); + if (!queried.ok) assert.equal(queried.error.code, 'persistence_failed'); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a package that fails Runtime loading can still be uninstalled for repair', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-corrupt-package-removal-')); + try { + const control = join(root, 'control'); + const source = await writeFixturePackage(root, 'broken-package', 'broken', { + runtimePackageId: 'wrong-package', + }); + await new PluginPackageStore(control).install(source); + const platform = new HostPluginPlatform(control); + await platform.recover(); + assert.equal( + platform.failures().some(({ extensionId }) => extensionId === 'broken-package'), + true, + ); + + await platform.uninstallPackage('broken-package'); + assert.deepEqual(await platform.packages.identities(), []); + assert.equal( + platform.failures().some(({ extensionId }) => extensionId === 'broken-package'), + false, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Manifest configuration is enforced before desired state is committed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-contract-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'configured-package', 'configured', { + manifest: { + configuration: { + properties: { enabled: { type: 'boolean' } }, + required: ['enabled'], + }, + }, + }), + ); + + await assert.rejects( + () => + platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'configured-entry', packageId: 'configured-package' } }, + ], + }), + (error: unknown) => + error instanceof Error && + error.cause instanceof Error && + /missing required key/u.test(error.cause.message), + ); + assert.deepEqual(platform.desiredSnapshot().roots.profile, []); + assert.deepEqual(platform.composition.snapshot().roots.profile, []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Manifest configuration defaults are committed to desired and live Entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-defaults-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'defaulted-package', 'defaulted', { + manifest: { + configuration: { + properties: { enabled: { type: 'boolean', default: true } }, + }, + }, + }), + ); + + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'defaulted-entry', packageId: 'defaulted-package' } }, + ], + }); + assert.deepEqual(platform.desiredSnapshot().roots.profile[0]?.config, { enabled: true }); + assert.deepEqual(platform.composition.snapshot().roots.profile[0]?.config, { enabled: true }); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Manifest dependencies gate activation and protect required packages', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-dependencies-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'dependent-package', 'dependent', { + manifest: { dependencies: [{ id: 'required-package' }] }, + }), + ); + await assert.rejects( + () => + platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'dependent-entry', packageId: 'dependent-package' } }, + ], + }), + /Plugin composition mutation failed/u, + ); + assert.deepEqual(platform.desiredSnapshot().roots.profile, []); + + await platform.installPackage(await writeFixturePackage(root, 'required-package', 'required')); + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'required-entry', packageId: 'required-package' } }, + { type: 'insert', entry: { id: 'dependent-entry', packageId: 'dependent-package' } }, + ], + }); + await assert.rejects( + () => + platform.apply({ + operations: [{ type: 'remove', entryId: 'required-entry' }], + }), + /Plugin composition mutation failed/u, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('package storage repairs an owner-death previous generation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-recovery-')); + try { + const control = join(root, 'control'); + const platform = new HostPluginPlatform(control); + await platform.recover(); + await platform.installPackage(await writeFixturePackage(root, 'recover-package', 'recover')); + await platform.close(); + const packages = join(control, 'plugin-packages-v2'); + await rename(join(packages, 'recover-package'), join(packages, '.previous-owner-death')); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal((await recovered.packages.load('recover-package')).extensionId, 'recover-package'); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function writeFixturePackage( + root: string, + packageId: string, + contributionId: string, + options: { + readonly runtimePackageId?: string; + readonly directorySuffix?: string; + readonly throwOnApply?: boolean; + readonly manifest?: Readonly>; + } = {}, +): Promise { + const source = join( + root, + `source-${packageId}${options.directorySuffix ? `-${options.directorySuffix}` : ''}`, + ); + await mkdir(source, { recursive: true }); + await writeFile( + join(source, 'maka.extension.json'), + JSON.stringify({ + schemaVersion: 1, + id: packageId, + runtime: { entry: 'index.mjs' }, + ...(options.manifest ?? {}), + }), + ); + await writeFile( + join(source, 'index.mjs'), + `export default Object.freeze({ + packageId: ${JSON.stringify(options.runtimePackageId ?? packageId)}, + contributions: Object.freeze([{ id: ${JSON.stringify(contributionId)}, kind: 'foundation-test' }]), + host: Object.freeze({ apply(ctx) { + ${options.throwOnApply ? "throw new Error('fixture activation failed');" : ''} + ctx.effect(() => () => undefined, 'fixture'); + } }), + });\n`, + ); + return source; +} + +class FailingCompositionStore extends HostPluginCompositionStore { + fail = false; + + override async replace(snapshot: PersistedPluginComposition): Promise { + if (this.fail) throw new Error('injected persistence failure'); + await super.replace(snapshot); + } +} + +class UnknownCommitCompositionStore extends HostPluginCompositionStore { + fail = false; + + override async replace(snapshot: PersistedPluginComposition): Promise { + if (this.fail) { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit outcome', + ); + } + await super.replace(snapshot); + } +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 19d5dd1f5b..a37f558aee 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 48 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 49 as const; +// 49: Plugin package and Entry composition operations become Host-owned protocol +// surfaces. Older peers cannot safely apply these snapshot semantics. // 48: Session branch creation accepts an explicit Side Conversation intent. // Older peers reject the strict input shape or cannot apply its snapshot semantics. // 47: Project registration can carry an explicit location preference. Epoch-46 diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index fc83a8eba4..8f170eb4cb 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -39,6 +39,7 @@ import { MEMORY_OPERATION_SPECS } from './memory.js'; import { NETWORK_PROXY_OPERATION_SPECS } from './network-proxy.js'; import { OAUTH_OPERATION_SPECS } from './oauth.js'; import { PLAN_OPERATION_SPECS } from './plan.js'; +import { PLUGIN_PLATFORM_OPERATION_SPECS } from './plugin-platform.js'; import { PROJECT_CATALOG_OPERATION_SPECS } from './project-catalog.js'; import { composeOperationSpecMaps, @@ -159,6 +160,7 @@ export * from './memory.js'; export * from './network-proxy.js'; export * from './oauth.js'; export * from './plan.js'; +export * from './plugin-platform.js'; export * from './project-catalog.js'; export * from './runtime-policy.js'; export * from './runtime-resource.js'; @@ -211,6 +213,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( WEB_SEARCH_OPERATION_SPECS, NETWORK_PROXY_OPERATION_SPECS, CONFIGURATION_OPERATION_SPECS, + PLUGIN_PLATFORM_OPERATION_SPECS, ); export type OperationSpecMap = typeof HOST_OPERATION_SPECS; diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts new file mode 100644 index 0000000000..88dffd3cc6 --- /dev/null +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -0,0 +1,616 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + validateCompositionEntry, + validatePluginRootId, + type MakaCompositionApplyInput, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionOperation, + type MakaCompositionSnapshot, + type MakaPluginRootId, + type MakaRuntimeCompositionSnapshot, +} from '@maka/runtime/plugin-runtime'; +import { + requireCount, + requireEncodedByteLimit, + requireExactRecord, + requireId, + requireRecord, + requireShapedRecord, + requireString, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineHostPathOperation, defineOperation } from './operation-spec.js'; + +const QUERY_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'persistence_failed', + 'internal_failure', +] as const; +const MUTATE_ERRORS = [ + ...QUERY_ERRORS, + 'not_found', + 'operation_conflict', + 'commit_outcome_unknown', +] as const; +const MAX_FRAME_BYTES = 512 * 1024; + +export interface PluginPackageProjection { + readonly extensionId: string; + readonly displayName: string; + readonly description?: string; + readonly dependencies: readonly string[]; +} + +export interface PluginPlatformQueryInput { + readonly rootId?: MakaPluginRootId; +} + +export interface PluginPlatformQueryResult { + readonly packages: readonly PluginPackageProjection[]; + readonly desired: MakaCompositionSnapshot; + readonly inspections: readonly MakaCompositionEntryInspection[]; + readonly runtime: MakaRuntimeCompositionSnapshot | null; + readonly failures: readonly PluginPlatformFailureProjection[]; +} + +export interface PluginPlatformFailureProjection { + readonly entryId?: string; + readonly extensionId?: string; + readonly diagnostic: string; +} + +export interface PluginPackageInstallInput { + readonly sourcePath: string; +} + +export interface PluginPackageInstallResult { + readonly extensionId: string; +} + +export interface PluginPackageUninstallInput { + readonly extensionId: string; +} + +export interface PluginPackageExportInput extends PluginPackageUninstallInput { + readonly targetPath: string; +} + +export interface PluginPackageExportResult { + readonly targetPath: string; +} + +export interface PluginCompositionApplyResult { + readonly desired: MakaCompositionSnapshot; + readonly inspections: readonly MakaCompositionEntryInspection[]; +} + +export const PLUGIN_PLATFORM_OPERATION_SPECS = { + 'plugin.platform.query': defineOperation< + PluginPlatformQueryInput, + PluginPlatformQueryResult, + (typeof QUERY_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodePluginPlatformQueryInput, + decodeOutput: decodePluginPlatformQueryResult, + }), + 'plugin.package.install': defineHostPathOperation< + PluginPackageInstallInput, + PluginPackageInstallResult, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: (value) => { + const input = requireExactRecord(value, 'Plugin package install input', ['sourcePath']); + return { sourcePath: requireString(input.sourcePath, 'Plugin package source path', 4096) }; + }, + decodeOutput: decodePluginPackageInstallResult, + }), + 'plugin.package.uninstall': defineOperation< + PluginPackageUninstallInput, + Record, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: decodePluginPackageUninstallInput, + decodeOutput: (value) => { + requireExactRecord(value, 'Plugin package uninstall result', []); + return {}; + }, + }), + 'plugin.package.reload': defineOperation< + PluginPackageUninstallInput, + Record, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: decodePluginPackageUninstallInput, + decodeOutput: (value) => { + requireExactRecord(value, 'Plugin package reload result', []); + return {}; + }, + }), + 'plugin.package.export': defineHostPathOperation< + PluginPackageExportInput, + PluginPackageExportResult, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: (value) => { + const input = requireExactRecord(value, 'Plugin package export input', [ + 'extensionId', + 'targetPath', + ]); + return { + extensionId: requireId(input.extensionId, 'Plugin package identity'), + targetPath: requireString(input.targetPath, 'Plugin package export path', 4096), + }; + }, + decodeOutput: (value) => { + const output = requireExactRecord(value, 'Plugin package export result', ['targetPath']); + return { targetPath: requireString(output.targetPath, 'Plugin package export path', 4096) }; + }, + }), + 'plugin.composition.apply': defineOperation< + MakaCompositionApplyInput, + PluginCompositionApplyResult, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: decodePluginCompositionApplyInput, + decodeOutput: (value) => { + const output = requireExactRecord(value, 'Plugin composition apply result', [ + 'desired', + 'inspections', + ]); + const decoded = { + desired: decodeCompositionSnapshot(output.desired), + inspections: decodeInspections(output.inspections), + }; + requireEncodedByteLimit(decoded, 'Plugin composition apply result', MAX_FRAME_BYTES); + return decoded; + }, + }), +} as const; + +function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInput { + const input = requireShapedRecord(value, 'Plugin Platform query input', [], ['rootId']); + if (input.rootId === undefined) return {}; + const rootId = requireString(input.rootId, 'Plugin root identity', 256); + try { + validatePluginRootId(rootId); + } catch { + throw invalidProtocolFrame('Invalid Plugin root identity'); + } + return { rootId }; +} + +function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryResult { + const output = requireExactRecord(value, 'Plugin Platform query result', [ + 'packages', + 'desired', + 'inspections', + 'runtime', + 'failures', + ]); + if (!Array.isArray(output.packages) || !Array.isArray(output.failures)) { + throw invalidProtocolFrame('Invalid Plugin Platform projection'); + } + const decoded = { + packages: output.packages.map(decodePackageProjection), + desired: decodeCompositionSnapshot(output.desired), + inspections: decodeInspections(output.inspections), + runtime: output.runtime === null ? null : decodeRuntimeSnapshot(output.runtime), + failures: output.failures.map(decodePlatformFailure), + }; + requireEncodedByteLimit(decoded, 'Plugin Platform query result', MAX_FRAME_BYTES); + return decoded; +} + +function decodePlatformFailure(value: unknown): PluginPlatformFailureProjection { + const failure = requireShapedRecord( + value, + 'Plugin Platform failure', + ['diagnostic'], + ['entryId', 'extensionId'], + ); + if (failure.entryId === undefined && failure.extensionId === undefined) { + throw invalidProtocolFrame('Plugin Platform failure has no identity'); + } + return { + ...(failure.entryId === undefined + ? {} + : { entryId: requireId(failure.entryId, 'Plugin Entry identity') }), + ...(failure.extensionId === undefined + ? {} + : { extensionId: requireId(failure.extensionId, 'Plugin package identity') }), + diagnostic: requireString(failure.diagnostic, 'Plugin Platform diagnostic', 4096), + }; +} + +function decodePackageProjection(value: unknown): PluginPackageProjection { + const item = requireShapedRecord( + value, + 'Plugin package projection', + ['extensionId', 'displayName', 'dependencies'], + ['description'], + ); + if (!Array.isArray(item.dependencies)) throw invalidProtocolFrame('Invalid Plugin dependencies'); + return { + extensionId: requireId(item.extensionId, 'Plugin package identity'), + displayName: requireString(item.displayName, 'Plugin display name', 512), + ...(item.description === undefined + ? {} + : { description: requireString(item.description, 'Plugin description', 4096) }), + dependencies: item.dependencies.map((dependency) => + requireId(dependency, 'Plugin dependency identity'), + ), + }; +} + +function decodePluginPackageInstallResult(value: unknown): PluginPackageInstallResult { + const output = requireExactRecord(value, 'Plugin package install result', ['extensionId']); + return { extensionId: requireId(output.extensionId, 'Plugin package identity') }; +} + +function decodePluginPackageUninstallInput(value: unknown): PluginPackageUninstallInput { + const input = requireExactRecord(value, 'Plugin package uninstall input', ['extensionId']); + return { extensionId: requireId(input.extensionId, 'Plugin package identity') }; +} + +function decodePluginCompositionApplyInput(value: unknown): MakaCompositionApplyInput { + const input = requireShapedRecord( + value, + 'Plugin composition apply input', + ['operations'], + ['baseGeneration'], + ); + if (!Array.isArray(input.operations) || input.operations.length === 0) { + throw invalidProtocolFrame('Invalid Plugin composition operations'); + } + const decoded = { + ...(input.baseGeneration === undefined + ? {} + : { baseGeneration: requireCount(input.baseGeneration, 'Plugin composition generation') }), + operations: input.operations.map(decodeCompositionOperation), + }; + requireEncodedByteLimit(decoded, 'Plugin composition apply input', MAX_FRAME_BYTES); + return decoded; +} + +function decodeCompositionOperation(value: unknown): MakaCompositionOperation { + const operation = requireRecord(value, 'Plugin composition operation'); + switch (operation.type) { + case 'insert': { + const input = requireShapedRecord( + operation, + 'Plugin insert operation', + ['type', 'entry'], + ['rootId', 'parentId', 'position'], + ); + return { + type: 'insert', + ...(input.rootId === undefined ? {} : { rootId: decodeRootId(input.rootId) }), + ...(input.parentId === undefined + ? {} + : { parentId: requireId(input.parentId, 'Plugin parent Entry identity') }), + entry: decodeCompositionEntry(input.entry), + ...(input.position === undefined + ? {} + : { position: requireCount(input.position, 'Plugin Entry position') }), + }; + } + case 'update': { + const input = requireExactRecord(operation, 'Plugin update operation', [ + 'type', + 'entryId', + 'patch', + ]); + return { + type: 'update', + entryId: requireId(input.entryId, 'Plugin Entry identity'), + patch: decodeEntryPatch(input.patch), + }; + } + case 'move': { + const input = requireShapedRecord( + operation, + 'Plugin move operation', + ['type', 'entryId'], + ['parentId', 'position'], + ); + return { + type: 'move', + entryId: requireId(input.entryId, 'Plugin Entry identity'), + ...(input.parentId === undefined + ? {} + : { parentId: requireId(input.parentId, 'Plugin parent Entry identity') }), + ...(input.position === undefined + ? {} + : { position: requireCount(input.position, 'Plugin Entry position') }), + }; + } + case 'remove': { + const input = requireExactRecord(operation, 'Plugin remove operation', ['type', 'entryId']); + return { type: 'remove', entryId: requireId(input.entryId, 'Plugin Entry identity') }; + } + default: + throw invalidProtocolFrame('Invalid Plugin composition operation type'); + } +} + +function decodeCompositionEntry(value: unknown): MakaCompositionEntry { + const entry = requireShapedRecord( + value, + 'Plugin composition Entry', + ['id'], + ['packageId', 'config', 'disabled', 'inject', 'isolate', 'intercept', 'children'], + ); + const decoded: MakaCompositionEntry = { + id: requireId(entry.id, 'Plugin Entry identity'), + ...(entry.packageId === undefined + ? {} + : { packageId: requireId(entry.packageId, 'Plugin package identity') }), + ...(entry.config === undefined ? {} : { config: decodeScalarRecord(entry.config, 'config') }), + ...(entry.disabled === undefined ? {} : { disabled: requireBoolean(entry.disabled) }), + ...(entry.inject === undefined ? {} : { inject: decodeInject(entry.inject) }), + ...(entry.isolate === undefined ? {} : { isolate: decodeIsolate(entry.isolate) }), + ...(entry.intercept === undefined + ? {} + : { intercept: decodeJsonRecord(entry.intercept, 'intercept') }), + ...(entry.children === undefined ? {} : { children: decodeEntries(entry.children) }), + }; + try { + validateCompositionEntry(decoded); + } catch { + throw invalidProtocolFrame('Invalid Plugin composition Entry'); + } + return decoded; +} + +function decodeEntryPatch(value: unknown): Partial> { + const patch = requireShapedRecord( + value, + 'Plugin Entry patch', + [], + ['packageId', 'config', 'disabled', 'inject', 'isolate', 'intercept'], + ); + return { + ...(patch.packageId === undefined + ? {} + : { packageId: requireId(patch.packageId, 'Plugin package identity') }), + ...(patch.config === undefined ? {} : { config: decodeScalarRecord(patch.config, 'config') }), + ...(patch.disabled === undefined ? {} : { disabled: requireBoolean(patch.disabled) }), + ...(patch.inject === undefined ? {} : { inject: decodeInject(patch.inject) }), + ...(patch.isolate === undefined ? {} : { isolate: decodeIsolate(patch.isolate) }), + ...(patch.intercept === undefined + ? {} + : { intercept: decodeJsonRecord(patch.intercept, 'intercept') }), + }; +} + +function decodeCompositionSnapshot(value: unknown): MakaCompositionSnapshot { + const snapshot = requireExactRecord(value, 'Plugin composition snapshot', [ + 'schemaVersion', + 'generation', + 'roots', + ]); + if (snapshot.schemaVersion !== 1) throw invalidProtocolFrame('Invalid Plugin snapshot schema'); + const roots = requireExactRecord(snapshot.roots, 'Plugin composition roots', [ + 'profile', + 'desktopUi', + 'sessions', + ]); + const sessions = requireRecord(roots.sessions, 'Plugin session compositions'); + return { + schemaVersion: 1, + generation: requireCount(snapshot.generation, 'Plugin composition generation'), + roots: { + profile: decodeEntries(roots.profile), + desktopUi: decodeEntries(roots.desktopUi), + sessions: Object.fromEntries( + Object.entries(sessions).map(([scopeId, entries]) => { + decodeRootId(`session:${scopeId}`); + return [scopeId, decodeEntries(entries)]; + }), + ), + }, + }; +} + +function decodeInspections(value: unknown): readonly MakaCompositionEntryInspection[] { + if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid Plugin Entry inspections'); + return value.map((item) => { + const inspection = requireShapedRecord( + item, + 'Plugin Entry inspection', + ['id', 'rootId', 'disabled', 'status', 'waitingFor', 'effects', 'children'], + ['parentId', 'packageId', 'config', 'generation', 'diagnostic'], + ); + const statuses = [ + 'disabled', + 'pending', + 'loading', + 'active', + 'failed', + 'unloading', + 'disposed', + ]; + if (!statuses.includes(inspection.status as string)) { + throw invalidProtocolFrame('Invalid Plugin Entry status'); + } + if (!Array.isArray(inspection.waitingFor) || !Array.isArray(inspection.effects)) { + throw invalidProtocolFrame('Invalid Plugin Entry inspection details'); + } + return { + id: requireId(inspection.id, 'Plugin Entry identity'), + rootId: decodeRootId(inspection.rootId), + ...(inspection.parentId === undefined + ? {} + : { parentId: requireId(inspection.parentId, 'Plugin parent Entry identity') }), + ...(inspection.packageId === undefined + ? {} + : { packageId: requireId(inspection.packageId, 'Plugin package identity') }), + ...(inspection.config === undefined + ? {} + : { config: decodeScalarRecord(inspection.config, 'config') }), + disabled: requireBoolean(inspection.disabled), + status: inspection.status as MakaCompositionEntryInspection['status'], + ...(inspection.generation === undefined + ? {} + : { generation: requireCount(inspection.generation, 'Plugin Fiber generation') }), + waitingFor: inspection.waitingFor.map((item) => requireId(item, 'Plugin dependency')), + effects: inspection.effects.map((item) => requireString(item, 'Plugin Effect label', 512)), + children: decodeInspections(inspection.children), + ...(inspection.diagnostic === undefined + ? {} + : { diagnostic: requireString(inspection.diagnostic, 'Plugin diagnostic', 4096) }), + }; + }); +} + +function decodeRuntimeSnapshot(value: unknown): MakaRuntimeCompositionSnapshot { + const snapshot = requireExactRecord(value, 'Plugin Runtime snapshot', [ + 'schemaVersion', + 'rootId', + 'digest', + 'entries', + ]); + if (snapshot.schemaVersion !== 1 || !Array.isArray(snapshot.entries)) { + throw invalidProtocolFrame('Invalid Plugin Runtime snapshot'); + } + const rootId = decodeRootId(snapshot.rootId); + const digest = requireString(snapshot.digest, 'Plugin Runtime digest', 71); + if (!/^sha256:[a-f0-9]{64}$/u.test(digest)) { + throw invalidProtocolFrame('Invalid Plugin Runtime digest'); + } + return { + schemaVersion: 1, + rootId, + digest: digest as `sha256:${string}`, + entries: snapshot.entries.map((item) => { + const entry = requireExactRecord(item, 'Plugin Runtime Entry', [ + 'entryId', + 'packageId', + 'generation', + 'contributions', + ]); + if (!Array.isArray(entry.contributions)) { + throw invalidProtocolFrame('Invalid Plugin Runtime contributions'); + } + return { + entryId: requireId(entry.entryId, 'Plugin Entry identity'), + packageId: requireId(entry.packageId, 'Plugin package identity'), + generation: requireCount(entry.generation, 'Plugin Fiber generation'), + contributions: entry.contributions.map((value) => { + const contribution = requireExactRecord(value, 'Plugin contribution', ['id', 'kind']); + return { + id: requireId(contribution.id, 'Plugin contribution identity'), + kind: requireId(contribution.kind, 'Plugin contribution kind'), + }; + }), + }; + }), + }; +} + +function decodeEntries(value: unknown): readonly MakaCompositionEntry[] { + if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid Plugin Entry list'); + return value.map(decodeCompositionEntry); +} + +function decodeRootId(value: unknown): MakaPluginRootId { + const rootId = requireString(value, 'Plugin root identity', 256); + try { + validatePluginRootId(rootId); + return rootId; + } catch { + throw invalidProtocolFrame('Invalid Plugin root identity'); + } +} + +function decodeInject(value: unknown): readonly string[] | Readonly> { + if (Array.isArray(value)) return value.map((item) => requireId(item, 'Plugin injection')); + return decodeJsonRecord(value, 'inject'); +} + +function decodeIsolate(value: unknown): Readonly> { + const record = requireRecord(value, 'Plugin Entry isolate'); + const output: Record = {}; + for (const [key, item] of Object.entries(record)) { + requireId(key, 'Plugin Entry isolate key'); + if (item !== true && (typeof item !== 'string' || !item)) { + throw invalidProtocolFrame('Invalid Plugin Entry isolate value'); + } + output[key] = item; + } + return output; +} + +function decodeJsonRecord(value: unknown, label: string): Readonly> { + const record = requireRecord(value, `Plugin Entry ${label}`); + requireEncodedByteLimit(record, `Plugin Entry ${label}`, 64 * 1024); + try { + return structuredClone(record); + } catch { + throw invalidProtocolFrame(`Invalid Plugin Entry ${label}`); + } +} + +function decodeScalarRecord( + value: unknown, + label: string, +): Readonly> { + const record = requireRecord(value, `Plugin Entry ${label}`); + const output: Record = {}; + for (const [key, item] of Object.entries(record)) { + requireId(key, `Plugin Entry ${label} key`); + if ( + typeof item === 'string' || + typeof item === 'boolean' || + (typeof item === 'number' && Number.isFinite(item)) + ) + output[key] = item; + else throw invalidProtocolFrame(`Invalid Plugin Entry ${label} value`); + } + return output; +} + +function requireBoolean(value: unknown): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame('Invalid Plugin Entry disabled flag'); + return value; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 639a8beb7e..f6302e2e18 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -144,6 +144,8 @@ import { } from './project-directory-authority.js'; import { HostProjectCatalogCoordinator } from './project-catalog-coordinator.js'; import { HostProjectMembershipGate } from './project-membership-gate.js'; +import { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js'; +import { HostPluginPlatform } from './plugin-platform.js'; import { RootAdmissionOwner } from './root-admission-owner.js'; import { RootTurnCoordinator } from './root-turn-coordinator.js'; import { RuntimePolicyActivationGate } from './runtime-policy-activation-gate.js'; @@ -180,6 +182,7 @@ import { export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; + readonly plugins: HostPluginPlatform; } export interface CreateExecutionRuntimeHostCompositionOptions { @@ -231,7 +234,10 @@ export async function createExecutionRuntimeHostComposition( let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; + let pluginPlatform: HostPluginPlatform | undefined; try { + pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory); + const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; const runtimePolicyStores = storage.runtimePolicy; const oauthCredentials = new HostOAuthExecutionAuthority(runtimePolicyStores); @@ -1341,6 +1347,13 @@ export async function createExecutionRuntimeHostComposition( ); let recoverySessions: Awaited> = []; domainModules = [ + createRuntimeHostDomainModule({ + id: 'plugin-platform', + handlers: [pluginPlatformCoordinator.handlers], + recovery: { state: () => pluginPlatform!.recover() }, + drain: [() => pluginPlatform!.beginDrain()], + close: [() => pluginPlatform!.close()], + }), createRuntimeHostDomainModule({ id: 'memory', handlers: [requireMemory(memory).handlers], @@ -1589,6 +1602,7 @@ export async function createExecutionRuntimeHostComposition( handlers, moduleIds: Object.freeze(domainModules.map(({ id }) => id)), workspaceExecution: requireWorkspaceExecution(workspaceExecution), + plugins: pluginPlatform, continuity: continuityCoordinator, clientCapabilities, hostChanges, @@ -1601,6 +1615,11 @@ export async function createExecutionRuntimeHostComposition( }; } catch (error) { const errors: unknown[] = [error]; + try { + await pluginPlatform?.close(); + } catch (closeError) { + errors.push(closeError); + } goalExecutions?.beginDrain(); try { await workspaceExecution?.close(); diff --git a/packages/runtime-host/src/server/extension-bundle.ts b/packages/runtime-host/src/server/extension-bundle.ts new file mode 100644 index 0000000000..40a665511e --- /dev/null +++ b/packages/runtime-host/src/server/extension-bundle.ts @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { copyFile, mkdir, open, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, join, posix, resolve } from 'node:path'; + +const MAX_FILES = 256; +const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_BUNDLE_BYTES = 16 * 1024 * 1024; + +interface BundleFile { + readonly path: string; + readonly sha256: string; + readonly content: string; +} + +interface ExtensionBundleDocument { + readonly schemaVersion: 1; + readonly digest: string; + readonly files: readonly BundleFile[]; +} + +export class ExtensionBundleError extends Error { + readonly name = 'ExtensionBundleError'; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export async function exportExtensionBundle(sourceRoot: string, targetPath: string): Promise { + if (!isAbsolute(targetPath)) throw invalid('Extension bundle targetPath must be absolute'); + const files = await readDirectory(sourceRoot); + const document: ExtensionBundleDocument = Object.freeze({ + schemaVersion: 1, + digest: packageDigest(files), + files: Object.freeze( + files.map((file) => + Object.freeze({ + path: file.path, + sha256: createHash('sha256').update(file.content).digest('hex'), + content: file.content.toString('base64'), + }), + ), + ), + }); + const encoded = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8'); + if (encoded.byteLength > MAX_BUNDLE_BYTES * 2) + throw invalid('Encoded Extension bundle is too large'); + await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 }); + const temporary = `${targetPath}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + try { + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(encoded); + await handle.sync(); + await handle.close(); + handle = undefined; + await copyFile(temporary, targetPath, constants.COPYFILE_EXCL); + } catch (error) { + throw invalid('Unable to export Extension bundle', error); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +export async function materializeExtensionPackage( + sourcePath: string, + controlDirectory: string, +): Promise<{ readonly root: string; readonly dispose: () => Promise }> { + if (!isAbsolute(sourcePath)) throw invalid('Extension package sourcePath must be absolute'); + const canonical = await realpath(resolve(sourcePath)).catch((error) => { + throw invalid('Extension package source is unavailable', error); + }); + const metadata = await stat(canonical); + if (metadata.isDirectory()) return { root: canonical, dispose: async () => undefined }; + if (!metadata.isFile()) + throw invalid('Extension package source must be a directory or bundle file'); + if (metadata.size > MAX_BUNDLE_BYTES * 2) + throw invalid('Extension bundle exceeds its size limit'); + const handle = await open(canonical, constants.O_RDONLY | constants.O_NOFOLLOW); + let document: ExtensionBundleDocument; + try { + document = decodeBundle(JSON.parse((await handle.readFile()).toString('utf8'))); + } catch (error) { + if (error instanceof ExtensionBundleError) throw error; + throw invalid('Extension bundle is invalid', error); + } finally { + await handle.close(); + } + const imports = join(controlDirectory, 'bundle-imports-v1'); + const root = join(imports, randomUUID()); + await mkdir(root, { recursive: true, mode: 0o700 }); + try { + for (const file of document.files) { + const target = join(root, ...file.path.split('/')); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + const output = await open(target, 'wx', 0o600); + try { + await output.writeFile(Buffer.from(file.content, 'base64')); + } finally { + await output.close(); + } + } + return { root, dispose: () => rm(root, { recursive: true, force: true }) }; + } catch (error) { + await rm(root, { recursive: true, force: true }).catch(() => undefined); + throw invalid('Unable to materialize Extension bundle', error); + } +} + +async function readDirectory( + rootValue: string, +): Promise { + const root = await realpath(rootValue); + if (!(await stat(root)).isDirectory()) + throw invalid('Extension bundle source is not a directory'); + const paths: string[] = []; + await collect(root, '', paths); + if (paths.length === 0 || paths.length > MAX_FILES) + throw invalid('Extension bundle file count is invalid'); + let total = 0; + const files: { path: string; content: Buffer }[] = []; + for (const path of paths.sort()) { + const handle = await open( + join(root, ...path.split('/')), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES) + throw invalid(`Extension bundle file is invalid: ${path}`); + const content = await handle.readFile(); + total += content.byteLength; + if (total > MAX_BUNDLE_BYTES) throw invalid('Extension bundle payload is too large'); + files.push({ path, content }); + } finally { + await handle.close(); + } + } + return files; +} + +async function collect(root: string, directory: string, paths: string[]): Promise { + const entries = await readdir(directory ? join(root, ...directory.split('/')) : root, { + withFileTypes: true, + }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.name === '.git') continue; + const path = directory ? `${directory}/${entry.name}` : entry.name; + safePath(path); + if (entry.isSymbolicLink()) throw invalid(`Extension bundle may not contain symlinks: ${path}`); + if (entry.isDirectory()) await collect(root, path, paths); + else if (entry.isFile()) paths.push(path); + else throw invalid(`Extension bundle contains an unsupported entry: ${path}`); + if (paths.length > MAX_FILES) throw invalid('Extension bundle contains too many files'); + } +} + +function decodeBundle(value: unknown): ExtensionBundleDocument { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid('Extension bundle must be an object'); + const record = value as Record; + if ( + Object.keys(record).sort().join() !== 'digest,files,schemaVersion' || + record.schemaVersion !== 1 || + !Array.isArray(record.files) || + record.files.length === 0 || + record.files.length > MAX_FILES + ) { + throw invalid('Extension bundle fields are invalid'); + } + let total = 0; + const paths = new Set(); + const files = record.files.map((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid('Extension bundle file is invalid'); + const file = value as Record; + if ( + Object.keys(file).sort().join() !== 'content,path,sha256' || + typeof file.path !== 'string' || + typeof file.content !== 'string' || + typeof file.sha256 !== 'string' + ) + throw invalid('Extension bundle file fields are invalid'); + const path = safePath(file.path); + if (paths.has(path)) throw invalid(`Extension bundle repeats file: ${path}`); + paths.add(path); + const content = Buffer.from(file.content, 'base64'); + total += content.byteLength; + if ( + content.byteLength > MAX_FILE_BYTES || + total > MAX_BUNDLE_BYTES || + createHash('sha256').update(content).digest('hex') !== file.sha256 + ) { + throw invalid(`Extension bundle file integrity failed: ${path}`); + } + return { path, content }; + }); + if (typeof record.digest !== 'string' || packageDigest(files) !== record.digest) + throw invalid('Extension bundle digest is invalid'); + return Object.freeze({ + schemaVersion: 1, + digest: record.digest, + files: Object.freeze( + files.map((file) => + Object.freeze({ + path: file.path, + sha256: createHash('sha256').update(file.content).digest('hex'), + content: file.content.toString('base64'), + }), + ), + ), + }); +} + +function packageDigest(files: readonly { path: string; content: Buffer }[]): string { + const hash = createHash('sha256'); + for (const file of files) { + const path = Buffer.from(file.path, 'utf8'); + const length = Buffer.allocUnsafe(8); + length.writeBigUInt64BE(BigInt(path.byteLength)); + hash.update(length).update(path); + length.writeBigUInt64BE(BigInt(file.content.byteLength)); + hash.update(length).update(file.content); + } + return `sha256-${hash.digest('hex')}`; +} + +function safePath(value: string): string { + if ( + !value || + value.length > 512 || + value.includes('\\') || + value.startsWith('/') || + posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid('Extension bundle path is invalid'); + } + return value; +} + +function invalid(message: string, cause?: unknown): ExtensionBundleError { + return new ExtensionBundleError(message, { cause }); +} diff --git a/packages/runtime-host/src/server/extension-package-manifest.ts b/packages/runtime-host/src/server/extension-package-manifest.ts new file mode 100644 index 0000000000..379d66211c --- /dev/null +++ b/packages/runtime-host/src/server/extension-package-manifest.ts @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { isCanonicalExtensionId } from '@maka/runtime/plugin-runtime'; + +export const EXTENSION_PACKAGE_MANIFEST_FILE = 'maka.extension.json'; +const MAX_MANIFEST_BYTES = 256 * 1024; +const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u; + +export type ExtensionConfigurationScalar = string | number | boolean; + +export interface ExtensionPackageDependency { + readonly id: string; +} + +export interface ExtensionConfigurationProperty { + readonly type: 'string' | 'number' | 'boolean'; + readonly title?: string; + readonly description?: string; + readonly default?: ExtensionConfigurationScalar; + readonly enum?: readonly ExtensionConfigurationScalar[]; + readonly secret: boolean; +} + +export interface ExtensionConfigurationSchema { + readonly properties: Readonly>; + readonly required: readonly string[]; +} + +export interface ExtensionPackageManifest { + readonly schemaVersion: 1; + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly dependencies: readonly ExtensionPackageDependency[]; + readonly configuration: ExtensionConfigurationSchema; + readonly runtime?: ExtensionPackageRuntime; +} + +export interface ExtensionPackageRuntime { + readonly entry: string; +} + +export class ExtensionPackageManifestError extends Error { + readonly name = 'ExtensionPackageManifestError'; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export async function loadExtensionPackageManifest( + root: string, +): Promise { + let encoded: Buffer; + try { + encoded = await readFile(join(root, EXTENSION_PACKAGE_MANIFEST_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw invalid('Unable to read unified Extension manifest', error); + } + if (encoded.byteLength > MAX_MANIFEST_BYTES) { + throw invalid('Unified Extension manifest exceeds its size limit'); + } + try { + return decodeExtensionPackageManifest(JSON.parse(encoded.toString('utf8'))); + } catch (error) { + if (error instanceof ExtensionPackageManifestError) throw error; + throw invalid('Unified Extension manifest is invalid JSON', error); + } +} + +export function decodeExtensionPackageManifest(value: unknown): ExtensionPackageManifest { + const source = record(value, 'Extension manifest'); + exactOptional( + source, + ['schemaVersion', 'id'], + ['displayName', 'description', 'dependencies', 'configuration', 'runtime'], + ); + if (source.schemaVersion !== 1) throw invalid('Extension manifest schemaVersion must be 1'); + const id = extensionId(source.id); + const displayName = + source.displayName === undefined ? id : text(source.displayName, 'displayName', 128); + const description = + source.description === undefined ? '' : boundedDescription(source.description); + const dependencies = decodeDependencies(source.dependencies); + const configuration = decodeConfigurationSchema(source.configuration); + const runtime = decodeRuntime(source.runtime); + return Object.freeze({ + schemaVersion: 1, + id, + displayName, + description, + dependencies, + configuration, + ...(runtime === undefined ? {} : { runtime }), + }); +} + +function decodeRuntime(value: unknown): ExtensionPackageRuntime | undefined { + if (value === undefined) return undefined; + const runtime = record(value, 'runtime'); + if (runtime.entry === undefined) return undefined; + if ( + typeof runtime.entry !== 'string' || + runtime.entry.length === 0 || + runtime.entry.length > 512 || + runtime.entry.includes('\\') || + runtime.entry.startsWith('/') || + runtime.entry.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid('Extension manifest runtime.entry is invalid'); + } + return Object.freeze({ entry: runtime.entry }); +} + +export function validateExtensionConfiguration( + schema: ExtensionConfigurationSchema, + value: unknown, +): Readonly> { + const input = value === undefined ? {} : record(value, 'Extension configuration'); + const unknown = Object.keys(input).find((key) => !Object.hasOwn(schema.properties, key)); + if (unknown) throw invalid(`Extension configuration key is not declared: ${unknown}`); + const result: Record = {}; + for (const [key, property] of Object.entries(schema.properties)) { + const configured = input[key] ?? property.default; + if (configured === undefined) { + if (schema.required.includes(key)) { + throw invalid(`Extension configuration is missing required key: ${key}`); + } + continue; + } + if (typeof configured !== property.type || !isScalar(configured)) { + throw invalid(`Extension configuration type is invalid for key: ${key}`); + } + if (property.enum && !property.enum.some((candidate) => candidate === configured)) { + throw invalid(`Extension configuration value is not allowed for key: ${key}`); + } + result[key] = configured; + } + const encoded = JSON.stringify(result); + if (Buffer.byteLength(encoded, 'utf8') > 64 * 1024) { + throw invalid('Extension configuration exceeds its size limit'); + } + return Object.freeze(result); +} + +function decodeDependencies(value: unknown): readonly ExtensionPackageDependency[] { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value) || value.length > 64) + throw invalid('Extension dependencies are invalid'); + const ids = new Set(); + const dependencies = value.map((item, index) => { + const dependency = record(item, `dependencies[${index}]`); + exactOptional(dependency, ['id'], []); + const id = extensionId(dependency.id); + if (ids.has(id)) throw invalid(`Extension dependency repeats: ${id}`); + ids.add(id); + return Object.freeze({ id }); + }); + return Object.freeze(dependencies.sort((left, right) => left.id.localeCompare(right.id))); +} + +function decodeConfigurationSchema(value: unknown): ExtensionConfigurationSchema { + if (value === undefined) + return Object.freeze({ properties: Object.freeze({}), required: Object.freeze([]) }); + const schema = record(value, 'configuration'); + exactOptional(schema, ['properties'], ['required']); + const propertiesSource = record(schema.properties, 'configuration properties'); + if (Object.keys(propertiesSource).length > 128) + throw invalid('Too many Extension configuration properties'); + const properties: Record = {}; + for (const [key, value] of Object.entries(propertiesSource)) { + if (!KEY_PATTERN.test(key)) throw invalid(`Extension configuration key is invalid: ${key}`); + const property = record(value, `configuration.properties.${key}`); + exactOptional(property, ['type'], ['title', 'description', 'default', 'enum', 'secret']); + if (property.type !== 'string' && property.type !== 'number' && property.type !== 'boolean') { + throw invalid(`Extension configuration property type is invalid: ${key}`); + } + const type = property.type; + const defaultValue = property.default; + if (defaultValue !== undefined && (typeof defaultValue !== type || !isScalar(defaultValue))) { + throw invalid(`Extension configuration default is invalid: ${key}`); + } + let values: readonly ExtensionConfigurationScalar[] | undefined; + if (property.enum !== undefined) { + if ( + !Array.isArray(property.enum) || + property.enum.length === 0 || + property.enum.length > 64 || + property.enum.some((item) => typeof item !== type || !isScalar(item)) + ) + throw invalid(`Extension configuration enum is invalid: ${key}`); + values = Object.freeze([...new Set(property.enum as ExtensionConfigurationScalar[])]); + if ( + defaultValue !== undefined && + !values.includes(defaultValue as ExtensionConfigurationScalar) + ) { + throw invalid(`Extension configuration default is outside enum: ${key}`); + } + } + properties[key] = Object.freeze({ + type, + ...(property.title === undefined + ? {} + : { title: text(property.title, 'configuration title', 128) }), + ...(property.description === undefined + ? {} + : { description: text(property.description, 'configuration description', 1024) }), + ...(defaultValue === undefined + ? {} + : { default: defaultValue as ExtensionConfigurationScalar }), + ...(values ? { enum: values } : {}), + secret: property.secret === true, + }); + } + const required = schema.required === undefined ? [] : schema.required; + if ( + !Array.isArray(required) || + required.length > Object.keys(properties).length || + required.some((key) => typeof key !== 'string' || !Object.hasOwn(properties, key)) || + new Set(required).size !== required.length + ) + throw invalid('Extension configuration required keys are invalid'); + return Object.freeze({ + properties: Object.freeze(properties), + required: Object.freeze(required as string[]), + }); +} + +function exactOptional( + value: Record, + required: readonly string[], + optional: readonly string[], +): void { + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some((key) => !allowed.has(key)) + ) { + throw invalid('Extension manifest fields are invalid'); + } +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid(`${label} must be an object`); + return value as Record; +} + +function extensionId(value: unknown): string { + if (!isCanonicalExtensionId(value)) throw invalid('Extension manifest id is invalid'); + return value; +} + +function text(value: unknown, label: string, maxBytes: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + /[\0\r\n]/u.test(value) + ) { + throw invalid(`Extension manifest ${label} is invalid`); + } + return value; +} + +function boundedDescription(value: unknown): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > 4096 || + value.includes('\0') + ) { + throw invalid('Extension manifest description is invalid'); + } + return value; +} + +function isScalar(value: unknown): value is ExtensionConfigurationScalar { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function invalid(message: string, cause?: unknown): ExtensionPackageManifestError { + return new ExtensionPackageManifestError(message, { cause }); +} diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index 1621c78c67..129b6b7da7 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -30,3 +30,41 @@ export { readRuntimeHostAccessCredentialMetadata, type RuntimeHostAccessCredentialMetadata, } from './access-credential-metadata.js'; +export { + ExtensionBundleError, + exportExtensionBundle, + materializeExtensionPackage, +} from './extension-bundle.js'; +export { + EXTENSION_PACKAGE_MANIFEST_FILE, + ExtensionPackageManifestError, + decodeExtensionPackageManifest, + loadExtensionPackageManifest, + validateExtensionConfiguration, + type ExtensionConfigurationProperty, + type ExtensionConfigurationScalar, + type ExtensionConfigurationSchema, + type ExtensionPackageDependency, + type ExtensionPackageManifest, + type ExtensionPackageRuntime, +} from './extension-package-manifest.js'; +export { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, + type PersistedPluginEntry, +} from './plugin-composition-store.js'; +export { PluginPackageLoaderError, TrustedPluginPackageLoader } from './plugin-package-loader.js'; +export { + PluginPackageStore, + PluginPackageStoreError, + type InstalledPluginPackage, + type PreparedPluginPackageInstall, +} from './plugin-package-store.js'; +export { + HostPluginPlatform, + HostPluginPlatformError, + type HostPluginPlatformFailure, + type HostPluginPlatformOptions, +} from './plugin-platform.js'; +export { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js'; diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 70a705555a..92de0afcc6 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -138,6 +138,7 @@ export type DailyReviewOperationKey = Extract; export type NetworkProxyOperationKey = Extract; export type ConfigurationOperationKey = Extract; +export type PluginPlatformOperationKey = Extract; export type DomainOperationHandlerMap = Pick; export type TurnOperationHandlerMap = Pick; export type ContextOperationHandlerMap = Pick; @@ -204,6 +205,10 @@ export type DailyReviewOperationHandlerMap = Pick; export type NetworkProxyOperationHandlerMap = Pick; export type ConfigurationOperationHandlerMap = Pick; +export type PluginPlatformOperationHandlerMap = Pick< + OperationHandlerMap, + PluginPlatformOperationKey +>; export type AccessAuthorityOperationHandlerMap = Pick< OperationHandlerMap, keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS diff --git a/packages/runtime-host/src/server/plugin-composition-store.ts b/packages/runtime-host/src/server/plugin-composition-store.ts new file mode 100644 index 0000000000..06be34a089 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-composition-store.ts @@ -0,0 +1,329 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { isCanonicalExtensionId, isCanonicalPluginId } from '@maka/runtime/plugin-runtime'; +import type { ExtensionConfigurationScalar } from './extension-package-manifest.js'; + +const FILE_NAME = 'plugin-composition-v2.json'; +const MAX_BYTES = 2 * 1024 * 1024; + +export interface PersistedPluginEntry { + readonly id: string; + readonly packageId?: string; + readonly disabled: boolean; + readonly config: Readonly>; + readonly inject?: readonly string[] | Readonly>; + readonly isolate?: Readonly>; + readonly intercept?: Readonly>; + readonly children?: readonly PersistedPluginEntry[]; + readonly error?: string | null; +} + +export interface PersistedPluginComposition { + readonly schemaVersion: 1; + readonly generation: number; + readonly roots: { + readonly profile: readonly PersistedPluginEntry[]; + readonly desktopUi: readonly PersistedPluginEntry[]; + readonly sessions: Readonly>; + }; +} + +export class HostPluginCompositionStoreError extends Error { + readonly name = 'HostPluginCompositionStoreError'; + + constructor( + readonly code: 'persistence_failed' | 'invalid_state' | 'commit_outcome_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export class HostPluginCompositionStore { + readonly path: string; + + constructor(controlDirectory: string) { + this.path = join(controlDirectory, FILE_NAME); + } + + async read(): Promise { + let encoded: Buffer; + try { + encoded = await readFile(this.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw persistence('Unable to read Plugin Composition', error); + } + if (encoded.byteLength > MAX_BYTES) throw invalid('Plugin Composition exceeds its size limit'); + try { + return decode(JSON.parse(encoded.toString('utf8'))); + } catch (error) { + if (error instanceof HostPluginCompositionStoreError) throw error; + throw invalid('Plugin Composition is invalid JSON', error); + } + } + + async replace(snapshot: PersistedPluginComposition): Promise { + const normalized = decode(snapshot); + const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, 'utf8'); + if (encoded.byteLength > MAX_BYTES) throw invalid('Plugin Composition exceeds its size limit'); + const directory = dirname(this.path); + const temporary = join(directory, `.${FILE_NAME}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + let published = false; + try { + await mkdir(directory, { recursive: true, mode: 0o700 }); + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(encoded); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, this.path); + published = true; + const directoryHandle = await open(directory, 'r'); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } catch (error) { + if (published) { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'Plugin Composition was renamed but its directory sync was not confirmed', + { cause: error }, + ); + } + throw persistence('Unable to persist Plugin Composition', error); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + } + } +} + +function decode(value: unknown): PersistedPluginComposition { + const root = record(value, 'Plugin Composition'); + exact(root, ['schemaVersion', 'generation', 'roots']); + if ( + root.schemaVersion !== 1 || + !Number.isSafeInteger(root.generation) || + (root.generation as number) < 0 + ) { + throw invalid('Plugin Composition header is invalid'); + } + const roots = record(root.roots, 'Plugin Composition roots'); + exact(roots, ['profile', 'desktopUi', 'sessions']); + const sessions = record(roots.sessions, 'Plugin Composition sessions'); + const decodedSessions = Object.fromEntries( + Object.entries(sessions).map(([scopeId, entries]) => { + if (!scopeId || scopeId.length > 256 || /[\r\n\0]/u.test(scopeId)) { + throw invalid(`Invalid Session scope: ${scopeId}`); + } + return [scopeId, decodeEntries(entries, `sessions.${scopeId}`)] as const; + }), + ); + const profile = decodeEntries(roots.profile, 'profile'); + const desktopUi = decodeEntries(roots.desktopUi, 'desktopUi'); + const all = [ + ...walk(profile), + ...walk(desktopUi), + ...Object.values(decodedSessions).flatMap(walk), + ]; + const ids = new Set(); + for (const entry of all) { + if (ids.has(entry.id)) throw invalid(`Plugin entry id is repeated: ${entry.id}`); + ids.add(entry.id); + } + return Object.freeze({ + schemaVersion: 1, + generation: root.generation as number, + roots: Object.freeze({ + profile, + desktopUi, + sessions: Object.freeze(decodedSessions), + }), + }); +} + +function decodeEntries(value: unknown, label: string): readonly PersistedPluginEntry[] { + if (!Array.isArray(value) || value.length > 256) throw invalid(`${label} entries are invalid`); + return Object.freeze(value.map((item, index) => decodeEntry(item, `${label}[${index}]`))); +} + +function decodeEntry(value: unknown, label: string): PersistedPluginEntry { + const entry = record(value, label); + exactOptional( + entry, + ['id', 'disabled', 'config'], + ['packageId', 'inject', 'isolate', 'intercept', 'children', 'error'], + ); + if (!isCanonicalPluginId(entry.id)) throw invalid(`${label}.id is invalid`); + const group = entry.packageId === undefined; + if (!group && !isCanonicalExtensionId(entry.packageId)) + throw invalid(`${label}.packageId is invalid`); + if (typeof entry.disabled !== 'boolean') throw invalid(`${label}.disabled is invalid`); + const config = scalarRecord(entry.config, `${label}.config`); + const error = + entry.error === undefined + ? undefined + : entry.error === null + ? null + : text(entry.error, `${label}.error`, 4096); + const inject = decodeInject(entry.inject, `${label}.inject`); + const isolate = decodeIsolate(entry.isolate, `${label}.isolate`); + const intercept = decodeJsonRecord(entry.intercept, `${label}.intercept`); + const children = + entry.children === undefined ? undefined : decodeEntries(entry.children, `${label}.children`); + return Object.freeze({ + id: entry.id as string, + ...(group ? {} : { packageId: entry.packageId as string }), + disabled: entry.disabled, + config, + ...(inject === undefined ? {} : { inject }), + ...(isolate === undefined ? {} : { isolate }), + ...(intercept === undefined ? {} : { intercept }), + ...(children === undefined ? {} : { children }), + ...(error === undefined ? {} : { error }), + }); +} + +function decodeInject( + value: unknown, + label: string, +): readonly string[] | Readonly> | undefined { + if (value === undefined) return undefined; + if (Array.isArray(value)) { + if (value.length > 64 || value.some((item) => typeof item !== 'string' || !item)) { + throw invalid(`${label} is invalid`); + } + return Object.freeze([...value]); + } + return decodeJsonRecord(value, label); +} + +function decodeIsolate( + value: unknown, + label: string, +): Readonly> | undefined { + if (value === undefined) return undefined; + const source = record(value, label); + const output: Record = {}; + for (const [key, item] of Object.entries(source)) { + if (item !== true && (typeof item !== 'string' || !item)) + throw invalid(`${label}.${key} is invalid`); + output[key] = item; + } + return Object.freeze(output); +} + +function decodeJsonRecord( + value: unknown, + label: string, +): Readonly> | undefined { + if (value === undefined) return undefined; + const source = record(value, label); + let encoded: string; + try { + encoded = JSON.stringify(source); + } catch (error) { + throw invalid(`${label} is not JSON`, error); + } + if (Buffer.byteLength(encoded, 'utf8') > 64 * 1024) + throw invalid(`${label} exceeds its size limit`); + return Object.freeze(structuredClone(source)); +} + +function walk(entries: readonly PersistedPluginEntry[]): readonly PersistedPluginEntry[] { + return entries.flatMap((entry) => [entry, ...walk(entry.children ?? [])]); +} + +function scalarRecord( + value: unknown, + label: string, +): Readonly> { + const source = record(value, label); + const output: Record = {}; + for (const [key, item] of Object.entries(source)) { + if (!/^[A-Za-z][A-Za-z0-9._-]{0,127}$/u.test(key)) throw invalid(`${label} key is invalid`); + if ( + typeof item !== 'string' && + typeof item !== 'boolean' && + !(typeof item === 'number' && Number.isFinite(item)) + ) { + throw invalid(`${label}.${key} is invalid`); + } + output[key] = item; + } + return Object.freeze(output); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid(`${label} must be an object`); + return value as Record; +} + +function exact(value: Record, keys: readonly string[]): void { + if ( + keys.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some((key) => !keys.includes(key)) + ) { + throw invalid('Plugin Composition fields are invalid'); + } +} + +function exactOptional( + value: Record, + required: readonly string[], + optional: readonly string[], +): void { + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some((key) => !allowed.has(key)) + ) { + throw invalid('Plugin Composition fields are invalid'); + } +} + +function text(value: unknown, label: string, maxBytes: number): string { + if ( + typeof value !== 'string' || + !value || + Buffer.byteLength(value, 'utf8') > maxBytes || + /[\r\n\0]/u.test(value) + ) { + throw invalid(`${label} is invalid`); + } + return value; +} + +function invalid(message: string, cause?: unknown): HostPluginCompositionStoreError { + return new HostPluginCompositionStoreError('invalid_state', message, { cause }); +} + +function persistence(message: string, cause?: unknown): HostPluginCompositionStoreError { + return new HostPluginCompositionStoreError('persistence_failed', message, { cause }); +} diff --git a/packages/runtime-host/src/server/plugin-package-loader.ts b/packages/runtime-host/src/server/plugin-package-loader.ts new file mode 100644 index 0000000000..7d8e74c0a6 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-package-loader.ts @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { cp, mkdir, rm } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + type MakaPluginPackage, + MakaPluginRuntimeError, + validatePluginPackage, +} from '@maka/runtime/plugin-runtime'; +import { PluginPackageStore, PluginPackageStoreError } from './plugin-package-store.js'; + +const GENERATION_DIRECTORY = 'plugin-generations-v1'; +const GENERATION_PATH = Symbol('maka.pluginGenerationPath'); + +export class PluginPackageLoaderError extends Error { + readonly name = 'PluginPackageLoaderError'; + + constructor( + readonly code: 'not_found' | 'invalid_package' | 'load_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Loads trusted packages from immutable generation directories. */ +export class TrustedPluginPackageLoader { + readonly #generations: string; + readonly #owned = new Set(); + + constructor( + controlDirectory: string, + readonly store: PluginPackageStore, + ) { + this.#generations = join(controlDirectory, GENERATION_DIRECTORY); + } + + async load(extensionId: string): Promise { + let installed; + try { + installed = await this.store.load(extensionId); + } catch (error) { + throw translate(error); + } + const generation = join(this.#generations, `${extensionId}-${randomUUID()}`); + try { + await mkdir(this.#generations, { recursive: true, mode: 0o700 }); + await cp(installed.root, generation, { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: false, + }); + const entry = join(generation, relative(installed.root, installed.entry)); + const imported = (await import(pathToFileURL(entry).href)) as Record; + const candidate = imported.default ?? imported.plugin; + if (!candidate || typeof candidate !== 'object') { + throw invalid('Plugin Runtime entry must export a MakaPluginPackage as default'); + } + const pkg = candidate as MakaPluginPackage; + validatePluginPackage(pkg); + if (pkg.packageId !== extensionId) { + throw invalid( + `Plugin Runtime packageId ${pkg.packageId} does not match manifest ${extensionId}`, + ); + } + if (!pkg.host) throw invalid('Trusted Host package must export a host Plugin'); + const owned = freezeGeneration(pkg, generation); + this.#owned.add(generation); + return owned; + } catch (error) { + await rm(generation, { recursive: true, force: true }).catch(() => undefined); + throw translate(error); + } + } + + async collectGarbage(): Promise { + this.#owned.clear(); + await rm(this.#generations, { recursive: true, force: true }); + } + + async release(pkg: MakaPluginPackage): Promise { + const generation = (pkg as MakaPluginPackage & { readonly [GENERATION_PATH]?: string })[ + GENERATION_PATH + ]; + if (!generation || !this.#owned.delete(generation)) return; + await rm(generation, { recursive: true, force: true }); + } + + async close(): Promise { + this.#owned.clear(); + await rm(this.#generations, { recursive: true, force: true }); + } +} + +function freezeGeneration(pkg: MakaPluginPackage, generation: string): MakaPluginPackage { + return Object.freeze({ + ...pkg, + [GENERATION_PATH]: generation, + ...(pkg.contributions + ? { + contributions: Object.freeze(pkg.contributions.map((item) => Object.freeze({ ...item }))), + } + : {}), + }); +} + +function invalid(message: string, cause?: unknown): PluginPackageLoaderError { + return new PluginPackageLoaderError('invalid_package', message, { cause }); +} + +function translate(error: unknown): PluginPackageLoaderError { + if (error instanceof PluginPackageLoaderError) return error; + if (error instanceof PluginPackageStoreError) { + return new PluginPackageLoaderError( + error.code === 'not_found' + ? 'not_found' + : error.code === 'invalid_package' + ? 'invalid_package' + : 'load_failed', + error.message, + { cause: error }, + ); + } + if (error instanceof MakaPluginRuntimeError) return invalid(error.message, error); + return new PluginPackageLoaderError('load_failed', 'Unable to load Plugin package', { + cause: error, + }); +} diff --git a/packages/runtime-host/src/server/plugin-package-store.ts b/packages/runtime-host/src/server/plugin-package-store.ts new file mode 100644 index 0000000000..b68efc1f27 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-package-store.ts @@ -0,0 +1,404 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import type { Dirent } from 'node:fs'; +import { mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; +import { dirname, join, posix } from 'node:path'; +import { isCanonicalExtensionId } from '@maka/runtime/plugin-runtime'; +import { exportExtensionBundle, materializeExtensionPackage } from './extension-bundle.js'; +import { + EXTENSION_PACKAGE_MANIFEST_FILE, + type ExtensionPackageManifest, + loadExtensionPackageManifest, +} from './extension-package-manifest.js'; + +const STORE_DIRECTORY = 'plugin-packages-v2'; +const MAX_FILES = 256; +const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_PACKAGE_BYTES = 16 * 1024 * 1024; + +interface PackageFile { + readonly path: string; + readonly content: Buffer; +} + +export interface InstalledPluginPackage { + readonly extensionId: string; + readonly root: string; + readonly entry: string; + readonly manifest: ExtensionPackageManifest; +} + +export interface PreparedPluginPackageInstall { + readonly installed: InstalledPluginPackage; + commit(): Promise; + rollback(): Promise; +} + +export class PluginPackageStoreError extends Error { + readonly name = 'PluginPackageStoreError'; + + constructor( + readonly code: + | 'not_found' + | 'invalid_package' + | 'persistence_failed' + | 'commit_outcome_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Atomic, root-private storage for trusted in-process Plugin packages. */ +export class PluginPackageStore { + readonly root: string; + readonly #controlDirectory: string; + + constructor(controlDirectory: string) { + this.#controlDirectory = controlDirectory; + this.root = join(controlDirectory, STORE_DIRECTORY); + } + + async install(sourcePath: string): Promise { + const prepared = await this.prepareInstall(sourcePath); + await prepared.commit(); + return prepared.installed; + } + + /** Repairs or removes package-store transaction remnants after owner death. */ + async recover(): Promise { + let entries: Dirent[]; + try { + entries = await readdir(this.root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw persistence('Unable to recover Plugin package storage', error); + } + let changed = false; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isDirectory() || !entry.name.startsWith('.')) continue; + const path = join(this.root, entry.name); + if (entry.name.startsWith('.previous-')) { + try { + const files = await readPackage(path); + const decoded = await decodePackage(path, files); + const target = join(this.root, decoded.manifest.id); + try { + await stat(target); + await rm(path, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await rename(path, target); + } + changed = true; + continue; + } catch (error) { + throw persistence(`Unable to recover Plugin package transaction ${entry.name}`, error); + } + } + if ( + entry.name.startsWith('.staging-') || + entry.name.startsWith('.rejected-') || + entry.name.startsWith('.removed-') + ) { + await rm(path, { recursive: true, force: true }); + changed = true; + } + } + if (changed) await syncDirectory(this.root); + } + + async identities(): Promise { + let entries: Dirent[]; + try { + entries = await readdir(this.root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Object.freeze([]); + throw persistence('Unable to list Plugin package identities', error); + } + return Object.freeze( + entries + .filter((entry) => entry.isDirectory() && isCanonicalExtensionId(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)), + ); + } + + async prepareInstall(sourcePath: string): Promise { + const source = await materializeExtensionPackage(sourcePath, this.#controlDirectory); + try { + const files = await readPackage(source.root); + const decoded = await decodePackage(source.root, files); + const target = join(this.root, decoded.manifest.id); + const staging = join(this.root, `.staging-${randomUUID()}`); + const previous = join(this.root, `.previous-${randomUUID()}`); + let movedPrevious = false; + let published = false; + let settled = false; + try { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await mkdir(staging, { mode: 0o700 }); + for (const file of files) await writeFile(staging, file); + await syncTree(staging, files); + await rename(target, previous) + .then(() => { + movedPrevious = true; + }) + .catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + await rename(staging, target); + published = true; + await syncDirectory(this.root); + const installed = await this.load(decoded.manifest.id); + return Object.freeze({ + installed, + commit: async () => { + if (settled) return; + settled = true; + await rm(previous, { recursive: true, force: true }).catch(() => undefined); + }, + rollback: async () => { + if (settled) return; + settled = true; + const rejected = join(this.root, `.rejected-${randomUUID()}`); + await rename(target, rejected).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + if (movedPrevious) await rename(previous, target); + await syncDirectory(this.root); + await rm(rejected, { recursive: true, force: true }); + }, + }); + } catch (error) { + if (published) { + await rm(target, { recursive: true, force: true }).catch(() => undefined); + if (movedPrevious) await rename(previous, target).catch(() => undefined); + await syncDirectory(this.root).catch(() => undefined); + } else if (movedPrevious) { + await rename(previous, target).catch(() => undefined); + } + if (error instanceof PluginPackageStoreError) throw error; + throw persistence(`Unable to install Plugin package ${decoded.manifest.id}`, error); + } finally { + await rm(staging, { recursive: true, force: true }).catch(() => undefined); + } + } finally { + await source.dispose(); + } + } + + async list(): Promise { + const installed: InstalledPluginPackage[] = []; + for (const extensionId of await this.identities()) installed.push(await this.load(extensionId)); + return Object.freeze(installed); + } + + async load(extensionId: string): Promise { + requireIdentity(extensionId); + const root = join(this.root, extensionId); + try { + if (!(await stat(root)).isDirectory()) throw invalid('Installed package is not a directory'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new PluginPackageStoreError( + 'not_found', + `Plugin package is not installed: ${extensionId}`, + ); + } + if (error instanceof PluginPackageStoreError) throw error; + throw persistence(`Unable to read Plugin package ${extensionId}`, error); + } + const files = await readPackage(root); + const decoded = await decodePackage(root, files); + if (decoded.manifest.id !== extensionId) { + throw invalid(`Installed Plugin identity does not match its directory: ${extensionId}`); + } + return freezeInstalled(root, decoded); + } + + async export(extensionId: string, targetPath: string): Promise { + const installed = await this.load(extensionId); + await exportExtensionBundle(installed.root, targetPath); + } + + async uninstall(extensionId: string): Promise { + await this.load(extensionId); + const target = join(this.root, extensionId); + const removed = join(this.root, `.removed-${extensionId}-${randomUUID()}`); + let published = false; + try { + await rename(target, removed); + published = true; + await syncDirectory(this.root); + await rm(removed, { recursive: true, force: true }).catch(() => undefined); + } catch (error) { + if (published) { + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + `Plugin package uninstall outcome is unknown: ${extensionId}`, + { cause: error }, + ); + } + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw persistence(`Unable to uninstall Plugin package ${extensionId}`, error); + } + } + } +} + +async function decodePackage( + root: string, + files: readonly PackageFile[], +): Promise<{ readonly manifest: ExtensionPackageManifest; readonly entry: string }> { + if (!files.some((file) => file.path === EXTENSION_PACKAGE_MANIFEST_FILE)) { + throw invalid(`Plugin package is missing ${EXTENSION_PACKAGE_MANIFEST_FILE}`); + } + const manifest = await loadExtensionPackageManifest(root); + if (!manifest) throw invalid(`Plugin package is missing ${EXTENSION_PACKAGE_MANIFEST_FILE}`); + if (!manifest.runtime?.entry) throw invalid('Plugin package has no trusted Runtime entry'); + if (!files.some((file) => file.path === manifest.runtime!.entry)) { + throw invalid(`Plugin Runtime entry does not exist: ${manifest.runtime.entry}`); + } + return Object.freeze({ manifest, entry: manifest.runtime.entry }); +} + +async function readPackage(rootValue: string): Promise { + let root: string; + try { + root = await realpath(rootValue); + } catch (error) { + throw invalid('Plugin package source is unavailable', error); + } + const paths: string[] = []; + await collect(root, '', paths); + if (paths.length === 0 || paths.length > MAX_FILES) { + throw invalid('Plugin package file count is invalid'); + } + let total = 0; + const files: PackageFile[] = []; + for (const path of paths.sort()) { + const handle = await open(join(root, ...path.split('/')), 'r'); + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES) { + throw invalid(`Plugin package file is invalid: ${path}`); + } + const content = await handle.readFile(); + total += content.byteLength; + if (total > MAX_PACKAGE_BYTES) throw invalid('Plugin package is too large'); + files.push(Object.freeze({ path, content })); + } finally { + await handle.close(); + } + } + return Object.freeze(files); +} + +async function collect(root: string, directory: string, paths: string[]): Promise { + const entries = await readdir(directory ? join(root, ...directory.split('/')) : root, { + withFileTypes: true, + }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.name === '.git') continue; + const path = safePath(directory ? `${directory}/${entry.name}` : entry.name); + if (entry.isSymbolicLink()) throw invalid(`Plugin package may not contain symlinks: ${path}`); + if (entry.isDirectory()) await collect(root, path, paths); + else if (entry.isFile()) paths.push(path); + else throw invalid(`Plugin package contains an unsupported entry: ${path}`); + if (paths.length > MAX_FILES) throw invalid('Plugin package contains too many files'); + } +} + +async function writeFile(root: string, file: PackageFile): Promise { + const target = join(root, ...file.path.split('/')); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + const handle = await open(target, 'wx', 0o600); + try { + await handle.writeFile(file.content); + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncTree(root: string, files: readonly PackageFile[]): Promise { + const directories = new Set([root]); + for (const file of files) { + let current = dirname(join(root, ...file.path.split('/'))); + while (current.startsWith(root)) { + directories.add(current); + if (current === root) break; + current = dirname(current); + } + } + for (const directory of [...directories].sort((a, b) => b.length - a.length)) { + await syncDirectory(directory); + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function freezeInstalled( + root: string, + decoded: { readonly manifest: ExtensionPackageManifest; readonly entry: string }, +): InstalledPluginPackage { + return Object.freeze({ + extensionId: decoded.manifest.id, + root, + entry: join(root, ...decoded.entry.split('/')), + manifest: decoded.manifest, + }); +} + +function safePath(value: string): string { + if ( + !value || + value.length > 512 || + value.includes('\\') || + value.startsWith('/') || + posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid('Plugin package path is invalid'); + } + return value; +} + +function requireIdentity(extensionId: string): void { + if (!isCanonicalExtensionId(extensionId)) throw invalid('Plugin package identity is invalid'); +} + +function invalid(message: string, cause?: unknown): PluginPackageStoreError { + return new PluginPackageStoreError('invalid_package', message, { cause }); +} + +function persistence(message: string, cause?: unknown): PluginPackageStoreError { + return new PluginPackageStoreError('persistence_failed', message, { cause }); +} diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts new file mode 100644 index 0000000000..34567d6dd7 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -0,0 +1,207 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MakaPluginRuntimeError, + type MakaCompositionApplyInput, +} from '@maka/runtime/plugin-runtime'; +import type { + OperationOutcome, + PluginPackageExportInput, + PluginPackageInstallInput, + PluginPackageUninstallInput, + PluginPlatformQueryInput, +} from '../protocol/index.js'; +import { ExtensionBundleError } from './extension-bundle.js'; +import { ExtensionPackageManifestError } from './extension-package-manifest.js'; +import type { PluginPlatformOperationHandlerMap } from './operation-dispatcher.js'; +import { PluginPackageLoaderError } from './plugin-package-loader.js'; +import { PluginPackageStoreError } from './plugin-package-store.js'; +import { HostPluginPlatform, HostPluginPlatformError } from './plugin-platform.js'; + +export class HostPluginPlatformCoordinator { + readonly handlers: PluginPlatformOperationHandlerMap = { + 'plugin.platform.query': (input) => this.#query(input), + 'plugin.package.install': (input) => this.#install(input), + 'plugin.package.uninstall': (input) => this.#uninstall(input), + 'plugin.package.reload': (input) => this.#reload(input), + 'plugin.package.export': (input) => this.#export(input), + 'plugin.composition.apply': (input) => this.#apply(input), + }; + + constructor(readonly platform: HostPluginPlatform) {} + + async #query( + input: PluginPlatformQueryInput, + ): Promise> { + try { + return await this.platform.read(async () => { + const packages = []; + const failures = [...this.platform.failures()]; + for (const extensionId of await this.platform.packages.identities()) { + try { + const { manifest } = await this.platform.packages.load(extensionId); + packages.push({ + extensionId, + displayName: manifest.displayName, + ...(manifest.description ? { description: manifest.description } : {}), + dependencies: manifest.dependencies.map(({ id }) => id), + }); + } catch (error) { + if (!failures.some((failure) => failure.extensionId === extensionId)) { + failures.push({ + extensionId, + diagnostic: error instanceof Error ? error.message.slice(0, 4096) : String(error), + }); + } + } + } + return { + ok: true, + result: { + packages, + desired: this.platform.desiredSnapshot(), + inspections: this.platform.inspect(input.rootId), + runtime: input.rootId ? this.platform.runtimeSnapshot(input.rootId) : null, + failures, + }, + }; + }); + } catch (error) { + return failure(error); + } + } + + async #install( + input: PluginPackageInstallInput, + ): Promise> { + try { + return { ok: true, result: await this.platform.installPackage(input.sourcePath) }; + } catch (error) { + return failure(error); + } + } + + async #uninstall( + input: PluginPackageUninstallInput, + ): Promise> { + try { + await this.platform.uninstallPackage(input.extensionId); + return { ok: true, result: {} }; + } catch (error) { + return failure(error); + } + } + + async #reload( + input: PluginPackageUninstallInput, + ): Promise> { + try { + await this.platform.reloadPackage(input.extensionId); + return { ok: true, result: {} }; + } catch (error) { + return failure(error); + } + } + + async #export( + input: PluginPackageExportInput, + ): Promise> { + try { + await this.platform.read(() => + this.platform.packages.export(input.extensionId, input.targetPath), + ); + return { ok: true, result: { targetPath: input.targetPath } }; + } catch (error) { + return failure(error); + } + } + + async #apply( + input: MakaCompositionApplyInput, + ): Promise> { + try { + return { + ok: true, + result: { + inspections: await this.platform.apply(input), + desired: this.platform.desiredSnapshot(), + }, + }; + } catch (error) { + return failure(error); + } + } +} + +function failure( + error: unknown, +): OperationOutcome { + if (error instanceof HostPluginPlatformError) { + if (error.code === 'closed') return failed('host_draining', error.message); + if (error.code === 'persistence_failed') return failed('persistence_failed', error.message); + if (error.code === 'recovery_failed') return failed('persistence_failed', error.message); + if (error.code === 'commit_outcome_unknown') { + return failed('commit_outcome_unknown', error.message); + } + if (error.code === 'mutation_failed' && error.cause) return failure(error.cause); + return failed('internal_failure', error.message); + } + if (error instanceof PluginPackageStoreError) { + if (error.code === 'not_found') return failed('not_found', error.message); + if (error.code === 'invalid_package') return failed('invalid_request', error.message); + if (error.code === 'commit_outcome_unknown') { + return failed('commit_outcome_unknown', error.message); + } + return failed('persistence_failed', error.message); + } + if (error instanceof PluginPackageLoaderError) { + if (error.code === 'not_found') return failed('not_found', error.message); + if (error.code === 'invalid_package') return failed('invalid_request', error.message); + return failed('persistence_failed', error.message); + } + if (error instanceof ExtensionBundleError || error instanceof ExtensionPackageManifestError) { + return failed('invalid_request', error.message); + } + if (error instanceof MakaPluginRuntimeError) { + switch (error.code) { + case 'package_not_found': + case 'entry_not_found': + return failed('not_found', error.message); + case 'package_exists': + case 'package_in_use': + case 'entry_exists': + return failed('operation_conflict', error.message); + case 'invalid_package': + case 'invalid_entry': + case 'dependency_cycle': + return failed('invalid_request', error.message); + default: + return failed('internal_failure', error.message); + } + } + return failed('internal_failure', 'Plugin Platform operation failed'); +} + +function failed( + code: string, + message: string, +): OperationOutcome { + return { ok: false, error: { code, message } } as OperationOutcome; +} diff --git a/packages/runtime-host/src/server/plugin-platform.ts b/packages/runtime-host/src/server/plugin-platform.ts new file mode 100644 index 0000000000..db1bb25b09 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -0,0 +1,966 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MakaCompositionLoader, + type MakaCompositionRecoveryFailure, +} from '@maka/runtime/plugin-composition-loader'; +import { + applyCompositionSnapshot, + MakaPluginRuntimeError, + type MakaCompositionApplyInput, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionOperation, + type MakaCompositionSnapshot, + type MakaPluginPackage, + type MakaPluginRootId, + type MakaRuntimeCompositionSnapshot, +} from '@maka/runtime/plugin-runtime'; +import type { ExtensionPackageManifest } from './extension-package-manifest.js'; +import { validateExtensionConfiguration } from './extension-package-manifest.js'; +import { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, + type PersistedPluginEntry, +} from './plugin-composition-store.js'; +import { TrustedPluginPackageLoader } from './plugin-package-loader.js'; +import { PluginPackageStore, PluginPackageStoreError } from './plugin-package-store.js'; + +export class HostPluginPlatformError extends Error { + readonly name = 'HostPluginPlatformError'; + + constructor( + readonly code: + | 'closed' + | 'persistence_failed' + | 'commit_outcome_unknown' + | 'recovery_failed' + | 'mutation_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export interface HostPluginPlatformOptions { + readonly composition?: MakaCompositionLoader; + readonly packages?: PluginPackageStore; + readonly packageLoader?: TrustedPluginPackageLoader; + readonly store?: HostPluginCompositionStore; +} + +export interface HostPluginPlatformFailure { + readonly entryId?: string; + readonly extensionId?: string; + readonly diagnostic: string; +} + +interface CompositionEntryRecord { + readonly entry: MakaCompositionEntry; + readonly rootId: MakaPluginRootId; + readonly disabled: boolean; +} + +/** + * Runtime Host's sole authority for trusted Plugin packages and desired Entry composition. + * Concrete Tool, UI, Hook, Event, Service, and Timer consumers attach in later slices. + */ +export class HostPluginPlatform { + readonly composition: MakaCompositionLoader; + readonly packages: PluginPackageStore; + readonly packageLoader: TrustedPluginPackageLoader; + readonly store: HostPluginCompositionStore; + + #desired: PersistedPluginComposition = emptyComposition(); + #mutation: Promise = Promise.resolve(); + #closed = false; + #draining = false; + #poisoned?: Error; + #diverged = false; + #failures: readonly HostPluginPlatformFailure[] = Object.freeze([]); + + constructor( + readonly controlDirectory: string, + options: HostPluginPlatformOptions = {}, + ) { + this.composition = options.composition ?? new MakaCompositionLoader(); + this.packages = options.packages ?? new PluginPackageStore(controlDirectory); + this.packageLoader = + options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, this.packages); + this.store = options.store ?? new HostPluginCompositionStore(controlDirectory); + } + + async recover(): Promise { + if (this.#closed) throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); + await this.#serialize(async () => { + try { + await this.packages.recover(); + await this.packageLoader.collectGarbage(); + const storedDesired = (await this.store.read()) ?? emptyComposition(); + const packageFailures: HostPluginPlatformFailure[] = []; + for (const extensionId of await this.packages.identities()) { + try { + await this.composition.install(await this.packageLoader.load(extensionId)); + } catch (error) { + packageFailures.push( + Object.freeze({ + extensionId, + diagnostic: boundedDiagnostic(error), + }), + ); + } + } + const desiredSnapshot = await this.#normalizeSnapshotConfigurations( + runtimeSnapshot(storedDesired), + ); + const desired = persistedSnapshot(desiredSnapshot); + const entryFailures = await this.#recoverDesiredRuntime(desiredSnapshot); + this.#failures = Object.freeze([ + ...packageFailures, + ...entryFailures.map((failure) => + Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), + ), + ]); + this.#diverged = entryFailures.length > 0; + this.#desired = withEntryFailures(desired, entryFailures); + if (!sameComposition(desired, this.#desired)) await this.store.replace(this.#desired); + } catch (error) { + this.#poisoned = asError(error); + // Plugin recovery is fail-open for the Host. Mutations and Plugin + // queries remain fenced until the persisted authority is repaired. + } + }); + } + + async installPackage(sourcePath: string): Promise<{ readonly extensionId: string }> { + this.#assertMutable(); + return await this.#serialize(async () => { + const prepared = await this.packages.prepareInstall(sourcePath); + let loaded: MakaPluginPackage | undefined; + let previous: MakaPluginPackage | undefined; + let activated = false; + try { + loaded = await this.packageLoader.load(prepared.installed.extensionId); + const alreadyInstalled = this.composition + .installedPackages() + .some(({ packageId }) => packageId === prepared.installed.extensionId); + if (alreadyInstalled) { + await this.#validateDesired(this.desiredSnapshot()); + previous = this.composition.package(prepared.installed.extensionId); + await this.composition.reload(loaded); + } else { + await this.composition.install(loaded); + } + activated = true; + await prepared.commit(); + this.#clearPackageFailure(prepared.installed.extensionId); + if (previous) await this.#releaseGeneration(previous); + if (this.#diverged) await this.#convergeDesired(); + return Object.freeze({ extensionId: prepared.installed.extensionId }); + } catch (error) { + if (activated && previous) { + try { + await this.composition.reload(previous); + } catch (rollbackError) { + this.#poisoned = asError(rollbackError); + this.#draining = true; + } + } else if (activated && loaded) { + await this.composition.uninstall(loaded.packageId).catch(() => undefined); + } + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + try { + await prepared.rollback(); + } catch (rollbackError) { + this.#poisoned = asError(rollbackError); + this.#draining = true; + throw new HostPluginPlatformError( + 'persistence_failed', + 'Plugin package installation and stored-package rollback both failed', + { cause: new AggregateError([error, rollbackError]) }, + ); + } + throw error; + } + }); + } + + async reloadPackage(extensionId: string): Promise { + this.#assertMutable(); + await this.#serialize(async () => { + const previous = this.composition.package(extensionId); + const loaded = await this.packageLoader.load(extensionId); + try { + await this.#validateDesired(this.desiredSnapshot()); + await this.composition.reload(loaded); + } catch (error) { + await this.packageLoader.release(loaded).catch(() => undefined); + throw error; + } + await this.#releaseGeneration(previous); + this.#clearPackageFailure(extensionId); + if (this.#diverged) await this.#convergeDesired(); + }); + } + + async uninstallPackage(extensionId: string): Promise { + this.#assertMutable(); + await this.#serialize(async () => { + const desiredUser = findPersistedEntry( + this.#desired, + (entry) => entry.packageId === extensionId, + ); + if (desiredUser) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is used by desired entry ${desiredUser.id}`, + ); + } + const dependent = await this.#desiredPackageDependent(extensionId); + if (dependent) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is required by desired entry ${dependent.id}`, + ); + } + const installedInRuntime = this.composition + .installedPackages() + .some(({ packageId }) => packageId === extensionId); + const pkg = installedInRuntime ? this.composition.package(extensionId) : undefined; + if (pkg) await this.composition.uninstall(extensionId); + try { + await this.packages.uninstall(extensionId); + this.#clearPackageFailure(extensionId); + if (pkg) await this.#releaseGeneration(pkg); + } catch (error) { + if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') { + this.#poisoned = error; + this.#draining = true; + throw new HostPluginPlatformError( + 'commit_outcome_unknown', + 'Plugin package uninstall outcome is unknown; Plugin Platform was fenced', + { cause: error }, + ); + } + if (pkg) { + let restored: MakaPluginPackage | undefined; + try { + restored = await this.packageLoader.load(extensionId); + await this.composition.install(restored); + await this.#releaseGeneration(pkg); + } catch (rollbackError) { + if (restored) await this.packageLoader.release(restored).catch(() => undefined); + this.#poisoned = asError(rollbackError); + } + } + throw error; + } + }); + } + + async apply( + input: MakaCompositionApplyInput, + ): Promise { + this.#assertMutable(); + return await this.#serialize(async () => { + const desired = runtimeSnapshot(this.#desired); + let normalizedInput: MakaCompositionApplyInput; + let planned: MakaCompositionSnapshot; + try { + normalizedInput = await this.#normalizeApplyInput(desired, input); + planned = applyCompositionSnapshot(desired, normalizedInput); + await this.#validateDesired(planned); + } catch (error) { + throw new HostPluginPlatformError('mutation_failed', 'Plugin composition mutation failed', { + cause: error, + }); + } + const next = persistedSnapshot(planned); + try { + await this.store.replace(next); + this.#desired = next; + } catch (error) { + if ( + error instanceof HostPluginCompositionStoreError && + error.code === 'commit_outcome_unknown' + ) { + this.#poisoned = error; + this.#draining = true; + throw new HostPluginPlatformError( + 'commit_outcome_unknown', + 'Plugin composition commit outcome is unknown; Plugin Platform was fenced', + { cause: error }, + ); + } + throw new HostPluginPlatformError( + 'persistence_failed', + 'Plugin composition persistence failed; Runtime state was not changed', + { cause: error }, + ); + } + + let convergenceFailures: readonly MakaCompositionRecoveryFailure[] | undefined; + try { + if (this.#diverged) { + const failures = await this.composition.recoverSnapshot(planned); + await this.#publishEntryFailures(failures); + if (failures.length > 0) { + convergenceFailures = failures; + throw new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')); + } + return this.composition.inspectTree(); + } + const inspections = await this.composition.apply(normalizedInput); + this.#failures = Object.freeze( + this.#failures.filter((failure) => failure.entryId === undefined), + ); + return inspections; + } catch (error) { + this.#diverged = true; + if (!convergenceFailures) { + await this.#publishEntryFailures(operationFailures(normalizedInput, error)); + } + throw new HostPluginPlatformError( + 'mutation_failed', + 'Desired Plugin composition was committed but Runtime convergence failed', + { cause: error }, + ); + } + }); + } + + desiredSnapshot(): MakaCompositionSnapshot { + return runtimeSnapshot(this.#desired); + } + + failures(): readonly HostPluginPlatformFailure[] { + return this.#failures; + } + + runtimeSnapshot(rootId: MakaPluginRootId): MakaRuntimeCompositionSnapshot { + return this.composition.runtimeSnapshot(rootId); + } + + inspect(rootId?: MakaPluginRootId): readonly MakaCompositionEntryInspection[] { + return this.composition.inspectTree(rootId); + } + + read(operation: () => T | Promise): Promise { + this.#assertOpen(); + return this.#serialize(async () => await operation()); + } + + beginDrain(): void { + this.#draining = true; + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + await this.#mutation; + await this.composition.close(); + await this.packageLoader.close(); + } + + async #validateDesired(snapshot: MakaCompositionSnapshot): Promise { + const records = compositionEntryRecords(snapshot); + for (const record of records) { + await this.#validateEntry(record.entry, !record.disabled); + await this.#validateActiveDependencies(record, records); + } + } + + async #normalizeApplyInput( + desired: MakaCompositionSnapshot, + input: MakaCompositionApplyInput, + ): Promise { + if (input.baseGeneration !== undefined && input.baseGeneration !== desired.generation) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${desired.generation}`, + ); + } + let working = desired; + const operations: MakaCompositionOperation[] = []; + for (const operation of input.operations) { + let normalized: MakaCompositionOperation; + if (operation.type === 'insert') { + normalized = Object.freeze({ + ...operation, + entry: await this.#normalizeEntryConfiguration(operation.entry), + }); + } else if (operation.type === 'update') { + const current = findCompositionEntry(working, operation.entryId); + if (!current) { + throw new MakaPluginRuntimeError( + 'entry_not_found', + `Composition entry not found: ${operation.entryId}`, + ); + } + const effective = Object.freeze({ ...current, ...operation.patch }); + const configured = await this.#normalizeEntryConfiguration(effective, false); + normalized = Object.freeze({ + ...operation, + patch: Object.freeze({ ...operation.patch, config: configured.config }), + }); + } else { + normalized = operation; + } + operations.push(normalized); + const advanced = applyCompositionSnapshot(working, { operations: [normalized] }); + working = snapshotWithGeneration(advanced, desired.generation); + } + return Object.freeze({ + ...(input.baseGeneration === undefined ? {} : { baseGeneration: input.baseGeneration }), + operations: Object.freeze(operations), + }); + } + + async #normalizeSnapshotConfigurations( + snapshot: MakaCompositionSnapshot, + ): Promise { + const normalize = async (entry: MakaCompositionEntry): Promise => { + let configured = entry; + try { + configured = await this.#normalizeEntryConfiguration(entry, false); + } catch { + // Recovery records malformed or unavailable package configuration as + // an Entry failure below instead of failing the Runtime Host. + } + return Object.freeze({ + ...configured, + children: Object.freeze(await Promise.all((entry.children ?? []).map(normalize))), + }); + }; + const sessions = await Promise.all( + Object.entries(snapshot.roots.sessions).map( + async ([scopeId, entries]) => + [scopeId, Object.freeze(await Promise.all(entries.map(normalize)))] as const, + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: snapshot.generation, + roots: Object.freeze({ + profile: Object.freeze(await Promise.all(snapshot.roots.profile.map(normalize))), + desktopUi: Object.freeze(await Promise.all(snapshot.roots.desktopUi.map(normalize))), + sessions: Object.freeze(Object.fromEntries(sessions)), + }), + }); + } + + async #normalizeEntryConfiguration( + entry: MakaCompositionEntry, + recursive = true, + ): Promise { + const config = entry.packageId + ? validateExtensionConfiguration( + (await this.packages.load(entry.packageId)).manifest.configuration, + entry.config, + ) + : scalarConfiguration(entry.config); + return Object.freeze({ + ...entry, + config, + ...(recursive + ? { + children: Object.freeze( + await Promise.all( + (entry.children ?? []).map((child) => this.#normalizeEntryConfiguration(child)), + ), + ), + } + : {}), + }); + } + + async #desiredValidationFailures( + snapshot: MakaCompositionSnapshot, + ): Promise { + const failures: MakaCompositionRecoveryFailure[] = []; + const records = compositionEntryRecords(snapshot); + for (const record of records) { + try { + await this.#validateEntry(record.entry, !record.disabled); + await this.#validateActiveDependencies(record, records); + } catch (error) { + failures.push( + Object.freeze({ entryId: record.entry.id, diagnostic: boundedDiagnostic(error) }), + ); + } + } + return Object.freeze(failures); + } + + async #validateEntry( + entry: MakaCompositionEntry, + active = entry.disabled !== true, + ): Promise { + if (!entry.packageId) return; + const manifests = new Map(); + const visiting = new Set(); + const visited = new Set(); + const visit = async (extensionId: string): Promise => { + if (visited.has(extensionId)) return; + if (visiting.has(extensionId)) { + throw new MakaPluginRuntimeError( + 'dependency_cycle', + `Plugin package dependency cycle includes ${extensionId}`, + ); + } + visiting.add(extensionId); + let manifest = manifests.get(extensionId); + if (!manifest) { + manifest = (await this.packages.load(extensionId)).manifest; + manifests.set(extensionId, manifest); + } + for (const dependency of manifest.dependencies) await visit(dependency.id); + visiting.delete(extensionId); + visited.add(extensionId); + }; + const manifest = (await this.packages.load(entry.packageId)).manifest; + validateExtensionConfiguration(manifest.configuration, entry.config); + if (active) await visit(entry.packageId); + } + + async #validateActiveDependencies( + record: CompositionEntryRecord, + records: readonly CompositionEntryRecord[], + ): Promise { + if (record.disabled || !record.entry.packageId) return; + const manifest = (await this.packages.load(record.entry.packageId)).manifest; + for (const dependency of manifest.dependencies) { + if ( + !records.some( + (candidate) => + candidate.rootId === record.rootId && + !candidate.disabled && + candidate.entry.packageId === dependency.id, + ) + ) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Required dependency ${dependency.id} is not active in ${record.rootId}`, + ); + } + } + } + + async #convergeDesired(): Promise { + const desired = this.desiredSnapshot(); + const failures = await this.#recoverDesiredRuntime(desired); + await this.#publishEntryFailures(failures); + } + + async #recoverDesiredRuntime( + desired: MakaCompositionSnapshot, + ): Promise { + let failures = new Map( + (await this.#desiredValidationFailures(desired)).map((failure) => [failure.entryId, failure]), + ); + for (;;) { + const recovered = await this.composition.recoverSnapshot( + withoutEntries(desired, new Set(failures.keys())), + ); + for (const failure of recovered) failures.set(failure.entryId, failure); + const expanded = await this.#expandDependencyFailures(desired, [...failures.values()]); + if (expanded.length === failures.size) return Object.freeze([...failures.values()]); + failures = new Map(expanded.map((failure) => [failure.entryId, failure])); + } + } + + async #expandDependencyFailures( + snapshot: MakaCompositionSnapshot, + initial: readonly MakaCompositionRecoveryFailure[], + ): Promise { + const failures = new Map(initial.map((failure) => [failure.entryId, failure])); + const records = compositionEntryRecords(snapshot); + let changed = true; + while (changed) { + changed = false; + for (const record of records) { + if (record.disabled || !record.entry.packageId || failures.has(record.entry.id)) continue; + const manifest = (await this.packages.load(record.entry.packageId)).manifest; + for (const dependency of manifest.dependencies) { + const candidates = records.filter( + (candidate) => + candidate.rootId === record.rootId && + !candidate.disabled && + candidate.entry.packageId === dependency.id, + ); + if (candidates.length > 0 && candidates.every(({ entry }) => failures.has(entry.id))) { + failures.set( + record.entry.id, + Object.freeze({ + entryId: record.entry.id, + diagnostic: `Required dependency ${dependency.id} failed in ${record.rootId}`, + }), + ); + changed = true; + break; + } + } + } + } + return Object.freeze([...failures.values()]); + } + + async #desiredPackageDependent(extensionId: string): Promise { + const dependsOn = async (packageId: string, visited: Set): Promise => { + if (packageId === extensionId) return true; + if (visited.has(packageId)) return false; + visited.add(packageId); + const manifest = (await this.packages.load(packageId)).manifest; + for (const dependency of manifest.dependencies) { + if (await dependsOn(dependency.id, visited)) return true; + } + return false; + }; + for (const entry of compositionEntries(this.desiredSnapshot())) { + if ( + entry.packageId && + entry.packageId !== extensionId && + entry.disabled !== true && + (await dependsOn(entry.packageId, new Set())) + ) { + return entry; + } + } + return undefined; + } + + async #publishEntryFailures(failures: readonly MakaCompositionRecoveryFailure[]): Promise { + const packageFailures = this.#failures.filter((failure) => failure.entryId === undefined); + this.#failures = Object.freeze([ + ...packageFailures, + ...failures.map((failure) => + Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), + ), + ]); + this.#diverged = failures.length > 0; + const diagnosed = withEntryFailures(this.#desired, failures); + this.#desired = diagnosed; + try { + await this.store.replace(diagnosed); + } catch (error) { + this.composition.root.logger.warn('Unable to persist Plugin recovery diagnostics', error); + } + } + + async #releaseGeneration(pkg: MakaPluginPackage): Promise { + try { + await this.packageLoader.release(pkg); + } catch (error) { + this.composition.root.logger.warn('Unable to remove retired Plugin generation', error); + } + } + + #clearPackageFailure(extensionId: string): void { + this.#failures = Object.freeze( + this.#failures.filter((failure) => failure.extensionId !== extensionId), + ); + } + + #assertOpen(): void { + if (this.#closed) throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); + if (this.#poisoned) { + throw new HostPluginPlatformError('recovery_failed', 'Plugin Platform is fenced', { + cause: this.#poisoned, + }); + } + } + + #assertMutable(): void { + this.#assertOpen(); + if (this.#draining) throw new HostPluginPlatformError('closed', 'Plugin Platform is draining'); + } + + #serialize(operation: () => Promise): Promise { + const result = this.#mutation.then(operation, operation); + this.#mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function emptyComposition(): PersistedPluginComposition { + return Object.freeze({ + schemaVersion: 1, + generation: 0, + roots: Object.freeze({ + profile: Object.freeze([]), + desktopUi: Object.freeze([]), + sessions: Object.freeze({}), + }), + }); +} + +function runtimeSnapshot(composition: PersistedPluginComposition): MakaCompositionSnapshot { + return Object.freeze({ + schemaVersion: 1, + generation: composition.generation, + roots: Object.freeze({ + profile: Object.freeze(composition.roots.profile.map(runtimeEntry)), + desktopUi: Object.freeze(composition.roots.desktopUi.map(runtimeEntry)), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(composition.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + Object.freeze(entries.map(runtimeEntry)), + ]), + ), + ), + }), + }); +} + +function runtimeEntry(entry: PersistedPluginEntry): MakaCompositionEntry { + return Object.freeze({ + id: entry.id, + ...(entry.packageId ? { packageId: entry.packageId } : {}), + config: entry.config, + disabled: entry.disabled, + ...(entry.inject === undefined ? {} : { inject: entry.inject }), + ...(entry.isolate === undefined ? {} : { isolate: entry.isolate }), + ...(entry.intercept === undefined ? {} : { intercept: entry.intercept }), + ...(entry.children === undefined + ? {} + : { children: Object.freeze(entry.children.map(runtimeEntry)) }), + }); +} + +function persistedSnapshot(snapshot: MakaCompositionSnapshot): PersistedPluginComposition { + return Object.freeze({ + schemaVersion: 1, + generation: snapshot.generation, + roots: Object.freeze({ + profile: Object.freeze(snapshot.roots.profile.map(persistedEntry)), + desktopUi: Object.freeze(snapshot.roots.desktopUi.map(persistedEntry)), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(snapshot.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + Object.freeze(entries.map(persistedEntry)), + ]), + ), + ), + }), + }); +} + +function persistedEntry(entry: MakaCompositionEntry): PersistedPluginEntry { + return Object.freeze({ + id: entry.id, + ...(entry.packageId ? { packageId: entry.packageId } : {}), + disabled: entry.disabled === true, + config: scalarConfiguration(entry.config), + ...(entry.inject === undefined ? {} : { inject: entry.inject }), + ...(entry.isolate === undefined ? {} : { isolate: entry.isolate }), + ...(entry.intercept === undefined ? {} : { intercept: entry.intercept }), + ...(entry.children === undefined + ? {} + : { children: Object.freeze(entry.children.map(persistedEntry)) }), + }); +} + +function withEntryFailures( + composition: PersistedPluginComposition, + failures: readonly MakaCompositionRecoveryFailure[], +): PersistedPluginComposition { + const diagnostics = new Map(failures.map((failure) => [failure.entryId, failure.diagnostic])); + const annotate = (entry: PersistedPluginEntry): PersistedPluginEntry => + Object.freeze({ + ...entry, + ...(diagnostics.has(entry.id) ? { error: diagnostics.get(entry.id)! } : { error: null }), + ...(entry.children ? { children: Object.freeze(entry.children.map(annotate)) } : {}), + }); + return Object.freeze({ + schemaVersion: 1, + generation: composition.generation, + roots: Object.freeze({ + profile: Object.freeze(composition.roots.profile.map(annotate)), + desktopUi: Object.freeze(composition.roots.desktopUi.map(annotate)), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(composition.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + Object.freeze(entries.map(annotate)), + ]), + ), + ), + }), + }); +} + +function compositionEntries(snapshot: MakaCompositionSnapshot): readonly MakaCompositionEntry[] { + const walk = (entries: readonly MakaCompositionEntry[]): MakaCompositionEntry[] => + entries.flatMap((entry) => [entry, ...walk(entry.children ?? [])]); + return [ + ...walk(snapshot.roots.profile), + ...walk(snapshot.roots.desktopUi), + ...Object.values(snapshot.roots.sessions).flatMap(walk), + ]; +} + +function findCompositionEntry( + snapshot: MakaCompositionSnapshot, + entryId: string, +): MakaCompositionEntry | undefined { + return compositionEntries(snapshot).find((entry) => entry.id === entryId); +} + +function snapshotWithGeneration( + snapshot: MakaCompositionSnapshot, + generation: number, +): MakaCompositionSnapshot { + return Object.freeze({ ...snapshot, generation }); +} + +function compositionEntryRecords( + snapshot: MakaCompositionSnapshot, +): readonly CompositionEntryRecord[] { + const records: CompositionEntryRecord[] = []; + const visit = ( + entries: readonly MakaCompositionEntry[], + rootId: MakaPluginRootId, + ancestorDisabled: boolean, + ): void => { + for (const entry of entries) { + const disabled = ancestorDisabled || entry.disabled === true; + records.push(Object.freeze({ entry, rootId, disabled })); + visit(entry.children ?? [], rootId, disabled); + } + }; + visit(snapshot.roots.profile, 'profile', false); + visit(snapshot.roots.desktopUi, 'desktop-ui', false); + for (const [scopeId, entries] of Object.entries(snapshot.roots.sessions)) { + visit(entries, `session:${scopeId}`, false); + } + return Object.freeze(records); +} + +function withoutEntries( + snapshot: MakaCompositionSnapshot, + excluded: ReadonlySet, +): MakaCompositionSnapshot { + const filter = (entries: readonly MakaCompositionEntry[]): readonly MakaCompositionEntry[] => + Object.freeze( + entries.flatMap((entry) => + excluded.has(entry.id) + ? [] + : [Object.freeze({ ...entry, children: filter(entry.children ?? []) })], + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: snapshot.generation, + roots: Object.freeze({ + profile: filter(snapshot.roots.profile), + desktopUi: filter(snapshot.roots.desktopUi), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(snapshot.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + filter(entries), + ]), + ), + ), + }), + }); +} + +function findPersistedEntry( + composition: PersistedPluginComposition, + predicate: (entry: PersistedPluginEntry) => boolean, +): PersistedPluginEntry | undefined { + const visit = (entries: readonly PersistedPluginEntry[]): PersistedPluginEntry | undefined => { + for (const entry of entries) { + if (predicate(entry)) return entry; + const child = visit(entry.children ?? []); + if (child) return child; + } + return undefined; + }; + return ( + visit(composition.roots.profile) ?? + visit(composition.roots.desktopUi) ?? + Object.values(composition.roots.sessions) + .map(visit) + .find((entry) => entry !== undefined) + ); +} + +function operationFailures( + input: MakaCompositionApplyInput, + error: unknown, +): readonly MakaCompositionRecoveryFailure[] { + const diagnostic = boundedDiagnostic(error); + const ids = new Set(); + for (const operation of input.operations) { + if (operation.type === 'insert') ids.add(operation.entry.id); + else ids.add(operation.entryId); + } + return Object.freeze([...ids].map((entryId) => Object.freeze({ entryId, diagnostic }))); +} + +function sameComposition( + left: PersistedPluginComposition, + right: PersistedPluginComposition, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function scalarConfiguration(value: unknown): Readonly> { + if (value === undefined) return Object.freeze({}); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new HostPluginCompositionStoreError( + 'invalid_state', + 'Plugin Entry config must be a scalar record', + ); + } + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + if ( + typeof item !== 'string' && + typeof item !== 'boolean' && + !(typeof item === 'number' && Number.isFinite(item)) + ) { + throw new HostPluginCompositionStoreError( + 'invalid_state', + `Plugin Entry config value is invalid: ${key}`, + ); + } + output[key] = item; + } + return Object.freeze(output); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function boundedDiagnostic(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.slice(0, 4096) || 'Plugin Platform operation failed'; +} diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts index 53e44f61ab..6a1dccfd94 100644 --- a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -19,9 +19,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Context, type Plugin } from '../plugin-kernel.js'; +import { Context, type Fiber, type Plugin } from '../plugin-kernel.js'; import { MakaCompositionLoader } from '../plugin-composition-loader.js'; import { + applyCompositionSnapshot, MakaPluginTransactionBuffer, type MakaCompositionEntry, type MakaPluginPackage, @@ -58,6 +59,46 @@ test('composition tree supports nested groups and repeated package instances', a await loader.close(); }); +test('package Entry descendants stay owned by the parent package Fiber', async () => { + const fibers = new Map(); + const capture = (ctx: Context) => { + fibers.set(ctx.maka!.entryId, ctx.fiber); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('parent-package', capture)); + await loader.install(pkg('child-package', capture)); + + await loader.create('profile', entry('parent-entry', 'parent-package')); + await loader.create('profile', entry('child-entry', 'child-package'), 'parent-entry'); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + + await loader.create('profile', { id: 'scope-group' }); + await loader.move('child-entry', 'scope-group'); + assert.equal(fibers.get('child-entry')?.parent, loader.root.fiber); + await loader.move('child-entry', 'parent-entry'); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + + await loader.recoverSnapshot({ + schemaVersion: 1, + generation: 7, + roots: { + profile: [ + { + ...entry('parent-entry', 'parent-package'), + children: [entry('child-entry', 'child-package')], + }, + ], + desktopUi: [], + sessions: {}, + }, + }); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + + await loader.reload(pkg('parent-package', capture)); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + await loader.close(); +}); + test('missing injected service enters pending and activates when provided', async () => { let started = 0; const plugin = Object.assign( @@ -670,6 +711,168 @@ test('failed composition batches restore the prior generation exactly', async () await loader.close(); }); +test('runtime snapshots compose profile and session Entries with stable frozen digests', async () => { + const loader = new MakaCompositionLoader(); + await loader.install({ + packageId: 'profile-package', + host: () => undefined, + contributions: [ + { id: 'zeta', kind: 'service' }, + { id: 'alpha', kind: 'tool' }, + ], + }); + await loader.install({ + packageId: 'session-package', + host: () => undefined, + contributions: [{ id: 'session-item', kind: 'hook' }], + }); + await loader.create('profile', entry('profile-entry', 'profile-package')); + await loader.create('session:one', entry('session-entry', 'session-package')); + await loader.create('session:one', { + ...entry('disabled-entry', 'session-package'), + disabled: true, + }); + + const first = loader.runtimeSnapshot('session:one'); + const repeated = loader.runtimeSnapshot('session:one'); + assert.equal(first.digest, repeated.digest); + assert.deepEqual( + first.entries.map(({ entryId }) => entryId), + ['profile-entry', 'session-entry'], + ); + assert.deepEqual( + first.entries[0]?.contributions, + [ + { id: 'zeta', kind: 'service' }, + { id: 'alpha', kind: 'tool' }, + ].sort((left, right) => left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id)), + ); + assert.ok(Object.isFrozen(first)); + assert.ok(Object.isFrozen(first.entries)); + + await loader.remove('session-entry'); + assert.equal(first.entries.length, 2, 'published Runtime snapshots remain immutable'); + assert.notEqual(loader.runtimeSnapshot('session:one').digest, first.digest); + await loader.close(); +}); + +test('package reload replaces every matching mount without restarting unrelated Entries', async () => { + const events: string[] = []; + const host = + (label: string): Plugin => + (ctx: Context) => { + events.push(`start:${label}:${ctx.maka!.entryId}`); + ctx.effect(() => () => events.push(`stop:${label}:${ctx.maka!.entryId}`), label); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('reload-target', host('old'))); + await loader.install(pkg('reload-bystander', host('bystander'))); + await loader.create('profile', entry('reload-one', 'reload-target')); + await loader.create('session:one', entry('reload-two', 'reload-target')); + await loader.create('profile', entry('reload-unrelated', 'reload-bystander')); + const unrelatedGeneration = loader.inspect('reload-unrelated').generation; + const desiredGeneration = loader.snapshot().generation; + + await loader.reload(pkg('reload-target', host('new'))); + + assert.equal(loader.inspect('reload-unrelated').generation, unrelatedGeneration); + assert.equal(loader.snapshot().generation, desiredGeneration); + assert.deepEqual( + events.filter((event) => event.startsWith('start:new')), + ['start:new:reload-one', 'start:new:reload-two'], + ); + assert.equal(events.includes('stop:bystander:reload-unrelated'), false); + await loader.close(); +}); + +test('partial recovery preserves desired generation and isolates failed siblings', async () => { + const loader = new MakaCompositionLoader(); + await loader.install(pkg('recoverable', () => undefined)); + const failures = await loader.recoverSnapshot({ + schemaVersion: 1, + generation: 7, + roots: { + profile: [entry('recovered-entry', 'recoverable'), entry('missing-entry', 'missing-package')], + desktopUi: [], + sessions: {}, + }, + }); + + assert.deepEqual( + failures.map(({ entryId }) => entryId), + ['missing-entry'], + ); + assert.equal(loader.inspect('recovered-entry').status, 'active'); + assert.throws(() => loader.inspect('missing-entry'), /not found/u); + assert.equal(loader.snapshot().generation, 7); + await loader.close(); +}); + +test('desired-state reducer applies dependent operations without activating code', () => { + const initial = { + schemaVersion: 1, + generation: 3, + roots: { + profile: [{ id: 'parent', children: [{ id: 'child' }] }], + desktopUi: [], + sessions: {}, + }, + } as const; + + const next = applyCompositionSnapshot(initial, { + baseGeneration: 3, + operations: [ + { type: 'update', entryId: 'parent', patch: { disabled: true } }, + { type: 'update', entryId: 'child', patch: { disabled: true } }, + { type: 'move', entryId: 'child', position: 0 }, + ], + }); + + assert.equal(next.generation, 4); + assert.deepEqual(next.roots.profile, [ + { id: 'child', disabled: true, children: [] }, + { id: 'parent', disabled: true, children: [] }, + ]); +}); + +test('desired-state reducer stays equivalent to live Entry Tree batch semantics', async () => { + const loader = new MakaCompositionLoader(); + await loader.replaceSnapshot({ + schemaVersion: 1, + generation: 4, + roots: { + profile: [ + { id: 'equivalence-a', children: [{ id: 'equivalence-a1' }, { id: 'equivalence-a2' }] }, + { id: 'equivalence-b' }, + ], + desktopUi: [], + sessions: {}, + }, + }); + const before = loader.snapshot(); + const input = { + baseGeneration: before.generation, + operations: [ + { type: 'update', entryId: 'equivalence-a', patch: { disabled: true } }, + { + type: 'insert', + parentId: 'equivalence-a', + position: 1, + entry: { id: 'equivalence-a3' }, + }, + { type: 'move', entryId: 'equivalence-a2', parentId: 'equivalence-b' }, + { type: 'remove', entryId: 'equivalence-a1' }, + { type: 'update', entryId: 'equivalence-a3', patch: { disabled: true } }, + ], + } as const; + + const planned = applyCompositionSnapshot(before, input); + await loader.apply(input); + + assert.deepEqual(loader.snapshot(), planned); + await loader.close(); +}); + function pkg(packageId: string, host: Plugin): MakaPluginPackage { return Object.freeze({ packageId, host }); } diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index abb887437c..f897da3f10 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -17,7 +17,8 @@ * under the License. */ -import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; +import { createHash } from 'node:crypto'; +import { Context, type Fiber, FiberState, type Inject, type Plugin } from './plugin-kernel.js'; import { fiberStateName, type MakaCompositionEntry, @@ -27,6 +28,8 @@ import { type MakaPluginMetadata, type MakaPluginPackage, type MakaPluginRootId, + type MakaRuntimeCompositionEntry, + type MakaRuntimeCompositionSnapshot, MakaPluginRuntimeError, MakaPluginTransactionBuffer, type MakaPluginTransaction, @@ -60,6 +63,11 @@ export interface MakaCompositionLoaderOptions { readonly transaction?: (context: Context) => MakaPluginTransaction | undefined; } +export interface MakaCompositionRecoveryFailure { + readonly entryId: string; + readonly diagnostic: string; +} + export class MakaCompositionLoader { readonly root: Context; readonly #packages = new Map(); @@ -89,6 +97,26 @@ export class MakaCompositionLoader { }); } + reload(pkg: MakaPluginPackage): Promise { + return this.#mutate(async () => { + validatePluginPackage(pkg); + const previous = this.#packages.get(pkg.packageId); + if (!previous) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${pkg.packageId}`, + ); + } + this.#packages.set(pkg.packageId, freezePackage(pkg)); + try { + await this.#reloadPackage(pkg.packageId); + } catch (error) { + this.#packages.set(pkg.packageId, previous); + throw error; + } + }); + } + uninstall(packageId: string): Promise { return this.#mutate(async () => { if (!this.#packages.has(packageId)) { @@ -310,10 +338,175 @@ export class MakaCompositionLoader { }); } + /** Immutable active-package projection for one Runtime root. */ + runtimeSnapshot(rootId: MakaPluginRootId): MakaRuntimeCompositionSnapshot { + validatePluginRootId(rootId); + const roots = rootId.startsWith('session:') ? (['profile', rootId] as const) : [rootId]; + const entries: MakaRuntimeCompositionEntry[] = []; + for (const currentRoot of roots) { + for (const entry of this.#roots.get(currentRoot)?.entries ?? []) { + for (const live of walkLive(entry)) { + if ( + !live.spec.packageId || + live.fiber?.state !== FiberState.ACTIVE || + live.generation === undefined + ) { + continue; + } + const pkg = this.#packages.get(live.spec.packageId); + if (!pkg) continue; + const contributions = Object.freeze( + [...(pkg.contributions ?? [])] + .map((item) => Object.freeze({ ...item })) + .sort( + (left, right) => + left.kind.localeCompare(right.kind) || left.id.localeCompare(right.id), + ), + ); + entries.push( + Object.freeze({ + entryId: live.spec.id, + packageId: live.spec.packageId, + generation: live.generation, + contributions, + }), + ); + } + } + } + entries.sort( + (left, right) => + left.packageId.localeCompare(right.packageId) || left.entryId.localeCompare(right.entryId), + ); + const frozen = Object.freeze(entries); + const digest = createHash('sha256').update(JSON.stringify(frozen)).digest('hex'); + return Object.freeze({ + schemaVersion: 1, + rootId, + digest: `sha256:${digest}`, + entries: frozen, + }); + } + replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish')); } + /** Restores an externally uncommitted mutation without advancing its generation. */ + restoreSnapshot(snapshot: MakaCompositionSnapshot): Promise { + return this.#mutate(() => this.#replaceSnapshot(snapshot, 'rollback')); + } + + /** + * Recovers as much of a durable desired tree as possible. A failed Entry + * does not prevent unrelated roots or siblings from becoming active. + */ + recoverSnapshot( + snapshot: MakaCompositionSnapshot, + ): Promise { + return this.#mutate(async () => { + if (snapshot.schemaVersion !== 1) { + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + } + const failures: MakaCompositionRecoveryFailure[] = []; + const stagedRoots = new Map(); + const stagedIds = new Set(); + const specs = new Map([ + ['profile', snapshot.roots.profile], + ['desktop-ui', snapshot.roots.desktopUi], + ...Object.entries(snapshot.roots.sessions).map( + ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, + ), + ]); + const recoverEntry = async ( + spec: MakaCompositionEntry, + rootId: MakaPluginRootId, + parent: LiveEntry | undefined, + parentContext: Context, + ancestorDisabled: boolean, + ): Promise => { + for (const item of walk(spec)) { + if (stagedIds.has(item.id)) { + failures.push( + Object.freeze({ + entryId: spec.id, + diagnostic: `Composition entry already exists: ${item.id}`, + }), + ); + return undefined; + } + } + const shallow = freezeEntry({ ...spec, children: [] }); + let live: LiveEntry | undefined; + try { + validateCompositionEntry(shallow); + live = await this.#stage(shallow, rootId, parent, parentContext, ancestorDisabled); + await this.#commitSubtree(live); + } catch (error) { + if (live) await this.#dispose(live).catch(() => undefined); + failures.push( + Object.freeze({ entryId: spec.id, diagnostic: diagnostic(error).slice(0, 4096) }), + ); + return undefined; + } + stagedIds.add(spec.id); + const disabled = ancestorDisabled || spec.disabled === true; + for (const child of spec.children ?? []) { + const recovered = await recoverEntry( + child, + rootId, + live, + childMountContext(live), + disabled, + ); + if (recovered) live.children.push(recovered); + } + live.spec = freezeEntry({ ...live.spec, children: live.children.map(serialize) }); + return live; + }; + + try { + for (const [rootId, entries] of specs) { + validatePluginRootId(rootId); + const context = this.root.extend({ makaRootId: rootId }); + const root: LiveRoot = { id: rootId, context, entries: [] }; + stagedRoots.set(rootId, root); + for (const spec of entries) { + const recovered = await recoverEntry(spec, rootId, undefined, context, false); + if (recovered) root.entries.push(recovered); + } + } + } catch (error) { + await settleAll( + [...stagedRoots.values()].flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Recovered composition cleanup failed', + ); + throw error; + } + + const previous = [...this.#roots.values()]; + this.#roots.clear(); + this.#entries.clear(); + for (const [rootId, root] of stagedRoots) { + this.#roots.set(rootId, root); + for (const entry of root.entries) this.#index(entry); + } + this.#compositionGeneration = snapshot.generation; + await this.#retire( + settleAll( + previous.flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Previous composition cleanup failed', + ), + 'Previous composition cleanup failed after recovering desired state', + ); + return Object.freeze(failures); + }); + } + async #replaceSnapshot( snapshot: MakaCompositionSnapshot, generationMode: 'publish' | 'rollback', @@ -416,7 +609,9 @@ export class MakaCompositionLoader { current: LiveEntry, spec: MakaCompositionEntry, ): Promise { - const parentContext = current.parent?.context ?? this.#root(current.rootId).context; + const parentContext = current.parent + ? childMountContext(current.parent) + : this.#root(current.rootId).context; const candidate = await this.#stage( spec, current.rootId, @@ -446,12 +641,59 @@ export class MakaCompositionLoader { return this.#inspect(candidate); } + async #reloadPackage(packageId: string): Promise { + const affected = [...this.#entries.values()].filter( + (entry) => + entry.spec.packageId === packageId && + ![...ancestors(entry)].some((ancestor) => ancestor.spec.packageId === packageId), + ); + if (!affected.length) return; + const candidates: { readonly current: LiveEntry; readonly replacement: LiveEntry }[] = []; + try { + for (const current of affected) { + const replacement = await this.#stage( + serialize(current), + current.rootId, + current.parent, + current.parent ? childMountContext(current.parent) : this.#root(current.rootId).context, + current.parent ? isDisabled(current.parent) : false, + ); + candidates.push({ current, replacement }); + } + for (const { replacement } of candidates) await this.#commitSubtree(replacement); + } catch (error) { + return rethrowAfterCleanup( + error, + () => + settleAll( + candidates.map(({ replacement }) => this.#dispose(replacement)), + `Plugin package ${packageId} candidate cleanup failed`, + ), + `Plugin package ${packageId} reload and cleanup failed`, + ); + } + for (const { current, replacement } of candidates) { + const siblings = current.parent?.children ?? this.#root(current.rootId).entries; + const index = siblings.indexOf(current); + this.#unindex(current); + siblings[index] = replacement; + this.#index(replacement); + } + await this.#retire( + settleAll( + candidates.map(({ current }) => this.#dispose(current)), + `Plugin package ${packageId} previous generation cleanup failed`, + ), + `Plugin package ${packageId} cleanup failed after publishing its replacement`, + ); + } + async #rebind(entry: LiveEntry, parent: LiveEntry | undefined, position: number): Promise { const replacement = await this.#stage( serialize(entry), entry.rootId, parent, - parent?.context ?? this.#root(entry.rootId).context, + parent ? childMountContext(parent) : this.#root(entry.rootId).context, parent ? isDisabled(parent) : false, ); try { @@ -547,8 +789,11 @@ export class MakaCompositionLoader { } } try { - for (const child of spec.children ?? []) - live.children.push(await this.#stage(child, rootId, live, live.context, disabled)); + for (const child of spec.children ?? []) { + live.children.push( + await this.#stage(child, rootId, live, childMountContext(live), disabled), + ); + } } catch (error) { return rethrowAfterCleanup( error, @@ -632,7 +877,7 @@ export class MakaCompositionLoader { entry, rootId, parent, - parent?.context ?? root.context, + parent ? childMountContext(parent) : root.context, parent ? isDisabled(parent) : false, ); try { @@ -864,6 +1109,24 @@ function* walk(entry: MakaCompositionEntry): Generator { for (const child of entry.children ?? []) yield* walk(child); } +function* walkLive(entry: LiveEntry): Generator { + yield entry; + for (const child of entry.children) yield* walkLive(child); +} + +function* ancestors(entry: LiveEntry): Generator { + for (let current = entry.parent; current; current = current.parent) yield current; +} + +/** + * Package Entries introduce a Fiber ownership boundary. Their descendants + * must mount through that Fiber's Context; scope-only Entries keep using the + * Context view owned by their nearest package ancestor (or the root Fiber). + */ +function childMountContext(entry: LiveEntry): Context { + return entry.fiber?.context ?? entry.context; +} + function isWithin(entry: LiveEntry, root: LiveEntry): boolean { for (let current: LiveEntry | undefined = entry; current; current = current.parent) if (current === root) return true; diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts index 06fbfe0408..58af7bc58a 100644 --- a/packages/runtime/src/plugin-runtime.ts +++ b/packages/runtime/src/plugin-runtime.ts @@ -82,6 +82,202 @@ export interface MakaCompositionApplyInput { readonly operations: readonly MakaCompositionOperation[]; } +/** + * Applies Entry Tree operations to the desired-state value without activating + * Plugin code. Runtime Host uses this reducer to durably commit desired state + * before asking the live Composition Loader to converge. + */ +export function applyCompositionSnapshot( + snapshot: MakaCompositionSnapshot, + input: MakaCompositionApplyInput, +): MakaCompositionSnapshot { + if (snapshot.schemaVersion !== 1) { + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + } + if (input.baseGeneration !== undefined && input.baseGeneration !== snapshot.generation) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${snapshot.generation}`, + ); + } + if (input.operations.length === 0) return snapshot; + if (snapshot.generation >= Number.MAX_SAFE_INTEGER) { + throw new MakaPluginRuntimeError('invalid_entry', 'Composition generation is exhausted'); + } + + interface MutableLocation { + entry: MakaCompositionEntry; + parent?: MutableLocation; + readonly rootId: MakaPluginRootId; + siblings: MakaCompositionEntry[]; + } + + const profile = snapshot.roots.profile.map(cloneCompositionEntry); + const desktopUi = snapshot.roots.desktopUi.map(cloneCompositionEntry); + const sessions = Object.fromEntries( + Object.entries(snapshot.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + entries.map(cloneCompositionEntry), + ]), + ) as Record; + const locations = new Map(); + + const index = ( + entries: MakaCompositionEntry[], + rootId: MakaPluginRootId, + parent?: MutableLocation, + ): void => { + validatePluginRootId(rootId); + for (const entry of entries) { + validateCompositionEntry(entry); + if (locations.has(entry.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${entry.id}`, + ); + } + const location: MutableLocation = { entry, parent, rootId, siblings: entries }; + locations.set(entry.id, location); + index(entry.children as MakaCompositionEntry[], rootId, location); + } + }; + index(profile, 'profile'); + index(desktopUi, 'desktop-ui'); + for (const [scopeId, entries] of Object.entries(sessions)) { + index(entries, `session:${scopeId}`); + } + + const requireLocation = (entryId: string): MutableLocation => { + const location = locations.get(entryId); + if (!location) { + throw new MakaPluginRuntimeError( + 'entry_not_found', + `Composition entry not found: ${entryId}`, + ); + } + return location; + }; + const rootEntries = (rootId: MakaPluginRootId): MakaCompositionEntry[] => { + validatePluginRootId(rootId); + if (rootId === 'profile') return profile; + if (rootId === 'desktop-ui') return desktopUi; + const scopeId = rootId.slice('session:'.length); + return (sessions[scopeId] ??= []); + }; + const unindex = (entry: MakaCompositionEntry): void => { + locations.delete(entry.id); + for (const child of entry.children ?? []) unindex(child); + }; + const indexInserted = ( + entry: MakaCompositionEntry, + rootId: MakaPluginRootId, + siblings: MakaCompositionEntry[], + parent?: MutableLocation, + ): void => { + if (locations.has(entry.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${entry.id}`, + ); + } + const location: MutableLocation = { entry, parent, rootId, siblings }; + locations.set(entry.id, location); + for (const child of entry.children ?? []) { + indexInserted(child, rootId, entry.children as MakaCompositionEntry[], location); + } + }; + + for (const operation of input.operations) { + switch (operation.type) { + case 'insert': { + const parent = operation.parentId ? requireLocation(operation.parentId) : undefined; + const rootId = operation.rootId ?? parent?.rootId ?? 'profile'; + validatePluginRootId(rootId); + if (parent && parent.rootId !== rootId) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + } + const entry = cloneCompositionEntry(operation.entry); + validateCompositionEntry(entry); + const subtreeIds = new Set(); + for (const item of walkCompositionEntry(entry)) { + if (subtreeIds.has(item.id) || locations.has(item.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + } + subtreeIds.add(item.id); + } + const siblings = parent + ? (parent.entry.children as MakaCompositionEntry[]) + : rootEntries(rootId); + siblings.splice(Math.min(operation.position ?? Infinity, siblings.length), 0, entry); + indexInserted(entry, rootId, siblings, parent); + break; + } + case 'update': { + const location = requireLocation(operation.entryId); + const next: MakaCompositionEntry = { + ...location.entry, + ...operation.patch, + id: location.entry.id, + children: location.entry.children, + }; + validateCompositionEntry(next); + const position = location.siblings.indexOf(location.entry); + location.siblings[position] = next; + location.entry = next; + break; + } + case 'move': { + const location = requireLocation(operation.entryId); + const parent = operation.parentId ? requireLocation(operation.parentId) : undefined; + if (parent && parent.rootId !== location.rootId) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + } + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor === location) { + throw new MakaPluginRuntimeError( + 'dependency_cycle', + `Entry ${operation.entryId} cannot contain itself`, + ); + } + } + location.siblings.splice(location.siblings.indexOf(location.entry), 1); + const siblings = parent + ? (parent.entry.children as MakaCompositionEntry[]) + : rootEntries(location.rootId); + siblings.splice( + Math.min(operation.position ?? Infinity, siblings.length), + 0, + location.entry, + ); + location.parent = parent; + location.siblings = siblings; + break; + } + case 'remove': { + const location = requireLocation(operation.entryId); + location.siblings.splice(location.siblings.indexOf(location.entry), 1); + unindex(location.entry); + break; + } + } + } + + return freezeCompositionSnapshot({ + schemaVersion: 1, + generation: snapshot.generation + 1, + roots: { profile, desktopUi, sessions }, + }); +} + export type MakaCompositionEntryStatus = | 'disabled' | 'pending' @@ -202,6 +398,40 @@ export function validatePluginPackage(pkg: MakaPluginPackage): void { `Plugin package ${pkg.packageId} has no host or client plugin`, ); } + if (!Array.isArray(pkg.contributions ?? []) || (pkg.contributions?.length ?? 0) > 1024) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} has invalid contributions`, + ); + } + const contributions = new Set(); + for (const contribution of pkg.contributions ?? []) { + if ( + !contribution || + typeof contribution !== 'object' || + typeof contribution.id !== 'string' || + contribution.id.length === 0 || + contribution.id.length > 128 || + /[\u0000-\u001f\u007f]/u.test(contribution.id) || + typeof contribution.kind !== 'string' || + contribution.kind.length === 0 || + contribution.kind.length > 128 || + /[\u0000-\u001f\u007f]/u.test(contribution.kind) + ) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} has an invalid contribution`, + ); + } + const identity = `${contribution.kind}\0${contribution.id}`; + if (contributions.has(identity)) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} repeats contribution ${contribution.kind}:${contribution.id}`, + ); + } + contributions.add(identity); + } } export function validateCompositionEntry(entry: MakaCompositionEntry): void { @@ -378,6 +608,56 @@ export function isCanonicalPluginId(value: unknown): value is string { export const isCanonicalExtensionId = isCanonicalPluginId; +function cloneCompositionEntry(entry: MakaCompositionEntry): MakaCompositionEntry { + return { + ...entry, + ...(entry.inject && !Array.isArray(entry.inject) + ? { inject: { ...entry.inject } } + : entry.inject + ? { inject: [...entry.inject] } + : {}), + ...(entry.isolate ? { isolate: { ...entry.isolate } } : {}), + ...(entry.intercept ? { intercept: { ...entry.intercept } } : {}), + children: (entry.children ?? []).map(cloneCompositionEntry), + }; +} + +function* walkCompositionEntry(entry: MakaCompositionEntry): Generator { + yield entry; + for (const child of entry.children ?? []) yield* walkCompositionEntry(child); +} + +function freezeCompositionSnapshot(snapshot: MakaCompositionSnapshot): MakaCompositionSnapshot { + const freezeEntry = (entry: MakaCompositionEntry): MakaCompositionEntry => + Object.freeze({ + ...entry, + ...(entry.inject && !Array.isArray(entry.inject) + ? { inject: Object.freeze({ ...entry.inject }) } + : entry.inject + ? { inject: Object.freeze([...entry.inject]) } + : {}), + ...(entry.isolate ? { isolate: Object.freeze({ ...entry.isolate }) } : {}), + ...(entry.intercept ? { intercept: Object.freeze({ ...entry.intercept }) } : {}), + children: Object.freeze((entry.children ?? []).map(freezeEntry)), + }); + return Object.freeze({ + schemaVersion: 1, + generation: snapshot.generation, + roots: Object.freeze({ + profile: Object.freeze(snapshot.roots.profile.map(freezeEntry)), + desktopUi: Object.freeze(snapshot.roots.desktopUi.map(freezeEntry)), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(snapshot.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + Object.freeze(entries.map(freezeEntry)), + ]), + ), + ), + }), + }); +} + export function isCanonicalExtensionScopeId(value: unknown): value is string { return ( typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value)