Prompt for project trust in remote TUI workspaces (#39082)

## What changed

- Query the remote app server for project config layers before starting a thread and show the trust prompt when the project has no existing decision.
- Resolve relative remote working directories and repository-root trust targets, then persist accepted trust through `config/batchWrite` on the remote server.
- Preserve existing trusted and untrusted decisions, including an untrusted repository that contains the requested working directory.
- Exit when the remote trust prompt is declined and ignore repeated key events in the trust selector.

## Testing

- Add coverage for remote trust detection, persistence, thread startup, existing decisions, nested untrusted projects, and rendering a remote Git subdirectory.

GitOrigin-RevId: e5fba2ea23bad1fb28f01df522cadbe05fcbb942
This commit is contained in:
Eric Traut
2026-08-17 20:41:50 +00:00
committed by copyberry
parent 9c099e94a2
commit 34e4823a1d
6 changed files with 362 additions and 16 deletions

View File

@@ -11,22 +11,33 @@ use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::EnvironmentInfoParams;
use codex_app_server_protocol::EnvironmentInfoResponse;
use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_config::loader::project_trust_key;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_features::FEATURES;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::LegacyAppPathString;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use serde_json::Value as JsonValue;
use std::fmt::Display;
use std::path::Path;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteProjectTrust {
pub cwd: PathBuf,
pub trust_target: PathBuf,
}
pub(crate) fn replace_config_value(key_path: impl Into<String>, value: JsonValue) -> ConfigEdit {
ConfigEdit {
key_path: key_path.into(),
@@ -187,6 +198,116 @@ pub(crate) async fn read_effective_config(
.wrap_err("config/read failed in TUI")
}
pub(crate) async fn read_remote_project_trust(
request_handle: AppServerRequestHandle,
cwd: &Path,
) -> Result<Option<RemoteProjectTrust>> {
let cwd_string = cwd.to_string_lossy().into_owned();
let cwd = match LegacyAppPathString::from_string(cwd_string.clone()).to_inferred_path_uri() {
Some(cwd) => cwd,
None => {
let request_id = RequestId::String(format!("tui-environment-info-{}", Uuid::new_v4()));
let environment: EnvironmentInfoResponse = request_handle
.request_typed(ClientRequest::EnvironmentInfo {
request_id,
params: EnvironmentInfoParams {
environment_id: LOCAL_ENVIRONMENT_ID.to_string(),
},
})
.await
.wrap_err("environment/info failed while resolving remote project trust")?;
environment
.cwd
.ok_or_else(|| color_eyre::eyre::eyre!("remote app server did not provide a cwd"))?
.join(&cwd_string)
.wrap_err("failed to resolve the remote project directory")?
}
};
let cwd_uri = cwd;
let cwd = cwd_uri.inferred_native_path_string();
let request_id = RequestId::String(format!("tui-project-trust-read-{}", Uuid::new_v4()));
let response: JsonValue = request_handle
.request_typed(ClientRequest::ConfigRead {
request_id,
params: ConfigReadParams {
include_layers: true,
cwd: Some(cwd.clone()),
},
})
.await
.wrap_err("config/read failed while checking remote project trust")?;
let project_layers = response
.get("layers")
.and_then(JsonValue::as_array)
.into_iter()
.flatten()
.filter(|layer| layer["name"]["type"] == "project")
.collect::<Vec<_>>();
let disabled_project = project_layers.iter().rev().find(|layer| {
layer
.get("disabledReason")
.and_then(JsonValue::as_str)
.is_some()
});
let disabled_reason = disabled_project
.and_then(|layer| layer.get("disabledReason"))
.and_then(JsonValue::as_str);
let trust_target = disabled_reason
.and_then(|reason| reason.split_once(", add "))
.and_then(|(_, reason)| reason.rsplit_once(" as a trusted project in "))
.map(|(trust_target, _)| trust_target)
.or_else(|| {
disabled_project
.and_then(|layer| layer["name"]["dotCodexFolder"].as_str())
.and_then(|path| {
path.strip_suffix("/.codex")
.or_else(|| path.strip_suffix("\\.codex"))
})
})
.unwrap_or(&cwd);
let projects = response["config"]["projects"].as_object();
let has_trust_decision = projects
.and_then(|projects| projects.get(trust_target))
.and_then(|project| project.get("trust_level"))
.and_then(JsonValue::as_str)
.is_some_and(|level| matches!(level, "trusted" | "untrusted"));
let explicitly_untrusted = disabled_reason.is_some_and(|reason| {
projects.into_iter().flatten().any(|(path, project)| {
project.get("trust_level").and_then(JsonValue::as_str) == Some("untrusted")
&& reason
.strip_prefix(path)
.is_some_and(|suffix| suffix.starts_with(" is marked as untrusted"))
})
});
if has_trust_decision
|| explicitly_untrusted
|| (disabled_project.is_none()
&& project_layers
.iter()
.any(|layer| layer.get("disabledReason").is_none()))
{
return Ok(None);
}
if project_layers.is_empty()
&& projects.into_iter().flatten().any(|(path, project)| {
project.get("trust_level").and_then(JsonValue::as_str) == Some("untrusted")
&& LegacyAppPathString::from_string(path.clone())
.to_inferred_path_uri()
.is_some_and(|project_uri| cwd_uri.starts_with(&project_uri))
})
{
return Err(color_eyre::eyre::eyre!(
"remote project directory is inside an explicitly untrusted project; pass the repository root explicitly with --cd"
));
}
Ok(Some(RemoteProjectTrust {
cwd: PathBuf::from(&cwd),
trust_target: PathBuf::from(trust_target),
}))
}
pub(crate) async fn write_skill_enabled(
request_handle: AppServerRequestHandle,
path: AbsolutePathBuf,

View File

@@ -1,4 +1,9 @@
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use codex_app_server_client::AppServerClient;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use color_eyre::eyre::WrapErr;
use pretty_assertions::assert_eq;
use std::path::Path;
@@ -23,6 +28,98 @@ fn trusted_project_edit_targets_project_trust_level() {
);
}
#[tokio::test]
async fn remote_project_trust_guards_thread_start_and_preserves_repository_decisions() -> Result<()>
{
let temp_dir = tempfile::tempdir()?;
let codex_home = temp_dir.path().join("codex-home");
let project_root = temp_dir
.path()
.join("project as a trusted project in sibling");
let project_cwd = project_root.join("nested");
std::fs::create_dir_all(&codex_home)?;
std::fs::create_dir_all(&project_cwd)?;
std::fs::create_dir(project_root.join(".git"))?;
std::fs::create_dir(project_cwd.join(".codex"))?;
let undecided_config = format!(
"[{}]\n",
trusted_project_edit(&project_root)
.key_path
.trim_end_matches(".trust_level")
);
std::fs::write(codex_home.join("config.toml"), &undecided_config)?;
std::fs::write(
project_cwd.join(".codex/config.toml"),
"model_reasoning_effort = \"high\"\n",
)?;
let config = ConfigBuilder::default()
.codex_home(codex_home.clone())
.harness_overrides(ConfigOverrides {
cwd: Some(codex_home.clone()),
..ConfigOverrides::default()
})
.build()
.await?;
let app_server =
AppServerClient::InProcess(crate::tests::start_test_embedded_app_server(config).await?);
let relative_cwd = pathdiff::diff_paths(&project_cwd, std::env::current_dir()?)
.ok_or_else(|| color_eyre::eyre::eyre!("failed to calculate relative project path"))?;
assert_eq!(
read_remote_project_trust(app_server.request_handle(), &relative_cwd).await?,
Some(RemoteProjectTrust {
cwd: project_cwd.clone(),
trust_target: PathBuf::from(project_trust_key(&project_root)),
})
);
assert_eq!(
std::fs::read_to_string(codex_home.join("config.toml"))?,
undecided_config
);
write_trusted_project(app_server.request_handle(), &project_root).await?;
let persisted_config: toml::Value =
toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?;
assert_eq!(
persisted_config["projects"][project_trust_key(&project_root)]["trust_level"].as_str(),
Some("trusted")
);
let _: ThreadStartResponse = app_server
.request_typed(ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: ThreadStartParams {
cwd: Some(project_cwd.to_string_lossy().into_owned()),
ephemeral: Some(true),
..ThreadStartParams::default()
},
})
.await?;
assert_eq!(
read_remote_project_trust(app_server.request_handle(), &project_cwd).await?,
None
);
let mut untrusted_project = trusted_project_edit(&project_root);
untrusted_project.value = serde_json::json!("untrusted");
write_config_batch(app_server.request_handle(), vec![untrusted_project]).await?;
assert_eq!(
read_remote_project_trust(app_server.request_handle(), &project_cwd).await?,
None
);
std::fs::remove_file(project_cwd.join(".codex/config.toml"))?;
std::fs::remove_dir(project_cwd.join(".codex"))?;
let canonical_project_cwd = PathBuf::from(project_trust_key(&project_root)).join("nested");
let error = read_remote_project_trust(app_server.request_handle(), &canonical_project_cwd)
.await
.expect_err("an untrusted repository must not be overridden by its subdirectory");
assert!(error.to_string().contains("explicitly untrusted project"));
app_server.shutdown().await?;
Ok(())
}
#[test]
fn format_config_error_preserves_server_validation_message() {
let err = Err::<(), _>(color_eyre::eyre::eyre!(

View File

@@ -1072,10 +1072,36 @@ async fn run_ratatui_app(
}
}
}
let remote_project_trust =
if uses_remote_workspace && let Some(remote_cwd) = remote_cwd_override.as_deref() {
match startup_draft
.run_until(
&mut tui,
config_update::read_remote_project_trust(
app_server_session.request_handle(),
remote_cwd,
),
)
.await
{
Ok(Ok(remote_project_trust)) => remote_project_trust,
Ok(Err(err)) => {
shutdown_startup_session(Some(app_server_session), &mut terminal_restore_guard)
.await;
return Err(err);
}
Err(err) => {
shutdown_startup_session(Some(app_server_session), &mut terminal_restore_guard)
.await;
return Err(err.into());
}
}
} else {
None
};
let mut app_server = Some(app_server_session);
let should_show_trust_screen_flag =
!uses_remote_workspace && should_show_trust_screen(&initial_config);
let should_show_trust_screen_flag = remote_project_trust.is_some()
|| (!uses_remote_workspace && should_show_trust_screen(&initial_config));
#[cfg(target_os = "windows")]
let mut trust_decision_was_made = false;
let startup_model_provider = initial_config.model_provider_id.clone();
@@ -1117,6 +1143,7 @@ async fn run_ratatui_app(
OnboardingScreenArgs {
show_login_screen,
show_trust_screen: should_show_trust_screen_flag,
remote_project_trust,
login_status,
app_server_request_handle: app_server
.as_ref()
@@ -1151,7 +1178,8 @@ async fn run_ratatui_app(
}
#[cfg(target_os = "windows")]
{
trust_decision_was_made = onboarding_result.directory_trust_persisted;
trust_decision_was_made =
!uses_remote_workspace && onboarding_result.directory_trust_persisted;
}
let reloaded_config = startup_draft
.run_until(&mut tui, async {
@@ -1168,8 +1196,8 @@ async fn run_ratatui_app(
// Reload config when persisted trust or auth changes alter the current process.
Ok::<_, std::io::Error>(
if onboarding_result.directory_trust_persisted
|| (show_login_screen && !uses_remote_workspace)
if !uses_remote_workspace
&& (onboarding_result.directory_trust_persisted || show_login_screen)
{
load_config_or_exit(
cli_kv_overrides.clone(),

View File

@@ -12,6 +12,9 @@
use codex_app_server_client::AppServerEvent;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
@@ -32,7 +35,9 @@ use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::app_server_session::AppServerSession;
use crate::config_update::RemoteProjectTrust;
use crate::config_update::format_config_error;
use crate::config_update::replace_config_value;
use crate::config_update::write_trusted_project;
use crate::key_hint::KeyBindingListExt;
use crate::legacy_core::config::Config;
@@ -47,8 +52,10 @@ use crate::tui::FrameRequester;
use crate::tui::Tui;
use crate::tui::TuiEvent;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use std::sync::Arc;
use std::sync::RwLock;
use uuid::Uuid;
#[allow(clippy::large_enum_variant)]
enum Step {
@@ -76,12 +83,14 @@ pub(crate) trait StepStateProvider {
pub(crate) struct OnboardingScreen {
request_frame: FrameRequester,
steps: Vec<Step>,
remote_trust_key: Option<String>,
is_done: bool,
should_exit: bool,
}
pub(crate) struct OnboardingScreenArgs {
pub show_trust_screen: bool,
pub remote_project_trust: Option<RemoteProjectTrust>,
pub show_login_screen: bool,
pub login_status: LoginStatus,
pub app_server_request_handle: Option<AppServerRequestHandle>,
@@ -105,12 +114,16 @@ impl OnboardingScreen {
pub(crate) async fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
show_trust_screen,
remote_project_trust,
show_login_screen,
login_status,
app_server_request_handle,
config,
} = args;
let cwd = config.cwd.to_path_buf();
let remote_trust_key = remote_project_trust
.as_ref()
.map(|project| project.trust_target.to_string_lossy().into_owned());
let auth_config = config.auth_config();
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
@@ -142,16 +155,23 @@ impl OnboardingScreen {
}
}
#[cfg(target_os = "windows")]
let show_windows_create_sandbox_hint =
crate::windows_sandbox::level_from_config(&config) == WindowsSandboxLevel::Disabled;
let show_windows_create_sandbox_hint = remote_project_trust.is_none()
&& crate::windows_sandbox::level_from_config(&config) == WindowsSandboxLevel::Disabled;
#[cfg(not(target_os = "windows"))]
let show_windows_create_sandbox_hint = false;
let highlighted = TrustDirectorySelection::Trust;
if show_trust_screen {
let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.map(Into::into)
.unwrap_or_else(|| cwd.clone());
let (cwd, trust_target) = match remote_project_trust {
Some(RemoteProjectTrust { cwd, trust_target }) => (cwd, trust_target),
None => {
let trust_target =
resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd)
.await
.map(Into::into)
.unwrap_or_else(|| cwd.clone());
(cwd, trust_target)
}
};
steps.push(Step::TrustDirectory(TrustDirectoryWidget {
cwd,
trust_target,
@@ -165,6 +185,7 @@ impl OnboardingScreen {
Self {
request_frame: tui.frame_requester(),
steps,
remote_trust_key,
is_done: false,
should_exit: false,
}
@@ -305,6 +326,8 @@ impl KeyboardHandler for OnboardingScreen {
// If the user cancels the auth menu, exit the app rather than
// leave the user at a prompt in an unauthed state.
self.should_exit = true;
} else if self.is_trust_step_active() {
self.should_exit = true;
}
self.is_done = true;
} else {
@@ -635,11 +658,36 @@ async fn persist_selected_trust(
return false;
};
let result = match request_handle {
Some(request_handle) => write_trusted_project(request_handle, &trust_target)
let result = match (
request_handle,
onboarding_screen.remote_trust_key.as_deref(),
) {
(Some(request_handle), Some(project_key)) => {
let project_key = project_key.replace('\\', "\\\\").replace('"', "\\\"");
request_handle
.request_typed::<serde_json::Value>(ClientRequest::ConfigBatchWrite {
request_id: RequestId::String(format!(
"tui-project-trust-write-{}",
Uuid::new_v4()
)),
params: ConfigBatchWriteParams {
edits: vec![replace_config_value(
format!("projects.\"{project_key}\".trust_level"),
serde_json::json!("trusted"),
)],
file_path: None,
expected_version: None,
reload_user_config: true,
},
})
.await
.map(|_| ())
.wrap_err("config/batchWrite failed while persisting remote project trust")
}
(Some(request_handle), None) => write_trusted_project(request_handle, &trust_target)
.await
.map(|_| ()),
None => Err(color_eyre::eyre::eyre!("app server unavailable")),
(None, _) => Err(color_eyre::eyre::eyre!("app server unavailable")),
};
match result {
@@ -742,6 +790,7 @@ mod tests {
highlighted: TrustDirectorySelection::Trust,
error: None,
})],
remote_trust_key: None,
is_done: false,
should_exit: false,
};
@@ -787,6 +836,8 @@ mod tests {
panic!("trust step should remain present");
};
assert_eq!(widget.highlighted, TrustDirectorySelection::Quit);
onboarding_screen.handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE));
assert!(onboarding_screen.should_exit());
}
#[tokio::test]
@@ -802,6 +853,7 @@ mod tests {
highlighted: TrustDirectorySelection::Trust,
error: None,
})],
remote_trust_key: None,
is_done: false,
should_exit: false,
};

View File

@@ -0,0 +1,18 @@
---
source: tui/src/onboarding/trust_directory.rs
expression: "terminal.backend().to_string().lines().map(str::trim_end).collect::<Vec<_>>().join(\"\\n\")"
---
> You are in /srv/remote/project/nested
Note: Youre in a subdirectory of a Git project. Trusting will apply
to the repository root: /srv/remote/project
Do you trust the contents of this directory? Working with untrusted
contents comes with higher risk of prompt injection. Trusting the
directory allows project-local config, hooks, and exec policies to
load.
1. Yes, continue
2. No, quit
Press enter to continue

View File

@@ -127,7 +127,7 @@ impl WidgetRef for &TrustDirectoryWidget {
impl KeyboardHandler for TrustDirectoryWidget {
fn handle_key_event(&mut self, key_event: KeyEvent) {
if key_event.kind == KeyEventKind::Release {
if key_event.kind != KeyEventKind::Press {
return;
}
@@ -222,6 +222,11 @@ mod tests {
widget.handle_key_event(release);
assert_eq!(widget.selection, None);
let repeat =
KeyEvent::new_with_kind(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat);
widget.handle_key_event(repeat);
assert_eq!(widget.selection, None);
let press = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
widget.handle_key_event(press);
assert!(widget.should_quit);
@@ -240,6 +245,31 @@ mod tests {
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn renders_snapshot_for_remote_git_subdirectory() {
let widget = TrustDirectoryWidget {
cwd: PathBuf::from("/srv/remote/project/nested"),
trust_target: PathBuf::from("/srv/remote/project"),
..widget(/*error*/ None)
};
let mut terminal =
Terminal::new(VT100Backend::new(/*width*/ 70, /*height*/ 18)).expect("terminal");
terminal
.draw(|f| (&widget).render_ref(f.area(), f.buffer_mut()))
.expect("draw");
insta::assert_snapshot!(
terminal
.backend()
.to_string()
.lines()
.map(str::trim_end)
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn renders_snapshot_for_trust_error() {
let widget = widget(Some(