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( 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;