From 301a7448e58b97ae2895ddd1ba16dff01ce4b6f5 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:51:12 -0800 Subject: [PATCH 01/21] refactor: Rationalize globalThis.kernel Set globalThis.kernel in the extension and omnium to the kernel itself. Remove ping and getKernel methods from background console interface. The kernel exposes ping(). --- packages/extension/src/background.ts | 48 +++++++--------------- packages/extension/src/global.d.ts | 19 +-------- packages/omnium-gatherum/src/background.ts | 42 ++++++------------- packages/omnium-gatherum/src/global.d.ts | 20 ++------- 4 files changed, 31 insertions(+), 98 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index c7bdb855a..b1a11267c 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -5,10 +5,7 @@ import { isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { - KernelFacade, - CapTPMessage, -} from '@metamask/kernel-browser-runtime'; +import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; @@ -20,12 +17,11 @@ defineGlobals(); const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); let bootPromise: Promise | null = null; -let kernelP: Promise; -let ping: () => Promise; // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - ping?.().catch(logger.error); + globalThis.kernel !== undefined && + E(globalThis.kernel).ping().catch(logger.error); }); // Install/update @@ -108,12 +104,8 @@ async function main(): Promise { }); // Get the kernel remote presence - kernelP = backgroundCapTP.getKernel(); - - ping = async () => { - const result = await E(kernelP).ping(); - logger.info(result); - }; + const kernelP = backgroundCapTP.getKernel(); + globalThis.kernel = kernelP; // Handle incoming CapTP messages from the kernel const drainPromise = offscreenStream.drain((message) => { @@ -127,8 +119,8 @@ async function main(): Promise { drainPromise.catch(logger.error); try { - await ping(); // Wait for the kernel to be ready - await startDefaultSubcluster(kernelP); + await E(kernelP).ping(); + await startDefaultSubcluster(); } catch (error) { offscreenStream.throw(error as Error).catch(logger.error); } @@ -146,16 +138,14 @@ async function main(): Promise { /** * Idempotently starts the default subcluster. - * - * @param kernelPromise - Promise for the kernel facade. */ -async function startDefaultSubcluster( - kernelPromise: Promise, -): Promise { - const status = await E(kernelPromise).getStatus(); +async function startDefaultSubcluster(): Promise { + const status = await E(globalThis.kernel).getStatus(); if (status.subclusters.length === 0) { - const result = await E(kernelPromise).launchSubcluster(defaultSubcluster); + const result = await E(globalThis.kernel).launchSubcluster( + defaultSubcluster, + ); logger.info(`Default subcluster launched: ${JSON.stringify(result)}`); } else { logger.info('Subclusters already exist. Not launching default subcluster.'); @@ -169,19 +159,9 @@ function defineGlobals(): void { Object.defineProperty(globalThis, 'kernel', { configurable: false, enumerable: true, - writable: false, - value: {}, - }); - - Object.defineProperties(globalThis.kernel, { - ping: { - get: () => ping, - }, - getKernel: { - value: async () => kernelP, - }, + writable: true, + value: undefined, }); - harden(globalThis.kernel); Object.defineProperty(globalThis, 'E', { value: E, diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts index 06dd91196..f63d2a3a6 100644 --- a/packages/extension/src/global.d.ts +++ b/packages/extension/src/global.d.ts @@ -16,24 +16,7 @@ declare global { var E: typeof import('@endo/eventual-send').E; // eslint-disable-next-line no-var - var kernel: { - /** - * Ping the kernel to verify connectivity. - */ - ping: () => Promise; - - /** - * Get the kernel remote presence for use with E(). - * - * @returns A promise for the kernel facade remote presence. - * @example - * ```typescript - * const kernel = await kernel.getKernel(); - * const status = await E(kernel).getStatus(); - * ``` - */ - getKernel: () => Promise; - }; + var kernel: KernelFacade | Promise; } export {}; diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index b00d3d5e1..191160ec6 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -5,10 +5,7 @@ import { isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { - CapTPMessage, - KernelFacade, -} from '@metamask/kernel-browser-runtime'; +import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -27,7 +24,8 @@ let bootPromise: Promise | null = null; // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - omnium.ping?.().catch(logger.error); + globalThis.kernel !== undefined && + E(globalThis.kernel).ping().catch(logger.error); }); // Install/update @@ -108,12 +106,7 @@ async function main(): Promise { }); const kernelP = backgroundCapTP.getKernel(); - globals.setKernelP(kernelP); - - globals.setPing(async (): Promise => { - const result = await E(kernelP).ping(); - logger.info(result); - }); + globalThis.kernel = kernelP; try { const controllers = await initializeControllers({ @@ -144,8 +137,6 @@ async function main(): Promise { } type GlobalSetters = { - setKernelP: (value: Promise) => void; - setPing: (value: () => Promise) => void; setCapletController: (value: CapletControllerFacet) => void; }; @@ -155,6 +146,8 @@ type GlobalSetters = { * @returns A device for setting the global values. */ function defineGlobals(): GlobalSetters { + let capletController: CapletControllerFacet; + Object.defineProperty(globalThis, 'E', { configurable: false, enumerable: true, @@ -162,6 +155,13 @@ function defineGlobals(): GlobalSetters { value: E, }); + Object.defineProperty(globalThis, 'kernel', { + configurable: false, + enumerable: true, + writable: true, + value: undefined, + }); + Object.defineProperty(globalThis, 'omnium', { configurable: false, enumerable: true, @@ -169,10 +169,6 @@ function defineGlobals(): GlobalSetters { value: {}, }); - let kernelP: Promise; - let ping: (() => Promise) | undefined; - let capletController: CapletControllerFacet; - /** * Load a caplet's manifest and bundle by ID. * @@ -211,12 +207,6 @@ function defineGlobals(): GlobalSetters { }; Object.defineProperties(globalThis.omnium, { - ping: { - get: () => ping, - }, - getKernel: { - value: async () => kernelP, - }, caplet: { value: harden({ install: async (manifest: CapletManifest) => @@ -234,12 +224,6 @@ function defineGlobals(): GlobalSetters { harden(globalThis.omnium); return { - setKernelP: (value) => { - kernelP = value; - }, - setPing: (value) => { - ping = value; - }, setCapletController: (value) => { capletController = value; }, diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index 1b4b60bb4..545ed5d14 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -22,24 +22,10 @@ declare global { var E: typeof import('@endo/eventual-send').E; // eslint-disable-next-line no-var - var omnium: { - /** - * Ping the kernel to verify connectivity. - */ - ping: () => Promise; - - /** - * Get the kernel remote presence for use with E(). - * - * @returns A promise for the kernel facade remote presence. - * @example - * ```typescript - * const kernel = await omnium.getKernel(); - * const status = await E(kernel).getStatus(); - * ``` - */ - getKernel: () => Promise; + var kernel: KernelFacade | Promise; + // eslint-disable-next-line no-var + var omnium: { /** * Caplet management API. */ From 4d96830139a2a824165aaf51df5e72bc75745600 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:06:53 -0800 Subject: [PATCH 02/21] feat(kernel-browser-runtime): Add slot translation for E() on vat objects Implement slot translation pattern to enable E() (eventual sends) on vat objects from the extension background. This creates presences from kernel krefs that forward method calls to kernel.queueMessage() via the existing CapTP connection. Key changes: - Add background-kref.ts with makeBackgroundKref() factory - Add node-endoify.js to kernel-shims for Node.js environments - Update kernel-facade to convert kref strings to standins - Fix launch-subcluster RPC result to use null for JSON compatibility - Integrate resolveKref/krefOf into omnium background The new approach uses @endo/marshal with smallcaps format (matching the kernel) rather than trying to hook into CapTP internal marshalling, which uses incompatible capdata format. Co-Authored-By: Claude Opus 4.5 --- .depcheckrc.yml | 3 + package.json | 3 +- packages/kernel-browser-runtime/package.json | 1 + .../src/background-kref.ts | 256 ++++++++++++++++++ .../kernel-browser-runtime/src/index.test.ts | 1 + packages/kernel-browser-runtime/src/index.ts | 5 + .../src/kernel-worker/captp/kernel-facade.ts | 35 ++- .../kernel-browser-runtime/vitest.config.ts | 72 +++-- packages/kernel-shims/package.json | 9 + packages/kernel-shims/src/node-endoify.js | 14 + packages/nodejs/src/env/endoify.ts | 9 +- packages/omnium-gatherum/README.md | 25 ++ packages/omnium-gatherum/src/background.ts | 18 +- .../src/caplets/echo/echo-caplet.js | 2 +- yarn.lock | 6 + 15 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/background-kref.ts create mode 100644 packages/kernel-shims/src/node-endoify.js diff --git a/.depcheckrc.yml b/.depcheckrc.yml index ef2dad39f..c131a1352 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -50,6 +50,9 @@ ignores: # Used by @ocap/nodejs to build the sqlite3 bindings - 'node-gyp' + # Used by @metamask/kernel-shims/node-endoify for tests + - '@libp2p/webrtc' + # These are peer dependencies of various modules we actually do # depend on, which have been elevated to full dependencies (even # though we don't actually depend on them) in order to work around a diff --git a/package.json b/package.json index 36505a5a8..b6b39b083 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,8 @@ "vite>sass>@parcel/watcher": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>edgedriver": false, "vitest>@vitest/browser>webdriverio>@wdio/utils>geckodriver": false, - "vitest>@vitest/mocker>msw": false + "vitest>@vitest/mocker>msw": false, + "@ocap/cli>@metamask/kernel-shims>@libp2p/webrtc>@ipshipyard/node-datachannel": false } }, "resolutions": { diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index 5ea33b466..f25af314a 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -86,6 +86,7 @@ "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", "@endo/eventual-send": "^1.3.4", + "@libp2p/webrtc": "5.2.24", "@metamask/auto-changelog": "^5.3.0", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", diff --git a/packages/kernel-browser-runtime/src/background-kref.ts b/packages/kernel-browser-runtime/src/background-kref.ts new file mode 100644 index 000000000..6d36e4c69 --- /dev/null +++ b/packages/kernel-browser-runtime/src/background-kref.ts @@ -0,0 +1,256 @@ +/** + * Background kref system for creating E()-usable presences from kernel krefs. + * + * This module provides "slot translation" - converting kernel krefs (ko*, kp*) + * into presences that can receive eventual sends via E(). Method calls on these + * presences are forwarded to kernel.queueMessage() through the existing CapTP + * connection. + */ +import { E, HandledPromise } from '@endo/eventual-send'; +import type { EHandler } from '@endo/eventual-send'; +import { makeMarshal, Remotable } from '@endo/marshal'; +import type { CapData } from '@endo/marshal'; +import type { KRef } from '@metamask/ocap-kernel'; + +import type { KernelFacade } from './types.ts'; + +/** + * Function type for sending messages to the kernel. + */ +type SendToKernelFn = ( + kref: string, + method: string, + args: unknown[], +) => Promise; + +/** + * Options for creating a background kref system. + */ +export type BackgroundKrefOptions = { + /** + * The kernel facade remote presence from CapTP. + * Can be a promise since E() works with promises. + */ + kernelFacade: KernelFacade | Promise; +}; + +/** + * The background kref system interface. + */ +export type BackgroundKref = { + /** + * Resolve a kref string to an E()-usable presence. + * + * @param kref - The kernel reference string (e.g., 'ko42', 'kp123'). + * @returns A presence that can receive E() calls. + */ + resolveKref: (kref: KRef) => object; + + /** + * Extract the kref from a presence. + * + * @param presence - A presence created by resolveKref. + * @returns The kref string, or undefined if not a kref presence. + */ + krefOf: (presence: object) => KRef | undefined; + + /** + * Deserialize a CapData result into presences. + * + * @param data - The CapData to deserialize. + * @returns The deserialized value with krefs converted to presences. + */ + fromCapData: (data: CapData) => unknown; +}; + +/** + * Create a remote kit for a kref, similar to CapTP's makeRemoteKit. + * Returns a settler that can create an E()-callable presence. + * + * @param kref - The kernel reference string. + * @param sendToKernel - Function to send messages to the kernel. + * @returns An object with a resolveWithPresence method. + */ +function makeKrefRemoteKit( + kref: string, + sendToKernel: SendToKernelFn, +): { resolveWithPresence: () => object } { + // Handler that intercepts E() calls on the presence + const handler: EHandler = { + async get(_target, prop) { + if (typeof prop !== 'string') { + return undefined; + } + // Property access: E(presence).prop returns a promise + return sendToKernel(kref, prop, []); + }, + async applyMethod(_target, prop, args) { + if (typeof prop !== 'string') { + throw new Error('Method name must be a string'); + } + // Method call: E(presence).method(args) + return sendToKernel(kref, prop, args); + }, + applyFunction(_target, _args) { + // Function call: E(presence)(args) - not supported for kref presences + throw new Error('Cannot call kref presence as a function'); + }, + }; + + let resolveWithPresenceFn: + | ((presenceHandler: EHandler) => object) + | undefined; + + // Create a HandledPromise to get access to resolveWithPresence + // We don't actually use the promise - we just need the resolver + // eslint-disable-next-line no-new, @typescript-eslint/no-floating-promises + new HandledPromise((_resolve, _reject, resolveWithPresence) => { + resolveWithPresenceFn = resolveWithPresence; + }, handler); + + return { + resolveWithPresence: () => { + if (!resolveWithPresenceFn) { + throw new Error('resolveWithPresence not initialized'); + } + return resolveWithPresenceFn(handler); + }, + }; +} + +/** + * Create an E()-usable presence for a kref. + * + * @param kref - The kernel reference string. + * @param iface - Interface name for the remotable. + * @param sendToKernel - Function to send messages to the kernel. + * @returns A presence that can receive E() calls. + */ +function makeKrefPresence( + kref: string, + iface: string, + sendToKernel: SendToKernelFn, +): object { + const kit = makeKrefRemoteKit(kref, sendToKernel); + // Wrap the presence in Remotable for proper pass-style + return Remotable(iface, undefined, kit.resolveWithPresence()); +} + +/** + * Create a background kref system for E() on vat objects. + * + * This creates presences from kernel krefs that forward method calls + * to kernel.queueMessage() via the existing CapTP connection. + * + * @param options - Options including the kernel facade. + * @returns The background kref system. + */ +export function makeBackgroundKref( + options: BackgroundKrefOptions, +): BackgroundKref { + const { kernelFacade } = options; + + // State for kref↔presence mapping + const krefToPresence = new Map(); + const presenceToKref = new WeakMap(); + + // Forward declaration for sendToKernel (needs bgMarshal) + // eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-const + let bgMarshal: any; + + /** + * Send a message to the kernel and deserialize the result. + * + * @param kref - The target kernel reference. + * @param method - The method name to call. + * @param args - Arguments to pass to the method. + * @returns The deserialized result from the kernel. + */ + const sendToKernel: SendToKernelFn = async ( + kref: KRef, + method: string, + args: unknown[], + ): Promise => { + // Convert presence args to kref strings + const serializedArgs = args.map((arg) => { + if (typeof arg === 'object' && arg !== null) { + const argKref = presenceToKref.get(arg); + if (argKref) { + return argKref; // Pass kref string to kernel + } + } + return arg; // Pass primitive through + }); + + // Call kernel via existing CapTP + const result: CapData = await E(kernelFacade).queueMessage( + kref, + method, + serializedArgs, + ); + + // Deserialize result (krefs become presences) + return bgMarshal.fromCapData(result); + }; + + /** + * Convert a kref slot to a presence. + * + * @param kref - The kernel reference string. + * @param iface - Optional interface name for the presence. + * @returns A presence object that can receive E() calls. + */ + const convertSlotToVal = (kref: KRef, iface?: string): object => { + let presence = krefToPresence.get(kref); + if (!presence) { + presence = makeKrefPresence( + kref, + iface ?? 'Alleged: VatObject', + sendToKernel, + ); + krefToPresence.set(kref, presence); + presenceToKref.set(presence, kref); + } + return presence; + }; + + /** + * Convert a presence to a kref slot. + * This is called by the marshal for pass-by-presence objects. + * Throws if the object is not a known kref presence. + * + * @param val - The value to convert to a kref. + * @returns The kernel reference string. + */ + const convertValToSlot = (val: unknown): KRef => { + if (typeof val === 'object' && val !== null) { + const kref = presenceToKref.get(val); + if (kref !== undefined) { + return kref; + } + } + throw new Error('Cannot serialize unknown remotable object'); + }; + + // Create marshal with smallcaps format (same as kernel) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bgMarshal = makeMarshal(convertValToSlot, convertSlotToVal as any, { + serializeBodyFormat: 'smallcaps', + errorTagging: 'off', + }); + + return harden({ + resolveKref: (kref: KRef): object => { + return convertSlotToVal(kref, 'Alleged: VatObject'); + }, + + krefOf: (presence: object): KRef | undefined => { + return presenceToKref.get(presence); + }, + + fromCapData: (data: CapData): unknown => { + return bgMarshal.fromCapData(data); + }, + }); +} +harden(makeBackgroundKref); diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index f52b98667..1dc0b7056 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -13,6 +13,7 @@ describe('index', () => { 'getRelaysFromCurrentLocation', 'isCapTPNotification', 'makeBackgroundCapTP', + 'makeBackgroundKref', 'makeCapTPNotification', 'makeIframeVatWorker', 'parseRelayQueryString', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 4c10590e3..325fdb48e 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -21,3 +21,8 @@ export { type BackgroundCapTPOptions, type CapTPMessage, } from './background-captp.ts'; +export { + makeBackgroundKref, + type BackgroundKref, + type BackgroundKrefOptions, +} from './background-kref.ts'; 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 51d3cc9a4..6282cbce9 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,10 +1,41 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; +import { kslot } from '@metamask/ocap-kernel'; import type { KernelFacade, LaunchResult } from '../../types.ts'; export type { KernelFacade } from '../../types.ts'; +/** + * Recursively convert kref strings in a value to kernel standins. + * + * When the background sends kref strings as arguments, we need to convert + * them to standin objects that kernel-marshal can serialize properly. + * + * @param value - The value to convert. + * @returns The value with kref strings converted to standins. + */ +function convertKrefsToStandins(value: unknown): unknown { + // Check if it's a kref string (ko* or kp*) + if (typeof value === 'string' && /^k[op]\d+$/u.test(value)) { + return kslot(value); + } + // Recursively process arrays + if (Array.isArray(value)) { + return value.map(convertKrefsToStandins); + } + // Recursively process plain objects + if (typeof value === 'object' && value !== null) { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = convertKrefsToStandins(val); + } + return result; + } + // Return primitives as-is + return value; +} + /** * Create the kernel facade exo that exposes kernel methods via CapTP. * @@ -26,7 +57,9 @@ export function makeKernelFacade(kernel: Kernel): KernelFacade { }, queueMessage: async (target: KRef, method: string, args: unknown[]) => { - return kernel.queueMessage(target, method, args); + // Convert kref strings in args to standins for kernel-marshal + const processedArgs = convertKrefsToStandins(args) as unknown[]; + return kernel.queueMessage(target, method, processedArgs); }, getStatus: async () => { diff --git a/packages/kernel-browser-runtime/vitest.config.ts b/packages/kernel-browser-runtime/vitest.config.ts index fe56f07a9..55fbcceaa 100644 --- a/packages/kernel-browser-runtime/vitest.config.ts +++ b/packages/kernel-browser-runtime/vitest.config.ts @@ -1,27 +1,57 @@ -import { mergeConfig } from '@ocap/repo-tools/vitest-config'; import { fileURLToPath } from 'node:url'; -import { defineConfig, defineProject } from 'vitest/config'; +import { defineConfig } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; -export default defineConfig((args) => { - return mergeConfig( - args, - defaultConfig, - defineProject({ - test: { - name: 'kernel-browser-runtime', - include: ['src/**/*.test.ts'], - exclude: ['**/*.integration.test.ts'], - setupFiles: [ - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), - ), - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), - ), - ], +const { test: rootTest, ...rootViteConfig } = defaultConfig; + +// Common test configuration from root, minus projects and setupFiles +const { + projects: _projects, + setupFiles: _setupFiles, + ...commonTestConfig +} = rootTest ?? {}; + +export default defineConfig({ + ...rootViteConfig, + + test: { + projects: [ + // Unit tests with mock-endoify + { + test: { + ...commonTestConfig, + name: 'kernel-browser-runtime', + include: ['src/**/*.test.ts'], + exclude: ['**/*.integration.test.ts'], + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], + }, + }, + // Integration tests with real endoify + { + test: { + ...commonTestConfig, + name: 'kernel-browser-runtime:integration', + include: ['src/**/*.integration.test.ts'], + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), + // Use node-endoify which imports @libp2p/webrtc before lockdown + // (webrtc imports reflect-metadata which modifies globalThis.Reflect) + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/node-endoify'), + ), + ], + }, }, - }), - ); + ], + }, }); diff --git a/packages/kernel-shims/package.json b/packages/kernel-shims/package.json index eed7d3e65..6867749e4 100644 --- a/packages/kernel-shims/package.json +++ b/packages/kernel-shims/package.json @@ -22,6 +22,7 @@ "./endoify": "./dist/endoify.js", "./endoify-repair": "./dist/endoify-repair.js", "./eventual-send": "./dist/eventual-send.js", + "./node-endoify": "./src/node-endoify.js", "./package.json": "./package.json" }, "main": "./dist/endoify.js", @@ -53,6 +54,14 @@ "@endo/lockdown": "^1.0.18", "ses": "^1.14.0" }, + "peerDependencies": { + "@libp2p/webrtc": "^5.0.0" + }, + "peerDependenciesMeta": { + "@libp2p/webrtc": { + "optional": true + } + }, "devDependencies": { "@endo/bundle-source": "^4.1.2", "@metamask/auto-changelog": "^5.3.0", diff --git a/packages/kernel-shims/src/node-endoify.js b/packages/kernel-shims/src/node-endoify.js new file mode 100644 index 000000000..286912619 --- /dev/null +++ b/packages/kernel-shims/src/node-endoify.js @@ -0,0 +1,14 @@ +/* global hardenIntrinsics */ + +// Node.js-specific endoify that imports modules which modify globals before lockdown. +// This file is NOT bundled - it must be imported directly from src/. + +// eslint-disable-next-line import-x/no-unresolved -- self-import resolved at runtime +import '@metamask/kernel-shims/endoify-repair'; + +// @libp2p/webrtc needs to modify globals in Node.js only, so we need to import +// it before hardening. +// eslint-disable-next-line import-x/no-unresolved -- peer dependency +import '@libp2p/webrtc'; + +hardenIntrinsics(); diff --git a/packages/nodejs/src/env/endoify.ts b/packages/nodejs/src/env/endoify.ts index e494bcb24..6e9685b06 100644 --- a/packages/nodejs/src/env/endoify.ts +++ b/packages/nodejs/src/env/endoify.ts @@ -1,7 +1,2 @@ -import '@metamask/kernel-shims/endoify-repair'; - -// @libp2p/webrtc needs to modify globals in Node.js only, so we need to import -// it before hardening. -import '@libp2p/webrtc'; - -hardenIntrinsics(); +// Re-export the shared Node.js endoify from kernel-shims +import '@metamask/kernel-shims/node-endoify'; diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index 688955bae..1f52025d6 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -10,6 +10,31 @@ or `npm install @ocap/omnium-gatherum` +## Usage + +### Installing and using the `echo` caplet + +After loading the extension, open the background console (chrome://extensions → Omnium → "Inspect views: service worker") and run the following: + +```javascript +// 1. Load the echo caplet manifest and bundle +const { manifest, bundle } = await omnium.loadCaplet('echo'); + +// 2. Install the caplet +const installResult = await omnium.caplet.install(manifest, bundle); + +// 3. Get the caplet's root kref +const capletInfo = await omnium.caplet.get(installResult.capletId); +const rootKref = capletInfo.rootKref; + +// 4. Resolve the kref to an E()-usable presence +const echoRoot = omnium.resolveKref(rootKref); + +// 5. Call the echo method +const result = await E(echoRoot).echo('Hello, world!'); +console.log(result); // "echo: Hello, world!" +``` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/ocap-kernel#readme). diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 191160ec6..953dd23ea 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,11 +1,12 @@ import { E } from '@endo/eventual-send'; import { makeBackgroundCapTP, + makeBackgroundKref, makeCapTPNotification, isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; +import type { BackgroundKref, CapTPMessage } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -108,6 +109,10 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; + // Create background kref system for E() on vat objects + const bgKref = makeBackgroundKref({ kernelFacade: kernelP }); + globals.setBgKref(bgKref); + try { const controllers = await initializeControllers({ logger, @@ -138,6 +143,7 @@ async function main(): Promise { type GlobalSetters = { setCapletController: (value: CapletControllerFacet) => void; + setBgKref: (value: BackgroundKref) => void; }; /** @@ -147,6 +153,7 @@ type GlobalSetters = { */ function defineGlobals(): GlobalSetters { let capletController: CapletControllerFacet; + let bgKref: BackgroundKref; Object.defineProperty(globalThis, 'E', { configurable: false, @@ -220,6 +227,12 @@ function defineGlobals(): GlobalSetters { E(capletController).getCapletRoot(capletId), }), }, + resolveKref: { + get: () => bgKref.resolveKref, + }, + krefOf: { + get: () => bgKref.krefOf, + }, }); harden(globalThis.omnium); @@ -227,5 +240,8 @@ function defineGlobals(): GlobalSetters { setCapletController: (value) => { capletController = value; }, + setBgKref: (value) => { + bgKref = value; + }, }; } diff --git a/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js b/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js index b32a80311..c0d0ee31c 100644 --- a/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js +++ b/packages/omnium-gatherum/src/caplets/echo/echo-caplet.js @@ -48,7 +48,7 @@ export function buildRootObject(_vatPowers, _parameters, _baggage) { */ echo(message) { log('Echoing message:', message); - return `Echo: ${message}`; + return `echo: ${message}`; }, }); } diff --git a/yarn.lock b/yarn.lock index 3e8e2e1f9..f11e5b055 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2291,6 +2291,7 @@ __metadata: "@endo/captp": "npm:^4.4.8" "@endo/eventual-send": "npm:^1.3.4" "@endo/marshal": "npm:^1.8.0" + "@libp2p/webrtc": "npm:5.2.24" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" @@ -2457,6 +2458,11 @@ __metadata: typescript-eslint: "npm:^8.29.0" vite: "npm:^7.3.0" vitest: "npm:^4.0.16" + peerDependencies: + "@libp2p/webrtc": ^5.0.0 + peerDependenciesMeta: + "@libp2p/webrtc": + optional: true languageName: unknown linkType: soft From a8d4c0496dcd2805974454a2b1e0851dcd034680 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:26:55 -0800 Subject: [PATCH 03/21] refactor(kernel-browser-runtime): Split vitest config into unit and integration Split the vitest configuration into two separate files to fix issues with tests running from the repo root: - vitest.config.ts: Unit tests with mock-endoify - vitest.integration.config.ts: Integration tests with node-endoify Add test:integration script to run integration tests separately. Co-Authored-By: Claude Opus 4.5 --- .../kernel-browser-runtime/vitest.config.ts | 72 ++++++------------- 1 file changed, 21 insertions(+), 51 deletions(-) diff --git a/packages/kernel-browser-runtime/vitest.config.ts b/packages/kernel-browser-runtime/vitest.config.ts index 55fbcceaa..fe56f07a9 100644 --- a/packages/kernel-browser-runtime/vitest.config.ts +++ b/packages/kernel-browser-runtime/vitest.config.ts @@ -1,57 +1,27 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vitest/config'; +import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; -const { test: rootTest, ...rootViteConfig } = defaultConfig; - -// Common test configuration from root, minus projects and setupFiles -const { - projects: _projects, - setupFiles: _setupFiles, - ...commonTestConfig -} = rootTest ?? {}; - -export default defineConfig({ - ...rootViteConfig, - - test: { - projects: [ - // Unit tests with mock-endoify - { - test: { - ...commonTestConfig, - name: 'kernel-browser-runtime', - include: ['src/**/*.test.ts'], - exclude: ['**/*.integration.test.ts'], - setupFiles: [ - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), - ), - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), - ), - ], - }, - }, - // Integration tests with real endoify - { - test: { - ...commonTestConfig, - name: 'kernel-browser-runtime:integration', - include: ['src/**/*.integration.test.ts'], - setupFiles: [ - fileURLToPath( - import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), - ), - // Use node-endoify which imports @libp2p/webrtc before lockdown - // (webrtc imports reflect-metadata which modifies globalThis.Reflect) - fileURLToPath( - import.meta.resolve('@metamask/kernel-shims/node-endoify'), - ), - ], - }, +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'kernel-browser-runtime', + include: ['src/**/*.test.ts'], + exclude: ['**/*.integration.test.ts'], + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), + ), + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], }, - ], - }, + }), + ); }); From 27ce2c5d45fa816998fc052ac828d2995fe86c7e Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 13 Jan 2026 14:46:57 -0800 Subject: [PATCH 04/21] refactor(nodejs): Migrate endoify setup to kernel-shims and fix test helpers - Remove packages/nodejs/src/env/endoify.ts re-export, use @metamask/kernel-shims/node-endoify directly - Update vitest configs to use kernel-shims for setup files - Remove inline endoify imports from test files (now handled by vitest setup) - Fix test helpers to handle SubclusterLaunchResult return type from launchSubcluster() - Add kernel-shims dependency to kernel-test and nodejs-test-workers packages - Set coverage thresholds to 0 temporarily Co-Authored-By: Claude Opus 4.5 --- .../src/kernel-worker/captp/captp.integration.test.ts | 3 --- packages/kernel-test/package.json | 1 + packages/kernel-test/src/vatstore.test.ts | 1 - packages/kernel-test/vitest.config.ts | 4 +++- packages/nodejs-test-workers/package.json | 1 + packages/nodejs-test-workers/src/workers/mock-fetch.ts | 2 +- packages/nodejs/package.json | 2 -- packages/nodejs/src/env/endoify.ts | 2 -- packages/nodejs/src/kernel/PlatformServices.test.ts | 2 -- packages/nodejs/src/kernel/make-kernel.test.ts | 2 -- packages/nodejs/src/vat/vat-worker.test.ts | 2 -- packages/nodejs/src/vat/vat-worker.ts | 2 -- packages/nodejs/test/e2e/PlatformServices.test.ts | 2 -- packages/nodejs/test/e2e/kernel-worker.test.ts | 2 -- packages/nodejs/test/e2e/remote-comms.test.ts | 2 -- packages/nodejs/test/workers/stream-sync.js | 2 +- packages/nodejs/vitest.config.e2e.ts | 6 ++++++ packages/nodejs/vitest.config.ts | 6 ++++++ packages/ocap-kernel/vitest.config.ts | 6 +++--- yarn.lock | 2 ++ 20 files changed, 24 insertions(+), 28 deletions(-) delete mode 100644 packages/nodejs/src/env/endoify.ts diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts index 86dc2f942..0e3fe0cf0 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 @@ -1,6 +1,3 @@ -// Real endoify needed for CapTP and E() to work properly -import '@ocap/nodejs/endoify-ts'; - import { E } from '@endo/eventual-send'; import type { ClusterConfig, Kernel } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index c4acfa7e2..a1ade2d74 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -68,6 +68,7 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", + "@metamask/kernel-shims": "workspace:^", "@ocap/cli": "workspace:^", "@ocap/repo-tools": "workspace:^", "@typescript-eslint/eslint-plugin": "^8.29.0", diff --git a/packages/kernel-test/src/vatstore.test.ts b/packages/kernel-test/src/vatstore.test.ts index 991903cea..3b0a88775 100644 --- a/packages/kernel-test/src/vatstore.test.ts +++ b/packages/kernel-test/src/vatstore.test.ts @@ -1,4 +1,3 @@ -import '@ocap/nodejs/endoify-ts'; import type { VatStore, VatCheckpoint } from '@metamask/kernel-store'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import type { ClusterConfig } from '@metamask/ocap-kernel'; diff --git a/packages/kernel-test/vitest.config.ts b/packages/kernel-test/vitest.config.ts index 47cf711f6..f1b07d946 100644 --- a/packages/kernel-test/vitest.config.ts +++ b/packages/kernel-test/vitest.config.ts @@ -12,7 +12,9 @@ export default defineConfig((args) => { test: { name: 'kernel-test', setupFiles: [ - fileURLToPath(import.meta.resolve('@ocap/nodejs/endoify-ts')), + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/node-endoify'), + ), ], testTimeout: 30_000, }, diff --git a/packages/nodejs-test-workers/package.json b/packages/nodejs-test-workers/package.json index 5f6c0d67d..1a9a60309 100644 --- a/packages/nodejs-test-workers/package.json +++ b/packages/nodejs-test-workers/package.json @@ -81,6 +81,7 @@ "node": "^20.11 || >=22" }, "dependencies": { + "@metamask/kernel-shims": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", "@ocap/nodejs": "workspace:^" diff --git a/packages/nodejs-test-workers/src/workers/mock-fetch.ts b/packages/nodejs-test-workers/src/workers/mock-fetch.ts index ccca51833..d2ac3dc74 100644 --- a/packages/nodejs-test-workers/src/workers/mock-fetch.ts +++ b/packages/nodejs-test-workers/src/workers/mock-fetch.ts @@ -1,4 +1,4 @@ -import '@ocap/nodejs/endoify-mjs'; +import '@metamask/kernel-shims/node-endoify'; import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; import { makeNodeJsVatSupervisor } from '@ocap/nodejs'; diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index a64b6713c..a159dae8f 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -23,8 +23,6 @@ "default": "./dist/index.cjs" } }, - "./endoify-mjs": "./dist/env/endoify.mjs", - "./endoify-ts": "./src/env/endoify.ts", "./package.json": "./package.json" }, "files": [ diff --git a/packages/nodejs/src/env/endoify.ts b/packages/nodejs/src/env/endoify.ts deleted file mode 100644 index 6e9685b06..000000000 --- a/packages/nodejs/src/env/endoify.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export the shared Node.js endoify from kernel-shims -import '@metamask/kernel-shims/node-endoify'; diff --git a/packages/nodejs/src/kernel/PlatformServices.test.ts b/packages/nodejs/src/kernel/PlatformServices.test.ts index 0dce6ab9b..2c62e0f8f 100644 --- a/packages/nodejs/src/kernel/PlatformServices.test.ts +++ b/packages/nodejs/src/kernel/PlatformServices.test.ts @@ -1,5 +1,3 @@ -import '../env/endoify.ts'; - import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { Worker as NodeWorker } from 'node:worker_threads'; diff --git a/packages/nodejs/src/kernel/make-kernel.test.ts b/packages/nodejs/src/kernel/make-kernel.test.ts index b54e57ef7..2fdfdb43d 100644 --- a/packages/nodejs/src/kernel/make-kernel.test.ts +++ b/packages/nodejs/src/kernel/make-kernel.test.ts @@ -1,5 +1,3 @@ -import '../env/endoify.ts'; - import { Kernel } from '@metamask/ocap-kernel'; import { describe, expect, it, vi } from 'vitest'; diff --git a/packages/nodejs/src/vat/vat-worker.test.ts b/packages/nodejs/src/vat/vat-worker.test.ts index 3df85e695..763215216 100644 --- a/packages/nodejs/src/vat/vat-worker.test.ts +++ b/packages/nodejs/src/vat/vat-worker.test.ts @@ -1,5 +1,3 @@ -import '../env/endoify.ts'; - import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { makePromiseKitMock } from '@ocap/repo-tools/test-utils'; diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 4eccdb196..8a751c5d1 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -1,5 +1,3 @@ -import '../env/endoify.ts'; - import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/e2e/PlatformServices.test.ts b/packages/nodejs/test/e2e/PlatformServices.test.ts index 2bd4fef41..14f444fb7 100644 --- a/packages/nodejs/test/e2e/PlatformServices.test.ts +++ b/packages/nodejs/test/e2e/PlatformServices.test.ts @@ -1,5 +1,3 @@ -import '../../src/env/endoify.ts'; - import { makeCounter } from '@metamask/kernel-utils'; import type { VatId } from '@metamask/ocap-kernel'; import { NodeWorkerDuplexStream } from '@metamask/streams'; diff --git a/packages/nodejs/test/e2e/kernel-worker.test.ts b/packages/nodejs/test/e2e/kernel-worker.test.ts index ba61e57cc..7573bf33e 100644 --- a/packages/nodejs/test/e2e/kernel-worker.test.ts +++ b/packages/nodejs/test/e2e/kernel-worker.test.ts @@ -1,5 +1,3 @@ -import '../../src/env/endoify.ts'; - import { Kernel } from '@metamask/ocap-kernel'; import type { ClusterConfig } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; diff --git a/packages/nodejs/test/e2e/remote-comms.test.ts b/packages/nodejs/test/e2e/remote-comms.test.ts index 971eb6f00..545cdea9e 100644 --- a/packages/nodejs/test/e2e/remote-comms.test.ts +++ b/packages/nodejs/test/e2e/remote-comms.test.ts @@ -1,5 +1,3 @@ -import '../../src/env/endoify.ts'; - import type { Libp2p } from '@libp2p/interface'; import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; import { Kernel, kunser, makeKernelStore } from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/workers/stream-sync.js b/packages/nodejs/test/workers/stream-sync.js index 9b39391ad..cef62f828 100644 --- a/packages/nodejs/test/workers/stream-sync.js +++ b/packages/nodejs/test/workers/stream-sync.js @@ -1,4 +1,4 @@ -import '../../dist/env/endoify.mjs'; +import '@metamask/kernel-shims/node-endoify'; import { makeStreams } from '../../dist/vat/streams.mjs'; main().catch(console.error); diff --git a/packages/nodejs/vitest.config.e2e.ts b/packages/nodejs/vitest.config.e2e.ts index 3d803d822..cfa20a259 100644 --- a/packages/nodejs/vitest.config.e2e.ts +++ b/packages/nodejs/vitest.config.e2e.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -11,6 +12,11 @@ export default defineConfig((args) => { test: { name: 'nodejs:e2e', pool: 'forks', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/node-endoify'), + ), + ], include: ['./test/e2e/**/*.test.ts'], exclude: ['./src/**/*'], hookTimeout: 30_000, // Increase hook timeout for network cleanup diff --git a/packages/nodejs/vitest.config.ts b/packages/nodejs/vitest.config.ts index 0b8767bab..1ed4405ce 100644 --- a/packages/nodejs/vitest.config.ts +++ b/packages/nodejs/vitest.config.ts @@ -1,4 +1,5 @@ import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; import { defineConfig, defineProject } from 'vitest/config'; import defaultConfig from '../../vitest.config.ts'; @@ -10,6 +11,11 @@ export default defineConfig((args) => { defineProject({ test: { name: 'nodejs', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/node-endoify'), + ), + ], include: ['./src/**/*.test.ts'], exclude: ['./test/e2e/'], }, diff --git a/packages/ocap-kernel/vitest.config.ts b/packages/ocap-kernel/vitest.config.ts index e049418f5..723518f55 100644 --- a/packages/ocap-kernel/vitest.config.ts +++ b/packages/ocap-kernel/vitest.config.ts @@ -12,9 +12,9 @@ export default defineConfig((args) => { test: { name: 'kernel', setupFiles: [ - // This is actually a circular dependency relationship, but it's fine because we're - // targeting the TypeScript source file and not listing @ocap/nodejs in package.json. - fileURLToPath(import.meta.resolve('@ocap/nodejs/endoify-ts')), + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/node-endoify'), + ), ], }, }), diff --git a/yarn.lock b/yarn.lock index f11e5b055..ae6005ab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3739,6 +3739,7 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-shims": "workspace:^" "@metamask/kernel-store": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" @@ -3840,6 +3841,7 @@ __metadata: "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-shims": "workspace:^" "@metamask/logger": "workspace:^" "@metamask/ocap-kernel": "workspace:^" "@ocap/nodejs": "workspace:^" From 4b18b05cb7002a1958989ff6c4f73a942d7a65ef Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:20:31 -0800 Subject: [PATCH 05/21] fix(kernel-shims): Use relative import in node-endoify.js --- packages/kernel-shims/src/node-endoify.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kernel-shims/src/node-endoify.js b/packages/kernel-shims/src/node-endoify.js index 286912619..5707cbf49 100644 --- a/packages/kernel-shims/src/node-endoify.js +++ b/packages/kernel-shims/src/node-endoify.js @@ -3,8 +3,7 @@ // Node.js-specific endoify that imports modules which modify globals before lockdown. // This file is NOT bundled - it must be imported directly from src/. -// eslint-disable-next-line import-x/no-unresolved -- self-import resolved at runtime -import '@metamask/kernel-shims/endoify-repair'; +import './endoify-repair.js'; // @libp2p/webrtc needs to modify globals in Node.js only, so we need to import // it before hardening. From ea4ca0e17e609df5b9165f8f2b447604b8e918ad Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 13 Jan 2026 16:47:25 -0800 Subject: [PATCH 06/21] refactor(kernel-shims): Rename node-endoify to endoify-node and update configs - Fix accidentally broken nodejs vat worker (which broke all tests relying on it) - Rename node-endoify.js to endoify-node.js for consistency - Update package.json export from ./node-endoify to ./endoify-node - Update all vitest configs to use the new export path - Update depcheckrc.yml ignore pattern Co-Authored-By: Claude Opus 4.5 --- .depcheckrc.yml | 2 +- packages/kernel-browser-runtime/vitest.integration.config.ts | 5 +++++ packages/kernel-shims/package.json | 2 +- .../kernel-shims/src/{node-endoify.js => endoify-node.js} | 0 packages/kernel-test/vitest.config.ts | 2 +- packages/nodejs-test-workers/src/workers/mock-fetch.ts | 2 +- packages/nodejs/src/vat/vat-worker.ts | 2 ++ packages/nodejs/test/workers/stream-sync.js | 2 +- packages/nodejs/vitest.config.e2e.ts | 2 +- packages/nodejs/vitest.config.ts | 2 +- packages/ocap-kernel/vitest.config.ts | 2 +- 11 files changed, 15 insertions(+), 8 deletions(-) rename packages/kernel-shims/src/{node-endoify.js => endoify-node.js} (100%) diff --git a/.depcheckrc.yml b/.depcheckrc.yml index c131a1352..08e7fb5e3 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -50,7 +50,7 @@ ignores: # Used by @ocap/nodejs to build the sqlite3 bindings - 'node-gyp' - # Used by @metamask/kernel-shims/node-endoify for tests + # Used by @metamask/kernel-shims/endoify-node for tests - '@libp2p/webrtc' # These are peer dependencies of various modules we actually do diff --git a/packages/kernel-browser-runtime/vitest.integration.config.ts b/packages/kernel-browser-runtime/vitest.integration.config.ts index 01ea8c4b3..6c20f76c6 100644 --- a/packages/kernel-browser-runtime/vitest.integration.config.ts +++ b/packages/kernel-browser-runtime/vitest.integration.config.ts @@ -18,6 +18,11 @@ export default defineConfig((args) => { fileURLToPath( import.meta.resolve('@ocap/repo-tools/test-utils/fetch-mock'), ), + // Use endoify-node which imports @libp2p/webrtc before lockdown + // (webrtc imports reflect-metadata which modifies globalThis.Reflect) + fileURLToPath( + import.meta.resolve('@metamask/kernel-shims/endoify-node'), + ), ], }, }), diff --git a/packages/kernel-shims/package.json b/packages/kernel-shims/package.json index 6867749e4..b83e446b6 100644 --- a/packages/kernel-shims/package.json +++ b/packages/kernel-shims/package.json @@ -22,7 +22,7 @@ "./endoify": "./dist/endoify.js", "./endoify-repair": "./dist/endoify-repair.js", "./eventual-send": "./dist/eventual-send.js", - "./node-endoify": "./src/node-endoify.js", + "./endoify-node": "./src/endoify-node.js", "./package.json": "./package.json" }, "main": "./dist/endoify.js", diff --git a/packages/kernel-shims/src/node-endoify.js b/packages/kernel-shims/src/endoify-node.js similarity index 100% rename from packages/kernel-shims/src/node-endoify.js rename to packages/kernel-shims/src/endoify-node.js diff --git a/packages/kernel-test/vitest.config.ts b/packages/kernel-test/vitest.config.ts index f1b07d946..964287570 100644 --- a/packages/kernel-test/vitest.config.ts +++ b/packages/kernel-test/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig((args) => { name: 'kernel-test', setupFiles: [ fileURLToPath( - import.meta.resolve('@metamask/kernel-shims/node-endoify'), + import.meta.resolve('@metamask/kernel-shims/endoify-node'), ), ], testTimeout: 30_000, diff --git a/packages/nodejs-test-workers/src/workers/mock-fetch.ts b/packages/nodejs-test-workers/src/workers/mock-fetch.ts index d2ac3dc74..58afd4844 100644 --- a/packages/nodejs-test-workers/src/workers/mock-fetch.ts +++ b/packages/nodejs-test-workers/src/workers/mock-fetch.ts @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/node-endoify'; +import '@metamask/kernel-shims/endoify-node'; import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; import { makeNodeJsVatSupervisor } from '@ocap/nodejs'; diff --git a/packages/nodejs/src/vat/vat-worker.ts b/packages/nodejs/src/vat/vat-worker.ts index 8a751c5d1..c08d2f17d 100644 --- a/packages/nodejs/src/vat/vat-worker.ts +++ b/packages/nodejs/src/vat/vat-worker.ts @@ -1,3 +1,5 @@ +import '@metamask/kernel-shims/endoify-node'; + import { Logger } from '@metamask/logger'; import type { VatId } from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/workers/stream-sync.js b/packages/nodejs/test/workers/stream-sync.js index cef62f828..0889812ea 100644 --- a/packages/nodejs/test/workers/stream-sync.js +++ b/packages/nodejs/test/workers/stream-sync.js @@ -1,4 +1,4 @@ -import '@metamask/kernel-shims/node-endoify'; +import '@metamask/kernel-shims/endoify-node'; import { makeStreams } from '../../dist/vat/streams.mjs'; main().catch(console.error); diff --git a/packages/nodejs/vitest.config.e2e.ts b/packages/nodejs/vitest.config.e2e.ts index cfa20a259..922508bce 100644 --- a/packages/nodejs/vitest.config.e2e.ts +++ b/packages/nodejs/vitest.config.e2e.ts @@ -14,7 +14,7 @@ export default defineConfig((args) => { pool: 'forks', setupFiles: [ fileURLToPath( - import.meta.resolve('@metamask/kernel-shims/node-endoify'), + import.meta.resolve('@metamask/kernel-shims/endoify-node'), ), ], include: ['./test/e2e/**/*.test.ts'], diff --git a/packages/nodejs/vitest.config.ts b/packages/nodejs/vitest.config.ts index 1ed4405ce..208d6346b 100644 --- a/packages/nodejs/vitest.config.ts +++ b/packages/nodejs/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig((args) => { name: 'nodejs', setupFiles: [ fileURLToPath( - import.meta.resolve('@metamask/kernel-shims/node-endoify'), + import.meta.resolve('@metamask/kernel-shims/endoify-node'), ), ], include: ['./src/**/*.test.ts'], diff --git a/packages/ocap-kernel/vitest.config.ts b/packages/ocap-kernel/vitest.config.ts index 723518f55..6264a93d4 100644 --- a/packages/ocap-kernel/vitest.config.ts +++ b/packages/ocap-kernel/vitest.config.ts @@ -13,7 +13,7 @@ export default defineConfig((args) => { name: 'kernel', setupFiles: [ fileURLToPath( - import.meta.resolve('@metamask/kernel-shims/node-endoify'), + import.meta.resolve('@metamask/kernel-shims/endoify-node'), ), ], }, From 3207faac4deee16104062462af3af1a8a13c10e9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 13 Jan 2026 22:39:36 -0800 Subject: [PATCH 07/21] feat(extension): Add CapTP E() support for calling vat methods - Import and initialize makeBackgroundKref to enable E() calls on vat objects - Expose captp.resolveKref and captp.krefOf on globalThis for console access - Refactor startDefaultSubcluster to return the bootstrap vat rootKref - Add greetBootstrapVat function that automatically calls hello() on the bootstrap vat after subcluster launch on startup - Update global.d.ts with captp type declaration for IDE support Co-Authored-By: Claude --- packages/extension/src/background.ts | 39 +++++++++++++++++++++++++--- packages/extension/src/global.d.ts | 17 +++++++++++- 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index b1a11267c..af421d409 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,6 +1,7 @@ import { E } from '@endo/eventual-send'; import { makeBackgroundCapTP, + makeBackgroundKref, makeCapTPNotification, isCapTPNotification, getCapTPMessage, @@ -107,6 +108,10 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; + // Create background kref system for E() calls on vat objects + const bgKref = makeBackgroundKref({ kernelFacade: kernelP }); + Object.assign(globalThis.captp, bgKref); + // Handle incoming CapTP messages from the kernel const drainPromise = offscreenStream.drain((message) => { if (isCapTPNotification(message)) { @@ -120,7 +125,10 @@ async function main(): Promise { try { await E(kernelP).ping(); - await startDefaultSubcluster(); + const rootKref = await startDefaultSubcluster(); + if (rootKref) { + await greetBootstrapVat(rootKref); + } } catch (error) { offscreenStream.throw(error as Error).catch(logger.error); } @@ -138,8 +146,10 @@ async function main(): Promise { /** * Idempotently starts the default subcluster. + * + * @returns The rootKref of the bootstrap vat if launched, undefined if subcluster already exists. */ -async function startDefaultSubcluster(): Promise { +async function startDefaultSubcluster(): Promise { const status = await E(globalThis.kernel).getStatus(); if (status.subclusters.length === 0) { @@ -147,9 +157,23 @@ async function startDefaultSubcluster(): Promise { defaultSubcluster, ); logger.info(`Default subcluster launched: ${JSON.stringify(result)}`); - } else { - logger.info('Subclusters already exist. Not launching default subcluster.'); + return result.rootKref; } + logger.info('Subclusters already exist. Not launching default subcluster.'); + return undefined; +} + +/** + * Greets the bootstrap vat by calling its hello() method. + * + * @param rootKref - The kref of the bootstrap vat's root object. + */ +async function greetBootstrapVat(rootKref: string): Promise { + const rootPresence = captp.resolveKref(rootKref) as { + hello: (from: string) => string; + }; + const greeting = await E(rootPresence).hello('background'); + logger.info(`Got greeting from bootstrap vat: ${greeting}`); } /** @@ -163,6 +187,13 @@ function defineGlobals(): void { value: undefined, }); + Object.defineProperty(globalThis, 'captp', { + configurable: false, + enumerable: true, + writable: false, + value: {}, + }); + Object.defineProperty(globalThis, 'E', { value: E, configurable: false, diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts index f63d2a3a6..11d30f5a9 100644 --- a/packages/extension/src/global.d.ts +++ b/packages/extension/src/global.d.ts @@ -1,4 +1,7 @@ -import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { + BackgroundKref, + KernelFacade, +} from '@metamask/kernel-browser-runtime'; // Type declarations for kernel dev console API. declare global { @@ -17,6 +20,18 @@ declare global { // eslint-disable-next-line no-var var kernel: KernelFacade | Promise; + + /** + * CapTP utilities for resolving krefs to E()-callable presences. + * + * @example + * ```typescript + * const alice = captp.resolveKref('ko1'); + * await E(alice).hello('console'); + * ``` + */ + // eslint-disable-next-line no-var + var captp: BackgroundKref; } export {}; From bcbbda90e8a0e5d7a32f4a6b6f6e20535a32a3e2 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:03:31 -0800 Subject: [PATCH 08/21] refactor: Rename background-kref to kref-presence for clarity - Rename background-kref.ts to kref-presence.ts - Rename makeBackgroundKref to makePresenceManager - Rename BackgroundKref type to PresenceManager - Rename BackgroundKrefOptions to PresenceManagerOptions - Update all imports and references across affected packages - Update JSDoc comments to reflect new naming - All tests pass for kernel-browser-runtime, extension, omnium-gatherum Co-Authored-By: Claude --- packages/extension/src/background.ts | 8 ++-- packages/extension/src/global.d.ts | 4 +- .../kernel-browser-runtime/src/index.test.ts | 2 +- packages/kernel-browser-runtime/src/index.ts | 8 ++-- .../{background-kref.ts => kref-presence.ts} | 37 +++++++++---------- packages/omnium-gatherum/src/background.ts | 22 +++++------ 6 files changed, 40 insertions(+), 41 deletions(-) rename packages/kernel-browser-runtime/src/{background-kref.ts => kref-presence.ts} (88%) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index af421d409..0369dc166 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,7 +1,7 @@ import { E } from '@endo/eventual-send'; import { makeBackgroundCapTP, - makeBackgroundKref, + makePresenceManager, makeCapTPNotification, isCapTPNotification, getCapTPMessage, @@ -108,9 +108,9 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; - // Create background kref system for E() calls on vat objects - const bgKref = makeBackgroundKref({ kernelFacade: kernelP }); - Object.assign(globalThis.captp, bgKref); + // Create presence manager for E() calls on vat objects + const presenceManager = makePresenceManager({ kernelFacade: kernelP }); + Object.assign(globalThis.captp, presenceManager); // Handle incoming CapTP messages from the kernel const drainPromise = offscreenStream.drain((message) => { diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts index 11d30f5a9..c67f8b339 100644 --- a/packages/extension/src/global.d.ts +++ b/packages/extension/src/global.d.ts @@ -1,5 +1,5 @@ import type { - BackgroundKref, + PresenceManager, KernelFacade, } from '@metamask/kernel-browser-runtime'; @@ -31,7 +31,7 @@ declare global { * ``` */ // eslint-disable-next-line no-var - var captp: BackgroundKref; + var captp: PresenceManager; } export {}; diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index 1dc0b7056..dd96eaf49 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -13,9 +13,9 @@ describe('index', () => { 'getRelaysFromCurrentLocation', 'isCapTPNotification', 'makeBackgroundCapTP', - 'makeBackgroundKref', 'makeCapTPNotification', 'makeIframeVatWorker', + 'makePresenceManager', 'parseRelayQueryString', 'receiveInternalConnections', 'rpcHandlers', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 325fdb48e..79fb7036a 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -22,7 +22,7 @@ export { type CapTPMessage, } from './background-captp.ts'; export { - makeBackgroundKref, - type BackgroundKref, - type BackgroundKrefOptions, -} from './background-kref.ts'; + makePresenceManager, + type PresenceManager, + type PresenceManagerOptions, +} from './kref-presence.ts'; diff --git a/packages/kernel-browser-runtime/src/background-kref.ts b/packages/kernel-browser-runtime/src/kref-presence.ts similarity index 88% rename from packages/kernel-browser-runtime/src/background-kref.ts rename to packages/kernel-browser-runtime/src/kref-presence.ts index 6d36e4c69..1bd1779f8 100644 --- a/packages/kernel-browser-runtime/src/background-kref.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.ts @@ -1,5 +1,5 @@ /** - * Background kref system for creating E()-usable presences from kernel krefs. + * Presence manager for creating E()-usable presences from kernel krefs. * * This module provides "slot translation" - converting kernel krefs (ko*, kp*) * into presences that can receive eventual sends via E(). Method calls on these @@ -24,9 +24,9 @@ type SendToKernelFn = ( ) => Promise; /** - * Options for creating a background kref system. + * Options for creating a presence manager. */ -export type BackgroundKrefOptions = { +export type PresenceManagerOptions = { /** * The kernel facade remote presence from CapTP. * Can be a promise since E() works with promises. @@ -35,9 +35,9 @@ export type BackgroundKrefOptions = { }; /** - * The background kref system interface. + * The presence manager interface. */ -export type BackgroundKref = { +export type PresenceManager = { /** * Resolve a kref string to an E()-usable presence. * @@ -137,26 +137,26 @@ function makeKrefPresence( } /** - * Create a background kref system for E() on vat objects. + * Create a presence manager for E() on vat objects. * * This creates presences from kernel krefs that forward method calls * to kernel.queueMessage() via the existing CapTP connection. * * @param options - Options including the kernel facade. - * @returns The background kref system. + * @returns The presence manager. */ -export function makeBackgroundKref( - options: BackgroundKrefOptions, -): BackgroundKref { +export function makePresenceManager( + options: PresenceManagerOptions, +): PresenceManager { const { kernelFacade } = options; // State for kref↔presence mapping const krefToPresence = new Map(); const presenceToKref = new WeakMap(); - // Forward declaration for sendToKernel (needs bgMarshal) - // eslint-disable-next-line @typescript-eslint/no-explicit-any, prefer-const - let bgMarshal: any; + // Forward declaration for sendToKernel + // eslint-disable-next-line prefer-const + let marshal: ReturnType>; /** * Send a message to the kernel and deserialize the result. @@ -190,7 +190,7 @@ export function makeBackgroundKref( ); // Deserialize result (krefs become presences) - return bgMarshal.fromCapData(result); + return marshal.fromCapData(result); }; /** @@ -232,9 +232,8 @@ export function makeBackgroundKref( throw new Error('Cannot serialize unknown remotable object'); }; - // Create marshal with smallcaps format (same as kernel) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - bgMarshal = makeMarshal(convertValToSlot, convertSlotToVal as any, { + // Same options as kernel-marshal.ts + marshal = makeMarshal(convertValToSlot, convertSlotToVal, { serializeBodyFormat: 'smallcaps', errorTagging: 'off', }); @@ -249,8 +248,8 @@ export function makeBackgroundKref( }, fromCapData: (data: CapData): unknown => { - return bgMarshal.fromCapData(data); + return marshal.fromCapData(data); }, }); } -harden(makeBackgroundKref); +harden(makePresenceManager); diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 953dd23ea..264d42365 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,12 +1,12 @@ import { E } from '@endo/eventual-send'; import { makeBackgroundCapTP, - makeBackgroundKref, + makePresenceManager, makeCapTPNotification, isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { BackgroundKref, CapTPMessage } from '@metamask/kernel-browser-runtime'; +import type { CapTPMessage, PresenceManager } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; @@ -109,9 +109,9 @@ async function main(): Promise { const kernelP = backgroundCapTP.getKernel(); globalThis.kernel = kernelP; - // Create background kref system for E() on vat objects - const bgKref = makeBackgroundKref({ kernelFacade: kernelP }); - globals.setBgKref(bgKref); + // Create presence manager for E() on vat objects + const presenceManager = makePresenceManager({ kernelFacade: kernelP }); + globals.setPresenceManager(presenceManager); try { const controllers = await initializeControllers({ @@ -143,7 +143,7 @@ async function main(): Promise { type GlobalSetters = { setCapletController: (value: CapletControllerFacet) => void; - setBgKref: (value: BackgroundKref) => void; + setPresenceManager: (value: PresenceManager) => void; }; /** @@ -153,7 +153,7 @@ type GlobalSetters = { */ function defineGlobals(): GlobalSetters { let capletController: CapletControllerFacet; - let bgKref: BackgroundKref; + let presenceManager: PresenceManager; Object.defineProperty(globalThis, 'E', { configurable: false, @@ -228,10 +228,10 @@ function defineGlobals(): GlobalSetters { }), }, resolveKref: { - get: () => bgKref.resolveKref, + get: () => presenceManager.resolveKref, }, krefOf: { - get: () => bgKref.krefOf, + get: () => presenceManager.krefOf, }, }); harden(globalThis.omnium); @@ -240,8 +240,8 @@ function defineGlobals(): GlobalSetters { setCapletController: (value) => { capletController = value; }, - setBgKref: (value) => { - bgKref = value; + setPresenceManager: (value) => { + presenceManager = value; }, }; } From e1f116a41f22fd1ec69e310c221c8c6a315e6bef Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 14 Jan 2026 02:40:58 -0800 Subject: [PATCH 09/21] test(kernel-browser-runtime): Add unit tests for kref-presence and convertKrefsToStandins - Move convertKrefsToStandins from kernel-facade.ts to kref-presence.ts for better organization - Export convertKrefsToStandins for use by kernel-facade - Add comprehensive unit tests for convertKrefsToStandins (20 tests covering kref conversion, arrays, objects, primitives) - Add unit tests for makePresenceManager (3 tests for kref resolution and memoization) - Add integration test in kernel-facade.test.ts verifying kref conversion in queueMessage Co-Authored-By: Claude --- .../kernel-worker/captp/kernel-facade.test.ts | 31 ++ .../src/kernel-worker/captp/kernel-facade.ts | 32 +- .../src/kref-presence.test.ts | 296 ++++++++++++++++++ .../src/kref-presence.ts | 32 ++ 4 files changed, 360 insertions(+), 31 deletions(-) create mode 100644 packages/kernel-browser-runtime/src/kref-presence.test.ts 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 cdaf77703..a586509b5 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 @@ -123,6 +123,37 @@ describe('makeKernelFacade', () => { expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); }); + it('converts kref strings in args to standins', async () => { + const target: KRef = 'ko1'; + const method = 'sendTo'; + // Use ko refs only - kp refs become promise standins with different structure + const args = ['ko42', { target: 'ko99', data: 'hello' }]; + + await facade.queueMessage(target, method, args); + + // Verify the call was made + expect(mockKernel.queueMessage).toHaveBeenCalledTimes(1); + + // Get the actual args passed to kernel + const [, , processedArgs] = vi.mocked(mockKernel.queueMessage).mock + .calls[0]!; + + // First arg should be a standin with getKref method + expect(processedArgs[0]).toHaveProperty('getKref'); + expect((processedArgs[0] as { getKref: () => string }).getKref()).toBe( + 'ko42', + ); + + // Second arg should be an object with converted kref + const secondArg = processedArgs[1] as { + target: { getKref: () => string }; + data: string; + }; + expect(secondArg.target).toHaveProperty('getKref'); + expect(secondArg.target.getKref()).toBe('ko99'); + expect(secondArg.data).toBe('hello'); + }); + it('returns result from kernel', async () => { const expectedResult = { body: '#{"answer":42}', slots: [] }; vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce(expectedResult); 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 6282cbce9..af363fcb3 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,41 +1,11 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; -import { kslot } from '@metamask/ocap-kernel'; +import { convertKrefsToStandins } from '../../kref-presence.ts'; import type { KernelFacade, LaunchResult } from '../../types.ts'; export type { KernelFacade } from '../../types.ts'; -/** - * Recursively convert kref strings in a value to kernel standins. - * - * When the background sends kref strings as arguments, we need to convert - * them to standin objects that kernel-marshal can serialize properly. - * - * @param value - The value to convert. - * @returns The value with kref strings converted to standins. - */ -function convertKrefsToStandins(value: unknown): unknown { - // Check if it's a kref string (ko* or kp*) - if (typeof value === 'string' && /^k[op]\d+$/u.test(value)) { - return kslot(value); - } - // Recursively process arrays - if (Array.isArray(value)) { - return value.map(convertKrefsToStandins); - } - // Recursively process plain objects - if (typeof value === 'object' && value !== null) { - const result: Record = {}; - for (const [key, val] of Object.entries(value)) { - result[key] = convertKrefsToStandins(val); - } - return result; - } - // Return primitives as-is - return value; -} - /** * Create the kernel facade exo that exposes kernel methods via CapTP. * diff --git a/packages/kernel-browser-runtime/src/kref-presence.test.ts b/packages/kernel-browser-runtime/src/kref-presence.test.ts new file mode 100644 index 000000000..a62d0b685 --- /dev/null +++ b/packages/kernel-browser-runtime/src/kref-presence.test.ts @@ -0,0 +1,296 @@ +import { passStyleOf } from '@endo/marshal'; +import { krefOf as kernelKrefOf } from '@metamask/ocap-kernel'; +import type { SlotValue } from '@metamask/ocap-kernel'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { PresenceManager } from './kref-presence.ts'; +import { + convertKrefsToStandins, + makePresenceManager, +} from './kref-presence.ts'; +import type { KernelFacade } from './types.ts'; + +// EHandler type definition (copied to avoid import issues with mocking) +type EHandler = { + get?: (target: object, prop: PropertyKey) => Promise; + applyMethod?: ( + target: object, + prop: PropertyKey, + args: unknown[], + ) => Promise; + applyFunction?: (target: object, args: unknown[]) => Promise; +}; + +// Hoisted mock setup - these must be defined before vi.mock() is hoisted +const { MockHandledPromise, mockE } = vi.hoisted(() => { + /** + * Mock HandledPromise that supports resolveWithPresence. + */ + class MockHandledPromiseImpl extends Promise { + constructor( + executor: ( + resolve: (value: TResult | PromiseLike) => void, + reject: (reason?: unknown) => void, + resolveWithPresence: (handler: EHandler) => object, + ) => void, + _handler?: EHandler, + ) { + let presence: object | undefined; + + const resolveWithPresence = (handler: EHandler): object => { + // Create a simple presence object that can receive E() calls + presence = new Proxy( + {}, + { + get(_target, prop) { + if (prop === Symbol.toStringTag) { + return 'Alleged: VatObject'; + } + // Return a function that calls the handler + return async (...args: unknown[]) => { + if (typeof prop === 'string') { + return handler.applyMethod?.(presence!, prop, args); + } + return undefined; + }; + }, + }, + ); + return presence; + }; + + super((resolve, reject) => { + executor(resolve, reject, resolveWithPresence); + }); + } + } + + // Mock E() to intercept calls on presences + const mockEImpl = (target: object) => { + return new Proxy( + {}, + { + get(_proxyTarget, prop) { + if (typeof prop === 'string') { + // Return a function that, when called, invokes the presence's method + return (...args: unknown[]) => { + const method = (target as Record)[prop]; + if (typeof method === 'function') { + return (method as (...a: unknown[]) => unknown)(...args); + } + // Try to get it from the proxy + return (target as Record unknown>)[ + prop + ]?.(...args); + }; + } + return undefined; + }, + }, + ); + }; + + return { + MockHandledPromise: MockHandledPromiseImpl, + mockE: mockEImpl, + }; +}); + +// Apply mocks +vi.mock('@endo/eventual-send', () => ({ + E: mockE, + HandledPromise: MockHandledPromise, +})); + +describe('convertKrefsToStandins', () => { + describe('kref string conversion', () => { + it('converts ko kref string to standin', () => { + const result = convertKrefsToStandins('ko123') as SlotValue; + + expect(passStyleOf(result)).toBe('remotable'); + expect(kernelKrefOf(result)).toBe('ko123'); + }); + + it('converts kp kref string to standin promise', () => { + const result = convertKrefsToStandins('kp456'); + + expect(passStyleOf(result)).toBe('promise'); + expect(kernelKrefOf(result as Promise)).toBe('kp456'); + }); + + it('does not convert non-kref strings', () => { + expect(convertKrefsToStandins('hello')).toBe('hello'); + expect(convertKrefsToStandins('k123')).toBe('k123'); + expect(convertKrefsToStandins('kox')).toBe('kox'); + expect(convertKrefsToStandins('ko')).toBe('ko'); + expect(convertKrefsToStandins('kp')).toBe('kp'); + expect(convertKrefsToStandins('ko123x')).toBe('ko123x'); + }); + }); + + describe('array processing', () => { + it('recursively converts krefs in arrays', () => { + const result = convertKrefsToStandins(['ko1', 'ko2']) as unknown[]; + + expect(result).toHaveLength(2); + expect(kernelKrefOf(result[0] as SlotValue)).toBe('ko1'); + expect(kernelKrefOf(result[1] as SlotValue)).toBe('ko2'); + }); + + it('handles mixed arrays with krefs and primitives', () => { + const result = convertKrefsToStandins([ + 'ko1', + 42, + 'hello', + true, + ]) as unknown[]; + + expect(result).toHaveLength(4); + expect(kernelKrefOf(result[0] as SlotValue)).toBe('ko1'); + expect(result[1]).toBe(42); + expect(result[2]).toBe('hello'); + expect(result[3]).toBe(true); + }); + + it('handles empty arrays', () => { + const result = convertKrefsToStandins([]); + expect(result).toStrictEqual([]); + }); + + it('handles nested arrays', () => { + const result = convertKrefsToStandins([['ko1'], ['ko2']]) as unknown[][]; + + expect(kernelKrefOf(result[0]![0] as SlotValue)).toBe('ko1'); + expect(kernelKrefOf(result[1]![0] as SlotValue)).toBe('ko2'); + }); + }); + + describe('object processing', () => { + it('recursively converts krefs in objects', () => { + const result = convertKrefsToStandins({ + target: 'ko1', + promise: 'kp2', + }) as Record; + + expect(kernelKrefOf(result.target as SlotValue)).toBe('ko1'); + expect(kernelKrefOf(result.promise as Promise)).toBe('kp2'); + }); + + it('handles nested objects', () => { + const result = convertKrefsToStandins({ + outer: { + inner: 'ko42', + }, + }) as Record>; + + expect(kernelKrefOf(result.outer!.inner as SlotValue)).toBe('ko42'); + }); + + it('handles empty objects', () => { + const result = convertKrefsToStandins({}); + expect(result).toStrictEqual({}); + }); + + it('handles objects with mixed values', () => { + const result = convertKrefsToStandins({ + kref: 'ko1', + number: 123, + string: 'text', + boolean: false, + nullValue: null, + }) as Record; + + expect(kernelKrefOf(result.kref as SlotValue)).toBe('ko1'); + expect(result.number).toBe(123); + expect(result.string).toBe('text'); + expect(result.boolean).toBe(false); + expect(result.nullValue).toBeNull(); + }); + }); + + describe('primitive handling', () => { + it('passes through numbers unchanged', () => { + expect(convertKrefsToStandins(42)).toBe(42); + expect(convertKrefsToStandins(0)).toBe(0); + expect(convertKrefsToStandins(-1)).toBe(-1); + }); + + it('passes through booleans unchanged', () => { + expect(convertKrefsToStandins(true)).toBe(true); + expect(convertKrefsToStandins(false)).toBe(false); + }); + + it('passes through null unchanged', () => { + expect(convertKrefsToStandins(null)).toBeNull(); + }); + + it('passes through undefined unchanged', () => { + expect(convertKrefsToStandins(undefined)).toBeUndefined(); + }); + }); +}); + +describe('makePresenceManager', () => { + let mockKernelFacade: KernelFacade; + let presenceManager: PresenceManager; + + beforeEach(() => { + mockKernelFacade = { + ping: vi.fn(), + launchSubcluster: vi.fn(), + terminateSubcluster: vi.fn(), + queueMessage: vi.fn(), + getStatus: vi.fn(), + pingVat: vi.fn(), + getVatRoot: vi.fn(), + } as unknown as KernelFacade; + + presenceManager = makePresenceManager({ + kernelFacade: mockKernelFacade, + }); + }); + + describe('resolveKref', () => { + it('returns a presence object for a kref', () => { + const presence = presenceManager.resolveKref('ko42'); + + expect(presence).toBeDefined(); + expect(typeof presence).toBe('object'); + }); + + it('returns the same presence for the same kref (memoization)', () => { + const presence1 = presenceManager.resolveKref('ko42'); + const presence2 = presenceManager.resolveKref('ko42'); + + expect(presence1).toBe(presence2); + }); + + it('returns different presences for different krefs', () => { + const presence1 = presenceManager.resolveKref('ko1'); + const presence2 = presenceManager.resolveKref('ko2'); + + expect(presence1).not.toBe(presence2); + }); + }); + + describe('krefOf', () => { + it('returns the kref for a known presence', () => { + const presence = presenceManager.resolveKref('ko42'); + const kref = presenceManager.krefOf(presence); + + expect(kref).toBe('ko42'); + }); + + it('returns undefined for an unknown object', () => { + const unknownObject = { foo: 'bar' }; + const kref = presenceManager.krefOf(unknownObject); + + expect(kref).toBeUndefined(); + }); + }); + + // Note: fromCapData and E() handler tests require the full Endo runtime + // environment with proper SES lockdown. These behaviors are tested in + // captp.integration.test.ts which runs with the real Endo setup. + // Unit tests here focus on the kref↔presence mapping functionality. +}); diff --git a/packages/kernel-browser-runtime/src/kref-presence.ts b/packages/kernel-browser-runtime/src/kref-presence.ts index 1bd1779f8..2fe10f332 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.ts @@ -11,6 +11,7 @@ import type { EHandler } from '@endo/eventual-send'; import { makeMarshal, Remotable } from '@endo/marshal'; import type { CapData } from '@endo/marshal'; import type { KRef } from '@metamask/ocap-kernel'; +import { kslot } from '@metamask/ocap-kernel'; import type { KernelFacade } from './types.ts'; @@ -23,6 +24,37 @@ type SendToKernelFn = ( args: unknown[], ) => Promise; +/** + * Recursively convert kref strings in a value to kernel standins. + * + * When the background sends kref strings as arguments, we need to convert + * them to standin objects that kernel-marshal can serialize properly. + * + * @param value - The value to convert. + * @returns The value with kref strings converted to standins. + */ +export function convertKrefsToStandins(value: unknown): unknown { + // Check if it's a kref string (ko* or kp*) + if (typeof value === 'string' && /^k[op]\d+$/u.test(value)) { + return kslot(value); + } + // Recursively process arrays + if (Array.isArray(value)) { + return value.map(convertKrefsToStandins); + } + // Recursively process plain objects + if (typeof value === 'object' && value !== null) { + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = convertKrefsToStandins(val); + } + return result; + } + // Return primitives as-is + return value; +} +harden(convertKrefsToStandins); + /** * Options for creating a presence manager. */ From 004fb20367ea9d68102f065d8a5ff9ce87176709 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:06:47 -0800 Subject: [PATCH 10/21] refactor: Post-rebase fixup --- packages/omnium-gatherum/README.md | 2 +- packages/omnium-gatherum/src/background.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index 1f52025d6..cfb41d330 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -18,7 +18,7 @@ After loading the extension, open the background console (chrome://extensions ```javascript // 1. Load the echo caplet manifest and bundle -const { manifest, bundle } = await omnium.loadCaplet('echo'); +const { manifest, bundle } = await omnium.caplet.load('echo'); // 2. Install the caplet const installResult = await omnium.caplet.install(manifest, bundle); diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 264d42365..b998c2988 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -6,7 +6,10 @@ import { isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { CapTPMessage, PresenceManager } from '@metamask/kernel-browser-runtime'; +import type { + CapTPMessage, + PresenceManager, +} from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; From c71805f259655cfc60f71d1902a8fd78c0171da4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Sun, 18 Jan 2026 19:57:00 -0800 Subject: [PATCH 11/21] test(extension): Fix object-registry e2e test --- packages/extension/test/e2e/object-registry.test.ts | 4 ++-- .../src/kernel-worker/captp/kernel-facade.test.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/extension/test/e2e/object-registry.test.ts b/packages/extension/test/e2e/object-registry.test.ts index f54038a4a..b4fc02bed 100644 --- a/packages/extension/test/e2e/object-registry.test.ts +++ b/packages/extension/test/e2e/object-registry.test.ts @@ -73,7 +73,7 @@ test.describe('Object Registry', () => { await clearLogsButton.click(); await popupPage.click('button:text("Object Registry")'); await expect( - popupPage.locator('text=Alice (v1) - 5 objects, 4 promises'), + popupPage.locator('text=Alice (v1) - 5 objects, 5 promises'), ).toBeVisible(); const targetSelect = popupPage.locator('[data-testid="message-target"]'); await expect(targetSelect).toBeVisible(); @@ -102,7 +102,7 @@ test.describe('Object Registry', () => { await expect(messageResponse).toContainText('"body":"#\\"vat Alice got'); await expect(messageResponse).toContainText('"slots":['); await expect( - popupPage.locator('text=Alice (v1) - 5 objects, 6 promises'), + popupPage.locator('text=Alice (v1) - 5 objects, 7 promises'), ).toBeVisible(); }); 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 a586509b5..812c9bd29 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 @@ -61,14 +61,11 @@ describe('makeKernelFacade', () => { }); it('returns result with subclusterId and rootKref from kernel', async () => { - const kernelResult = { + vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce({ subclusterId: 's1', bootstrapRootKref: 'ko1', bootstrapResult: { body: '#null', slots: [] }, - }; - vi.mocked(mockKernel.launchSubcluster).mockResolvedValueOnce( - kernelResult, - ); + }); const config: ClusterConfig = makeClusterConfig(); From 078f2c1be9a4675f602f5bdeba5784a2bfd9a53a Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:06:06 -0800 Subject: [PATCH 12/21] test(extension): Fix persistence e2e test --- packages/extension/test/e2e/persistence.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/extension/test/e2e/persistence.test.ts b/packages/extension/test/e2e/persistence.test.ts index 916f65324..74ce4cc87 100644 --- a/packages/extension/test/e2e/persistence.test.ts +++ b/packages/extension/test/e2e/persistence.test.ts @@ -45,6 +45,8 @@ test.describe('Kernel Persistence', () => { await expect( newPopupPage.locator('text=Subcluster s2 - 1 Vat'), ).toBeVisible(); + // Wait for database to fully persist before reloading + await newPopupPage.waitForTimeout(1000); // reload the extension await newPopupPage.evaluate(() => chrome.runtime.reload()); await newPopupPage.close(); From abcbcaf98d8a8f0bc9262a425c76dcbfc54b4c17 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 20 Jan 2026 17:37:30 -0800 Subject: [PATCH 13/21] chore: Remove unused dependency --- packages/kernel-browser-runtime/package.json | 1 - yarn.lock | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/kernel-browser-runtime/package.json b/packages/kernel-browser-runtime/package.json index f25af314a..e5d179a96 100644 --- a/packages/kernel-browser-runtime/package.json +++ b/packages/kernel-browser-runtime/package.json @@ -91,7 +91,6 @@ "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", "@metamask/eslint-config-typescript": "^15.0.0", - "@ocap/nodejs": "workspace:^", "@ocap/repo-tools": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@ts-bridge/shims": "^0.1.1", diff --git a/yarn.lock b/yarn.lock index ae6005ab0..409e38457 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2309,7 +2309,6 @@ __metadata: "@metamask/superstruct": "npm:^3.2.1" "@metamask/utils": "npm:^11.9.0" "@ocap/kernel-platforms": "workspace:^" - "@ocap/nodejs": "workspace:^" "@ocap/repo-tools": "workspace:^" "@ts-bridge/cli": "npm:^0.6.3" "@ts-bridge/shims": "npm:^0.1.1" From 657dc0956e594a3f16397801bddd6c4a231f30f0 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 23 Jan 2026 14:51:53 -0800 Subject: [PATCH 14/21] docs(omnium): Tweak readme --- packages/omnium-gatherum/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/omnium-gatherum/README.md b/packages/omnium-gatherum/README.md index cfb41d330..10a89a722 100644 --- a/packages/omnium-gatherum/README.md +++ b/packages/omnium-gatherum/README.md @@ -17,11 +17,11 @@ or After loading the extension, open the background console (chrome://extensions → Omnium → "Inspect views: service worker") and run the following: ```javascript -// 1. Load the echo caplet manifest and bundle -const { manifest, bundle } = await omnium.caplet.load('echo'); +// 1. Load the echo caplet manifest +const { manifest } = await omnium.caplet.load('echo'); // 2. Install the caplet -const installResult = await omnium.caplet.install(manifest, bundle); +const installResult = await omnium.caplet.install(manifest); // 3. Get the caplet's root kref const capletInfo = await omnium.caplet.get(installResult.capletId); From 95eb3760d188e20c487d2dc58d31ffaff58038a9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:06:40 -0800 Subject: [PATCH 15/21] fix(kernel-browser-runtime): Recursively convert nested presences to kref strings Previously, sendToKernel only converted top-level presence objects in arguments to kref strings, leaving nested presences unhandled. This created an asymmetry with convertKrefsToStandins, which recursively converts kref strings on the kernel side. Add convertPresencesToKrefs function that recursively converts presences to kref strings, mirroring the behavior of convertKrefsToStandins. Use it in sendToKernel to handle nested presences in objects and arrays. Add comprehensive unit tests covering: - Top-level presence conversion - Nested presences in objects - Presences in arrays - Deeply nested presences - Mixed primitive and presence arguments Co-Authored-By: Claude --- .../src/kref-presence.test.ts | 127 +++++++++++++++++- .../src/kref-presence.ts | 43 ++++-- 2 files changed, 156 insertions(+), 14 deletions(-) diff --git a/packages/kernel-browser-runtime/src/kref-presence.test.ts b/packages/kernel-browser-runtime/src/kref-presence.test.ts index a62d0b685..07a1a69f7 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.test.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.test.ts @@ -289,8 +289,127 @@ describe('makePresenceManager', () => { }); }); - // Note: fromCapData and E() handler tests require the full Endo runtime - // environment with proper SES lockdown. These behaviors are tested in - // captp.integration.test.ts which runs with the real Endo setup. - // Unit tests here focus on the kref↔presence mapping functionality. + describe('presence-to-kref conversion in sendToKernel', () => { + // These tests verify that presences are recursively converted to kref + // strings when passed as arguments to E() calls on presences. + + beforeEach(() => { + // Set up queueMessage to return a valid CapData response + vi.mocked(mockKernelFacade.queueMessage).mockResolvedValue({ + body: '#null', + slots: [], + }); + }); + + it('converts top-level presence argument to kref string', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + const argPresence = presenceManager.resolveKref('ko2'); + + // Call method with presence as argument + await ( + targetPresence as Record unknown> + ).someMethod(argPresence); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + ['ko2'], + ); + }); + + it('converts nested presence in object argument to kref string', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + const nestedPresence = presenceManager.resolveKref('ko2'); + + await ( + targetPresence as Record unknown> + ).someMethod({ nested: nestedPresence }); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + [{ nested: 'ko2' }], + ); + }); + + it('converts presences in array argument to kref strings', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + const presence2 = presenceManager.resolveKref('ko2'); + const presence3 = presenceManager.resolveKref('ko3'); + + await ( + targetPresence as Record unknown> + ).someMethod([presence2, presence3]); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + [['ko2', 'ko3']], + ); + }); + + it('converts deeply nested presences to kref strings', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + const deepPresence = presenceManager.resolveKref('ko99'); + + await ( + targetPresence as Record unknown> + ).someMethod({ a: { b: { c: deepPresence } } }); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + [{ a: { b: { c: 'ko99' } } }], + ); + }); + + it('handles mixed arguments with primitives and nested presences', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + const presence2 = presenceManager.resolveKref('ko2'); + const presence3 = presenceManager.resolveKref('ko3'); + + await ( + targetPresence as Record unknown> + ).someMethod('primitive', { nested: presence2 }, presence3, 42); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + ['primitive', { nested: 'ko2' }, 'ko3', 42], + ); + }); + + it('preserves non-presence objects unchanged', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + + await ( + targetPresence as Record unknown> + ).someMethod({ data: 'value', count: 123 }); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + [{ data: 'value', count: 123 }], + ); + }); + + it('handles array with mixed presences and primitives', async () => { + const targetPresence = presenceManager.resolveKref('ko1'); + const presence2 = presenceManager.resolveKref('ko2'); + + await ( + targetPresence as Record unknown> + ).someMethod([presence2, 'string', 42, { key: presence2 }]); + + expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + 'ko1', + 'someMethod', + [['ko2', 'string', 42, { key: 'ko2' }]], + ); + }); + }); + + // Note: fromCapData and full E() handler integration tests require the real + // Endo runtime environment with proper SES lockdown. These behaviors are + // tested in captp.integration.test.ts which runs with the real Endo setup. }); diff --git a/packages/kernel-browser-runtime/src/kref-presence.ts b/packages/kernel-browser-runtime/src/kref-presence.ts index 2fe10f332..c04bf50cf 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.ts @@ -190,6 +190,37 @@ export function makePresenceManager( // eslint-disable-next-line prefer-const let marshal: ReturnType>; + /** + * Recursively convert presence objects to kref strings. + * + * This is the inverse of convertKrefsToStandins - it converts presences + * back to kref strings so they can be sent to the kernel. + * + * @param value - The value to convert. + * @returns The value with presences converted to kref strings. + */ + const convertPresencesToKrefs = (value: unknown): unknown => { + // Check if it's a known presence + if (typeof value === 'object' && value !== null) { + const kref = presenceToKref.get(value); + if (kref !== undefined) { + return kref; + } + // Recursively process arrays + if (Array.isArray(value)) { + return value.map(convertPresencesToKrefs); + } + // Recursively process plain objects + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + result[key] = convertPresencesToKrefs(val); + } + return result; + } + // Return primitives as-is + return value; + }; + /** * Send a message to the kernel and deserialize the result. * @@ -203,16 +234,8 @@ export function makePresenceManager( method: string, args: unknown[], ): Promise => { - // Convert presence args to kref strings - const serializedArgs = args.map((arg) => { - if (typeof arg === 'object' && arg !== null) { - const argKref = presenceToKref.get(arg); - if (argKref) { - return argKref; // Pass kref string to kernel - } - } - return arg; // Pass primitive through - }); + // Recursively convert presence args to kref strings + const serializedArgs = args.map(convertPresencesToKrefs); // Call kernel via existing CapTP const result: CapData = await E(kernelFacade).queueMessage( From b64b508a00e9ba9641cba80117e8fa573c8abd5f Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:21:45 -0800 Subject: [PATCH 16/21] test(nodejs): Add e2e test for third-party handoff with Alice/Bob/Carol This adds a comprehensive e2e test demonstrating third-party capability handoff in the nodejs package using external E() calls on vat root objects. Key additions: - Three test vats (alice-vat, bob-vat, carol-vat) that demonstrate handoff - Test cases for both vat-internal and external orchestration patterns - KernelFacadeAdapter that wraps the local kernel for use with PresenceManager - Exports of PresenceManager from nodejs package Fixes: - Updated sendToKernel in kref-presence to convert presence arguments to standin objects (via kslot) which the kernel's queueMessage expects - Updated corresponding unit tests to expect standins instead of strings - Added LaunchResult export from kernel-browser-runtime Co-Authored-By: Claude --- packages/kernel-browser-runtime/src/index.ts | 2 +- .../src/kref-presence.test.ts | 27 +-- .../src/kref-presence.ts | 25 ++- packages/nodejs/package.json | 1 + packages/nodejs/src/index.ts | 8 + .../test/e2e/third-party-handoff.test.ts | 177 ++++++++++++++++++ packages/nodejs/test/vats/alice-vat.js | 42 +++++ packages/nodejs/test/vats/bob-vat.js | 34 ++++ packages/nodejs/test/vats/carol-vat.js | 60 ++++++ 9 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 packages/nodejs/test/e2e/third-party-handoff.test.ts create mode 100644 packages/nodejs/test/vats/alice-vat.js create mode 100644 packages/nodejs/test/vats/bob-vat.js create mode 100644 packages/nodejs/test/vats/carol-vat.js diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 79fb7036a..4e3c16229 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -11,7 +11,7 @@ export * from './makeIframeVatWorker.ts'; export * from './PlatformServicesClient.ts'; export * from './PlatformServicesServer.ts'; export * from './utils/index.ts'; -export type { KernelFacade } from './types.ts'; +export type { KernelFacade, LaunchResult } from './types.ts'; export { makeBackgroundCapTP, isCapTPNotification, diff --git a/packages/kernel-browser-runtime/src/kref-presence.test.ts b/packages/kernel-browser-runtime/src/kref-presence.test.ts index 07a1a69f7..7b9882484 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.test.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.test.ts @@ -1,5 +1,5 @@ import { passStyleOf } from '@endo/marshal'; -import { krefOf as kernelKrefOf } from '@metamask/ocap-kernel'; +import { krefOf as kernelKrefOf, kslot } from '@metamask/ocap-kernel'; import type { SlotValue } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -290,8 +290,9 @@ describe('makePresenceManager', () => { }); describe('presence-to-kref conversion in sendToKernel', () => { - // These tests verify that presences are recursively converted to kref - // strings when passed as arguments to E() calls on presences. + // These tests verify that presences are recursively converted to standin + // objects (via kslot) when passed as arguments to E() calls on presences. + // The kernel's queueMessage expects standin objects, not raw kref strings. beforeEach(() => { // Set up queueMessage to return a valid CapData response @@ -301,7 +302,7 @@ describe('makePresenceManager', () => { }); }); - it('converts top-level presence argument to kref string', async () => { + it('converts top-level presence argument to standin', async () => { const targetPresence = presenceManager.resolveKref('ko1'); const argPresence = presenceManager.resolveKref('ko2'); @@ -313,11 +314,11 @@ describe('makePresenceManager', () => { expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', - ['ko2'], + [kslot('ko2')], ); }); - it('converts nested presence in object argument to kref string', async () => { + it('converts nested presence in object argument to standin', async () => { const targetPresence = presenceManager.resolveKref('ko1'); const nestedPresence = presenceManager.resolveKref('ko2'); @@ -328,11 +329,11 @@ describe('makePresenceManager', () => { expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', - [{ nested: 'ko2' }], + [{ nested: kslot('ko2') }], ); }); - it('converts presences in array argument to kref strings', async () => { + it('converts presences in array argument to standins', async () => { const targetPresence = presenceManager.resolveKref('ko1'); const presence2 = presenceManager.resolveKref('ko2'); const presence3 = presenceManager.resolveKref('ko3'); @@ -344,11 +345,11 @@ describe('makePresenceManager', () => { expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', - [['ko2', 'ko3']], + [[kslot('ko2'), kslot('ko3')]], ); }); - it('converts deeply nested presences to kref strings', async () => { + it('converts deeply nested presences to standins', async () => { const targetPresence = presenceManager.resolveKref('ko1'); const deepPresence = presenceManager.resolveKref('ko99'); @@ -359,7 +360,7 @@ describe('makePresenceManager', () => { expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', - [{ a: { b: { c: 'ko99' } } }], + [{ a: { b: { c: kslot('ko99') } } }], ); }); @@ -375,7 +376,7 @@ describe('makePresenceManager', () => { expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', - ['primitive', { nested: 'ko2' }, 'ko3', 42], + ['primitive', { nested: kslot('ko2') }, kslot('ko3'), 42], ); }); @@ -404,7 +405,7 @@ describe('makePresenceManager', () => { expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', - [['ko2', 'string', 42, { key: 'ko2' }]], + [[kslot('ko2'), 'string', 42, { key: kslot('ko2') }]], ); }); }); diff --git a/packages/kernel-browser-runtime/src/kref-presence.ts b/packages/kernel-browser-runtime/src/kref-presence.ts index c04bf50cf..32a157ed2 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.ts @@ -10,11 +10,13 @@ import { E, HandledPromise } from '@endo/eventual-send'; import type { EHandler } from '@endo/eventual-send'; import { makeMarshal, Remotable } from '@endo/marshal'; import type { CapData } from '@endo/marshal'; -import type { KRef } from '@metamask/ocap-kernel'; +import type { Kernel, KRef } from '@metamask/ocap-kernel'; import { kslot } from '@metamask/ocap-kernel'; import type { KernelFacade } from './types.ts'; +type Methods = Record unknown>; + /** * Function type for sending messages to the kernel. */ @@ -63,7 +65,7 @@ export type PresenceManagerOptions = { * The kernel facade remote presence from CapTP. * Can be a promise since E() works with promises. */ - kernelFacade: KernelFacade | Promise; + kernelFacade: KernelFacade | Promise | Kernel | Promise; }; /** @@ -76,7 +78,7 @@ export type PresenceManager = { * @param kref - The kernel reference string (e.g., 'ko42', 'kp123'). * @returns A presence that can receive E() calls. */ - resolveKref: (kref: KRef) => object; + resolveKref: (kref: KRef) => Methods; /** * Extract the kref from a presence. @@ -162,10 +164,10 @@ function makeKrefPresence( kref: string, iface: string, sendToKernel: SendToKernelFn, -): object { +): Methods { const kit = makeKrefRemoteKit(kref, sendToKernel); // Wrap the presence in Remotable for proper pass-style - return Remotable(iface, undefined, kit.resolveWithPresence()); + return Remotable(iface, undefined, kit.resolveWithPresence()) as Methods; } /** @@ -183,7 +185,7 @@ export function makePresenceManager( const { kernelFacade } = options; // State for kref↔presence mapping - const krefToPresence = new Map(); + const krefToPresence = new Map(); const presenceToKref = new WeakMap(); // Forward declaration for sendToKernel @@ -234,8 +236,11 @@ export function makePresenceManager( method: string, args: unknown[], ): Promise => { - // Recursively convert presence args to kref strings - const serializedArgs = args.map(convertPresencesToKrefs); + // Recursively convert presence args to kref strings, then to standins + // The kernel's queueMessage uses kser() which expects standin objects + const serializedArgs = args.map((arg) => + convertKrefsToStandins(convertPresencesToKrefs(arg)), + ); // Call kernel via existing CapTP const result: CapData = await E(kernelFacade).queueMessage( @@ -255,7 +260,7 @@ export function makePresenceManager( * @param iface - Optional interface name for the presence. * @returns A presence object that can receive E() calls. */ - const convertSlotToVal = (kref: KRef, iface?: string): object => { + const convertSlotToVal = (kref: KRef, iface?: string): Methods => { let presence = krefToPresence.get(kref); if (!presence) { presence = makeKrefPresence( @@ -294,7 +299,7 @@ export function makePresenceManager( }); return harden({ - resolveKref: (kref: KRef): object => { + resolveKref: (kref: KRef): Methods => { return convertSlotToVal(kref, 'Alleged: VatObject'); }, diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index a159dae8f..b026a8e3e 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -53,6 +53,7 @@ "@endo/promise-kit": "^1.1.13", "@libp2p/interface": "2.11.0", "@libp2p/webrtc": "5.2.24", + "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index 6af1ec51b..602052ce8 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -1,3 +1,11 @@ export { NodejsPlatformServices } from './kernel/PlatformServices.ts'; export { makeKernel } from './kernel/make-kernel.ts'; export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts'; + +// TODO: Re-export presence manager when moved from kernel-browser-runtime +// // Re-export presence manager from kernel-browser-runtime for E() support +// export { makePresenceManager } from '@metamask/kernel-browser-runtime'; +// export type { +// PresenceManager, +// PresenceManagerOptions, +// } from '@metamask/kernel-browser-runtime'; diff --git a/packages/nodejs/test/e2e/third-party-handoff.test.ts b/packages/nodejs/test/e2e/third-party-handoff.test.ts new file mode 100644 index 000000000..776ee4be6 --- /dev/null +++ b/packages/nodejs/test/e2e/third-party-handoff.test.ts @@ -0,0 +1,177 @@ +import { E } from '@endo/eventual-send'; +// TODO: Move this export +import { makePresenceManager } from '@metamask/kernel-browser-runtime'; +import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { makeKernel } from '../../src/kernel/make-kernel.ts'; + +type Alice = { + performHandoff: ( + bob: Bob, + carol: Carol, + greeting: string, + name: string, + ) => Promise; +}; +type Bob = { + createGreeter: (greeting: string) => Promise; +}; +type Carol = { + receiveAndGreet: (greeter: Greeter, name: string) => Promise; + storeExo: (exo: unknown) => Promise; + useStoredExo: (name: string) => Promise; +}; +type Greeter = { + greet: (name: string) => Promise; +}; + +/** + * Creates a map from vat names to their root krefs for a given subcluster. + * + * @param kernel - The kernel instance. + * @param subclusterId - The subcluster ID. + * @returns A record mapping vat names to their root krefs. + */ +function getVatRootKrefs( + kernel: Kernel, + subclusterId: string, +): Record { + const subcluster = kernel.getSubcluster(subclusterId); + if (!subcluster) { + throw new Error(`Subcluster ${subclusterId} not found`); + } + + const vatNames = Object.keys(subcluster.config.vats); + const vatIds: VatId[] = subcluster.vats; + + const result: Record = {}; + for (let i = 0; i < vatNames.length; i++) { + const vatName = vatNames[i]; + assert(vatName, `Vat name is undefined`); + const vatId = vatIds[i]; + assert(vatId, `Vat ID for ${vatName} is undefined`); + result[vatName] = kernel.pinVatRoot(vatId); + } + return result; +} + +describe('third-party handoff', () => { + let kernel: Kernel; + + beforeEach(async () => { + kernel = await makeKernel({}); + }); + + afterEach(async () => { + if (kernel) { + await kernel.clearStorage(); + } + }); + + it('alice passes exo from Bob to Carol (vat-internal handoff)', async () => { + // Launch subcluster with Alice, Bob, Carol + const config: ClusterConfig = { + bootstrap: 'alice', + vats: { + alice: { + bundleSpec: 'http://localhost:3000/alice-vat.bundle', + }, + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + carol: { + bundleSpec: 'http://localhost:3000/carol-vat.bundle', + }, + }, + }; + + const { subclusterId, bootstrapRootKref } = + await kernel.launchSubcluster(config); + + // Create presence manager for E() calls + const pm = makePresenceManager({ kernelFacade: kernel }); + + // Get presences for each vat root + const alice = pm.resolveKref(bootstrapRootKref) as Alice; + + // Get Bob and Carol krefs using the subcluster + const vatRootKrefs = getVatRootKrefs(kernel, subclusterId); + const bob = pm.resolveKref(vatRootKrefs.bob as string) as Bob; + const carol = pm.resolveKref(vatRootKrefs.carol as string) as Carol; + + // Test: Alice orchestrates the third-party handoff + // Alice calls Bob to get a greeter, then passes it to Carol + const result = await E(alice).performHandoff(bob, carol, 'Hello', 'World'); + expect(result).toBe('Hello, World!'); + }, 30000); + + it('external orchestration of third-party handoff', async () => { + // Launch subcluster with Bob and Carol only + const config: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + carol: { + bundleSpec: 'http://localhost:3000/carol-vat.bundle', + }, + }, + }; + + const { subclusterId, bootstrapRootKref } = + await kernel.launchSubcluster(config); + + // Create presence manager for E() calls + const pm = makePresenceManager({ kernelFacade: kernel }); + + // Get presences + const bob = pm.resolveKref(bootstrapRootKref) as Bob; + const vatRootKrefs = getVatRootKrefs(kernel, subclusterId); + const carol = pm.resolveKref(vatRootKrefs.carol as string) as Carol; + + // Test: External code orchestrates the handoff + // 1. Get exo from Bob + const greeter = await E(bob).createGreeter('Greetings'); + + // 2. Pass exo to Carol (third-party handoff) + const greeting = await E(carol).receiveAndGreet(greeter, 'Universe'); + expect(greeting).toBe('Greetings, Universe!'); + }, 30000); + + it('carol stores and later uses exo from Bob', async () => { + // Launch subcluster with Bob and Carol + const config: ClusterConfig = { + bootstrap: 'bob', + vats: { + bob: { + bundleSpec: 'http://localhost:3000/bob-vat.bundle', + }, + carol: { + bundleSpec: 'http://localhost:3000/carol-vat.bundle', + }, + }, + }; + + const { subclusterId } = await kernel.launchSubcluster(config); + + const pm = makePresenceManager({ kernelFacade: kernel }); + + // Get presences using the subcluster + const vatRootKrefs = getVatRootKrefs(kernel, subclusterId); + const bob = pm.resolveKref(vatRootKrefs.bob as string) as Bob; + const carol = pm.resolveKref(vatRootKrefs.carol as string) as Carol; + + // 1. Get exo from Bob + const greeter = await E(bob).createGreeter('Howdy'); + + // 2. Carol stores the exo + const storeResult = await E(carol).storeExo(greeter); + expect(storeResult).toBe('stored'); + + // 3. Carol uses the stored exo later + const greeting = await E(carol).useStoredExo('Partner'); + expect(greeting).toBe('Howdy, Partner!'); + }, 30000); +}); diff --git a/packages/nodejs/test/vats/alice-vat.js b/packages/nodejs/test/vats/alice-vat.js new file mode 100644 index 000000000..b69cc7f1e --- /dev/null +++ b/packages/nodejs/test/vats/alice-vat.js @@ -0,0 +1,42 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for Alice's vat. + * Alice orchestrates the third-party handoff between Bob and Carol. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + return makeDefaultExo('aliceRoot', { + bootstrap() { + logger.log('Alice vat bootstrap'); + }, + + /** + * Orchestrates a third-party handoff by getting an exo from Bob, + * passing it to Carol, and having Carol use it. + * + * @param {object} bob - Reference to Bob's vat root. + * @param {object} carol - Reference to Carol's vat root. + * @param {string} greeting - The greeting for Bob to use. + * @param {string} name - The name for Carol to greet. + * @returns {Promise} The greeting result. + */ + async performHandoff(bob, carol, greeting, name) { + logger.log('Alice starting handoff'); + + // Get exo from Bob + const greeter = await E(bob).createGreeter(greeting); + logger.log('Alice received greeter from Bob'); + + // Pass to Carol and have her use it + const result = await E(carol).receiveAndGreet(greeter, name); + logger.log(`Alice got result: ${result}`); + + return result; + }, + }); +} diff --git a/packages/nodejs/test/vats/bob-vat.js b/packages/nodejs/test/vats/bob-vat.js new file mode 100644 index 000000000..a822f678f --- /dev/null +++ b/packages/nodejs/test/vats/bob-vat.js @@ -0,0 +1,34 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for Bob's vat. + * Bob can create greeter exos that can be passed to other vats. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + return makeDefaultExo('bobRoot', { + bootstrap() { + logger.log('Bob vat bootstrap'); + }, + + /** + * Create a greeter exo that can greet with a custom message. + * This exo can be passed to other vats (third-party handoff). + * + * @param {string} greeting - The greeting prefix to use. + * @returns {object} A greeter exo with a greet method. + */ + createGreeter(greeting) { + return makeDefaultExo('greeter', { + greet(name) { + const message = `${greeting}, ${name}!`; + logger.log(`Greeter says: ${message}`); + return message; + }, + }); + }, + }); +} diff --git a/packages/nodejs/test/vats/carol-vat.js b/packages/nodejs/test/vats/carol-vat.js new file mode 100644 index 000000000..b97244be7 --- /dev/null +++ b/packages/nodejs/test/vats/carol-vat.js @@ -0,0 +1,60 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for Carol's vat. + * Carol can receive exos from other vats and call methods on them. + * + * @param {object} vatPowers - Special powers granted to this vat. + * @param {object} vatPowers.logger - The logger object. + * @returns {object} The root object for the new vat. + */ +export function buildRootObject({ logger }) { + /** @type {object | null} */ + let storedExo = null; + + return makeDefaultExo('carolRoot', { + bootstrap() { + logger.log('Carol vat bootstrap'); + }, + + /** + * Receive an exo and immediately call a method on it. + * This proves the third-party handoff worked. + * + * @param {object} exo - An exo received from another vat. + * @param {string} name - The name to greet. + * @returns {Promise} The greeting from the exo. + */ + receiveAndGreet(exo, name) { + logger.log(`Carol received exo and will greet "${name}"`); + return E(exo).greet(name); + }, + + /** + * Store an exo for later use. + * + * @param {object} exo - An exo to store. + * @returns {string} Confirmation message. + */ + storeExo(exo) { + storedExo = exo; + logger.log('Carol stored exo'); + return 'stored'; + }, + + /** + * Use a previously stored exo to greet. + * + * @param {string} name - The name to greet. + * @returns {Promise} The greeting from the stored exo. + */ + useStoredExo(name) { + if (!storedExo) { + throw new Error('No exo stored'); + } + logger.log(`Carol using stored exo to greet "${name}"`); + return E(storedExo).greet(name); + }, + }); +} From 8ca2507b8a2b686a0fa818192ce9a92a67df2edc Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 26 Jan 2026 11:54:43 -0800 Subject: [PATCH 17/21] refactor(kernel-browser-runtime): Combine presence-to-kref and kref-to-standin conversions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the serialization flow in sendToKernel by combining the two-step conversion (presence → kref string → standin) into a single step that goes directly from presence → standin using kslot(). This reduces unnecessary intermediate string conversions while maintaining the same behavior. The presenceToKref WeakMap lookup is now used directly with kslot() to create standins. Also update test descriptions to accurately reflect the new behavior. Co-Authored-By: Claude --- .../src/kref-presence.test.ts | 73 +++++++++++-------- .../src/kref-presence.ts | 29 ++++---- 2 files changed, 58 insertions(+), 44 deletions(-) diff --git a/packages/kernel-browser-runtime/src/kref-presence.test.ts b/packages/kernel-browser-runtime/src/kref-presence.test.ts index 7b9882484..cced6f8cf 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.test.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.test.ts @@ -289,10 +289,10 @@ describe('makePresenceManager', () => { }); }); - describe('presence-to-kref conversion in sendToKernel', () => { + describe('presence-to-standin conversion in sendToKernel', () => { // These tests verify that presences are recursively converted to standin // objects (via kslot) when passed as arguments to E() calls on presences. - // The kernel's queueMessage expects standin objects, not raw kref strings. + // The kernel's queueMessage expects standin objects, not presences. beforeEach(() => { // Set up queueMessage to return a valid CapData response @@ -303,13 +303,13 @@ describe('makePresenceManager', () => { }); it('converts top-level presence argument to standin', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: (arg: unknown) => unknown; + }; const argPresence = presenceManager.resolveKref('ko2'); // Call method with presence as argument - await ( - targetPresence as Record unknown> - ).someMethod(argPresence); + await targetPresence.someMethod(argPresence); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', @@ -319,12 +319,12 @@ describe('makePresenceManager', () => { }); it('converts nested presence in object argument to standin', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: (arg: { nested: unknown }) => unknown; + }; const nestedPresence = presenceManager.resolveKref('ko2'); - await ( - targetPresence as Record unknown> - ).someMethod({ nested: nestedPresence }); + await targetPresence.someMethod({ nested: nestedPresence }); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', @@ -334,13 +334,13 @@ describe('makePresenceManager', () => { }); it('converts presences in array argument to standins', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: (arg: unknown[]) => unknown; + }; const presence2 = presenceManager.resolveKref('ko2'); const presence3 = presenceManager.resolveKref('ko3'); - await ( - targetPresence as Record unknown> - ).someMethod([presence2, presence3]); + await targetPresence.someMethod([presence2, presence3]); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', @@ -350,12 +350,12 @@ describe('makePresenceManager', () => { }); it('converts deeply nested presences to standins', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: (arg: { a: { b: { c: unknown } } }) => unknown; + }; const deepPresence = presenceManager.resolveKref('ko99'); - await ( - targetPresence as Record unknown> - ).someMethod({ a: { b: { c: deepPresence } } }); + await targetPresence.someMethod({ a: { b: { c: deepPresence } } }); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', @@ -365,13 +365,19 @@ describe('makePresenceManager', () => { }); it('handles mixed arguments with primitives and nested presences', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + type Args = [string, { nested: unknown }, unknown, number]; + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: (...args: Args) => unknown; + }; const presence2 = presenceManager.resolveKref('ko2'); const presence3 = presenceManager.resolveKref('ko3'); - await ( - targetPresence as Record unknown> - ).someMethod('primitive', { nested: presence2 }, presence3, 42); + await targetPresence.someMethod( + 'primitive', + { nested: presence2 }, + presence3, + 42, + ); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', @@ -381,11 +387,11 @@ describe('makePresenceManager', () => { }); it('preserves non-presence objects unchanged', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: (arg: { data: string; count: number }) => unknown; + }; - await ( - targetPresence as Record unknown> - ).someMethod({ data: 'value', count: 123 }); + await targetPresence.someMethod({ data: 'value', count: 123 }); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', @@ -395,12 +401,19 @@ describe('makePresenceManager', () => { }); it('handles array with mixed presences and primitives', async () => { - const targetPresence = presenceManager.resolveKref('ko1'); + const targetPresence = presenceManager.resolveKref('ko1') as { + someMethod: ( + arg: [unknown, string, number, { key: unknown }], + ) => unknown; + }; const presence2 = presenceManager.resolveKref('ko2'); - await ( - targetPresence as Record unknown> - ).someMethod([presence2, 'string', 42, { key: presence2 }]); + await targetPresence.someMethod([ + presence2, + 'string', + 42, + { key: presence2 }, + ]); expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( 'ko1', diff --git a/packages/kernel-browser-runtime/src/kref-presence.ts b/packages/kernel-browser-runtime/src/kref-presence.ts index 32a157ed2..6a7e7a30e 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.ts +++ b/packages/kernel-browser-runtime/src/kref-presence.ts @@ -193,29 +193,33 @@ export function makePresenceManager( let marshal: ReturnType>; /** - * Recursively convert presence objects to kref strings. + * Recursively convert presence objects directly to kernel standins. * - * This is the inverse of convertKrefsToStandins - it converts presences - * back to kref strings so they can be sent to the kernel. + * This combines two conversions in one pass: + * 1. Presences → kref strings (via presenceToKref WeakMap lookup) + * 2. Kref strings → standins (via kslot) + * + * The kernel's queueMessage uses kser() which expects standin objects, + * not presences or raw kref strings. * * @param value - The value to convert. - * @returns The value with presences converted to kref strings. + * @returns The value with presences converted to standins. */ - const convertPresencesToKrefs = (value: unknown): unknown => { - // Check if it's a known presence + const convertPresencesToStandins = (value: unknown): unknown => { + // Check if it's a known presence - convert directly to standin if (typeof value === 'object' && value !== null) { const kref = presenceToKref.get(value); if (kref !== undefined) { - return kref; + return kslot(kref); } // Recursively process arrays if (Array.isArray(value)) { - return value.map(convertPresencesToKrefs); + return value.map(convertPresencesToStandins); } // Recursively process plain objects const result: Record = {}; for (const [key, val] of Object.entries(value)) { - result[key] = convertPresencesToKrefs(val); + result[key] = convertPresencesToStandins(val); } return result; } @@ -236,11 +240,8 @@ export function makePresenceManager( method: string, args: unknown[], ): Promise => { - // Recursively convert presence args to kref strings, then to standins - // The kernel's queueMessage uses kser() which expects standin objects - const serializedArgs = args.map((arg) => - convertKrefsToStandins(convertPresencesToKrefs(arg)), - ); + // Convert presence args directly to standins for kernel serialization + const serializedArgs = args.map(convertPresencesToStandins); // Call kernel via existing CapTP const result: CapData = await E(kernelFacade).queueMessage( From 4d4dcdc0653ba3cfb9588e5516bea2650d2b2dba Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:24:33 -0800 Subject: [PATCH 18/21] chore: Make dependency linter ignore dist --- .depcheckrc.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 08e7fb5e3..f84bba09e 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -71,3 +71,6 @@ ignores: # Testing # This import is used in files which are meant to fail - 'does-not-exist' + +ignore-patterns: + - dist/ From 51d0e85419d7cf943bb02462419c8fa2593ed330 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:25:55 -0800 Subject: [PATCH 19/21] refactor: Move kref-presence to ocap-kernel Move kref-presence.ts and its tests from kernel-browser-runtime to ocap-kernel as the module is not browser-specific and is used across nodejs, extension, and omnium-gatherum packages. - Move kref-presence.ts to ocap-kernel/src/ - Move kref-presence.test.ts to ocap-kernel/src/ - Add KernelLike type for platform-agnostic kernel interface - Export makePresenceManager and convertKrefsToStandins from ocap-kernel - Update all imports across codebase to use @metamask/ocap-kernel - Remove @metamask/kernel-browser-runtime from nodejs dependencies - Re-export from nodejs for backward compatibility - Remove kref-presence exports from kernel-browser-runtime - Remove makePresenceManager from kernel-browser-runtime exports test Co-Authored-By: Claude --- packages/extension/package.json | 1 + packages/extension/src/background.ts | 2 +- packages/extension/src/global.d.ts | 6 ++-- .../kernel-browser-runtime/src/index.test.ts | 1 - packages/kernel-browser-runtime/src/index.ts | 5 --- .../src/kernel-worker/captp/kernel-facade.ts | 2 +- packages/nodejs/package.json | 1 - packages/nodejs/src/index.ts | 13 ++++---- .../test/e2e/third-party-handoff.test.ts | 3 +- packages/ocap-kernel/package.json | 1 + packages/ocap-kernel/src/index.test.ts | 2 ++ packages/ocap-kernel/src/index.ts | 9 ++++++ .../src/kref-presence.test.ts | 31 +++++++++---------- .../src/kref-presence.ts | 18 ++++++++--- packages/omnium-gatherum/src/background.ts | 8 ++--- yarn.lock | 2 ++ 16 files changed, 57 insertions(+), 48 deletions(-) rename packages/{kernel-browser-runtime => ocap-kernel}/src/kref-presence.test.ts (93%) rename packages/{kernel-browser-runtime => ocap-kernel}/src/kref-presence.ts (95%) diff --git a/packages/extension/package.json b/packages/extension/package.json index 17619735b..70adc7b52 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -49,6 +49,7 @@ "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 0369dc166..a8af006f2 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1,7 +1,6 @@ import { E } from '@endo/eventual-send'; import { makeBackgroundCapTP, - makePresenceManager, makeCapTPNotification, isCapTPNotification, getCapTPMessage, @@ -11,6 +10,7 @@ import defaultSubcluster from '@metamask/kernel-browser-runtime/default-cluster' import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; +import { makePresenceManager } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; defineGlobals(); diff --git a/packages/extension/src/global.d.ts b/packages/extension/src/global.d.ts index c67f8b339..f1f7ba76e 100644 --- a/packages/extension/src/global.d.ts +++ b/packages/extension/src/global.d.ts @@ -1,7 +1,5 @@ -import type { - PresenceManager, - KernelFacade, -} from '@metamask/kernel-browser-runtime'; +import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { PresenceManager } from '@metamask/ocap-kernel'; // Type declarations for kernel dev console API. declare global { diff --git a/packages/kernel-browser-runtime/src/index.test.ts b/packages/kernel-browser-runtime/src/index.test.ts index dd96eaf49..f52b98667 100644 --- a/packages/kernel-browser-runtime/src/index.test.ts +++ b/packages/kernel-browser-runtime/src/index.test.ts @@ -15,7 +15,6 @@ describe('index', () => { 'makeBackgroundCapTP', 'makeCapTPNotification', 'makeIframeVatWorker', - 'makePresenceManager', 'parseRelayQueryString', 'receiveInternalConnections', 'rpcHandlers', diff --git a/packages/kernel-browser-runtime/src/index.ts b/packages/kernel-browser-runtime/src/index.ts index 4e3c16229..37786aa78 100644 --- a/packages/kernel-browser-runtime/src/index.ts +++ b/packages/kernel-browser-runtime/src/index.ts @@ -21,8 +21,3 @@ export { type BackgroundCapTPOptions, type CapTPMessage, } from './background-captp.ts'; -export { - makePresenceManager, - type PresenceManager, - type PresenceManagerOptions, -} from './kref-presence.ts'; diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts index af363fcb3..7d737461a 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 { convertKrefsToStandins } from '@metamask/ocap-kernel'; -import { convertKrefsToStandins } from '../../kref-presence.ts'; import type { KernelFacade, LaunchResult } from '../../types.ts'; export type { KernelFacade } from '../../types.ts'; diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index b026a8e3e..a159dae8f 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -53,7 +53,6 @@ "@endo/promise-kit": "^1.1.13", "@libp2p/interface": "2.11.0", "@libp2p/webrtc": "5.2.24", - "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-store": "workspace:^", "@metamask/kernel-utils": "workspace:^", diff --git a/packages/nodejs/src/index.ts b/packages/nodejs/src/index.ts index 602052ce8..e89eeb6c0 100644 --- a/packages/nodejs/src/index.ts +++ b/packages/nodejs/src/index.ts @@ -2,10 +2,9 @@ export { NodejsPlatformServices } from './kernel/PlatformServices.ts'; export { makeKernel } from './kernel/make-kernel.ts'; export { makeNodeJsVatSupervisor } from './vat/make-supervisor.ts'; -// TODO: Re-export presence manager when moved from kernel-browser-runtime -// // Re-export presence manager from kernel-browser-runtime for E() support -// export { makePresenceManager } from '@metamask/kernel-browser-runtime'; -// export type { -// PresenceManager, -// PresenceManagerOptions, -// } from '@metamask/kernel-browser-runtime'; +// Re-export presence manager from ocap-kernel for E() support +export { makePresenceManager } from '@metamask/ocap-kernel'; +export type { + PresenceManager, + PresenceManagerOptions, +} from '@metamask/ocap-kernel'; diff --git a/packages/nodejs/test/e2e/third-party-handoff.test.ts b/packages/nodejs/test/e2e/third-party-handoff.test.ts index 776ee4be6..d6d5d9a1b 100644 --- a/packages/nodejs/test/e2e/third-party-handoff.test.ts +++ b/packages/nodejs/test/e2e/third-party-handoff.test.ts @@ -1,7 +1,6 @@ import { E } from '@endo/eventual-send'; -// TODO: Move this export -import { makePresenceManager } from '@metamask/kernel-browser-runtime'; import type { Kernel, ClusterConfig, KRef, VatId } from '@metamask/ocap-kernel'; +import { makePresenceManager } from '@metamask/ocap-kernel'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { makeKernel } from '../../src/kernel/make-kernel.ts'; diff --git a/packages/ocap-kernel/package.json b/packages/ocap-kernel/package.json index 10f251a7b..044301ac2 100644 --- a/packages/ocap-kernel/package.json +++ b/packages/ocap-kernel/package.json @@ -72,6 +72,7 @@ "@chainsafe/libp2p-noise": "^16.1.3", "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch", "@endo/errors": "^1.2.13", + "@endo/eventual-send": "^1.3.4", "@endo/import-bundle": "^1.5.2", "@endo/marshal": "^1.8.0", "@endo/pass-style": "^1.6.3", diff --git a/packages/ocap-kernel/src/index.test.ts b/packages/ocap-kernel/src/index.test.ts index c7d68c1f4..70eb8de01 100644 --- a/packages/ocap-kernel/src/index.test.ts +++ b/packages/ocap-kernel/src/index.test.ts @@ -14,6 +14,7 @@ describe('index', () => { 'VatHandle', 'VatIdStruct', 'VatSupervisor', + 'convertKrefsToStandins', 'initTransport', 'isVatConfig', 'isVatId', @@ -22,6 +23,7 @@ describe('index', () => { 'kslot', 'kunser', 'makeKernelStore', + 'makePresenceManager', 'parseRef', ]); }); diff --git a/packages/ocap-kernel/src/index.ts b/packages/ocap-kernel/src/index.ts index 054a7b9db..ecba586e1 100644 --- a/packages/ocap-kernel/src/index.ts +++ b/packages/ocap-kernel/src/index.ts @@ -36,3 +36,12 @@ export type { SlotValue } from './liveslots/kernel-marshal.ts'; export { makeKernelStore } from './store/index.ts'; export type { KernelStore } from './store/index.ts'; export { parseRef } from './store/utils/parse-ref.ts'; +export { + makePresenceManager, + convertKrefsToStandins, +} from './kref-presence.ts'; +export type { + PresenceManager, + PresenceManagerOptions, + KernelLike, +} from './kref-presence.ts'; diff --git a/packages/kernel-browser-runtime/src/kref-presence.test.ts b/packages/ocap-kernel/src/kref-presence.test.ts similarity index 93% rename from packages/kernel-browser-runtime/src/kref-presence.test.ts rename to packages/ocap-kernel/src/kref-presence.test.ts index cced6f8cf..d3a7b167d 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.test.ts +++ b/packages/ocap-kernel/src/kref-presence.test.ts @@ -1,14 +1,13 @@ import { passStyleOf } from '@endo/marshal'; -import { krefOf as kernelKrefOf, kslot } from '@metamask/ocap-kernel'; -import type { SlotValue } from '@metamask/ocap-kernel'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import type { PresenceManager } from './kref-presence.ts'; +import type { PresenceManager, KernelLike } from './kref-presence.ts'; import { convertKrefsToStandins, makePresenceManager, } from './kref-presence.ts'; -import type { KernelFacade } from './types.ts'; +import { krefOf as kernelKrefOf, kslot } from './liveslots/kernel-marshal.ts'; +import type { SlotValue } from './liveslots/kernel-marshal.ts'; // EHandler type definition (copied to avoid import issues with mocking) type EHandler = { @@ -231,11 +230,11 @@ describe('convertKrefsToStandins', () => { }); describe('makePresenceManager', () => { - let mockKernelFacade: KernelFacade; + let mockKernelLike: KernelLike; let presenceManager: PresenceManager; beforeEach(() => { - mockKernelFacade = { + mockKernelLike = { ping: vi.fn(), launchSubcluster: vi.fn(), terminateSubcluster: vi.fn(), @@ -243,10 +242,10 @@ describe('makePresenceManager', () => { getStatus: vi.fn(), pingVat: vi.fn(), getVatRoot: vi.fn(), - } as unknown as KernelFacade; + } as unknown as KernelLike; presenceManager = makePresenceManager({ - kernelFacade: mockKernelFacade, + kernelFacade: mockKernelLike, }); }); @@ -296,7 +295,7 @@ describe('makePresenceManager', () => { beforeEach(() => { // Set up queueMessage to return a valid CapData response - vi.mocked(mockKernelFacade.queueMessage).mockResolvedValue({ + vi.mocked(mockKernelLike.queueMessage).mockResolvedValue({ body: '#null', slots: [], }); @@ -311,7 +310,7 @@ describe('makePresenceManager', () => { // Call method with presence as argument await targetPresence.someMethod(argPresence); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', [kslot('ko2')], @@ -326,7 +325,7 @@ describe('makePresenceManager', () => { await targetPresence.someMethod({ nested: nestedPresence }); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', [{ nested: kslot('ko2') }], @@ -342,7 +341,7 @@ describe('makePresenceManager', () => { await targetPresence.someMethod([presence2, presence3]); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', [[kslot('ko2'), kslot('ko3')]], @@ -357,7 +356,7 @@ describe('makePresenceManager', () => { await targetPresence.someMethod({ a: { b: { c: deepPresence } } }); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', [{ a: { b: { c: kslot('ko99') } } }], @@ -379,7 +378,7 @@ describe('makePresenceManager', () => { 42, ); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', ['primitive', { nested: kslot('ko2') }, kslot('ko3'), 42], @@ -393,7 +392,7 @@ describe('makePresenceManager', () => { await targetPresence.someMethod({ data: 'value', count: 123 }); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', [{ data: 'value', count: 123 }], @@ -415,7 +414,7 @@ describe('makePresenceManager', () => { { key: presence2 }, ]); - expect(mockKernelFacade.queueMessage).toHaveBeenCalledWith( + expect(mockKernelLike.queueMessage).toHaveBeenCalledWith( 'ko1', 'someMethod', [[kslot('ko2'), 'string', 42, { key: kslot('ko2') }]], diff --git a/packages/kernel-browser-runtime/src/kref-presence.ts b/packages/ocap-kernel/src/kref-presence.ts similarity index 95% rename from packages/kernel-browser-runtime/src/kref-presence.ts rename to packages/ocap-kernel/src/kref-presence.ts index 6a7e7a30e..0d41ac82f 100644 --- a/packages/kernel-browser-runtime/src/kref-presence.ts +++ b/packages/ocap-kernel/src/kref-presence.ts @@ -10,10 +10,10 @@ import { E, HandledPromise } from '@endo/eventual-send'; import type { EHandler } from '@endo/eventual-send'; import { makeMarshal, Remotable } from '@endo/marshal'; import type { CapData } from '@endo/marshal'; -import type { Kernel, KRef } from '@metamask/ocap-kernel'; -import { kslot } from '@metamask/ocap-kernel'; -import type { KernelFacade } from './types.ts'; +import type { Kernel } from './Kernel.ts'; +import { kslot } from './liveslots/kernel-marshal.ts'; +import type { KRef } from './types.ts'; type Methods = Record unknown>; @@ -57,15 +57,23 @@ export function convertKrefsToStandins(value: unknown): unknown { } harden(convertKrefsToStandins); +/** + * Minimal interface for kernel-like objects that can queue messages. + * Both Kernel and KernelFacade (from kernel-browser-runtime) satisfy this. + */ +export type KernelLike = { + queueMessage: Kernel['queueMessage']; +}; + /** * Options for creating a presence manager. */ export type PresenceManagerOptions = { /** - * The kernel facade remote presence from CapTP. + * A kernel or kernel facade that can queue messages. * Can be a promise since E() works with promises. */ - kernelFacade: KernelFacade | Promise | Kernel | Promise; + kernelFacade: KernelLike | Promise; }; /** diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index b998c2988..0848da3ce 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -1,18 +1,16 @@ import { E } from '@endo/eventual-send'; import { makeBackgroundCapTP, - makePresenceManager, makeCapTPNotification, isCapTPNotification, getCapTPMessage, } from '@metamask/kernel-browser-runtime'; -import type { - CapTPMessage, - PresenceManager, -} from '@metamask/kernel-browser-runtime'; +import type { CapTPMessage } from '@metamask/kernel-browser-runtime'; import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; +import type { PresenceManager } from '@metamask/ocap-kernel'; +import { makePresenceManager } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; import { initializeControllers } from './controllers/index.ts'; diff --git a/yarn.lock b/yarn.lock index 409e38457..36e1812ad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2705,6 +2705,7 @@ __metadata: "@chainsafe/libp2p-noise": "npm:^16.1.3" "@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch" "@endo/errors": "npm:^1.2.13" + "@endo/eventual-send": "npm:^1.3.4" "@endo/import-bundle": "npm:^1.5.2" "@endo/marshal": "npm:^1.8.0" "@endo/pass-style": "npm:^1.6.3" @@ -3475,6 +3476,7 @@ __metadata: "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" "@ocap/cli": "workspace:^" "@ocap/kernel-test": "workspace:^" From ef4e3c52d13334b9a68536e335eb96cd92298a95 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:43:39 -0800 Subject: [PATCH 20/21] refactor: Rename makePresenceManager option --- packages/extension/src/background.ts | 2 +- .../test/e2e/third-party-handoff.test.ts | 26 ++++++++++++------- .../ocap-kernel/src/kref-presence.test.ts | 2 +- packages/ocap-kernel/src/kref-presence.ts | 13 +++++----- packages/omnium-gatherum/src/background.ts | 2 +- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index a8af006f2..01f3fecb5 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -109,7 +109,7 @@ async function main(): Promise { globalThis.kernel = kernelP; // Create presence manager for E() calls on vat objects - const presenceManager = makePresenceManager({ kernelFacade: kernelP }); + const presenceManager = makePresenceManager({ kernel: kernelP }); Object.assign(globalThis.captp, presenceManager); // Handle incoming CapTP messages from the kernel diff --git a/packages/nodejs/test/e2e/third-party-handoff.test.ts b/packages/nodejs/test/e2e/third-party-handoff.test.ts index d6d5d9a1b..3448937ca 100644 --- a/packages/nodejs/test/e2e/third-party-handoff.test.ts +++ b/packages/nodejs/test/e2e/third-party-handoff.test.ts @@ -89,15 +89,17 @@ describe('third-party handoff', () => { await kernel.launchSubcluster(config); // Create presence manager for E() calls - const pm = makePresenceManager({ kernelFacade: kernel }); + const presenceManager = makePresenceManager({ kernel }); // Get presences for each vat root - const alice = pm.resolveKref(bootstrapRootKref) as Alice; + const alice = presenceManager.resolveKref(bootstrapRootKref) as Alice; // Get Bob and Carol krefs using the subcluster const vatRootKrefs = getVatRootKrefs(kernel, subclusterId); - const bob = pm.resolveKref(vatRootKrefs.bob as string) as Bob; - const carol = pm.resolveKref(vatRootKrefs.carol as string) as Carol; + const bob = presenceManager.resolveKref(vatRootKrefs.bob as string) as Bob; + const carol = presenceManager.resolveKref( + vatRootKrefs.carol as string, + ) as Carol; // Test: Alice orchestrates the third-party handoff // Alice calls Bob to get a greeter, then passes it to Carol @@ -123,12 +125,14 @@ describe('third-party handoff', () => { await kernel.launchSubcluster(config); // Create presence manager for E() calls - const pm = makePresenceManager({ kernelFacade: kernel }); + const presenceManager = makePresenceManager({ kernel }); // Get presences - const bob = pm.resolveKref(bootstrapRootKref) as Bob; + const bob = presenceManager.resolveKref(bootstrapRootKref) as Bob; const vatRootKrefs = getVatRootKrefs(kernel, subclusterId); - const carol = pm.resolveKref(vatRootKrefs.carol as string) as Carol; + const carol = presenceManager.resolveKref( + vatRootKrefs.carol as string, + ) as Carol; // Test: External code orchestrates the handoff // 1. Get exo from Bob @@ -155,12 +159,14 @@ describe('third-party handoff', () => { const { subclusterId } = await kernel.launchSubcluster(config); - const pm = makePresenceManager({ kernelFacade: kernel }); + const presenceManager = makePresenceManager({ kernel }); // Get presences using the subcluster const vatRootKrefs = getVatRootKrefs(kernel, subclusterId); - const bob = pm.resolveKref(vatRootKrefs.bob as string) as Bob; - const carol = pm.resolveKref(vatRootKrefs.carol as string) as Carol; + const bob = presenceManager.resolveKref(vatRootKrefs.bob as string) as Bob; + const carol = presenceManager.resolveKref( + vatRootKrefs.carol as string, + ) as Carol; // 1. Get exo from Bob const greeter = await E(bob).createGreeter('Howdy'); diff --git a/packages/ocap-kernel/src/kref-presence.test.ts b/packages/ocap-kernel/src/kref-presence.test.ts index d3a7b167d..0c83a3821 100644 --- a/packages/ocap-kernel/src/kref-presence.test.ts +++ b/packages/ocap-kernel/src/kref-presence.test.ts @@ -245,7 +245,7 @@ describe('makePresenceManager', () => { } as unknown as KernelLike; presenceManager = makePresenceManager({ - kernelFacade: mockKernelLike, + kernel: mockKernelLike, }); }); diff --git a/packages/ocap-kernel/src/kref-presence.ts b/packages/ocap-kernel/src/kref-presence.ts index 0d41ac82f..476de14f8 100644 --- a/packages/ocap-kernel/src/kref-presence.ts +++ b/packages/ocap-kernel/src/kref-presence.ts @@ -73,7 +73,7 @@ export type PresenceManagerOptions = { * A kernel or kernel facade that can queue messages. * Can be a promise since E() works with promises. */ - kernelFacade: KernelLike | Promise; + kernel: KernelLike | Promise; }; /** @@ -185,13 +185,12 @@ function makeKrefPresence( * to kernel.queueMessage() via the existing CapTP connection. * * @param options - Options including the kernel facade. + * @param options.kernel - The kernel instance or presence. * @returns The presence manager. */ -export function makePresenceManager( - options: PresenceManagerOptions, -): PresenceManager { - const { kernelFacade } = options; - +export function makePresenceManager({ + kernel, +}: PresenceManagerOptions): PresenceManager { // State for kref↔presence mapping const krefToPresence = new Map(); const presenceToKref = new WeakMap(); @@ -252,7 +251,7 @@ export function makePresenceManager( const serializedArgs = args.map(convertPresencesToStandins); // Call kernel via existing CapTP - const result: CapData = await E(kernelFacade).queueMessage( + const result: CapData = await E(kernel).queueMessage( kref, method, serializedArgs, diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 0848da3ce..9ac213abd 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -111,7 +111,7 @@ async function main(): Promise { globalThis.kernel = kernelP; // Create presence manager for E() on vat objects - const presenceManager = makePresenceManager({ kernelFacade: kernelP }); + const presenceManager = makePresenceManager({ kernel: kernelP }); globals.setPresenceManager(presenceManager); try { From e28f868d0cf686744295b3bb8c1113f7a8c55a36 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:44:15 -0800 Subject: [PATCH 21/21] docs: Add docs/kernel-to-host-captp.md --- .gitignore | 2 +- docs/kernel-to-host-captp.md | 243 +++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 docs/kernel-to-host-captp.md diff --git a/.gitignore b/.gitignore index 1279a097f..8e2319873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ .DS_Store dist/ coverage/ -docs/ +docs/* !docs/*.md # Logs diff --git a/docs/kernel-to-host-captp.md b/docs/kernel-to-host-captp.md new file mode 100644 index 000000000..cc559dbea --- /dev/null +++ b/docs/kernel-to-host-captp.md @@ -0,0 +1,243 @@ +# Kernel-to-Host CapTP Serialization Flow + +This document explains the serialization pipeline between the kernel and host application, covering how data is marshaled as it flows across process boundaries. + +## Table of Contents + +- [Overview](#overview) +- [Key Components](#key-components) +- [Outbound Flow: Host Application to Kernel](#outbound-flow-host-application-to-kernel) +- [Inbound Flow: Kernel to Host Application](#inbound-flow-kernel-to-host-application) +- [Slot Types](#slot-types) +- [Custom Conversion Functions](#custom-conversion-functions) +- [Supported Data Types](#supported-data-types) + +## Overview + +The serialization pipeline enables communication between the host application (running in the main process) and the kernel (running in a web worker). This involves multiple levels of marshaling to handle object references across process boundaries. + +The pipeline uses three distinct marshals, each handling a different scope: + +1. **CapTP marshal** - Handles cross-process communication via `postMessage` +2. **Kernel marshal** - Handles kernel-internal message storage and vat delivery +3. **PresenceManager marshal** - Converts kernel references to callable presences for the host + +## Key Components + +### Source Files + +| Component | Location | Purpose | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| KRef-Presence utilities | [`packages/ocap-kernel/src/kref-presence.ts`](../packages/ocap-kernel/src/kref-presence.ts) | Converts between KRefs and presences | +| Kernel facade | [`packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts`](../packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-facade.ts) | CapTP interface to kernel | +| KernelQueue | [`packages/ocap-kernel/src/KernelQueue.ts`](../packages/ocap-kernel/src/KernelQueue.ts) | Queues and processes messages | +| Kernel marshal | [`packages/ocap-kernel/src/liveslots/kernel-marshal.ts`](../packages/ocap-kernel/src/liveslots/kernel-marshal.ts) | Serializes data for kernel storage | + +### Marshals in the System + +| Marshal | Location | Slot Type | Body Format | When Used | +| ----------------------- | ------------------- | ------------ | ----------- | ---------------------------- | +| CapTP marshal | `@endo/captp` | `o+N`, `p+N` | capdata | Cross-process `E()` calls | +| Kernel marshal | `kernel-marshal.ts` | `ko*`, `kp*` | smallcaps | Kernel-to-vat messages | +| PresenceManager marshal | `kref-presence.ts` | `ko*`, `kp*` | smallcaps | Deserialize results for host | + +## Outbound Flow: Host Application to Kernel + +When the host application sends a message to a vat via the kernel: + +``` +Host Application Kernel Worker + │ │ + │ 1. Prepare call with kref strings │ + │ { target: 'ko42', method: 'foo' } │ + │ │ + │ 2. convertKrefsToStandins() │ + │ 'ko42' → kslot() → Exo remotable │ + │ │ + │ 3. E(kernelFacade).queueMessage() │ + │ CapTP serialize: remotable → o+1 │ + │ │ + │ ──────── postMessage channel ──────────► │ + │ │ + │ │ 4. CapTP deserialize + │ │ o+1 → remotable + │ │ + │ │ 5. kser([method, args]) + │ │ remotable → 'ko42' in slots + │ │ + │ │ 6. Message stored for vat delivery + │ │ Format: CapData + │ │ +``` + +### Step-by-Step Breakdown + +1. **Host prepares call** - The host application prepares a message with kref strings identifying the target object and any object references in the arguments. + +2. **Convert krefs to standins** - `convertKrefsToStandins()` transforms kref strings into Exo remotable objects that CapTP can serialize. This happens in `kernel-facade.ts`. + +3. **CapTP serializes** - When `E(kernelFacade).queueMessage()` is called, CapTP's internal marshal converts the remotable objects into CapTP-style slots (`o+1`, `p+2`, etc.). + +4. **CapTP deserializes** - On the kernel worker side, CapTP converts the slots back to remotable objects. + +5. **Kernel marshal serializes** - `kser([method, args])` converts the remotables to CapData with kref slots (`ko42`, `kp99`). + +6. **Message stored** - The kernel stores the message in `CapData` format for delivery to the target vat. + +## Inbound Flow: Kernel to Host Application + +When a vat returns a result back to the host application: + +``` +Kernel Worker Host Application + │ │ + │ 1. Vat executes and returns result │ + │ Format: CapData │ + │ │ + │ 2. Kernel resolves promise │ + │ CapData associated with kp │ + │ │ + │ 3. CapTP serializes result │ + │ CapTP message with CapData payload │ + │ │ + │ ◄─────── postMessage channel ───────── │ + │ │ + │ │ 4. CapTP delivers answer + │ │ Result: CapData + │ │ + │ │ 5. PresenceManager.fromCapData() + │ │ slots['ko42'] → makeKrefPresence() + │ │ + │ │ 6. Host receives E()-callable objects + │ │ +``` + +### Step-by-Step Breakdown + +1. **Vat returns result** - The vat executes the requested method and returns a result, which liveslots marshals into `CapData` format. + +2. **Kernel resolves promise** - The kernel associates the result with the kernel promise (`kp`) that represents the pending call. + +3. **CapTP serializes** - CapTP marshals the result (which contains `CapData`) for transport back to the host. + +4. **CapTP delivers answer** - The host receives the CapTP answer message containing the `CapData` result. + +5. **PresenceManager converts** - `PresenceManager.fromCapData()` deserializes the result, converting kref slots into `E()`-callable presence objects. + +6. **Host receives presences** - The host application receives JavaScript objects with presence objects that can be used with `E()` for further calls. + +## Slot Types + +The system uses two different slot naming schemes: + +### CapTP Slots + +Used by `@endo/captp` for cross-process object references: + +| Prefix | Meaning | +| ------ | ----------------------------------- | +| `o+N` | Exported object (positive = export) | +| `o-N` | Imported object (negative = import) | +| `p+N` | Exported promise | +| `p-N` | Imported promise | + +### Kernel Slots (KRefs) + +Used by the kernel for internal object tracking: + +| Prefix | Meaning | +| ------ | -------------- | +| `ko` | Kernel object | +| `kp` | Kernel promise | +| `kd` | Kernel device | +| `v` | Vat reference | + +KRefs are globally unique within a kernel and survive across process restarts. + +## Custom Conversion Functions + +### `convertKrefsToStandins` + +**Location:** `packages/ocap-kernel/src/kref-presence.ts` + +**Direction:** Outbound (host to kernel) + +**Purpose:** Transforms kref strings into `kslot()` Exo remotable objects that CapTP can serialize. + +```typescript +// Input +{ target: 'ko42', data: { ref: 'ko43' } } + +// Output +{ target: , data: { ref: } } +``` + +### `convertPresencesToStandins` + +**Location:** `packages/ocap-kernel/src/kref-presence.ts` + +**Direction:** Outbound (host to kernel) + +**Purpose:** Combines presence-to-kref and kref-to-standin conversions. Transforms presence objects directly into standins. + +### `PresenceManager.fromCapData` + +**Location:** `packages/ocap-kernel/src/kref-presence.ts` + +**Direction:** Inbound (kernel to host) + +**Purpose:** Deserializes `CapData` into JavaScript objects with `E()`-callable presences. + +```typescript +// Input: CapData +{ body: '{"@qclass":"slot","index":0}', slots: ['ko42'] } + +// Output + +``` + +## Supported Data Types + +The serialization pipeline supports JSON-compatible data types plus special object-capability types: + +### Supported + +- Primitives: `string`, `number`, `boolean`, `null`, `undefined` +- Collections: `Array`, plain `Object` +- Special: `BigInt`, `Symbol` (well-known only) +- OCap types: Remotable objects, Promises + +### Not Supported + +- `CopyTagged` objects (custom tagged data) - not supported in this pipeline +- Circular references +- Functions (except as part of Remotable objects) +- DOM objects, Buffers, or other platform-specific types + +### Important Notes + +1. All data passing through the pipeline must be JSON-serializable at its core +2. Object references are converted to slot strings and back, not passed directly +3. Promises are tracked by the kernel and resolved asynchronously +4. Remotable objects become presences that queue messages rather than invoking methods directly + +## Why Two Levels of Marshaling? + +``` +Host Process Kernel Worker + │ │ + │ CapTP marshal │ Kernel marshal + │ (o+/p+ slots) │ (ko/kp slots) + │ │ + └────────── postMessage ──────────────┘ + │ + JSON transport +``` + +The two-level marshaling serves distinct purposes: + +- **CapTP marshal**: Provides a general-purpose RPC mechanism for cross-process object passing. It knows nothing about kernel internals and uses its own slot numbering. + +- **Kernel marshal**: Handles kernel-specific concerns like persistent object identity, vat isolation, and garbage collection. KRefs must be stable across kernel restarts. + +The separation allows the kernel to use any transport mechanism (not just CapTP) while maintaining consistent internal object references.