Add read-only policy support to MCP tool requests (#46042)

## Why

Read-only MCP discovery and invocation need a consistent policy across requests, connection reuse, and tool catalogs. Reusing an unrestricted connection or cached catalog can bypass filtered discovery.

## What changed

- Thread `requires_read_only_mcp_tools` through MCP configuration and client setup, leaving it disabled by default.
- When enabled, set `openai/readOnly` to `true` in `tools/list` and `tools/call` request metadata, overriding a caller-supplied `false` while preserving other metadata, pagination, and arguments.
- Include the policy in connection identity and bypass shared Apps and persistent tool catalog caches for read-only connections.

## Testing

Add coverage for metadata propagation in legacy and modern protocol modes, preservation through session recovery, and isolation from unrestricted connections and shared Apps catalogs.

GitOrigin-RevId: f4a3a4021a54e302bffd53f08092b080c056246b
This commit is contained in:
Rennie
2026-09-16 22:25:54 +00:00
committed by copyberry
parent 78e7825a47
commit b97abdbe30
15 changed files with 312 additions and 42 deletions
+6 -1
View File
@@ -289,6 +289,7 @@ impl McpConnectionSet {
.into_iter()
.filter(|(_, server)| server.enabled())
{
let server = server.with_read_only_mcp_tools(config.requires_read_only_mcp_tools);
let registration = config.mcp_server_catalog.server(&server_name);
let client_mcp_extensions = crate::client_capabilities::server_mcp_extensions(
&client_mcp_extensions,
@@ -349,7 +350,9 @@ impl McpConnectionSet {
} => bearer_token_env_var.is_some(),
McpServerTransportConfig::Stdio { .. } => false,
};
// Filtered catalogs must not read or populate an unrestricted shared cache.
let shares_codex_apps_tools_cache = is_host_owned_codex_apps
&& !server.requires_read_only_mcp_tools()
&& should_share_codex_apps_tools_cache(&server_name, uses_env_bearer_token);
let codex_apps_tools_cache_context = shares_codex_apps_tools_cache.then(|| {
// Only equivalent discovery inputs may share executable Apps tools.
@@ -551,7 +554,9 @@ impl McpConnectionSet {
}
}
let cancel_token = startup_cancellation_token.child_token();
let tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME {
let tool_catalog_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME
|| server.requires_read_only_mcp_tools()
{
None
} else if let Ok(environment) = resolved_environment.as_ref() {
tool_catalog_cache.context(
@@ -1995,6 +1995,74 @@ fn codex_apps_env_bearer_token_bypasses_shared_tools_cache() {
));
}
#[tokio::test]
async fn read_only_apps_discovery_never_uses_a_shared_writable_catalog() -> anyhow::Result<()> {
let codex_home = tempdir()?;
let cache = ConnectorRuntimeManager::new_without_cache();
let cache_key = ConnectorRuntimeContextKey::personal(
/*account_id*/ None, /*chatgpt_user_id*/ None,
);
let cache_context = cache.context(codex_home.path().to_path_buf(), cache_key.clone());
store_current_tools(
&cache_context,
vec![create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "write")],
);
let server_config: McpServerConfig = serde_json::from_value(serde_json::json!({
"url": "http://127.0.0.1:1/mcp",
"http_headers": {"authorization": "Bearer fixture"},
}))?;
for read_only in [false, true] {
let mut config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf());
config.requires_read_only_mcp_tools = read_only;
let mut catalog = crate::ResolvedMcpCatalog::builder();
catalog.register(crate::McpServerRegistration::from_hosted_apps(
"fixture",
/*contribution_order*/ 0,
server_config.clone(),
));
config.mcp_server_catalog = catalog.build();
let cancellation = CancellationToken::new();
cancellation.cancel();
let manager = McpConnectionSet::new(
/*previous*/ None,
McpPublicationGate::already_published(),
McpRuntimeInput {
startup_policy: McpStartupPolicy::Eager,
config: Arc::new(config),
plugins_available: false,
ready_selected_capability_roots: Vec::new(),
mcp_servers: HashMap::from([(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
EffectiveMcpServer::configured(server_config.clone()),
)]),
submit_id: "test".to_string(),
tx_event: None,
startup_cancellation_token: cancellation,
runtime_context: reusable_server_runtime_context(),
codex_apps_tools_cache: cache.clone(),
tool_catalog_cache: McpToolCatalogCache::default(),
codex_apps_tools_cache_key: cache_key.clone(),
client_mcp_extensions: ClientMcpExtensions::default(),
auth: None,
auth_manager: None,
elicitation_reviewer: None,
elicitation_lifecycle: None,
},
ElicitationRequestRouter::default(),
)
.await;
let tools = manager.list_all_tools().await;
assert_eq!(
tools
.iter()
.map(|tool| tool.callable_name.as_str())
.collect::<Vec<_>>(),
if read_only { vec![] } else { vec!["write"] },
);
}
Ok(())
}
#[tokio::test]
async fn hosted_apps_protocol_mode_is_independent_of_generic_mode() -> anyhow::Result<()> {
let codex_home = tempdir()?;
@@ -5116,6 +5184,35 @@ async fn reconcile_reusable_server_with_mcp_config(
.await
}
#[tokio::test]
async fn read_only_policy_does_not_reuse_a_writable_connection() {
let codex_home = tempdir().expect("tempdir");
let runtime_context = reusable_server_runtime_context();
let config = reusable_server_config("http://127.0.0.1:1");
let previous = manager_with_reusable_ready_server(
&config,
&runtime_context,
vec![create_test_tool("docs", "write")],
)
.await;
for read_only in [false, true] {
let mut mcp_config = crate::mcp::tests::test_mcp_config(codex_home.path().to_path_buf());
mcp_config.requires_read_only_mcp_tools = read_only;
let reconciled = reconcile_reusable_server_with_mcp_config(
&previous,
"docs",
config.clone(),
runtime_context.clone(),
mcp_config,
)
.await;
assert_eq!(
previous.shares_test_connection_with(&reconciled, "docs"),
!read_only
);
}
}
#[tokio::test]
async fn apps_catalog_broadcast_preserves_running_calls_and_rejects_stale_calls()
-> anyhow::Result<()> {
+2
View File
@@ -126,6 +126,8 @@ pub struct McpConfig {
pub chatgpt_base_url: String,
/// Optional product SKU forwarded to the host-owned apps MCP server.
pub apps_mcp_product_sku: Option<String>,
/// Requests server-side read-only filtering and invocation checks for MCP tools.
pub requires_read_only_mcp_tools: bool,
/// Codex home directory used for MCP OAuth state and app-tool cache files.
pub codex_home: PathBuf,
/// Trusted enterprise IdP inherited after normal catalog and policy resolution.
+1
View File
@@ -86,6 +86,7 @@ pub(crate) fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
McpConfig {
chatgpt_base_url: "https://chatgpt.com".to_string(),
apps_mcp_product_sku: None,
requires_read_only_mcp_tools: false,
codex_home,
mcp_enterprise_managed_auth: None,
xaa_enabled: false,
+3 -1
View File
@@ -367,7 +367,9 @@ impl ManagedClientStartup {
)
.await
{
Ok(result) => Arc::new(result?),
Ok(result) => Arc::new(
result?.with_read_only_tools(server.requires_read_only_mcp_tools()),
),
Err(_) => {
return Err(StartupOutcomeError::from(anyhow!(
"MCP client startup timed out after {startup_timeout:?}"
+14
View File
@@ -27,6 +27,7 @@ use tracing::warn;
pub struct EffectiveMcpServer {
config: McpServerConfig,
agent_plugin: bool,
requires_read_only_mcp_tools: bool,
}
impl EffectiveMcpServer {
@@ -34,6 +35,7 @@ impl EffectiveMcpServer {
Self {
config,
agent_plugin: false,
requires_read_only_mcp_tools: false,
}
}
@@ -42,6 +44,15 @@ impl EffectiveMcpServer {
self
}
pub(crate) fn with_read_only_mcp_tools(mut self, requires_read_only_mcp_tools: bool) -> Self {
self.requires_read_only_mcp_tools = requires_read_only_mcp_tools;
self
}
pub(crate) fn requires_read_only_mcp_tools(&self) -> bool {
self.requires_read_only_mcp_tools
}
pub fn config(&self) -> &McpServerConfig {
&self.config
}
@@ -112,6 +123,7 @@ pub(crate) struct McpServerConnectionIdentity {
client_elicitation_capability: ElicitationCapability,
client_mcp_extensions: ClientMcpExtensions,
agent_plugin: bool,
requires_read_only_mcp_tools: bool,
}
impl McpServerConnectionIdentity {
@@ -233,6 +245,7 @@ impl McpServerConnectionIdentity {
client_elicitation_capability,
client_mcp_extensions,
agent_plugin: server.is_agent_plugin(),
requires_read_only_mcp_tools: server.requires_read_only_mcp_tools(),
}
}
@@ -265,6 +278,7 @@ impl McpServerConnectionIdentity {
&& self.client_elicitation_capability == other.client_elicitation_capability
&& self.client_mcp_extensions == other.client_mcp_extensions
&& self.agent_plugin == other.agent_plugin
&& self.requires_read_only_mcp_tools == other.requires_read_only_mcp_tools
}
pub(crate) fn oauth_credentials(&self) -> Result<Option<&StoredOAuthTokens>, &String> {
+1
View File
@@ -1758,6 +1758,7 @@ impl Config {
McpConfig {
chatgpt_base_url: self.chatgpt_base_url.clone(),
apps_mcp_product_sku: self.apps_mcp_product_sku.clone(),
requires_read_only_mcp_tools: false,
codex_home: self.codex_home.to_path_buf(),
mcp_enterprise_managed_auth: self.mcp_enterprise_managed_auth.clone(),
xaa_enabled: self.features.enabled(Feature::UseXaa)
@@ -59,6 +59,7 @@ fn lite_tool_catalog_and_code_calls_are_visible() {
json!({ "type": "custom_tool_call_output", "output": {
"content": "plan updated", "success": true
} }),
json!({ "type": "function_call_output", "output": "Wall time: 0.0025 seconds\nOutput: Report ready." }),
],
&ContextSnapshotOptions::default(),
);
@@ -72,6 +73,7 @@ fn lite_tool_catalog_and_code_calls_are_visible() {
));
assert!(rendered.contains(" | ready"));
assert!(rendered.contains("03:custom_tool_call_output:success=true:plan updated"));
assert!(rendered.contains("04:function_call_output:Wall time: <DURATION> seconds"));
}
#[test]
@@ -109,15 +109,17 @@ impl Normalizer {
}
fn normalize_values(&mut self, text: &str) -> String {
// Code Mode reports elapsed wall time in an otherwise stable tool output header.
let text = if text.starts_with("Script completed\nWall time ") {
// Tool calls report elapsed wall time in an otherwise stable output header.
let text = if text.starts_with("Script completed\nWall time ")
|| text.starts_with("Wall time: ")
{
static WALL_TIME: OnceLock<Regex> = OnceLock::new();
WALL_TIME
.get_or_init(|| {
Regex::new(r"(?m)^Wall time [0-9]+(?:\.[0-9]+)? seconds$")
.expect("code mode wall time regex")
Regex::new(r"(?m)^(Wall time:?) [0-9]+(?:\.[0-9]+)? seconds$")
.expect("tool wall time regex")
})
.replace(text, "Wall time <DURATION> seconds")
.replace(text, "${1} <DURATION> seconds")
.into_owned()
} else {
text.to_string()
@@ -0,0 +1 @@
//! Thread policy reaches MCP discovery and execution without relying on model arguments.
@@ -0,0 +1,47 @@
---
source: core/tests/suite/scenarios_read_only_mcp.rs
expression: "context_snapshot::format_request_history_snapshot(\"A read-only thread receives only read tools from the MCP server and executes a read.\",\n&mock.requests(),\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)"
---
Scenario: A read-only thread receives only read tools from the MCP server and executes a read.
## Window 1
Settings:
include: ["reasoning.encrypted_content"]
instructions: <MODEL_INSTRUCTIONS>
model: "gpt-5.5"
parallel_tool_calls: true
prompt_cache_key: "<PROMPT_CACHE_KEY 1>"
reasoning: {"effort":"medium"}
store: false
stream: true
text: {"verbosity":"low"}
tool_choice: "auto"
tools (11; hash=b74b49b673a090be):
- function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=c04108e51a8548c5
- function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2
- function/list_mcp_resources: Lists resources provided by MCP servers. Resources allow servers to share data that provides cont...; args=[cursor, server]; hash=6792edaff13561bd
- function/list_mcp_resource_templates: Lists resource templates provided by MCP servers. Parameterized resource templates allow servers ...; args=[cursor, server]; hash=60f17c90ef7a4ebb
- function/read_mcp_resource: Read a specific resource from an MCP server given the server name and resource URI.; args=[server, uri]; hash=24246896b39008ae
- function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6
- custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=dea9f12b0111a4ba
- function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, path]; hash=3b766d5bdec5d50e
- namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=f2a2334f757ddd80
- function/close_agent
- function/resume_agent
- function/send_input
- function/spawn_agent
- function/wait_agent
- namespace/mcp__policy: Tools in the mcp__policy namespace.; hash=4868c429e15f1bec
- function/read
- web_search; hash=e214d4db1bc49e89
-- request 1 (turn) --
00:message/developer:
<PERMISSIONS_INSTRUCTIONS>
01:message/user:
<ENVIRONMENT_CONTEXT>
02:message/user:
Read the report.
-- request 2 (turn) --
03:function_call/mcp__policy.read:{}
04:function_call_output:Wall time: <DURATION> seconds
Output: | Report ready.
+26 -10
View File
@@ -107,6 +107,8 @@ use self::streamable_http_retry::HandshakeError;
use self::streamable_http_retry::STREAMABLE_HTTP_RETRY_DELAYS_MS;
use self::streamable_http_retry::sleep_with_retry_deadline;
const READ_ONLY_TOOLS_META_KEY: &str = "openai/readOnly";
enum PendingTransport {
InProcess {
transport: tokio::io::DuplexStream,
@@ -396,12 +398,26 @@ pub struct RmcpClient {
stdio_process: Option<StdioServerProcessHandle>,
transport_recipe: TransportRecipe,
protocol_mode: McpProtocolMode,
requires_read_only_tools: bool,
initialize_context: Mutex<Option<InitializeContext>>,
session_recovery_lock: Semaphore,
elicitation_pause_state: ElicitationPauseState,
}
impl RmcpClient {
/// Requests server-side read-only filtering and invocation checks for every tool request.
pub fn with_read_only_tools(mut self, requires_read_only_tools: bool) -> Self {
self.requires_read_only_tools = requires_read_only_tools;
self
}
fn apply_read_only_tools_meta(&self, meta: &mut Option<RequestMetaObject>) {
if self.requires_read_only_tools {
meta.get_or_insert_default()
.insert(READ_ONLY_TOOLS_META_KEY.to_string(), Value::Bool(true));
}
}
/// Returns the protocol compatibility policy captured when this client was created.
pub fn protocol_mode(&self) -> McpProtocolMode {
self.protocol_mode
@@ -422,6 +438,7 @@ impl RmcpClient {
stdio_process: None,
transport_recipe,
protocol_mode: McpProtocolMode::Legacy,
requires_read_only_tools: false,
initialize_context: Mutex::new(None),
session_recovery_lock: Semaphore::new(/*permits*/ 1),
elicitation_pause_state: ElicitationPauseState::new(),
@@ -496,6 +513,7 @@ impl RmcpClient {
transport_recipe,
protocol_mode,
initialize_context: Mutex::new(None),
requires_read_only_tools: false,
session_recovery_lock: Semaphore::new(/*permits*/ 1),
elicitation_pause_state: ElicitationPauseState::new(),
})
@@ -598,6 +616,7 @@ impl RmcpClient {
transport_recipe,
protocol_mode,
initialize_context: Mutex::new(None),
requires_read_only_tools: false,
session_recovery_lock: Semaphore::new(/*permits*/ 1),
elicitation_pause_state: ElicitationPauseState::new(),
})
@@ -666,10 +685,13 @@ impl RmcpClient {
pub async fn list_tools(
&self,
params: Option<PaginatedRequestParams>,
mut params: Option<PaginatedRequestParams>,
timeout: Option<Duration>,
) -> Result<ListToolsResult> {
self.refresh_oauth_if_needed().await?;
if self.requires_read_only_tools {
self.apply_read_only_tools_meta(&mut params.get_or_insert_default().meta);
}
let result = self
.run_service_operation("tools/list", timeout, move |service| {
let params = params.clone();
@@ -686,13 +708,7 @@ impl RmcpClient {
params: Option<PaginatedRequestParams>,
timeout: Option<Duration>,
) -> Result<ListToolsWithConnectorIdResult> {
self.refresh_oauth_if_needed().await?;
let result = self
.run_service_operation("tools/list", timeout, move |service| {
let params = params.clone();
async move { service.list_tools(params).await }.boxed()
})
.await?;
let result = self.list_tools(params, timeout).await?;
let tools = result
.tools
.into_iter()
@@ -711,7 +727,6 @@ impl RmcpClient {
})
})
.collect::<Result<Vec<_>>>()?;
self.persist_oauth_tokens().await;
Ok(ListToolsWithConnectorIdResult {
next_cursor: result.next_cursor,
tools,
@@ -823,7 +838,7 @@ impl RmcpClient {
}
None => None,
};
let meta = match meta {
let mut meta = match meta {
Some(Value::Object(map)) => Some(RequestMetaObject::from(map)),
Some(other) => {
return Err(anyhow!(
@@ -832,6 +847,7 @@ impl RmcpClient {
}
None => None,
};
self.apply_read_only_tools_meta(&mut meta);
let mut rmcp_params = CallToolRequestParams::new(name);
rmcp_params.arguments = arguments;
let requested_modern = self.protocol_mode == McpProtocolMode::V20260728;
@@ -113,6 +113,82 @@ fn legacy_initialize_response(request: &Value) -> ResponseTemplate {
}))
}
#[tokio::test]
async fn read_only_tool_requests_preserve_host_policy_and_caller_metadata() -> anyhow::Result<()> {
for mode in [McpProtocolMode::Legacy, McpProtocolMode::V20260728] {
for read_only in [false, true] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/mcp"))
.respond_with(|request: &Request| {
let body: Value = request.body_json().expect("JSON-RPC request");
let result = match body["method"].as_str() {
Some("server/discover") => return modern_discover_response(&body),
Some("initialize") => return legacy_initialize_response(&body),
Some("notifications/initialized") => return ResponseTemplate::new(202),
Some("tools/list") => json!({"resultType": "complete", "tools": []}),
Some("tools/call") => json!({"resultType": "complete", "content": []}),
other => panic!("unexpected request: {other:?}"),
};
ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0", "id": body["id"], "result": result,
}))
})
.mount(&server)
.await;
let client = create_client(&server, mode)
.await?
.with_read_only_tools(read_only);
initialize_client(&client).await?;
client.list_tools(/*params*/ None, /*timeout*/ None).await?;
let caller_meta = json!({"openai/readOnly": false, "callId": "preserved"});
let mut page = rmcp::model::PaginatedRequestParams::default()
.with_cursor(Some("next".to_string()));
page.meta = Some(serde_json::from_value(caller_meta.clone())?);
client
.list_tools_with_connector_ids(Some(page), /*timeout*/ None)
.await?;
client
.call_tool(
"write".to_string(),
Some(json!({"message": "hello"})),
Some(caller_meta),
/*timeout*/ None,
)
.await?;
let requests = server
.received_requests()
.await
.expect("recorded requests")
.into_iter()
.map(|request| request.body_json::<Value>().expect("JSON-RPC request"))
.filter(|body| matches!(body["method"].as_str(), Some("tools/list" | "tools/call")))
.map(|body| {
let params = &body["params"];
json!({
"method": body["method"],
"readOnly": params["_meta"]["openai/readOnly"],
"callId": params["_meta"]["callId"],
"cursor": params["cursor"],
"arguments": params["arguments"],
})
})
.collect::<Vec<_>>();
assert_eq!(
requests,
vec![
json!({"method": "tools/list", "readOnly": read_only.then_some(true), "callId": null, "cursor": null, "arguments": null}),
json!({"method": "tools/list", "readOnly": read_only, "callId": "preserved", "cursor": "next", "arguments": null}),
json!({"method": "tools/call", "readOnly": read_only, "callId": "preserved", "cursor": null, "arguments": {"message": "hello"}}),
]
);
client.shutdown().await;
}
}
Ok(())
}
#[tokio::test]
async fn modern_mode_uses_sdk_discovery_and_self_contained_request_metadata() -> anyhow::Result<()>
{
+8 -2
View File
@@ -378,6 +378,10 @@ async fn modern_tool_mrtr_uses_recovered_protocol_after_legacy_session_expiry()
body.pointer("/params/_meta/requestContext"),
Some(&json!("caller-context"))
);
assert_eq!(
body.pointer("/params/_meta/openai~1readOnly"),
Some(&json!(true))
);
let attempt = {
let mut calls = recorded_calls.lock().expect("requests lock");
calls.push(body.clone());
@@ -449,12 +453,14 @@ async fn modern_tool_mrtr_uses_recovered_protocol_after_legacy_session_expiry()
.await;
let elicitation_modes = Arc::new(Mutex::new(Vec::new()));
let client = create_client(&server, Arc::clone(&elicitation_modes)).await?;
let client = create_client(&server, Arc::clone(&elicitation_modes))
.await?
.with_read_only_tools(/*requires_read_only_tools*/ true);
let result = client
.call_tool(
"confirm".into(),
Some(json!({})),
Some(json!({"requestContext": "caller-context"})),
Some(json!({"requestContext": "caller-context", "openai/readOnly": false})),
Some(Duration::from_secs(5)),
)
.await?;
+21 -23
View File
@@ -7608,10 +7608,10 @@ async fn in_app_resume_session_cwd_without_metadata_is_non_fatal() -> Result<()>
#[tokio::test]
async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()> {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
// Keep the large setup and resume futures off the Windows test stack.
let (mut app, _app_event_rx, _op_rx) = Box::pin(make_test_app_with_channels()).await;
let local_cwd = app.config.cwd.to_path_buf();
let local_workspace_roots = app
.rebuild_config_for_cwd(local_cwd.clone())
let local_workspace_roots = Box::pin(app.rebuild_config_for_cwd(local_cwd.clone()))
.await?
.workspace_roots;
let remote_cwd = if cfg!(windows) {
@@ -7642,14 +7642,13 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()>
};
app.harness_overrides.cwd = Some(remote_cwd.clone());
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app_server
.resume_thread(
&crate::local_settings::LocalSettings::from(&app.config),
app.config.clone(),
ThreadId::from_string(&thread_id)?,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
Box::pin(app_server.resume_thread(
&crate::local_settings::LocalSettings::from(&app.config),
app.config.clone(),
ThreadId::from_string(&thread_id)?,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
))
.await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Restored(
AskForApproval::Never,
@@ -7662,18 +7661,17 @@ async fn remote_resume_keeps_server_only_cwd_out_of_local_config() -> Result<()>
RuntimePermissionProfileOverride::from_restored_config(&prior_config),
);
let control = app
.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path: Some(rollout_path),
thread_id: ThreadId::from_string(&thread_id)?,
cwd: None,
history_mode: None,
},
)
.await?;
let control = Box::pin(app.resume_target_session(
&mut tui,
&mut app_server,
crate::resume_picker::SessionTarget {
path: Some(rollout_path),
thread_id: ThreadId::from_string(&thread_id)?,
cwd: None,
history_mode: None,
},
))
.await?;
assert!(matches!(control, AppRunControl::Continue));
assert_eq!(app.harness_overrides.cwd, Some(remote_cwd));