Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/ocap-kernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<unknown[]> {
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);
});
});
});
73 changes: 71 additions & 2 deletions packages/ocap-kernel/src/rpc/kernel-control/queue-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -25,6 +26,73 @@ export type QueueMessageHooks = {
kernel: Pick<Kernel, 'queueMessage'>;
};

/**
* 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<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(record)) {
out[key] = expandRefMarkers(val);
}
return out;
}

export const queueMessageHandler: Handler<
'queueMessage',
[KRef, string, Json[]],
Expand All @@ -37,6 +105,7 @@ export const queueMessageHandler: Handler<
{ kernel }: QueueMessageHooks,
[target, method, args],
): Promise<CapData<KRef>> => {
return kernel.queueMessage(target, method, args);
const expandedArgs = expandRefMarkers(args) as unknown[];
return kernel.queueMessage(target, method, expandedArgs);
},
};
Loading