diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2872c16d58..dc7a50af3c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2927,12 +2927,14 @@ dependencies = [ name = "codex-wasm-harness" version = "0.0.0" dependencies = [ + "codex-core", "js-sys", "pretty_assertions", "serde", "serde_json", "wasm-bindgen", "wasm-bindgen-futures", + "web-sys", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a91e2f86be..85c15e5b46 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -339,6 +339,7 @@ vt100 = "0.16.2" walkdir = "2.5.0" wasm-bindgen = "0.2.108" wasm-bindgen-futures = "0.4.58" +web-sys = "0.3.85" webbrowser = "1.0" which = "8" wildmatch = "2.6.1" diff --git a/codex-rs/wasm-harness/Cargo.toml b/codex-rs/wasm-harness/Cargo.toml index 6b5583e5b1..91f654e4c3 100644 --- a/codex-rs/wasm-harness/Cargo.toml +++ b/codex-rs/wasm-harness/Cargo.toml @@ -12,12 +12,23 @@ path = "src/lib.rs" [lints] workspace = true +[features] +real-core = ["dep:codex-core"] + [dependencies] +codex-core = { workspace = true, optional = true } js-sys = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } wasm-bindgen = { workspace = true } wasm-bindgen-futures = { workspace = true } +web-sys = { workspace = true, features = [ + "Headers", + "RequestInit", + "RequestMode", + "Response", + "Window", +] } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/wasm-harness/README.md b/codex-rs/wasm-harness/README.md index da5eb38186..65ba4b6c75 100644 --- a/codex-rs/wasm-harness/README.md +++ b/codex-rs/wasm-harness/README.md @@ -9,10 +9,10 @@ establishes the intended browser API shape: - 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 sampler is currently implemented in Rust/WASM. The demo page uses a +deterministic local response by default, or passes the user's API key into the +WASM facade so Rust can make a direct browser `fetch` request to the Responses +API. 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 @@ -23,6 +23,11 @@ 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. +The `real-core` feature is an explicit compile probe for depending on +`codex-core`. It currently does not build for `wasm32-unknown-unknown`; the +first blocker is native Tokio/Mio networking pulled through the host-heavy +`codex-core` dependency graph. + ## Current Limitations This is a boundary prototype, not a port of `codex-core` yet. diff --git a/codex-rs/wasm-harness/examples/browser/index.html b/codex-rs/wasm-harness/examples/browser/index.html index b99969e800..e403fc1015 100644 --- a/codex-rs/wasm-harness/examples/browser/index.html +++ b/codex-rs/wasm-harness/examples/browser/index.html @@ -208,100 +208,31 @@ const apiKeyStatus = document.querySelector("#api-key-status"); const clearApiKeyButton = document.querySelector("#clear-api-key"); const API_KEY_STORAGE_KEY = "codexWasmHarness.apiKey"; - const RESPONSE_MODEL = "gpt-5.1"; + const codex = new BrowserCodex(readStoredApiKey()); apiKeyInput.value = readStoredApiKey(); updateApiKeyStatus(); apiKeyInput.addEventListener("input", () => { - writeStoredApiKey(apiKeyInput.value.trim()); + const apiKey = apiKeyInput.value.trim(); + writeStoredApiKey(apiKey); + codex.set_api_key(apiKey); updateApiKeyStatus(); }); clearApiKeyButton.addEventListener("click", () => { apiKeyInput.value = ""; writeStoredApiKey(""); + codex.set_api_key(""); updateApiKeyStatus(); }); - const sampler = async ({ prompt, instructions }) => { - const apiKey = readStoredApiKey(); - if (apiKey) { - return sampleWithResponsesApi({ apiKey, prompt, instructions }); - } - - if (prompt.toLowerCase().includes("hello world")) { - return { - message: - 'Here is a minimal hello world example:\\n\\n```js\\nconsole.log("hello world");\\n```', - }; - } - return { - message: `Browser sampler placeholder response for: ${prompt}`, - }; - }; - - const codex = new BrowserCodex(sampler); - form.addEventListener("submit", async (event) => { event.preventDefault(); events.replaceChildren(); await codex.submit_turn(input.value, renderEvent); }); - async function sampleWithResponsesApi({ apiKey, prompt, instructions }) { - try { - const response = await fetch("https://api.openai.com/v1/responses", { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: RESPONSE_MODEL, - instructions, - input: prompt, - }), - }); - const body = await response.json(); - if (!response.ok) { - const message = - body?.error?.message ?? `Responses API returned ${response.status}`; - throw new Error(message); - } - - return { message: extractResponseText(body) }; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - return { - message: - `Responses API request failed: ${errorMessage}\n\n` + - "This browser prototype sends the stored key directly from the page. " + - "If the request is blocked, use a local dev proxy or an ephemeral-token flow.", - }; - } - } - - function extractResponseText(response) { - if (typeof response.output_text === "string" && response.output_text) { - return response.output_text; - } - - const chunks = []; - for (const item of response.output ?? []) { - for (const content of item.content ?? []) { - if (typeof content.text === "string") { - chunks.push(content.text); - } else if (typeof content.output_text === "string") { - chunks.push(content.output_text); - } - } - } - - return chunks.join("\n") || JSON.stringify(response, null, 2); - } - function readStoredApiKey() { try { return localStorage.getItem(API_KEY_STORAGE_KEY) ?? ""; diff --git a/codex-rs/wasm-harness/src/lib.rs b/codex-rs/wasm-harness/src/lib.rs index fb27e70da7..a03b19986f 100644 --- a/codex-rs/wasm-harness/src/lib.rs +++ b/codex-rs/wasm-harness/src/lib.rs @@ -2,24 +2,29 @@ //! //! 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 +//! iterations can replace the direct model request 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; +use web_sys::Headers; +use web_sys::RequestInit; +use web_sys::RequestMode; +use web_sys::Response; const DEFAULT_INSTRUCTIONS: &str = "You are Codex running in a browser WASM prototype."; +const RESPONSES_API_URL: &str = "https://api.openai.com/v1/responses"; +const RESPONSES_MODEL: &str = "gpt-5.1"; /// Browser entrypoint for the prototype harness. #[wasm_bindgen] pub struct BrowserCodex { - sampler: Option, + api_key: String, next_turn_id: u32, } @@ -27,18 +32,23 @@ pub struct BrowserCodex { 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. + /// `api_key` may be empty. When it is empty, the harness uses a + /// deterministic local demo response. When it is present, the harness makes + /// a browser `fetch` call to the Responses API from Rust/WASM. #[wasm_bindgen(constructor)] #[must_use] - pub fn new(sampler: JsValue) -> Self { + pub fn new(api_key: String) -> Self { Self { - sampler: sampler.dyn_into::().ok(), + api_key, next_turn_id: 0, } } + /// Updates the API key used by future turns. + pub fn set_api_key(&mut self, api_key: String) { + self.api_key = api_key; + } + /// Submits one browser turn and calls `on_event` for every emitted event. /// /// The method resolves after `turn_complete` has been emitted. This mirrors @@ -68,8 +78,7 @@ impl BrowserCodex { }, )?; - let request = SamplingRequest::new(turn_id.clone(), prompt.clone()); - let agent_message = self.sample(request).await?; + let agent_message = self.sample(&prompt).await?; emit_event( &on_event, @@ -98,37 +107,21 @@ impl BrowserCodex { } impl BrowserCodex { - async fn sample(&self, request: SamplingRequest) -> Result { - let Some(sampler) = &self.sampler else { - return Ok(default_demo_response(&request.prompt)); - }; + async fn sample(&self, prompt: &str) -> Result { + if self.api_key.trim().is_empty() { + return Ok(default_demo_response(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) + sample_with_responses_api(self.api_key.trim(), prompt).await } } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] -struct SamplingRequest { - turn_id: String, - prompt: String, +struct ResponsesRequest<'a> { + model: &'static str, 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(), - } - } + input: &'a str, } #[derive(Debug, Serialize)] @@ -158,11 +151,102 @@ enum HarnessEvent<'a> { } #[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SamplerResponse { +struct ResponsesBody { + output_text: Option, + output: Option>, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct ResponsesError { message: Option, } +#[derive(Debug, Deserialize)] +struct ResponsesOutputItem { + content: Option>, +} + +#[derive(Debug, Deserialize)] +struct ResponsesContentItem { + text: Option, + output_text: Option, +} + +async fn sample_with_responses_api(api_key: &str, prompt: &str) -> Result { + let body = ResponsesRequest { + model: RESPONSES_MODEL, + instructions: DEFAULT_INSTRUCTIONS, + input: prompt, + }; + let body = serde_json::to_string(&body).map_err(js_error)?; + + let headers = Headers::new()?; + headers.append("Authorization", &format!("Bearer {api_key}"))?; + headers.append("Content-Type", "application/json")?; + + let request = RequestInit::new(); + request.set_method("POST"); + request.set_mode(RequestMode::Cors); + request.set_headers(&headers); + request.set_body(&JsValue::from_str(&body)); + + let window = web_sys::window().ok_or_else(|| js_error("window is unavailable"))?; + let response_value = + JsFuture::from(window.fetch_with_str_and_init(RESPONSES_API_URL, &request)) + .await + .map_err(response_error)?; + let response: Response = response_value.dyn_into()?; + let status = response.status(); + let ok = response.ok(); + let json = JsFuture::from(response.json()?) + .await + .map_err(response_error)?; + let response_body = parse_response_body(json)?; + + if !ok { + let message = response_body + .error + .and_then(|err| err.message) + .unwrap_or_else(|| format!("Responses API returned {status}")); + return Err(js_error(message)); + } + + Ok(extract_response_text(response_body)) +} + +fn parse_response_body(value: JsValue) -> Result { + let json = js_sys::JSON::stringify(&value)? + .as_string() + .ok_or_else(|| js_error("Responses API returned non-JSON output"))?; + serde_json::from_str(&json).map_err(js_error) +} + +fn extract_response_text(response: ResponsesBody) -> String { + if let Some(output_text) = response.output_text + && !output_text.is_empty() + { + return output_text; + } + + let mut chunks = Vec::new(); + for item in response.output.unwrap_or_default() { + for content in item.content.unwrap_or_default() { + if let Some(text) = content.text { + chunks.push(text); + } else if let Some(output_text) = content.output_text { + chunks.push(output_text); + } + } + } + + if chunks.is_empty() { + "Responses API returned no output text.".to_string() + } else { + chunks.join("\n") + } +} + 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)?; @@ -170,41 +254,32 @@ fn emit_event(on_event: &Function, event: &HarnessEvent<'_>) -> Result<(), JsVal 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}" + "Browser Codex prototype received the prompt, but no API key was configured: {prompt}" ) } } +fn response_error(error: JsValue) -> JsValue { + if let Some(message) = error.as_string() { + return js_error(message); + } + + if error.is_object() + && let Ok(value) = Reflect::get(&error, &JsValue::from_str("message")) + && let Some(message) = value.as_string() + { + return js_error(message); + } + + js_error("Responses API request failed") +} + fn js_error(message: impl ToString) -> JsValue { JsValue::from_str(&message.to_string()) }