diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2edc559c1d..2872c16d58 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2923,6 +2923,18 @@ dependencies = [ "v8", ] +[[package]] +name = "codex-wasm-harness" +version = "0.0.0" +dependencies = [ + "js-sys", + "pretty_assertions", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "codex-windows-sandbox" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index d5a4baa4d5..a91e2f86be 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -52,6 +52,7 @@ members = [ "tui", "tools", "v8-poc", + "wasm-harness", "utils/absolute-path", "utils/cargo-bin", "git-utils", @@ -229,6 +230,7 @@ insta = "1.46.3" inventory = "0.3.19" itertools = "0.14.0" jsonwebtoken = "9.3.1" +js-sys = "0.3.85" keyring = { version = "3.6", default-features = false } landlock = "0.4.4" lazy_static = "1" @@ -335,6 +337,8 @@ urlencoding = "2.1" uuid = "1" vt100 = "0.16.2" walkdir = "2.5.0" +wasm-bindgen = "0.2.108" +wasm-bindgen-futures = "0.4.58" webbrowser = "1.0" which = "8" wildmatch = "2.6.1" diff --git a/codex-rs/wasm-harness/.gitignore b/codex-rs/wasm-harness/.gitignore new file mode 100644 index 0000000000..5c01e36ff1 --- /dev/null +++ b/codex-rs/wasm-harness/.gitignore @@ -0,0 +1 @@ +/examples/pkg/ diff --git a/codex-rs/wasm-harness/BUILD.bazel b/codex-rs/wasm-harness/BUILD.bazel new file mode 100644 index 0000000000..e383cad250 --- /dev/null +++ b/codex-rs/wasm-harness/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "wasm-harness", + crate_name = "codex_wasm_harness", +) diff --git a/codex-rs/wasm-harness/Cargo.toml b/codex-rs/wasm-harness/Cargo.toml new file mode 100644 index 0000000000..6b5583e5b1 --- /dev/null +++ b/codex-rs/wasm-harness/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-wasm-harness" +version.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] +name = "codex_wasm_harness" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +js-sys = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/wasm-harness/README.md b/codex-rs/wasm-harness/README.md new file mode 100644 index 0000000000..da5eb38186 --- /dev/null +++ b/codex-rs/wasm-harness/README.md @@ -0,0 +1,46 @@ +# Codex WASM Harness Prototype + +This crate is the first browser-facing seam for a Codex harness prototype. + +It does not yet call `codex-core::run_turn` or `RegularTask::run`. Instead, it +establishes the intended browser API shape: + +- submit a prompt from JavaScript; +- stream Codex-shaped turn events back to the page; and +- resolve after a `turn_complete` event. + +The sampler is currently a JavaScript callback so the browser demo can keep +network and credential policy outside the WASM bundle. The demo page uses a +deterministic local sampler by default, or a direct Responses API request when +the user enters an API key. + +The API key field is for local prototype use only: it stores the key in browser +`localStorage` and sends it directly from the page. A production browser +integration should use a proxy or an ephemeral-token flow instead of persisting +long-lived API keys in the page origin. + +The next step is to replace the callback boundary with a real model transport +and then wire the facade to the Codex turn loop after host services are +injectable. + +## Current Limitations + +This is a boundary prototype, not a port of `codex-core` yet. + +- It does not construct a `Session` or `TurnContext`. +- It does not call `RegularTask::run` or `run_turn`. +- It does not expose native Codex tools. +- It emits Codex-shaped events, but not the full protocol event set. + +The immediate implementation value is that the browser API and demo page can be +iterated independently while the host-heavy Codex services are moved behind +browser-compatible traits. + +## Build Sketch + +```sh +rustup target add wasm32-unknown-unknown +codex-rs/wasm-harness/scripts/build-browser-demo.sh +``` + +Then serve `codex-rs/wasm-harness/examples` and open `/browser/index.html`. diff --git a/codex-rs/wasm-harness/examples/browser/index.html b/codex-rs/wasm-harness/examples/browser/index.html new file mode 100644 index 0000000000..b99969e800 --- /dev/null +++ b/codex-rs/wasm-harness/examples/browser/index.html @@ -0,0 +1,344 @@ + + + + + + Codex WASM Harness Prototype + + + +
+

Codex WASM harness prototype

+

+ This page exercises the browser boundary: submit a prompt, stream + Codex-shaped events from WASM, and resolve when the turn completes. +

+
+
+

Responses API key

+

+ Local prototype only. The key is stored in this browser's + localStorage and is sent directly from this page to the Responses + API when you run a turn. +

+
+ +
+ + +
+
+
+ + +
+
+
+ + + diff --git a/codex-rs/wasm-harness/scripts/build-browser-demo.sh b/codex-rs/wasm-harness/scripts/build-browser-demo.sh new file mode 100755 index 0000000000..9b39718880 --- /dev/null +++ b/codex-rs/wasm-harness/scripts/build-browser-demo.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +crate_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +workspace_dir="$(cd "$crate_dir/.." && pwd)" + +cd "$workspace_dir" + +cargo build \ + -p codex-wasm-harness \ + --target wasm32-unknown-unknown + +wasm-bindgen \ + --target web \ + --out-dir "$crate_dir/examples/pkg" \ + "$workspace_dir/target/wasm32-unknown-unknown/debug/codex_wasm_harness.wasm" diff --git a/codex-rs/wasm-harness/src/lib.rs b/codex-rs/wasm-harness/src/lib.rs new file mode 100644 index 0000000000..fb27e70da7 --- /dev/null +++ b/codex-rs/wasm-harness/src/lib.rs @@ -0,0 +1,224 @@ +//! Browser-facing prototype facade for a future Codex WASM harness. +//! +//! This crate intentionally starts outside `codex-core`: the first milestone is +//! a working browser boundary that streams Codex-shaped turn events. Later +//! iterations can replace the sampler callback with the real Codex +//! `RegularTask` / `run_turn` path as host services become injectable. + +use js_sys::Function; +use js_sys::Promise; +use js_sys::Reflect; +use serde::Deserialize; +use serde::Serialize; +use wasm_bindgen::JsCast; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +const DEFAULT_INSTRUCTIONS: &str = "You are Codex running in a browser WASM prototype."; + +/// Browser entrypoint for the prototype harness. +#[wasm_bindgen] +pub struct BrowserCodex { + sampler: Option, + next_turn_id: u32, +} + +#[wasm_bindgen] +impl BrowserCodex { + /// Creates a new browser harness. + /// + /// `sampler` may be a JavaScript function that accepts a request object and + /// returns either a string, `{ message: string }`, or a Promise for either. + /// When omitted, the harness uses a deterministic local demo response. + #[wasm_bindgen(constructor)] + #[must_use] + pub fn new(sampler: JsValue) -> Self { + Self { + sampler: sampler.dyn_into::().ok(), + next_turn_id: 0, + } + } + + /// Submits one browser turn and calls `on_event` for every emitted event. + /// + /// The method resolves after `turn_complete` has been emitted. This mirrors + /// Codex's event-driven shape rather than exposing a `prompt -> string` + /// shortcut. + pub async fn submit_turn( + &mut self, + prompt: String, + on_event: Function, + ) -> Result { + self.next_turn_id += 1; + let turn_id = format!("browser-turn-{}", self.next_turn_id); + + emit_event( + &on_event, + &HarnessEvent::TurnStarted { + turn_id: turn_id.clone(), + model_context_window: None, + collaboration_mode_kind: "default", + }, + )?; + emit_event( + &on_event, + &HarnessEvent::UserMessage { + turn_id: turn_id.clone(), + message: prompt.clone(), + }, + )?; + + let request = SamplingRequest::new(turn_id.clone(), prompt.clone()); + let agent_message = self.sample(request).await?; + + emit_event( + &on_event, + &HarnessEvent::AgentMessageDelta { + turn_id: turn_id.clone(), + delta: agent_message.clone(), + }, + )?; + emit_event( + &on_event, + &HarnessEvent::AgentMessage { + turn_id: turn_id.clone(), + message: agent_message.clone(), + }, + )?; + emit_event( + &on_event, + &HarnessEvent::TurnComplete { + turn_id, + last_agent_message: Some(agent_message.clone()), + }, + )?; + + Ok(JsValue::from_str(&agent_message)) + } +} + +impl BrowserCodex { + async fn sample(&self, request: SamplingRequest) -> Result { + let Some(sampler) = &self.sampler else { + return Ok(default_demo_response(&request.prompt)); + }; + + let request_json = serde_json::to_string(&request).map_err(js_error)?; + let request_value = js_sys::JSON::parse(&request_json)?; + let sampled = sampler.call1(&JsValue::NULL, &request_value)?; + let resolved = JsFuture::from(Promise::resolve(&sampled)).await?; + extract_message(resolved) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SamplingRequest { + turn_id: String, + prompt: String, + instructions: &'static str, + tools: Vec, +} + +impl SamplingRequest { + fn new(turn_id: String, prompt: String) -> Self { + Self { + turn_id, + prompt, + instructions: DEFAULT_INSTRUCTIONS, + tools: Vec::new(), + } + } +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum HarnessEvent<'a> { + TurnStarted { + turn_id: String, + model_context_window: Option, + collaboration_mode_kind: &'a str, + }, + UserMessage { + turn_id: String, + message: String, + }, + AgentMessageDelta { + turn_id: String, + delta: String, + }, + AgentMessage { + turn_id: String, + message: String, + }, + TurnComplete { + turn_id: String, + last_agent_message: Option, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SamplerResponse { + message: Option, +} + +fn emit_event(on_event: &Function, event: &HarnessEvent<'_>) -> Result<(), JsValue> { + let json = serde_json::to_string(event).map_err(js_error)?; + let value = js_sys::JSON::parse(&json)?; + on_event.call1(&JsValue::NULL, &value)?; + Ok(()) +} + +fn extract_message(value: JsValue) -> Result { + if let Some(message) = value.as_string() { + return Ok(message); + } + + if value.is_object() { + if let Some(message) = Reflect::get(&value, &JsValue::from_str("message"))?.as_string() { + return Ok(message); + } + + let json = js_sys::JSON::stringify(&value)?; + if let Some(json) = json.as_string() + && let Ok(response) = serde_json::from_str::(&json) + && let Some(message) = response.message + { + return Ok(message); + } + } + + Err(js_error( + "sampler must return a string or an object with a string message field", + )) +} + +fn default_demo_response(prompt: &str) -> String { + if prompt.to_ascii_lowercase().contains("hello world") { + "Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```" + .to_string() + } else { + format!( + "Browser Codex prototype received the prompt, but no model sampler was configured: {prompt}" + ) + } +} + +fn js_error(message: impl ToString) -> JsValue { + JsValue::from_str(&message.to_string()) +} + +#[cfg(test)] +mod tests { + use super::default_demo_response; + use pretty_assertions::assert_eq; + + #[test] + fn default_demo_response_handles_hello_world() { + assert_eq!( + default_demo_response("write hello world"), + "Here is a minimal hello world example:\n\n```js\nconsole.log(\"hello world\");\n```" + ); + } +}