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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .opencode/plans/phase-9-portable-platform.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ The immediate slice (P9.1) is a backwards-compatible, validated cell-grid presen
4. **P9.4 Standard libraries.** Add tested Roc JSON parse/encode primitives and migrate an app/example to structured host events, records, and presentation. Grow data, game, and communication helpers on stable capabilities rather than host-specific shortcuts.
5. **P9.5 Adapters/examples.** Provide a Rust/Ratatui buffer-to-screen adapter or documented capture interface, plus equivalent terminal and browser examples. Ratatui widgets alone are not an app runtime; applications still supply update state and event-loop logic.

## Current worktree scope
## Status and current worktree scope

Implement P9.1 only, as a stacked PR on `platform-roc-web`. Do not mix source-only policy, compiler pinning, generalized storage, or unconfined execution into this presentation change. Capture all of those follow-ups here and in the design record.
- **P9.1 complete:** portable surface contract and SSH/web presenters are committed on `platform-portable-ui` (PR #214, stacked on #209).
- **P9.3a complete in this worktree:** server-wide source-only install policy, stacked on #214. Source compilation remains enabled and raw artifacts are refused. Compiler provenance, per-world profiles, and broader allowed-function ceilings remain open.
- **P9.2 next:** viewport and typed interactive input needs transport/session design so resize is not accidentally journaled as shared durable application state.
- **P9.4/P9.5 remain planned:** Roc JSON/data helpers and Ratatui-shaped adapters/examples.

“Direct control” is limited to explicit named capabilities under an admin-controlled ceiling. This plan does not enable unrestricted filesystem, network, or process access; native execution remains OS-sandboxed.

## Acceptance for P9.1

Expand All @@ -28,3 +33,12 @@ Implement P9.1 only, as a stacked PR on `platform-roc-web`. Do not mix source-on
- Roc `Screen` can produce the DTO without asking app authors to hand-concatenate JSON.

See [the design record](../../doc/2026-10-14T19-39-30Z_architecture_portable_world_surfaces/2026-10-14T19-39-30Z_architecture_portable_world_surfaces.md).

P9.3a record: [source-only world installs](../../doc/2026-10-14T20-00-05Z_feature_source_only_world_installs/2026-10-14T20-00-05Z_feature_source_only_world_installs.md).

## P9.3a acceptance

- Source-only is an explicit server option, off by default, and requires an enabled world plus a configured server-side Roc compiler/platform.
- Raw upload is rejected before consuming chunks and raw installation is also refused through the service interface.
- Source compilation still validates and installs through the established module validator, persistence, and activation path.
- Native execution stays separately gated; trusted compiler provenance is not claimed until compiler identity is recorded.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Source-only world module installation

## Intent

### Request (verbatim)

> continue with any missing features. build out the platforms, capabilities, standard libraries across the feature set. ensure that most any ratatui shaped ssh app could be built using this tool set, and that any tui/ssh interface can usually be presented in web, either a direct translation or the app offers multiple presentation interfaces. ensure security sanbdoxing and other limits are customizable, in case an admin wants to Enable direct control. a server can also control access by what functions it allows in the roc interface and by only accepting untrusted code in source form, compiling the roc code itself and only using trusted sources. continue to fully implement all features one would expect for a general platform for data games communication etc.

This phase implements the source-only policy slice of that request; it does not declare the whole platform roadmap complete.

Add an operator-controlled mode for Roc worlds that accepts new guest programs only as Roc source compiled by this server's configured compiler. The normal Wasm path remains bounded and import-free. Raw Wasm/native artifact installation is a trusted-artifact feature and must be disabled entirely when source-only mode is enabled, including direct library calls and the streaming upload protocol.

This does not claim compiler supply-chain provenance: the executable is configured by the operator, but its version/digest is not currently recorded beside the active module. That remains a follow-up before compiler provenance can be claimed. Nor does source-only mean compilation is harmless; source size, compiler wall time and child process limits still apply.

## Interface and behavior

- Add `--world-source-only` (and `ID_WORLD_SOURCE_ONLY`) for `id serve --world`.
- Require a configured Roc compiler and reject `--world-module` at startup; those raw artifacts bypass the source path.
- Reject raw `install_wasm` and the WebSocket/Iroh streaming upload protocol before accepting bytes.
- Preserve `compile_and_install`: successful server-compiled output goes through the same hash validation, Wasm validator, module persistence, and atomic activation, with an internal marker distinguishing it from artifact upload.
- Do not implicitly enable native execution. If native compilation is requested, it still requires the separately opted-in OS-sandboxed native tier.
- Default remains backward compatible (`source-only = false`).

## Validation

Test both the CLI option constraints and `WorldService` behavior: raw artifacts are denied with/without admin auth; source compilation remains available when a compiler is configured; default mode still accepts an admin-authenticated Wasm module; and a source-only server cannot bootstrap from a raw `--world-module` file.

## Follow-up gaps

- Store compiler identity/version or digest with the installed module and expose it in module info.
- Add configurable compiler subprocess memory/CPU/file-size limits; current compiler limits include fixed time and output/source caps.
- Make resource profiles per-world and expose the implemented screen/payload limits as documented policy where appropriate.
- Keep native code isolated by Landlock/seccomp/rlimits; no unconfined host direct-control switch is introduced.
19 changes: 19 additions & 0 deletions pkgs/id/examples/roc-world/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ the probe re-snapshots to identical text and publishes identical records.
Both exports are optional at the Wasm level, so older modules keep working and
keep their full journals.

## Source-only server policy

For a server that accepts guest programs only as Roc source, enable
`id serve --world --world-admin-token <secret> --world-source-only` and configure
the trusted compiler/platform with `--roc-bin` and `--roc-platform` (or their
`ID_ROC_BIN` / `ID_ROC_PLATFORM` environment variables). The server refuses
startup if source-only mode has no compiler/platform, and the flag cannot be
combined with a raw `--world-module` bootstrap. It rejects raw Wasm/native
uploads before receiving upload chunks; authorized source compilation still
installs through the normal Wasm validation and execution bounds. The default
remains backward-compatible and accepts admin-authenticated raw modules.

Source-only mode is not a compiler sandbox or compiler provenance guarantee:
the server operator must trust the configured compiler and platform. The
compiler version/digest is not yet recorded with each module; see the [source
policy design record](../../../../doc/2026-10-14T20-00-05Z_feature_source_only_world_installs/2026-10-14T20-00-05Z_feature_source_only_world_installs.md)
for tracked follow-ups. Native execution remains a separate opt-in and retains
OS sandboxing.

The host passes the id of the participant who sent an event as the second
argument of `update`, so programs can implement turn-taking or per-player
rules without the host knowing anything about the game.
Expand Down
42 changes: 42 additions & 0 deletions pkgs/id/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,15 @@ pub enum Command {
/// import-free format, so this tier is opt-in.
#[arg(long, requires = "world")]
world_native: bool,
/// Accept new world programs only as Roc source compiled by this server.
/// Disables raw Wasm/native module uploads and `--world-module`.
#[arg(
long,
env = "ID_WORLD_SOURCE_ONLY",
requires = "world",
conflicts_with = "world_module"
)]
world_source_only: bool,
/// Execution bounds for world programs.
#[command(flatten)]
world_runtime: WorldRuntimeArgs,
Expand Down Expand Up @@ -3369,6 +3378,39 @@ mod tests {
);
}

