From 6d29cc6bac4c3099d604cdc0d0dd96215d197974 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 18 Sep 2026 02:35:51 +0000 Subject: [PATCH] Keep remote workspace roots under server control (#46494) ## Why Workspace paths from the client configuration belong to the client host. Sending them to a remote app server can override the server's workspace roots. ## What changed - Omit client-configured `runtimeWorkspaceRoots` from remote start, resume, and fork requests so the server resolves defaults or restores saved roots. - Preserve server-provided roots when forking an active session or side conversation, including after reloading client configuration. - Reject `--add-dir` and `sandbox_workspace_write.writable_roots` command-line overrides with `--remote` before connecting, directing users to configure additional roots on the server. ## Testing Add regression coverage for remote workspace roots across start, turn, resume, and fork operations; active-session forks after configuration reloads; and embedded requests retaining explicit roots. CLI tests cover rejected root overrides and continued acceptance of network-access overrides. GitOrigin-RevId: b87ea6037d197db68e25a1a8610d693f6aa13caf --- codex-rs/cli/tests/features.rs | 55 +++++ ...art_rejects_add_dir_before_connecting.snap | 5 + codex-rs/tui/src/app/event_dispatch.rs | 5 + codex-rs/tui/src/app/tests.rs | 2 + .../app/tests/fork_workspace_roots_tests.rs | 81 +++++++ codex-rs/tui/src/app_server_session.rs | 61 ++++-- .../workspace_roots_tests.rs | 207 ++++++++++++++++++ codex-rs/tui/src/startup_orchestration.rs | 15 ++ 8 files changed, 409 insertions(+), 22 deletions(-) create mode 100644 codex-rs/cli/tests/snapshots/features__remote_start_rejects_add_dir_before_connecting.snap create mode 100644 codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs create mode 100644 codex-rs/tui/src/app_server_session/workspace_roots_tests.rs diff --git a/codex-rs/cli/tests/features.rs b/codex-rs/cli/tests/features.rs index a3ec9bc096..6a0c5603fc 100644 --- a/codex-rs/cli/tests/features.rs +++ b/codex-rs/cli/tests/features.rs @@ -299,6 +299,61 @@ async fn features_list_honors_cloud_managed_feature_requirements() -> Result<()> Ok(()) } +#[test] +fn remote_start_rejects_add_dir_before_connecting() -> Result<()> { + let home = TempDir::new()?; + let output = codex_command(home.path())? + .env("TERM", "xterm-256color") + .args(["--remote", "ws://127.0.0.1:1", "--add-dir", "remote-extra"]) + .assert() + .failure() + .get_output() + .stderr + .clone(); + insta::assert_snapshot!(String::from_utf8(output)?); + Ok(()) +} + +#[test] +fn remote_start_rejects_writable_root_overrides_before_connecting() -> Result<()> { + for config_override in [ + "sandbox_workspace_write.writable_roots=[\"./extra\"]", + "sandbox_workspace_write={writable_roots=[\"./extra\"],network_access=true}", + ] { + let home = TempDir::new()?; + let output = codex_command(home.path())? + .env("TERM", "xterm-256color") + .args(["--remote", "ws://127.0.0.1:1", "-c", config_override]) + .assert() + .failure() + .get_output() + .stderr + .clone(); + let output = String::from_utf8(output)?; + insta::allow_duplicates! { + insta::assert_snapshot!(output, @"Error: sandbox_workspace_write.writable_roots overrides are not supported with --remote. Configure additional workspace roots on the server."); + } + } + Ok(()) +} + +#[test] +fn remote_start_allows_network_access_overrides_before_requiring_terminal() -> Result<()> { + for config_override in [ + "sandbox_workspace_write.network_access=true", + "sandbox_workspace_write={network_access=true}", + ] { + let home = TempDir::new()?; + codex_command(home.path())? + .env("TERM", "xterm-256color") + .args(["--remote", "ws://127.0.0.1:1", "-c", config_override]) + .assert() + .failure() + .stderr(contains("stdin is not a terminal")); + } + Ok(()) +} + #[test] fn no_daemon_rejects_agents_and_explicit_remote_targets() -> Result<()> { for args in [ diff --git a/codex-rs/cli/tests/snapshots/features__remote_start_rejects_add_dir_before_connecting.snap b/codex-rs/cli/tests/snapshots/features__remote_start_rejects_add_dir_before_connecting.snap new file mode 100644 index 0000000000..6bf3dea9a8 --- /dev/null +++ b/codex-rs/cli/tests/snapshots/features__remote_start_rejects_add_dir_before_connecting.snap @@ -0,0 +1,5 @@ +--- +source: cli/tests/features.rs +expression: "String::from_utf8(output)?" +--- +Error: --add-dir is not supported with --remote. Configure additional workspace roots on the server. diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index e61e76d439..5a6f2506a3 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -454,6 +454,11 @@ impl App { self.refresh_in_memory_config_from_disk_best_effort("forking the thread") .await; let mut fork_config = self.config.clone(); + if app_server.uses_remote_workspace() { + fork_config.workspace_roots.clone_from( + &self.chat_widget.config_ref().workspace_roots, + ); + } fork_config.model = Some(self.chat_widget.current_model().to_string()); fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6496ea88f4..1a8ac65ae6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -27,6 +27,8 @@ mod buffered_replay; mod connector_policy; #[path = "tests/disconnect_tests.rs"] mod disconnect; +#[path = "tests/fork_workspace_roots_tests.rs"] +mod fork_workspace_roots_tests; #[path = "tests/fresh_sparkle_tests.rs"] mod fresh_sparkle_tests; #[path = "tests/key_chords.rs"] diff --git a/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs b/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs new file mode 100644 index 0000000000..b18a99ebae --- /dev/null +++ b/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs @@ -0,0 +1,81 @@ +//! Remote forks must retain server roots after reloading the client's configuration. + +use super::session_lifecycle_requests::recorded_params; +use super::session_lifecycle_requests::start_recording_remote_app_server; +use super::*; +use app_test_support::create_fake_rollout; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn remote_fork_dispatch_preserves_server_workspace_roots() -> Result<()> { + let mut app = Box::pin(make_test_app()).await; + let client_home = tempdir()?; + let server_home = tempdir()?; + let workspace = tempdir()?; + let remote_cwd = workspace.path().canonicalize()?.abs(); + let server_root = server_home.path().canonicalize()?.abs(); + for home in [&client_home, &server_home] { + let root = serde_json::to_string(&home.path().canonicalize()?)?; + std::fs::write( + home.path().join("config.toml"), + format!( + "sandbox_mode = \"workspace-write\"\n[sandbox_workspace_write]\nwritable_roots = [{root}]\n" + ), + )?; + } + app.config.codex_home = client_home.path().to_path_buf().abs(); + app.config.sqlite = codex_state::SqliteConfig::new_for_testing(client_home.path().abs()); + let server_config = ConfigBuilder::default() + .codex_home(server_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(remote_cwd.to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let (server, requests, proxy) = + Box::pin(start_recording_remote_app_server(&server_config)).await?; + let mut server = server.with_remote_cwd_override(Some(remote_cwd.to_path_buf())); + let source_thread_id = ThreadId::from_string( + &create_fake_rollout( + server_home.path(), + "2026-01-01T00-00-00", + "2026-01-01T00:00:00Z", + "Saved user message", + Some(server_config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let started = server + .resume_thread( + &app.local_settings, + app.config.clone(), + source_thread_id, + app.resume_model_settings(), + ) + .await?; + let expected_roots = vec![remote_cwd, server_root]; + assert_eq!(started.session.runtime_workspace_roots, expected_roots); + app.enqueue_primary_thread_session(started.session, started.turns) + .await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + Box::pin(app.handle_event( + &mut tui, + &mut server, + AppEvent::ForkCurrentSession { name: None }, + )) + .await?; + + assert_ne!(app.chat_widget.thread_id(), Some(source_thread_id)); + assert_eq!(app.chat_widget.config_ref().workspace_roots, expected_roots); + let fork_roots: Vec<_> = recorded_params(&requests, "thread/fork") + .into_iter() + .map(|params| params["runtimeWorkspaceRoots"].clone()) + .collect(); + assert_eq!(fork_roots, vec![serde_json::json!(expected_roots)]); + server.shutdown().await?; + proxy.await??; + Ok(()) +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index af479366ca..c5714e22d4 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -184,6 +184,12 @@ enum ForkPresentation { SideConversation, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ForkConfigSource { + Local, + Session, +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ThreadHistorySupport { Paginated, @@ -343,6 +349,15 @@ pub(crate) enum ResumeModelSettings { } impl ThreadParamsMode { + fn workspace_roots_from_config(self, config: &Config) -> Option> { + match self { + Self::Embedded => Some(config.workspace_roots.clone()), + // Client config paths belong to the client host. Let the server resolve its + // defaults or restore the saved roots, then adopt the roots in its response. + Self::Remote => None, + } + } + fn model_provider_from_config(self, config: &Config) -> Option { match self { Self::Embedded => Some(config.model_provider_id.clone()), @@ -883,6 +898,7 @@ impl AppServerSession { ForkPresentation::Regular, /*selected_profile*/ None, permission_mode, + ForkConfigSource::Local, ) .await } @@ -911,6 +927,7 @@ impl AppServerSession { ForkPresentation::Regular, selected_profile, ForkPermissionMode::InheritSaved, + ForkConfigSource::Session, ) .await } @@ -931,6 +948,7 @@ impl AppServerSession { ForkPresentation::SideConversation, /*selected_profile*/ None, ForkPermissionMode::InheritSaved, + ForkConfigSource::Session, ) .await } @@ -950,6 +968,7 @@ impl AppServerSession { presentation: ForkPresentation, selected_profile: Option<&PermissionProfileSelection>, permission_mode: ForkPermissionMode, + config_source: ForkConfigSource, ) -> Result { let fork_parent = match presentation { ForkPresentation::Regular => self @@ -982,6 +1001,11 @@ impl AppServerSession { self.remote_cwd_override.as_deref(), ) }; + if config_source == ForkConfigSource::Session { + // Active-session config has already adopted the server's roots. Fork does + // not restore saved roots when omitted, so preserve this explicit selection. + params.runtime_workspace_roots = Some(config.workspace_roots.clone()); + } if self.thread_params_mode() == ThreadParamsMode::Remote && permission_mode == ForkPermissionMode::InheritSaved { @@ -2072,7 +2096,7 @@ pub(crate) fn thread_start_params_from_config( model_provider: thread_params_mode.model_provider_from_config(config), service_tier: service_tier_override_from_config(config), cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some(config.workspace_roots.clone()), + runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(config), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(config), sandbox, @@ -2139,7 +2163,7 @@ fn thread_resume_params_from_config( model_provider, service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some(config.workspace_roots.clone()), + runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(&config), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -2183,7 +2207,7 @@ fn thread_fork_params_from_config( model_provider: thread_params_mode.model_provider_from_config(&config), service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), - runtime_workspace_roots: Some(config.workspace_roots.clone()), + runtime_workspace_roots: thread_params_mode.workspace_roots_from_config(&config), approval_policy: Some(config.permissions.approval_policy.value().into()), approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, @@ -2516,6 +2540,10 @@ pub(crate) fn app_server_rate_limit_snapshots( #[path = "app_server_session/reasoning_defaults_tests.rs"] mod reasoning_defaults_tests; +#[cfg(test)] +#[path = "app_server_session/workspace_roots_tests.rs"] +mod workspace_roots_tests; + #[cfg(test)] #[path = "app_server_session/prompt_history_tests.rs"] mod prompt_history_tests; @@ -3157,7 +3185,7 @@ mod tests { } #[tokio::test] - async fn thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions() { + async fn thread_lifecycle_params_omit_local_paths_for_remote_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); @@ -3165,7 +3193,6 @@ mod tests { &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); - let expected_runtime_workspace_roots = Some(config.workspace_roots.clone()); let start = thread_start_params_from_config( &config, @@ -3190,18 +3217,9 @@ mod tests { assert_eq!(start.cwd, None); assert_eq!(resume.cwd, None); assert_eq!(fork.cwd, None); - assert_eq!( - start.runtime_workspace_roots, - expected_runtime_workspace_roots - ); - assert_eq!( - resume.runtime_workspace_roots, - expected_runtime_workspace_roots - ); - assert_eq!( - fork.runtime_workspace_roots, - expected_runtime_workspace_roots - ); + assert_eq!(start.runtime_workspace_roots, None); + assert_eq!(resume.runtime_workspace_roots, None); + assert_eq!(fork.runtime_workspace_roots, None); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); @@ -3221,7 +3239,6 @@ mod tests { async fn remote_resume_params_keep_cwd_without_overriding_saved_permissions() { let temp_dir = tempfile::tempdir().expect("tempdir"); let config = build_config(&temp_dir).await; - let expected_workspace_roots = config.workspace_roots.clone(); let remote_cwd = if cfg!(windows) { std::path::PathBuf::from("/srv/remote/project") } else { @@ -3237,10 +3254,7 @@ mod tests { ); assert_eq!(resume.cwd, Some(remote_cwd.to_string_lossy().to_string())); - assert_eq!( - resume.runtime_workspace_roots, - Some(expected_workspace_roots) - ); + assert_eq!(resume.runtime_workspace_roots, None); } #[test] @@ -3344,6 +3358,9 @@ mod tests { assert_eq!(start.cwd.as_deref(), Some("repo/on/server")); assert_eq!(resume.cwd.as_deref(), Some("repo/on/server")); assert_eq!(fork.cwd.as_deref(), Some("repo/on/server")); + assert_eq!(start.runtime_workspace_roots, None); + assert_eq!(resume.runtime_workspace_roots, None); + assert_eq!(fork.runtime_workspace_roots, None); assert_eq!(start.model_provider, None); assert_eq!(resume.model_provider, None); assert_eq!(fork.model_provider, None); diff --git a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs new file mode 100644 index 0000000000..29e8611d80 --- /dev/null +++ b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs @@ -0,0 +1,207 @@ +//! Verify that remote lifecycle requests use workspace roots owned by the server. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::ConfigOverrides; +use codex_app_server_protocol::ServerNotification; +use core_test_support::responses; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn remote_workspace_roots_survive_start_turn_resume_and_fork() -> Result<()> { + let model_server = responses::start_mock_server().await; + let response = responses::mount_sse_once( + &model_server, + responses::sse(vec![ + responses::ev_response_created("response"), + responses::ev_completed("response"), + ]), + ) + .await; + let server_home = tempfile::tempdir()?; + let client_home = tempfile::tempdir()?; + let workspace = tempfile::tempdir()?; + let remote_cwd = AbsolutePathBuf::from_absolute_path(workspace.path().canonicalize()?)?; + let extra_root = remote_cwd.join("shared"); + std::fs::create_dir(extra_root.as_path())?; + let base_url = model_server.uri(); + let extra_root_toml = serde_json::to_string(&extra_root)?; + std::fs::write( + server_home.path().join("config.toml"), + format!( + r#" +model = "gpt-5.2" +model_provider = "workspace-test" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = [{extra_root_toml}] +[model_providers.workspace-test] +name = "OpenAI" +base_url = "{base_url}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + let server_config = ConfigBuilder::default() + .codex_home(server_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(remote_cwd.to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let client_config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(client_home.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let local_settings = LocalSettings::from(&client_config); + let expected_roots = vec![remote_cwd.clone(), extra_root.clone()]; + let mut app_server = crate::start_embedded_app_server_for_picker(&server_config).await?; + // Exercise remote request semantics with a real server and separate client config. + app_server.thread_params_mode = ThreadParamsMode::Remote; + app_server.remote_cwd_override = Some(remote_cwd.to_path_buf()); + let started = app_server.start_thread(&client_config).await?; + let thread_id = started.session.thread_id; + assert_eq!(started.session.cwd, remote_cwd); + assert_eq!(started.session.runtime_workspace_roots, expected_roots); + + let (mut chat, _sender, _events, _commands) = + crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await; + chat.handle_thread_session(started.session); + let config = chat.config_ref(); + assert_eq!(config.workspace_roots, expected_roots); + app_server + .turn_start( + thread_id, + "user-message".to_string(), + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + config.cwd.to_path_buf(), + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + TurnPermissionsOverride::Preserve, + config.permissions.user_visible_workspace_roots(), + "gpt-5.2".to_string(), + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + /*output_schema*/ None, + ) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { + while let Some(event) = app_server.next_event().await { + if let AppServerEvent::ServerNotification(notification) = event + && let ServerNotification::TurnCompleted(completed) = *notification + { + assert_eq!( + completed.turn.status, + codex_app_server_protocol::TurnStatus::Completed + ); + return; + } + } + panic!("app-server disconnected before completing the turn"); + }) + .await?; + response.single_request(); + + let resumed = app_server + .resume_thread( + &local_settings, + client_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.runtime_workspace_roots, expected_roots); + let forked = app_server + .fork_thread(&local_settings, client_config.clone(), thread_id) + .await?; + assert_eq!(forked.session.runtime_workspace_roots, expected_roots); + + // Existing tasks may retain roots that are no longer in the server defaults. + let config_path = server_home.path().join("config.toml"); + let updated_config = std::fs::read_to_string(&config_path)?.replace( + &format!("writable_roots = [{extra_root_toml}]"), + "writable_roots = []", + ); + std::fs::write(config_path, updated_config)?; + let session_config = chat.config_ref().clone(); + let forked = app_server + .fork_thread_at( + &local_settings, + session_config.clone(), + thread_id, + /*last_turn_id*/ None, + /*before_turn_id*/ None, + ForkGoalContinuation::StartIfIdle, + /*selected_profile*/ None, + ) + .await?; + assert_eq!(forked.session.runtime_workspace_roots, expected_roots); + let side = app_server + .fork_side_thread(&local_settings, session_config, thread_id) + .await?; + assert_eq!(side.session.runtime_workspace_roots, expected_roots); + + app_server.remote_cwd_override = None; + let started = app_server.start_thread(&client_config).await?; + let default_cwd = AbsolutePathBuf::current_dir()?; + assert_eq!(started.session.cwd, default_cwd); + assert_eq!(started.session.runtime_workspace_roots, vec![default_cwd]); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn embedded_lifecycle_requests_preserve_explicit_workspace_roots() -> Result<()> { + let home = tempfile::tempdir()?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + additional_writable_roots: vec![home.path().to_path_buf()], + ..Default::default() + }) + .build() + .await?; + let expected = Some(config.workspace_roots.clone()); + let thread_id = ThreadId::new(); + let start = thread_start_params_from_config( + &config, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + /*session_start_source*/ None, + ); + let resume = thread_resume_params_from_config( + config.clone(), + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ResumeModelSettings::RestoreFromThread, + ); + let fork = thread_fork_params_from_config( + config, + thread_id, + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ); + assert_eq!( + [ + start.runtime_workspace_roots, + resume.runtime_workspace_roots, + fork.runtime_workspace_roots + ], + [expected.clone(), expected.clone(), expected] + ); + Ok(()) +} diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index d8acaa1921..ccefde1548 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -18,6 +18,11 @@ pub(super) async fn run_main_inner( "--no-daemon cannot be used with --remote.", )); } + if explicit_remote_endpoint.is_some() && !cli.add_dir.is_empty() { + return Err(std::io::Error::other( + "--add-dir is not supported with --remote. Configure additional workspace roots on the server.", + )); + } let strict_config = cli.strict_config; if cli.shared.worktree { if explicit_remote_endpoint.is_some() { @@ -67,6 +72,16 @@ pub(super) async fn run_main_inner( std::process::exit(1); } }; + if explicit_remote_endpoint.is_some() + && cli_kv_overrides.iter().any(|(key, value)| { + key == "sandbox_workspace_write.writable_roots" + || (key == "sandbox_workspace_write" && value.get("writable_roots").is_some()) + }) + { + return Err(std::io::Error::other( + "sandbox_workspace_write.writable_roots overrides are not supported with --remote. Configure additional workspace roots on the server.", + )); + } // we load config.toml here to determine project state. #[allow(clippy::print_stderr)]