permissions: move workspace roots onto thread state

This commit is contained in:
Michael Bolin
2026-05-13 12:54:30 -07:00
parent d666238b40
commit f65d5b52cb
169 changed files with 3991 additions and 2423 deletions
+112 -73
View File
@@ -448,12 +448,12 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClie
let was_initialized = session.initialized();
processor
.process_client_request(
IN_PROCESS_CONNECTION_ID,
*request,
Arc::clone(&session),
&outbound_initialized,
)
.await;
IN_PROCESS_CONNECTION_ID,
*request,
Arc::clone(&session),
&outbound_initialized,
)
.await;
let opted_out_notification_methods_snapshot =
session.opted_out_notification_methods();
let experimental_api_enabled =
@@ -797,80 +797,119 @@ mod tests {
start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await
}
#[tokio::test]
async fn in_process_start_initializes_and_handles_typed_v2_request() {
let client = start_test_client(SessionSource::Cli).await;
let response = client
.request(ClientRequest::ConfigRequirementsRead {
request_id: RequestId::Integer(1),
params: None,
fn run_current_thread_test_with_stack<F>(name: &str, future: F)
where
F: Future<Output = ()> + Send + 'static,
{
const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024;
let handle = std::thread::Builder::new()
.name(name.to_string())
.stack_size(TEST_STACK_SIZE_BYTES)
.spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime should build");
runtime.block_on(Box::pin(future));
})
.await
.expect("request transport should work")
.expect("request should succeed");
assert!(response.is_object());
.expect("stack-sized test thread should spawn");
let _parsed: ConfigRequirementsReadResponse =
serde_json::from_value(response).expect("response should match v2 schema");
client
.shutdown()
.await
.expect("in-process runtime should shutdown cleanly");
assert!(handle.join().is_ok(), "{name} thread panicked");
}
#[tokio::test]
async fn in_process_start_uses_requested_session_source_for_thread_start() {
for (requested_source, expected_source) in [
(SessionSource::Cli, ApiSessionSource::Cli),
(SessionSource::Exec, ApiSessionSource::Exec),
] {
let client = start_test_client(requested_source).await;
let response = client
.request(ClientRequest::ThreadStart {
request_id: RequestId::Integer(2),
params: ThreadStartParams {
ephemeral: Some(true),
..ThreadStartParams::default()
},
})
.await
.expect("request transport should work")
.expect("thread/start should succeed");
let parsed: ThreadStartResponse =
serde_json::from_value(response).expect("thread/start response should parse");
assert_eq!(parsed.thread.source, expected_source);
client
.shutdown()
.await
.expect("in-process runtime should shutdown cleanly");
}
#[test]
fn in_process_start_initializes_and_handles_typed_v2_request() {
run_current_thread_test_with_stack(
"in_process_start_initializes_and_handles_typed_v2_request",
async {
let client = start_test_client(SessionSource::Cli).await;
let response = client
.request(ClientRequest::ConfigRequirementsRead {
request_id: RequestId::Integer(1),
params: None,
})
.await
.expect("request transport should work")
.expect("request should succeed");
assert!(response.is_object());
let _parsed: ConfigRequirementsReadResponse =
serde_json::from_value(response).expect("response should match v2 schema");
client
.shutdown()
.await
.expect("in-process runtime should shutdown cleanly");
},
);
}
#[tokio::test]
async fn in_process_start_clamps_zero_channel_capacity() {
let client =
start_test_client_with_capacity(SessionSource::Cli, /*channel_capacity*/ 0).await;
let response = loop {
match client
.request(ClientRequest::ConfigRequirementsRead {
request_id: RequestId::Integer(4),
params: None,
})
.await
{
Ok(response) => break response.expect("request should succeed"),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
tokio::task::yield_now().await;
#[test]
fn in_process_start_uses_requested_session_source_for_thread_start() {
run_current_thread_test_with_stack(
"in_process_start_uses_requested_session_source_for_thread_start",
async {
for (requested_source, expected_source) in [
(SessionSource::Cli, ApiSessionSource::Cli),
(SessionSource::Exec, ApiSessionSource::Exec),
] {
let client = start_test_client(requested_source).await;
let response = client
.request(ClientRequest::ThreadStart {
request_id: RequestId::Integer(2),
params: ThreadStartParams {
ephemeral: Some(true),
..ThreadStartParams::default()
},
})
.await
.expect("request transport should work")
.expect("thread/start should succeed");
let parsed: ThreadStartResponse = serde_json::from_value(response)
.expect("thread/start response should parse");
assert_eq!(parsed.thread.source, expected_source);
client
.shutdown()
.await
.expect("in-process runtime should shutdown cleanly");
}
Err(err) => panic!("request transport should work: {err}"),
}
};
let _parsed: ConfigRequirementsReadResponse =
serde_json::from_value(response).expect("response should match v2 schema");
client
.shutdown()
.await
.expect("in-process runtime should shutdown cleanly");
},
);
}
#[test]
fn in_process_start_clamps_zero_channel_capacity() {
run_current_thread_test_with_stack(
"in_process_start_clamps_zero_channel_capacity",
async {
let client = start_test_client_with_capacity(
SessionSource::Cli,
/*channel_capacity*/ 0,
)
.await;
let response = loop {
match client
.request(ClientRequest::ConfigRequirementsRead {
request_id: RequestId::Integer(4),
params: None,
})
.await
{
Ok(response) => break response.expect("request should succeed"),
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
tokio::task::yield_now().await;
}
Err(err) => panic!("request transport should work: {err}"),
}
};
let _parsed: ConfigRequirementsReadResponse =
serde_json::from_value(response).expect("response should match v2 schema");
client
.shutdown()
.await
.expect("in-process runtime should shutdown cleanly");
},
);
}
#[test]
+1 -1
View File
@@ -581,7 +581,7 @@ pub async fn run_main_with_transport_options(
});
}
if let Some(warning) =
codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get())
codex_core::config::system_bwrap_warning(config.permissions.permission_profile_ref())
{
config_warnings.push(ConfigWarningNotification {
summary: warning,
+71 -67
View File
@@ -1,5 +1,6 @@
use std::collections::HashSet;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
@@ -501,13 +502,13 @@ impl MessageProcessor {
self.account_processor.clear_external_auth();
}
pub(crate) async fn process_request(
self: &Arc<Self>,
pub(crate) fn process_request<'a>(
self: &'a Arc<Self>,
connection_id: ConnectionId,
request: JSONRPCRequest,
transport: &AppServerTransport,
transport: &'a AppServerTransport,
session: Arc<ConnectionSessionState>,
) {
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
let request_method = request.method.as_str();
tracing::trace!(
?connection_id,
@@ -525,52 +526,55 @@ impl MessageProcessor {
tracestate: trace.tracestate.clone(),
});
let request_context = RequestContext::new(request_id.clone(), request_span, request_trace);
Self::run_request_with_context(
Arc::clone(&self.outgoing),
request_context.clone(),
async {
let codex_request = serde_json::to_value(&request)
.map_err(|err| invalid_request(format!("Invalid request: {err}")))
.and_then(|request_json| {
serde_json::from_value::<ClientRequest>(request_json)
.map_err(|err| invalid_request(format!("Invalid request: {err}")))
});
let result = match codex_request {
Ok(codex_request) => {
// Websocket callers finalize outbound readiness in lib.rs after mirroring
// session state into outbound state and sending initialize notifications to
// this specific connection. Passing `None` avoids marking the connection
// ready too early from inside the shared request handler.
self.handle_client_request(
request_id.clone(),
codex_request,
Arc::clone(&session),
/*outbound_initialized*/ None,
request_context.clone(),
)
.await
Box::pin(async move {
Self::run_request_with_context(
Arc::clone(&self.outgoing),
request_context.clone(),
Box::pin(async move {
let codex_request = serde_json::to_value(&request)
.map_err(|err| invalid_request(format!("Invalid request: {err}")))
.and_then(|request_json| {
serde_json::from_value::<ClientRequest>(request_json)
.map_err(|err| invalid_request(format!("Invalid request: {err}")))
});
let result = match codex_request {
Ok(codex_request) => {
// Websocket callers finalize outbound readiness in lib.rs after
// mirroring session state into outbound state and sending initialize
// notifications to this specific connection. Passing `None` avoids
// marking the connection ready too early from inside the shared
// request handler.
self.handle_client_request(
request_id.clone(),
codex_request,
Arc::clone(&session),
/*outbound_initialized*/ None,
request_context.clone(),
)
.await
}
Err(error) => Err(error),
};
if let Err(error) = result {
self.outgoing.send_error(request_id.clone(), error).await;
}
Err(error) => Err(error),
};
if let Err(error) = result {
self.outgoing.send_error(request_id.clone(), error).await;
}
},
)
.await;
}),
)
.await;
})
}
/// Handles a typed request path used by in-process embedders.
///
/// This bypasses JSON request deserialization but keeps identical request
/// semantics by delegating to `handle_client_request`.
pub(crate) async fn process_client_request(
self: &Arc<Self>,
pub(crate) fn process_client_request<'a>(
self: &'a Arc<Self>,
connection_id: ConnectionId,
request: ClientRequest,
session: Arc<ConnectionSessionState>,
outbound_initialized: &AtomicBool,
) {
outbound_initialized: &'a AtomicBool,
) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id().clone(),
@@ -584,28 +588,30 @@ impl MessageProcessor {
request_id = ?request_id.request_id,
"app-server typed request"
);
Self::run_request_with_context(
Arc::clone(&self.outgoing),
request_context.clone(),
async {
// In-process clients do not have the websocket transport loop that performs
// post-initialize bookkeeping, so they still finalize outbound readiness in
// the shared request handler.
let result = self
.handle_client_request(
request_id.clone(),
request,
Arc::clone(&session),
Some(outbound_initialized),
request_context.clone(),
)
.await;
if let Err(error) = result {
self.outgoing.send_error(request_id.clone(), error).await;
}
},
)
.await;
Box::pin(async move {
Self::run_request_with_context(
Arc::clone(&self.outgoing),
request_context.clone(),
Box::pin(async move {
// In-process clients do not have the websocket transport loop that performs
// post-initialize bookkeeping, so they still finalize outbound readiness in
// the shared request handler.
let result = self
.handle_client_request(
request_id.clone(),
request,
Arc::clone(&session),
Some(outbound_initialized),
request_context.clone(),
)
.await;
if let Err(error) = result {
self.outgoing.send_error(request_id.clone(), error).await;
}
}),
)
.await;
})
}
pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) {
@@ -621,13 +627,11 @@ impl MessageProcessor {
tracing::info!("<- typed notification: {:?}", notification);
}
async fn run_request_with_context<F>(
async fn run_request_with_context<'a>(
outgoing: Arc<OutgoingMessageSender>,
request_context: RequestContext,
request_fut: F,
) where
F: Future<Output = ()>,
{
request_fut: Pin<Box<dyn Future<Output = ()> + Send + 'a>>,
) {
outgoing
.register_request_context(request_context.clone())
.await;
@@ -632,77 +632,80 @@ fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Resul
)
}
#[tokio::test(flavor = "current_thread")]
#[serial(app_server_tracing)]
async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
let mut harness = TracingHarness::new().await?;
let thread_start_response = harness.start_thread(/*request_id*/ 2, /*trace*/ None).await;
let thread_id = thread_start_response.thread.id.clone();
#[test]
fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> {
run_current_thread_test_with_stack("turn_start_jsonrpc_span_parents_core_turn_spans", async {
let mut harness = TracingHarness::new().await?;
let thread_start_response = harness.start_thread(/*request_id*/ 2, /*trace*/ None).await;
let thread_id = thread_start_response.thread.id.clone();
harness.reset_tracing();
harness.reset_tracing();
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
} = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088");
let turn_start_response: TurnStartResponse = harness
.request(
ClientRequest::TurnStart {
request_id: RequestId::Integer(3),
params: TurnStartParams {
environments: None,
thread_id,
input: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
cwd: None,
approval_policy: None,
sandbox_policy: None,
permissions: None,
approvals_reviewer: None,
model: None,
service_tier: None,
effort: None,
summary: None,
personality: None,
output_schema: None,
collaboration_mode: None,
let RemoteTrace {
trace_id: remote_trace_id,
parent_span_id: remote_parent_span_id,
context: remote_trace,
} = RemoteTrace::new("00000000000000000000000000000077", "0000000000000088");
let turn_start_response: TurnStartResponse = harness
.request(
ClientRequest::TurnStart {
request_id: RequestId::Integer(3),
params: TurnStartParams {
environments: None,
thread_id,
input: vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
responsesapi_client_metadata: None,
cwd: None,
workspace_roots: None,
approval_policy: None,
sandbox_policy: None,
permissions: None,
approvals_reviewer: None,
model: None,
service_tier: None,
effort: None,
summary: None,
personality: None,
output_schema: None,
collaboration_mode: None,
},
},
},
Some(remote_trace),
)
.await;
let spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("turn/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans.iter().any(|span| {
span_attr(span, "codex.op") == Some("user_input")
&& span.span_context.trace_id() == remote_trace_id
Some(remote_trace),
)
.await;
let spans = wait_for_exported_spans(harness.tracing, |spans| {
spans.iter().any(|span| {
span.span_kind == SpanKind::Server
&& span_attr(span, "rpc.method") == Some("turn/start")
&& span.span_context.trace_id() == remote_trace_id
}) && spans.iter().any(|span| {
span_attr(span, "codex.op") == Some("user_input")
&& span.span_context.trace_id() == remote_trace_id
})
})
.await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id);
let core_turn_span =
find_span_with_trace(&spans, remote_trace_id, "codex.op=user_input", |span| {
span_attr(span, "codex.op") == Some("user_input")
});
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_eq!(
span_attr(server_request_span, "turn.id"),
Some(turn_start_response.turn.id.as_str())
);
assert_span_descends_from(&spans, core_turn_span, server_request_span);
harness.shutdown().await;
Ok(())
})
.await;
let server_request_span =
find_rpc_span_with_trace(&spans, SpanKind::Server, "turn/start", remote_trace_id);
let core_turn_span =
find_span_with_trace(&spans, remote_trace_id, "codex.op=user_input", |span| {
span_attr(span, "codex.op") == Some("user_input")
});
assert_eq!(server_request_span.parent_span_id, remote_parent_span_id);
assert!(server_request_span.parent_span_is_remote);
assert_eq!(server_request_span.span_context.trace_id(), remote_trace_id);
assert_eq!(
span_attr(server_request_span, "turn.id"),
Some(turn_start_response.turn.id.as_str())
);
assert_span_descends_from(&spans, core_turn_span, server_request_span);
harness.shutdown().await;
Ok(())
}
@@ -103,8 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PermissionProfileModificationParams;
use codex_app_server_protocol::PermissionProfileSelectionParams;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
@@ -164,7 +164,7 @@ impl CommandExecRequestProcessor {
let started_network_proxy = match self.config.permissions.network.as_ref() {
Some(spec) => match spec
.start_proxy(
self.config.permissions.permission_profile.get(),
self.config.permissions.permission_profile_ref(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
@@ -200,7 +200,8 @@ impl CommandExecRequestProcessor {
} else {
ExecCapturePolicy::ShellTool
};
let sandbox_cwd = if permission_profile.is_some() {
let has_request_permission_profile = permission_profile.is_some();
let sandbox_cwd = if has_request_permission_profile {
cwd.clone()
} else {
self.config.cwd.clone()
@@ -243,28 +244,20 @@ impl CommandExecRequestProcessor {
);
self.config
.permissions
.permission_profile
.permission_profile_constraint()
.can_set(&effective_permission_profile)
.map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?;
effective_permission_profile
} else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) {
} else if let Some(policy) = sandbox_policy {
let legacy_policy = policy.to_core();
self.config
.permissions
.can_set_legacy_sandbox_policy(&policy, &sandbox_cwd)
.can_set_legacy_sandbox_policy(&legacy_policy, &sandbox_cwd)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
let file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
let permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
let permission_profile = policy.to_permission_profile_for_cwd(&sandbox_cwd);
self.config
.permissions
.permission_profile
.permission_profile_constraint()
.can_set(&permission_profile)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
permission_profile
@@ -283,10 +276,16 @@ impl CommandExecRequestProcessor {
None => None,
};
let workspace_roots = if has_request_permission_profile {
vec![sandbox_cwd.clone()]
} else {
self.config.workspace_roots.clone()
};
let exec_request = codex_core::exec::build_exec_request(
exec_params,
&effective_permission_profile,
&sandbox_cwd,
&workspace_roots,
&codex_linux_sandbox_exe,
use_legacy_landlock,
)
@@ -604,11 +604,15 @@ pub(super) async fn handle_pending_thread_resume_request(
permission_profile,
active_permission_profile,
cwd,
workspace_roots,
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
let sandbox =
thread_response_sandbox_policy(&permission_profile, &workspace_roots, cwd.as_path());
let response_permission_profile =
thread_response_permission_profile(&permission_profile, &workspace_roots);
let active_permission_profile =
thread_response_active_permission_profile(active_permission_profile);
let session_id = conversation.session_configured().session_id.to_string();
@@ -624,7 +628,8 @@ pub(super) async fn handle_pending_thread_resume_request(
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox,
permission_profile: Some(permission_profile.into()),
permission_profile: Some(response_permission_profile.into()),
workspace_roots,
active_permission_profile,
reasoning_effort,
};
@@ -1,5 +1,7 @@
use super::*;
use crate::error_code::method_not_found;
use codex_protocol::models::ActivePermissionProfile;
use codex_protocol::models::PermissionProfile;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
@@ -17,6 +19,95 @@ struct ThreadListFilters {
use_state_db_only: bool,
}
#[derive(Clone)]
struct PersistedThreadPermissionState {
permission_profile: PermissionProfile,
active_permission_profile: Option<ActivePermissionProfile>,
workspace_roots: Vec<AbsolutePathBuf>,
}
fn absolute_path_from_history_path(
path: &Path,
base: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
if let Ok(path) = AbsolutePathBuf::try_from(path) {
Some(path)
} else if let Some(base) = base {
Some(AbsolutePathBuf::resolve_path_against_base(
path,
base.as_path(),
))
} else {
AbsolutePathBuf::relative_to_current_dir(path).ok()
}
}
fn persisted_thread_permission_state(
history: &InitialHistory,
fallback_cwd: Option<&Path>,
fallback_sandbox_policy: Option<&codex_protocol::protocol::SandboxPolicy>,
) -> Option<PersistedThreadPermissionState> {
let mut cwd =
fallback_cwd.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None));
let mut workspace_roots = None;
let mut permission_profile = None;
let mut active_permission_profile = None;
for item in history.get_rollout_items() {
match item {
RolloutItem::SessionMeta(meta_line) => {
cwd = absolute_path_from_history_path(meta_line.meta.cwd.as_path(), cwd.as_ref())
.or(cwd);
workspace_roots = Some(if meta_line.meta.workspace_roots.is_empty() {
cwd.iter().cloned().collect()
} else {
meta_line.meta.workspace_roots
});
}
RolloutItem::TurnContext(context) => {
cwd = absolute_path_from_history_path(context.cwd.as_path(), cwd.as_ref()).or(cwd);
workspace_roots = Some(context.workspace_roots);
let context_cwd = cwd
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(context.cwd.as_path());
permission_profile = Some(context.permission_profile.unwrap_or_else(|| {
PermissionProfile::from_legacy_sandbox_policy_for_cwd(
&context.sandbox_policy,
context_cwd,
)
}));
if context.active_permission_profile.is_some() {
active_permission_profile = context.active_permission_profile;
}
}
RolloutItem::EventMsg(EventMsg::SessionConfigured(event)) => {
cwd = Some(event.cwd.clone());
workspace_roots = Some(event.workspace_roots);
permission_profile = Some(event.permission_profile);
active_permission_profile = event.active_permission_profile;
}
RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => {
}
}
}
if permission_profile.is_none() {
let cwd = cwd.as_ref()?;
let fallback_sandbox_policy = fallback_sandbox_policy?;
permission_profile = Some(PermissionProfile::from_legacy_sandbox_policy_for_cwd(
fallback_sandbox_policy,
cwd.as_path(),
));
}
Some(PersistedThreadPermissionState {
permission_profile: permission_profile?,
active_permission_profile,
workspace_roots: workspace_roots.unwrap_or_else(|| cwd.into_iter().collect()),
})
}
fn collect_resume_override_mismatches(
request: &ThreadResumeParams,
config_snapshot: &ThreadConfigSnapshot,
@@ -802,6 +893,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -835,6 +927,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -1160,8 +1253,13 @@ impl ThreadRequestProcessor {
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
config_snapshot.cwd.as_path(),
);
let permission_profile = thread_response_permission_profile(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
);
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
@@ -1175,7 +1273,8 @@ impl ThreadRequestProcessor {
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox,
permission_profile: Some(config_snapshot.permission_profile.into()),
permission_profile: Some(permission_profile.into()),
workspace_roots: config_snapshot.workspace_roots,
active_permission_profile,
reasoning_effort: config_snapshot.reasoning_effort,
};
@@ -1212,10 +1311,11 @@ impl ThreadRequestProcessor {
model_provider: Option<String>,
service_tier: Option<Option<String>>,
cwd: Option<String>,
workspace_roots: Option<Vec<AbsolutePathBuf>>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox: Option<SandboxMode>,
permissions: Option<PermissionProfileSelectionParams>,
permissions: Option<String>,
base_instructions: Option<String>,
developer_instructions: Option<String>,
personality: Option<Personality>,
@@ -1225,6 +1325,8 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd: cwd.map(PathBuf::from),
workspace_roots: workspace_roots
.map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()),
approval_policy: approval_policy
.map(codex_app_server_protocol::AskForApproval::to_core),
approvals_reviewer: approvals_reviewer
@@ -2349,6 +2451,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -2379,11 +2482,22 @@ impl ThreadRequestProcessor {
};
let history_cwd = thread_history.session_cwd();
let persisted_permission_state = persisted_thread_permission_state(
&thread_history,
history_cwd.as_deref(),
resume_source_thread
.as_ref()
.map(|thread| &thread.sandbox_policy),
);
let permissions_were_explicit = permissions.is_some();
let sandbox_was_explicit = sandbox.is_some();
let workspace_roots_were_explicit = workspace_roots.is_some();
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -2392,6 +2506,29 @@ impl ThreadRequestProcessor {
developer_instructions,
personality,
);
if !permissions_were_explicit
&& !sandbox_was_explicit
&& let Some(persisted_permission_state) = persisted_permission_state.as_ref()
{
typesafe_overrides.permission_profile =
Some(persisted_permission_state.permission_profile.clone());
}
if !workspace_roots_were_explicit {
if let Some(persisted_permission_state) = persisted_permission_state.as_ref() {
typesafe_overrides.workspace_roots = Some(
persisted_permission_state
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect(),
);
} else if let Some(root) = history_cwd
.as_deref()
.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None))
{
typesafe_overrides.workspace_roots = Some(vec![root.to_path_buf()]);
}
}
self.load_and_apply_persisted_resume_metadata(
&thread_history,
&mut request_overrides,
@@ -2400,7 +2537,7 @@ impl ThreadRequestProcessor {
.await;
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = match self
let mut config = match self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
@@ -2412,6 +2549,17 @@ impl ThreadRequestProcessor {
return Ok(());
}
};
if !permissions_were_explicit
&& !sandbox_was_explicit
&& let Some(persisted_permission_state) = persisted_permission_state
{
config
.permissions
.set_active_permission_profile_for_current_profile(
persisted_permission_state.active_permission_profile,
Some(&persisted_permission_state.permission_profile),
);
}
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let response_history = thread_history.clone();
@@ -2505,8 +2653,13 @@ impl ThreadRequestProcessor {
let config_snapshot = codex_thread.config_snapshot().await;
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
config_snapshot.cwd.as_path(),
);
let permission_profile = thread_response_permission_profile(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
);
let active_permission_profile = thread_response_active_permission_profile(
config_snapshot.active_permission_profile,
);
@@ -2525,7 +2678,8 @@ impl ThreadRequestProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
permission_profile: Some(config_snapshot.permission_profile.into()),
permission_profile: Some(permission_profile.into()),
workspace_roots: config_snapshot.workspace_roots,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
};
@@ -2985,6 +3139,7 @@ impl ThreadRequestProcessor {
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -3045,11 +3200,24 @@ impl ThreadRequestProcessor {
} else {
Some(cli_overrides)
};
let fork_history = InitialHistory::Forked(history_items.clone());
let persisted_permission_state = persisted_thread_permission_state(
&fork_history,
history_cwd.as_deref(),
Some(&source_thread.sandbox_policy),
)
.ok_or_else(|| {
invalid_request("thread history is missing persisted permission configuration")
})?;
let permissions_were_explicit = permissions.is_some();
let sandbox_was_explicit = sandbox.is_some();
let workspace_roots_were_explicit = workspace_roots.is_some();
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
service_tier,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox,
@@ -3058,13 +3226,34 @@ impl ThreadRequestProcessor {
developer_instructions,
/*personality*/ None,
);
if !permissions_were_explicit && !sandbox_was_explicit {
typesafe_overrides.permission_profile =
Some(persisted_permission_state.permission_profile.clone());
}
if !workspace_roots_were_explicit {
typesafe_overrides.workspace_roots = Some(
persisted_permission_state
.workspace_roots
.iter()
.map(AbsolutePathBuf::to_path_buf)
.collect(),
);
}
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let config = self
let mut config = self
.config_manager
.load_for_cwd(request_overrides, typesafe_overrides, history_cwd)
.await
.map_err(|err| config_load_error(&err))?;
if !permissions_were_explicit && !sandbox_was_explicit {
config
.permissions
.set_active_permission_profile_for_current_profile(
persisted_permission_state.active_permission_profile,
Some(&persisted_permission_state.permission_profile),
);
}
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
@@ -3168,8 +3357,13 @@ impl ThreadRequestProcessor {
let config_snapshot = forked_thread.config_snapshot().await;
let sandbox = thread_response_sandbox_policy(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
config_snapshot.cwd.as_path(),
);
let permission_profile = thread_response_permission_profile(
&config_snapshot.permission_profile,
&config_snapshot.workspace_roots,
);
let active_permission_profile =
thread_response_active_permission_profile(config_snapshot.active_permission_profile);
@@ -3183,7 +3377,8 @@ impl ThreadRequestProcessor {
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox,
permission_profile: Some(config_snapshot.permission_profile.into()),
permission_profile: Some(permission_profile.into()),
workspace_roots: config_snapshot.workspace_roots,
active_permission_profile,
reasoning_effort: session_configured.reasoning_effort,
};
@@ -631,6 +631,7 @@ mod thread_processor_behavior_tests {
model_provider: None,
service_tier: Some(Some("priority".to_string())),
cwd: None,
workspace_roots: None,
approval_policy: None,
approvals_reviewer: None,
sandbox: None,
@@ -651,6 +652,7 @@ mod thread_processor_behavior_tests {
permission_profile: codex_protocol::models::PermissionProfile::Disabled,
active_permission_profile: None,
cwd,
workspace_roots: Vec::new(),
ephemeral: false,
reasoning_effort: None,
personality: None,
@@ -177,37 +177,38 @@ pub(super) fn thread_response_active_permission_profile(
pub(super) fn apply_permission_profile_selection_to_config_overrides(
overrides: &mut ConfigOverrides,
permissions: Option<PermissionProfileSelectionParams>,
permissions: Option<String>,
) {
let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else {
let Some(id) = permissions else {
return;
};
overrides.default_permissions = Some(id);
overrides
.additional_writable_roots
.extend(modifications.unwrap_or_default().into_iter().map(
|modification| match modification {
PermissionProfileModificationParams::AdditionalWritableRoot { path } => {
path.to_path_buf()
}
},
));
}
pub(super) fn thread_response_sandbox_policy(
permission_profile: &codex_protocol::models::PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
cwd: &Path,
) -> codex_app_server_protocol::SandboxPolicy {
let file_system_policy = permission_profile.file_system_sandbox_policy();
let materialized_permission_profile =
thread_response_permission_profile(permission_profile, workspace_roots);
let file_system_policy = materialized_permission_profile.file_system_sandbox_policy();
let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile(
permission_profile,
&materialized_permission_profile,
&file_system_policy,
permission_profile.network_sandbox_policy(),
materialized_permission_profile.network_sandbox_policy(),
cwd,
);
sandbox_policy.into()
}
pub(super) fn thread_response_permission_profile(
permission_profile: &codex_protocol::models::PermissionProfile,
workspace_roots: &[AbsolutePathBuf],
) -> codex_protocol::models::PermissionProfile {
permission_profile.materialize_project_roots_with_workspace_roots(workspace_roots)
}
#[cfg(test)]
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
@@ -357,6 +357,7 @@ impl TurnRequestProcessor {
let turn_has_input = !mapped_items.is_empty();
let has_any_overrides = params.cwd.is_some()
|| params.workspace_roots.is_some()
|| params.approval_policy.is_some()
|| params.approvals_reviewer.is_some()
|| params.sandbox_policy.is_some()
@@ -375,6 +376,7 @@ impl TurnRequestProcessor {
}
let cwd = params.cwd;
let workspace_roots = params.workspace_roots;
let approval_policy = params.approval_policy.map(AskForApproval::to_core);
let approvals_reviewer = params
.approvals_reviewer
@@ -432,6 +434,9 @@ impl TurnRequestProcessor {
thread
.validate_turn_context_overrides(CodexThreadTurnContextOverrides {
cwd: cwd.clone(),
workspace_roots: workspace_roots
.clone()
.map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()),
approval_policy,
approvals_reviewer,
sandbox_policy: sandbox_policy.clone(),
@@ -457,6 +462,7 @@ impl TurnRequestProcessor {
final_output_json_schema: params.output_schema,
responsesapi_client_metadata: params.responsesapi_client_metadata,
cwd,
workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,