diff --git a/.claude/plans/phase-1-caplet-installation-with-consumer.md b/.claude/plans/phase-1-caplet-installation-with-consumer.md new file mode 100644 index 000000000..2885cf44e --- /dev/null +++ b/.claude/plans/phase-1-caplet-installation-with-consumer.md @@ -0,0 +1,479 @@ +# Plan: Immediate Next Step for Omnium Phase 1 + +## Context + +Looking at the Phase 1 goals in `packages/omnium-gatherum/PLAN.md`, the critical path to achieving a working PoC requires: + +1. Install two caplets (service producer and consumer) +2. Service producer can be discovered by consumer +3. Consumer calls methods on producer (e.g., `E(serviceProducer).echo(message)`) +4. Caplets can be uninstalled and the process repeated + +**Current Status:** + +- ✅ CapletController architecture complete (install/uninstall/list/get) +- ✅ CapTP infrastructure working +- ✅ Dev console integration (`globalThis.omnium`) +- ✅ Unit tests with mocks comprehensive +- ✅ Kernel bundle loading fully functional +- ❌ **BLOCKER**: No actual caplet vat implementations exist +- ❌ Caplet vat contract not documented +- ❌ Integration tests with real vats not written + +## Immediate Next Steps (1-2 Commits) + +### Step 1: Define Caplet Vat Contract + Create Echo Caplet + +**Commit 1: Define contract and create echo-caplet source** + +This is identified as "High Priority" and a blocker in PLAN.md line 254. Everything else depends on this. + +#### 1.1 Document Caplet Vat Contract + +Create `packages/omnium-gatherum/docs/caplet-contract.md`: + +**Contract specification:** + +- All caplet vats must export `buildRootObject(vatPowers, parameters, baggage)` +- `vatPowers`: Standard kernel vat powers (logger, etc.) +- `parameters`: Bootstrap data from omnium + - Phase 1: Service krefs passed directly as `{ serviceName: kref }` + - Phase 2+: Registry vat reference for dynamic discovery +- `baggage`: Persistent state storage (standard Endo pattern) +- Root object must be hardened and returned from `buildRootObject()` +- Services are accessed via `E()` on received krefs + +**Phase 1 approach:** + +- Services resolved at install time (no runtime discovery) +- Requested services passed in `parameters` object +- Service names from `manifest.requestedServices` map to parameter keys + +**Based on existing patterns from:** + +- `/packages/kernel-test/src/vats/exo-vat.js` (exo patterns) +- `/packages/kernel-test/src/vats/service-vat.js` (service injection) +- `/packages/kernel-test/src/vats/logger-vat.js` (minimal example) + +#### 1.2 Create Echo Caplet Source + +Create `packages/omnium-gatherum/src/vats/echo-caplet.ts`: + +```typescript +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Echo service caplet - provides a simple echo method for testing + * + * @param {VatPowers} vatPowers - Standard vat powers + * @param {object} parameters - Bootstrap parameters (empty for echo-caplet) + * @param {MapStore} baggage - Persistent state storage + * @returns {object} Root object with echo service methods + */ +export function buildRootObject(vatPowers, parameters, baggage) { + const logger = vatPowers.logger.subLogger({ tags: ['echo-caplet'] }); + + logger.log('Echo caplet initializing...'); + + return makeDefaultExo('echo-caplet-root', { + bootstrap() { + logger.log('Echo caplet bootstrapped'); + }, + + /** + * Echo service method - returns the input message with "Echo: " prefix + * @param {string} message - Message to echo + * @returns {string} Echoed message + */ + echo(message) { + logger.log('Echoing message:', message); + return `Echo: ${message}`; + }, + }); +} +``` + +**Manifest for echo-caplet:** + +```typescript +const echoCapletManifest: CapletManifest = { + id: 'com.example.echo', + name: 'Echo Service', + version: '1.0.0', + bundleSpec: 'file:///path/to/echo-caplet.bundle', + requestedServices: [], // Echo provides service, doesn't request any + providedServices: ['echo'], +}; +``` + +#### 1.3 Add Bundle Build Script + +Update `packages/omnium-gatherum/package.json`: + +```json +{ + "scripts": { + "build": "yarn build:vats", + "build:vats": "ocap bundle src/vats" + } +} +``` + +This will use `@endo/bundle-source` (via the `ocap` CLI) to generate `.bundle` files. + +#### 1.4 Create Test Fixture + +Create `packages/omnium-gatherum/test/fixtures/manifests.ts`: + +```typescript +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import type { CapletManifest } from '../../src/controllers/caplet/types.js'; + +const VATS_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/vats', +); + +export const echoCapletManifest: CapletManifest = { + id: 'com.example.echo', + name: 'Echo Service', + version: '1.0.0', + bundleSpec: new URL('./echo-caplet.bundle', `file://${VATS_DIR}/`).toString(), + requestedServices: [], + providedServices: ['echo'], +}; +``` + +### Step 2: Create Consumer Caplet + Integration Test + +**Commit 2: Add consumer-caplet and end-to-end integration test** + +#### 2.1 Create Consumer Caplet Source + +Create `packages/omnium-gatherum/src/vats/consumer-caplet.ts`: + +```typescript +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Consumer caplet - demonstrates calling methods on another caplet's service + * + * @param {VatPowers} vatPowers - Standard vat powers + * @param {object} parameters - Bootstrap parameters with service references + * @param {object} parameters.echo - Echo service kref + * @param {MapStore} baggage - Persistent state storage + * @returns {object} Root object with test methods + */ +export function buildRootObject(vatPowers, parameters, baggage) { + const logger = vatPowers.logger.subLogger({ tags: ['consumer-caplet'] }); + + logger.log('Consumer caplet initializing...'); + + const { echo: echoService } = parameters; + + if (!echoService) { + throw new Error('Echo service not provided in parameters'); + } + + return makeDefaultExo('consumer-caplet-root', { + bootstrap() { + logger.log('Consumer caplet bootstrapped with echo service'); + }, + + /** + * Test method that calls the echo service + * @param {string} message - Message to send to echo service + * @returns {Promise} Result from echo service + */ + async testEcho(message) { + logger.log('Calling echo service with:', message); + const result = await E(echoService).echo(message); + logger.log('Received from echo service:', result); + return result; + }, + }); +} +``` + +**Manifest for consumer-caplet:** + +```typescript +export const consumerCapletManifest: CapletManifest = { + id: 'com.example.consumer', + name: 'Echo Consumer', + version: '1.0.0', + bundleSpec: new URL( + './consumer-caplet.bundle', + `file://${VATS_DIR}/`, + ).toString(), + requestedServices: ['echo'], // Requests echo service + providedServices: [], +}; +``` + +#### 2.2 Implement Service Injection in CapletController + +**Current gap:** CapletController doesn't yet capture the caplet's root kref or pass services to dependent caplets. + +Update `packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts`: + +**Add to `install()` method:** + +```typescript +// After launchSubcluster completes: +const subclusterId = /* ... determine subcluster ID ... */; + +// Get the root kref for this caplet +// TODO: Need to capture this from launch result or query kernel +const rootKref = /* ... capture from kernel ... */; + +// Resolve requested services +const serviceParams: Record = {}; +for (const serviceName of manifest.requestedServices) { + const provider = await this.getByService(serviceName); + if (!provider) { + throw new Error(`Requested service not found: ${serviceName}`); + } + // Get provider's root kref and add to parameters + serviceParams[serviceName] = /* ... provider's kref ... */; +} + +// TODO: Pass serviceParams to vat during bootstrap +// This requires kernel support for passing parameters +``` + +**Note:** This reveals a kernel integration gap - we need a way to: + +1. Capture the root kref when a subcluster launches +2. Pass parameters to a vat's bootstrap method + +**For Phase 1 PoC, we can work around this by:** + +- Manually passing service references via dev console +- Using kernel's `queueMessage()` to send services after launch +- Or: Enhance `launchSubcluster` to return root krefs + +#### 2.3 Create Integration Test + +Create `packages/omnium-gatherum/test/caplet-integration.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { E } from '@endo/eventual-send'; +import { makeCapletController } from '../src/controllers/caplet/caplet-controller.js'; +import { echoCapletManifest, consumerCapletManifest } from './fixtures/manifests.js'; +import type { BackgroundCapTP } from '@metamask/kernel-browser-runtime'; + +describe('Caplet Integration', () => { + let capletController; + let kernel: BackgroundCapTP['kernel']; + + beforeEach(async () => { + // Set up real kernel connection + const omnium = await setupOmnium(); // Helper to initialize omnium + kernel = await omnium.getKernel(); + capletController = await makeCapletController({ + adapter: /* ... real storage adapter ... */, + launchSubcluster: (config) => E(kernel).launchSubcluster(config), + terminateSubcluster: (id) => E(kernel).terminateSubcluster(id), + }); + }); + + afterEach(async () => { + // Clean up all caplets + const caplets = await capletController.list(); + for (const caplet of caplets) { + await capletController.uninstall(caplet.manifest.id); + } + }); + + it('installs echo-caplet and calls its echo method', async () => { + // Install echo-caplet + const { capletId, subclusterId } = await capletController.install( + echoCapletManifest + ); + + expect(capletId).toBe('com.example.echo'); + expect(subclusterId).toBeDefined(); + + // Get echo-caplet from storage + const installedCaplet = await capletController.get(capletId); + expect(installedCaplet).toBeDefined(); + expect(installedCaplet?.manifest.name).toBe('Echo Service'); + + // TODO: Get root kref for echo-caplet + // const echoKref = /* ... get from kernel ... */; + + // Call echo method + // const result = await E(echoKref).echo('Hello, Omnium!'); + // expect(result).toBe('Echo: Hello, Omnium!'); + }); + + it('installs both caplets and consumer calls echo service', async () => { + // Install echo-caplet (service provider) + const echoResult = await capletController.install(echoCapletManifest); + + // Install consumer-caplet (service consumer) + // Note: Consumer requests 'echo' service via manifest + const consumerResult = await capletController.install(consumerCapletManifest); + + // TODO: Get consumer's root kref + // const consumerKref = /* ... get from kernel ... */; + + // Call consumer's testEcho method + // const result = await E(consumerKref).testEcho('Test message'); + // expect(result).toBe('Echo: Test message'); + }); + + it('uninstalls caplets cleanly', async () => { + // Install both + await capletController.install(echoCapletManifest); + await capletController.install(consumerCapletManifest); + + // Verify both installed + let list = await capletController.list(); + expect(list).toHaveLength(2); + + // Uninstall consumer first + await capletController.uninstall('com.example.consumer'); + list = await capletController.list(); + expect(list).toHaveLength(1); + + // Uninstall echo + await capletController.uninstall('com.example.echo'); + list = await capletController.list(); + expect(list).toHaveLength(0); + }); +}); +``` + +## Critical Files + +### To Create + +- `packages/omnium-gatherum/docs/caplet-contract.md` - Caplet vat interface documentation +- `packages/omnium-gatherum/src/vats/echo-caplet.ts` - Echo service vat source +- `packages/omnium-gatherum/src/vats/consumer-caplet.ts` - Consumer vat source +- `packages/omnium-gatherum/test/fixtures/manifests.ts` - Test manifest definitions +- `packages/omnium-gatherum/test/caplet-integration.test.ts` - Integration tests + +### To Modify + +- `packages/omnium-gatherum/package.json` - Add bundle build script +- `packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts` - Service injection logic + +### To Reference + +- `/packages/kernel-test/src/vats/exo-vat.js` - Exo pattern examples +- `/packages/kernel-test/src/vats/service-vat.js` - Service injection pattern +- `/packages/kernel-test/src/utils.ts:24-26` - `getBundleSpec()` helper +- `/packages/kernel-test/src/cluster-launch.test.ts` - Real subcluster launch pattern + +## Known Gaps Revealed + +During implementation, we'll need to address: + +1. **Kref Capture** - Need to capture root kref when caplet launches + + - Option A: Enhance `launchSubcluster` to return root krefs + - Option B: Query kernel status after launch to get krefs + - Option C: Use `queueMessage` with well-known pattern + +2. **Service Parameter Passing** - Need to pass resolved services to vat bootstrap + + - Currently `ClusterConfig` doesn't have a parameters field + - May need to enhance kernel's `VatConfig` type + - Or: Pass services via post-bootstrap message + +3. **Bundle Build Integration** - Need to run `ocap bundle` as part of build + - Add to omnium-gatherum build script + - Ensure bundles are generated before tests run + - Consider git-ignoring bundles or checking them in + +## Verification + +After completing both commits: + +1. **Build bundles:** + + ```bash + cd packages/omnium-gatherum + yarn build:vats + ``` + +2. **Run integration tests:** + + ```bash + yarn test:integration + ``` + +3. **Manual dev console test:** + + ```javascript + // In browser console + const result = await omnium.caplet.install(echoCapletManifest); + console.log('Installed:', result); + + const list = await omnium.caplet.list(); + console.log('Caplets:', list); + + await omnium.caplet.uninstall('com.example.echo'); + ``` + +4. **Verify Phase 1 goals:** + - ✓ Two caplets can be installed + - ✓ Service discovery works (hard-coded is acceptable) + - ✓ Consumer can call provider methods + - ✓ Caplets can be uninstalled and reinstalled + +## Success Criteria + +**Commit 1 Complete When:** + +- ✓ `docs/caplet-contract.md` exists and documents the interface +- ✓ `src/vats/echo-caplet.ts` compiles successfully +- ✓ Bundle build script works (`yarn build:vats`) +- ✓ `echo-caplet.bundle` file generated +- ✓ Test manifest can reference the bundle + +**Commit 2 Complete When:** + +- ✓ `src/vats/consumer-caplet.ts` compiles successfully +- ✓ `consumer-caplet.bundle` file generated +- ✓ Integration test file created (even if some tests are pending TODOs) +- ✓ At least one test passes showing caplet installation/uninstallation + +**Phase 1 PoC Complete When:** + +- ✓ Both caplets install successfully +- ✓ Consumer receives reference to echo service +- ✓ Consumer successfully calls `E(echo).echo(msg)` and gets response +- ✓ Both caplets can be uninstalled +- ✓ Process can be repeated + +## Notes + +- This is the **highest priority** work according to PLAN.md +- It's marked as a blocker for integration testing +- No kernel changes are required (bundle loading already works) +- We're following established patterns from kernel-test vats +- This unblocks all remaining Phase 1 work + +## Alternative Approach + +If service parameter passing proves complex, we can start with an even simpler approach: + +**Phase 1a: Single Echo Caplet (Commit 1 only)** + +- Install echo-caplet only +- Test by calling its methods directly via dev console +- Defer consumer-caplet until service injection is figured out + +This still achieves significant progress: + +- Validates caplet contract +- Proves bundle loading works end-to-end +- Exercises install/uninstall lifecycle +- Provides foundation for service injection work diff --git a/.depcheckrc.yml b/.depcheckrc.yml index ef2dad39f..08e7fb5e3 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -50,6 +50,9 @@ ignores: # Used by @ocap/nodejs to build the sqlite3 bindings - 'node-gyp' + # Used by @metamask/kernel-shims/endoify-node for tests + - '@libp2p/webrtc' + # These are peer dependencies of various modules we actually do # depend on, which have been elevated to full dependencies (even # though we don't actually depend on them) in order to work around a diff --git a/.github/workflows/lint-build-test.yml b/.github/workflows/lint-build-test.yml index b78bb6279..3a6224dce 100644 --- a/.github/workflows/lint-build-test.yml +++ b/.github/workflows/lint-build-test.yml @@ -132,6 +132,7 @@ jobs: node-version: ${{ matrix.node-version }} env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 + - run: yarn build - run: yarn test:integration - name: Require clean working directory shell: bash diff --git a/package.json b/package.json index a283cb9c0..062c1821c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '**/*.yml' '!**/CHANGELOG.old.md' '!.yarnrc.yml' '!CLAUDE.md' '!merged-packages/**' --ignore-path .gitignore --log-level error", "postinstall": "simple-git-hooks && yarn rebuild:native", "prepack": "./scripts/prepack.sh", - "pretest": "bash scripts/reset-coverage-thresholds.sh", + "pretest": "./scripts/reset-coverage-thresholds.sh", "rebuild:native": "./scripts/rebuild-native.sh", "test": "yarn pretest && vitest run", "test:ci": "vitest run --coverage false", @@ -35,6 +35,7 @@ "test:e2e": "yarn workspaces foreach --all run test:e2e", "test:e2e:ci": "yarn workspaces foreach --all run test:e2e:ci", "test:e2e:local": "yarn workspaces foreach --all run test:e2e:local", + "test:integration": "yarn workspaces foreach --all run test:integration", "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest", "why:batch": "./scripts/why-batch.sh" @@ -121,7 +122,8 @@ "vite>sass>@parcel/watcher": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>edgedriver": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>geckodriver": false, - "vitest>@vitest/mocker>msw": false + "vitest>@vitest/mocker>msw": false, + "@ocap/cli>@metamask/kernel-shims>@libp2p/webrtc>@ipshipyard/node-datachannel": false } }, "resolutions": { diff --git a/packages/extension/package.json b/packages/extension/package.json index b2ce2a876..3ef92a0f2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -42,15 +42,13 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { + "@endo/eventual-send": "^1.3.4", "@metamask/kernel-browser-runtime": "workspace:^", - "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", - "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.9.0", "react": "^17.0.2", "react-dom": "^17.0.2", "ses": "^1.14.0" diff --git a/packages/extension/scripts/build-constants.mjs b/packages/extension/scripts/build-constants.mjs index 2954c8f7c..8d91c97c0 100644 --- a/packages/extension/scripts/build-constants.mjs +++ b/packages/extension/scripts/build-constants.mjs @@ -18,7 +18,7 @@ export const kernelBrowserRuntimeSrcDir = path.resolve( */ export const trustedPreludes = { background: { - path: path.resolve(sourceDir, 'env/background-trusted-prelude.js'), + content: "import './endoify.js';", }, 'kernel-worker': { content: "import './endoify.js';" }, }; diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index de4fabca5..2805e01e7 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,16 +1,22 @@ +import { E } from '@endo/eventual-send'; import { - connectToKernel, - rpcMethodSpecs, + makeBackgroundCapTP, + makePresenceManager, + makeCapTPNotification, + isCapTPNotification, + getCapTPMessage, +} from '@metamask/kernel-browser-runtime'; +import type { + KernelFacade, + CapTPMessage, } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; -import { RpcClient } from '@metamask/kernel-rpc-methods'; -import { delay } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import { kernelMethodSpecs } from '@metamask/ocap-kernel/rpc'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -import { isJsonRpcResponse } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; + +defineGlobals(); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); @@ -79,49 +85,49 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); + // Create stream for CapTP messages const offscreenStream = await ChromeRuntimeDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(chrome.runtime, 'background', 'offscreen', isJsonRpcResponse); - - const rpcClient = new RpcClient( - kernelMethodSpecs, - async (request) => { - await offscreenStream.write(request); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); + + // Set up CapTP for E() based communication with the kernel + const backgroundCapTP = makeBackgroundCapTP({ + send: (captpMessage: CapTPMessage) => { + const notification = makeCapTPNotification(captpMessage); + offscreenStream.write(notification).catch((error) => { + logger.error('Failed to send CapTP message:', error); + }); }, - 'background:', - ); + }); - const ping = async (): Promise => { - const result = await rpcClient.call('ping', []); - logger.info(result); - }; + // Get the kernel remote presence + const kernelP = backgroundCapTP.getKernel(); + globalThis.kernel = kernelP; - // globalThis.kernel will exist due to dev-console.js in background-trusted-prelude.js - Object.defineProperties(globalThis.kernel, { - ping: { - value: ping, - }, - sendMessage: { - value: async (message: JsonRpcCall) => - await offscreenStream.write(message), - }, - }); - harden(globalThis.kernel); + // Create presence manager for E() calls on vat objects + const presenceManager = makePresenceManager({ kernelFacade: kernelP }); + Object.assign(globalThis.captp, presenceManager); // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - ping().catch(logger.error); + E(kernelP).ping().catch(logger.error); }); - // Pipe responses back to the RpcClient - const drainPromise = offscreenStream.drain(async (message) => - rpcClient.handleResponse(message.id as string, message), - ); + // Handle incoming CapTP messages from the kernel + const drainPromise = offscreenStream.drain((message) => { + if (isCapTPNotification(message)) { + const captpMessage = getCapTPMessage(message); + backgroundCapTP.dispatch(captpMessage); + } + }); drainPromise.catch(logger.error); - await ping(); // Wait for the kernel to be ready - await startDefaultSubcluster(); + await E(kernelP).ping(); // Wait for the kernel to be ready + const rootKref = await startDefaultSubcluster(kernelP); + if (rootKref) { + await greetBootstrapVat(rootKref); + } try { await drainPromise; @@ -134,30 +140,60 @@ async function main(): Promise { /** * Idempotently starts the default subcluster. + * + * @param kernelPromise - Promise for the kernel facade. + * @returns The rootKref of the bootstrap vat if launched, undefined if subcluster already exists. */ -async function startDefaultSubcluster(): Promise { - const kernelStream = await connectToKernel({ label: 'background', logger }); - const rpcClient = new RpcClient( - rpcMethodSpecs, - async (request) => { - await kernelStream.write(request); - }, - 'background', - ); +async function startDefaultSubcluster( + kernelPromise: Promise, +): Promise { + const kernel = await kernelPromise; + const status = await E(kernel).getStatus(); - kernelStream - .drain(async (message) => - rpcClient.handleResponse(message.id as string, message), - ) - .catch(logger.error); - - const status = await rpcClient.call('getStatus', []); if (status.subclusters.length === 0) { - const result = await rpcClient.call('launchSubcluster', { - config: defaultSubcluster, - }); + const result = await E(kernel).launchSubcluster(defaultSubcluster); logger.info(`Default subcluster launched: ${JSON.stringify(result)}`); - } else { - logger.info('Subclusters already exist. Not launching default subcluster.'); + return result.rootKref; } + logger.info('Subclusters already exist. Not launching default subcluster.'); + return undefined; +} + +/** + * Greets the bootstrap vat by calling its hello() method. + * + * @param rootKref - The kref of the bootstrap vat's root object. + */ +async function greetBootstrapVat(rootKref: string): Promise { + const rootPresence = captp.resolveKref(rootKref) as { + hello: (from: string) => string; + }; + const greeting = await E(rootPresence).hello('background'); + logger.info(`Got greeting from bootstrap vat: ${greeting}`); +} + +/** + * Define globals accessible via the background console. + */ +function defineGlobals(): void { + Object.defineProperty(globalThis, 'kernel', { + configurable: false, + enumerable: true, + writable: true, + value: {}, + }); + + Object.defineProperty(globalThis, 'captp', { + configurable: false, + enumerable: true, + writable: false, + value: {}, + }); + + Object.defineProperty(globalThis, 'E', { + value: E, + configurable: false, + enumerable: true, + writable: false, + }); } diff --git a/packages/extension/src/env/background-trusted-prelude.js b/packages/extension/src/env/background-trusted-prelude.js deleted file mode 100644 index d026032b6..000000000 --- a/packages/extension/src/env/background-trusted-prelude.js +++ /dev/null @@ -1,3 +0,0 @@ -// eslint-disable-next-line import-x/no-unresolved -import './endoify.js'; -import './dev-console.js'; diff --git a/packages/extension/src/env/dev-console.js b/packages/extension/src/env/dev-console.js deleted file mode 100644 index c91e8e197..000000000 --- a/packages/extension/src/env/dev-console.js +++ /dev/null @@ -1,9 +0,0 @@ -// We set this property on globalThis in the background before lockdown. -Object.defineProperty(globalThis, 'kernel', { - configurable: false, - enumerable: true, - writable: false, - value: {}, -}); - -export {}; diff --git a/packages/extension/src/env/dev-console.test.ts b/packages/extension/src/env/dev-console.test.ts deleted file mode 100644 index e086ecda8..000000000 --- a/packages/extension/src/env/dev-console.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import './dev-console.js'; - -describe('vat-console', () => { - describe('kernel', () => { - it('is available on globalThis', async () => { - expect(kernel).toBeDefined(); - }); - - it('has expected property descriptors', async () => { - expect( - Object.getOwnPropertyDescriptor(globalThis, 'kernel'), - ).toMatchObject({ - configurable: false, - enumerable: true, - writable: false, - }); - }); - }); -}); diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts new file mode 100644 index 000000000..c67f8b339 --- /dev/null +++ b/packages/extension/src/global.d.ts @@ -0,0 +1,37 @@ +import type { + PresenceManager, + KernelFacade, +} from '@metamask/kernel-browser-runtime'; + +// Type declarations for kernel dev console API. +declare global { + /** + * The E() function from @endo/eventual-send for making eventual sends. + * Set globally in the trusted prelude before lockdown. + * + * @example + * ```typescript + * const kernel = await kernel.getKernel(); + * const status = await E(kernel).getStatus(); + * ``` + */ + // eslint-disable-next-line no-var,id-length + var E: typeof import('@endo/eventual-send').E; + + // eslint-disable-next-line no-var + var kernel: KernelFacade | Promise; + + /** + * CapTP utilities for resolving krefs to E()-callable presences. + * + * @example + * ```typescript + * const alice = captp.resolveKref('ko1'); + * await E(alice).hello('console'); + * ``` + */ + // eslint-disable-next-line no-var + var captp: PresenceManager; +} + +export {}; diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 0f0e2dcef..c09ec2772 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -3,8 +3,8 @@ import { PlatformServicesServer, createRelayQueryString, } from '@metamask/kernel-browser-runtime'; -import { delay, isJsonRpcCall } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { DuplexStream } from '@metamask/streams'; import { @@ -13,8 +13,6 @@ import { MessagePortDuplexStream, } from '@metamask/streams/browser'; import type { PostMessageTarget } from '@metamask/streams/browser'; -import type { JsonRpcResponse } from '@metamask/utils'; -import { isJsonRpcResponse } from '@metamask/utils'; const logger = new Logger('offscreen'); @@ -27,11 +25,11 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); - // Create stream for messages from the background script + // Create stream for CapTP messages from the background script const backgroundStream = await ChromeRuntimeDuplexStream.make< - JsonRpcCall, - JsonRpcResponse - >(chrome.runtime, 'offscreen', 'background', isJsonRpcCall); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); const kernelStream = await makeKernelWorker(); @@ -48,7 +46,7 @@ async function main(): Promise { * @returns The message port stream for worker communication */ async function makeKernelWorker(): Promise< - DuplexStream + DuplexStream > { // Assign local relay address generated from `yarn ocap relay` const relayQueryString = createRelayQueryString([ @@ -72,9 +70,9 @@ async function makeKernelWorker(): Promise< ); const kernelStream = await MessagePortDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(port, isJsonRpcResponse); + JsonRpcMessage, + JsonRpcMessage + >(port, isJsonRpcMessage); await PlatformServicesServer.make(worker as PostMessageTarget, (vatId) => makeIframeVatWorker({ diff --git a/packages/extension/test/build/build-tests.ts b/packages/extension/test/build/build-tests.ts index fcd0aedd8..f293c5fe8 100644 --- a/packages/extension/test/build/build-tests.ts +++ b/packages/extension/test/build/build-tests.ts @@ -2,21 +2,13 @@ import { runTests } from '@ocap/repo-tools/build-utils/test'; import type { UntransformedFiles } from '@ocap/repo-tools/build-utils/test'; import path from 'node:path'; -import { - outDir, - sourceDir, - trustedPreludes, -} from '../../scripts/build-constants.mjs'; +import { outDir, trustedPreludes } from '../../scripts/build-constants.mjs'; const untransformedFiles = [ { sourcePath: path.resolve('../kernel-shims/dist/endoify.js'), buildPath: path.resolve(outDir, 'endoify.js'), }, - { - sourcePath: path.resolve(sourceDir, 'env/dev-console.js'), - buildPath: path.resolve(outDir, 'dev-console.js'), - }, ...Object.values(trustedPreludes).map((prelude) => { if ('path' in prelude) { return { diff --git a/packages/extension/tsconfig.build.json b/packages/extension/tsconfig.build.json index 8da52bd25..d7b547202 100644 --- a/packages/extension/tsconfig.build.json +++ b/packages/extension/tsconfig.build.json @@ -21,10 +21,5 @@ { "path": "../ocap-kernel/tsconfig.build.json" }, { "path": "../streams/tsconfig.build.json" } ], - "include": [ - "./src/**/*.ts", - "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js" - ] + "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index bd2e0aef6..e2d7cddd2 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -28,8 +28,6 @@ "./playwright.config.ts", "./src/**/*.ts", "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js", "./test/**/*.ts", "./vite.config.ts", "./vitest.config.ts" diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index fc7482636..91ed7d421 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -35,8 +35,6 @@ const staticCopyTargets: readonly (string | Target)[] = [ // The extension manifest 'packages/extension/src/manifest.json', // Trusted prelude-related - 'packages/extension/src/env/dev-console.js', - 'packages/extension/src/env/background-trusted-prelude.js', 'packages/kernel-shims/dist/endoify.js', ]; diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index cb930dd93..ba706b009 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -59,10 +59,12 @@ "test:build": "tsx ./test/build-tests.ts", "test:clean": "yarn test --no-cache --coverage.clean", "test:dev": "yarn test --mode development --reporter dot", + "test:integration": "vitest run --config vitest.integration.config.ts", "test:verbose": "yarn test --reporter verbose", "test:watch": "vitest --config vitest.config.ts" }, "dependencies": { + "@endo/captp": "^4.4.8", "@endo/marshal": "^1.8.0", "@metamask/json-rpc-engine": "^10.2.0", "@metamask/kernel-errors": "workspace:^", @@ -82,6 +84,8 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", + "@endo/eventual-send": "^1.3.4", + "@libp2p/webrtc": "5.2.24", "@metamask/auto-changelog": "^5.3.0", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", diff --git a/packages/kernel-browser-runtime/src/background-captp.test.ts b/packages/kernel-browser-runtime/src/background-captp.test.ts new file mode 100644 index 000000000..c5b5a9af8 --- /dev/null +++ b/packages/kernel-browser-runtime/src/background-captp.test.ts @@ -0,0 +1,148 @@ +import type { JsonRpcNotification } from '@metamask/utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { + isCapTPNotification, + getCapTPMessage, + makeCapTPNotification, + makeBackgroundCapTP, +} from './background-captp.ts'; +import type { CapTPMessage } from './background-captp.ts'; + +const makeNotification = ( + params: CapTPMessage[], + method = 'captp', +): JsonRpcNotification => ({ + jsonrpc: '2.0', + method, + params, +}); + +describe('isCapTPNotification', () => { + it('returns true for valid CapTP notification', () => { + const notification = makeNotification([{ type: 'foo' }]); + expect(isCapTPNotification(notification)).toBe(true); + }); + + it('returns false when method is not "captp"', () => { + const message = makeNotification([{ type: 'foo' }], 'other'); + expect(isCapTPNotification(message)).toBe(false); + }); + + it('returns false when params is not an array', () => { + // @ts-expect-error - we want to test the error case + const message = makeNotification({ type: 'foo' }); + expect(isCapTPNotification(message as never)).toBe(false); + }); + + it('returns false when params is empty', () => { + const message = makeNotification([]); + expect(isCapTPNotification(message)).toBe(false); + }); + + it('returns false when params has more than one element', () => { + const message = makeNotification([{ type: 'foo' }, { type: 'bar' }]); + expect(isCapTPNotification(message)).toBe(false); + }); + + it('returns true for JSON-RPC request with id if it matches captp format', () => { + // A request with an id is still a valid captp message format-wise + const request = { + jsonrpc: '2.0' as const, + id: 1, + method: 'captp', + params: [{ type: 'foo' }], + }; + expect(isCapTPNotification(request)).toBe(true); + }); +}); + +describe('getCapTPMessage', () => { + it('extracts CapTP message from valid notification', () => { + const captpMessage: CapTPMessage = { type: 'CTP_CALL', methargs: [] }; + const notification = makeNotification([captpMessage]); + expect(getCapTPMessage(notification)).toStrictEqual(captpMessage); + }); + + it('throws for non-CapTP notification', () => { + const message = { + jsonrpc: '2.0', + method: 'other', + params: [], + }; + // @ts-expect-error - we want to test the error case + expect(() => getCapTPMessage(message)).toThrow('Not a CapTP notification'); + }); + + it('throws when params is empty', () => { + const message = makeNotification([]); + expect(() => getCapTPMessage(message)).toThrow('Not a CapTP notification'); + }); +}); + +describe('makeCapTPNotification', () => { + it('wraps CapTP message in JSON-RPC notification', () => { + const captpMessage: CapTPMessage = { type: 'CTP_CALL', target: 'ko1' }; + const result = makeCapTPNotification(captpMessage); + + expect(result).toStrictEqual({ + jsonrpc: '2.0', + method: 'captp', + params: [captpMessage], + }); + }); + + it('creates valid notification that passes isCapTPNotification', () => { + const captpMessage: CapTPMessage = { type: 'CTP_RESOLVE' }; + const notification = makeCapTPNotification(captpMessage); + + expect(isCapTPNotification(notification)).toBe(true); + }); +}); + +describe('makeBackgroundCapTP', () => { + let sendMock: (message: CapTPMessage) => void; + + beforeEach(() => { + sendMock = vi.fn(); + }); + + it('returns object with dispatch, getKernel, and abort', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + + expect(capTP).toHaveProperty('dispatch'); + expect(capTP).toHaveProperty('getKernel'); + expect(capTP).toHaveProperty('abort'); + expect(typeof capTP.dispatch).toBe('function'); + expect(typeof capTP.getKernel).toBe('function'); + expect(typeof capTP.abort).toBe('function'); + }); + + it('getKernel returns a promise', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + const result = capTP.getKernel(); + + expect(result).toBeInstanceOf(Promise); + }); + + it('calls send function when dispatching bootstrap request', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + + // Calling getKernel triggers a bootstrap request (ignore unhandled promise) + capTP.getKernel().catch(() => undefined); + + // CapTP should have sent a message to request bootstrap + expect(sendMock).toHaveBeenCalled(); + const sentMessage = vi.mocked(sendMock).mock.calls[0]?.[0] as CapTPMessage; + expect(sentMessage).toBeDefined(); + }); + + it('dispatch returns boolean', () => { + const capTP = makeBackgroundCapTP({ send: sendMock }); + + // Dispatch a dummy message (will return false since it's not a valid CapTP message) + const result = capTP.dispatch({ type: 'unknown' }); + + expect(typeof result).toBe('boolean'); + }); +}); diff --git a/packages/kernel-browser-runtime/src/background-captp.ts b/packages/kernel-browser-runtime/src/background-captp.ts new file mode 100644 index 000000000..d6692e3b5 --- /dev/null +++ b/packages/kernel-browser-runtime/src/background-captp.ts @@ -0,0 +1,127 @@ +import { makeCapTP } from '@endo/captp'; +import type { JsonRpcMessage, JsonRpcCall } from '@metamask/kernel-utils'; +import type { Json, JsonRpcNotification } from '@metamask/utils'; + +import type { KernelFacade } from './types.ts'; + +/** + * A CapTP message that can be sent over the wire. + */ +export type CapTPMessage = Record; + +/** + * A CapTP JSON-RPC notification. + */ +export type CapTPNotification = JsonRpcNotification & { + method: 'captp'; + params: [CapTPMessage]; +}; + +/** + * Check if a message is a CapTP JSON-RPC notification. + * + * @param message - The message to check. + * @returns True if the message is a CapTP notification. + */ +export function isCapTPNotification( + message: JsonRpcMessage, +): message is CapTPNotification { + const { method, params } = message as JsonRpcCall; + return method === 'captp' && Array.isArray(params) && params.length === 1; +} + +/** + * Extract the CapTP message from a notification. + * + * @param message - The notification message. + * @returns The CapTP message. + */ +export function getCapTPMessage(message: JsonRpcMessage): CapTPMessage { + if (!isCapTPNotification(message)) { + throw new Error('Not a CapTP notification'); + } + return (message as unknown as { params: [CapTPMessage] }).params[0]; +} + +/** + * Create a CapTP JSON-RPC notification. + * + * @param captpMessage - The CapTP message to wrap. + * @returns The JSON-RPC notification. + */ +export function makeCapTPNotification(captpMessage: CapTPMessage): JsonRpcCall { + return { + jsonrpc: '2.0', + method: 'captp', + params: [captpMessage as unknown as Record], + }; +} + +/** + * Options for creating a background CapTP endpoint. + */ +export type BackgroundCapTPOptions = { + /** + * Function to send CapTP messages to the kernel. + * + * @param message - The CapTP message to send. + */ + send: (message: CapTPMessage) => void; +}; + +/** + * The background's CapTP endpoint. + */ +export type BackgroundCapTP = { + /** + * Dispatch an incoming CapTP message from the kernel. + * + * @param message - The CapTP message to dispatch. + * @returns True if the message was handled. + */ + dispatch: (message: CapTPMessage) => boolean; + + /** + * Get the remote kernel facade. + * This is how the background calls kernel methods using E(). + * + * @returns A promise for the kernel facade remote presence. + */ + getKernel: () => Promise; + + /** + * Abort the CapTP connection. + * + * @param reason - The reason for aborting. + */ + abort: (reason?: unknown) => void; +}; + +/** + * Create a CapTP endpoint for the background script. + * + * This sets up a CapTP connection to the kernel. The background can then use + * `E(kernel).method()` to call kernel methods. + * + * @param options - The options for creating the CapTP endpoint. + * @returns The background CapTP endpoint. + */ +export function makeBackgroundCapTP( + options: BackgroundCapTPOptions, +): BackgroundCapTP { + const { send } = options; + + // Create the CapTP endpoint (no bootstrap - we only want to call the kernel) + const { dispatch, getBootstrap, abort } = makeCapTP( + 'background', + send, + undefined, + ); + + return harden({ + dispatch, + getKernel: getBootstrap as () => Promise, + abort, + }); +} +harden(makeBackgroundCapTP); diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index a564a7a53..dd96eaf49 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -9,8 +9,13 @@ describe('index', () => { 'PlatformServicesServer', 'connectToKernel', 'createRelayQueryString', + 'getCapTPMessage', 'getRelaysFromCurrentLocation', + 'isCapTPNotification', + 'makeBackgroundCapTP', + 'makeCapTPNotification', 'makeIframeVatWorker', + 'makePresenceManager', 'parseRelayQueryString', 'receiveInternalConnections', 'rpcHandlers', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 646db42f1..79fb7036a 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -11,3 +11,18 @@ export * from './makeIframeVatWorker.ts'; export * from './PlatformServicesClient.ts'; export * from './PlatformServicesServer.ts'; export * from './utils/index.ts'; +export type { KernelFacade } from './types.ts'; +export { + makeBackgroundCapTP, + isCapTPNotification, + getCapTPMessage, + makeCapTPNotification, + type BackgroundCapTP, + type BackgroundCapTPOptions, + type CapTPMessage, +} from './background-captp.ts'; +export { + makePresenceManager, + type PresenceManager, + type PresenceManagerOptions, +} from './kref-presence.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts new file mode 100644 index 000000000..1b0ea2e0a --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts @@ -0,0 +1,199 @@ +// Note: Lockdown runs via setupFiles in vitest.config.ts (endoify-node.js) +// which imports @libp2p/webrtc (and thus reflect-metadata) before lockdown + +import { E } from '@endo/eventual-send'; +import type { ClusterConfig, Kernel } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelCapTP } from './kernel-captp.ts'; +import { makeBackgroundCapTP } from '../../background-captp.ts'; +import type { CapTPMessage } from '../../background-captp.ts'; + +/** + * Integration tests for CapTP communication between background and kernel endpoints. + * + * These tests validate that the two CapTP endpoints can communicate correctly + * and that E() works properly with the kernel facade remote presence. + */ +describe('CapTP Integration', () => { + let mockKernel: Kernel; + let kernelCapTP: ReturnType; + let backgroundCapTP: ReturnType; + + beforeEach(() => { + // Create mock kernel with method implementations + mockKernel = { + launchSubcluster: vi.fn().mockResolvedValue({ + subclusterId: 'sc1', + bootstrapRootKref: 'ko1', + bootstrapResult: { + body: '#{"result":"ok"}', + slots: [], + }, + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"message-sent"}', + slots: [], + }), + getStatus: vi.fn().mockResolvedValue({ + vats: [{ id: 'v1', name: 'test-vat' }], + subclusters: ['sc1'], + remoteComms: false, + }), + pingVat: vi.fn().mockResolvedValue({ + pingVatResult: 'pong', + roundTripMs: 5, + }), + } as unknown as Kernel; + + // Wire up CapTP endpoints to dispatch messages synchronously to each other + // This simulates direct message passing for testing + + // Kernel-side: exposes facade as bootstrap + kernelCapTP = makeKernelCapTP({ + kernel: mockKernel, + send: (message: CapTPMessage) => { + // Dispatch synchronously for testing + backgroundCapTP.dispatch(message); + }, + }); + + // Background-side: gets remote presence of kernel + backgroundCapTP = makeBackgroundCapTP({ + send: (message: CapTPMessage) => { + // Dispatch synchronously for testing + kernelCapTP.dispatch(message); + }, + }); + }); + + describe('bootstrap', () => { + it('background can get kernel remote presence via getKernel', async () => { + // Request the kernel facade - with synchronous dispatch, this resolves immediately + const kernel = await backgroundCapTP.getKernel(); + expect(kernel).toBeDefined(); + }); + }); + + describe('ping', () => { + it('e(kernel).ping() returns "pong"', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call ping via E() + const result = await E(kernel).ping(); + expect(result).toBe('pong'); + }); + }); + + describe('getStatus', () => { + it('e(kernel).getStatus() returns status from mock kernel', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call getStatus via E() + const result = await E(kernel).getStatus(); + expect(result).toStrictEqual({ + vats: [{ id: 'v1', name: 'test-vat' }], + subclusters: ['sc1'], + remoteComms: false, + }); + + expect(mockKernel.getStatus).toHaveBeenCalled(); + }); + }); + + describe('launchSubcluster', () => { + it('e(kernel).launchSubcluster() passes arguments correctly', async () => { + const config: ClusterConfig = { + bootstrap: 'v1', + vats: { + v1: { + bundleSpec: 'test-source', + }, + }, + }; + + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call launchSubcluster via E() + const result = await E(kernel).launchSubcluster(config); + + // The kernel facade now returns LaunchResult instead of CapData + expect(result).toStrictEqual({ + subclusterId: 'sc1', + rootKref: 'ko1', + }); + + expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); + }); + }); + + describe('terminateSubcluster', () => { + it('e(kernel).terminateSubcluster() delegates to kernel', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call terminateSubcluster via E() + await E(kernel).terminateSubcluster('sc1'); + expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith('sc1'); + }); + }); + + describe('queueMessage', () => { + it('e(kernel).queueMessage() passes arguments correctly', async () => { + const target = 'ko1'; + const method = 'doSomething'; + const args = ['arg1', { nested: 'value' }]; + + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call queueMessage via E() + const result = await E(kernel).queueMessage(target, method, args); + expect(result).toStrictEqual({ + body: '#{"result":"message-sent"}', + slots: [], + }); + + expect(mockKernel.queueMessage).toHaveBeenCalledWith( + target, + method, + args, + ); + }); + }); + + describe('pingVat', () => { + it('e(kernel).pingVat() delegates to kernel', async () => { + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call pingVat via E() + const result = await E(kernel).pingVat('v1'); + expect(result).toStrictEqual({ + pingVatResult: 'pong', + roundTripMs: 5, + }); + + expect(mockKernel.pingVat).toHaveBeenCalledWith('v1'); + }); + }); + + describe('error propagation', () => { + it('errors from kernel methods propagate to background', async () => { + const error = new Error('Kernel operation failed'); + vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); + + // Get kernel remote presence + const kernel = await backgroundCapTP.getKernel(); + + // Call getStatus which will fail + await expect(E(kernel).getStatus()).rejects.toThrow( + 'Kernel operation failed', + ); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts new file mode 100644 index 000000000..6e3ee7053 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/index.ts @@ -0,0 +1,7 @@ +export { + makeKernelCapTP, + type KernelCapTP, + type KernelCapTPOptions, +} from './kernel-captp.ts'; + +export { makeKernelFacade, type KernelFacade } from './kernel-facade.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts new file mode 100644 index 000000000..79dff95c6 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts @@ -0,0 +1,115 @@ +import type { Kernel } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelCapTP } from './kernel-captp.ts'; +import type { CapTPMessage } from './kernel-captp.ts'; + +describe('makeKernelCapTP', () => { + let mockKernel: Kernel; + let sendMock: (message: CapTPMessage) => void; + + beforeEach(() => { + mockKernel = { + launchSubcluster: vi.fn().mockResolvedValue({ + body: '#{"status":"ok"}', + slots: [], + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"success"}', + slots: [], + }), + getStatus: vi.fn().mockResolvedValue({ + vats: [], + subclusters: [], + remoteComms: false, + }), + pingVat: vi.fn().mockResolvedValue({ + pingVatResult: 'pong', + roundTripMs: 10, + }), + } as unknown as Kernel; + + sendMock = vi.fn(); + }); + + it('returns object with dispatch and abort', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(capTP).toHaveProperty('dispatch'); + expect(capTP).toHaveProperty('abort'); + expect(typeof capTP.dispatch).toBe('function'); + expect(typeof capTP.abort).toBe('function'); + }); + + it('dispatch returns boolean', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + // Dispatch a dummy message - will return false since it's not valid + const result = capTP.dispatch({ type: 'unknown' }); + + expect(typeof result).toBe('boolean'); + }); + + it('processes valid CapTP messages without errors', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + // Dispatch a valid CapTP message format + // CapTP uses array-based message format internally + // A CTP_CALL message triggers method calls on the bootstrap object + const callMessage: CapTPMessage = { + type: 'CTP_CALL', + questionID: 1, + target: 0, // Bootstrap slot + method: 'ping', + args: { body: '[]', slots: [] }, + }; + + // Should not throw when processing a message + expect(() => capTP.dispatch(callMessage)).not.toThrow(); + }); + + it('abort does not throw', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(() => capTP.abort()).not.toThrow(); + }); + + it('abort can be called with a reason', () => { + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(() => capTP.abort({ reason: 'test shutdown' })).not.toThrow(); + }); + + describe('kref marshalling', () => { + it('creates kernel CapTP with custom import/export tables', () => { + // Verify that makeKernelCapTP with the custom tables doesn't throw + const capTP = makeKernelCapTP({ + kernel: mockKernel, + send: sendMock, + }); + + expect(capTP).toBeDefined(); + expect(capTP.dispatch).toBeDefined(); + expect(capTP.abort).toBeDefined(); + + // The custom tables are internal to CapTP, so we can't test them directly + // Integration tests will verify the end-to-end kref marshalling functionality + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts new file mode 100644 index 000000000..c077b189b --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts @@ -0,0 +1,204 @@ +import { makeCapTP } from '@endo/captp'; +import type { Kernel, KRef } from '@metamask/ocap-kernel'; +import type { Json } from '@metamask/utils'; + +import { makeKernelFacade } from './kernel-facade.ts'; + +/** + * A CapTP message that can be sent over the wire. + */ +export type CapTPMessage = Record; + +/** + * Options for creating a kernel CapTP endpoint. + */ +export type KernelCapTPOptions = { + /** + * The kernel instance to expose via CapTP. + */ + kernel: Kernel; + + /** + * Function to send CapTP messages to the background. + * + * @param message - The CapTP message to send. + */ + send: (message: CapTPMessage) => void; +}; + +/** + * The kernel's CapTP endpoint. + */ +export type KernelCapTP = { + /** + * Dispatch an incoming CapTP message from the background. + * + * @param message - The CapTP message to dispatch. + * @returns True if the message was handled. + */ + dispatch: (message: CapTPMessage) => boolean; + + /** + * Abort the CapTP connection. + * + * @param reason - The reason for aborting. + */ + abort: (reason?: Json) => void; +}; + +/** + * Create a proxy object that routes method calls to kernel.queueMessage(). + * + * This proxy is what kernel-side code receives when background passes + * a kref presence back as an argument. + * + * @param kref - The kernel reference string. + * @param kernel - The kernel instance to route calls to. + * @returns A proxy object that routes method calls. + */ +function makeKrefProxy(kref: KRef, kernel: Kernel): Record { + return new Proxy( + {}, + { + get(_target, prop: string | symbol) { + if (typeof prop !== 'string') { + return undefined; + } + + // Return a function that queues the message + return async (...args: unknown[]) => { + return kernel.queueMessage(kref, prop, args); + }; + }, + }, + ); +} + +/** + * Create custom CapTP import/export tables that handle krefs specially. + * + * Export side: When kernel returns CapData with krefs in slots, we convert + * each kref into an exportable object that CapTP can marshal. + * + * Import side: When background sends a kref presence back, we convert it + * back to the original kref for kernel.queueMessage(). + * + * @param kernel - The kernel instance for routing messages. + * @returns Import/export tables for CapTP. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function makeKrefTables(kernel: Kernel): { + exportSlot: (passable: unknown) => string | undefined; + importSlot: (slotId: string) => unknown; + didDisconnect: () => void; +} { + // Map kref strings to unique slot IDs for CapTP + const krefToSlotId = new Map(); + const slotIdToKref = new Map(); + let nextSlotId = 0; + + // Map kref strings to proxy objects (for import side) + const krefToProxy = new Map(); + + return { + /** + * Export: Convert kref wrapper objects into CapTP slot IDs. + * + * When kernel facade returns `{ kref: 'ko42' }`, this converts it to + * a slot ID like 'kref:0' that CapTP can send to background. + * + * @param passable - The object to potentially export as a slot. + * @returns Slot ID if the object is a kref wrapper, undefined otherwise. + */ + exportSlot(passable: unknown): string | undefined { + // Check if passable is a kref wrapper: exactly { kref: string } where kref starts with 'ko' + if ( + typeof passable === 'object' && + passable !== null && + Object.keys(passable).length === 1 && + 'kref' in passable && + typeof (passable as { kref: unknown }).kref === 'string' && + (passable as { kref: string }).kref.startsWith('ko') + ) { + const { kref } = passable as { kref: string }; + + // Get or create slot ID for this kref + let slotId = krefToSlotId.get(kref); + if (!slotId) { + slotId = `kref:${nextSlotId}`; + nextSlotId += 1; + krefToSlotId.set(kref, slotId); + slotIdToKref.set(slotId, kref); + } + + return slotId; + } + return undefined; + }, + + /** + * Import: Convert CapTP slot IDs back into kref proxy objects. + * + * When background sends a kref presence back as an argument, this + * converts it to a proxy that routes calls to kernel.queueMessage(). + * + * @param slotId - The CapTP slot ID to import. + * @returns A proxy object for the kref, or undefined if unknown slot. + */ + importSlot(slotId: string): unknown { + const kref = slotIdToKref.get(slotId); + if (!kref) { + return undefined; + } + + // Return cached proxy or create new one + let proxy = krefToProxy.get(kref); + if (!proxy) { + proxy = makeKrefProxy(kref, kernel); + krefToProxy.set(kref, proxy); + } + + return proxy; + }, + + /** + * Hook called when CapTP disconnects. Not used for kref marshalling. + */ + didDisconnect() { + // Clean up resources if needed + krefToSlotId.clear(); + slotIdToKref.clear(); + krefToProxy.clear(); + }, + }; +} + +/** + * Create a CapTP endpoint for the kernel. + * + * This sets up a CapTP connection that exposes the kernel facade as the + * bootstrap object. The background can then use `E(kernel).method()` to + * call kernel methods. + * + * @param options - The options for creating the CapTP endpoint. + * @returns The kernel CapTP endpoint. + */ +export function makeKernelCapTP(options: KernelCapTPOptions): KernelCapTP { + const { kernel, send } = options; + + // Create the kernel facade that will be exposed to the background + const kernelFacade = makeKernelFacade(kernel); + + // TODO: Custom kref tables for marshalling are currently disabled + // They need further investigation to work correctly with CapTP's message flow + // const krefTables = makeKrefTables(kernel); + + // Create the CapTP endpoint + const { dispatch, abort } = makeCapTP('kernel', send, kernelFacade); + + return harden({ + dispatch, + abort, + }); +} +harden(makeKernelCapTP); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts new file mode 100644 index 000000000..1b9d89098 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts @@ -0,0 +1,231 @@ +import type { + ClusterConfig, + Kernel, + KernelStatus, + KRef, + VatId, +} from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeKernelFacade } from './kernel-facade.ts'; +import type { KernelFacade } from './kernel-facade.ts'; + +const makeClusterConfig = (): ClusterConfig => ({ + bootstrap: 'v1', + vats: { + v1: { + bundleSpec: 'test-source', + }, + }, +}); + +describe('makeKernelFacade', () => { + let mockKernel: Kernel; + let facade: KernelFacade; + + beforeEach(() => { + mockKernel = { + launchSubcluster: vi.fn().mockResolvedValue({ + subclusterId: 'sc1', + bootstrapRootKref: 'ko1', + bootstrapResult: { + body: '#{"result":"ok"}', + slots: [], + }, + }), + terminateSubcluster: vi.fn().mockResolvedValue(undefined), + queueMessage: vi.fn().mockResolvedValue({ + body: '#{"result":"success"}', + slots: [], + }), + getStatus: vi.fn().mockResolvedValue({ + vats: [], + subclusters: [], + remoteComms: { isInitialized: false }, + }), + pingVat: vi.fn().mockResolvedValue('pong'), + } as unknown as Kernel; + + facade = makeKernelFacade(mockKernel); + }); + + describe('ping', () => { + it('returns "pong"', async () => { + const result = await facade.ping(); + expect(result).toBe('pong'); + }); + }); + + describe('launchSubcluster', () => { + it('delegates to kernel with correct arguments', async () => { + const config = makeClusterConfig(); + + await facade.launchSubcluster(config); + + expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); + expect(mockKernel.launchSubcluster).toHaveBeenCalledTimes(1); + }); + + it('returns result with subclusterId and rootKref from kernel', async () => { + const kernelResult = { + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult: { body: '#null', slots: [] }, + }; + vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce( + kernelResult, + ); + + const config = makeClusterConfig(); + + const result = await facade.launchSubcluster(config); + + expect(result).toStrictEqual({ + subclusterId: 's1', + rootKref: 'ko1', + }); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Launch failed'); + vi.mocked(mockKernel.launchSubcluster).mockRejectedValueOnce(error); + + const config = makeClusterConfig(); + + await expect(facade.launchSubcluster(config)).rejects.toThrow(error); + }); + }); + + describe('terminateSubcluster', () => { + it('delegates to kernel with correct arguments', async () => { + const subclusterId = 'sc1'; + + await facade.terminateSubcluster(subclusterId); + + expect(mockKernel.terminateSubcluster).toHaveBeenCalledWith(subclusterId); + expect(mockKernel.terminateSubcluster).toHaveBeenCalledTimes(1); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Terminate failed'); + vi.mocked(mockKernel.terminateSubcluster).mockRejectedValueOnce(error); + + await expect(facade.terminateSubcluster('sc1')).rejects.toThrow(error); + }); + }); + + describe('queueMessage', () => { + it('delegates to kernel with correct arguments', async () => { + const target: KRef = 'ko1'; + const method = 'doSomething'; + const args = ['arg1', { nested: 'value' }]; + + await facade.queueMessage(target, method, args); + + expect(mockKernel.queueMessage).toHaveBeenCalledWith( + target, + method, + args, + ); + expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); + }); + + it('converts kref strings in args to standins', async () => { + const target: KRef = 'ko1'; + const method = 'sendTo'; + // Use ko refs only - kp refs become promise standins with different structure + const args = ['ko42', { target: 'ko99', data: 'hello' }]; + + await facade.queueMessage(target, method, args); + + // Verify the call was made + expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); + + // Get the actual args passed to kernel + const [, , processedArgs] = vi.mocked(mockKernel.queueMessage).mock + .calls[0]!; + + // First arg should be a standin with getKref method + expect(processedArgs[0]).toHaveProperty('getKref'); + expect((processedArgs[0] as { getKref: () => string }).getKref()).toBe( + 'ko42', + ); + + // Second arg should be an object with converted kref + const secondArg = processedArgs[1] as { + target: { getKref: () => string }; + data: string; + }; + expect(secondArg.target).toHaveProperty('getKref'); + expect(secondArg.target.getKref()).toBe('ko99'); + expect(secondArg.data).toBe('hello'); + }); + + it('returns result from kernel', async () => { + const expectedResult = { body: '#{"answer":42}', slots: [] }; + vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce(expectedResult); + + const result = await facade.queueMessage('ko1', 'compute', []); + expect(result).toStrictEqual(expectedResult); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Queue message failed'); + vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error); + + await expect(facade.queueMessage('ko1', 'method', [])).rejects.toThrow( + error, + ); + }); + }); + + describe('getStatus', () => { + it('delegates to kernel', async () => { + await facade.getStatus(); + + expect(mockKernel.getStatus).toHaveBeenCalled(); + expect(mockKernel.getStatus).toHaveBeenCalledTimes(1); + }); + + it('returns status from kernel', async () => { + const expectedStatus: KernelStatus = { + vats: [], + subclusters: [], + remoteComms: { isInitialized: false }, + }; + + const result = await facade.getStatus(); + expect(result).toStrictEqual(expectedStatus); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Get status failed'); + vi.mocked(mockKernel.getStatus).mockRejectedValueOnce(error); + + await expect(facade.getStatus()).rejects.toThrow(error); + }); + }); + + describe('pingVat', () => { + it('delegates to kernel with correct vatId', async () => { + const vatId: VatId = 'v1'; + + await facade.pingVat(vatId); + + expect(mockKernel.pingVat).toHaveBeenCalledWith(vatId); + expect(mockKernel.pingVat).toHaveBeenCalledTimes(1); + }); + + it('returns result from kernel', async () => { + const result = await facade.pingVat('v1'); + expect(result).toBe('pong'); + }); + + it('propagates errors from kernel', async () => { + const error = new Error('Ping vat failed'); + vi.mocked(mockKernel.pingVat).mockRejectedValueOnce(error); + + await expect(facade.pingVat('v1')).rejects.toThrow(error); + }); + }); +}); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts new file mode 100644 index 000000000..af363fcb3 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -0,0 +1,50 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; + +import { convertKrefsToStandins } from '../../kref-presence.ts'; +import type { KernelFacade, LaunchResult } from '../../types.ts'; + +export type { KernelFacade } from '../../types.ts'; + +/** + * Create the kernel facade exo that exposes kernel methods via CapTP. + * + * @param kernel - The kernel instance to wrap. + * @returns The kernel facade exo. + */ +export function makeKernelFacade(kernel: Kernel): KernelFacade { + return makeDefaultExo('KernelFacade', { + ping: async () => 'pong' as const, + + launchSubcluster: async (config: ClusterConfig): Promise => { + const { subclusterId, bootstrapRootKref } = + await kernel.launchSubcluster(config); + return { subclusterId, rootKref: bootstrapRootKref }; + }, + + terminateSubcluster: async (subclusterId: string) => { + return kernel.terminateSubcluster(subclusterId); + }, + + queueMessage: async (target: KRef, method: string, args: unknown[]) => { + // Convert kref strings in args to standins for kernel-marshal + const processedArgs = convertKrefsToStandins(args) as unknown[]; + return kernel.queueMessage(target, method, processedArgs); + }, + + getStatus: async () => { + return kernel.getStatus(); + }, + + pingVat: async (vatId: VatId) => { + return kernel.pingVat(vatId); + }, + + getVatRoot: async (krefString: string) => { + // Return wrapped kref for future CapTP marshalling to presence + // TODO: Enable custom CapTP marshalling tables to convert this to a presence + return { kref: krefString }; + }, + }); +} +harden(makeKernelFacade); diff --git a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts index 894711634..b480093c1 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/kernel-worker.ts @@ -1,7 +1,7 @@ import { JsonRpcServer } from '@metamask/json-rpc-engine/v2'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/wasm'; -import { isJsonRpcCall } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; import type { PostMessageTarget } from '@metamask/streams/browser'; @@ -9,13 +9,18 @@ import { MessagePortDuplexStream, receiveMessagePort, } from '@metamask/streams/browser'; -import type { JsonRpcResponse } from '@metamask/utils'; +import { + isCapTPNotification, + makeCapTPNotification, +} from '../background-captp.ts'; +import type { CapTPMessage } from '../background-captp.ts'; import { receiveInternalConnections } from '../internal-comms/internal-connections.ts'; import { PlatformServicesClient } from '../PlatformServicesClient.ts'; -import { getRelaysFromCurrentLocation } from '../utils/relay-query-string.ts'; +import { makeKernelCapTP } from './captp/index.ts'; import { makeLoggingMiddleware } from './middleware/logging.ts'; import { makePanelMessageMiddleware } from './middleware/panel-message.ts'; +import { getRelaysFromCurrentLocation } from '../utils/relay-query-string.ts'; const logger = new Logger('kernel-worker'); const DB_FILENAME = 'store.db'; @@ -32,11 +37,11 @@ async function main(): Promise { ); // Initialize kernel dependencies - const [kernelStream, platformServicesClient, kernelDatabase] = + const [messageStream, platformServicesClient, kernelDatabase] = await Promise.all([ - MessagePortDuplexStream.make( + MessagePortDuplexStream.make( port, - isJsonRpcCall, + isJsonRpcMessage, ), PlatformServicesClient.make(globalThis as PostMessageTarget), makeSQLKernelDatabase({ dbFilename: DB_FILENAME }), @@ -46,22 +51,19 @@ async function main(): Promise { new URLSearchParams(globalThis.location.search).get('reset-storage') === 'true'; - const kernelP = Kernel.make( - kernelStream, - platformServicesClient, - kernelDatabase, - { - resetStorage, - }, - ); + const kernelP = Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + }); + + // Set up internal RPC server for UI panel connections (uses separate MessagePorts) const handlerP = kernelP.then((kernel) => { const server = new JsonRpcServer({ middleware: [ - makeLoggingMiddleware(logger.subLogger('kernel-command')), + makeLoggingMiddleware(logger.subLogger('internal-rpc')), makePanelMessageMiddleware(kernel, kernelDatabase), ], }); - return async (request: JsonRpcCall) => server.handle(request); + return async (request: JsonRpcMessage) => server.handle(request); }); receiveInternalConnections({ @@ -71,6 +73,29 @@ async function main(): Promise { const kernel = await kernelP; + // Set up CapTP for background ↔ kernel communication + const kernelCapTP = makeKernelCapTP({ + kernel, + send: (captpMessage: CapTPMessage) => { + const notification = makeCapTPNotification(captpMessage); + messageStream.write(notification).catch((error) => { + logger.error('Failed to send CapTP message:', error); + }); + }, + }); + + // Handle incoming CapTP messages from the background + messageStream + .drain((message) => { + if (isCapTPNotification(message)) { + const captpMessage = message.params[0]; + kernelCapTP.dispatch(captpMessage); + } + }) + .catch((error) => { + logger.error('Message stream error:', error); + }); + // Initialize remote communications with the relay server passed in the query string const relays = getRelaysFromCurrentLocation(); await kernel.initRemoteComms({ relays }); diff --git a/packages/kernel-browser-runtime/src/kref-presence.test.ts b/packages/kernel-browser-runtime/src/kref-presence.test.ts new file mode 100644 index 000000000..a62d0b685 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kref-presence.test.ts @@ -0,0 +1,296 @@ +import { passStyleOf } from '@endo/marshal'; +import { krefOf as kernelKrefOf } from '@metamask/ocap-kernel'; +import type { SlotValue } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { PresenceManager } from './kref-presence.ts'; +import { + convertKrefsToStandins, + makePresenceManager, +} from './kref-presence.ts'; +import type { KernelFacade } from './types.ts'; + +// EHandler type definition (copied to avoid import issues with mocking) +type EHandler = { + get?: (target: object, prop: PropertyKey) => Promise; + applyMethod?: ( + target: object, + prop: PropertyKey, + args: unknown[], + ) => Promise; + applyFunction?: (target: object, args: unknown[]) => Promise; +}; + +// Hoisted mock setup - these must be defined before vi.mock() is hoisted +const { MockHandledPromise, mockE } = vi.hoisted(() => { + /** + * Mock HandledPromise that supports resolveWithPresence. + */ + class MockHandledPromiseImpl extends Promise { + constructor( + executor: ( + resolve: (value: TResult | PromiseLike) => void, + reject: (reason?: unknown) => void, + resolveWithPresence: (handler: EHandler) => object, + ) => void, + _handler?: EHandler, + ) { + let presence: object | undefined; + + const resolveWithPresence = (handler: EHandler): object => { + // Create a simple presence object that can receive E() calls + presence = new Proxy( + {}, + { + get(_target, prop) { + if (prop === Symbol.toStringTag) { + return 'Alleged: VatObject'; + } + // Return a function that calls the handler + return async (...args: unknown[]) => { + if (typeof prop === 'string') { + return handler.applyMethod?.(presence!, prop, args); + } + return undefined; + }; + }, + }, + ); + return presence; + }; + + super((resolve, reject) => { + executor(resolve, reject, resolveWithPresence); + }); + } + } + + // Mock E() to intercept calls on presences + const mockEImpl = (target: object) => { + return new Proxy( + {}, + { + get(_proxyTarget, prop) { + if (typeof prop === 'string') { + // Return a function that, when called, invokes the presence's method + return (...args: unknown[]) => { + const method = (target as Record)[prop]; + if (typeof method === 'function') { + return (method as (...a: unknown[]) => unknown)(...args); + } + // Try to get it from the proxy + return (target as Record unknown>)[ + prop + ]?.(...args); + }; + } + return undefined; + }, + }, + ); + }; + + return { + MockHandledPromise: MockHandledPromiseImpl, + mockE: mockEImpl, + }; +}); + +// Apply mocks +vi.mock('@endo/eventual-send', () => ({ + E: mockE, + HandledPromise: MockHandledPromise, +})); + +describe('convertKrefsToStandins', () => { + describe('kref string conversion', () => { + it('converts ko kref string to standin', () => { + const result = convertKrefsToStandins('ko123') as SlotValue; + + expect(passStyleOf(result)).toBe('remotable'); + expect(kernelKrefOf(result)).toBe('ko123'); + }); + + it('converts kp kref string to standin promise', () => { + const result = convertKrefsToStandins('kp456'); + + expect(passStyleOf(result)).toBe('promise'); + expect(kernelKrefOf(result as Promise)).toBe('kp456'); + }); + + it('does not convert non-kref strings', () => { + expect(convertKrefsToStandins('hello')).toBe('hello'); + expect(convertKrefsToStandins('k123')).toBe('k123'); + expect(convertKrefsToStandins('kox')).toBe('kox'); + expect(convertKrefsToStandins('ko')).toBe('ko'); + expect(convertKrefsToStandins('kp')).toBe('kp'); + expect(convertKrefsToStandins('ko123x')).toBe('ko123x'); + }); + }); + + describe('array processing', () => { + it('recursively converts krefs in arrays', () => { + const result = convertKrefsToStandins(['ko1', 'ko2']) as unknown[]; + + expect(result).toHaveLength(2); + expect(kernelKrefOf(result[0] as SlotValue)).toBe('ko1'); + expect(kernelKrefOf(result[1] as SlotValue)).toBe('ko2'); + }); + + it('handles mixed arrays with krefs and primitives', () => { + const result = convertKrefsToStandins([ + 'ko1', + 42, + 'hello', + true, + ]) as unknown[]; + + expect(result).toHaveLength(4); + expect(kernelKrefOf(result[0] as SlotValue)).toBe('ko1'); + expect(result[1]).toBe(42); + expect(result[2]).toBe('hello'); + expect(result[3]).toBe(true); + }); + + it('handles empty arrays', () => { + const result = convertKrefsToStandins([]); + expect(result).toStrictEqual([]); + }); + + it('handles nested arrays', () => { + const result = convertKrefsToStandins([['ko1'], ['ko2']]) as unknown[][]; + + expect(kernelKrefOf(result[0]![0] as SlotValue)).toBe('ko1'); + expect(kernelKrefOf(result[1]![0] as SlotValue)).toBe('ko2'); + }); + }); + + describe('object processing', () => { + it('recursively converts krefs in objects', () => { + const result = convertKrefsToStandins({ + target: 'ko1', + promise: 'kp2', + }) as Record; + + expect(kernelKrefOf(result.target as SlotValue)).toBe('ko1'); + expect(kernelKrefOf(result.promise as Promise)).toBe('kp2'); + }); + + it('handles nested objects', () => { + const result = convertKrefsToStandins({ + outer: { + inner: 'ko42', + }, + }) as Record>; + + expect(kernelKrefOf(result.outer!.inner as SlotValue)).toBe('ko42'); + }); + + it('handles empty objects', () => { + const result = convertKrefsToStandins({}); + expect(result).toStrictEqual({}); + }); + + it('handles objects with mixed values', () => { + const result = convertKrefsToStandins({ + kref: 'ko1', + number: 123, + string: 'text', + boolean: false, + nullValue: null, + }) as Record; + + expect(kernelKrefOf(result.kref as SlotValue)).toBe('ko1'); + expect(result.number).toBe(123); + expect(result.string).toBe('text'); + expect(result.boolean).toBe(false); + expect(result.nullValue).toBeNull(); + }); + }); + + describe('primitive handling', () => { + it('passes through numbers unchanged', () => { + expect(convertKrefsToStandins(42)).toBe(42); + expect(convertKrefsToStandins(0)).toBe(0); + expect(convertKrefsToStandins(-1)).toBe(-1); + }); + + it('passes through booleans unchanged', () => { + expect(convertKrefsToStandins(true)).toBe(true); + expect(convertKrefsToStandins(false)).toBe(false); + }); + + it('passes through null unchanged', () => { + expect(convertKrefsToStandins(null)).toBeNull(); + }); + + it('passes through undefined unchanged', () => { + expect(convertKrefsToStandins(undefined)).toBeUndefined(); + }); + }); +}); + +describe('makePresenceManager', () => { + let mockKernelFacade: KernelFacade; + let presenceManager: PresenceManager; + + beforeEach(() => { + mockKernelFacade = { + ping: vi.fn(), + launchSubcluster: vi.fn(), + terminateSubcluster: vi.fn(), + queueMessage: vi.fn(), + getStatus: vi.fn(), + pingVat: vi.fn(), + getVatRoot: vi.fn(), + } as unknown as KernelFacade; + + presenceManager = makePresenceManager({ + kernelFacade: mockKernelFacade, + }); + }); + + describe('resolveKref', () => { + it('returns a presence object for a kref', () => { + const presence = presenceManager.resolveKref('ko42'); + + expect(presence).toBeDefined(); + expect(typeof presence).toBe('object'); + }); + + it('returns the same presence for the same kref (memoization)', () => { + const presence1 = presenceManager.resolveKref('ko42'); + const presence2 = presenceManager.resolveKref('ko42'); + + expect(presence1).toBe(presence2); + }); + + it('returns different presences for different krefs', () => { + const presence1 = presenceManager.resolveKref('ko1'); + const presence2 = presenceManager.resolveKref('ko2'); + + expect(presence1).not.toBe(presence2); + }); + }); + + describe('krefOf', () => { + it('returns the kref for a known presence', () => { + const presence = presenceManager.resolveKref('ko42'); + const kref = presenceManager.krefOf(presence); + + expect(kref).toBe('ko42'); + }); + + it('returns undefined for an unknown object', () => { + const unknownObject = { foo: 'bar' }; + const kref = presenceManager.krefOf(unknownObject); + + expect(kref).toBeUndefined(); + }); + }); + + // Note: fromCapData and E() handler tests require the full Endo runtime + // environment with proper SES lockdown. These behaviors are tested in + // captp.integration.test.ts which runs with the real Endo setup. + // Unit tests here focus on the kref↔presence mapping functionality. +}); diff --git a/packages/kernel-browser-runtime/src/kref-presence.ts b/packages/kernel-browser-runtime/src/kref-presence.ts new file mode 100644 index 000000000..2fe10f332 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kref-presence.ts @@ -0,0 +1,287 @@ +/** + * Presence manager for creating E()-usable presences from kernel krefs. + * + * This module provides "slot translation" - converting kernel krefs (ko*, kp*) + * into presences that can receive eventual sends via E(). Method calls on these + * presences are forwarded to kernel.queueMessage() through the existing CapTP + * connection. + */ +import { E, HandledPromise } from '@endo/eventual-send'; +import type { EHandler } from '@endo/eventual-send'; +import { makeMarshal, Remotable } from '@endo/marshal'; +import type { CapData } from '@endo/marshal'; +import type { KRef } from '@metamask/ocap-kernel'; +import { kslot } from '@metamask/ocap-kernel'; + +import type { KernelFacade } from './types.ts'; + +/** + * Function type for sending messages to the kernel. + */ +type SendToKernelFn = ( + kref: string, + method: string, + args: unknown[], +) => Promise; + +/** + * Recursively convert kref strings in a value to kernel standins. + * + * When the background sends kref strings as arguments, we need to convert + * them to standin objects that kernel-marshal can serialize properly. + * + * @param value - The value to convert. + * @returns The value with kref strings converted to standins. + */ +export function convertKrefsToStandins(value: unknown): unknown { + // Check if it's a kref string (ko* or kp*) + if (typeof value === 'string' && /^k[op]\d+$/u.test(value)) { + return kslot(value); + } + // Recursively process arrays + if (Array.isArray(value)) { + return value.map(convertKrefsToStandins); + } + // Recursively process plain objects + if (typeof value === 'object' && value !== null) { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = convertKrefsToStandins(val); + } + return result; + } + // Return primitives as-is + return value; +} +harden(convertKrefsToStandins); + +/** + * Options for creating a presence manager. + */ +export type PresenceManagerOptions = { + /** + * The kernel facade remote presence from CapTP. + * Can be a promise since E() works with promises. + */ + kernelFacade: KernelFacade | Promise; +}; + +/** + * The presence manager interface. + */ +export type PresenceManager = { + /** + * Resolve a kref string to an E()-usable presence. + * + * @param kref - The kernel reference string (e.g., 'ko42', 'kp123'). + * @returns A presence that can receive E() calls. + */ + resolveKref: (kref: KRef) => object; + + /** + * Extract the kref from a presence. + * + * @param presence - A presence created by resolveKref. + * @returns The kref string, or undefined if not a kref presence. + */ + krefOf: (presence: object) => KRef | undefined; + + /** + * Deserialize a CapData result into presences. + * + * @param data - The CapData to deserialize. + * @returns The deserialized value with krefs converted to presences. + */ + fromCapData: (data: CapData) => unknown; +}; + +/** + * Create a remote kit for a kref, similar to CapTP's makeRemoteKit. + * Returns a settler that can create an E()-callable presence. + * + * @param kref - The kernel reference string. + * @param sendToKernel - Function to send messages to the kernel. + * @returns An object with a resolveWithPresence method. + */ +function makeKrefRemoteKit( + kref: string, + sendToKernel: SendToKernelFn, +): { resolveWithPresence: () => object } { + // Handler that intercepts E() calls on the presence + const handler: EHandler = { + async get(_target, prop) { + if (typeof prop !== 'string') { + return undefined; + } + // Property access: E(presence).prop returns a promise + return sendToKernel(kref, prop, []); + }, + async applyMethod(_target, prop, args) { + if (typeof prop !== 'string') { + throw new Error('Method name must be a string'); + } + // Method call: E(presence).method(args) + return sendToKernel(kref, prop, args); + }, + applyFunction(_target, _args) { + // Function call: E(presence)(args) - not supported for kref presences + throw new Error('Cannot call kref presence as a function'); + }, + }; + + let resolveWithPresenceFn: + | ((presenceHandler: EHandler) => object) + | undefined; + + // Create a HandledPromise to get access to resolveWithPresence + // We don't actually use the promise - we just need the resolver + // eslint-disable-next-line no-new, @typescript-eslint/no-floating-promises + new HandledPromise((_resolve, _reject, resolveWithPresence) => { + resolveWithPresenceFn = resolveWithPresence; + }, handler); + + return { + resolveWithPresence: () => { + if (!resolveWithPresenceFn) { + throw new Error('resolveWithPresence not initialized'); + } + return resolveWithPresenceFn(handler); + }, + }; +} + +/** + * Create an E()-usable presence for a kref. + * + * @param kref - The kernel reference string. + * @param iface - Interface name for the remotable. + * @param sendToKernel - Function to send messages to the kernel. + * @returns A presence that can receive E() calls. + */ +function makeKrefPresence( + kref: string, + iface: string, + sendToKernel: SendToKernelFn, +): object { + const kit = makeKrefRemoteKit(kref, sendToKernel); + // Wrap the presence in Remotable for proper pass-style + return Remotable(iface, undefined, kit.resolveWithPresence()); +} + +/** + * Create a presence manager for E() on vat objects. + * + * This creates presences from kernel krefs that forward method calls + * to kernel.queueMessage() via the existing CapTP connection. + * + * @param options - Options including the kernel facade. + * @returns The presence manager. + */ +export function makePresenceManager( + options: PresenceManagerOptions, +): PresenceManager { + const { kernelFacade } = options; + + // State for kref↔presence mapping + const krefToPresence = new Map(); + const presenceToKref = new WeakMap(); + + // Forward declaration for sendToKernel + // eslint-disable-next-line prefer-const + let marshal: ReturnType>; + + /** + * Send a message to the kernel and deserialize the result. + * + * @param kref - The target kernel reference. + * @param method - The method name to call. + * @param args - Arguments to pass to the method. + * @returns The deserialized result from the kernel. + */ + const sendToKernel: SendToKernelFn = async ( + kref: KRef, + method: string, + args: unknown[], + ): Promise => { + // Convert presence args to kref strings + const serializedArgs = args.map((arg) => { + if (typeof arg === 'object' && arg !== null) { + const argKref = presenceToKref.get(arg); + if (argKref) { + return argKref; // Pass kref string to kernel + } + } + return arg; // Pass primitive through + }); + + // Call kernel via existing CapTP + const result: CapData = await E(kernelFacade).queueMessage( + kref, + method, + serializedArgs, + ); + + // Deserialize result (krefs become presences) + return marshal.fromCapData(result); + }; + + /** + * Convert a kref slot to a presence. + * + * @param kref - The kernel reference string. + * @param iface - Optional interface name for the presence. + * @returns A presence object that can receive E() calls. + */ + const convertSlotToVal = (kref: KRef, iface?: string): object => { + let presence = krefToPresence.get(kref); + if (!presence) { + presence = makeKrefPresence( + kref, + iface ?? 'Alleged: VatObject', + sendToKernel, + ); + krefToPresence.set(kref, presence); + presenceToKref.set(presence, kref); + } + return presence; + }; + + /** + * Convert a presence to a kref slot. + * This is called by the marshal for pass-by-presence objects. + * Throws if the object is not a known kref presence. + * + * @param val - The value to convert to a kref. + * @returns The kernel reference string. + */ + const convertValToSlot = (val: unknown): KRef => { + if (typeof val === 'object' && val !== null) { + const kref = presenceToKref.get(val); + if (kref !== undefined) { + return kref; + } + } + throw new Error('Cannot serialize unknown remotable object'); + }; + + // Same options as kernel-marshal.ts + marshal = makeMarshal(convertValToSlot, convertSlotToVal, { + serializeBodyFormat: 'smallcaps', + errorTagging: 'off', + }); + + return harden({ + resolveKref: (kref: KRef): object => { + return convertSlotToVal(kref, 'Alleged: VatObject'); + }, + + krefOf: (presence: object): KRef | undefined => { + return presenceToKref.get(presence); + }, + + fromCapData: (data: CapData): unknown => { + return marshal.fromCapData(data); + }, + }); +} +harden(makePresenceManager); diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts index eb5abe2f0..aa60f21b4 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.test.ts @@ -3,9 +3,14 @@ import { describe, it, expect, vi } from 'vitest'; import { launchSubclusterHandler } from './launch-subcluster.ts'; describe('launchSubclusterHandler', () => { - it('should call kernel.launchSubcluster with the provided config', async () => { + it('calls kernel.launchSubcluster with the provided config', async () => { + const mockResult = { + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult: { body: '#null', slots: [] }, + }; const mockKernel = { - launchSubcluster: vi.fn().mockResolvedValue(undefined), + launchSubcluster: vi.fn().mockResolvedValue(mockResult), }; const params = { config: { @@ -20,9 +25,14 @@ describe('launchSubclusterHandler', () => { expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(params.config); }); - it('should return null when kernel.launchSubcluster returns undefined', async () => { + it('returns the result from kernel.launchSubcluster', async () => { + const mockResult = { + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult: { body: '#{"result":"ok"}', slots: [] }, + }; const mockKernel = { - launchSubcluster: vi.fn().mockResolvedValue(undefined), + launchSubcluster: vi.fn().mockResolvedValue(mockResult), }; const params = { config: { @@ -34,11 +44,15 @@ describe('launchSubclusterHandler', () => { { kernel: mockKernel }, params, ); - expect(result).toBeNull(); + expect(result).toStrictEqual(mockResult); }); - it('should return the result from kernel.launchSubcluster when not undefined', async () => { - const mockResult = { body: 'test', slots: [] }; + it('converts undefined bootstrapResult to null for JSON compatibility', async () => { + const mockResult = { + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult: undefined, + }; const mockKernel = { launchSubcluster: vi.fn().mockResolvedValue(mockResult), }; @@ -52,6 +66,10 @@ describe('launchSubclusterHandler', () => { { kernel: mockKernel }, params, ); - expect(result).toBe(mockResult); + expect(result).toStrictEqual({ + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult: null, + }); }); }); diff --git a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts index a79f7385a..c899b3dcd 100644 --- a/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts +++ b/packages/kernel-browser-runtime/src/rpc-handlers/launch-subcluster.ts @@ -1,17 +1,38 @@ import type { CapData } from '@endo/marshal'; import type { MethodSpec, Handler } from '@metamask/kernel-rpc-methods'; import type { Kernel, ClusterConfig, KRef } from '@metamask/ocap-kernel'; -import { CapDataStruct, ClusterConfigStruct } from '@metamask/ocap-kernel'; -import { object, nullable } from '@metamask/superstruct'; +import { ClusterConfigStruct, CapDataStruct } from '@metamask/ocap-kernel'; +import { + object, + string, + nullable, + type as structType, +} from '@metamask/superstruct'; + +/** + * JSON-compatible version of SubclusterLaunchResult for RPC. + * Uses null instead of undefined for JSON serialization. + */ +type LaunchSubclusterRpcResult = { + subclusterId: string; + bootstrapRootKref: string; + bootstrapResult: CapData | null; +}; + +const LaunchSubclusterRpcResultStruct = structType({ + subclusterId: string(), + bootstrapRootKref: string(), + bootstrapResult: nullable(CapDataStruct), +}); export const launchSubclusterSpec: MethodSpec< 'launchSubcluster', { config: ClusterConfig }, - Promise | null> + Promise > = { method: 'launchSubcluster', params: object({ config: ClusterConfigStruct }), - result: nullable(CapDataStruct), + result: LaunchSubclusterRpcResultStruct, }; export type LaunchSubclusterHooks = { @@ -21,7 +42,7 @@ export type LaunchSubclusterHooks = { export const launchSubclusterHandler: Handler< 'launchSubcluster', { config: ClusterConfig }, - Promise | null>, + Promise, LaunchSubclusterHooks > = { ...launchSubclusterSpec, @@ -29,8 +50,13 @@ export const launchSubclusterHandler: Handler< implementation: async ( { kernel }: LaunchSubclusterHooks, params: { config: ClusterConfig }, - ): Promise | null> => { + ): Promise => { const result = await kernel.launchSubcluster(params.config); - return result ?? null; + // Convert undefined to null for JSON compatibility + return { + subclusterId: result.subclusterId, + bootstrapRootKref: result.bootstrapRootKref, + bootstrapResult: result.bootstrapResult ?? null, + }; }, }; diff --git a/packages/kernel-browser-runtime/src/types.ts b/packages/kernel-browser-runtime/src/types.ts new file mode 100644 index 000000000..0d5565c5f --- /dev/null +++ b/packages/kernel-browser-runtime/src/types.ts @@ -0,0 +1,26 @@ +import type { Kernel, ClusterConfig } from '@metamask/ocap-kernel'; + +/** + * Result of launching a subcluster. + * + * The rootKref contains the kref string for the bootstrap vat's root object. + */ +export type LaunchResult = { + subclusterId: string; + rootKref: string; +}; + +/** + * The kernel facade interface - methods exposed to userspace via CapTP. + * + * This is the remote presence type that the background receives from the kernel. + */ +export type KernelFacade = { + ping: () => Promise<'pong'>; + launchSubcluster: (config: ClusterConfig) => Promise; + terminateSubcluster: Kernel['terminateSubcluster']; + queueMessage: Kernel['queueMessage']; + getStatus: Kernel['getStatus']; + pingVat: Kernel['pingVat']; + getVatRoot: (krefString: string) => Promise; +}; diff --git a/packages/kernel-browser-runtime/src/vat/iframe.ts b/packages/kernel-browser-runtime/src/vat/iframe.ts index 2e914fa6a..c5e0b8527 100644 --- a/packages/kernel-browser-runtime/src/vat/iframe.ts +++ b/packages/kernel-browser-runtime/src/vat/iframe.ts @@ -28,13 +28,15 @@ async function main(): Promise { const urlParams = new URLSearchParams(window.location.search); const vatId = urlParams.get('vatId') ?? 'unknown'; + const vatLogger = logger.subLogger(vatId); // eslint-disable-next-line no-new new VatSupervisor({ id: vatId, kernelStream, - logger: logger.subLogger(vatId), + logger: vatLogger, makePlatform, + vatPowers: { logger: vatLogger }, }); logger.info('VatSupervisor initialized with vatId:', vatId); diff --git a/packages/kernel-browser-runtime/tsconfig.json b/packages/kernel-browser-runtime/tsconfig.json index 175067430..6df549148 100644 --- a/packages/kernel-browser-runtime/tsconfig.json +++ b/packages/kernel-browser-runtime/tsconfig.json @@ -21,6 +21,7 @@ "./test/**/*.ts", "./src", "./vite.config.ts", - "./vitest.config.ts" + "./vitest.config.ts", + "./vitest.integration.config.ts" ] } diff --git a/packages/kernel-browser-runtime/vitest.config.ts b/packages/kernel-browser-runtime/vitest.config.ts index 7ffeda649..fe56f07a9 100644 --- a/packages/kernel-browser-runtime/vitest.config.ts +++ b/packages/kernel-browser-runtime/vitest.config.ts @@ -11,7 +11,12 @@ export default defineConfig((args) => { defineProject({ test: { name: 'kernel-browser-runtime', + include: ['src/**/*.test.ts'], + exclude: ['**/*.integration.test.ts'], setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), fileURLToPath( import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), ), diff --git a/packages/kernel-browser-runtime/vitest.integration.config.ts b/packages/kernel-browser-runtime/vitest.integration.config.ts new file mode 100644 index 000000000..6c20f76c6 --- /dev/null +++ b/packages/kernel-browser-runtime/vitest.integration.config.ts @@ -0,0 +1,34 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + delete defaultConfig.test?.setupFiles; + + const config = mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'kernel-browser-runtime:integration', + include: ['src/**/*.integration.test.ts'], + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), + // Use endoify-node which imports @libp2p/webrtc before lockdown + // (webrtc imports reflect-metadata which modifies globalThis.Reflect) + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/endoify-node'), + ), + ], + }, + }), + ); + + delete config.test?.coverage; + + return config; +}); diff --git a/packages/kernel-shims/package.json b/packages/kernel-shims/package.json index eff3f6ba9..8ac7a013d 100644 --- a/packages/kernel-shims/package.json +++ b/packages/kernel-shims/package.json @@ -22,6 +22,7 @@ "./endoify": "./dist/endoify.js", "./endoify-repair": "./dist/endoify-repair.js", "./eventual-send": "./dist/eventual-send.js", + "./endoify-node": "./src/endoify-node.js", "./package.json": "./package.json" }, "main": "./dist/endoify.js", @@ -52,6 +53,14 @@ "@endo/lockdown": "^1.0.18", "ses": "^1.14.0" }, + "peerDependencies": { + "@libp2p/webrtc": "^5.0.0" + }, + "peerDependenciesMeta": { + "@libp2p/webrtc": { + "optional": true + } + }, "devDependencies": { "@endo/bundle-source": "^4.1.2", "@metamask/auto-changelog": "^5.3.0", diff --git a/packages/kernel-shims/src/endoify-node.js b/packages/kernel-shims/src/endoify-node.js new file mode 100644 index 000000000..5707cbf49 --- /dev/null +++ b/packages/kernel-shims/src/endoify-node.js @@ -0,0 +1,13 @@ +/* global hardenIntrinsics */ + +// Node.js-specific endoify that imports modules which modify globals before lockdown. +// This file is NOT bundled - it must be imported directly from src/. + +import './endoify-repair.js'; + +// @libp2p/webrtc needs to modify globals in Node.js only, so we need to import +// it before hardening. +// eslint-disable-next-line import-x/no-unresolved -- peer dependency +import '@libp2p/webrtc'; + +hardenIntrinsics(); diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index 12138472c..d10c186f2 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -57,8 +57,6 @@ "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", - "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.9.0", "@ocap/kernel-language-model-service": "workspace:^", "@ocap/nodejs": "workspace:^", "@ocap/nodejs-test-workers": "workspace:^", @@ -69,6 +67,7 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", + "@metamask/kernel-shims": "workspace:^", "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@typescript-eslint/eslint-plugin": "^8.29.0", diff --git a/packages/kernel-test/src/liveslots.test.ts b/packages/kernel-test/src/liveslots.test.ts index 2ee0ef6f9..f7c3de795 100644 --- a/packages/kernel-test/src/liveslots.test.ts +++ b/packages/kernel-test/src/liveslots.test.ts @@ -67,14 +67,14 @@ describe('liveslots promise handling', () => { testName: string, ): Promise { const bundleSpec = getBundleSpec(bundleName); - const bootstrapResultRaw = await kernel.launchSubcluster( + const { bootstrapResult } = await kernel.launchSubcluster( makeTestSubcluster(testName, bundleSpec), ); await waitUntilQuiescent(1000); - if (bootstrapResultRaw === undefined) { + if (bootstrapResult === undefined) { throw Error(`this can't happen but eslint is stupid`); } - return kunser(bootstrapResultRaw); + return kunser(bootstrapResult); } it('promiseArg1: send promise parameter, resolve after send', async () => { diff --git a/packages/kernel-test/src/persistence.test.ts b/packages/kernel-test/src/persistence.test.ts index dcb0bcd21..af0551f5d 100644 --- a/packages/kernel-test/src/persistence.test.ts +++ b/packages/kernel-test/src/persistence.test.ts @@ -155,7 +155,7 @@ describe('persistent storage', { timeout: 20_000 }, () => { false, logger.logger.subLogger({ tags: ['test'] }), ); - const bootstrapResult = await kernel1.launchSubcluster(testSubcluster); + const { bootstrapResult } = await kernel1.launchSubcluster(testSubcluster); expect(kunser(bootstrapResult as CapData)).toBe( 'Counter initialized with count: 1', ); diff --git a/packages/kernel-test/src/utils.ts b/packages/kernel-test/src/utils.ts index 361ad2cdb..76a558d7d 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -11,13 +11,7 @@ import { import type { LogEntry } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig, PlatformServices } from '@metamask/ocap-kernel'; -import { NodeWorkerDuplexStream } from '@metamask/streams'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; import { NodejsPlatformServices } from '@ocap/nodejs'; -import { - MessagePort as NodeMessagePort, - MessageChannel as NodeMessageChannel, -} from 'node:worker_threads'; import { vi } from 'vitest'; /** @@ -43,12 +37,12 @@ export async function runTestVats( kernel: Kernel, config: ClusterConfig, ): Promise { - const bootstrapResultRaw = await kernel.launchSubcluster(config); + const { bootstrapResult } = await kernel.launchSubcluster(config); await waitUntilQuiescent(); - if (bootstrapResultRaw === undefined) { + if (bootstrapResult === undefined) { throw Error(`this can't happen but eslint is stupid`); } - return kunser(bootstrapResultRaw); + return kunser(bootstrapResult); } /** @@ -87,11 +81,6 @@ export async function makeKernel( platformServices?: PlatformServices, keySeed?: string, ): Promise { - const kernelPort: NodeMessagePort = new NodeMessageChannel().port1; - const nodeStream = new NodeWorkerDuplexStream< - JsonRpcRequest, - JsonRpcResponse - >(kernelPort); const platformServicesConfig: { logger: Logger; workerFilePath?: string } = { logger: logger.subLogger({ tags: ['vat-worker-manager'] }), }; @@ -100,16 +89,11 @@ export async function makeKernel( } const platformServicesClient = platformServices ?? new NodejsPlatformServices(platformServicesConfig); - const kernel = await Kernel.make( - nodeStream, - platformServicesClient, - kernelDatabase, - { - resetStorage, - logger, - keySeed, - }, - ); + const kernel = await Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + logger, + keySeed, + }); return kernel; } diff --git a/packages/kernel-test/src/vatstore.test.ts b/packages/kernel-test/src/vatstore.test.ts index 991903cea..3b0a88775 100644 --- a/packages/kernel-test/src/vatstore.test.ts +++ b/packages/kernel-test/src/vatstore.test.ts @@ -1,4 +1,3 @@ -import '@ocap/nodejs/endoify-ts'; import type { VatStore, VatCheckpoint } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import type { ClusterConfig } from '@metamask/ocap-kernel'; diff --git a/packages/kernel-test/vitest.config.ts b/packages/kernel-test/vitest.config.ts index 47cf711f6..964287570 100644 --- a/packages/kernel-test/vitest.config.ts +++ b/packages/kernel-test/vitest.config.ts @@ -12,7 +12,9 @@ export default defineConfig((args) => { test: { name: 'kernel-test', setupFiles: [ - fileURLToPath(import.meta.resolve('@ocap/nodejs/endoify-ts')), + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/endoify-node'), + ), ], testTimeout: 30_000, }, diff --git a/packages/nodejs-test-workers/package.json b/packages/nodejs-test-workers/package.json index 60a72f88e..51357c716 100644 --- a/packages/nodejs-test-workers/package.json +++ b/packages/nodejs-test-workers/package.json @@ -80,6 +80,7 @@ "node": "^20.11 || >=22" }, "dependencies": { + "@metamask/kernel-shims": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@ocap/nodejs": "workspace:^" diff --git a/packages/nodejs-test-workers/src/workers/mock-fetch.ts b/packages/nodejs-test-workers/src/workers/mock-fetch.ts index ccca51833..58afd4844 100644 --- a/packages/nodejs-test-workers/src/workers/mock-fetch.ts +++ b/packages/nodejs-test-workers/src/workers/mock-fetch.ts @@ -1,4 +1,4 @@ -import '@ocap/nodejs/endoify-mjs'; +import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; import { makeNodeJsVatSupervisor } from '@ocap/nodejs'; diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 1dfd8e283..34416e409 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -23,8 +23,6 @@ "default": "./dist/index.cjs" } }, - "./endoify-mjs": "./dist/env/endoify.mjs", - "./endoify-ts": "./src/env/endoify.ts", "./package.json": "./package.json" }, "files": [ @@ -61,7 +59,6 @@ "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", - "@metamask/utils": "^11.9.0", "@ocap/kernel-platforms": "workspace:^", "ses": "^1.14.0" }, diff --git a/packages/nodejs/src/env/endoify.ts b/packages/nodejs/src/env/endoify.ts deleted file mode 100644 index e494bcb24..000000000 --- a/packages/nodejs/src/env/endoify.ts +++ /dev/null @@ -1,7 +0,0 @@ -import '@metamask/kernel-shims/endoify-repair'; - -// @libp2p/webrtc needs to modify globals in Node.js only, so we need to import -// it before hardening. -import '@libp2p/webrtc'; - -hardenIntrinsics(); diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index 609613990..e648193f7 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -1,5 +1,3 @@ -import '../env/endoify.ts'; - import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { Worker as NodeWorker } from 'node:worker_threads'; diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts index 35b2f6689..2fdfdb43d 100644 --- a/packages/nodejs/src/kernel/make-kernel.test.ts +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -1,11 +1,5 @@ -import '../env/endoify.ts'; - import { Kernel } from '@metamask/ocap-kernel'; -import { - MessagePort as NodeMessagePort, - MessageChannel as NodeMessageChannel, -} from 'node:worker_threads'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { makeKernel } from './make-kernel.ts'; @@ -19,16 +13,8 @@ vi.mock('@metamask/kernel-store/sqlite/nodejs', async () => { }); describe('makeKernel', () => { - let kernelPort: NodeMessagePort; - - beforeEach(() => { - kernelPort = new NodeMessageChannel().port1; - }); - it('should return a Kernel', async () => { - const kernel = await makeKernel({ - port: kernelPort, - }); + const kernel = await makeKernel({}); expect(kernel).toBeInstanceOf(Kernel); }); diff --git a/packages/nodejs/src/kernel/make-kernel.ts b/packages/nodejs/src/kernel/make-kernel.ts index 66af358ee..a359c35a9 100644 --- a/packages/nodejs/src/kernel/make-kernel.ts +++ b/packages/nodejs/src/kernel/make-kernel.ts @@ -1,9 +1,6 @@ import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { Logger } from '@metamask/logger'; import { Kernel } from '@metamask/ocap-kernel'; -import { NodeWorkerDuplexStream } from '@metamask/streams'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; -import { MessagePort as NodeMessagePort } from 'node:worker_threads'; import { NodejsPlatformServices } from './PlatformServices.ts'; @@ -11,7 +8,6 @@ import { NodejsPlatformServices } from './PlatformServices.ts'; * The main function for the kernel worker. * * @param options - The options for the kernel. - * @param options.port - The kernel's end of a node:worker_threads MessageChannel * @param options.workerFilePath - The path to a file defining each vat worker's routine. * @param options.resetStorage - If true, clear kernel storage as part of setting up the kernel. * @param options.dbFilename - The filename of the SQLite database file. @@ -20,24 +16,18 @@ import { NodejsPlatformServices } from './PlatformServices.ts'; * @returns The kernel, initialized. */ export async function makeKernel({ - port, workerFilePath, resetStorage = false, dbFilename, logger, keySeed, }: { - port: NodeMessagePort; workerFilePath?: string; resetStorage?: boolean; dbFilename?: string; logger?: Logger; keySeed?: string | undefined; }): Promise { - const nodeStream = new NodeWorkerDuplexStream< - JsonRpcRequest, - JsonRpcResponse - >(port); const rootLogger = logger ?? new Logger('kernel-worker'); const platformServicesClient = new NodejsPlatformServices({ workerFilePath, @@ -48,16 +38,11 @@ export async function makeKernel({ const kernelDatabase = await makeSQLKernelDatabase({ dbFilename }); // Create and start kernel. - const kernel = await Kernel.make( - nodeStream, - platformServicesClient, - kernelDatabase, - { - resetStorage, - logger: rootLogger.subLogger({ tags: ['kernel'] }), - keySeed, - }, - ); + const kernel = await Kernel.make(platformServicesClient, kernelDatabase, { + resetStorage, + logger: rootLogger.subLogger({ tags: ['kernel'] }), + keySeed, + }); return kernel; } diff --git a/packages/nodejs/src/vat/vat-worker.test.ts b/packages/nodejs/src/vat/vat-worker.test.ts index 3df85e695..763215216 100644 --- a/packages/nodejs/src/vat/vat-worker.test.ts +++ b/packages/nodejs/src/vat/vat-worker.test.ts @@ -1,5 +1,3 @@ -import '../env/endoify.ts'; - import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { makePromiseKitMock } from '@ocap/repo-tools/test-utils'; diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 4eccdb196..c08d2f17d 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -1,4 +1,4 @@ -import '../env/endoify.ts'; +import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/e2e/PlatformServices.test.ts b/packages/nodejs/test/e2e/PlatformServices.test.ts index 2bd4fef41..14f444fb7 100644 --- a/packages/nodejs/test/e2e/PlatformServices.test.ts +++ b/packages/nodejs/test/e2e/PlatformServices.test.ts @@ -1,5 +1,3 @@ -import '../../src/env/endoify.ts'; - import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { NodeWorkerDuplexStream } from '@metamask/streams'; diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index 2275c07cd..7573bf33e 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -1,11 +1,5 @@ -import '../../src/env/endoify.ts'; - import { Kernel } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; -import { - MessageChannel as NodeMessageChannel, - MessagePort as NodePort, -} from 'node:worker_threads'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { makeKernel } from '../../src/kernel/make-kernel.ts'; @@ -17,20 +11,13 @@ vi.mock('node:process', () => ({ })); describe('Kernel Worker', () => { - let kernelPort: NodePort; let kernel: Kernel; // Tests below assume these are sorted for convenience. const testVatIds = ['v1', 'v2', 'v3'].sort(); beforeEach(async () => { - if (kernelPort) { - kernelPort.close(); - } - kernelPort = new NodeMessageChannel().port1; - kernel = await makeKernel({ - port: kernelPort, - }); + kernel = await makeKernel({}); }); afterEach(async () => { diff --git a/packages/nodejs/test/e2e/remote-comms.test.ts b/packages/nodejs/test/e2e/remote-comms.test.ts index b5127c09b..faeb2cae2 100644 --- a/packages/nodejs/test/e2e/remote-comms.test.ts +++ b/packages/nodejs/test/e2e/remote-comms.test.ts @@ -1,5 +1,3 @@ -import '../../src/env/endoify.ts'; - import type { Libp2p } from '@libp2p/interface'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { Kernel, kunser, makeKernelStore } from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index c902d64f7..16fed0662 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -3,9 +3,6 @@ import { waitUntilQuiescent } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { Kernel, kunser } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; -import { NodeWorkerDuplexStream } from '@metamask/streams'; -import type { JsonRpcRequest, JsonRpcResponse } from '@metamask/utils'; -import { MessageChannel as NodeMessageChannel } from 'node:worker_threads'; import { NodejsPlatformServices } from '../../src/kernel/PlatformServices.ts'; @@ -21,24 +18,14 @@ export async function makeTestKernel( kernelDatabase: KernelDatabase, resetStorage: boolean, ): Promise { - const port = new NodeMessageChannel().port1; - const nodeStream = new NodeWorkerDuplexStream< - JsonRpcRequest, - JsonRpcResponse - >(port); const logger = new Logger('test-kernel'); const platformServices = new NodejsPlatformServices({ logger: logger.subLogger({ tags: ['platform-services'] }), }); - const kernel = await Kernel.make( - nodeStream, - platformServices, - kernelDatabase, - { - resetStorage, - logger: logger.subLogger({ tags: ['kernel'] }), - }, - ); + const kernel = await Kernel.make(platformServices, kernelDatabase, { + resetStorage, + logger: logger.subLogger({ tags: ['kernel'] }), + }); return kernel; } @@ -55,10 +42,10 @@ export async function runTestVats( kernel: Kernel, config: ClusterConfig, ): Promise { - const bootstrapResultRaw = await kernel.launchSubcluster(config); + const { bootstrapResult } = await kernel.launchSubcluster(config); await waitUntilQuiescent(); - if (bootstrapResultRaw === undefined) { + if (bootstrapResult === undefined) { throw Error(`this can't happen but eslint is stupid`); } - return kunser(bootstrapResultRaw); + return kunser(bootstrapResult); } diff --git a/packages/nodejs/test/helpers/remote-comms.ts b/packages/nodejs/test/helpers/remote-comms.ts index a5a17050f..64faf376b 100644 --- a/packages/nodejs/test/helpers/remote-comms.ts +++ b/packages/nodejs/test/helpers/remote-comms.ts @@ -59,7 +59,7 @@ export async function launchVatAndGetURL( config: ClusterConfig, ): Promise { const result = await kernel.launchSubcluster(config); - return kunser(result as CapData) as string; + return kunser(result.bootstrapResult as CapData) as string; } /** diff --git a/packages/nodejs/test/workers/stream-sync.js b/packages/nodejs/test/workers/stream-sync.js index 9b39391ad..0889812ea 100644 --- a/packages/nodejs/test/workers/stream-sync.js +++ b/packages/nodejs/test/workers/stream-sync.js @@ -1,4 +1,4 @@ -import '../../dist/env/endoify.mjs'; +import '@metamask/kernel-shims/endoify-node'; import { makeStreams } from '../../dist/vat/streams.mjs'; main().catch(console.error); diff --git a/packages/nodejs/vitest.config.e2e.ts b/packages/nodejs/vitest.config.e2e.ts index cd509ee6b..b72ebb5cb 100644 --- a/packages/nodejs/vitest.config.e2e.ts +++ b/packages/nodejs/vitest.config.e2e.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -11,6 +12,11 @@ export default defineConfig((args) => { test: { name: 'nodejs:e2e', pool: 'forks', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/endoify-node'), + ), + ], include: ['./test/e2e/**/*.test.ts'], exclude: ['./src/**/*'], env: { diff --git a/packages/nodejs/vitest.config.ts b/packages/nodejs/vitest.config.ts index 0b8767bab..208d6346b 100644 --- a/packages/nodejs/vitest.config.ts +++ b/packages/nodejs/vitest.config.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -10,6 +11,11 @@ export default defineConfig((args) => { defineProject({ test: { name: 'nodejs', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/endoify-node'), + ), + ], include: ['./src/**/*.test.ts'], exclude: ['./test/e2e/'], }, diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 6a2a18de6..159941e1b 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -3,8 +3,6 @@ import type { KernelDatabase } from '@metamask/kernel-store'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { DuplexStream } from '@metamask/streams'; -import type { JsonRpcResponse, JsonRpcRequest } from '@metamask/utils'; -import { TestDuplexStream } from '@ocap/repo-tools/test-utils/streams'; import type { Mocked, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -94,7 +92,6 @@ const makeMockClusterConfig = (): ClusterConfig => ({ }); describe('Kernel', () => { - let mockStream: DuplexStream; let mockPlatformServices: PlatformServices; let launchWorkerMock: MockInstance; let terminateWorkerMock: MockInstance; @@ -103,11 +100,6 @@ describe('Kernel', () => { let mockKernelDatabase: KernelDatabase; beforeEach(async () => { - const dummyDispatch = vi.fn(); - mockStream = await TestDuplexStream.make( - dummyDispatch, - ); - mockPlatformServices = { launch: async () => ({}) as unknown as DuplexStream, @@ -151,7 +143,6 @@ describe('Kernel', () => { describe('constructor()', () => { it('initializes the kernel without errors', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -162,7 +153,7 @@ describe('Kernel', () => { const db = makeMapKernelDatabase(); db.kernelKVStore.set('foo', 'bar'); // Create with resetStorage should clear existing keys - await Kernel.make(mockStream, mockPlatformServices, db, { + await Kernel.make(mockPlatformServices, db, { resetStorage: true, }); expect(db.kernelKVStore.get('foo')).toBeUndefined(); @@ -172,7 +163,6 @@ describe('Kernel', () => { describe('init()', () => { it('initializes the kernel store', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -180,51 +170,16 @@ describe('Kernel', () => { expect(kernel.getVatIds()).toStrictEqual(['v1']); }); - it('starts receiving messages', async () => { - let drainHandler: ((message: JsonRpcRequest) => Promise) | null = - null; - const customMockStream = { - drain: async (handler: (message: JsonRpcRequest) => Promise) => { - drainHandler = handler; - return Promise.resolve(); - }, - write: vi.fn().mockResolvedValue(undefined), - } as unknown as DuplexStream; - await Kernel.make( - customMockStream, - mockPlatformServices, - mockKernelDatabase, - ); - expect(drainHandler).toBeInstanceOf(Function); - }); - it('initializes and starts the kernel queue', async () => { - await Kernel.make(mockStream, mockPlatformServices, mockKernelDatabase); + await Kernel.make(mockPlatformServices, mockKernelDatabase); const queueInstance = mocks.KernelQueue.lastInstance; expect(queueInstance.run).toHaveBeenCalledTimes(1); }); - it('throws if the stream throws', async () => { - const streamError = new Error('Stream error'); - const throwingMockStream = { - drain: () => { - throw streamError; - }, - write: vi.fn().mockResolvedValue(undefined), - } as unknown as DuplexStream; - await expect( - Kernel.make( - throwingMockStream, - mockPlatformServices, - mockKernelDatabase, - ), - ).rejects.toThrow('Stream error'); - }); - it('recovers vats from persistent storage on startup', async () => { const db = makeMapKernelDatabase(); // Launch initial kernel and vat - const kernel1 = await Kernel.make(mockStream, mockPlatformServices, db); + const kernel1 = await Kernel.make(mockPlatformServices, db); await kernel1.launchSubcluster(makeSingleVatClusterConfig()); expect(kernel1.getVatIds()).toStrictEqual(['v1']); // Clear spies @@ -232,7 +187,7 @@ describe('Kernel', () => { makeVatHandleMock.mockClear(); // New kernel should recover existing vat immediately during make() - const kernel2 = await Kernel.make(mockStream, mockPlatformServices, db); + const kernel2 = await Kernel.make(mockPlatformServices, db); // The vat should be recovered immediately expect(launchWorkerMock).toHaveBeenCalledOnce(); @@ -244,7 +199,6 @@ describe('Kernel', () => { describe('reload()', () => { it('should reload all subclusters', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -263,7 +217,6 @@ describe('Kernel', () => { it('should handle empty subclusters gracefully', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -275,7 +228,6 @@ describe('Kernel', () => { describe('queueMessage()', () => { it('enqueues a message and returns the result', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -288,7 +240,6 @@ describe('Kernel', () => { describe('launchSubcluster()', () => { it('launches a subcluster according to config', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -303,7 +254,6 @@ describe('Kernel', () => { it('throws an error for invalid configs', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -315,7 +265,6 @@ describe('Kernel', () => { it('throws an error when bootstrap vat name is invalid', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -334,20 +283,22 @@ describe('Kernel', () => { it('returns the bootstrap message result when bootstrap vat is specified', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); const config = makeMockClusterConfig(); const result = await kernel.launchSubcluster(config); - expect(result).toStrictEqual({ body: '{"result":"ok"}', slots: [] }); + expect(result).toMatchObject({ + subclusterId: 's1', + bootstrapResult: { body: '{"result":"ok"}', slots: [] }, + }); + expect(result.bootstrapRootKref).toMatch(/^ko\d+$/u); }); }); describe('terminateSubcluster()', () => { it('terminates all vats in a subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -372,7 +323,6 @@ describe('Kernel', () => { it('throws when terminating non-existent subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -385,7 +335,6 @@ describe('Kernel', () => { describe('getSubcluster()', () => { it('returns subcluster by id', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -403,7 +352,6 @@ describe('Kernel', () => { it('returns undefined for non-existent subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -414,7 +362,6 @@ describe('Kernel', () => { describe('isVatInSubcluster()', () => { it('correctly identifies vat membership in subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -433,7 +380,6 @@ describe('Kernel', () => { describe('getSubclusterVats()', () => { it('returns all vat IDs in a subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -458,7 +404,6 @@ describe('Kernel', () => { describe('reloadSubcluster()', () => { it('reloads a specific subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -476,7 +421,6 @@ describe('Kernel', () => { it('throws when reloading non-existent subcluster', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -489,7 +433,6 @@ describe('Kernel', () => { describe('clearStorage()', () => { it('clears the kernel storage', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -502,7 +445,6 @@ describe('Kernel', () => { describe('getVats()', () => { it('returns an empty array when no vats are added', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -511,7 +453,6 @@ describe('Kernel', () => { it('returns vat information after adding vats', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -530,7 +471,6 @@ describe('Kernel', () => { it('includes subcluster information for vats in subclusters', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -549,7 +489,6 @@ describe('Kernel', () => { describe('getVatIds()', () => { it('returns an empty array when no vats are added', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -558,7 +497,6 @@ describe('Kernel', () => { it('returns the vat IDs after adding a vat', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -568,7 +506,6 @@ describe('Kernel', () => { it('returns multiple vat IDs after adding multiple vats', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -581,7 +518,6 @@ describe('Kernel', () => { describe('getStatus()', () => { it('returns the current kernel status', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -597,7 +533,6 @@ describe('Kernel', () => { it('includes vats and subclusters in status', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -616,7 +551,6 @@ describe('Kernel', () => { describe('launchVat()', () => { it('adds a vat to the kernel without errors when no vat with the same ID exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -628,7 +562,6 @@ describe('Kernel', () => { it('adds multiple vats to the kernel without errors when no vat with the same ID exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -643,7 +576,6 @@ describe('Kernel', () => { describe('terminateVat()', () => { it('deletes a vat from the kernel without errors when the vat exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -657,7 +589,6 @@ describe('Kernel', () => { it('throws an error when deleting a vat that does not exist in the kernel', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -670,7 +601,6 @@ describe('Kernel', () => { it('throws an error when a vat terminate method throws', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -688,7 +618,6 @@ describe('Kernel', () => { .spyOn(mockPlatformServices, 'terminate') .mockResolvedValue(undefined); const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -712,15 +641,8 @@ describe('Kernel', () => { const stopRemoteCommsMock = vi .spyOn(mockPlatformServices, 'stopRemoteComms') .mockResolvedValue(undefined); - const endStreamMock = vi.fn().mockResolvedValue(undefined); - const mockStreamWithEnd = { - drain: mockStream.drain.bind(mockStream), - write: mockStream.write.bind(mockStream), - end: endStreamMock, - } as unknown as DuplexStream; const kernel = await Kernel.make( - mockStreamWithEnd, mockPlatformServices, mockKernelDatabase, ); @@ -741,22 +663,13 @@ describe('Kernel', () => { // Verify stop sequence expect(queueInstance.waitForCrank).toHaveBeenCalledOnce(); - expect(endStreamMock).toHaveBeenCalledOnce(); expect(stopRemoteCommsMock).toHaveBeenCalledOnce(); expect(remoteManagerInstance.cleanup).toHaveBeenCalledOnce(); expect(workerTerminateAllMock).toHaveBeenCalledOnce(); }); it('waits for crank before stopping', async () => { - const endStreamMock = vi.fn().mockResolvedValue(undefined); - const mockStreamWithEnd = { - drain: mockStream.drain.bind(mockStream), - write: mockStream.write.bind(mockStream), - end: endStreamMock, - } as unknown as DuplexStream; - const kernel = await Kernel.make( - mockStreamWithEnd, mockPlatformServices, mockKernelDatabase, ); @@ -767,32 +680,12 @@ describe('Kernel', () => { // Verify waitForCrank is called before other operations expect(waitForCrankSpy).toHaveBeenCalledOnce(); - expect(endStreamMock).toHaveBeenCalledOnce(); - }); - - it('handles errors during stop gracefully', async () => { - const stopError = new Error('Stop failed'); - const endStreamMock = vi.fn().mockRejectedValue(stopError); - const mockStreamWithEnd = { - drain: mockStream.drain.bind(mockStream), - write: mockStream.write.bind(mockStream), - end: endStreamMock, - } as unknown as DuplexStream; - - const kernel = await Kernel.make( - mockStreamWithEnd, - mockPlatformServices, - mockKernelDatabase, - ); - - await expect(kernel.stop()).rejects.toThrow('Stop failed'); }); }); describe('restartVat()', () => { it('preserves vat state across multiple restarts', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -814,7 +707,6 @@ describe('Kernel', () => { it('restarts a vat', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -834,7 +726,6 @@ describe('Kernel', () => { it('throws error when restarting non-existent vat', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -845,7 +736,6 @@ describe('Kernel', () => { it('handles restart failure during termination', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -861,7 +751,6 @@ describe('Kernel', () => { it('handles restart failure during launch', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -874,7 +763,6 @@ describe('Kernel', () => { it('returns the new vat handle', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -890,7 +778,6 @@ describe('Kernel', () => { describe('pingVat()', () => { it('pings a vat without errors when the vat exists', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -903,7 +790,6 @@ describe('Kernel', () => { it('throws an error when pinging a vat that does not exist in the kernel', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -915,7 +801,6 @@ describe('Kernel', () => { it('propagates errors from the vat ping method', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -930,11 +815,7 @@ describe('Kernel', () => { it('terminates all vats and resets kernel state', async () => { const mockDb = makeMapKernelDatabase(); const clearSpy = vi.spyOn(mockDb, 'clear'); - const kernel = await Kernel.make( - mockStream, - mockPlatformServices, - mockDb, - ); + const kernel = await Kernel.make(mockPlatformServices, mockDb); await kernel.launchSubcluster(makeSingleVatClusterConfig()); await kernel.reset(); expect(clearSpy).toHaveBeenCalled(); @@ -945,12 +826,9 @@ describe('Kernel', () => { const mockDb = makeMapKernelDatabase(); const logger = new Logger('test'); const logErrorSpy = vi.spyOn(logger, 'error'); - const kernel = await Kernel.make( - mockStream, - mockPlatformServices, - mockDb, - { logger }, - ); + const kernel = await Kernel.make(mockPlatformServices, mockDb, { + logger, + }); await kernel.launchSubcluster(makeSingleVatClusterConfig()); vi.spyOn(mockDb, 'clear').mockImplementationOnce(() => { @@ -967,7 +845,6 @@ describe('Kernel', () => { describe('revoke and isRevoked', () => { it('reflect when an object is revoked', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -979,7 +856,6 @@ describe('Kernel', () => { it('throws when revoking a promise', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -991,7 +867,6 @@ describe('Kernel', () => { describe('pinVatRoot and unpinVatRoot', () => { it('pins and unpins a vat root correctly', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1012,7 +887,6 @@ describe('Kernel', () => { describe('sendRemoteMessage()', () => { it('sends message to remote peer via RemoteManager', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1028,7 +902,6 @@ describe('Kernel', () => { describe('closeConnection()', () => { it('closes connection via RemoteManager', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1043,7 +916,6 @@ describe('Kernel', () => { describe('reconnectPeer()', () => { it('reconnects peer via RemoteManager with hints', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); @@ -1059,7 +931,6 @@ describe('Kernel', () => { it('reconnects peer with empty hints when hints not provided', async () => { const kernel = await Kernel.make( - mockStream, mockPlatformServices, mockKernelDatabase, ); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 879b090d3..b53ee003f 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -1,12 +1,6 @@ import type { CapData } from '@endo/marshal'; -import { RpcService } from '@metamask/kernel-rpc-methods'; import type { KernelDatabase } from '@metamask/kernel-store'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import { serializeError } from '@metamask/rpc-errors'; -import type { DuplexStream } from '@metamask/streams'; -import { hasProperty } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; @@ -15,7 +9,6 @@ import type { KernelService } from './KernelServiceManager.ts'; import { OcapURLManager } from './remotes/OcapURLManager.ts'; import { RemoteManager } from './remotes/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; -import { kernelHandlers } from './rpc/index.ts'; import type { PingVatResult } from './rpc/index.ts'; import { makeKernelStore } from './store/index.ts'; import type { KernelStore } from './store/index.ts'; @@ -28,6 +21,7 @@ import type { VatConfig, KernelStatus, Subcluster, + SubclusterLaunchResult, EndpointHandle, } from './types.ts'; import { isVatId, isRemoteId } from './types.ts'; @@ -49,11 +43,6 @@ import { VatManager } from './vats/VatManager.ts'; * @returns A new {@link Kernel}. */ export class Kernel { - /** Command channel from the controlling console/browser extension/test driver */ - readonly #commandStream: DuplexStream; - - readonly #rpcService: RpcService; - /** Manages vat lifecycle operations */ readonly #vatManager: VatManager; @@ -90,7 +79,6 @@ export class Kernel { /** * Construct a new kernel instance. * - * @param commandStream - Command channel from whatever external software is driving the kernel. * @param platformServices - Service to do things the kernel worker can't. * @param kernelDatabase - Database holding the kernel's persistent state. * @param options - Options for the kernel constructor. @@ -100,7 +88,6 @@ export class Kernel { */ // eslint-disable-next-line no-restricted-syntax private constructor( - commandStream: DuplexStream, platformServices: PlatformServices, kernelDatabase: KernelDatabase, options: { @@ -109,8 +96,6 @@ export class Kernel { keySeed?: string | undefined; } = {}, ) { - this.#commandStream = commandStream; - this.#rpcService = new RpcService(kernelHandlers, {}); this.#platformServices = platformServices; this.#logger = options.logger ?? new Logger('ocap-kernel'); this.#kernelStore = makeKernelStore(kernelDatabase, this.#logger); @@ -188,7 +173,6 @@ export class Kernel { /** * Create a new kernel instance. * - * @param commandStream - Command channel from whatever external software is driving the kernel. * @param platformServices - Service to do things the kernel worker can't. * @param kernelDatabase - Database holding the kernel's persistent state. * @param options - Options for the kernel constructor. @@ -198,7 +182,6 @@ export class Kernel { * @returns A promise for the new kernel instance. */ static async make( - commandStream: DuplexStream, platformServices: PlatformServices, kernelDatabase: KernelDatabase, options: { @@ -207,19 +190,13 @@ export class Kernel { keySeed?: string | undefined; } = {}, ): Promise { - const kernel = new Kernel( - commandStream, - platformServices, - kernelDatabase, - options, - ); + const kernel = new Kernel(platformServices, kernelDatabase, options); await kernel.#init(); return kernel; } /** - * Start the kernel running. Sets it up to actually receive command messages - * and then begin processing the run queue. + * Start the kernel running. */ async #init(): Promise { // Set up the remote message handler @@ -228,18 +205,6 @@ export class Kernel { this.#remoteManager.handleRemoteMessage(from, message), ); - // Start the command stream handler (non-blocking) - // This runs for the entire lifetime of the kernel - this.#commandStream - .drain(this.#handleCommandMessage.bind(this)) - .catch((error) => { - this.#logger.error( - 'Stream read error (kernel may be non-functional):', - error, - ); - // Don't re-throw to avoid unhandled rejection in this long-running task - }); - // Start all vats that were previously running before starting the queue // This ensures that any messages in the queue have their target vats ready await this.#vatManager.initializeAllVats(); @@ -298,37 +263,6 @@ export class Kernel { await this.#remoteManager.reconnectPeer(peerId, hints); } - /** - * Handle messages received over the command channel. - * - * @param message - The message to handle. - */ - async #handleCommandMessage(message: JsonRpcCall): Promise { - try { - this.#rpcService.assertHasMethod(message.method); - const result = await this.#rpcService.execute( - message.method, - message.params, - ); - if (hasProperty(message, 'id') && typeof message.id === 'string') { - await this.#commandStream.write({ - id: message.id, - jsonrpc: '2.0', - result, - }); - } - } catch (error) { - this.#logger.error('Error executing command', error); - if (hasProperty(message, 'id') && typeof message.id === 'string') { - await this.#commandStream.write({ - id: message.id, - jsonrpc: '2.0', - error: serializeError(error), - }); - } - } - } - /** * Send a message from the kernel to an object in a vat. * @@ -360,11 +294,12 @@ export class Kernel { * Launches a sub-cluster of vats. * * @param config - Configuration object for sub-cluster. - * @returns a promise for the (CapData encoded) result of the bootstrap message. + * @returns A promise for the subcluster ID and the (CapData encoded) result + * of the bootstrap message. */ async launchSubcluster( config: ClusterConfig, - ): Promise | undefined> { + ): Promise { return this.#subclusterManager.launchSubcluster(config); } @@ -623,7 +558,6 @@ export class Kernel { */ async stop(): Promise { await this.#kernelQueue.waitForCrank(); - await this.#commandStream.end(); await this.#platformServices.stopRemoteComms(); this.#remoteManager.cleanup(); await this.#platformServices.terminateAll(); diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index c9fe1413c..88d065beb 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -12,6 +12,7 @@ export type { KernelStatus, Subcluster, SubclusterId, + SubclusterLaunchResult, } from './types.ts'; export type { RemoteMessageHandler, diff --git a/packages/ocap-kernel/src/rpc/index.test.ts b/packages/ocap-kernel/src/rpc/index.test.ts index 9aa4e21b9..51f6e5795 100644 --- a/packages/ocap-kernel/src/rpc/index.test.ts +++ b/packages/ocap-kernel/src/rpc/index.test.ts @@ -5,8 +5,6 @@ import * as indexModule from './index.ts'; describe('index', () => { it('has the expected exports', () => { expect(Object.keys(indexModule).sort()).toStrictEqual([ - 'kernelHandlers', - 'kernelMethodSpecs', 'kernelRemoteHandlers', 'kernelRemoteMethodSpecs', 'platformServicesHandlers', diff --git a/packages/ocap-kernel/src/rpc/index.ts b/packages/ocap-kernel/src/rpc/index.ts index 09b87a0a7..6a6b5d133 100644 --- a/packages/ocap-kernel/src/rpc/index.ts +++ b/packages/ocap-kernel/src/rpc/index.ts @@ -1,5 +1,3 @@ -export * from './kernel/index.ts'; - // PlatformServicesServer <-> PlatformServicesClient export * from './platform-services/index.ts'; export * from './kernel-remote/index.ts'; diff --git a/packages/ocap-kernel/src/rpc/kernel/index.ts b/packages/ocap-kernel/src/rpc/kernel/index.ts deleted file mode 100644 index c989c13b8..000000000 --- a/packages/ocap-kernel/src/rpc/kernel/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - HandlerRecord, - MethodRequest, - MethodSpecRecord, -} from '@metamask/kernel-rpc-methods'; - -import { pingHandler, pingSpec } from '../vat/ping.ts'; - -export const kernelHandlers = { - ping: pingHandler, -} as HandlerRecord; - -export const kernelMethodSpecs = { - ping: pingSpec, -} as MethodSpecRecord; - -type Handlers = (typeof kernelHandlers)[keyof typeof kernelHandlers]; - -export type KernelMethod = Handlers['method']; - -export type KernelMethodSpec = (typeof kernelMethodSpecs)['ping']; - -export type KernelMethodRequest = MethodRequest; diff --git a/packages/ocap-kernel/src/types.ts b/packages/ocap-kernel/src/types.ts index 5c7043b48..e5ad35dbe 100644 --- a/packages/ocap-kernel/src/types.ts +++ b/packages/ocap-kernel/src/types.ts @@ -430,6 +430,18 @@ export const SubclusterStruct = object({ export type Subcluster = Infer; +/** + * Result of launching a subcluster. + */ +export type SubclusterLaunchResult = { + /** The ID of the launched subcluster. */ + subclusterId: string; + /** The kref of the bootstrap vat's root object. */ + bootstrapRootKref: KRef; + /** The CapData result of calling bootstrap() on the root object, if any. */ + bootstrapResult: CapData | undefined; +}; + export const KernelStatusStruct = type({ subclusters: array(SubclusterStruct), vats: array( diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts index 1cdb69c06..427c825e4 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.test.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.test.ts @@ -101,7 +101,11 @@ describe('SubclusterManager', () => { { testVat: expect.anything() }, {}, ]); - expect(result).toStrictEqual({ body: '{"result":"ok"}', slots: [] }); + expect(result).toStrictEqual({ + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult: { body: '{"result":"ok"}', slots: [] }, + }); }); it('launches subcluster with multiple vats', async () => { @@ -204,27 +208,24 @@ describe('SubclusterManager', () => { ); }); - it('throws when bootstrap message returns error', async () => { + it('returns bootstrap result when bootstrap does not return error', async () => { const config = createMockClusterConfig(); - const errorResult = { + const bootstrapResult = { body: '{"error":"Bootstrap failed"}', slots: [], }; (mockQueueMessage as ReturnType).mockResolvedValue( - errorResult, + bootstrapResult, ); - // Mock kunser to return an Error - const kunserMock = vi.fn().mockReturnValue(new Error('Bootstrap failed')); - vi.doMock('../liveslots/kernel-marshal.ts', () => ({ - kunser: kunserMock, - kslot: vi.fn(), - })); - - // We can't easily mock kunser since it's imported at module level - // So we'll just test that the result is returned + // Note: We can't easily mock kunser since it's imported at module level + // kunser doesn't return an Error for this body, so launchSubcluster succeeds const result = await subclusterManager.launchSubcluster(config); - expect(result).toStrictEqual(errorResult); + expect(result).toStrictEqual({ + subclusterId: 's1', + bootstrapRootKref: 'ko1', + bootstrapResult, + }); }); }); diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index b0293e3c6..663751d2c 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -6,7 +6,13 @@ import type { VatManager } from './VatManager.ts'; import { kslot, kunser } from '../liveslots/kernel-marshal.ts'; import type { SlotValue } from '../liveslots/kernel-marshal.ts'; import type { KernelStore } from '../store/index.ts'; -import type { VatId, KRef, ClusterConfig, Subcluster } from '../types.ts'; +import type { + VatId, + KRef, + ClusterConfig, + Subcluster, + SubclusterLaunchResult, +} from '../types.ts'; import { isClusterConfig } from '../types.ts'; import { Fail } from '../utils/assert.ts'; @@ -74,18 +80,21 @@ export class SubclusterManager { * Launches a sub-cluster of vats. * * @param config - Configuration object for sub-cluster. - * @returns a promise for the (CapData encoded) result of the bootstrap message. + * @returns A promise for the subcluster ID, bootstrap root kref, and + * bootstrap result. */ async launchSubcluster( config: ClusterConfig, - ): Promise | undefined> { + ): Promise { await this.#kernelQueue.waitForCrank(); isClusterConfig(config) || Fail`invalid cluster config`; if (!config.vats[config.bootstrap]) { Fail`invalid bootstrap vat name ${config.bootstrap}`; } const subclusterId = this.#kernelStore.addSubcluster(config); - return this.#launchVatsForSubcluster(subclusterId, config); + const { bootstrapRootKref, bootstrapResult } = + await this.#launchVatsForSubcluster(subclusterId, config); + return { subclusterId, bootstrapRootKref, bootstrapResult }; } /** @@ -179,12 +188,15 @@ export class SubclusterManager { * * @param subclusterId - The ID of the subcluster to launch vats for. * @param config - The configuration for the subcluster. - * @returns A promise for the (CapData encoded) result of the bootstrap message, if any. + * @returns A promise for the bootstrap root kref and bootstrap result. */ async #launchVatsForSubcluster( subclusterId: string, config: ClusterConfig, - ): Promise | undefined> { + ): Promise<{ + bootstrapRootKref: KRef; + bootstrapResult: CapData | undefined; + }> { const rootIds: Record = {}; const roots: Record = {}; for (const [vatName, vatConfig] of Object.entries(config.vats)) { @@ -204,19 +216,22 @@ export class SubclusterManager { } } } - const bootstrapRoot = rootIds[config.bootstrap]; - if (bootstrapRoot) { - const result = await this.#queueMessage(bootstrapRoot, 'bootstrap', [ - roots, - services, - ]); - const unserialized = kunser(result); - if (unserialized instanceof Error) { - throw unserialized; - } - return result; + const bootstrapRootKref = rootIds[config.bootstrap]; + if (!bootstrapRootKref) { + throw new Error( + `Bootstrap vat "${config.bootstrap}" not found in rootIds`, + ); + } + const bootstrapResult = await this.#queueMessage( + bootstrapRootKref, + 'bootstrap', + [roots, services], + ); + const unserialized = kunser(bootstrapResult); + if (unserialized instanceof Error) { + throw unserialized; } - return undefined; + return { bootstrapRootKref, bootstrapResult }; } /** diff --git a/packages/ocap-kernel/vitest.config.ts b/packages/ocap-kernel/vitest.config.ts index e049418f5..6264a93d4 100644 --- a/packages/ocap-kernel/vitest.config.ts +++ b/packages/ocap-kernel/vitest.config.ts @@ -12,9 +12,9 @@ export default defineConfig((args) => { test: { name: 'kernel', setupFiles: [ - // This is actually a circular dependency relationship, but it's fine because we're - // targeting the TypeScript source file and not listing @ocap/nodejs in package.json. - fileURLToPath(import.meta.resolve('@ocap/nodejs/endoify-ts')), + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/endoify-node'), + ), ], }, }), diff --git a/packages/omnium-gatherum/PLAN.md b/packages/omnium-gatherum/PLAN.md new file mode 100644 index 000000000..5029d7986 --- /dev/null +++ b/packages/omnium-gatherum/PLAN.md @@ -0,0 +1,763 @@ +# Omnium plan + +## TODO + +### Immediate Next Steps + +To complete Phase 1 and achieve a working PoC: + +1. **Define Caplet Vat Contract** (Section 1.2) + + - Document `buildRootObject()` signature and expected interface + - Decide on service initialization approach (bootstrap params vs explicit initialize method) + - Create `docs/caplet-contract.md` + +2. **Create Example Caplet Vats** (Section 1.6, 1.7) + + - `test/fixtures/echo-caplet/`: Simple service provider + - `test/fixtures/consumer-caplet/`: Service consumer + - Use for both dev console examples and integration tests + +3. **Bundle Loading** (Section 1.3) + + - Implement bundle-loader utility for inline/URL/file sources + - Integrate into CapletController.install() + +4. **Integration & E2E Testing** (Section 1.7) + + - Write `test/caplet-integration.test.ts` with real vat bundles + - Validate full install → communicate → uninstall lifecycle + - Test error cases and edge conditions + - Write `test/e2e/caplet.spec.ts` with real vat bundles, testing the full flow + +5. **Documentation** (Section 1.8) + - Architecture doc with CapTP and controller patterns + - Caplet development guide + - Dev console usage examples + +### Phase 1: Caplet Installation and Service Discovery + +This phase focuses on establishing the foundational architecture for Caplets: +defining their structure, implementing installation mechanics, and creating a +service discovery mechanism that allows Caplets to communicate using object +capabilities. This phase will be complete when we have a working PoC that: + +1. Install two caplets, a service producer and a service consumer +2. The service producer can be discovered by the service consumer + - Hard-coding "discovery" is acceptable for Phase 1. +3. The service consumer calls methods on the service producer + - e.g. `E(serviceProducer).echo(message) => 'Hello, world!'` +4. The caplets can be uninstalled, and the process repeated + +**Current Status (as of 2026-01-09)**: + +- ✅ **Sections 1.0-1.2 Complete**: Dev console, CapTP infrastructure, controller architecture fully implemented and tested +- 🚧 **Section 1.3 Partially Complete**: Basic caplet install/uninstall works; bundle loading and service resolution deferred +- ⏸️ **Section 1.4 Deferred**: Service registry vat deferred to Phase 2 (using direct reference passing in Phase 1) +- 🚧 **Section 1.6 Mostly Complete**: Dev console API implemented; examples and docs needed +- 🚧 **Section 1.7 Partially Complete**: Comprehensive unit tests; integration tests with actual caplet vats needed +- 📝 **Section 1.8 TODO**: Architecture and developer documentation needed + +#### 1.0 Omnium dev console + +- [x] Extension background dev console implementation + + - Add `globalThis.omnium` in `background.ts` + - Model this on `globalThis.kernel` in @packages/extension + - This "dev console" object is how we expose remote objects and + other functionality in the dev console + +#### 1.1 Build Userspace E() Infrastructure + +**Goal**: Enable userspace (background script) to use `E()` naturally with kernel and vat objects, establishing the foundation for omnium ↔ kernel ↔ vat communication. + +**Architecture**: Use **CapTP** (`@endo/captp`) to create proper remote presences that work with `E()`. CapTP is the standard Endo capability transfer protocol that handles remote object references, promise resolution, and garbage collection automatically. + +- [x] **CapTP-based Remote Presence Implementation** + + - Using `@endo/captp` for proper remote presence handling + - Kernel-side CapTP setup: + - Location: `packages/kernel-browser-runtime/src/kernel-worker/captp/` + - `kernel-facade.ts` - Creates a kernel facade exo using `makeDefaultExo` + - `kernel-captp.ts` - Sets up CapTP endpoint with kernel facade as bootstrap + - Background-side CapTP setup: + - Location: `packages/kernel-browser-runtime/src/background-captp.ts` + - Shared by both omnium-gatherum and extension packages + - Exports: `makeBackgroundCapTP`, `isCapTPNotification`, `getCapTPMessage`, `makeCapTPNotification` + - TypeScript types: `KernelFacade`, `CapTPMessage`, `BackgroundCapTP` + - CapTP messages are wrapped in JSON-RPC notifications: `{ method: 'captp', params: [captpMsg] }` + - `E` is globally available (set in trusted prelude before lockdown) + - `getKernel()` exposed on `globalThis.omnium` (omnium) or `globalThis.kernel` (extension) + - Kernel's internal commandStream and RPC removed - CapTP is now the only communication path + - Usage example: + + ```typescript + const kernel = await omnium.getKernel(); + const status = await E(kernel).getStatus(); + ``` + +- [x] **Kernel Facade** + + - Kernel facade exposes kernel methods via CapTP: + - `launchSubcluster(config)` - Launch a subcluster of vats + - `terminateSubcluster(subclusterId)` - Terminate a subcluster + - `queueMessage(target, method, args)` - Send a message to a kref + - `getStatus()` - Get kernel status + - `pingVat(vatId)` - Ping a vat + +- [x] **Message Routing** + + - Messages flow: background → offscreen → kernel-worker + - All streams use `JsonRpcMessage` type for bidirectional messaging + - Kernel-worker receives CapTP notifications and dispatches to kernel's CapTP endpoint + - No message router needed - all background ↔ kernel communication uses CapTP exclusively + +- [ ] **Argument Serialization** (Partial - Phase 2) + + - Phase 1: JSON-serializable arguments only + - Phase 2: Handle serialization of arguments that may contain object references + - Pass-by-reference: Other krefs in arguments should be preserved + - Pass-by-copy: Plain data (JSON-serializable) should be copied + - CapTP handles this automatically with proper configuration + +- [x] **Promise Management** + + - CapTP handles promise resolution automatically via CTP_RESOLVE messages + - Phase 1: Basic promise resolution + - Phase 2+: Promise pipelining supported by CapTP + +- [x] **Testing** + - Tests to be added for CapTP-based approach + +**Note**: Using CapTP provides several advantages over a custom implementation: + +1. Proper integration with `E()` from `@endo/eventual-send` via `resolveWithPresence()` +2. Automatic promise pipelining support +3. Garbage collection of remote references +4. Battle-tested implementation from the Endo ecosystem + +#### 1.2 Define Caplet Structure + +**Goal**: Establish the data structures, storage abstractions, and controller architecture for Caplets. + +- [x] **Controller Architecture** + + - Established modular controller pattern in `packages/omnium-gatherum/src/controllers/`: + - **Abstract `Controller` base class** (`base-controller.ts`): + - Generic base class parameterized by controller name, state shape, and methods + - Provides protected `state`, `update()`, and `logger` accessors + - Subclasses must implement `makeFacet()` to return hardened exo + - Enforces hardening pattern (`harden(this)` in constructor) + - Controllers manage state and business logic + - Controllers communicate via `E()` for capability attenuation (POLA) + - Each controller receives namespaced storage (isolated key space) + - `controllers/types.ts`: Base controller types (`ControllerConfig`, `ControllerMethods`) + - `controllers/facet.ts`: `makeFacet()` utility for POLA attenuation between controllers + +- [x] **Storage Abstraction Layer** + + - `controllers/storage/types.ts`: Storage interfaces + - `StorageAdapter`: Low-level wrapper for platform storage APIs + - `controllers/storage/chrome-storage.ts`: `makeChromeStorageAdapter()` for Chrome Storage API + - `controllers/storage/controller-storage.ts`: **`ControllerStorage` class** for controller state management + - **Refactored to class-based design** with static `make()` factory method + - Controllers work with a typed `state` object instead of managing storage keys directly + - Uses Immer for immutable updates with change tracking + - **Synchronous `update()` with debounced fire-and-forget persistence**: + - Updates are synchronous in memory for immediate consistency + - Persistence is debounced (default 100ms) with accumulated key tracking + - Implements bounded latency (timer not reset across updates) + - Immediate writes when idle > debounceMs for better responsiveness + - Only persists modified top-level keys (via Immer patches) + - Storage keys automatically prefixed: `${namespace}.${key}` (e.g., `caplet.caplets`) + - `clear()` and `clearState()` methods to reset to defaults + +- [x] **Caplet Manifest Schema** + + - Defined TypeScript types with superstruct validation in `controllers/caplet/types.ts`: + - `CapletId`: Reverse domain notation (e.g., `"com.example.bitcoin-signer"`) + - `SemVer`: Semantic version string (strict format, no `v` prefix) + - `CapletManifest`: Full manifest with id, name, version, bundleSpec, requestedServices, providedServices + - `InstalledCaplet`: Runtime record with manifest, subclusterId, installedAt timestamp + - Validation functions: `isCapletId()`, `isSemVer()`, `isCapletManifest()`, `assertCapletManifest()` + +- [x] **CapletController** + + - `controllers/caplet/caplet-controller.ts`: **`CapletController` class extends `Controller` base** + - **Refactored to use Controller base class**: + - Static `make()` factory creates storage internally + - Private constructor ensures proper initialization flow + - `makeFacet()` returns hardened exo with public methods + - Uses protected `state`, `update()`, and `logger` from base class + - Methods exposed via `CapletControllerFacet`: + - `install(manifest, bundle?)`: Validate manifest, launch subcluster, store metadata + - `uninstall(capletId)`: Terminate subcluster, remove metadata + - `list()`: Get all installed caplets + - `get(capletId)`: Get specific caplet + - `getByService(serviceName)`: Find caplet providing a service + - State structure (`CapletControllerState`): + - `caplets`: `Record` - all caplet data in a single record + - Dependencies injected via `CapletControllerDeps` (attenuated for POLA): + - `adapter`: Storage adapter + - `launchSubcluster`: Function to launch subclusters + - `terminateSubcluster`: Function to terminate subclusters + - State management via `ControllerStorage`: + - Synchronous reads via `this.state.caplets[id]` + - Synchronous updates via `this.update(draft => { ... })` + +- [x] **Dev Console Integration** + + - Wired CapletController into `background.ts` + - Exposed on `globalThis.omnium.caplet`: + - `install(manifest, bundle?)`, `uninstall(capletId)`, `list()`, `get(capletId)`, `getByService(serviceName)` + +**Recent Refactorings (commits cd5adbd, 9b8c4c9, e400c93)**: + +1. **Controller Base Class** (9b8c4c9): + + - Extracted common patterns into abstract `Controller` base class + - Enforces consistent initialization flow (static `make()`, private constructor, `makeFacet()`) + - Provides protected accessors for `state`, `update()`, `logger` + - CapletController now extends Controller instead of standalone implementation + +2. **ControllerStorage Refactoring** (cd5adbd): + + - Converted from factory function to class-based design with static `make()` + - Implemented synchronous `update()` for immediate in-memory consistency + - Added debounced fire-and-forget persistence with: + - Accumulated key tracking across debounce window (critical bug fix) + - Bounded latency (timer not reset on subsequent updates) + - Immediate writes after idle period for better responsiveness + - Added `clear()` and `clearState()` methods + - Removed old `namespaced-storage` implementation (no longer needed) + +3. **State Structure Simplification** (e400c93): + - Consolidated CapletController state into single `caplets: Record` + - Eliminated separate per-caplet storage keys in favor of single consolidated state object + - Simplified queries (list, get, getByService) to work directly on in-memory state + +**Architecture Evolution Notes**: + +- Storage layer now provides strong consistency guarantees (synchronous updates) +- Controllers can safely call `this.state` immediately after `this.update()` +- Persistence failures are logged but don't block operations (fire-and-forget) +- Future controllers can extend the base class with minimal boilerplate + +- [ ] **Caplet Vat Bundle Format** (Deferred - High Priority) + + - A Caplet's code is a standard vat bundle (JSON output from `@endo/bundle-source`) + - The vat must export `buildRootObject(vatPowers, parameters, baggage)` as per kernel conventions + - The root object should implement a standard Caplet interface (TBD): + - Option A: `initialize(services)` receives requested services, returns own service interface(s) + - Option B: Root object IS the service interface, services injected via bootstrap parameters + - `shutdown()` cleanup hook (if needed) + - **Blocker for integration testing**: Need to define and document this contract before writing actual caplet vats + - Document the Caplet vat contract in `packages/omnium-gatherum/docs/caplet-contract.md` + - Create minimal example caplet in `test/fixtures/echo-caplet/` to validate the contract + +#### 1.3 Implement Caplet Installation + +**Goal**: Enable loading a Caplet into omnium, creating its subcluster, and registering it. + +- [x] **Basic Caplet Installation (Implemented in CapletController)** + + - **Current implementation in `CapletController.install()`**: + - ✓ Validates Caplet manifest using `isCapletManifest()` + - ✓ Checks for duplicate installations + - ✓ Creates `ClusterConfig` with single vat named after Caplet ID + - ✓ Calls `E(kernel).launchSubcluster(config)` via injected dependency + - ✓ Determines subclusterId by diffing kernel status before/after launch + - ✓ Stores Caplet metadata (manifest, subclusterId, installedAt) in storage + - ✓ Returns `InstallResult` with capletId and subclusterId + - **Current limitations**: + - Bundle parameter currently unused (uses `bundleSpec` from manifest directly) + - No service resolution yet (Phase 1 deferred - see 1.4) + - No kref capture from launch result + - Basic error handling (throws on validation/launch failures) + +- [ ] **Bundle Loading Utilities** (TODO) + + - Currently: `bundleSpec` passed through directly to kernel's ClusterConfig + - Need to support multiple bundle sources over time: + - Inline bundle (passed as JSON) + - Local file path (for development) + - HTTP(S) URL (fetch bundle remotely) + - Use existing `@endo/bundle-source` for creating bundles + - Proposed location: `packages/omnium-gatherum/src/controllers/caplet/bundle-loader.ts` + +- [~] **Installation Lifecycle** (Partially implemented) + - ✓ 1. Validate manifest + - [ ] 2. Load bundle (currently bypassed - uses bundleSpec directly) + - [ ] 3. Resolve requested services (Phase 1 deferred) + - ✓ 4. Create subcluster via `launchSubcluster()` + - [ ] 5. Capture Caplet's root kref from launch result (TODO) + - ✓ 6. Store Caplet metadata in storage + - [ ] 7. Pass resolved service krefs in bootstrap (Phase 1 deferred) + - [~] 8. Handle installation errors (basic error handling, no rollback) + +**Phase 1 Status**: Basic installation flow works for simple caplets. Service resolution and advanced bundle loading deferred until PoC validation with actual caplet vats. + +#### 1.4 Create Omnium Service Registry (DEFERRED to Phase 2) + +**Goal**: Provide dynamic service discovery where Caplets can register services and request capabilities at runtime. + +**Architecture Decision**: The service registry will be a **"well-known" vat** that omnium populates with service data from Chrome storage (the canonical source of truth). + +**Status**: **Deferred to Phase 2**. Phase 1 uses direct reference passing for PoC. + +**Future Architecture (Phase 2+)**: + +- [ ] **TODO: Design revocable service connections** + + - Service connections need to be revocable (not just direct object references) + - Consider: membrane pattern, revocable proxies, explicit grant/revoke lifecycle + - Who can revoke? Omnium? Service provider? User? + - What happens to in-flight messages when revoked? + - How do we represent revocation in the UI? + +- [ ] **Service Registry Vat** (Phase 2) + + - Create `packages/omnium-gatherum/src/vats/registry-vat.js` + - Implement a vat that exports `buildRootObject()` returning a registry exo + - Methods: + - `registerService(capletId, serviceName, serviceObject)`: Associates service with Caplet + - `getService(serviceName)`: Returns service object (or revocable proxy) + - `listServices()`: Returns available services + - `unregisterCapletServices(capletId)`: Cleanup on uninstall + - `revokeService(capletId, serviceName)`: Revoke a specific service grant + - **Note**: Registry vat's baggage may be minimal or empty - it's primarily a mediator + - Omnium populates it with data from Chrome storage using E() + +- [ ] **Omnium Populates Registry** (Phase 2) + + - After installing a Caplet: + 1. Omnium launches the Caplet, captures its root kref + 2. Omnium calls `E(registry).registerService(capletId, serviceName, capletKref)` + 3. Registry vat now knows about this service + - When a Caplet requests a service: + 1. Caplet calls `E(registry).getService(serviceName)` + 2. Registry returns the provider's kref (or revocable proxy) + - Canonical state: Chrome storage + - Registry vat: Derived state, populated by omnium + +- [ ] **Caplet Service Registration Flow** (Phase 2) + - All Caplets receive registry vat reference in bootstrap + - Dynamic discovery: Caplets can request services at runtime + - Revocation: Connections can be terminated, must handle gracefully + +**Phase 1 Approach**: Skip registry vat entirely. Services resolved at install time and passed directly to Caplets via bootstrap arguments. This gets us to a working PoC faster while we design the revocation model. + +#### 1.5 Caplet Communication Protocol + +**Goal**: Define how Caplets use capabilities from other Caplets. + +**Status**: Deferred until we have actual caplet vats to test with. + +- [ ] **Phase 1: Direct Reference Pattern** (Design complete, implementation deferred) + + - Document the flow in `packages/omnium-gatherum/docs/service-discovery.md`: + 1. Caplet A's manifest declares `requestedServices: ["bitcoin"]` + 2. Omnium looks up bitcoin service provider (Caplet B) in Chrome storage + 3. Omnium retrieves Caplet B's root kref + 4. Omnium passes Caplet B's kref to Caplet A in bootstrap: `bootstrap(vats, { bitcoin: capletBKref })` + 5. Caplet A uses `E(bitcoin).someMethod()` to invoke methods + 6. Messages are routed through kernel (standard vat-to-vat messaging) + - **Limitation**: Services resolved at install time, no runtime discovery + - **Benefit**: Simple, no registry vat needed for PoC + +- [ ] **Phase 2+: Dynamic Discovery Pattern** (Deferred) + + - Caplets receive registry vat reference + - Can request services at runtime: `E(registry).getService("someService")` + - Services can be revoked + - More flexible but requires registry vat infrastructure + +- [ ] **Service Interface Conventions** + - Define recommended patterns for service interfaces: + - Use async methods (return promises) + - Accept/return serializable data or object references + - Document expected methods in service interface types + - Create example service interfaces in `packages/omnium-gatherum/src/services/interfaces.ts` + +#### 1.6 Dev Console Integration + +**Goal**: Make Caplet installation usable from the Chrome DevTools console. + +- [x] **Expose Caplet Operations on globalThis.omnium** + + - **Implemented in `packages/omnium-gatherum/src/background.ts`**: + - ✓ `globalThis.omnium` object defined and hardened + - ✓ `globalThis.E` exposed for manual E() calls + - ✓ `omnium.ping()`: Test kernel connectivity + - ✓ `omnium.getKernel()`: Get kernel remote presence for E() calls + - ✓ `omnium.caplet.install(manifest, bundle?)`: Install a Caplet + - Delegates to `E(capletController).install(manifest, bundle)` + - Returns: `Promise` with `{ capletId, subclusterId }` + - ✓ `omnium.caplet.uninstall(capletId)`: Uninstall a Caplet + - Terminates its subcluster and removes from storage + - ✓ `omnium.caplet.list()`: List installed Caplets + - Returns: `Promise` + - ✓ `omnium.caplet.get(capletId)`: Get specific caplet + - Returns: `Promise` + - ✓ `omnium.caplet.getByService(serviceName)`: Find caplet providing a service + - Returns: `Promise` + - ✓ All `omnium.caplet` methods are hardened + - **Not yet implemented**: + - `omnium.service` namespace (deferred - Phase 2 registry vat) + +- [ ] **Example Usage in Console** (TODO) + + - Create test Caplets in `packages/omnium-gatherum/test/fixtures/`: + - `echo-caplet`: Simple Caplet that registers an "echo" service + - `consumer-caplet`: Caplet that discovers and calls the "echo" service + - Document console commands in `packages/omnium-gatherum/docs/dev-console-usage.md`: + + ```javascript + // Install echo Caplet + await omnium.caplet.install({ + id: 'com.example.echo', + name: 'Echo Service', + version: '1.0.0', + bundleSpec: '/path/to/echo.bundle', + providedServices: ['echo'], + requestedServices: [], + }); + + // List installed Caplets + await omnium.caplet.list(); + + // Get specific caplet + await omnium.caplet.get('com.example.echo'); + + // Find caplet by service + await omnium.caplet.getByService('echo'); + + // Uninstall + await omnium.caplet.uninstall('com.example.echo'); + ``` + +**Status**: Core dev console integration complete. Documentation and example fixtures needed. + +#### 1.7 Testing + +**Goal**: Validate that Caplets can be installed and communicate with each other. + +- [x] **Unit Tests** (Implemented) + + - ✓ `controllers/caplet/types.test.ts`: Validates manifest schema, CapletId, SemVer formats + - ✓ `controllers/caplet/caplet-controller.test.ts`: Tests CapletController methods (install, uninstall, list, get, getByService) + - ✓ `controllers/base-controller.test.ts`: Tests abstract Controller base class (12 tests) + - ✓ `controllers/storage/controller-storage.test.ts`: Tests ControllerStorage with debouncing, accumulation, bounded latency + - ✓ `controllers/storage/chrome-storage.test.ts`: Tests ChromeStorageAdapter + - ✓ `controllers/facet.test.ts`: Tests makeFacet utility + - ✓ `kernel-browser-runtime`: CapTP infrastructure tests (background-captp, kernel-facade, kernel-captp, integration) + +- [ ] **Integration Tests** (TODO) + + - Need: End-to-end caplet tests with actual vat bundles + - `packages/omnium-gatherum/test/caplet-integration.test.ts`: + - Install two Caplets with real vat code + - Verify one can discover and call the other's service + - Verify message passing works correctly through kernel + - Test uninstallation and cleanup + - Test error handling (invalid manifests, launch failures, etc.) + +- [~] **E2E Tests (Playwright)** (Smoke test only) + - ✓ `test/e2e/smoke.test.ts`: Basic extension loading + - [ ] `test/e2e/caplet.spec.ts`: Full caplet workflow + - Load omnium extension in browser + - Use console to install Caplets + - Verify they can communicate + - Check DevTools console output + - Test UI interactions (if applicable) + +#### 1.8 Documentation + +- [ ] **Architecture Documentation** (TODO) + + - Create `packages/omnium-gatherum/docs/architecture.md`: + - Explain how Caplets relate to subclusters and vats + - Diagram showing omnium → kernel → Caplet subclusters + - Userspace E() infrastructure (CapTP-based) + - Controller architecture and storage layer + - Phase 1: Direct reference passing vs Phase 2: Dynamic service discovery + +- [ ] **Developer Guide** (TODO) + + - Create `packages/omnium-gatherum/docs/caplet-development.md`: + - How to write a Caplet vat + - Caplet vat contract (buildRootObject, initialization, etc.) + - Service registration examples + - Requesting services from other Caplets + - Testing Caplets locally + - Bundle creation with @endo/bundle-source + +- [ ] **Dev Console Usage Guide** (TODO) + - Create `packages/omnium-gatherum/docs/dev-console-usage.md`: + - Using `globalThis.omnium` in Chrome DevTools + - Installing/uninstalling caplets + - Querying installed caplets and services + - Example console workflows + +--- + +### Future Phases: UI Architecture + +**Context**: Phase 1 focuses on headless Caplets with dev console interaction only. This section outlines the vision for how Caplets will eventually provide user-facing UI while maintaining security and composability. + +#### Core Principles + +1. **Zero trust for Caplet UI code**: Caplet-provided UI code must not run in privileged extension contexts +2. **Composability**: Multiple Caplets' UIs should compose naturally into a cohesive experience +3. **Security isolation**: Caplet UI should be isolated from other Caplets and omnium's privileged code +4. **User experience**: UI should feel cohesive, not fragmented + +#### Phase 2: Declarative UI Contributions + +**Goal**: Enable Caplets to describe their data and capabilities using a safe, declarative format that Omnium renders using trusted UI components. + +- **Caplet UI Manifest**: + + - Caplets declare what they provide via structured metadata (not code): + - Account types: `{ type: "bitcoin", properties: ["address", "balance", "publicKey"] }` + - Actions: `{ name: "signTransaction", inputs: [...], confirmation: "Show tx details" }` + - Settings: `{ name: "Network", type: "select", options: [...] }` + - Similar to how native apps declare permissions and intents + +- **Omnium UI Framework**: + + - Provides trusted, pre-built UI components: + - Account list view (renders all accounts from all Caplets) + - Transaction confirmation modal + - Settings panels + - Status indicators + - Caplets' data flows into these components + - Omnium controls all rendering (no Caplet code execution in UI context) + +- **Data Flow**: + + ``` + Caplet vat → Service methods → RPC → Background → Omnium UI components → Rendered UI + ``` + +- **Benefits**: + + - Caplets customize UX without providing arbitrary code + - Omnium maintains UX consistency + - Security: Only trusted omnium code renders UI + - Composability: Multiple Caplets' data can be combined in standard views + +- **Limitations**: + - Caplets cannot provide fully custom UX + - Limited to omnium's predefined UI patterns + - Novel UI patterns require omnium updates + +#### Phase 3: Isolated UI Frames (Advanced) + +**Goal**: Allow Caplets to provide custom UI for complex use cases while maintaining security isolation. + +- **Architecture**: + + - Caplets can optionally provide UI content served in isolated iframes + - Each Caplet's UI runs in a separate iframe with strict CSP + - Communication between Caplet UI and Caplet vat via postMessage/RPC + - Caplet UI cannot access other Caplets or omnium privileged APIs + +- **UI Composition Challenges**: + + - Multiple iframes are harder to compose into cohesive UX + - Cross-frame communication complexity + - Performance and visual consistency concerns + +- **Possible Solutions**: + + - Web Components: Caplets define custom elements that omnium can compose + - Shadow DOM for style isolation + - Standardized theming/design tokens for visual consistency + - Message bus for inter-Caplet UI communication (mediated by omnium) + +- **Research Questions**: + - Can we achieve seamless composition with iframe-based isolation? + - Are Web Components + Shadow DOM sufficient for security isolation? + - How do we handle shared state (e.g., global loading indicators, modals)? + - Can we use technologies like import maps with module federation for safer code loading? + +#### Phase 4: Trusted UI Plugins (Speculative) + +**Goal**: Separate the trust model for UI from backend Caplet logic. + +- **Two-tier system**: + + - **Caplets**: Headless services (untrusted, fully sandboxed) + - **UI Plugins**: Separate entities that call Caplet services (potentially more trusted) + +- **UI Plugin Trust Model**: + + - UI plugins go through different review/curation + - May have different permission model + - Could run in less-sandboxed contexts if they meet trust requirements + - Users explicitly install UI plugins separately from backend Caplets + +- **Benefits**: + + - Flexibility: Same backend Caplet can have multiple UIs + - Security: Can have stricter requirements for UI plugins + - Separation: Backend and frontend evolve independently + +- **Challenges**: + - More complex installation/discovery + - Coordination between Caplet and UI plugin developers + - User confusion about two types of plugins + +#### Open Research Questions + +1. **Secure UI composition**: Is it possible to achieve truly composable UI while maintaining strong security isolation? +2. **Web platform primitives**: Can we leverage Web Components, Shadow DOM, import maps, etc. effectively? +3. **User experience**: How do we maintain UX cohesion with third-party UI contributions? +4. **Performance**: What's the overhead of iframe/web component isolation? +5. **Developer experience**: How do we make it easy to build Caplet UIs within constraints? + +#### Recommendation for Phase 1 + +For Phase 1, **defer all UI architecture decisions**: + +- Caplets are purely headless services +- Dev console provides all interaction +- This gives us time to research and experiment with UI approaches +- Backend architecture (service discovery, vat communication) is orthogonal to UI + +--- + +### Open Questions / Design Decisions for Phase 1 + +1. **One vat vs. multiple vats per Caplet?** + + - Start with one vat per Caplet (simplest) + - A Caplet can launch multiple vats if needed by creating its own sub-subcluster + +2. **Capability approval mechanism?** + + - Phase 1: No approval UI, services are freely accessible once registered + - Phase 2: Add approval prompts before granting service access + +3. **Service naming conflicts?** + + - Phase 1: Last-registered wins + - Phase 2: Support namespacing or multiple providers + +4. **Where does omnium's own code run?** + + - Background script: Installation management, E() calls to kernel, Chrome storage for metadata (canonical) + - Phase 1: No registry vat (services passed directly) + - Phase 2+: Registry vat for dynamic discovery (omnium-populated, revocable connections) + - Caplets: Each in their own subcluster + - Clean separation: kernel knows nothing about Caplets, only vats/subclusters + +5. **Bundle storage?** + + - Phase 1: Bundles are ephemeral, not stored (must re-provide on install) + - Phase 2: Store bundles in Chrome storage or IndexedDB for persistence across restarts + - Never in kernel store - maintains user/kernel space separation + +6. **How do Caplets receive service references?** + + - Phase 1: Via bootstrap arguments - resolved krefs passed directly (e.g., `bootstrap(vats, { bitcoin: kref })`) + - Phase 2+: Via registry vat - dynamic discovery at runtime + +7. **Userspace E() infrastructure** + - Critical foundation: Enables omnium to use E() to interact with kernel and vat objects + - Kernel exposes exo interface + - Userspace creates remote proxies to vat objects using returned krefs + - This is how omnium will populate the registry vat in Phase 2 + +## High-level plan + +### Components Built Into Omnium Directly + +These are the core distribution components that ship with omnium-gatherum: + +1. Extension Shell + +- Background service worker orchestration +- Offscreen document for kernel isolation +- Popup interface +- DevTools integration +- Communication with third-party context via `externally_connectable` + +2. Kernel Integration Layer + +- Kernel worker initialization and lifecycle management +- RPC client/server plumbing between extension contexts +- Stream-based IPC infrastructure +- Storage initialization and migration + +3. Caplet Management UI + +- Install/uninstall Caplets interface +- View all installed Caplets with versions +- Update management (review diffs, approve updates, pin versions) +- Search/browse Caplets from configured registries +- Direct installation by CID (for uncensored access) + +4. Capability Management System + +- Capability grant approval UI (shown on install and at runtime) +- Revocation controls for active capabilities +- Attenuation interface (time limits, rate limits, scoping) +- Capability audit log/visualization +- Inter-Caplet capability delegation review + +5. Security & Trust UI + +- Risk labels and warnings +- Attestation display (audits, security reviews, community ratings) +- Requested capabilities review on install +- Code diff viewer for updates +- Emergency quarantine controls (opt-in to DAO flags) +- Reproducible build verification status + +6. Wallet Configuration Management + +- Blueprint export/import (save/restore entire wallet setup) +- Registry management (add/remove registries) +- Settings and preferences +- Backup/recovery workflows (delegates to installed signer Caplets) + +7. Bootstrap Experience + +- First-run setup flow +- Default registry configuration +- Possibly a minimal set of "blessed" initial Caplets (or truly zero - TBD) +- Onboarding education about the Caplet model + +### Caplet Ecosystem Support (External Components) + +These enable the permissionless, decentralized Caplet ecosystem: + +1. Publishing Infrastructure + +- IPFS pinning services, deterministic builds, code signing tools, registry + registration protocol + +2. Registry System + +- Onchain registry contracts, multiple independent registries, curation + mechanisms (staking, slashing), search/discovery APIs + +3. Governance & Economics + +- TBD + +4. Security & Attestation + +- Auditor network, bug bounty platform, attestation publication (EAS/DIDs), + continuous monitoring + +5. Developer Tooling + +- Caplet SDK (TypeScript), testing harness for sandbox behavior, build/publish + CLI, reference implementations and templates, capability protocol documentation + +The key distinction: omnium is the user-facing distribution that makes the +kernel usable, while the ecosystem components enable the permissionless +marketplace of Caplets that omnium consumers can install. diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index 688955bae..1f52025d6 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -10,6 +10,31 @@ or `npm install @ocap/omnium-gatherum` +## Usage + +### Installing and using the `echo` caplet + +After loading the extension, open the background console (chrome://extensions → Omnium → "Inspect views: service worker") and run the following: + +```javascript +// 1. Load the echo caplet manifest and bundle +const { manifest, bundle } = await omnium.loadCaplet('echo'); + +// 2. Install the caplet +const installResult = await omnium.caplet.install(manifest, bundle); + +// 3. Get the caplet's root kref +const capletInfo = await omnium.caplet.get(installResult.capletId); +const rootKref = capletInfo.rootKref; + +// 4. Resolve the kref to an E()-usable presence +const echoRoot = omnium.resolveKref(rootKref); + +// 5. Call the echo method +const result = await E(echoRoot).echo('Hello, world!'); +console.log(result); // "echo: Hello, world!" +``` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/omnium-gatherum/docs/caplet-contract.md b/packages/omnium-gatherum/docs/caplet-contract.md new file mode 100644 index 000000000..55adcacc4 --- /dev/null +++ b/packages/omnium-gatherum/docs/caplet-contract.md @@ -0,0 +1,343 @@ +# Caplet Vat Contract + +This document defines the interface that all Caplet vats must implement to work within the Omnium system. + +## Overview + +A Caplet is a sandboxed application that runs in its own vat (Virtual Address Table) within the kernel. Each Caplet provides services and/or consumes services from other Caplets using object capabilities. + +## Core Contract + +### buildRootObject Function + +All Caplet vats must export a `buildRootObject` function with the following signature: + +```javascript +export function buildRootObject(vatPowers, parameters, baggage) { + // Implementation + return rootObject; +} +``` + +#### Parameters + +**`vatPowers`**: Object providing kernel-granted capabilities +- `vatPowers.logger`: Structured logging interface + - Use `vatPowers.logger.subLogger({ tags: ['tag1', 'tag2'] })` to create a namespaced logger + - Supports `.log()`, `.error()`, `.warn()`, `.debug()` methods +- Other powers as defined by the kernel + +**`parameters`**: Bootstrap parameters from Omnium +- Phase 1: Contains service references as `{ serviceName: kref }` + - Service names match those declared in the Caplet's `manifest.requestedServices` + - Each requested service is provided as a remote presence (kref) +- Phase 2+: Will include registry vat reference for dynamic service discovery +- May include optional configuration fields + +**`baggage`**: Persistent state storage (MapStore) +- Root of the vat's persistent state +- Survives vat restarts and upgrades +- Use for storing durable data + +### Root Object + +The `buildRootObject` function must return a hardened root object. This object becomes the Caplet's public interface. + +**Recommended pattern:** +Use `makeDefaultExo` from `@metamask/kernel-utils/exo`: + +```javascript +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +export function buildRootObject(vatPowers, parameters, baggage) { + const logger = vatPowers.logger.subLogger({ tags: ['my-caplet'] }); + + return makeDefaultExo('my-caplet-root', { + bootstrap() { + logger.log('Caplet initialized'); + }, + // ... service methods + }); +} +``` + +### Bootstrap Method (Optional but Recommended) + +The root object may expose a `bootstrap` method that gets called during vat initialization: + +```javascript +{ + bootstrap() { + // Initialization logic + // Access to injected services via parameters + } +} +``` + +**For service consumers:** +```javascript +bootstrap(_vats, services) { + // Phase 1: Services passed directly via parameters + const myService = parameters.myService; + + // Phase 2+: Services accessed via registry + const registry = parameters.registry; + const myService = await E(registry).getService('myService'); +} +``` + +## Service Patterns + +### Providing Services + +Caplets that provide services should: + +1. Declare provided services in `manifest.providedServices: ['serviceName']` +2. Expose service methods on the root object +3. Return hardened results or promises + +```javascript +export function buildRootObject(vatPowers, parameters, baggage) { + const logger = vatPowers.logger.subLogger({ tags: ['echo-service'] }); + + return makeDefaultExo('echo-service-root', { + bootstrap() { + logger.log('Echo service ready'); + }, + + // Service method + echo(message) { + logger.log('Echoing:', message); + return `Echo: ${message}`; + }, + }); +} +``` + +### Consuming Services + +Caplets that consume services should: + +1. Declare requested services in `manifest.requestedServices: ['serviceName']` +2. Access services from the `parameters` object +3. Use `E()` from `@endo/eventual-send` for async calls + +```javascript +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +export function buildRootObject(vatPowers, parameters, baggage) { + const logger = vatPowers.logger.subLogger({ tags: ['consumer'] }); + + // Phase 1: Services passed directly in parameters + const { echoService } = parameters; + + if (!echoService) { + throw new Error('Required service "echoService" not provided'); + } + + return makeDefaultExo('consumer-root', { + bootstrap() { + logger.log('Consumer initialized with echo service'); + }, + + async useService(message) { + // Call service method using E() + const result = await E(echoService).echo(message); + logger.log('Received from service:', result); + return result; + }, + }); +} +``` + +## Phase 1 Service Discovery + +In Phase 1, service discovery is **static** and happens at install time: + +1. Caplet manifest declares `requestedServices: ['serviceName']` +2. Omnium resolves each requested service by looking up providers in storage +3. Omnium retrieves the provider Caplet's root kref +4. Omnium passes the kref to the consumer via `parameters` object +5. Consumer accesses service as `parameters.serviceName` + +**Limitations:** +- Services must already be installed before dependent Caplets +- No runtime service discovery or dynamic registration +- Services are bound at install time + +**Example flow:** +```javascript +// 1. Install echo-caplet (provides "echo" service) +await omnium.caplet.install(echoManifest); + +// 2. Install consumer-caplet (requests "echo" service) +// Omnium automatically resolves and passes echo service kref +await omnium.caplet.install(consumerManifest); +``` + +## Phase 2+ Service Discovery (Future) + +In Phase 2+, service discovery will be **dynamic** via a registry vat: + +- All Caplets receive a registry vat reference in `parameters.registry` +- Services can be requested at runtime: `await E(registry).getService('name')` +- Services can be revoked +- More flexible but requires registry vat infrastructure + +## Code Patterns + +### Using Logger + +```javascript +const logger = vatPowers.logger.subLogger({ tags: ['my-caplet', 'feature'] }); + +logger.log('Informational message', { data: 'value' }); +logger.error('Error occurred', error); +logger.warn('Warning message'); +logger.debug('Debug info'); +``` + +### Using Baggage (Persistent State) + +```javascript +import { makeScalarMapStore } from '@agoric/store'; + +export function buildRootObject(vatPowers, parameters, baggage) { + // Initialize persistent store + if (!baggage.has('state')) { + baggage.init('state', makeScalarMapStore('caplet-state')); + } + + const state = baggage.get('state'); + + return makeDefaultExo('root', { + setValue(key, value) { + state.init(key, value); + }, + getValue(key) { + return state.get(key); + }, + }); +} +``` + +### Using E() for Async Calls + +```javascript +import { E } from '@endo/eventual-send'; + +// Call methods on remote objects (service krefs) +const result = await E(serviceKref).methodName(arg1, arg2); + +// Chain promises +const final = await E(E(service).getChild()).doWork(); + +// Pass object references in arguments +await E(service).processObject(myLocalObject); +``` + +### Error Handling + +```javascript +{ + async callService() { + try { + const result = await E(service).riskyMethod(); + return result; + } catch (error) { + logger.error('Service call failed:', error); + throw new Error(`Failed to call service: ${error.message}`); + } + } +} +``` + +## Type Safety (Advanced) + +For type-safe Caplets, use `@endo/patterns` and `@endo/exo`: + +```javascript +import { M } from '@endo/patterns'; +import { defineExoClass } from '@endo/exo'; + +const ServiceI = M.interface('ServiceInterface', { + echo: M.call(M.string()).returns(M.string()), +}); + +const Service = defineExoClass( + 'Service', + ServiceI, + () => ({}), + { + echo(message) { + return `Echo: ${message}`; + }, + }, +); + +export function buildRootObject(vatPowers, parameters, baggage) { + return Service.make(); +} +``` + +## Security Considerations + +1. **Always harden objects**: Use `makeDefaultExo` or `harden()` to prevent mutation +2. **Validate inputs**: Check arguments before processing +3. **Capability discipline**: Only pass necessary capabilities, follow POLA (Principle of Least Authority) +4. **Don't leak references**: Be careful about returning internal objects +5. **Handle errors gracefully**: Don't expose internal state in error messages + +## Example Caplets + +See reference implementations: +- `packages/omnium-gatherum/src/vats/echo-caplet.ts` - Simple service provider +- `packages/omnium-gatherum/src/vats/consumer-caplet.ts` - Service consumer (Phase 2) + +Also see kernel test vats for patterns: +- `packages/kernel-test/src/vats/exo-vat.js` - Advanced exo patterns +- `packages/kernel-test/src/vats/service-vat.js` - Service injection example +- `packages/kernel-test/src/vats/logger-vat.js` - Minimal vat example + +## Bundle Creation + +Caplet source files must be bundled using `@endo/bundle-source`: + +```bash +# Using the ocap CLI +yarn ocap bundle src/vats/my-caplet.ts + +# Creates: src/vats/my-caplet.bundle +``` + +The generated `.bundle` file is referenced in the Caplet manifest's `bundleSpec` field. + +## Manifest Integration + +Each Caplet must have a manifest that references its bundle: + +```typescript +const myCapletManifest: CapletManifest = { + id: 'com.example.my-caplet', + name: 'My Caplet', + version: '1.0.0', + bundleSpec: 'file:///path/to/my-caplet.bundle', + requestedServices: ['someService'], + providedServices: ['myService'], +}; +``` + +## Summary + +A valid Caplet vat must: + +1. ✅ Export `buildRootObject(vatPowers, parameters, baggage)` +2. ✅ Return a hardened root object (use `makeDefaultExo`) +3. ✅ Optionally implement `bootstrap()` for initialization +4. ✅ Access services from `parameters` object (Phase 1) +5. ✅ Use `E()` for async service calls +6. ✅ Use `vatPowers.logger` for logging +7. ✅ Follow object capability security principles + +This contract ensures Caplets can interoperate within the Omnium ecosystem while maintaining security and composability. diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index e97cedf61..ac1f1e76f 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -16,10 +16,11 @@ "dist/" ], "scripts": { - "build": "yarn build:vite && yarn test:build", + "build": "yarn build:vats && yarn build:vite && yarn test:build", "build:dev": "yarn build:vite --mode development", "build:watch": "yarn build:dev --watch", "build:browser": "OPEN_BROWSER=true yarn build:dev --watch", + "build:vats": "ocap bundle src/vats", "build:vite": "vite build --configLoader runner --config vite.config.ts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/omnium-gatherum", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo", @@ -43,17 +44,21 @@ "test:e2e:debug": "playwright test --debug" }, "dependencies": { + "@endo/eventual-send": "^1.3.4", + "@endo/exo": "^1.5.12", "@metamask/kernel-browser-runtime": "workspace:^", - "@metamask/kernel-rpc-methods": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", + "@metamask/superstruct": "^3.2.1", "@metamask/utils": "^11.9.0", + "immer": "^10.1.1", "react": "^17.0.2", "react-dom": "^17.0.2", + "semver": "^7.7.1", "ses": "^1.14.0" }, "devDependencies": { @@ -70,6 +75,7 @@ "@types/chrome": "^0.0.313", "@types/react": "^17.0.11", "@types/react-dom": "^17.0.11", + "@types/semver": "^7.7.1", "@types/webextension-polyfill": "^0", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 7b2b07ba4..659782d63 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,11 +1,25 @@ -import { RpcClient } from '@metamask/kernel-rpc-methods'; -import { delay } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { E } from '@endo/eventual-send'; +import { + makeBackgroundCapTP, + makePresenceManager, + makeCapTPNotification, + isCapTPNotification, + getCapTPMessage, +} from '@metamask/kernel-browser-runtime'; +import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; -import { kernelMethodSpecs } from '@metamask/ocap-kernel/rpc'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -import { isJsonRpcResponse } from '@metamask/utils'; -import type { JsonRpcResponse } from '@metamask/utils'; + +import { + CapletController, + makeChromeStorageAdapter, +} from './controllers/index.ts'; +import type { CapletManifest, LaunchResult } from './controllers/index.ts'; + +defineGlobals(); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); @@ -74,37 +88,158 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); + // Create stream for CapTP messages const offscreenStream = await ChromeRuntimeDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(chrome.runtime, 'background', 'offscreen', isJsonRpcResponse); - - const rpcClient = new RpcClient( - kernelMethodSpecs, - async (request) => { - await offscreenStream.write(request); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'background', 'offscreen', isJsonRpcMessage); + + // Set up CapTP for E() based communication with the kernel + const backgroundCapTP = makeBackgroundCapTP({ + send: (captpMessage: CapTPMessage) => { + const notification = makeCapTPNotification(captpMessage); + offscreenStream.write(notification).catch((error) => { + logger.error('Failed to send CapTP message:', error); + }); + }, + }); + + // Get the kernel remote presence + const kernelP = backgroundCapTP.getKernel(); + globalThis.kernel = kernelP; + + // Create presence manager for E() on vat objects + const presenceManager = makePresenceManager({ kernelFacade: kernelP }); + + // Create storage adapter + const storageAdapter = makeChromeStorageAdapter(); + + // Create CapletController with attenuated kernel access + // Controller creates its own storage internally + const capletController = await CapletController.make( + { logger: logger.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => { + const result = await E(kernelP).launchSubcluster(config); + return { + subclusterId: result.subclusterId, + rootKref: result.rootKref, + }; + }, + terminateSubcluster: async (subclusterId: string): Promise => { + await E(kernelP).terminateSubcluster(subclusterId); + }, + getVatRoot: async (krefString: string): Promise => { + // Convert kref string to presence via kernel facade + return E(kernelP).getVatRoot(krefString); + }, }, - 'background:', ); - const ping = async (): Promise => { - const result = await rpcClient.call('ping', []); - logger.info(result); + /** + * Load a caplet's manifest and bundle by ID. + * + * @param id - The short caplet ID (e.g., 'echo'). + * @returns The manifest and bundle for installation. + */ + const loadCaplet = async ( + id: string, + ): Promise<{ manifest: CapletManifest; bundle: unknown }> => { + const baseUrl = chrome.runtime.getURL(''); + + // Fetch manifest + const manifestUrl = `${baseUrl}${id}.manifest.json`; + const manifestResponse = await fetch(manifestUrl); + if (!manifestResponse.ok) { + throw new Error(`Failed to fetch manifest for caplet "${id}"`); + } + const manifestData = (await manifestResponse.json()) as Omit< + CapletManifest, + 'bundleSpec' + >; + + // Construct full manifest with bundleSpec + const bundleSpec = `${baseUrl}${id}-caplet.bundle`; + const manifest: CapletManifest = { + ...manifestData, + bundleSpec, + }; + + // Fetch bundle + const bundleResponse = await fetch(bundleSpec); + if (!bundleResponse.ok) { + throw new Error(`Failed to fetch bundle for caplet "${id}"`); + } + const bundle: unknown = await bundleResponse.json(); + + return { manifest, bundle }; }; + Object.defineProperties(globalThis.omnium, { + loadCaplet: { + value: loadCaplet, + }, + caplet: { + value: harden({ + install: async (manifest: CapletManifest, bundle?: unknown) => + E(capletController).install(manifest, bundle), + uninstall: async (capletId: string) => + E(capletController).uninstall(capletId), + list: async () => E(capletController).list(), + get: async (capletId: string) => E(capletController).get(capletId), + getByService: async (serviceName: string) => + E(capletController).getByService(serviceName), + getCapletRoot: async (capletId: string) => + E(capletController).getCapletRoot(capletId), + }), + }, + resolveKref: { + value: presenceManager.resolveKref, + }, + krefOf: { + value: presenceManager.krefOf, + }, + }); + harden(globalThis.omnium); + // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - ping().catch(logger.error); + E(kernelP).ping().catch(logger.error); }); try { - // Pipe responses back to the RpcClient - await offscreenStream.drain(async (message) => - rpcClient.handleResponse(message.id as string, message), - ); + // Handle incoming CapTP messages from the kernel + await offscreenStream.drain((message) => { + if (isCapTPNotification(message)) { + const captpMessage = getCapTPMessage(message); + backgroundCapTP.dispatch(captpMessage); + } + }); } catch (error) { throw new Error('Offscreen connection closed unexpectedly', { cause: error, }); } } + +/** + * Define globals accessible via the background console. + */ +function defineGlobals(): void { + Object.defineProperty(globalThis, 'omnium', { + configurable: false, + enumerable: true, + writable: false, + value: {}, + }); + + Object.defineProperty(globalThis, 'E', { + configurable: false, + enumerable: true, + writable: false, + value: E, + }); +} diff --git a/packages/omnium-gatherum/src/caplets/echo.manifest.json b/packages/omnium-gatherum/src/caplets/echo.manifest.json new file mode 100644 index 000000000..436d60cfb --- /dev/null +++ b/packages/omnium-gatherum/src/caplets/echo.manifest.json @@ -0,0 +1,7 @@ +{ + "id": "com.example.echo", + "name": "Echo Service", + "version": "1.0.0", + "requestedServices": [], + "providedServices": ["echo"] +} diff --git a/packages/omnium-gatherum/src/controllers/base-controller.test.ts b/packages/omnium-gatherum/src/controllers/base-controller.test.ts new file mode 100644 index 000000000..1bfa82f9b --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/base-controller.test.ts @@ -0,0 +1,313 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { Controller } from './base-controller.ts'; +import type { ControllerConfig } from './base-controller.ts'; +import { ControllerStorage } from './storage/controller-storage.ts'; +import type { StorageAdapter } from './storage/types.ts'; +import { makeMockStorageAdapter } from '../../test/utils.ts'; + +/** + * Test state for the concrete test controller. + */ +type TestState = { + items: Record; + count: number; +}; + +/** + * Test methods for the concrete test controller. + */ +type TestMethods = { + addItem: (id: string, name: string, value: number) => Promise; + removeItem: (id: string) => Promise; + getItem: (id: string) => Promise<{ name: string; value: number } | undefined>; + getCount: () => Promise; + clearState: () => void; + getState: () => Readonly; +}; + +/** + * Concrete controller for testing the abstract Controller base class. + */ +class TestController extends Controller< + 'TestController', + TestState, + TestMethods +> { + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor(storage: ControllerStorage, logger: Logger) { + super('TestController', storage, logger); + harden(this); + } + + static async make( + config: ControllerConfig, + adapter: StorageAdapter, + ): Promise { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter, + defaultState: { + items: {}, + count: 0, + }, + logger: config.logger, + debounceMs: 0, + }); + + const controller = new TestController(storage, config.logger); + return controller.makeFacet(); + } + + makeFacet(): TestMethods { + return makeDefaultExo('TestController', { + addItem: async ( + id: string, + name: string, + value: number, + ): Promise => { + this.logger.info(`Adding item: ${id}`); + this.update((draft) => { + draft.items[id] = { name, value }; + draft.count += 1; + }); + }, + removeItem: async (id: string): Promise => { + this.logger.info(`Removing item: ${id}`); + this.update((draft) => { + delete draft.items[id]; + draft.count -= 1; + }); + }, + getItem: async ( + id: string, + ): Promise<{ name: string; value: number } | undefined> => { + return this.state.items[id]; + }, + getCount: async (): Promise => { + return this.state.count; + }, + clearState: (): void => { + this.clearState(); + }, + getState: (): Readonly => { + return this.state; + }, + }); + } +} +harden(TestController); + +describe('Controller', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const config: ControllerConfig = { + logger: mockLogger as unknown as ControllerConfig['logger'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('state access', () => { + it('provides read-only access to state', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); + + const item = await controller.getItem('foo'); + + expect(item).toStrictEqual({ name: 'Foo', value: 42 }); + }); + + it('returns undefined for non-existent items', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + const item = await controller.getItem('nonexistent'); + + expect(item).toBeUndefined(); + }); + + it('reflects initial state count', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }); + await mockAdapter.set('test.count', 2); + + const controller = await TestController.make(config, mockAdapter); + + const count = await controller.getCount(); + + expect(count).toBe(2); + }); + }); + + describe('state updates', () => { + it('updates state through update method', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('test', 'Test Item', 100); + + const item = await controller.getItem('test'); + expect(item).toStrictEqual({ name: 'Test Item', value: 100 }); + }); + + it('increments count when adding items', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('a', 'Item A', 1); + await controller.addItem('b', 'Item B', 2); + + const count = await controller.getCount(); + expect(count).toBe(2); + }); + + it('decrements count when removing items', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }); + await mockAdapter.set('test.count', 2); + + const controller = await TestController.make(config, mockAdapter); + + await controller.removeItem('a'); + + const count = await controller.getCount(); + expect(count).toBe(1); + }); + + it('removes item from state', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); + + await controller.removeItem('foo'); + + const item = await controller.getItem('foo'); + expect(item).toBeUndefined(); + }); + + it('persists state modifications to storage', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('a', 'A', 1); + await controller.addItem('b', 'B', 2); + await controller.removeItem('a'); + + // Wait for debounced persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Check that state was persisted + const items = await mockAdapter.get('test.items'); + const count = await mockAdapter.get('test.count'); + expect(items).toStrictEqual({ b: { name: 'B', value: 2 } }); + expect(count).toBe(1); + }); + }); + + describe('logging', () => { + it('logs through provided logger', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('test', 'Test', 1); + + expect(mockLogger.info).toHaveBeenCalledWith('Adding item: test'); + }); + + it('logs remove operations', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); + + await controller.removeItem('foo'); + + expect(mockLogger.info).toHaveBeenCalledWith('Removing item: foo'); + }); + }); + + describe('clearState', () => { + it('clears state through clearState method', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + await controller.addItem('a', 'A', 1); + + const stateBefore = controller.getState(); + expect(stateBefore.items).toStrictEqual({ a: { name: 'A', value: 1 } }); + expect(stateBefore.count).toBe(1); + + controller.clearState(); + + const stateAfter = controller.getState(); + expect(stateAfter.items).toStrictEqual({}); + expect(stateAfter.count).toBe(0); + }); + + it('persists cleared state', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + await controller.addItem('a', 'A', 1); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + controller.clearState(); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + const items = await mockAdapter.get('test.items'); + const count = await mockAdapter.get('test.count'); + expect(items).toStrictEqual({}); + expect(count).toBe(0); + }); + }); + + describe('makeFacet', () => { + it('returns hardened exo with all methods', async () => { + const mockAdapter = makeMockStorageAdapter(); + const facet = await TestController.make(config, mockAdapter); + + expect(typeof facet.addItem).toBe('function'); + expect(typeof facet.removeItem).toBe('function'); + expect(typeof facet.getItem).toBe('function'); + expect(typeof facet.getCount).toBe('function'); + expect(typeof facet.clearState).toBe('function'); + expect(typeof facet.getState).toBe('function'); + }); + + it('methods work correctly through exo', async () => { + const mockAdapter = makeMockStorageAdapter(); + const facet = await TestController.make(config, mockAdapter); + + await facet.addItem('x', 'X', 10); + const item = await facet.getItem('x'); + const count = await facet.getCount(); + + expect(item).toStrictEqual({ name: 'X', value: 10 }); + expect(count).toBe(1); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/base-controller.ts b/packages/omnium-gatherum/src/controllers/base-controller.ts new file mode 100644 index 000000000..5b049576f --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/base-controller.ts @@ -0,0 +1,138 @@ +import type { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; + +import type { ControllerStorage } from './storage/controller-storage.ts'; + +/** + * Base type for controller methods. + * Controllers expose their public API through a methods object. + */ +export type ControllerMethods = Record unknown>; + +/** + * Configuration passed to all controllers during initialization. + */ +export type ControllerConfig = { + logger: Logger; +}; + +/** + * Abstract base class for controllers. + * + * Provides state management via ControllerStorage with: + * - Synchronous state access via `this.state` + * - Async state updates via `this.update()` + * - Automatic persistence handled by storage layer + * + * Subclasses must: + * - Call `super()` in constructor with name, storage, and logger + * - Call `harden(this)` at the end of their constructor + * - Implement `makeFacet()` to return a hardened exo with public API + * + * @template ControllerName - Literal string type for the controller name + * @template State - The state object shape (must be JSON-serializable) + * @template Methods - The public method interface + * + * @example + * ```typescript + * class MyController extends Controller<'MyController', MyState, MyMethods> { + * private constructor(storage: ControllerStorage, logger: Logger) { + * super('MyController', storage, logger); + * harden(this); + * } + * + * static create(config: ControllerConfig, deps: MyDeps): MyMethods { + * const controller = new MyController(deps.storage, config.logger); + * return controller.makeFacet(); + * } + * + * makeFacet(): MyMethods { + * return makeDefaultExo('MyController', { ... }); + * } + * } + * ``` + */ +export abstract class Controller< + ControllerName extends string, + State extends Record, + Methods extends ControllerMethods, +> { + readonly #name: ControllerName; + + readonly #storage: ControllerStorage; + + readonly #logger: Logger; + + /** + * Protected constructor - subclasses must call this via super(). + * + * @param name - Controller name for debugging/logging. + * @param storage - ControllerStorage instance for state management. + * @param logger - Logger instance. + */ + protected constructor( + name: ControllerName, + storage: ControllerStorage, + logger: Logger, + ) { + this.#name = name; + this.#storage = storage; + this.#logger = logger; + // Note: Subclass must call harden(this) after its own initialization + } + + /** + * Controller name for debugging/logging. + * + * @returns The controller name. + */ + protected get name(): ControllerName { + return this.#name; + } + + /** + * Current state (readonly). + * Provides synchronous access to in-memory state. + * + * @returns The current readonly state. + */ + protected get state(): Readonly { + return this.#storage.state; + } + + /** + * Logger instance for this controller. + * + * @returns The logger instance. + */ + protected get logger(): Logger { + return this.#logger; + } + + /** + * Update state using an immer producer function. + * State is updated synchronously in memory. + * Persistence is handled automatically by the storage layer (debounced). + * + * @param producer - Function that mutates a draft of the state. + */ + protected update(producer: (draft: State) => void): void { + this.#storage.update(producer); + } + + /** + * Clear storage and reset to default state. + */ + clearState(): void { + this.#storage.clear(); + } + + /** + * Returns the hardened exo with public methods. + * Subclasses implement this to define their public interface. + * + * @returns A hardened exo object with the controller's public methods. + */ + abstract makeFacet(): Methods; +} +harden(Controller); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts new file mode 100644 index 000000000..ce483096b --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -0,0 +1,467 @@ +import type { Json } from '@metamask/utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { CapletController } from './caplet-controller.ts'; +import type { CapletManifest } from './types.ts'; +import { makeMockStorageAdapter } from '../../../test/utils.ts'; +import type { StorageAdapter } from '../storage/types.ts'; +import type { ControllerConfig } from '../types.ts'; + +/** + * Seed a mock adapter with caplet controller state. + * + * @param adapter - The adapter to seed. + * @param caplets - The caplets to pre-populate. + * @returns A promise that resolves when seeding is complete. + */ +async function seedAdapter( + adapter: StorageAdapter, + caplets: Record, +): Promise { + await adapter.set('caplet.caplets', caplets as Json); +} + +describe('CapletController.make', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const mockLaunchSubcluster = vi.fn(); + const mockTerminateSubcluster = vi.fn(); + + const config: ControllerConfig = { + logger: mockLogger as unknown as ControllerConfig['logger'], + }; + + const validManifest: CapletManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: ['keyring'], + providedServices: ['signer'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockLaunchSubcluster).mockResolvedValue({ + subclusterId: 'subcluster-123', + }); + }); + + describe('install', () => { + it('installs a caplet successfully', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.install(validManifest); + + expect(result).toStrictEqual({ + capletId: 'com.example.test', + subclusterId: 'subcluster-123', + }); + }); + + it('validates the manifest', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const invalidManifest = { id: 'invalid' } as CapletManifest; + + await expect(controller.install(invalidManifest)).rejects.toThrow( + 'Invalid caplet manifest for invalid', + ); + }); + + it('throws if caplet already installed', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await expect(controller.install(validManifest)).rejects.toThrow( + 'Caplet com.example.test is already installed', + ); + }); + + it('launches subcluster with correct config', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + expect(mockLaunchSubcluster).toHaveBeenCalledWith({ + bootstrap: 'com.example.test', + vats: { + 'com.example.test': { + bundleSpec: 'https://example.com/bundle.json', + }, + }, + }); + }); + + it('stores caplet with manifest, subclusterId, and installedAt', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); + + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + const caplet = await controller.get('com.example.test'); + expect(caplet).toBeDefined(); + expect(caplet?.manifest).toStrictEqual(validManifest); + expect(caplet?.subclusterId).toBe('subcluster-123'); + expect(caplet?.installedAt).toBe(Date.now()); + + vi.useRealTimers(); + }); + + it('preserves existing caplets when installing', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + const caplets = await controller.list(); + const capletIds = caplets.map((caplet) => caplet.manifest.id).sort(); + expect(capletIds).toStrictEqual(['com.example.test', 'com.other.caplet']); + }); + + it('logs installation progress', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Installing caplet: com.example.test', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Caplet com.example.test installed with subcluster subcluster-123', + ); + }); + }); + + describe('uninstall', () => { + it('uninstalls a caplet successfully', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + expect(mockTerminateSubcluster).toHaveBeenCalledWith('subcluster-123'); + }); + + it('throws if caplet not found', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await expect( + controller.uninstall('com.example.notfound'), + ).rejects.toThrow('Caplet com.example.notfound not found'); + }); + + it('removes caplet from state', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + const caplet = await controller.get('com.example.test'); + expect(caplet).toBeUndefined(); + }); + + it('preserves other caplets when uninstalling', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, + }, + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + const caplets = await controller.list(); + const capletIds = caplets.map((caplet) => caplet.manifest.id); + expect(capletIds).toStrictEqual(['com.other.caplet']); + }); + + it('logs uninstallation progress', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Uninstalling caplet: com.example.test', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Caplet com.example.test uninstalled', + ); + }); + }); + + describe('list', () => { + it('returns empty array when no caplets installed', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.list(); + + expect(result).toStrictEqual([]); + }); + + it('returns all installed caplets', async () => { + const manifest2: CapletManifest = { + ...validManifest, + id: 'com.example.test2', + name: 'Test Caplet 2', + }; + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.list(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }); + expect(result).toContainEqual({ + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }); + }); + }); + + describe('get', () => { + it('returns caplet if exists', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.get('com.example.test'); + + expect(result).toStrictEqual({ + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, + }); + }); + + it('returns undefined if caplet not found', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.get('com.example.notfound'); + + expect(result).toBeUndefined(); + }); + }); + + describe('getByService', () => { + it('returns caplet providing the service', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.getByService('signer'); + + expect(result).toBeDefined(); + expect(result?.manifest.id).toBe('com.example.test'); + }); + + it('returns undefined if no caplet provides the service', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.getByService('unknown-service'); + + expect(result).toBeUndefined(); + }); + + it('returns a matching caplet when multiple provide the service', async () => { + const manifest2: CapletManifest = { + ...validManifest, + id: 'com.example.test2', + name: 'Test Caplet 2', + providedServices: ['signer', 'verifier'], + }; + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.getByService('signer'); + + // Returns a match (object key order is not guaranteed) + expect(result?.manifest.providedServices).toContain('signer'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts new file mode 100644 index 000000000..458855448 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -0,0 +1,329 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; + +import type { + CapletId, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, +} from './types.ts'; +import { isCapletManifest } from './types.ts'; +import { Controller } from '../base-controller.ts'; +import type { ControllerConfig } from '../base-controller.ts'; +import { ControllerStorage } from '../storage/controller-storage.ts'; +import type { StorageAdapter } from '../storage/types.ts'; + +/** + * Caplet controller persistent state. + * This is the shape of the state managed by the CapletController + * through the ControllerStorage abstraction. + */ +export type CapletControllerState = { + /** Installed caplets keyed by caplet ID */ + caplets: Record; +}; + +/** + * Methods exposed by the CapletController. + */ +export type CapletControllerFacet = { + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param _bundle - The caplet bundle (currently unused, bundle loaded from bundleSpec). + * @returns The installation result. + */ + install: ( + manifest: CapletManifest, + _bundle?: unknown, + ) => Promise; + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + uninstall: (capletId: CapletId) => Promise; + + /** + * List all installed caplets. + * + * @returns Array of installed caplets. + */ + list: () => Promise; + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID. + * @returns The installed caplet or undefined if not found. + */ + get: (capletId: CapletId) => Promise; + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + getByService: (serviceName: string) => Promise; + + /** + * Get the root object presence for a caplet. + * + * @param capletId - The caplet ID. + * @returns A promise for the caplet's root object (as a CapTP presence). + */ + getCapletRoot: (capletId: CapletId) => Promise; +}; + +/** + * Dependencies for the CapletController. + * These are attenuated - only the methods needed are provided. + */ +export type CapletControllerDeps = { + /** Storage adapter for creating controller storage */ + adapter: StorageAdapter; + /** Launch a subcluster for a caplet */ + launchSubcluster: (config: ClusterConfig) => Promise; + /** Terminate a caplet's subcluster */ + terminateSubcluster: (subclusterId: string) => Promise; + /** Get the root object for a vat by kref string */ + getVatRoot: (krefString: string) => Promise; +}; + +/** + * Controller for managing caplet lifecycle. + * + * The CapletController manages: + * - Installing caplets (validating manifest, launching subcluster, storing metadata) + * - Uninstalling caplets (terminating subcluster, removing metadata) + * - Querying installed caplets + */ +export class CapletController extends Controller< + 'CapletController', + CapletControllerState, + CapletControllerFacet +> { + readonly #launchSubcluster: (config: ClusterConfig) => Promise; + + readonly #terminateSubcluster: (subclusterId: string) => Promise; + + readonly #getVatRoot: (krefString: string) => Promise; + + /** + * Private constructor - use static create() method. + * + * @param storage - ControllerStorage for caplet state. + * @param logger - Logger instance. + * @param launchSubcluster - Function to launch a subcluster. + * @param terminateSubcluster - Function to terminate a subcluster. + * @param getVatRoot - Function to get a vat's root object as a presence. + */ + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor( + storage: ControllerStorage, + logger: Logger, + launchSubcluster: (config: ClusterConfig) => Promise, + terminateSubcluster: (subclusterId: string) => Promise, + getVatRoot: (krefString: string) => Promise, + ) { + super('CapletController', storage, logger); + this.#launchSubcluster = launchSubcluster; + this.#terminateSubcluster = terminateSubcluster; + this.#getVatRoot = getVatRoot; + harden(this); + } + + /** + * Create a CapletController and return its public methods. + * + * @param config - Controller configuration. + * @param deps - Controller dependencies (attenuated for POLA). + * @returns A hardened CapletController exo. + */ + static async make( + config: ControllerConfig, + deps: CapletControllerDeps, + ): Promise { + // Create storage internally + const storage = await ControllerStorage.make({ + namespace: 'caplet', + adapter: deps.adapter, + defaultState: { caplets: {} }, + logger: config.logger.subLogger({ tags: ['storage'] }), + }); + + const controller = new CapletController( + storage, + config.logger, + deps.launchSubcluster, + deps.terminateSubcluster, + deps.getVatRoot, + ); + return controller.makeFacet(); + } + + /** + * Returns the hardened exo with public methods. + * + * @returns A hardened exo object with the controller's public methods. + */ + makeFacet(): CapletControllerFacet { + return makeDefaultExo('CapletController', { + install: async ( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise => { + return this.#install(manifest, _bundle); + }, + uninstall: async (capletId: CapletId): Promise => { + return this.#uninstall(capletId); + }, + list: async (): Promise => { + return this.#list(); + }, + get: async (capletId: CapletId): Promise => { + return this.#get(capletId); + }, + getByService: async ( + serviceName: string, + ): Promise => { + return this.#getByService(serviceName); + }, + getCapletRoot: async (capletId: CapletId): Promise => { + return this.#getCapletRoot(capletId); + }, + }); + } + + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param _bundle - The caplet bundle (currently unused). + * @returns The installation result. + */ + async #install( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise { + const { id } = manifest; + this.logger.info(`Installing caplet: ${id}`); + + // Validate manifest + if (!isCapletManifest(manifest)) { + throw new Error(`Invalid caplet manifest for ${id}`); + } + + // Check if already installed + if (this.state.caplets[id] !== undefined) { + throw new Error(`Caplet ${id} is already installed`); + } + + // Create cluster config for this caplet + const clusterConfig: ClusterConfig = { + bootstrap: id, + vats: { + [id]: { + bundleSpec: manifest.bundleSpec, + }, + }, + }; + + // Launch subcluster + const { subclusterId, rootKref } = + await this.#launchSubcluster(clusterConfig); + + this.update((draft) => { + draft.caplets[id] = { + manifest, + subclusterId, + rootKref, + installedAt: Date.now(), + }; + }); + + this.logger.info(`Caplet ${id} installed with subcluster ${subclusterId}`); + return { capletId: id, subclusterId }; + } + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + async #uninstall(capletId: CapletId): Promise { + this.logger.info(`Uninstalling caplet: ${capletId}`); + + const caplet = this.state.caplets[capletId]; + if (caplet === undefined) { + throw new Error(`Caplet ${capletId} not found`); + } + + // Terminate the subcluster + await this.#terminateSubcluster(caplet.subclusterId); + + this.update((draft) => { + delete draft.caplets[capletId]; + }); + + this.logger.info(`Caplet ${capletId} uninstalled`); + } + + /** + * Get all installed caplets. + * + * @returns Array of all installed caplets. + */ + #list(): InstalledCaplet[] { + return Object.values(this.state.caplets); + } + + /** + * Get an installed caplet by ID. + * + * @param capletId - The caplet ID to retrieve. + * @returns The installed caplet or undefined if not found. + */ + #get(capletId: CapletId): InstalledCaplet | undefined { + return this.state.caplets[capletId]; + } + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + #getByService(serviceName: string): InstalledCaplet | undefined { + const caplets = this.#list(); + return caplets.find((caplet: InstalledCaplet) => + caplet.manifest.providedServices.includes(serviceName), + ); + } + + /** + * Get the root object presence for a caplet. + * + * @param capletId - The caplet ID. + * @returns A promise for the caplet's root object (as a CapTP presence). + */ + async #getCapletRoot(capletId: CapletId): Promise { + const caplet = this.state.caplets[capletId]; + if (!caplet) { + throw new Error(`Caplet ${capletId} not found`); + } + + if (!caplet.rootKref) { + throw new Error(`Caplet ${capletId} has no root object`); + } + + // Convert the stored kref string to a presence using the kernel facade + return this.#getVatRoot(caplet.rootKref); + } +} +harden(CapletController); diff --git a/packages/omnium-gatherum/src/controllers/caplet/index.ts b/packages/omnium-gatherum/src/controllers/caplet/index.ts new file mode 100644 index 000000000..af216b869 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/index.ts @@ -0,0 +1,23 @@ +export type { + CapletId, + SemVer, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, +} from './types.ts'; +export { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, + CapletIdStruct, + SemVerStruct, + CapletManifestStruct, +} from './types.ts'; +export type { + CapletControllerFacet, + CapletControllerDeps, + CapletControllerState, +} from './caplet-controller.ts'; +export { CapletController } from './caplet-controller.ts'; diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.test.ts b/packages/omnium-gatherum/src/controllers/caplet/types.test.ts new file mode 100644 index 000000000..a48da144a --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/types.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; + +import { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, +} from './types.ts'; + +describe('isCapletId', () => { + it.each([ + ['com.example.test', true], + ['simple', true], + ['bitcoin-signer', true], + ['test_caplet', true], + ['My-Caplet', true], + ['123', true], + ['a.b.c.d', true], + ])('validates "%s" as %s', (value, expected) => { + expect(isCapletId(value)).toBe(expected); + }); + + it.each([ + ['', false], // Empty + ['has space', false], // Whitespace + ['has\ttab', false], // Tab + ['has\nnewline', false], // Newline + ['café', false], // Non-ASCII + ['🎉', false], // Emoji + [123, false], // Not a string + [null, false], + [undefined, false], + [{}, false], + ])('rejects %s', (value, expected) => { + expect(isCapletId(value)).toBe(expected); + }); +}); + +describe('isSemVer', () => { + it.each([ + ['1.0.0', true], + ['0.0.1', true], + ['10.20.30', true], + ['1.0.0-alpha', true], + ['1.0.0-alpha.1', true], + ['0.0.0', true], + ['999.999.999', true], + ['1.2.3-0', true], + ])('validates "%s" as %s', (value, expected) => { + expect(isSemVer(value)).toBe(expected); + }); + + it.each([ + ['1.0', false], + ['1', false], + ['v1.0.0', false], // No 'v' prefix + ['1.0.0.0', false], + ['', false], + ['not-a-version', false], + ['1.0.0+build.123', false], // Build metadata not supported (semver strips it) + ['1.0.0-beta+build', false], // Build metadata not supported + [123, false], + [null, false], + [undefined, false], + ])('rejects %s', (value, expected) => { + expect(isSemVer(value)).toBe(expected); + }); +}); + +describe('isCapletManifest', () => { + const validManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: ['keyring'], + providedServices: ['signer'], + }; + + it('validates a complete manifest', () => { + expect(isCapletManifest(validManifest)).toBe(true); + }); + + it('validates a manifest with empty service arrays', () => { + const manifest = { + ...validManifest, + requestedServices: [], + providedServices: [], + }; + expect(isCapletManifest(manifest)).toBe(true); + }); + + it('rejects manifest with invalid id', () => { + expect(isCapletManifest({ ...validManifest, id: 'has space' })).toBe(false); + }); + + it('rejects manifest with invalid version', () => { + expect(isCapletManifest({ ...validManifest, version: '1.0' })).toBe(false); + }); + + it('rejects manifest missing required field', () => { + const { name: _name, ...missingName } = validManifest; + expect(isCapletManifest(missingName)).toBe(false); + }); + + it('rejects null', () => { + expect(isCapletManifest(null)).toBe(false); + }); + + it('rejects non-object', () => { + expect(isCapletManifest('string')).toBe(false); + }); +}); + +describe('assertCapletManifest', () => { + const validManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: [], + providedServices: [], + }; + + it('does not throw for valid manifest', () => { + expect(() => assertCapletManifest(validManifest)).not.toThrow(); + }); + + it('throws for invalid manifest', () => { + expect(() => assertCapletManifest({ id: '' })).toThrow( + 'Invalid CapletManifest', + ); + }); + + it('throws for null', () => { + expect(() => assertCapletManifest(null)).toThrow('Invalid CapletManifest'); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.ts b/packages/omnium-gatherum/src/controllers/caplet/types.ts new file mode 100644 index 000000000..2ff5e621f --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/types.ts @@ -0,0 +1,111 @@ +import { array, define, is, object, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import semverValid from 'semver/functions/valid'; + +/** + * Unique identifier for a Caplet (any non-empty ASCII string without whitespace). + */ +export type CapletId = string; + +/** + * Validate CapletId format. + * Requires non-empty ASCII string with no whitespace. + * + * @param value - The value to validate. + * @returns True if valid CapletId format. + */ +export const isCapletId = (value: unknown): value is CapletId => + typeof value === 'string' && + value.length > 0 && + // eslint-disable-next-line no-control-regex + /^[\x00-\x7F]+$/u.test(value) && // ASCII only + !/\s/u.test(value); // No whitespace + +export const CapletIdStruct = define('CapletId', isCapletId); + +/** + * Semantic version string (e.g., "1.0.0"). + */ +export type SemVer = string; + +/** + * Validate SemVer format using the semver package. + * Requires strict format without 'v' prefix (e.g., "1.0.0" not "v1.0.0"). + * + * @param value - The value to validate. + * @returns True if valid SemVer format. + */ +export const isSemVer = (value: unknown): value is SemVer => + typeof value === 'string' && + // semver.valid() is lenient and strips 'v' prefix, so check that cleaned value equals original + semverValid(value) === value; + +export const SemVerStruct = define('SemVer', isSemVer); + +/** + * Superstruct schema for validating CapletManifest objects. + */ +export const CapletManifestStruct = object({ + id: CapletIdStruct, + name: string(), + version: SemVerStruct, + bundleSpec: string(), + requestedServices: array(string()), + providedServices: array(string()), +}); + +/** + * Metadata that defines a Caplet's identity, dependencies, and capabilities. + */ +export type CapletManifest = Infer; + +/** + * Type guard for CapletManifest validation. + * + * @param value - The value to validate. + * @returns True if the value is a valid CapletManifest. + */ +export const isCapletManifest = (value: unknown): value is CapletManifest => + is(value, CapletManifestStruct); + +/** + * Assert that a value is a valid CapletManifest. + * + * @param value - The value to validate. + * @throws If the value is not a valid CapletManifest. + */ +export function assertCapletManifest( + value: unknown, +): asserts value is CapletManifest { + if (!isCapletManifest(value)) { + throw new Error('Invalid CapletManifest'); + } +} + +/** + * Record for an installed Caplet. + * Combines manifest with runtime identifiers. + */ +export type InstalledCaplet = { + manifest: CapletManifest; + subclusterId: string; + rootKref: string; + installedAt: number; +}; + +/** + * Result of installing a Caplet. + */ +export type InstallResult = { + capletId: CapletId; + subclusterId: string; +}; + +/** + * Result of launching a subcluster. + * This is the interface expected by CapletController's deps. + */ +export type LaunchResult = { + subclusterId: string; + rootKref: string; +}; diff --git a/packages/omnium-gatherum/src/controllers/facet.test.ts b/packages/omnium-gatherum/src/controllers/facet.test.ts new file mode 100644 index 000000000..7cb784897 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/facet.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { makeFacet } from './facet.ts'; + +describe('makeFacet', () => { + const makeSourceObject = () => ({ + method1: vi.fn().mockReturnValue('result1'), + method2: vi.fn().mockReturnValue('result2'), + method3: vi.fn().mockReturnValue('result3'), + asyncMethod: vi.fn().mockResolvedValue('asyncResult'), + }); + + it('creates a facet with only specified methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1', 'method2']); + + expect(facet.method1).toBeDefined(); + expect(facet.method2).toBeDefined(); + expect((facet as Record).method3).toBeUndefined(); + expect((facet as Record).asyncMethod).toBeUndefined(); + }); + + it('facet methods call the source methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + facet.method1(); + + expect(source.method1).toHaveBeenCalledOnce(); + }); + + it('facet methods return the same result as source', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + const result = facet.method1(); + + expect(result).toBe('result1'); + }); + + it('facet methods pass arguments to source', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + facet.method1('arg1', 'arg2'); + + expect(source.method1).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + it('works with async methods', async () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['asyncMethod']); + const result = await facet.asyncMethod(); + + expect(result).toBe('asyncResult'); + expect(source.asyncMethod).toHaveBeenCalledOnce(); + }); + + it('creates facet with single method', () => { + const source = makeSourceObject(); + + const facet = makeFacet('SingleMethodFacet', source, ['method1']); + + expect(facet.method1).toBeDefined(); + // Verify only the specified method is accessible + expect((facet as Record).method2).toBeUndefined(); + expect((facet as Record).method3).toBeUndefined(); + }); + + it('creates facet with all methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('AllMethodsFacet', source, [ + 'method1', + 'method2', + 'method3', + 'asyncMethod', + ]); + + expect(facet.method1).toBeDefined(); + expect(facet.method2).toBeDefined(); + expect(facet.method3).toBeDefined(); + expect(facet.asyncMethod).toBeDefined(); + }); + + it('throws when method does not exist on source', () => { + const source = makeSourceObject(); + + expect(() => + makeFacet('TestFacet', source, ['nonExistent' as keyof typeof source]), + ).toThrow( + "makeFacet: Method 'nonExistent' not found on source or is not a function", + ); + }); + + it('throws when property is not a function', () => { + const source = { + method1: vi.fn(), + notAMethod: 'string value', + }; + + expect(() => + // @ts-expect-error Destructive testing + makeFacet('TestFacet', source, ['notAMethod' as keyof typeof source]), + ).toThrow( + "makeFacet: Method 'notAMethod' not found on source or is not a function", + ); + }); + + it('preserves this context when methods use it', () => { + const source = { + value: 42, + getValue(this: { value: number }): number { + return this.value; + }, + }; + + const facet = makeFacet('TestFacet', source, ['getValue']); + const result = facet.getValue(); + + expect(result).toBe(42); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/facet.ts b/packages/omnium-gatherum/src/controllers/facet.ts new file mode 100644 index 000000000..1825ceebd --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/facet.ts @@ -0,0 +1,71 @@ +import type { Methods } from '@endo/exo'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Extract keys from Source that are callable functions. + * Filters to string | symbol to match RemotableMethodName from @endo/pass-style. + */ +type MethodKeys = { + [Key in keyof Source]: Source[Key] extends CallableFunction ? Key : never; +}[keyof Source] & + (string | symbol); + +type BoundMethod = Func extends CallableFunction + ? OmitThisParameter + : never; + +type FacetMethods> = Methods & { + [Key in MethodNames]: BoundMethod; +}; + +/** + * Create an attenuated facet of a source object that exposes only specific methods. + * + * This enforces POLA (Principle of Least Authority) by allowing Controller A + * to receive only the methods it needs from Controller B. + * + * @param name - Name for the facet (used in debugging/logging). + * @param source - The source object containing methods. + * @param methodNames - Array of method names to expose. + * @returns A hardened facet exo with only the specified methods. + * @example + * ```typescript + * // StorageController exposes full interface internally + * const storageController = makeStorageController(config); + * + * // CapletController only needs get/set, not clear/getAll + * const storageFacet = makeFacet('CapletStorage', storageController, ['get', 'set']); + * const capletController = CapletController.make({ storage: storageFacet }); + * ``` + */ +export function makeFacet< + Source extends Record, + MethodNames extends MethodKeys, +>( + name: string, + source: Source, + methodNames: readonly MethodNames[], +): FacetMethods { + const methods: Partial> = {}; + + for (const methodName of methodNames) { + const method = source[methodName]; + if (typeof method !== 'function') { + throw new Error( + `makeFacet: Method '${String( + methodName, + )}' not found on source or is not a function`, + ); + } + // Bind the method to preserve 'this' context if needed + methods[methodName] = (method as CallableFunction).bind( + source, + ) as BoundMethod as FacetMethods< + Source, + MethodNames + >[MethodNames]; + } + + return makeDefaultExo(name, methods as FacetMethods); +} +harden(makeFacet); diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts new file mode 100644 index 000000000..120d56561 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -0,0 +1,38 @@ +// Base controller +export { Controller } from './base-controller.ts'; +export type { ControllerConfig, ControllerMethods, FacetOf } from './types.ts'; +export { makeFacet } from './facet.ts'; + +// Storage +export type { + NamespacedStorage, + StorageAdapter, + ControllerStorageConfig, +} from './storage/index.ts'; +export { + makeChromeStorageAdapter, + ControllerStorage, +} from './storage/index.ts'; + +// Caplet +export type { + CapletId, + SemVer, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, + CapletControllerState, + CapletControllerFacet, + CapletControllerDeps, +} from './caplet/index.ts'; +export { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, + CapletIdStruct, + SemVerStruct, + CapletManifestStruct, + CapletController, +} from './caplet/index.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts new file mode 100644 index 000000000..403fe7dcb --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeChromeStorageAdapter } from './chrome-storage.ts'; + +describe('makeChromeStorageAdapter', () => { + const mockStorage = { + get: vi.fn().mockResolvedValue({}), + set: vi.fn(), + remove: vi.fn(), + }; + + beforeEach(() => { + mockStorage.get.mockResolvedValue({}); + }); + + describe('get', () => { + it('returns value for existing key', async () => { + mockStorage.get.mockResolvedValue({ testKey: 'testValue' }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('testKey'); + + expect(result).toBe('testValue'); + expect(mockStorage.get).toHaveBeenCalledWith('testKey'); + }); + + it('returns undefined for non-existent key', async () => { + mockStorage.get.mockResolvedValue({}); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('nonExistent'); + + expect(result).toBeUndefined(); + }); + + it('returns complex objects', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + mockStorage.get.mockResolvedValue({ complex: complexValue }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('complex'); + + expect(result).toStrictEqual(complexValue); + }); + }); + + describe('set', () => { + it('sets a value', async () => { + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.set('key', 'value'); + + expect(mockStorage.set).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('sets complex objects', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.set('complex', complexValue); + + expect(mockStorage.set).toHaveBeenCalledWith({ complex: complexValue }); + }); + }); + + describe('delete', () => { + it('deletes a key', async () => { + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.delete('keyToDelete'); + + expect(mockStorage.remove).toHaveBeenCalledWith('keyToDelete'); + }); + }); + + describe('keys', () => { + it('returns all keys when no prefix provided', async () => { + mockStorage.get.mockResolvedValue({ + key1: 'value1', + key2: 'value2', + other: 'value3', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys(); + + expect(result).toStrictEqual(['key1', 'key2', 'other']); + expect(mockStorage.get).toHaveBeenCalledWith(null); + }); + + it('filters keys by prefix', async () => { + mockStorage.get.mockResolvedValue({ + 'prefix.key1': 'value1', + 'prefix.key2': 'value2', + other: 'value3', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys('prefix.'); + + expect(result).toStrictEqual(['prefix.key1', 'prefix.key2']); + }); + + it('returns empty array when no keys match prefix', async () => { + mockStorage.get.mockResolvedValue({ + key1: 'value1', + key2: 'value2', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys('nonexistent.'); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts new file mode 100644 index 000000000..4c0134757 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts @@ -0,0 +1,38 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from './types.ts'; + +/** + * Create a storage adapter backed by Chrome Storage API. + * + * @param storage - The Chrome storage area to use (defaults to chrome.storage.local). + * @returns A hardened StorageAdapter instance. + */ +export function makeChromeStorageAdapter( + storage: chrome.storage.StorageArea = chrome.storage.local, +): StorageAdapter { + return harden({ + async get(key: string): Promise { + const result = await storage.get(key); + return result[key] as Value | undefined; + }, + + async set(key: string, value: Json): Promise { + await storage.set({ [key]: value }); + }, + + async delete(key: string): Promise { + await storage.remove(key); + }, + + async keys(prefix?: string): Promise { + const all = await storage.get(null); + const allKeys = Object.keys(all); + if (prefix === undefined) { + return allKeys; + } + return allKeys.filter((k) => k.startsWith(prefix)); + }, + }); +} +harden(makeChromeStorageAdapter); diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts new file mode 100644 index 000000000..93ea2b5c2 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { ControllerStorage } from './controller-storage.ts'; +import type { StorageAdapter } from './types.ts'; + +type TestState = { + installed: string[]; + manifests: Record; + count: number; +}; + +describe('ControllerStorage', () => { + const mockAdapter: StorageAdapter = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + keys: vi.fn(), + }; + + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const defaultState: TestState = { + installed: [], + manifests: {}, + count: 0, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockAdapter.get).mockResolvedValue(undefined); + vi.mocked(mockAdapter.set).mockResolvedValue(undefined); + vi.mocked(mockAdapter.delete).mockResolvedValue(undefined); + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + }); + + describe('initialization', () => { + it('loads existing state from storage on creation', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([ + 'test.installed', + 'test.manifests', + ]); + vi.mocked(mockAdapter.get).mockImplementation(async (key: string) => { + if (key === 'test.installed') { + return ['app1']; + } + if (key === 'test.manifests') { + return { app1: { name: 'App 1' } }; + } + return undefined; + }); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.installed).toStrictEqual(['app1']); + expect(storage.state.manifests).toStrictEqual({ + app1: { name: 'App 1' }, + }); + }); + + it('uses defaults for missing keys', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); + vi.mocked(mockAdapter.get).mockImplementation(async (key: string) => { + if (key === 'test.installed') { + return ['existing']; + } + return undefined; + }); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { + installed: [] as string[], + manifests: {}, + metadata: { version: 1 }, + }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.installed).toStrictEqual(['existing']); + expect(storage.state.manifests).toStrictEqual({}); + expect(storage.state.metadata).toStrictEqual({ version: 1 }); + }); + + it('uses all defaults when storage is empty', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.installed).toStrictEqual([]); + expect(storage.state.manifests).toStrictEqual({}); + expect(storage.state.count).toBe(0); + }); + + it('returns hardened state copy', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { items: ['original'] as string[] }, + logger: mockLogger as never, + debounceMs: 0, + }); + + // Get a reference to the state + const state1 = storage.state; + + // Modifications to the returned state should not affect the internal state + // (In SES environment, this would throw; in tests, we verify isolation) + try { + (state1 as { items: string[] }).items.push('modified'); + } catch { + // Expected in SES environment + } + + // Get a fresh state - it should still have the original value + const state2 = storage.state; + expect(state2.items).toStrictEqual(['original']); + }); + }); + + describe('state access', () => { + it('provides readonly access to current state', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['ns.count']); + vi.mocked(mockAdapter.get).mockResolvedValue(42); + + const storage = await ControllerStorage.make({ + namespace: 'ns', + adapter: mockAdapter, + defaultState: { count: 0 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.count).toBe(42); + }); + }); + + describe('update', () => { + it('persists only modified top-level keys', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.installed.push('new-app'); + // manifests and count not modified + }); + + // Wait for persistence (debounced but set to 0ms) + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledTimes(1); + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', [ + 'new-app', + ]); + }); + + it('updates in-memory state immediately', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.installed.push('item1'); + }); + + // State updated synchronously + expect(storage.state.installed).toStrictEqual(['item1']); + }); + + it('does not persist when no changes made', async () => { + // Clear any pending operations from previous tests + await new Promise((resolve) => setTimeout(resolve, 15)); + vi.clearAllMocks(); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + // No actual changes + draft.count = 0; + }); + + // Wait for potential persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).not.toHaveBeenCalled(); + }); + + it('persists multiple modified keys', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 1, b: 2, c: 3 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.a = 10; + draft.c = 30; + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledTimes(2); + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 10); + expect(mockAdapter.set).toHaveBeenCalledWith('test.c', 30); + }); + + it('updates state even if persistence fails (fire-and-forget)', async () => { + vi.mocked(mockAdapter.set).mockRejectedValue(new Error('Storage error')); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.count = 100; + }); + + // State updated immediately despite persistence failure + expect(storage.state.count).toBe(100); + + // Wait for persistence attempt + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to persist state changes:', + expect.any(Error), + ); + }); + + it('handles nested object modifications', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.manifests['new-app'] = { name: 'New App' }; + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { + 'new-app': { name: 'New App' }, + }); + }); + + it('handles array operations', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); + vi.mocked(mockAdapter.get).mockResolvedValue(['app1', 'app2']); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.installed = draft.installed.filter((id) => id !== 'app1'); + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', ['app2']); + }); + + it('handles delete operations on nested objects', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.manifests']); + vi.mocked(mockAdapter.get).mockResolvedValue({ + app1: { name: 'App 1' }, + app2: { name: 'App 2' }, + }); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + delete draft.manifests.app1; + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { + app2: { name: 'App 2' }, + }); + }); + }); + + describe('namespace isolation', () => { + it('uses different prefixes for different namespaces', async () => { + await ControllerStorage.make({ + namespace: 'caplet', + adapter: mockAdapter, + defaultState: { value: 1 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + await ControllerStorage.make({ + namespace: 'service', + adapter: mockAdapter, + defaultState: { value: 2 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); + expect(mockAdapter.keys).toHaveBeenCalledWith('service.'); + }); + }); + + describe('debouncing with key accumulation', () => { + it('accumulates modified keys across multiple updates', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0, b: 0, c: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + // First update: modifies a and b + storage.update((draft) => { + draft.a = 1; + draft.b = 1; + }); + + // Second update at t=50ms: modifies only a + vi.advanceTimersByTime(50); + storage.update((draft) => { + draft.a = 2; + }); + + // Timer should fire at t=100ms (from first update) + vi.advanceTimersByTime(50); + await vi.runAllTimersAsync(); + + // Both a and b should be persisted (accumulated keys) + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + expect(mockAdapter.set).toHaveBeenCalledWith('test.b', 1); + + vi.useRealTimers(); + }); + + it('does not reset timer on subsequent writes', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + storage.update((draft) => { + draft.a = 1; + }); + + // Second write at t=90ms (before first timer fires) + vi.advanceTimersByTime(90); + storage.update((draft) => { + draft.a = 2; + }); + + // Timer fires at t=100ms (NOT reset to t=190ms) + vi.advanceTimersByTime(10); + await vi.runAllTimersAsync(); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + + vi.useRealTimers(); + }); + + it('writes immediately when idle > debounceMs', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + storage.update((draft) => { + draft.a = 1; + }); + await vi.runAllTimersAsync(); + vi.clearAllMocks(); + + // Wait 150ms (> debounceMs) + vi.advanceTimersByTime(150); + + // Next write should be immediate (no debounce) + storage.update((draft) => { + draft.a = 2; + }); + await vi.runAllTimersAsync(); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + + vi.useRealTimers(); + }); + }); + + describe('clear', () => { + it('resets state to default', async () => { + const testDefaultState = { items: [] as string[], count: 0 }; + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: testDefaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + // Modify state + storage.update((draft) => { + draft.items.push('item1'); + draft.count = 1; + }); + + expect(storage.state.items).toStrictEqual(['item1']); + expect(storage.state.count).toBe(1); + + // Clear + storage.clear(); + + expect(storage.state.items).toStrictEqual([]); + expect(storage.state.count).toBe(0); + }); + + it('persists cleared state', async () => { + const clearDefaultState = { a: 0, b: 0 }; + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: clearDefaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.a = 5; + draft.b = 10; + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + vi.clearAllMocks(); + + storage.clear(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 0); + expect(mockAdapter.set).toHaveBeenCalledWith('test.b', 0); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts new file mode 100644 index 000000000..a2c1939e9 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts @@ -0,0 +1,317 @@ +import type { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; +import { enablePatches, produce } from 'immer'; +import type { Patch } from 'immer'; + +import type { StorageAdapter } from './types.ts'; + +// Enable immer patches globally (called once at module load) +enablePatches(); + +// TODO: Add migration utility for converting from per-key storage format +// (e.g., caplet.{id}.manifest) to consolidated state format (caplet.manifests) +// when there is deployed data to migrate. + +/** + * Configuration for creating a ControllerStorage instance. + */ +export type ControllerStorageConfig> = { + /** The namespace prefix for storage keys (e.g., 'caplet') */ + namespace: string; + /** The underlying storage adapter */ + adapter: StorageAdapter; + /** Default state values - used for initialization and type inference */ + defaultState: State; + /** Logger for storage operations */ + logger: Logger; + /** Debounce delay in milliseconds (default: 100, set to 0 for tests) */ + debounceMs?: number; +}; + +/** + * Internal options passed to constructor after async initialization. + */ +type ControllerStorageOptions> = + ControllerStorageConfig & { + /** Initial state loaded from storage */ + initialState: State; + }; + +/** + * ControllerStorage provides a simplified state management interface for controllers. + * + * Features: + * - Flat top-level key mapping: `state.foo` maps to `{namespace}.foo` in storage + * - Immer-based updates with automatic change detection + * - Synchronous state updates with debounced persistence + * - Only modified top-level keys are persisted + * - Fire-and-forget persistence (errors logged but don't rollback state) + * - Eager loading on initialization + * + * @template State - The state object type (must have Json-serializable values) + */ +export class ControllerStorage> { + readonly #adapter: StorageAdapter; + + readonly #prefix: string; + + readonly #defaultState: State; + + readonly #logger: Logger; + + readonly #debounceMs: number; + + #state: State; + + #pendingPersist: ReturnType | null = null; + + readonly #pendingKeys: Set = new Set(); + + #lastWriteTime: number = 0; + + /** + * Private constructor - use static make() factory method. + * + * @param options - Configuration including initial loaded state. + */ + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor(options: ControllerStorageOptions) { + this.#adapter = options.adapter; + this.#prefix = `${options.namespace}.`; + this.#defaultState = options.defaultState; + this.#logger = options.logger; + this.#debounceMs = options.debounceMs ?? 100; + this.#state = options.initialState; + } + + /** + * Create a ControllerStorage instance for a controller. + * + * This factory function: + * 1. Loads existing state from storage for the namespace + * 2. Merges with defaults (storage values take precedence) + * 3. Returns a hardened ControllerStorage instance + * + * @param config - Configuration including namespace, adapter, and default state. + * @returns Promise resolving to a hardened ControllerStorage instance. + * + * @example + * ```typescript + * const capletState = await ControllerStorage.make({ + * namespace: 'caplet', + * adapter: storageAdapter, + * defaultState: { installed: [], manifests: {} }, + * logger: logger.subLogger({ tags: ['storage'] }), + * }); + * + * // Read state + * console.log(capletState.state.installed); + * + * // Update state (synchronous) + * capletState.update(draft => { + * draft.installed.push('com.example.app'); + * }); + * ``` + */ + static async make>( + config: ControllerStorageConfig, + ): Promise> { + const initialState = await this.#loadState(config); + return harden( + new ControllerStorage({ + ...config, + initialState, + }), + ); + } + + /** + * Load all state from storage, merging with defaults. + * Storage values take precedence over defaults. + * + * @param config - Configuration with adapter, namespace, and defaults. + * @returns The merged state object. + */ + static async #loadState>( + config: ControllerStorageConfig, + ): Promise { + const { namespace, adapter, defaultState } = config; + const prefix = `${namespace}.`; + const allKeys = await adapter.keys(prefix); + + // Start with a copy of defaults + const state = { ...defaultState }; + + // Load and merge values from storage + await Promise.all( + allKeys.map(async (fullKey) => { + const key = fullKey.slice(prefix.length) as keyof State; + const value = await adapter.get(fullKey); + if (value !== undefined) { + state[key] = value as State[keyof State]; + } + }), + ); + + return produce({}, (draft) => { + Object.assign(draft, state); + }) as State; + } + + /** + * Current state (readonly, deeply frozen by immer). + * Access individual properties: `storage.state.installed` + * + * @returns The current readonly state. + */ + get state(): Readonly { + return this.#state; + } + + /** + * Update state using an immer producer function. + * State is updated synchronously in memory. + * Persistence is queued and debounced (fire-and-forget). + * + * @param producer - Function that mutates a draft of the state or returns new state + * + * @example + * ```typescript + * // Mutate draft + * storage.update(draft => { + * draft.installed.push('com.example.app'); + * draft.manifests['com.example.app'] = manifest; + * }); + */ + update(producer: (draft: State) => void | State): void { + // Capture state before operations to avoid race conditions + const stateSnapshot = this.#state; + + // Use immer's produce with patches callback to track changes + let patches: Patch[] = []; + const nextState = produce(stateSnapshot, producer, (patchList) => { + patches = patchList; + }); + + // No changes - nothing to do + if (patches.length === 0) { + return; + } + + // Update in-memory state immediately + this.#state = nextState; + + // Queue debounced persistence (fire-and-forget) + this.#schedulePersist(patches); + } + + /** + * Clear all state and reset to default values. + * Updates state synchronously, persistence is debounced. + */ + clear(): void { + this.update((draft) => { + Object.assign(draft, this.#defaultState); + }); + } + + /** + * Schedule debounced persistence with key accumulation. + * Implements bounded latency (timer not reset) and immediate writes after idle. + * + * @param patches - Immer patches describing changes. + */ + #schedulePersist(patches: Patch[]): void { + const now = Date.now(); + const timeSinceLastWrite = now - this.#lastWriteTime; + this.#lastWriteTime = now; + + const modifiedKeys = this.#getModifiedKeys(patches); + for (const key of modifiedKeys) { + this.#pendingKeys.add(key); + } + + if ( + timeSinceLastWrite > this.#debounceMs && + this.#pendingPersist === null + ) { + this.#flushPendingWrites(); + return; + } + + if (this.#pendingPersist === null) { + this.#pendingPersist = setTimeout(() => { + this.#flushPendingWrites(); + }, this.#debounceMs); + } + // else: timer already running, just accumulate keys, don't reset + } + + /** + * Flush pending writes to storage. + * Captures accumulated keys and persists current state values. + */ + #flushPendingWrites(): void { + if (this.#pendingKeys.size === 0) { + this.#pendingPersist = null; + return; + } + + const keysToWrite = new Set(this.#pendingKeys); + this.#pendingKeys.clear(); + this.#pendingPersist = null; + + // Persist current state values for accumulated keys + this.#persistAccumulatedKeys(this.#state, keysToWrite).catch((error) => { + this.#logger.error('Failed to persist state changes:', error); + }); + } + + /** + * Persist accumulated keys to storage. + * Always persists current state values (last-write-wins). + * + * @param state - The current state to persist from. + * @param keys - Set of top-level keys to persist. + */ + async #persistAccumulatedKeys( + state: State, + keys: Set, + ): Promise { + await Promise.all( + Array.from(keys).map(async (key) => { + const storageKey = this.#buildKey(key); + const value = state[key as keyof State]; + await this.#adapter.set(storageKey, value as Json); + }), + ); + } + + /** + * Extract top-level keys that were modified from immer patches. + * + * @param patches - Array of immer patches describing changes. + * @returns Set of modified top-level keys. + */ + #getModifiedKeys(patches: Patch[]): Set { + const keys = new Set(); + for (const patch of patches) { + // The first element of path is always the top-level key + if (patch.path.length > 0) { + keys.add(String(patch.path[0])); + } + } + return keys; + } + + /** + * Build a storage key from a state property name. + * + * @param stateKey - The state property name. + * @returns The namespaced storage key. + */ + #buildKey(stateKey: string): string { + return `${this.#prefix}${stateKey}`; + } +} +harden(ControllerStorage); diff --git a/packages/omnium-gatherum/src/controllers/storage/index.ts b/packages/omnium-gatherum/src/controllers/storage/index.ts new file mode 100644 index 000000000..8f0382e45 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/index.ts @@ -0,0 +1,4 @@ +export type { NamespacedStorage, StorageAdapter } from './types.ts'; +export type { ControllerStorageConfig } from './controller-storage.ts'; +export { makeChromeStorageAdapter } from './chrome-storage.ts'; +export { ControllerStorage } from './controller-storage.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/types.ts b/packages/omnium-gatherum/src/controllers/storage/types.ts new file mode 100644 index 000000000..dab4a14a4 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/types.ts @@ -0,0 +1,88 @@ +import type { Json } from '@metamask/utils'; + +/** + * Low-level storage adapter interface. + * Wraps platform-specific storage APIs (e.g., chrome.storage.local). + */ +export type StorageAdapter = { + /** + * Get a value from storage. + * + * @param key - The storage key. + * @returns The stored value, or undefined if not found. + */ + get: (key: string) => Promise; + + /** + * Set a value in storage. + * + * @param key - The storage key. + * @param value - The value to store. + */ + set: (key: string, value: Json) => Promise; + + /** + * Delete a value from storage. + * + * @param key - The storage key. + */ + delete: (key: string) => Promise; + + /** + * Get all keys matching a prefix. + * + * @param prefix - Optional prefix to filter keys. + * @returns Array of matching keys. + */ + keys: (prefix?: string) => Promise; +}; + +/** + * Storage interface bound to a specific namespace. + * Controllers receive this instead of raw storage access. + * Keys are automatically prefixed with the namespace. + */ +export type NamespacedStorage = { + /** + * Get a value from the namespaced storage. + * + * @param key - The key within this namespace. + * @returns The stored value, or undefined if not found. + */ + get: (key: string) => Promise; + + /** + * Set a value in the namespaced storage. + * + * @param key - The key within this namespace. + * @param value - The value to store. + */ + set: (key: string, value: Json) => Promise; + + /** + * Delete a value from the namespaced storage. + * + * @param key - The key within this namespace. + */ + delete: (key: string) => Promise; + + /** + * Check if a key exists in the namespaced storage. + * + * @param key - The key within this namespace. + * @returns True if the key exists. + */ + has: (key: string) => Promise; + + /** + * Get all keys within this namespace. + * + * @returns Array of keys (without namespace prefix). + */ + keys: () => Promise; + + /** + * Clear all values in this namespace. + */ + clear: () => Promise; +}; diff --git a/packages/omnium-gatherum/src/controllers/types.ts b/packages/omnium-gatherum/src/controllers/types.ts new file mode 100644 index 000000000..84f2287e4 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/types.ts @@ -0,0 +1,19 @@ +import type { Methods } from '@endo/exo'; + +// Re-export from base-controller for backward compatibility +export type { ControllerConfig, ControllerMethods } from './base-controller.ts'; + +/** + * Type helper for defining facet interfaces. + * Extracts a subset of methods from a controller type for POLA attenuation. + * + * @example + * ```typescript + * type StorageReadFacet = FacetOf; + * type StorageWriteFacet = FacetOf; + * ``` + */ +export type FacetOf< + TController extends Methods, + TMethodNames extends keyof TController, +> = Pick; diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts new file mode 100644 index 000000000..e330a9b0f --- /dev/null +++ b/packages/omnium-gatherum/src/global.d.ts @@ -0,0 +1,119 @@ +import type { KernelFacade } from '@metamask/kernel-browser-runtime'; + +import type { + CapletManifest, + InstalledCaplet, + InstallResult, +} from './controllers/index.ts'; + +// Type declarations for omnium dev console API. +declare global { + /** + * The E() function from @endo/eventual-send for making eventual sends. + * Set globally in the trusted prelude before lockdown. + * + * @example + * ```typescript + * const kernel = await omnium.getKernel(); + * const status = await E(kernel).getStatus(); + * ``` + */ + // eslint-disable-next-line no-var,id-length + var E: typeof import('@endo/eventual-send').E; + + // eslint-disable-next-line no-var + var kernel: KernelFacade | Promise; + + // eslint-disable-next-line no-var + var omnium: { + /** + * Load a caplet's manifest and bundle by ID. + * + * @param id - The short caplet ID (e.g., 'echo'). + * @returns The manifest and bundle for installation. + * @example + * ```typescript + * const { manifest, bundle } = await omnium.loadCaplet('echo'); + * await omnium.caplet.install(manifest, bundle); + * ``` + */ + loadCaplet: ( + id: string, + ) => Promise<{ manifest: CapletManifest; bundle: unknown }>; + + /** + * Caplet management API. + */ + caplet: { + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param bundle - Optional bundle (currently unused). + * @returns The installation result. + * @example + * ```typescript + * const result = await omnium.caplet.install({ + * id: 'com.example.test', + * name: 'Test Caplet', + * version: '1.0.0', + * bundleSpec: '/path/to/bundle.json', + * requestedServices: [], + * providedServices: ['test'], + * }); + * ``` + */ + install: ( + manifest: CapletManifest, + bundle?: unknown, + ) => Promise; + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + uninstall: (capletId: string) => Promise; + + /** + * List all installed caplets. + * + * @returns Array of installed caplets. + */ + list: () => Promise; + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID. + * @returns The installed caplet or undefined if not found. + */ + get: (capletId: string) => Promise; + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + getByService: ( + serviceName: string, + ) => Promise; + + /** + * Get the root object presence for a caplet. + * + * @param capletId - The caplet ID. + * @returns A promise for the caplet's root object (as a CapTP presence). + * @example + * ```typescript + * const root = await omnium.caplet.getCapletRoot('com.example.echo'); + * const result = await E(root).echo('Hello!'); + * ``` + */ + getCapletRoot: (capletId: string) => Promise; + }; + }; +} + +export {}; diff --git a/packages/omnium-gatherum/src/manifest.json b/packages/omnium-gatherum/src/manifest.json index 8f815cecd..653d0b8bd 100644 --- a/packages/omnium-gatherum/src/manifest.json +++ b/packages/omnium-gatherum/src/manifest.json @@ -10,7 +10,7 @@ "action": { "default_popup": "popup.html" }, - "permissions": ["offscreen", "unlimitedStorage"], + "permissions": ["offscreen", "storage", "unlimitedStorage"], "sandbox": { "pages": ["iframe.html"] }, diff --git a/packages/omnium-gatherum/src/offscreen.ts b/packages/omnium-gatherum/src/offscreen.ts index 6130ff72a..0cf807894 100644 --- a/packages/omnium-gatherum/src/offscreen.ts +++ b/packages/omnium-gatherum/src/offscreen.ts @@ -3,8 +3,8 @@ import { PlatformServicesServer, createRelayQueryString, } from '@metamask/kernel-browser-runtime'; -import { delay, isJsonRpcCall } from '@metamask/kernel-utils'; -import type { JsonRpcCall } from '@metamask/kernel-utils'; +import { delay, isJsonRpcMessage } from '@metamask/kernel-utils'; +import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { DuplexStream } from '@metamask/streams'; import { @@ -13,8 +13,6 @@ import { MessagePortDuplexStream, } from '@metamask/streams/browser'; import type { PostMessageTarget } from '@metamask/streams/browser'; -import type { JsonRpcResponse } from '@metamask/utils'; -import { isJsonRpcResponse } from '@metamask/utils'; const logger = new Logger('offscreen'); @@ -27,11 +25,11 @@ async function main(): Promise { // Without this delay, sending messages via the chrome.runtime API can fail. await delay(50); - // Create stream for messages from the background script + // Create stream for CapTP messages from the background script const backgroundStream = await ChromeRuntimeDuplexStream.make< - JsonRpcCall, - JsonRpcResponse - >(chrome.runtime, 'offscreen', 'background', isJsonRpcCall); + JsonRpcMessage, + JsonRpcMessage + >(chrome.runtime, 'offscreen', 'background', isJsonRpcMessage); const kernelStream = await makeKernelWorker(); @@ -48,7 +46,7 @@ async function main(): Promise { * @returns The message port stream for worker communication */ async function makeKernelWorker(): Promise< - DuplexStream + DuplexStream > { // Assign local relay address generated from `yarn ocap relay` const relayQueryString = createRelayQueryString([ @@ -70,9 +68,9 @@ async function makeKernelWorker(): Promise< ); const kernelStream = await MessagePortDuplexStream.make< - JsonRpcResponse, - JsonRpcCall - >(port, isJsonRpcResponse); + JsonRpcMessage, + JsonRpcMessage + >(port, isJsonRpcMessage); await PlatformServicesServer.make(worker as PostMessageTarget, (vatId) => makeIframeVatWorker({ diff --git a/packages/omnium-gatherum/src/types/semver.d.ts b/packages/omnium-gatherum/src/types/semver.d.ts new file mode 100644 index 000000000..9a6ab706d --- /dev/null +++ b/packages/omnium-gatherum/src/types/semver.d.ts @@ -0,0 +1,7 @@ +declare module 'semver/functions/valid' { + function valid( + version: string | null | undefined, + optionsOrLoose?: boolean | { loose?: boolean; includePrerelease?: boolean }, + ): string | null; + export default valid; +} diff --git a/packages/omnium-gatherum/src/vats/echo-caplet.js b/packages/omnium-gatherum/src/vats/echo-caplet.js new file mode 100644 index 000000000..83d99f828 --- /dev/null +++ b/packages/omnium-gatherum/src/vats/echo-caplet.js @@ -0,0 +1,48 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Echo service caplet - provides a simple echo method for testing. + * + * This Caplet demonstrates the basic structure of a service provider: + * - Exports buildRootObject following the Caplet vat contract. + * - Uses makeDefaultExo to create a hardened root object. + * - Provides an "echo" service that returns the input with a prefix. + * - Implements a bootstrap method for initialization. + * + * @param {object} vatPowers - Standard vat powers granted by the kernel. + * @param {object} vatPowers.logger - Structured logging interface. + * @param {object} _parameters - Bootstrap parameters from Omnium (empty for echo-caplet). + * @param {object} _baggage - Persistent state storage (not used in this simple example). + * @returns {object} Hardened root object with echo service methods. + */ +export function buildRootObject(vatPowers, _parameters, _baggage) { + const logger = vatPowers.logger.subLogger({ tags: ['echo-caplet'] }); + + logger.log('Echo caplet buildRootObject called'); + + return makeDefaultExo('echo-caplet-root', { + /** + * Bootstrap method called during vat initialization. + * + * This method is optional but recommended for initialization logic. + * For service providers, this is where you would set up initial state. + */ + bootstrap() { + logger.log('Echo caplet bootstrapped and ready'); + }, + + /** + * Echo service method - returns the input message with "Echo: " prefix. + * + * This demonstrates a simple synchronous service method. + * Service methods can also return promises for async operations. + * + * @param {string} message - The message to echo. + * @returns {string} The echoed message with prefix. + */ + echo(message) { + logger.log('Echoing message:', message); + return `echo: ${message}`; + }, + }); +} diff --git a/packages/omnium-gatherum/test/caplet-integration.test.ts b/packages/omnium-gatherum/test/caplet-integration.test.ts new file mode 100644 index 000000000..6157ebd7d --- /dev/null +++ b/packages/omnium-gatherum/test/caplet-integration.test.ts @@ -0,0 +1,201 @@ +import type { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { echoCapletManifest } from './fixtures/manifests.ts'; +import { makeMockStorageAdapter } from './utils.ts'; +import { CapletController } from '../src/controllers/caplet/caplet-controller.ts'; +import type { + CapletControllerFacet, + CapletControllerDeps, +} from '../src/controllers/caplet/caplet-controller.ts'; + +const makeMockLogger = (): Logger => { + const mockLogger = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + subLogger: vi.fn(() => mockLogger), + } as unknown as Logger; + return mockLogger; +}; + +describe('Caplet Integration - Echo Caplet', () => { + let capletController: CapletControllerFacet; + let mockStorage: Map; + let mockSubclusterCounter: number; + + beforeEach(async () => { + // Reset state + mockStorage = new Map(); + mockSubclusterCounter = 0; + + // Create a mock logger + const mockLogger = makeMockLogger(); + // Create a mock storage adapter + const mockAdapter = makeMockStorageAdapter(mockStorage); + + // Create mock kernel functions + const mockLaunchSubcluster = vi.fn(async () => { + mockSubclusterCounter += 1; + return { + subclusterId: `test-subcluster-${mockSubclusterCounter}`, + rootKref: `ko${mockSubclusterCounter}`, + }; + }); + + const mockTerminateSubcluster = vi.fn(async () => { + // No-op for tests + }); + + const mockGetVatRoot = vi.fn(async (krefString: string) => { + // In real implementation, this returns a CapTP presence + // For tests, we return a mock object + return { kref: krefString }; + }); + + const deps: CapletControllerDeps = { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, + }; + + // Create the caplet controller using static make() method + capletController = await CapletController.make( + { logger: mockLogger }, + deps, + ); + }); + + it('installs echo-caplet successfully', async () => { + const result = await capletController.install(echoCapletManifest); + + expect(result.capletId).toBe('com.example.echo'); + expect(result.subclusterId).toBe('test-subcluster-1'); + }); + + it('retrieves installed echo-caplet', async () => { + await capletController.install(echoCapletManifest); + + const caplet = await capletController.get('com.example.echo'); + + expect(caplet).toStrictEqual({ + manifest: { + id: 'com.example.echo', + name: 'Echo Service', + version: '1.0.0', + bundleSpec: expect.anything(), + requestedServices: [], + providedServices: ['echo'], + }, + subclusterId: 'test-subcluster-1', + rootKref: 'ko1', + installedAt: expect.any(Number), + }); + }); + + it('lists all installed caplets', async () => { + const emptyList = await capletController.list(); + expect(emptyList).toHaveLength(0); + + await capletController.install(echoCapletManifest); + + const list = await capletController.list(); + expect(list).toHaveLength(1); + expect(list[0]?.manifest.id).toBe('com.example.echo'); + }); + + it('finds caplet by service name', async () => { + const notFound = await capletController.getByService('echo'); + expect(notFound).toBeUndefined(); + + await capletController.install(echoCapletManifest); + + const provider = await capletController.getByService('echo'); + expect(provider).toBeDefined(); + expect(provider?.manifest.id).toBe('com.example.echo'); + }); + + it('uninstalls echo-caplet cleanly', async () => { + // Install + await capletController.install(echoCapletManifest); + + let list = await capletController.list(); + expect(list).toHaveLength(1); + + // Uninstall + await capletController.uninstall('com.example.echo'); + + list = await capletController.list(); + expect(list).toHaveLength(0); + + // Verify it's also gone from get() and getByService() + const caplet = await capletController.get('com.example.echo'); + expect(caplet).toBeUndefined(); + + const provider = await capletController.getByService('echo'); + expect(provider).toBeUndefined(); + }); + + it('prevents duplicate installations', async () => { + await capletController.install(echoCapletManifest); + + // Attempting to install again should throw + await expect(capletController.install(echoCapletManifest)).rejects.toThrow( + 'already installed', + ); + }); + + it('handles uninstalling non-existent caplet', async () => { + await expect( + capletController.uninstall('com.example.nonexistent'), + ).rejects.toThrow('not found'); + }); + + it('gets caplet root object as presence', async () => { + await capletController.install(echoCapletManifest); + + const rootPresence = + await capletController.getCapletRoot('com.example.echo'); + + // The presence should be the object returned by getVatRoot mock + expect(rootPresence).toStrictEqual({ kref: 'ko1' }); + }); + + it('throws when getting root for non-existent caplet', async () => { + await expect( + capletController.getCapletRoot('com.example.nonexistent'), + ).rejects.toThrow('not found'); + }); + + it('persists caplet state across controller restarts', async () => { + // Install a caplet + await capletController.install(echoCapletManifest); + + // Simulate a restart by creating a new controller with the same storage + const mockLogger = makeMockLogger(); + + const newDeps: CapletControllerDeps = { + adapter: makeMockStorageAdapter(mockStorage), + launchSubcluster: vi.fn(async () => ({ + subclusterId: 'test-subcluster', + rootKref: 'ko1', + })), + terminateSubcluster: vi.fn(), + getVatRoot: vi.fn(async (krefString: string) => ({ kref: krefString })), + }; + + const newController = await CapletController.make( + { logger: mockLogger }, + newDeps, + ); + + // The caplet should still be there + const list = await newController.list(); + expect(list).toHaveLength(1); + expect(list[0]?.manifest.id).toBe('com.example.echo'); + }); +}); diff --git a/packages/omnium-gatherum/test/e2e/smoke.test.ts b/packages/omnium-gatherum/test/e2e/smoke.test.ts index 96640f725..f2ec0f92d 100644 --- a/packages/omnium-gatherum/test/e2e/smoke.test.ts +++ b/packages/omnium-gatherum/test/e2e/smoke.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import type { Page, BrowserContext } from '@playwright/test'; -import { loadExtension } from '../helpers.ts'; +import { loadExtension } from './utils.ts'; test.describe.configure({ mode: 'serial' }); diff --git a/packages/omnium-gatherum/test/helpers.ts b/packages/omnium-gatherum/test/e2e/utils.ts similarity index 96% rename from packages/omnium-gatherum/test/helpers.ts rename to packages/omnium-gatherum/test/e2e/utils.ts index a8306e37a..1caa88d6d 100644 --- a/packages/omnium-gatherum/test/helpers.ts +++ b/packages/omnium-gatherum/test/e2e/utils.ts @@ -6,7 +6,7 @@ export { sessionPath } from '@ocap/repo-tools/test-utils/extension'; const extensionPath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - '../dist', + '../../dist', ); export const loadExtension = async (contextId?: string) => { diff --git a/packages/omnium-gatherum/test/fixtures/manifests.ts b/packages/omnium-gatherum/test/fixtures/manifests.ts new file mode 100644 index 000000000..feba0d09a --- /dev/null +++ b/packages/omnium-gatherum/test/fixtures/manifests.ts @@ -0,0 +1,41 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { CapletManifest } from '../../src/controllers/caplet/types.js'; + +/** + * Helper to get the absolute path to the vats directory. + */ +const VATS_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/vats', +); + +/** + * Helper function to create a file:// URL for a bundle in the vats directory. + * + * @param bundleName - Name of the bundle file (e.g., 'echo-caplet.bundle') + * @returns file:// URL string + */ +function getBundleSpec(bundleName: string): string { + return new URL(bundleName, `file://${VATS_DIR}/`).toString(); +} + +/** + * Manifest for the echo-caplet test fixture. + * + * This Caplet provides a simple "echo" service that returns + * the input message with an "Echo: " prefix. + * + * Usage: + * - Provides: "echo" service + * - Requests: No services (standalone) + */ +export const echoCapletManifest: CapletManifest = { + id: 'com.example.echo', + name: 'Echo Service', + version: '1.0.0', + bundleSpec: getBundleSpec('echo-caplet.bundle'), + requestedServices: [], + providedServices: ['echo'], +}; diff --git a/packages/omnium-gatherum/test/utils.ts b/packages/omnium-gatherum/test/utils.ts new file mode 100644 index 000000000..0d53ad574 --- /dev/null +++ b/packages/omnium-gatherum/test/utils.ts @@ -0,0 +1,29 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from '../src/controllers/storage/types.ts'; + +/** + * Create a mock StorageAdapter for testing. + * + * @param storage - Optional Map to use as the backing store. Defaults to a new Map. + * @returns A mock storage adapter backed by an in-memory Map. + */ +export function makeMockStorageAdapter( + storage: Map = new Map(), +): StorageAdapter { + return { + async get(key: string): Promise { + return storage.get(key) as Value | undefined; + }, + async set(key: string, value: Json): Promise { + storage.set(key, value); + }, + async delete(key: string): Promise { + storage.delete(key); + }, + async keys(prefix?: string): Promise { + const allKeys = Array.from(storage.keys()); + return prefix ? allKeys.filter((k) => k.startsWith(prefix)) : allKeys; + }, + }; +} diff --git a/packages/omnium-gatherum/tsconfig.build.json b/packages/omnium-gatherum/tsconfig.build.json index 8da52bd25..d7b547202 100644 --- a/packages/omnium-gatherum/tsconfig.build.json +++ b/packages/omnium-gatherum/tsconfig.build.json @@ -21,10 +21,5 @@ { "path": "../ocap-kernel/tsconfig.build.json" }, { "path": "../streams/tsconfig.build.json" } ], - "include": [ - "./src/**/*.ts", - "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js" - ] + "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/packages/omnium-gatherum/tsconfig.json b/packages/omnium-gatherum/tsconfig.json index 1197a400d..83fedfd08 100644 --- a/packages/omnium-gatherum/tsconfig.json +++ b/packages/omnium-gatherum/tsconfig.json @@ -27,8 +27,6 @@ "./playwright.config.ts", "./src/**/*.ts", "./src/**/*.tsx", - "./src/**/*-trusted-prelude.js", - "./src/env/dev-console.js", "./test/**/*.ts", "./vite.config.ts", "./vitest.config.ts" diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 1caf51ceb..9b08033e0 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -38,15 +38,18 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related 'packages/kernel-shims/dist/endoify.js', + // Caplet manifests and bundles + 'packages/omnium-gatherum/src/caplets/*.manifest.json', + 'packages/omnium-gatherum/src/vats/*-caplet.bundle', ]; const endoifyImportStatement = `import './endoify.js';`; -const trustedPreludes: PreludeRecord = { +const trustedPreludes = { background: { content: endoifyImportStatement, }, 'kernel-worker': { content: endoifyImportStatement }, -}; +} satisfies PreludeRecord; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { diff --git a/vitest.config.ts b/vitest.config.ts index 740a0991b..952fac397 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -75,10 +75,10 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 1.42, + statements: 1.44, functions: 0, branches: 0, - lines: 1.44, + lines: 1.47, }, 'packages/kernel-agents/**': { statements: 92.34, @@ -87,10 +87,10 @@ export default defineConfig({ lines: 92.48, }, 'packages/kernel-browser-runtime/**': { - statements: 85.88, - functions: 78.88, - branches: 81.92, - lines: 86.15, + statements: 70.06, + functions: 64.61, + branches: 59.42, + lines: 70.22, }, 'packages/kernel-errors/**': { statements: 99.24, @@ -147,10 +147,10 @@ export default defineConfig({ lines: 100, }, 'packages/nodejs/**': { - statements: 88.98, + statements: 88.79, functions: 87.5, branches: 90.9, - lines: 89.74, + lines: 89.56, }, 'packages/nodejs-test-workers/**': { statements: 23.52, @@ -159,16 +159,16 @@ export default defineConfig({ lines: 25, }, 'packages/ocap-kernel/**': { - statements: 95.12, - functions: 97.69, - branches: 86.95, - lines: 95.1, + statements: 95.44, + functions: 98.06, + branches: 87.65, + lines: 95.42, }, 'packages/omnium-gatherum/**': { - statements: 5.26, - functions: 5.55, - branches: 0, - lines: 5.35, + statements: 61.88, + functions: 64.63, + branches: 68.62, + lines: 61.82, }, 'packages/remote-iterables/**': { statements: 100, diff --git a/yarn.lock b/yarn.lock index ca1372055..4afd1d06e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2282,7 +2282,10 @@ __metadata: resolution: "@metamask/kernel-browser-runtime@workspace:packages/kernel-browser-runtime" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/captp": "npm:^4.4.8" + "@endo/eventual-send": "npm:^1.3.4" "@endo/marshal": "npm:^1.8.0" + "@libp2p/webrtc": "npm:5.2.24" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" @@ -2448,6 +2451,11 @@ __metadata: typescript-eslint: "npm:^8.29.0" vite: "npm:^7.3.0" vitest: "npm:^4.0.16" + peerDependencies: + "@libp2p/webrtc": ^5.0.0 + peerDependenciesMeta: + "@libp2p/webrtc": + optional: true languageName: unknown linkType: soft @@ -3454,19 +3462,17 @@ __metadata: resolution: "@ocap/extension@workspace:packages/extension" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-browser-runtime": "workspace:^" - "@metamask/kernel-rpc-methods": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" - "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.9.0" "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^" "@ocap/repo-tools": "workspace:^" @@ -3686,12 +3692,11 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-store": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" - "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.9.0" "@ocap/cli": "workspace:^" "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" @@ -3789,6 +3794,7 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-shims": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@ocap/nodejs": "workspace:^" @@ -3839,7 +3845,6 @@ __metadata: "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" - "@metamask/utils": "npm:^11.9.0" "@ocap/cli": "workspace:^" "@ocap/kernel-platforms": "workspace:^" "@ocap/repo-tools": "workspace:^" @@ -3877,18 +3882,20 @@ __metadata: resolution: "@ocap/omnium-gatherum@workspace:packages/omnium-gatherum" dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" + "@endo/exo": "npm:^1.5.12" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" "@metamask/kernel-browser-runtime": "workspace:^" - "@metamask/kernel-rpc-methods": "workspace:^" "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.9.0" "@ocap/cli": "workspace:^" "@ocap/repo-tools": "workspace:^" @@ -3898,6 +3905,7 @@ __metadata: "@types/chrome": "npm:^0.0.313" "@types/react": "npm:^17.0.11" "@types/react-dom": "npm:^17.0.11" + "@types/semver": "npm:^7.7.1" "@types/webextension-polyfill": "npm:^0" "@typescript-eslint/eslint-plugin": "npm:^8.29.0" "@typescript-eslint/parser": "npm:^8.29.0" @@ -3913,12 +3921,14 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" + immer: "npm:^10.1.1" jsdom: "npm:^27.4.0" playwright: "npm:^1.54.2" prettier: "npm:^3.5.3" react: "npm:^17.0.2" react-dom: "npm:^17.0.2" rimraf: "npm:^6.0.1" + semver: "npm:^7.7.1" ses: "npm:^1.14.0" tsx: "npm:^4.20.6" turbo: "npm:^2.5.6" @@ -5383,10 +5393,10 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.6": - version: 7.7.0 - resolution: "@types/semver@npm:7.7.0" - checksum: 10/ee4514c6c852b1c38f951239db02f9edeea39f5310fad9396a00b51efa2a2d96b3dfca1ae84c88181ea5b7157c57d32d7ef94edacee36fbf975546396b85ba5b +"@types/semver@npm:^7.3.6, @types/semver@npm:^7.7.1": + version: 7.7.1 + resolution: "@types/semver@npm:7.7.1" + checksum: 10/8f09e7e6ca3ded67d78ba7a8f7535c8d9cf8ced83c52e7f3ac3c281fe8c689c3fe475d199d94390dc04fc681d51f2358b430bb7b2e21c62de24f2bee2c719068 languageName: node linkType: hard @@ -9941,6 +9951,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.2.0 + resolution: "immer@npm:10.2.0" + checksum: 10/d73e218c8f8ffbb39f9290dfafa478b94af73403dcf26b5672eef35233bb30f09ffe231f8a78a6c9cb442968510edd89e851776ec90a5ddfa82cee6db6b35137 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21"