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
This commit is contained in:
sayan-oai
2026-08-07 22:41:20 +00:00
committed by copyberry
parent 45f8cafa4e
commit 4ca25a2c4e
10 changed files with 124 additions and 15 deletions
+7
View File
@@ -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<String>,
pub(crate) thread_source: Option<ThreadSource>,
pub(crate) sandbox: Option<String>,
pub(crate) sandbox_mode: Option<String>,
pub(crate) workspaces: BTreeMap<String, TurnMetadataWorkspace>,
pub(crate) tool_namespaces_info: Option<TurnToolNamespacesInfo>,
pub(crate) turn_started_at_unix_ms: Option<i64>,
@@ -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<String, TurnMetadataWorkspace>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_namespaces_info: Option<&'a TurnToolNamespacesInfo>,
+10
View File
@@ -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<ThreadSource>,
turn_id: String,
sandbox: Option<String>,
sandbox_mode: Option<String>,
enriched_workspaces: RwLock<Option<BTreeMap<String, TurnMetadataWorkspace>>>,
tool_namespaces_info: RwLock<Option<TurnToolNamespacesInfo>>,
turn_started_at_unix_ms: RwLock<Option<i64>>,
@@ -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
+19 -2
View File
@@ -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]
+20 -2
View File
@@ -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")
@@ -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()
@@ -11,7 +11,7 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s
- "thread_id": "<UUID>",
- "turn_id": "<UUID>",
- "x-codex-installation-id": "<UUID>",
- "x-codex-turn-metadata": "{\"installation_id\":\"<UUID>\",\"session_id\":\"<UUID>\",\"thread_id\":\"<UUID>\",\"turn_id\":\"<UUID>\",\"window_id\":\"<UUID>:0\",\"request_kind\":\"turn\",\"sandbox\":\"<SANDBOX>\",\"turn_started_at_unix_ms\":<UNIX_MS>}",
- "x-codex-turn-metadata": "{\"installation_id\":\"<UUID>\",\"session_id\":\"<UUID>\",\"thread_id\":\"<UUID>\",\"turn_id\":\"<UUID>\",\"window_id\":\"<UUID>:0\",\"request_kind\":\"turn\",\"sandbox\":\"<SANDBOX>\",\"sandbox_mode\":\"read-only\",\"turn_started_at_unix_ms\":<UNIX_MS>}",
- "x-codex-window-id": "<UUID>:0"
- },
- "include": [
@@ -11,7 +11,7 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses
- "thread_id": "<UUID>",
- "turn_id": "<UUID>",
- "x-codex-installation-id": "<UUID>",
- "x-codex-turn-metadata": "{\"installation_id\":\"<UUID>\",\"session_id\":\"<UUID>\",\"thread_id\":\"<UUID>\",\"turn_id\":\"<UUID>\",\"window_id\":\"<UUID>:0\",\"request_kind\":\"turn\",\"sandbox\":\"<SANDBOX>\",\"turn_started_at_unix_ms\":<UNIX_MS>}",
- "x-codex-turn-metadata": "{\"installation_id\":\"<UUID>\",\"session_id\":\"<UUID>\",\"thread_id\":\"<UUID>\",\"turn_id\":\"<UUID>\",\"window_id\":\"<UUID>:0\",\"request_kind\":\"turn\",\"sandbox\":\"<SANDBOX>\",\"sandbox_mode\":\"read-only\",\"turn_started_at_unix_ms\":<UNIX_MS>}",
- "x-codex-window-id": "<UUID>:0"
- },
- "include": [
+62 -9
View File
@@ -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::<Vec<_>>();
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<_>>(),
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::<Value>(
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("websocket request should include turn metadata"),
)
})
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(
turn_metadata
.iter()
.map(|metadata| metadata["request_kind"].clone())
.collect::<Vec<_>>(),
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<_>>(),
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(())
+2
View File
@@ -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;
@@ -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());