diff --git a/eslint.config.mjs b/eslint.config.mjs index d6c0bc673..1c956602d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -270,6 +270,7 @@ const config = createConfig([ { files: [ 'packages/*/src/**/vats/**/*', + 'packages/*/src/**/caplets/**/*.js', 'packages/*/test/**/vats/**/*', 'packages/nodejs/test/workers/**/*', 'packages/logger/test/workers/**/*', 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-facade.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.test.ts index 298650d41..cdaf77703 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,24 @@ 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', + bootstrapResult: { body: '#null', slots: [] }, + }; 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 e11f84139..02d014d2b 100644 --- a/packages/kernel-browser-runtime/src/types.ts +++ b/packages/kernel-browser-runtime/src/types.ts @@ -1,4 +1,4 @@ -import type { Kernel } from '@metamask/ocap-kernel'; +import type { Kernel, ClusterConfig } from '@metamask/ocap-kernel'; import type { Json } from '@metamask/utils'; /** @@ -6,6 +6,16 @@ import type { Json } from '@metamask/utils'; */ export type CapTPMessage = Record; +/** + * 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. * @@ -13,9 +23,10 @@ export type CapTPMessage = Record; */ 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-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 c095d65cb..a64b6713c 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -52,7 +52,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 a4628d4fc..dc6bdadc1 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -286,7 +286,11 @@ describe('Kernel', () => { ); const config = makeMockClusterConfig(); const result = await kernel.launchSubcluster(config); - expect(result).toStrictEqual({ body: '{"result":"ok"}', slots: [] }); + expect(result).toStrictEqual({ + subclusterId: 's1', + bootstrapResult: { body: '{"result":"ok"}', slots: [] }, + bootstrapRootKref: expect.stringMatching(/^ko\d+$/u), + }); }); }); diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index fac3bbac3..1259e833e 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'; @@ -283,11 +284,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 8ae0b5ae4..054a7b9db 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/package.json b/packages/omnium-gatherum/package.json index ea5c100c5..8e00bdde0 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:caplets && 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:caplets": "ocap bundle src/caplets/echo", "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 c5da01dd6..b00d3d5e1 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -173,6 +173,43 @@ 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(''); + const capletBaseUrl = `${baseUrl}${id}/`; + + // Fetch manifest + const manifestUrl = `${capletBaseUrl}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 CapletManifest; + + // Resolve bundleSpec to absolute URL + const bundleSpec = `${capletBaseUrl}${manifestData.bundleSpec}`; + 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, @@ -187,7 +224,10 @@ 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), + getCapletRoot: async (capletId: string) => + E(capletController).getCapletRoot(capletId), }), }, }); diff --git a/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js b/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js new file mode 100644 index 000000000..b32a80311 --- /dev/null +++ b/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js @@ -0,0 +1,54 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Log a message with caplet prefix. + * + * @param {...any} args - Arguments to log. + */ +const log = (...args) => { + console.log('[echo-caplet]', ...args); +}; + +/** + * 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} _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) { + log('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() { + log('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) { + log('Echoing message:', message); + return `Echo: ${message}`; + }, + }); +} 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..5a31c4f12 --- /dev/null +++ b/packages/omnium-gatherum/src/caplets/echo/manifest.json @@ -0,0 +1,6 @@ +{ + "id": "com.example.echo", + "name": "Echo Caplet", + "version": "1.0.0", + "bundleSpec": "echo-caplet.bundle" +} diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts index 4d68f8b84..84a82bd35 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -26,6 +26,7 @@ vi.useFakeTimers(); describe('CapletController.make', () => { const mockLaunchSubcluster = vi.fn(); const mockTerminateSubcluster = vi.fn(); + const mockGetVatRoot = vi.fn(); const makeMockLogger = () => ({ @@ -51,7 +52,9 @@ describe('CapletController.make', () => { vi.clearAllMocks(); vi.mocked(mockLaunchSubcluster).mockResolvedValue({ subclusterId: 'subcluster-123', + rootKref: 'ko1', }); + vi.mocked(mockGetVatRoot).mockResolvedValue({}); }); describe('install', () => { @@ -61,6 +64,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const result = await controller.install(makeManifest()); @@ -77,6 +81,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const invalidManifest = { id: 'someCaplet' } as CapletManifest; @@ -92,6 +97,7 @@ describe('CapletController.make', () => { 'com.example.test': { manifest: makeManifest(), subclusterId: 'subcluster-123', + rootKref: 'ko1', installedAt: 1000, }, }); @@ -99,6 +105,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await expect(controller.install(makeManifest())).rejects.toThrow( @@ -112,6 +119,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await controller.install(makeManifest()); @@ -134,6 +142,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await controller.install(makeManifest()); @@ -146,12 +155,16 @@ describe('CapletController.make', () => { }); it('prevents concurrent installations of the same caplet', async () => { - let resolveFirst: (value: { subclusterId: string }) => void; - const firstInstallPromise = new Promise<{ subclusterId: string }>( - (resolve) => { - resolveFirst = resolve; - }, - ); + let resolveFirst: (value: { + subclusterId: string; + rootKref: string; + }) => void; + const firstInstallPromise = new Promise<{ + subclusterId: string; + rootKref: string; + }>((resolve) => { + resolveFirst = resolve; + }); const mockAdapter = makeMockStorageAdapter(); const slowLaunchSubcluster = vi.fn().mockReturnValue(firstInstallPromise); @@ -159,6 +172,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: slowLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const firstInstall = controller.install(makeManifest()); @@ -167,7 +181,7 @@ describe('CapletController.make', () => { 'Caplet com.example.test is already being installed', ); - resolveFirst!({ subclusterId: 'subcluster-123' }); + resolveFirst!({ subclusterId: 'subcluster-123', rootKref: 'ko1' }); expect(await firstInstall).toStrictEqual({ capletId: 'com.example.test', subclusterId: 'subcluster-123', @@ -179,11 +193,15 @@ describe('CapletController.make', () => { const failingLaunchSubcluster = vi .fn() .mockRejectedValueOnce(new Error('Subcluster launch failed')) - .mockResolvedValueOnce({ subclusterId: 'subcluster-123' }); + .mockResolvedValueOnce({ + subclusterId: 'subcluster-123', + rootKref: 'ko1', + }); const controller = await CapletController.make(makeConfig(), { adapter: mockAdapter, launchSubcluster: failingLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await expect(controller.install(makeManifest())).rejects.toThrow( @@ -212,6 +230,7 @@ describe('CapletController.make', () => { 'com.example.test': { manifest: makeManifest(), subclusterId: 'subcluster-123', + rootKref: 'ko1', installedAt: 1000, }, }); @@ -219,6 +238,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await controller.uninstall('com.example.test'); @@ -232,6 +252,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await expect( @@ -245,6 +266,7 @@ describe('CapletController.make', () => { 'com.example.test': { manifest: makeManifest(), subclusterId: 'subcluster-123', + rootKref: 'ko1', installedAt: 1000, }, }); @@ -252,6 +274,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); await controller.uninstall('com.example.test'); @@ -266,6 +289,7 @@ describe('CapletController.make', () => { 'com.example.test': { manifest: makeManifest(), subclusterId: 'subcluster-123', + rootKref: 'ko1', installedAt: 1000, }, }); @@ -277,6 +301,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: slowTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const firstUninstall = controller.uninstall('com.example.test'); @@ -296,6 +321,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const result = controller.list(); @@ -314,11 +340,13 @@ describe('CapletController.make', () => { 'com.example.test': { manifest: makeManifest(), subclusterId: 'subcluster-1', + rootKref: 'ko1', installedAt: 1000, }, 'com.example.test2': { manifest: manifest2, subclusterId: 'subcluster-2', + rootKref: 'ko2', installedAt: 2000, }, }); @@ -326,6 +354,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const result = controller.list(); @@ -334,11 +363,13 @@ describe('CapletController.make', () => { expect(result).toContainEqual({ manifest: makeManifest(), subclusterId: 'subcluster-1', + rootKref: 'ko1', installedAt: 1000, }); expect(result).toContainEqual({ manifest: manifest2, subclusterId: 'subcluster-2', + rootKref: 'ko2', installedAt: 2000, }); }); @@ -351,6 +382,7 @@ describe('CapletController.make', () => { 'com.example.test': { manifest: makeManifest(), subclusterId: 'subcluster-123', + rootKref: 'ko1', installedAt: 1705320000000, }, }); @@ -358,6 +390,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const result = controller.get('com.example.test'); @@ -365,6 +398,7 @@ describe('CapletController.make', () => { expect(result).toStrictEqual({ manifest: makeManifest(), subclusterId: 'subcluster-123', + rootKref: 'ko1', installedAt: 1705320000000, }); }); @@ -375,6 +409,7 @@ describe('CapletController.make', () => { adapter: mockAdapter, launchSubcluster: mockLaunchSubcluster, terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, }); const result = controller.get('com.example.notfound'); @@ -382,4 +417,61 @@ describe('CapletController.make', () => { expect(result).toBeUndefined(); }); }); + + describe('getCapletRoot', () => { + it('returns caplet root successfully', async () => { + const mockRoot = { greeting: vi.fn() }; + vi.mocked(mockGetVatRoot).mockResolvedValue(mockRoot); + + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(makeConfig(), { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, + }); + + await controller.install(makeManifest()); + const result = await controller.getCapletRoot('com.example.test'); + + expect(mockGetVatRoot).toHaveBeenCalledWith('ko1'); + expect(result).toBe(mockRoot); + }); + + it('throws if caplet not found', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(makeConfig(), { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, + }); + + await expect( + controller.getCapletRoot('com.example.notfound'), + ).rejects.toThrow('Caplet com.example.notfound not found'); + }); + + it('throws if caplet has no root object', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: makeManifest(), + subclusterId: 'subcluster-123', + rootKref: '', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(makeConfig(), { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + getVatRoot: mockGetVatRoot, + }); + + await expect( + controller.getCapletRoot('com.example.test'), + ).rejects.toThrow('Caplet com.example.test has no root object'); + }); + }); }); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts index 139c35377..5a1f929d0 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -67,6 +67,14 @@ export type CapletControllerFacet = { * @returns The installed caplet or undefined if not found. */ get: (capletId: CapletId) => InstalledCaplet | undefined; + + /** + * 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; }; /** @@ -80,6 +88,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; }; /** @@ -101,6 +111,8 @@ export class CapletController extends Controller< readonly #terminateSubcluster: (subclusterId: string) => Promise; + readonly #getVatRoot: (krefString: string) => Promise; + /** * Private constructor - use static create() method. * @@ -108,6 +120,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( @@ -115,10 +128,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); } @@ -146,6 +161,7 @@ export class CapletController extends Controller< config.logger, deps.launchSubcluster, deps.terminateSubcluster, + deps.getVatRoot, ); return controller.makeFacet(); } @@ -169,6 +185,9 @@ export class CapletController extends Controller< get: (capletId: CapletId): InstalledCaplet | undefined => { return this.#get(capletId); }, + getCapletRoot: async (capletId: CapletId): Promise => { + return this.#getCapletRoot(capletId); + }, }); } @@ -210,12 +229,14 @@ export class CapletController extends Controller< }; try { - 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(), }; }); @@ -269,5 +290,25 @@ export class CapletController extends Controller< #get(capletId: CapletId): InstalledCaplet | undefined { return this.state.caplets[capletId]; } + + /** + * 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 c8f715ab0..512dd98de 100644 --- a/packages/omnium-gatherum/src/controllers/caplet/types.ts +++ b/packages/omnium-gatherum/src/controllers/caplet/types.ts @@ -88,6 +88,7 @@ export function assertCapletManifest( export type InstalledCaplet = { manifest: CapletManifest; subclusterId: string; + rootKref: string; installedAt: number; }; @@ -105,4 +106,5 @@ export type InstallResult = { */ export type LaunchResult = { subclusterId: string; + rootKref: string; }; diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts index 8ecef88cc..e31664d41 100644 --- a/packages/omnium-gatherum/src/controllers/index.ts +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -78,27 +78,19 @@ export async function initializeControllers({ launchSubcluster: async ( config: ClusterConfig, ): Promise => { - const statusBefore = await E(kernel).getStatus(); - const beforeIds = new Set( - statusBefore.subclusters.map((subcluster) => subcluster.id), - ); - - await E(kernel).launchSubcluster(config); - - const statusAfter = await E(kernel).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(kernel).launchSubcluster(config); + return { + subclusterId: result.subclusterId, + rootKref: result.rootKref, + }; }, terminateSubcluster: async (subclusterId: string): Promise => { await E(kernel).terminateSubcluster(subclusterId); }, + getVatRoot: async (krefString: string): Promise => { + // Convert kref string to presence via kernel facade + return E(kernel).getVatRoot(krefString); + }, }, ); diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 4216e9869..1b4b60bb4 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,7 +1,10 @@ import type { KernelFacade } from '@metamask/kernel-browser-runtime'; import type { Promisified } from '@metamask/kernel-utils'; -import type { CapletControllerFacet } from './controllers/index.ts'; +import type { + CapletControllerFacet, + CapletManifest, +} from './controllers/index.ts'; // Type declarations for omnium dev console API. declare global { @@ -40,7 +43,22 @@ declare global { /** * Caplet management API. */ - caplet: Promisified; + caplet: Promisified & { + /** + * 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.caplet.load('echo'); + * await omnium.caplet.install(manifest); + * ``` + */ + load: ( + id: string, + ) => Promise<{ manifest: CapletManifest; bundle: unknown }>; + }; }; } 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..d4c591557 --- /dev/null +++ b/packages/omnium-gatherum/test/caplet-integration.test.ts @@ -0,0 +1,174 @@ +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 () => { + mockStorage = new Map(); + mockSubclusterCounter = 0; + + const mockLogger = makeMockLogger(); + const mockAdapter = makeMockStorageAdapter(mockStorage); + + 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, + }; + + capletController = await CapletController.make( + { logger: mockLogger }, + deps, + ); + }); + + it('installs a caplet', async () => { + const result = await capletController.install(echoCapletManifest); + + expect(result.capletId).toBe('com.example.echo'); + expect(result.subclusterId).toBe('test-subcluster-1'); + }); + + it('retrieves installed a caplet', async () => { + await capletController.install(echoCapletManifest); + + const caplet = capletController.get('com.example.echo'); + + expect(caplet).toStrictEqual({ + manifest: { + id: 'com.example.echo', + name: 'Echo Caplet', + version: '1.0.0', + bundleSpec: expect.anything(), + }, + subclusterId: 'test-subcluster-1', + rootKref: 'ko1', + installedAt: expect.any(Number), + }); + }); + + it('lists all installed caplets', async () => { + const emptyList = capletController.list(); + expect(emptyList).toHaveLength(0); + + await capletController.install(echoCapletManifest); + + const list = capletController.list(); + expect(list).toHaveLength(1); + expect(list[0]?.manifest.id).toBe('com.example.echo'); + }); + + it('uninstalls a caplet', async () => { + await capletController.install(echoCapletManifest); + + let list = capletController.list(); + expect(list).toHaveLength(1); + + await capletController.uninstall('com.example.echo'); + + list = capletController.list(); + expect(list).toHaveLength(0); + + const caplet = capletController.get('com.example.echo'); + expect(caplet).toBeUndefined(); + }); + + it('prevents duplicate installations', async () => { + await capletController.install(echoCapletManifest); + + 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', async () => { + await capletController.install(echoCapletManifest); + + const rootPresence = + await capletController.getCapletRoot('com.example.echo'); + + 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 () => { + 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 = 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..538104057 --- /dev/null +++ b/packages/omnium-gatherum/test/fixtures/manifests.ts @@ -0,0 +1,34 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import echoManifestJson from '../../src/caplets/echo/manifest.json'; +import type { CapletManifest } from '../../src/controllers/caplet/types.js'; + +/** + * Helper to get the absolute path to the echo caplet directory. + */ +const ECHO_CAPLET_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '../../src/caplets/echo', +); + +/** + * Manifest for the echo-caplet test fixture. + * + * This imports the actual manifest.json and resolves the bundleSpec + * to an absolute file:// URL for tests. + * + * 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 = { + ...echoManifestJson, + bundleSpec: new URL( + echoManifestJson.bundleSpec, + `file://${ECHO_CAPLET_DIR}/`, + ).toString(), +}; 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..57abda3b8 100644 --- a/packages/omnium-gatherum/vite.config.ts +++ b/packages/omnium-gatherum/vite.config.ts @@ -38,6 +38,11 @@ const staticCopyTargets: readonly (string | Target)[] = [ 'packages/omnium-gatherum/src/manifest.json', // Trusted prelude-related 'packages/kernel-shims/dist/endoify.js', + // Caplets (add new caplet entries here) + { + src: 'packages/omnium-gatherum/src/caplets/echo/{manifest.json,*.bundle}', + dest: 'echo/', + }, ]; const endoifyImportStatement = `import './endoify.js';`; diff --git a/yarn.lock b/yarn.lock index 069f1b9b1..3e8e2e1f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3870,7 +3870,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"