diff --git a/.github/workflows/test-all-packages.yml b/.github/workflows/test-all-packages.yml index f674220d276a..f010300699f5 100644 --- a/.github/workflows/test-all-packages.yml +++ b/.github/workflows/test-all-packages.yml @@ -203,6 +203,8 @@ jobs: run: cd packages/swingset-liveslots && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT - name: yarn test (swingset-xsnap-supervisor) run: cd packages/swingset-xsnap-supervisor && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT + - name: yarn test (swingset-worker-xsnap-v1) + run: cd packages/swingset-worker-xsnap-v1 && yarn ${{ steps.vars.outputs.test }} | $TEST_COLLECT # The meta-test! - name: Check for untested packages diff --git a/packages/SwingSet/misc-tools/replay-transcript.js b/packages/SwingSet/misc-tools/replay-transcript.js index 8484b49d6a87..8585dd44495c 100644 --- a/packages/SwingSet/misc-tools/replay-transcript.js +++ b/packages/SwingSet/misc-tools/replay-transcript.js @@ -1,5 +1,6 @@ /* global WeakRef FinalizationRegistry */ /* eslint-disable no-constant-condition */ +/* eslint-disable import/no-extraneous-dependencies */ import fs from 'fs'; // import '@endo/init'; import '../tools/install-ses-debug.js'; diff --git a/packages/SwingSet/package.json b/packages/SwingSet/package.json index c4a4f53b3c2d..467b94f368c4 100644 --- a/packages/SwingSet/package.json +++ b/packages/SwingSet/package.json @@ -21,6 +21,8 @@ "lint:eslint": "eslint ." }, "devDependencies": { + "@agoric/swingset-xsnap-supervisor": "^0.9.0", + "@agoric/xsnap-lockdown": "^0.13.2", "@types/better-sqlite3": "^7.5.0", "@types/microtime": "^2.1.0", "@types/tmp": "^0.2.0", @@ -34,11 +36,10 @@ "@agoric/store": "^0.8.3", "@agoric/swing-store": "^0.8.1", "@agoric/swingset-liveslots": "^0.9.0", - "@agoric/swingset-xsnap-supervisor": "^0.9.0", + "@agoric/swingset-worker-xsnap-v1": "^0.9.0", "@agoric/time": "^0.2.1", "@agoric/vat-data": "^0.4.3", "@agoric/xsnap": "^0.13.2", - "@agoric/xsnap-lockdown": "^0.13.2", "@endo/base64": "^0.2.28", "@endo/bundle-source": "^2.4.2", "@endo/captp": "^2.0.18", diff --git a/packages/SwingSet/src/controller/startXSnap.js b/packages/SwingSet/src/controller/startXSnap.js index 8af3552c188f..d42a1f6994d2 100644 --- a/packages/SwingSet/src/controller/startXSnap.js +++ b/packages/SwingSet/src/controller/startXSnap.js @@ -1,12 +1,5 @@ -import fs from 'fs'; import path from 'path'; -import { Fail } from '@agoric/assert'; -import { type as osType } from 'os'; -import { xsnap, recordXSnap } from '@agoric/xsnap'; -import { getLockdownBundle } from '@agoric/xsnap-lockdown'; -import { getSupervisorBundle } from '@agoric/swingset-xsnap-supervisor'; - -const NETSTRING_MAX_CHUNK_SIZE = 12_000_000; +import { makeStartXSnapV1 } from '@agoric/swingset-worker-xsnap-v1'; /** * @param {{ @@ -20,9 +13,8 @@ const NETSTRING_MAX_CHUNK_SIZE = 12_000_000; export function makeStartXSnap(options) { // our job is to simply curry some authorities and settings into the // 'startXSnap' function we return - const { snapStore, spawn, debug = false, traceFile } = options; - const { overrideBundles } = options; + const { traceFile, ...other } = options; let serial = 0; const makeTraceFile = traceFile ? () => { @@ -32,6 +24,8 @@ export function makeStartXSnap(options) { } : undefined; + const startXSnapV1 = makeStartXSnapV1({ makeTraceFile, ...other }); + /** * @param {string} workerVersion * @param {string} vatID @@ -48,69 +42,11 @@ export function makeStartXSnap(options) { metered, reload = false, ) { - await 0; // empty synchronous prelude - - /** @type { import('@agoric/xsnap/src/xsnap').XSnapOptions } */ - const xsnapOpts = { - os: osType(), - spawn, - stdout: 'inherit', - stderr: 'inherit', - debug, - netstringMaxChunkSize: NETSTRING_MAX_CHUNK_SIZE, - }; - - let doXSnap = xsnap; - if (makeTraceFile) { - doXSnap = opts => { - const workerTrace = makeTraceFile(); - console.log('SwingSet xs-worker tracing:', { workerTrace }); - fs.mkdirSync(workerTrace, { recursive: true }); - return recordXSnap(opts, workerTrace, { - writeFileSync: fs.writeFileSync, - }); - }; - } - - const meterOpts = metered ? {} : { meteringLimit: 0 }; - if (snapStore && reload) { - // console.log('startXSnap from', { snapshotHash }); - return snapStore.loadSnapshot(vatID, async snapshot => { - const xs = doXSnap({ - snapshot, - name, - handleCommand, - ...meterOpts, - ...xsnapOpts, - }); - await xs.isReady(); - return xs; - }); - } - // console.log('fresh xsnap', { snapStore: snapStore }); - const worker = doXSnap({ handleCommand, name, ...meterOpts, ...xsnapOpts }); - - let bundles = []; if (workerVersion === 'xsnap-v1') { - // eslint-disable-next-line @jessie.js/no-nested-await - bundles.push(await getLockdownBundle()); - // eslint-disable-next-line @jessie.js/no-nested-await - bundles.push(await getSupervisorBundle()); - } else { - throw Error(`unsupported worker version ${workerVersion}`); - } - if (overrideBundles) { - bundles = overrideBundles; // replace the usual bundles + return startXSnapV1(vatID, name, handleCommand, metered, reload); } - - for (const bundle of bundles) { - bundle.moduleFormat === 'getExport' || - bundle.moduleFormat === 'nestedEvaluate' || - Fail`unexpected: ${bundle.moduleFormat}`; - // eslint-disable-next-line no-await-in-loop, @jessie.js/no-nested-await - await worker.evaluate(`(${bundle.source}\n)()`.trim()); - } - return worker; + throw Error(`unsupported worker version ${workerVersion}`); } + return startXSnap; } diff --git a/packages/agoric-cli/src/sdk-package-names.js b/packages/agoric-cli/src/sdk-package-names.js index a07e24bfec8f..93ed550d8210 100644 --- a/packages/agoric-cli/src/sdk-package-names.js +++ b/packages/agoric-cli/src/sdk-package-names.js @@ -31,6 +31,7 @@ export default [ "@agoric/swingset-liveslots", "@agoric/swingset-runner", "@agoric/swingset-vat", + "@agoric/swingset-worker-xsnap-v1", "@agoric/swingset-xsnap-supervisor", "@agoric/telemetry", "@agoric/time", diff --git a/packages/swingset-worker-xsnap-v1/.gitignore b/packages/swingset-worker-xsnap-v1/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/swingset-worker-xsnap-v1/README.md b/packages/swingset-worker-xsnap-v1/README.md new file mode 100644 index 000000000000..b7d98c01f600 --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/README.md @@ -0,0 +1,25 @@ +# swingset-worker-xs-v1 + +This package provides a function to create a "xsnap-v1" SwingSet vat worker. + +This worker will include specific (stable) versions of the following components: + +* the `xsnap` package (@agoric/xsnap), which includes: + * the `xsnap` executable, a C program that combines a specific version of the XS JavaScript runtime, and a driver program (`xsnap.c`) that accepts command messages over a socket + * a JS library to launch that program as a child process, and then send/receive messages over the socket +* a "lockdown bundle" (@agoric/xsnap-lockdown), which can be evaluated inside the xsnap program, to transform the plain (unsecured) JS environment into our preferred (secure) SES/Endo environment, by taming the global constructors, removing ambient authority, and creating the `Compartment` constructor +* a "supervisor bundle", which can be evaluated after lockdown, to hook into the globally-registered handler function to accept delivery messages + * this imports a specific version of "@agoric/swingset-liveslots", to create the object-capability / distributed-messaging environment, which can route messages through syscalls, and provide virtual/durable object support + +By importing `@agoric/swingset-worker-xsnap-v1`, the kernel will get a stable behavior from the worker (including consistent heap snapshot contents), regardless of changes to other packages, or behavior-neutral changes to the kernel itself. Any two kernels which use `xsnap-v1` and make the same sequence of deliveries (and syscalls responses) should get the same XS state, the same syscalls, the same metering, and the same XS heap snapshot hash. + +To guard against accidental changes to the `@agoric/swingset-worker-xsnap-v1` package (perhaps yarn.lock being insufficient to lock down the bundle package versions correctly), the API can also return a hash string that describes the contents of the bundles. The kernel code that imports this module can compare this string against hard-coded values, which would only be changed when deliberately switching to a new version of the worker. + +The intention is for the `@agoric/swingset-worker-xsnap-v1` package to be published to NPM only once: we publish version 1.0.0 and then never publish again. Any new changes would go into a new `@agoric/swingset-worker-xsnap-v2` package (of which we'd only ever publish 1.0.0 as well), etc. + +We will figure out a different approach for "dev" development, where downstream clients want to use unstable/recent versions instead. Follow https://github.com/Agoric/agoric-sdk/issues/7056 for details. + +## API + +The primary export is a function named `makeStartXSnapV1()`. This takes a set of authorities (the snapStore, `env`, and a `spawn` function) and returns a `startXSnapV1()` function. This latter function encapsulates all the components listed above. + diff --git a/packages/swingset-worker-xsnap-v1/jsconfig.json b/packages/swingset-worker-xsnap-v1/jsconfig.json new file mode 100644 index 000000000000..b7cc9a19a3bd --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/jsconfig.json @@ -0,0 +1,12 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "extends": "../../tsconfig.json", + "include": [ + "*.js", + "lib/**/*.js", + "src/**/*.d.ts", + "src/**/*.js", + "test/**/*.js", + "tools/**/*.js", + ], +} diff --git a/packages/swingset-worker-xsnap-v1/package.json b/packages/swingset-worker-xsnap-v1/package.json new file mode 100644 index 000000000000..d728206a6261 --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/package.json @@ -0,0 +1,47 @@ +{ + "name": "@agoric/swingset-worker-xsnap-v1", + "version": "0.9.0", + "description": "make swingset xsnap-v1 workers", + "author": "Agoric", + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.js", + "scripts": { + "build": "exit 0", + "clean": "exit 0", + "lint": "run-s --continue-on-error lint:*", + "lint:js": "eslint 'src/**/*.js' 'test/**/*.js'", + "lint:types": "tsc -p jsconfig.json", + "lint-fix": "eslint --fix 'src/**/*.js' 'test/**/*.js'", + "test": "ava", + "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", + "test:xs": "exit 0" + }, + "dependencies": { + "@agoric/assert": "^0.5.1", + "@agoric/swingset-xsnap-supervisor": "^0.9.0", + "@agoric/xsnap": "^0.13.2", + "@agoric/xsnap-lockdown": "^0.13.2" + }, + "devDependencies": { + "@endo/bundle-source": "^2.4.2", + "@endo/init": "^0.5.52", + "@endo/marshal": "^0.8.1", + "ava": "^5.1.0", + "c8": "^7.12.0" + }, + "files": [ + "LICENSE*", + "src" + ], + "publishConfig": { + "access": "public" + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "timeout": "2m", + "workerThreads": false + } +} diff --git a/packages/swingset-worker-xsnap-v1/src/index.js b/packages/swingset-worker-xsnap-v1/src/index.js new file mode 100644 index 000000000000..1b7809749dec --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/src/index.js @@ -0,0 +1 @@ +export { makeStartXSnapV1 } from './make-v1.js'; diff --git a/packages/swingset-worker-xsnap-v1/src/make-v1.js b/packages/swingset-worker-xsnap-v1/src/make-v1.js new file mode 100644 index 000000000000..2d8c6a0fdc0a --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/src/make-v1.js @@ -0,0 +1,108 @@ +import fs from 'fs'; +import { Fail } from '@agoric/assert'; +import { type as osType } from 'os'; +import { xsnap, recordXSnap } from '@agoric/xsnap'; +import { getLockdownBundle } from '@agoric/xsnap-lockdown'; +import { getSupervisorBundle } from '@agoric/swingset-xsnap-supervisor'; + +const NETSTRING_MAX_CHUNK_SIZE = 12_000_000; + +/** + * SnapStore is defined by swing-store, but we don't import that: we get it + * from swingset as an option. To avoid an inconvenient dependency graph, + * we define the salient methods locally. + * + * @typedef {(vatID: string, loadRaw: (filePath: string) => Promise) => Promise} LoadSnapshot + * @typedef {{ loadSnapshot: LoadSnapshot }} SnapStore + */ +/** + * @param {{ + * snapStore?: SnapStore, + * spawn: typeof import('child_process').spawn + * debug?: boolean, + * makeTraceFile?: () => string, + * overrideBundles?: { moduleFormat: string, source: string }[], + * }} options + */ +export function makeStartXSnapV1(options) { + // our job is to simply curry some authorities and settings into the + // 'startXSnapV1' function we return + const { snapStore, spawn, debug = false, makeTraceFile } = options; + const { overrideBundles } = options; + + /** + * @param {string} vatID + * @param {string} name + * @param {(request: Uint8Array) => Promise} handleCommand + * @param {boolean} [metered] + * @param {boolean} [reload] + */ + async function startXSnapV1( + vatID, + name, + handleCommand, + metered, + reload = false, + ) { + await 0; // empty synchronous prelude + + /** @type { import('@agoric/xsnap/src/xsnap').XSnapOptions } */ + const xsnapOpts = { + os: osType(), + spawn, + stdout: 'inherit', + stderr: 'inherit', + debug, + netstringMaxChunkSize: NETSTRING_MAX_CHUNK_SIZE, + }; + + let doXSnap = xsnap; + if (makeTraceFile) { + doXSnap = opts => { + const workerTrace = makeTraceFile(); + console.log('SwingSet xs-worker tracing:', { workerTrace }); + fs.mkdirSync(workerTrace, { recursive: true }); + return recordXSnap(opts, workerTrace, { + writeFileSync: fs.writeFileSync, + }); + }; + } + + const meterOpts = metered ? {} : { meteringLimit: 0 }; + if (snapStore && reload) { + // console.log('startXSnap from', { snapshotHash }); + return snapStore.loadSnapshot(vatID, async snapshot => { + const xs = doXSnap({ + snapshot, + name, + handleCommand, + ...meterOpts, + ...xsnapOpts, + }); + await xs.isReady(); + return xs; + }); + } + // console.log('fresh xsnap', { snapStore: snapStore }); + const worker = doXSnap({ handleCommand, name, ...meterOpts, ...xsnapOpts }); + + let bundles = []; + // eslint-disable-next-line @jessie.js/no-nested-await + bundles.push(await getLockdownBundle()); + // eslint-disable-next-line @jessie.js/no-nested-await + bundles.push(await getSupervisorBundle()); + if (overrideBundles) { + bundles = overrideBundles; // replace the usual bundles + } + + for (const bundle of bundles) { + bundle.moduleFormat === 'getExport' || + bundle.moduleFormat === 'nestedEvaluate' || + Fail`unexpected: ${bundle.moduleFormat}`; + // eslint-disable-next-line no-await-in-loop, @jessie.js/no-nested-await + await worker.evaluate(`(${bundle.source}\n)()`.trim()); + } + return worker; + } + return startXSnapV1; +} diff --git a/packages/swingset-worker-xsnap-v1/test/test-worker.js b/packages/swingset-worker-xsnap-v1/test/test-worker.js new file mode 100644 index 000000000000..54279276b3c9 --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/test/test-worker.js @@ -0,0 +1,104 @@ +import test from 'ava'; +import '@endo/init/debug.js'; + +import { spawn } from 'child_process'; +import bundleSource from '@endo/bundle-source'; +import { makeMarshal } from '@endo/marshal'; +import { Fail } from '@agoric/assert'; +import { makeStartXSnapV1 } from '../src/index.js'; + +const nope = () => Fail`nope`; +const kmarshal = makeMarshal(nope, nope, { + serializeBodyFormat: 'smallcaps', + errorTagging: 'off', +}); +const kser = value => kmarshal.serialize(harden(value)); +const kunser = value => kmarshal.unserialize(value); + +test('basic delivery', async t => { + const fn = new URL('./vat-simple.js', import.meta.url).pathname; + const bundle = await bundleSource(fn, { dev: true }); + + const snapStore = /** @type {SnapStore} */ undefined; + const startXSnapV1 = makeStartXSnapV1({ + snapStore, // unused by this test + spawn, + }); + + const vatstore = new Map(); + const resolutions = new Map(); + + const vatID = 'v1'; + const argName = 'v1-name'; + function handleUpstream(body) { + if (body[0] === 'syscall') { + let result; + const vso = body[1]; + if (vso[0] === 'vatstoreGet') { + result = vatstore.get(vso[1]); + } else if (vso[0] === 'vatstoreSet') { + vatstore.set(vso[1], vso[2]); + } else if (vso[0] === 'vatstoreGetNextKey') { + if (vso[1] === 'vom.dkind.') { + // liveslots is looking for KindHandles from a previous version + result = undefined; // tell it there are none + } else { + console.log(`unhandled vatstoreGetNextKey`, vso); + throw Error('unhandled vatstoreGetNextKey'); + } + } else if (vso[0] === 'resolve') { + for (const [vpid, isReject, value] of vso[1]) { + resolutions.set(vpid, [isReject, value]); + } + } else { + console.log(`unhandled syscall`, vso); + throw Error('unhandled syscall'); + } + return ['ok', result]; + } else if (body[0] === 'sourcedConsole') { + const [_, source, level, value] = body; + console.log(`log: ${source} console.${level}: ${value}`); + } else { + console.log(`unhandled upstream`, body); + throw Error('unhandled upstream'); + } + return ['ok']; + } + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + async function handleCommand(msg) { + const tagged = handleUpstream(JSON.parse(decoder.decode(msg))); + return encoder.encode(JSON.stringify(tagged)); + } + const metered = false; + const worker = await startXSnapV1(vatID, argName, handleCommand, metered); + console.log(`-- started worker`); + + const liveSlotsOptions = {}; + const setBundle = ['setBundle', vatID, bundle, liveSlotsOptions]; + const sbResult = await worker.issueStringCommand(JSON.stringify(setBundle)); + t.deepEqual(JSON.parse(sbResult.reply), ['dispatchReady']); + + // liveslots does a bunch of vatstore operations during startVat + const vdoStartVat = ['startVat', kser()]; + const deliverStartVat = ['deliver', vdoStartVat]; + const dResult = await worker.issueStringCommand( + JSON.stringify(deliverStartVat), + ); + t.deepEqual(JSON.parse(dResult.reply), ['ok', null, null]); + + const pingMethargsBody = JSON.stringify(['ping', []]); + const vdoPing = [ + 'message', + 'o+0', + { methargs: { body: pingMethargsBody, slots: [] }, result: 'p-1' }, + ]; + const deliverPing = ['deliver', vdoPing]; + const pResult = await worker.issueStringCommand(JSON.stringify(deliverPing)); + t.deepEqual(JSON.parse(pResult.reply), ['ok', null, null]); + + t.true(resolutions.has('p-1')); + const [isReject, value] = resolutions.get('p-1'); + t.is(isReject, false); + t.is(kunser(value), 'pong'); +}); diff --git a/packages/swingset-worker-xsnap-v1/test/vat-simple.js b/packages/swingset-worker-xsnap-v1/test/vat-simple.js new file mode 100644 index 000000000000..035dbb03c02b --- /dev/null +++ b/packages/swingset-worker-xsnap-v1/test/vat-simple.js @@ -0,0 +1,11 @@ +import { Far } from '@endo/marshal'; + +export function buildRootObject() { + console.log(`--vat: in buildRootObject`); + return Far('root', { + ping: () => { + console.log(`--vat: in ping`); + return 'pong'; + }, + }); +}