From 1b46ad502f52f3bc2db47ca7de9e5e303d387ffe Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Mar 2026 13:56:10 -0800 Subject: [PATCH 1/3] feat(ocap-kernel): use E() for kernel service invocation to support remote presences Switch invokeKernelService from direct property access to E() from @endo/eventual-send, enabling CapTP remote presences to be registered as kernel service objects. Expose registerKernelServiceObject on the kernel facet so callers over CapTP can register services. Co-Authored-By: Claude Opus 4.6 --- .../kernel-worker/captp/kernel-captp.test.ts | 1 + packages/ocap-kernel/package.json | 1 + .../src/KernelServiceManager.test.ts | 108 ++++++++++++++---- .../ocap-kernel/src/KernelServiceManager.ts | 29 ++--- packages/ocap-kernel/src/kernel-facet.test.ts | 7 ++ packages/ocap-kernel/src/kernel-facet.ts | 1 + yarn.lock | 1 + 7 files changed, 108 insertions(+), 40 deletions(-) diff --git a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts index 861059db1c..96a91880bd 100644 --- a/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts +++ b/packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts @@ -19,6 +19,7 @@ describe('makeKernelCapTP', () => { launchSubcluster: vi.fn(), pingVat: vi.fn(), queueMessage: vi.fn(), + registerKernelServiceObject: vi.fn(), reset: vi.fn(), terminateSubcluster: vi.fn(), provideFacet: vi.fn(), diff --git a/packages/ocap-kernel/package.json b/packages/ocap-kernel/package.json index 8b65631e39..7694bc7c5c 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/marshal": "^1.8.0", "@endo/pass-style": "^1.6.3", "@endo/promise-kit": "^1.1.13", diff --git a/packages/ocap-kernel/src/KernelServiceManager.test.ts b/packages/ocap-kernel/src/KernelServiceManager.test.ts index af375ba766..6ccf6a5d37 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.test.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.test.ts @@ -9,6 +9,22 @@ import { makeKernelStore } from './store/index.ts'; import type { Message } from './types.ts'; import { makeMapKernelDatabase } from '../test/storage.ts'; +/** + * Create a trackable service method that records calls without using vi.fn(), + * which doesn't work well with E() under SES/lockdown (frozen mock state). + * + * @param implementation - Optional implementation to call. + * @returns An object with the method and call tracking state. + */ +function makeTrackableMethod(implementation?: (...args: unknown[]) => unknown) { + const calls: unknown[][] = []; + const method = (...args: unknown[]) => { + calls.push(args); + return implementation?.(...args); + }; + return { method, calls }; +} + describe('KernelServiceManager', () => { let serviceManager: KernelServiceManager; let kernelStore: ReturnType; @@ -272,10 +288,10 @@ describe('KernelServiceManager', () => { describe('invokeKernelService', () => { it('successfully invokes a service method without result', async () => { - const testMethod = vi.fn().mockReturnValue('test result'); - const testService = { - testMethod, - }; + const { method: testMethod, calls } = makeTrackableMethod( + () => 'test result', + ); + const testService = { testMethod }; const registered = serviceManager.registerKernelServiceObject( 'testService', @@ -289,15 +305,15 @@ describe('KernelServiceManager', () => { serviceManager.invokeKernelService(registered.kref, message); await delay(); - expect(testMethod).toHaveBeenCalledWith('arg1', 'arg2'); + expect(calls).toStrictEqual([['arg1', 'arg2']]); expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); }); it('successfully invokes a service method with result', async () => { - const testMethod = vi.fn().mockResolvedValue('test result'); - const testService = { - testMethod, - }; + const { method: testMethod, calls } = makeTrackableMethod(async () => + Promise.resolve('test result'), + ); + const testService = { testMethod }; const registered = serviceManager.registerKernelServiceObject( 'testService', @@ -312,7 +328,7 @@ describe('KernelServiceManager', () => { serviceManager.invokeKernelService(registered.kref, message); await delay(); - expect(testMethod).toHaveBeenCalledWith('arg1'); + expect(calls).toStrictEqual([['arg1']]); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ ['kp123', false, kser('test result')], ]); @@ -320,10 +336,10 @@ describe('KernelServiceManager', () => { it('handles errors when invoking service method with result', async () => { const testError = new Error('Test error'); - const testMethod = vi.fn().mockRejectedValue(testError); - const testService = { - testMethod, - }; + const { method: testMethod } = makeTrackableMethod(async () => + Promise.reject(testError), + ); + const testService = { testMethod }; const registered = serviceManager.registerKernelServiceObject( 'testService', @@ -346,10 +362,10 @@ describe('KernelServiceManager', () => { it('handles errors when invoking service method without result', async () => { const loggerErrorSpy = vi.spyOn(logger, 'error'); const testError = new Error('Test error'); - const testMethod = vi.fn().mockRejectedValue(testError); - const testService = { - testMethod, - }; + const { method: testMethod } = makeTrackableMethod(async () => + Promise.reject(testError), + ); + const testService = { testMethod }; const registered = serviceManager.registerKernelServiceObject( 'testService', @@ -399,7 +415,13 @@ describe('KernelServiceManager', () => { await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ - ['kp123', true, kser(Error("unknown service method 'unknownMethod'"))], + [ + 'kp123', + true, + expect.objectContaining({ + body: expect.stringContaining('unknownMethod'), + }), + ], ]); }); @@ -422,7 +444,10 @@ describe('KernelServiceManager', () => { await delay(); expect(loggerErrorSpy).toHaveBeenCalledWith( - "unknown service method 'unknownMethod'", + 'Error in kernel service method:', + expect.objectContaining({ + message: expect.stringContaining('unknownMethod'), + }), ); expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled(); }); @@ -444,7 +469,48 @@ describe('KernelServiceManager', () => { await delay(); expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ - ['kp123', true, kser(Error("unknown service method 'anyMethod'"))], + [ + 'kp123', + true, + expect.objectContaining({ + body: expect.stringContaining('anyMethod'), + }), + ], + ]); + }); + + it('invokes methods on a proxy-based service (simulated remote presence)', async () => { + const { method: testMethod, calls } = makeTrackableMethod(async () => + Promise.resolve('remote result'), + ); + const proxyService = new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'testMethod') { + return testMethod; + } + return undefined; + }, + }, + ); + + const registered = serviceManager.registerKernelServiceObject( + 'proxyService', + proxyService, + ); + + const message: Message = { + methargs: kser(['testMethod', ['arg1']]), + result: 'kp123', + }; + + serviceManager.invokeKernelService(registered.kref, message); + await delay(); + + expect(calls).toStrictEqual([['arg1']]); + expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [ + ['kp123', false, kser('remote result')], ]); }); diff --git a/packages/ocap-kernel/src/KernelServiceManager.ts b/packages/ocap-kernel/src/KernelServiceManager.ts index f3c1dc001b..0af3164ff1 100644 --- a/packages/ocap-kernel/src/KernelServiceManager.ts +++ b/packages/ocap-kernel/src/KernelServiceManager.ts @@ -1,3 +1,4 @@ +import { E } from '@endo/eventual-send'; import type { Logger } from '@metamask/logger'; import type { KernelQueue } from './KernelQueue.ts'; @@ -156,30 +157,20 @@ export class KernelServiceManager { if (result) { assert.typeof(result, 'string'); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type - const service = kernelService.service as Record; - const methodFunction = service[method]; - if (methodFunction === undefined) { - if (result) { - this.#kernelQueue.resolvePromises('kernel', [ - [result, true, kser(Error(`unknown service method '${method}'`))], - ]); - } else { - this.#logger?.error(`unknown service method '${method}'`); - } - return; - } - assert.typeof(methodFunction, 'function'); assert(Array.isArray(args)); + // Use E() so this works for both local objects and remote presences + // (CapTP proxies whose methods aren't enumerable). // Call the method without awaiting. This allows the crank to complete // even if the method internally waits for the crank to end. try { - const maybePromise = methodFunction.apply(service, args); - // Use Promise.resolve to normalize: if maybePromise is a Promise, it - // returns that Promise; if it's a value, it returns an immediately- - // resolved Promise. - Promise.resolve(maybePromise) + const service = kernelService.service as Record< + string, + (...methodArgs: unknown[]) => unknown + >; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const resultPromise = E(service)[method]!(...args); + Promise.resolve(resultPromise) .then((resultValue) => { if (result) { this.#kernelQueue.resolvePromises('kernel', [ diff --git a/packages/ocap-kernel/src/kernel-facet.test.ts b/packages/ocap-kernel/src/kernel-facet.test.ts index 4e53f8a582..aec0cfe475 100644 --- a/packages/ocap-kernel/src/kernel-facet.test.ts +++ b/packages/ocap-kernel/src/kernel-facet.test.ts @@ -19,6 +19,12 @@ const makeMockKernel = (): KernelFacetSource => ({ }), pingVat: async () => Promise.resolve('pong'), queueMessage: async () => Promise.resolve({ body: '#null', slots: [] }), + registerKernelServiceObject: () => ({ + name: 'test', + kref: 'ko1', + service: {}, + systemOnly: false, + }), reset: async () => Promise.resolve(), terminateSubcluster: async () => Promise.resolve(), }); @@ -36,6 +42,7 @@ describe('makeKernelFacet', () => { expect(typeof facet.ping).toBe('function'); expect(typeof facet.pingVat).toBe('function'); expect(typeof facet.queueMessage).toBe('function'); + expect(typeof facet.registerKernelServiceObject).toBe('function'); expect(typeof facet.reset).toBe('function'); expect(typeof facet.terminateSubcluster).toBe('function'); }); diff --git a/packages/ocap-kernel/src/kernel-facet.ts b/packages/ocap-kernel/src/kernel-facet.ts index 79ecbe4438..fb95fb4442 100644 --- a/packages/ocap-kernel/src/kernel-facet.ts +++ b/packages/ocap-kernel/src/kernel-facet.ts @@ -11,6 +11,7 @@ const kernelFacetMethodNames = [ 'launchSubcluster', 'pingVat', 'queueMessage', + 'registerKernelServiceObject', 'reset', 'terminateSubcluster', ] as const; diff --git a/yarn.lock b/yarn.lock index 41672ece52..970aa392b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,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/marshal": "npm:^1.8.0" "@endo/pass-style": "npm:^1.6.3" "@endo/promise-kit": "npm:^1.1.13" From 42875ad49943acfd2787d487067e27dcb9a6ea20 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:54:27 -0800 Subject: [PATCH 2/3] test: Fix kernel service / facet tests --- .../src/kernel-worker/captp/captp.integration.test.ts | 1 + packages/kernel-test/src/service.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) 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 4eb2d94610..08d7b99569 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 @@ -49,6 +49,7 @@ describe('CapTP Integration', () => { }), reset: vi.fn().mockResolvedValue(undefined), terminateSubcluster: vi.fn().mockResolvedValue(undefined), + registerKernelServiceObject: vi.fn(), provideFacet: vi.fn(), } as unknown as Kernel; diff --git a/packages/kernel-test/src/service.test.ts b/packages/kernel-test/src/service.test.ts index 5275daf062..2313e19dee 100644 --- a/packages/kernel-test/src/service.test.ts +++ b/packages/kernel-test/src/service.test.ts @@ -88,8 +88,10 @@ describe('Kernel service object invocation', () => { await kernel.queueMessage(testVatRootObject, 'goBadly', []); await waitUntilQuiescent(100); const testLogs = extractTestLogs(entries); - expect(testLogs).toContain( - `kernel service threw: unknown service method 'nonexistentMethod'`, + expect(testLogs).toStrictEqual( + expect.arrayContaining([ + expect.stringMatching(/kernel service threw:.*nonexistentMethod/u), + ]), ); }); }); From e9cd1c0602b6a9fbcc0563fff705031f8bbbb7f5 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 6 Mar 2026 16:28:27 -0800 Subject: [PATCH 3/3] test(nodejs): add e2e test for kernel service invocation via CapTP remote presence Proves the full round-trip: a worker thread creates an exo, registers it on the kernel over CapTP, a real vat invokes a method on it, the call traverses CapTP back to the worker, and the result returns. Co-Authored-By: Claude Opus 4.6 --- packages/nodejs/package.json | 1 + .../nodejs/test/e2e/captp-service.test.ts | 98 +++++++++++++++++++ .../nodejs/test/vats/captp-service-vat.ts | 22 +++++ .../test/workers/captp-service-client.js | 42 ++++++++ yarn.lock | 1 + 5 files changed, 164 insertions(+) create mode 100644 packages/nodejs/test/e2e/captp-service.test.ts create mode 100644 packages/nodejs/test/vats/captp-service-vat.ts create mode 100644 packages/nodejs/test/workers/captp-service-client.js diff --git a/packages/nodejs/package.json b/packages/nodejs/package.json index 6b327988e2..681b3b0469 100644 --- a/packages/nodejs/package.json +++ b/packages/nodejs/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.17.4", + "@endo/captp": "^4.4.8", "@metamask/auto-changelog": "^5.3.0", "@metamask/eslint-config": "^15.0.0", "@metamask/eslint-config-nodejs": "^15.0.0", diff --git a/packages/nodejs/test/e2e/captp-service.test.ts b/packages/nodejs/test/e2e/captp-service.test.ts new file mode 100644 index 0000000000..e1d1cd550d --- /dev/null +++ b/packages/nodejs/test/e2e/captp-service.test.ts @@ -0,0 +1,98 @@ +import { makeCapTP } from '@endo/captp'; +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { Kernel, kunser } from '@metamask/ocap-kernel'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; +import { Worker } from 'node:worker_threads'; +import { describe, it, expect, afterEach } from 'vitest'; + +import { makeTestKernel } from '../helpers/kernel.ts'; + +const CAPTP_SERVICE_VAT_BUNDLE_URL = + 'http://localhost:3000/captp-service-vat.bundle'; + +const READY_SIGNAL = 'captp-service-client:ready'; + +const workerPath = new URL( + '../workers/captp-service-client.js', + import.meta.url, +).pathname; + +describe('CapTP kernel service registration', { timeout: 30_000 }, () => { + let kernel: Kernel | undefined; + let worker: Worker | undefined; + let abortCapTP: ((reason?: unknown) => void) | undefined; + + afterEach(async () => { + abortCapTP?.('test cleanup'); + abortCapTP = undefined; + + if (worker) { + const workerRef = worker; + worker = undefined; + await workerRef.terminate(); + } + + if (kernel) { + const stopResult = kernel.stop(); + kernel = undefined; + await stopResult; + } + }); + + it('vat invokes a method on a service object registered over CapTP from a worker', async () => { + // 1. Create a real kernel + kernel = await makeTestKernel( + await makeSQLKernelDatabase({ dbFilename: ':memory:' }), + ); + + // 2. Spawn the worker that will act as the CapTP client + worker = new Worker(workerPath); + + // 3. Set up CapTP on the main thread (kernel side) + const { dispatch, abort } = makeCapTP( + 'kernel', + (message: Record) => worker!.postMessage(message), + kernel.provideFacet(), + ); + abortCapTP = abort; + + // 4. Wire up message dispatching from worker → kernel CapTP + // and wait for the worker to signal that registration is complete + await new Promise((resolve, reject) => { + worker!.on('message', (message: unknown) => { + if (message === READY_SIGNAL) { + resolve(); + } else { + dispatch(message as Record); + } + }); + worker!.on('error', reject); + worker!.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker exited with code ${code}`)); + } + }); + }); + + // 5. Launch a subcluster with a vat that uses the 'testService' service + const config: ClusterConfig = { + bootstrap: 'main', + services: ['testService'], + vats: { + main: { + bundleSpec: CAPTP_SERVICE_VAT_BUNDLE_URL, + }, + }, + }; + + const { rootKref } = await kernel.launchSubcluster(config); + await waitUntilQuiescent(); + + // 6. Have the vat call E(testService).doSomething(3, 4) and verify the result + const result = await kernel.queueMessage(rootKref, 'go', []); + await waitUntilQuiescent(); + + expect(kunser(result)).toBe(7); + }); +}); diff --git a/packages/nodejs/test/vats/captp-service-vat.ts b/packages/nodejs/test/vats/captp-service-vat.ts new file mode 100644 index 0000000000..1977b4cbc5 --- /dev/null +++ b/packages/nodejs/test/vats/captp-service-vat.ts @@ -0,0 +1,22 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Build function for a vat that invokes a CapTP-registered kernel service. + * + * @param _vatPowers - Special powers granted to this vat (unused). + * @param _parameters - Initialization parameters (unused). + * @returns The root object for the new vat. + */ +export function buildRootObject(_vatPowers: unknown, _parameters: unknown) { + let testService: unknown; + + return makeDefaultExo('root', { + async bootstrap(_vats: unknown, services: { testService: unknown }) { + testService = services.testService; + }, + async go() { + return E(testService).doSomething(3, 4); + }, + }); +} diff --git a/packages/nodejs/test/workers/captp-service-client.js b/packages/nodejs/test/workers/captp-service-client.js new file mode 100644 index 0000000000..c5b198061b --- /dev/null +++ b/packages/nodejs/test/workers/captp-service-client.js @@ -0,0 +1,42 @@ +// @ts-check + +import '@metamask/kernel-shims/endoify-node'; + +import { makeCapTP } from '@endo/captp'; +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils'; +import { parentPort } from 'node:worker_threads'; + +if (!parentPort) { + throw new Error('Expected to run as a Node.js worker thread'); +} + +const port = parentPort; + +const READY_SIGNAL = 'captp-service-client:ready'; + +const { dispatch, getBootstrap } = makeCapTP( + 'service-client', + (message) => port.postMessage(message), + undefined, +); + +port.on('message', (message) => { + dispatch(message); +}); + +const testExo = makeDefaultExo('testExo', { + doSomething(left, right) { + return left + right; + }, +}); + +async function main() { + const kernel = await getBootstrap(); + await E(kernel).registerKernelServiceObject('testService', testExo); + port.postMessage(READY_SIGNAL); +} + +main().catch((error) => { + throw error; +}); diff --git a/yarn.lock b/yarn.lock index 970aa392b9..d900788bb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3962,6 +3962,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@chainsafe/libp2p-quic": "npm:^1.1.8" + "@endo/captp": "npm:^4.4.8" "@endo/eventual-send": "npm:^1.3.4" "@endo/promise-kit": "npm:^1.1.13" "@libp2p/interface": "npm:2.11.0"