#[test]
fn source_only_mode_is_opt_in_and_requires_world_mode() {
let parsed = Cli::try_parse_from([
"id",
"serve",
"--world",
"--world-admin-token",
"admin",
"--world-source-only",
])
.unwrap();
match parsed.command {
Some(Command::Serve {
world_source_only, ..
}) => assert!(world_source_only),
_ => panic!("Expected Serve command"),
}
assert!(Cli::try_parse_from(["id", "serve", "--world-source-only"]).is_err());
assert!(
Cli::try_parse_from([
"id",
"serve",
"--world",
"--world-admin-token",
"admin",
"--world-source-only",
"--world-module",
"trusted.wasm",
])
.is_err()
);
}

#[test]
fn test_cli_parse_web_cookie_secure() {
match Cli::parse_from(["id", "serve", "--web", "--web-cookie-secure"]).command {
Expand Down
73 changes: 58 additions & 15 deletions pkgs/id/src/commands/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,8 @@ pub struct ServeOptions {
pub world_ssh_port: Option<u16>,
/// Accept native (ELF) module installs.
pub world_native: bool,
/// Accept program changes only through server-side Roc compilation.
pub world_source_only: bool,
/// Execution bounds for world programs.
pub world_runtime: crate::cli::WorldRuntimeArgs,
/// Nodes allowed to modify the store.
Expand All @@ -339,6 +341,7 @@ fn validate_world_options(
world_module: Option<&PathBuf>,
world_name: &str,
world_cap_policy: &str,
world_source_only: bool,
) -> Result<()> {
ensure!(
!world || admin_token.is_some_and(|token| !token.is_empty()),
Expand All @@ -354,6 +357,14 @@ fn validate_world_options(
world_cap_policy == "deny" || world_cap_policy == "grant-on-use",
"--world-cap-policy must be `deny` or `grant-on-use`"
);
ensure!(
!world_source_only || world_module.is_none(),
"--world-source-only cannot be combined with --world-module"
);
ensure!(
!world_source_only || world,
"--world-source-only requires --world"
);
#[cfg(not(feature = "world"))]
let _ = world_name;
Ok(())
Expand Down Expand Up @@ -474,6 +485,7 @@ struct ServeWorlds {
grant_on_use: bool,
compiler: Option<crate::world_compile::Compiler>,
native: bool,
source_only: bool,
runtime: crate::world_limits::RuntimeLimits,
mail: Option<Arc<dyn crate::directory_mail::MailSink>>,
}
Expand Down Expand Up @@ -530,10 +542,12 @@ impl ServeWorlds {
let admin_token = self.admin_token.clone();
let blobs = self.blobs.clone();
let mail = self.mail.clone();
let source_only = self.source_only;
move |handle| {
let service = WorldService::new(handle, admin_token)
.with_blob_store(blobs)
.with_runtime_limits(runtime);
.with_runtime_limits(runtime)
.with_source_only(source_only);
match &mail {
Some(sink) => service.with_mail(Arc::clone(sink)),
None => service,
Expand Down Expand Up @@ -697,6 +711,7 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> {
roc_bin,
roc_platform,
world_native,
world_source_only,
world_ssh_port,
world_runtime,
allow_node,
Expand All @@ -708,6 +723,7 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> {
world_module.as_ref(),
&world_name,
&world_cap_policy,
world_source_only,
)?;
let key = load_or_create_keypair(KEY_FILE).await?;
let node_id: EndpointId = key.public();
Expand Down Expand Up @@ -797,6 +813,11 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> {
let world_hub = if world {
use crate::world_hub::{HubLimits, WorldHub};
let runtime = crate::world_limits::RuntimeLimits::from_args(&world_runtime)?;
let compiler = resolve_compiler(roc_bin, roc_platform)?;
ensure!(
!world_source_only || compiler.is_some(),
"--world-source-only requires a configured Roc compiler and platform (see --roc-bin and --roc-platform)"
);
let opener = Arc::new(ServeWorlds {
ephemeral,
admin_token: world_admin_token.clone(),
Expand All @@ -806,8 +827,9 @@ pub async fn cmd_serve(opts: ServeOptions) -> Result<()> {
default_world: world_name.clone(),
default_module: world_module.clone(),
grant_on_use: world_cap_policy == "grant-on-use",
compiler: resolve_compiler(roc_bin, roc_platform)?,
compiler,
native: world_native,
source_only: world_source_only,
runtime,
mail: mail_sink(
world_mail_outbox,
Expand Down Expand Up @@ -1302,27 +1324,41 @@ mod tests {

#[test]
fn world_mode_requires_an_admin_token_but_not_web() {
assert!(validate_world_options(true, None, None, "lobby", "deny").is_err());
assert!(validate_world_options(true, Some(""), None, "lobby", "deny").is_err());
assert!(validate_world_options(true, Some("admin"), None, "lobby", "deny").is_ok());
assert!(validate_world_options(false, None, None, "lobby", "deny").is_ok());
assert!(validate_world_options(true, None, None, "lobby", "deny", false).is_err());
assert!(validate_world_options(true, Some(""), None, "lobby", "deny", false).is_err());
assert!(validate_world_options(true, Some("admin"), None, "lobby", "deny", false).is_ok());
assert!(validate_world_options(false, None, None, "lobby", "deny", false).is_ok());
assert!(
validate_world_options(
true,
Some("admin"),
Some(&PathBuf::from("world.wasm")),
"lobby",
"deny"
"deny",
false
)
.is_ok()
);
assert!(validate_world_options(true, Some("admin"), None, "lobby", "deny", true).is_ok());
assert!(
validate_world_options(
true,
Some("admin"),
Some(&PathBuf::from("world.wasm")),
"lobby",
"deny",
true
)
.is_err()
);
assert!(
validate_world_options(
false,
None,
Some(&PathBuf::from("world.wasm")),
"lobby",
"deny"
"deny",
false
)
.is_err()
);
Expand Down Expand Up @@ -1357,19 +1393,26 @@ mod tests {
"wörld",
] {
assert!(
validate_world_options(true, Some("admin"), None, bad, "deny").is_err(),
validate_world_options(true, Some("admin"), None, bad, "deny", false).is_err(),
"{bad:?} must be refused"
);
}
assert!(validate_world_options(true, Some("admin"), None, "tt-2_x", "deny").is_ok());
assert!(validate_world_options(true, Some("admin"), None, &"a".repeat(64), "deny").is_ok());
assert!(validate_world_options(true, Some("admin"), None, "tt-2_x", "deny", false).is_ok());
assert!(
validate_world_options(true, Some("admin"), None, &"a".repeat(64), "deny", false)
.is_ok()
);
assert!(
validate_world_options(true, Some("admin"), None, &"a".repeat(65), "deny", false)
.is_err()
);
assert!(validate_world_options(true, Some("admin"), None, "lobby", "deny", false).is_ok());
assert!(
validate_world_options(true, Some("admin"), None, &"a".repeat(65), "deny").is_err()
validate_world_options(true, Some("admin"), None, "lobby", "grant-on-use", false)
.is_ok()
);
assert!(validate_world_options(true, Some("admin"), None, "lobby", "deny").is_ok());
assert!(validate_world_options(true, Some("admin"), None, "lobby", "grant-on-use").is_ok());
assert!(
validate_world_options(true, Some("admin"), None, "lobby", "sometimes").is_err(),
validate_world_options(true, Some("admin"), None, "lobby", "sometimes", false).is_err(),
"an unknown policy is refused"
);
}
Expand Down
2 changes: 2 additions & 0 deletions pkgs/id/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ async fn main() -> Result<()> {
roc_bin,
roc_platform,
world_native,
world_source_only,
world_runtime,
allow_node,
open_writes,
Expand Down Expand Up @@ -224,6 +225,7 @@ async fn main() -> Result<()> {
roc_bin,
roc_platform,
world_native,
world_source_only,
world_runtime,
allow_node,
open_writes,
Expand Down
2 changes: 1 addition & 1 deletion pkgs/id/src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ impl SaveRateLimiter {
if let Some(last) = map.get(name) {
let elapsed = last.elapsed();
if elapsed < self.cooldown {
return Err(self.cooldown.checked_sub(elapsed).unwrap());
return Err(self.cooldown.saturating_sub(elapsed));
}
}
Ok(())
Expand Down
3 changes: 2 additions & 1 deletion pkgs/id/src/web/world_ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,8 @@ mod tests {
let blobs: iroh_blobs::api::Store = MemStore::new().into();
let service = WorldService::new(world.clone(), Some("admin".to_owned()))
.with_blob_store(blobs.clone())
.with_compiler(compiler);
.with_compiler(compiler)
.with_source_only(true);
let addr = serve(world_routes().with_state(WorldWebState {
hub: Some(WorldHub::single(service)),
cookie_secure: false,
Expand Down
13 changes: 11 additions & 2 deletions pkgs/id/src/world_compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,17 @@ mod tests {
)
.unwrap();
let frame = String::from_utf8(world.view().unwrap()).unwrap();
let rows: Vec<&str> = frame.split('\n').collect();
assert_eq!(rows.len(), 3);
let Some(crate::world_presentation::WorldSurface::Screen(screen)) =
crate::world_presentation::parse_surface(&frame).unwrap()
else {
panic!("Roc screen example must return a structured screen");
};
assert_eq!((screen.width, screen.height), (20, 3));
let rows: Vec<String> = screen
.cells
.chunks(usize::from(screen.width))
.map(|row| row.iter().map(|cell| cell.symbol.as_str()).collect())
.collect();
assert!(rows[0].starts_with("+-counter"));
assert!(rows[1].starts_with("| count=1"));
assert_eq!(rows[2], format!("+{}+", "-".repeat(18)));
Expand Down
Loading
Loading