Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8ea777a
fix(ocap-kernel): honor the run-queue length cache's invalid sentinel
FUDCo Aug 4, 2026
0ad9437
feat(ocap-kernel): anonymous kernel-hosted objects
FUDCo Aug 4, 2026
2a4173a
feat(ocap-kernel): IOListener with accept(), replacing single-client …
FUDCo Aug 4, 2026
43edc1a
feat(kernel-node-runtime): socket listener with per-connection channels
FUDCo Aug 4, 2026
e1eaefb
test(kernel-test): io-vat accepts connections; cover two concurrent p…
FUDCo Aug 4, 2026
4c338c6
feat(kernel-utils,service-discovery-types): interface variant for Jso…
FUDCo Aug 4, 2026
0b27067
docs: changelog entries for the IOListener and JsonSchema interface work
FUDCo Aug 4, 2026
1db4f5c
test(kernel-utils): cover the interface JsonSchema validator
FUDCo Aug 4, 2026
32ea01d
feat(ocap-jsonrpc-vat): line-delimited JSON-RPC over a Unix socket
FUDCo Aug 5, 2026
bb20f3b
Merge remote-tracking branch 'origin/main' into chip/kernel-io-listener
FUDCo Aug 5, 2026
8bd1dd7
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 5, 2026
9aaceba
fix(ocap-kernel,kernel-node-runtime): address review on IOListener li…
FUDCo Aug 5, 2026
1d1e5f2
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 5, 2026
2659ad4
fix(ocap-jsonrpc-vat): keep the wire protocol well-formed on edge cases
FUDCo Aug 5, 2026
2c72192
fix(kernel-node-runtime): report a holder-initiated close to the list…
FUDCo Aug 5, 2026
f136c72
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 5, 2026
e68a80f
chore(ocap-jsonrpc-vat): correct the stale vitest project name
FUDCo Aug 5, 2026
e236325
docs(ocap-kernel): the run-queue bug is not startup-only
FUDCo Aug 5, 2026
2467302
Merge remote-tracking branch 'origin/main' into chip/kernel-io-listener
FUDCo Aug 5, 2026
f76df40
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 5, 2026
65474ed
fix(kernel-node-runtime): ignore socket data arriving after the chann…
FUDCo Aug 5, 2026
f9de8d8
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 5, 2026
547054a
fix(ocap-jsonrpc-vat): require a request id instead of accepting noti…
FUDCo Aug 5, 2026
f9e6ef9
Merge remote-tracking branch 'origin/main' into chip/kernel-io-listener
FUDCo Aug 6, 2026
20fb3bc
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 6, 2026
f18ca41
fix(ocap-kernel): sweep anonymous kernel objects abandoned by a previ…
FUDCo Aug 6, 2026
c5103c9
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 6, 2026
6dee3a5
fix(ocap-jsonrpc-vat): refuse to serialize unsettled promises
FUDCo Aug 6, 2026
585818a
docs(ocap-jsonrpc-vat): stale o<n> in module docstring is now j<n>
FUDCo Aug 6, 2026
7747dce
fix(ocap-kernel): reject, don't throw, for an unregistered kernel ser…
FUDCo Aug 6, 2026
4bcabdc
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 6, 2026
f7570df
chore: retrigger CI
FUDCo Aug 7, 2026
91f21aa
Merge branch 'chip/kernel-io-listener' into chip/ocap-jsonrpc-vat
FUDCo Aug 7, 2026
da0133e
fix(ocap-jsonrpc-vat): refuse non-finite numbers in results
FUDCo Aug 7, 2026
a29b09a
Merge remote-tracking branch 'origin/main' into chip/ocap-jsonrpc-vat
FUDCo Aug 7, 2026
746450a
fix(ocap-jsonrpc-vat): commit reference names only for a sendable reply
FUDCo Aug 7, 2026
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
19 changes: 19 additions & 0 deletions packages/ocap-jsonrpc-vat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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<n>` referring to the resulting live reference
- `send(target, method, args)` invokes `E(target)[method](...args)` with `@@j<n>` 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<n>` 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
- Session state is in-memory only and resets on socket disconnect

