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/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 index 960d640f6..86dc2f942 100644 --- 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 @@ -24,8 +24,12 @@ describe('CapTP Integration', () => { // Create mock kernel with method implementations mockKernel = { launchSubcluster: vi.fn().mockResolvedValue({ - body: '#{"rootKref":"ko1"}', - slots: ['ko1'], + subclusterId: 'sc1', + bootstrapRootKref: 'ko1', + bootstrapResult: { + body: '#{"result":"ok"}', + slots: [], + }, }), terminateSubcluster: vi.fn().mockResolvedValue(undefined), queueMessage: vi.fn().mockResolvedValue({ @@ -113,9 +117,11 @@ describe('CapTP Integration', () => { // Call launchSubcluster via E() const result = await E(kernel).launchSubcluster(config); + + // The kernel facade now returns LaunchResult instead of CapData expect(result).toStrictEqual({ - body: '#{"rootKref":"ko1"}', - slots: ['ko1'], + subclusterId: 'sc1', + rootKref: 'ko1', }); expect(mockKernel.launchSubcluster).toHaveBeenCalledWith(config); 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 index 21c9686ea..3f3f2f43f 100644 --- 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 @@ -74,4 +74,21 @@ describe('makeKernelCapTP', () => { 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 index b20152d24..c077b189b 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.ts @@ -1,5 +1,5 @@ import { makeCapTP } from '@endo/captp'; -import type { Kernel } from '@metamask/ocap-kernel'; +import type { Kernel, KRef } from '@metamask/ocap-kernel'; import type { Json } from '@metamask/utils'; import { makeKernelFacade } from './kernel-facade.ts'; @@ -46,6 +46,133 @@ export type KernelCapTP = { 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. * @@ -62,6 +189,10 @@ export function makeKernelCapTP(options: KernelCapTPOptions): KernelCapTP { // 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); 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 index 298650d41..e8306893f 100644 --- 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 @@ -24,8 +24,8 @@ describe('makeKernelFacade', () => { beforeEach(() => { mockKernel = { launchSubcluster: vi.fn().mockResolvedValue({ - body: '#{"status":"ok"}', - slots: [], + subclusterId: 'sc1', + bootstrapRootKref: 'ko1', }), terminateSubcluster: vi.fn().mockResolvedValue(undefined), queueMessage: vi.fn().mockResolvedValue({ @@ -60,16 +60,23 @@ describe('makeKernelFacade', () => { expect(mockKernel.launchSubcluster).toHaveBeenCalledTimes(1); }); - it('returns result from kernel', async () => { - const expectedResult = { body: '#{"rootObject":"ko1"}', slots: ['ko1'] }; + it('returns result with subclusterId and rootKref from kernel', async () => { + const kernelResult = { + subclusterId: 's1', + bootstrapRootKref: 'ko1', + }; vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce( - expectedResult, + kernelResult, ); const config: ClusterConfig = makeClusterConfig(); const result = await facade.launchSubcluster(config); - expect(result).toStrictEqual(expectedResult); + + expect(result).toStrictEqual({ + subclusterId: 's1', + rootKref: 'ko1', + }); }); it('propagates errors from kernel', async () => { 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 index 199147980..51d3cc9a4 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts @@ -1,7 +1,7 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; -import type { KernelFacade } from '../../types.ts'; +import type { KernelFacade, LaunchResult } from '../../types.ts'; export type { KernelFacade } from '../../types.ts'; @@ -15,8 +15,10 @@ export function makeKernelFacade(kernel: Kernel): KernelFacade { return makeDefaultExo('KernelFacade', { ping: async () => 'pong' as const, - launchSubcluster: async (config: ClusterConfig) => { - return kernel.launchSubcluster(config); + launchSubcluster: async (config: ClusterConfig): Promise => { + const { subclusterId, bootstrapRootKref } = + await kernel.launchSubcluster(config); + return { subclusterId, rootKref: bootstrapRootKref }; }, terminateSubcluster: async (subclusterId: string) => { @@ -34,6 +36,12 @@ export function makeKernelFacade(kernel: Kernel): KernelFacade { 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/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 index 967abc71a..0d5565c5f 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -1,4 +1,14 @@ -import type { Kernel } from '@metamask/ocap-kernel'; +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. @@ -7,9 +17,10 @@ import type { Kernel } from '@metamask/ocap-kernel'; */ export type KernelFacade = { ping: () => Promise<'pong'>; - launchSubcluster: Kernel['launchSubcluster']; + 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-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 441cb7e77..76a558d7d 100644 --- a/packages/kernel-test/src/utils.ts +++ b/packages/kernel-test/src/utils.ts @@ -37,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); } /** diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index ec7cebc4a..a2eb79406 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -51,7 +51,6 @@ }, "dependencies": { "@endo/eventual-send": "^1.3.4", - "@endo/marshal": "^1.8.0", "@endo/promise-kit": "^1.1.13", "@libp2p/interface": "2.11.0", "@libp2p/webrtc": "5.2.24", diff --git a/packages/nodejs/test/helpers/kernel.ts b/packages/nodejs/test/helpers/kernel.ts index 7fede0d50..4b965006b 100644 --- a/packages/nodejs/test/helpers/kernel.ts +++ b/packages/nodejs/test/helpers/kernel.ts @@ -42,10 +42,7 @@ 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) { - throw Error(`this can't happen but eslint is stupid`); - } - return kunser(bootstrapResultRaw); + return bootstrapResult && kunser(bootstrapResult); } diff --git a/packages/nodejs/test/helpers/remote-comms.ts b/packages/nodejs/test/helpers/remote-comms.ts index a5a17050f..bcd7b80f4 100644 --- a/packages/nodejs/test/helpers/remote-comms.ts +++ b/packages/nodejs/test/helpers/remote-comms.ts @@ -1,5 +1,5 @@ -import type { CapData } from '@endo/marshal'; import type { KernelDatabase } from '@metamask/kernel-store'; +import { stringify } from '@metamask/kernel-utils'; import { Kernel, kunser, makeKernelStore } from '@metamask/ocap-kernel'; import type { ClusterConfig, KRef } from '@metamask/ocap-kernel'; @@ -58,8 +58,13 @@ export async function launchVatAndGetURL( kernel: Kernel, config: ClusterConfig, ): Promise { - const result = await kernel.launchSubcluster(config); - return kunser(result as CapData) as string; + const { bootstrapResult } = await kernel.launchSubcluster(config); + if (!bootstrapResult) { + throw new Error( + `No bootstrap result for vat "${config.bootstrap}" with config ${stringify(config)}`, + ); + } + return kunser(bootstrapResult) as string; } /** diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 6c7ae274f..159941e1b 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -288,7 +288,11 @@ describe('Kernel', () => { ); 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); }); }); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 93deee72b..b53ee003f 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -21,6 +21,7 @@ import type { VatConfig, KernelStatus, Subcluster, + SubclusterLaunchResult, EndpointHandle, } from './types.ts'; import { isVatId, isRemoteId } from './types.ts'; @@ -293,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); } 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/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/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 62f19937a..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", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index b73855fc6..942bb4054 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -129,34 +129,22 @@ async function main(): Promise { { logger: logger.subLogger({ tags: ['caplet'] }) }, { adapter: storageAdapter, - // Wrap launchSubcluster to return subclusterId launchSubcluster: async ( config: ClusterConfig, ): Promise => { - // Get current subcluster count - const statusBefore = await E(kernelP).getStatus(); - const beforeIds = new Set( - statusBefore.subclusters.map((subcluster) => subcluster.id), - ); - - // Launch the subcluster - await E(kernelP).launchSubcluster(config); - - // Get status after and find the new subcluster - const statusAfter = await E(kernelP).getStatus(); - const newSubcluster = statusAfter.subclusters.find( - (subcluster) => !beforeIds.has(subcluster.id), - ); - - if (!newSubcluster) { - throw new Error('Failed to determine subclusterId after launch'); - } - - return { subclusterId: newSubcluster.id }; + 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); + }, }, ); globals.setCapletController(capletController); @@ -209,6 +197,45 @@ function defineGlobals(): GlobalSetters { let ping: (() => Promise) | undefined; let capletController: CapletControllerFacet; + /** + * 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, { ping: { get: () => ping, @@ -223,9 +250,12 @@ function defineGlobals(): GlobalSetters { uninstall: async (capletId: string) => E(capletController).uninstall(capletId), list: async () => E(capletController).list(), + load: loadCaplet, get: async (capletId: string) => E(capletController).get(capletId), getByService: async (serviceName: string) => E(capletController).getByService(serviceName), + getCapletRoot: async (capletId: string) => + E(capletController).getCapletRoot(capletId), }), }, }); 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/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 3f7a062d4..458855448 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -70,6 +70,14 @@ export type CapletControllerFacet = { * @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; }; /** @@ -83,6 +91,8 @@ export type CapletControllerDeps = { 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; }; /** @@ -102,6 +112,8 @@ export class CapletController extends Controller< readonly #terminateSubcluster: (subclusterId: string) => Promise; + readonly #getVatRoot: (krefString: string) => Promise; + /** * Private constructor - use static create() method. * @@ -109,6 +121,7 @@ export class CapletController extends Controller< * @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( @@ -116,10 +129,12 @@ export class CapletController extends Controller< 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); } @@ -147,6 +162,7 @@ export class CapletController extends Controller< config.logger, deps.launchSubcluster, deps.terminateSubcluster, + deps.getVatRoot, ); return controller.makeFacet(); } @@ -178,6 +194,9 @@ export class CapletController extends Controller< ): Promise => { return this.#getByService(serviceName); }, + getCapletRoot: async (capletId: CapletId): Promise => { + return this.#getCapletRoot(capletId); + }, }); } @@ -216,12 +235,14 @@ export class CapletController extends Controller< }; // Launch subcluster - const { subclusterId } = await this.#launchSubcluster(clusterConfig); + const { subclusterId, rootKref } = + await this.#launchSubcluster(clusterConfig); this.update((draft) => { draft.caplets[id] = { manifest, subclusterId, + rootKref, installedAt: Date.now(), }; }); @@ -284,5 +305,25 @@ export class CapletController extends Controller< 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/types.ts b/packages/omnium-gatherum/src/controllers/caplet/types.ts index 68a3ff10d..2ff5e621f 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/types.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/types.ts @@ -89,6 +89,7 @@ export function assertCapletManifest( export type InstalledCaplet = { manifest: CapletManifest; subclusterId: string; + rootKref: string; installedAt: number; }; @@ -106,4 +107,5 @@ export type InstallResult = { */ export type LaunchResult = { subclusterId: string; + rootKref: string; }; diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 7e1d58bf2..ae1c07853 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -40,6 +40,21 @@ declare global { */ getKernel: () => Promise; + /** + * 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. */ @@ -98,6 +113,19 @@ declare global { 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; }; }; } 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..d6c03d660 --- /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/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 index c6294a8ca..0d53ad574 100644 --- a/packages/omnium-gatherum/test/utils.ts +++ b/packages/omnium-gatherum/test/utils.ts @@ -5,27 +5,25 @@ 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(): StorageAdapter { - const store = new Map(); - +export function makeMockStorageAdapter( + storage: Map = new Map(), +): StorageAdapter { return { async get(key: string): Promise { - return store.get(key) as Value | undefined; + return storage.get(key) as Value | undefined; }, async set(key: string, value: Json): Promise { - store.set(key, value); + storage.set(key, value); }, async delete(key: string): Promise { - store.delete(key); + storage.delete(key); }, async keys(prefix?: string): Promise { - const allKeys = Array.from(store.keys()); - if (prefix === undefined) { - return allKeys; - } - return allKeys.filter((k) => k.startsWith(prefix)); + const allKeys = Array.from(storage.keys()); + return prefix ? allKeys.filter((k) => k.startsWith(prefix)) : allKeys; }, }; } diff --git a/packages/omnium-gatherum/vite.config.ts b/packages/omnium-gatherum/vite.config.ts index 1c314ffff..9b08033e0 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -38,6 +38,9 @@ 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';`; diff --git a/vitest.config.ts b/vitest.config.ts index 35bac3850..6c8d2ca8d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -87,10 +87,10 @@ export default defineConfig({ lines: 88.13, }, 'packages/kernel-browser-runtime/**': { - statements: 84.16, - functions: 78.3, - branches: 81.11, - lines: 84.4, + statements: 77.21, + functions: 72.8, + branches: 68.86, + lines: 77.41, }, 'packages/kernel-errors/**': { statements: 99.24, @@ -159,16 +159,16 @@ export default defineConfig({ lines: 25, }, 'packages/ocap-kernel/**': { - statements: 95.44, + statements: 95.17, functions: 98.06, - branches: 87.65, - lines: 95.42, + branches: 87.04, + lines: 95.15, }, 'packages/omnium-gatherum/**': { - statements: 60.5, - functions: 61.44, - branches: 71.11, - lines: 60.42, + statements: 58.98, + functions: 60.22, + branches: 68.62, + lines: 58.89, }, 'packages/remote-iterables/**': { statements: 100, diff --git a/yarn.lock b/yarn.lock index a41a32194..2dad074d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3867,7 +3867,6 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/eventual-send": "npm:^1.3.4" - "@endo/marshal": "npm:^1.8.0" "@endo/promise-kit": "npm:^1.1.13" "@libp2p/interface": "npm:2.11.0" "@libp2p/webrtc": "npm:5.2.24"