diff --git a/packages/ocap-kernel/CHANGELOG.md b/packages/ocap-kernel/CHANGELOG.md index de6c99c0e..ed8da249c 100644 --- a/packages/ocap-kernel/CHANGELOG.md +++ b/packages/ocap-kernel/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Accept optional `allowedGlobals` on `VatSupervisor` for custom allowlists - Log a warning when a vat requests an unknown global - Export `OcapURLIssuerService` and `OcapURLRedemptionService` types so vats can type the corresponding kernel-service endowments ([#952](https://github.com/MetaMask/ocap-kernel/pull/952)) +- Reference-marker sigil (`@@NAME`) at the `queueMessage` RPC boundary lets JSON-RPC callers name a live kernel object as a call argument ([#984](https://github.com/MetaMask/ocap-kernel/pull/984)) + - Anywhere in the args tree, a string of the form `@@NAME` (NAME is one or more alphanumeric characters, currently a well-formed kref) is expanded to a `kslot` standin so `kser` encodes it as a real CapData slot in the dispatched message + - Purely an RPC-boundary concern: internal callers of `Kernel.queueMessage` are unaffected + - Caveat: a legitimate string argument that begins with `@@` followed by alphanumerics will be misinterpreted as a marker; wrap such literals inside an object ### Changed 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 de399f12a..2a9e02afc 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 @@ -1,8 +1,11 @@ import type { CapData } from '@endo/marshal'; +import { passStyleOf } from '@endo/marshal'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { queueMessageSpec, queueMessageHandler } from './queue-message.ts'; import type { Kernel } from '../../Kernel.ts'; +import { krefOf } from '../../liveslots/kernel-marshal.ts'; +import type { SlotValue } from '../../liveslots/kernel-marshal.ts'; describe('queueMessageSpec', () => { it('should define the correct method name', () => { @@ -76,4 +79,94 @@ describe('queueMessageHandler', () => { ]), ).rejects.toThrow('Queue message failed'); }); + + describe('reference-marker sigil (`@@NAME`)', () => { + /** + * Invoke the handler and return whatever args reached kernel.queueMessage. + * + * @param args - The args to send through the handler. + * @returns The args as kernel.queueMessage saw them. + */ + async function forward(args: unknown[]): Promise { + vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce({ + body: 'r', + slots: [], + }); + await queueMessageHandler.implementation({ kernel: mockKernel }, [ + 'target', + 'method', + args, + ]); + const call = vi.mocked(mockKernel.queueMessage).mock.calls[0]; + return call?.[2] as unknown[]; + } + + it('expands a top-level sigil string into a kslot standin', async () => { + const [only] = await forward(['@@ko7']); + expect(passStyleOf(only as object)).toBe('remotable'); + expect(krefOf(only as SlotValue)).toBe('ko7'); + }); + + it('expands a sigil string nested inside an array', async () => { + const [outer] = await forward([['@@ko8', 'plain']]); + const arr = outer as unknown[]; + expect(krefOf(arr[0] as SlotValue)).toBe('ko8'); + expect(arr[1]).toBe('plain'); + }); + + it('expands a sigil string nested inside an object', async () => { + const [only] = await forward([ + { receiver: '@@ko9', label: 'parts shipment' }, + ]); + const record = only as { receiver: SlotValue; label: string }; + expect(krefOf(record.receiver)).toBe('ko9'); + expect(record.label).toBe('parts shipment'); + }); + + it('accepts a promise ref', async () => { + const [only] = await forward(['@@kp3']); + expect(krefOf(only as SlotValue)).toBe('kp3'); + }); + + it('leaves non-sigil strings alone', async () => { + const [only] = await forward(['ordinary string']); + expect(only).toBe('ordinary string'); + }); + + it('leaves strings with the sigil embedded (not at start) alone', async () => { + const [only] = await forward(['prefix@@ko7']); + expect(only).toBe('prefix@@ko7'); + }); + + it('leaves strings with non-alphanumerics after the sigil alone', async () => { + const [only] = await forward(['@@ko-7']); + expect(only).toBe('@@ko-7'); + }); + + it('leaves a bare `@@` string alone', async () => { + const [only] = await forward(['@@']); + expect(only).toBe('@@'); + }); + + it('rejects a well-formed sigil naming a malformed kref', async () => { + vi.mocked(mockKernel.queueMessage).mockResolvedValueOnce({ + body: 'r', + slots: [], + }); + await expect( + queueMessageHandler.implementation({ kernel: mockKernel }, [ + 'target', + 'method', + ['@@notakref'], + ]), + ).rejects.toThrow(/kref/iu); + expect(mockKernel.queueMessage).not.toHaveBeenCalled(); + }); + + it('preserves plain-data args unchanged', async () => { + const input = [1, 'string', { key: 'value' }, [1, 2, 3]]; + const forwarded = await forward(input); + expect(forwarded).toStrictEqual(input); + }); + }); }); diff --git a/packages/ocap-kernel/src/rpc/kernel-control/queue-message.ts b/packages/ocap-kernel/src/rpc/kernel-control/queue-message.ts index 4175fdcd6..fcd7b8699 100644 --- a/packages/ocap-kernel/src/rpc/kernel-control/queue-message.ts +++ b/packages/ocap-kernel/src/rpc/kernel-control/queue-message.ts @@ -5,8 +5,9 @@ import { UnsafeJsonStruct } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import type { Kernel } from '../../Kernel.ts'; +import { kslot } from '../../liveslots/kernel-marshal.ts'; import type { KRef } from '../../types.ts'; -import { KernelCapDataStruct, KRefStruct } from '../../types.ts'; +import { insistKRef, KernelCapDataStruct, KRefStruct } from '../../types.ts'; /** * Enqueue a message to a vat via the kernel's crank queue. @@ -25,6 +26,73 @@ export type QueueMessageHooks = { kernel: Pick; }; +/** + * Reference-marker sigil. In the JSON args arriving over this RPC, a + * string of the form `@@NAME` (where `NAME` is one or more + * alphanumeric characters) is interpreted as an object-reference + * marker naming the kernel object with that reference. The marker is + * expanded to a `kslot` standin so the kernel's serializer (`kser`) + * encodes it as a real CapData slot in the dispatched message. + * + * The sigil convention exists because JSON has no native way to + * express an object reference. External callers (plugin code + * speaking JSON over the daemon socket, CLI users typing message + * sends at a terminal, test harnesses composing RPC calls) can name + * a live kernel object without having to synthesize a remotable. + * Internal callers of `Kernel.queueMessage` never traffic in + * markers and are unaffected. + * + * NAME currently must be a well-formed kref (`ko\d+` or `kp\d+`); + * a future registry mapping symbolic names to krefs could accept + * richer names here while preserving the sigil syntax. + * + * Caveat: a legitimate string argument that begins with `@@` + * followed by alphanumerics will be misinterpreted as a reference + * marker. Callers that need to send such a literal string must + * wrap it inside an object or otherwise structure it so the sigil + * appears in a context where it isn't the whole string value. + */ +const REF_SIGIL_PATTERN = /^@@([A-Za-z0-9]+)$/u; + +/** + * Walk `value` and replace every reference-marker string + * (`"@@NAME"`) with a corresponding `kslot(kref)` standin. Arrays + * and plain object records are walked recursively. + * + * The input is guaranteed to be JSON-parsed data at this layer + * (queueMessage's `params` type is `Json[]`), so there are no live + * remotables, promises, or other exotic passables to protect: every + * value is either JSON data or a marker string. Any hardened kslot + * standins the walker introduces will be hardened again as part of + * kser's normal serialization pass. + * + * @param value - The value to walk. + * @returns A value with markers expanded to kslot standins. + */ +function expandRefMarkers(value: unknown): unknown { + if (typeof value === 'string') { + const match = REF_SIGIL_PATTERN.exec(value); + if (!match) { + return value; + } + const kref = match[1] as string; + insistKRef(kref); + return kslot(kref); + } + if (Array.isArray(value)) { + return value.map((item) => expandRefMarkers(item)); + } + if (typeof value !== 'object' || value === null) { + return value; + } + const record = value as Record; + const out: Record = {}; + for (const [key, val] of Object.entries(record)) { + out[key] = expandRefMarkers(val); + } + return out; +} + export const queueMessageHandler: Handler< 'queueMessage', [KRef, string, Json[]], @@ -37,6 +105,7 @@ export const queueMessageHandler: Handler< { kernel }: QueueMessageHooks, [target, method, args], ): Promise> => { - return kernel.queueMessage(target, method, args); + const expandedArgs = expandRefMarkers(args) as unknown[]; + return kernel.queueMessage(target, method, expandedArgs); }, };