Skip to content

Commit 4e5da63

Browse files
rekmarksclaude
andauthored
feat(ocap-kernel): use E() for kernel service invocation to support remote presences (#872)
## Description Switches `KernelServiceManager.invokeKernelService()` to use `E()` from `@endo/eventual-send` instead of direct property access. This enables CapTP remote presences (whose methods aren't enumerable) to be registered as kernel service objects — a separate process can now create an exo, register it on the kernel over CapTP, and have vats invoke methods on it seamlessly. ## Changes - **`KernelServiceManager.ts`** — Replace direct `service[method]()` invocation with `E(service)[method]()`, removing the manual method-exists check (E() handles this natively) - **`kernel-facet.ts`** — Add `registerKernelServiceObject` to the kernel facet so it's callable over CapTP - **`captp-service-client.js`** (new) — Worker thread that sets up a CapTP connection to the kernel, creates a test exo, and registers it as a kernel service - **`captp-service-vat.ts`** (new) — Minimal vat that calls `E(testService).doSomething(3, 4)` and returns the result - **`captp-service.test.ts`** (new) — E2E test proving the full round-trip: worker registers exo over CapTP → vat invokes method → call traverses CapTP → result returns - **`service.test.ts`** — Fix assertion for error message format change (exact match → regex) - **`captp.integration.test.ts`** — Add `registerKernelServiceObject` to mock kernel (required by `makeKernelFacet`) ## Testing The core behavior change is unit-tested in `KernelServiceManager.test.ts` (updated to cover both local exo and remote presence scenarios). The new e2e test in `packages/nodejs/test/e2e/captp-service.test.ts` validates the full CapTP round-trip: a real kernel, a worker thread acting as a CapTP client, and a bundled vat that invokes the remotely-registered service. The existing `kernel-test` service test and `kernel-browser-runtime` integration tests continue to pass with the updated assertions. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the kernel’s service invocation path to use `E()` (asynchronous eventual-send) and exposes service registration over the kernel facet, which can affect error propagation and method resolution for all kernel services. > > **Overview** > **Kernel services can now be implemented as remote CapTP presences.** `KernelServiceManager.invokeKernelService()` switches from direct `service[method](...)` calls to `E(service)[method](...)`, enabling proxy/presence objects whose methods aren’t enumerable. > > The kernel facet now exposes `registerKernelServiceObject`, and tests/mocks are updated accordingly (including looser assertions around error formatting under eventual-send/SES). > > Adds a Node.js e2e that spawns a worker CapTP client to register a service exo over CapTP and verifies a vat can call it end-to-end; updates dependencies (`@endo/eventual-send` in `ocap-kernel`, `@endo/captp` for nodejs tests). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit e9cd1c0. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent be13ec6 commit 4e5da63

13 files changed

Lines changed: 277 additions & 42 deletions

File tree

packages/kernel-browser-runtime/src/kernel-worker/captp/captp.integration.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe('CapTP Integration', () => {
4949
}),
5050
reset: vi.fn().mockResolvedValue(undefined),
5151
terminateSubcluster: vi.fn().mockResolvedValue(undefined),
52+
registerKernelServiceObject: vi.fn(),
5253
provideFacet: vi.fn(),
5354
} as unknown as Kernel;
5455

