diff --git a/packages/ocap-jsonrpc-vat/CHANGELOG.md b/packages/ocap-jsonrpc-vat/CHANGELOG.md new file mode 100644 index 000000000..92770c909 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release: vat serving a line-delimited JSON-RPC 2.0 protocol on a Unix-domain-socket `IOService` endowment + - `redeemURL(url)` redeems an OCAP URL through the kernel's `ocapURLRedemptionService` and returns a sigil name of the form `@@j` referring to the resulting live reference + - `send(target, method, args)` invokes `E(target)[method](...args)` with `@@j` markers in `args` expanded to their live references and any remotable in the result substituted for its sigil name + - A result is refused with an internal error, rather than serialized, when it holds a value that `JSON.stringify` accepts but cannot represent — an unsettled promise, which has no own enumerable properties and would become `{}`, or a non-finite number (`NaN`, `±Infinity`), which would become `null`. Either would otherwise hand the client a success payload whose value is silently wrong, and `null` in particular is indistinguishable from the `null` a void method legitimately returns. `-0` is allowed through, since it serializes to a numerically equal `0` + - Name disclosure is atomic per request: the `@@j` names minted while walking a result are committed only once the reply is known to be a sendable success, and discarded otherwise. Without this, a request that failed partway — or whose reply could not be encoded — left its names in the connection's table, and since names are sequential the client could reach those references by guessing, having been told only that the call failed. Encodability is therefore settled in the bridge, where the names are, rather than in the writer + - Each connection is served concurrently with its own bridge, and so its own `@@j` table: a name minted on one connection does not resolve on another, both connections mint names from their own counter, and a peer that stalls without hanging up does not stop new peers being accepted + - Session state is in-memory only and resets on socket disconnect + +[Unreleased]: https://github.com/MetaMask/ocap-kernel/ diff --git a/packages/ocap-jsonrpc-vat/README.md b/packages/ocap-jsonrpc-vat/README.md new file mode 100644 index 000000000..b86c29a8d --- /dev/null +++ b/packages/ocap-jsonrpc-vat/README.md @@ -0,0 +1,39 @@ +# `@ocap/ocap-jsonrpc-vat` + +Ocap kernel vat that exposes access to kernel objects via a JSON-RPC +interface on a Unix-domain socket. Intended as the routine path for +local, non-vat processes (e.g. LLM tool plugins) to redeem OCAP URLs +and send messages to the resulting objects, replacing ad-hoc use of +the kernel-cli's `queueMessage` RPC. + +## Protocol + +The vat serves a line-delimited JSON-RPC 2.0 interface on the socket. +Two methods: + +- `redeemURL({ url: string }) -> "@@j"` + + Redeems `url` through the kernel's `ocapURLRedemptionService` and + returns a sigil name of the form `"@@j1"`, `"@@j2"`, ... referring + to the resulting live reference. Callable at any time. + +- `send({ target: string, method: string, args?: unknown[] }) -> unknown` + + Invokes `E(target)[method](...args)`. The `target` and any nested + `"@@j"` string in `args` is expanded to its live remotable + before dispatch. The awaited result is walked and every remotable + it contains (previously known or newly encountered) is replaced by + its `"@@j"` name in the response. + +Object identity is preserved: an object the caller has already seen +keeps the same `@@j` name across `redeemURL` and `send` calls. + +## Session lifecycle + +The naming table lives in memory only. On socket disconnect the vat +resets its state and awaits a new client; the new client's names +start at `j1` again. + +Restarting the daemon likewise resets the session — this is the +common case, since restart is typically how the operator triggers a +fresh state. diff --git a/packages/ocap-jsonrpc-vat/package.json b/packages/ocap-jsonrpc-vat/package.json new file mode 100644 index 000000000..68912a38d --- /dev/null +++ b/packages/ocap-jsonrpc-vat/package.json @@ -0,0 +1,90 @@ +{ + "name": "@ocap/ocap-jsonrpc-vat", + "version": "0.0.0", + "private": true, + "description": "Ocap kernel vat that exposes access to kernel objects via a JSON-RPC interface on a Unix-domain socket", + "homepage": "https://github.com/MetaMask/ocap-kernel/tree/main/packages/ocap-jsonrpc-vat#readme", + "bugs": { + "url": "https://github.com/MetaMask/ocap-kernel/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/ocap-kernel.git" + }, + "type": "module", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --no-references --clean", + "bundle-vat": "node ../kernel-cli/dist/app.mjs bundle ./src/vat/index.ts", + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @ocap/ocap-jsonrpc-vat", + "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", + "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", + "lint:dependencies": "depcheck --quiet", + "lint:eslint": "eslint . --cache", + "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies", + "lint:misc": "prettier --no-error-on-unmatched-pattern '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path ../../.gitignore --log-level error", + "publish:preview": "yarn npm publish --tag preview", + "test": "vitest run --config vitest.config.ts", + "test:clean": "yarn test --no-cache --coverage.clean", + "test:dev": "yarn test --mode development", + "test:verbose": "yarn test --reporter verbose", + "test:watch": "vitest --config vitest.config.ts", + "test:dev:quiet": "yarn test:dev --reporter @ocap/repo-tools/vitest-reporters/silent" + }, + "dependencies": { + "@endo/eventual-send": "^1.3.4", + "@endo/pass-style": "^1.6.3", + "@metamask/kernel-utils": "workspace:^", + "@metamask/ocap-kernel": "workspace:^" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.4", + "@metamask/auto-changelog": "^5.3.0", + "@metamask/eslint-config": "^15.0.0", + "@metamask/eslint-config-nodejs": "^15.0.0", + "@metamask/eslint-config-typescript": "^15.0.0", + "@ocap/repo-tools": "workspace:^", + "@ts-bridge/cli": "^0.6.3", + "@ts-bridge/shims": "^0.1.1", + "@typescript-eslint/eslint-plugin": "^8.29.0", + "@typescript-eslint/parser": "^8.29.0", + "@typescript-eslint/utils": "^8.29.0", + "@vitest/eslint-plugin": "^1.6.14", + "depcheck": "^1.4.7", + "eslint": "^9.23.0", + "eslint-config-prettier": "^10.1.1", + "eslint-import-resolver-typescript": "^4.3.1", + "eslint-plugin-import-x": "^4.10.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-n": "^17.17.0", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-promise": "^7.2.1", + "prettier": "^3.5.3", + "rimraf": "^6.0.1", + "turbo": "^2.9.1", + "typedoc": "^0.28.1", + "typescript": "~5.8.2", + "typescript-eslint": "^8.29.0", + "vite": "^8.0.6", + "vitest": "^4.1.3" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/ocap-jsonrpc-vat/scripts/probe.mjs b/packages/ocap-jsonrpc-vat/scripts/probe.mjs new file mode 100644 index 000000000..0efc53c18 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/scripts/probe.mjs @@ -0,0 +1,142 @@ +// Minimal JSON-RPC probe for the ocap JSON-RPC vat. +// +// Connects to the vat's Unix socket. For each URL supplied on the +// command line, sends a `redeemURL` request; if any URLs were supplied, +// follows with a deliberately-invalid `send` to prove the error path +// also works. Prints every request/response pair to stdout. +// +// Usage: +// node scripts/probe.mjs [SOCKET_PATH] [URL ...] +// +// Defaults SOCKET_PATH to ~/.ocap/ocap-jsonrpc.sock and the URL list to +// empty (which exercises just connection setup). + +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +const args = process.argv.slice(2); +const defaultSocket = path.join( + process.env.OCAP_HOME ?? path.join(os.homedir(), '.ocap'), + 'ocap-jsonrpc.sock', +); + +let socketPath = defaultSocket; +let urls = []; +if (args.length > 0) { + if (args[0].startsWith('/') || args[0].startsWith('.')) { + socketPath = args[0]; + urls = args.slice(1); + } else { + urls = args; + } +} + +/** + * Connect a client socket, resolving once connected. + * + * @param {string} target - Filesystem path of the Unix socket. + * @returns {Promise} The connected socket. + */ +function connectSocket(target) { + return new Promise((resolve, reject) => { + const client = net.createConnection(target); + client.once('connect', () => resolve(client)); + client.once('error', reject); + }); +} + +/** + * Send one JSON-RPC request over `socket` and await the next line of + * response. The vat's protocol is strictly request/reply on a single + * stream, so this simple wait-for-one-line loop is safe as long as + * callers issue requests serially. + * + * @param {net.Socket} socket - The connected socket. + * @param {object} request - The JSON-RPC request envelope. + * @returns {Promise} The parsed response envelope. + */ +function callOnce(socket, request) { + return new Promise((resolve, reject) => { + let buffer = ''; + /** + * Detach both listeners so we don't double-fire on the socket. + */ + const detach = () => { + // eslint-disable-next-line no-use-before-define + socket.removeListener('data', onData); + // eslint-disable-next-line no-use-before-define + socket.removeListener('error', onError); + }; + /** + * Buffer incoming bytes and resolve on the first complete line. + * + * @param {Buffer} chunk - Incoming data. + */ + const onData = (chunk) => { + buffer += chunk.toString('utf8'); + const newline = buffer.indexOf('\n'); + if (newline < 0) { + return; + } + const line = buffer.slice(0, newline); + detach(); + try { + resolve(JSON.parse(line)); + } catch { + reject(new Error(`bad response line: ${line}`)); + } + }; + /** + * Propagate socket errors as promise rejection. + * + * @param {Error} cause - Socket error. + */ + const onError = (cause) => { + detach(); + reject(cause); + }; + socket.on('data', onData); + socket.once('error', onError); + socket.write(`${JSON.stringify(request)}\n`); + }); +} + +const socket = await connectSocket(socketPath); +process.stderr.write(`connected to ${socketPath}\n`); + +let firstRef; +let nextId = 1; +for (const url of urls) { + const req = { + jsonrpc: '2.0', + id: nextId, + method: 'redeemURL', + params: { url }, + }; + nextId += 1; + process.stdout.write(`→ ${JSON.stringify(req)}\n`); + const reply = await callOnce(socket, req); + process.stdout.write(`← ${JSON.stringify(reply)}\n`); + if (firstRef === undefined && typeof reply?.result === 'string') { + firstRef = reply.result; + } +} + +if (urls.length > 0) { + const sendRequest = { + jsonrpc: '2.0', + id: nextId, + method: 'send', + params: { + target: firstRef ?? '@@j1', + method: '__nonexistent_method__', + args: [], + }, + }; + process.stdout.write(`→ ${JSON.stringify(sendRequest)}\n`); + const sendReply = await callOnce(socket, sendRequest); + process.stdout.write(`← ${JSON.stringify(sendReply)}\n`); +} + +socket.destroy(); diff --git a/packages/ocap-jsonrpc-vat/scripts/start-ocap-jsonrpc-vat.sh b/packages/ocap-jsonrpc-vat/scripts/start-ocap-jsonrpc-vat.sh new file mode 100755 index 000000000..192e6b5c7 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/scripts/start-ocap-jsonrpc-vat.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Launch the ocap JSON-RPC subcluster in a local ocap daemon. +# +# The vat exposes a line-delimited JSON-RPC 2.0 interface on a +# Unix-domain socket under the daemon's home directory. Its two +# methods — `redeemURL(url)` and `send(target,method,args)` — are +# intended as the routine path by which local, non-vat processes +# reach kernel objects, replacing ad-hoc use of the kernel-cli's +# `queueMessage` RPC. +# +# The target daemon is chosen by (in order): +# 1. --home (explicit override on the CLI) +# 2. $OCAP_HOME (environment variable) +# 3. ~/.ocap (default) +# The vat's socket lives at /ocap-jsonrpc.sock. +# +# Prerequisites: the target daemon must already be running and have +# `ocapURLRedemptionService` available (i.e. remote comms initialised +# if you plan to redeem URLs pointing at other peers). +# +# Usage: +# start-ocap-jsonrpc-vat.sh [--home DIR] [--no-build] [--force-reset] + +set -euo pipefail + +SKIP_BUILD=false +FORCE_RESET=false +OCAP_HOME_ARG="" + +usage() { + cat >&2 </ocap-jsonrpc.sock. + --no-build Skip building/bundling the ocap JSON-RPC vat. + --force-reset Force-reset the subcluster if one already exists. + Without this, an existing subcluster is reused as-is. + --help, -h Show this help. +EOF + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --home) + [[ $# -lt 2 ]] && { echo "Error: --home requires a value" >&2; usage; } + OCAP_HOME_ARG="$2"; shift 2 ;; + --no-build) SKIP_BUILD=true; shift ;; + --force-reset) FORCE_RESET=true; shift ;; + --help|-h) usage ;; + *) echo "Error: unknown argument: $1" >&2; usage ;; + esac +done + +info() { echo "[start-ocap-jsonrpc-vat] $*" >&2; } +fail() { echo "[start-ocap-jsonrpc-vat] ERROR: $*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$PKG_DIR/../.." && pwd)" +OCAP_BIN="$REPO_ROOT/packages/kernel-cli/dist/app.mjs" +BUNDLE_FILE="$PKG_DIR/src/vat/index.bundle" + +OCAP_HOME_DIR="${OCAP_HOME_ARG:-${OCAP_HOME:-${HOME}/.ocap}}" +SOCKET_PATH="$OCAP_HOME_DIR/ocap-jsonrpc.sock" + +if [[ ! -f "$OCAP_BIN" ]]; then + fail "ocap CLI not found at $OCAP_BIN. Run \`yarn workspace @metamask/kernel-cli build\` first." +fi + +if $SKIP_BUILD; then + info "Skipping build (--no-build)" + [[ -f "$BUNDLE_FILE" ]] || fail "Bundle not found at $BUNDLE_FILE. Remove --no-build or build first." +else + info "Building ocap-jsonrpc-vat package..." + (cd "$REPO_ROOT" && yarn workspace @ocap/ocap-jsonrpc-vat build >&2) + info "Bundling vat..." + (cd "$REPO_ROOT" && yarn workspace @ocap/ocap-jsonrpc-vat bundle-vat >&2) +fi + +# All CLI invocations against this daemon go through this wrapper so +# they use the requested home rather than the CLI default. +daemon_cli() { + (cd "$REPO_ROOT" && node "$OCAP_BIN" --home "$OCAP_HOME_DIR" "$@") +} + +# Fast-fail if the daemon isn't up. +if ! daemon_cli daemon exec getStatus >/dev/null 2>&1; then + fail "daemon at $OCAP_HOME_DIR does not respond to \`daemon exec getStatus\`. Start it first." +fi + +# Look up any existing subcluster: reuse it, unless --force-reset was +# passed — in which case terminate it first so we can launch a fresh +# one (kernel state stays, the vat's baggage and @@ name counter go). +EXISTING_ID=$(daemon_cli daemon exec getStatus | node -e " + const raw = require('fs').readFileSync('/dev/stdin','utf8').trim(); + const data = JSON.parse(raw); + const subclusters = data.subclusters ?? []; + const found = subclusters.find( + (sc) => sc?.config?.bootstrap === 'ocapJsonrpcVat', + ); + if (found) { + process.stdout.write(found.id); + } +") + +if [[ -n "$EXISTING_ID" ]]; then + if [[ "$FORCE_RESET" == "true" ]]; then + info "Terminating existing subcluster $EXISTING_ID before relaunch..." + daemon_cli daemon exec terminateSubcluster "$(node -e \ + "process.stdout.write(JSON.stringify({id: process.argv[1]}))" \ + "$EXISTING_ID")" >/dev/null \ + || fail "terminateSubcluster $EXISTING_ID failed" + # Give the kernel a moment to tear down the IO channel. + sleep 0.3 + else + info "Subcluster already exists ($EXISTING_ID); reusing." + if [[ ! -S "$SOCKET_PATH" ]]; then + fail "Existing subcluster claims to be up but socket $SOCKET_PATH is missing." + fi + info "Home: $OCAP_HOME_DIR" + info "Socket: $SOCKET_PATH" + echo "socket: $SOCKET_PATH" + exit 0 + fi +fi + +CONFIG=$(BUNDLE="file://$BUNDLE_FILE" \ + SOCKET="$SOCKET_PATH" \ + node -e " + const config = { + config: { + bootstrap: 'ocapJsonrpcVat', + services: ['ocapURLRedemptionService'], + io: { + socket: { type: 'socket', path: process.env.SOCKET } + }, + vats: { + ocapJsonrpcVat: { bundleSpec: process.env.BUNDLE } + } + } + }; + process.stdout.write(JSON.stringify(config)); +") + +info "Launching subcluster in $OCAP_HOME_DIR..." +daemon_cli daemon exec launchSubcluster "$CONFIG" >/dev/null + +# Give the vat a moment to open its listener before reporting readiness. +for i in $(seq 1 20); do + if [[ -S "$SOCKET_PATH" ]]; then + break + fi + if [[ "$i" -eq 20 ]]; then + fail "Socket $SOCKET_PATH did not appear after 2s. See daemon logs." + fi + sleep 0.1 +done + +info "Vat ready." +info "Home: $OCAP_HOME_DIR" +info "Socket: $SOCKET_PATH" +echo "socket: $SOCKET_PATH" diff --git a/packages/ocap-jsonrpc-vat/src/bridge.test.ts b/packages/ocap-jsonrpc-vat/src/bridge.test.ts new file mode 100644 index 000000000..463b01ff4 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/bridge.test.ts @@ -0,0 +1,671 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { makeBridge } from './bridge.ts'; +import type { BridgeHooks } from './bridge.ts'; +import { JSON_RPC_ERROR } from './json-rpc.ts'; + +type FakeRemotable = { __fakeRemotable__: true; label: string }; + +const makeFake = (label: string): FakeRemotable => ({ + __fakeRemotable__: true, + label, +}); + +const isFakeRemotable = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + (value as { __fakeRemotable__?: unknown }).__fakeRemotable__ === true; + +/** + * Build a bridge with configurable hooks. `redeem` and `invoke` + * default to `vi.fn()` so tests can inspect calls. + * + * @param overrides - Any hooks to replace defaults with. + * @returns The bridge plus references to the hook mocks. + */ +function buildBridge(overrides: Partial = {}): { + bridge: ReturnType; + hooks: { + redeem: ReturnType; + invoke: ReturnType; + }; +} { + const redeem = vi.fn(async (_url: string): Promise => makeFake('x')); + const invoke = vi.fn( + async (_target: unknown, _method: string, _args: unknown[]) => + undefined as unknown, + ); + const hooks: BridgeHooks = { + redeem, + invoke, + isRemotable: isFakeRemotable, + ...overrides, + }; + return { + bridge: makeBridge(hooks), + hooks: { redeem, invoke }, + }; +} + +describe('dispatch: request validation', () => { + it('rejects a non-object request', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch(null); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: JSON_RPC_ERROR.INVALID_REQUEST, + message: 'not a well-formed JSON-RPC 2.0 request', + }, + }); + }); + + it('rejects a request without jsonrpc: "2.0"', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch({ + id: 1, + method: 'send', + params: {}, + }); + expect(response).toMatchObject({ + error: { code: JSON_RPC_ERROR.INVALID_REQUEST }, + }); + }); + + it('rejects a request with no id rather than treating it as a notification', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + + // Every line in gets exactly one line back. Serving notifications + // would make some lines answerable and others not, which desynchronizes + // a persistent line-delimited stream for good. + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: JSON_RPC_ERROR.INVALID_REQUEST, + message: 'not a well-formed JSON-RPC 2.0 request', + }, + }); + }); + + it('accepts an explicit null id', async () => { + const { bridge } = buildBridge({ redeem: async () => makeFake('alpha') }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: null, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + + // A null id is legal in a request; only an absent one is a notification. + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: null, + result: '@@j1', + }); + }); + + it('rejects an unknown method', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 7, + method: 'destroyEverything', + }); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 7, + error: { + code: JSON_RPC_ERROR.METHOD_NOT_FOUND, + message: 'unknown method "destroyEverything"', + }, + }); + }); +}); + +describe('redeemURL', () => { + it('redeems a URL and returns its marker name', async () => { + const alpha = makeFake('alpha'); + const redeem = vi.fn(async (url: string) => { + expect(url).toBe('ocap:alpha'); + return alpha; + }); + const { bridge } = buildBridge({ redeem }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + result: '@@j1', + }); + }); + + it('reuses the same name across successive redemptions of the same identity', async () => { + const alpha = makeFake('alpha'); + const { bridge } = buildBridge({ redeem: async () => alpha }); + const first = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + })) as { result?: unknown }; + const second = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + })) as { result?: unknown }; + expect(first.result).toBe('@@j1'); + expect(second.result).toBe('@@j1'); + }); + + it('assigns distinct names for distinct identities', async () => { + const alpha = makeFake('alpha'); + const beta = makeFake('beta'); + const { bridge } = buildBridge({ + redeem: async (url) => (url === 'ocap:alpha' ? alpha : beta), + }); + const first = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + })) as { result?: unknown }; + const second = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'redeemURL', + params: { url: 'ocap:beta' }, + })) as { result?: unknown }; + expect(first.result).toBe('@@j1'); + expect(second.result).toBe('@@j2'); + }); + + it('rejects a non-string url param', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 42 }, + }); + expect(response).toMatchObject({ + error: { code: JSON_RPC_ERROR.INVALID_PARAMS }, + }); + }); + + it('surfaces a redeem() rejection as an application error', async () => { + const { bridge } = buildBridge({ + redeem: async () => { + throw new Error('remote said no'); + }, + }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:x' }, + }); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 1, + error: { + code: JSON_RPC_ERROR.APPLICATION_ERROR, + message: 'remote said no', + }, + }); + }); +}); + +describe('send', () => { + it('rejects an unknown @@ target', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'send', + params: { target: '@@j99', method: 'noSuch', args: [] }, + }); + expect(response).toMatchObject({ + error: { code: JSON_RPC_ERROR.INVALID_PARAMS }, + }); + }); + + it('rejects a badly-formed target string', async () => { + const { bridge } = buildBridge(); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'send', + params: { target: 'not-a-marker', method: 'x', args: [] }, + }); + expect(response).toMatchObject({ + error: { code: JSON_RPC_ERROR.INVALID_PARAMS }, + }); + }); + + it('expands marker args to live references before invoking', async () => { + const alpha = makeFake('alpha'); + const beta = makeFake('beta'); + const invoke = vi.fn( + async (_target: unknown, _method: string, _args: unknown[]) => 42, + ); + const { bridge } = buildBridge({ + redeem: async (url) => (url === 'ocap:alpha' ? alpha : beta), + invoke, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'redeemURL', + params: { url: 'ocap:beta' }, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 3, + method: 'send', + params: { + target: '@@j1', + method: 'handoff', + args: ['@@j2', { via: '@@j2', tag: 'plain' }], + }, + }); + expect(invoke).toHaveBeenCalledWith(alpha, 'handoff', [ + beta, + { via: beta, tag: 'plain' }, + ]); + }); + + it('substitutes remotables in the result with marker strings', async () => { + const alpha = makeFake('alpha'); + const beta = makeFake('beta'); + const { bridge } = buildBridge({ + redeem: async () => alpha, + invoke: async () => ({ echo: 'ok', partner: beta }), + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j1', method: 'introducePartner', args: [] }, + }); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 2, + result: { echo: 'ok', partner: '@@j2' }, + }); + }); + + it('reuses names for objects seen previously', async () => { + const alpha = makeFake('alpha'); + const beta = makeFake('beta'); + const { bridge } = buildBridge({ + redeem: async () => alpha, + invoke: async () => beta, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + const first = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j1', method: 'getBeta', args: [] }, + })) as { result?: unknown }; + expect(first.result).toBe('@@j2'); + const second = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 3, + method: 'send', + params: { target: '@@j1', method: 'getBeta', args: [] }, + })) as { result?: unknown }; + expect(second.result).toBe('@@j2'); + }); + + it('packages an invoke() rejection as an application error', async () => { + const alpha = makeFake('alpha'); + const { bridge } = buildBridge({ + redeem: async () => alpha, + invoke: async () => { + throw new Error('remote said no'); + }, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j1', method: 'boom', args: [] }, + }); + expect(response).toStrictEqual({ + jsonrpc: '2.0', + id: 2, + error: { + code: JSON_RPC_ERROR.APPLICATION_ERROR, + message: 'remote said no', + }, + }); + }); + + it('reports a void result as null so the response survives encoding', async () => { + const alpha = makeFake('alpha'); + const { bridge } = buildBridge({ + redeem: async () => alpha, + invoke: async () => undefined, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j1', method: 'doNothing', args: [] }, + }); + + // `undefined` would be dropped by JSON.stringify, leaving a response + // with neither `result` nor `error` — valid as neither outcome. + expect(response).toStrictEqual({ jsonrpc: '2.0', id: 2, result: null }); + expect(JSON.parse(JSON.stringify(response))).toHaveProperty('result', null); + }); + + it.each([ + ['false', false], + ['zero', 0], + ['empty string', ''], + ])( + 'preserves a falsy %s result rather than nulling it', + async (_l, value) => { + const alpha = makeFake('alpha'); + const { bridge } = buildBridge({ + redeem: async () => alpha, + invoke: async () => value, + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j1', method: 'give', args: [] }, + }); + + expect(response).toStrictEqual({ jsonrpc: '2.0', id: 2, result: value }); + }, + ); +}); + +describe('resetSession', () => { + it('discards names so previously-known targets are no longer known', async () => { + const alpha = makeFake('alpha'); + const { bridge } = buildBridge({ redeem: async () => alpha }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + bridge.resetSession(); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j1', method: 'ping', args: [] }, + }); + expect(response).toMatchObject({ + error: { + code: JSON_RPC_ERROR.INVALID_PARAMS, + message: + 'params.target "@@j1" is not a known reference on this ' + + 'connection (known here: none)', + }, + }); + }); + + it('names the connection and its known refs when a lookup misses', async () => { + const alpha = makeFake('alpha'); + const { bridge } = buildBridge({ + redeem: async () => alpha, + label: 'connection 7', + }); + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + }); + const response = await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j9', method: 'ping', args: [] }, + }); + // The usual cause is a name minted on a different connection, so the + // message has to say which connection is complaining and what it holds. + expect(response).toMatchObject({ + error: { + code: JSON_RPC_ERROR.INVALID_PARAMS, + message: + 'params.target "@@j9" is not a known reference on ' + + 'connection 7 (known here: @@j1)', + }, + }); + }); + + it('resets the name counter so o1 is reallocated fresh', async () => { + const alpha = makeFake('alpha'); + const gamma = makeFake('gamma'); + const { bridge } = buildBridge({ + redeem: async (url) => (url === 'ocap:alpha' ? alpha : gamma), + }); + const first = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'redeemURL', + params: { url: 'ocap:alpha' }, + })) as { result?: unknown }; + expect(first.result).toBe('@@j1'); + bridge.resetSession(); + const second = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'redeemURL', + params: { url: 'ocap:gamma' }, + })) as { result?: unknown }; + expect(second.result).toBe('@@j1'); + }); +}); + +describe('dispatch: name disclosure is atomic', () => { + const redeemFake = async (): Promise => makeFake('root'); + + /** + * Redeem a URL so the connection has a usable target, returning its name. + * + * @param bridge - The bridge to prime. + * @returns The marker string naming the redeemed object. + */ + async function primeTarget( + bridge: ReturnType, + ): Promise { + const response = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 'prime', + method: 'redeemURL', + params: { url: 'ocap://root' }, + })) as { result: string }; + return response.result; + } + + it.each([ + [ + 'a non-finite number', + (): unknown => ({ ref: makeFake('leaked'), bad: Number.NaN }), + ], + [ + 'an unsettled promise', + (): unknown => ({ + ref: makeFake('leaked'), + bad: new Promise(() => undefined), + }), + ], + ['a bigint', (): unknown => ({ ref: makeFake('leaked'), bad: 1n })], + ])( + 'discards names minted for a reply rejected over %s', + async (_label, makeResult) => { + const { bridge } = buildBridge({ + redeem: redeemFake, + invoke: async (): Promise => makeResult(), + }); + const target = await primeTarget(bridge); + + const failed = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'send', + params: { target, method: 'getRef', args: [] }, + })) as { error?: { code: number } }; + expect(failed.error).toBeDefined(); + + // The walk minted a name for `ref` before hitting the bad value. Names + // are sequential, so guessing it takes no work — it must not resolve. + const probe = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: '@@j2', method: 'anything', args: [] }, + })) as { error?: { code: number; message: string } }; + expect(probe.error?.code).toBe(JSON_RPC_ERROR.INVALID_PARAMS); + expect(probe.error?.message).toMatch(/not a known reference/u); + }, + ); + + it('keeps a name already disclosed by an earlier successful reply', async () => { + const shared = makeFake('shared'); + let failNext = false; + const { bridge } = buildBridge({ + redeem: redeemFake, + invoke: async (): Promise => + failNext ? { ref: shared, bad: Number.NaN } : shared, + }); + const target = await primeTarget(bridge); + + const first = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'send', + params: { target, method: 'getShared', args: [] }, + })) as { result: string }; + const disclosed = first.result; + + // A later failed request mentions the same object. Rolling that request + // back must not revoke a name the client was legitimately given. + failNext = true; + const failed = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target, method: 'getShared', args: [] }, + })) as { error?: unknown }; + expect(failed.error).toBeDefined(); + + failNext = false; + const after = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 3, + method: 'send', + params: { target: disclosed, method: 'stillThere', args: [] }, + })) as { result?: unknown; error?: unknown }; + expect(after.error).toBeUndefined(); + }); + + it('reuses the id a rolled-back name held, leaving no gap', async () => { + let failNext = true; + const { bridge } = buildBridge({ + redeem: redeemFake, + invoke: async (): Promise => + failNext + ? { ref: makeFake('discarded'), bad: Number.NaN } + : makeFake('kept'), + }); + const target = await primeTarget(bridge); + + await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'send', + params: { target, method: 'fails', args: [] }, + }); + failNext = false; + const ok = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target, method: 'works', args: [] }, + })) as { result: string }; + + // The discarded name was never disclosed, so its id is free to reuse. + expect(ok.result).toBe('@@j2'); + }); + + it('still registers names for a reply that succeeds', async () => { + const { bridge } = buildBridge({ + redeem: redeemFake, + invoke: async (): Promise => makeFake('handed over'), + }); + const target = await primeTarget(bridge); + + const response = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 1, + method: 'send', + params: { target, method: 'getRef', args: [] }, + })) as { result: string }; + expect(response.result).toBe('@@j2'); + + const reuse = (await bridge.dispatch({ + jsonrpc: '2.0', + id: 2, + method: 'send', + params: { target: response.result, method: 'usable', args: [] }, + })) as { error?: unknown }; + expect(reuse.error).toBeUndefined(); + }); +}); diff --git a/packages/ocap-jsonrpc-vat/src/bridge.ts b/packages/ocap-jsonrpc-vat/src/bridge.ts new file mode 100644 index 000000000..2963ba2c6 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/bridge.ts @@ -0,0 +1,408 @@ +/** + * Bridge core: the state machine and per-request dispatch used by the + * ocap JSON-RPC vat. + * + * Factored out of the vat body so its behavior can be exercised in a plain + * Node test environment. The vat wraps this factory with an IOService-driven + * read/dispatch/write loop; production hooks bind `redeem` and `invoke` to + * `E(...)` calls, tests pass in plain-function stand-ins. + */ + +import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse } from './json-rpc.ts'; +import { + JSON_RPC_ERROR, + MARKER_PATTERN, + MARKER_PREFIX, + BridgeRpcError, + expandMarkers, + substituteRemotables, +} from './json-rpc.ts'; + +export type BridgeHooks = { + /** Redeem an OCAP URL to a live reference. */ + redeem: (url: string) => Promise; + /** + * Send `method` with `args` to `target` and await the resolved result. + * In production this is `E(target)[method](...args)`. + */ + invoke: ( + target: unknown, + method: string, + args: unknown[], + ) => Promise; + /** + * Predicate identifying values the response walker should replace with + * `"@@j"` sigil strings. Production wires this to `passStyleOf`. + */ + isRemotable: (value: unknown) => boolean; + /** + * Optional label identifying which connection this bridge serves, used + * in error messages. + * + * Names are scoped to a connection, so "unknown reference" almost + * always means the name was minted on a *different* connection than + * the one asking. Without the label that has to be reconstructed by + * correlating kernel refs in the daemon log, which is a lot of work to + * learn something the error could simply have said. + */ + label?: string | undefined; +}; + +export type Bridge = { + /** + * Handle one already-parsed JSON-RPC request and return the response. + * Never throws; all errors are packaged as JSON-RPC error responses. + */ + dispatch: (request: unknown) => Promise; + /** + * Discard the naming table. Invoked by the vat when the socket client + * disconnects so the next connection begins with fresh names. + */ + resetSession: () => void; +}; + +/** + * Construct a bridge with an empty naming table. + * + * @param hooks - Callbacks that bridge into the environment (URL + * redemption, message send, remotable identification). + * @returns The bridge control interface. + */ +export function makeBridge(hooks: BridgeHooks): Bridge { + let nameToObj = new Map(); + let objToName = new Map(); + let nextObjId = 0; + + /** + * Describe this bridge for error messages. + * + * @returns The connection label, or a generic phrase when unlabelled. + */ + const where = (): string => hooks.label ?? 'this connection'; + + /** + * Names minted while handling the current request and not yet disclosed to + * the client. A name only becomes usable once the client has actually been + * sent a reply carrying it; see `dispatch`. + */ + let stagedNames: string[] = []; + + /** `nextObjId` as of the start of the current request, for rollback. */ + let objIdBeforeRequest = 0; + + const resetSession = (): void => { + nameToObj = new Map(); + objToName = new Map(); + nextObjId = 0; + stagedNames = []; + objIdBeforeRequest = 0; + }; + + const assignName = (obj: unknown): string => { + const existing = objToName.get(obj); + if (existing !== undefined) { + // Already disclosed by an earlier reply, so it is not this request's to + // stage — and must survive if this request is rolled back. + return existing; + } + nextObjId += 1; + const name = `j${nextObjId}`; + nameToObj.set(name, obj); + objToName.set(obj, name); + stagedNames.push(name); + return name; + }; + + /** + * Discard the names minted for the current request, so a reply the client + * never received leaves no reachable reference behind. + * + * `nextObjId` is rewound too. Reusing an id is safe precisely because a + * rolled-back name was never disclosed: no client can be holding it. + */ + const rollbackNames = (): void => { + for (const name of stagedNames) { + if (nameToObj.has(name)) { + objToName.delete(nameToObj.get(name)); + nameToObj.delete(name); + } + } + nextObjId = objIdBeforeRequest; + stagedNames = []; + }; + + const resolveName = (name: string): unknown => nameToObj.get(name); + + const handleRedeemURL = async (params: unknown): Promise => { + const url = requireUrlString(params); + const obj = await hooks.redeem(url); + return `${MARKER_PREFIX}${assignName(obj)}`; + }; + + const handleSend = async (params: unknown): Promise => { + const { target, method, args } = requireSendParams(params); + const targetObj = resolveName(target); + if (targetObj === undefined) { + // Report which connection failed to resolve the name and what it + // does hold. Names are per-connection, so the usual cause is a name + // minted on a different connection than the one now using it. + const known = [...nameToObj.keys()] + .map((name) => `${MARKER_PREFIX}${name}`) + .join(', '); + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + `params.target "@@${target}" is not a known reference on ` + + `${where()} (known here: ${known || 'none'})`, + ); + } + const expandedArgs = expandMarkers(args, resolveName) as unknown[]; + const result = await hooks.invoke(targetObj, method, expandedArgs); + return substituteRemotables(result, hooks.isRemotable, assignName); + }; + + const handleRequest = async (request: unknown): Promise => { + const id = extractId(request); + if (!isJsonRpcRequest(request)) { + return errorResponse( + id, + JSON_RPC_ERROR.INVALID_REQUEST, + 'not a well-formed JSON-RPC 2.0 request', + ); + } + try { + switch (request.method) { + case 'redeemURL': + return successResponse( + request.id, + await handleRedeemURL(request.params), + ); + case 'send': + return successResponse(request.id, await handleSend(request.params)); + default: + return errorResponse( + request.id, + JSON_RPC_ERROR.METHOD_NOT_FOUND, + `unknown method "${request.method}"`, + ); + } + } catch (error) { + if (error instanceof BridgeRpcError) { + return errorResponse(request.id, error.code, error.message, error.data); + } + const message = error instanceof Error ? error.message : String(error); + return errorResponse( + request.id, + JSON_RPC_ERROR.APPLICATION_ERROR, + message, + ); + } + }; + + const dispatch = async (request: unknown): Promise => { + objIdBeforeRequest = nextObjId; + stagedNames = []; + const response = await handleRequest(request); + if ('error' in response) { + // The client is being told the call failed, so it must not come away + // able to reach references the result walk minted before giving up. + // Names are sequential, so an undisclosed one is trivially guessable. + rollbackNames(); + return response; + } + try { + // A name becomes reachable only for a reply that can actually be sent. + // `JSON.stringify` still throws on values the walkers do not screen — + // a bigint, or a circular structure — and that failure has to roll the + // names back as well, which is why encodability is settled here rather + // than left to whoever writes the reply. Costs one extra encode per + // request, which is worth it to keep the two decisions in one place. + JSON.stringify(response); + } catch (error) { + rollbackNames(); + return errorResponse( + response.id, + JSON_RPC_ERROR.INTERNAL_ERROR, + 'result could not be encoded as JSON', + error instanceof Error ? error.message : String(error), + ); + } + stagedNames = []; + return response; + }; + + return { dispatch, resetSession }; +} + +/** + * Validate `redeemURL`'s params bag and return the URL string. + * + * @param params - The raw `params` field from the request. + * @returns The validated URL. + */ +function requireUrlString(params: unknown): string { + if ( + typeof params !== 'object' || + params === null || + typeof (params as { url?: unknown }).url !== 'string' + ) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + 'params.url must be a string', + ); + } + return (params as { url: string }).url; +} + +/** + * Validate `send`'s params bag and return the extracted call target, + * method name, and args array. + * + * @param params - The raw `params` field from the request. + * @returns The validated send arguments; `target` is the NAME (without + * the `@@` sigil) and `args` defaults to `[]` when omitted. + */ +function requireSendParams(params: unknown): { + target: string; + method: string; + args: unknown[]; +} { + if (typeof params !== 'object' || params === null) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + 'params must be an object', + ); + } + const bag = params as { + target?: unknown; + method?: unknown; + args?: unknown; + }; + if (typeof bag.target !== 'string') { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + 'params.target must be a string', + ); + } + const match = MARKER_PATTERN.exec(bag.target); + if (!match) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + `params.target must be a marker string like "${MARKER_PREFIX}j1"`, + ); + } + if (typeof bag.method !== 'string') { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + 'params.method must be a string', + ); + } + if (bag.args !== undefined && !Array.isArray(bag.args)) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + 'params.args must be an array', + ); + } + return { + target: match[1] as string, + method: bag.method, + args: (bag.args as unknown[] | undefined) ?? [], + }; +} + +/** + * Type-guard for a well-formed JSON-RPC 2.0 request envelope. + * + * @param value - Candidate parsed-JSON value. + * @returns True iff `value` has the required envelope fields. + */ +function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + if (typeof value !== 'object' || value === null) { + return false; + } + const bag = value as { + jsonrpc?: unknown; + method?: unknown; + id?: unknown; + }; + if (bag.jsonrpc !== '2.0' || typeof bag.method !== 'string') { + return false; + } + // An `id` is required: a missing one denotes a JSON-RPC notification, + // which this vat does not serve. Every line in gets exactly one line + // back, and that invariant is what keeps a persistent line-delimited + // stream in step — an unanswered request or an unexpected extra reply + // desynchronizes it permanently, with the client reading each answer as + // the response to some later request. Notifications would also be + // pointless here, since both methods exist to return a value. + // + // Rejecting also makes the predicate honest: `JsonRpcRequest.id` is + // `JsonRpcId`, which does not include `undefined`. + if ( + bag.id !== null && + typeof bag.id !== 'number' && + typeof bag.id !== 'string' + ) { + return false; + } + return true; +} + +/** + * Best-effort extraction of the request `id`, used when the request + * fails validation and must be echoed on the error response. + * + * @param value - Candidate parsed-JSON value. + * @returns The id, or `null` if none is recoverable. + */ +function extractId(value: unknown): JsonRpcId { + if (typeof value !== 'object' || value === null) { + return null; + } + const { id } = value as { id?: unknown }; + if (id === null || typeof id === 'number' || typeof id === 'string') { + return id; + } + return null; +} + +/** + * Build a JSON-RPC success response. + * + * A void method yields `undefined`, which `JSON.stringify` drops entirely — + * producing a response carrying neither `result` nor `error`, which is + * well-formed as neither outcome under JSON-RPC 2.0. Normalizing to `null` + * keeps the success shape intact. Only `undefined` is substituted, so + * falsy results like `0`, `''`, and `false` are reported as they are. + * + * @param id - The request id to echo. + * @param result - The result payload. + * @returns The response envelope. + */ +function successResponse(id: JsonRpcId, result: unknown): JsonRpcResponse { + return { jsonrpc: '2.0', id, result: result ?? null }; +} + +/** + * Build a JSON-RPC error response. + * + * @param id - The request id to echo. + * @param code - JSON-RPC error code (see `JSON_RPC_ERROR`). + * @param message - Human-readable error description. + * @param data - Optional additional error data. + * @returns The response envelope. + */ +function errorResponse( + id: JsonRpcId, + code: number, + message: string, + data?: unknown, +): JsonRpcResponse { + const error: { code: number; message: string; data?: unknown } = { + code, + message, + }; + if (data !== undefined) { + error.data = data; + } + return { jsonrpc: '2.0', id, error }; +} diff --git a/packages/ocap-jsonrpc-vat/src/cluster-config.test.ts b/packages/ocap-jsonrpc-vat/src/cluster-config.test.ts new file mode 100644 index 000000000..aa201ed93 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/cluster-config.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + OCAP_JSONRPC_BUNDLE_FILENAME, + OCAP_JSONRPC_SOCKET_CHANNEL, + OCAP_JSONRPC_VAT_NAME, + makeOcapJsonrpcClusterConfig, +} from './cluster-config.ts'; + +describe('makeOcapJsonrpcClusterConfig', () => { + it('produces a config with the ocap JSON-RPC vat as the bootstrap', () => { + const config = makeOcapJsonrpcClusterConfig({ + bundleBaseUrl: 'file:///tmp/jsonrpc', + socketPath: '/tmp/ocap-jsonrpc.sock', + }); + expect(config.bootstrap).toBe(OCAP_JSONRPC_VAT_NAME); + expect(config.services).toStrictEqual(['ocapURLRedemptionService']); + expect(config.io?.[OCAP_JSONRPC_SOCKET_CHANNEL]).toStrictEqual({ + type: 'socket', + path: '/tmp/ocap-jsonrpc.sock', + }); + expect(config.vats[OCAP_JSONRPC_VAT_NAME]?.bundleSpec).toBe( + `file:///tmp/jsonrpc/${OCAP_JSONRPC_BUNDLE_FILENAME}`, + ); + }); + + it('defaults forceReset to false', () => { + const config = makeOcapJsonrpcClusterConfig({ + bundleBaseUrl: 'x', + socketPath: '/x.sock', + }); + expect(config.forceReset).toBe(false); + }); + + it('passes forceReset through when set', () => { + const config = makeOcapJsonrpcClusterConfig({ + bundleBaseUrl: 'x', + socketPath: '/x.sock', + forceReset: true, + }); + expect(config.forceReset).toBe(true); + }); +}); diff --git a/packages/ocap-jsonrpc-vat/src/cluster-config.ts b/packages/ocap-jsonrpc-vat/src/cluster-config.ts new file mode 100644 index 000000000..80bf01f9c --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/cluster-config.ts @@ -0,0 +1,50 @@ +import type { ClusterConfig } from '@metamask/ocap-kernel'; + +/** Vat name for the ocap JSON-RPC vat inside its subcluster. */ +export const OCAP_JSONRPC_VAT_NAME = 'ocapJsonrpcVat'; + +/** + * Filename of the vat bundle produced by `yarn bundle-vat` in this + * package. A launcher supplies a `bundleBaseUrl` pointing at the + * directory containing this file. + */ +export const OCAP_JSONRPC_BUNDLE_FILENAME = 'index.bundle'; + +/** IO channel name the vat expects in its endowments. */ +export const OCAP_JSONRPC_SOCKET_CHANNEL = 'socket'; + +/** + * Build a `ClusterConfig` for the ocap JSON-RPC subcluster. + * + * @param options - Configuration options. + * @param options.bundleBaseUrl - Base URL (or filesystem path) where the + * vat bundle is reachable. The bundle filename is appended. + * @param options.socketPath - Filesystem path for the Unix-domain-socket + * IO channel the vat listens on. + * @param options.forceReset - Whether to reset the subcluster on launch. + * Defaults to `false`. + * @returns A ClusterConfig ready for `kernel.launchSubcluster(...)`. + */ +export function makeOcapJsonrpcClusterConfig(options: { + bundleBaseUrl: string; + socketPath: string; + forceReset?: boolean; +}): ClusterConfig { + const { bundleBaseUrl, socketPath, forceReset = false } = options; + return { + bootstrap: OCAP_JSONRPC_VAT_NAME, + forceReset, + services: ['ocapURLRedemptionService'], + io: { + [OCAP_JSONRPC_SOCKET_CHANNEL]: { + type: 'socket', + path: socketPath, + }, + }, + vats: { + [OCAP_JSONRPC_VAT_NAME]: { + bundleSpec: `${bundleBaseUrl}/${OCAP_JSONRPC_BUNDLE_FILENAME}`, + }, + }, + }; +} diff --git a/packages/ocap-jsonrpc-vat/src/index.ts b/packages/ocap-jsonrpc-vat/src/index.ts new file mode 100644 index 000000000..0998eb9e8 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/index.ts @@ -0,0 +1,18 @@ +export { + OCAP_JSONRPC_BUNDLE_FILENAME, + OCAP_JSONRPC_SOCKET_CHANNEL, + OCAP_JSONRPC_VAT_NAME, + makeOcapJsonrpcClusterConfig, +} from './cluster-config.ts'; + +export { + MARKER_PATTERN, + MARKER_PREFIX, + JSON_RPC_ERROR, + BridgeRpcError, + type JsonRpcId, + type JsonRpcRequest, + type JsonRpcResponse, + type JsonRpcSuccessResponse, + type JsonRpcErrorResponse, +} from './json-rpc.ts'; diff --git a/packages/ocap-jsonrpc-vat/src/json-rpc.test.ts b/packages/ocap-jsonrpc-vat/src/json-rpc.test.ts new file mode 100644 index 000000000..83dff2bea --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/json-rpc.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; + +import { + MARKER_PREFIX, + BridgeRpcError, + expandMarkers, + substituteRemotables, +} from './json-rpc.ts'; + +/** A stand-in for a remotable, identified by an isRemotable predicate. */ +type FakeRemotable = { __fakeRemotable__: true; id: string }; + +const isFakeRemotable = (value: unknown): boolean => + typeof value === 'object' && + value !== null && + (value as { __fakeRemotable__?: unknown }).__fakeRemotable__ === true; + +const makeFake = (id: string): FakeRemotable => ({ + __fakeRemotable__: true, + id, +}); + +describe('expandMarkers', () => { + const table = new Map([ + ['j1', makeFake('one')], + ['j2', makeFake('two')], + ]); + const resolve = (name: string): unknown => table.get(name); + + it('replaces a top-level marker string', () => { + expect(expandMarkers('@@j1', resolve)).toBe(table.get('j1')); + }); + + it('leaves non-marker strings alone', () => { + expect(expandMarkers('plain string', resolve)).toBe('plain string'); + expect(expandMarkers('@@', resolve)).toBe('@@'); + expect(expandMarkers('prefix@@j1', resolve)).toBe('prefix@@j1'); + expect(expandMarkers('@@j-1', resolve)).toBe('@@j-1'); + }); + + it('walks nested arrays', () => { + const result = expandMarkers(['@@j1', 42, '@@j2'], resolve); + expect(result).toStrictEqual([table.get('j1'), 42, table.get('j2')]); + }); + + it('walks nested objects', () => { + const result = expandMarkers( + { target: '@@j1', label: 'ship', payload: { via: '@@j2' } }, + resolve, + ); + expect(result).toStrictEqual({ + target: table.get('j1'), + label: 'ship', + payload: { via: table.get('j2') }, + }); + }); + + it('passes through primitives untouched', () => { + expect(expandMarkers(42, resolve)).toBe(42); + expect(expandMarkers(null, resolve)).toBeNull(); + expect(expandMarkers(true, resolve)).toBe(true); + }); + + it('throws on an unknown marker', () => { + expect(() => expandMarkers('@@missing', resolve)).toThrow(BridgeRpcError); + expect(() => expandMarkers(['@@missing'], resolve)).toThrow(/@@missing/u); + }); +}); + +describe('substituteRemotables', () => { + it('replaces a top-level remotable with a marker string', () => { + const obj = makeFake('alpha'); + const nameOf = (): string => 'j5'; + expect(substituteRemotables(obj, isFakeRemotable, nameOf)).toBe( + `${MARKER_PREFIX}j5`, + ); + }); + + it('walks nested arrays and objects', () => { + const alpha = makeFake('alpha'); + const beta = makeFake('beta'); + const counter = { n: 0 }; + const assigned = new Map(); + const assign = (obj: unknown): string => { + const existing = assigned.get(obj); + if (existing !== undefined) { + return existing; + } + counter.n += 1; + const name = `j${counter.n}`; + assigned.set(obj, name); + return name; + }; + const result = substituteRemotables( + { via: alpha, args: [beta, 'plain', { echo: alpha }] }, + isFakeRemotable, + assign, + ); + expect(result).toStrictEqual({ + via: '@@j1', + args: ['@@j2', 'plain', { echo: '@@j1' }], + }); + }); + + it.each([ + ['a bare promise', (): unknown => new Promise(() => undefined)], + [ + 'a nested promise', + (): unknown => ({ inner: new Promise(() => undefined) }), + ], + ['a promise in an array', (): unknown => [new Promise(() => undefined)]], + ['a foreign thenable', (): unknown => ({ then: () => undefined })], + ])('refuses to serialize %s', (_label, make) => { + const assign = (): string => 'j1'; + // A promise has no own enumerable properties, so walking it would yield + // `{}` and JSON.stringify would accept that — the client would receive a + // success response with the value silently gone. + expect(() => substituteRemotables(make(), isFakeRemotable, assign)).toThrow( + /unsettled promise/u, + ); + }); + + it.each([ + ['NaN', (): unknown => Number.NaN, /NaN/u], + ['Infinity', (): unknown => Number.POSITIVE_INFINITY, /Infinity/u], + ['-Infinity', (): unknown => Number.NEGATIVE_INFINITY, /-Infinity/u], + ['a nested NaN', (): unknown => ({ ratio: Number.NaN }), /NaN/u], + [ + 'an Infinity in an array', + (): unknown => [Number.POSITIVE_INFINITY], + /Infinity/u, + ], + ])('rejects %s rather than emitting null', (_label, make, pattern) => { + const assign = (): string => 'j1'; + // JSON.stringify turns a non-finite number into `null`, which is exactly + // what a void method produces — so the client cannot tell a missing value + // from a real one. + expect(() => substituteRemotables(make(), isFakeRemotable, assign)).toThrow( + pattern, + ); + }); + + it('leaves -0 alone, since it serializes to a numerically equal 0', () => { + const assign = (): string => 'unused'; + expect(substituteRemotables(-0, isFakeRemotable, assign)).toBe(-0); + }); + + it('leaves primitives and non-remotable objects alone', () => { + const assign = (): string => 'unused'; + expect(substituteRemotables(42, isFakeRemotable, assign)).toBe(42); + expect(substituteRemotables(null, isFakeRemotable, assign)).toBeNull(); + expect(substituteRemotables('text', isFakeRemotable, assign)).toBe('text'); + expect( + substituteRemotables({ a: 1, b: [2, 3] }, isFakeRemotable, assign), + ).toStrictEqual({ a: 1, b: [2, 3] }); + }); + + it('emits a JSON-serializable tree', () => { + const alpha = makeFake('alpha'); + const assign = (): string => 'j1'; + const tree = substituteRemotables( + { via: alpha, args: [alpha, 'plain'] }, + isFakeRemotable, + assign, + ); + // The whole point of substituteRemotables is that the result can be + // JSON-stringified without special handling. + expect(() => JSON.stringify(tree)).not.toThrow(); + expect(JSON.parse(JSON.stringify(tree))).toStrictEqual({ + via: '@@j1', + args: ['@@j1', 'plain'], + }); + }); +}); diff --git a/packages/ocap-jsonrpc-vat/src/json-rpc.ts b/packages/ocap-jsonrpc-vat/src/json-rpc.ts new file mode 100644 index 000000000..d909d4410 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/json-rpc.ts @@ -0,0 +1,206 @@ +/** + * Wire-shape types and walker helpers for the ocap JSON-RPC vat's + * line-delimited JSON-RPC 2.0 protocol. + * + * Object references are named via the sigil convention `"@@NAME"` (NAME + * one or more alphanumeric characters). The mediator assigns names of the + * form `j`; other allocation schemes remain compatible with the walker. + */ + +/** Full sigil string prefix (two `@`). */ +export const MARKER_PREFIX = '@@'; + +/** + * Match a whole string that consists solely of the sigil plus an + * alphanumeric name. Anchored deliberately: an embedded `@@x` is + * plain data. + */ +export const MARKER_PATTERN = /^@@([A-Za-z0-9]+)$/u; + +export type JsonRpcId = number | string | null; + +export type JsonRpcRequest = { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: unknown; +}; + +export type JsonRpcSuccessResponse = { + jsonrpc: '2.0'; + id: JsonRpcId; + result: unknown; +}; + +export type JsonRpcErrorResponse = { + jsonrpc: '2.0'; + id: JsonRpcId; + error: { code: number; message: string; data?: unknown }; +}; + +export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse; + +/** + * Standard JSON-RPC 2.0 error codes plus a mediator-specific application + * code in the reserved `-32000..-32099` range. + */ +export const JSON_RPC_ERROR = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + APPLICATION_ERROR: -32000, +} as const; + +/** + * Thrown from inside the mediator's request handlers to signal the + * intended JSON-RPC error code and message. + */ +export class BridgeRpcError extends Error { + readonly code: number; + + readonly data?: unknown; + + /** + * @param code - JSON-RPC error code to report (see {@link JSON_RPC_ERROR}). + * @param message - Human-readable error description. + * @param data - Optional additional error data to attach. + */ + constructor(code: number, message: string, data?: unknown) { + super(message); + this.code = code; + this.data = data; + } +} + +/** + * Walk `value`, replacing every `"@@NAME"` marker string with + * `resolve(name)`. Descends into plain arrays and record-like objects. + * + * @param value - The value to walk. + * @param resolve - Callback that turns a NAME into a live reference. + * If it returns `undefined` the walker throws — an unknown marker is + * always an error, since silently passing the string through would let + * callers accidentally send the literal `"@@..."` to a service. + * @returns A tree in which markers have been replaced by their live + * references and everything else is unchanged. + */ +export function expandMarkers( + value: unknown, + resolve: (name: string) => unknown, +): unknown { + if (typeof value === 'string') { + const match = MARKER_PATTERN.exec(value); + if (!match) { + return value; + } + const name = match[1] as string; + const resolved = resolve(name); + if (resolved === undefined) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INVALID_PARAMS, + `unknown reference marker "@@${name}"`, + ); + } + return resolved; + } + if (Array.isArray(value)) { + return value.map((item) => expandMarkers(item, resolve)); + } + if (typeof value === 'object' && value !== null) { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = expandMarkers(val, resolve); + } + return out; + } + return value; +} + +/** + * Identify a thenable. Checked structurally rather than via `passStyleOf` + * so this module stays free of environment assumptions — it takes + * `isRemotable` as a hook for the same reason — and so that a CapTP promise + * or any other foreign thenable is recognized alongside a native one. + * + * @param value - The value to test. + * @returns True if `value` has a callable `then`. + */ +function isThenable(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { then?: unknown }).then === 'function' + ); +} + +/** + * Walk `value`, replacing every remotable (as identified by + * `isRemotable`) with `"${MARKER_PREFIX}${assign(remotable)}"`. + * Descends into arrays and record-like objects. Primitives pass + * through unchanged. + * + * The result is a JSON-safe tree ready for `JSON.stringify`. + * + * @throws If the tree contains a value with no JSON form that + * `JSON.stringify` would nonetheless accept — an unsettled promise (which + * becomes `{}`) or a non-finite number (which becomes `null`) — since either + * would reach the client as a silently wrong success. + * + * @param value - The value to walk. + * @param isRemotable - Predicate identifying a value that should be + * substituted for a marker. + * @param assign - Callback that turns a remotable into a marker NAME + * (assigning one on first sight, reusing on subsequent sight). + * @returns A JSON-safe tree with remotables replaced by marker strings. + */ +export function substituteRemotables( + value: unknown, + isRemotable: (candidate: unknown) => boolean, + assign: (remotable: unknown) => string, +): unknown { + if (isRemotable(value)) { + return `${MARKER_PREFIX}${assign(value)}`; + } + if (Array.isArray(value)) { + return value.map((item) => substituteRemotables(item, isRemotable, assign)); + } + if (isThenable(value)) { + // A promise has no own enumerable properties, so the object walk below + // would quietly turn it into `{}` — and `JSON.stringify` would accept + // that, handing the client a plausible-looking success payload with the + // value silently missing. Refusing is the only honest option here: + // awaiting an arbitrarily nested promise could block the connection for + // as long as it stays unsettled. A method that returns a promise-valued + // field has to settle it before returning. + throw new BridgeRpcError( + JSON_RPC_ERROR.INTERNAL_ERROR, + 'result contains an unsettled promise, which has no JSON form', + ); + } + if (typeof value === 'number' && !Number.isFinite(value)) { + // JSON has no way to write `NaN` or `±Infinity`, and `JSON.stringify` + // does not complain — it emits `null`. That is indistinguishable from + // the `null` a void method legitimately produces (`successResponse` + // normalizes `undefined` to `null`), so the client cannot tell a missing + // value from a real one. Same reasoning as the promise case above: + // silently wrong is worse than an explicit failure. + // + // `-0` is deliberately allowed through. It serializes to `0`, which is + // a numerically equal JSON number rather than a value replaced by an + // unrelated one. + throw new BridgeRpcError( + JSON_RPC_ERROR.INTERNAL_ERROR, + `result contains ${String(value)}, which has no JSON form`, + ); + } + if (typeof value === 'object' && value !== null) { + const out: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + out[key] = substituteRemotables(val, isRemotable, assign); + } + return out; + } + return value; +} diff --git a/packages/ocap-jsonrpc-vat/src/vat/index.test.ts b/packages/ocap-jsonrpc-vat/src/vat/index.test.ts new file mode 100644 index 000000000..f1d9f7d9e --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/vat/index.test.ts @@ -0,0 +1,155 @@ +import type { Baggage } from '@metamask/ocap-kernel'; +import { describe, expect, it, vi } from 'vitest'; + +import { + makeMockBaggage, + makeMockConnection, + makeMockListener, + requestLine, +} from '../../test/helpers.ts'; +import { JSON_RPC_ERROR } from '../json-rpc.ts'; + +// The exo wrapper is irrelevant here and would need lockdown; the method bag +// is what these tests drive. Same approach as the other vat tests in the repo. +vi.mock('@metamask/kernel-utils/exo', () => ({ + makeDefaultExo: (_name: string, methods: Record) => methods, +})); + +// `E()` is not functional under `mock-endoify` — `HandledPromise` is absent, +// so any `E(x).m()` throws. Every target the vat reaches here is a local +// plain object, so identity is the faithful substitute, and what these tests +// cover is the shape of the accept/serve loops rather than eventual-send +// semantics. `bridge.ts` takes `redeem`/`invoke` as hooks for the same +// reason: it is meant to be exercised without a live kernel. +vi.mock('@endo/eventual-send', () => ({ + E: (target: unknown) => target, +})); + +const { buildRootObject } = await import('./index.ts'); + +type VatRoot = { + bootstrap: (vats: unknown, services: unknown) => Promise; +}; + +/** + * A stand-in for a reference a URL redeems to. Distinct per URL so a test + * can tell whose reference it is holding. + * + * @param label - Identifies which redemption produced this reference. + * @returns A callable stand-in reference. + */ +function makeRedeemed(label: string): { whoami: () => string } { + return { whoami: () => label }; +} + +/** + * Start the vat against a set of connections. + * + * @param connections - Connections for the listener to hand out. + * @returns The listener handle, so tests can count `accept()` calls. + */ +async function startVat( + connections: ReturnType[], +): Promise> { + const listener = makeMockListener(connections); + const root = buildRootObject( + undefined, + undefined, + makeMockBaggage() as unknown as Baggage, + ) as VatRoot; + await root.bootstrap( + {}, + { + ocapURLRedemptionService: { + redeem: async (url: string) => makeRedeemed(url), + }, + socket: listener.socket, + }, + ); + return listener; +} + +describe('accept loop: per-connection name tables', () => { + it('does not resolve a name minted on another connection', async () => { + const first = makeMockConnection([ + requestLine(1, 'redeemURL', { url: 'ocap://alpha' }), + ]); + // Forges the name the other connection was just given. + const second = makeMockConnection([ + requestLine(1, 'send', { target: '@@j1', method: 'whoami', args: [] }), + ]); + await startVat([first, second]); + + await vi.waitFor(() => { + expect(first.written).toHaveLength(1); + expect(second.written).toHaveLength(1); + }); + + expect(first.replies()[0]?.result).toBe('@@j1'); + const failure = second.replies()[0]?.error as { + code: number; + message: string; + }; + expect(failure.code).toBe(JSON_RPC_ERROR.INVALID_PARAMS); + expect(failure.message).toMatch(/not a known reference/u); + // The label makes it obvious which connection failed to resolve it. + expect(failure.message).toMatch(/connection 2/u); + }); + + it('mints the same name for different references on each connection', async () => { + const first = makeMockConnection([ + requestLine(1, 'redeemURL', { url: 'ocap://alpha' }), + requestLine(2, 'send', { target: '@@j1', method: 'whoami', args: [] }), + ]); + const second = makeMockConnection([ + requestLine(1, 'redeemURL', { url: 'ocap://beta' }), + requestLine(2, 'send', { target: '@@j1', method: 'whoami', args: [] }), + ]); + await startVat([first, second]); + + await vi.waitFor(() => { + expect(first.written).toHaveLength(2); + expect(second.written).toHaveLength(2); + }); + + // Both connections independently mint `@@j1` — the counters are their + // own — and each name resolves to that connection's own reference. + expect(first.replies()[0]?.result).toBe('@@j1'); + expect(second.replies()[0]?.result).toBe('@@j1'); + expect(first.replies()[1]?.result).toBe('ocap://alpha'); + expect(second.replies()[1]?.result).toBe('ocap://beta'); + }); +}); + +describe('accept loop: liveness', () => { + it('keeps serving new peers while an earlier one is stalled', async () => { + // Never sends anything and never hangs up. + const stalled = makeMockConnection([], { stall: true }); + const healthy = makeMockConnection([ + requestLine(1, 'redeemURL', { url: 'ocap://later' }), + ]); + const listener = await startVat([stalled, healthy]); + + await vi.waitFor(() => { + expect(healthy.written).toHaveLength(1); + }); + expect(healthy.replies()[0]?.result).toBe('@@j1'); + // Third accept() is the one that drains the queue and ends the loop, + // which can only happen if serving never blocked accepting. + await vi.waitFor(() => { + expect(listener.acceptCount()).toBe(3); + }); + expect(stalled.written).toHaveLength(0); + }); + + it('closes a connection once its peer goes away', async () => { + const connection = makeMockConnection([ + requestLine(1, 'redeemURL', { url: 'ocap://transient' }), + ]); + await startVat([connection]); + + await vi.waitFor(() => { + expect(connection.isClosed()).toBe(true); + }); + }); +}); diff --git a/packages/ocap-jsonrpc-vat/src/vat/index.ts b/packages/ocap-jsonrpc-vat/src/vat/index.ts new file mode 100644 index 000000000..3129bcda8 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/src/vat/index.ts @@ -0,0 +1,319 @@ +/** + * Ocap JSON-RPC vat. + * + * Serves a line-delimited JSON-RPC 2.0 interface on a Unix-domain-socket + * `IOListener` endowment named `socket`. External processes connect and + * call `redeemURL(url)` and `send(target, method, args)` — see the + * package README for the wire protocol. + * + * Each connection is served independently, with its own bridge and + * therefore its own `@@j` name table. Two clients can be connected at + * once without either being able to name the other's references: the + * names are closure state of one connection's serve loop, so a forged + * name simply misses that client's own table. Since those names cross a + * non-ocap boundary as plain forgeable strings, per-connection scoping is + * what keeps them from conveying authority they were never granted. + * + * The vat's authority is exactly: + * - the `ocapURLRedemptionService` endowment (for `redeemURL`), + * - whatever references the URLs happen to redeem to, + * - and whatever those references introduce as return values. + * + * The vat has no other public facet: the socket is the sole interface. + */ + +import { E } from '@endo/eventual-send'; +import { passStyleOf } from '@endo/pass-style'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage, OcapURLRedemptionService } from '@metamask/ocap-kernel'; + +import { makeBridge } from '../bridge.ts'; +import { BridgeRpcError, JSON_RPC_ERROR } from '../json-rpc.ts'; +import type { JsonRpcResponse } from '../json-rpc.ts'; + +/** + * The vat-facing shape of one accepted connection. The kernel-side + * implementation lives in `packages/ocap-kernel/src/io/io-service.ts`. + */ +type IOConnection = { + read: () => Promise; + write: (data: string) => Promise; + close: () => Promise; +}; + +/** + * The vat-facing shape of an `IOListener`. `accept()` resolves to the next + * peer's connection, or `null` once the listener has been closed. Wired + * via the cluster config's `io` block. + */ +type IOListener = { + accept: () => Promise; +}; + +type Services = { + ocapURLRedemptionService: OcapURLRedemptionService; + socket: IOListener; +}; + +/** + * Build the vat's root object. + * + * The `@@j` name table lives in ordinary closure state and is + * intentionally non-durable — each re-incarnation begins with an + * empty table. The services endowments delivered to `bootstrap` are + * stashed in baggage so that on re-incarnation `buildRootObject` can + * restart the socket serve loop without bootstrap having to run + * again (bootstrap only runs once per subcluster lifetime, not on + * every daemon restart). + * + * @param _vatPowers - Unused. + * @param _parameters - Unused. + * @param baggage - Vat baggage. Used to persist the services endowment + * bag so the serve loop can be resumed on re-incarnation. + * @returns The vat root exo. + */ +export function buildRootObject( + _vatPowers: unknown, + _parameters: unknown, + baggage: Baggage, +): unknown { + const log = (...args: unknown[]): void => { + // eslint-disable-next-line no-console + console.log('[ocap-jsonrpc-vat]', ...args); + }; + + const isRemotable = (value: unknown): boolean => { + if (typeof value !== 'object' || value === null) { + return false; + } + try { + const style: string = passStyleOf(value as never); + return style === 'remotable'; + } catch { + return false; + } + }; + + /** + * Read one request line, dispatch it, and write the response. Never + * throws to its caller — decoding, dispatch, and encoding errors are + * either logged and swallowed (when we can't recover an id to reply + * on) or packaged as JSON-RPC error responses. + * + * @param connection - The connection to serve. + * @param dispatch - The bridge's dispatch function. + * @returns 'ok' after processing a request, and 'closed' once the peer + * has gone away or the connection failed. + */ + async function processOne( + connection: IOConnection, + dispatch: (request: unknown) => Promise, + ): Promise<'ok' | 'closed'> { + let line: string | null; + try { + line = await E(connection).read(); + } catch (error) { + log('connection read failed:', error); + return 'closed'; + } + if (line === null) { + return 'closed'; + } + let request: unknown; + try { + request = JSON.parse(line); + } catch (error) { + // Reply rather than dropping: this is a request/reply socket, so a + // client awaiting an answer would otherwise wait forever. The id is + // unknowable from an unparseable line, which is exactly the case + // JSON-RPC 2.0 covers with a null id. + log('failed to parse request line as JSON:', error); + return await respond(connection, { + jsonrpc: '2.0', + id: null, + error: { + code: JSON_RPC_ERROR.PARSE_ERROR, + message: 'request line is not valid JSON', + }, + }); + } + return await respond(connection, await dispatch(request)); + } + + /** + * Encode and write one response. + * + * The encode guard here is now defensive rather than load-bearing: a + * response from `dispatch` has already been proven encodable, because the + * bridge has to know whether the reply is sendable before it commits the + * `@@j` names minted for it. This still covers the responses built + * directly in this module, and keeps a `JSON.stringify` throw from being + * reported as a write failure, which would drop the connection and leave + * the client waiting instead of answering it. + * + * @param connection - The connection to write to. + * @param response - The response to encode and send. + * @returns 'ok' if the response was written, 'closed' if the connection + * could not be written to. + */ + async function respond( + connection: IOConnection, + response: JsonRpcResponse, + ): Promise<'ok' | 'closed'> { + let encoded: string; + try { + encoded = JSON.stringify(response); + } catch (error) { + log('failed to encode response:', error); + encoded = JSON.stringify({ + jsonrpc: '2.0', + id: response.id, + error: { + code: JSON_RPC_ERROR.INTERNAL_ERROR, + message: 'result could not be encoded as JSON', + }, + }); + } + try { + await E(connection).write(encoded); + } catch (error) { + log('failed to write response:', error); + return 'closed'; + } + return 'ok'; + } + + /** + * Serve one connection for its whole lifetime, with a bridge — and so a + * name table — belonging to it alone. Returns when the peer goes away. + * + * @param services - The endowments delivered by bootstrap. + * @param connection - The connection to serve. + * @param label - Diagnostic label identifying this connection in logs. + */ + async function serveConnection( + services: Services, + connection: IOConnection, + label: string, + ): Promise { + const bridge = makeBridge({ + redeem: async (url) => E(services.ocapURLRedemptionService).redeem(url), + invoke: async (target, method, args) => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + E(target as any)[method](...args), + isRemotable, + label, + }); + try { + for (;;) { + const outcome = await processOne(connection, bridge.dispatch); + if (outcome === 'closed') { + log(`${label}: peer disconnected`); + return; + } + } + } finally { + // Discard this connection's names and let the kernel stop hosting + // it. Nothing else referenced them, so the table dies with the + // connection rather than leaking into whoever connects next. + bridge.resetSession(); + try { + await E(connection).close(); + } catch (error) { + log(`${label}: error closing connection:`, error); + } + } + } + + /** + * Accept connections forever, serving each one concurrently. A peer + * that stalls or floods only affects its own serve loop. + * + * @param services - The endowments delivered by bootstrap. + */ + async function acceptLoop(services: Services): Promise { + let acceptedCount = 0; + for (;;) { + let connection: IOConnection | null; + try { + connection = await E(services.socket).accept(); + } catch (error) { + log('accept failed; ending accept loop:', error); + return; + } + if (!connection) { + log('listener closed; ending accept loop'); + return; + } + acceptedCount += 1; + const label = `connection ${acceptedCount}`; + log(`${label}: accepted`); + // Deliberately not awaited: serving must not block accepting, or a + // single long-lived client would keep everyone else out — which is + // the failure the listener split exists to prevent. + serveConnection(services, connection, label).catch((error) => + log(`${label}: serve loop crashed:`, error), + ); + } + } + + /** + * Kick off the accept loop as a background task. Any crash inside it is + * logged; the vat itself remains alive so it can be introspected. + * + * @param services - The endowments to serve against. + */ + const startAcceptLoop = (services: Services): void => { + acceptLoop(services).catch((error) => log('accept loop crashed:', error)); + }; + + // On re-incarnation (e.g. after `daemon stop`/`daemon start`), + // bootstrap is not re-run — but this `buildRootObject` is. Read the + // previously-stashed services out of baggage and resume accepting. + // + // Only the listener reference has to survive, and it does: the kernel + // re-creates the listener under the same service kref before the vats + // are re-incarnated, so the baggage-held Presence is live again. The + // connections from the previous incarnation are gone, which is correct + // — a socket does not outlive the process on the other end of it. + // + // Deferred to a microtask so vat init completes and the vat is fully + // connected to kernel dispatch before we start issuing E() calls. + if (baggage.has('services')) { + const restored = baggage.get('services') as Services; + Promise.resolve() + .then(() => { + startAcceptLoop(restored); + log('vat re-incarnated; accept loop resumed'); + return undefined; + }) + .catch((error) => + log('failed to resume accept loop on re-incarnation:', error), + ); + } + + return makeDefaultExo('ocapJsonrpcVatRoot', { + async bootstrap(_vats: Record, incoming: Services) { + if (!incoming?.ocapURLRedemptionService) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INTERNAL_ERROR, + 'ocapURLRedemptionService is required', + ); + } + if (!incoming.socket) { + throw new BridgeRpcError( + JSON_RPC_ERROR.INTERNAL_ERROR, + 'socket IOListener is required (configure it in the cluster config under `io.socket`)', + ); + } + if (baggage.has('services')) { + baggage.set('services', incoming); + } else { + baggage.init('services', incoming); + } + startAcceptLoop(incoming); + log('vat bootstrap complete'); + return harden({}); + }, + }); +} diff --git a/packages/ocap-jsonrpc-vat/test/helpers.ts b/packages/ocap-jsonrpc-vat/test/helpers.ts new file mode 100644 index 000000000..45e6e4512 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/test/helpers.ts @@ -0,0 +1,124 @@ +/** + * Test helpers for driving the vat's accept loop against stand-in + * endowments. The vat only ever sees `accept()`, `read()`, `write()`, and + * `close()`, so a plain-object listener is enough to exercise the real + * wiring without a kernel. + */ + +/** + * Create a mock baggage store. + * + * @returns A mock baggage with Map semantics plus an `init` method. + */ +export function makeMockBaggage(): Map & { + init: (key: string, value: unknown) => void; +} { + const store = new Map(); + return Object.assign(store, { + init(key: string, value: unknown) { + if (store.has(key)) { + throw new Error(`Key already exists: ${key}`); + } + store.set(key, value); + }, + }); +} + +export type MockConnection = { + /** The connection as the vat sees it. */ + connection: { + read: () => Promise; + write: (data: string) => Promise; + close: () => Promise; + }; + /** Every line the vat has written, in order. */ + written: string[]; + /** Parsed view of `written`, for assertions. */ + replies: () => Record[]; + /** Whether the vat has closed this connection. */ + isClosed: () => boolean; +}; + +/** + * Create a mock connection that serves `lines` and then reports EOF. + * + * @param lines - Request lines to hand to the vat, in order. + * @param options - Behavior options. + * @param options.stall - When true, `read()` never settles once `lines` is + * drained, standing in for a peer that has gone quiet without hanging up. + * A stalled connection is what proves serving does not block accepting. + * @returns The connection plus inspection hooks. + */ +export function makeMockConnection( + lines: string[], + { stall = false }: { stall?: boolean } = {}, +): MockConnection { + const pending = [...lines]; + const written: string[] = []; + let closed = false; + return { + written, + replies: () => + written.map((line) => JSON.parse(line) as Record), + isClosed: () => closed, + connection: { + read: async (): Promise => { + const next = pending.shift(); + if (next !== undefined) { + return next; + } + if (stall) { + return new Promise(() => undefined); + } + return null; + }, + write: async (data: string): Promise => { + written.push(data); + }, + close: async (): Promise => { + closed = true; + }, + }, + }; +} + +/** + * Create a mock `IOListener` that hands out `connections` in order and then + * reports closure by resolving `null`. + * + * @param connections - The connections to yield from `accept()`. + * @returns The listener plus a count of `accept()` calls. + */ +export function makeMockListener(connections: MockConnection[]): { + socket: { accept: () => Promise }; + acceptCount: () => number; +} { + const queue = [...connections]; + let accepts = 0; + return { + acceptCount: () => accepts, + socket: { + accept: async (): Promise => { + accepts += 1; + const next = queue.shift(); + return next ? next.connection : null; + }, + }, + }; +} + +/** + * Build a JSON-RPC request line. + * + * @param id - The request id. + * @param method - The method to call. + * @param params - The params bag. + * @returns The encoded request line. + */ +export function requestLine( + id: number | string, + method: string, + params: unknown, +): string { + return JSON.stringify({ jsonrpc: '2.0', id, method, params }); +} diff --git a/packages/ocap-jsonrpc-vat/tsconfig.build.json b/packages/ocap-jsonrpc-vat/tsconfig.build.json new file mode 100644 index 000000000..6d2c3bc0d --- /dev/null +++ b/packages/ocap-jsonrpc-vat/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "types": [] + }, + "references": [ + { "path": "../kernel-utils/tsconfig.build.json" }, + { "path": "../ocap-kernel/tsconfig.build.json" } + ], + "files": [], + "include": ["./src"] +} diff --git a/packages/ocap-jsonrpc-vat/tsconfig.json b/packages/ocap-jsonrpc-vat/tsconfig.json new file mode 100644 index 000000000..4b626ea51 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "lib": ["ES2022"], + "types": ["vitest"] + }, + "references": [ + { "path": "../kernel-utils" }, + { "path": "../ocap-kernel" }, + { "path": "../repo-tools" } + ], + "include": [ + "../../vitest.config.ts", + "./src", + "./test", + "./vite.config.ts", + "./vitest.config.ts" + ] +} diff --git a/packages/ocap-jsonrpc-vat/typedoc.json b/packages/ocap-jsonrpc-vat/typedoc.json new file mode 100644 index 000000000..f8eb78ae1 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": [], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json", + "projectDocuments": ["documents/*.md"] +} diff --git a/packages/ocap-jsonrpc-vat/vitest.config.ts b/packages/ocap-jsonrpc-vat/vitest.config.ts new file mode 100644 index 000000000..82bd3b007 --- /dev/null +++ b/packages/ocap-jsonrpc-vat/vitest.config.ts @@ -0,0 +1,22 @@ +import { mergeConfig } from '@ocap/repo-tools/vitest-config'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, defineProject } from 'vitest/config'; + +import defaultConfig from '../../vitest.config.ts'; + +export default defineConfig((args) => { + return mergeConfig( + args, + defaultConfig, + defineProject({ + test: { + name: 'ocap-jsonrpc-vat', + setupFiles: [ + fileURLToPath( + import.meta.resolve('@ocap/repo-tools/test-utils/mock-endoify'), + ), + ], + }, + }), + ); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json index 3eacbee1f..d278b91d3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -17,6 +17,7 @@ { "path": "./packages/llm-bridge/tsconfig.build.json" }, { "path": "./packages/logger/tsconfig.build.json" }, { "path": "./packages/nodejs-test-workers/tsconfig.build.json" }, + { "path": "./packages/ocap-jsonrpc-vat/tsconfig.build.json" }, { "path": "./packages/ocap-kernel/tsconfig.build.json" }, { "path": "./packages/omnium-gatherum/tsconfig.build.json" }, { "path": "./packages/remote-iterables/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index fca57f680..aeda0aadc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ { "path": "./packages/llm-bridge" }, { "path": "./packages/logger" }, { "path": "./packages/nodejs-test-workers" }, + { "path": "./packages/ocap-jsonrpc-vat" }, { "path": "./packages/ocap-kernel" }, { "path": "./packages/omnium-gatherum" }, { "path": "./packages/remote-iterables" }, diff --git a/yarn.lock b/yarn.lock index cdce88f2c..436bb1784 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4297,6 +4297,46 @@ __metadata: languageName: unknown linkType: soft +"@ocap/ocap-jsonrpc-vat@workspace:packages/ocap-jsonrpc-vat": + version: 0.0.0-use.local + resolution: "@ocap/ocap-jsonrpc-vat@workspace:packages/ocap-jsonrpc-vat" + dependencies: + "@arethetypeswrong/cli": "npm:^0.17.4" + "@endo/eventual-send": "npm:^1.3.4" + "@endo/pass-style": "npm:^1.6.3" + "@metamask/auto-changelog": "npm:^5.3.0" + "@metamask/eslint-config": "npm:^15.0.0" + "@metamask/eslint-config-nodejs": "npm:^15.0.0" + "@metamask/eslint-config-typescript": "npm:^15.0.0" + "@metamask/kernel-utils": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" + "@ocap/repo-tools": "workspace:^" + "@ts-bridge/cli": "npm:^0.6.3" + "@ts-bridge/shims": "npm:^0.1.1" + "@typescript-eslint/eslint-plugin": "npm:^8.29.0" + "@typescript-eslint/parser": "npm:^8.29.0" + "@typescript-eslint/utils": "npm:^8.29.0" + "@vitest/eslint-plugin": "npm:^1.6.14" + depcheck: "npm:^1.4.7" + eslint: "npm:^9.23.0" + eslint-config-prettier: "npm:^10.1.1" + eslint-import-resolver-typescript: "npm:^4.3.1" + eslint-plugin-import-x: "npm:^4.10.0" + eslint-plugin-jsdoc: "npm:^50.6.9" + eslint-plugin-n: "npm:^17.17.0" + eslint-plugin-prettier: "npm:^5.2.6" + eslint-plugin-promise: "npm:^7.2.1" + prettier: "npm:^3.5.3" + rimraf: "npm:^6.0.1" + turbo: "npm:^2.9.1" + typedoc: "npm:^0.28.1" + typescript: "npm:~5.8.2" + typescript-eslint: "npm:^8.29.0" + vite: "npm:^8.0.6" + vitest: "npm:^4.1.3" + languageName: unknown + linkType: soft + "@ocap/omnium-gatherum@workspace:packages/omnium-gatherum": version: 0.0.0-use.local resolution: "@ocap/omnium-gatherum@workspace:packages/omnium-gatherum"