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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ jobs:
with:
tool: wasm-tools,just,wac-cli
- uses: Swatinem/rust-cache@v2
# The endpoint component is built from polymorph-iroh's source, not
# taken from its jsr package (justfile `endpoint`: the interface the
# runtime binds is feature-gated). That is a ~20 s cargo build of a
# second dependency tree; the 2 MB artifact is what the composition
# actually needs, so cache the artifact rather than the checkout. The
# key is the justfile because the pinned revision lives there — a
# justfile edit costs one rebuild, a stale artifact is impossible
# (`endpoint` looks for the revision's own filename).
- uses: actions/cache@v4
with:
path: target/iroh_endpoint-*.wasm
key: iroh-endpoint-${{ runner.os }}-${{ hashFiles('justfile') }}
- run: just ci

e2e:
Expand All @@ -42,6 +54,18 @@ jobs:
with:
tool: wasm-tools,just,wac-cli
- uses: Swatinem/rust-cache@v2
# The endpoint component is built from polymorph-iroh's source, not
# taken from its jsr package (justfile `endpoint`: the interface the
# runtime binds is feature-gated). That is a ~20 s cargo build of a
# second dependency tree; the 2 MB artifact is what the composition
# actually needs, so cache the artifact rather than the checkout. The
# key is the justfile because the pinned revision lives there — a
# justfile edit costs one rebuild, a stale artifact is impossible
# (`endpoint` looks for the revision's own filename).
- uses: actions/cache@v4
with:
path: target/iroh_endpoint-*.wasm
key: iroh-endpoint-${{ runner.os }}-${{ hashFiles('justfile') }}
# Chromium plus its shared-library deps, at the exact version deno.json
# imports — a newer CLI fetches a build the library will not find.
- run: npx -y playwright@1.62.1 install --with-deps chromium
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ jobs:
with:
tool: wasm-tools,just,wac-cli
- uses: Swatinem/rust-cache@v2
# The endpoint component is built from polymorph-iroh's source, not
# taken from its jsr package (justfile `endpoint`: the interface the
# runtime binds is feature-gated). That is a ~20 s cargo build of a
# second dependency tree; the 2 MB artifact is what the composition
# actually needs, so cache the artifact rather than the checkout. The
# key is the justfile because the pinned revision lives there — a
# justfile edit costs one rebuild, a stale artifact is impossible
# (`endpoint` looks for the revision's own filename).
- uses: actions/cache@v4
with:
path: target/iroh_endpoint-*.wasm
key: iroh-endpoint-${{ runner.os }}-${{ hashFiles('justfile') }}
# `site` composes first: the shipped runtime is the wac-plugged
# artifact (justfile `compose`), and a dist built from the bare one
# fails loudly in web/build.ts.
Expand Down
1 change: 0 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
"@polymorph/webcrypto": "jsr:@polymorph/webcrypto@^0.6.0",
"@polymorph/websocket": "jsr:@polymorph/websocket@^0.6.0",
"@polymorph/webrtc-datachannels": "jsr:@polymorph/webrtc-datachannels@^0.6.0",
"@polymorph/iroh": "jsr:@polymorph/iroh@^0.6.0",
"npm:node-datachannel@0.32.3/polyfill": "./web/platform/no-node-datachannel.ts",
"@remote-dom/core": "npm:@remote-dom/core@^1.11.1",
"@remote-dom/core/receivers": "npm:@remote-dom/core@^1.11.1/receivers",
Expand Down
19 changes: 1 addition & 18 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 26 additions & 21 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,27 @@ checkpoint path bound `wasi:filesystem@0.2` (sync WIT) over OPFS

Ruling: the runtime uses `wasi:filesystem@0.3` (async in WIT) through
generated bindings, not `std::fs`; every glue-implemented import is
async in WIT; the visor and frame embedders force `jspi: false`, so a
regression there fails loudly.

