From 35e9cea9f6107e4835d5220e66bbe2e675fd306b Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 25 Oct 2026 01:01:30 -0500 Subject: [PATCH 1/6] feat(id): versioned shell contract v1 with a conformance check (F13.1) Write the client-server frame protocol down as data: pkgs/id/contract/shell-contract.v1.json names every client and server frame, its fields and bounds, its class (journaled event, ephemeral, query, control, admin, delegation), the session phases it may be sent in, the transports, and the projection's seed rule. Version 1 is the /id-world/1 protocol as it exists on main; no frame kind is added (F11.12, #227, still gates new ones). - shell_contract.rs validates frames and whole transcripts against it; `id world contract [--check FILE]` prints it or checks a recording. - Drift tests pin it to the host: client variant names come from serde's own error, server variants from an exhaustive match, required/nullable fields from serde round trips, limits from the host constants. - Session tests run real joined, query, upload and one-reply sessions through the check, re-assert the F11.11 seed-leak rule, and execute the journaled-vs-ephemeral classes against the world sequence. The first conformance run found three terminal-shell bugs, fixed here (#836): the `id world join --command-mode` pump sent frames without their capability and frame types that do not exist; `/stats` in an SSH `cmd:` session was refused as "already joined" (the host now answers `info` in the joined phase); and wide SSH terminals sent out-of-range resizes (now clamped to the contract bounds). Refs #783, #836, #227 --- ...0-25T05-54-40Z_reference_shell_contract.md | 232 ++++++ pkgs/id/contract/shell-contract.v1.json | 349 +++++++++ pkgs/id/src/cli.rs | 11 + pkgs/id/src/commands/world.rs | 183 ++++- pkgs/id/src/lib.rs | 2 + pkgs/id/src/shell_contract.rs | 696 ++++++++++++++++++ pkgs/id/src/shell_contract/tests.rs | 406 ++++++++++ pkgs/id/src/world_session/contract_tests.rs | 253 +++++++ pkgs/id/src/world_session/mod.rs | 20 +- pkgs/id/src/world_ssh.rs | 194 +++-- pkgs/id/tests/cli_integration.rs | 35 + 11 files changed, 2288 insertions(+), 93 deletions(-) create mode 100644 doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md create mode 100644 pkgs/id/contract/shell-contract.v1.json create mode 100644 pkgs/id/src/shell_contract.rs create mode 100644 pkgs/id/src/shell_contract/tests.rs create mode 100644 pkgs/id/src/world_session/contract_tests.rs diff --git a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md new file mode 100644 index 000000000..08a18a915 --- /dev/null +++ b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md @@ -0,0 +1,232 @@ +# The id shell contract, version 1 + +Reference for Phase 13 task F13.1 (issue #783): the frame protocol every `id` +shell speaks to a world host, written down as a versioned, checkable contract. +See the [Phase 13 plan](../../.opencode/plans/phase-13-client-shells.md). + +**Status:** version 1, revision 1. The machine-readable contract is +[`pkgs/id/contract/shell-contract.v1.json`](../../pkgs/id/contract/shell-contract.v1.json). +That file is the source of truth; this document explains it and does not +repeat its tables. `id world contract` prints it. + +## Why a contract + +Before F13.1 the protocol existed only as the Rust `ClientFrame` and +`ServerFrame` enums (`pkgs/id/src/world_session/types.rs`). Each shell +followed them by hand: the SSH terminal (`pkgs/id/src/world_ssh.rs`), the +`id world join` pump (`pkgs/id/src/commands/world.rs`), and the browser +(`pkgs/id/web/src/world.ts`). Nothing caught a shell that drifted. The first +run of the conformance check found three shell bugs this way (see +[Findings](#findings-from-the-first-conformance-run)). + +With the contract as data, a new shell (a native Bevy or Qt client, F13.6) +proves conformance by recording a session and running one command. It does +not have to read host code, and the host does not have to change. + +## Where it sits + +The platform has three layers (Phase 13 plan, "Architecture"): + +1. **Transport envelope.** SSH channel bytes, iroh QUIC stream frames, + WebSocket messages. +2. **Session protocol.** This contract. It is identical under every transport. +3. **Guest ABI.** The `plaza_*` Wasm exports. They are specified by **F12.7**, + the layer beneath this contract. A guest never sees a frame, and a shell + never sees the ABI. + +Two Phase 12 items are stepping stones that the contract will absorb as +additive revisions: + +- **F12.12** (many worlds per node): today's `world` routing field. +- **F12.14** (portals): cross-world messages, which would arrive as new server + frames. + +## Versioning + +- `version` is the wire major. Version 1 is the protocol behind the + `/id-world/1` ALPN (`pkgs/id/src/world_net.rs`), and a test pins the two + together. +- `revision` counts additive changes within a version: a new optional field, + or a new server frame that old shells may ignore. +- Removing or changing a frame or field needs version 2 and a new ALPN. +- **A shell sends only frames and fields the contract names.** It ignores + server frame types and fields it does not know, because a later revision may + add them. The conformance check is strict about what each side _sends_. + Shells are tolerant about what they _read_. +- **New frame kinds are gated.** Any new frame kind is forward-only for older + binaries (#227). The plan therefore requires F11.12 (journal format + management) to land before the first Phase 13 frame type. F11.12 has not + landed: only the `JOURNAL_VERSION` check exists, and the `check`/`backup` + tooling is open. Version 1 therefore adds **no** frame kind. Everything it + describes already existed on `main`. + +## Transports + +| Transport | Envelope | One session is | +| --------- | ----------------------------------------- | ------------------------------- | +| iroh | `u32` big-endian length prefix, then JSON | one bidirectional stream | +| WebSocket | one text message per frame | one connection | +| SSH exec | `u32` big-endian length prefix, then JSON | one exec channel (`id-world/1`) | + +Every frame is one UTF-8 JSON object of at most 1 MiB (`MAX_FRAME_BYTES`). +The SSH exec transport is F13.2. It reuses the iroh envelope byte for byte, +so unwrapping either one gives identical frames. + +## Session lifecycle + +A session moves through **phases**. Each client frame lists the phases in +which a shell may send it. Each frame a shell may open a session with names +the phase it `opens`: + +- **opening.** The first frame, which may carry the `world` routing field. + Query frames (`info`, `records`, `records_ticket`, `download_module`) open + **query**. `join` opens **joined**. `install_begin` opens **install**. Every + other opening frame (`invite`, the admin and policy frames, `caps`, + `execution_profile_info`, `compile`) gets one reply, and then the host ends + the session (**closed**). +- **query.** Further query frames, or `join`. +- **install.** `install_chunk` frames, then `install_end`. +- **joined.** The participant's live session: `chat`, `input`, `paste`, + `resize`, `records`, `info`, `records_ticket`, `caps`, delegation and the + admin capability and profile frames. Every frame keeps the session joined. + +Before a join, an `error` reply ends the session. After a join, most errors +are answers, and the session continues. + +## The journaled-vs-ephemeral boundary + +Every client frame has a **class**: + +| Class | Meaning | Frames | +| ------------ | ------------------------------------------------------------------------ | --------------------------------------------- | +| `event` | Becomes one committed world event: journaled, replayed, and projected. | `chat`, `input`, `paste` | +| `ephemeral` | Connection-local presentation context. Never journaled and never shared. | `resize` | +| `query` | Read-only. Never journaled. | `info`, `records`, `caps`, ... | +| `control` | Session lifecycle. Never journaled itself. | `join`, `install_chunk`, `install_end` | +| `admin` | Authority-bearing configuration. May write a journal entry. | `invite`, `update_caps`, `install_begin`, ... | +| `delegation` | Mints a narrower capability. Never widens authority. | `attenuate`, `delegate_to_friend` | + +Server frames are `durable` (`snapshot` and `event`, built from committed and +projected state), `ephemeral` (`view`, a presentation of the current state for +this connection's viewport) or `reply`. + +This is the Phase 9 (P9.2) separation, now executable. Viewport and partial +typing never enter the journal. Committed input does. The test +`the_journaled_vs_ephemeral_boundary_is_what_the_contract_says` sends every +frame that is valid in the joined phase and checks the world's sequence after +each one. An `event` frame must commit exactly one event. Every other class +must leave the journal alone. Reclassifying a frame in the JSON changes what +the test demands. + +Focus is not in version 1. A focus frame is reserved for the presentation work +(F13.4/F13.5), behind the F11.12 gate. + +## The client projection + +Every server frame is built from F11.11's single client projection. The +contract records the projection's one hard rule as data: +`projection.redacted_keys` is `["seed"]`, and any `seed` anywhere in a server +frame must be `0`. The conformance check enforces it, and the session tests +re-assert the F11.11 seed-leak test against the contract. Both a live install +and a reinstall with nonzero seeds produce transcripts that pass the check. A +transcript with a leaked seed fails it. + +## Capability negotiation + +Version 1 negotiates nothing in-band. It has one optional feature: + +- **`surface`.** A `view` may carry a typed `surface` (the portable screen + document). A shell that does not understand it renders `data`. + +Presentation tiers (F13.4), the kitty keyboard protocol (F13.5) and client +capability providers (F13.7) are listed under `reserved`. Each needs a new +frame kind and waits for F11.12. + +## Running the conformance check + +A shell records each session as JSON lines, one frame per line: + +```json +{"from": "client", "frame": {"type": "join", "capability": "..."}} +{"from": "server", "frame": {"type": "snapshot", "snapshot": {"...": "..."}}} +``` + +It then runs `id world contract --check session.jsonl` (`-` reads stdin). The +command prints `N frames conform to shell contract v1.1`, or lists each +violation as `frame (): ` and exits nonzero. The check +reports: + +- unknown frame types +- missing or unknown fields +- wrong JSON types or non-hex `data_hex` +- out-of-range integers, such as a `resize` wider than 500 +- strings over their byte bound, and values outside an `enum` +- `uses` anywhere in a delegation +- a frame sent in the wrong phase, or after the host closed the session +- a leaked seed + +The validator lives in `pkgs/id/src/shell_contract.rs`, with a TypeScript port +for the browser tests in `pkgs/id/web/src/shell-contract.ts`. + +## How the contract stays true + +The contract would be worthless if it drifted from the host. These tests in +`pkgs/id/src/shell_contract/tests.rs` pin it: + +- **Variants.** The client frame names are read from serde's own + unknown-variant error, and they must equal the contract's list. The server + frame list is enforced by an exhaustive `match`, so a new `ServerFrame` + variant does not compile until it is named, and then it must also be in the + contract. +- **Required fields.** For every client frame, the smallest valid sample built + from the contract must deserialize. Removing any required field must fail in + serde. `null` must be accepted exactly where the contract says a field is + nullable. This caught `compile.native`, a `serde(default)` field that may be + absent but not `null`. +- **Limits.** The contract's numbers must match the host constants: frame + size, ALPN, capability length, chunk size, module size and paste size. + +These session tests in `pkgs/id/src/world_session/contract_tests.rs` run real +sessions through the check: a full joined session, query sessions, +single-reply opening sessions, an upload, and the journaling boundary. + +These shell tests check the shells themselves: + +- The terminal shells: `terminal_shell_frames_conform_to_the_shell_contract` + (SSH raw mode and `cmd:` mode) and + `the_pump_command_mode_maps_the_shared_grammar_to_contract_frames` + (`id world join`). +- The web shell: `pkgs/id/web/src/world-contract.test.ts`. It drives the + unmodified `world.ts` over a fake WebSocket and checks every frame the page + sends. + +## Findings from the first conformance run + +The first run of the check found three bugs. Each is fixed in this change (#836): + +1. **`id world join --command-mode` sent invalid frames.** `/stats`, `/caps` + and `/records` were sent without their required `capability`. + `/documents`, `/document`, `/logs` and `/help` were sent as frame types + that do not exist. The host answered "invalid world frame". Now the pump + fills in the session's capability and answers the local commands on + stderr, so stdout stays a pure stream of frames. +2. **`/stats` in an SSH `cmd:` session always failed.** The shell sends + `info` from the joined session, but the host answered "already joined", + even though the SSH shell already had a renderer for the reply. The host + now answers `info` in the joined phase, authorized by the session's own + capability, the same way it answers `records`. +3. **A wide SSH terminal was refused a view.** A pty wider than 500 columns or + taller than 200 rows produced a `resize` outside the contract, and the + host answered with an error. The SSH shell now clamps to the contract's + bounds, as the browser already did. + +## References + +- [Phase 13 plan](../../.opencode/plans/phase-13-client-shells.md), F13.1 and + "Architecture" +- [Phase 12 plan](../../.opencode/plans/phase-12-platform-flexibility.md): + F12.7 guest ABI, F12.12 many worlds, F12.14 portals +- [Phase 11 plan](../../.opencode/plans/phase-11-foundations.md): F11.11 + projection, F11.12 journal format +- Issues: #783 (F13.1), #227 (forward-only compatibility), #543 (F11.11) +- [SSH input modes decision](../2026-10-24T20-18-37Z_decision_ssh_input_modes/2026-10-24T20-18-37Z_decision_ssh_input_modes.md) diff --git a/pkgs/id/contract/shell-contract.v1.json b/pkgs/id/contract/shell-contract.v1.json new file mode 100644 index 000000000..9d593c125 --- /dev/null +++ b/pkgs/id/contract/shell-contract.v1.json @@ -0,0 +1,349 @@ +{ + "contract": "id-shell", + "version": 1, + "revision": 1, + "description": "The client-server frame protocol every id shell (terminal, web, native) speaks to a world host. Version 1 is the protocol behind the /id-world/1 ALPN. A shell sends only frames and fields named here; it ignores server frame types and fields it does not know. Additive changes bump `revision`; removing or changing a frame or field needs version 2 and a new ALPN. A new frame kind waits for the journal format work (F11.12, #227).", + "max_frame_bytes": 1048576, + "routing_fields": ["world"], + "transports": { + "iroh": { + "alpn": "/id-world/1", + "envelope": "u32be-length-prefix", + "session": "one bidirectional QUIC stream" + }, + "websocket": { + "path": "/ws/world", + "envelope": "text-message", + "session": "one WebSocket connection" + }, + "ssh-exec": { + "command": "id-world/1", + "envelope": "u32be-length-prefix", + "session": "one SSH session channel running the exec command" + } + }, + "phases": { + "opening": "The session's first frame. It may carry the `world` routing field; absent means the host's default world.", + "query": "After an opening query frame: further query frames, or a join.", + "install": "After install_begin was answered with upload_ready: chunks, then install_end.", + "joined": "After a join was answered with a snapshot or events. Every frame keeps the session joined." + }, + "classes": { + "event": "Becomes one committed world event: journaled, replayed, and delivered to sessions through the client projection.", + "ephemeral": "Connection-local presentation context. Never journaled and never seen by other sessions.", + "query": "Read-only. Never journaled and never changes the world.", + "control": "Session lifecycle. The frame itself is never journaled.", + "admin": "Authority-bearing configuration. Needs the admin secret or a signed moderator request, and may write a journal entry.", + "delegation": "Mints a narrower capability from the session's own. It may write a journal entry; it never widens authority." + }, + "client_frames": { + "join": { + "class": "control", + "phases": ["opening", "query"], + "opens": "joined", + "fields": { + "capability": { "type": "string", "max_bytes": 256 }, + "after": "u64?" + } + }, + "info": { + "class": "query", + "phases": ["opening", "query", "joined"], + "opens": "query", + "fields": { "capability": "string" } + }, + "records": { + "class": "query", + "phases": ["opening", "query", "joined"], + "opens": "query", + "fields": { + "capability": "string", + "prefix": "string?", + "after": "string?" + } + }, + "records_ticket": { + "class": "query", + "phases": ["opening", "query", "joined"], + "opens": "query", + "fields": { "capability": "string" } + }, + "download_module": { + "class": "query", + "phases": ["opening", "query"], + "opens": "query", + "fields": { "capability": "string", "module_hash": "string" } + }, + "caps": { + "class": "query", + "phases": ["opening", "joined"], + "opens": "closed", + "fields": { "capability": "string" } + }, + "execution_profile_info": { + "class": "query", + "phases": ["opening", "joined"], + "opens": "closed", + "fields": { "admin_token": "string?", "capability": "string?" } + }, + "invite": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { + "admin_token": "string", + "display_name": "string", + "expires_in_secs": "u64?", + "uses": "u32?", + "role": { + "type": "string", + "optional": true, + "enum": ["spectator", "observer", "player", "guest", "delegator", "builder", "moderator"] + } + } + }, + "list_worlds": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { "admin_token": "string" } + }, + "create_world": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { "admin_token": "string" } + }, + "set_isolation": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { + "isolation": { "type": "string", "enum": ["isolated", "unisolated"] }, + "admin_token": "string?", + "signed": "object?" + } + }, + "set_cross_world_write": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { "enabled": "bool", "admin_token": "string?", "signed": "object?" } + }, + "force_cross_world_write": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { "admin_token": "string" } + }, + "set_file_scope": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { + "scope": { "type": "string", "enum": ["confined", "unrestricted"] }, + "write_override": "bool?", + "admin_token": "string?", + "signed": "object?" + } + }, + "compile": { + "class": "admin", + "phases": ["opening"], + "opens": "closed", + "fields": { + "admin_token": "string", + "files": "array", + "seed": "u64?", + "native": { "type": "bool", "optional": true, "nullable": false } + } + }, + "update_caps": { + "class": "admin", + "phases": ["opening", "joined"], + "opens": "closed", + "fields": { "admin_token": "string", "grant": "string[]", "revoke": "string[]" } + }, + "set_caps_ceiling": { + "class": "admin", + "phases": ["opening", "joined"], + "opens": "closed", + "fields": { "admin_token": "string", "ceiling": "string[]" } + }, + "set_execution_profile": { + "class": "admin", + "phases": ["opening", "joined"], + "opens": "closed", + "fields": { "admin_token": "string", "profile": "object" } + }, + "install_begin": { + "class": "admin", + "phases": ["opening"], + "opens": "install", + "fields": { + "admin_token": "string", + "total_bytes": { "type": "u64", "min": 1, "max": 16777216 }, + "module_hash": "string?", + "seed": "u64" + } + }, + "install_chunk": { + "class": "control", + "phases": ["install"], + "fields": { "offset": "u64", "data_hex": { "type": "hex", "max_bytes": 12288 } } + }, + "install_end": { + "class": "control", + "phases": ["install"], + "fields": {} + }, + "chat": { + "class": "event", + "phases": ["joined"], + "fields": { "text": "string" } + }, + "input": { + "class": "event", + "phases": ["joined"], + "fields": { "data_hex": "hex" } + }, + "paste": { + "class": "event", + "phases": ["joined"], + "fields": { "text": { "type": "string", "max_bytes": 4096 } } + }, + "resize": { + "class": "ephemeral", + "phases": ["joined"], + "fields": { + "width": { "type": "u16", "min": 1, "max": 500 }, + "height": { "type": "u16", "min": 1, "max": 200 } + } + }, + "attenuate": { + "class": "delegation", + "phases": ["joined"], + "forbidden_keys": ["uses"], + "fields": { "name": "string", "scopes": "string[]", "expires_at": "u64?" } + }, + "delegate_to_friend": { + "class": "delegation", + "phases": ["joined"], + "forbidden_keys": ["uses"], + "fields": { + "friend": "string", + "name": "string", + "scopes": "string[]", + "expires_at": "u64?" + } + } + }, + "server_frames": { + "snapshot": { + "class": "durable", + "fields": { "snapshot": "object" } + }, + "event": { + "class": "durable", + "fields": { "event": "object" } + }, + "view": { + "class": "ephemeral", + "fields": { "sequence": "u64", "data": "string", "surface": "object?" } + }, + "program_changed": { + "class": "reply", + "fields": { "module_hash": "string", "sequence": "u64" } + }, + "module_info": { + "class": "reply", + "fields": { + "world_id": "string", + "active_module_hash": "string?", + "active_module_provenance": "object?", + "current_sequence": "u64" + } + }, + "records": { + "class": "reply", + "fields": { "sequence": "u64", "records": "object", "next_after": "string?" } + }, + "records_ticket": { + "class": "reply", + "fields": { "ticket": "string" } + }, + "module_begin": { + "class": "reply", + "fields": { "module_hash": "string", "total_bytes": "u64" } + }, + "module_chunk": { + "class": "reply", + "fields": { "offset": "u64", "data_hex": "hex" } + }, + "module_end": { + "class": "reply", + "fields": { "module_hash": "string" } + }, + "invite": { + "class": "reply", + "fields": { "capability": "string", "participant_id": "u64", "display_name": "string" } + }, + "worlds": { + "class": "reply", + "fields": { "default": "string", "worlds": "array" } + }, + "world_created": { + "class": "reply", + "fields": { "world": "string", "created": "bool" } + }, + "isolation": { + "class": "reply", + "fields": { "world": "string", "isolation": "string" } + }, + "policy": { + "class": "reply", + "fields": { "world": "string", "policy": "object" } + }, + "caps": { + "class": "reply", + "fields": { "report": "object" } + }, + "caps_ceiling": { + "class": "reply", + "fields": { "report": "object", "message": "string" } + }, + "execution_profile": { + "class": "reply", + "fields": { "update": "object", "message": "string" } + }, + "compiled": { + "class": "reply", + "fields": { "module_hash": "string", "sequence": "u64", "diagnostics": "string" } + }, + "upload_ready": { + "class": "reply", + "fields": { "chunk_bytes": "u64" } + }, + "module_installed": { + "class": "reply", + "fields": { "module_hash": "string", "sequence": "u64" } + }, + "error": { + "class": "reply", + "fields": { "message": "string" } + } + }, + "projection": { + "description": "Every server frame is built from the one client projection (F11.11). A program seed never reaches a session: any `seed` key in a server frame must be 0.", + "redacted_keys": ["seed"] + }, + "features": { + "surface": "A view frame may carry a typed `surface` (the portable screen document). A shell that does not understand it renders `data`." + }, + "reserved": { + "presentation_tiers": "In-band terminal tier detection and negotiation (F13.4). Needs a new frame kind, so it waits for F11.12.", + "focus": "A focus frame for typed input (F13.4/F13.5). Not in version 1.", + "kitty_keyboard": "Kitty keyboard protocol key events (F13.5).", + "client_providers": "Client-side capability requests and replies (F13.7). Gated on F12.4 and F12.6." + } +} diff --git a/pkgs/id/src/cli.rs b/pkgs/id/src/cli.rs index 5dc67b7d4..c0a5ca8fb 100644 --- a/pkgs/id/src/cli.rs +++ b/pkgs/id/src/cli.rs @@ -1739,6 +1739,17 @@ pub enum WorldCommand { #[arg(long)] no_relay: bool, }, + /// Print the versioned shell contract (the frame protocol every shell + /// speaks), or check a recorded session against it. + /// + /// A transcript has one JSON object per line: + /// `{"from": "client"|"server", "frame": {...}}`. The check prints each + /// violation and fails if there is any. + Contract { + /// Transcript to check (`-` reads stdin); without it, print the contract. + #[arg(long, value_name = "FILE")] + check: Option, + }, /// Measure a compiled `.wasm` world program's fuel use, offline. /// /// Compiles the module with the default limits, runs `view`, then diff --git a/pkgs/id/src/commands/world.rs b/pkgs/id/src/commands/world.rs index 84bc75d0b..52a443ac9 100644 --- a/pkgs/id/src/commands/world.rs +++ b/pkgs/id/src/commands/world.rs @@ -501,6 +501,21 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { endpoint.close().await; Ok(()) } + WorldCommand::Contract { check: None } => { + print!("{}", crate::shell_contract::SHELL_CONTRACT_JSON); + Ok(()) + } + WorldCommand::Contract { check: Some(path) } => { + let text = if path.as_os_str() == "-" { + std::io::read_to_string(std::io::stdin()).context("read transcript")? + } else { + std::fs::read_to_string(&path) + .with_context(|| format!("read transcript {}", path.display()))? + }; + let checked = check_contract(&text)?; + println!("{checked}"); + Ok(()) + } #[cfg(feature = "sandbox")] WorldCommand::Measure { module, @@ -686,7 +701,7 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { "after": after, })) .await?; - let result = pump(client, command_mode).await; + let result = pump(client, command_mode, &capability).await; endpoint.close().await; result } @@ -1197,7 +1212,8 @@ impl Replica { } /// Print server frames as JSON lines while sending stdin lines as chat. -async fn pump(client: WorldClient, command_mode: bool) -> Result<()> { +/// Notes for the user go to stderr, so stdout stays a stream of frames. +async fn pump(client: WorldClient, command_mode: bool, capability: &str) -> Result<()> { let (conn, mut send, mut recv) = client.into_parts(); let mut reader = tokio::spawn(async move { while let Some(body) = read_frame(&mut recv).await? { @@ -1214,14 +1230,20 @@ async fn pump(client: WorldClient, command_mode: bool) -> Result<()> { let line = line.trim(); if line.is_empty() { continue; } let frame = if command_mode { - pump_command_frame(line)? + match pump_command(line, capability) { + PumpLine::Send(frame) => frame, + PumpLine::Say(text) => { + eprintln!("{text}"); + continue; + } + PumpLine::Quit => break Ok(()), + } } else { match line.strip_prefix("/input ") { Some(hex) => serde_json::json!({"type": "input", "data_hex": hex.trim()}), None => serde_json::json!({"type": "chat", "text": line}), } }; - if frame.is_null() { break Ok(()); } if let Err(e) = write_frame(&mut send, frame.to_string().as_bytes()).await { break Err(e); } @@ -1239,66 +1261,157 @@ async fn pump(client: WorldClient, command_mode: bool) -> Result<()> { result } -/// Map one parsed [`crate::world_line::WorldLine`] to the session frame it -/// needs in the `id world join --command-mode` pump. `null` ends the pump. -fn pump_command_frame(line: &str) -> Result { +/// Check a transcript against the shell contract, returning a summary. +/// +/// # Errors +/// +/// Fails on an unreadable transcript, or with every violation listed. +fn check_contract(text: &str) -> Result { + let contract = crate::shell_contract::ShellContract::embedded().map_err(anyhow::Error::msg)?; + let violations = crate::shell_contract::check_transcript(contract, text)?; + let frames = text.lines().filter(|line| !line.trim().is_empty()).count(); + if violations.is_empty() { + return Ok(format!( + "{frames} frames conform to shell contract v{}.{}", + contract.version, contract.revision + )); + } + let listed: Vec = violations.iter().map(ToString::to_string).collect(); + bail!( + "{} of {frames} frames violate shell contract v{}.{}:\n{}", + violations.len(), + contract.version, + contract.revision, + listed.join("\n") + ) +} + +/// What one stdin line does in the `id world join --command-mode` pump. +#[derive(Debug, PartialEq, Eq)] +enum PumpLine { + /// Send this session frame. + Send(serde_json::Value), + /// Answer the user locally; nothing is sent. + Say(String), + /// End the session. + Quit, +} + +const PUMP_HELP: &str = "Commands: a line is chat; /input , /records [prefix], /stats, /caps, /logs, /help, /quit. Server frames print to stdout as JSON lines."; + +/// Map one line of the shared grammar ([`crate::world_line`]) to what the +/// pump does. Every frame it sends is in the shell contract's joined phase; +/// the commands that need the host's own store (`/documents`) or that the +/// pump already does (`/logs`) are answered locally. +fn pump_command(line: &str, capability: &str) -> PumpLine { use crate::world_line::{WorldLine, parse_line}; let Some(action) = parse_line(line) else { - return Ok(serde_json::Value::Null); + return PumpLine::Quit; }; - Ok(match action { + PumpLine::Send(match action { WorldLine::Chat(text) => serde_json::json!({"type": "chat", "text": text}), WorldLine::Input(hex) => serde_json::json!({"type": "input", "data_hex": hex}), - WorldLine::Records { prefix } => { - serde_json::json!({"type": "records", "prefix": prefix, "after": null}) - } - WorldLine::Documents { prefix } => { - serde_json::json!({"type": "documents", "prefix": prefix}) + WorldLine::Records { prefix } => serde_json::json!({ + "type": "records", "capability": capability, "prefix": prefix, "after": null + }), + WorldLine::Stats => serde_json::json!({"type": "info", "capability": capability}), + WorldLine::Caps => serde_json::json!({"type": "caps", "capability": capability}), + WorldLine::Documents { .. } | WorldLine::Document(_) => { + return PumpLine::Say( + "documents are read from the host's own store over SSH (`ssh cmd:@host`); `id world join` has no document reader".to_owned(), + ); } - WorldLine::Document(name) => serde_json::json!({"type": "document", "name": name}), - WorldLine::Stats => serde_json::json!({"type": "info"}), - WorldLine::Caps => serde_json::json!({"type": "caps"}), - WorldLine::Logs { .. } => serde_json::json!({"type": "logs"}), - WorldLine::Help => serde_json::json!({"type": "help"}), - WorldLine::Quit => return Ok(serde_json::Value::Null), - WorldLine::Message(message) => { - anyhow::bail!("{message}") + WorldLine::Logs { .. } => { + return PumpLine::Say( + "events already print live here as JSON lines; use `id world logs` for a bounded tail".to_owned(), + ); } + WorldLine::Help => return PumpLine::Say(PUMP_HELP.to_owned()), + WorldLine::Quit => return PumpLine::Quit, + WorldLine::Message(message) => return PumpLine::Say(message), }) } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { - use super::{pump_command_frame, scaffold_world}; + use super::{PumpLine, check_contract, pump_command, scaffold_world}; /// The shared line grammar maps the same line to the same action on the /// join pump as it does on the SSH command mode (both call - /// [`crate::world_line::parse_line`]); this asserts the pump's mapping. + /// [`crate::world_line::parse_line`]); this asserts the pump's mapping, + /// and that every frame it sends is in the shell contract. #[test] - fn the_pump_command_mode_maps_the_shared_grammar() { + fn the_pump_command_mode_maps_the_shared_grammar_to_contract_frames() { + use crate::shell_contract::{Phase, ShellContract}; + let send = |line: &str| match pump_command(line, "cap") { + PumpLine::Send(frame) => frame, + other => panic!("{line}: {other:?}"), + }; assert_eq!( - pump_command_frame("/stats").unwrap(), - serde_json::json!({"type": "info"}) + send("/stats"), + serde_json::json!({"type": "info", "capability": "cap"}) ); assert_eq!( - pump_command_frame("/caps").unwrap(), - serde_json::json!({"type": "caps"}) + send("/caps"), + serde_json::json!({"type": "caps", "capability": "cap"}) ); assert_eq!( - pump_command_frame("/records x").unwrap(), - serde_json::json!({"type": "records", "prefix": "x", "after": null}) + send("/records x"), + serde_json::json!({"type": "records", "capability": "cap", "prefix": "x", "after": null}) ); assert_eq!( - pump_command_frame("/input 00aF").unwrap(), + send("/input 00aF"), serde_json::json!({"type": "input", "data_hex": "00af"}) ); assert_eq!( - pump_command_frame("hello").unwrap(), + send("hello"), serde_json::json!({"type": "chat", "text": "hello"}) ); - assert!(pump_command_frame("/quit").unwrap().is_null()); - assert!(pump_command_frame("/nope").is_err()); + let contract = ShellContract::embedded().unwrap(); + for line in [ + "/stats", + "/caps", + "/records", + "/records x", + "/input 00", + "hi", + ] { + contract + .check_client_frame(&send(line), Phase::Joined) + .unwrap_or_else(|error| panic!("{line}: {error}")); + } + for line in ["/logs", "/help", "/documents", "/document a.md", "/nope"] { + assert!( + matches!(pump_command(line, "cap"), PumpLine::Say(_)), + "{line}" + ); + } + assert_eq!(pump_command("/quit", "cap"), PumpLine::Quit); + } + + #[test] + fn the_contract_check_reports_conformance_or_every_violation() { + let good = [ + r#"{"from": "client", "frame": {"type": "join", "capability": "c"}}"#, + r#"{"from": "server", "frame": {"type": "snapshot", "snapshot": {}}}"#, + r#"{"from": "client", "frame": {"type": "resize", "width": 80, "height": 24}}"#, + ] + .join("\n"); + assert_eq!( + check_contract(&good).unwrap(), + "3 frames conform to shell contract v1.1" + ); + let bad = [ + r#"{"from": "client", "frame": {"type": "chat", "text": "too early"}}"#, + r#"{"from": "server", "frame": {"type": "teleport"}}"#, + ] + .join("\n"); + let error = check_contract(&bad).unwrap_err().to_string(); + assert!(error.starts_with("2 of 2 frames violate"), "{error}"); + assert!(error.contains("frame 0 (client)"), "{error}"); + assert!(error.contains("frame 1 (server)"), "{error}"); + assert!(check_contract("{").is_err()); } #[test] diff --git a/pkgs/id/src/lib.rs b/pkgs/id/src/lib.rs index cf2316aaa..8fdb23e8f 100644 --- a/pkgs/id/src/lib.rs +++ b/pkgs/id/src/lib.rs @@ -205,6 +205,8 @@ pub mod search; pub mod seitan; #[cfg(feature = "world")] pub mod server_config; +#[cfg(feature = "world")] +pub mod shell_contract; pub mod store; pub mod store_traits; pub mod sync_method; diff --git a/pkgs/id/src/shell_contract.rs b/pkgs/id/src/shell_contract.rs new file mode 100644 index 000000000..6506ef817 --- /dev/null +++ b/pkgs/id/src/shell_contract.rs @@ -0,0 +1,696 @@ +//! The versioned shell contract (F13.1). +//! +//! Every shell (the SSH terminal, `id world join`, the browser, a native +//! client) speaks the same frame protocol to a world host. The contract is +//! data, [`SHELL_CONTRACT_JSON`] (`contract/shell-contract.v1.json`): each +//! client and server frame with its fields, its class (journaled event, +//! ephemeral context, query, control, admin, delegation) and the session +//! phases it may be sent in. This module reads that file and checks frames and +//! whole session transcripts against it, so a new shell proves conformance by +//! running a check instead of reading host code. +//! +//! The check is strict about what is *sent*: a frame type, field, or phase +//! the contract does not name is a violation, on either side. Shells must +//! still ignore unknown server frames at run time, because later revisions +//! may add them. +//! +//! Layer 2 of the platform (the session protocol); the guest ABI beneath it +//! is F12.7, and transports (iroh, WebSocket, SSH exec) only move these +//! frames. + +use std::collections::BTreeMap; +use std::fmt; +use std::sync::OnceLock; + +use serde::Deserialize; +use serde_json::Value; + +/// The contract document, embedded at build time. +pub const SHELL_CONTRACT_JSON: &str = include_str!("../contract/shell-contract.v1.json"); + +/// The contract's wire version; it matches the `/id-world/1` ALPN. +pub const SHELL_CONTRACT_VERSION: u32 = 1; + +/// A point in a session's lifecycle; the contract names which client frames +/// each one accepts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + /// The session's first frame. + Opening, + /// After an opening query frame. + Query, + /// During a module upload. + Install, + /// After a join. + Joined, + /// The host answers once and ends the session; nothing more may be sent. + Closed, +} + +impl Phase { + const fn as_str(self) -> &'static str { + match self { + Self::Opening => "opening", + Self::Query => "query", + Self::Install => "install", + Self::Joined => "joined", + Self::Closed => "closed", + } + } +} + +/// Which side sent a frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Side { + /// The shell. + Client, + /// The world host. + Server, +} + +/// How a client frame relates to the journal. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FrameClass { + /// Becomes one committed, journaled world event. + Event, + /// Connection-local presentation context; never journaled. + Ephemeral, + /// Read-only; never journaled. + Query, + /// Session lifecycle; never journaled itself. + Control, + /// Authority-bearing configuration; may write a journal entry. + Admin, + /// Mints a narrower capability; may write a journal entry. + Delegation, + /// A server frame carrying committed, projected world state. + Durable, + /// A server frame answering a request. + Reply, +} + +/// The JSON type of one field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +pub enum FieldType { + /// A JSON string. + #[serde(rename = "string")] + String, + /// A string of an even number of hex digits. + #[serde(rename = "hex")] + Hex, + /// A JSON boolean. + #[serde(rename = "bool")] + Bool, + /// An integer in `0..=u16::MAX`. + #[serde(rename = "u16")] + U16, + /// An integer in `0..=u32::MAX`. + #[serde(rename = "u32")] + U32, + /// A non-negative integer. + #[serde(rename = "u64")] + U64, + /// An array of strings. + #[serde(rename = "string[]")] + StringArray, + /// Any JSON array. + #[serde(rename = "array")] + Array, + /// Any JSON object. + #[serde(rename = "object")] + Object, +} + +/// One field of a frame. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Field { + /// Its JSON type. + pub kind: FieldType, + /// Whether it may be absent. + pub optional: bool, + /// Whether an optional field may also be `null`. + pub nullable: bool, + /// Smallest allowed integer. + pub min: Option, + /// Largest allowed integer. + pub max: Option, + /// Longest allowed string, in UTF-8 bytes. + pub max_bytes: Option, + /// The only allowed string values. + pub allowed: Option>, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum RawField { + Short(String), + Full { + #[serde(rename = "type")] + kind: FieldType, + #[serde(default)] + optional: bool, + nullable: Option, + min: Option, + max: Option, + max_bytes: Option, + #[serde(rename = "enum")] + allowed: Option>, + }, +} + +impl TryFrom for Field { + type Error = String; + + fn try_from(raw: RawField) -> Result { + match raw { + RawField::Short(text) => { + let (name, optional) = text + .strip_suffix('?') + .map_or((text.as_str(), false), |name| (name, true)); + let kind = serde_json::from_value(Value::from(name)) + .map_err(|_| format!("unknown field type `{name}`"))?; + Ok(Self { + kind, + optional, + nullable: optional, + min: None, + max: None, + max_bytes: None, + allowed: None, + }) + } + RawField::Full { + kind, + optional, + nullable, + min, + max, + max_bytes, + allowed, + } => Ok(Self { + kind, + optional, + nullable: nullable.unwrap_or(optional), + min, + max, + max_bytes, + allowed, + }), + } + } +} + +/// One frame type. +#[derive(Clone, Debug)] +pub struct FrameSpec { + /// How it relates to the journal. + pub class: FrameClass, + /// Phases a client may send it in (empty for server frames). + pub phases: Vec, + /// The phase an opening frame of this type moves the session to. + pub opens: Option, + /// Keys that may not appear anywhere in the frame. + pub forbidden_keys: Vec, + /// Its fields, by name. + pub fields: BTreeMap, +} + +#[derive(Deserialize)] +struct RawFrame { + class: FrameClass, + #[serde(default)] + phases: Vec, + opens: Option, + #[serde(default)] + forbidden_keys: Vec, + fields: BTreeMap, +} + +#[derive(Deserialize)] +struct RawProjection { + redacted_keys: Vec, +} + +#[derive(Deserialize)] +struct RawContract { + contract: String, + version: u32, + revision: u32, + max_frame_bytes: usize, + routing_fields: Vec, + transports: BTreeMap, + client_frames: BTreeMap, + server_frames: BTreeMap, + projection: RawProjection, +} + +/// The parsed shell contract. +#[derive(Clone, Debug)] +pub struct ShellContract { + /// Contract name (`id-shell`). + pub name: String, + /// Wire version. + pub version: u32, + /// Additive revision within the version. + pub revision: u32, + /// Largest frame either side may send. + pub max_frame_bytes: usize, + /// Fields any client frame may carry for routing (`world`). + pub routing_fields: Vec, + /// Transport envelopes, by name. + pub transports: BTreeMap, + /// Frames a shell may send. + pub client_frames: BTreeMap, + /// Frames a host may send. + pub server_frames: BTreeMap, + /// Keys the client projection redacts to zero in every server frame. + pub redacted_keys: Vec, +} + +fn frames(raw: BTreeMap) -> Result, String> { + raw.into_iter() + .map(|(name, frame)| { + let fields = frame + .fields + .into_iter() + .map(|(field, raw)| { + Field::try_from(raw) + .map(|spec| (field.clone(), spec)) + .map_err(|error| format!("{name}.{field}: {error}")) + }) + .collect::>()?; + Ok(( + name, + FrameSpec { + class: frame.class, + phases: frame.phases, + opens: frame.opens, + forbidden_keys: frame.forbidden_keys, + fields, + }, + )) + }) + .collect() +} + +impl ShellContract { + /// Parse a contract document. + /// + /// # Errors + /// + /// Fails if the document is not a valid contract. + pub fn parse(text: &str) -> Result { + let raw: RawContract = serde_json::from_str(text).map_err(|error| error.to_string())?; + Ok(Self { + name: raw.contract, + version: raw.version, + revision: raw.revision, + max_frame_bytes: raw.max_frame_bytes, + routing_fields: raw.routing_fields, + transports: raw.transports, + client_frames: frames(raw.client_frames)?, + server_frames: frames(raw.server_frames)?, + redacted_keys: raw.projection.redacted_keys, + }) + } + + /// The embedded version 1 contract. + /// + /// # Errors + /// + /// Fails only if the embedded document is broken, which its tests rule out. + pub fn embedded() -> Result<&'static Self, String> { + static CONTRACT: OnceLock> = OnceLock::new(); + CONTRACT + .get_or_init(|| Self::parse(SHELL_CONTRACT_JSON)) + .as_ref() + .map_err(Clone::clone) + } + + /// Check one client frame sent in `phase`, returning its type. + /// + /// # Errors + /// + /// Returns what the frame got wrong. + pub fn check_client_frame<'v>( + &self, + frame: &'v Value, + phase: Phase, + ) -> Result<&'v str, String> { + let (kind, spec) = self.lookup(frame, &self.client_frames, "client")?; + if !spec.phases.contains(&phase) { + return Err(format!( + "`{kind}` may not be sent in the {} phase", + phase.as_str() + )); + } + for key in &spec.forbidden_keys { + if names_key(frame, key) { + return Err(format!("`{kind}` may not carry `{key}` anywhere")); + } + } + check_fields(kind, frame, spec, &self.routing_fields)?; + Ok(kind) + } + + /// Check one server frame, returning its type. + /// + /// # Errors + /// + /// Returns what the frame got wrong, including a leaked seed. + pub fn check_server_frame<'v>(&self, frame: &'v Value) -> Result<&'v str, String> { + let (kind, spec) = self.lookup(frame, &self.server_frames, "server")?; + check_fields(kind, frame, spec, &[])?; + for key in &self.redacted_keys { + if let Some(value) = nonzero_key(frame, key) { + return Err(format!( + "`{kind}` leaks `{key}` ({value}) past the client projection" + )); + } + } + Ok(kind) + } + + fn lookup<'v, 's>( + &self, + frame: &'v Value, + table: &'s BTreeMap, + side: &str, + ) -> Result<(&'v str, &'s FrameSpec), String> { + let encoded = frame.to_string().len(); + if encoded > self.max_frame_bytes { + return Err(format!( + "frame of {encoded} bytes exceeds {}", + self.max_frame_bytes + )); + } + let Some(object) = frame.as_object() else { + return Err("a frame must be a JSON object".to_owned()); + }; + let Some(kind) = object.get("type").and_then(Value::as_str) else { + return Err("a frame needs a string `type`".to_owned()); + }; + let spec = table + .get(kind) + .ok_or_else(|| format!("`{kind}` is not a {side} frame in the contract"))?; + Ok((kind, spec)) + } +} + +fn check_fields( + kind: &str, + frame: &Value, + spec: &FrameSpec, + routing: &[String], +) -> Result<(), String> { + let Some(object) = frame.as_object() else { + return Err("a frame must be a JSON object".to_owned()); + }; + for (key, value) in object { + if key == "type" { + continue; + } + if routing.iter().any(|name| name == key) { + if !(value.is_null() || value.is_string()) { + return Err(format!("routing field `{key}` must be a string")); + } + continue; + } + if !spec.fields.contains_key(key) { + return Err(format!("`{kind}` has no field `{key}`")); + } + } + for (name, field) in &spec.fields { + match object.get(name) { + None if field.optional => {} + Some(Value::Null) if field.nullable => {} + None | Some(Value::Null) => { + return Err(format!("`{kind}` needs field `{name}`")); + } + Some(value) => { + check_value(field, value).map_err(|error| format!("`{kind}.{name}` {error}"))? + } + } + } + Ok(()) +} + +fn check_value(field: &Field, value: &Value) -> Result<(), String> { + let integer_max = match field.kind { + FieldType::U16 => Some(u64::from(u16::MAX)), + FieldType::U32 => Some(u64::from(u32::MAX)), + FieldType::U64 => Some(u64::MAX), + _ => None, + }; + if let Some(type_max) = integer_max { + let Some(number) = value.as_u64() else { + return Err("must be a non-negative integer".to_owned()); + }; + let max = field.max.unwrap_or(type_max).min(type_max); + let min = field.min.unwrap_or(0); + if number < min || number > max { + return Err(format!("must be in {min}..={max}, got {number}")); + } + return Ok(()); + } + match field.kind { + FieldType::String | FieldType::Hex => { + let Some(text) = value.as_str() else { + return Err("must be a string".to_owned()); + }; + if field.kind == FieldType::Hex + && (!text.len().is_multiple_of(2) || !text.bytes().all(|b| b.is_ascii_hexdigit())) + { + return Err("must be an even number of hex digits".to_owned()); + } + if let Some(max) = field.max_bytes.filter(|max| text.len() > *max) { + return Err(format!("is {} bytes, more than {max}", text.len())); + } + if let Some(allowed) = field + .allowed + .as_ref() + .filter(|allowed| !allowed.iter().any(|word| word == text)) + { + return Err(format!("must be one of {}", allowed.join(", "))); + } + Ok(()) + } + FieldType::Bool if value.is_boolean() => Ok(()), + FieldType::Bool => Err("must be a boolean".to_owned()), + FieldType::StringArray => match value.as_array() { + Some(items) if items.iter().all(Value::is_string) => Ok(()), + _ => Err("must be an array of strings".to_owned()), + }, + FieldType::Array if value.is_array() => Ok(()), + FieldType::Array => Err("must be an array".to_owned()), + FieldType::Object if value.is_object() => Ok(()), + FieldType::Object => Err("must be an object".to_owned()), + FieldType::U16 | FieldType::U32 | FieldType::U64 => Ok(()), + } +} + +fn names_key(value: &Value, key: &str) -> bool { + match value { + Value::Object(map) => map + .iter() + .any(|(name, inner)| name == key || names_key(inner, key)), + Value::Array(items) => items.iter().any(|item| names_key(item, key)), + _ => false, + } +} + +/// The first value of `key`, anywhere in `value`, that is not zero. +fn nonzero_key<'v>(value: &'v Value, key: &str) -> Option<&'v Value> { + match value { + Value::Object(map) => map.iter().find_map(|(name, inner)| { + if name == key && inner.as_u64() != Some(0) && !inner.is_null() { + Some(inner) + } else { + nonzero_key(inner, key) + } + }), + Value::Array(items) => items.iter().find_map(|item| nonzero_key(item, key)), + _ => None, + } +} + +/// One contract violation in a transcript. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Violation { + /// Zero-based position of the frame in the transcript. + pub index: usize, + /// Who sent it. + pub side: Side, + /// What was wrong. + pub message: String, +} + +impl fmt::Display for Violation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let side = match self.side { + Side::Client => "client", + Side::Server => "server", + }; + write!(f, "frame {} ({side}): {}", self.index, self.message) + } +} + +/// Checks one session's frames in order, following its phase. +#[derive(Debug)] +pub struct Conformance<'c> { + contract: &'c ShellContract, + phase: Phase, + index: usize, + violations: Vec, +} + +impl<'c> Conformance<'c> { + /// Start checking a new session. + #[must_use] + pub const fn new(contract: &'c ShellContract) -> Self { + Self { + contract, + phase: Phase::Opening, + index: 0, + violations: Vec::new(), + } + } + + /// The session's current phase. + #[must_use] + pub const fn phase(&self) -> Phase { + self.phase + } + + /// Record a frame the shell sent. + pub fn client(&mut self, frame: &Value) { + if self.phase == Phase::Closed { + self.violate( + Side::Client, + "the host already ended this session".to_owned(), + ); + } else { + match self.contract.check_client_frame(frame, self.phase) { + Ok(kind) => { + let opens = self + .contract + .client_frames + .get(kind) + .and_then(|spec| spec.opens); + self.phase = match (self.phase, kind) { + (Phase::Opening, _) => opens.unwrap_or(Phase::Closed), + (Phase::Query, "join") => Phase::Joined, + (Phase::Install, "install_end") => Phase::Closed, + (phase, _) => phase, + }; + } + Err(message) => self.violate(Side::Client, message), + } + } + self.index += 1; + } + + /// Record a frame the host sent. + pub fn server(&mut self, frame: &Value) { + if let Err(message) = self.contract.check_server_frame(frame) { + self.violate(Side::Server, message); + } + // Before a join, an error ends the session. + if frame.get("type").and_then(Value::as_str) == Some("error") + && matches!(self.phase, Phase::Opening | Phase::Query | Phase::Install) + { + self.phase = Phase::Closed; + } + self.index += 1; + } + + fn violate(&mut self, side: Side, message: String) { + self.violations.push(Violation { + index: self.index, + side, + message, + }); + } + + /// Every violation so far. + #[must_use] + pub fn violations(&self) -> &[Violation] { + &self.violations + } + + /// Finish, returning every violation. + #[must_use] + pub fn finish(self) -> Vec { + self.violations + } +} + +/// One line of a recorded transcript: `{"from": "client"|"server", "frame": {...}}`. +#[derive(Debug, Deserialize)] +pub struct TranscriptLine { + /// Who sent the frame. + pub from: Side, + /// The frame. + pub frame: Value, +} + +/// Check a JSON-lines transcript of one session. +/// +/// # Errors +/// +/// Fails if a line is not a transcript entry; contract violations are +/// returned, not errors. +pub fn check_transcript(contract: &ShellContract, text: &str) -> anyhow::Result> { + let mut check = Conformance::new(contract); + for (number, line) in text.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let entry: TranscriptLine = serde_json::from_str(line) + .map_err(|error| anyhow::anyhow!("transcript line {}: {error}", number + 1))?; + match entry.from { + Side::Client => check.client(&entry.frame), + Side::Server => check.server(&entry.frame), + } + } + Ok(check.finish()) +} + +/// The smallest frame of `kind` the contract accepts: every required field +/// with a valid value, `capability` fields set to `capability`. +#[cfg(test)] +pub(crate) fn sample_client_frame(contract: &ShellContract, kind: &str, capability: &str) -> Value { + let mut frame = serde_json::Map::new(); + frame.insert("type".to_owned(), Value::from(kind)); + for (name, field) in contract + .client_frames + .get(kind) + .map(|spec| &spec.fields) + .into_iter() + .flatten() + .filter(|(_, field)| !field.optional) + { + let value = match field.kind { + FieldType::String if name == "capability" => Value::from(capability), + FieldType::String => field + .allowed + .as_ref() + .and_then(|allowed| allowed.first()) + .map_or_else(|| Value::from("x"), |word| Value::from(word.as_str())), + FieldType::Hex => Value::from("00"), + FieldType::Bool => Value::from(false), + FieldType::U16 | FieldType::U32 | FieldType::U64 => Value::from(field.min.unwrap_or(1)), + FieldType::StringArray | FieldType::Array => Value::Array(Vec::new()), + FieldType::Object => Value::Object(serde_json::Map::new()), + }; + frame.insert(name.clone(), value); + } + Value::Object(frame) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests; diff --git a/pkgs/id/src/shell_contract/tests.rs b/pkgs/id/src/shell_contract/tests.rs new file mode 100644 index 000000000..50785cf0c --- /dev/null +++ b/pkgs/id/src/shell_contract/tests.rs @@ -0,0 +1,406 @@ +use std::collections::BTreeSet; + +use serde_json::json; + +use super::*; +use crate::world_session::{ClientFrame, InviteResponse, ServerFrame}; + +fn contract() -> &'static ShellContract { + ShellContract::embedded().unwrap() +} + +/// Every `type` serde accepts for [`ClientFrame`], read from its +/// unknown-variant error so the list cannot drift from the enum. +fn client_frame_variants() -> BTreeSet { + let error = serde_json::from_value::(json!({"type": "__shell_contract_probe__"})) + .unwrap_err() + .to_string(); + let (_, expected) = error.split_once("expected one of").unwrap(); + expected + .split(',') + .map(|name| name.trim().trim_matches('`').to_owned()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// Exhaustive on purpose: a new [`ServerFrame`] variant does not compile until +/// it is named here, and the test below then needs it in the contract. +fn server_frame_type(frame: &ServerFrame<'_>) -> &'static str { + match frame { + ServerFrame::Snapshot { .. } => "snapshot", + ServerFrame::Event { .. } => "event", + ServerFrame::View { .. } => "view", + ServerFrame::ModuleInfo { .. } => "module_info", + ServerFrame::Records { .. } => "records", + ServerFrame::RecordsTicket { .. } => "records_ticket", + ServerFrame::ModuleBegin { .. } => "module_begin", + ServerFrame::ModuleChunk { .. } => "module_chunk", + ServerFrame::ModuleEnd { .. } => "module_end", + ServerFrame::Invite(_) => "invite", + ServerFrame::Worlds { .. } => "worlds", + ServerFrame::WorldCreated { .. } => "world_created", + ServerFrame::Isolation { .. } => "isolation", + ServerFrame::Policy { .. } => "policy", + ServerFrame::Caps { .. } => "caps", + ServerFrame::ExecutionProfile { .. } => "execution_profile", + ServerFrame::CapsCeiling { .. } => "caps_ceiling", + ServerFrame::Compiled { .. } => "compiled", + ServerFrame::UploadReady { .. } => "upload_ready", + ServerFrame::ModuleInstalled { .. } => "module_installed", + ServerFrame::ProgramChanged { .. } => "program_changed", + ServerFrame::Error { .. } => "error", + } +} + +const SERVER_FRAME_TYPES: &[&str] = &[ + "snapshot", + "event", + "view", + "module_info", + "records", + "records_ticket", + "module_begin", + "module_chunk", + "module_end", + "invite", + "worlds", + "world_created", + "isolation", + "policy", + "caps", + "execution_profile", + "caps_ceiling", + "compiled", + "upload_ready", + "module_installed", + "program_changed", + "error", +]; + +#[test] +fn the_embedded_contract_is_version_one_and_matches_the_host_limits() { + let contract = contract(); + assert_eq!(contract.name, "id-shell"); + assert_eq!(contract.version, SHELL_CONTRACT_VERSION); + assert_eq!(contract.max_frame_bytes, crate::frames::MAX_FRAME_BYTES); + assert_eq!( + contract.transports["iroh"]["alpn"], + std::str::from_utf8(crate::world_net::WORLD_ALPN).unwrap() + ); + assert_eq!( + contract.transports["iroh"]["alpn"].as_str().unwrap(), + format!("/id-world/{SHELL_CONTRACT_VERSION}") + ); + let join = &contract.client_frames["join"].fields["capability"]; + assert_eq!( + join.max_bytes, + Some(crate::world_session::MAX_CAPABILITY_BYTES) + ); + let chunk = &contract.client_frames["install_chunk"].fields["data_hex"]; + assert_eq!( + chunk.max_bytes, + Some(crate::world_session::MAX_WORLD_MODULE_CHUNK_BYTES * 2) + ); + let total = &contract.client_frames["install_begin"].fields["total_bytes"]; + assert_eq!( + total.max, + Some(crate::world_session::MAX_WORLD_MODULE_BYTES as u64) + ); + let paste = &contract.client_frames["paste"].fields["text"]; + assert_eq!(paste.max_bytes, Some(4096)); + assert_eq!(contract.redacted_keys, ["seed"]); +} + +#[test] +fn every_client_frame_variant_is_in_the_contract_and_nothing_else_is() { + let named: BTreeSet = contract().client_frames.keys().cloned().collect(); + assert_eq!(client_frame_variants(), named); +} + +#[test] +fn every_server_frame_variant_is_in_the_contract_and_nothing_else_is() { + let named: BTreeSet<&str> = contract() + .server_frames + .keys() + .map(String::as_str) + .collect(); + let listed: BTreeSet<&str> = SERVER_FRAME_TYPES.iter().copied().collect(); + assert_eq!(listed, named); + // The exhaustive match agrees with serde for frames that are cheap to build. + let invite = InviteResponse { + capability: "cap".to_owned(), + participant_id: 3, + display_name: "ada".to_owned(), + }; + for frame in [ + ServerFrame::View { + sequence: 1, + data: "x", + surface: None, + }, + ServerFrame::RecordsTicket { + ticket: "t".to_owned(), + }, + ServerFrame::ModuleBegin { + module_hash: "h".to_owned(), + total_bytes: 2, + }, + ServerFrame::ModuleChunk { + offset: 0, + data_hex: "00".to_owned(), + }, + ServerFrame::ModuleEnd { + module_hash: "h".to_owned(), + }, + ServerFrame::Invite(&invite), + ServerFrame::WorldCreated { + world: "w", + created: true, + }, + ServerFrame::Isolation { + world: "w", + isolation: crate::world::Isolation::Isolated, + }, + ServerFrame::Compiled { + module_hash: "h".to_owned(), + sequence: 1, + diagnostics: String::new(), + }, + ServerFrame::UploadReady { chunk_bytes: 6 }, + ServerFrame::ModuleInstalled { + module_hash: "h", + sequence: 1, + }, + ServerFrame::ProgramChanged { + module_hash: "h", + sequence: 1, + }, + ServerFrame::Error { message: "no" }, + ServerFrame::ModuleInfo { + world_id: "w".to_owned(), + active_module_hash: None, + active_module_provenance: None, + current_sequence: 0, + }, + ] { + let value = serde_json::to_value(&frame).unwrap(); + assert_eq!(value["type"], server_frame_type(&frame)); + contract() + .check_server_frame(&value) + .unwrap_or_else(|error| panic!("{value}: {error}")); + } +} + +#[test] +fn the_contracts_required_fields_are_exactly_what_the_host_requires() { + let contract = contract(); + for (kind, spec) in &contract.client_frames { + let sample = sample_client_frame(contract, kind, "cap"); + let phase = spec.phases[0]; + contract + .check_client_frame(&sample, phase) + .unwrap_or_else(|error| panic!("{kind} sample: {error}")); + serde_json::from_value::(sample.clone()) + .unwrap_or_else(|error| panic!("{kind} sample {sample} is refused: {error}")); + for (name, field) in &spec.fields { + let mut changed = sample.clone(); + if field.optional { + // Absent is always fine; `null` exactly when the contract says so. + assert!(contract.check_client_frame(&changed, phase).is_ok()); + changed[name.as_str()] = Value::Null; + assert_eq!( + serde_json::from_value::(changed.clone()).is_ok(), + field.nullable, + "{kind}.{name}: the contract and the host disagree about null" + ); + assert_eq!( + contract.check_client_frame(&changed, phase).is_ok(), + field.nullable + ); + } else { + changed.as_object_mut().unwrap().remove(name); + assert!( + serde_json::from_value::(changed.clone()).is_err(), + "{kind}.{name} is required in the contract but the host accepts it absent" + ); + assert!(contract.check_client_frame(&changed, phase).is_err()); + } + } + } +} + +#[test] +fn every_frame_has_a_class_and_every_client_frame_a_phase() { + for (kind, spec) in &contract().client_frames { + assert!(!spec.phases.is_empty(), "{kind}"); + assert!( + !matches!(spec.class, FrameClass::Durable | FrameClass::Reply), + "{kind} uses a server class" + ); + assert_eq!( + spec.phases.contains(&Phase::Opening), + spec.opens.is_some(), + "{kind}: an opening frame says which phase it opens" + ); + } + for (kind, spec) in &contract().server_frames { + assert!( + matches!( + spec.class, + FrameClass::Durable | FrameClass::Reply | FrameClass::Ephemeral + ), + "{kind}" + ); + } + // The journaled-vs-ephemeral boundary as the contract states it. + let class = |kind: &str| contract().client_frames[kind].class; + assert_eq!(class("resize"), FrameClass::Ephemeral); + for kind in ["input", "paste", "chat"] { + assert_eq!(class(kind), FrameClass::Event, "{kind}"); + } + assert_eq!( + contract().server_frames["view"].class, + FrameClass::Ephemeral + ); +} + +#[test] +fn the_check_names_what_a_frame_got_wrong() { + let contract = contract(); + let joined = |frame: Value| { + contract + .check_client_frame(&frame, Phase::Joined) + .map(str::to_owned) + }; + assert_eq!( + joined(json!({"type": "input", "data_hex": "0aFf"})).as_deref(), + Ok("input") + ); + assert!( + joined(json!({"type": "teleport"})) + .unwrap_err() + .contains("not a client frame") + ); + assert!( + joined(json!({"type": "input"})) + .unwrap_err() + .contains("needs field") + ); + assert!( + joined(json!({"type": "input", "data_hex": "abc"})) + .unwrap_err() + .contains("hex") + ); + assert!( + joined(json!({"type": "chat", "text": "hi", "colour": "red"})) + .unwrap_err() + .contains("no field `colour`") + ); + assert!( + joined(json!({"type": "resize", "width": 501, "height": 10})) + .unwrap_err() + .contains("1..=500") + ); + assert!(joined(json!({"type": "resize", "width": 80, "height": -1})).is_err()); + assert!( + joined(json!({"type": "paste", "text": "x".repeat(4097)})) + .unwrap_err() + .contains("more than 4096") + ); + assert!( + joined(json!({"type": "attenuate", "name": "n", "scopes": [], "nested": {"uses": 1}})) + .unwrap_err() + .contains("`uses`") + ); + assert!( + joined(json!({"type": "download_module", "capability": "c", "module_hash": "h"})) + .unwrap_err() + .contains("joined phase") + ); + assert!( + contract + .check_client_frame(&json!({"type": "input", "data_hex": "00"}), Phase::Opening) + .is_err() + ); + // The routing field is allowed on any frame, but only as a string. + assert!(joined(json!({"type": "chat", "text": "hi", "world": "arena"})).is_ok()); + assert!(joined(json!({"type": "chat", "text": "hi", "world": 7})).is_err()); + assert!(joined(json!(["input"])).is_err()); + assert!(joined(json!({"type": "chat", "text": "x".repeat(2 * 1024 * 1024)})).is_err()); +} + +#[test] +fn the_check_refuses_a_seed_that_escaped_the_projection() { + let contract = contract(); + let event = |seed: u64| { + json!({"type": "event", "event": {"sequence": 1, "participant_id": 0, + "kind": {"kind": "program_installed", "data": {"module_hash": "h", "seed": seed}}}}) + }; + assert_eq!(contract.check_server_frame(&event(0)), Ok("event")); + assert!( + contract + .check_server_frame(&event(424_242)) + .unwrap_err() + .contains("leaks `seed`") + ); + let snapshot = json!({"type": "snapshot", "snapshot": {"events": [ + {"kind": {"kind": "program_installed", "data": {"seed": 9}}} + ]}}); + assert!(contract.check_server_frame(&snapshot).is_err()); + assert!( + contract + .check_server_frame(&json!({"type": "error"})) + .unwrap_err() + .contains("needs field `message`") + ); + assert!( + contract + .check_server_frame(&json!({"type": "view", "sequence": 1, "data": "", "extra": 1})) + .is_err() + ); +} + +#[test] +fn a_transcript_follows_the_session_phases() { + let contract = contract(); + let transcript = [ + json!({"from": "client", "frame": {"type": "info", "capability": "c"}}), + json!({"from": "server", "frame": {"type": "module_info", "world_id": "w", + "active_module_hash": null, "current_sequence": 0}}), + json!({"from": "client", "frame": {"type": "join", "capability": "c", "after": null}}), + json!({"from": "server", "frame": {"type": "snapshot", "snapshot": {}}}), + json!({"from": "client", "frame": {"type": "resize", "width": 80, "height": 24}}), + json!({"from": "client", "frame": {"type": "input", "data_hex": "6869"}}), + ] + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + assert_eq!(check_transcript(contract, &transcript).unwrap(), []); + + // An opening invite ends the session; a frame after it is a violation. + let mut check = Conformance::new(contract); + check.client(&json!({"type": "invite", "admin_token": "a", "display_name": "d"})); + check.server(&json!({"type": "error", "message": "invite denied"})); + assert_eq!(check.phase(), Phase::Closed); + check.client(&json!({"type": "chat", "text": "hi"})); + let violations = check.finish(); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert_eq!(violations[0].index, 2); + assert_eq!(violations[0].side, Side::Client); + assert!(violations[0].to_string().starts_with("frame 2 (client)")); + + // An upload: begin, chunks, end, then closed. + let mut check = Conformance::new(contract); + check + .client(&json!({"type": "install_begin", "admin_token": "a", "total_bytes": 2, "seed": 1})); + assert_eq!(check.phase(), Phase::Install); + check.server(&json!({"type": "upload_ready", "chunk_bytes": 6144})); + check.client(&json!({"type": "install_chunk", "offset": 0, "data_hex": "0000"})); + check.client(&json!({"type": "install_end"})); + assert_eq!(check.phase(), Phase::Closed); + assert!(check.violations().is_empty(), "{:?}", check.violations()); + + assert!(check_transcript(contract, "not json").is_err()); + assert!(check_transcript(contract, r#"{"from": "peer", "frame": {}}"#).is_err()); +} diff --git a/pkgs/id/src/world_session/contract_tests.rs b/pkgs/id/src/world_session/contract_tests.rs new file mode 100644 index 000000000..92b8006e3 --- /dev/null +++ b/pkgs/id/src/world_session/contract_tests.rs @@ -0,0 +1,253 @@ +//! Sessions checked against the shell contract (F13.1): the host's own +//! frames conform, and the contract's journaled-vs-ephemeral classes hold. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::Duration; + +use serde_json::{Value, json}; +use tokio::sync::mpsc; + +use crate::shell_contract::{Conformance, FrameClass, Phase, ShellContract, sample_client_frame}; +use crate::world::{Participant, WorldCore, WorldHandle, WorldLimits, WorldScopes}; +use crate::world_hub::WorldHub; +use crate::world_session::{Inbound, SessionConfig, SessionIo, WorldService, run_session_with}; + +struct ChannelIo { + inbound: mpsc::Receiver, + outbound: mpsc::Sender, +} + +impl SessionIo for ChannelIo { + async fn recv(&mut self) -> Inbound { + self.inbound.recv().await.unwrap_or(Inbound::Closed) + } + async fn send(&mut self, frame: String) -> bool { + self.outbound.send(frame).await.is_ok() + } +} + +/// One session whose every frame, both ways, goes through the contract check. +struct CheckedPeer { + to_server: mpsc::Sender, + from_server: mpsc::Receiver, + check: Conformance<'static>, + seen: Vec, +} + +impl CheckedPeer { + fn connect(service: WorldService) -> Self { + let hub = WorldHub::single(service); + let (to_server, inbound) = mpsc::channel(16); + let (outbound, from_server) = mpsc::channel(64); + tokio::spawn(async move { + let mut io = ChannelIo { inbound, outbound }; + run_session_with(&hub, &mut io, SessionConfig::default()).await; + }); + Self { + to_server, + from_server, + check: Conformance::new(ShellContract::embedded().unwrap()), + seen: Vec::new(), + } + } + + async fn say(&mut self, frame: Value) { + self.check.client(&frame); + self.to_server + .send(Inbound::Text(frame.to_string())) + .await + .unwrap(); + } + + /// Every frame the host sends until it goes quiet. + async fn drain(&mut self) -> Vec { + let mut frames = Vec::new(); + while let Ok(Some(text)) = + tokio::time::timeout(Duration::from_millis(300), self.from_server.recv()).await + { + let frame: Value = serde_json::from_str(&text).unwrap(); + self.check.server(&frame); + self.seen + .push(frame["type"].as_str().unwrap_or_default().to_owned()); + frames.push(frame); + } + frames + } + + fn assert_conforms(&self) { + assert!( + self.check.violations().is_empty(), + "{:#?}", + self.check.violations() + ); + } +} + +/// A program with a view, so sessions receive `view` frames. +struct Viewed; + +impl crate::world::WorldProgram for Viewed { + fn view(&mut self, viewer: &Participant) -> anyhow::Result> { + Ok(Some(format!("hello {}", viewer.display_name))) + } +} + +fn service() -> WorldService { + WorldService::new( + WorldHandle::spawn(WorldCore::new("lobby", WorldLimits::default()).unwrap()), + Some("admin".to_owned()), + ) +} + +async fn sequence(world: &WorldHandle, token: &crate::world::JoinCapability) -> u64 { + world + .snapshot(token.clone()) + .await + .unwrap() + .current_sequence +} + +#[tokio::test] +async fn a_full_joined_session_conforms_and_never_shows_the_seed() { + let svc = service(); + let world = svc.world().clone(); + world + .install_program("ab".repeat(32), 424_242, Box::new(Viewed)) + .await + .unwrap(); + let delegator = WorldScopes::from_role("delegator").unwrap(); + let (_, guest) = world.issue("ada", delegator).await.unwrap(); + let cap = guest.expose().to_owned(); + + let mut peer = CheckedPeer::connect(svc.clone()); + peer.say(json!({"type": "join", "capability": cap, "after": null})) + .await; + peer.drain().await; + assert_eq!(peer.check.phase(), Phase::Joined); + for frame in [ + json!({"type": "resize", "width": 80, "height": 24}), + json!({"type": "input", "data_hex": "6869"}), + json!({"type": "paste", "text": "pasted"}), + json!({"type": "chat", "text": "hello"}), + json!({"type": "info", "capability": cap}), + json!({"type": "records", "capability": cap, "prefix": null, "after": null}), + json!({"type": "caps", "capability": cap}), + json!({"type": "attenuate", "name": "spectator", "scopes": ["join"]}), + ] { + peer.say(frame).await; + peer.drain().await; + } + // A live reinstall reaches the session as a snapshot plus program_changed. + world + .install_program("cd".repeat(32), 777, Box::new(Viewed)) + .await + .unwrap(); + let frames = peer.drain().await; + assert!( + !serde_json::to_string(&frames).unwrap().contains("777"), + "{frames:?}" + ); + peer.assert_conforms(); + for kind in [ + "snapshot", + "view", + "event", + "module_info", + "records", + "caps", + "invite", + "program_changed", + ] { + assert!( + peer.seen.iter().any(|seen| seen == kind), + "no {kind}: {:?}", + peer.seen + ); + } + assert!( + !peer.seen.iter().any(|seen| seen == "error"), + "{:?}", + peer.seen + ); +} + +#[tokio::test] +async fn query_and_upload_sessions_conform() { + let svc = service(); + let world = svc.world().clone(); + let (_, guest) = world.issue("ada", WorldScopes::GUEST).await.unwrap(); + let cap = guest.expose().to_owned(); + + // A query session that pages records and then joins. + let mut peer = CheckedPeer::connect(svc.clone()); + peer.say(json!({"type": "info", "capability": cap})).await; + peer.drain().await; + assert_eq!(peer.check.phase(), Phase::Query); + peer.say(json!({"type": "records", "capability": cap})) + .await; + peer.drain().await; + peer.say(json!({"type": "join", "capability": cap})).await; + peer.drain().await; + assert_eq!(peer.check.phase(), Phase::Joined); + peer.assert_conforms(); + + // One-reply opening frames end the session. + for opening in [ + json!({"type": "invite", "admin_token": "admin", "display_name": "bo", "role": "player"}), + json!({"type": "list_worlds", "admin_token": "admin"}), + json!({"type": "caps", "capability": cap}), + json!({"type": "set_caps_ceiling", "admin_token": "admin", "ceiling": ["time.now"]}), + json!({"type": "execution_profile_info", "capability": cap}), + json!({"type": "invite", "admin_token": "wrong", "display_name": "eve"}), + ] { + let mut peer = CheckedPeer::connect(svc.clone()); + peer.say(opening.clone()).await; + let frames = peer.drain().await; + assert_eq!(frames.len(), 1, "{opening}: {frames:?}"); + assert_eq!(peer.check.phase(), Phase::Closed, "{opening}"); + peer.assert_conforms(); + } +} + +/// The contract's classes are executable: every frame a joined shell may send +/// leaves the journal alone unless the contract calls it an event, and every +/// event frame commits exactly one. +#[tokio::test] +async fn the_journaled_vs_ephemeral_boundary_is_what_the_contract_says() { + let contract = ShellContract::embedded().unwrap(); + let svc = service(); + let world = svc.world().clone(); + world + .install_program("ab".repeat(32), 1, Box::new(Viewed)) + .await + .unwrap(); + let (_, guest) = world.issue("ada", WorldScopes::GUEST).await.unwrap(); + let cap = guest.expose().to_owned(); + let mut peer = CheckedPeer::connect(svc.clone()); + peer.say(json!({"type": "join", "capability": cap})).await; + peer.drain().await; + + let mut checked = 0; + for (kind, spec) in &contract.client_frames { + if !spec.phases.contains(&Phase::Joined) { + continue; + } + let before = sequence(&world, &guest).await; + peer.say(sample_client_frame(contract, kind, &cap)).await; + peer.drain().await; + let after = sequence(&world, &guest).await; + match spec.class { + FrameClass::Event => assert_eq!(after, before + 1, "{kind} must commit one event"), + FrameClass::Ephemeral | FrameClass::Query | FrameClass::Control => { + assert_eq!(after, before, "{kind} must not touch the journal"); + } + // Authority-bearing frames may journal configuration; the sample's + // admin token is wrong, so here they are refused. + FrameClass::Admin | FrameClass::Delegation => assert_eq!(after, before, "{kind}"), + FrameClass::Durable | FrameClass::Reply => unreachable!("{kind}"), + } + checked += 1; + } + assert!(checked >= 10, "{checked}"); + peer.assert_conforms(); +} diff --git a/pkgs/id/src/world_session/mod.rs b/pkgs/id/src/world_session/mod.rs index a9924eabb..3ccf208a6 100644 --- a/pkgs/id/src/world_session/mod.rs +++ b/pkgs/id/src/world_session/mod.rs @@ -43,6 +43,8 @@ use crate::world_hub::{WorldHub, WorldLease}; /// `world` feature. pub use crate::frames::MAX_FRAME_BYTES; +#[cfg(test)] +mod contract_tests; #[cfg(test)] mod tests; /// Largest Wasm world module that may be installed (16 MiB). @@ -1974,6 +1976,23 @@ async fn handle_client_frame( } return true; } + // Authorized by the session's own capability, like `records`; the + // shell contract lists `info` in the joined phase (`/stats`). + ClientFrame::Info { .. } => { + let Ok(snapshot) = world.snapshot(token.clone()).await else { + return send_error(io, "join denied").await; + }; + return send_json( + io, + &ServerFrame::ModuleInfo { + world_id: snapshot.world_id, + active_module_hash: snapshot.active_module_hash, + active_module_provenance: snapshot.active_module_provenance, + current_sequence: snapshot.current_sequence, + }, + ) + .await; + } ClientFrame::Caps { .. } => { match service.world().caps_report().await { Ok(report) => { @@ -2026,7 +2045,6 @@ async fn handle_client_frame( } ClientFrame::Join { .. } | ClientFrame::Invite { .. } - | ClientFrame::Info { .. } | ClientFrame::ListWorlds { .. } | ClientFrame::CreateWorld { .. } | ClientFrame::SetIsolation { .. } diff --git a/pkgs/id/src/world_ssh.rs b/pkgs/id/src/world_ssh.rs index 40ef1fd0e..3d14d6c4f 100644 --- a/pkgs/id/src/world_ssh.rs +++ b/pkgs/id/src/world_ssh.rs @@ -69,6 +69,10 @@ const ALT_SCREEN_ENTER: &str = "\x1b[?1049h"; const ALT_SCREEN_LEAVE: &str = "\x1b[?1049l"; /// Input bytes dropped before the session tells the viewer about it. const INPUT_DROP_NOTICE_BYTES: usize = 1024; +/// Widest viewport a `resize` frame may name (shell contract v1). +const MAX_VIEWPORT_WIDTH: u32 = 500; +/// Tallest viewport a `resize` frame may name (shell contract v1). +const MAX_VIEWPORT_HEIGHT: u32 = 200; /// Serve SSH connections until the listener fails. pub async fn serve( @@ -436,6 +440,48 @@ impl Handler for SshClient { } } +/// The session frame one raw terminal event becomes: keystrokes are +/// `input`, a window change is `resize` (shell contract, joined phase). +fn raw_frame(input: &SshInputFrame) -> String { + match input { + SshInputFrame::Bytes(bytes) => { + json!({ "type": "input", "data_hex": encode_hex(bytes) }).to_string() + } + // A terminal outside the contract's viewport bounds still gets a + // view: it is clamped instead of refused. + SshInputFrame::Resize(width, height) => json!({ + "type": "resize", + "width": (*width).clamp(1, MAX_VIEWPORT_WIDTH), + "height": (*height).clamp(1, MAX_VIEWPORT_HEIGHT), + }) + .to_string(), + } +} + +/// The session frame a command-mode line sends, or `None` for the commands +/// the shell answers itself (`/help`, `/logs`, `/documents`, ...). +fn command_frame(command: &WorldCommand, capability: &str) -> Option { + let frame = match command { + WorldCommand::Chat(text) => json!({ "type": "chat", "text": text }), + WorldCommand::Input(data_hex) => json!({ "type": "input", "data_hex": data_hex }), + WorldCommand::Records { prefix } => json!({ + "type": "records", "capability": capability, "prefix": prefix, "after": null + }), + WorldCommand::RecordsContinue { prefix, after } => json!({ + "type": "records", "capability": capability, "prefix": prefix, "after": after + }), + WorldCommand::Stats => json!({ "type": "info", "capability": capability }), + WorldCommand::Caps => json!({ "type": "caps", "capability": capability }), + WorldCommand::Documents { .. } + | WorldCommand::Document(_) + | WorldCommand::Logs + | WorldCommand::Help + | WorldCommand::Message(_) + | WorldCommand::Quit => return None, + }; + Some(frame.to_string()) +} + fn join_frame(login: &Login) -> String { let mut join = json!({ "type": "join", "capability": login.capability }); if let Some(world) = &login.world { @@ -556,44 +602,25 @@ impl SessionIo for SshIo { return Inbound::Text(join); } if let Some((width, height)) = self.first_resize.take() { - return Inbound::Text( - json!({ "type": "resize", "width": width, "height": height }).to_string(), - ); + return Inbound::Text(raw_frame(&SshInputFrame::Resize(width, height))); } match self.mode { - ShellMode::Raw => match self.frames.recv().await { - Some(SshInputFrame::Bytes(bytes)) => Inbound::Text( - json!({ "type": "input", "data_hex": encode_hex(&bytes) }).to_string(), - ), - Some(SshInputFrame::Resize(width, height)) => Inbound::Text( - json!({ "type": "resize", "width": width, "height": height }).to_string(), - ), - None => Inbound::Closed, - }, + ShellMode::Raw => self + .frames + .recv() + .await + .map_or(Inbound::Closed, |input| Inbound::Text(raw_frame(&input))), ShellMode::Command => loop { if let Some(command) = self.commands.pop_front() { - match command { - WorldCommand::Chat(text) => { - return Inbound::Text( - json!({ "type": "chat", "text": text }).to_string(), - ); - } - WorldCommand::Input(data_hex) => { - return Inbound::Text( - json!({ "type": "input", "data_hex": data_hex }).to_string(), - ); - } - WorldCommand::Records { prefix } => { + if let Some(frame) = command_frame(&command, &self.capability) { + if let WorldCommand::Records { prefix } = &command { self.records_prefix = Some(prefix.clone()); self.records_drained = 0; - return Inbound::Text( - json!({ - "type": "records", "capability": self.capability, - "prefix": prefix, "after": null - }) - .to_string(), - ); } + return Inbound::Text(frame); + } + // Everything else is answered by the shell itself. + match command { WorldCommand::Documents { prefix } => { let text = match list_public_documents(&self.blobs, prefix.as_deref()).await { @@ -626,33 +653,12 @@ impl SessionIo for SshIo { return Inbound::Closed; } } - WorldCommand::RecordsContinue { prefix, after } => { - return Inbound::Text( - json!({ - "type": "records", "capability": self.capability, - "prefix": prefix, "after": after - }) - .to_string(), - ); - } WorldCommand::Help => { let help = "World chat: type a line to chat. Commands: /input , /records [prefix], /stats, /logs, /caps, /documents [prefix], /document , /help, /quit\r\nworld> "; if self.handle.data(self.channel, help).await.is_err() { return Inbound::Closed; } } - WorldCommand::Stats => { - return Inbound::Text( - json!({ "type": "info", "capability": self.capability }) - .to_string(), - ); - } - WorldCommand::Caps => { - return Inbound::Text( - json!({ "type": "caps", "capability": self.capability }) - .to_string(), - ); - } WorldCommand::Logs => { let text = "event tailing is not available in command mode; join in raw mode (no `cmd:` prefix) to see events live, or use `id world logs`\r\nworld> "; if self @@ -671,17 +677,20 @@ impl SessionIo for SshIo { } } WorldCommand::Quit => return Inbound::Closed, + WorldCommand::Chat(_) + | WorldCommand::Input(_) + | WorldCommand::Records { .. } + | WorldCommand::RecordsContinue { .. } + | WorldCommand::Stats + | WorldCommand::Caps => {} } } match self.frames.recv().await { Some(SshInputFrame::Bytes(bytes)) => { self.commands.extend(self.lines.push(&bytes)); } - Some(SshInputFrame::Resize(width, height)) => { - return Inbound::Text( - json!({ "type": "resize", "width": width, "height": height }) - .to_string(), - ); + Some(resize @ SshInputFrame::Resize(..)) => { + return Inbound::Text(raw_frame(&resize)); } None => return Inbound::Closed, } @@ -2012,6 +2021,68 @@ mod tests { server.abort(); } + #[test] + fn terminal_shell_frames_conform_to_the_shell_contract() { + use crate::shell_contract::{Phase, ShellContract}; + let contract = ShellContract::embedded().unwrap(); + let check = |frame: &str, phase| { + let value: serde_json::Value = serde_json::from_str(frame).unwrap(); + contract + .check_client_frame(&value, phase) + .map(str::to_owned) + .unwrap_or_else(|error| panic!("{frame}: {error}")) + }; + for (world, command_mode) in [(None, false), (Some("arena"), true)] { + let login = Login { + world: world.map(str::to_owned), + capability: "cap".to_owned(), + explore: false, + command_mode, + key: None, + }; + assert_eq!(check(&join_frame(&login), Phase::Opening), "join"); + } + for input in [ + SshInputFrame::Bytes(b"\x1b[A".to_vec()), + SshInputFrame::Resize(80, 24), + SshInputFrame::Resize(0, 0), + SshInputFrame::Resize(4000, 1000), + ] { + check(&raw_frame(&input), Phase::Joined); + } + let mut sent = Vec::new(); + for line in [ + "hello", + "/input 00ff", + "/records", + "/records key-", + "/stats", + "/caps", + "/logs", + "/help", + "/documents", + "/document a.md", + "/nope", + "/quit", + ] { + let command = world_command(line).unwrap(); + if let Some(frame) = command_frame(&command, "cap") { + sent.push(check(&frame, Phase::Joined)); + } + } + let more = WorldCommand::RecordsContinue { + prefix: None, + after: "k".to_owned(), + }; + sent.push(check(&command_frame(&more, "cap").unwrap(), Phase::Joined)); + assert_eq!( + sent, + [ + "chat", "input", "records", "records", "info", "caps", "records" + ] + ); + } + #[test] fn terminal_text_drops_control_characters_but_keeps_lines() { assert_eq!( @@ -2307,6 +2378,15 @@ mod tests { read_until(&mut channel, &mut terminal, "count=0").await; channel.data(&b"/input 696e63\r"[..]).await.unwrap(); read_until(&mut channel, &mut terminal, "count=1").await; + // `/stats` sends `info` from the joined session; the host answers it + // (it used to refuse it as "already joined"). + channel.data(&b"/stats\r"[..]).await.unwrap(); + read_until(&mut channel, &mut terminal, "current_sequence").await; + assert!( + !terminal.screen().contents().contains("already joined"), + "{}", + terminal.screen().contents() + ); server.abort(); } } diff --git a/pkgs/id/tests/cli_integration.rs b/pkgs/id/tests/cli_integration.rs index c22300da0..d58e31c95 100644 --- a/pkgs/id/tests/cli_integration.rs +++ b/pkgs/id/tests/cli_integration.rs @@ -373,6 +373,41 @@ mod cli_tests { assert!(output.status.success(), "Help failed for {cmd}"); } } + + /// `id world contract` prints the shell contract and checks a recorded + /// session against it (F13.1). + #[test] + fn test_world_contract_prints_and_checks_transcripts() { + let tmp = TempDir::new().unwrap(); + let printed = run_cmd_success_stdout(&["world", "contract"], tmp.path()); + let contract: serde_json::Value = serde_json::from_str(&printed).unwrap(); + assert_eq!(contract["contract"], "id-shell"); + assert_eq!(contract["version"], 1); + + std::fs::write( + tmp.path().join("good.jsonl"), + concat!( + r#"{"from": "client", "frame": {"type": "join", "capability": "c"}}"#, + "\n", + r#"{"from": "client", "frame": {"type": "input", "data_hex": "00"}}"#, + "\n", + ), + ) + .unwrap(); + let checked = + run_cmd_success_stdout(&["world", "contract", "--check", "good.jsonl"], tmp.path()); + assert!(checked.contains("2 frames conform"), "{checked}"); + + std::fs::write( + tmp.path().join("bad.jsonl"), + r#"{"from": "client", "frame": {"type": "input", "data_hex": "00"}}"#, + ) + .unwrap(); + let output = run_cmd(&["world", "contract", "--check", "bad.jsonl"], tmp.path()); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("opening phase"), "{stderr}"); + } } mod put_get_tests { From 9bf4f2f95ee1631a9030ab19221dbb1ccb468aeb Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 25 Oct 2026 01:01:40 -0500 Subject: [PATCH 2/6] docs(id): design client-side capability providers under the ceiling model (F13.7) Design only; implementation is gated on F12.4 (provider registry) and F12.6 (audit), and on F11.12 for its new frame kinds. Answers who grants with a two-key rule (world admin grant under the ceiling, plus the client user's local per-provider opt-in), keeps the guest import-free, journals consumed replies as existing System events, and picks a bounded local file read as the first provider. Refs #789, #837, #838, #839, #840 --- ...-57Z_design_client_capability_providers.md | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 doc/2026-10-25T05-37-57Z_design_client_capability_providers/2026-10-25T05-37-57Z_design_client_capability_providers.md diff --git a/doc/2026-10-25T05-37-57Z_design_client_capability_providers/2026-10-25T05-37-57Z_design_client_capability_providers.md b/doc/2026-10-25T05-37-57Z_design_client_capability_providers/2026-10-25T05-37-57Z_design_client_capability_providers.md new file mode 100644 index 000000000..68e919e9d --- /dev/null +++ b/doc/2026-10-25T05-37-57Z_design_client_capability_providers/2026-10-25T05-37-57Z_design_client_capability_providers.md @@ -0,0 +1,481 @@ +# Client-side capability providers under the ceiling model (F13.7) + +See the [Phase 13 plan](../../.opencode/plans/phase-13-client-shells.md) +(F13.7, "Capability-provider model", "Security and trust", Risks, and open +question 3). Tracking issue: #789. Prerequisites: F12.4 and F12.6 in the +[Phase 12 plan](../../.opencode/plans/phase-12-platform-flexibility.md). + +**Status: Design only. Implementation is gated on F12.4 and F12.6 (and on +F11.12 for any new frame kind).** Neither the F12.4 provider registry nor the +F12.6 structured audit log exists yet. Capability execution today is a string +`match` in the world actor (`pkgs/id/src/world/actor.rs:538-562`) over the +hard-coded `CATALOG` (`pkgs/id/src/world_caps.rs:71-89`). This document +designs against the _planned_ shape of F12.4 and F12.6, and it lists exactly +what it needs from each (see "Dependencies"). Nothing below is built. + +## Intent + +Phase 12 says no authority path exists except through a named, ceilinged +provider. F13.7 applies the same rule to resources held by the _client_: a +local file, a GPU buffer, a native notification. A world program may ask for +one of these through the capability protocol it already uses. The request is +routed to the shell of the participant who holds the resource. That shell +decides locally, executes, and returns a bounded reply. The host never gains +the authority, and the guest gains no Wasm import. + +## Goals + +- A client provider is a named, versioned provider with an F12.4 manifest. It + is added to the registry without editing the host `CATALOG` or the actor's + executor `match`. +- It is granted and ceilinged per world exactly like a host provider, and it is + also opted in per provider by the client's own user. Both keys are required. +- Every use, allowed or refused, leaves an F12.6 audit entry on the host and a + matching entry on the client. +- A refusal carries a stable F11.13 error code that the guest can branch on. +- Replay is deterministic and never needs the client: a reply the program + consumed is in the journal. +- The guest stays import-free (`pkgs/id/src/sandbox.rs:1-11`). + +## Non-goals + +- New host capabilities (`kv`, `blob`, `http.fetch`, `notify` are F12.5). +- Any change to the guest ABI (F12.7) or to the `wants` document schema. +- Writes to the client machine. Slice 1 is read-only. +- Directory listing, file watching, or streaming reads. +- GPU buffers and native notifications (deferred, see "First provider slice"). +- Delegating a client provider to another participant or another world. +- A WIT or component-model interface for providers (F12.8 decides that). + +## Background: the capability protocol today + +A program publishes a `wants` document, a pure projection of its model +(`world_caps.rs:1-24`, parsed and bounded by `parse_wants` at +`world_caps.rs:221`). After every change the actor recomputes `wants`, +executes each request id it has not yet handled, and delivers +`{"cap":"result","id":…,"ok":…}` back into `update` as a host event +(`actor.rs:367-418`, `result_event` at `world_caps.rs:311`). The decision is +made by `CapLedger::decide_for` (`world_caps.rs:531-558`) in a fixed order: +unknown name, ceiling, actor scope, grant, grant-on-use. The ceiling is the +intersection of the server and world ceilings within the catalog (see the +[ceilings record](../2026-10-16T11-00-00Z_feature_world_capability_ceilings/2026-10-16T11-00-00Z_feature_world_capability_ceilings.md)). +A request caused by a participant's input is checked against that +participant's scopes (`actor.rs:522-529`, `WorldScopes::allows_effect` at +`world/mod.rs:248`), which is the confused-deputy fix from the +[attenuated capabilities design](../2026-10-09T09-59-07Z_design_attenuated_capabilities/2026-10-09T09-59-07Z_design_attenuated_capabilities.md). + +Delivery matters for journaling. `deliver` (`actor.rs:326-361`) hands the +result to the program as a `System` event, and only after `update` accepts it +does it persist `JournalEntry::System`. Restore replays those entries into the +program (`world/core.rs:297-319`) without calling any provider. `deliver` does +not publish the event on the participant event stream, so the result reaches +other participants only if the program puts it into its view or records. + +## The model + +### A client provider is a manifested provider with a side + +A client provider uses the F12.4 manifest unchanged, plus one field. The +planned F12.4 fields are name, version, argument schema, bounds, required +scope, and default ceiling. F13.7 adds `side`, which says who holds the +resource: + +- `side: host` — executed by the host actor. Every current provider. +- `side: client` — never executed by the host. The host validates, decides, + routes, and relays. Execution happens in the shell of the client that holds + the resource. + +```json +{ + "name": "client.file.read", + "version": "1", + "side": "client", + "args": { "path": "string, relative", "max_bytes": "u32, optional" }, + "bounds": { "max_bytes": 32768, "per_minute": 10, "in_flight": 2 }, + "required_scope": "input", + "default_ceiling": "excluded" +} +``` + +Client provider names live under a reserved `client.` prefix. The manifest +`side` is authoritative; the prefix makes the side visible in grant lists and +audit lines, and it prevents a client provider from shadowing a host name. + +The host registry holds the manifest for every client provider it will route, +with no executor. A client advertises, per session, which `(name, version)` +pairs it implements _and_ its user has opted in for this world. The host +routes only to a session that advertised a compatible version (same major +version). An unadvertised provider is unavailable on that session, which is +not the same as unknown. + +### Default ceiling is "excluded" + +Today the default server and world ceiling is the whole catalog +(`world_caps.rs:129-133`, `CapLedger::new` at `world_caps.rs:497-504`), and a +checkpoint omits a ceiling that equals the full catalog. If a client +provider entered the registry under that rule, it would join the ceiling of +every such world the moment the binary was upgraded. A client provider's +manifest therefore carries `default_ceiling: excluded`: it is in no ceiling +until an admin names it in `SetCapsCeiling` (and in `--world-allowed-caps` if +the server ceiling is set). F12.4 must support a per-manifest default ceiling +for this to hold (see "Dependencies"). + +## Who grants: the two-key rule + +This answers open question 3 of the plan. **Recommendation: both the world and +the client's own user grant, and either can revoke.** + +1. **World key.** An admin grants `client.file.read` to the world (the + existing `UpdateCaps` frame, `world_session/types.rs:158-162`) and admits it + under the world ceiling (`SetCapsCeiling`, `types.rs:165-168`). The world + ceiling may also lower the manifest bounds, for example `max_bytes` to 4096. + Grant-on-use never applies to client providers, whatever the world's + policy: a `side: client` name is decided as `deny` policy. +2. **Client key.** The client's user opts in locally, per provider and per + world, with a local ceiling: the root directory for file reads, and + optionally lower bounds. The default is off. The opt-in lives in the + client's own configuration, never in the world journal and never on the + host. It is advertised to the host only as "available on this session". +3. **Attribution.** The request must be caused by an acting participant (the + one whose input produced the change, `actor.rs:1039`), that participant must + hold the manifest's required scope, and the request is routed only to that + participant's own originating session. A request with no acting + participant, such as one first seen after a tick, is refused. A world + program can never use one participant's input to reach another + participant's machine. + +Revocation is immediate on either side. An admin revoke or ceiling change +takes effect on the next decision, exactly as for host providers. A user +revoke sends an updated advertisement (see "Wire shape") and makes the shell +refuse any request already in flight. Each key protects against a different +party: the world key protects participants from a guest the admin did not +mean to trust, and the client key protects the user from the host and from the +guest. Only the client key protects against a malicious host, because the +host can forge any routing decision. So the client must enforce its own key +fully and never trust the host's decision. + +Rejected alternatives: **world/admin only** gives the host operator reach into +participants' machines, which the plan's "client providers are ceilings, not +capabilities" rule forbids. **Client user only** lets any world a user joins +ask for files without the world's admin having chosen to run a program that +may do so, and it leaves the admin no ceiling to set for the world. + +## Request and reply flow + +1. The program lists `{"id":"r7","cap":"client.file.read","args":{…}}` in + `wants`. Nothing about the guest changes: no import, no new export. +2. The actor sees the fresh id (`actor.rs:391-396`) and decides it: unknown + name, then ceiling, then actor scope, then grant, then client-provider + checks (manifest argument schema, args within the effective bounds, an + acting participant, a host-side rate limit, and an advertising session). +3. If allowed, the actor does not call an executor. It records an ephemeral + pending entry (host request nonce, guest request id, acting participant, + originating session, deadline) and sends a `provider_request` frame to that + session. The actor stays free; the reply arrives later as a world command. +4. The shell checks its own key from scratch: opted in for this world and + provider, args within its local ceiling, path resolved beneath the chosen + root, its own rate limit. It executes and returns `provider_reply` with + either a bounded value or a refusal code. +5. The host matches the nonce to the pending entry (an unknown, duplicate, or + expired nonce is dropped and audited), checks the reply against the + manifest bounds again, and delivers the ordinary result event through + `deliver`. A deadline with no reply delivers `provider_unavailable`. +6. Both sides write their audit entries, which share the host nonce. + +The guest sees one shape for every outcome: the existing result event. Bounds +on in-flight work: at most `in_flight` pending requests per session (manifest, +2 in slice 1), at most 16 pending client requests per world, and a reply +deadline of 10 seconds. A session that closes fails its pending requests with +`provider_unavailable`. + +The flow is never a new host-authority path. The host executes nothing, the +reply is data delivered into `update`, and the shell never sends a frame that +the host treats as an admin or participant action because of a provider. + +## Ceilings and refusal codes + +The effective bound for each numeric limit is the minimum of the manifest +bound, the world ceiling's bound, and the client's local bound. A request for +more than the effective bound is refused, not truncated, so a program cannot +learn that a larger file exists by asking for less and receiving a cut. + +Codes follow F11.13: each is stable on the wire and documented in the +capability guide, and a refusal carries an advisory `reason` where one helps +(`world_caps.rs:26-40`, `CapError` at `world_caps.rs:347-456`). Existing codes +are reused where their meaning matches; two codes are new because no existing +code means "the client refused" or "nobody can serve this". + +| Code | When | Decided by | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------- | +| `unknown_cap` | No registry manifest for the name | host | +| `outside_ceiling` | Excluded by the server or world ceiling, or args exceed the world bound (`reason: world`) | host | +| `actor_denied` | No acting participant, or the actor lacks the required scope | host | +| `cap_denied` | The world has not granted it | host | +| `bad_args` | Args fail the manifest schema, or the path is absolute, contains `..`, or is not a regular file beneath the root | host or client | +| `outside_ceiling` | Over the host rate or in-flight limit (`reason: rate`); see open question 2 | host | +| `provider_unavailable` | New. No session advertises the provider, the session closed, or the deadline passed | host | +| `client_denied` | New. The user has not opted in, revoked, or args exceed the client's bound (`reason: client`) | client | +| `failed` | The provider failed (I/O error) | client | + +A bound exceeded on the client is `client_denied` with `reason: client`, not +`outside_ceiling`, so a program can tell "the world forbids this" from "this +user said no" and show the user the right hint. `result_too_large` +(`world_caps.rs:324-326`) remains the host's last guard if a reply still +exceeds `MAX_EVENT_BYTES` (64 KiB, `world_caps.rs:64`). + +## Audit + +F12.6 defines the log format; F13.7 needs one provider-use event kind with +these fields, written on both sides: + +- `at` (unix ms), `emitter` (`host` or `client`), and `nonce` (the host request + nonce, the correlation key between the two logs). +- `world`, `provider`, `version`, `side`. +- `guest_request_id`, `participant` (acting participant id), `session`. +- `decision` (`allowed` or `refused`), `code`, `reason`, and `decided_by` + (`host` or `client`). +- `requested` and `effective` bounds, and `path` as the guest asked for it. +- `bytes` returned and `digest` (BLAKE3 of the returned content), never the + content itself. +- `duration_ms` from dispatch to reply, on the host. + +The host writes its entry to the F12.6 log at decision time and again at +completion (or timeout). The client writes to a local, user-readable audit log +using the same F12.6 types, at receipt and completion. A forged or unmatched +reply and a request the client refused are audited on the side that saw them. +F12.6's exit test replays the log to the final grants; the client log replays +to the user's final opt-in state in the same way. + +## Journaling and replay + +A client reply that feeds `update` is journaled; everything about getting it +is ephemeral. + +- **Journaled.** The result event, allowed or refused, as the existing + `JournalEntry::System` written by `deliver` after `update` accepts it + (`actor.rs:355`). No new journal entry kind is needed, so the journal format + does not change. Grants and ceilings are journaled as today (`CapsGranted`, + `CapsCeiling`). +- **Ephemeral.** The pending table, the nonce, the routing decision, the + advertisement, the deadline, retries, and the client's opt-in. None of these + enter the journal, consistent with the journaled-versus-ephemeral boundary + F13.1 writes down. + +Replay never contacts a client. Restore feeds journaled `System` entries back +into the program (`core.rs:297-319`), so a world replays identically when the +client is offline, uninstalled, or has since revoked its opt-in. Revocation +does not erase history; it stops future reads. + +On reopen the pending table is empty, and so is `handled_requests` +(`actor.rs:1381`). A client request still listed in `wants` is then seen again +with no acting participant and is refused with `actor_denied` (reason +`restarted`), and that refusal is journaled. A program that wants to retry +asks again under a fresh id on the participant's next input. This is the +at-least-once rule the actor already documents (`actor.rs:400-402`), made safe +for client providers by refusing rather than re-routing. + +## First provider slice + +**Recommendation: `client.file.read`, read-only, beneath a user-chosen root, +with a size cap.** + +- Args: a relative `path` and an optional `max_bytes`. The path is normalised + on the client; absolute paths, `..` components, and non-regular files are + refused. Resolution must stay beneath the root even through symlinks + (`openat2` with `RESOLVE_BENEATH` on Linux; elsewhere a canonicalise-and-check + with the race noted in "Security"). +- Reply: `{"bytes":n,"digest":"…","content_b64":"…"}`. Content is always base64 + so binary and text take one path and the JSON size is predictable. The + manifest cap is 32 KiB raw, about 43 KiB encoded, which leaves room under the + 64 KiB event bound for the envelope. +- Bounds: 10 reads per minute per world per session, 2 in flight. +- Local config: per world, a root and an on/off switch. Default off. + +Deferred: + +- **GPU buffer.** It has no meaningful bounded reply: its value is a handle + into the client's GPU, and the guest cannot hold a handle across the + host boundary without a new reference model (OCapN-like, see the attenuated + capabilities design). It is also only meaningful in a native shell (F13.6), + which does not exist yet. +- **Native notification.** It is an output, not a read, so its main risks are + spam and phishing-style text, which need the F12.5 `notify` rate and content + rules first. It returns nothing useful to the guest, so it does not exercise + the reply path that slice 1 must prove. It should follow F12.5 `notify` and + reuse its bounds. + +File read exercises every part of the design: arguments, two ceilings, a +bounded reply, journaling of returned data, and the most obvious attack +(a host asking for files). + +## Wire shape (future shell contract v2) + +The v1 shell contract is being written in parallel in this branch at +`pkgs/id/contract/shell-contract.v1.json`. This design does not change v1. +The frames below are a sketch of an additive v2 addition. New frame kinds are +forward-only for older binaries (#227) and wait for F11.12; a v1 shell never +receives them, because the host sends `provider_request` only to a session +that advertised providers, and only a v2 shell advertises. + +Client to server, at join and on every opt-in change (ephemeral): + +```json +{ "type": "providers", "offer": [{ "name": "client.file.read", "version": "1" }] } +``` + +Server to client: + +```json +{ + "type": "provider_request", + "nonce": "b3f1…", + "provider": "client.file.read", + "version": "1", + "args": { "path": "notes/todo.txt", "max_bytes": 4096 }, + "deadline_ms": 10000 +} +``` + +Client to server: + +```json +{ "type": "provider_reply", "nonce": "b3f1…", "ok": false, "error": "client_denied", "reason": "client" } +``` + +The nonce is minted by the host from the OS RNG; it is not the guest's request +id, so a guest cannot predict or collide with it. The offer lists only +providers opted in for the world being joined, so a host learns nothing about +opt-ins for other worlds. None of these frames is journaled, and none carries +a seed or host event past the F11.11 projection. + +## Security analysis + +- **Authority creep.** The host registry entry for a client provider has no + executor, and the host's executor refuses any `side: client` name, so a + client provider can never be run by the host. Default ceiling `excluded` + stops a binary upgrade from widening any world. The provider is read-only, + capped, and bounded to one root. No Wasm import is added: the guest + validator still rejects every import (test + `sandbox::tests::rejects_modules_with_host_imports`). +- **Confused deputy.** Routing goes only to the acting participant's + originating session, so input from B can never read A's files, and a + world-level request (a tick, a schedule) can read nobody's files. The + remaining deputy risk is the program asking for a file during A's own input + and showing it to everyone. That is bounded by A's choice of root and by the + opt-in text, which must say that content becomes part of the world. +- **A malicious host.** It can send `provider_request` frames that no guest + asked for, and it can lie about grants. The client therefore enforces its + own key completely: opt-in for this world, root, bounds, and rate, checked + on every frame. The worst a malicious host can do is read files beneath the + root the user chose for that host's world, at the client rate limit, with + each read in the user's local audit log. +- **A malicious guest.** It can only name a provider in `wants`. It is held by + the world grant and ceiling, the acting-participant rule, host-side rate and + in-flight limits, and then the client's key. It cannot address a session, + choose a nonce, or exceed the bounds. +- **Replay and exfiltration through the journal.** Returned content lives in + the journal (`JournalEntry::System`) and, after compaction, in the program + model inside a checkpoint. Anyone with the journal, a backup, or a fork + (`JournalEntry::Forked` copies entries) can read it, and future replication + (F12.13) would carry it further. This cannot be avoided without breaking + replay, because `update` consumed the bytes. Mitigations: the size cap, the + root, the audit digest, and opt-in text that names the host operator as able + to read content. Replay cannot cause a new read, because replay never + contacts a client. +- **Forged or stale replies.** A reply with an unknown, expired, or duplicate + nonce, from the wrong session, or over the bounds is dropped and audited. + The guest sees `provider_unavailable` at the deadline, never forged data. +- **Rate limits.** Enforced independently on both sides: the host limits so a + guest cannot flood a client, and the client limits so a host cannot. +- **Path races.** Without `RESOLVE_BENEATH`, a canonicalise-then-open check can + race a symlink swap inside the root. That needs write access inside the + user's own root, so it is a local-attacker risk; it is documented, and + platforms without a beneath-resolution primitive should open by file handle + and recheck. +- **Client-only mode.** Serving a provider does not make a client a server + (F13.8): requests arrive only over a session the client opened. + +## Test plan + +Each acceptance criterion of #789 maps to tests. + +| Acceptance criterion | Tests | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Added without editing `CATALOG` (F12.4 manifest shape) | A test client provider registered from a manifest in a test registry, with `CATALOG` and the actor's executor untouched; it appears in `CapsReport.catalog` and is accepted by `validate_grant_names`. A `git diff` check in the PR shows neither file edited. | +| Appears under a ceiling | Not in any ceiling by default (default `excluded`); after `SetCapsCeiling` and a grant it is in `CapsReport.effective`, and an opted-in fake shell receives `provider_request` and the program receives `ok: true`. | +| Refused when over it | One test per refusal: ceiling excludes it (`outside_ceiling`), not granted (`cap_denied`), no acting participant and missing scope (`actor_denied`), args over the world bound (`outside_ceiling`, `reason: world`), not opted in and over the client bound (`client_denied`), no session and deadline (`provider_unavailable`), `..` and absolute paths and a symlink out of the root (`bad_args`). Plus: grant-on-use never grants it; input from B never routes to A. | +| Leaves an audit entry (F12.6 contract) | Every case above writes a host entry with the listed fields; allowed and client-refused cases also write a client entry with the same nonce; no entry contains file content (scan for the bytes). | +| Adds no Wasm import | `sandbox::tests::rejects_modules_with_host_imports` still passes; the example program that uses the provider compiles to a module with zero imports, asserted by inspecting its import section. | +| Answers "who grants" | This document; tests for both keys and both revocations (admin revoke and user revoke each stop the next read and abort in-flight ones). | + +Also: replay of a journal containing a client result restores the same model +and view with no client connected; reopen with a pending request journals the +`actor_denied` (`restarted`) refusal; a forged and a duplicate nonce are +dropped; host and client rate limits each refuse independently; a v1 shell +never receives a provider frame. + +## Dependencies and what unblocks implementation + +- **F12.4 provider registry (blocking).** Needs: the manifest fields named + above plus `side` (or an extension field that holds it); a per-manifest + default ceiling with an `excluded` value; the ledger, `validate_grant_names`, + `validate_ceiling_names`, and `CapsReport` reading the registry instead of + `CATALOG`; the actor dispatching by registry entry instead of the `match` at + `actor.rs:538`; argument-schema and bounds validation the host can run + before routing. +- **F12.6 audit (blocking).** Needs: a provider-use event kind with the fields + above; an `emitter` field and a correlation key; an append-only sink usable + from the client binary, not only the host; the replay-to-final-state test + pattern. +- **F11.12 journal format (blocking for frames only).** The plan's Risks rule + says new frame kinds wait for F11.12. No new journal entry kind is needed. +- **F11.13 error taxonomy.** The two new codes are added through it, with the + guide updated. +- **F13.1 shell contract.** v2 adds the three frames; v1 is untouched. +- **Session attribution.** `WorldCommand::Input` carries a token, not a session + (`actor.rs:1029-1033`). Routing to the originating session needs a session + id carried with the input. This is small and can land with the + implementation. + +Implementation can start when F12.4 and F12.6 have merged and their exit tests +pass. The pure parts (manifest, two-key decision, bounds intersection, path +normalisation) can be built and tested first; the frames follow F11.12. + +## Open questions + +1. **An `ask` mode.** Should the client key support a per-request prompt + besides on and off? It is the strongest defence against a malicious host, + but a prompt in a raw-mode terminal shell fights the world's own rendering. + Recommend: on and off in slice 1; `ask` in the native and web shells first. +2. **`rate_limited` as its own code.** Reusing `outside_ceiling` with + `reason: rate` follows the "reuse before adding" rule in + `world_caps.rs:353-357`, but rate refusals are transient and a program + would retry them, unlike a ceiling. Recommend: decide in F11.13 for host + and client providers together (F12.5 `http.fetch` and `notify` need it too). +3. **Several sessions for one participant.** Slice 1 routes only to the + originating session. A participant with a terminal and a native shell open + might want the native one to serve. Recommend: originating session only + until F13.8 defines multi-session identity. +4. **Content in the journal.** Should a world be able to declare that it keeps + only the digest and discards content after `update`? That breaks replay as + defined today; it is worth revisiting only with F12.13. +5. **Server ceiling.** Should `--world-allowed-caps` default to excluding + every `side: client` name, so a server operator must also opt in? Recommend + yes, matching `default_ceiling: excluded`. + +## References + +- [Phase 13 plan](../../.opencode/plans/phase-13-client-shells.md) (F13.7, + open question 3) +- [Phase 12 plan](../../.opencode/plans/phase-12-platform-flexibility.md) + (F12.4, F12.5, F12.6, F12.14) +- [Phase 11 plan](../../.opencode/plans/phase-11-foundations.md) (F11.11, + F11.12, F11.13) +- [World capability ceilings](../2026-10-16T11-00-00Z_feature_world_capability_ceilings/2026-10-16T11-00-00Z_feature_world_capability_ceilings.md) +- [Attenuated capabilities](../2026-10-09T09-59-07Z_design_attenuated_capabilities/2026-10-09T09-59-07Z_design_attenuated_capabilities.md) +- Issues: #789 (this task), #227 (forward-only journal compatibility), #564 + (Phase 13 plan), #534 (Phase 12 tracking) +- Code: `pkgs/id/src/world_caps.rs`, `pkgs/id/src/world/actor.rs`, + `pkgs/id/src/world/core.rs`, `pkgs/id/src/world/mod.rs`, + `pkgs/id/src/world_session/types.rs`, `pkgs/id/src/sandbox.rs` +- The v1 shell contract: `pkgs/id/contract/shell-contract.v1.json` From 1f2e3e43def72608b625264ea36bdf75b0807779 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 25 Oct 2026 01:04:16 -0500 Subject: [PATCH 3/6] test(id-web): the browser shell passes the shell contract unchanged (F13.1) A TypeScript port of the contract check (web/src/shell-contract.ts) and a vitest that drives the unmodified world.ts over a fake WebSocket: join, resize, input, paste, chat, paged records, module upload and compile. Every client and server frame goes through a Conformance check. The page markup is read from the real template. Also clarify that a shell may send joined-phase frames before the snapshot arrives. Refs #783 --- ...0-25T05-54-40Z_reference_shell_contract.md | 9 +- pkgs/id/contract/shell-contract.v1.json | 2 +- pkgs/id/web/src/shell-contract.ts | 288 +++++++++++++++++ pkgs/id/web/src/world-contract.test.ts | 306 ++++++++++++++++++ 4 files changed, 601 insertions(+), 4 deletions(-) create mode 100644 pkgs/id/web/src/shell-contract.ts create mode 100644 pkgs/id/web/src/world-contract.test.ts diff --git a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md index 08a18a915..0e59f6e46 100644 --- a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md +++ b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md @@ -86,9 +86,12 @@ the phase it `opens`: the session (**closed**). - **query.** Further query frames, or `join`. - **install.** `install_chunk` frames, then `install_end`. -- **joined.** The participant's live session: `chat`, `input`, `paste`, - `resize`, `records`, `info`, `records_ticket`, `caps`, delegation and the - admin capability and profile frames. Every frame keeps the session joined. +- **joined.** From the frame after a `join`. A shell may send joined-phase + frames, such as its first `resize`, before the snapshot arrives: the host + reads them once it has answered the join. The browser does exactly this. + Joined-phase frames are `chat`, `input`, `paste`, `resize`, `records`, + `info`, `records_ticket`, `caps`, the delegation frames, and the admin + capability and profile frames. Every frame keeps the session joined. Before a join, an `error` reply ends the session. After a join, most errors are answers, and the session continues. diff --git a/pkgs/id/contract/shell-contract.v1.json b/pkgs/id/contract/shell-contract.v1.json index 9d593c125..77b3e1ae2 100644 --- a/pkgs/id/contract/shell-contract.v1.json +++ b/pkgs/id/contract/shell-contract.v1.json @@ -26,7 +26,7 @@ "opening": "The session's first frame. It may carry the `world` routing field; absent means the host's default world.", "query": "After an opening query frame: further query frames, or a join.", "install": "After install_begin was answered with upload_ready: chunks, then install_end.", - "joined": "After a join was answered with a snapshot or events. Every frame keeps the session joined." + "joined": "From the frame after a join. A shell may send joined-phase frames (such as its first resize) before the snapshot arrives; the host reads them once the join is answered. Every frame keeps the session joined." }, "classes": { "event": "Becomes one committed world event: journaled, replayed, and delivered to sessions through the client projection.", diff --git a/pkgs/id/web/src/shell-contract.ts b/pkgs/id/web/src/shell-contract.ts new file mode 100644 index 000000000..0433634d9 --- /dev/null +++ b/pkgs/id/web/src/shell-contract.ts @@ -0,0 +1,288 @@ +/** + * The versioned shell contract (F13.1), checked from the browser side. + * + * A TypeScript port of `src/shell_contract.rs`: it reads + * `contract/shell-contract.v1.json` and checks client and server frames, and + * whole sessions, against it. Strict about what is sent: a frame type, field, + * or phase the contract does not name is a violation. + */ + +/** A point in a session's lifecycle. */ +export type Phase = "opening" | "query" | "install" | "joined" | "closed"; + +type FieldType = "string" | "hex" | "bool" | "u16" | "u32" | "u64" | "string[]" | "array" | "object"; + +type RawField = + | string + | { + type: FieldType; + optional?: boolean; + nullable?: boolean; + min?: number; + max?: number; + max_bytes?: number; + enum?: string[]; + }; + +type RawFrame = { + class: string; + phases?: Phase[]; + opens?: Phase; + forbidden_keys?: string[]; + fields: Record; +}; + +/** The parts of the contract document the checks read. */ +export type ShellContract = { + contract: string; + version: number; + revision: number; + max_frame_bytes: number; + routing_fields: string[]; + client_frames: Record; + server_frames: Record; + projection: { redacted_keys: string[] }; +}; + +type Field = { + kind: FieldType; + optional: boolean; + nullable: boolean; + min?: number; + max?: number; + maxBytes?: number; + allowed?: string[]; +}; + +/** The result of checking one frame. */ +export type Check = { ok: true; type: string } | { ok: false; error: string }; + +const FIELD_TYPES = new Set(["string", "hex", "bool", "u16", "u32", "u64", "string[]", "array", "object"]); +const INTEGER_MAX: Partial> = { + u16: 0xffff, + u32: 0xffff_ffff, + u64: Number.MAX_SAFE_INTEGER, +}; +const encoder = new TextEncoder(); +const byteLength = (text: string): number => encoder.encode(text).length; + +function parseField(raw: RawField): Field { + if (typeof raw === "string") { + const optional = raw.endsWith("?"); + const kind = optional ? raw.slice(0, -1) : raw; + if (!FIELD_TYPES.has(kind)) throw new Error(`unknown field type \`${kind}\``); + return { kind: kind as FieldType, optional, nullable: optional }; + } + if (!FIELD_TYPES.has(raw.type)) throw new Error(`unknown field type \`${raw.type}\``); + const optional = raw.optional ?? false; + return { + kind: raw.type, + optional, + nullable: raw.nullable ?? optional, + min: raw.min, + max: raw.max, + maxBytes: raw.max_bytes, + allowed: raw.enum, + }; +} + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isInteger = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value); + +function lookup( + contract: ShellContract, + frame: unknown, + table: Record, + side: string, +): { ok: true; kind: string; spec: RawFrame; object: Record } | { ok: false; error: string } { + const encoded = byteLength(JSON.stringify(frame) ?? ""); + if (encoded > contract.max_frame_bytes) { + return { ok: false, error: `frame of ${encoded} bytes exceeds ${contract.max_frame_bytes}` }; + } + if (!isObject(frame)) return { ok: false, error: "a frame must be a JSON object" }; + const kind = frame.type; + if (typeof kind !== "string") return { ok: false, error: "a frame needs a string `type`" }; + const spec = Object.hasOwn(table, kind) ? table[kind] : undefined; + if (!spec) return { ok: false, error: `\`${kind}\` is not a ${side} frame in the contract` }; + return { ok: true, kind, spec, object: frame }; +} + +function checkValue(field: Field, value: unknown): string | null { + const typeMax = INTEGER_MAX[field.kind]; + if (typeMax !== undefined) { + if (!isInteger(value) || value < 0) return "must be a non-negative integer"; + const max = Math.min(field.max ?? typeMax, typeMax); + const min = field.min ?? 0; + if (value < min || value > max) return `must be in ${min}..=${max}, got ${value}`; + return null; + } + switch (field.kind) { + case "string": + case "hex": { + if (typeof value !== "string") return "must be a string"; + if (field.kind === "hex" && (value.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(value))) { + return "must be an even number of hex digits"; + } + const bytes = byteLength(value); + if (field.maxBytes !== undefined && bytes > field.maxBytes) { + return `is ${bytes} bytes, more than ${field.maxBytes}`; + } + if (field.allowed && !field.allowed.includes(value)) return `must be one of ${field.allowed.join(", ")}`; + return null; + } + case "bool": + return typeof value === "boolean" ? null : "must be a boolean"; + case "string[]": + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? null + : "must be an array of strings"; + case "array": + return Array.isArray(value) ? null : "must be an array"; + case "object": + return isObject(value) ? null : "must be an object"; + default: + return null; + } +} + +function checkFields( + kind: string, + object: Record, + spec: RawFrame, + routing: readonly string[], +): string | null { + for (const [key, value] of Object.entries(object)) { + if (key === "type") continue; + if (routing.includes(key)) { + if (!(value === null || typeof value === "string")) return `routing field \`${key}\` must be a string`; + continue; + } + if (!Object.hasOwn(spec.fields, key)) return `\`${kind}\` has no field \`${key}\``; + } + for (const [name, raw] of Object.entries(spec.fields)) { + const field = parseField(raw); + const present = Object.hasOwn(object, name) && object[name] !== undefined; + const value = present ? object[name] : undefined; + if (!present && field.optional) continue; + if (value === null && field.nullable) continue; + if (!present || value === null) return `\`${kind}\` needs field \`${name}\``; + const error = checkValue(field, value); + if (error) return `\`${kind}.${name}\` ${error}`; + } + return null; +} + +function namesKey(value: unknown, key: string): boolean { + if (Array.isArray(value)) return value.some((item) => namesKey(item, key)); + if (isObject(value)) return Object.entries(value).some(([name, inner]) => name === key || namesKey(inner, key)); + return false; +} + +/** The first value of `key`, anywhere in `value`, that is not zero or null. */ +function nonzeroKey(value: unknown, key: string): { found: unknown } | null { + if (Array.isArray(value)) { + for (const item of value) { + const hit = nonzeroKey(item, key); + if (hit) return hit; + } + return null; + } + if (isObject(value)) { + for (const [name, inner] of Object.entries(value)) { + if (name === key && inner !== 0 && inner !== null) return { found: inner }; + const hit = nonzeroKey(inner, key); + if (hit) return hit; + } + } + return null; +} + +/** Check one client frame sent in `phase`. */ +export function checkClientFrame(contract: ShellContract, frame: unknown, phase: Phase): Check { + const found = lookup(contract, frame, contract.client_frames, "client"); + if (!found.ok) return found; + const { kind, spec, object } = found; + if (!(spec.phases ?? []).includes(phase)) { + return { ok: false, error: `\`${kind}\` may not be sent in the ${phase} phase` }; + } + for (const key of spec.forbidden_keys ?? []) { + if (namesKey(object, key)) return { ok: false, error: `\`${kind}\` may not carry \`${key}\` anywhere` }; + } + const error = checkFields(kind, object, spec, contract.routing_fields); + return error ? { ok: false, error } : { ok: true, type: kind }; +} + +/** Check one server frame, including that no redacted key leaks. */ +export function checkServerFrame(contract: ShellContract, frame: unknown): Check { + const found = lookup(contract, frame, contract.server_frames, "server"); + if (!found.ok) return found; + const { kind, spec, object } = found; + const error = checkFields(kind, object, spec, []); + if (error) return { ok: false, error }; + for (const key of contract.projection.redacted_keys) { + const leak = nonzeroKey(object, key); + if (leak) { + return { + ok: false, + error: `\`${kind}\` leaks \`${key}\` (${JSON.stringify(leak.found)}) past the client projection`, + }; + } + } + return { ok: true, type: kind }; +} + +/** One contract violation in a session. */ +export type Violation = { index: number; side: "client" | "server"; message: string }; + +/** Checks one session's frames in order, following its phase. */ +export class Conformance { + #phase: Phase = "opening"; + #index = 0; + readonly violations: Violation[] = []; + + constructor(private readonly contract: ShellContract) {} + + /** The session's current phase. */ + get phase(): Phase { + return this.#phase; + } + + /** Record a frame the shell sent. */ + client(frame: unknown): void { + if (this.#phase === "closed") { + this.#violate("client", "the host already ended this session"); + } else { + const check = checkClientFrame(this.contract, frame, this.#phase); + if (check.ok) { + const opens = this.contract.client_frames[check.type]?.opens; + if (this.#phase === "opening") this.#phase = opens ?? "closed"; + else if (this.#phase === "query" && check.type === "join") this.#phase = "joined"; + else if (this.#phase === "install" && check.type === "install_end") this.#phase = "closed"; + } else { + this.#violate("client", check.error); + } + } + this.#index += 1; + } + + /** Record a frame the host sent. */ + server(frame: unknown): void { + const check = checkServerFrame(this.contract, frame); + if (!check.ok) this.#violate("server", check.error); + // Before a join, an error ends the session. + if ( + isObject(frame) && + frame.type === "error" && + (this.#phase === "opening" || this.#phase === "query" || this.#phase === "install") + ) { + this.#phase = "closed"; + } + this.#index += 1; + } + + #violate(side: Violation["side"], message: string): void { + this.violations.push({ index: this.#index, side, message }); + } +} diff --git a/pkgs/id/web/src/world-contract.test.ts b/pkgs/id/web/src/world-contract.test.ts new file mode 100644 index 000000000..200e1ddb2 --- /dev/null +++ b/pkgs/id/web/src/world-contract.test.ts @@ -0,0 +1,306 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { Conformance, checkClientFrame, checkServerFrame, type ShellContract } from "./shell-contract"; + +const here = dirname(fileURLToPath(import.meta.url)); +const contract = JSON.parse( + readFileSync(resolve(here, "../../contract/shell-contract.v1.json"), "utf8"), +) as ShellContract; + +/** The world page markup, read from the Rust template so the test cannot drift from it. */ +function worldPageMarkup(): string { + const source = readFileSync(resolve(here, "../../src/web/templates.rs"), "utf8"); + const start = source.indexOf("pub fn render_world_page"); + const open = source.indexOf('r#"', start); + const close = source.indexOf('"#;', open); + if (start < 0 || open < 0 || close < 0) throw new Error("world page template not found in templates.rs"); + return source.slice(open + 3, close); +} + +describe("shell contract validator", () => { + const cap = "cap"; + + it("accepts the frames a joined shell sends", () => { + expect(checkClientFrame(contract, { type: "join", capability: cap, after: null, world: null }, "opening")).toEqual({ + ok: true, + type: "join", + }); + expect(checkClientFrame(contract, { type: "resize", width: 500, height: 200 }, "joined").ok).toBe(true); + expect(checkClientFrame(contract, { type: "input", data_hex: "1b5b41" }, "joined").ok).toBe(true); + expect(checkClientFrame(contract, { type: "records", capability: cap, after: "k" }, "joined").ok).toBe(true); + expect(checkServerFrame(contract, { type: "view", sequence: 1, data: "x", surface: null }).ok).toBe(true); + }); + + it.each([ + ["an unknown type", { type: "teleport" }, "joined", "is not a client frame"], + ["a missing field", { type: "chat" }, "joined", "needs field `text`"], + ["an unknown field", { type: "chat", text: "hi", loud: true }, "joined", "has no field `loud`"], + ["a resize wider than 500", { type: "resize", width: 501, height: 10 }, "joined", "must be in 1..=500"], + ["non-hex input", { type: "input", data_hex: "zz" }, "joined", "even number of hex digits"], + ["odd-length hex input", { type: "input", data_hex: "abc" }, "joined", "even number of hex digits"], + [ + "`uses` in attenuate", + { type: "attenuate", name: "n", scopes: ["chat"], extra: { uses: 3 } }, + "joined", + "may not carry `uses`", + ], + ["chat before a join", { type: "chat", text: "hi" }, "opening", "may not be sent in the opening phase"], + ["a non-string routing field", { type: "chat", text: "hi", world: 3 }, "joined", "routing field `world`"], + ["a paste over 4096 bytes", { type: "paste", text: "é".repeat(2049) }, "joined", "more than 4096"], + [ + "a role outside the enum", + { type: "invite", admin_token: "a", display_name: "d", role: "god" }, + "opening", + "one of", + ], + ] as const)("rejects %s", (_name, frame, phase, error) => { + const check = checkClientFrame(contract, frame, phase); + expect(check.ok).toBe(false); + if (!check.ok) expect(check.error).toContain(error); + }); + + it("rejects a seed leaked at any depth of a server frame", () => { + const leak = checkServerFrame(contract, { type: "event", event: { kind: { program: { seed: 7 } } } }); + expect(leak).toEqual({ ok: false, error: expect.stringContaining("leaks `seed` (7)") }); + expect(checkServerFrame(contract, { type: "event", event: { kind: { seed: 0 } } }).ok).toBe(true); + expect(checkServerFrame(contract, { type: "records", sequence: 1, records: {}, extra: 1 }).ok).toBe(false); + }); + + it("follows session phases", () => { + const query = new Conformance(contract); + query.client({ type: "info", capability: cap }); + expect(query.phase).toBe("query"); + query.client({ type: "join", capability: cap }); + expect(query.phase).toBe("joined"); + query.client({ type: "chat", text: "hi" }); + expect(query.violations).toEqual([]); + + const closed = new Conformance(contract); + closed.client({ type: "caps", capability: cap }); + expect(closed.phase).toBe("closed"); + closed.client({ type: "caps", capability: cap }); + expect(closed.violations).toEqual([{ index: 1, side: "client", message: "the host already ended this session" }]); + + const install = new Conformance(contract); + install.client({ type: "install_begin", admin_token: "a", total_bytes: 1, seed: 1 }); + expect(install.phase).toBe("install"); + install.client({ type: "install_chunk", offset: 0, data_hex: "00" }); + install.client({ type: "install_end" }); + expect(install.phase).toBe("closed"); + expect(install.violations).toEqual([]); + + const refused = new Conformance(contract); + refused.client({ type: "records", capability: cap }); + refused.server({ type: "error", message: "no" }); + expect(refused.phase).toBe("closed"); + refused.client({ type: "join", capability: cap }); + expect(refused.violations).toHaveLength(1); + }); +}); + +type Entry = { from: "client" | "server"; frame: unknown }; + +/** A WebSocket stand-in that records what the shell sends and lets the test answer. */ +class FakeSocket extends EventTarget { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static readonly all: FakeSocket[] = []; + + readyState = FakeSocket.CONNECTING; + bufferedAmount = 0; + onclose: ((event: Event) => void) | null = null; + readonly transcript: Entry[] = []; + + constructor(readonly url: string) { + super(); + FakeSocket.all.push(this); + } + + send(data: string): void { + if (this.readyState !== FakeSocket.OPEN) throw new Error(`send on a socket in state ${this.readyState}`); + this.transcript.push({ from: "client", frame: JSON.parse(data) }); + } + + close(): void { + if (this.readyState === FakeSocket.CLOSED) return; + this.readyState = FakeSocket.CLOSED; + this.dispatchEvent(new Event("close")); + } + + open(): void { + this.readyState = FakeSocket.OPEN; + this.dispatchEvent(new Event("open")); + } + + deliver(frame: Record): void { + this.transcript.push({ from: "server", frame }); + this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(frame) })); + } + + sent(type: string): Array> { + return this.transcript + .filter((entry) => entry.from === "client" && (entry.frame as { type?: string }).type === type) + .map((entry) => entry.frame as Record); + } +} + +class FakeResizeObserver { + static readonly all: FakeResizeObserver[] = []; + constructor(readonly callback: ResizeObserverCallback) { + FakeResizeObserver.all.push(this); + } + observe(): void {} + unobserve(): void {} + disconnect(): void {} + fire(): void { + this.callback([], this as unknown as ResizeObserver); + } +} + +function setFiles(input: HTMLInputElement, files: File[]): void { + Object.defineProperty(input, "files", { configurable: true, value: files }); +} + +describe("web shell conformance", () => { + const realWebSocket = globalThis.WebSocket; + const realResizeObserver = globalThis.ResizeObserver; + let root: HTMLElement; + const q = (selector: string): T => { + const found = root.querySelector(selector); + if (!found) throw new Error(`missing ${selector}`); + return found; + }; + + beforeAll(async () => { + vi.stubGlobal("WebSocket", FakeSocket); + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + sessionStorage.clear(); + document.body.innerHTML = worldPageMarkup(); + await import("./world"); + }); + + afterAll(() => { + vi.stubGlobal("WebSocket", realWebSocket); + vi.stubGlobal("ResizeObserver", realResizeObserver); + document.body.innerHTML = ""; + }); + + it("sends only contract frames, in the right phases, across every flow", async () => { + root = document.querySelector("[data-world-app]") as HTMLElement; + expect(root.dataset.initialized).toBe("true"); + + // Join. + q("[data-world-capability]").value = "guest-capability"; + q("[data-world-join]").click(); + const session = FakeSocket.all.at(-1) as FakeSocket; + expect(session.url).toMatch(/\/ws\/world$/); + session.open(); + expect(session.sent("join")).toHaveLength(1); + expect(session.sent("resize")).toHaveLength(1); + + // Snapshot and view; the snapshot asks for records, which page. + session.deliver({ + type: "snapshot", + snapshot: { world_id: "lobby", current_sequence: 4, participants: [{ id: 1, display_name: "ada" }], seed: 0 }, + }); + session.deliver({ type: "view", sequence: 4, data: "hello", surface: null }); + expect(q("[data-world-view]").textContent).toBe("hello"); + expect(session.sent("records")).toEqual([{ type: "records", capability: "guest-capability" }]); + session.deliver({ type: "records", sequence: 4, records: { a: 1 }, next_after: "a" }); + expect(session.sent("records").at(-1)).toEqual({ type: "records", capability: "guest-capability", after: "a" }); + session.deliver({ type: "records", sequence: 4, records: { b: 2 }, next_after: null }); + expect(JSON.parse(q("[data-world-records]").textContent ?? "")).toEqual({ a: 1, b: 2 }); + + // Resize, keys, paste, chat, input line, records refresh. + for (const observer of FakeResizeObserver.all) observer.fire(); + const view = q("[data-world-view]"); + view.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true, cancelable: true })); + view.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true, cancelable: true })); + const paste = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(paste, "clipboardData", { value: { getData: () => "pasted text" } }); + view.dispatchEvent(paste); + q("[data-world-chat]").value = "hi all"; + q("[data-world-chat-form]").dispatchEvent(new Event("submit", { cancelable: true })); + q("[data-world-input-form]").dispatchEvent(new Event("submit", { cancelable: true })); + session.deliver({ + type: "event", + event: { sequence: 5, participant_id: 1, kind: { kind: "chat", data: "hi all" } }, + }); + session.deliver({ + type: "event", + event: { sequence: 6, participant_id: 1, kind: { kind: "input", data: [97] } }, + }); + session.deliver({ type: "records", sequence: 6, records: {}, next_after: null }); + q("[data-world-records-refresh]").click(); + session.deliver({ type: "records", sequence: 6, records: {} }); + + expect(session.sent("input").map((frame) => frame.data_hex)).toEqual(["61", "1b5b41", "696e63"]); + expect(session.sent("paste")).toEqual([{ type: "paste", text: "pasted text" }]); + expect(session.sent("chat")).toEqual([{ type: "chat", text: "hi all" }]); + + // Module install over its own session. + q("[data-world-admin]").value = "admin-secret"; + setFiles(q("[data-world-module]"), [ + new File([new Uint8Array(9000).fill(7)], "game.wasm", { type: "application/wasm" }), + ]); + q("[data-world-install]").click(); + const upload = FakeSocket.all.at(-1) as FakeSocket; + expect(upload).not.toBe(session); + upload.open(); + upload.deliver({ type: "upload_ready", chunk_bytes: 6144 }); + await vi.waitFor(() => expect(upload.sent("install_end")).toHaveLength(1)); + expect(upload.sent("install_chunk").map((frame) => frame.offset)).toEqual([0, 6144]); + upload.deliver({ type: "module_installed", module_hash: "abc", sequence: 7 }); + expect(upload.readyState).toBe(FakeSocket.CLOSED); + + // Roc compile over its own session. + q("[data-world-admin]").value = "admin-secret"; + setFiles(q("[data-world-source]"), [ + new File(["app [main!] {}"], "main.roc"), + new File(["module []"], "Util.roc"), + ]); + q("[data-world-compile]").click(); + await vi.waitFor(() => expect(FakeSocket.all.at(-1)).not.toBe(upload)); + const compiler = FakeSocket.all.at(-1) as FakeSocket; + compiler.open(); + compiler.deliver({ type: "compiled", module_hash: "def", sequence: 8, diagnostics: "" }); + expect(compiler.readyState).toBe(FakeSocket.CLOSED); + + // Every recorded session, checked against the contract in order. + const exercised = new Set(); + const report: string[] = []; + for (const [number, socket] of FakeSocket.all.entries()) { + const check = new Conformance(contract); + for (const entry of socket.transcript) { + if (entry.from === "client") { + check.client(entry.frame); + exercised.add((entry.frame as { type: string }).type); + } else { + check.server(entry.frame); + } + } + for (const violation of check.violations) { + report.push(`socket ${number} frame ${violation.index} (${violation.side}): ${violation.message}`); + } + } + expect(report).toEqual([]); + expect([...exercised].sort()).toEqual( + [ + "chat", + "compile", + "input", + "install_begin", + "install_chunk", + "install_end", + "join", + "paste", + "records", + "resize", + ].sort(), + ); + }); +}); From 51b3b36141fb9c7e6557286f06fc5e7d39266bd7 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 25 Oct 2026 03:19:40 -0500 Subject: [PATCH 4/6] feat(id): SSH exec transport for the frame protocol (F13.2) `id world join --via ssh [user@]host[:port]` replays the same session as the iroh path, byte-for-byte, over an SSH exec channel (`id-world/1`) for constrained networks and jump hosts. - world_ssh: exec_request now accepts only `id-world/1`, one session per connection, and hands the channel's bytes to run_session through SshFrameIo (cancel-safe recv over FrameBuffer). A flooded backlog ends the session instead of dropping framing bytes. - Client: connect_frames + SshTarget + HostKeyCheck pin the host key by SHA256 fingerprint (--ssh-host-key) or known_hosts; no TOFU. The SSH login grants nothing; frames carry their own capability. - frames: encode_frame is the single envelope writer; write_frame_to / read_frame_from / FrameBuffer serve non-QUIC byte streams. - world_net::route_frame is shared, so client frames are identical bytes on both transports. - Tests: lockstep iroh-vs-SSH session equality on one world, refusal of other commands/untrusted keys/oversized frames, journal untouched by the transport, plus an end-to-end CLI join over SSH. Refs #784 --- ...0-25T05-54-40Z_reference_shell_contract.md | 21 + pkgs/id/src/cli.rs | 20 + pkgs/id/src/commands/world.rs | 101 ++- pkgs/id/src/frames.rs | 176 ++++- pkgs/id/src/world_net.rs | 34 +- pkgs/id/src/world_ssh.rs | 630 +++++++++++++++++- pkgs/id/tests/cli_integration.rs | 109 +++ 7 files changed, 1051 insertions(+), 40 deletions(-) diff --git a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md index 0e59f6e46..92b2349b3 100644 --- a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md +++ b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md @@ -124,6 +124,27 @@ the test demands. Focus is not in version 1. A focus frame is reserved for the presentation work (F13.4/F13.5), behind the F11.12 gate. +## SSH as a transport (F13.2) + +`id world join --via ssh [user@]host[:port]` runs the same session over an +SSH exec channel instead of iroh (`pkgs/id/src/world_ssh.rs`, +`connect_frames`). The host's world SSH server accepts one exec command, +`id-world/1`; the channel then carries the identical length-prefixed JSON +frames as the `/id-world/1` QUIC stream (`pkgs/id/src/frames.rs`, +`encode_frame`). Unwrapping either envelope yields the same bytes — the test +`an_exec_channel_carries_the_same_frames_as_the_iroh_stream` drives one +world over both transports in lockstep and compares raw frame bodies. The +transport never touches the journal: only the `chat`/`input` frames inside +it commit events, exactly as on iroh. + +Trust and authority stay separate. The client pins the SSH host key (the +`SHA256:` fingerprint `id serve` prints, `--ssh-host-key`, or an entry in +`~/.ssh/known_hosts`); an unknown key is refused with its fingerprint — +there is no trust-on-first-use. The SSH login itself grants nothing on the +exec channel: the capability or admin secret inside the frames is the only +authority, as on iroh. Other exec commands, shells after an exec session, +and oversized frame lengths are refused. + ## The client projection Every server frame is built from F11.11's single client projection. The diff --git a/pkgs/id/src/cli.rs b/pkgs/id/src/cli.rs index c0a5ca8fb..4c89d408e 100644 --- a/pkgs/id/src/cli.rs +++ b/pkgs/id/src/cli.rs @@ -1341,6 +1341,16 @@ pub enum GroupCommand { }, } +/// How `id world join` reaches a world host. +#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WorldVia { + /// An iroh QUIC stream to the host's node ID. + #[default] + Iroh, + /// An exec channel on the host's world SSH server. + Ssh, +} + /// The kinds of group `id group create` makes. #[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] pub enum GroupTemplate { @@ -1525,6 +1535,16 @@ pub enum WorldCommand { /// `/quit`, ...) instead of sending every line as chat. #[arg(long)] command_mode: bool, + /// How to reach the host: `iroh` (NODE is a node ID) or `ssh` (NODE + /// is `[user@]host[:port]` of the host's world SSH server, and the + /// frames travel over an SSH exec channel, for networks and jump + /// hosts that only pass SSH). + #[arg(long, value_enum, default_value_t = WorldVia::Iroh)] + via: WorldVia, + /// With `--via ssh`: the host key fingerprint `id serve` prints + /// (`SHA256:...`). Without it, the key must be in ~/.ssh/known_hosts. + #[arg(long, value_name = "FINGERPRINT")] + ssh_host_key: Option, /// World on the host to use (default: the host's default world). #[arg(long, env = "ID_WORLD")] world: Option, diff --git a/pkgs/id/src/commands/world.rs b/pkgs/id/src/commands/world.rs index 52a443ac9..c81b77e44 100644 --- a/pkgs/id/src/commands/world.rs +++ b/pkgs/id/src/commands/world.rs @@ -17,8 +17,8 @@ use tokio::io::{AsyncBufReadExt, BufReader}; use crate::{ CLIENT_KEY_FILE, - cli::WorldCommand, - frames::{read_frame, write_frame}, + cli::{WorldCommand, WorldVia}, + frames::{read_frame_from, write_frame_to}, is_node_id, load_or_create_keypair, world_net::WorldClient, }; @@ -692,22 +692,75 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { world, addrs, no_relay, + via, + ssh_host_key, } => { - let (endpoint, mut client) = connect(&node, &addrs, no_relay, world.as_deref()).await?; - client - .send_json(&serde_json::json!({ - "type": "join", - "capability": capability, - "after": after, - })) - .await?; - let result = pump(client, command_mode, &capability).await; - endpoint.close().await; - result + let join = serde_json::json!({ + "type": "join", + "capability": capability, + "after": after, + }); + // The same routed bytes on every transport. + let first = crate::world_net::route_frame(&join, world.as_deref()); + match via { + WorldVia::Iroh => { + if ssh_host_key.is_some() { + bail!("--ssh-host-key needs --via ssh"); + } + let (endpoint, client) = + connect(&node, &addrs, no_relay, world.as_deref()).await?; + let (conn, send, recv) = client.into_parts(); + let result = pump(recv, send, &first, command_mode, &capability).await; + conn.close(0_u32.into(), b"done"); + endpoint.close().await; + result + } + WorldVia::Ssh => { + if !addrs.is_empty() || no_relay { + bail!("--addr and --no-relay apply to --via iroh only"); + } + join_over_ssh(&node, ssh_host_key, &first, command_mode, &capability).await + } + } } } } +/// `id world join --via ssh`: the frame session over an SSH exec channel. +#[cfg(feature = "ssh")] +async fn join_over_ssh( + target: &str, + ssh_host_key: Option, + first: &str, + command_mode: bool, + capability: &str, +) -> Result<()> { + use crate::world_ssh::{HostKeyCheck, SshTarget, connect_frames}; + let target: SshTarget = target.parse()?; + let check = match ssh_host_key { + Some(fingerprint) => HostKeyCheck::Fingerprint(fingerprint), + None => { + let home = std::env::var_os("HOME") + .context("no HOME for ~/.ssh/known_hosts; pass --ssh-host-key")?; + HostKeyCheck::KnownHosts(PathBuf::from(home).join(".ssh").join("known_hosts")) + } + }; + let (_connection, stream) = connect_frames(&target, check).await?; + let (recv, send) = tokio::io::split(stream); + pump(recv, send, first, command_mode, capability).await +} + +#[cfg(not(feature = "ssh"))] +async fn join_over_ssh( + _target: &str, + _ssh_host_key: Option, + _first: &str, + _command_mode: bool, + _capability: &str, +) -> Result<()> { + bail!("this build of id has no SSH support (cargo feature `ssh`)") +} + /// Parse a `world.toml` file into a validated manifest. fn parse_manifest_file(file: &std::path::Path) -> Result { let text = std::fs::read_to_string(file) @@ -1213,10 +1266,23 @@ impl Replica { /// Print server frames as JSON lines while sending stdin lines as chat. /// Notes for the user go to stderr, so stdout stays a stream of frames. -async fn pump(client: WorldClient, command_mode: bool, capability: &str) -> Result<()> { - let (conn, mut send, mut recv) = client.into_parts(); +/// +/// The transport is any byte stream carrying the length-prefixed envelope +/// (an iroh stream or an SSH exec channel); `first` is the join frame. +async fn pump( + mut recv: R, + mut send: W, + first: &str, + command_mode: bool, + capability: &str, +) -> Result<()> +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, + W: tokio::io::AsyncWrite + Unpin, +{ + write_frame_to(&mut send, first.as_bytes()).await?; let mut reader = tokio::spawn(async move { - while let Some(body) = read_frame(&mut recv).await? { + while let Some(body) = read_frame_from(&mut recv).await? { println!("{}", String::from_utf8_lossy(&body)); } anyhow::Ok(()) @@ -1244,7 +1310,7 @@ async fn pump(client: WorldClient, command_mode: bool, capability: &str) -> Resu None => serde_json::json!({"type": "chat", "text": line}), } }; - if let Err(e) = write_frame(&mut send, frame.to_string().as_bytes()).await { + if let Err(e) = write_frame_to(&mut send, frame.to_string().as_bytes()).await { break Err(e); } } @@ -1256,7 +1322,6 @@ async fn pump(client: WorldClient, command_mode: bool, capability: &str) -> Resu _ = tokio::signal::ctrl_c() => break Ok(()), } }; - conn.close(0_u32.into(), b"done"); reader.abort(); result } diff --git a/pkgs/id/src/frames.rs b/pkgs/id/src/frames.rs index c52ca93e5..35cb2a1d1 100644 --- a/pkgs/id/src/frames.rs +++ b/pkgs/id/src/frames.rs @@ -1,13 +1,17 @@ -//! Length-prefixed framing for bidirectional Iroh QUIC streams. +//! Length-prefixed framing for bidirectional Iroh QUIC streams and SSH +//! exec channels. //! //! One frame is a big-endian `u32` byte length followed by the payload bytes. //! This is shared by the sync protocol (`crate::log_sync`), the envelope -//! protocol (`crate::envelope_net`), and the world session transport -//! (`crate::world_net`); it lives in its own ungated module so the crate -//! builds without the `world` feature. +//! protocol (`crate::envelope_net`), and the world session transports +//! (`crate::world_net` over iroh, `crate::world_ssh` over an SSH exec +//! channel); it lives in its own ungated module so the crate builds without +//! the `world` feature. Every writer goes through [`encode_frame`], so a frame +//! has the same envelope bytes on every transport that uses it. use anyhow::{Context, Result, bail}; use iroh::endpoint::{ReadExactError, RecvStream, SendStream}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _}; /// Largest accepted or emitted frame, in bytes (1 MiB). /// @@ -21,18 +25,120 @@ pub const MAX_FRAME_BYTES: usize = 1024 * 1024; /// /// Fails if the payload exceeds [`MAX_FRAME_BYTES`] or the stream is closed. pub async fn write_frame(send: &mut SendStream, payload: &[u8]) -> Result<()> { + let framed = encode_frame(payload)?; + // One write so a frame is never split by a cancelled caller. + send.write_all(&framed).await.context("write frame")?; + Ok(()) +} + +/// The envelope bytes of one frame: its big-endian `u32` length, then `payload`. +/// +/// # Errors +/// +/// Fails if the payload exceeds [`MAX_FRAME_BYTES`]. +pub fn encode_frame(payload: &[u8]) -> Result> { if payload.len() > MAX_FRAME_BYTES { bail!("frame of {} bytes exceeds {MAX_FRAME_BYTES}", payload.len()); } let len = u32::try_from(payload.len()).context("frame length")?; - // One write so a frame is never split by a cancelled caller. let mut framed = Vec::with_capacity(4 + payload.len()); framed.extend_from_slice(&len.to_be_bytes()); framed.extend_from_slice(payload); + Ok(framed) +} + +/// Write one frame to any byte stream (an SSH channel, a test pipe). +/// +/// # Errors +/// +/// Fails if the payload exceeds [`MAX_FRAME_BYTES`] or the stream is closed. +pub async fn write_frame_to(send: &mut W, payload: &[u8]) -> Result<()> { + let framed = encode_frame(payload)?; send.write_all(&framed).await.context("write frame")?; + send.flush().await.context("flush frame")?; Ok(()) } +/// Read one frame from any byte stream. `Ok(None)` means the peer finished +/// cleanly between frames. +/// +/// This is **not** cancel-safe; run it in its own task, not inside `select!`. +/// +/// # Errors +/// +/// Fails on a truncated frame, a stream error, or a length above +/// [`MAX_FRAME_BYTES`] (the oversized body is never read). +pub async fn read_frame_from(recv: &mut R) -> Result>> { + let mut len = [0_u8; 4]; + let mut filled = 0; + while filled < len.len() { + let read = recv + .read(&mut len[filled..]) + .await + .context("read frame length")?; + if read == 0 { + if filled == 0 { + return Ok(None); + } + bail!("stream ended inside a frame length"); + } + filled += read; + } + let len = u32::from_be_bytes(len) as usize; + if len > MAX_FRAME_BYTES { + bail!("frame of {len} bytes exceeds {MAX_FRAME_BYTES}"); + } + let mut body = vec![0_u8; len]; + recv.read_exact(&mut body) + .await + .context("read frame body")?; + Ok(Some(body)) +} + +/// Splits a byte stream that arrives in arbitrary pieces back into frames, +/// for transports that deliver bytes by callback (the SSH server). +#[derive(Debug, Default)] +pub struct FrameBuffer { + pending: Vec, +} + +/// One result of [`FrameBuffer::next_frame`]. +#[derive(Debug, PartialEq, Eq)] +pub enum Decoded { + /// A complete frame body. + Frame(Vec), + /// The next frame claims more than [`MAX_FRAME_BYTES`]; the stream is unusable. + Oversized, +} + +impl FrameBuffer { + /// Append bytes as they arrive. + pub fn push(&mut self, bytes: &[u8]) { + self.pending.extend_from_slice(bytes); + } + + /// The next complete frame, if one has fully arrived. + pub fn next_frame(&mut self) -> Option { + let header: [u8; 4] = self.pending.get(..4)?.try_into().ok()?; + let len = u32::from_be_bytes(header) as usize; + if len > MAX_FRAME_BYTES { + return Some(Decoded::Oversized); + } + if self.pending.len() < 4 + len { + return None; + } + let body = self.pending[4..4 + len].to_vec(); + self.pending.drain(..4 + len); + Some(Decoded::Frame(body)) + } + + /// Bytes received but not yet returned as a frame. + #[must_use] + pub const fn buffered(&self) -> usize { + self.pending.len() + } +} + /// Read one frame. `Ok(None)` means the peer finished cleanly between frames. /// /// This is **not** cancel-safe; run it in its own task, not inside `select!`. @@ -58,3 +164,63 @@ pub async fn read_frame(recv: &mut RecvStream) -> Result>> { .context("read frame body")?; Ok(Some(body)) } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[tokio::test] + async fn the_byte_stream_framing_round_trips_and_ends_cleanly() { + let (mut client, mut server) = tokio::io::duplex(64); + let writer = tokio::spawn(async move { + write_frame_to(&mut client, br#"{"type":"info"}"#) + .await + .unwrap(); + write_frame_to(&mut client, b"").await.unwrap(); + }); + assert_eq!( + read_frame_from(&mut server).await.unwrap().unwrap(), + br#"{"type":"info"}"# + ); + assert_eq!(read_frame_from(&mut server).await.unwrap().unwrap(), b""); + writer.await.unwrap(); + assert!(read_frame_from(&mut server).await.unwrap().is_none()); + } + + #[tokio::test] + async fn truncated_and_oversized_frames_fail() { + let (mut client, mut server) = tokio::io::duplex(64); + client.write_all(&[0, 0]).await.unwrap(); + drop(client); + assert!(read_frame_from(&mut server).await.is_err()); + + let (mut client, mut server) = tokio::io::duplex(64); + let too_long = u32::try_from(MAX_FRAME_BYTES + 1).unwrap().to_be_bytes(); + client.write_all(&too_long).await.unwrap(); + assert!(read_frame_from(&mut server).await.is_err()); + assert!(encode_frame(&vec![0; MAX_FRAME_BYTES + 1]).is_err()); + } + + #[test] + fn the_frame_buffer_reassembles_frames_from_any_split() { + let mut wire = encode_frame(b"hello").unwrap(); + wire.extend(encode_frame(b"").unwrap()); + wire.extend(encode_frame(b"world").unwrap()); + for split in 0..=wire.len() { + let mut buffer = FrameBuffer::default(); + let mut frames = Vec::new(); + for piece in [&wire[..split], &wire[split..]] { + buffer.push(piece); + while let Some(Decoded::Frame(frame)) = buffer.next_frame() { + frames.push(frame); + } + } + assert_eq!(frames, [b"hello".to_vec(), Vec::new(), b"world".to_vec()]); + assert_eq!(buffer.buffered(), 0); + } + let mut buffer = FrameBuffer::default(); + buffer.push(&u32::MAX.to_be_bytes()); + assert_eq!(buffer.next_frame(), Some(Decoded::Oversized)); + } +} diff --git a/pkgs/id/src/world_net.rs b/pkgs/id/src/world_net.rs index d9f8a85b6..6cf799001 100644 --- a/pkgs/id/src/world_net.rs +++ b/pkgs/id/src/world_net.rs @@ -152,6 +152,24 @@ impl SessionIo for StreamIo { } } +/// The text of a client frame addressed to `world`. +/// +/// The host reads the routing field from a session's first frame only; +/// adding it to every frame is harmless and keeps callers simple. Every +/// transport's client uses this, so the same frame is the same bytes on iroh +/// and on SSH. +#[must_use] +pub fn route_frame(frame: &serde_json::Value, world: Option<&str>) -> String { + match (world, frame) { + (Some(world), serde_json::Value::Object(object)) if !object.contains_key("world") => { + let mut routed = object.clone(); + routed.insert("world".to_owned(), serde_json::Value::from(world)); + serde_json::Value::Object(routed).to_string() + } + _ => frame.to_string(), + } +} + /// A client-side world connection (one session). #[derive(Debug)] pub struct WorldClient { @@ -211,20 +229,8 @@ impl WorldClient { /// /// Fails if the frame is too large or the stream is closed. pub async fn send_json(&mut self, frame: &serde_json::Value) -> Result<()> { - // The host reads the routing field from a session's first frame only; - // adding it to every frame is harmless and keeps callers simple. - match (&self.world, frame) { - (Some(world), serde_json::Value::Object(object)) if !object.contains_key("world") => { - let mut routed = object.clone(); - routed.insert("world".to_owned(), serde_json::Value::from(world.clone())); - write_frame( - &mut self.send, - serde_json::Value::Object(routed).to_string().as_bytes(), - ) - .await - } - _ => write_frame(&mut self.send, frame.to_string().as_bytes()).await, - } + let text = route_frame(frame, self.world.as_deref()); + write_frame(&mut self.send, text.as_bytes()).await } /// Compile Roc sources on the host and install the result. diff --git a/pkgs/id/src/world_ssh.rs b/pkgs/id/src/world_ssh.rs index 3d14d6c4f..9def12685 100644 --- a/pkgs/id/src/world_ssh.rs +++ b/pkgs/id/src/world_ssh.rs @@ -14,6 +14,16 @@ //! [`world_command`]). Prefixing the username with `explore:` enters a //! read-only explore session, the only mode that supports public-key login. //! See the SSH input-modes decision record. +//! +//! ## Frame transport (F13.2) +//! +//! An `exec` request for [`WORLD_EXEC_COMMAND`] turns the channel into a +//! transport for the shell contract itself: the same length-prefixed JSON +//! frames as the iroh `/id-world/1` stream, byte for byte, so +//! `id world join --via ssh` reaches a world through jump hosts and networks +//! that only pass SSH. The SSH login grants nothing on this channel: as on +//! iroh, the capability or admin secret inside the frames is the only +//! authority, and the frames route themselves with their `world` field. use std::{ collections::VecDeque, @@ -42,6 +52,7 @@ use crate::directory_auth::Caller; use crate::directory_view::{ DirectoryAction, DirectoryOutcome, HELP, Line, parse_line, render_text, }; +use crate::frames::{Decoded, FrameBuffer, encode_frame}; use crate::world::hex_encode; use crate::world_hub::{ResolveError, WorldHub}; use crate::world_session::{Inbound, SessionIo, encode_hex, run_session}; @@ -69,6 +80,14 @@ const ALT_SCREEN_ENTER: &str = "\x1b[?1049h"; const ALT_SCREEN_LEAVE: &str = "\x1b[?1049l"; /// Input bytes dropped before the session tells the viewer about it. const INPUT_DROP_NOTICE_BYTES: usize = 1024; +/// The exec command that carries the frame protocol (shell contract +/// transport `ssh-exec`). +pub const WORLD_EXEC_COMMAND: &str = "id-world/1"; +/// Unread frame bytes one exec channel may queue before the host ends its +/// session. The handler must never wait for space (it shares the loop that +/// flushes output), and dropping bytes would corrupt the framing, so a flood +/// ends the session instead. +const MAX_EXEC_BACKLOG: usize = 8 * crate::frames::MAX_FRAME_BYTES; /// Widest viewport a `resize` frame may name (shell contract v1). const MAX_VIEWPORT_WIDTH: u32 = 500; /// Tallest viewport a `resize` frame may name (shell contract v1). @@ -144,6 +163,8 @@ impl Server for SshServer { input: None, viewport: None, dropped_input: Arc::new(AtomicUsize::new(0)), + exec: None, + exec_backlog: Arc::new(AtomicUsize::new(0)), } } } @@ -175,6 +196,10 @@ struct SshClient { /// including after the shell opens, so the counter must be shared, not /// copied, when the shell is constructed. dropped_input: Arc, + /// Bytes for the frame session on an exec channel, if one runs. + exec: Option>>, + /// Bytes queued on `exec` and not yet read by the session. + exec_backlog: Arc, } enum SshInputFrame { @@ -266,10 +291,35 @@ impl Handler for SshClient { async fn exec_request( &mut self, channel: ChannelId, - _data: &[u8], + data: &[u8], session: &mut Session, ) -> Result<(), russh::Error> { - session.channel_failure(channel) + // The frame protocol is the only command, and one session (shell or + // frames) runs per connection. + if data != WORLD_EXEC_COMMAND.as_bytes() + || self.login.is_none() + || self.input.is_some() + || self.exec.is_some() + { + return session.channel_failure(channel); + } + let (exec, bytes) = mpsc::unbounded_channel(); + self.exec = Some(exec); + session.channel_success(channel)?; + let mut io = SshFrameIo { + handle: session.handle(), + channel, + bytes, + buffer: FrameBuffer::default(), + backlog: Arc::clone(&self.exec_backlog), + }; + let hub = self.hub.clone(); + tokio::spawn(async move { + run_session(&hub, &mut io).await; + let _ = io.handle.eof(channel).await; + let _ = io.handle.close(channel).await; + }); + Ok(()) } async fn subsystem_request( @@ -327,7 +377,11 @@ impl Handler for SshClient { channel: ChannelId, session: &mut Session, ) -> Result<(), russh::Error> { - let Some(login) = self.login.as_ref().filter(|_| self.input.is_none()) else { + let Some(login) = self + .login + .as_ref() + .filter(|_| self.input.is_none() && self.exec.is_none()) + else { return session.channel_failure(channel); }; if login.explore { @@ -396,6 +450,16 @@ impl Handler for SshClient { data: &[u8], _session: &mut Session, ) -> Result<(), russh::Error> { + if let Some(exec) = &self.exec { + let queued = self.exec_backlog.fetch_add(data.len(), Ordering::Relaxed) + data.len(); + if queued > MAX_EXEC_BACKLOG || exec.send(data.to_vec()).is_err() { + tracing::warn!( + "world ssh exec backlog over {MAX_EXEC_BACKLOG} bytes; ending session" + ); + self.exec = None; + } + return Ok(()); + } let Some(input) = &self.input else { return Ok(()); }; @@ -427,6 +491,7 @@ impl Handler for SshClient { _session: &mut Session, ) -> Result<(), russh::Error> { self.input = None; + self.exec = None; Ok(()) } @@ -436,6 +501,7 @@ impl Handler for SshClient { _session: &mut Session, ) -> Result<(), russh::Error> { self.input = None; + self.exec = None; Ok(()) } } @@ -490,6 +556,44 @@ fn join_frame(login: &Login) -> String { join.to_string() } +/// [`SessionIo`] for the frame protocol on an exec channel: the same +/// envelope as the iroh stream, reassembled from the channel's data packets. +struct SshFrameIo { + handle: Handle, + channel: ChannelId, + bytes: mpsc::UnboundedReceiver>, + buffer: FrameBuffer, + backlog: Arc, +} + +impl SessionIo for SshFrameIo { + // Cancel-safe: state changes only after a chunk has been received. + async fn recv(&mut self) -> Inbound { + loop { + match self.buffer.next_frame() { + Some(Decoded::Frame(body)) => { + return String::from_utf8(body).map_or(Inbound::Unsupported, Inbound::Text); + } + // As on iroh, an oversized length ends the stream unread. + Some(Decoded::Oversized) => return Inbound::Closed, + None => {} + } + let Some(chunk) = self.bytes.recv().await else { + return Inbound::Closed; + }; + self.backlog.fetch_sub(chunk.len(), Ordering::Relaxed); + self.buffer.push(&chunk); + } + } + + async fn send(&mut self, frame: String) -> bool { + match encode_frame(frame.as_bytes()) { + Ok(bytes) => self.handle.data(self.channel, bytes).await.is_ok(), + Err(_) => false, + } + } +} + /// [`SessionIo`] for one SSH shell: the join frame first, then terminal lines /// as chat/command frames, and terminal output for each view or record page. struct SshIo { @@ -811,6 +915,185 @@ impl SessionIo for SshIo { } } +/// Where `id world join --via ssh` connects: `[user@]host[:port]` (port 22 +/// by default; an IPv6 address goes in brackets). The user is only the SSH +/// login: on the frame channel it names no world and grants nothing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SshTarget { + /// SSH login name (`id` when absent). + pub user: String, + /// Host name or address. + pub host: String, + /// TCP port. + pub port: u16, +} + +impl std::str::FromStr for SshTarget { + type Err = anyhow::Error; + + fn from_str(text: &str) -> anyhow::Result { + let (user, rest) = text.rsplit_once('@').unwrap_or(("id", text)); + let port = |digits: &str| { + digits + .parse::() + .map_err(|_| anyhow::anyhow!("`{digits}` is not a port")) + }; + let (host, port) = if let Some(inner) = rest.strip_prefix('[') { + let Some((host, tail)) = inner.split_once(']') else { + anyhow::bail!("`{text}`: missing `]` after the IPv6 address"); + }; + match tail.strip_prefix(':') { + Some(digits) => (host, port(digits)?), + None if tail.is_empty() => (host, 22), + None => anyhow::bail!("`{text}`: expected `:port` after `]`"), + } + } else { + match rest.split_once(':') { + Some((host, digits)) if !digits.contains(':') => (host, port(digits)?), + Some(_) => anyhow::bail!("`{text}`: put an IPv6 address in brackets"), + None => (rest, 22), + } + }; + anyhow::ensure!( + !host.is_empty() && !user.is_empty(), + "`{text}` is not [user@]host[:port]" + ); + Ok(Self { + user: user.to_owned(), + host: host.to_owned(), + port, + }) + } +} + +/// How the SSH client decides to trust the host key. There is no +/// trust-on-first-use: an unknown key is refused with its fingerprint. +#[derive(Clone, Debug)] +pub enum HostKeyCheck { + /// The SHA256 fingerprint `id serve` prints (`SHA256:...`). + Fingerprint(String), + /// An OpenSSH `known_hosts` file. + KnownHosts(std::path::PathBuf), +} + +/// The client half of the host-key decision. +#[derive(Debug)] +pub struct HostKeyTrust { + host: String, + port: u16, + check: HostKeyCheck, + refused: Arc>>, +} + +impl russh::client::Handler for HostKeyTrust { + type Error = russh::Error; + + async fn check_server_key( + &mut self, + key: &russh::keys::PublicKeyOrCertificate, + ) -> Result { + let russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } = key else { + self.refuse("a certificate (not supported)".to_owned()); + return Ok(false); + }; + let fingerprint = key.fingerprint(HashAlg::Sha256).to_string(); + let trusted = match &self.check { + HostKeyCheck::Fingerprint(expected) => *expected == fingerprint, + HostKeyCheck::KnownHosts(path) => { + russh::keys::check_known_hosts_path(&self.host, self.port, key, path) + .unwrap_or(false) + } + }; + if !trusted { + self.refuse(fingerprint); + } + Ok(trusted) + } +} + +impl HostKeyTrust { + fn refuse(&self, fingerprint: String) { + if let Ok(mut refused) = self.refused.lock() { + *refused = Some(fingerprint); + } + } +} + +/// Keeps the SSH connection of a frame session open; dropping it disconnects. +pub struct SshConnection { + _session: russh::client::Handle, +} + +impl std::fmt::Debug for SshConnection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SshConnection").finish_non_exhaustive() + } +} + +/// Open a frame session over an SSH exec channel. The returned stream +/// carries exactly the bytes an iroh `/id-world/1` stream would: use +/// [`crate::frames::write_frame_to`] and [`crate::frames::read_frame_from`]. +/// +/// # Errors +/// +/// Fails if the host is unreachable, its key is not trusted, or it does not +/// offer the frame transport. +pub async fn connect_frames( + target: &SshTarget, + check: HostKeyCheck, +) -> anyhow::Result<(SshConnection, russh::ChannelStream)> { + use anyhow::Context as _; + + let refused = Arc::new(std::sync::Mutex::new(None)); + let trust = HostKeyTrust { + host: target.host.clone(), + port: target.port, + check, + refused: Arc::clone(&refused), + }; + let config = Arc::new(russh::client::Config::default()); + let mut session = + match russh::client::connect(config, (target.host.as_str(), target.port), trust).await { + Ok(session) => session, + Err(error) => { + if let Some(key) = refused.lock().ok().and_then(|mut refused| refused.take()) { + anyhow::bail!( + "the host key of {}:{} ({key}) is not trusted; compare it with the \ + `world ssh host key` line `id serve` prints, then pass \ + --ssh-host-key {key} or add the key to known_hosts", + target.host, + target.port + ); + } + return Err(error).context("connect over ssh"); + } + }; + // The login grants nothing here: the frames carry their own capability. + let auth = session + .authenticate_password(target.user.as_str(), "") + .await + .context("ssh login")?; + anyhow::ensure!(auth.success(), "the ssh server refused the login"); + let mut channel = session + .channel_open_session() + .await + .context("open ssh channel")?; + channel + .exec(true, WORLD_EXEC_COMMAND) + .await + .context("start the frame transport")?; + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Success) => break, + Some(russh::ChannelMsg::Failure) | None => anyhow::bail!( + "the ssh server does not offer the id world frame transport (exec {WORLD_EXEC_COMMAND})" + ), + Some(_) => {} + } + } + Ok((SshConnection { _session: session }, channel.into_stream())) +} + const MAX_WORLD_TERMINAL_LINE: usize = 1024; const MAX_RECORDS_TERMINAL_BYTES: usize = 384 * 1024; /// Total rendered bytes a single `/records` drain may produce across all its @@ -2021,6 +2304,347 @@ mod tests { server.abort(); } + #[test] + fn ssh_targets_parse_user_host_and_port() { + let parse = |text: &str| text.parse::(); + let target = |user: &str, host: &str, port| SshTarget { + user: user.to_owned(), + host: host.to_owned(), + port, + }; + assert_eq!( + parse("example.org").unwrap(), + target("id", "example.org", 22) + ); + assert_eq!( + parse("ada@example.org:2222").unwrap(), + target("ada", "example.org", 2222) + ); + assert_eq!(parse("[::1]:2022").unwrap(), target("id", "::1", 2022)); + assert_eq!(parse("me@[::1]").unwrap(), target("me", "::1", 22)); + for bad in ["", "host:", "host:99999", "::1", "[::1", "@host", "[::1]x"] { + assert!(parse(bad).is_err(), "{bad}"); + } + } + + #[test] + fn the_exec_command_is_the_contracts_ssh_transport() { + let contract = crate::shell_contract::ShellContract::embedded().unwrap(); + assert_eq!( + contract.transports["ssh-exec"]["command"], + WORLD_EXEC_COMMAND + ); + assert_eq!( + contract.transports["ssh-exec"]["envelope"], + contract.transports["iroh"]["envelope"] + ); + } + + /// A program whose view is the same for every viewer and every connection. + struct Shown; + + impl crate::world::WorldProgram for Shown { + fn view(&mut self, _viewer: &crate::world::Participant) -> anyhow::Result> { + Ok(Some("shown".to_owned())) + } + } + + /// One world served over iroh and SSH at once. + struct TwoTransports { + world: WorldHandle, + capability: String, + port: u16, + fingerprint: String, + endpoint: iroh::Endpoint, + addr: iroh::EndpointAddr, + _router: iroh::protocol::Router, + ssh: tokio::task::JoinHandle>, + } + + async fn two_transports() -> TwoTransports { + use iroh::endpoint::{RelayMode, presets}; + + let world = WorldHandle::spawn(WorldCore::new("lobby", WorldLimits::default()).unwrap()); + world + .install_program("ab".repeat(32), 5, Box::new(Shown)) + .await + .unwrap(); + let (_, token) = world + .issue("ada", crate::world::WorldScopes::GUEST) + .await + .unwrap(); + let hub = WorldHub::single(WorldService::new(world.clone(), Some("admin".to_owned()))); + + let bind = || async { + iroh::Endpoint::builder(presets::Minimal) + .relay_mode(RelayMode::Disabled) + .bind() + .await + .unwrap() + }; + let host = bind().await; + let router = iroh::protocol::Router::builder(host.clone()) + .accept( + crate::world_net::WORLD_ALPN, + crate::world_net::WorldProtocol::new(hub.clone()), + ) + .spawn(); + let addr = iroh::EndpointAddr::from_parts( + host.id(), + host.bound_sockets().iter().map(|a| { + let ip = if a.ip().is_unspecified() { + std::net::Ipv4Addr::LOCALHOST.into() + } else { + a.ip() + }; + iroh::TransportAddr::Ip(SocketAddr::new(ip, a.port())) + }), + ); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let host_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let fingerprint = host_key_fingerprint(&host_key); + let ssh = tokio::spawn(serve_limited( + listener, + hub, + host_key, + test_store(), + MAX_CONNECTIONS, + )); + TwoTransports { + world, + capability: token.expose().to_owned(), + port, + fingerprint, + endpoint: bind().await, + addr, + _router: router, + ssh, + } + } + + impl TwoTransports { + fn target(&self) -> SshTarget { + SshTarget { + user: "id".to_owned(), + host: "127.0.0.1".to_owned(), + port: self.port, + } + } + + fn trust(&self) -> HostKeyCheck { + HostKeyCheck::Fingerprint(self.fingerprint.clone()) + } + } + + /// The raw frame bodies a transport delivers, read in their own task so + /// a test can wait for quiet without cancelling a read. + struct RawFrames { + frames: mpsc::UnboundedReceiver>, + } + + impl RawFrames { + fn read(mut recv: R) -> Self { + let (tx, frames) = mpsc::unbounded_channel(); + tokio::spawn(async move { + while let Ok(Some(body)) = crate::frames::read_frame_from(&mut recv).await { + if tx.send(body).is_err() { + break; + } + } + }); + Self { frames } + } + + /// Every frame until the host has been quiet for a moment. + async fn drain(&mut self) -> Vec> { + let mut frames = Vec::new(); + while let Ok(Some(frame)) = + tokio::time::timeout(Duration::from_millis(400), self.frames.recv()).await + { + frames.push(frame); + } + frames + } + } + + fn texts(frames: &[Vec]) -> Vec { + frames + .iter() + .map(|frame| String::from_utf8_lossy(frame).into_owned()) + .collect() + } + + /// F13.2's exit test: one scripted session over iroh and over an SSH exec + /// channel, in lockstep on one world, unwraps to the same frame bytes. + #[tokio::test] + async fn an_exec_channel_carries_the_same_frames_as_the_iroh_stream() { + use crate::frames::{write_frame, write_frame_to}; + + let host = two_transports().await; + let iroh_client = crate::world_net::WorldClient::connect(&host.endpoint, host.addr.clone()) + .await + .unwrap(); + let (conn, mut iroh_send, iroh_recv) = iroh_client.into_parts(); + let mut iroh_frames = RawFrames::read(iroh_recv); + let (_connection, stream) = connect_frames(&host.target(), host.trust()).await.unwrap(); + let (ssh_recv, mut ssh_send) = tokio::io::split(stream); + let mut ssh_frames = RawFrames::read(ssh_recv); + + let cap = &host.capability; + let join = crate::world_net::route_frame( + &serde_json::json!({"type": "join", "capability": cap, "after": null}), + Some("lobby"), + ); + write_frame(&mut iroh_send, join.as_bytes()).await.unwrap(); + let iroh_joined = iroh_frames.drain().await; + write_frame_to(&mut ssh_send, join.as_bytes()) + .await + .unwrap(); + let ssh_joined = ssh_frames.drain().await; + assert_eq!(texts(&iroh_joined), texts(&ssh_joined)); + assert!( + iroh_frames.drain().await.is_empty(), + "a second join is invisible" + ); + + // (who sends, frame): broadcasts reach both; replies go to whoever asked. + let script = [ + ( + Some(true), + serde_json::json!({"type": "chat", "text": "over iroh"}), + ), + ( + Some(false), + serde_json::json!({"type": "input", "data_hex": "6869"}), + ), + (None, serde_json::json!({"type": "info", "capability": cap})), + ( + None, + serde_json::json!({"type": "records", "capability": cap}), + ), + (None, serde_json::json!({"type": "caps", "capability": cap})), + ( + None, + serde_json::json!({"type": "resize", "width": 40, "height": 10}), + ), + ( + None, + serde_json::json!({"type": "chat", "text": "\u{1}bad"}), + ), + (None, serde_json::json!({"type": "teleport"})), + ]; + let mut checked = 0; + for (sender, frame) in script { + let text = frame.to_string(); + if sender != Some(false) { + write_frame(&mut iroh_send, text.as_bytes()).await.unwrap(); + } + if sender != Some(true) { + write_frame_to(&mut ssh_send, text.as_bytes()) + .await + .unwrap(); + } + let over_iroh = iroh_frames.drain().await; + let over_ssh = ssh_frames.drain().await; + assert!(!over_iroh.is_empty(), "{text}"); + assert_eq!(over_iroh, over_ssh, "{text}: {:?}", texts(&over_iroh)); + checked += over_iroh.len(); + } + assert!(checked >= 10, "{checked}"); + // Only the chat and the input were journaled: the frames themselves + // never touch it. + let token = crate::world::JoinCapability::from_wire(cap.clone()); + let snapshot = host.world.snapshot(token.clone()).await.unwrap(); + // The install, the chat, the input, and each side's sanitized chat. + assert_eq!(snapshot.current_sequence, 5); + drop(ssh_send); + drop(ssh_frames); + conn.close(0_u32.into(), b"done"); + tokio::time::sleep(Duration::from_millis(200)).await; + let after = host.world.snapshot(token).await.unwrap(); + assert_eq!(after.current_sequence, snapshot.current_sequence); + host.ssh.abort(); + } + + #[tokio::test] + async fn the_exec_transport_refuses_other_commands_untrusted_keys_and_oversized_frames() { + let host = two_transports().await; + + // An unknown key is refused, and the error names its fingerprint. + let Err(error) = connect_frames( + &host.target(), + HostKeyCheck::Fingerprint("SHA256:not-the-key".to_owned()), + ) + .await + else { + panic!("an untrusted key was accepted"); + }; + let error = error.to_string(); + assert!(error.contains(&host.fingerprint), "{error}"); + let empty = tempfile::NamedTempFile::new().unwrap(); + assert!( + connect_frames( + &host.target(), + HostKeyCheck::KnownHosts(empty.path().to_owned()) + ) + .await + .is_err() + ); + + // Any other exec command, and a second session on one connection, fail. + let session = connect(host.port, "").await; + let mut channel = session.channel_open_session().await.unwrap(); + channel.exec(true, "sh -c id").await.unwrap(); + assert!(matches!( + channel.wait().await, + Some(russh::ChannelMsg::Failure) + )); + let mut channel = session.channel_open_session().await.unwrap(); + channel.exec(true, WORLD_EXEC_COMMAND).await.unwrap(); + assert!(matches!( + channel.wait().await, + Some(russh::ChannelMsg::Success) + )); + let mut second = session.channel_open_session().await.unwrap(); + second.exec(true, WORLD_EXEC_COMMAND).await.unwrap(); + assert!(matches!( + second.wait().await, + Some(russh::ChannelMsg::Failure) + )); + + // A length over the frame limit ends the session unread, as on iroh. + let too_long = u32::try_from(crate::frames::MAX_FRAME_BYTES + 1) + .unwrap() + .to_be_bytes(); + channel.data(&too_long[..]).await.unwrap(); + let ended = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Eof | russh::ChannelMsg::Close) | None => break, + Some(_) => {} + } + } + }) + .await; + assert!(ended.is_ok(), "the session did not end"); + + // A login grants nothing on the frame channel: a bad capability is refused. + let (_connection, stream) = connect_frames(&host.target(), host.trust()).await.unwrap(); + let (recv, mut send) = tokio::io::split(stream); + let mut frames = RawFrames::read(recv); + crate::frames::write_frame_to(&mut send, br#"{"type":"join","capability":"1.forged"}"#) + .await + .unwrap(); + let reply = frames.drain().await; + assert_eq!( + texts(&reply), + [r#"{"type":"error","message":"join denied"}"#] + ); + host.ssh.abort(); + } + #[test] fn terminal_shell_frames_conform_to_the_shell_contract() { use crate::shell_contract::{Phase, ShellContract}; diff --git a/pkgs/id/tests/cli_integration.rs b/pkgs/id/tests/cli_integration.rs index d58e31c95..5f0673d89 100644 --- a/pkgs/id/tests/cli_integration.rs +++ b/pkgs/id/tests/cli_integration.rs @@ -1834,6 +1834,115 @@ mod serve_tests { server.stop(); } + /// `id world join --via ssh` plays the same session over an SSH exec + /// channel as over iroh (F13.2), trusting only the pinned host key. + #[cfg(feature = "ssh")] + #[test] + fn test_world_join_via_ssh_carries_the_frame_protocol() { + let server_dir = TempDir::new().unwrap(); + let client_dir = TempDir::new().unwrap(); + let module = examples_dir().join("roc-counter/counter.wasm"); + let ssh_port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() + .to_string(); + let mut server = ServerHandle::spawn_with_args( + server_dir.path(), + &[ + "--world", + "--world-admin-token", + "adm", + "--world-module", + module.to_str().unwrap(), + "--world-ssh-port", + &ssh_port, + ], + ); + let node = server.wait_ready(); + let addr = lock_ipv4_addr(&server.lock_file_path()); + let pem = std::fs::read_to_string(server_dir.path().join(id::world_ssh::SSH_HOST_KEY_FILE)) + .unwrap(); + let key = russh::keys::PrivateKey::from_openssh(pem).unwrap(); + let fingerprint = id::world_ssh::host_key_fingerprint(&key); + + let world = |args: &[&str]| { + let mut cmd = StdCommand::new(get_binary_path()); + cmd.args(["world"]) + .args(args) + .current_dir(client_dir.path()) + .env("HOME", client_dir.path()) + .env_remove("ID_WORLD_ADMIN_TOKEN") + .env_remove("ID_WORLD_CAPABILITY"); + cmd + }; + let invited = world(&[ + "invite", + &node, + "--admin-token", + "adm", + "--name", + "ssh", + "--addr", + &addr, + ]) + .output_within(PROCESS_TIMEOUT) + .unwrap(); + assert!( + invited.status.success(), + "{}", + String::from_utf8_lossy(&invited.stderr) + ); + let capability = String::from_utf8(invited.stdout).unwrap().trim().to_owned(); + let target = format!("127.0.0.1:{ssh_port}"); + + // Without a pinned key (and with an empty known_hosts) the host is refused. + let refused = world(&["join", &target, "--via", "ssh", "--capability", &capability]) + .stdin(Stdio::null()) + .output_within(PROCESS_TIMEOUT) + .unwrap(); + assert!(!refused.status.success()); + let stderr = String::from_utf8_lossy(&refused.stderr); + assert!(stderr.contains(&fingerprint), "{stderr}"); + + let mut joined = world(&[ + "join", + &target, + "--via", + "ssh", + "--ssh-host-key", + &fingerprint, + "--capability", + &capability, + "--command-mode", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(KillOnDrop) + .unwrap(); + let mut stdin = joined.stdin.take().unwrap(); + let mut lines = BufReader::new(joined.stdout.take().unwrap()).lines(); + let first = lines.next().expect("snapshot line").unwrap(); + assert!(first.contains("\"type\":\"snapshot\""), "{first}"); + let view = lines.next().expect("view line").unwrap(); + assert!(view.contains("count=0"), "{view}"); + stdin.write_all(b"/input 696e63\n").unwrap(); + let event = lines.next().expect("event line").unwrap(); + assert!(event.contains("\"type\":\"event\""), "{event}"); + let view = lines.next().expect("view line").unwrap(); + assert!(view.contains("count=1"), "{view}"); + stdin.write_all(b"/stats\n").unwrap(); + let info = lines.next().expect("info line").unwrap(); + assert!(info.contains("\"type\":\"module_info\""), "{info}"); + stdin.write_all(b"/quit\n").unwrap(); + drop(stdin); + assert!(joined.wait().unwrap().success()); + server.stop(); + } + #[test] fn test_tic_tac_toe_installed_over_iroh_and_played_by_two_players() { let server_dir = TempDir::new().unwrap(); From 5097a35cb678d275b0bc90340f3e37b36d681c2c Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 25 Oct 2026 03:33:59 -0500 Subject: [PATCH 5/6] feat(id): SSH-over-iroh tunnel for third-party SSH clients (F13.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `id serve --world-ssh-port N` now also registers /id-ssh-tunnel/1: each iroh stream is one raw SSH connection forwarded to the host's SSH server on loopback (SshTunnelProtocol, bounded streams, loopback only). `id world ssh-tunnel ` exposes it as a local listener (127.0.0.1:2222 default) or --stdio ProxyCommand, so an unmodified `ssh` client reaches a NAT'd host. The tunnel grants nothing: SSH auth inside it is unchanged (no identity bypass). Verified with the system OpenSSH client against a locally served world (raw + cmd: modes, /input and /stats), plus tests: ssh_reaches_the_world_through_the_iroh_tunnel (russh over iroh) and test_world_ssh_tunnel_carries_join_via_ssh (CLI composition). Remaining in #785: the iroh-over-SSH fallback when UDP is denied — needs a non-QUIC transport that does not exist yet; F13.2's exec transport covers joins there meanwhile. Refs #785 --- ...0-25T05-54-40Z_reference_shell_contract.md | 28 ++++ pkgs/id/src/cli.rs | 20 +++ pkgs/id/src/commands/serve.rs | 9 ++ pkgs/id/src/commands/world.rs | 98 ++++++++++++- pkgs/id/src/world_ssh.rs | 138 +++++++++++++++++- pkgs/id/tests/cli_integration.rs | 127 ++++++++++++++++ 6 files changed, 409 insertions(+), 11 deletions(-) diff --git a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md index 92b2349b3..3ff06431d 100644 --- a/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md +++ b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md @@ -145,6 +145,34 @@ exec channel: the capability or admin secret inside the frames is the only authority, as on iroh. Other exec commands, shells after an exec session, and oversized frame lengths are refused. +## SSH over iroh (F13.3) + +`id serve --world-ssh-port N` also registers the `/id-ssh-tunnel/1` ALPN. +Each bidirectional stream on it is one raw SSH connection forwarded to the +host's own SSH server on loopback (`SshTunnelProtocol`, +`pkgs/id/src/world_ssh.rs`). `id world ssh-tunnel ` exposes it +locally — a loopback listener (`--listen`, default `127.0.0.1:2222`) or +`--stdio` for `ssh -o ProxyCommand=...` — so an unmodified `ssh` client +reaches a host behind NAT: + +```sh +id world ssh-tunnel & # or --stdio as a ProxyCommand +ssh -p 2222 @127.0.0.1 # password: a guest capability +``` + +The tunnel carries bytes only and grants nothing: the peer still +authenticates to the SSH server inside it (host key on the SSH side, peer +ID on the iroh side), so it is not an identity bypass. Only loopback SSH +servers are forwarded to, and streams per connection are bounded. +Verified manually with the system OpenSSH client (raw and `cmd:` modes) +and in tests (`ssh_reaches_the_world_through_the_iroh_tunnel`, +`test_world_ssh_tunnel_carries_join_via_ssh`). + +Still open from #785: the iroh-over-SSH fallback for networks where UDP is +denied but port 22 is open. That needs a non-QUIC iroh transport or an +SSH-carried datagram channel, which does not exist yet; the F13.2 exec +transport covers the join use-case there in the meantime. + ## The client projection Every server frame is built from F11.11's single client projection. The diff --git a/pkgs/id/src/cli.rs b/pkgs/id/src/cli.rs index 4c89d408e..efcbb9524 100644 --- a/pkgs/id/src/cli.rs +++ b/pkgs/id/src/cli.rs @@ -1555,6 +1555,26 @@ pub enum WorldCommand { #[arg(long)] no_relay: bool, }, + /// Reach a host's world SSH server through iroh, so an ordinary `ssh` + /// client can reach a world behind NAT: `ssh -p 2222 @127.0.0.1` + /// (password: the guest capability). With `--stdio`, acts as an SSH + /// ProxyCommand instead of listening. + SshTunnel { + /// The host's node ID (64 hex characters). + node: String, + /// Local address to listen on (loopback only is recommended). + #[arg(long, value_name = "ADDR")] + listen: Option, + /// Speak SSH on stdin/stdout (for `ssh -o ProxyCommand`). + #[arg(long, conflicts_with = "listen")] + stdio: bool, + /// Direct socket address of the host (repeatable); skips discovery. + #[arg(long = "addr")] + addrs: Vec, + /// Disable relay servers (direct connection only). + #[arg(long)] + no_relay: bool, + }, /// Compile Roc sources on the host and install the result. /// /// The host rewrites the app's `pf: platform "..."` path to its staged diff --git a/pkgs/id/src/commands/serve.rs b/pkgs/id/src/commands/serve.rs index 822bf5544..9f8e2eedb 100644 --- a/pkgs/id/src/commands/serve.rs +++ b/pkgs/id/src/commands/serve.rs @@ -909,6 +909,8 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { #[cfg(not(feature = "world"))] ensure!(!world, "this build has no world support (feature `world`)"); + #[cfg(all(feature = "world", feature = "ssh"))] + let (ssh_tunnel, ssh_tunnel_port) = crate::world_ssh::SshTunnelProtocol::deferred(); let router_builder = Router::builder(endpoint) .accept(META_ALPN, meta) .accept(BLOBS_ALPN, blobs) @@ -935,6 +937,8 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { ), None => router_builder, }; + #[cfg(all(feature = "world", feature = "ssh"))] + let router_builder = router_builder.accept(crate::world_ssh::SSH_TUNNEL_ALPN, ssh_tunnel); let router = router_builder.spawn(); if !no_gossip { @@ -1038,6 +1042,8 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { (Some(port), Some(hub)) => { let listener = tokio::net::TcpListener::bind(SocketAddr::new(bind, port)).await?; let ssh_port = listener.local_addr()?.port(); + #[cfg(feature = "world")] + let _ = ssh_tunnel_port.set(ssh_port); let host_key = crate::world_ssh::load_or_create_host_key(crate::world_ssh::SSH_HOST_KEY_FILE) .await?; @@ -1158,6 +1164,9 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> { "world ssh host key: {}", crate::world_ssh::host_key_fingerprint(&host_key) ); + status!( + "world ssh over iroh: `id world ssh-tunnel ` then `ssh -p 2222 @127.0.0.1`" + ); tokio::spawn(async move { if let Err(e) = crate::world_ssh::serve(listener, hub, host_key, blobs).await { tracing::error!("world ssh server error: {}", e); diff --git a/pkgs/id/src/commands/world.rs b/pkgs/id/src/commands/world.rs index c81b77e44..108103cd9 100644 --- a/pkgs/id/src/commands/world.rs +++ b/pkgs/id/src/commands/world.rs @@ -34,9 +34,18 @@ async fn connect( bail!("`{node}` is not a node ID (expected 64 hex characters)"); } let id: EndpointId = node.parse().context("parse node ID")?; + let endpoint = client_endpoint(addrs, no_relay).await?; + let addr = EndpointAddr::from_parts(id, addrs.iter().copied().map(TransportAddr::Ip)); + let client = WorldClient::connect(&endpoint, addr) + .await? + .with_world(world); + Ok((endpoint, client)) +} + +/// A client endpoint for reaching a host: direct addresses need neither +/// relay nor discovery, so keep it off the public network in that case. +async fn client_endpoint(addrs: &[SocketAddr], no_relay: bool) -> Result { let key = load_or_create_keypair(CLIENT_KEY_FILE).await?; - // Direct addresses need neither relay nor discovery, so keep the client - // off the public network entirely in that case. let endpoint = if addrs.is_empty() { crate::connectivity::public_builder(crate::connectivity::RelayPolicy::from_no_relay( no_relay, @@ -51,11 +60,7 @@ async fn connect( .bind() .await? }; - let addr = EndpointAddr::from_parts(id, addrs.iter().copied().map(TransportAddr::Ip)); - let client = WorldClient::connect(&endpoint, addr) - .await? - .with_world(world); - Ok((endpoint, client)) + Ok(endpoint) } /// Run an `id world` subcommand. @@ -723,6 +728,13 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> { } } } + WorldCommand::SshTunnel { + node, + listen, + stdio, + addrs, + no_relay, + } => ssh_tunnel(&node, listen.as_deref(), stdio, &addrs, no_relay).await, } } @@ -750,6 +762,78 @@ async fn join_over_ssh( pump(recv, send, first, command_mode, capability).await } +/// `id world ssh-tunnel`: forward a local TCP listener (or stdio, as an SSH +/// ProxyCommand) over iroh to the host's world SSH server (F13.3). The +/// tunnel carries SSH bytes only; the `ssh` client still authenticates to +/// the host inside it. +#[cfg(feature = "ssh")] +async fn ssh_tunnel( + node: &str, + listen: Option<&str>, + stdio: bool, + addrs: &[SocketAddr], + no_relay: bool, +) -> Result<()> { + if !is_node_id(node) { + bail!("`{node}` is not a node ID (expected 64 hex characters)"); + } + let id: EndpointId = node.parse().context("parse node ID")?; + let endpoint = client_endpoint(addrs, no_relay).await?; + let addr = EndpointAddr::from_parts(id, addrs.iter().copied().map(TransportAddr::Ip)); + let conn = crate::connectivity::connect(&endpoint, addr, crate::world_ssh::SSH_TUNNEL_ALPN) + .await + .context("connect ssh tunnel")?; + if stdio { + let (send, recv) = conn.open_bi().await.context("open tunnel stream")?; + let mut tunnel = tokio::io::join(recv, send); + let mut local = tokio::io::join(tokio::io::stdin(), tokio::io::stdout()); + tokio::io::copy_bidirectional(&mut local, &mut tunnel) + .await + .context("ssh tunnel stdio")?; + conn.close(0_u32.into(), b"done"); + endpoint.close().await; + return Ok(()); + } + let listen = listen.unwrap_or("127.0.0.1:2222"); + let listener = tokio::net::TcpListener::bind(listen) + .await + .with_context(|| format!("listen on {listen}"))?; + eprintln!( + "ssh tunnel: {} -> world host; connect with `ssh -p {} @{}` (password: a guest capability)", + listener.local_addr()?, + listener.local_addr()?.port(), + listener.local_addr()?.ip() + ); + loop { + tokio::select! { + accepted = listener.accept() => { + let (mut tcp, _) = accepted?; + let conn = conn.clone(); + tokio::spawn(async move { + let Ok((send, recv)) = conn.open_bi().await else { return; }; + let mut tunnel = tokio::io::join(recv, send); + let _ = tokio::io::copy_bidirectional(&mut tcp, &mut tunnel).await; + }); + } + _ = tokio::signal::ctrl_c() => break, + } + } + conn.close(0_u32.into(), b"done"); + endpoint.close().await; + Ok(()) +} + +#[cfg(not(feature = "ssh"))] +async fn ssh_tunnel( + _node: &str, + _listen: Option<&str>, + _stdio: bool, + _addrs: &[SocketAddr], + _no_relay: bool, +) -> Result<()> { + bail!("this build of id has no SSH support (cargo feature `ssh`)") +} + #[cfg(not(feature = "ssh"))] async fn join_over_ssh( _target: &str, diff --git a/pkgs/id/src/world_ssh.rs b/pkgs/id/src/world_ssh.rs index 9def12685..5e6508398 100644 --- a/pkgs/id/src/world_ssh.rs +++ b/pkgs/id/src/world_ssh.rs @@ -80,6 +80,74 @@ const ALT_SCREEN_ENTER: &str = "\x1b[?1049h"; const ALT_SCREEN_LEAVE: &str = "\x1b[?1049l"; /// Input bytes dropped before the session tells the viewer about it. const INPUT_DROP_NOTICE_BYTES: usize = 1024; +/// ALPN for SSH-over-iroh tunnels (F13.3): each bidirectional stream is one +/// raw SSH connection forwarded to the host's own SSH server. The tunnel +/// carries bytes only; SSH authentication happens inside it, unchanged. +pub const SSH_TUNNEL_ALPN: &[u8] = b"/id-ssh-tunnel/1"; +/// Concurrent tunnelled connections one iroh connection may open. +const MAX_TUNNEL_STREAMS: usize = 16; + +/// Host side of SSH-over-iroh: forwards each tunnel stream to the world SSH +/// server on loopback. Iroh peer identity grants nothing here; the peer +/// still has to authenticate to SSH inside the tunnel. +#[derive(Clone, Debug)] +pub struct SshTunnelProtocol { + port: Arc>, +} + +impl SshTunnelProtocol { + /// Tunnel to an SSH server already listening on `port`. + #[must_use] + pub fn with_port(port: u16) -> Self { + let cell = std::sync::OnceLock::new(); + let _ = cell.set(port); + Self { + port: Arc::new(cell), + } + } + + /// Tunnel whose SSH port is bound after the router starts; the returned + /// cell must be set once the SSH listener exists. + #[must_use] + pub fn deferred() -> (Self, Arc>) { + let cell = Arc::new(std::sync::OnceLock::new()); + ( + Self { + port: Arc::clone(&cell), + }, + cell, + ) + } +} + +impl iroh::protocol::ProtocolHandler for SshTunnelProtocol { + async fn accept( + &self, + conn: iroh::endpoint::Connection, + ) -> Result<(), iroh::protocol::AcceptError> { + let permits = Arc::new(Semaphore::new(MAX_TUNNEL_STREAMS)); + while let Ok((send, recv)) = conn.accept_bi().await { + let Some(port) = self.port.get().copied() else { + continue; + }; + let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else { + continue; + }; + tokio::spawn(async move { + let _permit = permit; + let Ok(mut tcp) = + tokio::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, port)).await + else { + return; + }; + let mut stream = tokio::io::join(recv, send); + let _ = tokio::io::copy_bidirectional(&mut tcp, &mut stream).await; + }); + } + Ok(()) + } +} + /// The exec command that carries the frame protocol (shell contract /// transport `ssh-exec`). pub const WORLD_EXEC_COMMAND: &str = "id-world/1"; @@ -2382,12 +2450,17 @@ mod tests { .await .unwrap() }; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let host_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); + let fingerprint = host_key_fingerprint(&host_key); let host = bind().await; let router = iroh::protocol::Router::builder(host.clone()) .accept( crate::world_net::WORLD_ALPN, crate::world_net::WorldProtocol::new(hub.clone()), ) + .accept(SSH_TUNNEL_ALPN, SshTunnelProtocol::with_port(port)) .spawn(); let addr = iroh::EndpointAddr::from_parts( host.id(), @@ -2401,10 +2474,6 @@ mod tests { }), ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - let host_key = PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(); - let fingerprint = host_key_fingerprint(&host_key); let ssh = tokio::spawn(serve_limited( listener, hub, @@ -2645,6 +2714,67 @@ mod tests { host.ssh.abort(); } + /// F13.3: an SSH client reaches the world SSH server through iroh only — + /// no TCP route to it is used after the tunnel connects. + #[tokio::test] + async fn ssh_reaches_the_world_through_the_iroh_tunnel() { + let host = two_transports().await; + let conn = crate::connectivity::connect(&host.endpoint, host.addr.clone(), SSH_TUNNEL_ALPN) + .await + .unwrap(); + let (send, recv) = conn.open_bi().await.unwrap(); + let stream = tokio::io::join(recv, send); + let config = Arc::new(client::Config::default()); + let mut session = client::connect_stream(config, stream, TrustAll) + .await + .unwrap(); + assert!( + session + .authenticate_password("", host.capability.as_str()) + .await + .unwrap() + .success() + ); + // Inside the tunnel the frame transport works as usual. + let mut channel = session.channel_open_session().await.unwrap(); + channel.exec(true, WORLD_EXEC_COMMAND).await.unwrap(); + assert!(matches!( + channel.wait().await, + Some(russh::ChannelMsg::Success) + )); + let join = crate::world_net::route_frame( + &serde_json::json!({"type": "join", "capability": host.capability, "after": null}), + Some("lobby"), + ); + channel + .data( + crate::frames::encode_frame(join.as_bytes()) + .unwrap() + .as_slice(), + ) + .await + .unwrap(); + let mut buffer = crate::frames::FrameBuffer::default(); + let frame = tokio::time::timeout(Duration::from_secs(10), async { + loop { + match channel.wait().await { + Some(russh::ChannelMsg::Data { data }) => { + buffer.push(&data); + if let Some(crate::frames::Decoded::Frame(body)) = buffer.next_frame() { + break String::from_utf8(body).unwrap(); + } + } + other => panic!("tunnel closed early: {other:?}"), + } + } + }) + .await + .unwrap(); + assert!(frame.contains("\"type\":\"snapshot\""), "{frame}"); + conn.close(0_u32.into(), b"done"); + host.ssh.abort(); + } + #[test] fn terminal_shell_frames_conform_to_the_shell_contract() { use crate::shell_contract::{Phase, ShellContract}; diff --git a/pkgs/id/tests/cli_integration.rs b/pkgs/id/tests/cli_integration.rs index 5f0673d89..427833b43 100644 --- a/pkgs/id/tests/cli_integration.rs +++ b/pkgs/id/tests/cli_integration.rs @@ -1943,6 +1943,133 @@ mod serve_tests { server.stop(); } + /// F13.3: `id world ssh-tunnel` lets the SSH transport reach a world + /// whose SSH server is only spoken to through iroh. + #[cfg(feature = "ssh")] + #[test] + fn test_world_ssh_tunnel_carries_join_via_ssh() { + let server_dir = TempDir::new().unwrap(); + let client_dir = TempDir::new().unwrap(); + let module = examples_dir().join("roc-counter/counter.wasm"); + let free_port = || { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() + }; + let ssh_port = free_port().to_string(); + let mut server = ServerHandle::spawn_with_args( + server_dir.path(), + &[ + "--world", + "--world-admin-token", + "adm", + "--world-module", + module.to_str().unwrap(), + "--world-ssh-port", + &ssh_port, + ], + ); + let node = server.wait_ready(); + let addr = lock_ipv4_addr(&server.lock_file_path()); + let pem = std::fs::read_to_string(server_dir.path().join(id::world_ssh::SSH_HOST_KEY_FILE)) + .unwrap(); + let key = russh::keys::PrivateKey::from_openssh(pem).unwrap(); + let fingerprint = id::world_ssh::host_key_fingerprint(&key); + + let world = |args: &[&str]| { + let mut cmd = StdCommand::new(get_binary_path()); + cmd.args(["world"]) + .args(args) + .current_dir(client_dir.path()) + .env("HOME", client_dir.path()) + .env_remove("ID_WORLD_ADMIN_TOKEN") + .env_remove("ID_WORLD_CAPABILITY"); + cmd + }; + let invited = world(&[ + "invite", + &node, + "--admin-token", + "adm", + "--name", + "tun", + "--addr", + &addr, + ]) + .output_within(PROCESS_TIMEOUT) + .unwrap(); + assert!( + invited.status.success(), + "{}", + String::from_utf8_lossy(&invited.stderr) + ); + let capability = String::from_utf8(invited.stdout).unwrap().trim().to_owned(); + + let listen = format!("127.0.0.1:{}", free_port()); + let mut tunnel = world(&["ssh-tunnel", &node, "--addr", &addr, "--listen", &listen]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(KillOnDrop) + .unwrap(); + + // Wait until the tunnel accepts, then join through it via SSH. + let target = listen.clone(); + let joined_result = (|| -> Result { + for _ in 0..50 { + let Ok(mut child) = world(&[ + "join", + &target, + "--via", + "ssh", + "--ssh-host-key", + &fingerprint, + "--capability", + &capability, + "--command-mode", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(KillOnDrop) else { + continue; + }; + let mut stdin = child.stdin.take().unwrap(); + let mut lines = BufReader::new(child.stdout.take().unwrap()).lines(); + let _ = stdin.write_all(b"/input 696e63\n"); + let mut text = String::new(); + for _ in 0..6 { + match lines.next() { + Some(Ok(line)) => { + text.push_str(&line); + text.push('\n'); + if line.contains("count=1") { + break; + } + } + _ => break, + } + } + let _ = stdin.write_all(b"/quit\n"); + drop(stdin); + if text.contains("count=1") { + let _ = child.wait(); + return Ok(text); + } + std::thread::sleep(Duration::from_millis(200)); + } + Err("tunnel never carried a join".to_owned()) + })(); + let text = joined_result.unwrap(); + assert!(text.contains("\"type\":\"snapshot\""), "{text}"); + tunnel.kill().unwrap(); + server.stop(); + } + #[test] fn test_tic_tac_toe_installed_over_iroh_and_played_by_two_players() { let server_dir = TempDir::new().unwrap(); From 698bd207c1463a448152aeda3c3cebf9d2fafc04 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Sun, 25 Oct 2026 05:39:34 -0500 Subject: [PATCH 6/6] =?UTF-8?q?fix(config):=20restore=20{env:=E2=80=A6}=20?= =?UTF-8?q?proxy=20port=20fallbacks=20in=20opencode.jsonc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with main regressed two baseURLs to literal ports (18747 muse, 18748 commandcode), failing the treefmt 'proxy port fallbacks' check (#435). Restore the {env:MUSE_PROXY_PORT:-18747} and {env:COMMANDCODE_PROXY_PORT:-18748} forms from main; the file now matches origin/main exactly and check-proxy-port-fallbacks.sh passes. --- .opencode/opencode.jsonc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 2340c5ce7..5340d3988 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -4146,7 +4146,7 @@ // "npm": "@ai-sdk/openai-compatible", // "name": "Muse (CLI via muse-proxy)", // "options": { -// "baseURL": "http://127.0.0.1:18747/v1", +// "baseURL": "http://127.0.0.1:{env:MUSE_PROXY_PORT:-18747}/v1", // "apiKey": "muse-local" // }, // "models": { @@ -4213,7 +4213,7 @@ "npm": "@ai-sdk/openai-compatible", "name": "Command Code (CLI proxy)", "options": { - "baseURL": "http://127.0.0.1:18748/v1", + "baseURL": "http://127.0.0.1:{env:COMMANDCODE_PROXY_PORT:-18748}/v1", "apiKey": "commandcode-local" }, "models": {