Add browser session instruction injection

This commit is contained in:
Jeremy lewi
2026-04-16 09:40:33 -07:00
parent 45cfcb0c7a
commit 64f78a3dc5
4 changed files with 280 additions and 10 deletions
+149
View File
@@ -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.
+22
View File
@@ -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<INSTRUCTIONS>\n...\n</INSTRUCTIONS>",
},
});
```
## Current Limitations
This is a minimal browser port of the Codex turn loop, not full desktop Codex.
@@ -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),
);
+91 -10
View File
@@ -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<String>,
developer: Option<String>,
user: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct BrowserSessionOptions {
cwd: Option<String>,
#[serde(default)]
instructions: BrowserInstructionOverrides,
}
#[derive(Default)]
struct BrowserRuntimeState {
executor: Mutex<Option<JsFunctionHandle>>,
@@ -213,6 +229,7 @@ pub struct BrowserCodex {
api_key: String,
runtime_state: Arc<BrowserRuntimeState>,
session: Option<BrowserSession>,
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<BrowserSession, HarnessError> {
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<Config, HarnessError> {
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<Config, HarnessError> {
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<Config, HarnessError> {
.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<BrowserSessionOptions, HarnessError> {
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<JsValue, HarnessError> {
if let Ok(promise) = value.clone().dyn_into::<Promise>() {
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");
}
}