From 64f78a3dc5cd6c76bad98fd03849ac91553a6ac5 Mon Sep 17 00:00:00 2001 From: Jeremy lewi Date: Thu, 16 Apr 2026 09:40:33 -0700 Subject: [PATCH] Add browser session instruction injection --- 20260416_system_instructions.md | 149 ++++++++++++++++++ codex-rs/wasm-harness/README.md | 22 +++ .../wasm-harness/examples/browser/index.html | 18 +++ codex-rs/wasm-harness/src/browser.rs | 101 ++++++++++-- 4 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 20260416_system_instructions.md diff --git a/20260416_system_instructions.md b/20260416_system_instructions.md new file mode 100644 index 0000000000..950ac4023e --- /dev/null +++ b/20260416_system_instructions.md @@ -0,0 +1,149 @@ +# Browser System Instructions Injection + +## Problem + +The browser harness can run a real Codex turn, but the `wasm32` build does not +load project docs from `AGENTS.md` today. In the browser, the application often +knows important environment-specific policy that the harness does not know: + +- code runs inside AppKernel; +- browser APIs such as OPFS and network access are available; +- only a specific OPFS path layout is allowed; +- only specific GitHub URL prefixes are allowed; and +- the agent is expected to fetch, cache, and search source code itself. + +Those details need to reach the model as prompt-layer instructions even when the +harness cannot discover them from the filesystem. + +## Goals + +- Let browser applications inject session-scoped prompt instructions. +- Keep prompt roles distinct: + - base instructions remain the base/system layer; + - developer instructions carry application or runtime policy; + - user instructions carry AGENTS/project-doc style content. +- Keep responsibility boundaries clean: + - the application decides where instructions come from; + - the harness only accepts and applies resolved instruction text. + +## Non-Goals + +- Teach the wasm harness to discover `AGENTS.md` on its own. +- Move application-specific policy such as AppKernel, OPFS layout, or GitHub + allowlists into `codex-core`. +- Replace Codex's built-in base prompt. + +## Existing State + +`codex-core` already has the right internal prompt channels: + +- `Config.base_instructions` +- `Config.developer_instructions` +- `Config.user_instructions` + +The browser adapter does not expose them. `BrowserCodex` currently accepts an +API key and a code executor, then builds a session config internally with a +fixed cwd. The wasm project-doc loader is also stubbed out, so no `AGENTS.md` +content is discovered automatically. + +## Proposed API + +Add a browser-session configuration object that the application can set before +starting a turn: + +```ts +type BrowserSessionOptions = { + cwd?: string; + instructions?: { + base?: string; + developer?: string; + user?: string; + }; +}; +``` + +Expose it through the browser harness as a session-scoped setter: + +```ts +const codex = new BrowserCodex(apiKey); +codex.setSessionOptions({ + cwd: "/workspace", + instructions: { + developer: "...AppKernel/OPFS/network/GitHub policy...", + user: "...resolved AGENTS.md text...", + }, +}); +``` + +## Prompt Layer Mapping + +### `instructions.developer` + +Use for environment and policy details supplied by the browser application, for +example: + +- code runs inside AppKernel; +- OPFS and network APIs are available; +- approved OPFS path layout; +- approved GitHub URL prefixes; and +- fetch/cache/search expectations. + +This maps to `Config.developer_instructions`. + +### `instructions.user` + +Use for application-resolved project docs such as `AGENTS.md`. + +This maps to `Config.user_instructions`. + +### `instructions.base` + +Use only as an escape hatch when the embedding application intentionally wants +to replace the base instruction bundle for the session. + +This maps to `Config.base_instructions`. + +Most callers should leave this unset so Codex keeps its built-in base prompt. + +## Session Lifetime + +These values are session-scoped, not turn-scoped. + +If `cwd` or any injected instruction changes, the harness should discard the +current browser session and create a new one on the next turn. This avoids +mixing old prompt state with new environment policy. + +## API Boundary + +### Application responsibilities + +- discover `AGENTS.md` or any equivalent project-doc source; +- decide the approved OPFS layout; +- decide the approved GitHub URL prefixes; +- decide whether AppKernel/network guidance should be present; and +- assemble those values into prompt text. + +### Harness responsibilities + +- accept already-resolved session options; +- validate and store them; +- apply them to the Codex session config; +- ensure the configured cwd is used consistently for the session and for turn + submission; and +- recreate the session when session options change. + +## Implementation Notes + +- Keep the API additive. Existing callers that only set the API key and code + executor should continue to work. +- Reject unknown fields in the session-options payload so applications do not + silently think a field is applied when it is ignored. +- Apply the configured cwd both when building the browser config and when + calling `Op::UserTurn`. + +## Initial Scope + +Implement the new `BrowserCodex` session-options setter in `wasm-harness`, +apply it to config creation, and update the browser demo to act like an +embedding application by supplying explicit developer instructions for the +browser/AppKernel environment. diff --git a/codex-rs/wasm-harness/README.md b/codex-rs/wasm-harness/README.md index aca411b6cb..82970f51f1 100644 --- a/codex-rs/wasm-harness/README.md +++ b/codex-rs/wasm-harness/README.md @@ -29,10 +29,32 @@ webapps can: - depend on `codex-wasm-harness` from a Git branch or local path; - construct `BrowserCodex` from JavaScript or wrap the crate with their own browser bindings; +- provide session-scoped prompt inputs such as `cwd`, developer instructions, + and user/project-doc instructions; - supply their own browser/runtime implementations for host services such as code execution, event rendering, or persistence; and - keep app-specific browser glue out of the Codex repo. +The intended boundary is: + +- the application resolves instruction sources such as `AGENTS.md`, AppKernel + policy, approved OPFS layout, and approved GitHub URL prefixes; +- the harness accepts those resolved strings and injects them into the Codex + session as base, developer, or user instructions. + +Example: + +```js +const codex = new BrowserCodex(apiKey); +codex.setSessionOptions({ + cwd: "/workspace", + instructions: { + developer: "Code runs inside AppKernel. OPFS and network APIs are available.", + user: "# AGENTS.md instructions for /workspace\n\n\n...\n", + }, +}); +``` + ## Current Limitations This is a minimal browser port of the Codex turn loop, not full desktop Codex. diff --git a/codex-rs/wasm-harness/examples/browser/index.html b/codex-rs/wasm-harness/examples/browser/index.html index 7cae344054..9ea5d8970b 100644 --- a/codex-rs/wasm-harness/examples/browser/index.html +++ b/codex-rs/wasm-harness/examples/browser/index.html @@ -215,9 +215,27 @@ const API_KEY_STORAGE_KEY = "codexWasmHarness.apiKey"; const codex = new BrowserCodex(readStoredApiKey()); const sandbox = createSandboxKernel(); + const browserSessionOptions = { + cwd: "/workspace", + instructions: { + developer: [ + "Code mode runs inside AppKernel, exposed in this demo through a browser iframe sandbox.", + "Browser network APIs and OPFS are available to code running in AppKernel.", + "Approved OPFS path layout:", + "- /workspace: active workspace files and entrypoints", + "- /workspace/cache: fetched artifacts and caches", + "- /workspace/repos: cloned or mirrored source trees", + "Approved GitHub URL prefixes:", + "- https://github.com/openai/", + "- https://raw.githubusercontent.com/openai/", + "Fetch, cache, and search source code yourself using the available browser APIs instead of assuming the harness preloads repository contents.", + ].join("\n"), + }, + }; apiKeyInput.value = readStoredApiKey(); updateApiKeyStatus(); + codex.setSessionOptions(browserSessionOptions); codex.set_code_executor(async (requestJson) => sandbox.execute(requestJson), ); diff --git a/codex-rs/wasm-harness/src/browser.rs b/codex-rs/wasm-harness/src/browser.rs index 5a4029aba2..273b023f8a 100644 --- a/codex-rs/wasm-harness/src/browser.rs +++ b/codex-rs/wasm-harness/src/browser.rs @@ -32,6 +32,22 @@ const BROWSER_CODEX_HOME: &str = "/codex-wasm"; const BROWSER_CWD: &str = "/workspace"; const DEFAULT_MODEL: &str = "gpt-5-codex"; +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrowserInstructionOverrides { + base: Option, + developer: Option, + user: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct BrowserSessionOptions { + cwd: Option, + #[serde(default)] + instructions: BrowserInstructionOverrides, +} + #[derive(Default)] struct BrowserRuntimeState { executor: Mutex>, @@ -213,6 +229,7 @@ pub struct BrowserCodex { api_key: String, runtime_state: Arc, session: Option, + session_options: BrowserSessionOptions, } #[wasm_bindgen] @@ -224,6 +241,7 @@ impl BrowserCodex { api_key, runtime_state: Arc::new(BrowserRuntimeState::default()), session: None, + session_options: BrowserSessionOptions::default(), } } @@ -232,6 +250,22 @@ impl BrowserCodex { self.session = None; } + #[wasm_bindgen(js_name = setSessionOptions)] + pub fn set_session_options(&mut self, options: JsValue) -> Result<(), JsValue> { + let parsed = parse_browser_session_options(options).map_err(harness_error_to_js)?; + if self.session_options != parsed { + self.session = None; + } + self.session_options = parsed; + Ok(()) + } + + #[wasm_bindgen(js_name = clearSessionOptions)] + pub fn clear_session_options(&mut self) { + self.session_options = BrowserSessionOptions::default(); + self.session = None; + } + pub fn set_code_executor(&mut self, executor: Function) { *self .runtime_state @@ -312,7 +346,7 @@ impl BrowserCodex { text: prompt, text_elements: Vec::new(), }], - cwd: PathBuf::from(BROWSER_CWD), + cwd: session.config.cwd.as_path().to_path_buf(), approval_policy: session.config.permissions.approval_policy.get().clone(), approvals_reviewer: Some(session.config.approvals_reviewer), sandbox_policy: session.config.permissions.sandbox_policy.get().clone(), @@ -354,9 +388,7 @@ impl BrowserCodex { } async fn create_session(&self) -> Result { - let config = build_browser_config() - .await - .map_err(|error| HarnessError::new(format!("build_browser_config: {error}")))?; + let config = build_browser_config(&self.session_options)?; let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key(self.api_key.trim())); let environment_manager = Arc::new(EnvironmentManager::new(None)); @@ -384,12 +416,11 @@ impl BrowserCodex { } } -async fn build_browser_config() -> Result { - let mut config = Config::load_embedded_defaults( - PathBuf::from(BROWSER_CODEX_HOME), - PathBuf::from(BROWSER_CWD), - ) - .map_err(|error| HarnessError::new(error.to_string()))?; +fn build_browser_config(session_options: &BrowserSessionOptions) -> Result { + let cwd = session_options.cwd.as_deref().unwrap_or(BROWSER_CWD); + let mut config = + Config::load_embedded_defaults(PathBuf::from(BROWSER_CODEX_HOME), PathBuf::from(cwd)) + .map_err(|error| HarnessError::new(error.to_string()))?; let _ = config.features.enable(Feature::CodeMode); let _ = config.features.enable(Feature::CodeModeOnly); config.model = Some( @@ -398,9 +429,25 @@ async fn build_browser_config() -> Result { .clone() .unwrap_or_else(|| DEFAULT_MODEL.to_string()), ); + config.base_instructions = session_options.instructions.base.clone(); + config.developer_instructions = session_options.instructions.developer.clone(); + config.user_instructions = session_options.instructions.user.clone(); Ok(config) } +fn parse_browser_session_options(options: JsValue) -> Result { + if options.is_null() || options.is_undefined() { + return Ok(BrowserSessionOptions::default()); + } + + let json = js_sys::JSON::stringify(&options).map_err(js_exception)?; + let text = json + .as_string() + .ok_or_else(|| HarnessError::new("browser session options must be JSON-serializable"))?; + serde_json::from_str(&text) + .map_err(|error| HarnessError::new(format!("invalid browser session options: {error}"))) +} + async fn await_possible_promise(value: JsValue) -> Result { if let Ok(promise) = value.clone().dyn_into::() { JsFuture::from(promise).await.map_err(js_exception) @@ -442,3 +489,37 @@ fn js_value_to_string_lossy(value: &JsValue) -> String { fn harness_error_to_js(error: HarnessError) -> JsValue { JsValue::from_str(error.message()) } + +#[cfg(test)] +mod tests { + use super::BrowserInstructionOverrides; + use super::BrowserSessionOptions; + use super::build_browser_config; + use pretty_assertions::assert_eq; + + #[test] + fn build_browser_config_applies_session_options() { + let config = build_browser_config(&BrowserSessionOptions { + cwd: Some("/workspace/repo".to_string()), + instructions: BrowserInstructionOverrides { + base: Some("base".to_string()), + developer: Some("developer".to_string()), + user: Some("user".to_string()), + }, + }) + .expect("browser config"); + + assert_eq!(config.cwd.as_path().to_string_lossy(), "/workspace/repo"); + assert_eq!(config.base_instructions.as_deref(), Some("base")); + assert_eq!(config.developer_instructions.as_deref(), Some("developer")); + assert_eq!(config.user_instructions.as_deref(), Some("user")); + } + + #[test] + fn build_browser_config_defaults_to_workspace_cwd() { + let config = + build_browser_config(&BrowserSessionOptions::default()).expect("browser config"); + + assert_eq!(config.cwd.as_path().to_string_lossy(), "/workspace"); + } +}