From 4ca25a2c4e6db7db0a5a5c907055786b85ca2321 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 7 Aug 2026 22:36:20 +0000 Subject: [PATCH] Include sandbox mode in response metadata (#37507) ## What changed - Add the effective permission profile as `sandbox_mode` in turn metadata for regular, prewarm, compaction, and detached memory requests. - Reserve `sandbox_mode` so client-provided metadata cannot override the computed value. ## Testing - Cover metadata headers and request bodies for read-only and danger-full-access modes. GitOrigin-RevId: 13c690cc99bc2691023f281ca527af049bb1286f --- codex-rs/core/src/responses_metadata.rs | 7 ++ codex-rs/core/src/turn_metadata.rs | 10 +++ codex-rs/core/src/turn_metadata_tests.rs | 21 +++++- codex-rs/core/tests/responses_headers.rs | 22 +++++- codex-rs/core/tests/suite/compact_remote.rs | 1 + ...pi_auth_prompt_cache_key_request_diff.snap | 2 +- ...ce_tier_prompt_cache_key_request_diff.snap | 2 +- codex-rs/core/tests/suite/turn_state.rs | 71 ++++++++++++++++--- codex-rs/memories/write/src/runtime.rs | 2 + codex-rs/memories/write/src/startup_tests.rs | 1 + 10 files changed, 124 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index b269fdd79c..d2f05011f6 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -41,6 +41,7 @@ pub(crate) const PARENT_TURN_ID_KEY: &str = "parent_turn_id"; pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind"; pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source"; pub(crate) const SANDBOX_KEY: &str = "sandbox"; +pub(crate) const SANDBOX_MODE_KEY: &str = "sandbox_mode"; pub(crate) const WORKSPACES_KEY: &str = "workspaces"; // App-server clients can specify additional metadata in the `responsesapi_client_metadata` param @@ -67,6 +68,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ SUBAGENT_KIND_KEY, THREAD_SOURCE_KEY, SANDBOX_KEY, + SANDBOX_MODE_KEY, WORKSPACES_KEY, ]; @@ -201,6 +203,7 @@ pub struct CodexResponsesMetadata { pub(crate) subagent_kind: Option, pub(crate) thread_source: Option, pub(crate) sandbox: Option, + pub(crate) sandbox_mode: Option, pub(crate) workspaces: BTreeMap, pub(crate) tool_namespaces_info: Option, pub(crate) turn_started_at_unix_ms: Option, @@ -229,6 +232,7 @@ impl CodexResponsesMetadata { subagent_kind: None, thread_source: None, sandbox: None, + sandbox_mode: None, workspaces: BTreeMap::new(), tool_namespaces_info: None, turn_started_at_unix_ms: None, @@ -339,6 +343,7 @@ impl CodexResponsesMetadata { subagent_kind: self.subagent_kind.as_deref(), thread_source: self.thread_source.as_ref(), sandbox: self.sandbox.as_deref(), + sandbox_mode: self.sandbox_mode.as_deref(), workspaces: non_empty_workspaces(&self.workspaces), tool_namespaces_info: self.tool_namespaces_info.as_ref(), turn_started_at_unix_ms: self.turn_started_at_unix_ms, @@ -431,6 +436,8 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] sandbox: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] + sandbox_mode: Option<&'a str>, + #[serde(default, skip_serializing_if = "Option::is_none")] workspaces: Option<&'a BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] tool_namespaces_info: Option<&'a TurnToolNamespacesInfo>, diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 1efeeaadaa..71242a8655 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -20,6 +20,7 @@ use crate::responses_metadata::TurnToolNamespacesInfo; use crate::responses_metadata::filter_extra_metadata; use crate::responses_metadata::subagent_header_value; use crate::responses_metadata::subagent_metadata_kind; +use crate::sandbox_tags::permission_profile_policy_tag; use crate::sandbox_tags::permission_profile_sandbox_tag; use codex_git_utils::get_git_remote_urls_assume_git_repo; use codex_git_utils::get_git_repo_root; @@ -76,12 +77,16 @@ pub async fn detached_memory_responses_metadata( window_id: String, session_source: &SessionSource, cwd: &AbsolutePathBuf, + permission_profile: &PermissionProfile, sandbox: Option<&str>, ) -> CodexResponsesMetadata { CodexResponsesMetadata { request_kind: Some(CodexResponsesRequestKind::Memory), subagent_header: subagent_header_value(session_source), sandbox: sandbox.map(ToString::to_string), + sandbox_mode: Some( + permission_profile_policy_tag(permission_profile, cwd.as_path()).to_string(), + ), workspaces: memory_workspaces(cwd).await, ..CodexResponsesMetadata::new(installation_id, session_id, thread_id, window_id) } @@ -101,6 +106,7 @@ pub(crate) struct TurnMetadataState { thread_source: Option, turn_id: String, sandbox: Option, + sandbox_mode: Option, enriched_workspaces: RwLock>>, tool_namespaces_info: RwLock>, turn_started_at_unix_ms: RwLock>, @@ -133,6 +139,8 @@ impl TurnMetadataState { ) .to_string(), ); + let sandbox_mode = + Some(permission_profile_policy_tag(permission_profile, cwd.as_path()).to_string()); Self { cwd, repo_root, @@ -146,6 +154,7 @@ impl TurnMetadataState { thread_source, turn_id, sandbox, + sandbox_mode, enriched_workspaces: RwLock::new(None), tool_namespaces_info: RwLock::new(None), turn_started_at_unix_ms: RwLock::new(None), @@ -258,6 +267,7 @@ impl TurnMetadataState { subagent_kind: self.subagent_kind.clone(), thread_source: self.thread_source.clone(), sandbox: self.sandbox.clone(), + sandbox_mode: self.sandbox_mode.clone(), workspaces: self.current_workspaces(), tool_namespaces_info: self .tool_namespaces_info diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 1571a75844..8287b299cb 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -5,6 +5,7 @@ use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_metadata::INSTALLATION_ID_KEY; use crate::responses_metadata::LEGACY_CODE_MODE_TOOL_NAMES_KEY; use crate::responses_metadata::PARENT_TURN_ID_KEY; +use crate::responses_metadata::SANDBOX_MODE_KEY; use crate::responses_metadata::TOOL_NAMESPACES_INFO_KEY; use crate::responses_metadata::TurnToolFunctionInfo; use crate::responses_metadata::TurnToolNamespaceInfo; @@ -146,6 +147,7 @@ async fn detached_memory_responses_metadata_omits_turn_identity() { String::new(), &SessionSource::Unknown, &repo_path, + &PermissionProfile::read_only(), Some("none"), ) .await @@ -155,6 +157,7 @@ async fn detached_memory_responses_metadata_omits_turn_identity() { assert!(!header.contains("東京")); let parsed: Value = serde_json::from_str(&header).expect("valid json"); assert_eq!(parsed["request_kind"].as_str(), Some("memory")); + assert_eq!(parsed[SANDBOX_MODE_KEY].as_str(), Some("read-only")); assert!(parsed.get("session_id").is_none()); assert!(parsed.get("thread_id").is_none()); assert!(parsed.get("forked_from_thread_id").is_none()); @@ -192,6 +195,7 @@ async fn detached_memory_responses_metadata_omits_empty_workspace_metadata() { String::new(), &SessionSource::Unknown, &cwd, + &PermissionProfile::read_only(), /*sandbox*/ None, ) .await @@ -199,11 +203,17 @@ async fn detached_memory_responses_metadata_omits_empty_workspace_metadata() { .expect("detached memory should emit its request kind"); let parsed: Value = serde_json::from_str(&header).expect("valid json"); - assert_eq!(parsed, serde_json::json!({"request_kind": "memory"})); + assert_eq!( + parsed, + serde_json::json!({ + "request_kind": "memory", + "sandbox_mode": "read-only", + }) + ); } #[test] -fn turn_metadata_state_uses_platform_sandbox_tag() { +fn turn_metadata_state_includes_sandbox_metadata() { let temp_dir = TempDir::new().expect("temp dir"); let cwd = temp_dir.path().abs(); let permission_profile = PermissionProfile::read_only(); @@ -225,6 +235,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let header = test_turn_metadata_header(&state); let json: Value = serde_json::from_str(&header).expect("json"); let sandbox_name = json.get("sandbox").and_then(Value::as_str); + let sandbox_mode = json.get(SANDBOX_MODE_KEY).and_then(Value::as_str); let session_id = json.get("session_id").and_then(Value::as_str); let thread_id = json.get("thread_id").and_then(Value::as_str); @@ -235,6 +246,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { /*enforce_managed_network*/ false, ); assert_eq!(sandbox_name, Some(expected_sandbox)); + assert_eq!(sandbox_mode, Some("read-only")); assert_eq!(session_id, Some("session-a")); assert_eq!(thread_id, Some("thread-a")); assert!(json.get("forked_from_thread_id").is_none()); @@ -570,6 +582,10 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { ), ("parent_turn_id".to_string(), "client-supplied".to_string()), ("subagent_kind".to_string(), "client-supplied".to_string()), + ( + SANDBOX_MODE_KEY.to_string(), + "danger-full-access".to_string(), + ), ])); let header = test_turn_metadata_header(&state); @@ -582,6 +598,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { assert!(json.get("parent_thread_id").is_none()); assert!(json.get("parent_turn_id").is_none()); assert!(json.get("subagent_kind").is_none()); + assert_eq!(json[SANDBOX_MODE_KEY].as_str(), Some("read-only")); } #[test] diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 7343e67038..9ae8c3c6b7 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -463,8 +463,8 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() test.submit_turn("hello") .await .expect("submit first turn prompt"); - let initial_header = first_request - .single_request() + let initial_request = first_request.single_request(); + let initial_header = initial_request .header("x-codex-turn-metadata") .expect("x-codex-turn-metadata header should be present"); let initial_parsed: serde_json::Value = @@ -492,6 +492,24 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e() .and_then(serde_json::Value::as_str), Some("none") ); + assert_eq!( + initial_parsed + .get("sandbox_mode") + .and_then(serde_json::Value::as_str), + Some("danger-full-access") + ); + let body_metadata: serde_json::Value = serde_json::from_str( + initial_request.body_json()["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("request body should include x-codex-turn-metadata"), + ) + .expect("body x-codex-turn-metadata should be valid JSON"); + assert_eq!( + body_metadata + .get("sandbox_mode") + .and_then(serde_json::Value::as_str), + Some("danger-full-access") + ); assert_eq!( initial_parsed .get("thread_source") diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 79dba83a80..8a86ef74a2 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -600,6 +600,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_metadata["request_kind"].as_str(), Some("compaction") ); + assert_eq!(compact_metadata["sandbox_mode"].as_str(), Some("read-only")); assert_eq!( compact_metadata["window_id"].as_str(), compact_request.header("x-codex-window-id").as_deref() diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap index 0df96a7c06..3371bcc984 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -11,7 +11,7 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s - "thread_id": "", - "turn_id": "", - "x-codex-installation-id": "", -- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"sandbox\":\"\",\"turn_started_at_unix_ms\":}", +- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"sandbox\":\"\",\"sandbox_mode\":\"read-only\",\"turn_started_at_unix_ms\":}", - "x-codex-window-id": ":0" - }, - "include": [ diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap index d959d526cc..d8e09548ad 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -11,7 +11,7 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses - "thread_id": "", - "turn_id": "", - "x-codex-installation-id": "", -- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"sandbox\":\"\",\"turn_started_at_unix_ms\":}", +- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"sandbox\":\"\",\"sandbox_mode\":\"read-only\",\"turn_started_at_unix_ms\":}", - "x-codex-window-id": ":0" - }, - "include": [ diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs index 8eda5bc446..30f69626a6 100644 --- a/codex-rs/core/tests/suite/turn_state.rs +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -94,6 +94,7 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { requests: vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ json!({ "type": "response.metadata", @@ -110,9 +111,9 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< ev_completed("resp-2"), ], vec![ - ev_response_created("resp-3"), + ev_response_created("resp-4"), ev_assistant_message("msg-2", "done"), - ev_completed("resp-3"), + ev_completed("resp-4"), ], ], response_headers: Vec::new(), @@ -123,22 +124,74 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< let mut builder = test_codex(); let test = builder.build_with_websocket_server(&server).await?; - // Phase 1: the first response mints state for its same-turn tool follow-up. + // Phase 1: startup prewarm uses the connection without generating a response. + // Phase 2: the first turn mints state for its same-turn tool follow-up. test.submit_turn("run the echo command").await?; - // Phase 2: the follow-up replays that state on the same physical connection. - // Phase 3: the next logical turn reuses the connection but starts with empty state. + // Phase 3: the follow-up replays that state on the same physical connection. + // Phase 4: the next logical turn reuses the connection but starts with empty state. test.submit_turn("start another turn").await?; assert_eq!(server.handshakes().len(), 1); let requests = server.single_connection(); - assert_eq!(requests.len(), 3); + assert_eq!(requests.len(), 4); + let bodies = requests + .iter() + .map(core_test_support::responses::WebSocketRequest::body_json) + .collect::>(); assert_eq!( - requests + bodies .iter() - .map(|request| request.body_json()["client_metadata"][TURN_STATE_HEADER].clone()) + .map(|body| body["client_metadata"][TURN_STATE_HEADER].clone()) .collect::>(), - vec![json!(null), json!("ts-1"), json!(null)] + vec![json!(null), json!(null), json!("ts-1"), json!(null)] ); + let turn_metadata = bodies + .iter() + .map(|body| { + serde_json::from_str::( + body["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("websocket request should include turn metadata"), + ) + }) + .collect::, _>>()?; + assert_eq!( + turn_metadata + .iter() + .map(|metadata| metadata["request_kind"].clone()) + .collect::>(), + vec![ + json!("prewarm"), + json!("turn"), + json!("turn"), + json!("turn"), + ] + ); + assert_eq!(bodies[0]["generate"], json!(false)); + assert!(bodies[1..].iter().all(|body| body["generate"].is_null())); + assert_eq!( + turn_metadata + .iter() + .map(|metadata| metadata["sandbox_mode"].clone()) + .collect::>(), + vec![ + json!("read-only"), + json!("danger-full-access"), + json!("danger-full-access"), + json!("danger-full-access"), + ] + ); + let first_turn_id = turn_metadata[1]["turn_id"] + .as_str() + .expect("first turn should include turn_id"); + let follow_up_turn_id = turn_metadata[2]["turn_id"] + .as_str() + .expect("same-turn follow-up should include turn_id"); + let next_turn_id = turn_metadata[3]["turn_id"] + .as_str() + .expect("next turn should include turn_id"); + assert_eq!(first_turn_id, follow_up_turn_id); + assert_ne!(follow_up_turn_id, next_turn_id); server.shutdown().await; Ok(()) diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index ae65006547..b80c4ebabf 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -266,6 +266,7 @@ impl MemoryStartupContext { let mut client_session = model_client.new_session(); let window_id = format!("{}:0", self.thread_id); + let permission_profile = config.permissions.effective_permission_profile(); let responses_metadata = detached_memory_responses_metadata( installation_id, session_id_string, @@ -273,6 +274,7 @@ impl MemoryStartupContext { window_id, &session_source, &config.cwd, + &permission_profile, /*sandbox*/ None, ) .await; diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 63913b7308..853ba3a2c4 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -399,6 +399,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier_and_detached_meta let metadata: serde_json::Value = serde_json::from_str(&metadata_header).expect("turn metadata json"); assert_eq!(metadata["request_kind"].as_str(), Some("memory")); + assert_eq!(metadata["sandbox_mode"].as_str(), Some("read-only")); assert!(metadata.get("session_id").is_none()); assert!(metadata.get("thread_id").is_none()); assert!(metadata.get("turn_id").is_none());