**The worker is the one exception, for now.** The composed iroh endpoint
authenticates its QUIC connections with rustls, and rustls has no async
signing path: `Signer::sign` is synchronous, and polymorph-iroh
implements it as `block_on` over the async `polymorph:webcrypto` sign
import (`core/src/crypto/sign.rs`). A sync lower of an async import is
exactly what JSPI exists for, so the accept side of every connection
needs it (found in M3a: with `jspi: false` the acceptor stalls in
`CertificateVerify`). The general fact: a platform-held, non-extractable
key as the TLS identity implies JSPI in a browser. So the worker is
instantiated with `jspi: true`, tolerated only until the transport's
signer is in-guest (polymorph-iroh: an identity built from a seed, which
is the posture the kernel already holds); then the worker returns to
`jspi: false` and the browser floor is wasm multi-memory alone. The
visor and frame realms never needed JSPI and stay without it.
async in WIT; every embedder forces `jspi: false`, so a regression
anywhere fails loudly.

**No realm is an exception.** The worker was one through M3a: the composed
iroh endpoint authenticates its QUIC connections with rustls, whose
`Signer::sign` is synchronous, and an identity built from platform key
handles (`polymorph:iroh/identity-from-keys`) reaches its key through an
async import — a sync lower of an async import, which is exactly what JSPI
exists for, so the accept side of every connection needed it (with
`jspi: false` the acceptor stalled in `CertificateVerify`). The general
fact stands: a platform-held, non-extractable key as the TLS identity
implies JSPI in a browser. polymorph-iroh's `identity-from-seed` (behind
the `guest-ed25519-signing` feature) takes the other side of that: the
identity holds its private key in the endpoint component's memory and
signs there with ed25519-dalek. So every realm is `jspi: false` and the
browser floor is wasm multi-memory alone. The trade, recorded: identity
signatures run in wasm rather than in the platform's native crypto, and
the device seed rests in the endpoint's memory for the endpoint's
lifetime — it was already in the kernel's, and passed through guest memory
at every bind, so this widens where it rests and not whether it is
there.

## The app frame

Expand Down Expand Up @@ -187,7 +191,7 @@ form. Polyvisor owns five implementations:
| `Transport` | one per connection over `polymorph:iroh` streams, relay-only: WebRTC is off in the worker because a SharedWorker has no `RTCPeerConnection` (the host backend never resolves there). Framing per `subduction_iroh` (u32 BE length prefix) so native subduction peers interoperate |
| `Storage` | M3a: an in-memory item store serialized into the sealed checkpoint with the automerge docs. Items in their own files under the state root is the follow-up once checkpoint size matters |
| `Policy` | group membership, read off the user-system document (`polyvisor:us`): a remote peer may read/write exactly while its key is a member. App-tree envelopes are keyhive's (M3c, `engine/src/vault.rs`) |
| `Signer` / `NodeEffect::Sign` | M3a: `ed25519-dalek` over a seed held in the sealed checkpoint (the seed posture; the same seed, imported through `polymorph:webcrypto`, builds the iroh identity). Later: a non-extractable platform key — signing is an effect with external custody, which is exactly what that needs |
| `Signer` / `NodeEffect::Sign` | M3a: `ed25519-dalek` over a seed held in the sealed checkpoint (the seed posture; the same seed builds the iroh identity, through `polymorph:iroh/identity-from-seed`). Later: a non-extractable platform key — signing is an effect with external custody, which is exactly what that needs |
| `Clock` | `wasi:clocks@0.3` |