packages/kernel-browser-runtime/src/kernel-worker/captp/kernel-captp.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ describe('makeKernelCapTP', () => {
1919
launchSubcluster: vi.fn(),
2020
pingVat: vi.fn(),
2121
queueMessage: vi.fn(),
22+
registerKernelServiceObject: vi.fn(),
2223
reset: vi.fn(),
2324
terminateSubcluster: vi.fn(),
2425
provideFacet: vi.fn(),

packages/kernel-test/src/service.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,10 @@ describe('Kernel service object invocation', () => {
8888
await kernel.queueMessage(testVatRootObject, 'goBadly', []);
8989
await waitUntilQuiescent(100);
9090
const testLogs = extractTestLogs(entries);
91-
expect(testLogs).toContain(
92-
`kernel service threw: unknown service method 'nonexistentMethod'`,
91+
expect(testLogs).toStrictEqual(
92+
expect.arrayContaining([
93+
expect.stringMatching(/kernel service threw:.*nonexistentMethod/u),
94+
]),
9395
);
9496
});
9597
});

packages/nodejs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
},
7878
"devDependencies": {
7979
"@arethetypeswrong/cli": "^0.17.4",
80+
"@endo/captp": "^4.4.8",
8081
"@metamask/auto-changelog": "^5.3.0",
8182
"@metamask/eslint-config": "^15.0.0",
8283
"@metamask/eslint-config-nodejs": "^15.0.0",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { makeCapTP } from '@endo/captp';
2+
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
3+
import { waitUntilQuiescent } from '@metamask/kernel-utils';
4+
import { Kernel, kunser } from '@metamask/ocap-kernel';
5+
import type { ClusterConfig } from '@metamask/ocap-kernel';
6+
import { Worker } from 'node:worker_threads';
7+
import { describe, it, expect, afterEach } from 'vitest';
8+
9+
import { makeTestKernel } from '../helpers/kernel.ts';
10+
11+
const CAPTP_SERVICE_VAT_BUNDLE_URL =
12+
'http://localhost:3000/captp-service-vat.bundle';
13+
14+
const READY_SIGNAL = 'captp-service-client:ready';
15+
16+
const workerPath = new URL(
17+
'../workers/captp-service-client.js',
18+
import.meta.url,
19+
).pathname;
20+
21+
describe('CapTP kernel service registration', { timeout: 30_000 }, () => {
22+
let kernel: Kernel | undefined;
23+
let worker: Worker | undefined;
24+
let abortCapTP: ((reason?: unknown) => void) | undefined;
25+
26+
afterEach(async () => {
27+
abortCapTP?.('test cleanup');
28+
abortCapTP = undefined;
29+
30+
if (worker) {
31+
const workerRef = worker;
32+
worker = undefined;
33+
await workerRef.terminate();
34+
}
35+
36+
if (kernel) {
37+
const stopResult = kernel.stop();
38+
kernel = undefined;
39+
await stopResult;
40+
}
41+
});
42+
43+
it('vat invokes a method on a service object registered over CapTP from a worker', async () => {
44+
// 1. Create a real kernel
45+
kernel = await makeTestKernel(
46+
await makeSQLKernelDatabase({ dbFilename: ':memory:' }),
47+
);
48+
49+
// 2. Spawn the worker that will act as the CapTP client
50+
worker = new Worker(workerPath);
51+
52+
// 3. Set up CapTP on the main thread (kernel side)
53+
const { dispatch, abort } = makeCapTP(
54+
'kernel',
55+
(message: Record<string, unknown>) => worker!.postMessage(message),
56+
kernel.provideFacet(),
57+
);
58+
abortCapTP = abort;
59+
60+
// 4. Wire up message dispatching from worker → kernel CapTP
61+
// and wait for the worker to signal that registration is complete
62+
await new Promise<void>((resolve, reject) => {
63+
worker!.on('message', (message: unknown) => {
64+
if (message === READY_SIGNAL) {
65+
resolve();
66+
} else {
67+
dispatch(message as Record<string, unknown>);
68+
}
69+
});
70+
worker!.on('error', reject);
71+
worker!.on('exit', (code) => {
72+
if (code !== 0) {
73+
reject(new Error(`Worker exited with code ${code}`));
74+
}
75+
});
76+
});
77+
78+
// 5. Launch a subcluster with a vat that uses the 'testService' service
79+
const config: ClusterConfig = {
80+
bootstrap: 'main',
81+
services: ['testService'],
82+
vats: {
83+
main: {
84+
bundleSpec: CAPTP_SERVICE_VAT_BUNDLE_URL,
85+
},
86+
},
87+
};
88+
89+
const { rootKref } = await kernel.launchSubcluster(config);
90+
await waitUntilQuiescent();
91+
92+
// 6. Have the vat call E(testService).doSomething(3, 4) and verify the result
93+
const result = await kernel.queueMessage(rootKref, 'go', []);
94+
await waitUntilQuiescent();
95+
96+
expect(kunser(result)).toBe(7);
97+
});
98+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { E } from '@endo/eventual-send';
2+
import { makeDefaultExo } from '@metamask/kernel-utils/exo';
3+
4+
/**
5+
* Build function for a vat that invokes a CapTP-registered kernel service.
6+
*
7+
* @param _vatPowers - Special powers granted to this vat (unused).
8+
* @param _parameters - Initialization parameters (unused).
9+
* @returns The root object for the new vat.
10+
*/
11+
export function buildRootObject(_vatPowers: unknown, _parameters: unknown) {
12+
let testService: unknown;
13+
14+
return makeDefaultExo('root', {
15+
async bootstrap(_vats: unknown, services: { testService: unknown }) {
16+
testService = services.testService;
17+
},
18+
async go() {
19+
return E(testService).doSomething(3, 4);
20+
},
21+
});
22+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// @ts-check
2+
3+
import '@metamask/kernel-shims/endoify-node';
4+
5+
import { makeCapTP } from '@endo/captp';
6+
import { E } from '@endo/eventual-send';
7+
import { makeDefaultExo } from '@metamask/kernel-utils';
8+
import { parentPort } from 'node:worker_threads';
9+
10+
if (!parentPort) {
11+
throw new Error('Expected to run as a Node.js worker thread');
12+
}
13+
14+
const port = parentPort;
15+
16+
const READY_SIGNAL = 'captp-service-client:ready';
17+
18+
const { dispatch, getBootstrap } = makeCapTP(
19+
'service-client',
20+
(message) => port.postMessage(message),
21+
undefined,
22+
);
23+
24+
port.on('message', (message) => {
25+
dispatch(message);
26+
});
27+
28+
const testExo = makeDefaultExo('testExo', {
29+
doSomething(left, right) {
30+
return left + right;
31+
},
32+
});
33+
34+
async function main() {
35+
const kernel = await getBootstrap();
36+
await E(kernel).registerKernelServiceObject('testService', testExo);
37+
port.postMessage(READY_SIGNAL);
38+
}
39+
40+
main().catch((error) => {
41+
throw error;
42+
});

packages/ocap-kernel/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"@chainsafe/libp2p-noise": "^16.1.3",
7373
"@chainsafe/libp2p-yamux": "patch:@chainsafe/libp2p-yamux@npm%3A7.0.4#~/.yarn/patches/@chainsafe-libp2p-yamux-npm-7.0.4-284c2f6812.patch",
7474
"@endo/errors": "^1.2.13",
75+
"@endo/eventual-send": "^1.3.4",
7576
"@endo/marshal": "^1.8.0",
7677
"@endo/pass-style": "^1.6.3",
7778
"@endo/promise-kit": "^1.1.13",

packages/ocap-kernel/src/KernelServiceManager.test.ts

Lines changed: 87 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ import { makeKernelStore } from './store/index.ts';
99
import type { Message } from './types.ts';
1010
import { makeMapKernelDatabase } from '../test/storage.ts';
1111

12+
/**
13+
* Create a trackable service method that records calls without using vi.fn(),
14+
* which doesn't work well with E() under SES/lockdown (frozen mock state).
15+
*
16+
* @param implementation - Optional implementation to call.
17+
* @returns An object with the method and call tracking state.
18+
*/
19+
function makeTrackableMethod(implementation?: (...args: unknown[]) => unknown) {
20+
const calls: unknown[][] = [];
21+
const method = (...args: unknown[]) => {
22+
calls.push(args);
23+
return implementation?.(...args);
24+
};
25+
return { method, calls };
26+
}
27+
1228
describe('KernelServiceManager', () => {
1329
let serviceManager: KernelServiceManager;
1430
let kernelStore: ReturnType<typeof makeKernelStore>;
@@ -272,10 +288,10 @@ describe('KernelServiceManager', () => {
272288

273289
describe('invokeKernelService', () => {
274290
it('successfully invokes a service method without result', async () => {
275-
const testMethod = vi.fn().mockReturnValue('test result');
276-
const testService = {
277-
testMethod,
278-
};
291+
const { method: testMethod, calls } = makeTrackableMethod(
292+
() => 'test result',
293+
);
294+
const testService = { testMethod };
279295

280296
const registered = serviceManager.registerKernelServiceObject(
281297
'testService',
@@ -289,15 +305,15 @@ describe('KernelServiceManager', () => {
289305
serviceManager.invokeKernelService(registered.kref, message);
290306
await delay();
291307

292-
expect(testMethod).toHaveBeenCalledWith('arg1', 'arg2');
308+
expect(calls).toStrictEqual([['arg1', 'arg2']]);
293309
expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled();
294310
});
295311

296312
it('successfully invokes a service method with result', async () => {
297-
const testMethod = vi.fn().mockResolvedValue('test result');
298-
const testService = {
299-
testMethod,
300-
};
313+
const { method: testMethod, calls } = makeTrackableMethod(async () =>
314+
Promise.resolve('test result'),
315+
);
316+
const testService = { testMethod };
301317

302318
const registered = serviceManager.registerKernelServiceObject(
303319
'testService',
@@ -312,18 +328,18 @@ describe('KernelServiceManager', () => {
312328
serviceManager.invokeKernelService(registered.kref, message);
313329
await delay();
314330

315-
expect(testMethod).toHaveBeenCalledWith('arg1');
331+
expect(calls).toStrictEqual([['arg1']]);
316332
expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
317333
['kp123', false, kser('test result')],
318334
]);
319335
});
320336

321337
it('handles errors when invoking service method with result', async () => {
322338
const testError = new Error('Test error');
323-
const testMethod = vi.fn().mockRejectedValue(testError);
324-
const testService = {
325-
testMethod,
326-
};
339+
const { method: testMethod } = makeTrackableMethod(async () =>
340+
Promise.reject(testError),
341+
);
342+
const testService = { testMethod };
327343

328344
const registered = serviceManager.registerKernelServiceObject(
329345
'testService',
@@ -346,10 +362,10 @@ describe('KernelServiceManager', () => {
346362
it('handles errors when invoking service method without result', async () => {
347363
const loggerErrorSpy = vi.spyOn(logger, 'error');
348364
const testError = new Error('Test error');
349-
const testMethod = vi.fn().mockRejectedValue(testError);
350-
const testService = {
351-
testMethod,
352-
};
365+
const { method: testMethod } = makeTrackableMethod(async () =>
366+
Promise.reject(testError),
367+
);
368+
const testService = { testMethod };
353369

354370
const registered = serviceManager.registerKernelServiceObject(
355371
'testService',
@@ -399,7 +415,13 @@ describe('KernelServiceManager', () => {
399415
await delay();
400416

401417
expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
402-
['kp123', true, kser(Error("unknown service method 'unknownMethod'"))],
418+
[
419+
'kp123',
420+
true,
421+
expect.objectContaining({
422+
body: expect.stringContaining('unknownMethod'),
423+
}),
424+
],
403425
]);
404426
});
405427

@@ -422,7 +444,10 @@ describe('KernelServiceManager', () => {
422444
await delay();
423445

424446
expect(loggerErrorSpy).toHaveBeenCalledWith(
425-
"unknown service method 'unknownMethod'",
447+
'Error in kernel service method:',
448+
expect.objectContaining({
449+
message: expect.stringContaining('unknownMethod'),
450+
}),
426451
);
427452
expect(mockKernelQueue.resolvePromises).not.toHaveBeenCalled();
428453
});
@@ -444,7 +469,48 @@ describe('KernelServiceManager', () => {
444469
await delay();
445470

446471
expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
447-
['kp123', true, kser(Error("unknown service method 'anyMethod'"))],
472+
[
473+
'kp123',
474+
true,
475+
expect.objectContaining({
476+
body: expect.stringContaining('anyMethod'),
477+
}),
478+
],
479+
]);
480+
});
481+
482+
it('invokes methods on a proxy-based service (simulated remote presence)', async () => {
483+
const { method: testMethod, calls } = makeTrackableMethod(async () =>
484+
Promise.resolve('remote result'),
485+
);
486+
const proxyService = new Proxy(
487+
{},
488+
{
489+
get: (_target, prop) => {
490+
if (prop === 'testMethod') {
491+
return testMethod;
492+
}
493+
return undefined;
494+
},
495+
},
496+
);
497+
498+
const registered = serviceManager.registerKernelServiceObject(
499+
'proxyService',
500+
proxyService,
501+
);
502+
503+
const message: Message = {
504+
methargs: kser(['testMethod', ['arg1']]),
505+
result: 'kp123',
506+
};
507+
508+
serviceManager.invokeKernelService(registered.kref, message);
509+
await delay();
510+
511+
expect(calls).toStrictEqual([['arg1']]);
512+
expect(mockKernelQueue.resolvePromises).toHaveBeenCalledWith('kernel', [
513+
['kp123', false, kser('remote result')],
448514
]);
449515
});
450516

0 commit comments

Comments
 (0)