From 2c830900e897871710daeed2bc3e5270c2ae6b7e Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:48:10 -0400 Subject: [PATCH 1/2] fix(ocap-kernel): deserialize CapData rejections in Kernel.queueMessage Vat rejections are serialized as CapData objects before leaving the vat runtime. Previously, callers of Kernel.queueMessage (the RPC handler and SubclusterManager) each had their own isCapData/kunser call-site patches to convert them back into plain Errors. Consolidate the deserialization into Kernel.queueMessage itself so all callers see plain Error objects without needing individual workarounds. ([#928](https://github.com/MetaMask/ocap-kernel/pull/928)) --- packages/ocap-kernel/CHANGELOG.md | 4 +++ packages/ocap-kernel/src/Kernel.test.ts | 28 +++++++++++++++++++ packages/ocap-kernel/src/Kernel.ts | 12 ++++++-- .../rpc/kernel-control/queue-message.test.ts | 2 +- .../ocap-kernel/src/vats/SubclusterManager.ts | 21 +++----------- 5 files changed, 47 insertions(+), 20 deletions(-) diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index 54aab81bc4..90b37cecc7 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Deserialize CapData rejections in `Kernel.queueMessage` so vat errors surface as plain `Error` objects to all callers ([#928](https://github.com/MetaMask/ocap-kernel/pull/928)) + ## [0.7.0] ### Added diff --git a/packages/ocap-kernel/src/Kernel.test.ts b/packages/ocap-kernel/src/Kernel.test.ts index 4fbcbc9a1f..ca1dde78b8 100644 --- a/packages/ocap-kernel/src/Kernel.test.ts +++ b/packages/ocap-kernel/src/Kernel.test.ts @@ -7,6 +7,7 @@ import type { Mocked, MockInstance } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Kernel } from './Kernel.ts'; +import { kser } from './liveslots/kernel-marshal.ts'; import type { VatId, VatConfig, @@ -214,6 +215,33 @@ describe('Kernel', () => { const result = await kernel.queueMessage('ko1', 'hello', []); expect(result).toStrictEqual({ body: '{"result":"ok"}', slots: [] }); }); + + it('deserializes CapData rejections into Errors', async () => { + const kernel = await Kernel.make( + mockPlatformServices, + mockKernelDatabase, + ); + mocks.KernelQueue.lastInstance.enqueueMessage.mockRejectedValueOnce( + kser(new Error('vat rejection message')), + ); + await expect(kernel.queueMessage('ko1', 'hello', [])).rejects.toThrow( + 'vat rejection message', + ); + }); + + it('propagates non-CapData rejections as-is', async () => { + const kernel = await Kernel.make( + mockPlatformServices, + mockKernelDatabase, + ); + const internalError = new Error('internal kernel error'); + mocks.KernelQueue.lastInstance.enqueueMessage.mockRejectedValueOnce( + internalError, + ); + await expect(kernel.queueMessage('ko1', 'hello', [])).rejects.toThrow( + 'internal kernel error', + ); + }); }); describe('launchSubcluster()', () => { diff --git a/packages/ocap-kernel/src/Kernel.ts b/packages/ocap-kernel/src/Kernel.ts index 646b4b0fd3..44978e0da3 100644 --- a/packages/ocap-kernel/src/Kernel.ts +++ b/packages/ocap-kernel/src/Kernel.ts @@ -1,5 +1,6 @@ import type { CapData } from '@endo/marshal'; import type { KernelDatabase } from '@metamask/kernel-store'; +import { isCapData } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import { IOManager } from './io/IOManager.ts'; @@ -10,8 +11,8 @@ import { KernelQueue } from './KernelQueue.ts'; import { KernelRouter } from './KernelRouter.ts'; import { KernelServiceManager } from './KernelServiceManager.ts'; import type { KernelService } from './KernelServiceManager.ts'; +import { kslot, kunser } from './liveslots/kernel-marshal.ts'; import type { SlotValue } from './liveslots/kernel-marshal.ts'; -import { kslot } from './liveslots/kernel-marshal.ts'; import { OcapURLManager } from './remotes/kernel/OcapURLManager.ts'; import { RemoteManager } from './remotes/kernel/RemoteManager.ts'; import type { RemoteCommsOptions } from './remotes/types.ts'; @@ -386,7 +387,14 @@ export class Kernel { method: string, args: unknown[], ): Promise> { - return this.#kernelQueue.enqueueMessage(target, method, args); + try { + return await this.#kernelQueue.enqueueMessage(target, method, args); + } catch (rejection) { + if (isCapData(rejection)) { + throw kunser(rejection as CapData); + } + throw rejection; + } } /** diff --git a/packages/ocap-kernel/src/rpc/kernel-control/queue-message.test.ts b/packages/ocap-kernel/src/rpc/kernel-control/queue-message.test.ts index e2fa014770..de399f12ae 100644 --- a/packages/ocap-kernel/src/rpc/kernel-control/queue-message.test.ts +++ b/packages/ocap-kernel/src/rpc/kernel-control/queue-message.test.ts @@ -64,7 +64,7 @@ describe('queueMessageHandler', () => { expect(result).toStrictEqual(expectedResult); }); - it('should propagate errors from kernel.queueMessage', async () => { + it('propagates rejections from kernel.queueMessage', async () => { const error = new Error('Queue message failed'); vi.mocked(mockKernel.queueMessage).mockRejectedValueOnce(error); diff --git a/packages/ocap-kernel/src/vats/SubclusterManager.ts b/packages/ocap-kernel/src/vats/SubclusterManager.ts index cb2fe5f6cd..cc0a9c74a8 100644 --- a/packages/ocap-kernel/src/vats/SubclusterManager.ts +++ b/packages/ocap-kernel/src/vats/SubclusterManager.ts @@ -1,6 +1,5 @@ import type { CapData } from '@endo/marshal'; import { SubclusterNotFoundError } from '@metamask/kernel-errors'; -import { isCapData } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; import type { IOManager } from '../io/IOManager.ts'; @@ -341,22 +340,10 @@ export class SubclusterManager { `Bootstrap vat "${config.bootstrap}" not found in rootIds`, ); } - let bootstrapResult: CapData; - try { - bootstrapResult = await this.#queueMessage(rootKref, 'bootstrap', [ - roots, - services, - ]); - } catch (rejection) { - // queueMessage rejects with CapData for rejected kernel promises. - // Deserialize to surface the original Error to the caller. - // If the rejection isn't CapData (e.g., an internal error before the - // kernel promise was created), re-throw as-is. - if (isCapData(rejection)) { - throw kunser(rejection as CapData); - } - throw rejection; - } + const bootstrapResult = await this.#queueMessage(rootKref, 'bootstrap', [ + roots, + services, + ]); const unserialized = kunser(bootstrapResult); if (unserialized instanceof Error) { throw unserialized; From b672c999d8ab64dfbfd6a1d5bacc63cbd6598f05 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:11:04 -0400 Subject: [PATCH 2/2] test: update rejection assertions after Kernel.queueMessage deserialization All callers of kernel.queueMessage now receive plain Error objects for vat rejections instead of raw CapData. Update 8 test files across evm-wallet-experiment, kernel-node-runtime, and kernel-test to assert rejects.toThrow() instead of rejects.toMatchObject({ body: ... }). --- .../test/integration/peer-wallet.test.ts | 10 ++------- .../test/e2e/libp2p-v3-features.test.ts | 6 +---- .../test/e2e/orphaned-ephemeral-exo.test.ts | 4 +--- .../test/e2e/remote-comms.test.ts | 22 +++++-------------- packages/kernel-test/src/endowments.test.ts | 4 +--- .../src/orphaned-ephemeral-exo.test.ts | 4 +--- .../src/syscall-validation.test.ts | 16 ++++---------- .../kernel-test/src/vat-lifecycle.test.ts | 4 +--- 8 files changed, 17 insertions(+), 53 deletions(-) diff --git a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts index 99e29b39e2..c9fd20ab32 100644 --- a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts +++ b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts @@ -265,11 +265,7 @@ describe.sequential('Peer wallet integration', () => { // keys so this should reject, not forward to kernel1. await expect( kernel2.queueMessage(coordinatorKref2, 'signTransaction', [tx]), - ).rejects.toMatchObject({ - body: expect.stringContaining( - 'No authority to sign this transaction', - ), - }); + ).rejects.toThrow('No authority to sign this transaction'); }, NETWORK_TIMEOUT, ); @@ -284,9 +280,7 @@ describe.sequential('Peer wallet integration', () => { kernel2.queueMessage(coordinatorKref2, 'signMessage', [ 'should fail', ]), - ).rejects.toMatchObject({ - body: expect.stringContaining('No authority to sign message'), - }); + ).rejects.toThrow('No authority to sign message'); }, NETWORK_TIMEOUT, ); diff --git a/packages/kernel-node-runtime/test/e2e/libp2p-v3-features.test.ts b/packages/kernel-node-runtime/test/e2e/libp2p-v3-features.test.ts index 12a86e08da..b55a68a954 100644 --- a/packages/kernel-node-runtime/test/e2e/libp2p-v3-features.test.ts +++ b/packages/kernel-node-runtime/test/e2e/libp2p-v3-features.test.ts @@ -241,11 +241,7 @@ describe.sequential('libp2p v3 Features E2E', () => { 'hello', ['Alice'], ]), - ).rejects.toMatchObject({ - body: expect.stringContaining( - 'Message delivery failed after intentional close', - ), - }); + ).rejects.toThrow('Message delivery failed after intentional close'); const elapsed = Date.now() - start; // Should fail well under the write timeout diff --git a/packages/kernel-node-runtime/test/e2e/orphaned-ephemeral-exo.test.ts b/packages/kernel-node-runtime/test/e2e/orphaned-ephemeral-exo.test.ts index afa54e0799..78b318fe61 100644 --- a/packages/kernel-node-runtime/test/e2e/orphaned-ephemeral-exo.test.ts +++ b/packages/kernel-node-runtime/test/e2e/orphaned-ephemeral-exo.test.ts @@ -58,9 +58,7 @@ describe('Orphaned ephemeral exo', { timeout: 30_000 }, () => { // This is surfaced to the caller as an OBJECT_DELETED kernel error. await expect( kernel.queueMessage(rootKref, 'useEphemeral', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_DELETED]'); } finally { await kernel.stop(); } diff --git a/packages/kernel-node-runtime/test/e2e/remote-comms.test.ts b/packages/kernel-node-runtime/test/e2e/remote-comms.test.ts index 6d262448fe..7fe1ee9a25 100644 --- a/packages/kernel-node-runtime/test/e2e/remote-comms.test.ts +++ b/packages/kernel-node-runtime/test/e2e/remote-comms.test.ts @@ -825,11 +825,9 @@ describe.sequential('Remote Communications E2E', () => { kernel2 = restartResult.kernel; // The message should not have been delivered because we didn't reconnect - await expect(messageAfterClose).rejects.toMatchObject({ - body: expect.stringContaining( - 'Message delivery failed after intentional close', - ), - }); + await expect(messageAfterClose).rejects.toThrow( + 'Message delivery failed after intentional close', + ); }, NETWORK_TIMEOUT * 2, ); @@ -916,11 +914,7 @@ describe.sequential('Remote Communications E2E', () => { 'hello', ['Alice'], ]), - ).rejects.toMatchObject({ - body: expect.stringContaining( - 'Message delivery failed after intentional close', - ), - }); + ).rejects.toThrow('Message delivery failed after intentional close'); // Manually reconnect await kernel1.reconnectPeer(peerId2); @@ -1009,9 +1003,7 @@ describe.sequential('Remote Communications E2E', () => { 'hello', ['Alice'], ]), - ).rejects.toMatchObject({ - body: expect.stringMatching(/Remote connection lost/u), - }); + ).rejects.toThrow(/Remote connection lost/u); }, NETWORK_TIMEOUT * 3, ); @@ -1064,9 +1056,7 @@ describe.sequential('Remote Communications E2E', () => { 'hello', ['Alice'], ]), - ).rejects.toMatchObject({ - body: expect.stringContaining('Remote connection lost'), - }); + ).rejects.toThrow('Remote connection lost'); }, NETWORK_TIMEOUT * 2, ); diff --git a/packages/kernel-test/src/endowments.test.ts b/packages/kernel-test/src/endowments.test.ts index 614dc9edd2..f307b4dcba 100644 --- a/packages/kernel-test/src/endowments.test.ts +++ b/packages/kernel-test/src/endowments.test.ts @@ -51,9 +51,7 @@ describe('endowments', () => { await expect( kernel.queueMessage(v1Root, 'hello', [`https://${badHost}`]), - ).rejects.toMatchObject({ - body: expect.stringContaining(`Invalid host: ${badHost}`), - }); + ).rejects.toThrow(`Invalid host: ${badHost}`); await waitUntilQuiescent(); diff --git a/packages/kernel-test/src/orphaned-ephemeral-exo.test.ts b/packages/kernel-test/src/orphaned-ephemeral-exo.test.ts index cb73d906fc..0bda694843 100644 --- a/packages/kernel-test/src/orphaned-ephemeral-exo.test.ts +++ b/packages/kernel-test/src/orphaned-ephemeral-exo.test.ts @@ -43,8 +43,6 @@ describe('orphaned ephemeral exo', () => { // crank, but the endpoint is gone — so it splats and rejects. await expect( kernel.queueMessage(rootKref, 'useEphemeral', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_DELETED]'); }); }); diff --git a/packages/kernel-test/src/syscall-validation.test.ts b/packages/kernel-test/src/syscall-validation.test.ts index ed18941aef..75fbe88479 100644 --- a/packages/kernel-test/src/syscall-validation.test.ts +++ b/packages/kernel-test/src/syscall-validation.test.ts @@ -97,9 +97,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => { // Try to send message to revoked object — kernel rejects the promise await expect( kernel.queueMessage(objectKRef, 'getValue', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_REVOKED]'); // Verify kernel doesn't crash and exporter vat remains operational const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []); expect(exporterStatus.body).toContain('noop'); @@ -145,9 +143,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => { // Send message to revoked object — kernel rejects the promise await expect( kernel.queueMessage(objectKRef, 'getValue', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_REVOKED]'); // Verify exporter vat is still operational const exporterStatus = await kernel.queueMessage(exporterKRef, 'noop', []); expect(exporterStatus.body).toContain('noop'); @@ -155,9 +151,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => { for (let i = 0; i < 5; i++) { await expect( kernel.queueMessage(objectKRef, 'getValue', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_REVOKED]'); } // Verify kernel remains stable const finalStatus = await kernel.queueMessage(exporterKRef, 'noop', []); @@ -217,9 +211,7 @@ describe('Syscall Validation & Revoked Objects', { timeout: 30_000 }, () => { for (const objectKRef of objectKRefs) { await expect( kernel.queueMessage(objectKRef, 'getValue', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_REVOKED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_REVOKED]'); } // Verify exporter vat is still operational diff --git a/packages/kernel-test/src/vat-lifecycle.test.ts b/packages/kernel-test/src/vat-lifecycle.test.ts index b4c652d3af..209da17e57 100644 --- a/packages/kernel-test/src/vat-lifecycle.test.ts +++ b/packages/kernel-test/src/vat-lifecycle.test.ts @@ -134,9 +134,7 @@ describe('Vat Lifecycle', { timeout: 30_000 }, () => { // Try to send a message to the terminated vat's root object — rejects await expect( kernel.queueMessage(deadRootObject, 'resume', []), - ).rejects.toMatchObject({ - body: expect.stringContaining('[KERNEL:OBJECT_DELETED]'), - }); + ).rejects.toThrow('[KERNEL:OBJECT_DELETED]'); // Verify that messaging works as expected expect(await runResume(kernel, liveRootObject)).toBe(