diff --git a/package-lock.json b/package-lock.json index 5481703044..caef6dc816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13983,6 +13983,7 @@ "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", "ws": "^8.21.3", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index abd195c545..6547dbe2ee 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -32,6 +32,7 @@ import { type RuntimeHostAccessIssueOptions, } from '../runtime-host-access-command.js'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; +import { runRuntimeHostPluginCli } from '../runtime-host-plugin-command.js'; import { runRuntimeHostProjectCli } from '../runtime-host-project-command.js'; import { createRuntimeHostServiceReadyEvent } from '../runtime-host-service-command.js'; @@ -53,6 +54,24 @@ describe('Runtime Host operator commands', () => { prefer: false, }, ); + assert.deepEqual( + parseRuntimeHostCommand(['plugin', 'inspect', '--scope', 'profile', '--limit', '16']), + { + kind: 'runtime-host-plugin', + action: 'inspect', + rootId: 'profile', + limit: 16, + }, + ); + assert.deepEqual( + parseRuntimeHostCommand(['plugin', 'export', 'fixture-plugin', './fixture.maka-extension']), + { + kind: 'runtime-host-plugin', + action: 'export', + subject: 'fixture-plugin', + targetPath: './fixture.maka-extension', + }, + ); assert.deepEqual( parseRuntimeHostCommand([ 'project', @@ -318,6 +337,59 @@ describe('Runtime Host operator commands', () => { ['project-1', 'project-1'], ); }); + + test('uses every Plugin Platform surface through the Runtime Host', async () => { + const requests: unknown[] = []; + let closeCount = 0; + const connection = { + request: async (operation: string, input: unknown) => { + requests.push({ operation, input }); + return {}; + }, + close: async () => { + closeCount += 1; + }, + } as unknown as RuntimeHostConnection; + const overrides = { + connect: async () => connection, + readText: async () => '{"operations":[{"type":"remove","entryId":"entry-one"}]}', + write: () => undefined, + }; + const commands = [ + { rootPath: '/srv/maka', action: 'status' as const }, + { rootPath: '/srv/maka', action: 'list' as const }, + { rootPath: '/srv/maka', action: 'inspect' as const, rootId: 'profile' }, + { rootPath: '/srv/maka', action: 'failures' as const }, + { rootPath: '/srv/maka', action: 'install' as const, subject: './plugin' }, + { rootPath: '/srv/maka', action: 'uninstall' as const, subject: 'plugin' }, + { rootPath: '/srv/maka', action: 'reload' as const, subject: 'plugin' }, + { + rootPath: '/srv/maka', + action: 'export' as const, + subject: 'plugin', + targetPath: './plugin.maka-extension', + }, + { rootPath: '/srv/maka', action: 'apply' as const, subject: './operations.json' }, + ]; + for (const command of commands) { + assert.equal(await runRuntimeHostPluginCli(command, overrides), 0); + } + assert.deepEqual( + requests.map((request) => (request as { operation: string }).operation), + [ + 'plugin.platform.query', + 'plugin.platform.query', + 'plugin.platform.query', + 'plugin.platform.query', + 'plugin.package.install', + 'plugin.package.uninstall', + 'plugin.package.reload', + 'plugin.package.export', + 'plugin.composition.apply', + ], + ); + assert.equal(closeCount, commands.length); + }); }); function presetOptions( diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 4189bd1d6a..584f454802 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -133,6 +133,10 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host access revoke --credential `, ` ${cliCommand} runtime-host project list [--root ]`, ` ${cliCommand} runtime-host project add [--prefer] [--root ]`, + ` ${cliCommand} runtime-host plugin status|list|inspect|failures [--root ]`, + ` ${cliCommand} runtime-host plugin install|uninstall|reload [--root ]`, + ` ${cliCommand} runtime-host plugin export [--root ]`, + ` ${cliCommand} runtime-host plugin apply [--root ]`, ` ${cliCommand} runtime-host profile list`, ` ${cliCommand} runtime-host profile set --id --name --tls-url --expected-root [--credential-env ]`, ` ${cliCommand} runtime-host profile set --id --name --ssh-destination --ssh-remote-port --expected-root [--ssh-port ] [--credential-env ]`, @@ -419,6 +423,18 @@ export async function runMakaCli( prefer: command.prefer, }); } + case 'runtime-host-plugin': { + const { runRuntimeHostPluginCli } = await import('./runtime-host-plugin-command.js'); + return runRuntimeHostPluginCli({ + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + action: command.action, + ...(command.subject ? { subject: command.subject } : {}), + ...(command.targetPath ? { targetPath: command.targetPath } : {}), + ...(command.rootId ? { rootId: command.rootId } : {}), + ...(command.cursor === undefined ? {} : { cursor: command.cursor }), + ...(command.limit === undefined ? {} : { limit: command.limit }), + }); + } case 'runtime-host-capability-provider-serve': { const { runRuntimeHostCapabilityProviderCli } = await import( './runtime-host-capability-provider-command.js' diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index d00a333ae6..17ccd816a1 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -145,6 +145,25 @@ export type RuntimeHostCliCommand = } | { kind: 'runtime-host-project-list'; rootPath?: string } | { kind: 'runtime-host-project-add'; rootPath?: string; path: string; prefer: boolean } + | { + kind: 'runtime-host-plugin'; + rootPath?: string; + action: + | 'status' + | 'list' + | 'inspect' + | 'failures' + | 'install' + | 'uninstall' + | 'reload' + | 'export' + | 'apply'; + subject?: string; + targetPath?: string; + rootId?: string; + cursor?: number; + limit?: number; + } | { kind: 'runtime-host-capability-provider-serve'; url: string; @@ -184,6 +203,7 @@ export function parseRuntimeHostCommand(argv: string[]): RuntimeHostCliCommand { if (argv[0] === 'service') return parseServiceManagementCommand(argv.slice(1)); if (argv[0] === 'access') return parseAccessCommand(argv.slice(1)); if (argv[0] === 'project') return parseProjectCommand(argv.slice(1)); + if (argv[0] === 'plugin') return parsePluginCommand(argv.slice(1)); if (argv[0] === 'capability-provider') { return parseCapabilityProviderCommand(argv.slice(1)); } @@ -191,7 +211,7 @@ export function parseRuntimeHostCommand(argv: string[]): RuntimeHostCliCommand { return error( argv[0] ? `Unexpected runtime-host command: ${argv[0]}` - : 'runtime-host requires the serve, setup, service, access, project, profile, or capability-provider command', + : 'runtime-host requires the serve, setup, service, access, project, plugin, profile, or capability-provider command', ); } @@ -599,6 +619,91 @@ function parseProjectCommand(argv: string[]): RuntimeHostCliCommand { }; } +function parsePluginCommand(argv: string[]): RuntimeHostCliCommand { + const action = argv[0]; + const actions = [ + 'status', + 'list', + 'inspect', + 'failures', + 'install', + 'uninstall', + 'reload', + 'export', + 'apply', + ] as const; + if (!actions.includes(action as (typeof actions)[number])) { + return error( + action + ? `Unexpected runtime-host plugin command: ${action}` + : 'runtime-host plugin requires an action', + ); + } + let rootPath: string | undefined; + let rootId: string | undefined; + let cursor: number | undefined; + let limit: number | undefined; + const positional: string[] = []; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if ( + argument === '--root' || + argument === '--scope' || + argument === '--cursor' || + argument === '--limit' + ) { + const parsed = optionValue(argv, index, argument); + if (typeof parsed !== 'string') return parsed; + if (argument === '--root') rootPath = parsed; + else if (argument === '--scope') rootId = parsed; + else { + const numeric = Number(parsed); + if ( + !Number.isSafeInteger(numeric) || + numeric < 0 || + (argument === '--limit' && (numeric < 1 || numeric > 64)) + ) { + return error(`${argument} requires a non-negative integer`); + } + if (argument === '--cursor') cursor = numeric; + else limit = numeric; + } + index += 1; + continue; + } + positional.push(argument ?? ''); + } + const selected = action as (typeof actions)[number]; + const expected = + selected === 'export' + ? 2 + : ['install', 'uninstall', 'reload', 'apply'].includes(selected) + ? 1 + : 0; + if (positional.length !== expected) { + return error( + `runtime-host plugin ${selected} requires ${expected} target${expected === 1 ? '' : 's'}`, + ); + } + if (rootId && selected !== 'inspect') return error('--scope is only valid for plugin inspect'); + if ( + (cursor !== undefined || limit !== undefined) && + !['list', 'inspect', 'failures'].includes(selected) + ) { + return error('--cursor and --limit require a paged Plugin query'); + } + return { + kind: 'runtime-host-plugin', + action: selected, + ...(rootPath ? { rootPath } : {}), + ...(positional[0] ? { subject: positional[0] } : {}), + ...(positional[1] ? { targetPath: positional[1] } : {}), + ...(rootId ? { rootId } : {}), + ...(cursor === undefined ? {} : { cursor }), + ...(limit === undefined ? {} : { limit }), + }; +} + function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { const action = argv[0]; if (action === 'list') { diff --git a/packages/cli/src/runtime-host-plugin-command.ts b/packages/cli/src/runtime-host-plugin-command.ts new file mode 100644 index 0000000000..fe2f6be937 --- /dev/null +++ b/packages/cli/src/runtime-host-plugin-command.ts @@ -0,0 +1,138 @@ +/* + * 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 { connectExistingRuntimeHost, type RuntimeHostConnection } from '@maka/runtime-host/client'; +import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const PROTOCOL = { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, +} as const; + +export interface RuntimeHostPluginCommand { + readonly rootPath: string; + readonly action: + | 'status' + | 'list' + | 'inspect' + | 'failures' + | 'install' + | 'uninstall' + | 'reload' + | 'export' + | 'apply'; + readonly subject?: string; + readonly targetPath?: string; + readonly rootId?: string; + readonly cursor?: number; + readonly limit?: number; +} + +interface RuntimeHostPluginCommandDeps { + readonly connect: (rootPath: string) => Promise; + readonly readText: (path: string) => Promise; + readonly write: (value: string) => void; +} + +export async function runRuntimeHostPluginCli( + command: RuntimeHostPluginCommand, + overrides: Partial = {}, +): Promise { + const deps = { ...defaultDeps(), ...overrides }; + const connection = await deps.connect(command.rootPath); + try { + const result = await execute(connection, command, deps.readText); + deps.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; + } finally { + await connection.close(); + } +} + +async function execute( + connection: RuntimeHostConnection, + command: RuntimeHostPluginCommand, + readText: (path: string) => Promise, +): Promise { + const paging = { + ...(command.cursor === undefined ? {} : { cursor: command.cursor }), + ...(command.limit === undefined ? {} : { limit: command.limit }), + }; + switch (command.action) { + case 'status': + return await connection.request('plugin.platform.query', { view: 'status' }); + case 'list': + return await connection.request('plugin.platform.query', { view: 'packages', ...paging }); + case 'inspect': + return await connection.request('plugin.platform.query', { + view: 'entries', + ...paging, + ...(command.rootId ? { rootId: command.rootId } : {}), + } as never); + case 'failures': + return await connection.request('plugin.platform.query', { view: 'failures', ...paging }); + case 'install': + return await connection.request('plugin.package.install', { + sourcePath: resolve(requireSubject(command)), + }); + case 'uninstall': + return await connection.request('plugin.package.uninstall', { + extensionId: requireSubject(command), + }); + case 'reload': + return await connection.request('plugin.package.reload', { + extensionId: requireSubject(command), + }); + case 'export': + return await connection.request('plugin.package.export', { + extensionId: requireSubject(command), + targetPath: resolve(command.targetPath ?? missing('Plugin export target path')), + }); + case 'apply': { + const decoded = JSON.parse(await readText(resolve(requireSubject(command)))) as unknown; + return await connection.request('plugin.composition.apply', decoded as never); + } + } +} + +function requireSubject(command: RuntimeHostPluginCommand): string { + return command.subject ?? missing(`Plugin ${command.action} target`); +} + +function missing(label: string): never { + throw new Error(`${label} is missing`); +} + +function defaultDeps(): RuntimeHostPluginCommandDeps { + return { + connect: connectLocalOwner, + readText: (path) => readFile(path, 'utf8'), + write: (value) => process.stdout.write(value), + }; +} + +async function connectLocalOwner(rootPath: string): Promise { + const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL }); + if (result.kind !== 'connected') { + throw new Error(`Runtime Host service is not available (${result.kind})`); + } + return result.connection; +} diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index ade03c0c32..05184d9dba 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -27,6 +27,7 @@ "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", "ws": "^8.21.3", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { 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..93cae14c74 --- /dev/null +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -0,0 +1,977 @@ +/* + * 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 { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { + decodePluginCompositionApplyInput, + 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 { TrustedPluginPackageLoader } from '../server/plugin-package-loader.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', { + composition: [ + { + type: 'insert', + rootId: 'profile', + entry: { id: 'fixture-entry', packageId: 'fixture-package' }, + }, + ], + }); + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + + assert.deepEqual(await platform.installPackage(source), { extensionId: 'fixture-package' }); + const published = platform.composition.package('fixture-package'); + assert.deepEqual(published.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' }); + assert.equal(imported.inspect('profile')[0]?.id, 'fixture-entry'); + 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.desiredComposition().generation, 1); + assert.deepEqual(recovered.composition.package('fixture-package').contributions, [ + { id: 'first', kind: 'foundation-test' }, + ]); + assert.deepEqual(Object.keys((await recovered.store.read()) ?? {}).sort(), [ + 'generation', + 'overlays', + 'packageLayers', + 'schemaVersion', + ]); + 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', { + composition: [ + { + type: 'insert', + entry: { id: 'protocol-entry', packageId: 'protocol-package' }, + }, + ], + }); + 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 queried = await coordinator.handlers['plugin.platform.query']( + { view: 'packages' }, + null as never, + ); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.view === 'packages') { + assert.deepEqual( + queried.result.items.map(({ extensionId }) => extensionId), + ['protocol-package'], + ); + } + const entries = await coordinator.handlers['plugin.platform.query']( + { view: 'entries', rootId: 'profile' }, + null as never, + ); + assert.equal(entries.ok && entries.result.view === 'entries', true); + if (entries.ok && entries.result.view === 'entries') { + assert.equal(entries.result.items[0]?.id, '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('package Composition layers override in install order and unwind on uninstall', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-layers-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'layer-base', 'base', { + manifest: { + configuration: { + properties: { theme: { type: 'string', default: 'base' } }, + }, + }, + composition: [ + { + type: 'insert', + entry: { + id: 'layer-entry', + packageId: 'layer-base', + config: { theme: 'base' }, + }, + }, + ], + }), + ); + const overrideSource = await writeFixturePackage(root, 'layer-override', 'override', { + composition: [ + { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'override' } } }, + ], + }); + await platform.installPackage(overrideSource); + const tailSource = await writeFixturePackage(root, 'layer-tail', 'tail', { + composition: [ + { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'tail' } } }, + ], + }); + await platform.installPackage(tailSource); + + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + theme: 'tail', + }); + await platform.installPackage(overrideSource); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'tail' }); + await platform.uninstallPackage('layer-tail'); + await platform.uninstallPackage('layer-override'); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'base' }); + await platform.installPackage(overrideSource); + await platform.apply({ + operations: [ + { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'user' } } }, + ], + }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); + await platform.uninstallPackage('layer-override'); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); + assert.deepEqual((await platform.store.read())?.packageLayers, ['layer-base']); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('invalid package Composition patch is rejected before package publication', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-invalid-patch-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + const source = await writeFixturePackage(root, 'invalid-patch', 'invalid', { + composition: [{}], + }); + await assert.rejects(() => platform.installPackage(source), /Composition patch is invalid/u); + assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + + const semanticSource = await writeFixturePackage(root, 'invalid-layer', 'invalid', { + composition: [ + { + type: 'insert', + entry: { id: 'missing-package-entry', packageId: 'missing-package' }, + }, + ], + }); + await assert.rejects(() => platform.installPackage(semanticSource), /missing-package/u); + assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + 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.composition.package('rollback-package').contributions; + 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.deepEqual(platform.composition.package('rollback-package').contributions, before); + 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.deepEqual(recovered.composition.package('rollback-package').contributions, before); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('package replacement recovery follows the durable Composition generation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-generation-')); + try { + for (const mode of ['before', 'after'] as const) { + const control = join(root, mode); + const store = new AmbiguousCompositionStore(control); + const initial = new HostPluginPlatform(control, { store }); + await initial.recover(); + await initial.installPackage( + await writeFixturePackage(root, `generation-${mode}`, 'stable', { + composition: [ + { + type: 'insert', + entry: { id: `entry-${mode}`, packageId: `generation-${mode}` }, + }, + ], + }), + ); + store.mode = mode; + const replacement = await writeFixturePackage(root, `generation-${mode}`, 'replacement', { + directorySuffix: mode, + composition: [ + { + type: 'insert', + entry: { id: `entry-${mode}`, packageId: `generation-${mode}` }, + }, + ], + }); + await assert.rejects(() => initial.installPackage(replacement), /commit outcome is unknown/u); + assert.equal( + initial.composition.package(`generation-${mode}`).contributions?.[0]?.id, + 'stable', + 'Runtime convergence waits until the authority outcome is known', + ); + await initial.close(); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal( + recovered.composition.package(`generation-${mode}`).contributions?.[0]?.id, + mode === 'after' ? 'replacement' : 'stable', + ); + 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: { + view: 'entries', + items: [], + nextCursor: 'invalid', + }, + }), + ); +}); + +test('durable overlays may accumulate beyond one command frame without oversized responses', () => { + const input = { + operations: Array.from({ length: 700 }, (_, index) => ({ + type: 'insert' as const, + entry: { id: `large-entry-${index}`, config: { value: 'x'.repeat(900) } }, + })), + }; + assert.throws(() => decodePluginCompositionApplyInput(input), /byte limit/u); + assert.equal(decodePluginCompositionApplyInput(input, 2 * 1024 * 1024).operations.length, 700); + assert.doesNotThrow(() => + decodeResponseFrame({ + requestId: 'large-apply', + operation: 'plugin.composition.apply', + ok: true, + result: { generation: 700 }, + }), + ); +}); + +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.compositionState(); + 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.compositionState(), 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.compositionState().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('a queued mutation rechecks the fence after an unknown commit outcome', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-queued-fence-')); + try { + const control = join(root, 'control'); + const store = new DeferredUnknownCompositionStore(control); + const platform = new HostPluginPlatform(control, { store }); + await platform.recover(); + store.fail = true; + + const first = platform.apply({ + operations: [{ type: 'insert', entry: { id: 'first-uncertain' } }], + }); + await store.entered; + const second = platform.apply({ + operations: [{ type: 'insert', entry: { id: 'second-must-not-run' } }], + }); + store.release(); + + await assert.rejects(() => first, /commit outcome is unknown/u); + await assert.rejects(() => second, /fenced/u); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('failed uninstall keeps Package layers and desired state unchanged', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-uninstall-plan-')); + try { + const control = join(root, 'control'); + const platform = new HostPluginPlatform(control); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'uninstall-plan', 'installed', { + composition: [ + { + type: 'insert', + entry: { id: 'package-default', packageId: 'uninstall-plan' }, + }, + ], + }), + ); + await platform.apply({ + operations: [{ type: 'insert', entry: { id: 'user-entry', packageId: 'uninstall-plan' } }], + }); + const authority = await platform.store.read(); + const desired = platform.desiredComposition(); + + await assert.rejects(() => platform.uninstallPackage('uninstall-plan'), /used by desired/u); + assert.deepEqual(await platform.store.read(), authority); + assert.deepEqual(platform.desiredComposition(), desired); + assert.equal(platform.inspect('profile').length, 2); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('composition authority 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.desiredComposition().roots.profile[0]?.id, 'desired-failure'); + assert.deepEqual(platform.composition.compositionState().roots.profile, []); + const queried = await coordinator.handlers['plugin.platform.query']( + { view: 'failures' }, + null as never, + ); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.view === 'failures') { + assert.equal(queried.result.items[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, + packageLayers: [], + overlays: [ + { + type: 'insert', + entry: { id: 'healthy-entry', packageId: 'healthy-package', config: {} }, + }, + { + type: 'insert', + entry: { id: 'broken-entry', packageId: 'missing-package', config: {} }, + }, + ], + }); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal(recovered.inspect('profile')[0]?.id, 'healthy-entry'); + assert.equal(recovered.desiredComposition().generation, 5); + assert.deepEqual( + recovered.desiredComposition().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']( + { view: 'status' }, + 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.desiredComposition().roots.profile, []); + assert.deepEqual(platform.composition.compositionState().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.desiredComposition().roots.profile[0]?.config, { enabled: true }); + assert.deepEqual(platform.composition.compositionState().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.desiredComposition().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 }); + } +}); + +test('Plugin Platform close aggregates every resource failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-close-')); + try { + const control = join(root, 'control'); + const packages = new PluginPackageStore(control); + const composition = new FailingCloseCompositionLoader(); + const packageLoader = new FailingClosePackageLoader(control, packages); + const platform = new HostPluginPlatform(control, { composition, packages, packageLoader }); + await platform.recover(); + await assert.rejects( + () => platform.close(), + (error: unknown) => error instanceof AggregateError && error.errors.length === 2, + ); + assert.equal(composition.closeAttempted, true); + assert.equal(packageLoader.closeAttempted, true); + } 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>; + readonly composition?: readonly unknown[]; + } = {}, +): 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.composition ? { composition: { patch: 'maka.composition.yml' } } : {}), + ...(options.manifest ?? {}), + }), + ); + if (options.composition) { + await writeFile(join(source, 'maka.composition.yml'), JSON.stringify(options.composition)); + } + 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(state: PersistedPluginComposition): Promise { + if (this.fail) throw new Error('injected persistence failure'); + await super.replace(state); + } +} + +class UnknownCommitCompositionStore extends HostPluginCompositionStore { + fail = false; + + override async replace(state: PersistedPluginComposition): Promise { + if (this.fail) { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit outcome', + ); + } + await super.replace(state); + } +} + +class AmbiguousCompositionStore extends HostPluginCompositionStore { + mode: 'before' | 'after' | undefined; + + override async replace(state: PersistedPluginComposition): Promise { + const mode = this.mode; + this.mode = undefined; + if (mode === 'before') { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit before authority publication', + ); + } + await super.replace(state); + if (mode === 'after') { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit after authority publication', + ); + } + } +} + +class DeferredUnknownCompositionStore extends HostPluginCompositionStore { + fail = false; + readonly entered: Promise; + readonly #signalEntered: () => void; + readonly #gate: Promise; + readonly #release: () => void; + + constructor(controlDirectory: string) { + super(controlDirectory); + let signalEntered!: () => void; + let release!: () => void; + this.entered = new Promise((resolve) => { + signalEntered = resolve; + }); + this.#gate = new Promise((resolve) => { + release = resolve; + }); + this.#signalEntered = signalEntered; + this.#release = release; + } + + release(): void { + this.#release(); + } + + override async replace(state: PersistedPluginComposition): Promise { + if (!this.fail) return await super.replace(state); + this.#signalEntered(); + await this.#gate; + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected deferred unknown commit outcome', + ); + } +} + +class FailingCloseCompositionLoader extends MakaCompositionLoader { + closeAttempted = false; + + override async close(): Promise { + this.closeAttempted = true; + throw new Error('injected composition close failure'); + } +} + +class FailingClosePackageLoader extends TrustedPluginPackageLoader { + closeAttempted = false; + + override async close(): Promise { + this.closeAttempted = true; + throw new Error('injected package loader close failure'); + } +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index a42993bfec..97132c1349 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -92,7 +92,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 = 49 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 50 as const; +// 50: Plugin package and Entry composition operations become Host-owned protocol +// surfaces. Older peers cannot safely exchange these strict operation shapes. // 49: WorkHub resolves one durable Coordination Session per Runtime Host. // Older peers do not know the operation or the hidden Session role. // 48: Session branch creation accepts an explicit Side Conversation intent. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 9de8bb5771..936e51c0c0 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, @@ -160,6 +161,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'; @@ -213,6 +215,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( NETWORK_PROXY_OPERATION_SPECS, CONFIGURATION_OPERATION_SPECS, WORKHUB_COORDINATION_OPERATION_SPECS, + PLUGIN_PLATFORM_OPERATION_SPECS, ); export type OperationSpecMap = typeof HOST_OPERATION_SPECS; @@ -272,6 +275,12 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'plan.control', 'plan.query', 'plan.turn.start', + 'plugin.composition.apply', + 'plugin.package.export', + 'plugin.package.install', + 'plugin.package.reload', + 'plugin.package.uninstall', + 'plugin.platform.query', 'pricing.mutate', 'pricing.query', 'project.catalog.mutate', 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..a2cd7e1f82 --- /dev/null +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -0,0 +1,615 @@ +/* + * 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 MakaPluginRootId, +} 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 view: 'status' | 'packages' | 'entries' | 'failures'; + readonly rootId?: MakaPluginRootId; + readonly cursor?: number; + readonly limit?: number; +} + +export type PluginPlatformQueryResult = + | { + readonly view: 'status'; + readonly generation: number; + readonly packageCount: number; + readonly entryCount: number; + readonly failureCount: number; + } + | { + readonly view: 'packages'; + readonly items: readonly PluginPackageProjection[]; + readonly nextCursor: number | null; + } + | { + readonly view: 'entries'; + readonly items: readonly MakaCompositionEntryInspection[]; + readonly nextCursor: number | null; + } + | { + readonly view: 'failures'; + readonly items: readonly PluginPlatformFailureProjection[]; + readonly nextCursor: number | null; + }; + +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 generation: number; +} + +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', ['generation']); + return { generation: requireCount(output.generation, 'Plugin composition generation') }; + }, + }), +} as const; + +function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInput { + const input = requireShapedRecord( + value, + 'Plugin Platform query input', + ['view'], + ['rootId', 'cursor', 'limit'], + ); + if (!['status', 'packages', 'entries', 'failures'].includes(input.view as string)) { + throw invalidProtocolFrame('Invalid Plugin Platform query view'); + } + const view = input.view as PluginPlatformQueryInput['view']; + const cursor = + input.cursor === undefined + ? undefined + : requireCount(input.cursor, 'Plugin Platform query cursor'); + const limit = + input.limit === undefined + ? undefined + : requireCount(input.limit, 'Plugin Platform query limit'); + if (limit !== undefined && (limit < 1 || limit > 64)) { + throw invalidProtocolFrame('Invalid Plugin Platform query limit'); + } + if ( + view === 'status' && + (input.rootId !== undefined || cursor !== undefined || limit !== undefined) + ) { + throw invalidProtocolFrame('Plugin Platform status query does not accept paging'); + } + if (input.rootId !== undefined && view !== 'entries') { + throw invalidProtocolFrame('Plugin root identity is only valid for Entry queries'); + } + let rootId: MakaPluginRootId | undefined; + if (input.rootId !== undefined) { + const decoded = requireString(input.rootId, 'Plugin root identity', 256); + try { + validatePluginRootId(decoded); + rootId = decoded; + } catch { + throw invalidProtocolFrame('Invalid Plugin root identity'); + } + } + return { + view, + ...(rootId ? { rootId } : {}), + ...(cursor === undefined ? {} : { cursor }), + ...(limit === undefined ? {} : { limit }), + }; +} + +function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryResult { + const record = requireRecord(value, 'Plugin Platform query result'); + const view = record.view; + let decoded: PluginPlatformQueryResult; + if (view === 'status') { + const output = requireExactRecord(record, 'Plugin Platform status result', [ + 'view', + 'generation', + 'packageCount', + 'entryCount', + 'failureCount', + ]); + decoded = { + view, + generation: requireCount(output.generation, 'Plugin composition generation'), + packageCount: requireCount(output.packageCount, 'Plugin package count'), + entryCount: requireCount(output.entryCount, 'Plugin Entry count'), + failureCount: requireCount(output.failureCount, 'Plugin failure count'), + }; + } else { + const output = requireExactRecord(record, 'Plugin Platform page result', [ + 'view', + 'items', + 'nextCursor', + ]); + if ( + !Array.isArray(output.items) || + output.items.length > 64 || + !['packages', 'entries', 'failures'].includes(view as string) + ) { + throw invalidProtocolFrame('Invalid Plugin Platform page'); + } + const nextCursor = + output.nextCursor === null + ? null + : requireCount(output.nextCursor, 'Plugin Platform next cursor'); + decoded = + view === 'packages' + ? { view, items: output.items.map(decodePackageProjection), nextCursor } + : view === 'entries' + ? { view, items: decodeInspections(output.items), nextCursor } + : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; + } + 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') }; +} + +export function decodePluginCompositionApplyInput( + value: unknown, + maxBytes = MAX_FRAME_BYTES, +): 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', maxBytes); + 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 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 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 575d7b1060..60e29c3030 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -145,6 +145,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'; @@ -182,6 +184,7 @@ import { export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; + readonly plugins: HostPluginPlatform; } export interface CreateExecutionRuntimeHostCompositionOptions { @@ -233,7 +236,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); @@ -1363,6 +1369,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], @@ -1618,6 +1631,7 @@ export async function createExecutionRuntimeHostComposition( handlers, moduleIds: Object.freeze(domainModules.map(({ id }) => id)), workspaceExecution: requireWorkspaceExecution(workspaceExecution), + plugins: pluginPlatform, continuity: continuityCoordinator, clientCapabilities, hostChanges, @@ -1630,6 +1644,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..a4d3f7ba62 --- /dev/null +++ b/packages/runtime-host/src/server/extension-package-manifest.ts @@ -0,0 +1,324 @@ +/* + * 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; + readonly composition?: ExtensionPackageComposition; +} + +export interface ExtensionPackageRuntime { + readonly entry: string; +} + +export interface ExtensionPackageComposition { + readonly patch: 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', 'composition'], + ); + 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); + const composition = decodeComposition(source.composition); + return Object.freeze({ + schemaVersion: 1, + id, + displayName, + description, + dependencies, + configuration, + ...(runtime === undefined ? {} : { runtime }), + ...(composition === undefined ? {} : { composition }), + }); +} + +function decodeComposition(value: unknown): ExtensionPackageComposition | undefined { + if (value === undefined) return undefined; + const composition = record(value, 'composition'); + exactOptional(composition, ['patch'], []); + return Object.freeze({ patch: packagePath(composition.patch, 'composition.patch') }); +} + +function decodeRuntime(value: unknown): ExtensionPackageRuntime | undefined { + if (value === undefined) return undefined; + const runtime = record(value, 'runtime'); + if (runtime.entry === undefined) return undefined; + return Object.freeze({ entry: packagePath(runtime.entry, 'runtime.entry') }); +} + +function packagePath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.includes('\\') || + value.startsWith('/') || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid(`Extension manifest ${label} is invalid`); + } + return value; +} + +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..8703cfee7f 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -30,3 +30,45 @@ 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 ExtensionPackageComposition, + type ExtensionPackageManifest, + type ExtensionPackageRuntime, +} from './extension-package-manifest.js'; +export { + PluginCompositionPatchError, + loadPluginCompositionPatch, +} from './plugin-composition-patch.js'; +export { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, +} 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 208befbb28..48570279a0 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -139,6 +139,7 @@ export type WebSearchOperationKey = Extract; export type ConfigurationOperationKey = Extract; export type WorkHubCoordinationOperationKey = Extract; +export type PluginPlatformOperationKey = Extract; export type DomainOperationHandlerMap = Pick; export type TurnOperationHandlerMap = Pick; export type ContextOperationHandlerMap = Pick; @@ -209,6 +210,10 @@ export type WorkHubCoordinationOperationHandlerMap = Pick< OperationHandlerMap, WorkHubCoordinationOperationKey >; +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-patch.ts b/packages/runtime-host/src/server/plugin-composition-patch.ts new file mode 100644 index 0000000000..5e5db76602 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-composition-patch.ts @@ -0,0 +1,63 @@ +/* + * 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 type { MakaCompositionApplyInput } from '@maka/runtime/plugin-runtime'; +import { parse } from 'yaml'; +import { decodePluginCompositionApplyInput } from '../protocol/plugin-platform.js'; +import type { InstalledPluginPackage } from './plugin-package-store.js'; + +const MAX_PATCH_BYTES = 512 * 1024; + +export class PluginCompositionPatchError extends Error { + readonly name = 'PluginCompositionPatchError'; +} + +/** Reads the declarative Composition layer shipped by one installed package. */ +export async function loadPluginCompositionPatch( + installed: InstalledPluginPackage, +): Promise { + const relativePath = installed.manifest.composition?.patch; + if (!relativePath) return undefined; + let encoded: Buffer; + try { + encoded = await readFile(join(installed.root, ...relativePath.split('/'))); + } catch (error) { + throw invalid(`Unable to read Plugin Composition patch: ${relativePath}`, error); + } + if (encoded.byteLength > MAX_PATCH_BYTES) { + throw invalid(`Plugin Composition patch exceeds its size limit: ${relativePath}`); + } + let value: unknown; + try { + value = parse(encoded.toString('utf8')); + } catch (error) { + throw invalid(`Plugin Composition patch is invalid YAML: ${relativePath}`, error); + } + try { + return decodePluginCompositionApplyInput({ operations: value }); + } catch (error) { + throw invalid(`Plugin Composition patch is invalid: ${relativePath}`, error); + } +} + +function invalid(message: string, cause?: unknown): PluginCompositionPatchError { + return new PluginCompositionPatchError(message, { cause }); +} 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..d19b91e99a --- /dev/null +++ b/packages/runtime-host/src/server/plugin-composition-store.ts @@ -0,0 +1,171 @@ +/* + * 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, + type MakaCompositionOperation, +} from '@maka/runtime/plugin-runtime'; +import { decodePluginCompositionApplyInput } from '../protocol/plugin-platform.js'; + +const FILE_NAME = 'plugin-composition-v2.json'; +const MAX_BYTES = 2 * 1024 * 1024; + +/** Durable inputs from which the desired Entry Tree is rebuilt. */ +export interface PersistedPluginComposition { + readonly schemaVersion: 1; + readonly generation: number; + readonly packageLayers: readonly string[]; + readonly overlays: readonly MakaCompositionOperation[]; +} + +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(composition: PersistedPluginComposition): Promise { + const normalized = decode(composition); + 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', 'packageLayers', 'overlays']); + if ( + root.schemaVersion !== 1 || + !Number.isSafeInteger(root.generation) || + (root.generation as number) < 0 + ) { + throw invalid('Plugin Composition header is invalid'); + } + if ( + !Array.isArray(root.packageLayers) || + root.packageLayers.length > 256 || + root.packageLayers.some((item) => !isCanonicalExtensionId(item)) || + new Set(root.packageLayers).size !== root.packageLayers.length + ) { + throw invalid('Plugin Composition package layers are invalid'); + } + if (!Array.isArray(root.overlays) || root.overlays.length > 4096) { + throw invalid('Plugin Composition overlays are invalid'); + } + const overlays = + root.overlays.length === 0 + ? Object.freeze([]) + : Object.freeze( + decodePluginCompositionApplyInput({ operations: root.overlays }, MAX_BYTES).operations, + ); + return Object.freeze({ + schemaVersion: 1, + generation: root.generation as number, + packageLayers: Object.freeze([...(root.packageLayers as string[])]), + overlays, + }); +} + +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 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..db26f55d1e --- /dev/null +++ b/packages/runtime-host/src/server/plugin-package-loader.ts @@ -0,0 +1,157 @@ +/* + * 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 { + type InstalledPluginPackage, + 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); + } + return await this.loadInstalled(installed); + } + + async loadInstalled(installed: InstalledPluginPackage): Promise { + const generation = join(this.#generations, `${installed.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 !== installed.extensionId) { + throw invalid( + `Plugin Runtime packageId ${pkg.packageId} does not match manifest ${installed.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..89b188ce07 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-package-store.ts @@ -0,0 +1,570 @@ +/* + * 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, readFile, 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; + publish(baseGeneration: number, nextGeneration: number): Promise; + commit(): Promise; + rollback(): Promise; +} + +interface PackageInstallTransaction { + readonly schemaVersion: 1; + readonly extensionId: string; + readonly baseGeneration: number; + readonly nextGeneration: number; +} + +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.publish(0, 1); + await prepared.commit(); + return await this.load(prepared.installed.extensionId); + } + + /** Repairs or removes package-store transaction remnants after owner death. */ + async recover(authorityGeneration = 0): 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('.install-')) { + await this.#recoverInstall(path, authorityGeneration); + changed = true; + continue; + } + 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 transaction = join(this.root, `.install-${randomUUID()}`); + const staging = join(transaction, 'candidate'); + const previous = join(transaction, 'previous'); + let movedPrevious = false; + let published = false; + let settled = false; + try { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await mkdir(transaction, { mode: 0o700 }); + await mkdir(staging, { mode: 0o700 }); + for (const file of files) await writeFile(staging, file); + await syncTree(staging, files); + await syncDirectory(transaction); + await syncDirectory(this.root); + const installed = freezeInstalled(staging, decoded); + return Object.freeze({ + installed, + publish: async (baseGeneration: number, nextGeneration: number) => { + if (settled || published) + throw persistence('Plugin package install is already settled'); + if ( + !Number.isSafeInteger(baseGeneration) || + !Number.isSafeInteger(nextGeneration) || + baseGeneration < 0 || + nextGeneration !== baseGeneration + 1 + ) { + throw invalid('Plugin package install generations are invalid'); + } + await writeTransaction(transaction, { + schemaVersion: 1, + extensionId: decoded.manifest.id, + baseGeneration, + nextGeneration, + }); + try { + 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); + } catch (error) { + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + `Plugin package publication outcome is unknown: ${decoded.manifest.id}`, + { cause: error }, + ); + } + }, + commit: async () => { + if (settled) return; + if (!published) throw persistence('Plugin package install was not published'); + settled = true; + await rm(transaction, { recursive: true, force: true }).catch(() => undefined); + }, + rollback: async () => { + if (settled) return; + settled = true; + if (!published) { + await rm(transaction, { recursive: true, force: true }); + return; + } + await rollbackPublishedInstall(this.root, target, transaction, movedPrevious); + }, + }); + } catch (error) { + if (published) { + try { + await rollbackPublishedInstall(this.root, target, transaction, movedPrevious); + } catch (rollbackError) { + if (rollbackError instanceof PluginPackageStoreError) throw rollbackError; + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + `Plugin package installation outcome is unknown: ${decoded.manifest.id}`, + { cause: new AggregateError([error, rollbackError]) }, + ); + } + } else { + await rm(transaction, { recursive: true, force: true }).catch(() => undefined); + } + if (error instanceof PluginPackageStoreError) throw error; + throw persistence(`Unable to install Plugin package ${decoded.manifest.id}`, error); + } + } finally { + await source.dispose(); + } + } + + async #recoverInstall(transactionRoot: string, authorityGeneration: number): Promise { + const transaction = await readTransaction(transactionRoot); + const target = join(this.root, transaction.extensionId); + const candidate = join(transactionRoot, 'candidate'); + const previous = join(transactionRoot, 'previous'); + const candidateExists = await exists(candidate); + const targetExists = await exists(target); + const previousExists = await exists(previous); + if (authorityGeneration === transaction.baseGeneration) { + if (!candidateExists && targetExists) { + await rm(target, { recursive: true, force: true }); + } + if (previousExists) await rename(previous, target); + await syncDirectory(this.root); + await rm(transactionRoot, { recursive: true, force: true }); + return; + } + if (authorityGeneration >= transaction.nextGeneration) { + if (candidateExists || !targetExists) { + throw persistence( + `Plugin package transaction does not match committed authority: ${transaction.extensionId}`, + ); + } + await rm(transactionRoot, { recursive: true, force: true }); + return; + } + throw persistence( + `Plugin package transaction generation is ambiguous: ${transaction.extensionId}`, + ); + } + + 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 writeTransaction( + root: string, + transaction: PackageInstallTransaction, +): Promise { + const handle = await open(join(root, 'transaction.json'), 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(transaction)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(root); + await syncDirectory(dirname(root)); +} + +async function readTransaction(root: string): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(join(root, 'transaction.json'), 'utf8')); + } catch (error) { + throw persistence('Unable to read Plugin package install transaction', error); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw persistence('Plugin package install transaction is invalid'); + } + const record = value as Record; + if ( + Object.keys(record).length !== 4 || + record.schemaVersion !== 1 || + !isCanonicalExtensionId(record.extensionId) || + !Number.isSafeInteger(record.baseGeneration) || + !Number.isSafeInteger(record.nextGeneration) || + (record.baseGeneration as number) < 0 || + record.nextGeneration !== (record.baseGeneration as number) + 1 + ) { + throw persistence('Plugin package install transaction is invalid'); + } + return Object.freeze({ + schemaVersion: 1, + extensionId: record.extensionId as string, + baseGeneration: record.baseGeneration as number, + nextGeneration: record.nextGeneration as number, + }); +} + +async function rollbackPublishedInstall( + storeRoot: string, + target: string, + transactionRoot: string, + movedPrevious: boolean, +): Promise { + const rejected = join(transactionRoot, 'rejected'); + try { + await rename(target, rejected).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + if (movedPrevious) await rename(join(transactionRoot, 'previous'), target); + await rename(rejected, join(transactionRoot, 'candidate')).catch( + (error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }, + ); + await syncDirectory(storeRoot); + await rm(transactionRoot, { recursive: true, force: true }); + await syncDirectory(storeRoot); + } catch (error) { + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + 'Plugin package rollback outcome is unknown', + { cause: error }, + ); + } +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw 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}`); + } + if (manifest.composition && !files.some((file) => file.path === manifest.composition!.patch)) { + throw invalid(`Plugin Composition patch does not exist: ${manifest.composition.patch}`); + } + 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..47514f5861 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -0,0 +1,287 @@ +/* + * 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, + type MakaCompositionEntryInspection, +} from '@maka/runtime/plugin-runtime'; +import type { + OperationOutcome, + PluginPackageExportInput, + PluginPackageInstallInput, + PluginPackageProjection, + PluginPackageUninstallInput, + PluginPlatformFailureProjection, + PluginPlatformQueryInput, + PluginPlatformQueryResult, +} 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 { PluginCompositionPatchError } from './plugin-composition-patch.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 identities = await this.platform.packages.identities(); + const failures = this.platform.failures(); + if (input.view === 'status') { + const entryCount = countInspections(this.platform.inspect()); + return { + ok: true, + result: { + view: 'status', + generation: this.platform.desiredComposition().generation, + packageCount: identities.length, + entryCount, + failureCount: failures.length, + }, + }; + } + if (input.view === 'entries') { + const inspections = flattenInspections(this.platform.inspect(input.rootId)); + return { ok: true, result: boundedPage('entries', inspections, input) }; + } + if (input.view === 'failures') { + return { ok: true, result: boundedPage('failures', failures, input) }; + } + const packages = []; + for (const extensionId of identities) { + 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), + }); + } + return { + ok: true, + result: boundedPage('packages', packages, input), + }; + }); + } 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 { + await this.platform.apply(input); + return { + ok: true, + result: { generation: this.platform.desiredComposition().generation }, + }; + } catch (error) { + return failure(error); + } + } +} + +function countInspections(inspections: readonly MakaCompositionEntryInspection[]): number { + return inspections.reduce( + (total, inspection) => total + 1 + countInspections(inspection.children), + 0, + ); +} + +function flattenInspections( + inspections: readonly MakaCompositionEntryInspection[], +): readonly MakaCompositionEntryInspection[] { + const flattened: MakaCompositionEntryInspection[] = []; + const visit = (items: readonly MakaCompositionEntryInspection[]): void => { + for (const item of items) { + flattened.push(Object.freeze({ ...item, children: Object.freeze([]) })); + visit(item.children); + } + }; + visit(inspections); + return Object.freeze(flattened); +} + +function boundedPage( + view: 'packages', + values: readonly PluginPackageProjection[], + input: PluginPlatformQueryInput, +): Extract; +function boundedPage( + view: 'entries', + values: readonly MakaCompositionEntryInspection[], + input: PluginPlatformQueryInput, +): Extract; +function boundedPage( + view: 'failures', + values: readonly PluginPlatformFailureProjection[], + input: PluginPlatformQueryInput, +): Extract; +function boundedPage( + view: 'packages' | 'entries' | 'failures', + values: readonly T[], + input: PluginPlatformQueryInput, +): PluginPlatformQueryResult { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? 32; + if (cursor > values.length) + throw new MakaPluginRuntimeError('invalid_entry', 'Invalid query cursor'); + const items: T[] = []; + for (let index = cursor; index < values.length && items.length < limit; index += 1) { + const candidate = [...items, values[index] as T]; + if ( + Buffer.byteLength(JSON.stringify({ view, items: candidate, nextCursor: index + 1 }), 'utf8') > + 480 * 1024 + ) { + break; + } + items.push(values[index] as T); + } + if (cursor < values.length && items.length === 0) { + throw new MakaPluginRuntimeError('invalid_entry', 'Plugin Platform page item is too large'); + } + const next = cursor + items.length; + return Object.freeze({ + view, + items: Object.freeze(items), + nextCursor: next < values.length ? next : null, + }) as PluginPlatformQueryResult; +} + +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 || + error instanceof PluginCompositionPatchError + ) { + 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..59446525ed --- /dev/null +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -0,0 +1,1074 @@ +/* + * 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 { + applyCompositionState, + MakaPluginRuntimeError, + type MakaCompositionApplyInput, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionOperation, + type MakaCompositionState, + type MakaPluginPackage, + type MakaPluginRootId, +} from '@maka/runtime/plugin-runtime'; +import type { ExtensionPackageManifest } from './extension-package-manifest.js'; +import { validateExtensionConfiguration } from './extension-package-manifest.js'; +import { loadPluginCompositionPatch } from './plugin-composition-patch.js'; +import { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, +} 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 Entry composition. + * Package layers and user overlays are durable; the desired Entry Tree is derived from them. + */ +export class HostPluginPlatform { + readonly composition: MakaCompositionLoader; + readonly packages: PluginPackageStore; + readonly packageLoader: TrustedPluginPackageLoader; + readonly store: HostPluginCompositionStore; + + #authority: PersistedPluginComposition = emptyCompositionAuthority(); + #desired: MakaCompositionState = emptyCompositionState(); + #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 { + const storedAuthority = (await this.store.read()) ?? emptyCompositionAuthority(); + await this.packages.recover(storedAuthority.generation); + await this.packageLoader.collectGarbage(); + 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 desired = await this.#normalizeCompositionConfigurations( + await this.#composePersistedAuthority(storedAuthority), + ); + const entryFailures = await this.#recoverDesiredRuntime(desired); + this.#failures = Object.freeze([ + ...packageFailures, + ...entryFailures.map((failure) => + Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), + ), + ]); + this.#diverged = entryFailures.length > 0; + this.#authority = storedAuthority; + this.#desired = 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.#serializeMutable(async () => { + let prepared; + try { + prepared = await this.packages.prepareInstall(sourcePath); + } catch (error) { + if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') { + throw this.#fenceUnknownPackageOutcome(error, 'preparation'); + } + throw error; + } + let loaded: MakaPluginPackage | undefined; + let previous: MakaPluginPackage | undefined; + let authorityCommitted = false; + let runtimeAdopted = false; + try { + const compositionPatch = await loadPluginCompositionPatch(prepared.installed); + loaded = await this.packageLoader.loadInstalled(prepared.installed); + const alreadyInstalled = this.composition + .installedPackages() + .some(({ packageId }) => packageId === prepared.installed.extensionId); + if (alreadyInstalled) previous = this.composition.package(prepared.installed.extensionId); + const layerPlan = await this.#planPackageLayer( + prepared.installed.extensionId, + compositionPatch, + prepared.installed.manifest, + ); + await prepared.publish(this.#authority.generation, layerPlan.planned.generation); + await this.#commitDesiredAuthority( + layerPlan.planned, + layerPlan.packageLayers, + this.#authority.overlays, + ); + authorityCommitted = true; + await prepared.commit(); + this.#clearPackageFailure(prepared.installed.extensionId); + if (alreadyInstalled) await this.composition.reload(loaded); + else await this.composition.install(loaded); + runtimeAdopted = true; + const failures = await this.composition.recoverComposition(layerPlan.planned); + await this.#publishEntryFailures(failures); + if (failures.length > 0) { + throw new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')); + } + if (previous) await this.#releaseGeneration(previous); + return Object.freeze({ extensionId: prepared.installed.extensionId }); + } catch (error) { + if (authorityCommitted) { + this.#diverged = true; + if (loaded && !runtimeAdopted) { + await this.packageLoader.release(loaded).catch(() => undefined); + } + if (previous && runtimeAdopted) await this.#releaseGeneration(previous); + throw new HostPluginPlatformError( + 'mutation_failed', + 'Plugin package authority was committed but Runtime convergence failed', + { cause: error }, + ); + } + if (error instanceof HostPluginPlatformError && error.code === 'commit_outcome_unknown') { + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + throw error; + } + try { + await prepared.rollback(); + } catch (rollbackError) { + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + if ( + rollbackError instanceof PluginPackageStoreError && + rollbackError.code === 'commit_outcome_unknown' + ) { + throw this.#fenceUnknownPackageOutcome(rollbackError, 'rollback'); + } + 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]) }, + ); + } + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + throw error; + } + }); + } + + async reloadPackage(extensionId: string): Promise { + this.#assertMutable(); + await this.#serializeMutable(async () => { + const previous = this.composition.package(extensionId); + const loaded = await this.packageLoader.load(extensionId); + try { + await this.#validateDesired(this.desiredComposition()); + 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.#serializeMutable(async () => { + let planned: MakaCompositionState | undefined; + let packageLayers = this.#authority.packageLayers; + if (this.#authority.packageLayers.includes(extensionId)) { + packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); + planned = await this.#composeLayers(packageLayers, this.#authority.overlays); + } + const candidate = planned ?? this.#desired; + const desiredUser = compositionEntries(candidate).find( + (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, candidate); + if (dependent) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is required by desired entry ${dependent.id}`, + ); + } + if (planned) { + await this.#replaceDesiredComposition(planned, packageLayers, this.#authority.overlays); + } + 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.#serializeMutable(async () => { + const desired = this.#desired; + let normalizedInput: MakaCompositionApplyInput; + let planned: MakaCompositionState; + try { + normalizedInput = await this.#normalizeApplyInput(desired, input); + planned = applyCompositionState(desired, normalizedInput); + await this.#validateDesired(planned); + } catch (error) { + throw new HostPluginPlatformError('mutation_failed', 'Plugin composition mutation failed', { + cause: error, + }); + } + const next = compositionAuthority( + planned.generation, + this.#authority.packageLayers, + Object.freeze([...this.#authority.overlays, ...normalizedInput.operations]), + ); + try { + await this.store.replace(next); + this.#authority = next; + this.#desired = planned; + } 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.recoverComposition(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 }, + ); + } + }); + } + + desiredComposition(): MakaCompositionState { + return this.#desired; + } + + failures(): readonly HostPluginPlatformFailure[] { + return this.#failures; + } + + 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; + const errors: unknown[] = []; + try { + await this.#mutation; + } catch (error) { + errors.push(error); + } + try { + await this.composition.close(); + } catch (error) { + errors.push(error); + } + try { + await this.packageLoader.close(); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to close every Plugin Platform resource'); + } + } + + async #planPackageLayer( + extensionId: string, + patch: MakaCompositionApplyInput | undefined, + manifest: ExtensionPackageManifest, + ): Promise<{ + readonly planned: MakaCompositionState; + readonly packageLayers: readonly string[]; + }> { + const previousIndex = this.#authority.packageLayers.indexOf(extensionId); + const packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); + const nextIndex = previousIndex < 0 ? packageLayers.length : previousIndex; + packageLayers.splice(nextIndex, 0, extensionId); + const planned = await this.#composeLayers(packageLayers, this.#authority.overlays, { + extensionId, + patch, + manifest, + }); + return { planned, packageLayers }; + } + + async #composeLayers( + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], + override?: { + readonly extensionId: string; + readonly patch: MakaCompositionApplyInput | undefined; + readonly manifest: ExtensionPackageManifest; + }, + ): Promise { + let working = emptyCompositionState(); + for (const extensionId of packageLayers) { + const patch = + override?.extensionId === extensionId + ? override.patch + : await loadPluginCompositionPatch(await this.packages.load(extensionId)); + if (!patch) continue; + const normalized = await this.#normalizeApplyInput(working, patch, override?.manifest); + working = applyCompositionState(working, normalized); + } + if (overlays.length > 0) { + const normalized = await this.#normalizeApplyInput( + working, + { operations: overlays }, + override?.manifest, + ); + working = applyCompositionState(working, normalized); + } + await this.#validateDesired(working, override?.manifest); + return compositionWithGeneration(working, this.#desired.generation + 1); + } + + /** Rebuilds the desired Entry Tree without trusting a stored materialized projection. */ + async #composePersistedAuthority( + authority: PersistedPluginComposition, + ): Promise { + let working = emptyCompositionState(); + for (const extensionId of authority.packageLayers) { + const patch = await loadPluginCompositionPatch(await this.packages.load(extensionId)); + if (patch) working = applyCompositionState(working, patch); + } + if (authority.overlays.length > 0) { + working = applyCompositionState(working, { operations: authority.overlays }); + } + return compositionWithGeneration(working, authority.generation); + } + + async #replaceDesiredComposition( + planned: MakaCompositionState, + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], + ): Promise { + await this.#commitDesiredAuthority(planned, packageLayers, overlays); + const failures = await this.composition.recoverComposition(planned); + await this.#publishEntryFailures(failures); + this.#diverged = failures.length > 0; + if (failures.length > 0) { + throw new HostPluginPlatformError( + 'mutation_failed', + 'Desired Plugin composition was committed but Runtime convergence failed', + { cause: new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')) }, + ); + } + } + + async #commitDesiredAuthority( + planned: MakaCompositionState, + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], + ): Promise { + const next = compositionAuthority(planned.generation, packageLayers, overlays); + try { + await this.store.replace(next); + this.#authority = next; + this.#desired = planned; + } 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 }, + ); + } + } + + async #validateDesired( + state: MakaCompositionState, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + const records = compositionEntryRecords(state); + for (const record of records) { + await this.#validateEntry(record.entry, !record.disabled, manifestOverride); + await this.#validateActiveDependencies(record, records, manifestOverride); + } + } + + async #normalizeApplyInput( + desired: MakaCompositionState, + input: MakaCompositionApplyInput, + manifestOverride?: ExtensionPackageManifest, + ): 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, true, manifestOverride), + }); + } 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, + manifestOverride, + ); + normalized = Object.freeze({ + ...operation, + patch: Object.freeze({ ...operation.patch, config: configured.config }), + }); + } else { + normalized = operation; + } + operations.push(normalized); + const advanced = applyCompositionState(working, { operations: [normalized] }); + working = compositionWithGeneration(advanced, desired.generation); + } + return Object.freeze({ + ...(input.baseGeneration === undefined ? {} : { baseGeneration: input.baseGeneration }), + operations: Object.freeze(operations), + }); + } + + async #normalizeCompositionConfigurations( + state: MakaCompositionState, + ): 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(state.roots.sessions).map( + async ([scopeId, entries]) => + [scopeId, Object.freeze(await Promise.all(entries.map(normalize)))] as const, + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: state.generation, + roots: Object.freeze({ + profile: Object.freeze(await Promise.all(state.roots.profile.map(normalize))), + desktopUi: Object.freeze(await Promise.all(state.roots.desktopUi.map(normalize))), + sessions: Object.freeze(Object.fromEntries(sessions)), + }), + }); + } + + async #normalizeEntryConfiguration( + entry: MakaCompositionEntry, + recursive = true, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + const config = entry.packageId + ? validateExtensionConfiguration( + (await this.#packageManifest(entry.packageId, manifestOverride)).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, true, manifestOverride), + ), + ), + ), + } + : {}), + }); + } + + async #desiredValidationFailures( + state: MakaCompositionState, + ): Promise { + const failures: MakaCompositionRecoveryFailure[] = []; + const records = compositionEntryRecords(state); + 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, + manifestOverride?: ExtensionPackageManifest, + ): 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.#packageManifest(extensionId, manifestOverride); + manifests.set(extensionId, manifest); + } + for (const dependency of manifest.dependencies) await visit(dependency.id); + visiting.delete(extensionId); + visited.add(extensionId); + }; + const manifest = await this.#packageManifest(entry.packageId, manifestOverride); + validateExtensionConfiguration(manifest.configuration, entry.config); + if (active) await visit(entry.packageId); + } + + async #validateActiveDependencies( + record: CompositionEntryRecord, + records: readonly CompositionEntryRecord[], + manifestOverride?: ExtensionPackageManifest, + ): Promise { + if (record.disabled || !record.entry.packageId) return; + const manifest = await this.#packageManifest(record.entry.packageId, manifestOverride); + 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 #packageManifest( + extensionId: string, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + return manifestOverride?.id === extensionId + ? manifestOverride + : (await this.packages.load(extensionId)).manifest; + } + + async #convergeDesired(): Promise { + const desired = this.desiredComposition(); + const failures = await this.#recoverDesiredRuntime(desired); + await this.#publishEntryFailures(failures); + } + + async #recoverDesiredRuntime( + desired: MakaCompositionState, + ): Promise { + let failures = new Map( + (await this.#desiredValidationFailures(desired)).map((failure) => [failure.entryId, failure]), + ); + for (;;) { + const recovered = await this.composition.recoverComposition( + 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( + state: MakaCompositionState, + initial: readonly MakaCompositionRecoveryFailure[], + ): Promise { + const failures = new Map(initial.map((failure) => [failure.entryId, failure])); + const records = compositionEntryRecords(state); + 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, + desired: MakaCompositionState = this.desiredComposition(), + ): 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(desired)) { + 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; + } + + 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'); + } + + #fenceUnknownPackageOutcome( + error: PluginPackageStoreError, + operation: string, + ): HostPluginPlatformError { + this.#poisoned = error; + this.#draining = true; + return new HostPluginPlatformError( + 'commit_outcome_unknown', + `Plugin package ${operation} outcome is unknown; Plugin Platform was fenced`, + { cause: error }, + ); + } + + #serializeMutable(operation: () => Promise): Promise { + return this.#serialize(async () => { + this.#assertMutable(); + return await operation(); + }); + } + + #serialize(operation: () => Promise): Promise { + const result = this.#mutation.then(operation, operation); + this.#mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function emptyCompositionAuthority(): PersistedPluginComposition { + return Object.freeze({ + schemaVersion: 1, + generation: 0, + packageLayers: Object.freeze([]), + overlays: Object.freeze([]), + }); +} + +function emptyCompositionState(): MakaCompositionState { + return Object.freeze({ + schemaVersion: 1, + generation: 0, + roots: Object.freeze({ + profile: Object.freeze([]), + desktopUi: Object.freeze([]), + sessions: Object.freeze({}), + }), + }); +} + +function compositionAuthority( + generation: number, + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], +): PersistedPluginComposition { + return Object.freeze({ + schemaVersion: 1, + generation, + packageLayers: Object.freeze([...packageLayers]), + overlays: Object.freeze(structuredClone(overlays)), + }); +} + +function compositionEntries(state: MakaCompositionState): readonly MakaCompositionEntry[] { + const walk = (entries: readonly MakaCompositionEntry[]): MakaCompositionEntry[] => + entries.flatMap((entry) => [entry, ...walk(entry.children ?? [])]); + return [ + ...walk(state.roots.profile), + ...walk(state.roots.desktopUi), + ...Object.values(state.roots.sessions).flatMap(walk), + ]; +} + +function findCompositionEntry( + state: MakaCompositionState, + entryId: string, +): MakaCompositionEntry | undefined { + return compositionEntries(state).find((entry) => entry.id === entryId); +} + +function compositionWithGeneration( + state: MakaCompositionState, + generation: number, +): MakaCompositionState { + return Object.freeze({ ...state, generation }); +} + +function compositionEntryRecords(state: MakaCompositionState): 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(state.roots.profile, 'profile', false); + visit(state.roots.desktopUi, 'desktop-ui', false); + for (const [scopeId, entries] of Object.entries(state.roots.sessions)) { + visit(entries, `session:${scopeId}`, false); + } + return Object.freeze(records); +} + +function withoutEntries( + state: MakaCompositionState, + excluded: ReadonlySet, +): MakaCompositionState { + 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: state.generation, + roots: Object.freeze({ + profile: filter(state.roots.profile), + desktopUi: filter(state.roots.desktopUi), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(state.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + filter(entries), + ]), + ), + ), + }), + }); +} + +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 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..2680f17721 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 { + applyCompositionState, 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.recoverComposition({ + 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( @@ -109,7 +150,7 @@ test('config update uses the existing Fiber and preserves entry identity', async const updated = await loader.update('configurable-one', { config: { value: 2 } }); assert.equal(updated.id, initial.id); assert.equal(updated.generation, initial.generation); - assert.equal(loader.snapshot().generation, 2); + assert.equal(loader.compositionState().generation, 2); assert.deepEqual(values, [1, 2]); await loader.close(); }); @@ -290,11 +331,11 @@ test('retirement cleanup failure does not roll back a published removal generati }), ); await loader.create('profile', entry('retired-entry', 'retirement-failure')); - const generation = loader.snapshot().generation; + const generation = loader.compositionState().generation; await loader.remove('retired-entry'); - assert.equal(loader.snapshot().generation, generation + 1); + assert.equal(loader.compositionState().generation, generation + 1); assert.deepEqual(loader.inspectTree('profile'), []); await loader.close(); }); @@ -309,11 +350,11 @@ test('retirement cleanup failure does not roll back a published structural updat ); await loader.install(pkg('replacement-package', () => undefined)); await loader.create('profile', entry('updated-entry', 'retired-package')); - const generation = loader.snapshot().generation; + const generation = loader.compositionState().generation; await loader.update('updated-entry', { packageId: 'replacement-package' }); - assert.equal(loader.snapshot().generation, generation + 1); + assert.equal(loader.compositionState().generation, generation + 1); assert.equal(loader.inspect('updated-entry').packageId, 'replacement-package'); assert.equal(loader.inspect('updated-entry').status, 'active'); await loader.close(); @@ -347,19 +388,19 @@ test('contribution registrations are staged and owned by the entry Fiber', async await loader.close(); }); -test('snapshot replacement restores ordered roots and descendants', async () => { +test('state replacement restores ordered roots and descendants', async () => { const loader = new MakaCompositionLoader(); - await loader.install(pkg('snapshot', () => undefined)); - await loader.replaceSnapshot({ + await loader.install(pkg('state', () => undefined)); + await loader.replaceComposition({ schemaVersion: 1, generation: 41, roots: { - profile: [entry('profile-entry', 'snapshot')], - desktopUi: [{ id: 'ui-group', children: [entry('ui-entry', 'snapshot')] }], - sessions: { s1: [entry('session-entry', 'snapshot')] }, + profile: [entry('profile-entry', 'state')], + desktopUi: [{ id: 'ui-group', children: [entry('ui-entry', 'state')] }], + sessions: { s1: [entry('session-entry', 'state')] }, }, }); - assert.equal(loader.snapshot().generation, 41); + assert.equal(loader.compositionState().generation, 41); assert.deepEqual( loader.inspectTree().map(({ id }) => id), ['profile-entry', 'ui-group', 'session-entry'], @@ -368,26 +409,26 @@ test('snapshot replacement restores ordered roots and descendants', async () => await loader.close(); }); -test('live snapshot and subtree replacement publish a fresh composition generation', async () => { +test('live state and subtree replacement publish a fresh composition generation', async () => { const loader = new MakaCompositionLoader(); await loader.create('profile', { id: 'before' }); - const staleGeneration = loader.snapshot().generation; + const staleGeneration = loader.compositionState().generation; - await loader.replaceSnapshot({ + await loader.replaceComposition({ schemaVersion: 1, generation: staleGeneration, roots: { profile: [{ id: 'after' }], desktopUi: [], sessions: {} }, }); - assert.equal(loader.snapshot().generation, staleGeneration + 1); + assert.equal(loader.compositionState().generation, staleGeneration + 1); await assert.rejects( () => loader.apply({ baseGeneration: staleGeneration, operations: [] }), /Composition generation changed/u, ); - const beforeSubtreeReplacement = loader.snapshot().generation; + const beforeSubtreeReplacement = loader.compositionState().generation; await loader.replaceSubtree('after', { id: 'after', children: [{ id: 'child' }] }); - assert.equal(loader.snapshot().generation, beforeSubtreeReplacement + 1); + assert.equal(loader.compositionState().generation, beforeSubtreeReplacement + 1); await loader.close(); }); @@ -425,18 +466,18 @@ test('replacement subtrees reject duplicate ids across different branches', asyn await loader.close(); }); -test('snapshot preserves session ids that overlap object prototype properties', async () => { +test('state preserves session ids that overlap object prototype properties', async () => { const loader = new MakaCompositionLoader(); await loader.create('session:__proto__', { id: 'special-session-entry' }); - const snapshot = loader.snapshot(); - assert.equal(Object.hasOwn(snapshot.roots.sessions, '__proto__'), true); + const state = loader.compositionState(); + assert.equal(Object.hasOwn(state.roots.sessions, '__proto__'), true); assert.deepEqual( - snapshot.roots.sessions.__proto__?.map(({ id }) => id), + state.roots.sessions.__proto__?.map(({ id }) => id), ['special-session-entry'], ); - await loader.replaceSnapshot(snapshot); + await loader.replaceComposition(state); assert.deepEqual( loader.inspectTree('session:__proto__').map(({ id }) => id), ['special-session-entry'], @@ -444,26 +485,26 @@ test('snapshot preserves session ids that overlap object prototype properties', await loader.close(); }); -test('inspecting a missing root does not mutate the composition snapshot', async () => { +test('inspecting a missing root does not mutate the composition state', async () => { const loader = new MakaCompositionLoader(); - const before = loader.snapshot(); + const before = loader.compositionState(); assert.deepEqual(loader.inspectTree('session:missing'), []); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); test('failed insert does not create an empty composition root', async () => { const loader = new MakaCompositionLoader(); - const before = loader.snapshot(); + const before = loader.compositionState(); await assert.rejects( loader.create('session:ghost', { id: 'orphan' }, 'missing-parent'), /Composition entry not found: missing-parent/u, ); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); @@ -477,7 +518,7 @@ test('structural updates preserve descendants added after the parent was created assert.equal(loader.inspect('dynamic-child').parentId, 'dynamic-group'); assert.equal(loader.inspect('dynamic-child').disabled, true); assert.deepEqual( - loader.snapshot().roots.profile[0]?.children?.map(({ id }) => id), + loader.compositionState().roots.profile[0]?.children?.map(({ id }) => id), ['dynamic-child'], ); await loader.close(); @@ -492,12 +533,12 @@ test('failed rebind leaves parent and position unchanged', async () => { ); await loader.create('profile', { id: 'target-parent', intercept: { moveGuard: true } }); await loader.create('profile', entry('movable-entry', 'move-guard')); - const before = loader.snapshot(); + const before = loader.compositionState(); await assert.rejects(loader.move('movable-entry', 'target-parent'), /target rejected move/u); assert.equal(loader.inspect('movable-entry').parentId, undefined); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); @@ -562,7 +603,7 @@ test('callable config remains inspectable after publication', async () => { assert.equal(inspection.config, config); assert.equal(loader.inspect('callable-config-entry').config, config); - assert.equal(loader.snapshot().roots.profile[0]?.config, config); + assert.equal(loader.compositionState().roots.profile[0]?.config, config); await loader.close(); }); @@ -585,7 +626,7 @@ test('callable intercept changes trigger structural Context replacement', async await loader.update('callable-intercept-entry', { intercept: { fixture: second } }); assert.deepEqual(seen, [first, second]); - assert.equal(loader.snapshot().roots.profile[0]?.intercept?.fixture, second); + assert.equal(loader.compositionState().roots.profile[0]?.intercept?.fixture, second); await loader.close(); }); @@ -603,7 +644,7 @@ test('staging and commit failures do not retain newly created session roots', as }); }), ); - const before = loader.snapshot(); + const before = loader.compositionState(); await assert.rejects( loader.create('session:missing-package', entry('missing-package-entry', 'missing-package')), @@ -621,14 +662,14 @@ test('staging and commit failures do not retain newly created session roots', as /commit failed/u, ); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); test('composition apply batches EntryTree operations under one generation check', async () => { const loader = new MakaCompositionLoader(); await loader.install(pkg('batch', () => undefined)); - const initial = loader.snapshot().generation; + const initial = loader.compositionState().generation; const changed = await loader.apply({ baseGeneration: initial, operations: [ @@ -652,7 +693,7 @@ test('composition apply batches EntryTree operations under one generation check' test('failed composition batches restore the prior generation exactly', async () => { const loader = new MakaCompositionLoader(); await loader.create('profile', { id: 'stable-entry' }); - const before = loader.snapshot(); + const before = loader.compositionState(); await assert.rejects( () => @@ -666,7 +707,124 @@ test('failed composition batches restore the prior generation exactly', async () /Composition entry not found: missing-entry/u, ); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); + 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.compositionState().generation; + + await loader.reload(pkg('reload-target', host('new'))); + + assert.equal(loader.inspect('reload-unrelated').generation, unrelatedGeneration); + assert.equal(loader.compositionState().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.recoverComposition({ + 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.compositionState().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 = applyCompositionState(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.replaceComposition({ + schemaVersion: 1, + generation: 4, + roots: { + profile: [ + { id: 'equivalence-a', children: [{ id: 'equivalence-a1' }, { id: 'equivalence-a2' }] }, + { id: 'equivalence-b' }, + ], + desktopUi: [], + sessions: {}, + }, + }); + const before = loader.compositionState(); + 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 = applyCompositionState(before, input); + await loader.apply(input); + + assert.deepEqual(loader.compositionState(), planned); await loader.close(); }); diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index abb887437c..6cfd6a53e4 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -17,13 +17,13 @@ * under the License. */ -import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; +import { Context, type Fiber, FiberState, type Inject, type Plugin } from './plugin-kernel.js'; import { fiberStateName, type MakaCompositionEntry, type MakaCompositionEntryInspection, type MakaCompositionApplyInput, - type MakaCompositionSnapshot, + type MakaCompositionState, type MakaPluginMetadata, type MakaPluginPackage, type MakaPluginRootId, @@ -60,6 +60,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 +94,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)) { @@ -156,7 +181,7 @@ export class MakaCompositionLoader { 'invalid_entry', `Composition generation changed from ${input.baseGeneration} to ${this.#compositionGeneration}`, ); - const before = this.snapshot(); + const before = this.compositionState(); const inspections: MakaCompositionEntryInspection[] = []; let appliedOperations = 0; try { @@ -199,7 +224,7 @@ export class MakaCompositionLoader { // A candidate can fail before changing the live tree. Rebuilding in // that case would unnecessarily dispose the current Fiber and lose // its registered contributions. - if (appliedOperations > 0) await this.#replaceSnapshot(before, 'rollback'); + if (appliedOperations > 0) await this.#replaceComposition(before, 'rollback'); throw error; } if (input.operations.length > 0) this.#compositionGeneration += 1; @@ -289,7 +314,7 @@ export class MakaCompositionLoader { } } - snapshot(): MakaCompositionSnapshot { + compositionState(): MakaCompositionState { const encode = (rootId: MakaPluginRootId): readonly MakaCompositionEntry[] => Object.freeze((this.#roots.get(rootId)?.entries ?? []).map((entry) => serialize(entry))); const sessions = Object.fromEntries( @@ -310,22 +335,137 @@ export class MakaCompositionLoader { }); } - replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { - return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish')); + replaceComposition(state: MakaCompositionState): Promise { + return this.#mutate(() => this.#replaceComposition(state, 'publish')); + } + + /** Restores an externally uncommitted mutation without advancing its generation. */ + restoreComposition(state: MakaCompositionState): Promise { + return this.#mutate(() => this.#replaceComposition(state, 'rollback')); } - async #replaceSnapshot( - snapshot: MakaCompositionSnapshot, + /** + * Recovers as much of a durable desired tree as possible. A failed Entry + * does not prevent unrelated roots or siblings from becoming active. + */ + recoverComposition( + state: MakaCompositionState, + ): Promise { + return this.#mutate(async () => { + if (state.schemaVersion !== 1) { + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); + } + const failures: MakaCompositionRecoveryFailure[] = []; + const stagedRoots = new Map(); + const stagedIds = new Set(); + const specs = new Map([ + ['profile', state.roots.profile], + ['desktop-ui', state.roots.desktopUi], + ...Object.entries(state.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 = state.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 #replaceComposition( + state: MakaCompositionState, generationMode: 'publish' | 'rollback', ): Promise { - if (snapshot.schemaVersion !== 1) - throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + if (state.schemaVersion !== 1) + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); const previousGeneration = this.#compositionGeneration; const pristine = previousGeneration === 0 && this.#entries.size === 0 && this.#roots.size === 0; const specs = new Map([ - ['profile', snapshot.roots.profile], - ['desktop-ui', snapshot.roots.desktopUi], - ...Object.entries(snapshot.roots.sessions).map( + ['profile', state.roots.profile], + ['desktop-ui', state.roots.desktopUi], + ...Object.entries(state.roots.sessions).map( ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, ), ]); @@ -374,10 +514,10 @@ export class MakaCompositionLoader { } this.#compositionGeneration = generationMode === 'rollback' - ? snapshot.generation + ? state.generation : pristine - ? snapshot.generation - : Math.max(previousGeneration, snapshot.generation) + 1; + ? state.generation + : Math.max(previousGeneration, state.generation) + 1; await this.#retire( settleAll( previous.flatMap((root) => @@ -416,7 +556,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 +588,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 +736,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 +824,7 @@ export class MakaCompositionLoader { entry, rootId, parent, - parent?.context ?? root.context, + parent ? childMountContext(parent) : root.context, parent ? isDisabled(parent) : false, ); try { @@ -864,6 +1056,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..287a3abe57 100644 --- a/packages/runtime/src/plugin-runtime.ts +++ b/packages/runtime/src/plugin-runtime.ts @@ -46,7 +46,7 @@ export interface MakaCompositionEntry { readonly children?: readonly MakaCompositionEntry[]; } -export interface MakaCompositionSnapshot { +export interface MakaCompositionState { readonly schemaVersion: 1; readonly generation: number; readonly roots: { @@ -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 applyCompositionState( + state: MakaCompositionState, + input: MakaCompositionApplyInput, +): MakaCompositionState { + if (state.schemaVersion !== 1) { + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); + } + if (input.baseGeneration !== undefined && input.baseGeneration !== state.generation) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${state.generation}`, + ); + } + if (input.operations.length === 0) return state; + if (state.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 = state.roots.profile.map(cloneCompositionEntry); + const desktopUi = state.roots.desktopUi.map(cloneCompositionEntry); + const sessions = Object.fromEntries( + Object.entries(state.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 freezeCompositionState({ + schemaVersion: 1, + generation: state.generation + 1, + roots: { profile, desktopUi, sessions }, + }); +} + export type MakaCompositionEntryStatus = | 'disabled' | 'pending' @@ -125,20 +321,6 @@ export interface MakaPluginMountInspection { readonly diagnostic?: { readonly message: string }; } -export interface MakaRuntimeCompositionEntry { - readonly entryId: string; - readonly packageId: string; - readonly generation: number; - readonly contributions: readonly MakaPluginContribution[]; -} - -export interface MakaRuntimeCompositionSnapshot { - readonly schemaVersion: 1; - readonly rootId: string; - readonly digest: `sha256:${string}`; - readonly entries: readonly MakaRuntimeCompositionEntry[]; -} - export interface MakaPluginMetadata { readonly rootId: MakaPluginRootId; readonly entryId: string; @@ -202,6 +384,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 +594,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 freezeCompositionState(state: MakaCompositionState): MakaCompositionState { + 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: state.generation, + roots: Object.freeze({ + profile: Object.freeze(state.roots.profile.map(freezeEntry)), + desktopUi: Object.freeze(state.roots.desktopUi.map(freezeEntry)), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(state.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)