mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user