From b83af63034eb7c59d6f3021457cb707a5ada5933 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:21:59 +0800 Subject: [PATCH 01/48] feat(runtime): add extension lifecycle kernel --- .../extension-lifecycle-kernel.md | 54 + packages/runtime/package.json | 1 + .../extension-lifecycle-kernel.test.ts | 596 +++++++++ .../runtime/src/extension-lifecycle-kernel.ts | 1065 +++++++++++++++++ 4 files changed, 1716 insertions(+) create mode 100644 docs/architecture/extension-lifecycle-kernel.md create mode 100644 packages/runtime/src/__tests__/extension-lifecycle-kernel.test.ts create mode 100644 packages/runtime/src/extension-lifecycle-kernel.ts diff --git a/docs/architecture/extension-lifecycle-kernel.md b/docs/architecture/extension-lifecycle-kernel.md new file mode 100644 index 0000000000..62a15d760f --- /dev/null +++ b/docs/architecture/extension-lifecycle-kernel.md @@ -0,0 +1,54 @@ +# Extension Lifecycle Kernel (Phase 1) + +This module implements the product-independent lifecycle and revision semantics from issue #2973. It deliberately does not register Tools, UI, hooks, or execute dynamic scripts. + +## Authority and objects + +- An **Extension** is a stable identity. +- A **Revision** is immutable after `install`. Installing a revision never executes extension code. +- A **Binding** selects one exact revision for one scope. Only one binding for an extension may exist in a scope. +- A **Candidate** owns everything allocated by `prepare`, `healthCheck`, and `activate` until it either becomes current or is rolled back. +- A **Current Activation** owns its exposed dependency value and every registered effect. +- A **Composition Snapshot** is an immutable, deterministic view of the active revisions and contribution descriptors in a scope. + +## Lifecycle + +```text +install revision (no effects) + -> enable binding + -> wait for same-scope dependencies + -> prepare candidate + -> health check + -> activate + -> commit current + -> stop / remove binding / uninstall revision +``` + +Updates use current/candidate semantics: + +```text +current remains active + -> prepare + health-check candidate + -> stop active dependents + -> activate candidate + -> commit candidate as current + -> dispose old current + -> reactivate dependents against the new dependency value +``` + +If preparation, health checking, or activation fails, the candidate is disposed in reverse effect-registration order and current remains committed. If candidate activation had already stopped dependents, they are reconciled back against the old current. + +## Invariants + +- All mutations are serialized by the kernel. +- Dependencies resolve only inside the binding's scope. +- Missing dependencies produce `waiting`; activating the provider reconciles waiting consumers. +- Stopping a provider stops transitive dependents before the provider. +- Dependency cycles fail without executing extension code. +- Effects are disposed in reverse registration order; failed disposers remain inspectable and retryable. +- A revision cannot be uninstalled while any binding, current activation, or candidate references it. +- Previously returned composition snapshots never mutate. + +## Adapter boundary + +Future contribution adapters register their reversible work through `ExtensionActivationContext.ownEffect`. A Tool adapter, for example, will own the Tool registry entry's disposer. The kernel does not bypass Maka's existing Runtime, permission, sandbox, or Run-composition authorities; those integrations belong to later phases. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 4ea647d4a8..293be31364 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -31,6 +31,7 @@ "./runtime-event-read-model": "./dist/runtime-event-read-model.js", "./runtime-resume": "./dist/runtime-resume.js", "./runtime-kernel": "./dist/runtime-kernel.js", + "./extension-lifecycle-kernel": "./dist/extension-lifecycle-kernel.js", "./invocation-context": "./dist/invocation-context.js", "./ai-sdk-flow": "./dist/ai-sdk-flow.js", "./stream-graph-readiness": "./dist/stream-graph-readiness.js", diff --git a/packages/runtime/src/__tests__/extension-lifecycle-kernel.test.ts b/packages/runtime/src/__tests__/extension-lifecycle-kernel.test.ts new file mode 100644 index 0000000000..770c916663 --- /dev/null +++ b/packages/runtime/src/__tests__/extension-lifecycle-kernel.test.ts @@ -0,0 +1,596 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + ExtensionLifecycleKernel, + ExtensionLifecycleOperationError, + type ExtensionRevisionDefinition, +} from '../extension-lifecycle-kernel.js'; + +test('install is effect-free and snapshots immutable revision metadata', async () => { + const kernel = new ExtensionLifecycleKernel(); + const contributions = [{ id: 'alpha.fake', kind: 'fake' }]; + let prepares = 0; + + await kernel.install({ + extensionId: 'alpha', + revision: '1', + contributions, + prepare: () => { + prepares += 1; + return { activate: () => undefined }; + }, + }); + contributions.push({ id: 'mutated.after.install', kind: 'fake' }); + + assert.equal(prepares, 0, 'install must not execute extension code'); + assert.deepEqual(kernel.installedRevisions(), [{ extensionId: 'alpha', revision: '1' }]); + await assertCode( + kernel.install({ + extensionId: 'alpha', + revision: '1', + prepare: () => ({ activate: () => undefined }), + }), + 'revision_already_installed', + ); + + await kernel.activate(binding('alpha-binding', 'session-a', 'alpha', '1')); + assert.equal(prepares, 1); + const snapshot = kernel.composition('session-a'); + assert.equal(snapshot.entries.length, 1); + assert.deepEqual(snapshot.entries[0]!.contributions, [{ id: 'alpha.fake', kind: 'fake' }]); + assert.ok(Object.isFrozen(snapshot)); + assert.ok(Object.isFrozen(snapshot.entries)); + assert.ok(Object.isFrozen(snapshot.entries[0]!.contributions)); +}); + +test('stop disposes activation and preparation effects in reverse ownership order', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + await kernel.install({ + extensionId: 'ordered', + revision: '1', + prepare: (context) => { + context.ownEffect('prepare-effect', () => { + events.push('prepare-effect'); + }); + return { + dispose: () => { + events.push('prepared-dispose'); + }, + activate: (activation) => { + activation.ownEffect('first', () => { + events.push('first'); + }); + activation.ownEffect('second', async () => { + await Promise.resolve(); + events.push('second'); + }); + }, + }; + }, + }); + + await kernel.activate(binding('ordered-binding', 'session-a', 'ordered', '1')); + await kernel.stop('ordered-binding'); + + assert.deepEqual(events, ['second', 'first', 'prepared-dispose', 'prepare-effect']); + assert.equal(kernel.inspect('ordered-binding').status, 'stopped'); +}); + +test('failed activation rolls back every candidate-owned effect', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + await kernel.install({ + extensionId: 'broken', + revision: '1', + prepare: (context) => { + context.ownEffect('prepare-effect', () => { + events.push('prepare-effect'); + }); + return { + dispose: () => { + events.push('prepared-dispose'); + }, + activate: (activation) => { + activation.ownEffect('published-effect', () => { + events.push('published-effect'); + }); + throw new Error('boom'); + }, + }; + }, + }); + + await assertCode( + kernel.activate(binding('broken-binding', 'session-a', 'broken', '1')), + 'activation_failed', + ); + assert.deepEqual(events, ['published-effect', 'prepared-dispose', 'prepare-effect']); + assert.deepEqual(kernel.inspect('broken-binding'), { + bindingId: 'broken-binding', + scopeId: 'session-a', + extensionId: 'broken', + desiredRevision: '1', + enabled: true, + status: 'failed', + waitingFor: [], + pendingCleanupEffects: 0, + diagnostic: { + code: 'activation_failed', + message: 'Extension candidate broken@1 activation failed', + revision: '1', + at: kernel.inspect('broken-binding').diagnostic!.at, + }, + }); +}); + +test('health-check failure never publishes the candidate', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + await kernel.install({ + extensionId: 'unhealthy', + revision: '1', + prepare: () => ({ + healthCheck: () => { + events.push('health'); + throw new Error('not ready'); + }, + activate: () => { + events.push('activate'); + }, + dispose: () => { + events.push('dispose'); + }, + }), + }); + + await assertCode( + kernel.activate(binding('unhealthy-binding', 'session-a', 'unhealthy', '1')), + 'health_check_failed', + ); + assert.deepEqual(events, ['health', 'dispose']); + assert.equal(kernel.composition('session-a').entries.length, 0); +}); + +test('dependencies wait, inject the active value, stop dependents first, and recover', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const seen: string[] = []; + await kernel.install( + lifecycleRevision('provider', '1', events, { + value: 'provider-value', + }), + ); + await kernel.install({ + extensionId: 'consumer', + revision: '1', + dependencies: [{ extensionId: 'provider' }], + prepare: () => ({ + activate: (context) => { + seen.push( + `${context.dependency('provider')}@${context.dependencyRevision('provider')}`, + ); + context.ownEffect('consumer', () => { + events.push('consumer:dispose'); + }); + }, + }), + }); + + const waiting = await kernel.activate(binding('b-consumer', 'session-a', 'consumer', '1')); + assert.equal(waiting.status, 'waiting'); + assert.deepEqual(waiting.waitingFor, ['provider']); + + await kernel.activate(binding('a-provider', 'session-a', 'provider', '1')); + assert.equal(kernel.inspect('b-consumer').status, 'active'); + assert.deepEqual(seen, ['provider-value@1']); + + events.length = 0; + await kernel.stop('a-provider'); + assert.deepEqual(events, ['consumer:dispose', 'provider:1:dispose']); + assert.equal(kernel.inspect('b-consumer').status, 'waiting'); + assert.deepEqual(kernel.inspect('b-consumer').waitingFor, ['provider']); + + await kernel.start('a-provider'); + assert.equal(kernel.inspect('b-consumer').status, 'active'); + assert.deepEqual(seen, ['provider-value@1', 'provider-value@1']); +}); + +test('successful update keeps current during prepare and commits a new immutable composition', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const gate = deferred(); + const prepareStarted = deferred(); + await kernel.install(lifecycleRevision('updatable', '1', events)); + await kernel.install({ + extensionId: 'updatable', + revision: '2', + contributions: [{ id: 'new.fake', kind: 'fake' }], + prepare: async () => { + prepareStarted.resolve(); + await gate.promise; + return { + activate: (context) => { + events.push('updatable:2:activate'); + context.ownEffect('v2', () => { + events.push('updatable:2:dispose'); + }); + }, + }; + }, + }); + await kernel.activate(binding('updatable-binding', 'session-a', 'updatable', '1')); + const before = kernel.composition('session-a'); + + const update = kernel.update('updatable-binding', '2'); + await prepareStarted.promise; + const during = kernel.inspect('updatable-binding'); + assert.equal(during.current?.revision, '1'); + assert.deepEqual(during.candidate, { revision: '2', phase: 'preparing' }); + assert.equal(before.entries[0]!.revision, '1'); + + gate.resolve(); + const updated = await update; + assert.equal(updated.current?.revision, '2'); + assert.equal(updated.status, 'active'); + assert.deepEqual(events.slice(-2), ['updatable:2:activate', 'updatable:1:dispose']); + const after = kernel.composition('session-a'); + assert.equal(before.entries[0]!.revision, '1', 'old snapshot remains immutable'); + assert.equal(after.entries[0]!.revision, '2'); + assert.notEqual(before.digest, after.digest); +}); + +test('failed candidate health check preserves the committed activation', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + await kernel.install(lifecycleRevision('safe-update', '1', events)); + await kernel.install({ + extensionId: 'safe-update', + revision: '2', + prepare: (context) => { + context.ownEffect('candidate-prepare', () => { + events.push('candidate-prepare:dispose'); + }); + return { + healthCheck: () => { + throw new Error('candidate unhealthy'); + }, + activate: () => { + events.push('candidate:activate'); + }, + }; + }, + }); + await kernel.activate(binding('safe-binding', 'session-a', 'safe-update', '1')); + + await assertCode(kernel.update('safe-binding', '2'), 'health_check_failed'); + const inspection = kernel.inspect('safe-binding'); + assert.equal(inspection.current?.revision, '1'); + assert.equal(inspection.status, 'active'); + assert.equal(inspection.diagnostic?.code, 'health_check_failed'); + assert.deepEqual(events, ['safe-update:1:activate', 'candidate-prepare:dispose']); +}); + +test('failed provider activation restores dependents against the old current', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const seen: string[] = []; + await kernel.install(lifecycleRevision('provider', '1', events, { value: 'old' })); + await kernel.install({ + extensionId: 'provider', + revision: '2', + prepare: () => ({ + activate: (context) => { + events.push('provider:2:activate'); + context.ownEffect('provider-v2', () => { + events.push('provider:2:dispose'); + }); + throw new Error('candidate failed'); + }, + }), + }); + await kernel.install({ + extensionId: 'consumer', + revision: '1', + dependencies: [{ extensionId: 'provider' }], + prepare: () => ({ + activate: (context) => { + seen.push(context.dependency('provider')); + context.ownEffect('consumer', () => { + events.push('consumer:dispose'); + }); + }, + }), + }); + await kernel.activate(binding('a-provider', 'session-a', 'provider', '1')); + await kernel.activate(binding('b-consumer', 'session-a', 'consumer', '1')); + + await assertCode(kernel.update('a-provider', '2'), 'activation_failed'); + assert.equal(kernel.inspect('a-provider').current?.revision, '1'); + assert.equal(kernel.inspect('b-consumer').status, 'active'); + assert.deepEqual(seen, ['old', 'old']); + assert.deepEqual(events.slice(-3), [ + 'consumer:dispose', + 'provider:2:activate', + 'provider:2:dispose', + ]); +}); + +test('dependency cycles are diagnosed without executing extension code', async () => { + const kernel = new ExtensionLifecycleKernel(); + let activations = 0; + await kernel.install({ + extensionId: 'cycle-a', + revision: '1', + dependencies: [{ extensionId: 'cycle-b' }], + prepare: () => ({ activate: () => void (activations += 1) }), + }); + await kernel.install({ + extensionId: 'cycle-b', + revision: '1', + dependencies: [{ extensionId: 'cycle-a' }], + prepare: () => ({ activate: () => void (activations += 1) }), + }); + + const first = await kernel.activate(binding('cycle-a-binding', 'session-a', 'cycle-a', '1')); + assert.equal(first.status, 'waiting'); + await assertCode( + kernel.activate(binding('cycle-b-binding', 'session-a', 'cycle-b', '1')), + 'dependency_cycle', + ); + assert.equal(kernel.inspect('cycle-a-binding').diagnostic?.code, 'dependency_cycle'); + assert.equal(kernel.inspect('cycle-b-binding').diagnostic?.code, 'dependency_cycle'); + assert.equal(activations, 0); +}); + +test('dependency resolution and stop are isolated by scope', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const seen: string[] = []; + await kernel.install(lifecycleRevision('provider', '1', events, { value: 'scoped' })); + await kernel.install({ + extensionId: 'consumer', + revision: '1', + dependencies: [{ extensionId: 'provider' }], + prepare: () => ({ + activate: (context) => { + seen.push(`${context.scopeId}:${context.dependency('provider')}`); + context.ownEffect('consumer', () => undefined); + }, + }), + }); + for (const scope of ['session-a', 'session-b']) { + await kernel.activate(binding(`${scope}-provider`, scope, 'provider', '1')); + await kernel.activate(binding(`${scope}-consumer`, scope, 'consumer', '1')); + } + + await kernel.stop('session-a-provider'); + assert.equal(kernel.inspect('session-a-consumer').status, 'waiting'); + assert.equal(kernel.inspect('session-b-consumer').status, 'active'); + assert.equal(kernel.composition('session-a').entries.length, 0); + assert.equal(kernel.composition('session-b').entries.length, 2); + assert.deepEqual(seen, ['session-a:scoped', 'session-b:scoped']); +}); + +test('uninstall requires all bindings to release the immutable revision', async () => { + const kernel = new ExtensionLifecycleKernel(); + await kernel.install({ + extensionId: 'removable', + revision: '1', + prepare: () => ({ activate: () => undefined }), + }); + await kernel.activate(binding('removable-binding', 'session-a', 'removable', '1')); + await assertCode(kernel.uninstall('removable', '1'), 'revision_in_use'); + await kernel.stop('removable-binding'); + await assertCode(kernel.uninstall('removable', '1'), 'revision_in_use'); + await kernel.removeBinding('removable-binding'); + await kernel.uninstall('removable', '1'); + assert.deepEqual(kernel.installedRevisions(), []); +}); + +test('concurrent mutations serialize update before a later stop', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const gate = deferred(); + const started = deferred(); + await kernel.install(lifecycleRevision('serialized', '1', events)); + await kernel.install({ + extensionId: 'serialized', + revision: '2', + prepare: async () => { + started.resolve(); + await gate.promise; + return { + activate: (context) => { + events.push('serialized:2:activate'); + context.ownEffect('v2', () => { + events.push('serialized:2:dispose'); + }); + }, + }; + }, + }); + await kernel.activate(binding('serialized-binding', 'session-a', 'serialized', '1')); + + const update = kernel.update('serialized-binding', '2'); + await started.promise; + const stop = kernel.stop('serialized-binding'); + assert.equal(kernel.inspect('serialized-binding').candidate?.revision, '2'); + gate.resolve(); + await update; + await stop; + + assert.equal(kernel.inspect('serialized-binding').status, 'stopped'); + assert.deepEqual(events.slice(-3), [ + 'serialized:2:activate', + 'serialized:1:dispose', + 'serialized:2:dispose', + ]); +}); + +test('failed cleanup remains diagnosable and can be retried', async () => { + const kernel = new ExtensionLifecycleKernel(); + let attempts = 0; + await kernel.install({ + extensionId: 'cleanup-retry', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.ownEffect('flaky', () => { + attempts += 1; + if (attempts === 1) throw new Error('temporary cleanup failure'); + }); + }, + }), + }); + await kernel.activate(binding('cleanup-binding', 'session-a', 'cleanup-retry', '1')); + + await assertCode(kernel.stop('cleanup-binding'), 'cleanup_failed'); + const failed = kernel.inspect('cleanup-binding'); + assert.equal(failed.status, 'failed'); + assert.equal(failed.pendingCleanupEffects, 1); + assert.equal(failed.diagnostic?.code, 'cleanup_failed'); + + const stopped = await kernel.stop('cleanup-binding'); + assert.equal(stopped.status, 'stopped'); + assert.equal(stopped.pendingCleanupEffects, 0); + assert.equal(attempts, 2); +}); + +test('an update waits for new dependencies without disturbing current', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + await kernel.install(lifecycleRevision('switchable', '1', events)); + await kernel.install({ + extensionId: 'switchable', + revision: '2', + dependencies: [{ extensionId: 'provider' }], + prepare: () => ({ + activate: (context) => { + events.push(`switchable:2:${context.dependency('provider')}`); + context.ownEffect('switchable-v2', () => undefined); + }, + }), + }); + await kernel.install(lifecycleRevision('provider', '1', events, { value: 'ready' })); + await kernel.activate(binding('switchable-binding', 'session-a', 'switchable', '1')); + + const waiting = await kernel.update('switchable-binding', '2'); + assert.equal(waiting.status, 'waiting'); + assert.equal(waiting.current?.revision, '1'); + assert.equal(waiting.desiredRevision, '2'); + assert.deepEqual(waiting.waitingFor, ['provider']); + + await kernel.activate(binding('provider-binding', 'session-a', 'provider', '1')); + assert.equal(kernel.inspect('switchable-binding').current?.revision, '2'); + assert.ok(events.includes('switchable:2:ready')); +}); + +test('candidate commit remains authoritative when retired-current cleanup needs retry', async () => { + const kernel = new ExtensionLifecycleKernel(); + let oldCleanupAttempts = 0; + await kernel.install({ + extensionId: 'retired-cleanup', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.ownEffect('old', () => { + oldCleanupAttempts += 1; + if (oldCleanupAttempts === 1) throw new Error('old cleanup failed'); + }); + }, + }), + }); + await kernel.install({ + extensionId: 'retired-cleanup', + revision: '2', + prepare: () => ({ + activate: (context) => context.ownEffect('new', () => undefined), + }), + }); + await kernel.activate(binding('retired-binding', 'session-a', 'retired-cleanup', '1')); + + await assertCode(kernel.update('retired-binding', '2'), 'cleanup_failed'); + const committed = kernel.inspect('retired-binding'); + assert.equal(committed.current?.revision, '2'); + assert.equal(committed.pendingCleanupEffects, 1); + assert.equal(committed.diagnostic?.code, 'cleanup_failed'); + + const recovered = await kernel.start('retired-binding'); + assert.equal(recovered.current?.revision, '2'); + assert.equal(recovered.pendingCleanupEffects, 0); + assert.equal(oldCleanupAttempts, 2); +}); + +test('disposing a scope retracts dependents before providers and removes every binding', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + await kernel.install(lifecycleRevision('provider', '1', events)); + await kernel.install({ + extensionId: 'consumer', + revision: '1', + dependencies: [{ extensionId: 'provider' }], + prepare: () => ({ + activate: (context) => { + events.push('consumer:activate'); + context.ownEffect('consumer', () => { + events.push('consumer:dispose'); + }); + }, + }), + }); + await kernel.activate(binding('provider-binding', 'session-a', 'provider', '1')); + await kernel.activate(binding('consumer-binding', 'session-a', 'consumer', '1')); + events.length = 0; + + await kernel.disposeScope('session-a'); + assert.deepEqual(events, ['consumer:dispose', 'provider:1:dispose']); + assert.deepEqual(kernel.inspectScope('session-a'), []); + assert.deepEqual(kernel.composition('session-a').entries, []); +}); + +function binding(bindingId: string, scopeId: string, extensionId: string, revision: string) { + return { bindingId, scopeId, extensionId, revision }; +} + +function lifecycleRevision( + extensionId: string, + revision: string, + events: string[], + options: { value?: unknown } = {}, +): ExtensionRevisionDefinition { + return { + extensionId, + revision, + prepare: () => ({ + activate: (context) => { + events.push(`${extensionId}:${revision}:activate`); + context.ownEffect(`${extensionId}:${revision}`, () => { + events.push(`${extensionId}:${revision}:dispose`); + }); + return { value: options.value }; + }, + }), + }; +} + +async function assertCode( + promise: Promise, + code: ExtensionLifecycleOperationError['code'], +): Promise { + await assert.rejects( + promise, + (error) => error instanceof ExtensionLifecycleOperationError && error.code === code, + ); +} + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/packages/runtime/src/extension-lifecycle-kernel.ts b/packages/runtime/src/extension-lifecycle-kernel.ts new file mode 100644 index 0000000000..add10022a0 --- /dev/null +++ b/packages/runtime/src/extension-lifecycle-kernel.ts @@ -0,0 +1,1065 @@ +import { createHash } from 'node:crypto'; + +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const MAX_ID_LENGTH = 128; + +export type ExtensionEffectDisposer = () => void | Promise; + +export interface ExtensionDependencyDefinition { + readonly extensionId: string; +} + +export interface ExtensionContributionDefinition { + readonly id: string; + readonly kind: string; +} + +export interface ExtensionPreparationContext { + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly revision: string; + readonly signal: AbortSignal; + ownEffect(label: string, dispose: ExtensionEffectDisposer): void; +} + +export interface ExtensionActivationContext extends ExtensionPreparationContext { + dependency(extensionId: string): T; + dependencyRevision(extensionId: string): string; +} + +export interface ExtensionActivationResult { + readonly value?: unknown; +} + +export interface PreparedExtension { + /** Validate candidate-local resources without publishing contributions. */ + healthCheck?(): void | Promise; + /** Publish activation-owned effects through `context.ownEffect`. */ + activate( + context: ExtensionActivationContext, + ): void | ExtensionActivationResult | Promise; + /** Release candidate-local resources allocated by `prepare`. */ + dispose?(): void | Promise; +} + +export interface ExtensionRevisionDefinition { + readonly extensionId: string; + readonly revision: string; + readonly dependencies?: readonly ExtensionDependencyDefinition[]; + readonly contributions?: readonly ExtensionContributionDefinition[]; + /** + * Definition/install is data-only. The kernel calls `prepare` only when a + * binding is enabled and all required dependencies are active. + */ + prepare(context: ExtensionPreparationContext): PreparedExtension | Promise; +} + +export interface ExtensionBindingInput { + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly revision: string; +} + +export type ExtensionBindingStatus = + | 'stopped' + | 'waiting' + | 'preparing' + | 'health_check' + | 'activating' + | 'active' + | 'failed'; + +export type ExtensionLifecycleErrorCode = + | 'invalid_definition' + | 'revision_already_installed' + | 'revision_not_installed' + | 'revision_in_use' + | 'binding_conflict' + | 'binding_not_found' + | 'dependency_cycle' + | 'prepare_failed' + | 'health_check_failed' + | 'activation_failed' + | 'cleanup_failed'; + +export interface ExtensionLifecycleDiagnostic { + readonly code: ExtensionLifecycleErrorCode; + readonly message: string; + readonly revision?: string; + readonly at: number; +} + +export interface ExtensionBindingInspection { + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly desiredRevision: string; + readonly enabled: boolean; + readonly status: ExtensionBindingStatus; + readonly current?: { + readonly revision: string; + readonly generation: number; + }; + readonly candidate?: { + readonly revision: string; + readonly phase: 'preparing' | 'health_check' | 'activating'; + }; + readonly waitingFor: readonly string[]; + readonly pendingCleanupEffects: number; + readonly diagnostic?: ExtensionLifecycleDiagnostic; +} + +export interface ExtensionCompositionEntry { + readonly bindingId: string; + readonly extensionId: string; + readonly revision: string; + readonly generation: number; + readonly contributions: readonly ExtensionContributionDefinition[]; +} + +export interface ExtensionCompositionSnapshot { + readonly schemaVersion: 1; + readonly scopeId: string; + readonly digest: `sha256:${string}`; + readonly entries: readonly ExtensionCompositionEntry[]; +} + +export class ExtensionLifecycleOperationError extends Error { + readonly name = 'ExtensionLifecycleOperationError'; + + constructor( + readonly code: ExtensionLifecycleErrorCode, + message: string, + options?: { cause?: unknown }, + ) { + super(message, options); + } +} + +interface InstalledRevision { + readonly extensionId: string; + readonly revision: string; + readonly dependencies: readonly ExtensionDependencyDefinition[]; + readonly contributions: readonly ExtensionContributionDefinition[]; + readonly prepare: ExtensionRevisionDefinition['prepare']; +} + +interface OwnedEffect { + readonly label: string; + readonly dispose: ExtensionEffectDisposer; +} + +interface EffectCleanupFailure { + readonly label: string; + readonly cause: unknown; +} + +class EffectCleanupError extends Error { + readonly name = 'EffectCleanupError'; + + constructor(readonly failures: readonly EffectCleanupFailure[]) { + super( + `Failed to clean up ${failures.length} extension effect${failures.length === 1 ? '' : 's'}`, + { cause: failures[0]?.cause }, + ); + } +} + +class EffectOwner { + readonly #effects: OwnedEffect[] = []; + #accepting = true; + + get size(): number { + return this.#effects.length; + } + + own(label: string, dispose: ExtensionEffectDisposer): void { + if (!this.#accepting) { + throw new ExtensionLifecycleOperationError( + 'activation_failed', + `Cannot register effect "${label}" after activation setup completed`, + ); + } + if (!label || label.length > 256 || typeof dispose !== 'function') { + throw new ExtensionLifecycleOperationError( + 'invalid_definition', + 'Extension effects require a non-empty label and disposer', + ); + } + this.#effects.push({ label, dispose }); + } + + seal(): void { + this.#accepting = false; + } + + async dispose(): Promise { + this.#accepting = false; + const failures: EffectCleanupFailure[] = []; + for (let index = this.#effects.length - 1; index >= 0; index -= 1) { + const effect = this.#effects[index]!; + try { + await effect.dispose(); + this.#effects.splice(index, 1); + } catch (cause) { + failures.push({ label: effect.label, cause }); + } + } + if (failures.length > 0) throw new EffectCleanupError(Object.freeze(failures)); + } +} + +interface CandidateActivation { + readonly definition: InstalledRevision; + readonly owner: EffectOwner; + readonly controller: AbortController; + prepared?: PreparedExtension; + phase: 'preparing' | 'health_check' | 'activating'; +} + +interface CurrentActivation { + readonly definition: InstalledRevision; + readonly owner: EffectOwner; + readonly generation: number; + readonly value: unknown; +} + +interface BindingRecord { + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + desiredRevision: string; + enabled: boolean; + status: ExtensionBindingStatus; + waitingFor: readonly string[]; + current?: CurrentActivation; + candidate?: CandidateActivation; + readonly retiredOwners: EffectOwner[]; + diagnostic?: ExtensionLifecycleDiagnostic; +} + +interface ReconcileResult { + readonly errors: Map; +} + +/** + * Product-independent Phase 1 lifecycle authority. + * + * Mutations are serialized. Installed revisions are immutable, definition is + * effect-free, dependencies resolve only inside the same scope, candidates do + * not replace current until preparation/health/activation succeeds, and every + * owned effect is disposed in reverse registration order. + */ +export class ExtensionLifecycleKernel { + readonly #revisions = new Map(); + readonly #bindings = new Map(); + readonly #scopeExtensionBindings = new Map(); + #mutationTail: Promise = Promise.resolve(); + #generation = 0; + + install(definition: ExtensionRevisionDefinition): Promise { + return this.#mutate(async () => { + const installed = normalizeDefinition(definition); + const key = revisionKey(installed.extensionId, installed.revision); + if (this.#revisions.has(key)) { + throw new ExtensionLifecycleOperationError( + 'revision_already_installed', + `Extension revision already installed: ${key}`, + ); + } + this.#revisions.set(key, installed); + await this.#reconcileAllScopes(); + }); + } + + uninstall(extensionId: string, revision: string): Promise { + return this.#mutate(async () => { + validateId('extensionId', extensionId); + validateRevision(revision); + const key = revisionKey(extensionId, revision); + if (!this.#revisions.has(key)) { + throw new ExtensionLifecycleOperationError( + 'revision_not_installed', + `Extension revision is not installed: ${key}`, + ); + } + const user = [...this.#bindings.values()].find( + (binding) => + binding.extensionId === extensionId && + (binding.desiredRevision === revision || + binding.current?.definition.revision === revision || + binding.candidate?.definition.revision === revision), + ); + if (user) { + throw new ExtensionLifecycleOperationError( + 'revision_in_use', + `Extension revision ${key} is still referenced by binding ${user.bindingId}`, + ); + } + this.#revisions.delete(key); + }); + } + + activate(input: ExtensionBindingInput): Promise { + return this.#mutate(async () => { + validateBindingInput(input); + this.#requireRevision(input.extensionId, input.revision); + const existing = this.#bindings.get(input.bindingId); + let record: BindingRecord; + if (existing) { + if (existing.scopeId !== input.scopeId || existing.extensionId !== input.extensionId) { + throw new ExtensionLifecycleOperationError( + 'binding_conflict', + `Binding ${input.bindingId} cannot change scope or extension identity`, + ); + } + record = existing; + const cleanupFailures = await this.#retryRetiredOwners(record); + if (cleanupFailures.length > 0) throw cleanupOperationError(cleanupFailures); + record.desiredRevision = input.revision; + record.enabled = true; + } else { + const scopeKey = scopeExtensionKey(input.scopeId, input.extensionId); + const owner = this.#scopeExtensionBindings.get(scopeKey); + if (owner) { + throw new ExtensionLifecycleOperationError( + 'binding_conflict', + `Scope ${input.scopeId} already binds extension ${input.extensionId} as ${owner}`, + ); + } + record = { + bindingId: input.bindingId, + scopeId: input.scopeId, + extensionId: input.extensionId, + desiredRevision: input.revision, + enabled: true, + status: 'waiting', + waitingFor: Object.freeze([]), + retiredOwners: [], + }; + this.#bindings.set(input.bindingId, record); + this.#scopeExtensionBindings.set(scopeKey, input.bindingId); + } + const result = await this.#reconcileScope(record.scopeId); + const error = result.errors.get(record.bindingId); + if (error) throw error; + return this.#inspectRecord(record); + }); + } + + update(bindingId: string, revision: string): Promise { + return this.#mutate(async () => { + const record = this.#requireBinding(bindingId); + const cleanupFailures = await this.#retryRetiredOwners(record); + if (cleanupFailures.length > 0) throw cleanupOperationError(cleanupFailures); + validateRevision(revision); + this.#requireRevision(record.extensionId, revision); + record.desiredRevision = revision; + record.enabled = true; + record.diagnostic = undefined; + const result = await this.#reconcileScope(record.scopeId); + const error = result.errors.get(record.bindingId); + if (error) throw error; + return this.#inspectRecord(record); + }); + } + + start(bindingId: string): Promise { + return this.#mutate(async () => { + const record = this.#requireBinding(bindingId); + const cleanupFailures = await this.#retryRetiredOwners(record); + if (cleanupFailures.length > 0) throw cleanupOperationError(cleanupFailures); + record.enabled = true; + record.diagnostic = undefined; + const result = await this.#reconcileScope(record.scopeId); + const error = result.errors.get(record.bindingId); + if (error) throw error; + return this.#inspectRecord(record); + }); + } + + retry(bindingId: string): Promise { + return this.start(bindingId); + } + + stop(bindingId: string): Promise { + return this.#mutate(async () => { + const record = this.#requireBinding(bindingId); + record.enabled = false; + record.candidate?.controller.abort(); + const failures = await this.#retryRetiredOwners(record); + failures.push(...(await this.#deactivateCascade(record, new Set()))); + await this.#reconcileScope(record.scopeId); + record.waitingFor = Object.freeze([]); + if (record.retiredOwners.length === 0) { + record.status = 'stopped'; + record.diagnostic = undefined; + } else { + record.status = 'failed'; + record.diagnostic = cleanupDiagnostic(record.desiredRevision, failures); + } + if (failures.length > 0) throw cleanupOperationError(failures); + return this.#inspectRecord(record); + }); + } + + removeBinding(bindingId: string): Promise { + return this.#mutate(async () => { + const record = this.#requireBinding(bindingId); + record.enabled = false; + record.candidate?.controller.abort(); + const failures = await this.#retryRetiredOwners(record); + failures.push(...(await this.#deactivateCascade(record, new Set()))); + if (record.retiredOwners.length > 0 || failures.length > 0) { + record.status = 'failed'; + record.diagnostic = cleanupDiagnostic(record.desiredRevision, failures); + throw cleanupOperationError(failures); + } + this.#bindings.delete(bindingId); + this.#scopeExtensionBindings.delete(scopeExtensionKey(record.scopeId, record.extensionId)); + await this.#reconcileScope(record.scopeId); + }); + } + + disposeScope(scopeId: string): Promise { + return this.#mutate(async () => { + validateId('scopeId', scopeId); + const records = this.#scopeBindings(scopeId); + for (const record of records) record.enabled = false; + const failures: EffectCleanupFailure[] = []; + for (const record of records) { + failures.push(...(await this.#retryRetiredOwners(record))); + } + const visited = new Set(); + for (const record of records) { + failures.push(...(await this.#deactivateCascade(record, visited))); + } + if (records.some((record) => record.retiredOwners.length > 0)) { + for (const record of records) { + if (record.retiredOwners.length === 0) continue; + record.status = 'failed'; + record.diagnostic = cleanupDiagnostic(record.desiredRevision, failures); + } + throw cleanupOperationError(failures); + } + for (const record of records) { + this.#bindings.delete(record.bindingId); + this.#scopeExtensionBindings.delete(scopeExtensionKey(scopeId, record.extensionId)); + } + }); + } + + inspect(bindingId: string): ExtensionBindingInspection { + return this.#inspectRecord(this.#requireBinding(bindingId)); + } + + inspectScope(scopeId: string): readonly ExtensionBindingInspection[] { + validateId('scopeId', scopeId); + return Object.freeze(this.#scopeBindings(scopeId).map((record) => this.#inspectRecord(record))); + } + + installedRevisions(): readonly { + readonly extensionId: string; + readonly revision: string; + }[] { + return Object.freeze( + [...this.#revisions.values()] + .map(({ extensionId, revision }) => Object.freeze({ extensionId, revision })) + .sort(compareExtensionRevision), + ); + } + + composition(scopeId: string): ExtensionCompositionSnapshot { + validateId('scopeId', scopeId); + const entries = this.#scopeBindings(scopeId) + .flatMap((binding): ExtensionCompositionEntry[] => { + const current = binding.current; + if (!current) return []; + return [ + Object.freeze({ + bindingId: binding.bindingId, + extensionId: binding.extensionId, + revision: current.definition.revision, + generation: current.generation, + contributions: current.definition.contributions, + }), + ]; + }) + .sort((left, right) => compareString(left.extensionId, right.extensionId)); + const digestInput = entries.map((entry) => ({ + bindingId: entry.bindingId, + extensionId: entry.extensionId, + revision: entry.revision, + contributions: entry.contributions, + })); + const digest = createHash('sha256').update(JSON.stringify(digestInput)).digest('hex'); + return Object.freeze({ + schemaVersion: 1, + scopeId, + digest: `sha256:${digest}`, + entries: Object.freeze(entries), + }); + } + + #mutate(operation: () => Promise): Promise { + const result = this.#mutationTail.then(operation, operation); + this.#mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + async #reconcileAllScopes(): Promise { + const scopes = [...new Set([...this.#bindings.values()].map((binding) => binding.scopeId))]; + for (const scope of scopes.sort(compareString)) await this.#reconcileScope(scope); + } + + async #reconcileScope(scopeId: string): Promise { + const errors = new Map(); + const attempted = new Set(); + const cycles = this.#dependencyCycles(scopeId); + for (const bindingId of cycles) { + const record = this.#bindings.get(bindingId)!; + const error = new ExtensionLifecycleOperationError( + 'dependency_cycle', + `Extension dependency cycle includes binding ${bindingId}`, + ); + record.status = 'failed'; + record.waitingFor = Object.freeze([]); + record.diagnostic = diagnostic(error, record.desiredRevision); + errors.set(bindingId, error); + attempted.add(bindingId); + } + + let progress = true; + while (progress) { + progress = false; + for (const record of this.#scopeBindings(scopeId)) { + if (!record.enabled || attempted.has(record.bindingId)) continue; + if (record.retiredOwners.length > 0) { + record.status = 'failed'; + continue; + } + const definition = this.#requireRevision(record.extensionId, record.desiredRevision); + const waitingFor = this.#missingDependencies(record, definition); + if (waitingFor.length > 0) { + record.status = 'waiting'; + record.waitingFor = Object.freeze(waitingFor); + continue; + } + record.waitingFor = Object.freeze([]); + if (record.current?.definition.revision === record.desiredRevision) { + record.status = 'active'; + continue; + } + attempted.add(record.bindingId); + try { + if (record.current) await this.#updateCurrent(record, definition); + else await this.#activateInitial(record, definition); + record.status = 'active'; + record.diagnostic = undefined; + progress = true; + } catch (cause) { + const error = asLifecycleError(cause); + record.status = record.current ? 'active' : 'failed'; + record.diagnostic = diagnostic(error, definition.revision); + errors.set(record.bindingId, error); + // Candidate activation can temporarily stop dependents; one more pass + // restores them against the still-current revision. + if (record.current) progress = true; + } + } + } + return { errors }; + } + + async #activateInitial(record: BindingRecord, definition: InstalledRevision): Promise { + const candidate = await this.#prepareCandidate(record, definition); + try { + const activation = await this.#activateCandidate(record, candidate); + record.current = activation; + record.candidate = undefined; + } catch (cause) { + await this.#discardCandidate(record, candidate, cause); + } + } + + async #updateCurrent(record: BindingRecord, definition: InstalledRevision): Promise { + const current = record.current!; + const candidate = await this.#prepareCandidate(record, definition); + const dependentCleanupFailures = await this.#deactivateDependents(record, new Set()); + if (dependentCleanupFailures.length > 0) { + return this.#discardCandidate( + record, + candidate, + new ExtensionLifecycleOperationError( + 'cleanup_failed', + 'Cannot activate candidate because a dependent did not stop cleanly', + { cause: dependentCleanupFailures[0]?.cause }, + ), + ); + } + let activation: CurrentActivation; + try { + activation = await this.#activateCandidate(record, candidate); + } catch (cause) { + return this.#discardCandidate(record, candidate, cause); + } + record.current = activation; + record.candidate = undefined; + const cleanupFailures = await this.#disposeOwner(record, current.owner); + if (cleanupFailures.length > 0) { + throw cleanupOperationError( + cleanupFailures, + 'Candidate committed, but old activation cleanup failed', + ); + } + } + + async #prepareCandidate( + record: BindingRecord, + definition: InstalledRevision, + ): Promise { + const owner = new EffectOwner(); + const candidate: CandidateActivation = { + definition, + owner, + controller: new AbortController(), + phase: 'preparing', + }; + record.candidate = candidate; + record.status = 'preparing'; + try { + const prepared = await definition.prepare( + this.#preparationContext(record, definition, candidate), + ); + if (!prepared || typeof prepared !== 'object' || typeof prepared.activate !== 'function') { + throw new ExtensionLifecycleOperationError( + 'invalid_definition', + `Extension ${definition.extensionId}@${definition.revision} returned an invalid candidate`, + ); + } + candidate.prepared = prepared; + if (prepared.dispose) owner.own('prepared.dispose', () => prepared.dispose!()); + candidate.phase = 'health_check'; + record.status = 'health_check'; + await prepared.healthCheck?.(); + return candidate; + } catch (cause) { + const phaseCode = + candidate.phase === 'health_check' ? 'health_check_failed' : 'prepare_failed'; + const error = + cause instanceof ExtensionLifecycleOperationError + ? cause + : new ExtensionLifecycleOperationError( + phaseCode, + `Extension candidate ${definition.extensionId}@${definition.revision} ${candidate.phase} failed`, + { cause }, + ); + return this.#discardCandidate(record, candidate, error); + } + } + + async #activateCandidate( + record: BindingRecord, + candidate: CandidateActivation, + ): Promise { + const prepared = candidate.prepared!; + candidate.phase = 'activating'; + record.status = 'activating'; + try { + const result = await prepared.activate(this.#activationContext(record, candidate)); + candidate.owner.seal(); + return { + definition: candidate.definition, + owner: candidate.owner, + generation: ++this.#generation, + value: result?.value, + }; + } catch (cause) { + throw cause instanceof ExtensionLifecycleOperationError + ? cause + : new ExtensionLifecycleOperationError( + 'activation_failed', + `Extension candidate ${candidate.definition.extensionId}@${candidate.definition.revision} activation failed`, + { cause }, + ); + } + } + + async #discardCandidate( + record: BindingRecord, + candidate: CandidateActivation, + cause: unknown, + ): Promise { + candidate.controller.abort(); + candidate.owner.seal(); + record.candidate = undefined; + const cleanupFailures = await this.#disposeOwner(record, candidate.owner); + if (cleanupFailures.length > 0) { + throw cleanupOperationError( + cleanupFailures, + 'Candidate failed and cleanup was incomplete', + cause, + ); + } + throw cause; + } + + #preparationContext( + record: BindingRecord, + definition: InstalledRevision, + candidate: CandidateActivation, + ): ExtensionPreparationContext { + return Object.freeze({ + bindingId: record.bindingId, + scopeId: record.scopeId, + extensionId: record.extensionId, + revision: definition.revision, + signal: candidate.controller.signal, + ownEffect: (label: string, dispose: ExtensionEffectDisposer) => + candidate.owner.own(label, dispose), + }); + } + + #activationContext( + record: BindingRecord, + candidate: CandidateActivation, + ): ExtensionActivationContext { + const base = this.#preparationContext(record, candidate.definition, candidate); + const dependency = (extensionId: string): T => { + const activation = this.#dependencyActivation(record.scopeId, extensionId); + if (!activation) { + throw new ExtensionLifecycleOperationError( + 'activation_failed', + `Required dependency ${extensionId} is no longer active`, + ); + } + return activation.value as T; + }; + return Object.freeze({ + ...base, + dependency, + dependencyRevision: (extensionId: string) => { + const activation = this.#dependencyActivation(record.scopeId, extensionId); + if (!activation) { + throw new ExtensionLifecycleOperationError( + 'activation_failed', + `Required dependency ${extensionId} is no longer active`, + ); + } + return activation.definition.revision; + }, + }); + } + + #missingDependencies(record: BindingRecord, definition: InstalledRevision): string[] { + return definition.dependencies + .filter((dependency) => !this.#dependencyActivation(record.scopeId, dependency.extensionId)) + .map((dependency) => dependency.extensionId) + .sort(compareString); + } + + #dependencyActivation(scopeId: string, extensionId: string): CurrentActivation | undefined { + const bindingId = this.#scopeExtensionBindings.get(scopeExtensionKey(scopeId, extensionId)); + return bindingId ? this.#bindings.get(bindingId)?.current : undefined; + } + + #dependencyCycles(scopeId: string): Set { + const records = this.#scopeBindings(scopeId).filter((record) => record.enabled); + const byExtension = new Map(records.map((record) => [record.extensionId, record])); + const visiting = new Set(); + const visited = new Set(); + const stack: string[] = []; + const cycles = new Set(); + const visit = (record: BindingRecord): void => { + if (visited.has(record.bindingId)) return; + if (visiting.has(record.bindingId)) { + const start = stack.indexOf(record.bindingId); + for (const bindingId of stack.slice(start)) cycles.add(bindingId); + return; + } + visiting.add(record.bindingId); + stack.push(record.bindingId); + const definition = this.#revisions.get( + revisionKey(record.extensionId, record.desiredRevision), + ); + for (const dependency of definition?.dependencies ?? []) { + const target = byExtension.get(dependency.extensionId); + if (target) visit(target); + } + stack.pop(); + visiting.delete(record.bindingId); + visited.add(record.bindingId); + }; + for (const record of records) visit(record); + return cycles; + } + + async #deactivateDependents( + record: BindingRecord, + visited: Set, + ): Promise { + const failures: EffectCleanupFailure[] = []; + for (const dependent of this.#activeDependents(record)) { + failures.push(...(await this.#deactivateCascade(dependent, visited))); + } + return failures; + } + + async #deactivateCascade( + record: BindingRecord, + visited: Set, + ): Promise { + if (visited.has(record.bindingId)) return []; + visited.add(record.bindingId); + const failures = await this.#deactivateDependents(record, visited); + if (record.current) failures.push(...(await this.#stopCurrent(record))); + record.waitingFor = record.enabled + ? Object.freeze(this.#missingDependencies(record, this.#desiredDefinition(record))) + : Object.freeze([]); + if (record.retiredOwners.length > 0) { + record.status = 'failed'; + record.diagnostic = cleanupDiagnostic(record.desiredRevision, failures); + } else { + record.status = record.enabled ? 'waiting' : 'stopped'; + } + return failures; + } + + #activeDependents(record: BindingRecord): BindingRecord[] { + return this.#scopeBindings(record.scopeId).filter((candidate) => { + const definition = candidate.current?.definition; + return definition?.dependencies.some( + (dependency) => dependency.extensionId === record.extensionId, + ); + }); + } + + async #stopCurrent(record: BindingRecord): Promise { + const current = record.current; + if (!current) return []; + record.current = undefined; + return this.#disposeOwner(record, current.owner); + } + + async #disposeOwner(record: BindingRecord, owner: EffectOwner): Promise { + try { + await owner.dispose(); + return []; + } catch (cause) { + if (owner.size > 0 && !record.retiredOwners.includes(owner)) record.retiredOwners.push(owner); + return cleanupFailures(cause); + } + } + + async #retryRetiredOwners(record: BindingRecord): Promise { + const failures: EffectCleanupFailure[] = []; + for (let index = record.retiredOwners.length - 1; index >= 0; index -= 1) { + const owner = record.retiredOwners[index]!; + try { + await owner.dispose(); + record.retiredOwners.splice(index, 1); + } catch (cause) { + failures.push(...cleanupFailures(cause)); + } + } + return failures; + } + + #desiredDefinition(record: BindingRecord): InstalledRevision { + return this.#requireRevision(record.extensionId, record.desiredRevision); + } + + #requireRevision(extensionId: string, revision: string): InstalledRevision { + const key = revisionKey(extensionId, revision); + const definition = this.#revisions.get(key); + if (!definition) { + throw new ExtensionLifecycleOperationError( + 'revision_not_installed', + `Extension revision is not installed: ${key}`, + ); + } + return definition; + } + + #requireBinding(bindingId: string): BindingRecord { + validateId('bindingId', bindingId); + const record = this.#bindings.get(bindingId); + if (!record) { + throw new ExtensionLifecycleOperationError( + 'binding_not_found', + `Extension binding not found: ${bindingId}`, + ); + } + return record; + } + + #scopeBindings(scopeId: string): BindingRecord[] { + return [...this.#bindings.values()] + .filter((binding) => binding.scopeId === scopeId) + .sort((left, right) => compareString(left.bindingId, right.bindingId)); + } + + #inspectRecord(record: BindingRecord): ExtensionBindingInspection { + const pendingCleanupEffects = record.retiredOwners.reduce((sum, owner) => sum + owner.size, 0); + return Object.freeze({ + bindingId: record.bindingId, + scopeId: record.scopeId, + extensionId: record.extensionId, + desiredRevision: record.desiredRevision, + enabled: record.enabled, + status: record.status, + ...(record.current + ? { + current: Object.freeze({ + revision: record.current.definition.revision, + generation: record.current.generation, + }), + } + : {}), + ...(record.candidate + ? { + candidate: Object.freeze({ + revision: record.candidate.definition.revision, + phase: record.candidate.phase, + }), + } + : {}), + waitingFor: Object.freeze([...record.waitingFor]), + pendingCleanupEffects, + ...(record.diagnostic ? { diagnostic: Object.freeze({ ...record.diagnostic }) } : {}), + }); + } +} + +function normalizeDefinition(definition: ExtensionRevisionDefinition): InstalledRevision { + if (!definition || typeof definition !== 'object') invalidDefinition('Definition is required'); + validateId('extensionId', definition.extensionId); + validateRevision(definition.revision); + if (typeof definition.prepare !== 'function') invalidDefinition('Definition requires prepare'); + const dependencies = [...(definition.dependencies ?? [])].map((dependency) => { + validateId('dependency.extensionId', dependency.extensionId); + if (dependency.extensionId === definition.extensionId) { + invalidDefinition('An extension cannot depend on itself'); + } + return Object.freeze({ extensionId: dependency.extensionId }); + }); + dependencies.sort((left, right) => compareString(left.extensionId, right.extensionId)); + if ( + new Set(dependencies.map((dependency) => dependency.extensionId)).size !== dependencies.length + ) { + invalidDefinition('Extension dependencies must be unique'); + } + const contributions = [...(definition.contributions ?? [])].map((contribution) => { + validateId('contribution.id', contribution.id); + validateId('contribution.kind', contribution.kind); + return Object.freeze({ id: contribution.id, kind: contribution.kind }); + }); + contributions.sort((left, right) => compareString(left.id, right.id)); + if (new Set(contributions.map((contribution) => contribution.id)).size !== contributions.length) { + invalidDefinition('Extension contribution IDs must be unique'); + } + return Object.freeze({ + extensionId: definition.extensionId, + revision: definition.revision, + dependencies: Object.freeze(dependencies), + contributions: Object.freeze(contributions), + prepare: definition.prepare, + }); +} + +function validateBindingInput(input: ExtensionBindingInput): void { + if (!input || typeof input !== 'object') invalidDefinition('Binding input is required'); + validateId('bindingId', input.bindingId); + validateId('scopeId', input.scopeId); + validateId('extensionId', input.extensionId); + validateRevision(input.revision); +} + +function validateId(label: string, value: string): void { + if (typeof value !== 'string' || value.length > MAX_ID_LENGTH || !ID_PATTERN.test(value)) { + invalidDefinition(`Invalid ${label}: ${String(value)}`); + } +} + +function validateRevision(revision: string): void { + if ( + typeof revision !== 'string' || + revision.length === 0 || + revision.length > MAX_ID_LENGTH || + /[\r\n]/.test(revision) + ) { + invalidDefinition(`Invalid revision: ${String(revision)}`); + } +} + +function invalidDefinition(message: string): never { + throw new ExtensionLifecycleOperationError('invalid_definition', message); +} + +function revisionKey(extensionId: string, revision: string): string { + return `${extensionId}@${revision}`; +} + +function scopeExtensionKey(scopeId: string, extensionId: string): string { + return `${scopeId}\u0000${extensionId}`; +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function compareExtensionRevision( + left: { extensionId: string; revision: string }, + right: { extensionId: string; revision: string }, +): number { + return ( + compareString(left.extensionId, right.extensionId) || + compareString(left.revision, right.revision) + ); +} + +function asLifecycleError(cause: unknown): ExtensionLifecycleOperationError { + return cause instanceof ExtensionLifecycleOperationError + ? cause + : new ExtensionLifecycleOperationError('activation_failed', 'Extension activation failed', { + cause, + }); +} + +function diagnostic( + error: ExtensionLifecycleOperationError, + revision?: string, +): ExtensionLifecycleDiagnostic { + return Object.freeze({ + code: error.code, + message: error.message, + ...(revision ? { revision } : {}), + at: Date.now(), + }); +} + +function cleanupDiagnostic( + revision: string, + failures: readonly EffectCleanupFailure[], +): ExtensionLifecycleDiagnostic { + return diagnostic(cleanupOperationError(failures), revision); +} + +function cleanupFailures(cause: unknown): EffectCleanupFailure[] { + return cause instanceof EffectCleanupError ? [...cause.failures] : [{ label: 'unknown', cause }]; +} + +function cleanupOperationError( + failures: readonly EffectCleanupFailure[], + message = 'Extension effect cleanup failed', + cause?: unknown, +): ExtensionLifecycleOperationError { + return new ExtensionLifecycleOperationError('cleanup_failed', message, { + cause: cause ?? failures[0]?.cause, + }); +} From b53bfd22d506279c951723a20ea4d0c20b1d7323 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:16:47 +0800 Subject: [PATCH 02/48] test(runtime): exercise extension lifecycle system flows --- .../extension-lifecycle-kernel.md | 14 + .../extension-lifecycle-kernel.system.test.ts | 735 ++++++++++++++++++ 2 files changed, 749 insertions(+) create mode 100644 packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts diff --git a/docs/architecture/extension-lifecycle-kernel.md b/docs/architecture/extension-lifecycle-kernel.md index 62a15d760f..6be45126ed 100644 --- a/docs/architecture/extension-lifecycle-kernel.md +++ b/docs/architecture/extension-lifecycle-kernel.md @@ -52,3 +52,17 @@ If preparation, health checking, or activation fails, the candidate is disposed ## Adapter boundary Future contribution adapters register their reversible work through `ExtensionActivationContext.ownEffect`. A Tool adapter, for example, will own the Tool registry entry's disposer. The kernel does not bypass Maka's existing Runtime, permission, sandbox, or Run-composition authorities; those integrations belong to later phases. + +## System verification + +`extension-lifecycle-kernel.system.test.ts` treats the exported kernel as the test boundary. It does not replace lifecycle methods or assert mock call counts. The scenarios exercise: + +- a real TCP server and persistent client through health check, dependency injection, provider stop, automatic consumer restart, scope disposal, and port release; +- real `EventEmitter` listeners and timers to detect resource leaks across stop, restart, and binding removal; +- a diamond dependency graph moving from one provider revision to another, including transitive stop and reactivation order; +- candidate, dependent, binding-removal, and scope-disposal cleanup failures with retained ownership and retry; +- invalid definitions, candidates, effect registration, dependency reads, binding conflicts, and missing objects through public error codes; +- deterministic revision/composition ordering and stable composition digests across generation changes; +- 2,000 seeded public lifecycle operations across four scopes, three dependent extensions, and two revisions while continuously checking public-state/composition agreement and exact live-resource counts. + +The focused fault-matrix tests remain alongside these system scenarios. Coverage is collected from the compiled JavaScript with Node's test runner so the result measures the implementation that actually executes. diff --git a/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts b/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts new file mode 100644 index 0000000000..ed40710790 --- /dev/null +++ b/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts @@ -0,0 +1,735 @@ +import assert from 'node:assert/strict'; +import { EventEmitter, once } from 'node:events'; +import { createConnection, createServer, type Server, type Socket } from 'node:net'; +import { test } from 'node:test'; + +import { + ExtensionLifecycleKernel, + ExtensionLifecycleOperationError, + type ExtensionActivationContext, + type ExtensionLifecycleErrorCode, + type ExtensionRevisionDefinition, +} from '../extension-lifecycle-kernel.js'; + +test('system: a real TCP provider is health-checked, consumed, restarted, and fully released', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const providerPorts: number[] = []; + const consumerReplies: string[] = []; + + await kernel.install(tcpProviderRevision('tcp-provider', '1', providerPorts, events)); + await kernel.install({ + extensionId: 'tcp-consumer', + revision: '1', + dependencies: [{ extensionId: 'tcp-provider' }], + prepare: () => ({ + activate: async (context) => { + const endpoint = context.dependency('tcp-provider'); + const socket = createConnection({ host: '127.0.0.1', port: endpoint.port }); + context.ownEffect('tcp-client', async () => { + events.push(`consumer:${endpoint.instance}:close`); + await destroySocket(socket); + }); + await once(socket, 'connect'); + socket.write('keep'); + consumerReplies.push(await nextData(socket)); + events.push(`consumer:${endpoint.instance}:active`); + }, + }), + }); + + const waiting = await kernel.activate( + binding('consumer-binding', 'session-system', 'tcp-consumer', '1'), + ); + assert.equal(waiting.status, 'waiting'); + + await kernel.activate(binding('provider-binding', 'session-system', 'tcp-provider', '1')); + assert.equal(kernel.inspect('consumer-binding').status, 'active'); + assert.deepEqual(consumerReplies, ['pong:1:1']); + const firstPort = providerPorts[0]!; + assert.equal(await request(firstPort, 'health'), 'healthy:1:1'); + + events.length = 0; + await kernel.stop('provider-binding'); + assert.deepEqual(events, ['consumer:1:close', 'provider:1:close']); + await assert.rejects(request(firstPort, 'health')); + assert.equal(kernel.inspect('consumer-binding').status, 'waiting'); + + await kernel.start('provider-binding'); + assert.equal(kernel.inspect('provider-binding').status, 'active'); + assert.equal(kernel.inspect('consumer-binding').status, 'active'); + assert.deepEqual(consumerReplies, ['pong:1:1', 'pong:1:2']); + assert.equal(await request(providerPorts[1]!, 'health'), 'healthy:1:2'); + + const secondPort = providerPorts[1]!; + await kernel.disposeScope('session-system'); + await assert.rejects(request(secondPort, 'health')); + assert.deepEqual(kernel.inspectScope('session-system'), []); + assert.deepEqual(kernel.composition('session-system').entries, []); +}); + +test('system: real event listeners and timers do not survive stop or restart', async () => { + const kernel = new ExtensionLifecycleKernel(); + const bus = new EventEmitter(); + let messages = 0; + let ticks = 0; + + await kernel.install({ + extensionId: 'event-worker', + revision: '1', + prepare: () => ({ + activate: (context) => { + const listener = () => { + messages += 1; + }; + bus.on('message', listener); + context.ownEffect('event-listener', () => { + bus.off('message', listener); + }); + const timer = setInterval(() => { + ticks += 1; + }, 2); + context.ownEffect('timer', () => clearInterval(timer)); + }, + }), + }); + + await kernel.activate(binding('worker-binding', 'session-resources', 'event-worker', '1')); + bus.emit('message'); + await waitFor(() => ticks > 0); + assert.equal(messages, 1); + assert.equal(bus.listenerCount('message'), 1); + + await kernel.stop('worker-binding'); + const stoppedTicks = ticks; + bus.emit('message'); + await delay(15); + assert.equal(messages, 1); + assert.equal(ticks, stoppedTicks); + assert.equal(bus.listenerCount('message'), 0); + + await kernel.start('worker-binding'); + bus.emit('message'); + await waitFor(() => ticks > stoppedTicks); + assert.equal(messages, 2); + assert.equal(bus.listenerCount('message'), 1); + await kernel.removeBinding('worker-binding'); + assert.equal(bus.listenerCount('message'), 0); +}); + +test('system: a diamond dependency graph moves atomically to the new provider value', async () => { + const kernel = new ExtensionLifecycleKernel(); + const events: string[] = []; + const workerValues: string[] = []; + + await kernel.install(valueRevision('database', '1', 'db-v1', events)); + await kernel.install(valueRevision('database', '2', 'db-v2', events)); + await kernel.install(derivedRevision('api', ['database'], events)); + await kernel.install(derivedRevision('cache', ['database'], events)); + await kernel.install({ + extensionId: 'worker', + revision: '1', + dependencies: [{ extensionId: 'api' }, { extensionId: 'cache' }], + prepare: () => ({ + activate: (context) => { + const value = `${context.dependency('api')}+${context.dependency('cache')}`; + workerValues.push(value); + events.push(`worker:activate:${value}`); + context.ownEffect('worker', () => { + events.push('worker:dispose'); + }); + }, + }), + }); + + await kernel.activate(binding('d-worker', 'session-graph', 'worker', '1')); + await kernel.activate(binding('b-api', 'session-graph', 'api', '1')); + await kernel.activate(binding('c-cache', 'session-graph', 'cache', '1')); + await kernel.activate(binding('a-database', 'session-graph', 'database', '1')); + assert.deepEqual(workerValues, ['api(db-v1@1)+cache(db-v1@1)']); + + events.length = 0; + const before = kernel.composition('session-graph'); + await kernel.update('a-database', '2'); + const after = kernel.composition('session-graph'); + + assert.deepEqual(events, [ + 'worker:dispose', + 'api:dispose', + 'cache:dispose', + 'database:2:activate', + 'database:1:dispose', + 'api:activate:db-v2@2', + 'cache:activate:db-v2@2', + 'worker:activate:api(db-v2@2)+cache(db-v2@2)', + ]); + assert.deepEqual(workerValues, ['api(db-v1@1)+cache(db-v1@1)', 'api(db-v2@2)+cache(db-v2@2)']); + assert.equal(before.entries.find((entry) => entry.extensionId === 'database')?.revision, '1'); + assert.equal(after.entries.find((entry) => entry.extensionId === 'database')?.revision, '2'); + assert.notEqual(before.digest, after.digest); + assert.ok(kernel.inspectScope('session-graph').every((item) => item.status === 'active')); +}); + +test('system: a dependent cleanup failure blocks provider cutover and is recoverable', async () => { + const kernel = new ExtensionLifecycleKernel(); + let cleanupAttempts = 0; + const values: string[] = []; + await kernel.install(valueRevision('provider', '1', 'old', [])); + await kernel.install(valueRevision('provider', '2', 'new', [])); + await kernel.install({ + extensionId: 'consumer', + revision: '1', + dependencies: [{ extensionId: 'provider' }], + prepare: () => ({ + activate: (context) => { + values.push(context.dependency('provider')); + context.ownEffect('flaky-consumer', () => { + cleanupAttempts += 1; + if (cleanupAttempts === 1) throw new Error('busy'); + }); + }, + }), + }); + await kernel.activate(binding('a-provider', 'session-retry', 'provider', '1')); + await kernel.activate(binding('b-consumer', 'session-retry', 'consumer', '1')); + + await assertCode(kernel.update('a-provider', '2'), 'cleanup_failed'); + assert.equal(kernel.inspect('a-provider').current?.revision, '1'); + assert.equal(kernel.inspect('b-consumer').status, 'failed'); + assert.equal(kernel.inspect('b-consumer').pendingCleanupEffects, 1); + + await kernel.retry('b-consumer'); + assert.equal(kernel.inspect('b-consumer').status, 'active'); + assert.equal( + kernel.inspect('a-provider').desiredRevision, + '2', + 'the failed update remains desired and the scope converges after cleanup retry', + ); + assert.equal(kernel.inspect('a-provider').current?.revision, '2'); + assert.equal(kernel.inspect('b-consumer').status, 'active'); + assert.deepEqual(values, ['old', 'new']); +}); + +test('system: failed candidate cleanup is retained, retried, and leaves no duplicate resource', async () => { + const kernel = new ExtensionLifecycleKernel(); + let activationAttempts = 0; + let cleanupAttempts = 0; + let liveResources = 0; + await kernel.install({ + extensionId: 'candidate-recovery', + revision: '1', + prepare: (context) => { + liveResources += 1; + context.ownEffect('candidate-resource', () => { + cleanupAttempts += 1; + if (cleanupAttempts === 1) throw new Error('temporarily locked'); + liveResources -= 1; + }); + return { + activate: () => { + activationAttempts += 1; + if (activationAttempts === 1) throw new Error('first activation fails'); + }, + }; + }, + }); + + await assertCode( + kernel.activate(binding('recovery-binding', 'session-recovery', 'candidate-recovery', '1')), + 'cleanup_failed', + ); + assert.equal(liveResources, 1); + assert.equal(kernel.inspect('recovery-binding').pendingCleanupEffects, 1); + + await kernel.retry('recovery-binding'); + assert.equal(liveResources, 1, 'the retired resource is released before the retry allocates one'); + assert.equal(kernel.inspect('recovery-binding').status, 'active'); + await kernel.stop('recovery-binding'); + assert.equal(liveResources, 0); +}); + +test('system: disposeScope is retryable after a real cleanup failure', async () => { + const kernel = new ExtensionLifecycleKernel(); + const bus = new EventEmitter(); + let attempts = 0; + const listener = () => undefined; + await kernel.install({ + extensionId: 'scope-resource', + revision: '1', + prepare: () => ({ + activate: (context) => { + bus.on('data', listener); + context.ownEffect('listener', () => { + attempts += 1; + if (attempts === 1) throw new Error('temporary release failure'); + bus.off('data', listener); + }); + }, + }), + }); + await kernel.activate(binding('scope-binding', 'session-dispose', 'scope-resource', '1')); + + await assertCode(kernel.disposeScope('session-dispose'), 'cleanup_failed'); + assert.equal(bus.listenerCount('data'), 1); + assert.equal(kernel.inspect('scope-binding').status, 'failed'); + await kernel.disposeScope('session-dispose'); + assert.equal(bus.listenerCount('data'), 0); + assert.deepEqual(kernel.inspectScope('session-dispose'), []); +}); + +test('system: public error paths preserve state and the serialized queue remains usable', async () => { + const kernel = new ExtensionLifecycleKernel(); + const valid = valueRevision('valid', '1', 'value', []); + + for (const definition of [ + null, + { extensionId: 'Bad', revision: '1', prepare: () => ({ activate: () => undefined }) }, + { extensionId: 'missing-prepare', revision: '1' }, + { ...valid, revision: '' }, + { ...valid, dependencies: [{ extensionId: 'valid' }] }, + { ...valid, dependencies: [{ extensionId: 'dep' }, { extensionId: 'dep' }] }, + { + ...valid, + contributions: [ + { id: 'same', kind: 'fake' }, + { id: 'same', kind: 'fake' }, + ], + }, + ]) { + await assertCode( + kernel.install(definition as ExtensionRevisionDefinition), + 'invalid_definition', + ); + } + + await kernel.install(valid); + await assertCode(kernel.install(valid), 'revision_already_installed'); + await assertCode(kernel.uninstall('valid', 'missing'), 'revision_not_installed'); + await assertCode( + kernel.activate(binding('missing-revision', 'session-errors', 'valid', '2')), + 'revision_not_installed', + ); + await assertCode(kernel.update('missing-binding', '1'), 'binding_not_found'); + assert.throws(() => kernel.inspect('missing-binding'), hasCode('binding_not_found')); + + await kernel.activate(binding('valid-binding', 'session-errors', 'valid', '1')); + await assertCode( + kernel.activate(binding('valid-binding', 'other-scope', 'valid', '1')), + 'binding_conflict', + ); + await assertCode( + kernel.activate(binding('other-binding', 'session-errors', 'valid', '1')), + 'binding_conflict', + ); + assert.equal(kernel.inspect('valid-binding').status, 'active'); + + await kernel.stop('valid-binding'); + await kernel.start('valid-binding'); + assert.equal(kernel.inspect('valid-binding').status, 'active'); +}); + +test('system: effect registration is validated and sealed after activation', async () => { + const kernel = new ExtensionLifecycleKernel(); + let retainedContext: ExtensionActivationContext | undefined; + await kernel.install({ + extensionId: 'bad-effect', + revision: '1', + prepare: () => ({ + activate: (context) => context.ownEffect('', () => undefined), + }), + }); + await assertCode( + kernel.activate(binding('bad-effect-binding', 'session-effects', 'bad-effect', '1')), + 'invalid_definition', + ); + + await kernel.install({ + extensionId: 'sealed-effect', + revision: '1', + prepare: () => ({ + activate: (context) => { + retainedContext = context; + }, + }), + }); + await kernel.activate(binding('sealed-binding', 'session-effects', 'sealed-effect', '1')); + assert.throws( + () => retainedContext!.ownEffect('too-late', () => undefined), + hasCode('activation_failed'), + ); +}); + +test('system: invalid prepared candidates and unavailable dependency reads roll back cleanly', async () => { + const kernel = new ExtensionLifecycleKernel(); + await kernel.install({ + extensionId: 'invalid-candidate', + revision: '1', + prepare: () => null as never, + }); + await assertCode( + kernel.activate(binding('invalid-binding', 'session-invalid', 'invalid-candidate', '1')), + 'invalid_definition', + ); + assert.equal(kernel.composition('session-invalid').entries.length, 0); + + await kernel.install({ + extensionId: 'missing-value', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.dependency('not-active'); + }, + }), + }); + await assertCode( + kernel.activate(binding('missing-value-binding', 'session-invalid', 'missing-value', '1')), + 'activation_failed', + ); + + await kernel.install({ + extensionId: 'missing-revision-read', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.dependencyRevision('not-active'); + }, + }), + }); + await assertCode( + kernel.activate( + binding('missing-revision-binding', 'session-invalid', 'missing-revision-read', '1'), + ), + 'activation_failed', + ); +}); + +test('system: remove and repeated cleanup failures retain ownership until release succeeds', async () => { + const kernel = new ExtensionLifecycleKernel(); + let removeAttempts = 0; + await kernel.install({ + extensionId: 'remove-retry', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.ownEffect('remove-resource', () => { + removeAttempts += 1; + if (removeAttempts === 1) throw new Error('first release fails'); + }); + }, + }), + }); + await kernel.activate(binding('remove-binding', 'session-remove', 'remove-retry', '1')); + await assertCode(kernel.removeBinding('remove-binding'), 'cleanup_failed'); + assert.equal(kernel.inspect('remove-binding').pendingCleanupEffects, 1); + await kernel.removeBinding('remove-binding'); + assert.throws(() => kernel.inspect('remove-binding'), hasCode('binding_not_found')); + + let persistentAttempts = 0; + await kernel.install({ + extensionId: 'persistent-cleanup', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.ownEffect('persistent-resource', () => { + persistentAttempts += 1; + if (persistentAttempts < 3) throw new Error('still busy'); + }); + }, + }), + }); + await kernel.activate(binding('persistent-binding', 'session-remove', 'persistent-cleanup', '1')); + await assertCode(kernel.stop('persistent-binding'), 'cleanup_failed'); + await assertCode(kernel.retry('persistent-binding'), 'cleanup_failed'); + assert.equal(kernel.inspect('persistent-binding').pendingCleanupEffects, 1); + await kernel.retry('persistent-binding'); + assert.equal(kernel.inspect('persistent-binding').status, 'active'); +}); + +test('system: installed revisions and composition remain deterministically ordered', async () => { + const kernel = new ExtensionLifecycleKernel(); + for (const [extensionId, revision] of [ + ['zeta', '2'], + ['alpha', '1'], + ['zeta', '1'], + ] as const) { + await kernel.install(valueRevision(extensionId, revision, revision, [])); + } + assert.deepEqual(kernel.installedRevisions(), [ + { extensionId: 'alpha', revision: '1' }, + { extensionId: 'zeta', revision: '1' }, + { extensionId: 'zeta', revision: '2' }, + ]); + await kernel.activate(binding('zeta-binding', 'session-order', 'zeta', '2')); + const before = kernel.composition('session-order'); + await kernel.stop('zeta-binding'); + await kernel.start('zeta-binding'); + const after = kernel.composition('session-order'); + assert.equal(before.digest, after.digest, 'generation changes do not alter composition identity'); + assert.notEqual(before.entries[0]?.generation, after.entries[0]?.generation); +}); + +test('system: 2,000 deterministic lifecycle operations preserve composition and resource invariants', async () => { + const kernel = new ExtensionLifecycleKernel(); + const scopes = ['soak-a', 'soak-b', 'soak-c', 'soak-d']; + const extensions = ['alpha', 'beta', 'gamma'] as const; + const dependencies: Record<(typeof extensions)[number], readonly string[]> = { + alpha: [], + beta: ['alpha'], + gamma: ['beta'], + }; + const liveByBinding = new Map(); + + for (const extensionId of extensions) { + for (const revision of ['1', '2']) { + await kernel.install({ + extensionId, + revision, + dependencies: dependencies[extensionId].map((dependency) => ({ extensionId: dependency })), + contributions: [{ id: `${extensionId}.service`, kind: 'service' }], + prepare: () => ({ + activate: (context) => { + const current = liveByBinding.get(context.bindingId) ?? 0; + liveByBinding.set(context.bindingId, current + 1); + context.ownEffect('resource', async () => { + await Promise.resolve(); + liveByBinding.set(context.bindingId, liveByBinding.get(context.bindingId)! - 1); + }); + return { value: `${context.scopeId}:${extensionId}:${revision}` }; + }, + }), + }); + } + } + + const random = seededRandom(0x2973); + for (let step = 0; step < 2_000; step += 1) { + const scope = scopes[Math.floor(random() * scopes.length)]!; + const extensionId = extensions[Math.floor(random() * extensions.length)]!; + const revision = random() < 0.5 ? '1' : '2'; + const bindingId = `${scope}-${extensionId}`; + const existing = kernel.inspectScope(scope).find((item) => item.bindingId === bindingId); + switch (Math.floor(random() * 6)) { + case 0: + await kernel.activate(binding(bindingId, scope, extensionId, revision)); + break; + case 1: + if (existing) await kernel.update(bindingId, revision); + break; + case 2: + if (existing) await kernel.stop(bindingId); + break; + case 3: + if (existing) await kernel.start(bindingId); + break; + case 4: + if (existing) await kernel.removeBinding(bindingId); + break; + default: + await kernel.disposeScope(scope); + } + if (step % 25 === 0) assertKernelInvariants(kernel, scopes, liveByBinding); + } + + assertKernelInvariants(kernel, scopes, liveByBinding); + for (const scope of scopes) await kernel.disposeScope(scope); + assert.equal( + [...liveByBinding.values()].reduce((sum, count) => sum + count, 0), + 0, + ); +}); + +interface TcpEndpoint { + readonly port: number; + readonly instance: number; +} + +function tcpProviderRevision( + extensionId: string, + revision: string, + ports: number[], + events: string[], +): ExtensionRevisionDefinition { + let instance = 0; + return { + extensionId, + revision, + prepare: async (context) => { + instance += 1; + const currentInstance = instance; + const server = createServer((socket) => { + socket.once('data', (data) => { + const requestBody = data.toString(); + if (requestBody === 'health') socket.end(`healthy:${revision}:${currentInstance}`); + else socket.write(`pong:${revision}:${currentInstance}`); + }); + }); + await listen(server); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + ports.push(address.port); + context.ownEffect('tcp-server', async () => { + await closeServer(server); + events.push(`provider:${currentInstance}:close`); + }); + return { + healthCheck: async () => { + assert.equal( + await request(address.port, 'health'), + `healthy:${revision}:${currentInstance}`, + ); + }, + activate: () => ({ value: { port: address.port, instance: currentInstance } }), + }; + }, + }; +} + +function valueRevision( + extensionId: string, + revision: string, + value: string, + events: string[], +): ExtensionRevisionDefinition { + return { + extensionId, + revision, + prepare: () => ({ + activate: (context) => { + events.push(`${extensionId}:${revision}:activate`); + context.ownEffect(extensionId, () => { + events.push(`${extensionId}:${revision}:dispose`); + }); + return { value }; + }, + }), + }; +} + +function derivedRevision( + extensionId: string, + dependencyIds: string[], + events: string[], +): ExtensionRevisionDefinition { + return { + extensionId, + revision: '1', + dependencies: dependencyIds.map((dependency) => ({ extensionId: dependency })), + prepare: () => ({ + activate: (context) => { + const derived = dependencyIds + .map( + (dependency) => + `${context.dependency(dependency)}@${context.dependencyRevision(dependency)}`, + ) + .join('+'); + events.push(`${extensionId}:activate:${derived}`); + context.ownEffect(extensionId, () => { + events.push(`${extensionId}:dispose`); + }); + return { value: `${extensionId}(${derived})` }; + }, + }), + }; +} + +function assertKernelInvariants( + kernel: ExtensionLifecycleKernel, + scopes: string[], + liveByBinding: ReadonlyMap, +): void { + let composedResources = 0; + for (const scope of scopes) { + const inspections = kernel.inspectScope(scope); + const composition = kernel.composition(scope); + assert.ok(Object.isFrozen(inspections)); + assert.ok(Object.isFrozen(composition)); + assert.ok(Object.isFrozen(composition.entries)); + assert.equal(kernel.composition(scope).digest, composition.digest); + assert.deepEqual( + inspections.map((item) => item.bindingId), + [...inspections.map((item) => item.bindingId)].sort(), + ); + assert.equal(new Set(inspections.map((item) => item.extensionId)).size, inspections.length); + assert.equal( + new Set(composition.entries.map((item) => item.extensionId)).size, + composition.entries.length, + ); + for (const entry of composition.entries) { + const inspection = inspections.find((item) => item.bindingId === entry.bindingId); + assert.equal(inspection?.current?.revision, entry.revision); + assert.equal(liveByBinding.get(entry.bindingId), 1); + composedResources += 1; + } + for (const inspection of inspections) { + if (!inspection.current) assert.equal(liveByBinding.get(inspection.bindingId) ?? 0, 0); + assert.ok(inspection.pendingCleanupEffects >= 0); + } + } + assert.equal( + [...liveByBinding.values()].reduce((sum, count) => sum + count, 0), + composedResources, + ); +} + +function binding(bindingId: string, scopeId: string, extensionId: string, revision: string) { + return { bindingId, scopeId, extensionId, revision }; +} + +async function assertCode(promise: Promise, code: ExtensionLifecycleErrorCode) { + await assert.rejects(promise, hasCode(code)); +} + +function hasCode(code: ExtensionLifecycleErrorCode) { + return (error: unknown) => + error instanceof ExtensionLifecycleOperationError && error.code === code; +} + +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +async function listen(server: Server): Promise { + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) return; + server.close(); + await once(server, 'close'); +} + +async function destroySocket(socket: Socket): Promise { + if (socket.destroyed) return; + socket.destroy(); + await once(socket, 'close'); +} + +async function request(port: number, body: string): Promise { + const socket = createConnection({ host: '127.0.0.1', port }); + socket.setTimeout(1_000, () => socket.destroy(new Error('request timeout'))); + await once(socket, 'connect'); + socket.end(body); + return nextData(socket); +} + +async function nextData(socket: Socket): Promise { + const [data] = (await once(socket, 'data')) as [Buffer]; + return data.toString(); +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('condition timed out'); + await delay(2); + } +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} From 05fafe218af8ed65d3beafc8922363745829140d Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:20:59 +0800 Subject: [PATCH 03/48] feat(runtime): add extension tool contributions --- .../extension-tool-contributions.md | 47 ++ packages/runtime/package.json | 1 + .../extension-tool-contributions.test.ts | 538 ++++++++++++++++++ packages/runtime/src/ai-sdk-backend.ts | 69 ++- .../src/extension-tool-contributions.ts | 310 ++++++++++ 5 files changed, 946 insertions(+), 19 deletions(-) create mode 100644 docs/architecture/extension-tool-contributions.md create mode 100644 packages/runtime/src/__tests__/extension-tool-contributions.test.ts create mode 100644 packages/runtime/src/extension-tool-contributions.ts diff --git a/docs/architecture/extension-tool-contributions.md b/docs/architecture/extension-tool-contributions.md new file mode 100644 index 0000000000..44e1bbb488 --- /dev/null +++ b/docs/architecture/extension-tool-contributions.md @@ -0,0 +1,47 @@ +# Extension Tool Contributions (Phase 2) + +This vertical slice connects the Extension lifecycle authority to Maka's real Tool execution +path. It supports trusted static Tool revisions; it does not load arbitrary packages or scripts. + +## Completion contract + +Phase 2 is complete when all of these statements are true: + +- installing a revision remains effect-free; +- activating a binding publishes its declared Tools into that binding's scope; +- the next Backend `send()` sees Core and active Extension Tools through one catalog; +- an Extension Tool call settles through the existing `ToolRuntime` rather than a parallel + executor; +- Tool availability, argument validation, permissions, sandbox boundaries, durable settlement, + and product routing remain authoritative; +- updating a binding switches the complete Tool surface transactionally, and a failed candidate + restores the prior surface; +- stopping, removing, or disposing the binding retracts every Tool registration; +- Extension Tools cannot shadow Core Tools, Runtime protocol names, other extensions, or claim a + provider-native protocol; +- an empty Extension registry produces the same Core Tool surface as before. + +## Registration model + +`ExtensionToolContributionRegistry` is a typed resource registry. A trusted Tool revision publishes +through `contributeExtensionTool`, which immediately hands the matching unregister function to +`ExtensionActivationContext.ownEffect`. + +Candidate replacement is transactional. A candidate from the same binding may temporarily replace +its current Tool name while activating. Candidate cleanup restores the old entry; after commit, old +activation cleanup retires the replaced entry so a later stop cannot resurrect it. + +Core Tool names should be supplied through `protectedToolNames` so conflicts fail during activation. +`compose` checks again when producing the execution snapshot, which catches changes in the Core +catalog between activation and admission. + +## Runtime boundary + +`AiSdkBackendInput.resolveTools` is an optional trusted reader for the full Core + Extension Tool +catalog. The Backend snapshots it once at the beginning of each `send()` and then routes that +snapshot through the same apply-patch projection, `ToolAvailabilityRuntime`, provider schema, +repair path, and `ToolRuntime` dispatch used by Core Tools. + +This phase intentionally chooses the existing `send()`/Turn boundary. Refreshing at every physical +model request, pinning a larger Run composition, draining in-flight calls, persistence, an install +control plane, and isolated agent-authored code remain later decisions. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 293be31364..7cf906f22c 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -32,6 +32,7 @@ "./runtime-resume": "./dist/runtime-resume.js", "./runtime-kernel": "./dist/runtime-kernel.js", "./extension-lifecycle-kernel": "./dist/extension-lifecycle-kernel.js", + "./extension-tool-contributions": "./dist/extension-tool-contributions.js", "./invocation-context": "./dist/invocation-context.js", "./ai-sdk-flow": "./dist/ai-sdk-flow.js", "./stream-graph-readiness": "./dist/stream-graph-readiness.js", diff --git a/packages/runtime/src/__tests__/extension-tool-contributions.test.ts b/packages/runtime/src/__tests__/extension-tool-contributions.test.ts new file mode 100644 index 0000000000..fb87b5a5ac --- /dev/null +++ b/packages/runtime/src/__tests__/extension-tool-contributions.test.ts @@ -0,0 +1,538 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { LanguageModelV4StreamPart, LanguageModelV4Usage } from '@ai-sdk/provider'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { SessionHeader } from '@maka/core/session'; +import { MockLanguageModelV4, convertArrayToReadableStream } from 'ai/test'; +import { z } from 'zod'; + +import { ExtensionLifecycleKernel } from '../extension-lifecycle-kernel.js'; +import { + ExtensionToolContributionError, + ExtensionToolContributionRegistry, + defineTrustedToolExtensionRevision, +} from '../extension-tool-contributions.js'; +import type { MakaTool } from '../tool-runtime.js'; +import { LOAD_TOOLS_NAME } from '../tool-availability.js'; +import { createDurableTurnHarness, drainWithDurableTurn } from './durable-turn-harness.js'; +import { createTestAiSdkBackend } from './execution-boundary-test-helpers.js'; + +const ZERO_USAGE: LanguageModelV4Usage = { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, +}; + +describe('Extension Tool contributions', () => { + test('lifecycle activation, update, stop, restart, and removal own registry entries', async () => { + const kernel = new ExtensionLifecycleKernel(); + const registry = new ExtensionToolContributionRegistry(); + const v1 = tool('Weather', () => ({ revision: 1 })); + const v2 = tool('Weather', () => ({ revision: 2 })); + + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'weather', + revision: '1', + tools: [v1], + }), + ); + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'weather', + revision: '2', + tools: [v2], + }), + ); + + assert.deepEqual(registry.inspect('session-a'), []); + await kernel.activate({ + bindingId: 'weather-binding', + scopeId: 'session-a', + extensionId: 'weather', + revision: '1', + }); + assert.deepEqual(registry.inspect('session-a'), [ + { + scopeId: 'session-a', + bindingId: 'weather-binding', + extensionId: 'weather', + revision: '1', + toolName: 'Weather', + }, + ]); + assert.equal(registry.compose('session-a', [tool('Read')])[1]?.impl, v1.impl); + + await kernel.update('weather-binding', '2'); + assert.equal(registry.inspect('session-a')[0]?.revision, '2'); + assert.equal(registry.compose('session-a', [tool('Read')])[1]?.impl, v2.impl); + + await kernel.stop('weather-binding'); + assert.deepEqual(registry.inspect('session-a'), []); + assert.deepEqual( + registry.compose('session-a', [tool('Read')]).map(({ name }) => name), + ['Read'], + ); + + await kernel.start('weather-binding'); + assert.equal(registry.inspect('session-a')[0]?.revision, '2'); + await kernel.removeBinding('weather-binding'); + assert.deepEqual(registry.inspect('session-a'), []); + await kernel.uninstall('weather', '1'); + await kernel.uninstall('weather', '2'); + assert.deepEqual(kernel.installedRevisions(), []); + }); + + test('rejects extension-extension, Core, reserved, and provider-native conflicts', async () => { + const kernel = new ExtensionLifecycleKernel(); + const registry = new ExtensionToolContributionRegistry({ + protectedToolNames: () => ['Read'], + }); + for (const extensionId of ['first', 'second']) { + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId, + revision: '1', + tools: [tool(extensionId === 'first' ? 'Weather' : 'weather')], + }), + ); + } + await kernel.activate({ + bindingId: 'first-binding', + scopeId: 'session-a', + extensionId: 'first', + revision: '1', + }); + await assert.rejects( + kernel.activate({ + bindingId: 'second-binding', + scopeId: 'session-a', + extensionId: 'second', + revision: '1', + }), + /activation failed/, + ); + assert.deepEqual( + registry.inspect('session-a').map(({ extensionId }) => extensionId), + ['first'], + ); + assert.throws( + () => registry.compose('session-a', [tool('WEATHER')]), + (error: unknown) => + error instanceof ExtensionToolContributionError && error.code === 'tool_name_conflict', + ); + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'core-conflict', + revision: '1', + tools: [tool('read')], + }), + ); + await assert.rejects( + kernel.activate({ + bindingId: 'core-conflict-binding', + scopeId: 'session-a', + extensionId: 'core-conflict', + revision: '1', + }), + /activation failed/, + ); + assert.throws( + () => + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'reserved', + revision: '1', + tools: [tool('exec')], + }), + (error: unknown) => + error instanceof ExtensionToolContributionError && error.code === 'reserved_tool_name', + ); + assert.throws( + () => + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'native', + revision: '1', + tools: [ + { + ...tool('Native'), + providerTool: { kind: 'openai-web-search' }, + }, + ], + }), + /cannot claim a provider-native Runtime protocol/, + ); + }); + + test('failed multi-Tool candidate restores the complete current registry surface', async () => { + const kernel = new ExtensionLifecycleKernel(); + const registry = new ExtensionToolContributionRegistry(); + const weatherV1 = tool('Weather', () => ({ revision: 1 })); + const weatherV2 = tool('Weather', () => ({ revision: 2 })); + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'weather', + revision: '1', + tools: [weatherV1], + }), + ); + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'weather', + revision: '2', + tools: [weatherV2, tool('Calendar')], + }), + ); + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'calendar', + revision: '1', + tools: [tool('Calendar')], + }), + ); + await kernel.activate({ + bindingId: 'weather-binding', + scopeId: 'session-a', + extensionId: 'weather', + revision: '1', + }); + await kernel.activate({ + bindingId: 'calendar-binding', + scopeId: 'session-a', + extensionId: 'calendar', + revision: '1', + }); + + await assert.rejects(kernel.update('weather-binding', '2'), /activation failed/); + assert.equal(kernel.inspect('weather-binding').current?.revision, '1'); + assert.deepEqual( + registry.inspect('session-a').map(({ extensionId, revision, toolName }) => ({ + extensionId, + revision, + toolName, + })), + [ + { extensionId: 'calendar', revision: '1', toolName: 'Calendar' }, + { extensionId: 'weather', revision: '1', toolName: 'Weather' }, + ], + ); + assert.equal( + registry.compose('session-a', []).find(({ name }) => name === 'Weather')?.impl, + weatherV1.impl, + ); + }); + + test('one live Backend observes activation, executes through ToolRuntime, upgrades, and retracts', async () => { + const kernel = new ExtensionLifecycleKernel(); + const coreTools = [tool('Read')]; + const registry = new ExtensionToolContributionRegistry({ + protectedToolNames: () => coreTools.map(({ name }) => name), + }); + const executions: Array<{ revision: number; turnId: string; city: string }> = []; + const extensionTool = (revision: number): MakaTool<{ city: string }> => + tool( + 'Weather', + ({ city }, context) => { + executions.push({ revision, turnId: context.turnId, city }); + return { revision, city }; + }, + z.object({ city: z.string() }), + ); + + for (const revision of [1, 2]) { + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'weather', + revision: String(revision), + tools: [extensionTool(revision)], + }), + ); + } + + const model = dynamicToolModel(); + let nextId = 0; + const appended: unknown[] = []; + const traces: unknown[] = []; + const durableTurns = new Map>(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-a', + header: header(), + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model.model, + tools: coreTools, + resolveTools: () => registry.compose('session-a', coreTools), + loadTurnRuntimeEvents: async (turnId) => + durableTurns.get(turnId)?.loadTurnRuntimeEvents(turnId) ?? [], + recordRunTrace: (event) => traces.push(event), + newId: () => `id-${++nextId}`, + now: () => nextId, + }); + + model.setMode('observe'); + await sendDurable(backend, durableTurns, 'turn-core', 'core only'); + assert.deepEqual(model.requests.at(-1), ['Read']); + + await kernel.activate({ + bindingId: 'weather-binding', + scopeId: 'session-a', + extensionId: 'weather', + revision: '1', + }); + model.setMode('call'); + const v1Events = await sendDurable(backend, durableTurns, 'turn-v1', 'weather'); + assert.ok(model.requests.at(-1)?.includes('Weather')); + assert.deepEqual( + executions, + [{ revision: 1, turnId: 'turn-v1', city: 'Shanghai' }], + JSON.stringify({ v1Events, appended, traces }), + ); + + await kernel.update('weather-binding', '2'); + model.setMode('call'); + await sendDurable(backend, durableTurns, 'turn-v2', 'weather again'); + assert.deepEqual(executions.at(-1), { + revision: 2, + turnId: 'turn-v2', + city: 'Shanghai', + }); + + await kernel.stop('weather-binding'); + model.setMode('observe'); + await sendDurable(backend, durableTurns, 'turn-stopped', 'after stop'); + assert.deepEqual(model.requests.at(-1), ['Read']); + }); + + test('Extension Tools remain subject to Tool availability gating', async () => { + const kernel = new ExtensionLifecycleKernel(); + const coreTools = [tool('Read')]; + const registry = new ExtensionToolContributionRegistry({ + protectedToolNames: () => coreTools.map(({ name }) => name), + }); + const executions: string[] = []; + await kernel.install( + defineTrustedToolExtensionRevision({ + registry, + extensionId: 'weather', + revision: '1', + tools: [ + tool( + 'Weather', + ({ city }: { city: string }) => { + executions.push(city); + return { city }; + }, + z.object({ city: z.string() }), + ), + ], + }), + ); + await kernel.activate({ + bindingId: 'weather-binding', + scopeId: 'session-a', + extensionId: 'weather', + revision: '1', + }); + const model = deferredExtensionToolModel(); + const durableTurns = new Map>(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-a', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model.model, + tools: coreTools, + resolveTools: () => registry.compose('session-a', coreTools), + toolAvailability: { + economy: true, + groups: [{ id: 'extension', toolNames: ['Weather'] }], + }, + loadTurnRuntimeEvents: async (turnId) => + durableTurns.get(turnId)?.loadTurnRuntimeEvents(turnId) ?? [], + }); + + await sendDurable(backend, durableTurns, 'turn-gated', 'load and call weather'); + assert.ok(!model.requests[0]?.includes('Weather')); + assert.ok(model.requests[0]?.includes(LOAD_TOOLS_NAME)); + assert.ok(model.requests[1]?.includes('Weather')); + assert.deepEqual(executions, ['Shanghai']); + }); +}); + +function dynamicToolModel(): { + readonly model: MockLanguageModelV4; + readonly requests: string[][]; + setMode(mode: 'observe' | 'call'): void; +} { + const requests: string[][] = []; + let mode: 'observe' | 'call' = 'observe'; + let pendingCall = false; + const model = new MockLanguageModelV4({ + doStream: async ({ tools }) => { + requests.push((tools ?? []).map(({ name }) => name).filter((name) => name !== 'invalid')); + const call = mode === 'call' && pendingCall; + if (call) pendingCall = false; + const parts: LanguageModelV4StreamPart[] = call + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: `weather-${requests.length}`, + toolName: 'Weather', + input: JSON.stringify({ city: 'Shanghai' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: ZERO_USAGE, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage: ZERO_USAGE }, + ]; + return { stream: convertArrayToReadableStream(parts) }; + }, + }); + return { + model, + requests, + setMode(next) { + mode = next; + pendingCall = next === 'call'; + }, + }; +} + +function deferredExtensionToolModel(): { + readonly model: MockLanguageModelV4; + readonly requests: string[][]; +} { + const requests: string[][] = []; + const model = new MockLanguageModelV4({ + doStream: async ({ tools }) => { + requests.push((tools ?? []).map(({ name }) => name)); + const step = requests.length; + const parts: LanguageModelV4StreamPart[] = + step === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'load-extension', + toolName: LOAD_TOOLS_NAME, + input: JSON.stringify({ group: 'extension' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: ZERO_USAGE, + }, + ] + : step === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'call-weather', + toolName: 'Weather', + input: JSON.stringify({ city: 'Shanghai' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: ZERO_USAGE, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: ZERO_USAGE, + }, + ]; + return { stream: convertArrayToReadableStream(parts) }; + }, + }); + return { model, requests }; +} + +function tool

>( + name: string, + impl: MakaTool

['impl'] = (() => ({ ok: true })) as MakaTool

['impl'], + parameters: unknown = z.object({}), +): MakaTool

{ + return { + name, + description: `${name} test tool`, + parameters, + impl, + }; +} + +async function sendDurable( + backend: ReturnType, + durableTurns: Map>, + turnId: string, + text: string, +): Promise { + const durable = createDurableTurnHarness({ + sessionId: 'session-a', + turnId, + text, + runId: `run-${turnId}`, + }); + durableTurns.set(turnId, durable); + return drainWithDurableTurn(backend.send(durable.sendInput({ runId: `run-${turnId}` })), durable); +} + +function header(): SessionHeader { + return { + id: 'session-a', + workspaceRoot: '/tmp/maka', + cwd: '/tmp/maka', + createdAt: 1, + lastUsedAt: 1, + name: 'Extension Tool test', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'test', + connectionLocked: true, + model: 'mock-model-id', + permissionMode: 'bypass', + schemaVersion: 1, + }; +} + +function connection(): LlmConnection { + return { + slug: 'test', + name: 'Test', + providerType: 'openai', + defaultModel: 'mock-model-id', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; +} diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index b9293f0834..55ab302553 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -697,6 +697,13 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { // ── Process-singleton deps ───────────────────────────────────────────── /** Canonical-named tools available this session. */ tools: MakaTool[]; + /** + * Optional trusted catalog reader. When present, Runtime snapshots the full + * Core + Extension Tool surface once at the beginning of every `send()`. + * The returned tools still pass through apply-patch routing, availability, + * argument validation, permission, sandbox, and ToolRuntime settlement. + */ + resolveTools?: () => readonly MakaTool[]; /** Active profile and enforcement capability snapshot for this session backend. */ sandboxDiagnosticsSnapshot?: SandboxDiagnosticsSnapshot; /** Diagnostic-only Plan Mode/execution identity snapshot. */ @@ -1042,7 +1049,7 @@ export class AiSdkBackend implements AgentBackend { private readonly maxSteps: number | undefined; private readonly providerRetrySleep: (delayMs: number, signal: AbortSignal) => Promise; private readonly modelAdapter: ModelAdapter; - private readonly toolAvailabilityRuntime: ToolAvailabilityRuntime; + private readonly memoryTools: readonly MakaTool[]; private readonly applyPatchProfile: ApplyPatchProfile | null; /** @@ -1103,14 +1110,8 @@ export class AiSdkBackend implements AgentBackend { appendTurnTailPrompt: (content, turnTailPrompt) => this.appendTurnTailPrompt(content, turnTailPrompt), }); - if ( - input.tools.some( - (tool) => tool.name === MEMORY_REMEMBER_TOOL_NAME || tool.name === MEMORY_EXTRACT_TOOL_NAME, - ) - ) { - throw new Error('Long-term Memory trigger tool names are reserved by Runtime'); - } - const memoryTools = input.memoryExtraction + validateHostToolSnapshot(input.tools); + this.memoryTools = input.memoryExtraction ? buildMemoryExtractionTriggerTools({ capabilities: input.memoryExtraction, snapshot: (trigger, context) => this.memorySourceSnapshot(trigger, context), @@ -1128,14 +1129,28 @@ export class AiSdkBackend implements AgentBackend { : []; const runtime = resolveModelRuntime(input.connection, input.modelId); this.applyPatchProfile = runtime.applyPatchProfile; - const modelTools = routeApplyPatchTools(input.tools, this.applyPatchProfile); - this.toolAvailabilityRuntime = new ToolAvailabilityRuntime( - // The archive decoder is a runtime protocol tool, not a host binding: - // this session's placeholders name it, so this session advertises it. - bindToolResultArchiveDecoder([...modelTools, ...memoryTools], input.toolResultArchive), - input.toolAvailability, - buildInvalidMakaTool(), - ); + } + + private snapshotToolAvailability(): { + readonly hostTools: readonly MakaTool[]; + readonly runtime: ToolAvailabilityRuntime; + } { + const hostTools = Object.freeze([...(this.input.resolveTools?.() ?? this.input.tools)]); + validateHostToolSnapshot(hostTools); + const modelTools = routeApplyPatchTools(hostTools, this.applyPatchProfile); + return { + hostTools, + runtime: new ToolAvailabilityRuntime( + // The archive decoder is a runtime protocol tool, not a host binding: + // this session's placeholders name it, so this session advertises it. + bindToolResultArchiveDecoder( + [...modelTools, ...this.memoryTools], + this.input.toolResultArchive, + ), + this.input.toolAvailability, + buildInvalidMakaTool(), + ), + }; } private memorySourceSnapshot( @@ -1570,11 +1585,12 @@ export class AiSdkBackend implements AgentBackend { throw new Error(`Invalid tool mode: ${String(requestedToolMode)}`); } const toolMode = requestedToolMode; - if (toolMode === 'code_mode' && this.input.tools.some((tool) => tool.name === 'exec')) { + const toolSnapshot = this.snapshotToolAvailability(); + if (toolMode === 'code_mode' && toolSnapshot.hostTools.some((tool) => tool.name === 'exec')) { throw new Error('Tool name "exec" is reserved for Code Mode.'); } const plan = projectToolModePlan( - this.toolAvailabilityRuntime.prepare( + toolSnapshot.runtime.prepare( (input.runtimeContext ?? []).filter((event) => event.turnId !== turnId), requiredOrchestrationTools, ), @@ -4686,6 +4702,21 @@ function buildInvalidMakaTool(): MakaTool<{ tool?: string; error?: string }, nev }; } +function validateHostToolSnapshot(tools: readonly MakaTool[]): void { + const names = new Map(); + for (const tool of tools) { + const key = tool.name.toLowerCase(); + if (key === MEMORY_REMEMBER_TOOL_NAME || key === MEMORY_EXTRACT_TOOL_NAME) { + throw new Error('Long-term Memory trigger tool names are reserved by Runtime'); + } + const existing = names.get(key); + if (existing) { + throw new Error(`Tool names "${existing}" and "${tool.name}" conflict`); + } + names.set(key, tool.name); + } +} + function priorReplayFailureTrace(replay: { gate: string; diagnostics: readonly { code: string }[]; diff --git a/packages/runtime/src/extension-tool-contributions.ts b/packages/runtime/src/extension-tool-contributions.ts new file mode 100644 index 0000000000..23bd7e2166 --- /dev/null +++ b/packages/runtime/src/extension-tool-contributions.ts @@ -0,0 +1,310 @@ +import type { + ExtensionActivationContext, + ExtensionDependencyDefinition, + ExtensionRevisionDefinition, +} from './extension-lifecycle-kernel.js'; +import type { MakaTool } from './tool-runtime.js'; + +const RESERVED_TOOL_NAMES = new Set([ + 'exec', + 'invalid', + 'load_tools', + 'memory_extract', + 'memory_remember', +]); + +export type ExtensionToolContributionErrorCode = + | 'invalid_tool' + | 'reserved_tool_name' + | 'tool_name_conflict'; + +export class ExtensionToolContributionError extends Error { + readonly name = 'ExtensionToolContributionError'; + + constructor( + readonly code: ExtensionToolContributionErrorCode, + message: string, + ) { + super(message); + } +} + +export interface ExtensionToolContributionInspection { + readonly scopeId: string; + readonly bindingId: string; + readonly extensionId: string; + readonly revision: string; + readonly toolName: string; +} + +interface RegisteredExtensionTool extends ExtensionToolContributionInspection { + readonly key: string; + readonly tool: MakaTool; + readonly token: symbol; + retired: boolean; +} + +export interface ExtensionToolContributionRegistryOptions { + /** Core Tool names protected from Extension shadowing at activation time. */ + readonly protectedToolNames?: (scopeId: string) => readonly string[]; +} + +/** + * Typed contribution surface for trusted Extension Tools. + * + * The registry owns only extension entries. `compose` merges those entries with + * the protected Core Tool catalog and rejects every ambiguous name instead of + * relying on a later map conversion to choose a winner. + */ +export class ExtensionToolContributionRegistry { + readonly #byScope = new Map>(); + + constructor(private readonly options: ExtensionToolContributionRegistryOptions = {}) {} + + register( + context: Pick, + tool: MakaTool, + ): () => void { + validateContext(context); + validateTool(tool); + const key = toolNameKey(tool.name); + if (RESERVED_TOOL_NAMES.has(key)) { + throw new ExtensionToolContributionError( + 'reserved_tool_name', + `Tool name "${tool.name}" is reserved by Runtime`, + ); + } + const protectedName = this.options + .protectedToolNames?.(context.scopeId) + .find((name) => toolNameKey(name) === key); + if (protectedName) { + throw new ExtensionToolContributionError( + 'tool_name_conflict', + `Extension Tool "${tool.name}" conflicts with protected Core Tool "${protectedName}"`, + ); + } + let scope = this.#byScope.get(context.scopeId); + if (!scope) { + scope = new Map(); + this.#byScope.set(context.scopeId, scope); + } + const existing = scope.get(key); + if ( + existing && + (existing.bindingId !== context.bindingId || existing.extensionId !== context.extensionId) + ) { + throw new ExtensionToolContributionError( + 'tool_name_conflict', + `Tool name "${tool.name}" is already contributed by ${existing.extensionId}@${existing.revision}`, + ); + } + const token = Symbol(tool.name); + const entry: RegisteredExtensionTool = { + key, + scopeId: context.scopeId, + bindingId: context.bindingId, + extensionId: context.extensionId, + revision: context.revision, + toolName: tool.name, + tool, + token, + retired: false, + }; + scope.set(key, entry); + + // Idempotent and generation-safe: a stale disposer cannot delete a newer + // registration that reused the same name after this entry was removed. + return () => { + const currentScope = this.#byScope.get(context.scopeId); + if (currentScope?.get(key)?.token !== token) { + entry.retired = true; + return; + } + if (existing && !existing.retired) currentScope.set(key, existing); + else currentScope.delete(key); + entry.retired = true; + if (currentScope.size === 0) this.#byScope.delete(context.scopeId); + }; + } + + compose(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[] { + validateIdentity('scopeId', scopeId); + const byName = new Map(); + for (const tool of coreTools) { + validateTool(tool); + const key = toolNameKey(tool.name); + const existing = byName.get(key); + if (existing) { + throw new ExtensionToolContributionError( + 'tool_name_conflict', + `Core Tool names "${existing.name}" and "${tool.name}" conflict`, + ); + } + byName.set(key, tool); + } + for (const entry of this.#scopeEntries(scopeId)) { + const existing = byName.get(entry.key); + if (existing) { + throw new ExtensionToolContributionError( + 'tool_name_conflict', + `Extension Tool "${entry.toolName}" conflicts with Core Tool "${existing.name}"`, + ); + } + byName.set(entry.key, entry.tool); + } + return Object.freeze( + [...byName.values()].sort((left, right) => compareString(left.name, right.name)), + ); + } + + inspect(scopeId: string): readonly ExtensionToolContributionInspection[] { + validateIdentity('scopeId', scopeId); + return Object.freeze( + this.#scopeEntries(scopeId).map((entry) => + Object.freeze({ + scopeId: entry.scopeId, + bindingId: entry.bindingId, + extensionId: entry.extensionId, + revision: entry.revision, + toolName: entry.toolName, + }), + ), + ); + } + + #scopeEntries(scopeId: string): RegisteredExtensionTool[] { + return [...(this.#byScope.get(scopeId)?.values() ?? [])].sort((left, right) => + compareString(left.toolName, right.toolName), + ); + } +} + +/** Register one Tool and make its registry entry activation-owned atomically. */ +export function contributeExtensionTool( + context: ExtensionActivationContext, + registry: ExtensionToolContributionRegistry, + tool: MakaTool, +): void { + const unregister = registry.register(context, tool); + try { + context.ownEffect(`tool:${tool.name}`, unregister); + } catch (error) { + unregister(); + throw error; + } +} + +export interface TrustedToolExtensionRevisionInput { + readonly registry: ExtensionToolContributionRegistry; + readonly extensionId: string; + readonly revision: string; + readonly dependencies?: readonly ExtensionDependencyDefinition[]; + readonly tools: readonly MakaTool[]; + readonly healthCheck?: () => void | Promise; +} + +/** + * Build a trusted, static Tool revision using the same lifecycle contract as + * every later contribution adapter. Definition/install stays effect-free; + * registry publication happens only in `activate`. + */ +export function defineTrustedToolExtensionRevision( + input: TrustedToolExtensionRevisionInput, +): ExtensionRevisionDefinition { + validateIdentity('extensionId', input.extensionId); + if (!input.revision || typeof input.revision !== 'string') { + throw new ExtensionToolContributionError('invalid_tool', 'Revision is required'); + } + const tools = Object.freeze(input.tools.map((tool) => Object.freeze({ ...tool }))); + const names = new Set(); + for (const tool of tools) { + validateTool(tool); + const key = toolNameKey(tool.name); + if (RESERVED_TOOL_NAMES.has(key)) { + throw new ExtensionToolContributionError( + 'reserved_tool_name', + `Tool name "${tool.name}" is reserved by Runtime`, + ); + } + if (names.has(key)) { + throw new ExtensionToolContributionError( + 'tool_name_conflict', + `Tool revision declares conflicting name "${tool.name}"`, + ); + } + names.add(key); + } + return Object.freeze({ + extensionId: input.extensionId, + revision: input.revision, + ...(input.dependencies ? { dependencies: Object.freeze([...input.dependencies]) } : {}), + contributions: Object.freeze( + tools.map((_, index) => + Object.freeze({ id: `${input.extensionId}.tool-${index + 1}`, kind: 'tool' }), + ), + ), + prepare: () => ({ + ...(input.healthCheck ? { healthCheck: input.healthCheck } : {}), + activate: (context: ExtensionActivationContext) => { + for (const tool of tools) contributeExtensionTool(context, input.registry, tool); + }, + }), + }); +} + +function validateContext( + context: Pick, +): void { + validateIdentity('bindingId', context.bindingId); + validateIdentity('scopeId', context.scopeId); + validateIdentity('extensionId', context.extensionId); + if (!context.revision || typeof context.revision !== 'string') { + throw new ExtensionToolContributionError('invalid_tool', 'Revision is required'); + } +} + +function validateIdentity(label: string, value: string): void { + if (typeof value !== 'string' || value.length === 0 || /[\r\n\0]/.test(value)) { + throw new ExtensionToolContributionError('invalid_tool', `Invalid ${label}`); + } +} + +function validateTool(tool: MakaTool): void { + if (!tool || typeof tool !== 'object') { + throw new ExtensionToolContributionError('invalid_tool', 'Tool definition is required'); + } + if ( + typeof tool.name !== 'string' || + tool.name.length === 0 || + tool.name.length > 128 || + /[\r\n\0]/.test(tool.name) + ) { + throw new ExtensionToolContributionError('invalid_tool', 'Tool requires a valid name'); + } + if (typeof tool.description !== 'string' || typeof tool.impl !== 'function') { + throw new ExtensionToolContributionError( + 'invalid_tool', + `Tool "${tool.name}" requires a description and implementation`, + ); + } + if (tool.parameters === undefined) { + throw new ExtensionToolContributionError( + 'invalid_tool', + `Tool "${tool.name}" requires an input schema`, + ); + } + if (tool.providerTool) { + throw new ExtensionToolContributionError( + 'invalid_tool', + `Extension Tool "${tool.name}" cannot claim a provider-native Runtime protocol`, + ); + } +} + +function toolNameKey(name: string): string { + return name.toLowerCase(); +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} From ee5897c103016ef62e052596e15bce39d7718b4a Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:06:25 +0800 Subject: [PATCH 04/48] feat(runtime-host): wire extension tool runtime --- .../extension-tool-contributions.md | 15 ++ .../execution-model-composition.test.ts | 79 ++++++++ .../src/__tests__/extension-runtime.test.ts | 98 ++++++++++ .../src/server/execution-composition.ts | 29 ++- .../src/server/execution-model-composition.ts | 18 +- .../src/server/extension-runtime.ts | 175 ++++++++++++++++++ packages/runtime-host/src/server/index.ts | 5 + .../extension-tool-contributions.test.ts | 11 ++ packages/runtime/src/ai-sdk-backend.ts | 20 +- .../src/extension-tool-contributions.ts | 6 +- 10 files changed, 437 insertions(+), 19 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/extension-runtime.test.ts create mode 100644 packages/runtime-host/src/server/extension-runtime.ts diff --git a/docs/architecture/extension-tool-contributions.md b/docs/architecture/extension-tool-contributions.md index 44e1bbb488..1727042a78 100644 --- a/docs/architecture/extension-tool-contributions.md +++ b/docs/architecture/extension-tool-contributions.md @@ -45,3 +45,18 @@ repair path, and `ToolRuntime` dispatch used by Core Tools. This phase intentionally chooses the existing `send()`/Turn boundary. Refreshing at every physical model request, pinning a larger Run composition, draining in-flight calls, persistence, an install control plane, and isolated agent-authored code remain later decisions. + +## Runtime Host ownership + +`HostExtensionRuntime` is the in-process authority owned by the execution Runtime Host. It owns the +lifecycle kernel and Tool registry together, exposes the trusted-definition lifecycle seam for a +future control plane, and composes Session-scoped Extension Tools into both the model Backend and +the Host's available-Tool catalog. + +The exact Tool snapshot selected at the beginning of `send()` is also written into the durable Run +Composition record. During Host drain, new Extension mutations are rejected while read-only Tool +resolution remains available to admitted work. The Extension authority closes after execution +domains, disposes every tracked Scope, and only then uninstalls its in-memory revisions. + +This wiring does not define package discovery, persistence, restart restoration, or a remote +install/enable API. Those are control-plane concerns layered on this Host-owned authority. diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 81a49ba6b9..3d1264ecdb 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -58,6 +58,7 @@ import { import type { TurnSnapshot, UsageQueryResult } from '../protocol/index.js'; import type { ClientCapabilityHostFrame } from '../protocol/index.js'; import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; +import { HostExtensionRuntime } from '../server/extension-runtime.js'; import { createHostDailyReviewModel, createHostGoalEvaluator, @@ -258,6 +259,66 @@ test('provider dispatch fails closed when the Run Composition commit fails', asy } }); +test('production backend snapshots Host Extension Tools per send and records the same catalog', async () => { + const provider = await startProvider(); + const extensions = new HostExtensionRuntime(); + const snapshots: ReturnType[] = []; + let backend: Awaited> | undefined; + try { + await extensions.installTrustedToolRevision({ + extensionId: 'weather', + revision: '1', + tools: [ + { + name: 'Weather', + description: 'Read the current weather.', + parameters: z.object({ city: z.string() }), + impl: async ({ city }: { city: string }) => ({ city, temperature: 21 }), + }, + ], + }); + await extensions.activate({ + bindingId: 'weather-binding', + scopeId: 'backend-creation-session', + extensionId: 'weather', + revision: '1', + }); + backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => readyExecutionConnection(provider.baseUrl), + readPricing: async () => ({ revision: 0, overrides: [] }), + executionBoundary: createBypassExecutionBoundary(0), + tools: [ + { + name: 'Read', + description: 'Read a fixture resource.', + parameters: z.object({}), + impl: async () => 'read', + }, + ], + extensions, + recordRunComposition: async (_runId, snapshot) => { + snapshots.push(decodeRunCompositionSnapshot(snapshot)); + }, + }), + ); + + await drainBackendSend(backend, 'extension-run-1', 'extension-turn-1'); + assert.deepEqual(toolNames(provider.requests[0]?.body), ['ArchiveRead', 'Read', 'Weather']); + assert.deepEqual(snapshots[0]?.toolNames, ['Read', 'Weather']); + + await extensions.stop('weather-binding'); + await drainBackendSend(backend, 'extension-run-2', 'extension-turn-2'); + assert.deepEqual(toolNames(provider.requests[1]?.body), ['ArchiveRead', 'Read']); + assert.deepEqual(snapshots[1]?.toolNames, ['Read']); + } finally { + await backend?.dispose(); + await extensions.close(); + await provider.close(); + } +}); + test('backend abort cannot cancel the authority-owned OAuth refresh used by its successor', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-host-oauth-backend-')); const capability = await resolveStorageRoot({ @@ -2729,6 +2790,7 @@ function backendCreationFixture(input: { recordRunComposition?: BackendFactoryContext['recordRunComposition']; createFetchTransport?: HostAiSdkBackendInput['createFetchTransport']; createRunComposer?: HostAiSdkBackendInput['createRunComposer']; + extensions?: HostAiSdkBackendInput['extensions']; }): HostAiSdkBackendInput { const runtimePolicy = input.runtimePolicy ?? @@ -2794,6 +2856,7 @@ function backendCreationFixture(input: { ...(input.oauthCredentials ? { oauthCredentials: input.oauthCredentials } : {}), ...(input.claudeDeviceId ? { claudeDeviceId: input.claudeDeviceId } : {}), createRunComposer, + ...(input.extensions ? { extensions: input.extensions } : {}), artifacts: {}, executionArtifacts: { recordToolArtifacts: async () => undefined, @@ -2818,6 +2881,22 @@ function backendCreationFixture(input: { } as unknown as HostAiSdkBackendInput; } +async function drainBackendSend( + backend: Awaited>, + runId: string, + turnId: string, +): Promise { + for await (const _event of backend.send({ + invocationId: `${runId}-invocation`, + runId, + turnId, + text: 'Return a short answer without calling tools.', + context: [], + })) { + // Drain the real provider-backed send to settlement. + } +} + function readyExecutionConnection( baseUrl?: string, customization: { diff --git a/packages/runtime-host/src/__tests__/extension-runtime.test.ts b/packages/runtime-host/src/__tests__/extension-runtime.test.ts new file mode 100644 index 0000000000..a6d687ee2b --- /dev/null +++ b/packages/runtime-host/src/__tests__/extension-runtime.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { z } from 'zod'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { HostExtensionRuntime } from '../server/extension-runtime.js'; + +test('Host Extension authority owns trusted Tool lifecycle and close cleanup', async () => { + const extensions = new HostExtensionRuntime({ + protectedToolNames: () => ['Read'], + }); + const weatherV1 = tool('Weather', 1); + const weatherV2 = tool('Weather', 2); + + await extensions.installTrustedToolRevision({ + extensionId: 'weather', + revision: '1', + tools: [weatherV1], + }); + await extensions.installTrustedToolRevision({ + extensionId: 'weather', + revision: '2', + tools: [weatherV2], + }); + await extensions.activate({ + bindingId: 'weather-binding', + scopeId: 'session-a', + extensionId: 'weather', + revision: '1', + }); + + assert.deepEqual( + extensions.resolveTools('session-a', [tool('Read', 0)]).map(({ name }) => name), + ['Read', 'Weather'], + ); + assert.equal(extensions.resolveTools('session-a', [tool('Read', 0)])[1]?.impl, weatherV1.impl); + + await extensions.update('weather-binding', '2'); + assert.equal(extensions.resolveTools('session-a', [tool('Read', 0)])[1]?.impl, weatherV2.impl); + assert.equal(extensions.composition('session-a').entries[0]?.revision, '2'); + + extensions.beginDrain(); + assert.throws( + () => + extensions.installTrustedToolRevision({ + extensionId: 'late', + revision: '1', + tools: [tool('Late', 1)], + }), + /draining/, + ); + // Read-only resolution remains available while already-admitted work drains. + assert.equal(extensions.resolveTools('session-a', []).length, 1); + + await extensions.close(); + assert.deepEqual(extensions.inspectTools('session-a'), []); + assert.deepEqual(extensions.installedRevisions(), []); + assert.throws(() => extensions.resolveTools('session-a', []), /closed/); + await extensions.close(); +}); + +test('Host Extension close retries lifecycle cleanup before uninstalling revisions', async () => { + const extensions = new HostExtensionRuntime(); + let cleanupAttempts = 0; + await extensions.install({ + extensionId: 'retryable', + revision: '1', + prepare: () => ({ + activate: (context) => { + context.ownEffect('retryable-cleanup', () => { + cleanupAttempts += 1; + if (cleanupAttempts === 1) throw new Error('cleanup unavailable'); + }); + }, + }), + }); + await extensions.activate({ + bindingId: 'retryable-binding', + scopeId: 'session-retry', + extensionId: 'retryable', + revision: '1', + }); + + await assert.rejects(extensions.close(), /Unable to close Runtime Host Extension authority/); + assert.deepEqual(extensions.installedRevisions(), [{ extensionId: 'retryable', revision: '1' }]); + + await extensions.close(); + assert.equal(cleanupAttempts, 2); + assert.deepEqual(extensions.installedRevisions(), []); +}); + +function tool(name: string, revision: number): MakaTool { + return { + name, + description: `${name} revision ${revision}`, + parameters: z.object({}), + impl: async () => ({ revision }), + }; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 4b1c4a7702..fc0abec0cb 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -111,6 +111,7 @@ import { } from './execution-model-authority.js'; import { HostExecutionInspectCoordinator } from './execution-inspect-coordinator.js'; import { HostExternalSessionCoordinator } from './external-session-coordinator.js'; +import { HostExtensionRuntime } from './extension-runtime.js'; import { HostGoalCoordinator } from './goal-coordinator.js'; import { HostGoalExecutionCoordinator } from './goal-execution-coordinator.js'; import { HostHostedExecutionCoordinator } from './hosted-execution-coordinator.js'; @@ -174,6 +175,7 @@ import { export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; + readonly extensions: HostExtensionRuntime; } export interface CreateExecutionRuntimeHostCompositionOptions { @@ -203,6 +205,7 @@ export async function createExecutionRuntimeHostComposition( ): Promise { const stores = await openInteractiveExecutionStoresForWrite(context.owner.lease); await stores.sessionStore.ready(); + const extensions = new HostExtensionRuntime(); let graphControlStore: ReturnType | undefined; let taskLedgerStore: | Awaited> @@ -657,6 +660,7 @@ export async function createExecutionRuntimeHostComposition( backendContext.sessionId, ), runtimeCommitSink: stores.runtimeEventStore, + extensions, requestDrain: context.requestDrain, })), ); @@ -683,7 +687,7 @@ export async function createExecutionRuntimeHostComposition( if (tools.length !== header.subagentRuntime.toolNames.length) { throw new Error('Subagent runtime tool snapshot is unavailable'); } - return tools.map((tool) => tool.name); + return extensions.resolveTools(sessionId, tools).map((tool) => tool.name); } if (header.subagentParent) { throw new Error('Linked child session is missing its durable runtime snapshot'); @@ -697,7 +701,7 @@ export async function createExecutionRuntimeHostComposition( runtimePolicyStores.runtimePolicy.getSnapshot(), ]); const runProfile = hostedExecutionRunProfile(header.toolProfile); - return createInteractiveRunComposer({ + const composition = createInteractiveRunComposer({ runtimePolicy: runtimePolicySnapshot, skills, memory: requireMemory(memory), @@ -727,7 +731,8 @@ export async function createExecutionRuntimeHostComposition( }, } : {}), - }).tools.map((tool) => tool.name); + }); + return extensions.resolveTools(sessionId, composition.tools).map((tool) => tool.name); } finally { capabilitySnapshot?.release(); } @@ -745,7 +750,7 @@ export async function createExecutionRuntimeHostComposition( requireClientCapabilities(clientCapabilities).snapshotForSession(previewSessionId); try { const runtimePolicySnapshot = await runtimePolicyStores.runtimePolicy.getSnapshot(); - return createInteractiveRunComposer({ + const composition = createInteractiveRunComposer({ runtimePolicy: runtimePolicySnapshot, skills, memory: requireMemory(memory), @@ -762,7 +767,10 @@ export async function createExecutionRuntimeHostComposition( mode: collaborationMode, permissionMode, }, - }).tools.map((tool) => tool.name); + }); + return extensions + .resolveTools(previewSessionId, composition.tools) + .map((tool) => tool.name); } finally { capabilitySnapshot?.release(); } @@ -1255,6 +1263,11 @@ export async function createExecutionRuntimeHostComposition( ); let recoverySessions: Awaited> = []; domainModules = [ + createRuntimeHostDomainModule({ + id: 'extension', + drain: [() => extensions.beginDrain()], + close: [() => extensions.close()], + }), createRuntimeHostDomainModule({ id: 'memory', handlers: [requireMemory(memory).handlers], @@ -1508,6 +1521,7 @@ export async function createExecutionRuntimeHostComposition( handlers, moduleIds: Object.freeze(domainModules.map(({ id }) => id)), workspaceExecution: requireWorkspaceExecution(workspaceExecution), + extensions, continuity: continuityCoordinator, clientCapabilities, configurationChanges, @@ -1612,6 +1626,11 @@ export async function createExecutionRuntimeHostComposition( } catch (closeError) { errors.push(closeError); } + try { + await extensions.close(); + } catch (closeError) { + errors.push(closeError); + } if (errors.length === 1) throw error; throw new AggregateError(errors, 'Unable to clean up Runtime Host execution composition'); } diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 9d9a6e24b1..cd6e132694 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -23,6 +23,7 @@ import { stableHash, toolCatalogHash } from '@maka/runtime/request-shape'; import { toolAvailabilityHash } from '@maka/runtime/tool-availability'; import { type BackendFactoryContext } from '@maka/runtime/session-manager'; import { type RuntimeCommitSink } from '@maka/runtime/runtime-commit-sink'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; import { createAttachmentByteReader, persistProviderRequestCaptureArtifact, @@ -37,6 +38,7 @@ import { import type { HostChildAgentBackendCapabilities } from './child-agent-composition.js'; import type { HostExecutionArtifactServices } from './execution-artifacts.js'; import type { HostMemoryExtractionCoordinator } from './memory-extraction-coordinator.js'; +import type { HostExtensionToolResolver } from './extension-runtime.js'; import { readDuringBackendCreation, resolveExecutionTarget } from './execution-model-authority.js'; import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; import type { HostRunComposer, HostRunComposerFactory } from './host-run-composer.js'; @@ -54,6 +56,7 @@ export interface HostAiSdkBackendInput { readonly requestDrain: () => void; readonly runtimeCommitSink?: RuntimeCommitSink; readonly childAgents?: HostChildAgentBackendCapabilities; + readonly extensions?: HostExtensionToolResolver; readonly createFetchTransport?: (proxy: ProxiedFetchProxy | null) => ProxiedFetchTransport; } @@ -148,6 +151,9 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom await transport.close(); throw error; } + const resolveModelTools = (): readonly MakaTool[] => + input.extensions?.resolveTools(input.context.sessionId, modelComposition.tools) ?? + modelComposition.tools; const modelFactory = ( modelInput: Parameters[0], ): ReturnType => @@ -270,8 +276,13 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom }; const recordRunComposition = input.context.recordRunComposition; const commitRunComposition = recordRunComposition - ? async (context: { readonly turnId: string; readonly runId: string }): Promise => { + ? async (context: { + readonly turnId: string; + readonly runId: string; + readonly tools?: readonly MakaTool[]; + }): Promise => { const resolved = await resolveRunPrompt(context); + const tools = context.tools ?? resolveModelTools(); await recordRunComposition( context.runId, createRunCompositionSnapshot({ @@ -279,10 +290,10 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom composerRevision: modelComposition.composerRevision, sourceRevisions: resolved.sourceRevisions, baseSystemPromptHash: stableHash(resolved.text ?? ''), - toolCatalogHash: toolCatalogHash(modelComposition.tools), + toolCatalogHash: toolCatalogHash(tools), toolAvailabilityHash: toolAvailabilityHash(modelComposition.toolAvailability), baseProviderOptionsHash: stableHash(providerOptions), - toolNames: modelComposition.tools.map(({ name }) => name), + toolNames: tools.map(({ name }) => name), contextWindow: contextWindow ?? null, }), ); @@ -323,6 +334,7 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom modelId: target.model, modelFactory, tools: [...modelComposition.tools], + ...(input.extensions ? { resolveTools: resolveModelTools } : {}), toolAvailability: modelComposition.toolAvailability, ...(modelComposition.planTraceContext ? { planTraceContext: modelComposition.planTraceContext } diff --git a/packages/runtime-host/src/server/extension-runtime.ts b/packages/runtime-host/src/server/extension-runtime.ts new file mode 100644 index 0000000000..107fd4ee1e --- /dev/null +++ b/packages/runtime-host/src/server/extension-runtime.ts @@ -0,0 +1,175 @@ +import { + ExtensionLifecycleKernel, + type ExtensionBindingInput, + type ExtensionBindingInspection, + type ExtensionCompositionSnapshot, + type ExtensionRevisionDefinition, +} from '@maka/runtime/extension-lifecycle-kernel'; +import { + ExtensionToolContributionRegistry, + defineTrustedToolExtensionRevision, + type ExtensionToolContributionInspection, + type ExtensionToolContributionRegistryOptions, + type TrustedToolExtensionRevisionInput, +} from '@maka/runtime/extension-tool-contributions'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; + +export type HostTrustedToolExtensionRevisionInput = Omit< + TrustedToolExtensionRevisionInput, + 'registry' +>; + +export interface HostExtensionToolResolver { + resolveTools(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[]; +} + +/** + * Runtime Host-owned Extension authority. + * + * This is deliberately an in-process seam rather than a product control plane. + * It gives the Host one lifecycle owner, one typed Tool registry, and one close + * boundary while later API/CLI/UI work decides how trusted definitions arrive. + */ +export class HostExtensionRuntime implements HostExtensionToolResolver { + readonly #lifecycle = new ExtensionLifecycleKernel(); + readonly #tools: ExtensionToolContributionRegistry; + readonly #scopeIds = new Set(); + #draining = false; + #closed = false; + #closeTask: Promise | undefined; + + constructor(options: ExtensionToolContributionRegistryOptions = {}) { + this.#tools = new ExtensionToolContributionRegistry(options); + } + + install(definition: ExtensionRevisionDefinition): Promise { + this.#assertMutable(); + return this.#lifecycle.install(definition); + } + + installTrustedToolRevision(input: HostTrustedToolExtensionRevisionInput): Promise { + this.#assertMutable(); + return this.#lifecycle.install( + defineTrustedToolExtensionRevision({ + ...input, + registry: this.#tools, + }), + ); + } + + activate(input: ExtensionBindingInput): Promise { + this.#assertMutable(); + // Activation may leave a failed Binding behind for diagnosis/retry. Track + // the scope before entering the kernel so Host close still owns cleanup. + this.#scopeIds.add(input.scopeId); + return this.#lifecycle.activate(input); + } + + update(bindingId: string, revision: string): Promise { + this.#assertMutable(); + return this.#lifecycle.update(bindingId, revision); + } + + start(bindingId: string): Promise { + this.#assertMutable(); + return this.#lifecycle.start(bindingId); + } + + stop(bindingId: string): Promise { + this.#assertMutable(); + return this.#lifecycle.stop(bindingId); + } + + async removeBinding(bindingId: string): Promise { + this.#assertMutable(); + const scopeId = this.#lifecycle.inspect(bindingId).scopeId; + await this.#lifecycle.removeBinding(bindingId); + if (this.#lifecycle.inspectScope(scopeId).length === 0) this.#scopeIds.delete(scopeId); + } + + async disposeScope(scopeId: string): Promise { + this.#assertMutable(); + await this.#lifecycle.disposeScope(scopeId); + this.#scopeIds.delete(scopeId); + } + + uninstall(extensionId: string, revision: string): Promise { + this.#assertMutable(); + return this.#lifecycle.uninstall(extensionId, revision); + } + + inspect(bindingId: string): ExtensionBindingInspection { + return this.#lifecycle.inspect(bindingId); + } + + inspectScope(scopeId: string): readonly ExtensionBindingInspection[] { + return this.#lifecycle.inspectScope(scopeId); + } + + inspectTools(scopeId: string): readonly ExtensionToolContributionInspection[] { + return this.#tools.inspect(scopeId); + } + + installedRevisions(): readonly { + readonly extensionId: string; + readonly revision: string; + }[] { + return this.#lifecycle.installedRevisions(); + } + + composition(scopeId: string): ExtensionCompositionSnapshot { + return this.#lifecycle.composition(scopeId); + } + + resolveTools(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[] { + if (this.#closed) throw new Error('Runtime Host Extension authority is closed'); + return this.#tools.compose(scopeId, coreTools); + } + + beginDrain(): void { + this.#draining = true; + } + + close(): Promise { + if (this.#closed) return Promise.resolve(); + this.#closeTask ??= this.#closeOnce().finally(() => { + if (!this.#closed) this.#closeTask = undefined; + }); + return this.#closeTask; + } + + async #closeOnce(): Promise { + this.beginDrain(); + const errors: unknown[] = []; + for (const scopeId of [...this.#scopeIds].sort(compareString)) { + try { + await this.#lifecycle.disposeScope(scopeId); + this.#scopeIds.delete(scopeId); + } catch (error) { + errors.push(error); + } + } + if (errors.length === 0) { + for (const { extensionId, revision } of [...this.#lifecycle.installedRevisions()].reverse()) { + try { + await this.#lifecycle.uninstall(extensionId, revision); + } catch (error) { + errors.push(error); + } + } + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to close Runtime Host Extension authority'); + } + this.#closed = true; + } + + #assertMutable(): void { + if (this.#closed) throw new Error('Runtime Host Extension authority is closed'); + if (this.#draining) throw new Error('Runtime Host Extension authority is draining'); + } +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index c612afc009..622e6ba5fd 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -29,6 +29,11 @@ export { type InteractiveRuntimeHostCandidateResult, } from './candidate.js'; export { createUnavailableDomainOperationHandlers } from './operation-dispatcher.js'; +export { + HostExtensionRuntime, + type HostExtensionToolResolver, + type HostTrustedToolExtensionRevisionInput, +} from './extension-runtime.js'; export { RuntimeHostRootAlreadyOwnedError, startExecutionRuntimeHostService, diff --git a/packages/runtime/src/__tests__/extension-tool-contributions.test.ts b/packages/runtime/src/__tests__/extension-tool-contributions.test.ts index fb87b5a5ac..6b054b928f 100644 --- a/packages/runtime/src/__tests__/extension-tool-contributions.test.ts +++ b/packages/runtime/src/__tests__/extension-tool-contributions.test.ts @@ -123,6 +123,17 @@ describe('Extension Tool contributions', () => { (error: unknown) => error instanceof ExtensionToolContributionError && error.code === 'tool_name_conflict', ); + assert.deepEqual( + registry + .compose('session-b', [ + { + ...tool('NativeCore'), + providerTool: { kind: 'openai-web-search' }, + }, + ]) + .map(({ name }) => name), + ['NativeCore'], + ); await kernel.install( defineTrustedToolExtensionRevision({ registry, diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 55ab302553..316db35ff8 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -828,6 +828,8 @@ export interface AiSdkBackendInput extends AiSdkCompactionCapabilities { sessionId: string; turnId: string; runId: string; + /** Exact Host Tool snapshot selected for this send, when this is the main call. */ + tools?: readonly MakaTool[]; }) => void | Promise; /** * Optional artifact recorder. Runtime derives only deterministic candidates @@ -1526,14 +1528,6 @@ export class AiSdkBackend implements AgentBackend { toSandboxRunTraceProjection(this.input.sandboxDiagnosticsSnapshot), ); } - const providerRequestTracker = this.createProviderRequestTracker({ - turnId, - callKind: 'main', - modelId: this.input.modelId, - runId: scope.runId, - }); - const providerRequestTraceId = providerRequestTracker?.traceId; - // --- Resolve model (API key already attached at construct time) --- let model: unknown; try { @@ -1586,6 +1580,14 @@ export class AiSdkBackend implements AgentBackend { } const toolMode = requestedToolMode; const toolSnapshot = this.snapshotToolAvailability(); + const providerRequestTracker = this.createProviderRequestTracker({ + turnId, + callKind: 'main', + modelId: this.input.modelId, + runId: scope.runId, + tools: toolSnapshot.hostTools, + }); + const providerRequestTraceId = providerRequestTracker?.traceId; if (toolMode === 'code_mode' && toolSnapshot.hostTools.some((tool) => tool.name === 'exec')) { throw new Error('Tool name "exec" is reserved for Code Mode.'); } @@ -3196,6 +3198,7 @@ export class AiSdkBackend implements AgentBackend { turnId: string; callKind: ModelCallKind; modelId: string; + tools?: readonly MakaTool[]; /** * Stated by every caller, never defaulted: an unattributed provider request * is silently dropped by usage accounting, so the compiler has to be the @@ -3217,6 +3220,7 @@ export class AiSdkBackend implements AgentBackend { sessionId: this.sessionId, turnId: input.turnId, runId, + ...(input.tools ? { tools: input.tools } : {}), }) : undefined; if (!persistCapture && !accounting && !beforeDispatch) return undefined; diff --git a/packages/runtime/src/extension-tool-contributions.ts b/packages/runtime/src/extension-tool-contributions.ts index 23bd7e2166..3b0f4d617a 100644 --- a/packages/runtime/src/extension-tool-contributions.ts +++ b/packages/runtime/src/extension-tool-contributions.ts @@ -131,7 +131,7 @@ export class ExtensionToolContributionRegistry { validateIdentity('scopeId', scopeId); const byName = new Map(); for (const tool of coreTools) { - validateTool(tool); + validateTool(tool, { allowProviderTool: true }); const key = toolNameKey(tool.name); const existing = byName.get(key); if (existing) { @@ -269,7 +269,7 @@ function validateIdentity(label: string, value: string): void { } } -function validateTool(tool: MakaTool): void { +function validateTool(tool: MakaTool, options: { allowProviderTool?: boolean } = {}): void { if (!tool || typeof tool !== 'object') { throw new ExtensionToolContributionError('invalid_tool', 'Tool definition is required'); } @@ -293,7 +293,7 @@ function validateTool(tool: MakaTool): void { `Tool "${tool.name}" requires an input schema`, ); } - if (tool.providerTool) { + if (tool.providerTool && !options.allowProviderTool) { throw new ExtensionToolContributionError( 'invalid_tool', `Extension Tool "${tool.name}" cannot claim a provider-native Runtime protocol`, From 08cfa84273b7670bdd378d339cb71e683b93c4dd Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:47:35 +0800 Subject: [PATCH 05/48] feat(runtime-host): add trusted extension control plane --- .../extension-tool-contributions.md | 34 +- .../__tests__/extension-composition.test.ts | 101 ++++ .../__tests__/extension-controller.test.ts | 218 ++++++++ .../src/__tests__/extension-protocol.test.ts | 82 +++ .../runtime-host/src/protocol/extension.ts | 244 +++++++++ packages/runtime-host/src/protocol/index.ts | 1 + .../runtime-host/src/protocol/operations.ts | 2 + .../src/server/execution-candidate.ts | 2 + .../server/execution-composition-factory.ts | 5 + .../src/server/execution-composition.ts | 17 +- .../src/server/execution-service.ts | 2 + .../src/server/extension-controller.ts | 486 ++++++++++++++++++ .../src/server/extension-loader.ts | 165 ++++++ .../src/server/extension-state-store.ts | 201 ++++++++ packages/runtime-host/src/server/index.ts | 6 + .../src/server/operation-dispatcher.ts | 2 + 16 files changed, 1560 insertions(+), 8 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/extension-composition.test.ts create mode 100644 packages/runtime-host/src/__tests__/extension-controller.test.ts create mode 100644 packages/runtime-host/src/__tests__/extension-protocol.test.ts create mode 100644 packages/runtime-host/src/protocol/extension.ts create mode 100644 packages/runtime-host/src/server/extension-controller.ts create mode 100644 packages/runtime-host/src/server/extension-loader.ts create mode 100644 packages/runtime-host/src/server/extension-state-store.ts diff --git a/docs/architecture/extension-tool-contributions.md b/docs/architecture/extension-tool-contributions.md index 1727042a78..3cac17575e 100644 --- a/docs/architecture/extension-tool-contributions.md +++ b/docs/architecture/extension-tool-contributions.md @@ -43,20 +43,40 @@ snapshot through the same apply-patch projection, `ToolAvailabilityRuntime`, pro repair path, and `ToolRuntime` dispatch used by Core Tools. This phase intentionally chooses the existing `send()`/Turn boundary. Refreshing at every physical -model request, pinning a larger Run composition, draining in-flight calls, persistence, an install -control plane, and isolated agent-authored code remain later decisions. +model request, pinning a larger Run composition, draining in-flight calls, and isolated +agent-authored code remain later decisions. ## Runtime Host ownership `HostExtensionRuntime` is the in-process authority owned by the execution Runtime Host. It owns the -lifecycle kernel and Tool registry together, exposes the trusted-definition lifecycle seam for a -future control plane, and composes Session-scoped Extension Tools into both the model Backend and -the Host's available-Tool catalog. +lifecycle kernel and Tool registry together and composes Session-scoped Extension Tools into both +the model Backend and the Host's available-Tool catalog. The exact Tool snapshot selected at the beginning of `send()` is also written into the durable Run Composition record. During Host drain, new Extension mutations are rejected while read-only Tool resolution remains available to admitted work. The Extension authority closes after execution domains, disposes every tracked Scope, and only then uninstalls its in-memory revisions. -This wiring does not define package discovery, persistence, restart restoration, or a remote -install/enable API. Those are control-plane concerns layered on this Host-owned authority. +## Trusted control plane and restart recovery + +The Runtime Host composition may register a bounded catalog of trusted static Tool revisions. +`StaticTrustedToolExtensionLoader` resolves only those definitions; it never imports a workspace +path or executes user-authored code. The local owner can use `extension.catalog.query` and +`extension.catalog.mutate` to list, enable, disable, update, and remove bindings. These operations +are deliberately absent from the remote-owner grant list. + +`HostExtensionController` persists desired bindings, enabled state, last-good revision, and the +latest diagnostic in the root-private Host control directory. Desired state is committed before +enable, update, and disable convergence so a process crash can resume the command. At startup it: + +1. loads every available last-good and desired revision without activating code during install; +2. restores last-good bindings first; +3. attempts the desired upgrade through the lifecycle candidate transaction; +4. retains last-good Tools and records a diagnostic when loading, health check, or activation + fails; +5. leaves the rest of Runtime Host available when Extension state cannot be recovered. + +This completes the minimum trusted-static product slice: a Host integrator registers Tool +revisions, a local administrator controls them through the Runtime Host protocol, and enabled +bindings survive process restart. Manifest/npm discovery, third-party code isolation, UI, an Agent +authoring Tools, and model-call-level hot swapping remain outside this trust boundary. diff --git a/packages/runtime-host/src/__tests__/extension-composition.test.ts b/packages/runtime-host/src/__tests__/extension-composition.test.ts new file mode 100644 index 0000000000..69c92bdb55 --- /dev/null +++ b/packages/runtime-host/src/__tests__/extension-composition.test.ts @@ -0,0 +1,101 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { + resolveStorageRoot, + tryAcquireInteractiveRootOwner, + type InteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { z } from 'zod'; +import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const connection: ConnectionContext = { + hostEpoch: 'extension-composition-test', + connectionId: 'local-owner', + surface: 'desktop', + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), +}; + +test('production composition exposes trusted Extension control and restores it after restart', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-extension-composition-')); + const root = join(base, 'interactive'); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + let owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const trustedToolExtensions = [ + { + extensionId: 'weather', + revision: '1', + tools: [ + { + name: 'Weather', + description: 'Read the deterministic weather fixture', + parameters: z.object({}), + impl: async () => ({ forecast: 'sunny' }), + }, + ], + }, + ]; + let composition: Awaited> | undefined; + try { + composition = await createExecutionRuntimeHostComposition(compositionContext(owner), { + trustedToolExtensions, + }); + await composition.recover(); + const enabled = await composition.handlers['extension.catalog.mutate']( + { + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + revision: '1', + }, + connection, + ); + assert.equal(enabled.ok, true); + assert.deepEqual( + composition.extensions.resolveTools('session-1', []).map(({ name }) => name), + ['Weather'], + ); + + await composition.close(); + composition = undefined; + await owner.close(); + owner = await tryAcquireInteractiveRootOwner( + await resolveStorageRoot({ path: root, kind: 'interactive' }), + ); + assert.ok(owner); + if (!owner) return; + + composition = await createExecutionRuntimeHostComposition(compositionContext(owner), { + trustedToolExtensions, + }); + await composition.recover(); + const restored = await composition.handlers['extension.catalog.query']({}, connection); + assert.equal(restored.ok, true); + assert.equal(restored.ok && restored.result.bindings[0]?.status, 'active'); + assert.deepEqual( + composition.extensions.resolveTools('session-1', []).map(({ name }) => name), + ['Weather'], + ); + } finally { + await composition?.close().catch(() => undefined); + if (owner && !owner.closed) await owner.close(); + await rm(base, { recursive: true, force: true }); + } +}); + +function compositionContext(owner: InteractiveRootOwner) { + return { + owner, + hostEpoch: 'extension-composition-test', + acquireResidency: () => ({ release() {} }), + retainUntilProcessExit: () => undefined, + requestDrain: () => undefined, + }; +} diff --git a/packages/runtime-host/src/__tests__/extension-controller.test.ts b/packages/runtime-host/src/__tests__/extension-controller.test.ts new file mode 100644 index 0000000000..4b4c17cb52 --- /dev/null +++ b/packages/runtime-host/src/__tests__/extension-controller.test.ts @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { z } from 'zod'; +import { HostExtensionController } from '../server/extension-controller.js'; +import { StaticTrustedToolExtensionLoader } from '../server/extension-loader.js'; +import { HostExtensionRuntime } from '../server/extension-runtime.js'; +import { HostExtensionStateStore } from '../server/extension-state-store.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const connection: ConnectionContext = { + hostEpoch: 'extension-controller-test', + connectionId: 'local-owner', + surface: 'desktop', + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), +}; + +test('Extension control plane enables, upgrades, restores last-good, disables, and restarts', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-extension-control-')); + const loader = new StaticTrustedToolExtensionLoader([ + revision('1'), + revision('2'), + revision('3', async () => { + throw new Error('weather v3 is unhealthy'); + }), + ]); + const store = new HostExtensionStateStore(root); + let runtime = new HostExtensionRuntime(); + let controller = new HostExtensionController(runtime, loader, store, () => + assert.fail('deterministic Extension failures must not drain the Host'), + ); + + try { + await controller.recover(); + const initial = await controller.handlers['extension.catalog.query']({}, connection); + assert.equal(initial.ok, true); + assert.deepEqual(initial.ok && initial.result.revisions.map(({ revision: item }) => item), [ + '1', + '2', + '3', + ]); + + const enabled = await controller.handlers['extension.catalog.mutate']( + { + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + revision: '1', + }, + connection, + ); + assert.equal(enabled.ok, true); + assert.deepEqual(enabled.ok && enabled.result.binding, { + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + desiredRevision: '1', + lastGoodRevision: '1', + enabled: true, + status: 'active', + error: null, + }); + assert.equal(await invoke(runtime, 'session-1'), '1'); + + const upgraded = await controller.handlers['extension.catalog.mutate']( + { kind: 'update', bindingId: 'weather-binding', revision: '2' }, + connection, + ); + assert.equal(upgraded.ok, true); + assert.equal(upgraded.ok && upgraded.result.binding?.lastGoodRevision, '2'); + assert.equal(await invoke(runtime, 'session-1'), '2'); + assert.deepEqual(runtime.installedRevisions(), [{ extensionId: 'weather', revision: '2' }]); + + const failed = await controller.handlers['extension.catalog.mutate']( + { kind: 'update', bindingId: 'weather-binding', revision: '3' }, + connection, + ); + assert.deepEqual(failed.ok, false); + assert.equal(!failed.ok && failed.error.code, 'operation_conflict'); + assert.match(!failed.ok ? failed.error.message : '', /health_check failed/); + assert.equal(await invoke(runtime, 'session-1'), '2'); + + const afterFailure = await controller.handlers['extension.catalog.query']({}, connection); + assert.equal(afterFailure.ok, true); + assert.deepEqual(afterFailure.ok && afterFailure.result.bindings[0], { + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + desiredRevision: '3', + lastGoodRevision: '2', + enabled: true, + status: 'failed', + error: 'Extension candidate weather@3 health_check failed', + }); + + await runtime.close(); + runtime = new HostExtensionRuntime(); + controller = new HostExtensionController(runtime, loader, store, () => + assert.fail('last-good recovery must not drain the Host'), + ); + await controller.recover(); + assert.equal(await invoke(runtime, 'session-1'), '2'); + const recovered = await controller.handlers['extension.catalog.query']({}, connection); + assert.equal(recovered.ok, true); + assert.equal(recovered.ok && recovered.result.bindings[0]?.desiredRevision, '3'); + assert.equal(recovered.ok && recovered.result.bindings[0]?.lastGoodRevision, '2'); + assert.equal(recovered.ok && recovered.result.bindings[0]?.status, 'failed'); + + const disabled = await controller.handlers['extension.catalog.mutate']( + { kind: 'disable', bindingId: 'weather-binding' }, + connection, + ); + assert.equal(disabled.ok, true); + assert.equal(disabled.ok && disabled.result.binding?.status, 'disabled'); + assert.deepEqual(runtime.resolveTools('session-1', []), []); + + await runtime.close(); + runtime = new HostExtensionRuntime(); + controller = new HostExtensionController(runtime, loader, store, () => + assert.fail('disabled recovery must not drain the Host'), + ); + await controller.recover(); + assert.deepEqual(runtime.resolveTools('session-1', []), []); + assert.deepEqual(runtime.installedRevisions(), []); + + const persisted = JSON.parse(await readFile(store.path, 'utf8')) as { + bindings: Array<{ enabled: boolean; lastGoodRevision: string }>; + }; + assert.equal(persisted.bindings[0]?.enabled, false); + assert.equal(persisted.bindings[0]?.lastGoodRevision, '2'); + + const removed = await controller.handlers['extension.catalog.mutate']( + { kind: 'remove', bindingId: 'weather-binding' }, + connection, + ); + assert.deepEqual(removed, { ok: true, result: { binding: null } }); + assert.deepEqual(runtime.installedRevisions(), []); + assert.deepEqual(JSON.parse(await readFile(store.path, 'utf8')) as { bindings: unknown[] }, { + schemaVersion: 1, + bindings: [], + }); + } finally { + await runtime.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +test('Extension recovery isolates corrupt state from normal Runtime Host startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-extension-corrupt-')); + const store = new HostExtensionStateStore(root); + const runtime = new HostExtensionRuntime(); + const controller = new HostExtensionController( + runtime, + new StaticTrustedToolExtensionLoader([revision('1')]), + store, + () => undefined, + ); + try { + const { writeFile } = await import('node:fs/promises'); + await writeFile(store.path, '{not-json', 'utf8'); + await controller.recover(); + assert.deepEqual(await controller.handlers['extension.catalog.query']({}, connection), { + ok: false, + error: { code: 'persistence_failed', message: 'Extension state is unavailable' }, + }); + assert.deepEqual(runtime.resolveTools('session-1', []), []); + } finally { + await runtime.close(); + await rm(root, { recursive: true, force: true }); + } +}); + +function revision( + value: string, + healthCheck?: () => void | Promise, +): { + extensionId: string; + revision: string; + tools: readonly MakaTool[]; + healthCheck?: () => void | Promise; +} { + return { + extensionId: 'weather', + revision: value, + tools: [ + { + name: 'Weather', + description: `Weather revision ${value}`, + parameters: z.object({}), + impl: async () => ({ revision: value }), + }, + ], + ...(healthCheck ? { healthCheck } : {}), + }; +} + +async function invoke(runtime: HostExtensionRuntime, scopeId: string): Promise { + const tool = runtime.resolveTools(scopeId, []).find(({ name }) => name === 'Weather'); + assert.ok(tool); + const result = (await tool.impl( + {}, + { + sessionId: scopeId, + turnId: 'turn-1', + cwd: '/workspace', + toolCallId: 'tool-call-1', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + askUserQuestion: async () => ({ answers: [] }), + }, + )) as { revision: string }; + return result.revision; +} diff --git a/packages/runtime-host/src/__tests__/extension-protocol.test.ts b/packages/runtime-host/src/__tests__/extension-protocol.test.ts new file mode 100644 index 0000000000..7a4bd54a15 --- /dev/null +++ b/packages/runtime-host/src/__tests__/extension-protocol.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + decodeExtensionCatalogMutateInput, + decodeExtensionCatalogQueryResult, +} from '../protocol/extension.js'; +import { operationAllowsRemoteOwner } from '../protocol/operations.js'; + +test('Extension control protocol strictly decodes catalog and lifecycle mutations', () => { + assert.deepEqual( + decodeExtensionCatalogMutateInput({ + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + revision: '2', + }), + { + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + revision: '2', + }, + ); + assert.deepEqual( + decodeExtensionCatalogQueryResult({ + revisions: [{ extensionId: 'weather', revision: '2', toolNames: ['Weather'] }], + bindings: [ + { + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + desiredRevision: '2', + lastGoodRevision: '2', + enabled: true, + status: 'active', + error: null, + }, + ], + }), + { + revisions: [{ extensionId: 'weather', revision: '2', toolNames: ['Weather'] }], + bindings: [ + { + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + desiredRevision: '2', + lastGoodRevision: '2', + enabled: true, + status: 'active', + error: null, + }, + ], + }, + ); + + assert.throws( + () => + decodeExtensionCatalogMutateInput({ + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + revision: '2', + modulePath: '/tmp/untrusted.mjs', + }), + /Unknown extension enable input field/, + ); + assert.throws( + () => + decodeExtensionCatalogMutateInput({ + kind: 'update', + bindingId: 'weather-binding', + revision: 'bad\nrevision', + }), + /Invalid extension revision/, + ); + assert.equal(operationAllowsRemoteOwner('extension.catalog.query'), false); + assert.equal(operationAllowsRemoteOwner('extension.catalog.mutate'), false); +}); diff --git a/packages/runtime-host/src/protocol/extension.ts b/packages/runtime-host/src/protocol/extension.ts new file mode 100644 index 0000000000..ab6f6beeb5 --- /dev/null +++ b/packages/runtime-host/src/protocol/extension.ts @@ -0,0 +1,244 @@ +import { + requireEncodedByteLimit, + requireEntityId, + requireExactRecord, + requireRecord, + requireUtf8String, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineOperation } from './operation-spec.js'; + +export const EXTENSION_CATALOG_MAX_REVISIONS = 256; +export const EXTENSION_CATALOG_MAX_BINDINGS = 256; +export const EXTENSION_CATALOG_RESULT_MAX_BYTES = 96 * 1024; +export const EXTENSION_REVISION_MAX_BYTES = 128; +export const EXTENSION_ERROR_MAX_BYTES = 4 * 1024; + +const QUERY_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'persistence_failed', + 'internal_failure', +] as const; +const MUTATION_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'not_found', + 'operation_conflict', + 'invalid_request', + 'persistence_failed', + 'commit_outcome_unknown', + 'internal_failure', +] as const; + +export interface TrustedExtensionRevisionProjection { + readonly extensionId: string; + readonly revision: string; + readonly toolNames: readonly string[]; +} + +export type ExtensionBindingStatus = 'disabled' | 'active' | 'waiting' | 'failed'; + +export interface ExtensionBindingProjection { + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly desiredRevision: string; + readonly lastGoodRevision: string | null; + readonly enabled: boolean; + readonly status: ExtensionBindingStatus; + readonly error: string | null; +} + +export interface ExtensionCatalogQueryInput {} + +export interface ExtensionCatalogQueryResult { + readonly revisions: readonly TrustedExtensionRevisionProjection[]; + readonly bindings: readonly ExtensionBindingProjection[]; +} + +export type ExtensionCatalogMutateInput = + | { + readonly kind: 'enable'; + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly revision: string; + } + | { readonly kind: 'disable'; readonly bindingId: string } + | { readonly kind: 'update'; readonly bindingId: string; readonly revision: string } + | { readonly kind: 'remove'; readonly bindingId: string }; + +export interface ExtensionCatalogMutateResult { + readonly binding: ExtensionBindingProjection | null; +} + +export const EXTENSION_OPERATION_SPECS = { + 'extension.catalog.query': defineOperation< + ExtensionCatalogQueryInput, + ExtensionCatalogQueryResult, + (typeof QUERY_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodeExtensionCatalogQueryInput, + decodeOutput: decodeExtensionCatalogQueryResult, + }), + 'extension.catalog.mutate': defineOperation< + ExtensionCatalogMutateInput, + ExtensionCatalogMutateResult, + (typeof MUTATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATION_ERRORS, + decodeInput: decodeExtensionCatalogMutateInput, + decodeOutput: decodeExtensionCatalogMutateResult, + }), +} as const; + +export function decodeExtensionCatalogQueryInput(value: unknown): ExtensionCatalogQueryInput { + requireExactRecord(value, 'extension catalog query input', []); + return {}; +} + +export function decodeExtensionCatalogQueryResult(value: unknown): ExtensionCatalogQueryResult { + const result = requireExactRecord(value, 'extension catalog query result', [ + 'revisions', + 'bindings', + ]); + if ( + !Array.isArray(result.revisions) || + result.revisions.length > EXTENSION_CATALOG_MAX_REVISIONS || + !Array.isArray(result.bindings) || + result.bindings.length > EXTENSION_CATALOG_MAX_BINDINGS + ) { + throw invalidProtocolFrame('Invalid extension catalog result'); + } + const decoded = { + revisions: result.revisions.map(decodeRevisionProjection), + bindings: result.bindings.map(decodeBindingProjection), + }; + requireEncodedByteLimit(decoded, 'extension catalog result', EXTENSION_CATALOG_RESULT_MAX_BYTES); + return decoded; +} + +export function decodeExtensionCatalogMutateInput(value: unknown): ExtensionCatalogMutateInput { + const record = requireRecord(value, 'extension catalog mutation input'); + switch (record.kind) { + case 'enable': { + const input = requireExactRecord(record, 'extension enable input', [ + 'kind', + 'bindingId', + 'scopeId', + 'extensionId', + 'revision', + ]); + return { + kind: 'enable', + bindingId: requireEntityId(input.bindingId, 'extension bindingId'), + scopeId: requireEntityId(input.scopeId, 'extension scopeId'), + extensionId: requireEntityId(input.extensionId, 'extension extensionId'), + revision: decodeRevision(input.revision), + }; + } + case 'disable': + case 'remove': { + const input = requireExactRecord(record, `extension ${record.kind} input`, [ + 'kind', + 'bindingId', + ]); + return { + kind: record.kind, + bindingId: requireEntityId(input.bindingId, 'extension bindingId'), + }; + } + case 'update': { + const input = requireExactRecord(record, 'extension update input', [ + 'kind', + 'bindingId', + 'revision', + ]); + return { + kind: 'update', + bindingId: requireEntityId(input.bindingId, 'extension bindingId'), + revision: decodeRevision(input.revision), + }; + } + default: + throw invalidProtocolFrame('Invalid extension catalog mutation kind'); + } +} + +export function decodeExtensionCatalogMutateResult(value: unknown): ExtensionCatalogMutateResult { + const result = requireExactRecord(value, 'extension catalog mutation result', ['binding']); + return { + binding: result.binding === null ? null : decodeBindingProjection(result.binding), + }; +} + +function decodeRevisionProjection(value: unknown): TrustedExtensionRevisionProjection { + const revision = requireExactRecord(value, 'trusted extension revision', [ + 'extensionId', + 'revision', + 'toolNames', + ]); + if (!Array.isArray(revision.toolNames) || revision.toolNames.length > 128) { + throw invalidProtocolFrame('Invalid trusted extension tool names'); + } + return { + extensionId: requireEntityId(revision.extensionId, 'extension extensionId'), + revision: decodeRevision(revision.revision), + toolNames: revision.toolNames.map((name) => + requireUtf8String(name, 'extension tool name', 128), + ), + }; +} + +function decodeBindingProjection(value: unknown): ExtensionBindingProjection { + const binding = requireExactRecord(value, 'extension binding', [ + 'bindingId', + 'scopeId', + 'extensionId', + 'desiredRevision', + 'lastGoodRevision', + 'enabled', + 'status', + 'error', + ]); + return { + bindingId: requireEntityId(binding.bindingId, 'extension bindingId'), + scopeId: requireEntityId(binding.scopeId, 'extension scopeId'), + extensionId: requireEntityId(binding.extensionId, 'extension extensionId'), + desiredRevision: decodeRevision(binding.desiredRevision), + lastGoodRevision: + binding.lastGoodRevision === null ? null : decodeRevision(binding.lastGoodRevision), + enabled: decodeBoolean(binding.enabled, 'extension enabled'), + status: decodeBindingStatus(binding.status), + error: + binding.error === null + ? null + : requireUtf8String(binding.error, 'extension error', EXTENSION_ERROR_MAX_BYTES), + }; +} + +function decodeRevision(value: unknown): string { + const revision = requireUtf8String(value, 'extension revision', EXTENSION_REVISION_MAX_BYTES); + if (/[\r\n]/u.test(revision)) throw invalidProtocolFrame('Invalid extension revision'); + return revision; +} + +function decodeBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame(`Invalid ${label}`); + return value; +} + +function decodeBindingStatus(value: unknown): ExtensionBindingStatus { + if (value !== 'disabled' && value !== 'active' && value !== 'waiting' && value !== 'failed') { + throw invalidProtocolFrame('Invalid extension binding status'); + } + return value; +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9c5e607986..d2436a1a8f 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -56,6 +56,7 @@ export * from './project-catalog.js'; export * from './project-catalog-change.js'; export * from './execution-inspect.js'; export * from './external-session.js'; +export * from './extension.js'; export * from './message.js'; export * from './operations.js'; export * from './runtime-resource.js'; diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 7cb9923142..03910e13a1 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -8,6 +8,7 @@ import { DEEP_RESEARCH_OPERATION_SPECS } from './deep-research.js'; import { DAILY_REVIEW_OPERATION_SPECS } from './daily-review.js'; import { CONTEXT_OPERATION_SPECS } from './context.js'; import { EXECUTION_INSPECT_OPERATION_SPECS } from './execution-inspect.js'; +import { EXTENSION_OPERATION_SPECS } from './extension.js'; import { EXTERNAL_SESSION_OPERATION_SPECS } from './external-session.js'; import { CLIENT_CAPABILITY_OPERATION_SPECS } from './client-capability.js'; import { invalidProtocolFrame } from './errors.js'; @@ -165,6 +166,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( DEEP_RESEARCH_OPERATION_SPECS, DAILY_REVIEW_OPERATION_SPECS, EXECUTION_INSPECT_OPERATION_SPECS, + EXTENSION_OPERATION_SPECS, EXTERNAL_SESSION_OPERATION_SPECS, RUNTIME_POLICY_OPERATION_SPECS, RUNTIME_RESOURCE_OPERATION_SPECS, diff --git a/packages/runtime-host/src/server/execution-candidate.ts b/packages/runtime-host/src/server/execution-candidate.ts index 0aa666408a..85d673271b 100644 --- a/packages/runtime-host/src/server/execution-candidate.ts +++ b/packages/runtime-host/src/server/execution-candidate.ts @@ -8,6 +8,7 @@ import { createExecutionRuntimeHostCompositionSource, type ExecutionRuntimeHostCompositionDependencies, } from './execution-composition-factory.js'; +import type { StaticTrustedToolExtensionRevision } from './extension-loader.js'; export type ExecutionRuntimeHostCandidateResult = InteractiveRuntimeHostCandidateResult; @@ -16,6 +17,7 @@ export interface ExecutionRuntimeHostCandidateOptions readonly managedWorkspaceGitRuntime?: VerifiedGitRuntimeInput; /** Packaged resource root containing bundled-git.json and the Git toolchain. */ readonly bundledGitResourcesRoot?: string; + readonly trustedToolExtensions?: readonly StaticTrustedToolExtensionRevision[]; } export type ExecutionRuntimeHostCandidateDependencies = ExecutionRuntimeHostCompositionDependencies; diff --git a/packages/runtime-host/src/server/execution-composition-factory.ts b/packages/runtime-host/src/server/execution-composition-factory.ts index 741fed9092..ffba058085 100644 --- a/packages/runtime-host/src/server/execution-composition-factory.ts +++ b/packages/runtime-host/src/server/execution-composition-factory.ts @@ -1,5 +1,6 @@ import type { VerifiedGitRuntimeInput } from '@maka/storage/managed-workspace-owner'; import { resolveBundledGitRuntime } from './bundled-git-runtime.js'; +import type { StaticTrustedToolExtensionRevision } from './extension-loader.js'; import { createExecutionRuntimeHostComposition, type ExecutionRuntimeHostComposition, @@ -13,6 +14,7 @@ import { export interface ExecutionRuntimeHostCompositionSourceOptions { readonly managedWorkspaceGitRuntime?: VerifiedGitRuntimeInput; readonly bundledGitResourcesRoot?: string; + readonly trustedToolExtensions?: readonly StaticTrustedToolExtensionRevision[]; } export interface ExecutionRuntimeHostCompositionDependencies { @@ -34,6 +36,9 @@ export async function createExecutionRuntimeHostCompositionSource( : options.managedWorkspaceGitRuntime; const compositionOptions = { ...(managedWorkspaceGitRuntime ? { managedWorkspaceGitRuntime } : {}), + ...(options.trustedToolExtensions + ? { trustedToolExtensions: options.trustedToolExtensions } + : {}), }; const createComposition = dependencies.createComposition ?? createExecutionRuntimeHostComposition; return defineInteractiveRuntimeHostComposition((context) => diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index fc0abec0cb..e5f29ffc98 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -111,7 +111,13 @@ import { } from './execution-model-authority.js'; import { HostExecutionInspectCoordinator } from './execution-inspect-coordinator.js'; import { HostExternalSessionCoordinator } from './external-session-coordinator.js'; +import { HostExtensionController } from './extension-controller.js'; +import { + StaticTrustedToolExtensionLoader, + type StaticTrustedToolExtensionRevision, +} from './extension-loader.js'; import { HostExtensionRuntime } from './extension-runtime.js'; +import { HostExtensionStateStore } from './extension-state-store.js'; import { HostGoalCoordinator } from './goal-coordinator.js'; import { HostGoalExecutionCoordinator } from './goal-execution-coordinator.js'; import { HostHostedExecutionCoordinator } from './hosted-execution-coordinator.js'; @@ -182,6 +188,7 @@ export interface CreateExecutionRuntimeHostCompositionOptions { readonly managedWorkspaceGitRuntime?: VerifiedGitRuntimeInput; readonly bootstrapRuntimePolicy?: boolean; readonly skillHomeDirectory?: string; + readonly trustedToolExtensions?: readonly StaticTrustedToolExtensionRevision[]; } export interface ExecutionRuntimeHostCompositionDependencies { @@ -206,6 +213,12 @@ export async function createExecutionRuntimeHostComposition( const stores = await openInteractiveExecutionStoresForWrite(context.owner.lease); await stores.sessionStore.ready(); const extensions = new HostExtensionRuntime(); + const extensionController = new HostExtensionController( + extensions, + new StaticTrustedToolExtensionLoader(options.trustedToolExtensions), + new HostExtensionStateStore(context.owner.controlDirectory), + context.requestDrain, + ); let graphControlStore: ReturnType | undefined; let taskLedgerStore: | Awaited> @@ -1265,7 +1278,9 @@ export async function createExecutionRuntimeHostComposition( domainModules = [ createRuntimeHostDomainModule({ id: 'extension', - drain: [() => extensions.beginDrain()], + handlers: [extensionController.handlers], + recovery: { state: () => extensionController.recover() }, + drain: [() => extensionController.beginDrain(), () => extensions.beginDrain()], close: [() => extensions.close()], }), createRuntimeHostDomainModule({ diff --git a/packages/runtime-host/src/server/execution-service.ts b/packages/runtime-host/src/server/execution-service.ts index d8713e1ccf..1fc7b67be9 100644 --- a/packages/runtime-host/src/server/execution-service.ts +++ b/packages/runtime-host/src/server/execution-service.ts @@ -8,11 +8,13 @@ import { RuntimeHostKernel } from './host-kernel.js'; import { openRuntimeHostAccessAuthority } from './access-authority.js'; import { startRuntimeHostServiceListenerSet } from './listener-set.js'; import type { StartRuntimeHostWebSocketListenerOptions } from './websocket-listener.js'; +import type { StaticTrustedToolExtensionRevision } from './extension-loader.js'; export interface ExecutionRuntimeHostServiceOptions { readonly rootPath: string; readonly managedWorkspaceGitRuntime?: VerifiedGitRuntimeInput; readonly bundledGitResourcesRoot?: string; + readonly trustedToolExtensions?: readonly StaticTrustedToolExtensionRevision[]; readonly handshakeTimeoutMs?: number; readonly shutdownGraceMs?: number; readonly websocket?: Omit< diff --git a/packages/runtime-host/src/server/extension-controller.ts b/packages/runtime-host/src/server/extension-controller.ts new file mode 100644 index 0000000000..f22d79813f --- /dev/null +++ b/packages/runtime-host/src/server/extension-controller.ts @@ -0,0 +1,486 @@ +import type { ExtensionBindingInspection } from '@maka/runtime/extension-lifecycle-kernel'; +import { + type ExtensionBindingProjection, + type ExtensionCatalogMutateInput, + type ExtensionCatalogMutateResult, + type ExtensionCatalogQueryResult, + type OperationOutcome, +} from '../protocol/index.js'; +import type { ExtensionOperationHandlerMap } from './operation-dispatcher.js'; +import { + HostExtensionLoaderError, + type HostTrustedToolExtensionLoader, +} from './extension-loader.js'; +import { HostExtensionRuntime } from './extension-runtime.js'; +import { + HostExtensionStateStore, + HostExtensionStateStoreError, + type PersistedExtensionBinding, +} from './extension-state-store.js'; + +type MutationFailureCode = + | 'host_draining' + | 'not_found' + | 'operation_conflict' + | 'persistence_failed' + | 'commit_outcome_unknown'; + +/** Durable control plane that converges persisted desired bindings into the Host runtime. */ +export class HostExtensionController { + readonly handlers: ExtensionOperationHandlerMap = { + 'extension.catalog.query': () => this.#query(), + 'extension.catalog.mutate': (input) => this.#mutate(input), + }; + + readonly #bindings = new Map(); + #mutationTail: Promise = Promise.resolve(); + #recovered = false; + #draining = false; + #persistenceFailure: HostExtensionStateStoreError | undefined; + + constructor( + private readonly runtime: HostExtensionRuntime, + private readonly loader: HostTrustedToolExtensionLoader, + private readonly store: HostExtensionStateStore, + private readonly requestDrain: () => void, + ) {} + + /** Recovery is fail-open for the Host and fail-closed for Extension mutations. */ + async recover(): Promise { + if (this.#recovered) return; + try { + for (const binding of await this.store.read()) this.#bindings.set(binding.bindingId, binding); + await this.#recoverEnabledBindings(); + await this.#refreshRuntimeState(); + await this.#garbageCollectRevisions(); + await this.#persist(); + } catch (error) { + this.#persistenceFailure = asPersistenceFailure(error); + } finally { + this.#recovered = true; + } + } + + beginDrain(): void { + this.#draining = true; + } + + async #query(): Promise> { + if (this.#persistenceFailure) { + return queryFailure('persistence_failed', 'Extension state is unavailable'); + } + const result: ExtensionCatalogQueryResult = { + revisions: this.loader.list(), + bindings: this.#bindingProjections(), + }; + return { ok: true, result }; + } + + #mutate( + input: ExtensionCatalogMutateInput, + ): Promise> { + if (this.#draining) { + return Promise.resolve(mutationFailure('host_draining', 'Runtime Host is draining')); + } + return this.#serializeMutation(async () => { + if (this.#persistenceFailure) { + return mutationFailure('persistence_failed', 'Extension state is unavailable'); + } + switch (input.kind) { + case 'enable': + return this.#enable(input); + case 'disable': + return this.#disable(input.bindingId); + case 'update': + return this.#update(input.bindingId, input.revision); + case 'remove': + return this.#remove(input.bindingId); + } + }); + } + + async #enable( + input: Extract, + ): Promise> { + const available = await this.#requireAvailable(input.extensionId, input.revision); + if (available) return available; + const current = this.#bindings.get(input.bindingId); + if ( + current && + (current.scopeId !== input.scopeId || current.extensionId !== input.extensionId) + ) { + return mutationFailure( + 'operation_conflict', + `Binding ${input.bindingId} cannot change scope or Extension identity`, + ); + } + const scopeOwner = [...this.#bindings.values()].find( + (binding) => + binding.bindingId !== input.bindingId && + binding.scopeId === input.scopeId && + binding.extensionId === input.extensionId, + ); + if (scopeOwner) { + return mutationFailure( + 'operation_conflict', + `Scope ${input.scopeId} already binds ${input.extensionId} as ${scopeOwner.bindingId}`, + ); + } + this.#bindings.set( + input.bindingId, + bindingState({ + bindingId: input.bindingId, + scopeId: input.scopeId, + extensionId: input.extensionId, + desiredRevision: input.revision, + lastGoodRevision: current?.lastGoodRevision ?? null, + enabled: true, + error: null, + }), + ); + const committed = await this.#commitDesiredState(); + if (committed) return committed; + return this.#convergeMutation(input.bindingId); + } + + async #update( + bindingId: string, + revision: string, + ): Promise> { + const binding = this.#bindings.get(bindingId); + if (!binding) return mutationFailure('not_found', `Extension binding not found: ${bindingId}`); + const available = await this.#requireAvailable(binding.extensionId, revision); + if (available) return available; + this.#bindings.set( + bindingId, + bindingState({ ...binding, desiredRevision: revision, enabled: true, error: null }), + ); + const committed = await this.#commitDesiredState(); + if (committed) return committed; + return this.#convergeMutation(bindingId); + } + + async #disable(bindingId: string): Promise> { + const binding = this.#bindings.get(bindingId); + if (!binding) return mutationFailure('not_found', `Extension binding not found: ${bindingId}`); + this.#bindings.set(bindingId, bindingState({ ...binding, enabled: false, error: null })); + const committed = await this.#commitDesiredState(); + if (committed) return committed; + try { + if (this.#tryInspect(bindingId)) await this.runtime.stop(bindingId); + await this.#refreshRuntimeState(); + const persisted = await this.#commitDesiredState(); + return persisted ?? mutationSuccess(this.#projection(bindingId)); + } catch (error) { + return this.#recordRuntimeFailure(bindingId, error); + } + } + + async #remove(bindingId: string): Promise> { + const binding = this.#bindings.get(bindingId); + if (!binding) return mutationFailure('not_found', `Extension binding not found: ${bindingId}`); + try { + if (this.#tryInspect(bindingId)) await this.runtime.removeBinding(bindingId); + this.#bindings.delete(bindingId); + await this.#garbageCollectRevisions(); + const persisted = await this.#commitDesiredState(); + return persisted ?? mutationSuccess(null); + } catch (error) { + return this.#recordRuntimeFailure(bindingId, error); + } + } + + async #convergeMutation( + bindingId: string, + ): Promise> { + try { + await this.#convergeBinding(bindingId); + await this.#refreshRuntimeState(); + await this.#garbageCollectRevisions(); + const persisted = await this.#commitDesiredState(); + return persisted ?? mutationSuccess(this.#projection(bindingId)); + } catch (error) { + return this.#recordRuntimeFailure(bindingId, error); + } + } + + async #recordRuntimeFailure( + bindingId: string, + error: unknown, + ): Promise> { + const binding = this.#bindings.get(bindingId); + if (binding) { + this.#bindings.set( + bindingId, + bindingState({ ...binding, error: boundedErrorMessage(error) }), + ); + } + const persisted = await this.#commitDesiredState(); + return persisted ?? mutationFailure('operation_conflict', boundedErrorMessage(error)); + } + + async #recoverEnabledBindings(): Promise { + const enabled = [...this.#bindings.values()] + .filter((binding) => binding.enabled) + .sort(compareBinding); + for (const binding of enabled) { + for (const revision of uniqueRevisions(binding)) { + try { + await this.#ensureInstalled(binding.extensionId, revision); + } catch (error) { + this.#bindings.set( + binding.bindingId, + bindingState({ ...binding, error: boundedErrorMessage(error) }), + ); + } + } + } + for (const original of enabled) { + const binding = this.#bindings.get(original.bindingId)!; + let activeRevision: string | null = null; + if (binding.lastGoodRevision) { + try { + await this.#ensureInstalled(binding.extensionId, binding.lastGoodRevision); + await this.runtime.activate({ + bindingId: binding.bindingId, + scopeId: binding.scopeId, + extensionId: binding.extensionId, + revision: binding.lastGoodRevision, + }); + activeRevision = binding.lastGoodRevision; + } catch (error) { + this.#bindings.set( + binding.bindingId, + bindingState({ ...binding, error: boundedErrorMessage(error) }), + ); + } + } + if (activeRevision === binding.desiredRevision) continue; + try { + await this.#convergeBinding(binding.bindingId); + } catch (error) { + const latest = this.#bindings.get(binding.bindingId)!; + this.#bindings.set( + binding.bindingId, + bindingState({ ...latest, error: boundedErrorMessage(error) }), + ); + } + } + } + + async #convergeBinding(bindingId: string): Promise { + const binding = this.#bindings.get(bindingId); + if (!binding || !binding.enabled) return; + await this.#ensureInstalled(binding.extensionId, binding.desiredRevision); + const inspection = this.#tryInspect(bindingId); + if (!inspection) { + await this.runtime.activate({ + bindingId: binding.bindingId, + scopeId: binding.scopeId, + extensionId: binding.extensionId, + revision: binding.desiredRevision, + }); + return; + } + if (inspection.desiredRevision !== binding.desiredRevision) { + await this.runtime.update(bindingId, binding.desiredRevision); + return; + } + if (!inspection.enabled) await this.runtime.start(bindingId); + else if (inspection.current?.revision !== binding.desiredRevision) { + await this.runtime.update(bindingId, binding.desiredRevision); + } + } + + async #ensureInstalled(extensionId: string, revision: string): Promise { + if ( + this.runtime + .installedRevisions() + .some((item) => item.extensionId === extensionId && item.revision === revision) + ) { + return; + } + await this.runtime.installTrustedToolRevision(await this.loader.load(extensionId, revision)); + } + + async #garbageCollectRevisions(): Promise { + const retained = new Set( + [...this.#bindings.values()].flatMap((binding) => + uniqueRevisions(binding).map((revision) => revisionKey(binding.extensionId, revision)), + ), + ); + for (const installed of [...this.runtime.installedRevisions()].reverse()) { + if (retained.has(revisionKey(installed.extensionId, installed.revision))) continue; + try { + await this.runtime.uninstall(installed.extensionId, installed.revision); + } catch { + // A stale in-memory revision is inert. Keep control-plane convergence + // successful and let the Host close boundary retry complete cleanup. + } + } + } + + async #refreshRuntimeState(): Promise { + for (const binding of [...this.#bindings.values()]) { + const inspection = this.#tryInspect(binding.bindingId); + if (!inspection) continue; + const currentRevision = inspection.current?.revision; + const error = inspection.diagnostic?.message ?? binding.error; + this.#bindings.set( + binding.bindingId, + bindingState({ + ...binding, + ...(currentRevision ? { lastGoodRevision: currentRevision } : {}), + error: + currentRevision === binding.desiredRevision && !inspection.diagnostic ? null : error, + }), + ); + } + } + + #tryInspect(bindingId: string): ExtensionBindingInspection | undefined { + try { + return this.runtime.inspect(bindingId); + } catch { + return undefined; + } + } + + async #requireAvailable( + extensionId: string, + revision: string, + ): Promise | undefined> { + try { + await this.loader.load(extensionId, revision); + return undefined; + } catch (error) { + if (error instanceof HostExtensionLoaderError && error.code === 'not_found') { + return mutationFailure('not_found', error.message); + } + return mutationFailure('operation_conflict', boundedErrorMessage(error)); + } + } + + async #commitDesiredState(): Promise | undefined> { + try { + await this.#persist(); + return undefined; + } catch (error) { + const failure = asPersistenceFailure(error); + if (failure.code === 'commit_outcome_unknown') this.requestDrain(); + return mutationFailure(failure.code, failure.message); + } + } + + async #persist(): Promise { + await this.store.replace([...this.#bindings.values()].sort(compareBinding)); + } + + #bindingProjections(): readonly ExtensionBindingProjection[] { + return [...this.#bindings.keys()] + .sort(compareString) + .map((bindingId) => this.#projection(bindingId)); + } + + #projection(bindingId: string): ExtensionBindingProjection { + const binding = this.#bindings.get(bindingId); + if (!binding) throw new Error(`Extension binding not found: ${bindingId}`); + const inspection = this.#tryInspect(bindingId); + return { + bindingId: binding.bindingId, + scopeId: binding.scopeId, + extensionId: binding.extensionId, + desiredRevision: binding.desiredRevision, + lastGoodRevision: binding.lastGoodRevision, + enabled: binding.enabled, + status: projectStatus(binding, inspection), + error: binding.error, + }; + } + + #serializeMutation(operation: () => Promise): Promise { + const result = this.#mutationTail.then(operation, operation); + this.#mutationTail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function projectStatus( + binding: PersistedExtensionBinding, + inspection: ExtensionBindingInspection | undefined, +): ExtensionBindingProjection['status'] { + if (!binding.enabled) return 'disabled'; + if (binding.error || inspection?.status === 'failed') return 'failed'; + if (inspection?.current?.revision === binding.desiredRevision) return 'active'; + return 'waiting'; +} + +function bindingState(binding: PersistedExtensionBinding): PersistedExtensionBinding { + return Object.freeze({ ...binding }); +} + +function uniqueRevisions(binding: PersistedExtensionBinding): readonly string[] { + return [...new Set([binding.lastGoodRevision, binding.desiredRevision].filter(isString))]; +} + +function isString(value: string | null): value is string { + return value !== null; +} + +function boundedErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const encoded = Buffer.from(message || 'Extension operation failed', 'utf8'); + return encoded.byteLength <= 4096 + ? encoded.toString('utf8') + : encoded + .subarray(0, 4093) + .toString('utf8') + .replace(/\uFFFD$/u, '') + '...'; +} + +function asPersistenceFailure(error: unknown): HostExtensionStateStoreError { + return error instanceof HostExtensionStateStoreError + ? error + : new HostExtensionStateStoreError('persistence_failed', 'Extension state is unavailable', { + cause: error, + }); +} + +function mutationSuccess( + binding: ExtensionBindingProjection | null, +): OperationOutcome<'extension.catalog.mutate'> { + const result: ExtensionCatalogMutateResult = { binding }; + return { ok: true, result }; +} + +function queryFailure( + code: 'persistence_failed', + message: string, +): OperationOutcome<'extension.catalog.query'> { + return { ok: false, error: { code, message } }; +} + +function mutationFailure( + code: MutationFailureCode, + message: string, +): OperationOutcome<'extension.catalog.mutate'> { + return { ok: false, error: { code, message } }; +} + +function compareBinding( + left: Pick, + right: Pick, +): number { + return compareString(left.bindingId, right.bindingId); +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function revisionKey(extensionId: string, revision: string): string { + return `${extensionId}\u0000${revision}`; +} diff --git a/packages/runtime-host/src/server/extension-loader.ts b/packages/runtime-host/src/server/extension-loader.ts new file mode 100644 index 0000000000..9b4108e6e0 --- /dev/null +++ b/packages/runtime-host/src/server/extension-loader.ts @@ -0,0 +1,165 @@ +import type { TrustedExtensionRevisionProjection } from '../protocol/index.js'; +import type { HostTrustedToolExtensionRevisionInput } from './extension-runtime.js'; + +export type StaticTrustedToolExtensionRevision = HostTrustedToolExtensionRevisionInput; + +export class HostExtensionLoaderError extends Error { + readonly name = 'HostExtensionLoaderError'; + + constructor( + readonly code: 'not_found' | 'invalid_definition' | 'load_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export interface HostTrustedToolExtensionLoader { + list(): readonly TrustedExtensionRevisionProjection[]; + load(extensionId: string, revision: string): Promise; +} + +/** + * Loader for Tool revisions explicitly registered by the trusted Host composition. + * + * It never resolves a path or executes workspace code. Package discovery and an + * isolated untrusted-code loader can implement the same interface later without + * weakening this phase's trust boundary. + */ +export class StaticTrustedToolExtensionLoader implements HostTrustedToolExtensionLoader { + readonly #definitions = new Map(); + readonly #catalog: readonly TrustedExtensionRevisionProjection[]; + + constructor(definitions: readonly StaticTrustedToolExtensionRevision[] = []) { + for (const definition of definitions) { + assertDefinition(definition); + const key = revisionKey(definition.extensionId, definition.revision); + if (this.#definitions.has(key)) { + throw new HostExtensionLoaderError( + 'invalid_definition', + `Trusted Extension revision is registered more than once: ${key}`, + ); + } + this.#definitions.set(key, freezeDefinition(definition)); + } + this.#catalog = Object.freeze( + [...this.#definitions.values()] + .map((definition) => + Object.freeze({ + extensionId: definition.extensionId, + revision: definition.revision, + toolNames: Object.freeze(definition.tools.map(({ name }) => name).sort(compareString)), + }), + ) + .sort(compareRevision), + ); + } + + list(): readonly TrustedExtensionRevisionProjection[] { + return this.#catalog; + } + + async load( + extensionId: string, + revision: string, + ): Promise { + const definition = this.#definitions.get(revisionKey(extensionId, revision)); + if (!definition) { + throw new HostExtensionLoaderError( + 'not_found', + `Trusted Extension revision is not available: ${extensionId}@${revision}`, + ); + } + return definition; + } +} + +function assertDefinition(definition: HostTrustedToolExtensionRevisionInput): void { + if (!definition || typeof definition !== 'object') { + throw new HostExtensionLoaderError('invalid_definition', 'Trusted Extension is required'); + } + if (!/^[A-Za-z0-9_-]{1,128}$/.test(definition.extensionId)) { + throw new HostExtensionLoaderError( + 'invalid_definition', + 'Trusted Extension extensionId is invalid', + ); + } + if ( + typeof definition.revision !== 'string' || + definition.revision.length === 0 || + Buffer.byteLength(definition.revision, 'utf8') > 128 || + /[\r\n]/u.test(definition.revision) + ) { + throw new HostExtensionLoaderError( + 'invalid_definition', + 'Trusted Extension revision is invalid', + ); + } + if (!Array.isArray(definition.tools) || definition.tools.length === 0) { + throw new HostExtensionLoaderError( + 'invalid_definition', + 'Trusted Extension must declare at least one Tool', + ); + } + const names = new Set(); + for (const tool of definition.tools) { + if ( + !tool || + typeof tool !== 'object' || + typeof tool.name !== 'string' || + tool.name.length === 0 || + tool.name.length > 128 || + /[\r\n\0]/u.test(tool.name) || + typeof tool.description !== 'string' || + typeof tool.impl !== 'function' || + tool.parameters === undefined + ) { + throw new HostExtensionLoaderError('invalid_definition', 'Trusted Extension Tool is invalid'); + } + const key = tool.name.toLowerCase(); + if (names.has(key)) { + throw new HostExtensionLoaderError( + 'invalid_definition', + `Trusted Extension repeats Tool name: ${tool.name}`, + ); + } + names.add(key); + } +} + +function freezeDefinition( + definition: HostTrustedToolExtensionRevisionInput, +): HostTrustedToolExtensionRevisionInput { + return Object.freeze({ + extensionId: definition.extensionId, + revision: definition.revision, + tools: Object.freeze(definition.tools.map((tool) => Object.freeze({ ...tool }))), + ...(definition.dependencies + ? { + dependencies: Object.freeze( + definition.dependencies.map((item) => Object.freeze({ ...item })), + ), + } + : {}), + ...(definition.healthCheck ? { healthCheck: definition.healthCheck } : {}), + }); +} + +function revisionKey(extensionId: string, revision: string): string { + return `${extensionId}\u0000${revision}`; +} + +function compareRevision( + left: TrustedExtensionRevisionProjection, + right: TrustedExtensionRevisionProjection, +): number { + return ( + compareString(left.extensionId, right.extensionId) || + compareString(left.revision, right.revision) + ); +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/runtime-host/src/server/extension-state-store.ts b/packages/runtime-host/src/server/extension-state-store.ts new file mode 100644 index 0000000000..52e5da196a --- /dev/null +++ b/packages/runtime-host/src/server/extension-state-store.ts @@ -0,0 +1,201 @@ +import { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +const SCHEMA_VERSION = 1 as const; +const MAX_STATE_BYTES = 1024 * 1024; +const STATE_FILE_NAME = 'extension-bindings-v1.json'; + +export interface PersistedExtensionBinding { + readonly bindingId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly desiredRevision: string; + readonly lastGoodRevision: string | null; + readonly enabled: boolean; + readonly error: string | null; +} + +interface PersistedExtensionState { + readonly schemaVersion: typeof SCHEMA_VERSION; + readonly bindings: readonly PersistedExtensionBinding[]; +} + +export class HostExtensionStateStoreError extends Error { + readonly name = 'HostExtensionStateStoreError'; + + constructor( + readonly code: 'persistence_failed' | 'commit_outcome_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Root-private, single-Host durable desired state for trusted Extensions. */ +export class HostExtensionStateStore { + readonly path: string; + + constructor(controlDirectory: string) { + this.path = join(controlDirectory, STATE_FILE_NAME); + } + + async read(): Promise { + let encoded: Buffer; + try { + encoded = await readFile(this.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Object.freeze([]); + throw persistenceError('Unable to read Extension state', error); + } + if (encoded.byteLength > MAX_STATE_BYTES) { + throw persistenceError('Extension state exceeds its size limit'); + } + try { + return decodeState(JSON.parse(encoded.toString('utf8'))).bindings; + } catch (error) { + if (error instanceof HostExtensionStateStoreError) throw error; + throw persistenceError('Extension state is invalid', error); + } + } + + async replace(bindings: readonly PersistedExtensionBinding[]): Promise { + const document = decodeState({ + schemaVersion: SCHEMA_VERSION, + bindings: [...bindings].sort((left, right) => compareString(left.bindingId, right.bindingId)), + }); + const encoded = `${JSON.stringify(document)}\n`; + if (Buffer.byteLength(encoded, 'utf8') > MAX_STATE_BYTES) { + throw persistenceError('Extension state exceeds its size limit'); + } + const directory = dirname(this.path); + const temporary = `${this.path}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + let renamed = false; + try { + await mkdir(directory, { recursive: true, mode: 0o700 }); + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(encoded, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, this.path); + renamed = true; + const directoryHandle = await open(directory, 'r'); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } catch (error) { + if (renamed) { + throw new HostExtensionStateStoreError( + 'commit_outcome_unknown', + 'Extension state commit outcome is unknown', + { cause: error }, + ); + } + throw persistenceError('Unable to persist Extension state', error); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + } + } +} + +function decodeState(value: unknown): PersistedExtensionState { + const state = exactRecord(value, ['schemaVersion', 'bindings']); + if (state.schemaVersion !== SCHEMA_VERSION || !Array.isArray(state.bindings)) { + throw persistenceError('Extension state schema is invalid'); + } + const ids = new Set(); + const scopeExtensions = new Set(); + const bindings = state.bindings.map((item): PersistedExtensionBinding => { + const binding = exactRecord(item, [ + 'bindingId', + 'scopeId', + 'extensionId', + 'desiredRevision', + 'lastGoodRevision', + 'enabled', + 'error', + ]); + const decoded = Object.freeze({ + bindingId: entityId(binding.bindingId, 'bindingId'), + scopeId: entityId(binding.scopeId, 'scopeId'), + extensionId: entityId(binding.extensionId, 'extensionId'), + desiredRevision: revision(binding.desiredRevision, 'desiredRevision'), + lastGoodRevision: + binding.lastGoodRevision === null + ? null + : revision(binding.lastGoodRevision, 'lastGoodRevision'), + enabled: boolean(binding.enabled, 'enabled'), + error: nullableError(binding.error), + }); + if (ids.has(decoded.bindingId)) throw persistenceError('Extension bindingId is duplicated'); + ids.add(decoded.bindingId); + const scopeExtension = `${decoded.scopeId}\u0000${decoded.extensionId}`; + if (scopeExtensions.has(scopeExtension)) { + throw persistenceError('Extension scope and extensionId are duplicated'); + } + scopeExtensions.add(scopeExtension); + return decoded; + }); + return Object.freeze({ schemaVersion: SCHEMA_VERSION, bindings: Object.freeze(bindings) }); +} + +function exactRecord(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw persistenceError('Extension state record is invalid'); + } + const record = value as Record; + if ( + Object.keys(record).length !== keys.length || + keys.some((key) => !Object.hasOwn(record, key)) || + Object.keys(record).some((key) => !keys.includes(key)) + ) { + throw persistenceError('Extension state fields are invalid'); + } + return record; +} + +function entityId(value: unknown, label: string): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value)) { + throw persistenceError(`Extension ${label} is invalid`); + } + return value; +} + +function revision(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > 128 || + /[\r\n]/u.test(value) + ) { + throw persistenceError(`Extension ${label} is invalid`); + } + return value; +} + +function boolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') throw persistenceError(`Extension ${label} is invalid`); + return value; +} + +function nullableError(value: unknown): string | null { + if (value === null) return null; + if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > 4096) { + throw persistenceError('Extension error is invalid'); + } + return value; +} + +function persistenceError(message: string, cause?: unknown): HostExtensionStateStoreError { + return new HostExtensionStateStoreError('persistence_failed', message, { cause }); +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index 622e6ba5fd..9832cc903f 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -34,6 +34,12 @@ export { type HostExtensionToolResolver, type HostTrustedToolExtensionRevisionInput, } from './extension-runtime.js'; +export { + HostExtensionLoaderError, + StaticTrustedToolExtensionLoader, + type HostTrustedToolExtensionLoader, + type StaticTrustedToolExtensionRevision, +} from './extension-loader.js'; export { RuntimeHostRootAlreadyOwnedError, startExecutionRuntimeHostService, diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 32642edd91..1d920919eb 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -72,6 +72,7 @@ export type GoalOperationKey = Extract; export type ExecutionInspectOperationKey = Extract; export type HostedExecutionOperationKey = Extract; export type ExternalSessionOperationKey = Extract; +export type ExtensionOperationKey = Extract; export type AgentGraphOperationKey = Extract; export type SessionContinuityOperationKey = Extract< OperationKey, @@ -135,6 +136,7 @@ export type ExternalSessionOperationHandlerMap = Pick< OperationHandlerMap, ExternalSessionOperationKey >; +export type ExtensionOperationHandlerMap = Pick; export type AgentGraphOperationHandlerMap = Pick; export type SessionContinuityOperationHandlerMap = Pick< OperationHandlerMap, From ca96ed20dd2a562dfc2481a1e0455bfc650bea80 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:05:24 +0800 Subject: [PATCH 06/48] test(runtime-host): exercise trusted extension end to end --- .../__tests__/extension-e2e.system.test.ts | 638 ++++++++++++++++++ 1 file changed, 638 insertions(+) create mode 100644 packages/runtime-host/src/__tests__/extension-e2e.system.test.ts diff --git a/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts b/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts new file mode 100644 index 0000000000..abb094f054 --- /dev/null +++ b/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts @@ -0,0 +1,638 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { appendFile, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { + resolveRootControlNamespace, + resolveStorageRoot, + tryAcquireInteractiveRootOwner, +} from '@maka/storage/root-authority'; +import { + openInteractiveRuntimePolicyStoresForWrite, + type RuntimePolicyStoresWriter, +} from '@maka/storage/runtime-policy-stores'; +import { z } from 'zod'; +import { + connectRuntimeHost, + RuntimeHostOperationError, + type RuntimeHostConnection, +} from '../client/index.js'; +import { RUNTIME_HOST_PROTOCOL_VERSION } from '../protocol/index.js'; +import type { RuntimeHostKernel } from '../server/host-kernel.js'; +import type { StaticTrustedToolExtensionRevision } from '../server/extension-loader.js'; +import { startExecutionRuntimeHostService } from '../server/execution-service.js'; +import { waitForTerminalTurn } from './fixtures/execution-host-suite.js'; + +const MODEL_ID = 'extension-e2e-model'; +const CONNECTION_SLUG = 'extension-e2e-provider'; +const API_KEY = 'extension-e2e-key'; +const PROTOCOL = { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, +} as const; + +test('trusted Tool Extension works through UDS, provider execution, rollback, and Host restarts', { + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-extension-e2e-')); + const root = join(base, 'interactive'); + const invocationLog = join(base, 'extension-invocations.jsonl'); + const provider = await startProvider(); + const revisions = [ + extensionRevision('1', 21, invocationLog), + extensionRevision('2', 27, invocationLog), + extensionRevision('3', 99, invocationLog, async () => { + throw new Error('weather revision 3 failed its real health check'); + }), + ] satisfies readonly StaticTrustedToolExtensionRevision[]; + let host: RuntimeHostKernel | undefined; + let client: RuntimeHostConnection | undefined; + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const statePath = join( + resolveRootControlNamespace(), + capability.rootId, + 'extension-bindings-v1.json', + ); + + try { + await seedProvider(root, provider.baseUrl); + ({ host, client } = await startHost(root, revisions)); + + assert.deepEqual(await client.request('extension.catalog.query', {}), { + revisions: [ + { extensionId: 'weather', revision: '1', toolNames: ['Weather'] }, + { extensionId: 'weather', revision: '2', toolNames: ['Weather'] }, + { extensionId: 'weather', revision: '3', toolNames: ['Weather'] }, + ], + bindings: [], + }); + await createSession(client, 'extension-session-a', root); + await createSession(client, 'extension-session-b', root); + + const enabled = await client.request('extension.catalog.mutate', { + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'extension-session-a', + extensionId: 'weather', + revision: '1', + }); + assert.deepEqual(enabled.binding, { + bindingId: 'weather-binding', + scopeId: 'extension-session-a', + extensionId: 'weather', + desiredRevision: '1', + lastGoodRevision: '1', + enabled: true, + status: 'active', + error: null, + }); + assert.equal((await stat(statePath)).mode & 0o777, 0o600); + + const first = await runTurn(client, provider, 'extension-session-a', 'call weather v1'); + assert.deepEqual(first.tools.includes('Weather'), true); + assert.match(first.toolResult ?? '', /"revision":"1"/u); + assert.match(first.toolResult ?? '', /"temperature":21/u); + assert.deepEqual(await invocationRevisions(invocationLog), ['1']); + + const isolated = await runTurn( + client, + provider, + 'extension-session-b', + 'scope isolation must hide weather', + ); + assert.equal(isolated.tools.includes('Weather'), false); + assert.equal(isolated.toolResult, undefined); + assert.deepEqual(await invocationRevisions(invocationLog), ['1']); + + await assert.rejects( + client.request('extension.catalog.mutate', { + kind: 'enable', + bindingId: 'duplicate-weather-binding', + scopeId: 'extension-session-a', + extensionId: 'weather', + revision: '1', + }), + operationError('operation_conflict'), + ); + + const upgraded = await client.request('extension.catalog.mutate', { + kind: 'update', + bindingId: 'weather-binding', + revision: '2', + }); + assert.equal(upgraded.binding?.lastGoodRevision, '2'); + const second = await runTurn(client, provider, 'extension-session-a', 'call weather v2'); + assert.match(second.toolResult ?? '', /"revision":"2"/u); + assert.match(second.toolResult ?? '', /"temperature":27/u); + assert.deepEqual(await invocationRevisions(invocationLog), ['1', '2']); + + await assert.rejects( + client.request('extension.catalog.mutate', { + kind: 'update', + bindingId: 'weather-binding', + revision: '3', + }), + operationError('operation_conflict', /health_check/u), + ); + const failed = await client.request('extension.catalog.query', {}); + assert.deepEqual(failed.bindings[0], { + bindingId: 'weather-binding', + scopeId: 'extension-session-a', + extensionId: 'weather', + desiredRevision: '3', + lastGoodRevision: '2', + enabled: true, + status: 'failed', + error: 'Extension candidate weather@3 health_check failed', + }); + const afterFailure = await runTurn( + client, + provider, + 'extension-session-a', + 'failed upgrade must still call last-good', + ); + assert.match(afterFailure.toolResult ?? '', /"revision":"2"/u); + assert.deepEqual(await invocationRevisions(invocationLog), ['1', '2', '2']); + + await client.close(); + client = undefined; + await host.close(); + host = undefined; + ({ host, client } = await startHost(root, revisions)); + + const recovered = await client.request('extension.catalog.query', {}); + assert.equal(recovered.bindings[0]?.desiredRevision, '3'); + assert.equal(recovered.bindings[0]?.lastGoodRevision, '2'); + assert.equal(recovered.bindings[0]?.status, 'failed'); + const afterRestart = await runTurn( + client, + provider, + 'extension-session-a', + 'restart must restore last-good', + ); + assert.match(afterRestart.toolResult ?? '', /"revision":"2"/u); + assert.deepEqual(await invocationRevisions(invocationLog), ['1', '2', '2', '2']); + + const disabled = await client.request('extension.catalog.mutate', { + kind: 'disable', + bindingId: 'weather-binding', + }); + assert.equal(disabled.binding?.status, 'disabled'); + const afterDisable = await runTurn( + client, + provider, + 'extension-session-a', + 'disabled extension must disappear', + ); + assert.equal(afterDisable.tools.includes('Weather'), false); + assert.equal(afterDisable.toolResult, undefined); + + await client.close(); + client = undefined; + await host.close(); + host = undefined; + ({ host, client } = await startHost(root, revisions)); + const disabledAfterRestart = await client.request('extension.catalog.query', {}); + assert.equal(disabledAfterRestart.bindings[0]?.status, 'disabled'); + const stillDisabled = await runTurn( + client, + provider, + 'extension-session-a', + 'disabled state must survive restart', + ); + assert.equal(stillDisabled.tools.includes('Weather'), false); + + await client.request('extension.catalog.mutate', { + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'extension-session-a', + extensionId: 'weather', + revision: '1', + }); + const reenabled = await runTurn( + client, + provider, + 'extension-session-a', + 're-enabled extension must return', + ); + assert.match(reenabled.toolResult ?? '', /"revision":"1"/u); + assert.deepEqual(await invocationRevisions(invocationLog), ['1', '2', '2', '2', '1']); + + assert.deepEqual( + await client.request('extension.catalog.mutate', { + kind: 'remove', + bindingId: 'weather-binding', + }), + { binding: null }, + ); + const afterRemove = await runTurn( + client, + provider, + 'extension-session-a', + 'removed extension must disappear', + ); + assert.equal(afterRemove.tools.includes('Weather'), false); + assert.deepEqual(JSON.parse(await readFile(statePath, 'utf8')), { + schemaVersion: 1, + bindings: [], + }); + + await client.close(); + client = undefined; + await host.close(); + host = undefined; + ({ host, client } = await startHost(root, revisions)); + assert.deepEqual((await client.request('extension.catalog.query', {})).bindings, []); + const removedAfterRestart = await runTurn( + client, + provider, + 'extension-session-a', + 'removed state must survive restart', + ); + assert.equal(removedAfterRestart.tools.includes('Weather'), false); + } finally { + await client?.close().catch(() => undefined); + await host?.close().catch(() => undefined); + await provider.close(); + await rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }); + await rm(base, { recursive: true, force: true }); + } +}); + +function extensionRevision( + revision: string, + temperature: number, + invocationLog: string, + healthCheck?: () => void | Promise, +): StaticTrustedToolExtensionRevision { + const tool: MakaTool = { + name: 'Weather', + description: `Read deterministic weather from trusted revision ${revision}.`, + parameters: z.object({ city: z.string().min(1) }), + impl: async ({ city }: { city: string }, context) => { + await appendFile( + invocationLog, + `${JSON.stringify({ + revision, + city, + sessionId: context.sessionId, + turnId: context.turnId, + toolCallId: context.toolCallId, + })}\n`, + 'utf8', + ); + return { source: 'trusted-extension', revision, city, temperature }; + }, + }; + return { + extensionId: 'weather', + revision, + tools: [tool], + ...(healthCheck ? { healthCheck } : {}), + }; +} + +async function seedProvider(root: string, baseUrl: string): Promise { + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + try { + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: CONNECTION_SLUG, + name: 'Extension E2E provider', + providerType: 'moonshot', + baseUrl, + enabled: true, + enabledModelIds: [MODEL_ID], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) return; + const credential = await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: API_KEY, + }); + assert.equal(credential.kind, 'committed'); + await publishConnectionModel(policy, connection.connectionId); + } finally { + await owner.close(); + } +} + +async function publishConnectionModel( + policy: RuntimePolicyStoresWriter, + connectionId: string, +): Promise { + const prepared = await policy.operations.beginModelFetch(connectionId); + assert.equal(prepared.kind, 'ready'); + if (prepared.kind !== 'ready') return; + const committed = await policy.operations.completeModelFetch(prepared.ticket, { + models: [ + { + id: MODEL_ID, + capabilities: { chat: true, functionCalling: true }, + contextWindow: 8_192, + maxOutputTokens: 256, + }, + ], + source: 'fetched', + fetchedAt: Date.now(), + }); + assert.equal(committed.kind, 'committed'); +} + +async function startHost( + root: string, + trustedToolExtensions: readonly StaticTrustedToolExtensionRevision[], +): Promise<{ host: RuntimeHostKernel; client: RuntimeHostConnection }> { + const host = await startExecutionRuntimeHostService({ + rootPath: root, + trustedToolExtensions, + }); + const connected = await connectRuntimeHost({ + rootPath: root, + surface: 'desktop', + protocol: PROTOCOL, + }); + assert.equal(connected.kind, 'connected'); + if (connected.kind !== 'connected') { + await host.close(); + throw new Error('Runtime Host Client did not connect'); + } + return { host, client: connected.connection }; +} + +async function createSession( + client: RuntimeHostConnection, + sessionId: string, + root: string, +): Promise { + const created = await client.request('session.create', { + sessionId, + workspace: { kind: 'host_path', path: root }, + modelTarget: { + kind: 'explicit', + connectionSlug: CONNECTION_SLUG, + model: MODEL_ID, + }, + permissionMode: 'bypass', + }); + assert.equal('kind' in created, false); +} + +async function runTurn( + client: RuntimeHostConnection, + provider: Awaited>, + sessionId: string, + marker: string, +): Promise<{ tools: readonly string[]; toolResult: string | undefined }> { + const before = provider.requests.length; + const turnId = randomUUID(); + const started = await client.startTurn({ + sessionId, + turnId, + content: { text: marker }, + maxSteps: 8, + }); + assert.equal(started.kind, 'started'); + const terminal = await waitForTerminalTurn(client, sessionId, turnId); + assert.equal(terminal.status, 'completed', JSON.stringify(terminal)); + const requests = provider.requests.slice(before).filter(({ body }) => body.stream === true); + assert.ok(requests.length >= 1, `Provider did not receive Turn ${turnId}`); + const first = requests[0]; + const toolResult = requests.map(({ body }) => latestToolResult(body)).find(Boolean); + return { tools: toolNames(first?.body), toolResult }; +} + +async function invocationRevisions(path: string): Promise { + const content = await readFile(path, 'utf8'); + return content + .trim() + .split('\n') + .filter(Boolean) + .map((line) => (JSON.parse(line) as { revision: string }).revision); +} + +function operationError(code: string, message?: RegExp): (error: unknown) => boolean { + return (error: unknown) => + error instanceof RuntimeHostOperationError && + error.code === code && + (message ? message.test(error.message) : true); +} + +interface ProviderRequest { + readonly body: Record; +} + +async function startProvider(): Promise<{ + readonly baseUrl: string; + readonly requests: ProviderRequest[]; + close(): Promise; +}> { + const requests: ProviderRequest[] = []; + let callSequence = 0; + const server = createServer((request, response) => { + void (async () => { + assert.equal(request.method, 'POST'); + assert.equal(request.headers.authorization, `Bearer ${API_KEY}`); + const body = JSON.parse(await readBody(request)) as Record; + requests.push({ body }); + if (body.stream !== true) { + respondJson(response, 'Extension E2E background effect completed.'); + return; + } + const result = latestToolResult(body); + if (result !== undefined) { + respondText(response, `Observed trusted Tool result: ${result}`); + return; + } + if (toolNames(body).includes('Weather')) { + callSequence += 1; + respondToolCall(response, callSequence, 'Weather', { city: 'Hangzhou' }); + return; + } + respondText(response, 'Weather is not available in this Session scope.'); + })().catch((error) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + requests, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} + +function toolNames(body: Record | undefined): string[] { + const tools = Array.isArray(body?.tools) ? body.tools : []; + return tools.flatMap((tool) => { + if (!tool || typeof tool !== 'object') return []; + const fn = (tool as { function?: unknown }).function; + if (!fn || typeof fn !== 'object') return []; + const name = (fn as { name?: unknown }).name; + return typeof name === 'string' ? [name] : []; + }); +} + +function latestToolResult(body: Record): string | undefined { + const messages: unknown[] = Array.isArray(body.messages) ? body.messages : []; + let currentTurnStart = -1; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message && typeof message === 'object' && 'role' in message && message.role === 'user') { + currentTurnStart = index; + break; + } + } + const content = messages + .slice(currentTurnStart + 1) + .filter( + (message): message is Record => + message !== null && + typeof message === 'object' && + 'role' in message && + message.role === 'tool', + ) + .at(-1)?.content; + return typeof content === 'string' + ? content + : content === undefined + ? undefined + : JSON.stringify(content); +} + +function respondToolCall( + response: ServerResponse, + sequence: number, + toolName: string, + args: Record, +): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write( + `data: ${JSON.stringify({ + id: `extension-tool-${sequence}`, + object: 'chat.completion.chunk', + created: sequence, + model: MODEL_ID, + choices: [ + { + index: 0, + delta: { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `extension-tool-call-${sequence}`, + type: 'function', + function: { name: toolName, arguments: JSON.stringify(args) }, + }, + ], + }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + response.write( + `data: ${JSON.stringify({ + id: `extension-tool-${sequence}`, + object: 'chat.completion.chunk', + created: sequence, + model: MODEL_ID, + choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + })}\n\n`, + ); + response.end('data: [DONE]\n\n'); +} + +function respondText(response: ServerResponse, text: string): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write( + `data: ${JSON.stringify({ + id: `extension-text-${randomUUID()}`, + object: 'chat.completion.chunk', + created: 1, + model: MODEL_ID, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: text }, + finish_reason: null, + }, + ], + })}\n\n`, + ); + response.write( + `data: ${JSON.stringify({ + id: `extension-text-${randomUUID()}`, + object: 'chat.completion.chunk', + created: 1, + model: MODEL_ID, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: 12, completion_tokens: 6, total_tokens: 18 }, + })}\n\n`, + ); + response.end('data: [DONE]\n\n'); +} + +function respondJson(response: ServerResponse, text: string): void { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'extension-background-effect', + object: 'chat.completion', + created: 1, + model: MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + }), + ); +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => resolve(body)); + request.on('error', reject); + }); +} From ea89b108efbe725e6a160bd4511232a5d3dadf78 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:56:27 +0800 Subject: [PATCH 07/48] feat(runtime-host): add installable tool packages --- .../__tests__/extension-e2e.system.test.ts | 136 ++++- .../src/__tests__/extension-protocol.test.ts | 18 + .../tool-package-management.system.test.ts | 162 +++++ .../src/__tests__/tool-package.system.test.ts | 408 +++++++++++++ .../runtime-host/src/protocol/extension.ts | 62 +- packages/runtime-host/src/protocol/index.ts | 2 +- .../src/server/execution-composition.ts | 18 +- .../src/server/extension-controller.ts | 107 +++- .../src/server/extension-loader.ts | 146 ++++- .../src/server/extension-runtime.ts | 54 +- packages/runtime-host/src/server/index.ts | 11 + .../server/tool-package-management-tools.ts | 333 +++++++++++ .../src/server/tool-package-store.ts | 549 +++++++++++++++++ .../src/server/tool-package-worker.ts | 564 ++++++++++++++++++ .../src/tool-package-worker-main.ts | 232 +++++++ 15 files changed, 2788 insertions(+), 14 deletions(-) create mode 100644 packages/runtime-host/src/__tests__/tool-package-management.system.test.ts create mode 100644 packages/runtime-host/src/__tests__/tool-package.system.test.ts create mode 100644 packages/runtime-host/src/server/tool-package-management-tools.ts create mode 100644 packages/runtime-host/src/server/tool-package-store.ts create mode 100644 packages/runtime-host/src/server/tool-package-worker.ts create mode 100644 packages/runtime-host/src/tool-package-worker-main.ts diff --git a/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts b/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts index abb094f054..c44526e36b 100644 --- a/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts +++ b/packages/runtime-host/src/__tests__/extension-e2e.system.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; -import { appendFile, mkdtemp, readFile, rm, stat } from 'node:fs/promises'; +import { appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -266,6 +266,140 @@ test('trusted Tool Extension works through UDS, provider execution, rollback, an } }); +test('installed Tool package works through real UDS, provider execution, sandbox, restart, and uninstall', { + timeout: 120_000, +}, async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-package-extension-e2e-')); + const root = join(base, 'interactive'); + const source = join(base, 'weather-package'); + const invocationLog = join(root, 'package-weather-invocations.jsonl'); + const provider = await startProvider(); + let host: RuntimeHostKernel | undefined; + let client: RuntimeHostConnection | undefined; + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + + try { + await createWeatherPackage(source); + await seedProvider(root, provider.baseUrl); + ({ host, client } = await startHost(root, [])); + const installed = await client.request('extension.package.install', { sourcePath: source }); + assert.equal(installed.extensionId, 'package-weather'); + assert.deepEqual(installed.toolNames, ['Weather']); + assert.match(installed.revision, /^sha256-[a-f0-9]{64}$/u); + + await createSession(client, 'package-extension-session', root); + const enabled = await client.request('extension.catalog.mutate', { + kind: 'enable', + bindingId: 'package-weather-binding', + scopeId: 'package-extension-session', + extensionId: installed.extensionId, + revision: installed.revision, + }); + assert.equal(enabled.binding?.status, 'active'); + const first = await runTurn( + client, + provider, + 'package-extension-session', + 'call installed package weather', + ); + assert.equal(first.tools.includes('Weather'), true); + assert.match(first.toolResult ?? '', /"source":"installed-package"/u); + assert.match(first.toolResult ?? '', /"temperature":31/u); + assert.equal((await readFile(invocationLog, 'utf8')).trim().split('\n').length, 1); + + await client.close(); + client = undefined; + await host.close(); + host = undefined; + ({ host, client } = await startHost(root, [])); + const recovered = await client.request('extension.catalog.query', {}); + assert.equal(recovered.bindings[0]?.status, 'active'); + assert.equal(recovered.revisions[0]?.revision, installed.revision); + const afterRestart = await runTurn( + client, + provider, + 'package-extension-session', + 'call recovered package weather', + ); + assert.match(afterRestart.toolResult ?? '', /"source":"installed-package"/u); + assert.equal((await readFile(invocationLog, 'utf8')).trim().split('\n').length, 2); + + await client.request('extension.catalog.mutate', { + kind: 'remove', + bindingId: 'package-weather-binding', + }); + await client.request('extension.package.uninstall', { + extensionId: installed.extensionId, + revision: installed.revision, + }); + assert.deepEqual(await client.request('extension.catalog.query', {}), { + revisions: [], + bindings: [], + }); + const removed = await runTurn( + client, + provider, + 'package-extension-session', + 'uninstalled package must disappear', + ); + assert.equal(removed.tools.includes('Weather'), false); + } finally { + await client?.close().catch(() => undefined); + await host?.close().catch(() => undefined); + await provider.close(); + await rm(join(resolveRootControlNamespace(), capability.rootId), { + recursive: true, + force: true, + }); + await rm(base, { recursive: true, force: true }); + } +}); + +async function createWeatherPackage(source: string): Promise { + await mkdir(join(source, 'dist'), { recursive: true }); + await writeFile( + join(source, 'maka.tool.json'), + `${JSON.stringify( + { + schemaVersion: 1, + id: 'package-weather', + version: '1.0.0', + entry: 'dist/index.mjs', + tools: [ + { + name: 'Weather', + description: 'Read deterministic weather from an installed package.', + handler: 'Weather', + inputSchema: { + type: 'object', + properties: { city: { type: 'string' } }, + required: ['city'], + additionalProperties: false, + }, + category: 'file_write', + recoveryMode: 'never_auto_retry', + }, + ], + permissions: { workspace: 'write', network: false }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + join(source, 'dist', 'index.mjs'), + `import { appendFile } from 'node:fs/promises'; +import { join } from 'node:path'; +export default { + Weather: async ({ city }, context) => { + await appendFile(join(context.cwd, 'package-weather-invocations.jsonl'), JSON.stringify({ city, sessionId: context.sessionId }) + '\\n', 'utf8'); + return { source: 'installed-package', city, temperature: 31 }; + }, +}; +`, + ); +} + function extensionRevision( revision: string, temperature: number, diff --git a/packages/runtime-host/src/__tests__/extension-protocol.test.ts b/packages/runtime-host/src/__tests__/extension-protocol.test.ts index 7a4bd54a15..66e1a69609 100644 --- a/packages/runtime-host/src/__tests__/extension-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/extension-protocol.test.ts @@ -3,6 +3,8 @@ import { test } from 'node:test'; import { decodeExtensionCatalogMutateInput, decodeExtensionCatalogQueryResult, + decodeToolPackageInstallInput, + decodeToolPackageUninstallInput, } from '../protocol/extension.js'; import { operationAllowsRemoteOwner } from '../protocol/operations.js'; @@ -77,6 +79,22 @@ test('Extension control protocol strictly decodes catalog and lifecycle mutation }), /Invalid extension revision/, ); + assert.deepEqual(decodeToolPackageInstallInput({ sourcePath: '/tmp/weather-tool' }), { + sourcePath: '/tmp/weather-tool', + }); + assert.deepEqual( + decodeToolPackageUninstallInput({ + extensionId: 'weather', + revision: `sha256-${'a'.repeat(64)}`, + }), + { extensionId: 'weather', revision: `sha256-${'a'.repeat(64)}` }, + ); + assert.throws( + () => decodeToolPackageInstallInput({ sourcePath: '/tmp/weather-tool', source: 'inline' }), + /Unknown Tool package install input field/u, + ); assert.equal(operationAllowsRemoteOwner('extension.catalog.query'), false); assert.equal(operationAllowsRemoteOwner('extension.catalog.mutate'), false); + assert.equal(operationAllowsRemoteOwner('extension.package.install'), false); + assert.equal(operationAllowsRemoteOwner('extension.package.uninstall'), false); }); diff --git a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts new file mode 100644 index 0000000000..4079e12b8d --- /dev/null +++ b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts @@ -0,0 +1,162 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { HostExtensionController } from '../server/extension-controller.js'; +import { + InstalledToolPackageExtensionLoader, + StaticTrustedToolExtensionLoader, +} from '../server/extension-loader.js'; +import { HostExtensionRuntime } from '../server/extension-runtime.js'; +import { HostExtensionStateStore } from '../server/extension-state-store.js'; +import { HostToolPackageManagementTools } from '../server/tool-package-management-tools.js'; +import { ToolPackageStore } from '../server/tool-package-store.js'; + +test('Agent can inspect, define, test, activate, immediately invoke, update safely, stop, and delete a Tool', { + timeout: 60_000, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-agent-tool-package-')); + const store = new ToolPackageStore(root); + const runtime = new HostExtensionRuntime(); + const controller = new HostExtensionController( + runtime, + new InstalledToolPackageExtensionLoader(new StaticTrustedToolExtensionLoader(), store), + new HostExtensionStateStore(root), + () => assert.fail('Agent Tool failure must not drain the Host'), + ); + const management = new HostToolPackageManagementTools(root, controller, runtime, store); + runtime.registerHostTools(management.tools()); + const context = toolContext(root); + + try { + await controller.recover(); + const inspect = requireTool(runtime, 'inspect_tools'); + assert.deepEqual(await inspect.impl({}, context), { revisions: [], bindings: [] }); + + const define = requireTool(runtime, 'define_tool'); + const v1 = (await define.impl( + definition( + '1.0.0', + `export default { Add: ({ left, right }) => ({ sum: left + right, revision: 'v1' }) };`, + ), + context, + )) as { revision: string }; + assert.match(v1.revision, /^sha256-/u); + + const testTool = requireTool(runtime, 'test_tool'); + assert.deepEqual( + await testTool.impl( + { + extensionId: 'calculator', + revision: v1.revision, + toolName: 'Add', + args: { left: 2, right: 3 }, + }, + context, + ), + { sum: 5, revision: 'v1' }, + ); + + const manage = requireTool(runtime, 'manage_tool'); + const activated = (await manage.impl( + { action: 'activate', extensionId: 'calculator', revision: v1.revision }, + context, + )) as { binding: { status: string } }; + assert.equal(activated.binding.status, 'active'); + assert.ok(runtime.resolveTools('session-agent', []).some(({ name }) => name === 'Add')); + + const invoke = requireTool(runtime, 'invoke_tool'); + assert.deepEqual(await invoke.impl({ toolName: 'Add', args: { left: 7, right: 8 } }, context), { + sum: 15, + revision: 'v1', + }); + + const broken = (await define.impl( + definition('2.0.0', `export default { WrongName: () => ({ revision: 'broken' }) };`), + context, + )) as { revision: string }; + await assert.rejects( + async () => + await manage.impl( + { action: 'update', extensionId: 'calculator', revision: broken.revision }, + context, + ), + /health_check failed/u, + ); + assert.deepEqual(await invoke.impl({ toolName: 'Add', args: { left: 1, right: 4 } }, context), { + sum: 5, + revision: 'v1', + }); + + assert.deepEqual(await manage.impl({ action: 'stop', extensionId: 'calculator' }, context), { + binding: null, + }); + assert.equal( + runtime.resolveTools('session-agent', []).some(({ name }) => name === 'Add'), + false, + ); + await manage.impl( + { action: 'delete', extensionId: 'calculator', revision: broken.revision }, + context, + ); + await manage.impl( + { action: 'delete', extensionId: 'calculator', revision: v1.revision }, + context, + ); + const final = (await inspect.impl({}, context)) as { + revisions: unknown[]; + bindings: unknown[]; + }; + assert.deepEqual(final, { revisions: [], bindings: [] }); + } finally { + await runtime.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +function definition(version: string, source: string): Record { + return { + id: 'calculator', + version, + source, + tools: [ + { + name: 'Add', + description: 'Add two numbers', + handler: 'Add', + inputSchema: { + type: 'object', + properties: { left: { type: 'number' }, right: { type: 'number' } }, + required: ['left', 'right'], + additionalProperties: false, + }, + category: 'read', + recoveryMode: 'replay_safe', + }, + ], + permissions: { workspace: 'none', network: false }, + }; +} + +function requireTool(runtime: HostExtensionRuntime, name: string): MakaTool { + const tool = runtime + .resolveTools('session-agent', []) + .find((candidate) => candidate.name === name); + assert.ok(tool, `missing management Tool ${name}`); + return tool; +} + +function toolContext(cwd: string): MakaToolContext { + return { + sessionId: 'session-agent', + runId: 'run-agent', + turnId: 'turn-agent', + cwd, + toolCallId: 'call-agent', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + askUserQuestion: async () => ({ answers: [] }), + }; +} diff --git a/packages/runtime-host/src/__tests__/tool-package.system.test.ts b/packages/runtime-host/src/__tests__/tool-package.system.test.ts new file mode 100644 index 0000000000..c292255216 --- /dev/null +++ b/packages/runtime-host/src/__tests__/tool-package.system.test.ts @@ -0,0 +1,408 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { MakaTool } from '@maka/runtime/tool-runtime'; +import { HostExtensionController } from '../server/extension-controller.js'; +import { + InstalledToolPackageExtensionLoader, + StaticTrustedToolExtensionLoader, +} from '../server/extension-loader.js'; +import { HostExtensionRuntime } from '../server/extension-runtime.js'; +import { HostExtensionStateStore } from '../server/extension-state-store.js'; +import { ToolPackageStore } from '../server/tool-package-store.js'; +import { ToolPackageActivation } from '../server/tool-package-worker.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const connection: ConnectionContext = { + hostEpoch: 'tool-package-system-test', + connectionId: 'local-owner', + surface: 'desktop', + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), +}; + +test('real Tool package installs, runs in a sandboxed process, updates, drains, and uninstalls', { + timeout: 60_000, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-package-')); + const control = join(root, 'control'); + const workspace = join(root, 'workspace'); + await mkdir(workspace, { recursive: true }); + const packageV1 = await createPackage(root, 'v1', 21); + const packageV2 = await createPackage(root, 'v2', 27); + const packageStore = new ToolPackageStore(control); + const loader = new InstalledToolPackageExtensionLoader( + new StaticTrustedToolExtensionLoader(), + packageStore, + ); + const runtime = new HostExtensionRuntime(); + const controller = new HostExtensionController( + runtime, + loader, + new HostExtensionStateStore(control), + () => assert.fail('deterministic Tool package failures must not drain the Host'), + ); + + try { + await controller.recover(); + const installedV1 = await controller.handlers['extension.package.install']( + { sourcePath: packageV1 }, + connection, + ); + assert.equal(installedV1.ok, true); + assert.match(installedV1.ok ? installedV1.result.revision : '', /^sha256-[a-f0-9]{64}$/u); + const revisionV1 = installedV1.ok ? installedV1.result.revision : ''; + assert.deepEqual(installedV1.ok && installedV1.result.toolNames, ['Weather']); + + const installedV2 = await controller.handlers['extension.package.install']( + { sourcePath: packageV2 }, + connection, + ); + assert.equal(installedV2.ok, true); + const revisionV2 = installedV2.ok ? installedV2.result.revision : ''; + assert.notEqual(revisionV1, revisionV2); + assert.equal((await stat(join(packageStore.root, 'weather', revisionV1))).isDirectory(), true); + + const enabled = await controller.handlers['extension.catalog.mutate']( + { + kind: 'enable', + bindingId: 'weather-binding', + scopeId: 'session-1', + extensionId: 'weather', + revision: revisionV1, + }, + connection, + ); + assert.equal(enabled.ok, true, JSON.stringify(enabled)); + assert.equal(enabled.ok && enabled.result.binding?.status, 'active'); + assert.deepEqual(await invoke(runtime, workspace, 'v1'), { + label: 'v1', + temperature: 21, + location: 'Shanghai', + }); + assert.equal(await readFile(join(workspace, 'weather-v1.txt'), 'utf8'), 'Shanghai\n'); + + let startedSlow: (() => void) | undefined; + const slowStarted = new Promise((resolve) => { + startedSlow = resolve; + }); + const oldTool = runtime.resolveTools('session-1', []).find(({ name }) => name === 'Weather'); + assert.ok(oldTool); + const oldInvocation = oldTool.impl( + { location: 'Ningbo', delayMs: 500 }, + { + ...invocationContext(workspace), + toolCallId: 'slow-old-call', + emitOutput: () => startedSlow?.(), + }, + ); + await slowStarted; + const upgradeTask = controller.handlers['extension.catalog.mutate']( + { kind: 'update', bindingId: 'weather-binding', revision: revisionV2 }, + connection, + ); + await waitForRevision(runtime, revisionV2); + assert.deepEqual(await invoke(runtime, workspace, 'v2-during-drain', 'v2'), { + label: 'v2', + temperature: 27, + location: 'Shanghai', + }); + assert.deepEqual(await oldInvocation, { + label: 'v1', + temperature: 21, + location: 'Ningbo', + }); + const upgraded = await upgradeTask; + assert.equal(upgraded.ok, true); + assert.equal(upgraded.ok && upgraded.result.binding?.lastGoodRevision, revisionV2); + assert.deepEqual(await invoke(runtime, workspace, 'v2'), { + label: 'v2', + temperature: 27, + location: 'Shanghai', + }); + + const retained = await controller.handlers['extension.package.uninstall']( + { extensionId: 'weather', revision: revisionV2 }, + connection, + ); + assert.equal(retained.ok, false); + assert.equal(!retained.ok && retained.error.code, 'operation_conflict'); + + assert.deepEqual( + await controller.handlers['extension.catalog.mutate']( + { kind: 'remove', bindingId: 'weather-binding' }, + connection, + ), + { ok: true, result: { binding: null } }, + ); + assert.deepEqual( + await controller.handlers['extension.package.uninstall']( + { extensionId: 'weather', revision: revisionV1 }, + connection, + ), + { ok: true, result: {} }, + ); + assert.deepEqual( + await controller.handlers['extension.package.uninstall']( + { extensionId: 'weather', revision: revisionV2 }, + connection, + ), + { ok: true, result: {} }, + ); + assert.deepEqual(await packageStore.list(), []); + assert.deepEqual(await controller.handlers['extension.catalog.query']({}, connection), { + ok: true, + result: { revisions: [], bindings: [] }, + }); + } finally { + await runtime.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + +test('Tool package install rejects traversal, unknown fields, and missing entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-package-invalid-')); + const source = join(root, 'source'); + const store = new ToolPackageStore(join(root, 'control')); + try { + await mkdir(source, { recursive: true }); + await writeFile( + join(source, 'maka.tool.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'invalid', + version: '1.0.0', + entry: '../escape.mjs', + tools: [toolManifest()], + permissions: { workspace: 'none', network: false }, + }), + ); + await assert.rejects(store.install(source), /entry is invalid/u); + + await writeFile( + join(source, 'maka.tool.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'invalid', + version: '1.0.0', + entry: 'dist/missing.mjs', + tools: [toolManifest()], + permissions: { workspace: 'none', network: false }, + unexpected: true, + }), + ); + await assert.rejects(store.install(source), /unknown fields/u); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Tool package Store detects post-install content corruption', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-package-corrupt-')); + const source = await createPackage(root, 'sealed', 42); + const store = new ToolPackageStore(join(root, 'control')); + try { + const installed = await store.install(source); + await writeFile(installed.entry, 'export default {};\n', 'utf8'); + await assert.rejects( + store.load(installed.extensionId, installed.revision), + /content hash does not match/u, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Tool worker contains crashes, honors abort, and enforces denied network', { + timeout: 30_000, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-tool-worker-faults-')); + const source = join(root, 'source'); + const store = new ToolPackageStore(join(root, 'control')); + let networkRequests = 0; + const server = createServer((_request, response) => { + networkRequests += 1; + response.end('unexpected'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + try { + await createFaultPackage(source, `http://127.0.0.1:${address.port}/denied`); + const activation = new ToolPackageActivation(await store.install(source)); + try { + await activation.healthCheck(); + const tools = new Map(activation.tools().map((tool) => [tool.name, tool])); + const context = invocationContext(root); + + await assert.rejects( + async () => await tools.get('Crash')?.impl({}, context), + /without a result/u, + ); + assert.deepEqual(await tools.get('Echo')?.impl({ value: 'alive' }, context), { + value: 'alive', + }); + await assert.rejects( + async () => await tools.get('Network')?.impl({}, context), + /fetch|network|operation not permitted|failed/u, + ); + assert.equal(networkRequests, 0); + + const abort = new AbortController(); + const hanging = tools.get('Hang')?.impl({}, { ...context, abortSignal: abort.signal }); + setTimeout(() => abort.abort(new Error('test abort')), 50).unref(); + await assert.rejects(async () => await hanging, /aborted/u); + } finally { + await activation.dispose(); + } + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + await rm(root, { recursive: true, force: true }); + } +}); + +async function createPackage(root: string, label: string, temperature: number): Promise { + const source = join(root, `source-${label}`); + await mkdir(join(source, 'dist'), { recursive: true }); + await writeFile( + join(source, 'maka.tool.json'), + `${JSON.stringify( + { + schemaVersion: 1, + id: 'weather', + version: `1.0.${temperature}`, + entry: 'dist/index.mjs', + tools: [toolManifest()], + permissions: { workspace: 'write', network: false }, + }, + null, + 2, + )}\n`, + ); + await writeFile( + join(source, 'dist', 'index.mjs'), + `import { appendFile } from 'node:fs/promises'; +import { join } from 'node:path'; +export default { + Weather: async (args, context) => { + context.emitOutput('stdout', 'weather:${label}'); + if (args.delayMs) await new Promise((resolve) => setTimeout(resolve, args.delayMs)); + await appendFile(join(context.cwd, 'weather-${label}.txt'), args.location + '\\n', 'utf8'); + return { label: ${JSON.stringify(label)}, temperature: ${temperature}, location: args.location }; + }, +}; +`, + ); + return source; +} + +function toolManifest(): Record { + return { + name: 'Weather', + description: 'Read the test weather', + handler: 'Weather', + inputSchema: { + type: 'object', + properties: { + location: { type: 'string' }, + delayMs: { type: 'number', minimum: 0, maximum: 10_000 }, + }, + required: ['location'], + additionalProperties: false, + }, + displayName: 'Weather', + category: 'file_write', + recoveryMode: 'never_auto_retry', + }; +} + +async function createFaultPackage(source: string, deniedUrl: string): Promise { + await mkdir(join(source, 'dist'), { recursive: true }); + const declaration = (name: string): Record => ({ + name, + description: `Exercise ${name}`, + handler: name, + inputSchema: { type: 'object', additionalProperties: true }, + category: 'shell_unsafe', + recoveryMode: 'never_auto_retry', + }); + await writeFile( + join(source, 'maka.tool.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'faults', + version: '1.0.0', + entry: 'dist/index.mjs', + tools: ['Crash', 'Echo', 'Network', 'Hang'].map(declaration), + permissions: { workspace: 'none', network: false }, + }), + ); + await writeFile( + join(source, 'dist', 'index.mjs'), + `export default { + Crash: () => process.exit(23), + Echo: ({ value }) => ({ value }), + Network: async () => ({ body: await (await fetch(${JSON.stringify(deniedUrl)})).text() }), + Hang: async (_args, context) => await new Promise((_resolve, reject) => context.abortSignal.addEventListener('abort', () => reject(context.abortSignal.reason), { once: true })), +}; +`, + ); +} + +function invocationContext(cwd: string): Parameters[1] { + return { + sessionId: 'fault-session', + turnId: 'fault-turn', + cwd, + toolCallId: 'fault-call', + abortSignal: new AbortController().signal, + emitOutput: () => undefined, + askUserQuestion: async () => ({ answers: [] }), + }; +} + +async function waitForRevision(runtime: HostExtensionRuntime, revision: string): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (runtime.inspect('weather-binding').current?.revision === revision) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(`Tool revision did not become current: ${revision}`); +} + +async function invoke( + runtime: HostExtensionRuntime, + workspace: string, + label: string, + expectedRevisionLabel = label, +): Promise { + const tool = runtime.resolveTools('session-1', []).find(({ name }) => name === 'Weather'); + assert.ok(tool); + const output: string[] = []; + const result = await tool.impl( + { location: 'Shanghai' }, + { + sessionId: 'session-1', + runId: `run-${label}`, + turnId: `turn-${label}`, + cwd: workspace, + toolCallId: `call-${label}`, + abortSignal: new AbortController().signal, + emitOutput: (_stream, chunk) => output.push(chunk), + askUserQuestion: async () => ({ answers: [] }), + }, + ); + assert.deepEqual(output, [`weather:${expectedRevisionLabel}`]); + return result; +} diff --git a/packages/runtime-host/src/protocol/extension.ts b/packages/runtime-host/src/protocol/extension.ts index ab6f6beeb5..23cd1366a8 100644 --- a/packages/runtime-host/src/protocol/extension.ts +++ b/packages/runtime-host/src/protocol/extension.ts @@ -6,7 +6,7 @@ import { requireUtf8String, } from './codec.js'; import { invalidProtocolFrame } from './errors.js'; -import { defineOperation } from './operation-spec.js'; +import { defineHostPathOperation, defineOperation } from './operation-spec.js'; export const EXTENSION_CATALOG_MAX_REVISIONS = 256; export const EXTENSION_CATALOG_MAX_BINDINGS = 256; @@ -75,6 +75,19 @@ export interface ExtensionCatalogMutateResult { readonly binding: ExtensionBindingProjection | null; } +export interface ToolPackageInstallInput { + readonly sourcePath: string; +} + +export type ToolPackageInstallResult = TrustedExtensionRevisionProjection; + +export interface ToolPackageUninstallInput { + readonly extensionId: string; + readonly revision: string; +} + +export interface ToolPackageUninstallResult {} + export const EXTENSION_OPERATION_SPECS = { 'extension.catalog.query': defineOperation< ExtensionCatalogQueryInput, @@ -98,6 +111,28 @@ export const EXTENSION_OPERATION_SPECS = { decodeInput: decodeExtensionCatalogMutateInput, decodeOutput: decodeExtensionCatalogMutateResult, }), + 'extension.package.install': defineHostPathOperation< + ToolPackageInstallInput, + ToolPackageInstallResult, + (typeof MUTATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATION_ERRORS, + decodeInput: decodeToolPackageInstallInput, + decodeOutput: decodeToolPackageInstallResult, + }), + 'extension.package.uninstall': defineOperation< + ToolPackageUninstallInput, + ToolPackageUninstallResult, + (typeof MUTATION_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATION_ERRORS, + decodeInput: decodeToolPackageUninstallInput, + decodeOutput: decodeToolPackageUninstallResult, + }), } as const; export function decodeExtensionCatalogQueryInput(value: unknown): ExtensionCatalogQueryInput { @@ -180,6 +215,31 @@ export function decodeExtensionCatalogMutateResult(value: unknown): ExtensionCat }; } +export function decodeToolPackageInstallInput(value: unknown): ToolPackageInstallInput { + const input = requireExactRecord(value, 'Tool package install input', ['sourcePath']); + return { sourcePath: requireUtf8String(input.sourcePath, 'Tool package sourcePath', 16 * 1024) }; +} + +export function decodeToolPackageInstallResult(value: unknown): ToolPackageInstallResult { + return decodeRevisionProjection(value); +} + +export function decodeToolPackageUninstallInput(value: unknown): ToolPackageUninstallInput { + const input = requireExactRecord(value, 'Tool package uninstall input', [ + 'extensionId', + 'revision', + ]); + return { + extensionId: requireEntityId(input.extensionId, 'extension extensionId'), + revision: decodeRevision(input.revision), + }; +} + +export function decodeToolPackageUninstallResult(value: unknown): ToolPackageUninstallResult { + requireExactRecord(value, 'Tool package uninstall result', []); + return {}; +} + function decodeRevisionProjection(value: unknown): TrustedExtensionRevisionProjection { const revision = requireExactRecord(value, 'trusted extension revision', [ 'extensionId', diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index d2436a1a8f..5fb5d60651 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -73,7 +73,7 @@ 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 = 20 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 21 as const; // Transcript pages amortize storage and network round trips with a 512 KiB raw // payload. Base64 expansion plus the bounded fragment envelope must still fit in // one transport message; narrower domains retain their own encoded limits. diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index e5f29ffc98..03622d7386 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -113,11 +113,14 @@ import { HostExecutionInspectCoordinator } from './execution-inspect-coordinator import { HostExternalSessionCoordinator } from './external-session-coordinator.js'; import { HostExtensionController } from './extension-controller.js'; import { + InstalledToolPackageExtensionLoader, StaticTrustedToolExtensionLoader, type StaticTrustedToolExtensionRevision, } from './extension-loader.js'; import { HostExtensionRuntime } from './extension-runtime.js'; import { HostExtensionStateStore } from './extension-state-store.js'; +import { ToolPackageStore } from './tool-package-store.js'; +import { HostToolPackageManagementTools } from './tool-package-management-tools.js'; import { HostGoalCoordinator } from './goal-coordinator.js'; import { HostGoalExecutionCoordinator } from './goal-execution-coordinator.js'; import { HostHostedExecutionCoordinator } from './hosted-execution-coordinator.js'; @@ -213,12 +216,25 @@ export async function createExecutionRuntimeHostComposition( const stores = await openInteractiveExecutionStoresForWrite(context.owner.lease); await stores.sessionStore.ready(); const extensions = new HostExtensionRuntime(); + const toolPackageStore = new ToolPackageStore(context.owner.controlDirectory); + const extensionLoader = new InstalledToolPackageExtensionLoader( + new StaticTrustedToolExtensionLoader(options.trustedToolExtensions), + toolPackageStore, + ); const extensionController = new HostExtensionController( extensions, - new StaticTrustedToolExtensionLoader(options.trustedToolExtensions), + extensionLoader, new HostExtensionStateStore(context.owner.controlDirectory), context.requestDrain, ); + extensions.registerHostTools( + new HostToolPackageManagementTools( + context.owner.controlDirectory, + extensionController, + extensions, + toolPackageStore, + ).tools(), + ); let graphControlStore: ReturnType | undefined; let taskLedgerStore: | Awaited> diff --git a/packages/runtime-host/src/server/extension-controller.ts b/packages/runtime-host/src/server/extension-controller.ts index f22d79813f..2a6b3a91ce 100644 --- a/packages/runtime-host/src/server/extension-controller.ts +++ b/packages/runtime-host/src/server/extension-controller.ts @@ -5,6 +5,9 @@ import { type ExtensionCatalogMutateResult, type ExtensionCatalogQueryResult, type OperationOutcome, + type ToolPackageInstallInput, + type ToolPackageInstallResult, + type ToolPackageUninstallInput, } from '../protocol/index.js'; import type { ExtensionOperationHandlerMap } from './operation-dispatcher.js'; import { @@ -30,6 +33,8 @@ export class HostExtensionController { readonly handlers: ExtensionOperationHandlerMap = { 'extension.catalog.query': () => this.#query(), 'extension.catalog.mutate': (input) => this.#mutate(input), + 'extension.package.install': (input) => this.#installPackage(input), + 'extension.package.uninstall': (input) => this.#uninstallPackage(input), }; readonly #bindings = new Map(); @@ -70,12 +75,79 @@ export class HostExtensionController { return queryFailure('persistence_failed', 'Extension state is unavailable'); } const result: ExtensionCatalogQueryResult = { - revisions: this.loader.list(), + revisions: await this.loader.list(), bindings: this.#bindingProjections(), }; return { ok: true, result }; } + #installPackage( + input: ToolPackageInstallInput, + ): Promise> { + if (this.#draining) { + return Promise.resolve(packageFailure('host_draining', 'Runtime Host is draining')); + } + return this.#serializeMutation(async () => { + if (this.#persistenceFailure) { + return packageFailure('persistence_failed', 'Extension state is unavailable'); + } + if (!this.loader.installPackage) { + return packageFailure('operation_unavailable', 'Tool package installation is unavailable'); + } + try { + const result: ToolPackageInstallResult = await this.loader.installPackage(input.sourcePath); + return { ok: true, result }; + } catch (error) { + return packageLoaderFailure(error, 'install'); + } + }); + } + + #uninstallPackage( + input: ToolPackageUninstallInput, + ): Promise> { + if (this.#draining) { + return Promise.resolve(packageFailure('host_draining', 'Runtime Host is draining')); + } + return this.#serializeMutation(async () => { + if (this.#persistenceFailure) { + return packageFailure('persistence_failed', 'Extension state is unavailable'); + } + if (!this.loader.uninstallPackage) { + return packageFailure( + 'operation_unavailable', + 'Tool package uninstallation is unavailable', + ); + } + const referenced = [...this.#bindings.values()].find( + (binding) => + binding.extensionId === input.extensionId && + uniqueRevisions(binding).includes(input.revision), + ); + if (referenced) { + return packageFailure( + 'operation_conflict', + `Tool package revision is retained by binding ${referenced.bindingId}`, + ); + } + try { + if ( + this.runtime + .installedRevisions() + .some( + (item) => item.extensionId === input.extensionId && item.revision === input.revision, + ) + ) { + await this.runtime.uninstall(input.extensionId, input.revision); + } + await this.loader.uninstallPackage(input.extensionId, input.revision); + return { ok: true, result: {} }; + } catch (error) { + return packageLoaderFailure(error, 'uninstall'); + } + }); + } + #mutate( input: ExtensionCatalogMutateInput, ): Promise> { @@ -300,7 +372,7 @@ export class HostExtensionController { ) { return; } - await this.runtime.installTrustedToolRevision(await this.loader.load(extensionId, revision)); + await this.runtime.installToolRevision(await this.loader.load(extensionId, revision)); } async #garbageCollectRevisions(): Promise { @@ -470,6 +542,37 @@ function mutationFailure( return { ok: false, error: { code, message } }; } +function packageFailure( + code: + | 'host_draining' + | 'operation_unavailable' + | 'not_found' + | 'operation_conflict' + | 'invalid_request' + | 'persistence_failed', + message: string, +): OperationOutcome<'extension.package.install'> & OperationOutcome<'extension.package.uninstall'> { + return { ok: false, error: { code, message } }; +} + +function packageLoaderFailure( + error: unknown, + operation: 'install' | 'uninstall', +): OperationOutcome<'extension.package.install'> & OperationOutcome<'extension.package.uninstall'> { + if (error instanceof HostExtensionLoaderError) { + const code = + error.code === 'not_found' + ? 'not_found' + : error.code === 'invalid_definition' + ? operation === 'install' + ? 'invalid_request' + : 'operation_conflict' + : 'persistence_failed'; + return packageFailure(code, error.message); + } + return packageFailure('operation_conflict', boundedErrorMessage(error)); +} + function compareBinding( left: Pick, right: Pick, diff --git a/packages/runtime-host/src/server/extension-loader.ts b/packages/runtime-host/src/server/extension-loader.ts index 9b4108e6e0..72c3bf4152 100644 --- a/packages/runtime-host/src/server/extension-loader.ts +++ b/packages/runtime-host/src/server/extension-loader.ts @@ -1,5 +1,15 @@ import type { TrustedExtensionRevisionProjection } from '../protocol/index.js'; -import type { HostTrustedToolExtensionRevisionInput } from './extension-runtime.js'; +import type { + HostPreparedToolExtensionRevisionInput, + HostToolExtensionRevisionInput, + HostTrustedToolExtensionRevisionInput, +} from './extension-runtime.js'; +import { ToolPackageActivation } from './tool-package-worker.js'; +import { + type InstalledToolPackage, + ToolPackageStore, + ToolPackageStoreError, +} from './tool-package-store.js'; export type StaticTrustedToolExtensionRevision = HostTrustedToolExtensionRevisionInput; @@ -16,16 +26,17 @@ export class HostExtensionLoaderError extends Error { } export interface HostTrustedToolExtensionLoader { - list(): readonly TrustedExtensionRevisionProjection[]; - load(extensionId: string, revision: string): Promise; + list(): Promise; + load(extensionId: string, revision: string): Promise; + installPackage?(sourcePath: string): Promise; + uninstallPackage?(extensionId: string, revision: string): Promise; } /** * Loader for Tool revisions explicitly registered by the trusted Host composition. * - * It never resolves a path or executes workspace code. Package discovery and an - * isolated untrusted-code loader can implement the same interface later without - * weakening this phase's trust boundary. + * It never resolves a path or executes workspace code. Installed packages use + * a separate loader and isolated worker without weakening this static boundary. */ export class StaticTrustedToolExtensionLoader implements HostTrustedToolExtensionLoader { readonly #definitions = new Map(); @@ -56,7 +67,7 @@ export class StaticTrustedToolExtensionLoader implements HostTrustedToolExtensio ); } - list(): readonly TrustedExtensionRevisionProjection[] { + async list(): Promise { return this.#catalog; } @@ -75,6 +86,127 @@ export class StaticTrustedToolExtensionLoader implements HostTrustedToolExtensio } } +/** Combines Host-composed static Tools with real packages installed in the root-private Store. */ +export class InstalledToolPackageExtensionLoader implements HostTrustedToolExtensionLoader { + constructor( + private readonly statics: StaticTrustedToolExtensionLoader, + private readonly packages: ToolPackageStore, + ) {} + + async list(): Promise { + const combined = [...(await this.statics.list())]; + for (const installed of await this.packages.list()) combined.push(projectPackage(installed)); + const keys = new Set(); + for (const item of combined) { + const key = revisionKey(item.extensionId, item.revision); + if (keys.has(key)) { + throw new HostExtensionLoaderError( + 'invalid_definition', + `Tool Extension revision exists in both static and package catalogs: ${item.extensionId}@${item.revision}`, + ); + } + keys.add(key); + } + return Object.freeze(combined.sort(compareRevision)); + } + + async load(extensionId: string, revision: string): Promise { + try { + return await this.statics.load(extensionId, revision); + } catch (error) { + if (!(error instanceof HostExtensionLoaderError) || error.code !== 'not_found') throw error; + } + try { + return packageRevisionInput(await this.packages.load(extensionId, revision)); + } catch (error) { + throw translatePackageError(error); + } + } + + async installPackage(sourcePath: string): Promise { + try { + const installed = await this.packages.install(sourcePath); + const staticConflict = (await this.statics.list()).some( + (item) => + item.extensionId === installed.extensionId && item.revision === installed.revision, + ); + if (staticConflict) { + await this.packages + .uninstall(installed.extensionId, installed.revision) + .catch(() => undefined); + throw new HostExtensionLoaderError( + 'invalid_definition', + `Tool package conflicts with a static revision: ${installed.extensionId}@${installed.revision}`, + ); + } + return projectPackage(installed); + } catch (error) { + throw translatePackageError(error); + } + } + + async uninstallPackage(extensionId: string, revision: string): Promise { + const staticRevision = (await this.statics.list()).some( + (item) => item.extensionId === extensionId && item.revision === revision, + ); + if (staticRevision) { + throw new HostExtensionLoaderError( + 'invalid_definition', + `Static Tool Extension revisions cannot be uninstalled: ${extensionId}@${revision}`, + ); + } + try { + await this.packages.uninstall(extensionId, revision); + } catch (error) { + throw translatePackageError(error); + } + } +} + +function packageRevisionInput( + installed: InstalledToolPackage, +): HostPreparedToolExtensionRevisionInput { + return Object.freeze({ + extensionId: installed.extensionId, + revision: installed.revision, + toolNames: Object.freeze(installed.manifest.tools.map(({ name }) => name)), + prepare: async () => { + const activation = new ToolPackageActivation(installed); + return { + tools: activation.tools(), + healthCheck: () => activation.healthCheck(), + dispose: () => activation.dispose(), + }; + }, + }); +} + +function projectPackage(installed: InstalledToolPackage): TrustedExtensionRevisionProjection { + return Object.freeze({ + extensionId: installed.extensionId, + revision: installed.revision, + toolNames: Object.freeze(installed.manifest.tools.map(({ name }) => name).sort(compareString)), + }); +} + +function translatePackageError(error: unknown): HostExtensionLoaderError { + if (error instanceof HostExtensionLoaderError) return error; + if (error instanceof ToolPackageStoreError) { + return new HostExtensionLoaderError( + error.code === 'not_found' + ? 'not_found' + : error.code === 'invalid_package' || error.code === 'already_installed' + ? 'invalid_definition' + : 'load_failed', + error.message, + { cause: error }, + ); + } + return new HostExtensionLoaderError('load_failed', 'Tool package operation failed', { + cause: error, + }); +} + function assertDefinition(definition: HostTrustedToolExtensionRevisionInput): void { if (!definition || typeof definition !== 'object') { throw new HostExtensionLoaderError('invalid_definition', 'Trusted Extension is required'); diff --git a/packages/runtime-host/src/server/extension-runtime.ts b/packages/runtime-host/src/server/extension-runtime.ts index 107fd4ee1e..444eea00fd 100644 --- a/packages/runtime-host/src/server/extension-runtime.ts +++ b/packages/runtime-host/src/server/extension-runtime.ts @@ -2,10 +2,13 @@ import { ExtensionLifecycleKernel, type ExtensionBindingInput, type ExtensionBindingInspection, + type ExtensionActivationContext, type ExtensionCompositionSnapshot, + type ExtensionPreparationContext, type ExtensionRevisionDefinition, } from '@maka/runtime/extension-lifecycle-kernel'; import { + contributeExtensionTool, ExtensionToolContributionRegistry, defineTrustedToolExtensionRevision, type ExtensionToolContributionInspection, @@ -19,6 +22,21 @@ export type HostTrustedToolExtensionRevisionInput = Omit< 'registry' >; +export interface HostPreparedToolExtensionRevisionInput { + readonly extensionId: string; + readonly revision: string; + readonly toolNames: readonly string[]; + readonly prepare: (context: ExtensionPreparationContext) => Promise<{ + readonly tools: readonly MakaTool[]; + readonly healthCheck?: () => void | Promise; + readonly dispose?: () => void | Promise; + }>; +} + +export type HostToolExtensionRevisionInput = + | HostTrustedToolExtensionRevisionInput + | HostPreparedToolExtensionRevisionInput; + export interface HostExtensionToolResolver { resolveTools(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[]; } @@ -34,6 +52,7 @@ export class HostExtensionRuntime implements HostExtensionToolResolver { readonly #lifecycle = new ExtensionLifecycleKernel(); readonly #tools: ExtensionToolContributionRegistry; readonly #scopeIds = new Set(); + #hostTools: readonly MakaTool[] = Object.freeze([]); #draining = false; #closed = false; #closeTask: Promise | undefined; @@ -57,6 +76,32 @@ export class HostExtensionRuntime implements HostExtensionToolResolver { ); } + installToolRevision(input: HostToolExtensionRevisionInput): Promise { + if ('tools' in input) return this.installTrustedToolRevision(input); + this.#assertMutable(); + const definition: ExtensionRevisionDefinition = Object.freeze({ + extensionId: input.extensionId, + revision: input.revision, + contributions: Object.freeze( + input.toolNames.map((_, index) => + Object.freeze({ id: `${input.extensionId}.tool-${index + 1}`, kind: 'tool' }), + ), + ), + prepare: async (context: ExtensionPreparationContext) => { + const prepared = await input.prepare(context); + return { + ...(prepared.healthCheck ? { healthCheck: prepared.healthCheck } : {}), + activate: (activation: ExtensionActivationContext) => { + for (const tool of prepared.tools) + contributeExtensionTool(activation, this.#tools, tool); + }, + ...(prepared.dispose ? { dispose: prepared.dispose } : {}), + }; + }, + }); + return this.#lifecycle.install(definition); + } + activate(input: ExtensionBindingInput): Promise { this.#assertMutable(); // Activation may leave a failed Binding behind for diagnosis/retry. Track @@ -123,7 +168,14 @@ export class HostExtensionRuntime implements HostExtensionToolResolver { resolveTools(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[] { if (this.#closed) throw new Error('Runtime Host Extension authority is closed'); - return this.#tools.compose(scopeId, coreTools); + return this.#tools.compose(scopeId, [...coreTools, ...this.#hostTools]); + } + + registerHostTools(tools: readonly MakaTool[]): void { + this.#assertMutable(); + if (this.#hostTools.length > 0) + throw new Error('Runtime Host Extension Tools are already registered'); + this.#hostTools = Object.freeze(tools.map((tool) => Object.freeze({ ...tool }))); } beginDrain(): void { diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index 9832cc903f..deb9e659a6 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -32,14 +32,25 @@ export { createUnavailableDomainOperationHandlers } from './operation-dispatcher export { HostExtensionRuntime, type HostExtensionToolResolver, + type HostPreparedToolExtensionRevisionInput, + type HostToolExtensionRevisionInput, type HostTrustedToolExtensionRevisionInput, } from './extension-runtime.js'; export { HostExtensionLoaderError, + InstalledToolPackageExtensionLoader, StaticTrustedToolExtensionLoader, type HostTrustedToolExtensionLoader, type StaticTrustedToolExtensionRevision, } from './extension-loader.js'; +export { + ToolPackageStore, + ToolPackageStoreError, + decodeToolPackageManifest, + type InstalledToolPackage, + type ToolPackageManifest, + type ToolPackageManifestTool, +} from './tool-package-store.js'; export { RuntimeHostRootAlreadyOwnedError, startExecutionRuntimeHostService, diff --git a/packages/runtime-host/src/server/tool-package-management-tools.ts b/packages/runtime-host/src/server/tool-package-management-tools.ts new file mode 100644 index 0000000000..6bcd83487a --- /dev/null +++ b/packages/runtime-host/src/server/tool-package-management-tools.ts @@ -0,0 +1,333 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { z } from 'zod'; +import type { + ExtensionCatalogQueryResult, + OperationKey, + OperationOutcome, +} from '../protocol/index.js'; +import type { ConnectionContext } from './operation-dispatcher.js'; +import type { HostExtensionController } from './extension-controller.js'; +import type { HostExtensionRuntime } from './extension-runtime.js'; +import { ToolPackageActivation } from './tool-package-worker.js'; +import { ToolPackageStore } from './tool-package-store.js'; + +const MANAGEMENT_TOOL_NAMES = new Set([ + 'inspect_tools', + 'define_tool', + 'test_tool', + 'manage_tool', + 'invoke_tool', +]); +const CATEGORIES = [ + 'read', + 'web_read', + 'file_write', + 'fs_destructive', + 'shell_safe', + 'shell_unsafe', + 'git_destructive', + 'network_send', + 'subagent', + 'computer_use', + 'client_capability', +] as const; +const RECOVERY_MODES = [ + 'replay_safe', + 'idempotent', + 'reconcile', + 'reattach', + 'outcome_unknown', + 'never_auto_retry', +] as const; + +const jsonSchema = z.record(z.string(), z.unknown()); +const toolDeclaration = z.object({ + name: z.string().min(1).max(128), + description: z.string().min(1).max(4096), + handler: z.string().min(1).max(128), + inputSchema: jsonSchema, + displayName: z.string().min(1).max(128).optional(), + category: z.enum(CATEGORIES).optional(), + recoveryMode: z.enum(RECOVERY_MODES).optional(), +}); +const defineInput = z.object({ + id: z.string().min(1).max(128), + version: z.string().min(1).max(128), + source: z + .string() + .min(1) + .max(256 * 1024), + tools: z.array(toolDeclaration).min(1).max(64), + permissions: z.object({ + workspace: z.enum(['none', 'read', 'write']), + network: z.boolean(), + }), +}); +const revisionInput = z.object({ + extensionId: z.string().min(1).max(128), + revision: z.string().min(1).max(128), +}); +const testInput = revisionInput.extend({ + toolName: z.string().min(1).max(128), + args: z.unknown(), +}); +const manageInput = z.discriminatedUnion('action', [ + revisionInput.extend({ action: z.literal('activate') }), + revisionInput.extend({ action: z.literal('update') }), + z.object({ action: z.literal('stop'), extensionId: z.string().min(1).max(128) }), + revisionInput.extend({ action: z.literal('delete') }), +]); +const invokeInput = z.object({ + toolName: z.string().min(1).max(128), + args: z.unknown(), +}); + +/** Host-owned Agent surface for authoring and operating the same packages humans install. */ +export class HostToolPackageManagementTools { + readonly #draftRoot: string; + readonly #connection: ConnectionContext = { + hostEpoch: 'internal-tool-package-management', + connectionId: 'internal-agent-tool', + surface: 'activation', + principal: 'runtime_host', + acquireResidency: () => ({ release: () => undefined }), + }; + + constructor( + controlDirectory: string, + private readonly controller: HostExtensionController, + private readonly runtime: HostExtensionRuntime, + private readonly store: ToolPackageStore, + ) { + this.#draftRoot = join(controlDirectory, 'tool-package-drafts-v1'); + } + + tools(): readonly MakaTool[] { + return Object.freeze([ + this.#inspectTool(), + this.#defineTool(), + this.#testTool(), + this.#manageTool(), + this.#invokeTool(), + ]); + } + + #inspectTool(): MakaTool { + return Object.freeze({ + name: 'inspect_tools', + description: + 'Inspect installed Tool package revisions and active, failed, waiting, or disabled bindings before defining or changing a Tool.', + parameters: z.object({}), + categoryHint: 'read', + recoveryMode: 'replay_safe', + impl: async (): Promise => + unwrap(await this.controller.handlers['extension.catalog.query']({}, this.#connection)), + }); + } + + #defineTool(): MakaTool { + return Object.freeze({ + name: 'define_tool', + description: + 'Validate, seal, and install a prebuilt JavaScript Tool package draft. This does not activate it; call test_tool and then manage_tool.', + parameters: defineInput, + categoryHint: 'file_write', + recoveryMode: 'idempotent', + permissionArgs: (args: z.infer) => ({ + id: args.id, + version: args.version, + toolNames: args.tools.map(({ name }: { name: string }) => name), + permissions: args.permissions, + }), + impl: async (input: z.infer) => { + const draft = join(this.#draftRoot, randomUUID()); + try { + await mkdir(join(draft, 'dist'), { recursive: true, mode: 0o700 }); + await writeFile( + join(draft, 'maka.tool.json'), + `${JSON.stringify( + { + schemaVersion: 1, + id: input.id, + version: input.version, + entry: 'dist/index.mjs', + tools: input.tools, + permissions: input.permissions, + }, + null, + 2, + )}\n`, + { encoding: 'utf8', mode: 0o600 }, + ); + await writeFile(join(draft, 'dist', 'index.mjs'), input.source, { + encoding: 'utf8', + mode: 0o600, + }); + return unwrap( + await this.controller.handlers['extension.package.install']( + { sourcePath: draft }, + this.#connection, + ), + ); + } finally { + await rm(draft, { recursive: true, force: true }).catch(() => undefined); + } + }, + }); + } + + #testTool(): MakaTool { + return Object.freeze({ + name: 'test_tool', + description: + 'Run one installed Tool revision in its real isolated sandbox without publishing it to the session Tool catalog.', + parameters: testInput, + categoryHint: 'shell_unsafe', + recoveryMode: 'never_auto_retry', + permissionArgs: (args: z.infer) => args, + executionFacts: managementExecutionFacts(), + impl: async (input: z.infer, context: MakaToolContext) => { + const installed = await this.store.load(input.extensionId, input.revision); + const activation = new ToolPackageActivation(installed); + try { + await activation.healthCheck(); + const tool = activation.tools().find(({ name }) => name === input.toolName); + if (!tool) throw new Error(`Tool package does not declare Tool: ${input.toolName}`); + await validateArgs(tool, input.args); + return await tool.impl(input.args, context); + } finally { + await activation.dispose(); + } + }, + }); + } + + #manageTool(): MakaTool { + return Object.freeze({ + name: 'manage_tool', + description: + 'Activate, update, stop, or delete a Tool package for the current session. Activation persists with the session until stopped.', + parameters: manageInput, + categoryHint: 'file_write', + recoveryMode: 'idempotent', + permissionArgs: (args: z.infer) => args, + impl: async (input: z.infer, context: MakaToolContext) => { + const bindingId = bindingIdFor(context.sessionId, input.extensionId); + switch (input.action) { + case 'activate': + return unwrap( + await this.controller.handlers['extension.catalog.mutate']( + { + kind: 'enable', + bindingId, + scopeId: context.sessionId, + extensionId: input.extensionId, + revision: input.revision, + }, + this.#connection, + ), + ); + case 'update': + return unwrap( + await this.controller.handlers['extension.catalog.mutate']( + { kind: 'update', bindingId, revision: input.revision }, + this.#connection, + ), + ); + case 'stop': + return unwrap( + await this.controller.handlers['extension.catalog.mutate']( + { kind: 'remove', bindingId }, + this.#connection, + ), + ); + case 'delete': { + const catalog = unwrap( + await this.controller.handlers['extension.catalog.query']({}, this.#connection), + ); + if (catalog.bindings.some((binding) => binding.bindingId === bindingId)) { + unwrap( + await this.controller.handlers['extension.catalog.mutate']( + { kind: 'remove', bindingId }, + this.#connection, + ), + ); + } + return unwrap( + await this.controller.handlers['extension.package.uninstall']( + { extensionId: input.extensionId, revision: input.revision }, + this.#connection, + ), + ); + } + } + }, + }); + } + + #invokeTool(): MakaTool { + return Object.freeze({ + name: 'invoke_tool', + description: + 'Immediately invoke an active session Tool by name after manage_tool activation, including within the same model turn before native schemas refresh.', + parameters: invokeInput, + categoryHint: 'shell_unsafe', + recoveryMode: 'never_auto_retry', + permissionArgs: (args: z.infer) => args, + executionFacts: managementExecutionFacts(), + impl: async (input: z.infer, context: MakaToolContext) => { + if (MANAGEMENT_TOOL_NAMES.has(input.toolName)) { + throw new Error(`Tool management Tools cannot be invoked recursively: ${input.toolName}`); + } + const tool = this.runtime + .resolveTools(context.sessionId, []) + .find(({ name }) => name === input.toolName); + if (!tool) throw new Error(`Active session Tool was not found: ${input.toolName}`); + await validateArgs(tool, input.args); + return tool.impl(input.args, context); + }, + }); + } +} + +function unwrap( + outcome: OperationOutcome, +): Extract, { ok: true }>['result'] { + if (outcome.ok) return outcome.result; + throw new Error(`${outcome.error.code}: ${outcome.error.message}`); +} + +async function validateArgs(tool: MakaTool, args: unknown): Promise { + const schema = tool.parameters as { + safeParseAsync?: (value: unknown) => Promise<{ success: boolean; error?: unknown }>; + safeParse?: (value: unknown) => { success: boolean; error?: unknown }; + }; + const result = schema.safeParseAsync + ? await schema.safeParseAsync(args) + : schema.safeParse?.(args); + if (result && !result.success) { + throw new Error(`Tool arguments failed validation: ${String(result.error)}`); + } +} + +function bindingIdFor(sessionId: string, extensionId: string): string { + const digest = createHash('sha256') + .update(sessionId) + .update('\0') + .update(extensionId) + .digest('hex'); + return `agent_tool_${digest.slice(0, 32)}`; +} + +function managementExecutionFacts(): NonNullable { + return Object.freeze({ + isolation: 'container', + writesAffectHost: true, + writeBack: 'direct', + network: 'sandbox', + secrets: 'none', + }); +} diff --git a/packages/runtime-host/src/server/tool-package-store.ts b/packages/runtime-host/src/server/tool-package-store.ts new file mode 100644 index 0000000000..58f20376fd --- /dev/null +++ b/packages/runtime-host/src/server/tool-package-store.ts @@ -0,0 +1,549 @@ +import { constants, type Dirent } from 'node:fs'; +import { mkdir, open, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; +import { dirname, isAbsolute, join, posix, resolve } from 'node:path'; +import type { ToolCategory } from '@maka/core/permission'; +import type { ToolRecoveryMode } from '@maka/core/runtime-event'; + +const MANIFEST_FILE = 'maka.tool.json'; +const STORE_DIRECTORY = 'tool-packages-v1'; +const MAX_FILES = 128; +const MAX_FILE_BYTES = 4 * 1024 * 1024; +const MAX_PACKAGE_BYTES = 8 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 256 * 1024; +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; +const REVISION_PATTERN = /^sha256-[a-f0-9]{64}$/u; +const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/u; +const CATEGORIES = new Set([ + 'read', + 'web_read', + 'file_write', + 'fs_destructive', + 'shell_safe', + 'shell_unsafe', + 'git_destructive', + 'network_send', + 'subagent', + 'computer_use', + 'client_capability', +]); +const RECOVERY_MODES = new Set([ + 'replay_safe', + 'idempotent', + 'reconcile', + 'reattach', + 'outcome_unknown', + 'never_auto_retry', +]); + +export type ToolPackageWorkspacePermission = 'none' | 'read' | 'write'; + +export interface ToolPackageManifestTool { + readonly name: string; + readonly description: string; + readonly handler: string; + readonly inputSchema: Readonly>; + readonly displayName?: string; + readonly category?: ToolCategory; + readonly recoveryMode?: ToolRecoveryMode; +} + +export interface ToolPackageManifest { + readonly schemaVersion: 1; + readonly id: string; + readonly version: string; + readonly entry: string; + readonly tools: readonly ToolPackageManifestTool[]; + readonly permissions: { + readonly workspace: ToolPackageWorkspacePermission; + readonly network: boolean; + }; +} + +export interface InstalledToolPackage { + readonly extensionId: string; + readonly revision: string; + readonly root: string; + readonly entry: string; + readonly manifest: ToolPackageManifest; +} + +export class ToolPackageStoreError extends Error { + readonly name = 'ToolPackageStoreError'; + + constructor( + readonly code: 'not_found' | 'invalid_package' | 'already_installed' | 'persistence_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +interface PackageFile { + readonly path: string; + readonly content: Buffer; +} + +/** Root-private, content-addressed storage for prebuilt JavaScript Tool packages. */ +export class ToolPackageStore { + readonly root: string; + + constructor(controlDirectory: string) { + this.root = join(controlDirectory, STORE_DIRECTORY); + } + + async install(sourcePath: string): Promise { + const files = await readSourcePackage(sourcePath); + const manifestFile = files.find(({ path }) => path === MANIFEST_FILE); + if (!manifestFile) throw invalidPackage(`Tool package is missing ${MANIFEST_FILE}`); + if (manifestFile.content.byteLength > MAX_MANIFEST_BYTES) { + throw invalidPackage('Tool package manifest exceeds its size limit'); + } + const manifest = decodeToolPackageManifest(parseJson(manifestFile.content)); + if (!files.some(({ path }) => path === manifest.entry)) { + throw invalidPackage(`Tool package entry does not exist: ${manifest.entry}`); + } + const revision = packageRevision(files); + const extensionRoot = join(this.root, manifest.id); + const target = join(extensionRoot, revision); + try { + const installed = await this.load(manifest.id, revision); + if (sameManifest(installed.manifest, manifest)) return installed; + throw new ToolPackageStoreError( + 'already_installed', + `Tool package revision already exists with conflicting metadata: ${manifest.id}@${revision}`, + ); + } catch (error) { + if (!(error instanceof ToolPackageStoreError) || error.code !== 'not_found') throw error; + } + + const staging = join(this.root, `.staging-${randomUUID()}`); + let committed = false; + try { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await mkdir(staging, { recursive: false, mode: 0o700 }); + for (const file of files) await writeStoredFile(staging, file); + await syncTree(staging, files); + await mkdir(extensionRoot, { recursive: true, mode: 0o700 }); + await rename(staging, target); + committed = true; + await syncDirectory(extensionRoot); + return Object.freeze({ + extensionId: manifest.id, + revision, + root: target, + entry: join(target, ...manifest.entry.split('/')), + manifest, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return this.load(manifest.id, revision); + } + throw new ToolPackageStoreError( + 'persistence_failed', + `Unable to install Tool package ${manifest.id}@${revision}`, + { cause: error }, + ); + } finally { + if (!committed) await rm(staging, { recursive: true, force: true }).catch(() => undefined); + } + } + + async list(): Promise { + let extensions: Dirent[]; + try { + extensions = await readdir(this.root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Object.freeze([]); + throw persistenceFailure('Unable to list installed Tool packages', error); + } + const installed: InstalledToolPackage[] = []; + for (const extension of extensions.sort(compareDirent)) { + if (!extension.isDirectory() || !validId(extension.name)) continue; + const extensionRoot = join(this.root, extension.name); + let revisions: Dirent[]; + try { + revisions = await readdir(extensionRoot, { withFileTypes: true }); + } catch (error) { + throw persistenceFailure(`Unable to list Tool package ${extension.name}`, error); + } + for (const revision of revisions.sort(compareDirent)) { + if (!revision.isDirectory() || !REVISION_PATTERN.test(revision.name)) continue; + installed.push(await this.load(extension.name, revision.name)); + } + } + return Object.freeze(installed); + } + + async load(extensionId: string, revision: string): Promise { + requireId(extensionId); + requireRevision(revision); + const root = join(this.root, extensionId, revision); + try { + if (!(await stat(root)).isDirectory()) + throw invalidPackage('Installed Tool package is not a directory'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new ToolPackageStoreError( + 'not_found', + `Tool package revision is not installed: ${extensionId}@${revision}`, + ); + } + throw persistenceFailure(`Unable to read Tool package ${extensionId}@${revision}`, error); + } + const files = await readSourcePackage(root); + const manifestFile = files.find(({ path }) => path === MANIFEST_FILE); + if (!manifestFile) throw invalidPackage(`Installed Tool package is missing ${MANIFEST_FILE}`); + const encoded = manifestFile.content; + if (encoded.byteLength > MAX_MANIFEST_BYTES) { + throw invalidPackage( + `Installed Tool package manifest is too large: ${extensionId}@${revision}`, + ); + } + const manifest = decodeToolPackageManifest(parseJson(encoded)); + if (manifest.id !== extensionId) { + throw invalidPackage( + `Installed Tool package identity does not match its path: ${extensionId}`, + ); + } + if (packageRevision(files) !== revision) { + throw invalidPackage( + `Installed Tool package content hash does not match: ${extensionId}@${revision}`, + ); + } + const entry = join(root, ...manifest.entry.split('/')); + try { + const entryStat = await stat(entry); + if (!entryStat.isFile()) throw invalidPackage(`Tool package entry is not a file: ${entry}`); + } catch (error) { + if (error instanceof ToolPackageStoreError) throw error; + throw invalidPackage(`Tool package entry is unavailable: ${entry}`, error); + } + return Object.freeze({ extensionId, revision, root, entry, manifest }); + } + + async uninstall(extensionId: string, revision: string): Promise { + requireId(extensionId); + requireRevision(revision); + await this.load(extensionId, revision); + const target = join(this.root, extensionId, revision); + try { + await rm(target, { recursive: true, force: false }); + const extensionRoot = dirname(target); + const remaining = await readdir(extensionRoot); + if (remaining.length === 0) await rm(extensionRoot, { recursive: true, force: false }); + await syncDirectory(this.root).catch(() => undefined); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw persistenceFailure( + `Unable to uninstall Tool package ${extensionId}@${revision}`, + error, + ); + } + } +} + +export function decodeToolPackageManifest(value: unknown): ToolPackageManifest { + const record = exactRecord(value, [ + 'schemaVersion', + 'id', + 'version', + 'entry', + 'tools', + 'permissions', + ]); + if (record.schemaVersion !== 1) throw invalidPackage('Tool package schemaVersion must be 1'); + const id = requireId(record.id); + const version = boundedString(record.version, 'version', 128); + const entry = packagePath(record.entry, 'entry'); + if (!entry.endsWith('.mjs')) throw invalidPackage('Tool package entry must be an .mjs file'); + if (!Array.isArray(record.tools) || record.tools.length === 0 || record.tools.length > 64) { + throw invalidPackage('Tool package must declare between 1 and 64 Tools'); + } + const names = new Set(); + const tools = record.tools.map((value, index): ToolPackageManifestTool => { + const tool = optionalExactRecord(value, [ + 'name', + 'description', + 'handler', + 'inputSchema', + 'displayName', + 'category', + 'recoveryMode', + ]); + const name = boundedString(tool.name, `tools[${index}].name`, 128); + if (!TOOL_NAME_PATTERN.test(name)) throw invalidPackage(`Tool name is invalid: ${name}`); + const key = name.toLowerCase(); + if (names.has(key)) throw invalidPackage(`Tool package repeats Tool name: ${name}`); + names.add(key); + const handler = boundedString(tool.handler, `tools[${index}].handler`, 128); + if (!TOOL_NAME_PATTERN.test(handler)) + throw invalidPackage(`Tool handler is invalid: ${handler}`); + const inputSchema = jsonSchema(tool.inputSchema, `tools[${index}].inputSchema`); + const displayName = optionalBoundedString(tool.displayName, `tools[${index}].displayName`, 128); + const category = optionalEnum(tool.category, CATEGORIES, `tools[${index}].category`); + const recoveryMode = optionalEnum( + tool.recoveryMode, + RECOVERY_MODES, + `tools[${index}].recoveryMode`, + ); + return Object.freeze({ + name, + description: boundedString(tool.description, `tools[${index}].description`, 4096), + handler, + inputSchema, + ...(displayName ? { displayName } : {}), + ...(category ? { category } : {}), + ...(recoveryMode ? { recoveryMode } : {}), + }); + }); + const permissions = exactRecord(record.permissions, ['workspace', 'network']); + const workspace = permissions.workspace; + if (workspace !== 'none' && workspace !== 'read' && workspace !== 'write') { + throw invalidPackage('Tool package workspace permission is invalid'); + } + if (typeof permissions.network !== 'boolean') { + throw invalidPackage('Tool package network permission is invalid'); + } + return Object.freeze({ + schemaVersion: 1, + id, + version, + entry, + tools: Object.freeze(tools), + permissions: Object.freeze({ workspace, network: permissions.network }), + }); +} + +async function readSourcePackage(sourcePath: string): Promise { + if (typeof sourcePath !== 'string' || sourcePath.length === 0 || !isAbsolute(sourcePath)) { + throw invalidPackage('Tool package sourcePath must be absolute'); + } + let root: string; + try { + root = await realpath(resolve(sourcePath)); + if (!(await stat(root)).isDirectory()) + throw invalidPackage('Tool package source is not a directory'); + } catch (error) { + if (error instanceof ToolPackageStoreError) throw error; + throw invalidPackage('Tool package source directory is unavailable', error); + } + const paths: string[] = []; + await collectFiles(root, '', paths); + if (paths.length === 0 || paths.length > MAX_FILES) { + throw invalidPackage(`Tool package must contain between 1 and ${MAX_FILES} files`); + } + const files: PackageFile[] = []; + let total = 0; + for (const path of paths.sort(compareString)) { + const absolute = join(root, ...path.split('/')); + let handle: Awaited> | undefined; + try { + handle = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW); + const metadata = await handle.stat(); + if (!metadata.isFile()) throw invalidPackage(`Tool package contains a non-file: ${path}`); + if (metadata.size > MAX_FILE_BYTES) + throw invalidPackage(`Tool package file is too large: ${path}`); + const content = await handle.readFile(); + total += content.byteLength; + if (total > MAX_PACKAGE_BYTES) throw invalidPackage('Tool package exceeds its size limit'); + files.push(Object.freeze({ path, content })); + } catch (error) { + if (error instanceof ToolPackageStoreError) throw error; + throw invalidPackage(`Unable to read Tool package file: ${path}`, error); + } finally { + await handle?.close().catch(() => undefined); + } + } + return Object.freeze(files); +} + +async function collectFiles(root: string, directory: string, paths: string[]): Promise { + const absolute = directory ? join(root, ...directory.split('/')) : root; + const entries = await readdir(absolute, { withFileTypes: true }); + for (const entry of entries.sort(compareDirent)) { + const path = directory ? `${directory}/${entry.name}` : entry.name; + packagePath(path, 'file path'); + if (entry.isSymbolicLink()) + throw invalidPackage(`Tool package may not contain symlinks: ${path}`); + if (entry.isDirectory()) await collectFiles(root, path, paths); + else if (entry.isFile()) paths.push(path); + else throw invalidPackage(`Tool package contains an unsupported entry: ${path}`); + if (paths.length > MAX_FILES) throw invalidPackage('Tool package contains too many files'); + } +} + +async function writeStoredFile(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 directory = dirname(join(root, ...file.path.split('/'))); + while (directory.startsWith(root)) { + directories.add(directory); + if (directory === root) break; + directory = dirname(directory); + } + } + for (const directory of [...directories].sort((a, b) => b.length - a.length)) { + await syncDirectory(directory); + } +} + +async function syncDirectory(path: string): Promise { + const handle = await open(path, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function packageRevision(files: readonly PackageFile[]): 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 packagePath(value: unknown, label: string): string { + const path = boundedString(value, label, 512); + if ( + path.includes('\\') || + path.includes('\0') || + path.startsWith('/') || + path.endsWith('/') || + posix.normalize(path) !== path || + path.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') + ) { + throw invalidPackage(`Tool package ${label} is invalid`); + } + return path; +} + +function requireId(value: unknown): string { + const id = boundedString(value, 'id', 128); + if (!validId(id)) throw invalidPackage('Tool package id is invalid'); + return id; +} + +function validId(value: string): boolean { + return value.length <= 128 && ID_PATTERN.test(value); +} + +function requireRevision(value: string): void { + if (!REVISION_PATTERN.test(value)) throw invalidPackage('Tool package revision is invalid'); +} + +function boundedString(value: unknown, label: string, maxBytes: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + /[\r\n\0]/u.test(value) + ) { + throw invalidPackage(`Tool package ${label} is invalid`); + } + return value; +} + +function optionalBoundedString( + value: unknown, + label: string, + maxBytes: number, +): string | undefined { + return value === undefined ? undefined : boundedString(value, label, maxBytes); +} + +function optionalEnum( + value: unknown, + allowed: ReadonlySet, + label: string, +): T | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || !allowed.has(value as T)) { + throw invalidPackage(`Tool package ${label} is invalid`); + } + return value as T; +} + +function jsonSchema(value: unknown, label: string): Readonly> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidPackage(`Tool package ${label} must be a JSON Schema object`); + } + try { + const encoded = JSON.stringify(value); + if (Buffer.byteLength(encoded, 'utf8') > 64 * 1024) { + throw invalidPackage(`Tool package ${label} exceeds its size limit`); + } + return Object.freeze(structuredClone(value as Record)); + } catch (error) { + if (error instanceof ToolPackageStoreError) throw error; + throw invalidPackage(`Tool package ${label} is not JSON-serializable`, error); + } +} + +function exactRecord(value: unknown, keys: readonly string[]): Record { + const record = optionalExactRecord(value, keys); + if (keys.some((key) => !Object.hasOwn(record, key))) { + throw invalidPackage('Tool package record fields are invalid'); + } + return record; +} + +function optionalExactRecord(value: unknown, keys: readonly string[]): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw invalidPackage('Tool package record is invalid'); + } + const record = value as Record; + if (Object.keys(record).some((key) => !keys.includes(key))) { + throw invalidPackage('Tool package record contains unknown fields'); + } + return record; +} + +function parseJson(encoded: Buffer): unknown { + try { + return JSON.parse(encoded.toString('utf8')); + } catch (error) { + throw invalidPackage('Tool package manifest is not valid JSON', error); + } +} + +function sameManifest(left: ToolPackageManifest, right: ToolPackageManifest): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function invalidPackage(message: string, cause?: unknown): ToolPackageStoreError { + return new ToolPackageStoreError('invalid_package', message, { cause }); +} + +function persistenceFailure(message: string, cause?: unknown): ToolPackageStoreError { + const detail = cause instanceof Error && cause.message ? `: ${cause.message}` : ''; + return new ToolPackageStoreError('persistence_failed', `${message}${detail}`, { cause }); +} + +function compareDirent(left: Dirent, right: Dirent): number { + return compareString(left.name, right.name); +} + +function compareString(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} diff --git a/packages/runtime-host/src/server/tool-package-worker.ts b/packages/runtime-host/src/server/tool-package-worker.ts new file mode 100644 index 0000000000..e05b10af25 --- /dev/null +++ b/packages/runtime-host/src/server/tool-package-worker.ts @@ -0,0 +1,564 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname } from 'node:path'; +import type { Readable, Writable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import type { PermissionProfileManaged } from '@maka/core/permission-profile'; +import { createDefaultSandboxManager } from '@maka/runtime/sandbox'; +import type { MakaTool, MakaToolContext } from '@maka/runtime/tool-runtime'; +import { z } from 'zod'; +import type { InstalledToolPackage, ToolPackageManifest } from './tool-package-store.js'; + +const WORKER_MAIN = fileURLToPath(new URL('../tool-package-worker-main.js', import.meta.url)); +const HEALTH_TIMEOUT_MS = 10_000; +const INVOCATION_TIMEOUT_MS = 120_000; +const DRAIN_TIMEOUT_MS = 30_000; +const MAX_DIAGNOSTIC_BYTES = 64 * 1024; +const MAX_PROTOCOL_BYTES = 2 * 1024 * 1024; + +type WorkerRequest = + | { readonly kind: 'health'; readonly handlers: readonly string[] } + | { + readonly kind: 'invoke'; + readonly handler: string; + readonly args: unknown; + readonly context: { + readonly sessionId: string; + readonly runId?: string; + readonly turnId: string; + readonly cwd: string; + readonly toolCallId: string; + readonly operationId?: string; + }; + }; + +interface WorkerOutputFrame { + readonly kind: 'output'; + readonly stream: 'stdout' | 'stderr'; + readonly chunk: string; +} + +interface WorkerResultFrame { + readonly kind: 'result'; + readonly result: unknown; +} + +interface WorkerErrorFrame { + readonly kind: 'error'; + readonly error: { readonly name: string; readonly message: string; readonly stack?: string }; +} + +type WorkerFrame = WorkerOutputFrame | WorkerResultFrame | WorkerErrorFrame; + +export class ToolPackageWorkerError extends Error { + readonly name = 'ToolPackageWorkerError'; + + constructor( + readonly code: + | 'sandbox_unavailable' + | 'worker_failed' + | 'worker_crashed' + | 'timed_out' + | 'aborted' + | 'retired', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** One activation generation. Invocations use isolated one-shot workers and are lease-drained. */ +export class ToolPackageActivation { + readonly #children = new Set(); + readonly #invocations = new Set>(); + #retired = false; + + constructor(readonly packageRevision: InstalledToolPackage) {} + + tools(): readonly MakaTool[] { + return Object.freeze( + this.packageRevision.manifest.tools.map((declaration) => { + let parameters: unknown; + try { + parameters = z.fromJSONSchema(declaration.inputSchema); + } catch (error) { + throw new ToolPackageWorkerError( + 'worker_failed', + `Tool package JSON Schema is unsupported: ${declaration.name}`, + { cause: error }, + ); + } + const tool: MakaTool = { + name: declaration.name, + description: declaration.description, + parameters, + ...(declaration.displayName ? { displayName: declaration.displayName } : {}), + categoryHint: effectiveCategory(this.packageRevision.manifest, declaration.category), + recoveryMode: declaration.recoveryMode ?? 'never_auto_retry', + executionFacts: executionFacts(this.packageRevision.manifest), + permissionArgs: (args) => args, + impl: (args, context) => this.invoke(declaration.handler, args, context), + }; + return Object.freeze(tool); + }), + ); + } + + async healthCheck(): Promise { + this.#assertActive(); + await this.#run( + { + kind: 'health', + handlers: this.packageRevision.manifest.tools.map(({ handler }) => handler), + }, + this.packageRevision.root, + undefined, + HEALTH_TIMEOUT_MS, + ); + } + + invoke(handler: string, args: unknown, context: MakaToolContext): Promise { + this.#assertActive(); + const invocation = this.#run( + { + kind: 'invoke', + handler, + args, + context: { + sessionId: context.sessionId, + ...(context.runId ? { runId: context.runId } : {}), + turnId: context.turnId, + cwd: context.cwd, + toolCallId: context.toolCallId, + ...(context.operationId ? { operationId: context.operationId } : {}), + }, + }, + context.cwd, + context, + INVOCATION_TIMEOUT_MS, + ); + this.#invocations.add(invocation); + void invocation.then( + () => this.#invocations.delete(invocation), + () => this.#invocations.delete(invocation), + ); + return invocation; + } + + async dispose(): Promise { + if (this.#retired) return; + this.#retired = true; + if (this.#invocations.size === 0) return; + let timer: NodeJS.Timeout | undefined; + const drained = Promise.allSettled([...this.#invocations]); + const timedOut = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => resolve('timeout'), DRAIN_TIMEOUT_MS); + timer.unref(); + }); + try { + if ((await Promise.race([drained, timedOut])) === 'timeout') { + for (const child of this.#children) terminate(child); + await Promise.allSettled([...this.#invocations]); + } + } finally { + if (timer) clearTimeout(timer); + } + } + + async #run( + request: WorkerRequest, + cwd: string, + context: MakaToolContext | undefined, + timeoutMs: number, + ): Promise { + this.#assertActive(); + const canonicalCwd = canonicalPath(cwd); + const transformed = createDefaultSandboxManager().transform({ + command: { + program: process.execPath, + args: [WORKER_MAIN, this.packageRevision.entry], + cwd: canonicalCwd, + env: workerEnvironment(), + profile: workerProfile(this.packageRevision.manifest), + pathContext: { + workspaceRoots: [canonicalCwd], + tmpdir: tmpdir(), + slashTmp: '/tmp', + runtimeReadableRoots: [ + canonicalPath(dirname(WORKER_MAIN)), + canonicalPath(this.packageRevision.root), + ], + executableRoots: runtimeExecutableRoots(process.execPath), + ...(process.platform === 'linux' + ? { minimalRoots: linuxExecutableRoots({ execPath: process.execPath }) } + : {}), + }, + }, + preference: 'require', + }); + if (!transformed.ok) { + throw new ToolPackageWorkerError( + 'sandbox_unavailable', + transformed.message ?? `Tool package sandbox is unavailable: ${transformed.reason}`, + ); + } + const [program, ...args] = transformed.exec.argv; + if (!program) + throw new ToolPackageWorkerError('sandbox_unavailable', 'Sandbox launch is empty'); + const child = spawn(program, args, { + cwd: transformed.exec.cwd, + env: normalizedEnvironment(transformed.exec.env), + stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'], + }); + this.#children.add(child); + try { + return await exchange(child, request, context, timeoutMs); + } finally { + this.#children.delete(child); + } + } + + #assertActive(): void { + if (this.#retired) { + throw new ToolPackageWorkerError( + 'retired', + `Tool package activation is retired: ${this.packageRevision.extensionId}@${this.packageRevision.revision}`, + ); + } + } +} + +async function exchange( + child: ChildProcess, + request: WorkerRequest, + context: MakaToolContext | undefined, + timeoutMs: number, +): Promise { + const auth = randomBytes(32).toString('hex'); + const input = child.stdio[3] as Writable | null; + const output = child.stdio[4] as Readable | null; + if (!input || !output) { + terminate(child); + throw new ToolPackageWorkerError( + 'worker_failed', + 'Tool package protocol pipes are unavailable', + ); + } + input.on('error', () => terminate(child)); + output.on('error', () => terminate(child)); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + child.stdout?.on('data', (chunk: Buffer) => { + stdoutBytes = appendBounded(stdout, stdoutBytes, chunk, MAX_DIAGNOSTIC_BYTES); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderrBytes = appendBounded(stderr, stderrBytes, chunk, MAX_DIAGNOSTIC_BYTES); + }); + child.stdout?.on('error', () => terminate(child)); + child.stderr?.on('error', () => terminate(child)); + + let protocol = ''; + let protocolBytes = 0; + let terminal: WorkerResultFrame | WorkerErrorFrame | undefined; + let protocolFailure: Error | undefined; + output.on('data', (chunk: Buffer) => { + if (protocolFailure) return; + protocolBytes += chunk.byteLength; + if (protocolBytes > MAX_PROTOCOL_BYTES) { + protocolFailure = new Error('Tool package protocol output exceeds its size limit'); + terminate(child); + return; + } + protocol += chunk.toString('utf8'); + let newline: number; + while ((newline = protocol.indexOf('\n')) >= 0) { + const encoded = protocol.slice(0, newline); + protocol = protocol.slice(newline + 1); + if (!encoded) continue; + try { + const frame = decodeFrame(JSON.parse(encoded), auth); + if (frame.kind === 'output') context?.emitOutput(frame.stream, frame.chunk); + else if (terminal) throw new Error('Tool package worker emitted multiple terminal frames'); + else terminal = frame; + } catch (error) { + protocolFailure = error instanceof Error ? error : new Error(String(error)); + terminate(child); + return; + } + } + }); + + const encodedRequest = JSON.stringify({ ...request, auth }); + if (Buffer.byteLength(encodedRequest, 'utf8') > 512 * 1024) { + terminate(child); + throw new ToolPackageWorkerError('worker_failed', 'Tool package invocation input is too large'); + } + input.end(encodedRequest); + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: unknown, value?: unknown): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + context?.abortSignal.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(value); + }; + const onAbort = (): void => { + terminate(child); + finish( + new ToolPackageWorkerError('aborted', 'Tool package invocation was aborted', { + cause: context?.abortSignal.reason, + }), + ); + }; + const timeout = setTimeout(() => { + terminate(child); + finish(new ToolPackageWorkerError('timed_out', 'Tool package invocation timed out')); + }, timeoutMs); + timeout.unref(); + context?.abortSignal.addEventListener('abort', onAbort, { once: true }); + if (context?.abortSignal.aborted) return onAbort(); + + child.once('error', (error) => { + finish( + new ToolPackageWorkerError('worker_crashed', 'Unable to launch Tool package worker', { + cause: error, + }), + ); + }); + child.once('close', (code, signal) => { + if (protocolFailure) { + return finish( + new ToolPackageWorkerError('worker_failed', protocolFailure.message, { + cause: protocolFailure, + }), + ); + } + if (!terminal) { + return finish( + new ToolPackageWorkerError( + 'worker_crashed', + workerExitMessage(code, signal, stdout, stderr), + ), + ); + } + if (terminal.kind === 'error') { + return finish( + new ToolPackageWorkerError('worker_failed', terminal.error.message, { + cause: Object.assign(new Error(terminal.error.message), { + name: terminal.error.name, + stack: terminal.error.stack, + }), + }), + ); + } + if (code !== 0) { + return finish( + new ToolPackageWorkerError( + 'worker_crashed', + workerExitMessage(code, signal, stdout, stderr), + ), + ); + } + return finish(undefined, terminal.result); + }); + }); +} + +function decodeFrame(value: unknown, expectedAuth: string): WorkerFrame { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Tool package worker frame is invalid'); + } + const frame = value as Record; + if ( + typeof frame.auth !== 'string' || + frame.auth.length !== expectedAuth.length || + !timingSafeEqual(Buffer.from(frame.auth), Buffer.from(expectedAuth)) + ) { + throw new Error('Tool package worker frame authentication failed'); + } + if (frame.kind === 'output') { + exactFrameKeys(frame, ['kind', 'stream', 'chunk', 'auth']); + if ( + (frame.stream !== 'stdout' && frame.stream !== 'stderr') || + typeof frame.chunk !== 'string' || + Buffer.byteLength(frame.chunk, 'utf8') > 256 * 1024 + ) { + throw new Error('Tool package worker output frame is invalid'); + } + return { kind: 'output', stream: frame.stream, chunk: frame.chunk }; + } + if (frame.kind === 'result') { + exactFrameKeys(frame, ['kind', 'result', 'auth']); + return { kind: 'result', result: frame.result }; + } + if (frame.kind === 'error') { + exactFrameKeys(frame, ['kind', 'error', 'auth']); + if (!frame.error || typeof frame.error !== 'object' || Array.isArray(frame.error)) { + throw new Error('Tool package worker error frame is invalid'); + } + const error = frame.error as Record; + if (Object.keys(error).some((key) => !['name', 'message', 'stack'].includes(key))) { + throw new Error('Tool package worker error fields are invalid'); + } + if (typeof error.name !== 'string' || typeof error.message !== 'string') { + throw new Error('Tool package worker error is invalid'); + } + return { + kind: 'error', + error: { + name: error.name, + message: error.message, + ...(typeof error.stack === 'string' ? { stack: error.stack } : {}), + }, + }; + } + throw new Error('Tool package worker frame kind is invalid'); +} + +function exactFrameKeys(frame: Record, keys: readonly string[]): void { + if (Object.keys(frame).length !== keys.length || keys.some((key) => !Object.hasOwn(frame, key))) { + throw new Error('Tool package worker frame fields are invalid'); + } +} + +function workerProfile(manifest: ToolPackageManifest): PermissionProfileManaged { + const workspace = manifest.permissions.workspace; + return { + type: 'managed', + name: 'custom', + fileSystem: { + kind: 'restricted', + entries: [ + ...(workspace === 'none' + ? [] + : [ + { + kind: 'special' as const, + access: workspace, + special: ':workspace_roots' as const, + }, + ]), + ], + }, + network: { kind: manifest.permissions.network ? 'enabled' : 'restricted' }, + }; +} + +function executionFacts(manifest: ToolPackageManifest): MakaTool['executionFacts'] { + return Object.freeze({ + isolation: 'container', + writesAffectHost: manifest.permissions.workspace === 'write', + writeBack: 'direct', + network: manifest.permissions.network ? 'sandbox' : 'disabled', + secrets: 'none', + }); +} + +function workerEnvironment(): Readonly> { + return Object.freeze({ + PATH: process.env.PATH, + LANG: process.env.LANG ?? 'C.UTF-8', + LC_ALL: process.env.LC_ALL, + TMPDIR: tmpdir(), + NODE_NO_WARNINGS: '1', + ELECTRON_RUN_AS_NODE: process.versions.electron ? '1' : undefined, + }); +} + +function normalizedEnvironment( + env: Readonly> | undefined, +): NodeJS.ProcessEnv { + return Object.fromEntries( + Object.entries(env ?? {}).filter((item): item is [string, string] => item[1] !== undefined), + ); +} + +function runtimeExecutableRoots(execPath: string): readonly string[] { + const appContents = execPath.match(/^(.*\.app\/Contents)\//u)?.[1]; + return [ + ...linuxExecutableRoots({ execPath }), + ...(appContents ? [appContents] : []), + ...(execPath.startsWith('/opt/homebrew/') ? ['/opt/homebrew'] : []), + ...(execPath.startsWith('/usr/local/') ? ['/usr/local'] : []), + ]; +} + +function effectiveCategory( + manifest: ToolPackageManifest, + declared: MakaTool['categoryHint'], +): NonNullable { + if (manifest.permissions.workspace === 'write') { + return declared === 'fs_destructive' || + declared === 'git_destructive' || + declared === 'shell_unsafe' + ? declared + : 'shell_unsafe'; + } + if (manifest.permissions.network) { + return declared === 'shell_unsafe' || declared === 'network_send' ? declared : 'network_send'; + } + return declared ?? 'read'; +} + +function linuxExecutableRoots(input: { execPath: string; path?: string }): readonly string[] { + const roots: string[] = []; + const executableDirectory = dirname(input.execPath); + roots.push( + executableDirectory.endsWith('/bin') ? dirname(executableDirectory) : executableDirectory, + ); + for (const entry of input.path?.split(':') ?? []) { + if (entry.startsWith('/') && entry !== '/') roots.push(entry); + } + return roots.filter( + (root, index) => + roots.indexOf(root) === index && + !roots.some( + (parent, parentIndex) => + parentIndex !== index && root.startsWith(`${parent.replace(/\/$/u, '')}/`), + ), + ); +} + +function canonicalPath(path: string): string { + try { + return realpathSync(path); + } catch { + return path; + } +} + +function terminate(child: ChildProcess): void { + if (child.exitCode !== null || child.signalCode !== null) return; + child.kill('SIGTERM'); + const forced = setTimeout(() => child.kill('SIGKILL'), 1_000); + forced.unref(); + child.once('close', () => clearTimeout(forced)); +} + +function appendBounded(chunks: Buffer[], bytes: number, chunk: Buffer, limit: number): number { + if (bytes >= limit) return bytes; + const remaining = limit - bytes; + chunks.push(chunk.subarray(0, remaining)); + return bytes + Math.min(chunk.byteLength, remaining); +} + +function workerExitMessage( + code: number | null, + signal: NodeJS.Signals | null, + stdout: readonly Buffer[], + stderr: readonly Buffer[], +): string { + const diagnostic = Buffer.concat([...stderr, ...stdout]) + .toString('utf8') + .trim(); + const suffix = diagnostic ? `: ${diagnostic}` : ''; + return `Tool package worker exited without a result (code=${String(code)}, signal=${String(signal)})${suffix}`; +} diff --git a/packages/runtime-host/src/tool-package-worker-main.ts b/packages/runtime-host/src/tool-package-worker-main.ts new file mode 100644 index 0000000000..4f0ce01bb3 --- /dev/null +++ b/packages/runtime-host/src/tool-package-worker-main.ts @@ -0,0 +1,232 @@ +import { Console } from 'node:console'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +const MAX_REQUEST_BYTES = 512 * 1024; +const MAX_FRAME_BYTES = 1024 * 1024; +const protocolInput = createReadStream('', { fd: 3, autoClose: false }); +const protocolOutput = createWriteStream('', { fd: 4, autoClose: false }); +protocolInput.on('error', () => process.exit(1)); +protocolOutput.on('error', () => process.exit(1)); + +// Package diagnostics belong on stderr. Direct stdout writes are captured by +// the parent but can never corrupt the dedicated protocol descriptor. +globalThis.console = new Console({ stdout: process.stderr, stderr: process.stderr }); + +interface WorkerContext { + readonly sessionId: string; + readonly runId?: string; + readonly turnId: string; + readonly cwd: string; + readonly toolCallId: string; + readonly operationId?: string; +} + +type WorkerRequest = + | { readonly kind: 'health'; readonly handlers: readonly string[] } + | { + readonly kind: 'invoke'; + readonly handler: string; + readonly args: unknown; + readonly context: WorkerContext; + }; + +const abortController = new AbortController(); +let protocolAuth = ''; +process.once('SIGTERM', () => abortController.abort(new Error('Tool invocation was terminated'))); +process.once('SIGINT', () => abortController.abort(new Error('Tool invocation was interrupted'))); + +try { + const decoded = decodeRequest(JSON.parse(await readRequest())); + protocolAuth = decoded.auth; + const { request } = decoded; + const entry = process.argv[2]; + if (!entry) throw new Error('Tool package worker entry is missing'); + const handlers = await loadHandlers(entry); + if (request.kind === 'health') { + for (const handler of request.handlers) requireHandler(handlers, handler); + writeFrame({ kind: 'result', result: { ready: true } }); + } else { + const handler = requireHandler(handlers, request.handler); + const context = Object.freeze({ + ...request.context, + abortSignal: abortController.signal, + emitOutput: (stream: 'stdout' | 'stderr', chunk: string) => { + if (stream !== 'stdout' && stream !== 'stderr') { + throw new Error('Tool package emitted an invalid output stream'); + } + if (typeof chunk !== 'string') throw new Error('Tool package output must be a string'); + writeFrame({ kind: 'output', stream, chunk }); + }, + }); + const result = await handler(request.args, context); + assertJsonValue(result); + writeFrame({ kind: 'result', result: result ?? null }); + } +} catch (error) { + writeFrame({ kind: 'error', error: serializeError(error) }); + process.exitCode = 1; +} finally { + protocolOutput.end(); +} + +async function loadHandlers(entry: string): Promise>> { + const imported = (await import(`${pathToFileURL(entry).href}?worker=${process.pid}`)) as { + default?: unknown; + tools?: unknown; + }; + const value = imported.default ?? imported.tools; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Tool package entry must export a default handler object'); + } + return value as Readonly>; +} + +type ToolHandler = ( + args: unknown, + context: WorkerContext & { + readonly abortSignal: AbortSignal; + readonly emitOutput: (stream: 'stdout' | 'stderr', chunk: string) => void; + }, +) => unknown | Promise; + +function requireHandler( + handlers: Readonly>, + name: string, +): ToolHandler { + const handler = handlers[name]; + if (typeof handler !== 'function') throw new Error(`Tool package handler is missing: ${name}`); + return handler; +} + +async function readRequest(): Promise { + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of protocolInput) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += buffer.byteLength; + if (bytes > MAX_REQUEST_BYTES) throw new Error('Tool package worker request is too large'); + chunks.push(buffer); + } + const encoded = Buffer.concat(chunks).toString('utf8').trim(); + if (!encoded) throw new Error('Tool package worker request is empty'); + return encoded; +} + +function decodeRequest(value: unknown): { auth: string; request: WorkerRequest } { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Tool package worker request is invalid'); + } + const record = value as Record; + const auth = requiredAuth(record.auth); + if (record.kind === 'health') { + exactKeys(record, ['kind', 'handlers', 'auth']); + if ( + !Array.isArray(record.handlers) || + record.handlers.length === 0 || + record.handlers.some((handler) => typeof handler !== 'string') + ) { + throw new Error('Tool package health request is invalid'); + } + return { auth, request: { kind: 'health', handlers: record.handlers as string[] } }; + } + if (record.kind === 'invoke') { + exactKeys(record, ['kind', 'handler', 'args', 'context', 'auth']); + if (typeof record.handler !== 'string') throw new Error('Tool package handler is invalid'); + return { + auth, + request: { + kind: 'invoke', + handler: record.handler, + args: record.args, + context: decodeContext(record.context), + }, + }; + } + throw new Error('Tool package worker request kind is invalid'); +} + +function decodeContext(value: unknown): WorkerContext { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Tool package worker context is invalid'); + } + const context = value as Record; + const allowed = ['sessionId', 'runId', 'turnId', 'cwd', 'toolCallId', 'operationId']; + if (Object.keys(context).some((key) => !allowed.includes(key))) { + throw new Error('Tool package worker context fields are invalid'); + } + const result: WorkerContext = { + sessionId: requiredString(context.sessionId, 'sessionId'), + turnId: requiredString(context.turnId, 'turnId'), + cwd: requiredString(context.cwd, 'cwd'), + toolCallId: requiredString(context.toolCallId, 'toolCallId'), + ...(context.runId === undefined ? {} : { runId: requiredString(context.runId, 'runId') }), + ...(context.operationId === undefined + ? {} + : { operationId: requiredString(context.operationId, 'operationId') }), + }; + return Object.freeze(result); +} + +function requiredString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0 || Buffer.byteLength(value, 'utf8') > 4096) { + throw new Error(`Tool package worker ${label} is invalid`); + } + return value; +} + +function requiredAuth(value: unknown): string { + if (typeof value !== 'string' || !/^[a-f0-9]{64}$/u.test(value)) { + throw new Error('Tool package worker authentication is invalid'); + } + return value; +} + +function exactKeys(record: Record, keys: readonly string[]): void { + if ( + Object.keys(record).length !== keys.length || + keys.some((key) => !Object.hasOwn(record, key)) || + Object.keys(record).some((key) => !keys.includes(key)) + ) { + throw new Error('Tool package worker request fields are invalid'); + } +} + +function writeFrame(frame: unknown): void { + const encoded = `${JSON.stringify({ ...(frame as object), auth: protocolAuth })}\n`; + if (Buffer.byteLength(encoded, 'utf8') > MAX_FRAME_BYTES) { + throw new Error('Tool package worker response is too large'); + } + protocolOutput.write(encoded); +} + +function assertJsonValue(value: unknown): void { + if (value === undefined) return; + try { + const encoded = JSON.stringify(value); + if (encoded === undefined) throw new Error('undefined'); + if (Buffer.byteLength(encoded, 'utf8') > MAX_FRAME_BYTES / 2) { + throw new Error('Tool package result exceeds its size limit'); + } + } catch (error) { + throw new Error('Tool package result must be a bounded JSON value', { cause: error }); + } +} + +function serializeError(error: unknown): { name: string; message: string; stack?: string } { + const value = error instanceof Error ? error : new Error(String(error)); + return { + name: bounded(value.name || 'Error', 128), + message: bounded(value.message || 'Tool package execution failed', 4096), + ...(value.stack ? { stack: bounded(value.stack, 16 * 1024) } : {}), + }; +} + +function bounded(value: string, maxBytes: number): string { + const encoded = Buffer.from(value, 'utf8'); + if (encoded.byteLength <= maxBytes) return value; + return `${encoded + .subarray(0, maxBytes - 3) + .toString('utf8') + .replace(/\uFFFD$/u, '')}...`; +} From 004fca56b1a90e525d807cb4a3e7b7cd9595c00a Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:20:31 +0800 Subject: [PATCH 08/48] fix(runtime-host): harden tool package model integration --- .../execution-model-composition.test.ts | 13 +++++++ .../__tests__/extension-composition.test.ts | 4 +- .../src/__tests__/protocol.test.ts | 2 +- .../src/server/execution-composition.ts | 6 ++- .../src/server/execution-model-composition.ts | 7 +++- .../src/server/extension-runtime.ts | 18 ++++++++- .../server/tool-package-management-tools.ts | 38 ++++++++++++++----- 7 files changed, 69 insertions(+), 19 deletions(-) diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 3d1264ecdb..297d5b446d 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1094,7 +1094,11 @@ test('production Host executes a canonical ai-sdk Session against a real provide 'WebSearch', 'Write', 'WriteStdin', + 'define_tool', + 'inspect_tools', + 'invoke_tool', 'load_tools', + 'manage_tool', 'memory_extract', 'memory_remember', 'request_sandbox_boundary', @@ -1102,7 +1106,16 @@ test('production Host executes a canonical ai-sdk Session against a real provide 'task_get', 'task_list', 'task_update', + 'test_tool', ]); + const requestTools = request?.body.tools as Array> | undefined; + const manageTool = requestTools?.find((tool) => { + const fn = tool.function; + return ( + fn !== null && typeof fn === 'object' && (fn as { name?: unknown }).name === 'manage_tool' + ); + }) as { function?: { parameters?: { type?: unknown } } } | undefined; + assert.equal(manageTool?.function?.parameters?.type, 'object'); assert.match(JSON.stringify(compactRequests[0]?.body), /context summarization assistant/); const messages = await execution.sessionStore.readMessagesSnapshot(session.id); diff --git a/packages/runtime-host/src/__tests__/extension-composition.test.ts b/packages/runtime-host/src/__tests__/extension-composition.test.ts index 69c92bdb55..4b3963ade5 100644 --- a/packages/runtime-host/src/__tests__/extension-composition.test.ts +++ b/packages/runtime-host/src/__tests__/extension-composition.test.ts @@ -60,7 +60,7 @@ test('production composition exposes trusted Extension control and restores it a assert.equal(enabled.ok, true); assert.deepEqual( composition.extensions.resolveTools('session-1', []).map(({ name }) => name), - ['Weather'], + ['Weather', 'define_tool', 'inspect_tools', 'invoke_tool', 'manage_tool', 'test_tool'], ); await composition.close(); @@ -81,7 +81,7 @@ test('production composition exposes trusted Extension control and restores it a assert.equal(restored.ok && restored.result.bindings[0]?.status, 'active'); assert.deepEqual( composition.extensions.resolveTools('session-1', []).map(({ name }) => name), - ['Weather'], + ['Weather', 'define_tool', 'inspect_tools', 'invoke_tool', 'manage_tool', 'test_tool'], ); } finally { await composition?.close().catch(() => undefined); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 80a5a4e2df..fca2da57be 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -41,7 +41,7 @@ import { describe('Runtime Host bootstrap protocol', () => { test('publishes a new compatibility epoch for legacy Automation provenance', () => { - assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 20); + assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 21); }); test('selects the highest mutually supported protocol and rejects a gap', () => { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 03622d7386..72be31430e 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -716,7 +716,7 @@ export async function createExecutionRuntimeHostComposition( if (tools.length !== header.subagentRuntime.toolNames.length) { throw new Error('Subagent runtime tool snapshot is unavailable'); } - return extensions.resolveTools(sessionId, tools).map((tool) => tool.name); + return extensions.resolveTools(sessionId, tools, { exact: true }).map((tool) => tool.name); } if (header.subagentParent) { throw new Error('Linked child session is missing its durable runtime snapshot'); @@ -761,7 +761,9 @@ export async function createExecutionRuntimeHostComposition( } : {}), }); - return extensions.resolveTools(sessionId, composition.tools).map((tool) => tool.name); + return extensions + .resolveTools(sessionId, composition.tools, { exact: runProfile !== undefined }) + .map((tool) => tool.name); } finally { capabilitySnapshot?.release(); } diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index cd6e132694..2fa3ee207e 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -152,8 +152,11 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom throw error; } const resolveModelTools = (): readonly MakaTool[] => - input.extensions?.resolveTools(input.context.sessionId, modelComposition.tools) ?? - modelComposition.tools; + input.extensions?.resolveTools(input.context.sessionId, modelComposition.tools, { + exact: + input.context.header.subagentRuntime !== undefined || + input.context.header.toolProfile !== undefined, + }) ?? modelComposition.tools; const modelFactory = ( modelInput: Parameters[0], ): ReturnType => diff --git a/packages/runtime-host/src/server/extension-runtime.ts b/packages/runtime-host/src/server/extension-runtime.ts index 444eea00fd..4d26d56197 100644 --- a/packages/runtime-host/src/server/extension-runtime.ts +++ b/packages/runtime-host/src/server/extension-runtime.ts @@ -38,7 +38,16 @@ export type HostToolExtensionRevisionInput = | HostPreparedToolExtensionRevisionInput; export interface HostExtensionToolResolver { - resolveTools(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[]; + resolveTools( + scopeId: string, + coreTools: readonly MakaTool[], + options?: HostExtensionToolResolutionOptions, + ): readonly MakaTool[]; +} + +export interface HostExtensionToolResolutionOptions { + /** Preserve an exact caller-owned Tool ceiling without Host or Extension additions. */ + readonly exact?: boolean; } /** @@ -166,8 +175,13 @@ export class HostExtensionRuntime implements HostExtensionToolResolver { return this.#lifecycle.composition(scopeId); } - resolveTools(scopeId: string, coreTools: readonly MakaTool[]): readonly MakaTool[] { + resolveTools( + scopeId: string, + coreTools: readonly MakaTool[], + options: HostExtensionToolResolutionOptions = {}, + ): readonly MakaTool[] { if (this.#closed) throw new Error('Runtime Host Extension authority is closed'); + if (options.exact) return Object.freeze([...coreTools]); return this.#tools.compose(scopeId, [...coreTools, ...this.#hostTools]); } diff --git a/packages/runtime-host/src/server/tool-package-management-tools.ts b/packages/runtime-host/src/server/tool-package-management-tools.ts index 6bcd83487a..a0fbb9014e 100644 --- a/packages/runtime-host/src/server/tool-package-management-tools.ts +++ b/packages/runtime-host/src/server/tool-package-management-tools.ts @@ -74,12 +74,21 @@ const testInput = revisionInput.extend({ toolName: z.string().min(1).max(128), args: z.unknown(), }); -const manageInput = z.discriminatedUnion('action', [ - revisionInput.extend({ action: z.literal('activate') }), - revisionInput.extend({ action: z.literal('update') }), - z.object({ action: z.literal('stop'), extensionId: z.string().min(1).max(128) }), - revisionInput.extend({ action: z.literal('delete') }), -]); +const manageInput = z + .object({ + action: z.enum(['activate', 'update', 'stop', 'delete']), + extensionId: z.string().min(1).max(128), + revision: z.string().min(1).max(128).optional(), + }) + .superRefine((input, context) => { + if (input.action !== 'stop' && input.revision === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['revision'], + message: `revision is required for ${input.action}`, + }); + } + }); const invokeInput = z.object({ toolName: z.string().min(1).max(128), args: z.unknown(), @@ -217,7 +226,8 @@ export class HostToolPackageManagementTools { impl: async (input: z.infer, context: MakaToolContext) => { const bindingId = bindingIdFor(context.sessionId, input.extensionId); switch (input.action) { - case 'activate': + case 'activate': { + const revision = requireRevision(input); return unwrap( await this.controller.handlers['extension.catalog.mutate']( { @@ -225,15 +235,16 @@ export class HostToolPackageManagementTools { bindingId, scopeId: context.sessionId, extensionId: input.extensionId, - revision: input.revision, + revision, }, this.#connection, ), ); + } case 'update': return unwrap( await this.controller.handlers['extension.catalog.mutate']( - { kind: 'update', bindingId, revision: input.revision }, + { kind: 'update', bindingId, revision: requireRevision(input) }, this.#connection, ), ); @@ -258,7 +269,7 @@ export class HostToolPackageManagementTools { } return unwrap( await this.controller.handlers['extension.package.uninstall']( - { extensionId: input.extensionId, revision: input.revision }, + { extensionId: input.extensionId, revision: requireRevision(input) }, this.#connection, ), ); @@ -293,6 +304,13 @@ export class HostToolPackageManagementTools { } } +function requireRevision(input: z.infer): string { + if (input.revision === undefined) { + throw new Error(`revision is required for ${input.action}`); + } + return input.revision; +} + function unwrap( outcome: OperationOutcome, ): Extract, { ok: true }>['result'] { From 40e48904ecd537b645aeab721355290c377384bb Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 14:47:26 +0800 Subject: [PATCH 09/48] feat(runtime-host): let subagents author tool candidates --- .../locales/settings-subagents-copy.ts | 2 + packages/core/src/subagent-settings.ts | 7 +- .../execution-model-composition.test.ts | 290 +++++++++++++++++- .../tool-package-management.system.test.ts | 90 +++++- .../src/server/execution-composition.ts | 15 +- .../server/tool-package-management-tools.ts | 6 + .../extension-lifecycle-kernel.system.test.ts | 20 ++ .../src/__tests__/session-manager.test.ts | 7 + .../src/__tests__/subagent-tools.test.ts | 46 +++ packages/runtime/src/agent-catalog.ts | 28 ++ .../runtime/src/extension-lifecycle-kernel.ts | 15 +- 11 files changed, 505 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts index 03a27da730..57401e0ca2 100644 --- a/apps/desktop/src/renderer/locales/settings-subagents-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-subagents-copy.ts @@ -150,6 +150,7 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { local_read: { label: '代码阅读', description: '只读访问当前工作区,适合搜索、理解和总结代码。' }, web_research: { label: '网络研究', description: '只使用联网搜索,适合查找外部资料和最新信息。' }, implementation: { label: '实现代码', description: '可以读写文件并执行命令,在隔离 worktree 中完成改动。' }, + tool_author: { label: '工具作者', description: '通过正式接口创建、安装并在沙箱中测试候选 Tool,不修改工作区源码。' }, }, thinking: { off: '关闭', @@ -231,6 +232,7 @@ const SETTINGS_SUBAGENTS_COPY_BY_LOCALE = { local_read: { label: 'Code reading', description: 'Read-only access to the current workspace for search, understanding, and summaries.' }, web_research: { label: 'Web research', description: 'Web search only, for external sources and current information.' }, implementation: { label: 'Implementation', description: 'Read and write files and run commands in an isolated worktree.' }, + tool_author: { label: 'Tool author', description: 'Create, install, and sandbox-test Tool candidates through the formal authoring interface without editing workspace source.' }, }, thinking: { off: 'Off', diff --git a/packages/core/src/subagent-settings.ts b/packages/core/src/subagent-settings.ts index 5e0fa980cc..123a8520ce 100644 --- a/packages/core/src/subagent-settings.ts +++ b/packages/core/src/subagent-settings.ts @@ -1,6 +1,11 @@ import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js'; -export const SUBAGENT_PROFILES = ['local_read', 'web_research', 'implementation'] as const; +export const SUBAGENT_PROFILES = [ + 'local_read', + 'web_research', + 'implementation', + 'tool_author', +] as const; export type SubagentProfile = (typeof SUBAGENT_PROFILES)[number]; export const MAX_SUBAGENT_PRESETS = 64; diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 297d5b446d..610e10a6ce 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -93,6 +93,7 @@ const SUMMARY_TEXT = '## Goal\nContinue hosted real-model execution.'; const CLIENT_CAPABILITY_RESULT_TEXT = 'HOSTED_CLIENT_CAPABILITY_RESULT_SENTINEL'; const CHILD_AGENT_RESULT_TEXT = 'HOSTED_CHILD_AGENT_RESULT_SENTINEL'; const WEB_RESEARCH_CHILD_RESULT_TEXT = 'HOSTED_WEB_RESEARCH_RESULT_SENTINEL'; +const TOOL_AUTHOR_PARENT_RESULT_TEXT = 'HOSTED_TOOL_AUTHOR_PARENT_ACCEPTED'; const MAX_IMPLEMENTATION_CHILD_PTY_READS = 5; const MIN_IMPLEMENTATION_CHILD_REQUESTS = 6; const MAX_IMPLEMENTATION_CHILD_REQUESTS = @@ -1541,6 +1542,7 @@ test('production Host executes a durable runnable child with an exact tool ceili 'local_read', 'web_research', 'implementation', + 'tool_author', ]); // A child now carries the archive decoder alongside its allowlist (#2026). // Its own placeholders name `ArchiveRead`, so the ceiling that governs @@ -1631,6 +1633,177 @@ test('production Host executes a durable runnable child with an exact tool ceili } }); +test('production Host lets a child install and test a Tool before the parent accepts it', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-host-tool-author-')); + const root = join(base, 'interactive'); + const project = join(base, 'project'); + const provider = await startProvider(); + provider.configureToolAuthorChildFlow(); + const capability = await resolveStorageRoot({ path: root, kind: 'interactive' }); + const owner = await tryAcquireInteractiveRootOwner(capability); + assert.ok(owner); + if (!owner) return; + const context: ConnectionContext = { + hostEpoch: 'tool-author-test-epoch', + connectionId: 'tool-author-test-client', + surface: 'tui', + principal: 'local_os_user', + acquireResidency: () => ({ release() {} }), + }; + let composition: Awaited> | undefined; + try { + await mkdir(project); + await writeFile(join(project, 'numbers.txt'), '20\n22\n'); + const policy = await openInteractiveRuntimePolicyStoresForWrite(owner.lease); + const created = await policy.connectionCatalog.create({ + expectedCatalogRevision: 0, + connection: { + slug: 'hosted-tool-author-provider', + name: 'Hosted Tool author provider', + providerType: 'moonshot', + baseUrl: provider.baseUrl, + enabled: true, + enabledModelIds: [MODEL_ID], + }, + }); + assert.equal(created.kind, 'committed'); + if (created.kind !== 'committed') return; + const connection = created.snapshot.connections[0]; + assert.ok(connection); + if (!connection) return; + assert.equal( + ( + await policy.credentialVault.set({ + locator: { + scope: 'connection', + connectionId: connection.connectionId, + kind: 'api_key', + }, + expected: null, + secret: API_KEY, + }) + ).kind, + 'committed', + ); + await publishConnectionModel(policy, connection.connectionId, MODEL_ID, 32_768); + + const execution = await openInteractiveExecutionStoresForWrite(owner.lease); + const parent = await execution.sessionStore.create({ + cwd: project, + backend: 'ai-sdk', + llmConnectionSlug: 'hosted-tool-author-provider', + model: MODEL_ID, + permissionMode: 'bypass', + }); + composition = await createExecutionRuntimeHostComposition({ + owner, + hostEpoch: context.hostEpoch, + acquireResidency: context.acquireResidency, + retainUntilProcessExit: () => undefined, + requestDrain: () => undefined, + }); + await composition.recover(); + + const terminal = await waitForTerminal( + composition, + parent.id, + 'hosted-tool-author-parent-turn', + await startTurn( + composition, + parent.id, + 'hosted-tool-author-parent-turn', + 'Delegate Tool creation, then independently accept and invoke the installed candidate.', + context, + ), + context, + ); + assert.equal(terminal.status, 'completed'); + + const requests = provider.requests.filter((request) => request.body.stream === true); + assert.equal(requests.length, 10, JSON.stringify(providerRequestTrace(requests))); + const childToolNames = [ + 'ArchiveRead', + 'Glob', + 'Grep', + 'Read', + 'define_tool', + 'inspect_tools', + 'test_tool', + ]; + for (const request of requests.slice(2, 6)) { + assert.deepEqual(toolNames(request.body), childToolNames); + } + assert.equal(childToolNames.includes('manage_tool'), false); + assert.equal(childToolNames.includes('invoke_tool'), false); + assert.equal(childToolNames.includes('Write'), false); + assert.equal(childToolNames.includes('Bash'), false); + + const sessions = await execution.sessionStore.listForRecovery(); + const child = sessions.find((session) => session.subagentRuntime?.profile === 'tool_author'); + assert.ok(child); + assert.equal(child?.subagentParent?.parentSessionId, parent.id); + assert.equal(child?.cwd, project); + assert.equal(child?.subagentWorkspace, undefined); + if (!child) return; + const childRuns = await execution.agentRunStore.listSessionRuns(child.id); + assert.equal(childRuns.length, 1); + assert.equal(childRuns[0]?.status, 'completed'); + const childEvents = await execution.runtimeEventStore.readRuntimeEvents( + child.id, + childRuns[0]!.runId, + ); + assert.ok( + childEvents.some( + (event) => + event.content?.kind === 'function_response' && event.content.name === 'define_tool', + ), + ); + assert.ok( + childEvents.some( + (event) => + event.content?.kind === 'function_response' && event.content.name === 'test_tool', + ), + ); + + const parentEvents = await execution.runtimeEventStore.readRuntimeEvents( + parent.id, + terminal.runId, + ); + assert.ok( + parentEvents.some( + (event) => + event.content?.kind === 'function_response' && event.content.name === 'manage_tool', + ), + ); + assert.ok( + parentEvents.some( + (event) => + event.content?.kind === 'function_response' && event.content.name === 'invoke_tool', + ), + ); + const parentMessages = await execution.sessionStore.readMessagesSnapshot(parent.id); + const parentAssistant = parentMessages.find( + (message) => + message.type === 'assistant' && message.turnId === 'hosted-tool-author-parent-turn', + ); + assert.equal(parentAssistant?.type, 'assistant'); + if (parentAssistant?.type === 'assistant') { + assert.equal(parentAssistant.text, TOOL_AUTHOR_PARENT_RESULT_TEXT); + } + } finally { + try { + await composition?.close(); + } finally { + try { + await owner.close(); + } finally { + await provider.close(); + await rm(base, { recursive: true, force: true }); + } + } + } +}); + test('production Host publishes and retires an implementation child patch', async () => { const base = await mkdtemp(join(tmpdir(), 'maka-host-child-agent-')); const root = join(base, 'interactive'); @@ -1770,6 +1943,7 @@ test('production Host publishes and retires an implementation child patch', asyn assert.deepEqual(toolParameterEnum(requests[1]?.body, 'agent_spawn', 'profile'), [ 'local_read', 'implementation', + 'tool_author', ]); const childToolNames = [ 'ArchiveRead', @@ -3175,6 +3349,7 @@ type ProviderFlow = readonly toolName: string; } | { readonly kind: 'child_agent' } + | { kind: 'tool_author_child_agent'; revision?: string } | { readonly kind: 'implementation_child_agent'; ptyReadCount: number; @@ -3187,6 +3362,7 @@ async function startProvider(): Promise<{ readonly requests: ProviderRequest[]; configureClientCapability(input: { groupId: string; toolName: string }): void; configureChildAgentFlow(): void; + configureToolAuthorChildFlow(): void; configureImplementationChildAgentFlow(): void; configureAgentGraphFlow(): void; close(): Promise; @@ -3212,6 +3388,10 @@ async function startProvider(): Promise<{ if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); flow = { kind: 'child_agent' }; }, + configureToolAuthorChildFlow: () => { + if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); + flow = { kind: 'tool_author_child_agent' }; + }, configureImplementationChildAgentFlow: () => { if (flow.kind !== 'default') throw new Error('Provider flow is already configured'); flow = { kind: 'implementation_child_agent', ptyReadCount: 0, stopRequested: false }; @@ -3275,7 +3455,9 @@ async function handleProviderRequest( return; } if ( - (flow.kind === 'child_agent' || flow.kind === 'implementation_child_agent') && + (flow.kind === 'child_agent' || + flow.kind === 'implementation_child_agent' || + flow.kind === 'tool_author_child_agent') && streamRequestIndex === 1 ) { assert.ok(toolNames(body).includes('load_tools')); @@ -3284,21 +3466,117 @@ async function handleProviderRequest( return; } if ( - (flow.kind === 'child_agent' || flow.kind === 'implementation_child_agent') && + (flow.kind === 'child_agent' || + flow.kind === 'implementation_child_agent' || + flow.kind === 'tool_author_child_agent') && streamRequestIndex === 2 ) { assert.ok(toolNames(body).includes('agent_spawn')); respondProviderToolCall(response, streamRequestIndex, 'agent_spawn', { - profile: flow.kind === 'child_agent' ? 'local_read' : 'implementation', + profile: + flow.kind === 'child_agent' + ? 'local_read' + : flow.kind === 'tool_author_child_agent' + ? 'tool_author' + : 'implementation', task: flow.kind === 'child_agent' ? 'Inspect the hosted child execution boundary without changing files.' - : 'Create implementation.txt with the requested sentinel.', - isolation: flow.kind === 'child_agent' ? 'same_workspace' : 'worktree', - write_back: flow.kind === 'child_agent' ? 'summary' : 'patch', + : flow.kind === 'tool_author_child_agent' + ? 'Create, install, and sandbox-test a Tool that adds two numbers.' + : 'Create implementation.txt with the requested sentinel.', + isolation: flow.kind === 'implementation_child_agent' ? 'worktree' : 'same_workspace', + write_back: flow.kind === 'implementation_child_agent' ? 'patch' : 'summary', + }); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 3) { + assert.deepEqual(toolNames(body), [ + 'ArchiveRead', + 'Glob', + 'Grep', + 'Read', + 'define_tool', + 'inspect_tools', + 'test_tool', + ]); + respondProviderToolCall(response, streamRequestIndex, 'inspect_tools', {}); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 4) { + respondProviderToolCall(response, streamRequestIndex, 'define_tool', { + id: 'child-calculator', + version: '1.0.0', + source: + "export default { Add: ({ left, right }) => ({ sum: left + right, author: 'child' }) };", + tools: [ + { + name: 'Add', + description: 'Add two numbers using the child-authored candidate.', + handler: 'Add', + inputSchema: { + type: 'object', + properties: { left: { type: 'number' }, right: { type: 'number' } }, + required: ['left', 'right'], + additionalProperties: false, + }, + category: 'read', + recoveryMode: 'replay_safe', + }, + ], + permissions: { workspace: 'none', network: false }, }); return; } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 5) { + const installed = requireLatestToolResult(body); + assert.equal(typeof installed.revision, 'string'); + flow.revision = installed.revision as string; + respondProviderToolCall(response, streamRequestIndex, 'test_tool', { + extensionId: 'child-calculator', + revision: flow.revision, + toolName: 'Add', + args: { left: 20, right: 22 }, + }); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 6) { + assert.deepEqual(requireLatestToolResult(body), { sum: 42, author: 'child' }); + respondProviderText( + response, + `Installed and tested child-calculator revision ${flow.revision}.`, + ); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 7) { + assert.ok(flow.revision); + assert.ok(toolNames(body).includes('manage_tool')); + respondProviderToolCall(response, streamRequestIndex, 'manage_tool', { + action: 'activate', + extensionId: 'child-calculator', + revision: flow.revision, + }); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 8) { + respondProviderToolCall(response, streamRequestIndex, 'invoke_tool', { + toolName: 'Add', + args: { left: 19, right: 23 }, + }); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 9) { + assert.deepEqual(requireLatestToolResult(body), { sum: 42, author: 'child' }); + respondProviderToolCall(response, streamRequestIndex, 'manage_tool', { + action: 'stop', + extensionId: 'child-calculator', + }); + return; + } + if (flow.kind === 'tool_author_child_agent' && streamRequestIndex === 10) { + respondProviderText(response, TOOL_AUTHOR_PARENT_RESULT_TEXT); + return; + } if (flow.kind === 'child_agent' && streamRequestIndex === 3) { assert.deepEqual(toolNames(body), ['ArchiveRead', 'Glob', 'Grep', 'Read']); respondProviderText(response, CHILD_AGENT_RESULT_TEXT); diff --git a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts index 4079e12b8d..2447a581f5 100644 --- a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts +++ b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts @@ -116,6 +116,92 @@ test('Agent can inspect, define, test, activate, immediately invoke, update safe } }); +test('child author installs and sandbox-tests a candidate before the parent accepts it', { + timeout: 60_000, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-child-tool-author-')); + const store = new ToolPackageStore(root); + const runtime = new HostExtensionRuntime(); + const controller = new HostExtensionController( + runtime, + new InstalledToolPackageExtensionLoader(new StaticTrustedToolExtensionLoader(), store), + new HostExtensionStateStore(root), + () => assert.fail('Tool author failure must not drain the Host'), + ); + const management = new HostToolPackageManagementTools(root, controller, runtime, store); + runtime.registerHostTools(management.tools()); + const child = toolContext(root, 'session-child-author'); + const parent = toolContext(root, 'session-parent-owner'); + + try { + await controller.recover(); + const authorTools = new Map(management.authorTools().map((tool) => [tool.name, tool])); + assert.deepEqual([...authorTools.keys()], ['inspect_tools', 'define_tool', 'test_tool']); + assert.equal(authorTools.has('manage_tool'), false); + assert.equal(authorTools.has('invoke_tool'), false); + + const define = authorTools.get('define_tool'); + assert.ok(define); + const candidate = (await define.impl( + definition( + '1.0.0', + `export default { Add: ({ left, right }) => ({ sum: left + right, author: 'child' }) };`, + ), + child, + )) as { revision: string }; + assert.match(candidate.revision, /^sha256-/u); + + const testCandidate = authorTools.get('test_tool'); + assert.ok(testCandidate); + assert.deepEqual( + await testCandidate.impl( + { + extensionId: 'calculator', + revision: candidate.revision, + toolName: 'Add', + args: { left: 4, right: 6 }, + }, + child, + ), + { sum: 10, author: 'child' }, + ); + + assert.equal( + runtime.resolveTools(child.sessionId, []).some(({ name }) => name === 'Add'), + false, + ); + assert.equal( + runtime.resolveTools(parent.sessionId, []).some(({ name }) => name === 'Add'), + false, + ); + + const manage = management.tools().find(({ name }) => name === 'manage_tool'); + assert.ok(manage); + await manage.impl( + { action: 'activate', extensionId: 'calculator', revision: candidate.revision }, + parent, + ); + assert.equal( + runtime.resolveTools(child.sessionId, []).some(({ name }) => name === 'Add'), + false, + ); + assert.equal( + runtime.resolveTools(parent.sessionId, []).some(({ name }) => name === 'Add'), + true, + ); + + const invoke = management.tools().find(({ name }) => name === 'invoke_tool'); + assert.ok(invoke); + assert.deepEqual(await invoke.impl({ toolName: 'Add', args: { left: 8, right: 9 } }, parent), { + sum: 17, + author: 'child', + }); + } finally { + await runtime.close().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); + function definition(version: string, source: string): Record { return { id: 'calculator', @@ -148,9 +234,9 @@ function requireTool(runtime: HostExtensionRuntime, name: string): MakaTool { return tool; } -function toolContext(cwd: string): MakaToolContext { +function toolContext(cwd: string, sessionId = 'session-agent'): MakaToolContext { return { - sessionId: 'session-agent', + sessionId, runId: 'run-agent', turnId: 'turn-agent', cwd, diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 72be31430e..8ae4362310 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -227,14 +227,13 @@ export async function createExecutionRuntimeHostComposition( new HostExtensionStateStore(context.owner.controlDirectory), context.requestDrain, ); - extensions.registerHostTools( - new HostToolPackageManagementTools( - context.owner.controlDirectory, - extensionController, - extensions, - toolPackageStore, - ).tools(), + const toolPackageManagement = new HostToolPackageManagementTools( + context.owner.controlDirectory, + extensionController, + extensions, + toolPackageStore, ); + extensions.registerHostTools(toolPackageManagement.tools()); let graphControlStore: ReturnType | undefined; let taskLedgerStore: | Awaited> @@ -422,7 +421,7 @@ export async function createExecutionRuntimeHostComposition( const childAgentTools = createHostChildAgentToolComposition({ taskLedger, builtinTools, - hostTools, + hostTools: [...hostTools, ...toolPackageManagement.authorTools()], worktreePatchWriteBackAvailable: true, }); const openedGraphControlStore = createAgentGraphControlStore( diff --git a/packages/runtime-host/src/server/tool-package-management-tools.ts b/packages/runtime-host/src/server/tool-package-management-tools.ts index a0fbb9014e..c731ed1fb6 100644 --- a/packages/runtime-host/src/server/tool-package-management-tools.ts +++ b/packages/runtime-host/src/server/tool-package-management-tools.ts @@ -21,6 +21,7 @@ const MANAGEMENT_TOOL_NAMES = new Set([ 'manage_tool', 'invoke_tool', ]); +const AUTHOR_TOOL_NAMES = new Set(['inspect_tools', 'define_tool', 'test_tool']); const CATEGORIES = [ 'read', 'web_read', @@ -124,6 +125,11 @@ export class HostToolPackageManagementTools { ]); } + /** Safe child-authoring subset: install and test candidates without binding or deleting them. */ + authorTools(): readonly MakaTool[] { + return Object.freeze(this.tools().filter((tool) => AUTHOR_TOOL_NAMES.has(tool.name))); + } + #inspectTool(): MakaTool { return Object.freeze({ name: 'inspect_tools', diff --git a/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts b/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts index ed40710790..6b0a5ccf33 100644 --- a/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts +++ b/packages/runtime/src/__tests__/extension-lifecycle-kernel.system.test.ts @@ -68,6 +68,26 @@ test('system: a real TCP provider is health-checked, consumed, restarted, and fu assert.deepEqual(kernel.composition('session-system').entries, []); }); +test('system: opaque Session UUID scopes can start with a digit and still dispose cleanly', async () => { + const kernel = new ExtensionLifecycleKernel(); + const scopeId = '1e34fea8-6ab9-4699-ad74-20c4bb498f48'; + await kernel.install({ + extensionId: 'uuid-scope-tool', + revision: '1', + prepare: () => ({ activate: () => undefined }), + }); + + const activated = await kernel.activate( + binding('uuid-scope-binding', scopeId, 'uuid-scope-tool', '1'), + ); + assert.equal(activated.status, 'active'); + assert.equal(kernel.inspectScope(scopeId).length, 1); + assert.equal(kernel.composition(scopeId).entries.length, 1); + + await kernel.disposeScope(scopeId); + assert.deepEqual(kernel.inspectScope(scopeId), []); +}); + test('system: real event listeners and timers do not survive stop or restart', async () => { const kernel = new ExtensionLifecycleKernel(); const bus = new EventEmitter(); diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index f242bf0b32..07332260fe 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -97,6 +97,7 @@ import { LOCAL_READ_AGENT_DEFINITION, LOCAL_READ_AGENT_ID, LOCAL_READ_AGENT_PROFILE, + TOOL_AUTHOR_AGENT_ID, WEB_RESEARCH_AGENT_DEFINITION, WEB_RESEARCH_AGENT_ID, } from '../agent-catalog.js'; @@ -11492,6 +11493,7 @@ describe('SessionManager permission mode updates', () => { LOCAL_READ_AGENT_ID, WEB_RESEARCH_AGENT_ID, IMPLEMENTATION_AGENT_ID, + TOOL_AUTHOR_AGENT_ID, ]); expect(list.definitions[0]?.availability).toEqual({ status: 'available' }); expect(list.definitions[0]?.contract.defaultWriteBack).toBe('summary'); @@ -11507,6 +11509,11 @@ describe('SessionManager permission mode updates', () => { workspace: AGENT_WORKSPACE_WORKTREE, requiredRuntime: 'worktree_child_executor', }); + expect(list.definitions[3]?.availability).toEqual({ + status: 'unavailable', + reason: 'missing_tools', + missingTools: ['inspect_tools', 'define_tool', 'test_tool'], + }); expect(list.runs.map((agent) => agent.runId)).toEqual(['child-run']); expect(list.executions.map((agent) => agent.execution)).toEqual([ { diff --git a/packages/runtime/src/__tests__/subagent-tools.test.ts b/packages/runtime/src/__tests__/subagent-tools.test.ts index 768844f2cc..bf95a30d04 100644 --- a/packages/runtime/src/__tests__/subagent-tools.test.ts +++ b/packages/runtime/src/__tests__/subagent-tools.test.ts @@ -20,6 +20,9 @@ import { LOCAL_READ_AGENT_ID, LOCAL_READ_AGENT_DEFINITION, LOCAL_READ_AGENT_PROFILE, + TOOL_AUTHOR_AGENT_DEFINITION, + TOOL_AUTHOR_AGENT_ID, + TOOL_AUTHOR_AGENT_PROFILE, WEB_RESEARCH_AGENT_ID, WEB_RESEARCH_AGENT_DEFINITION, WEB_RESEARCH_AGENT_PROFILE, @@ -225,6 +228,38 @@ describe('subagent tools', () => { }); }); + test('built-in catalog exposes Tool author only with the bounded authoring surface', () => { + const tools = [ + testCatalogTool('Read', 'read'), + testCatalogTool('Glob', 'read'), + testCatalogTool('Grep', 'read'), + testCatalogTool('inspect_tools', 'read'), + testCatalogTool('define_tool', 'file_write'), + testCatalogTool('test_tool', 'shell_unsafe'), + testCatalogTool('manage_tool', 'file_write'), + testCatalogTool('invoke_tool', 'shell_unsafe'), + ]; + const definition = listBuiltinAgentDefinitions({ tools }).find( + (candidate) => candidate.id === TOOL_AUTHOR_AGENT_ID, + ); + expect(definition?.availability).toEqual({ status: 'available' }); + expect(definition?.profile).toBe(TOOL_AUTHOR_AGENT_PROFILE); + expect(definition?.tools).toEqual([ + 'Read', + 'Glob', + 'Grep', + 'inspect_tools', + 'define_tool', + 'test_tool', + ]); + expect(TOOL_AUTHOR_AGENT_DEFINITION.contract.workspace).toBe(AGENT_WORKSPACE_SAME_WORKSPACE); + expect(TOOL_AUTHOR_AGENT_DEFINITION.contract.defaultWriteBack).toBe(AGENT_WRITE_BACK_SUMMARY); + expect(TOOL_AUTHOR_AGENT_DEFINITION.tools).not.toContain('manage_tool'); + expect(TOOL_AUTHOR_AGENT_DEFINITION.tools).not.toContain('invoke_tool'); + expect(TOOL_AUTHOR_AGENT_DEFINITION.tools).not.toContain('Write'); + expect(TOOL_AUTHOR_AGENT_DEFINITION.tools).not.toContain('Bash'); + }); + test('agent definition policy uses the explicit tool allowlist', () => { expect( evaluateAgentDefinitionToolAccess( @@ -309,6 +344,11 @@ describe('subagent tools', () => { categoryHint: 'subagent', impl: async () => ({}), }, + testCatalogTool('inspect_tools', 'read'), + testCatalogTool('define_tool', 'file_write'), + testCatalogTool('test_tool', 'shell_unsafe'), + testCatalogTool('manage_tool', 'file_write'), + testCatalogTool('invoke_tool', 'shell_unsafe'), ]); expect(tools.map((tool) => tool.name)).toEqual([ @@ -322,6 +362,9 @@ describe('subagent tools', () => { 'Bash', 'WriteStdin', 'StopBackgroundTask', + 'inspect_tools', + 'define_tool', + 'test_tool', ]); expect([...CHILD_AGENT_TOOL_NAMES]).toEqual([ 'Read', @@ -334,6 +377,9 @@ describe('subagent tools', () => { 'Bash', 'WriteStdin', 'StopBackgroundTask', + 'inspect_tools', + 'define_tool', + 'test_tool', ]); }); diff --git a/packages/runtime/src/agent-catalog.ts b/packages/runtime/src/agent-catalog.ts index 0a59a9188d..34ca93c966 100644 --- a/packages/runtime/src/agent-catalog.ts +++ b/packages/runtime/src/agent-catalog.ts @@ -17,6 +17,8 @@ export const WEB_RESEARCH_AGENT_ID = 'web-research'; export const WEB_RESEARCH_AGENT_PROFILE = 'web_research'; export const IMPLEMENTATION_AGENT_ID = 'implementation'; export const IMPLEMENTATION_AGENT_PROFILE = 'implementation'; +export const TOOL_AUTHOR_AGENT_ID = 'tool-author'; +export const TOOL_AUTHOR_AGENT_PROFILE = 'tool_author'; export const BUILTIN_AGENT_PROFILES = SUBAGENT_PROFILES; export const AGENT_INVOCATION_FOREGROUND = 'foreground'; export const AGENT_CONTEXT_ISOLATED = 'isolated'; @@ -195,10 +197,36 @@ export const IMPLEMENTATION_AGENT_DEFINITION: AgentDefinition = { ].join('\n'), }; +export const TOOL_AUTHOR_AGENT_DEFINITION: AgentDefinition = { + definitionVersion: 1, + id: TOOL_AUTHOR_AGENT_ID, + profile: TOOL_AUTHOR_AGENT_PROFILE, + name: 'Tool Author', + description: + 'Build, install, and sandbox-test immutable Tool package candidates without editing the workspace or activating them for the parent Agent.', + contract: { + capability: TOOL_AUTHOR_AGENT_PROFILE, + invocation: AGENT_INVOCATION_FOREGROUND, + context: AGENT_CONTEXT_ISOLATED, + workspace: AGENT_WORKSPACE_SAME_WORKSPACE, + defaultWriteBack: AGENT_WRITE_BACK_SUMMARY, + supportedWriteBack: [AGENT_WRITE_BACK_SUMMARY], + }, + permissionMode: 'execute', + tools: ['Read', 'Glob', 'Grep', 'inspect_tools', 'define_tool', 'test_tool'], + systemPrompt: [ + 'You are a foreground Tool author child agent.', + 'Use inspect_tools before authoring, define_tool to seal and install an immutable candidate revision, and test_tool to execute that exact revision in the real sandbox.', + 'Do not modify workspace source files and do not ask the parent Agent to install source code for you.', + 'You cannot activate a Tool for the parent Session. Return the installed extension id, revision, declared Tool names, permissions, and concrete test evidence so the parent can independently accept or reject it.', + ].join('\n'), +}; + export const BUILTIN_AGENT_DEFINITIONS: readonly AgentDefinition[] = [ LOCAL_READ_AGENT_DEFINITION, WEB_RESEARCH_AGENT_DEFINITION, IMPLEMENTATION_AGENT_DEFINITION, + TOOL_AUTHOR_AGENT_DEFINITION, ]; export function listBuiltinAgentDefinitions( diff --git a/packages/runtime/src/extension-lifecycle-kernel.ts b/packages/runtime/src/extension-lifecycle-kernel.ts index add10022a0..3e35ecfb9a 100644 --- a/packages/runtime/src/extension-lifecycle-kernel.ts +++ b/packages/runtime/src/extension-lifecycle-kernel.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; +const SCOPE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; const MAX_ID_LENGTH = 128; export type ExtensionEffectDisposer = () => void | Promise; @@ -425,7 +426,7 @@ export class ExtensionLifecycleKernel { disposeScope(scopeId: string): Promise { return this.#mutate(async () => { - validateId('scopeId', scopeId); + validateScopeId(scopeId); const records = this.#scopeBindings(scopeId); for (const record of records) record.enabled = false; const failures: EffectCleanupFailure[] = []; @@ -456,7 +457,7 @@ export class ExtensionLifecycleKernel { } inspectScope(scopeId: string): readonly ExtensionBindingInspection[] { - validateId('scopeId', scopeId); + validateScopeId(scopeId); return Object.freeze(this.#scopeBindings(scopeId).map((record) => this.#inspectRecord(record))); } @@ -472,7 +473,7 @@ export class ExtensionLifecycleKernel { } composition(scopeId: string): ExtensionCompositionSnapshot { - validateId('scopeId', scopeId); + validateScopeId(scopeId); const entries = this.#scopeBindings(scopeId) .flatMap((binding): ExtensionCompositionEntry[] => { const current = binding.current; @@ -975,11 +976,17 @@ function normalizeDefinition(definition: ExtensionRevisionDefinition): Installed function validateBindingInput(input: ExtensionBindingInput): void { if (!input || typeof input !== 'object') invalidDefinition('Binding input is required'); validateId('bindingId', input.bindingId); - validateId('scopeId', input.scopeId); + validateScopeId(input.scopeId); validateId('extensionId', input.extensionId); validateRevision(input.revision); } +function validateScopeId(value: string): void { + if (typeof value !== 'string' || value.length > MAX_ID_LENGTH || !SCOPE_ID_PATTERN.test(value)) { + invalidDefinition(`Invalid scopeId: ${String(value)}`); + } +} + function validateId(label: string, value: string): void { if (typeof value !== 'string' || value.length > MAX_ID_LENGTH || !ID_PATTERN.test(value)) { invalidDefinition(`Invalid ${label}: ${String(value)}`); From 82fa50b50f37835672b5a44b22b30ad06ef38c0e Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:33:55 +0800 Subject: [PATCH 10/48] fix(runtime-host): harden agent-authored tool retries --- .../tool-package-management.system.test.ts | 33 ++++++++++++++++ .../server/tool-package-management-tools.ts | 27 ++++++++++++- .../agent-graph-supervisor-wake.test.ts | 39 +++++++++++++++++++ packages/runtime/src/agent-catalog.ts | 2 + .../src/agent-graph-supervisor-wake.ts | 25 ++++++++++++ 5 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts index 2447a581f5..b8661eea4e 100644 --- a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts +++ b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts @@ -36,6 +36,39 @@ test('Agent can inspect, define, test, activate, immediately invoke, update safe assert.deepEqual(await inspect.impl({}, context), { revisions: [], bindings: [] }); const define = requireTool(runtime, 'define_tool'); + assert.match(define.description, /export default \{ HandlerName:/u); + assert.match(define.description, /intentionally replaces the full source/u); + const projected = define.permissionArgs?.(definition('1.0.0', 'export default {};') as never, { + sessionId: context.sessionId, + turnId: context.turnId, + toolCallId: context.toolCallId, + }) as Record; + assert.equal(projected.sourceAccepted, true); + assert.equal(projected.toolDeclarationsAccepted, true); + assert.equal(projected.toolCount, 1); + assert.equal(typeof projected.sourceSha256, 'string'); + assert.equal(Object.hasOwn(projected, 'source'), false); + assert.equal(Object.hasOwn(projected, 'tools'), false); + assert.match(String(projected.historyProjectionNotice), /intentionally redacted/u); + + await assert.rejects( + async () => + await define.impl( + definition('0.9.0', `module.exports = { Add: ({ left, right }) => left + right };`), + context, + ), + /CommonJS module\.exports\/exports is unsupported/u, + ); + await assert.rejects( + async () => + await define.impl( + definition('0.9.1', `const handlers = { Add: ({ left, right }) => left + right };`), + context, + ), + /must export one default handler object/u, + ); + assert.deepEqual(await inspect.impl({}, context), { revisions: [], bindings: [] }); + const v1 = (await define.impl( definition( '1.0.0', diff --git a/packages/runtime-host/src/server/tool-package-management-tools.ts b/packages/runtime-host/src/server/tool-package-management-tools.ts index c731ed1fb6..3407152d2f 100644 --- a/packages/runtime-host/src/server/tool-package-management-tools.ts +++ b/packages/runtime-host/src/server/tool-package-management-tools.ts @@ -22,6 +22,10 @@ const MANAGEMENT_TOOL_NAMES = new Set([ 'invoke_tool', ]); const AUTHOR_TOOL_NAMES = new Set(['inspect_tools', 'define_tool', 'test_tool']); +const TOOL_SOURCE_HISTORY_NOTICE = + 'The full source and Tool declarations were accepted and intentionally redacted from model history. Reuse your original complete arguments for a new define_tool call; these summary fields are not define_tool input.'; +const ESM_HANDLER_EXAMPLE = + 'export default { HandlerName: async (args, context) => ({ ok: true }) };'; const CATEGORIES = [ 'read', 'web_read', @@ -146,18 +150,24 @@ export class HostToolPackageManagementTools { #defineTool(): MakaTool { return Object.freeze({ name: 'define_tool', - description: - 'Validate, seal, and install a prebuilt JavaScript Tool package draft. This does not activate it; call test_tool and then manage_tool.', + description: `Validate, seal, and install a prebuilt JavaScript Tool package draft. Source must be an ES module with one default handler object, for example: ${ESM_HANDLER_EXAMPLE} CommonJS module.exports/exports is unsupported. A successful call installs an immutable revision but does not activate it; call test_tool and then manage_tool. Model history intentionally replaces the full source and Tool declarations with an accepted/redacted summary; that summary does not mean arguments were missing.`, parameters: defineInput, categoryHint: 'file_write', recoveryMode: 'idempotent', permissionArgs: (args: z.infer) => ({ id: args.id, version: args.version, + sourceAccepted: true, + sourceBytes: Buffer.byteLength(args.source, 'utf8'), + sourceSha256: createHash('sha256').update(args.source).digest('hex'), + toolDeclarationsAccepted: true, + toolCount: args.tools.length, toolNames: args.tools.map(({ name }: { name: string }) => name), permissions: args.permissions, + historyProjectionNotice: TOOL_SOURCE_HISTORY_NOTICE, }), impl: async (input: z.infer) => { + assertSupportedToolSource(input.source); const draft = join(this.#draftRoot, randomUUID()); try { await mkdir(join(draft, 'dist'), { recursive: true, mode: 0o700 }); @@ -310,6 +320,19 @@ export class HostToolPackageManagementTools { } } +function assertSupportedToolSource(source: string): void { + if (/\bmodule\s*\.\s*exports\b|\bexports\s*(?:\.|\[)/u.test(source)) { + throw new Error( + `Tool package source must use ESM; CommonJS module.exports/exports is unsupported. Use: ${ESM_HANDLER_EXAMPLE}`, + ); + } + if (!/\bexport\s+default\b/u.test(source)) { + throw new Error( + `Tool package source must export one default handler object. Use: ${ESM_HANDLER_EXAMPLE}`, + ); + } +} + function requireRevision(input: z.infer): string { if (input.revision === undefined) { throw new Error(`revision is required for ${input.action}`); diff --git a/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts b/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts index 82981a36c3..72dc03e5c8 100644 --- a/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts +++ b/packages/runtime/src/__tests__/agent-graph-supervisor-wake.test.ts @@ -156,6 +156,45 @@ describe('Agent Graph supervisor wake delivery', () => { } }); + test('does not retry a provider billing failure', async () => { + const store = createSqliteSessionMetadataStore(':memory:'); + let turns = 0; + const coordinator = new AgentGraphSupervisorWakeCoordinator({ + activityRegistry: new SessionActivityRegistry(), + wakeStore: store, + readSnapshot: async () => snapshot(), + startTurn: async (_sessionId, input): Promise => { + turns += 1; + return { + kind: 'errored', + turnId: input.turnId, + reason: 'Turn ended with provider_billing', + }; + }, + inspectAttempt: async () => 'missing', + newId: sequentialIds(), + }); + try { + coordinator.notify('root-session', reconciliation()); + await coordinator.waitForIdle(); + + const wake = await store.readAgentGraphSupervisorWake('graph-1', 'graph-1:snapshot-1'); + assert.equal(turns, 1); + assert.equal(wake?.status, 'superseded'); + assert.equal(wake?.attemptCount, 1); + assert.equal(wake?.failureReason, 'non_retryable:errored: Turn ended with provider_billing'); + assert.deepEqual( + (await store.listAgentGraphSupervisorWakeAttempts('graph-1', 'graph-1:snapshot-1')).map( + (candidate) => candidate.status, + ), + ['superseded'], + ); + } finally { + await coordinator.close(); + store.close(); + } + }); + test('aggressively compacts after a context overflow before delivering a fresh turn', async () => { const store = createSqliteSessionMetadataStore(':memory:'); let turns = 0; diff --git a/packages/runtime/src/agent-catalog.ts b/packages/runtime/src/agent-catalog.ts index 34ca93c966..1ace2ea856 100644 --- a/packages/runtime/src/agent-catalog.ts +++ b/packages/runtime/src/agent-catalog.ts @@ -217,6 +217,8 @@ export const TOOL_AUTHOR_AGENT_DEFINITION: AgentDefinition = { systemPrompt: [ 'You are a foreground Tool author child agent.', 'Use inspect_tools before authoring, define_tool to seal and install an immutable candidate revision, and test_tool to execute that exact revision in the real sandbox.', + 'Tool source is ESM only and must export one default handler object, for example: export default { HandlerName: async (args, context) => ({ ok: true }) }; Never use module.exports or CommonJS exports.', + 'After define_tool succeeds, its replayed function_call intentionally contains accepted/redacted source and Tool-declaration summary fields instead of the full arguments. This is privacy-preserving history, not a failed or incomplete call; use the returned extensionId and revision with test_tool instead of redefining it.', 'Do not modify workspace source files and do not ask the parent Agent to install source code for you.', 'You cannot activate a Tool for the parent Session. Return the installed extension id, revision, declared Tool names, permissions, and concrete test evidence so the parent can independently accept or reject it.', ].join('\n'), diff --git a/packages/runtime/src/agent-graph-supervisor-wake.ts b/packages/runtime/src/agent-graph-supervisor-wake.ts index a265b9ab8e..e0f39daaec 100644 --- a/packages/runtime/src/agent-graph-supervisor-wake.ts +++ b/packages/runtime/src/agent-graph-supervisor-wake.ts @@ -525,6 +525,16 @@ export class AgentGraphSupervisorWakeCoordinator { return; } lastFailure = wakeOutcomeFailure(outcome); + if (isNonRetryableSupervisorFailure(lastFailure)) { + await this.#input.wakeStore.completeAgentGraphSupervisorWakeAttempt({ + graphId: wake.graphId, + wakeId: wake.wakeId, + attemptId, + status: 'superseded', + failureReason: `non_retryable:${lastFailure}`.slice(0, 4_000), + }); + return; + } await this.#markRetryable(wake.graphId, wake.wakeId, attemptId, lastFailure); if (outcome.kind === 'context_overflow' || isSupervisorContextOverflow(lastFailure)) { overflowAttempt = { attemptId, turnId, failureReason: lastFailure }; @@ -532,6 +542,16 @@ export class AgentGraphSupervisorWakeCoordinator { } catch (error) { if (this.#sessionWakesSuppressed(wake.rootSessionId)) return; lastFailure = errorMessage(error); + if (isNonRetryableSupervisorFailure(lastFailure)) { + await this.#input.wakeStore.completeAgentGraphSupervisorWakeAttempt({ + graphId: wake.graphId, + wakeId: wake.wakeId, + attemptId, + status: 'superseded', + failureReason: `non_retryable:${lastFailure}`.slice(0, 4_000), + }); + return; + } await this.#markRetryable(wake.graphId, wake.wakeId, attemptId, lastFailure); if (isSupervisorContextOverflow(lastFailure)) { overflowAttempt = { attemptId, turnId, failureReason: lastFailure }; @@ -821,6 +841,11 @@ function isSupervisorContextOverflow(failureReason: string): boolean { ); } +function isNonRetryableSupervisorFailure(failureReason: string): boolean { + const normalized = failureReason.toLowerCase(); + return normalized.includes('provider_billing') || normalized.includes('provider billing'); +} + function projectAgentGraphSupervisorPartialResult( snapshot: AgentGraphClientSnapshot, ): AgentGraphSupervisorPartialResult { From 05b6da97557d5cf64a7f0e7f4d57a2c336c45fe8 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:33:24 +0800 Subject: [PATCH 11/48] fix(runtime-host): align extension identity validation --- .../tool-package-management.system.test.ts | 31 +++++++++++++++---- .../src/server/extension-loader.ts | 3 +- .../src/server/extension-state-store.ts | 22 +++++++++++-- .../src/server/tool-package-store.ts | 4 +-- .../runtime/src/extension-lifecycle-kernel.ts | 14 +++++++-- 5 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts index b8661eea4e..c181cb8f8c 100644 --- a/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts +++ b/packages/runtime-host/src/__tests__/tool-package-management.system.test.ts @@ -154,8 +154,8 @@ test('child author installs and sandbox-tests a candidate before the parent acce }, async () => { const root = await mkdtemp(join(tmpdir(), 'maka-child-tool-author-')); const store = new ToolPackageStore(root); - const runtime = new HostExtensionRuntime(); - const controller = new HostExtensionController( + let runtime = new HostExtensionRuntime(); + let controller = new HostExtensionController( runtime, new InstalledToolPackageExtensionLoader(new StaticTrustedToolExtensionLoader(), store), new HostExtensionStateStore(root), @@ -179,6 +179,7 @@ test('child author installs and sandbox-tests a candidate before the parent acce definition( '1.0.0', `export default { Add: ({ left, right }) => ({ sum: left + right, author: 'child' }) };`, + 'dev.maka.calculator', ), child, )) as { revision: string }; @@ -189,7 +190,7 @@ test('child author installs and sandbox-tests a candidate before the parent acce assert.deepEqual( await testCandidate.impl( { - extensionId: 'calculator', + extensionId: 'dev.maka.calculator', revision: candidate.revision, toolName: 'Add', args: { left: 4, right: 6 }, @@ -211,7 +212,7 @@ test('child author installs and sandbox-tests a candidate before the parent acce const manage = management.tools().find(({ name }) => name === 'manage_tool'); assert.ok(manage); await manage.impl( - { action: 'activate', extensionId: 'calculator', revision: candidate.revision }, + { action: 'activate', extensionId: 'dev.maka.calculator', revision: candidate.revision }, parent, ); assert.equal( @@ -229,15 +230,33 @@ test('child author installs and sandbox-tests a candidate before the parent acce sum: 17, author: 'child', }); + + await runtime.close(); + runtime = new HostExtensionRuntime(); + controller = new HostExtensionController( + runtime, + new InstalledToolPackageExtensionLoader(new StaticTrustedToolExtensionLoader(), store), + new HostExtensionStateStore(root), + () => assert.fail('dotted Tool candidate recovery must not drain the Host'), + ); + await controller.recover(); + const recoveredInvoke = new HostToolPackageManagementTools(root, controller, runtime, store) + .tools() + .find(({ name }) => name === 'invoke_tool'); + assert.ok(recoveredInvoke); + assert.deepEqual( + await recoveredInvoke.impl({ toolName: 'Add', args: { left: 10, right: 11 } }, parent), + { sum: 21, author: 'child' }, + ); } finally { await runtime.close().catch(() => undefined); await rm(root, { recursive: true, force: true }); } }); -function definition(version: string, source: string): Record { +function definition(version: string, source: string, id = 'calculator'): Record { return { - id: 'calculator', + id, version, source, tools: [ diff --git a/packages/runtime-host/src/server/extension-loader.ts b/packages/runtime-host/src/server/extension-loader.ts index 72c3bf4152..8ca26f808d 100644 --- a/packages/runtime-host/src/server/extension-loader.ts +++ b/packages/runtime-host/src/server/extension-loader.ts @@ -1,3 +1,4 @@ +import { isCanonicalExtensionId } from '@maka/runtime/extension-lifecycle-kernel'; import type { TrustedExtensionRevisionProjection } from '../protocol/index.js'; import type { HostPreparedToolExtensionRevisionInput, @@ -211,7 +212,7 @@ function assertDefinition(definition: HostTrustedToolExtensionRevisionInput): vo if (!definition || typeof definition !== 'object') { throw new HostExtensionLoaderError('invalid_definition', 'Trusted Extension is required'); } - if (!/^[A-Za-z0-9_-]{1,128}$/.test(definition.extensionId)) { + if (!isCanonicalExtensionId(definition.extensionId)) { throw new HostExtensionLoaderError( 'invalid_definition', 'Trusted Extension extensionId is invalid', diff --git a/packages/runtime-host/src/server/extension-state-store.ts b/packages/runtime-host/src/server/extension-state-store.ts index 52e5da196a..8481a368ad 100644 --- a/packages/runtime-host/src/server/extension-state-store.ts +++ b/packages/runtime-host/src/server/extension-state-store.ts @@ -1,6 +1,10 @@ import { randomUUID } from 'node:crypto'; import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import { + isCanonicalExtensionId, + isCanonicalExtensionScopeId, +} from '@maka/runtime/extension-lifecycle-kernel'; const SCHEMA_VERSION = 1 as const; const MAX_STATE_BYTES = 1024 * 1024; @@ -123,8 +127,8 @@ function decodeState(value: unknown): PersistedExtensionState { ]); const decoded = Object.freeze({ bindingId: entityId(binding.bindingId, 'bindingId'), - scopeId: entityId(binding.scopeId, 'scopeId'), - extensionId: entityId(binding.extensionId, 'extensionId'), + scopeId: extensionScopeId(binding.scopeId), + extensionId: extensionId(binding.extensionId), desiredRevision: revision(binding.desiredRevision, 'desiredRevision'), lastGoodRevision: binding.lastGoodRevision === null @@ -167,6 +171,20 @@ function entityId(value: unknown, label: string): string { return value; } +function extensionScopeId(value: unknown): string { + if (!isCanonicalExtensionScopeId(value)) { + throw persistenceError('Extension scopeId is invalid'); + } + return value; +} + +function extensionId(value: unknown): string { + if (!isCanonicalExtensionId(value)) { + throw persistenceError('Extension extensionId is invalid'); + } + return value; +} + function revision(value: unknown, label: string): string { if ( typeof value !== 'string' || diff --git a/packages/runtime-host/src/server/tool-package-store.ts b/packages/runtime-host/src/server/tool-package-store.ts index 58f20376fd..ff22c63d14 100644 --- a/packages/runtime-host/src/server/tool-package-store.ts +++ b/packages/runtime-host/src/server/tool-package-store.ts @@ -4,6 +4,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { dirname, isAbsolute, join, posix, resolve } from 'node:path'; import type { ToolCategory } from '@maka/core/permission'; import type { ToolRecoveryMode } from '@maka/core/runtime-event'; +import { isCanonicalExtensionId } from '@maka/runtime/extension-lifecycle-kernel'; const MANIFEST_FILE = 'maka.tool.json'; const STORE_DIRECTORY = 'tool-packages-v1'; @@ -11,7 +12,6 @@ const MAX_FILES = 128; const MAX_FILE_BYTES = 4 * 1024 * 1024; const MAX_PACKAGE_BYTES = 8 * 1024 * 1024; const MAX_MANIFEST_BYTES = 256 * 1024; -const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u; const REVISION_PATTERN = /^sha256-[a-f0-9]{64}$/u; const TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/u; const CATEGORIES = new Set([ @@ -445,7 +445,7 @@ function requireId(value: unknown): string { } function validId(value: string): boolean { - return value.length <= 128 && ID_PATTERN.test(value); + return isCanonicalExtensionId(value); } function requireRevision(value: string): void { diff --git a/packages/runtime/src/extension-lifecycle-kernel.ts b/packages/runtime/src/extension-lifecycle-kernel.ts index 3e35ecfb9a..889a424c93 100644 --- a/packages/runtime/src/extension-lifecycle-kernel.ts +++ b/packages/runtime/src/extension-lifecycle-kernel.ts @@ -4,6 +4,16 @@ const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/; const SCOPE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; const MAX_ID_LENGTH = 128; +/** Canonical identity shared by Extension manifests, loaders, bindings, and persistence. */ +export function isCanonicalExtensionId(value: unknown): value is string { + return typeof value === 'string' && value.length <= MAX_ID_LENGTH && ID_PATTERN.test(value); +} + +/** Canonical identity for the Session/workspace scope that owns an Extension binding. */ +export function isCanonicalExtensionScopeId(value: unknown): value is string { + return typeof value === 'string' && value.length <= MAX_ID_LENGTH && SCOPE_ID_PATTERN.test(value); +} + export type ExtensionEffectDisposer = () => void | Promise; export interface ExtensionDependencyDefinition { @@ -982,13 +992,13 @@ function validateBindingInput(input: ExtensionBindingInput): void { } function validateScopeId(value: string): void { - if (typeof value !== 'string' || value.length > MAX_ID_LENGTH || !SCOPE_ID_PATTERN.test(value)) { + if (!isCanonicalExtensionScopeId(value)) { invalidDefinition(`Invalid scopeId: ${String(value)}`); } } function validateId(label: string, value: string): void { - if (typeof value !== 'string' || value.length > MAX_ID_LENGTH || !ID_PATTERN.test(value)) { + if (!isCanonicalExtensionId(value)) { invalidDefinition(`Invalid ${label}: ${String(value)}`); } } From 2d32d6d6520b5fe312d112ef217da73f7559960e Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:31:58 +0800 Subject: [PATCH 12/48] feat(extension): add lifecycle-managed UI contributions --- .../main/__tests__/ui-extension-host.test.ts | 44 ++ .../main/runtime-host-renderer-ipc-main.ts | 2 + .../runtime-host-renderer-operations.ts | 1 + apps/desktop/src/renderer/app.tsx | 7 +- apps/desktop/src/renderer/styles.css | 26 ++ .../src/renderer/ui-extension-host.tsx | 159 +++++++ .../extension-ui-contributions.md | 69 +++ .../execution-model-composition.test.ts | 4 + .../__tests__/extension-composition.test.ts | 26 +- .../__tests__/extension-e2e.system.test.ts | 6 +- .../src/__tests__/extension-protocol.test.ts | 46 +- .../ui-package-management.system.test.ts | 148 +++++++ .../runtime-host/src/protocol/extension.ts | 101 ++++- .../src/server/execution-composition.ts | 12 +- .../src/server/extension-controller.ts | 29 +- .../src/server/extension-loader.ts | 122 +++++- .../src/server/extension-runtime.ts | 39 ++ packages/runtime-host/src/server/index.ts | 10 + .../src/server/ui-package-management-tools.ts | 286 ++++++++++++ .../src/server/ui-package-store.ts | 414 ++++++++++++++++++ packages/runtime/package.json | 1 + .../extension-ui-contributions.test.ts | 107 +++++ .../runtime/src/extension-ui-contributions.ts | 233 ++++++++++ 23 files changed, 1869 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/ui-extension-host.test.ts create mode 100644 apps/desktop/src/renderer/ui-extension-host.tsx create mode 100644 docs/architecture/extension-ui-contributions.md create mode 100644 packages/runtime-host/src/__tests__/ui-package-management.system.test.ts create mode 100644 packages/runtime-host/src/server/ui-package-management-tools.ts create mode 100644 packages/runtime-host/src/server/ui-package-store.ts create mode 100644 packages/runtime/src/__tests__/extension-ui-contributions.test.ts create mode 100644 packages/runtime/src/extension-ui-contributions.ts diff --git a/apps/desktop/src/main/__tests__/ui-extension-host.test.ts b/apps/desktop/src/main/__tests__/ui-extension-host.test.ts new file mode 100644 index 0000000000..10e56c381d --- /dev/null +++ b/apps/desktop/src/main/__tests__/ui-extension-host.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { ExtensionUiContributionProjection } from '@maka/runtime-host/protocol'; +import { + selectUiSnapshots, + withUiSandboxPolicy, +} from '../../renderer/ui-extension-host.js'; + +describe('Desktop UI extension shell', () => { + test('selects one deterministic root and ordered independent overlays', () => { + const selected = selectUiSnapshots(null, [ + item('low', 'app.root', 1), + item('overlay-b', 'app.overlay', 20), + item('high', 'app.root', 100), + item('overlay-a', 'app.overlay', 20), + ]); + assert.equal(selected.root.id, 'high'); + assert.deepEqual(selected.overlays.map(({ id }) => id), ['overlay-a', 'overlay-b']); + }); + + test('injects an offline CSP by default and only opens declared network lanes', () => { + const offline = withUiSandboxPolicy('Hello', false); + assert.match(offline, /connect-src 'none'/); + assert.match(offline, /frame-src 'none'/); + assert.ok(offline.indexOf('Content-Security-Policy') < offline.indexOf('')); + const online = withUiSandboxPolicy('

Hello
', true); + assert.match(online, /connect-src https: wss:/); + assert.match(online, /form-action 'none'/); + }); +}); + +function item(id: string, surface: 'app.root' | 'app.overlay', priority: number): ExtensionUiContributionProjection { + return { + bindingId: `binding-${id}`, + extensionId: 'demo', + revision: '1', + id, + surface, + priority, + document: '

demo

', + documentSha256: 'sha256', + network: false, + }; +} diff --git a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts index 9bb25af465..dacd40eff9 100644 --- a/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-renderer-ipc-main.ts @@ -77,6 +77,8 @@ function request( return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'execution.inspect.query': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); + case 'extension.ui.snapshot': + return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'scheduled-task.mutate': return client.request(operation, HOST_OPERATION_SPECS[operation].decodeInput(value)); case 'scheduled-task.query': diff --git a/apps/desktop/src/preload/runtime-host-renderer-operations.ts b/apps/desktop/src/preload/runtime-host-renderer-operations.ts index e0dde01165..a4d9161bab 100644 --- a/apps/desktop/src/preload/runtime-host-renderer-operations.ts +++ b/apps/desktop/src/preload/runtime-host-renderer-operations.ts @@ -7,6 +7,7 @@ export const RENDERER_RUNTIME_HOST_QUERY_OPERATIONS = [ 'context.diagnostics.query', 'daily-review.query', 'execution.inspect.query', + 'extension.ui.snapshot', 'scheduled-task.query', ] as const satisfies readonly (keyof OperationSpecMap)[]; diff --git a/apps/desktop/src/renderer/app.tsx b/apps/desktop/src/renderer/app.tsx index 75d5e20573..f14fcacb04 100644 --- a/apps/desktop/src/renderer/app.tsx +++ b/apps/desktop/src/renderer/app.tsx @@ -7,6 +7,7 @@ import { useAstryxThemeMode } from './astryx-theme-mode'; import type { OnboardingSnapshot } from '../preload/bridge-contract.js'; import { RuntimeHostSshTerminalDialog } from './settings/runtime-host-ssh-terminal-dialog.js'; import { readSystemUiLocale } from './use-system-ui-locale'; +import { UiExtensionHost } from './ui-extension-host'; export function App({ initialOnboardingSnapshot = null, @@ -63,7 +64,11 @@ export function App({ {runtimeHostReady ? ( - + + } + /> ) : ( diff --git a/apps/desktop/src/renderer/styles.css b/apps/desktop/src/renderer/styles.css index a1fec42305..5496583e5b 100644 --- a/apps/desktop/src/renderer/styles.css +++ b/apps/desktop/src/renderer/styles.css @@ -47,3 +47,29 @@ @import "./styles/quote-side-panel.css" layer(components); @import "./styles/custom-pet-companion.css" layer(components); @import "./styles/astryx-mount.css" layer(components); +/* The bootstrap shell owns only snapshot selection and isolation. Product UI + layout remains inside the selected official or dynamic contribution. */ +.maka-ui-extension-shell, +.maka-ui-official-snapshot { + width: 100%; + height: 100%; +} + +.maka-ui-extension-frame { + border: 0; + background: transparent; +} + +.maka-ui-extension-frame--root { + display: block; + width: 100%; + height: 100%; +} + +.maka-ui-extension-frame--overlay { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 2147483000; +} diff --git a/apps/desktop/src/renderer/ui-extension-host.tsx b/apps/desktop/src/renderer/ui-extension-host.tsx new file mode 100644 index 0000000000..cb37bf92cf --- /dev/null +++ b/apps/desktop/src/renderer/ui-extension-host.tsx @@ -0,0 +1,159 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react'; +import type { + ExtensionUiContributionProjection, + ExtensionUiSnapshotResult, +} from '@maka/runtime-host/protocol'; + +const DESKTOP_UI_SCOPE = 'desktop-ui'; +const REFRESH_MS = 1_000; + +/** + * The fixed Desktop shell is intentionally tiny. The shipped Maka product UI + * is the trusted fallback snapshot; installed client-only revisions enter the + * same root/overlay selection path and may replace the entire product surface. + */ +export function UiExtensionHost({ officialSnapshot }: { officialSnapshot: ReactNode }) { + const [snapshot, setSnapshot] = useState(null); + const [safeMode, setSafeMode] = useState(false); + + useEffect(() => { + let disposed = false; + let timer: ReturnType | undefined; + const refresh = async () => { + try { + const next = await window.maka.runtimeHost.query('extension.ui.snapshot', { + scopeId: DESKTOP_UI_SCOPE, + }); + if (!disposed) setSnapshot((current) => (current?.digest === next.digest ? current : next)); + } catch { + // Fail open to the compiled official snapshot while the Host reconnects. + } finally { + if (!disposed) timer = setTimeout(refresh, REFRESH_MS); + } + }; + void refresh(); + return () => { + disposed = true; + if (timer) clearTimeout(timer); + }; + }, []); + + useEffect(() => { + const recover = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key === 'Backspace') { + event.preventDefault(); + setSafeMode(true); + } + }; + window.addEventListener('keydown', recover, { capture: true }); + return () => window.removeEventListener('keydown', recover, { capture: true }); + }, []); + + const selected = useMemo( + () => selectUiSnapshots(officialSnapshot, snapshot?.contributions ?? []), + [officialSnapshot, snapshot], + ); + const selectedRoot = safeMode ? selected.official : selected.root; + return ( +
+ {selectedRoot.kind === 'sandboxed' ? ( + + ) : ( +
+ {selectedRoot.node} +
+ )} + {!safeMode && selected.overlays.map((item) => ( + + ))} +
+ ); +} + +type UiSnapshotCandidate = + | { + readonly kind: 'official'; + readonly extensionId: 'dev.maka.desktop'; + readonly revision: 'desktop-build'; + readonly id: 'official-root'; + readonly priority: -10_000; + readonly node: ReactNode; + } + | { + readonly kind: 'sandboxed'; + readonly extensionId: string; + readonly revision: string; + readonly id: string; + readonly priority: number; + readonly contribution: ExtensionUiContributionProjection; + }; + +export function selectUiSnapshots( + officialNode: ReactNode, + contributions: readonly ExtensionUiContributionProjection[], +) { + const official: UiSnapshotCandidate = Object.freeze({ + kind: 'official', + extensionId: 'dev.maka.desktop', + revision: 'desktop-build', + id: 'official-root', + priority: -10_000, + node: officialNode, + }); + const ordered = [...contributions].sort( + (left, right) => + right.priority - left.priority || + left.extensionId.localeCompare(right.extensionId) || + left.id.localeCompare(right.id), + ); + const dynamicRoot = ordered.find(({ surface }) => surface === 'app.root'); + return Object.freeze({ + official, + root: dynamicRoot && dynamicRoot.priority > official.priority + ? Object.freeze({ + kind: 'sandboxed' as const, + extensionId: dynamicRoot.extensionId, + revision: dynamicRoot.revision, + id: dynamicRoot.id, + priority: dynamicRoot.priority, + contribution: dynamicRoot, + }) + : official, + overlays: Object.freeze(ordered.filter(({ surface }) => surface === 'app.overlay')), + }); +} + +function SandboxedUiFrame({ + contribution, + layer, +}: { + contribution: ExtensionUiContributionProjection; + layer: 'root' | 'overlay'; +}) { + return ( +