Why the branch rather than the released crates: one driver loop the
Expand Down Expand Up @@ -378,7 +382,8 @@ the handshake.
persisted in IndexedDB would rest under the same profile protection,
so it buys nothing at this tier. The signing identity is a seed in the
same sealed checkpoint (M3a); platform-held keys enter with the passkey
PRF rung (M5) and, for the transport, once its TLS signer is in-guest.
PRF rung (M5); the transport's TLS signer is in-guest instead (see
"No JSPI").
- **Checkpoints** are AES-GCM over the kernel's serialized state, written
to `/<id>/gen-<n>/` on the OPFS root through `wasi:filesystem@0.3` after
every mutation (state is small until the engine lands; a debounce is a
Expand Down Expand Up @@ -420,8 +425,8 @@ native tests, so browser gates are mandatory for every visor change.
| polymorph-stream-dom | git rev (see Cargo.toml / deno.json) | unpublished, moving; policy object and asset handles landed in #15 |
| subduction | git `sansio` rev | above |
| keyhive | git rev `a509a2d` | `keyhive_core` / `keyhive_crypto` / `beekem`, unreleased and moving. The sealed plaintext is keyhive's own `Envelope` and the read-back walk is keyhive's own `try_causal_decrypt`, so a rev bump is a wire-format change for every stored blob: its own PR |
| `@polymorph/*` | 0.6.1 (iroh, webcrypto, websocket), 0.6.2 (webrtc-datachannels) | the cuts current at the polyengine 0.6.7 pin; taken within the `^0.6` range |
| polymorph:iroh WIT | provisional | being upgraded upstream in parallel; re-checked before M3a, the first milestone that exercises it |
| `@polymorph/*` | 0.6.1 (webcrypto, websocket), 0.6.2 (webrtc-datachannels) | the cuts current at the polyengine 0.6.7 pin; taken within the `^0.6` range |
| polymorph-iroh | git rev `8ca991e` | the endpoint component is built from source, not taken from the jsr package: the runtime binds its identity through `identity-from-seed`, which the package gates behind the cargo feature `guest-ed25519-signing` and its published artifact excludes. `just endpoint` clones and builds the pin; the vendored `runtime/wit/deps/polymorph-iroh/iroh.wit` is that revision's |
| `wasi:*` WIT | 0.3.1 (consolidated WASI release) | what `@polyengine/wasi` serves on the `@0.3` track |

## Delivery
Expand Down
26 changes: 15 additions & 11 deletions e2e/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,11 +1453,13 @@ const scenarios: Scenario[] = [

{
// Both realms on this side, named: the visor on the main thread and the
// runtime in the SharedWorker. The worker is the exception that makes
// the name worth spelling out — a page can see its own realm fail, but
// a worker that throws while instantiating does so out of sight, and
// `workerBooted` is the only evidence on this side that it did not.
name: "visor-and-frame-without-jspi",
// runtime in the SharedWorker. The worker is worth spelling out — a
// page can see its own realm fail, but a worker that throws while
// instantiating does so out of sight, and `workerBooted` is the only
// evidence on this side that it did not. The frame realm is covered by
// every scenario that launches an app: a frame that suspended would
// never mount.
name: "instantiates-without-jspi",
async run(ctx, origin) {
const page = await open(ctx, origin);
await visorReady(page);
Expand All @@ -1472,12 +1474,14 @@ const scenarios: Scenario[] = [
undefined,
{ timeout: 30_000 },
);
// Both realms on this side instantiated, and both `instantiate` calls
// passed `{ jspi: false }` (web/jspi_test.ts pins that at the source
// level). Under that option polyengine refuses a sync-typed import
// that returns a Promise, so the visor having rendered its strip and
// the worker having answered `booted` — which needs the runtime
// component's exports — proves no import took a suspending path.
// Both realms on this side instantiated, and every `instantiate` call
// in the repository — visor, frame and worker alike — passes
// `{ jspi: false }` (web/jspi_test.ts pins that at the source level).
// Under that option polyengine refuses a sync-typed import that
// returns a Promise, so the visor having rendered its strip and the
// worker having answered `booted` — which needs the runtime
// component's exports, and its endpoint's in-guest signer — proves no
// import took a suspending path.
const marks = await page.evaluate(() =>
(globalThis as Record<string, unknown>).__polyvisor
);
Expand Down
58 changes: 53 additions & 5 deletions justfile
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
# polymorph-iroh, as a git revision rather than a jsr version: the endpoint
# component this repository plugs is built here, from source (see
# `endpoint`), because the interface the runtime needs is feature-gated.
iroh_rev := "8ca991e07cac01368f6df5abf4f96e0beaf6c223"
iroh_rev_short := replace_regex(iroh_rev, '^(.{7}).*$', '$1')
iroh_wasm := "target/iroh_endpoint-" + iroh_rev_short + ".wasm"

# Parse both WIT packages (docs/design.md "Contracts": the public and
# private halves). -o /dev/null: we only want the parse/resolve check.
#
# `--features guest-ed25519-signing`: `polymorph:iroh/identity-from-seed` is
# `@unstable(feature = guest-ed25519-signing)` (iroh.wit), and an unstable
# item is invisible — so an interface `world runtime` imports would resolve
# to "interface not found" — unless the feature is named here too.
wit:
wasm-tools component wit wit/ -o /dev/null
wasm-tools component wit runtime/wit/ -o /dev/null
wasm-tools component wit runtime/wit/ --features guest-ed25519-signing -o /dev/null

check:
cargo fmt --check
Expand All @@ -16,19 +28,55 @@ test:
build-wasm:
cargo build --workspace --target wasm32-wasip2 --release

# Build polymorph-iroh's endpoint component, from source at `iroh_rev`.
#
# From source and not from the jsr package: the runtime binds the endpoint's
# identity through `polymorph:iroh/identity-from-seed`, which the package
# gates behind the cargo feature `guest-ed25519-signing` and its published
# artifact is built without. An identity from a seed signs in-guest, which
# is what lets the worker realm run without JSPI (docs/design.md "No JSPI").
#
# The build runs under this repository's toolchain, not polymorph-iroh's own
# `rust-toolchain.toml` (1.97.0): RUSTUP_TOOLCHAIN overrides the file, 1.98.1
# compiles it, and CI then needs no second toolchain installed.
#
# Idempotent twice over: the checkout is fetched-or-cloned, and a
# materialized artifact for this revision is left alone, so `just compose` in
# a loop neither talks to the network nor re-runs cargo.
endpoint:
#!/usr/bin/env bash
set -euo pipefail
if [ -f {{ iroh_wasm }} ]; then
echo "endpoint: {{ iroh_wasm }} is already there"
exit 0
fi
if [ -d target/polymorph-iroh/.git ]; then
git -C target/polymorph-iroh fetch --quiet origin
else
mkdir -p target
git clone --quiet https://github.com/polymorph-components/polymorph-iroh \
target/polymorph-iroh
fi
git -C target/polymorph-iroh checkout --quiet --detach {{ iroh_rev }}
cd target/polymorph-iroh
RUSTUP_TOOLCHAIN=1.98.1 cargo build -p iroh-endpoint \
--features guest-ed25519-signing --target wasm32-wasip2 --release
cd ../..
cp target/polymorph-iroh/target/wasm32-wasip2/release/iroh_endpoint.wasm {{ iroh_wasm }}
echo "endpoint: wrote {{ iroh_wasm }}"

# Plug polymorph-iroh's endpoint component into the runtime.
#
# The runtime imports `polymorph:iroh/{endpoint,identity-from-keys}` and
# The runtime imports `polymorph:iroh/{endpoint,identity-from-seed}` and
# nothing in this repository implements them (internal.wit `world runtime`:
# "the endpoint component ... composed in at build time with `wac plug`").
# What the worker instantiates is therefore never the cargo artifact but this
# composition, whose remaining imports are the endpoint's own — websocket,
# webrtc-datachannels, webcrypto, a sockets stub — which the worker glue
# provides.
compose:
deno run -A web/fetch-endpoint.ts
compose: endpoint
wac plug target/wasm32-wasip2/release/polyvisor_runtime.wasm \
--plug target/iroh_endpoint.wasm \
--plug {{ iroh_wasm }} \
-o target/polyvisor_runtime.composed.wasm

# web/dist: exactly what a home origin serves.
Expand Down
6 changes: 0 additions & 6 deletions runtime/component/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,4 @@ crate-type = ["cdylib"]
[dependencies]
polyvisor-kernel = { path = "../crates/kernel" }
wit-bindgen.workspace = true
# `Net::bind` is handed the device's Ed25519 SEED, and `polymorph:webcrypto`
# deliberately offers no way to derive a public key from a private import
# (webcrypto.wit `interface signature`) while `identity-from-keys` requires
# both halves. So the public half is derived here, from the same crate the
# engine signs with.
ed25519-dalek.workspace = true
futures.workspace = true
8 changes: 8 additions & 0 deletions runtime/component/src/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@ use crate::net::IrohNet;
// default bindings mode will be used"), which is exactly the contract:
// everything in `polyvisor:internal` is `async func`, as is
// `wasi:http/client.send`, and nothing else here is.
// `features`: `polymorph:iroh/identity-from-seed` is
// `@unstable(feature = guest-ed25519-signing)` (iroh.wit), and `world
// runtime` imports it. Unstable items are invisible to the resolver unless
// their feature is named, so without this the world fails to resolve
// (wit-bindgen-rust-macro 0.60 lib.rs:205 — the listed features are pushed
// into `Resolve::features`). The endpoint component is built with the
// matching cargo feature; justfile `endpoint`.
wit_bindgen::generate!({
path: "../wit",
world: "runtime",
features: ["guest-ed25519-signing"],
generate_all,
});

Expand Down
Loading
Loading