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": { 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` 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..3ff06431d --- /dev/null +++ b/doc/2026-10-25T05-54-40Z_reference_shell_contract/2026-10-25T05-54-40Z_reference_shell_contract.md @@ -0,0 +1,284 @@ +# 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.** 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. + +## 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. + +## 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. + +## 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 +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..77b3e1ae2 --- /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": "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.", + "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..efcbb9524 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, @@ -1535,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 @@ -1739,6 +1779,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/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 74bd6f4d3..117cb2a97 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, }; @@ -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. @@ -510,6 +515,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,22 +706,154 @@ 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).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 + } + } } + WorldCommand::SshTunnel { + node, + listen, + stdio, + addrs, + no_relay, + } => ssh_tunnel(&node, listen.as_deref(), stdio, &addrs, no_relay).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 +} + +/// `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, + _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) @@ -1221,10 +1373,24 @@ impl Replica { } /// Print server frames as JSON lines while sending stdin lines as chat. -async fn pump(client: WorldClient, command_mode: bool) -> Result<()> { - let (conn, mut send, mut recv) = client.into_parts(); +/// Notes for the user go to stderr, so stdout stays a stream of frames. +/// +/// 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(()) @@ -1238,15 +1404,21 @@ 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 { + if let Err(e) = write_frame_to(&mut send, frame.to_string().as_bytes()).await { break Err(e); } } @@ -1258,43 +1430,85 @@ async fn pump(client: WorldClient, command_mode: bool) -> Result<()> { _ = tokio::signal::ctrl_c() => break Ok(()), } }; - conn.close(0_u32.into(), b"done"); reader.abort(); 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, step_is_applied}; + use super::{PumpLine, check_contract, pump_command, scaffold_world, step_is_applied}; /// #818: only grants/ceiling/profile steps are actually applied by /// `apply_steps`; module and schedule steps are reported-only until @@ -1327,31 +1541,79 @@ mod tests { /// 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/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/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_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_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..5e6508398 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,86 @@ 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"; +/// 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). +const MAX_VIEWPORT_HEIGHT: u32 = 200; /// Serve SSH connections until the listener fails. pub async fn serve( @@ -140,6 +231,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)), } } } @@ -171,6 +264,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 { @@ -262,10 +359,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( @@ -323,7 +445,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 { @@ -392,6 +518,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(()); }; @@ -423,6 +559,7 @@ impl Handler for SshClient { _session: &mut Session, ) -> Result<(), russh::Error> { self.input = None; + self.exec = None; Ok(()) } @@ -432,10 +569,53 @@ impl Handler for SshClient { _session: &mut Session, ) -> Result<(), russh::Error> { self.input = None; + self.exec = None; Ok(()) } } +/// 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 { @@ -444,6 +624,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 { @@ -556,44 +774,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 +825,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 +849,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, } @@ -802,6 +983,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 @@ -2012,6 +2372,471 @@ 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 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(), + 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 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(); + } + + /// 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}; + 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 +3132,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..427833b43 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 { @@ -1799,6 +1834,242 @@ 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(); + } + + /// 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(); 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(), + ); + }); +});