[Unreleased]: https://github.com/MetaMask/ocap-kernel/
39 changes: 39 additions & 0 deletions packages/ocap-jsonrpc-vat/README.md
Original file line number Diff line number Diff line change
@@ -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<n>"`

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<n>"` 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<n>"` name in the response.

Object identity is preserved: an object the caller has already seen
keeps the same `@@j<n>` 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.
90 changes: 90 additions & 0 deletions packages/ocap-jsonrpc-vat/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
87 changes: 87 additions & 0 deletions packages/ocap-jsonrpc-vat/scripts/VPS-REHEARSAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# VPS-side rehearsal notes for the ocap-jsonrpc-vat

The vat replaces the openclaw plugins' shell-execed `ocap daemon
queueMessage`/`redeem-url` calls with a persistent JSON-RPC 2.0
connection over a Unix socket. On the VPS it lives in the consumer
daemon (`~/.ocap-consumer`), which had no vats previously.

Both routine restarts and cold resets are automated:

- `rehearsal-restart-matcher.sh` — routine pre-rehearsal reset (URL,
registry, and vats stay put). Now includes a step 2b that runs
`start-ocap-jsonrpc-vat.sh --home ~/.ocap-consumer`.
- `reset-everything.sh` — cold reset with fresh URLs. Now includes a
step 7b that launches a fresh vat subcluster in the consumer
daemon.

So the operator does not have to invoke the vat launcher directly in
normal rehearsal flow. The manual launcher (below) is only for
debugging or ad-hoc use.

## Prerequisites

- The chip/orchestration-demo branch is checked out at the same path
as before (openclaw plugins install with `-l` from the workspace,
so the branch update is picked up automatically).
- `yarn workspace @metamask/kernel-cli build` and
`yarn workspace @ocap/ocap-jsonrpc-vat build` have run at least
once since the branch update.

## Manual launch (for debugging)

```bash
./packages/ocap-jsonrpc-vat/scripts/start-ocap-jsonrpc-vat.sh \
--home ~/.ocap-consumer
```

Confirm the socket:

```bash
ls -l ~/.ocap-consumer/ocap-jsonrpc.sock
node ./packages/ocap-jsonrpc-vat/scripts/probe.mjs \
~/.ocap-consumer/ocap-jsonrpc.sock ocap:some@peer
```

The probe should print a `redeemURL` request whose response is either
a `@@j<n>` marker (on success) or a `[KERNEL:DELIVERY_FAILED]` error
if the URL doesn't resolve or remote comms are down.

## Openclaw plugin config

For each of the three plugins in `~/.openclaw/openclaw.json` under
`plugins.entries` (`discovery`, `metamask`, `demo`):

- **Remove** `ocapCliPath`. The plugin's config schema no longer
accepts it — leaving it in will fail plugin registration.
- **Add or change** `ocapHome` to `~/.ocap-consumer`. All three
plugins point at the same consumer-daemon socket.

Alternatively set `socketPath` explicitly per plugin.

Example diff:

```jsonc
"discovery": {
"config": {
- "ocapCliPath": "/root/…/packages/kernel-cli/dist/app.mjs",
+ "ocapHome": "/root/.ocap-consumer",
"matcherUrl": "ocap:…"
}
}
```

Then restart openclaw (rehearsal-restart-matcher.sh does this in
step 3).

## Sanity check before an LLM turn

- `discovery_list_tracked` should show the matcher URL pre-redeemed
and its ref shown as `@@j<n>` (was previously a kref).
- A `discovery_find_services` turn against the matcher should behave
as before — matcher on VPS, provider vats on laptop are untouched.

## What changes on the laptop side

Nothing structural. The laptop's provider vats and consumer daemon
keep their existing OCAP URLs. The plugins on VPS reach them through
the same libp2p path; only the plugin-to-kernel hop changed.
142 changes: 142 additions & 0 deletions packages/ocap-jsonrpc-vat/scripts/probe.mjs
Original file line number Diff line number Diff line change
@@ -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<net.Socket>} 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<object>} 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();
Loading
Loading