mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why Filesystem helpers intentionally run with a minimal environment that excludes proxy variables. After filesystem operations started using the Windows sandbox wrapper, the wrapper derived an empty proxy configuration from that helper environment and compared it with the persistent sandbox setup marker. When the marker contained proxy ports, every filesystem operation appeared to require a firewall update, which could launch elevated setup, show a UAC or loader dialog, and fail operations such as `apply_patch` with error 1223. Filesystem helpers do not use network access, so they should preserve the proxy/firewall state established by normal sandboxed process launches. ## What changed - Add an explicit Windows sandbox proxy-settings mode for reconciling or preserving persistent proxy state. - Use preserve mode for filesystem helpers while normal process launches continue to reconcile proxy settings from their environment. - Carry the selected proxy state consistently through setup validation, elevated setup, and non-elevated ACL refreshes. - Cover wrapper argument propagation and marker-derived proxy preservation. ## Validation - `cargo build -p codex-cli --bin codex` - `just test -p codex-windows-sandbox preserving_proxy_settings_uses_the_existing_marker` - `just test -p codex-windows-sandbox windows_wrapper_args_round_trip` - `just test -p codex-windows-sandbox setup_request_prefers_explicit_proxy_settings` - `just test -p codex-sandboxing transform_for_direct_spawn_windows` - `just test -p codex-exec-server fs_sandbox::tests` - Ran the same sandboxed `fs/writeFile` reproduction against published `0.142.0-alpha.6` and the new CLI. The published CLI launched elevated setup and failed with `ShellExecuteExW ... 1223`; the new CLI completed without elevation. Related to #28359.
1956 lines
69 KiB
Rust
1956 lines
69 KiB
Rust
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::collections::BTreeSet;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::ffi::c_void;
|
|
use std::os::windows::process::CommandExt;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::process::Stdio;
|
|
|
|
use crate::allow::AllowDenyPaths;
|
|
use crate::allow::compute_allow_paths_for_permissions;
|
|
use crate::helper_materialization::bundled_executable_path_for_exe;
|
|
use crate::helper_materialization::helper_bin_dir;
|
|
use crate::identity::sandbox_setup_is_complete;
|
|
use crate::logging::current_log_file_path;
|
|
use crate::logging::log_note;
|
|
use crate::path_normalization::canonical_path_key;
|
|
use crate::path_normalization::canonicalize_path;
|
|
use crate::resolved_permissions::ResolvedWindowsSandboxPermissions;
|
|
use crate::setup_error::SetupErrorCode;
|
|
use crate::setup_error::SetupFailure;
|
|
use crate::setup_error::clear_setup_error_report;
|
|
use crate::setup_error::failure;
|
|
use crate::setup_error::read_setup_error_report;
|
|
use crate::ssh_config_dependencies::ssh_config_dependency_paths;
|
|
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use base64::Engine;
|
|
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
|
|
use windows_sys::Win32::Foundation::CloseHandle;
|
|
use windows_sys::Win32::Foundation::GetLastError;
|
|
use windows_sys::Win32::Security::AllocateAndInitializeSid;
|
|
use windows_sys::Win32::Security::CheckTokenMembership;
|
|
use windows_sys::Win32::Security::FreeSid;
|
|
use windows_sys::Win32::Security::SECURITY_NT_AUTHORITY;
|
|
|
|
pub const SETUP_VERSION: u32 = 5;
|
|
pub const OFFLINE_USERNAME: &str = "CodexSandboxOffline";
|
|
pub const ONLINE_USERNAME: &str = "CodexSandboxOnline";
|
|
const ERROR_CANCELLED: u32 = 1223;
|
|
const SECURITY_BUILTIN_DOMAIN_RID: u32 = 0x0000_0020;
|
|
const DOMAIN_ALIAS_RID_ADMINS: u32 = 0x0000_0220;
|
|
const SETUP_EXE_FILENAME: &str = "codex-windows-sandbox-setup.exe";
|
|
const USERPROFILE_ROOT_EXCLUSIONS: &[&str] = &[
|
|
".ssh",
|
|
".tsh",
|
|
".brev",
|
|
".gnupg",
|
|
".aws",
|
|
".azure",
|
|
".kube",
|
|
".docker",
|
|
".config",
|
|
".npm",
|
|
".pki",
|
|
".terraform.d",
|
|
];
|
|
const WINDOWS_PLATFORM_DEFAULT_READ_ROOTS: &[&str] = &[
|
|
r"C:\Windows",
|
|
r"C:\Program Files",
|
|
r"C:\Program Files (x86)",
|
|
r"C:\ProgramData",
|
|
];
|
|
|
|
pub fn sandbox_dir(codex_home: &Path) -> PathBuf {
|
|
codex_home.join(".sandbox")
|
|
}
|
|
|
|
pub fn sandbox_bin_dir(codex_home: &Path) -> PathBuf {
|
|
codex_home.join(".sandbox-bin")
|
|
}
|
|
|
|
pub fn sandbox_secrets_dir(codex_home: &Path) -> PathBuf {
|
|
codex_home.join(".sandbox-secrets")
|
|
}
|
|
|
|
pub fn setup_marker_path(codex_home: &Path) -> PathBuf {
|
|
sandbox_dir(codex_home).join("setup_marker.json")
|
|
}
|
|
|
|
pub fn sandbox_users_path(codex_home: &Path) -> PathBuf {
|
|
sandbox_secrets_dir(codex_home).join("sandbox_users.json")
|
|
}
|
|
|
|
pub struct SandboxSetupRequest<'a> {
|
|
pub permissions: &'a ResolvedWindowsSandboxPermissions,
|
|
pub command_cwd: &'a Path,
|
|
pub env_map: &'a HashMap<String, String>,
|
|
pub codex_home: &'a Path,
|
|
pub proxy_enforced: bool,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct SetupRootOverrides {
|
|
pub read_roots: Option<Vec<PathBuf>>,
|
|
pub read_roots_include_platform_defaults: bool,
|
|
pub write_roots: Option<Vec<PathBuf>>,
|
|
pub deny_read_paths: Option<Vec<PathBuf>>,
|
|
pub deny_write_paths: Option<Vec<PathBuf>>,
|
|
}
|
|
|
|
pub fn run_setup_refresh(
|
|
permission_profile: &PermissionProfile,
|
|
workspace_roots: &[AbsolutePathBuf],
|
|
command_cwd: &Path,
|
|
env_map: &HashMap<String, String>,
|
|
codex_home: &Path,
|
|
proxy_enforced: bool,
|
|
) -> Result<()> {
|
|
let Ok(permissions) =
|
|
ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots(
|
|
permission_profile,
|
|
workspace_roots,
|
|
)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
run_setup_refresh_inner(
|
|
SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd,
|
|
env_map,
|
|
codex_home,
|
|
proxy_enforced,
|
|
},
|
|
SetupRootOverrides::default(),
|
|
/*offline_proxy_settings_override*/ None,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn run_setup_refresh_with_overrides_and_proxy_settings(
|
|
request: SandboxSetupRequest<'_>,
|
|
overrides: SetupRootOverrides,
|
|
offline_proxy_settings: &OfflineProxySettings,
|
|
) -> Result<()> {
|
|
run_setup_refresh_inner(request, overrides, Some(offline_proxy_settings))
|
|
}
|
|
|
|
pub fn run_setup_refresh_with_extra_read_roots(
|
|
permission_profile: &PermissionProfile,
|
|
workspace_roots: &[AbsolutePathBuf],
|
|
command_cwd: &Path,
|
|
env_map: &HashMap<String, String>,
|
|
codex_home: &Path,
|
|
extra_read_roots: Vec<PathBuf>,
|
|
proxy_enforced: bool,
|
|
) -> Result<()> {
|
|
let Ok(permissions) =
|
|
ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots(
|
|
permission_profile,
|
|
workspace_roots,
|
|
)
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let mut read_roots = gather_read_roots(command_cwd, &permissions, env_map, codex_home);
|
|
read_roots.extend(extra_read_roots);
|
|
run_setup_refresh_inner(
|
|
SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd,
|
|
env_map,
|
|
codex_home,
|
|
proxy_enforced,
|
|
},
|
|
SetupRootOverrides {
|
|
read_roots: Some(read_roots),
|
|
read_roots_include_platform_defaults: false,
|
|
write_roots: Some(Vec::new()),
|
|
deny_read_paths: None,
|
|
deny_write_paths: None,
|
|
},
|
|
/*offline_proxy_settings_override*/ None,
|
|
)
|
|
}
|
|
|
|
fn run_setup_refresh_inner(
|
|
request: SandboxSetupRequest<'_>,
|
|
overrides: SetupRootOverrides,
|
|
offline_proxy_settings_override: Option<&OfflineProxySettings>,
|
|
) -> Result<()> {
|
|
if !request.permissions.is_enforceable_by_windows_sandbox() {
|
|
anyhow::bail!("unsupported filesystem permissions for Windows sandbox setup");
|
|
}
|
|
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
|
|
let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths);
|
|
let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths);
|
|
let offline_proxy_settings =
|
|
offline_proxy_settings_for_request(&request, offline_proxy_settings_override);
|
|
let payload = ElevationPayload {
|
|
version: SETUP_VERSION,
|
|
offline_username: OFFLINE_USERNAME.to_string(),
|
|
online_username: ONLINE_USERNAME.to_string(),
|
|
codex_home: request.codex_home.to_path_buf(),
|
|
command_cwd: request.command_cwd.to_path_buf(),
|
|
read_roots,
|
|
write_roots,
|
|
deny_read_paths,
|
|
deny_write_paths,
|
|
proxy_ports: offline_proxy_settings.proxy_ports,
|
|
allow_local_binding: offline_proxy_settings.allow_local_binding,
|
|
otel: None,
|
|
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
|
|
mode: SetupMode::Full,
|
|
refresh_only: true,
|
|
};
|
|
let json = serde_json::to_vec(&payload)?;
|
|
let b64 = BASE64_STANDARD.encode(json);
|
|
let exe = find_setup_exe();
|
|
let sbx_dir = sandbox_dir(request.codex_home);
|
|
let log_path = current_log_file_path(&sbx_dir);
|
|
let cleared_report = match clear_setup_error_report(request.codex_home) {
|
|
Ok(()) => true,
|
|
Err(err) => {
|
|
log_note(
|
|
&format!("setup refresh: failed to clear setup_error.json before launch: {err}"),
|
|
Some(&sbx_dir),
|
|
);
|
|
false
|
|
}
|
|
};
|
|
// Refresh should never request elevation; ensure verb isn't set and we don't trigger UAC.
|
|
let mut cmd = Command::new(&exe);
|
|
cmd.arg(&b64).stdout(Stdio::null()).stderr(Stdio::null());
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| request.codex_home.to_path_buf());
|
|
log_note(
|
|
&format!(
|
|
"setup refresh: spawning {} (cwd={}, payload_len={})",
|
|
exe.display(),
|
|
cwd.display(),
|
|
b64.len()
|
|
),
|
|
Some(&sbx_dir),
|
|
);
|
|
let status = cmd.status().map_err(|err| {
|
|
let message = format!(
|
|
"setup refresh failed to launch helper: helper={}, cwd={}, log={}, error={err}",
|
|
exe.display(),
|
|
cwd.display(),
|
|
log_path.display()
|
|
);
|
|
log_note(&format!("setup refresh: {message}"), Some(&sbx_dir));
|
|
failure(SetupErrorCode::OrchestratorHelperLaunchFailed, message)
|
|
})?;
|
|
if !status.success() {
|
|
log_note(
|
|
&format!("setup refresh: exited with status {status:?}"),
|
|
Some(&sbx_dir),
|
|
);
|
|
return Err(report_helper_failure(
|
|
request.codex_home,
|
|
cleared_report,
|
|
status.code(),
|
|
));
|
|
}
|
|
if let Err(err) = clear_setup_error_report(request.codex_home) {
|
|
log_note(
|
|
&format!("setup refresh: failed to clear setup_error.json after success: {err}"),
|
|
Some(&sbx_dir),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SetupMarker {
|
|
pub version: u32,
|
|
pub offline_username: String,
|
|
pub online_username: String,
|
|
#[serde(default)]
|
|
pub created_at: Option<String>,
|
|
#[serde(default)]
|
|
pub proxy_ports: Vec<u16>,
|
|
#[serde(default)]
|
|
pub allow_local_binding: bool,
|
|
}
|
|
|
|
impl SetupMarker {
|
|
pub fn version_matches(&self) -> bool {
|
|
self.version == SETUP_VERSION
|
|
}
|
|
|
|
pub(crate) fn offline_proxy_settings(&self) -> OfflineProxySettings {
|
|
OfflineProxySettings {
|
|
proxy_ports: self.proxy_ports.clone(),
|
|
allow_local_binding: self.allow_local_binding,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn request_mismatch_reason(
|
|
&self,
|
|
network_identity: SandboxNetworkIdentity,
|
|
offline_proxy_settings: &OfflineProxySettings,
|
|
) -> Option<String> {
|
|
if !network_identity.uses_offline_identity() {
|
|
return None;
|
|
}
|
|
if self.proxy_ports == offline_proxy_settings.proxy_ports
|
|
&& self.allow_local_binding == offline_proxy_settings.allow_local_binding
|
|
{
|
|
return None;
|
|
}
|
|
Some(format!(
|
|
"offline firewall settings changed (stored_ports={:?}, desired_ports={:?}, stored_allow_local_binding={}, desired_allow_local_binding={})",
|
|
self.proxy_ports,
|
|
offline_proxy_settings.proxy_ports,
|
|
self.allow_local_binding,
|
|
offline_proxy_settings.allow_local_binding
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SandboxUserRecord {
|
|
pub username: String,
|
|
/// DPAPI-encrypted password blob, base64 encoded.
|
|
pub password: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct SandboxUsersFile {
|
|
pub version: u32,
|
|
pub offline: SandboxUserRecord,
|
|
pub online: SandboxUserRecord,
|
|
}
|
|
|
|
impl SandboxUsersFile {
|
|
pub fn version_matches(&self) -> bool {
|
|
self.version == SETUP_VERSION
|
|
}
|
|
}
|
|
|
|
fn is_elevated() -> Result<bool> {
|
|
unsafe {
|
|
let mut administrators_group: *mut c_void = std::ptr::null_mut();
|
|
let ok = AllocateAndInitializeSid(
|
|
&SECURITY_NT_AUTHORITY,
|
|
2,
|
|
SECURITY_BUILTIN_DOMAIN_RID,
|
|
DOMAIN_ALIAS_RID_ADMINS,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
&mut administrators_group,
|
|
);
|
|
if ok == 0 {
|
|
return Err(anyhow!(
|
|
"AllocateAndInitializeSid failed: {}",
|
|
GetLastError()
|
|
));
|
|
}
|
|
let mut is_member = 0i32;
|
|
let check = CheckTokenMembership(0, administrators_group, &mut is_member as *mut _);
|
|
FreeSid(administrators_group as *mut _);
|
|
if check == 0 {
|
|
return Err(anyhow!("CheckTokenMembership failed: {}", GetLastError()));
|
|
}
|
|
Ok(is_member != 0)
|
|
}
|
|
}
|
|
|
|
fn canonical_existing(paths: &[PathBuf]) -> Vec<PathBuf> {
|
|
paths
|
|
.iter()
|
|
.filter_map(|p| {
|
|
if !p.exists() {
|
|
return None;
|
|
}
|
|
Some(dunce::canonicalize(p).unwrap_or_else(|_| p.clone()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn profile_read_roots(user_profile: &Path) -> Vec<PathBuf> {
|
|
let entries = match std::fs::read_dir(user_profile) {
|
|
Ok(entries) => entries,
|
|
Err(_) => return vec![user_profile.to_path_buf()],
|
|
};
|
|
|
|
entries
|
|
.filter_map(Result::ok)
|
|
.map(|entry| (entry.file_name(), entry.path()))
|
|
.filter(|(name, _)| {
|
|
let name = name.to_string_lossy();
|
|
!USERPROFILE_ROOT_EXCLUSIONS
|
|
.iter()
|
|
.any(|excluded| name.eq_ignore_ascii_case(excluded))
|
|
})
|
|
.map(|(_, path)| path)
|
|
.collect()
|
|
}
|
|
|
|
fn gather_helper_read_roots(codex_home: &Path) -> Vec<PathBuf> {
|
|
let helper_dir = helper_bin_dir(codex_home);
|
|
let _ = std::fs::create_dir_all(&helper_dir);
|
|
vec![helper_dir]
|
|
}
|
|
|
|
fn gather_full_read_roots_for_permissions(
|
|
command_cwd: &Path,
|
|
permissions: &ResolvedWindowsSandboxPermissions,
|
|
env_map: &HashMap<String, String>,
|
|
codex_home: &Path,
|
|
) -> Vec<PathBuf> {
|
|
let mut roots = gather_helper_read_roots(codex_home);
|
|
roots.extend(
|
|
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
|
|
.iter()
|
|
.map(PathBuf::from),
|
|
);
|
|
if let Ok(up) = std::env::var("USERPROFILE") {
|
|
roots.extend(profile_read_roots(Path::new(&up)));
|
|
}
|
|
roots.push(command_cwd.to_path_buf());
|
|
roots.extend(
|
|
permissions
|
|
.writable_roots_for_cwd(command_cwd, env_map)
|
|
.into_iter()
|
|
.map(|root| root.root),
|
|
);
|
|
canonical_existing(&roots)
|
|
}
|
|
|
|
pub(crate) fn gather_read_roots(
|
|
command_cwd: &Path,
|
|
permissions: &ResolvedWindowsSandboxPermissions,
|
|
env_map: &HashMap<String, String>,
|
|
codex_home: &Path,
|
|
) -> Vec<PathBuf> {
|
|
if permissions.has_full_disk_read_access() {
|
|
return gather_full_read_roots_for_permissions(
|
|
command_cwd,
|
|
permissions,
|
|
env_map,
|
|
codex_home,
|
|
);
|
|
}
|
|
|
|
let mut roots = gather_helper_read_roots(codex_home);
|
|
if permissions.include_platform_defaults() {
|
|
roots.extend(
|
|
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
|
|
.iter()
|
|
.map(PathBuf::from),
|
|
);
|
|
}
|
|
roots.extend(permissions.readable_roots_for_cwd(command_cwd));
|
|
canonical_existing(&roots)
|
|
}
|
|
|
|
pub(crate) fn gather_write_roots_for_permissions(
|
|
permissions: &ResolvedWindowsSandboxPermissions,
|
|
command_cwd: &Path,
|
|
env_map: &HashMap<String, String>,
|
|
) -> Vec<PathBuf> {
|
|
let roots = permissions
|
|
.writable_roots_for_cwd(command_cwd, env_map)
|
|
.into_iter()
|
|
.map(|root| root.root)
|
|
.collect::<Vec<_>>();
|
|
let mut dedup: HashSet<PathBuf> = HashSet::new();
|
|
let mut out: Vec<PathBuf> = Vec::new();
|
|
for r in canonical_existing(&roots) {
|
|
if dedup.insert(r.clone()) {
|
|
out.push(r);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub(crate) fn effective_write_roots_for_setup(
|
|
permissions: &ResolvedWindowsSandboxPermissions,
|
|
command_cwd: &Path,
|
|
env_map: &HashMap<String, String>,
|
|
codex_home: &Path,
|
|
write_roots_override: Option<&[PathBuf]>,
|
|
) -> Vec<PathBuf> {
|
|
effective_write_roots_for_permissions(
|
|
permissions,
|
|
command_cwd,
|
|
env_map,
|
|
codex_home,
|
|
write_roots_override,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn effective_write_roots_for_permissions(
|
|
permissions: &ResolvedWindowsSandboxPermissions,
|
|
command_cwd: &Path,
|
|
env_map: &HashMap<String, String>,
|
|
codex_home: &Path,
|
|
write_roots_override: Option<&[PathBuf]>,
|
|
) -> Vec<PathBuf> {
|
|
let write_roots = if let Some(roots) = write_roots_override {
|
|
canonical_existing(roots)
|
|
} else {
|
|
gather_write_roots_for_permissions(permissions, command_cwd, env_map)
|
|
};
|
|
let write_roots = expand_user_profile_root(write_roots);
|
|
let write_roots = filter_user_profile_root(write_roots);
|
|
let write_roots = filter_user_profile_root_exclusions(write_roots);
|
|
let write_roots = filter_ssh_config_dependency_roots(write_roots);
|
|
filter_sensitive_write_roots(write_roots, codex_home)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ElevationPayload {
|
|
version: u32,
|
|
offline_username: String,
|
|
online_username: String,
|
|
codex_home: PathBuf,
|
|
command_cwd: PathBuf,
|
|
read_roots: Vec<PathBuf>,
|
|
write_roots: Vec<PathBuf>,
|
|
#[serde(default)]
|
|
deny_read_paths: Vec<PathBuf>,
|
|
#[serde(default)]
|
|
deny_write_paths: Vec<PathBuf>,
|
|
proxy_ports: Vec<u16>,
|
|
#[serde(default)]
|
|
allow_local_binding: bool,
|
|
otel: Option<codex_otel::StatsigMetricsSettings>,
|
|
real_user: String,
|
|
mode: SetupMode,
|
|
#[serde(default)]
|
|
refresh_only: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Serialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
enum SetupMode {
|
|
Full,
|
|
ProvisionOnly,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub(crate) struct OfflineProxySettings {
|
|
pub proxy_ports: Vec<u16>,
|
|
pub allow_local_binding: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum SandboxNetworkIdentity {
|
|
Offline,
|
|
Online,
|
|
}
|
|
|
|
impl SandboxNetworkIdentity {
|
|
pub(crate) fn from_permissions(
|
|
permissions: &ResolvedWindowsSandboxPermissions,
|
|
proxy_enforced: bool,
|
|
) -> Self {
|
|
if proxy_enforced || !permissions.network_policy().is_enabled() {
|
|
Self::Offline
|
|
} else {
|
|
Self::Online
|
|
}
|
|
}
|
|
|
|
pub(crate) fn uses_offline_identity(self) -> bool {
|
|
matches!(self, Self::Offline)
|
|
}
|
|
}
|
|
|
|
const PROXY_ENV_KEYS: &[&str] = &[
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"WS_PROXY",
|
|
"WSS_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
"ws_proxy",
|
|
"wss_proxy",
|
|
];
|
|
const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
|
|
|
|
pub(crate) fn offline_proxy_settings_from_env(
|
|
env_map: &HashMap<String, String>,
|
|
network_identity: SandboxNetworkIdentity,
|
|
) -> OfflineProxySettings {
|
|
if !network_identity.uses_offline_identity() {
|
|
return OfflineProxySettings {
|
|
proxy_ports: vec![],
|
|
allow_local_binding: false,
|
|
};
|
|
}
|
|
OfflineProxySettings {
|
|
proxy_ports: proxy_ports_from_env(env_map),
|
|
allow_local_binding: env_map
|
|
.get(ALLOW_LOCAL_BINDING_ENV_KEY)
|
|
.is_some_and(|value| value == "1"),
|
|
}
|
|
}
|
|
|
|
fn offline_proxy_settings_for_request(
|
|
request: &SandboxSetupRequest<'_>,
|
|
offline_proxy_settings_override: Option<&OfflineProxySettings>,
|
|
) -> OfflineProxySettings {
|
|
offline_proxy_settings_override.cloned().unwrap_or_else(|| {
|
|
let network_identity =
|
|
SandboxNetworkIdentity::from_permissions(request.permissions, request.proxy_enforced);
|
|
offline_proxy_settings_from_env(request.env_map, network_identity)
|
|
})
|
|
}
|
|
|
|
pub(crate) fn proxy_ports_from_env(env_map: &HashMap<String, String>) -> Vec<u16> {
|
|
let mut ports = BTreeSet::new();
|
|
for key in PROXY_ENV_KEYS {
|
|
if let Some(value) = env_map.get(*key)
|
|
&& let Some(port) = loopback_proxy_port_from_url(value)
|
|
{
|
|
ports.insert(port);
|
|
}
|
|
}
|
|
ports.into_iter().collect()
|
|
}
|
|
|
|
fn loopback_proxy_port_from_url(url: &str) -> Option<u16> {
|
|
let authority = url.trim().split_once("://")?.1.split('/').next()?;
|
|
let host_port = authority.rsplit_once('@').map_or(authority, |(_, hp)| hp);
|
|
|
|
if let Some(host) = host_port.strip_prefix('[') {
|
|
let (host, rest) = host.split_once(']')?;
|
|
if host != "::1" {
|
|
return None;
|
|
}
|
|
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
|
|
return (port != 0).then_some(port);
|
|
}
|
|
|
|
let (host, port) = host_port.rsplit_once(':')?;
|
|
if !(host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1") {
|
|
return None;
|
|
}
|
|
let port = port.parse::<u16>().ok()?;
|
|
(port != 0).then_some(port)
|
|
}
|
|
|
|
fn quote_arg(arg: &str) -> String {
|
|
let needs = arg.is_empty()
|
|
|| arg
|
|
.chars()
|
|
.any(|c| matches!(c, ' ' | '\t' | '\n' | '\r' | '"'));
|
|
if !needs {
|
|
return arg.to_string();
|
|
}
|
|
let mut out = String::from("\"");
|
|
let mut bs = 0;
|
|
for ch in arg.chars() {
|
|
match ch {
|
|
'\\' => {
|
|
bs += 1;
|
|
}
|
|
'"' => {
|
|
out.push_str(&"\\".repeat(bs * 2 + 1));
|
|
out.push('"');
|
|
bs = 0;
|
|
}
|
|
_ => {
|
|
if bs > 0 {
|
|
out.push_str(&"\\".repeat(bs));
|
|
bs = 0;
|
|
}
|
|
out.push(ch);
|
|
}
|
|
}
|
|
}
|
|
if bs > 0 {
|
|
out.push_str(&"\\".repeat(bs * 2));
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|
|
|
|
fn find_setup_exe() -> PathBuf {
|
|
if let Ok(exe) = std::env::current_exe()
|
|
&& let Some(setup_exe) = find_setup_exe_for_current_exe(&exe)
|
|
{
|
|
return setup_exe;
|
|
}
|
|
PathBuf::from(SETUP_EXE_FILENAME)
|
|
}
|
|
|
|
fn find_setup_exe_for_current_exe(exe: &Path) -> Option<PathBuf> {
|
|
bundled_executable_path_for_exe(exe, SETUP_EXE_FILENAME)
|
|
}
|
|
|
|
fn report_helper_failure(
|
|
codex_home: &Path,
|
|
cleared_report: bool,
|
|
exit_code: Option<i32>,
|
|
) -> anyhow::Error {
|
|
let exit_detail = format!("setup helper exited with status {exit_code:?}");
|
|
if !cleared_report {
|
|
return failure(SetupErrorCode::OrchestratorHelperExitNonzero, exit_detail);
|
|
}
|
|
match read_setup_error_report(codex_home) {
|
|
Ok(Some(report)) => anyhow::Error::new(SetupFailure::from_report(report)),
|
|
Ok(None) => failure(SetupErrorCode::OrchestratorHelperExitNonzero, exit_detail),
|
|
Err(err) => failure(
|
|
SetupErrorCode::OrchestratorHelperReportReadFailed,
|
|
format!("{exit_detail}; failed to read setup_error.json: {err}"),
|
|
),
|
|
}
|
|
}
|
|
|
|
fn verify_setup_completed(codex_home: &Path) -> Result<()> {
|
|
if sandbox_setup_is_complete(codex_home) {
|
|
Ok(())
|
|
} else {
|
|
Err(failure(
|
|
SetupErrorCode::OrchestratorHelperIncomplete,
|
|
"setup helper exited successfully before setup completed",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn run_setup_exe(
|
|
payload: &ElevationPayload,
|
|
needs_elevation: bool,
|
|
codex_home: &Path,
|
|
) -> Result<()> {
|
|
use windows_sys::Win32::System::Threading::GetExitCodeProcess;
|
|
use windows_sys::Win32::System::Threading::INFINITE;
|
|
use windows_sys::Win32::System::Threading::WaitForSingleObject;
|
|
use windows_sys::Win32::UI::Shell::SEE_MASK_NOCLOSEPROCESS;
|
|
use windows_sys::Win32::UI::Shell::SHELLEXECUTEINFOW;
|
|
use windows_sys::Win32::UI::Shell::ShellExecuteExW;
|
|
let exe = find_setup_exe();
|
|
let payload_json = serde_json::to_string(payload).map_err(|err| {
|
|
failure(
|
|
SetupErrorCode::OrchestratorPayloadSerializeFailed,
|
|
format!("failed to serialize elevation payload: {err}"),
|
|
)
|
|
})?;
|
|
let payload_b64 = BASE64_STANDARD.encode(payload_json.as_bytes());
|
|
let cleared_report = match clear_setup_error_report(codex_home) {
|
|
Ok(()) => true,
|
|
Err(err) => {
|
|
log_note(
|
|
&format!(
|
|
"setup orchestrator: failed to clear setup_error.json before launch: {err}"
|
|
),
|
|
Some(&sandbox_dir(codex_home)),
|
|
);
|
|
false
|
|
}
|
|
};
|
|
|
|
if !needs_elevation {
|
|
let status = Command::new(&exe)
|
|
.arg(&payload_b64)
|
|
.creation_flags(0x08000000) // CREATE_NO_WINDOW
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map_err(|err| {
|
|
failure(
|
|
SetupErrorCode::OrchestratorHelperLaunchFailed,
|
|
format!("failed to launch setup helper (non-elevated): {err}"),
|
|
)
|
|
})?;
|
|
if !status.success() {
|
|
return Err(report_helper_failure(
|
|
codex_home,
|
|
cleared_report,
|
|
status.code(),
|
|
));
|
|
}
|
|
verify_setup_completed(codex_home)?;
|
|
if let Err(err) = clear_setup_error_report(codex_home) {
|
|
log_note(
|
|
&format!(
|
|
"setup orchestrator: failed to clear setup_error.json after success: {err}"
|
|
),
|
|
Some(&sandbox_dir(codex_home)),
|
|
);
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
let exe_w = crate::winutil::to_wide(&exe);
|
|
let params = quote_arg(&payload_b64);
|
|
let params_w = crate::winutil::to_wide(params);
|
|
let verb_w = crate::winutil::to_wide("runas");
|
|
let mut sei: SHELLEXECUTEINFOW = unsafe { std::mem::zeroed() };
|
|
sei.cbSize = std::mem::size_of::<SHELLEXECUTEINFOW>() as u32;
|
|
sei.fMask = SEE_MASK_NOCLOSEPROCESS;
|
|
sei.lpVerb = verb_w.as_ptr();
|
|
sei.lpFile = exe_w.as_ptr();
|
|
sei.lpParameters = params_w.as_ptr();
|
|
// Hide the window for the elevated helper.
|
|
sei.nShow = 0; // SW_HIDE
|
|
let ok = unsafe { ShellExecuteExW(&mut sei) };
|
|
if ok == 0 || sei.hProcess == 0 {
|
|
let last_error = unsafe { GetLastError() };
|
|
let code = if last_error == ERROR_CANCELLED {
|
|
SetupErrorCode::OrchestratorHelperLaunchCanceled
|
|
} else {
|
|
SetupErrorCode::OrchestratorHelperLaunchFailed
|
|
};
|
|
return Err(failure(
|
|
code,
|
|
format!("ShellExecuteExW failed to launch setup helper: {last_error}"),
|
|
));
|
|
}
|
|
unsafe {
|
|
WaitForSingleObject(sei.hProcess, INFINITE);
|
|
let mut code: u32 = 1;
|
|
GetExitCodeProcess(sei.hProcess, &mut code);
|
|
CloseHandle(sei.hProcess);
|
|
if code != 0 {
|
|
return Err(report_helper_failure(
|
|
codex_home,
|
|
cleared_report,
|
|
Some(code as i32),
|
|
));
|
|
}
|
|
}
|
|
verify_setup_completed(codex_home)?;
|
|
if let Err(err) = clear_setup_error_report(codex_home) {
|
|
log_note(
|
|
&format!("setup orchestrator: failed to clear setup_error.json after success: {err}"),
|
|
Some(&sandbox_dir(codex_home)),
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run_elevated_setup(
|
|
request: SandboxSetupRequest<'_>,
|
|
overrides: SetupRootOverrides,
|
|
) -> Result<()> {
|
|
run_elevated_setup_inner(
|
|
request, overrides, /*offline_proxy_settings_override*/ None,
|
|
)
|
|
}
|
|
|
|
pub(crate) fn run_elevated_setup_with_proxy_settings(
|
|
request: SandboxSetupRequest<'_>,
|
|
overrides: SetupRootOverrides,
|
|
offline_proxy_settings: &OfflineProxySettings,
|
|
) -> Result<()> {
|
|
run_elevated_setup_inner(request, overrides, Some(offline_proxy_settings))
|
|
}
|
|
|
|
fn run_elevated_setup_inner(
|
|
request: SandboxSetupRequest<'_>,
|
|
overrides: SetupRootOverrides,
|
|
offline_proxy_settings_override: Option<&OfflineProxySettings>,
|
|
) -> Result<()> {
|
|
if !request.permissions.is_enforceable_by_windows_sandbox() {
|
|
anyhow::bail!("unsupported filesystem permissions for Windows sandbox setup");
|
|
}
|
|
// Ensure the shared sandbox directory exists before we send it to the elevated helper.
|
|
let sbx_dir = sandbox_dir(request.codex_home);
|
|
std::fs::create_dir_all(&sbx_dir).map_err(|err| {
|
|
failure(
|
|
SetupErrorCode::OrchestratorSandboxDirCreateFailed,
|
|
format!("failed to create sandbox dir {}: {err}", sbx_dir.display()),
|
|
)
|
|
})?;
|
|
let (read_roots, write_roots) = build_payload_roots(&request, &overrides);
|
|
let deny_read_paths = build_payload_deny_read_paths(overrides.deny_read_paths);
|
|
let deny_write_paths = build_payload_deny_write_paths(&request, overrides.deny_write_paths);
|
|
let offline_proxy_settings =
|
|
offline_proxy_settings_for_request(&request, offline_proxy_settings_override);
|
|
let payload = ElevationPayload {
|
|
version: SETUP_VERSION,
|
|
offline_username: OFFLINE_USERNAME.to_string(),
|
|
online_username: ONLINE_USERNAME.to_string(),
|
|
codex_home: request.codex_home.to_path_buf(),
|
|
command_cwd: request.command_cwd.to_path_buf(),
|
|
read_roots,
|
|
write_roots,
|
|
deny_read_paths,
|
|
deny_write_paths,
|
|
proxy_ports: offline_proxy_settings.proxy_ports,
|
|
allow_local_binding: offline_proxy_settings.allow_local_binding,
|
|
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
|
|
otel: codex_otel::global_statsig_metrics_settings(),
|
|
mode: SetupMode::Full,
|
|
refresh_only: false,
|
|
};
|
|
let needs_elevation = !is_elevated().map_err(|err| {
|
|
failure(
|
|
SetupErrorCode::OrchestratorElevationCheckFailed,
|
|
format!("failed to determine elevation state: {err}"),
|
|
)
|
|
})?;
|
|
run_setup_exe(&payload, needs_elevation, request.codex_home)
|
|
}
|
|
|
|
pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Result<()> {
|
|
let sbx_dir = sandbox_dir(codex_home);
|
|
std::fs::create_dir_all(&sbx_dir).map_err(|err| {
|
|
failure(
|
|
SetupErrorCode::OrchestratorSandboxDirCreateFailed,
|
|
format!("failed to create sandbox dir {}: {err}", sbx_dir.display()),
|
|
)
|
|
})?;
|
|
if !is_elevated().map_err(|err| {
|
|
failure(
|
|
SetupErrorCode::OrchestratorElevationCheckFailed,
|
|
format!("failed to determine elevation state: {err}"),
|
|
)
|
|
})? {
|
|
return Err(failure(
|
|
SetupErrorCode::OrchestratorElevationRequired,
|
|
"sandbox provisioning setup must be run from an elevated process",
|
|
));
|
|
}
|
|
let payload = ElevationPayload {
|
|
version: SETUP_VERSION,
|
|
offline_username: OFFLINE_USERNAME.to_string(),
|
|
online_username: ONLINE_USERNAME.to_string(),
|
|
codex_home: codex_home.to_path_buf(),
|
|
command_cwd: codex_home.to_path_buf(),
|
|
read_roots: Vec::new(),
|
|
write_roots: Vec::new(),
|
|
deny_read_paths: Vec::new(),
|
|
deny_write_paths: Vec::new(),
|
|
proxy_ports: Vec::new(),
|
|
allow_local_binding: false,
|
|
otel: codex_otel::global_statsig_metrics_settings(),
|
|
real_user: real_user.to_string(),
|
|
mode: SetupMode::ProvisionOnly,
|
|
refresh_only: false,
|
|
};
|
|
run_setup_exe(&payload, /*needs_elevation*/ false, codex_home)
|
|
}
|
|
|
|
fn build_payload_roots(
|
|
request: &SandboxSetupRequest<'_>,
|
|
overrides: &SetupRootOverrides,
|
|
) -> (Vec<PathBuf>, Vec<PathBuf>) {
|
|
let write_roots = effective_write_roots_for_setup(
|
|
request.permissions,
|
|
request.command_cwd,
|
|
request.env_map,
|
|
request.codex_home,
|
|
overrides.write_roots.as_deref(),
|
|
);
|
|
let mut read_roots = if let Some(roots) = overrides.read_roots.as_deref() {
|
|
// An explicit override is the split policy's complete readable set. Keep only the
|
|
// helper/platform roots the elevated setup needs; do not re-add legacy cwd/full-read roots.
|
|
let mut read_roots = gather_helper_read_roots(request.codex_home);
|
|
if overrides.read_roots_include_platform_defaults {
|
|
read_roots.extend(
|
|
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
|
|
.iter()
|
|
.map(PathBuf::from),
|
|
);
|
|
}
|
|
read_roots.extend(roots.iter().cloned());
|
|
canonical_existing(&read_roots)
|
|
} else {
|
|
gather_read_roots(
|
|
request.command_cwd,
|
|
request.permissions,
|
|
request.env_map,
|
|
request.codex_home,
|
|
)
|
|
};
|
|
read_roots = expand_user_profile_root(read_roots);
|
|
read_roots = filter_user_profile_root(read_roots);
|
|
read_roots = filter_user_profile_root_exclusions(read_roots);
|
|
read_roots = filter_ssh_config_dependency_roots(read_roots);
|
|
let write_root_set: HashSet<PathBuf> = write_roots.iter().cloned().collect();
|
|
read_roots.retain(|root| !write_root_set.contains(root));
|
|
(read_roots, write_roots)
|
|
}
|
|
|
|
fn build_payload_deny_write_paths(
|
|
request: &SandboxSetupRequest<'_>,
|
|
explicit_deny_write_paths: Option<Vec<PathBuf>>,
|
|
) -> Vec<PathBuf> {
|
|
let allow_deny_paths: AllowDenyPaths = compute_allow_paths_for_permissions(
|
|
request.permissions,
|
|
request.command_cwd,
|
|
request.env_map,
|
|
);
|
|
let mut deny_write_paths: Vec<PathBuf> = explicit_deny_write_paths
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|path| canonicalize_path(&path))
|
|
.collect();
|
|
deny_write_paths.extend(allow_deny_paths.deny);
|
|
deny_write_paths
|
|
}
|
|
|
|
fn build_payload_deny_read_paths(explicit_deny_read_paths: Option<Vec<PathBuf>>) -> Vec<PathBuf> {
|
|
// Keep the configured spelling here so the ACL layer can plan both the
|
|
// lexical path and any existing canonical target for reparse-point aliases.
|
|
explicit_deny_read_paths.unwrap_or_default()
|
|
}
|
|
|
|
fn expand_user_profile_root(roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
|
let Ok(user_profile) = std::env::var("USERPROFILE") else {
|
|
return roots;
|
|
};
|
|
expand_user_profile_root_for(roots, Path::new(&user_profile))
|
|
}
|
|
|
|
fn expand_user_profile_root_for(roots: Vec<PathBuf>, user_profile: &Path) -> Vec<PathBuf> {
|
|
let user_profile_key = canonical_path_key(user_profile);
|
|
let mut expanded = Vec::new();
|
|
for root in roots {
|
|
if canonical_path_key(&root) == user_profile_key {
|
|
expanded.extend(profile_read_roots(user_profile));
|
|
} else {
|
|
expanded.push(root);
|
|
}
|
|
}
|
|
|
|
expanded.sort_by_key(|root| canonical_path_key(root));
|
|
expanded.dedup_by(|a, b| canonical_path_key(a.as_path()) == canonical_path_key(b.as_path()));
|
|
expanded
|
|
}
|
|
|
|
fn filter_user_profile_root(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
|
let Ok(user_profile) = std::env::var("USERPROFILE") else {
|
|
return roots;
|
|
};
|
|
let user_profile_key = canonical_path_key(Path::new(&user_profile));
|
|
roots.retain(|root| canonical_path_key(root) != user_profile_key);
|
|
roots
|
|
}
|
|
|
|
fn filter_user_profile_root_exclusions(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
|
let Ok(user_profile) = std::env::var("USERPROFILE") else {
|
|
return roots;
|
|
};
|
|
let user_profile = Path::new(&user_profile);
|
|
roots.retain(|root| !is_user_profile_root_exclusion(root, user_profile));
|
|
roots
|
|
}
|
|
|
|
fn is_user_profile_root_exclusion(root: &Path, user_profile: &Path) -> bool {
|
|
let root_key = canonical_path_key(root);
|
|
let profile_key = canonical_path_key(user_profile);
|
|
let profile_prefix = format!("{}/", profile_key.trim_end_matches('/'));
|
|
let Some(relative_key) = root_key.strip_prefix(&profile_prefix) else {
|
|
return false;
|
|
};
|
|
let Some(child_name) = relative_key
|
|
.split('/')
|
|
.next()
|
|
.filter(|name| !name.is_empty())
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
USERPROFILE_ROOT_EXCLUSIONS
|
|
.iter()
|
|
.any(|excluded| child_name.eq_ignore_ascii_case(excluded))
|
|
}
|
|
|
|
fn filter_ssh_config_dependency_roots(mut roots: Vec<PathBuf>) -> Vec<PathBuf> {
|
|
let Ok(user_profile) = std::env::var("USERPROFILE") else {
|
|
return roots;
|
|
};
|
|
let user_profile = Path::new(&user_profile);
|
|
let dependency_paths = ssh_config_dependency_paths(user_profile);
|
|
roots.retain(|root| !is_ssh_config_dependency_root(root, user_profile, &dependency_paths));
|
|
roots
|
|
}
|
|
|
|
fn is_ssh_config_dependency_root(
|
|
root: &Path,
|
|
user_profile: &Path,
|
|
dependency_paths: &[PathBuf],
|
|
) -> bool {
|
|
let Some(child_name) = user_profile_child_name(root, user_profile) else {
|
|
return false;
|
|
};
|
|
|
|
dependency_paths.iter().any(|path| {
|
|
user_profile_child_name(path, user_profile)
|
|
.is_some_and(|dependency_child| child_name.eq_ignore_ascii_case(&dependency_child))
|
|
})
|
|
}
|
|
|
|
fn user_profile_child_name(path: &Path, user_profile: &Path) -> Option<String> {
|
|
let root_key = canonical_path_key(path);
|
|
let profile_key = canonical_path_key(user_profile);
|
|
let profile_prefix = format!("{}/", profile_key.trim_end_matches('/'));
|
|
let relative_key = root_key.strip_prefix(&profile_prefix)?;
|
|
relative_key
|
|
.split('/')
|
|
.next()
|
|
.filter(|name| !name.is_empty())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
fn filter_sensitive_write_roots(mut roots: Vec<PathBuf>, codex_home: &Path) -> Vec<PathBuf> {
|
|
// Never grant capability write access to CODEX_HOME or anything under CODEX_HOME/.sandbox,
|
|
// CODEX_HOME/.sandbox-bin, or CODEX_HOME/.sandbox-secrets. These locations contain sandbox
|
|
// control/state and helper binaries and must remain tamper-resistant.
|
|
let codex_home_key = canonical_path_key(codex_home);
|
|
let sbx_dir_key = canonical_path_key(&sandbox_dir(codex_home));
|
|
let sbx_dir_prefix = format!("{}/", sbx_dir_key.trim_end_matches('/'));
|
|
let sbx_bin_dir_key = canonical_path_key(&sandbox_bin_dir(codex_home));
|
|
let sbx_bin_dir_prefix = format!("{}/", sbx_bin_dir_key.trim_end_matches('/'));
|
|
let secrets_dir_key = canonical_path_key(&sandbox_secrets_dir(codex_home));
|
|
let secrets_dir_prefix = format!("{}/", secrets_dir_key.trim_end_matches('/'));
|
|
|
|
roots.retain(|root| {
|
|
let key = canonical_path_key(root);
|
|
key != codex_home_key
|
|
&& key != sbx_dir_key
|
|
&& !key.starts_with(&sbx_dir_prefix)
|
|
&& key != sbx_bin_dir_key
|
|
&& !key.starts_with(&sbx_bin_dir_prefix)
|
|
&& key != secrets_dir_key
|
|
&& !key.starts_with(&secrets_dir_prefix)
|
|
});
|
|
roots
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS;
|
|
use super::build_payload_roots;
|
|
use super::find_setup_exe_for_current_exe;
|
|
use super::gather_full_read_roots_for_permissions;
|
|
use super::gather_read_roots;
|
|
use super::loopback_proxy_port_from_url;
|
|
use super::offline_proxy_settings_from_env;
|
|
use super::profile_read_roots;
|
|
use super::proxy_ports_from_env;
|
|
use super::verify_setup_completed;
|
|
use crate::helper_materialization::BIN_DIRNAME;
|
|
use crate::helper_materialization::RESOURCES_DIRNAME;
|
|
use crate::helper_materialization::helper_bin_dir;
|
|
use crate::resolved_permissions::ResolvedWindowsSandboxPermissions;
|
|
use crate::setup_error::SetupErrorCode;
|
|
use crate::setup_error::SetupErrorReport;
|
|
use crate::setup_error::extract_failure;
|
|
use crate::setup_error::write_setup_error_report;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
|
|
fn canonical_windows_platform_default_roots() -> Vec<PathBuf> {
|
|
WINDOWS_PLATFORM_DEFAULT_READ_ROOTS
|
|
.iter()
|
|
.map(|path| dunce::canonicalize(path).unwrap_or_else(|_| PathBuf::from(path)))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn setup_completion_requires_ready_artifacts() {
|
|
let codex_home = TempDir::new().expect("tempdir");
|
|
let err = verify_setup_completed(codex_home.path())
|
|
.expect_err("missing setup artifacts should fail");
|
|
|
|
assert_eq!(
|
|
extract_failure(&err).map(|failure| failure.code),
|
|
Some(SetupErrorCode::OrchestratorHelperIncomplete)
|
|
);
|
|
}
|
|
|
|
fn permissions_for(
|
|
permission_profile: &PermissionProfile,
|
|
workspace_roots: &[AbsolutePathBuf],
|
|
) -> ResolvedWindowsSandboxPermissions {
|
|
ResolvedWindowsSandboxPermissions::try_from_permission_profile_for_workspace_roots(
|
|
permission_profile,
|
|
workspace_roots,
|
|
)
|
|
.expect("managed permission profile")
|
|
}
|
|
|
|
fn workspace_roots_for(root: &Path) -> Vec<AbsolutePathBuf> {
|
|
vec![AbsolutePathBuf::from_absolute_path(root).expect("absolute workspace root")]
|
|
}
|
|
|
|
fn workspace_write_profile(
|
|
writable_roots: &[AbsolutePathBuf],
|
|
exclude_tmpdir_env_var: bool,
|
|
exclude_slash_tmp: bool,
|
|
) -> PermissionProfile {
|
|
PermissionProfile::workspace_write_with(
|
|
writable_roots,
|
|
NetworkSandboxPolicy::Restricted,
|
|
exclude_tmpdir_env_var,
|
|
exclude_slash_tmp,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn setup_request_prefers_explicit_proxy_settings() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
let permissions = permissions_for(
|
|
&PermissionProfile::read_only(),
|
|
workspace_roots_for(&command_cwd).as_slice(),
|
|
);
|
|
let env_map = HashMap::from([(
|
|
"HTTP_PROXY".to_string(),
|
|
"http://127.0.0.1:8080".to_string(),
|
|
)]);
|
|
let explicit = super::OfflineProxySettings {
|
|
proxy_ports: vec![7890],
|
|
allow_local_binding: true,
|
|
};
|
|
let request = super::SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd: &command_cwd,
|
|
env_map: &env_map,
|
|
codex_home: tmp.path(),
|
|
proxy_enforced: false,
|
|
};
|
|
|
|
assert_eq!(
|
|
super::offline_proxy_settings_for_request(&request, Some(&explicit)),
|
|
explicit
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
write_setup_error_report(
|
|
codex_home.as_path(),
|
|
&SetupErrorReport {
|
|
code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed,
|
|
message: "firewall policy unavailable".to_string(),
|
|
},
|
|
)
|
|
.expect("write setup error report");
|
|
|
|
let err = super::report_helper_failure(
|
|
codex_home.as_path(),
|
|
/*cleared_report*/ true,
|
|
/*exit_code*/ Some(1),
|
|
);
|
|
|
|
let failure = extract_failure(&err).expect("structured setup failure");
|
|
assert_eq!(
|
|
&super::SetupFailure::new(
|
|
super::SetupErrorCode::HelperFirewallPolicyAccessFailed,
|
|
"firewall policy unavailable",
|
|
),
|
|
failure
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_helper_failure_ignores_setup_error_report_when_clear_failed() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
write_setup_error_report(
|
|
codex_home.as_path(),
|
|
&SetupErrorReport {
|
|
code: super::SetupErrorCode::HelperFirewallPolicyAccessFailed,
|
|
message: "stale report".to_string(),
|
|
},
|
|
)
|
|
.expect("write setup error report");
|
|
|
|
let err = super::report_helper_failure(
|
|
codex_home.as_path(),
|
|
/*cleared_report*/ false,
|
|
/*exit_code*/ Some(1),
|
|
);
|
|
|
|
let failure = extract_failure(&err).expect("structured setup failure");
|
|
assert_eq!(
|
|
&super::SetupFailure::new(
|
|
super::SetupErrorCode::OrchestratorHelperExitNonzero,
|
|
"setup helper exited with status Some(1)",
|
|
),
|
|
failure
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_refresh_skips_profiles_without_managed_filesystem_permissions() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
let workspace_roots = workspace_roots_for(command_cwd.as_path());
|
|
|
|
for permission_profile in [
|
|
PermissionProfile::Disabled,
|
|
PermissionProfile::External {
|
|
network: NetworkSandboxPolicy::Restricted,
|
|
},
|
|
] {
|
|
super::run_setup_refresh(
|
|
&permission_profile,
|
|
workspace_roots.as_slice(),
|
|
command_cwd.as_path(),
|
|
&HashMap::new(),
|
|
codex_home.as_path(),
|
|
/*proxy_enforced*/ false,
|
|
)
|
|
.expect("unsupported profiles do not need setup refresh");
|
|
|
|
super::run_setup_refresh_with_extra_read_roots(
|
|
&permission_profile,
|
|
workspace_roots.as_slice(),
|
|
command_cwd.as_path(),
|
|
&HashMap::new(),
|
|
codex_home.as_path(),
|
|
vec![command_cwd.clone()],
|
|
/*proxy_enforced*/ false,
|
|
)
|
|
.expect("unsupported profiles do not need setup refresh");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn loopback_proxy_url_parsing_supports_common_forms() {
|
|
assert_eq!(
|
|
loopback_proxy_port_from_url("http://localhost:3128"),
|
|
Some(3128)
|
|
);
|
|
assert_eq!(
|
|
loopback_proxy_port_from_url("https://127.0.0.1:8080"),
|
|
Some(8080)
|
|
);
|
|
assert_eq!(
|
|
loopback_proxy_port_from_url("socks5h://user:pass@[::1]:1080"),
|
|
Some(1080)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_exe_lookup_checks_package_resource_dir_for_bin_exe() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let package_dir = tmp.path().join("package");
|
|
let bin_dir = package_dir.join(BIN_DIRNAME);
|
|
let resources_dir = package_dir.join(RESOURCES_DIRNAME);
|
|
fs::create_dir_all(&bin_dir).expect("create bin dir");
|
|
fs::create_dir_all(&resources_dir).expect("create resources dir");
|
|
let exe = bin_dir.join("codex.exe");
|
|
let setup_exe = resources_dir.join("codex-windows-sandbox-setup.exe");
|
|
fs::write(&exe, b"codex").expect("write exe");
|
|
fs::write(&setup_exe, b"setup").expect("write setup");
|
|
|
|
let resolved = find_setup_exe_for_current_exe(&exe).expect("setup exe");
|
|
|
|
assert_eq!(resolved, setup_exe);
|
|
}
|
|
|
|
#[test]
|
|
fn loopback_proxy_url_parsing_rejects_non_loopback_and_zero_port() {
|
|
assert_eq!(
|
|
loopback_proxy_port_from_url("http://example.com:3128"),
|
|
None
|
|
);
|
|
assert_eq!(loopback_proxy_port_from_url("http://127.0.0.1:0"), None);
|
|
assert_eq!(loopback_proxy_port_from_url("localhost:8080"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn proxy_ports_from_env_dedupes_and_sorts() {
|
|
let mut env = HashMap::new();
|
|
env.insert(
|
|
"HTTP_PROXY".to_string(),
|
|
"http://127.0.0.1:8080".to_string(),
|
|
);
|
|
env.insert(
|
|
"http_proxy".to_string(),
|
|
"http://localhost:8080".to_string(),
|
|
);
|
|
env.insert("ALL_PROXY".to_string(), "socks5h://[::1]:1081".to_string());
|
|
env.insert(
|
|
"HTTPS_PROXY".to_string(),
|
|
"https://example.com:9999".to_string(),
|
|
);
|
|
|
|
assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080]);
|
|
}
|
|
|
|
#[test]
|
|
fn offline_proxy_settings_ignore_proxy_env_when_online_identity_selected() {
|
|
let mut env = HashMap::new();
|
|
env.insert(
|
|
"HTTP_PROXY".to_string(),
|
|
"http://127.0.0.1:8080".to_string(),
|
|
);
|
|
env.insert(
|
|
"CODEX_NETWORK_ALLOW_LOCAL_BINDING".to_string(),
|
|
"1".to_string(),
|
|
);
|
|
|
|
assert_eq!(
|
|
offline_proxy_settings_from_env(&env, super::SandboxNetworkIdentity::Online),
|
|
super::OfflineProxySettings {
|
|
proxy_ports: vec![],
|
|
allow_local_binding: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn offline_proxy_settings_capture_proxy_ports_and_local_binding_for_offline_identity() {
|
|
let mut env = HashMap::new();
|
|
env.insert(
|
|
"HTTP_PROXY".to_string(),
|
|
"http://127.0.0.1:8080".to_string(),
|
|
);
|
|
env.insert(
|
|
"ALL_PROXY".to_string(),
|
|
"socks5h://127.0.0.1:1081".to_string(),
|
|
);
|
|
env.insert(
|
|
"CODEX_NETWORK_ALLOW_LOCAL_BINDING".to_string(),
|
|
"1".to_string(),
|
|
);
|
|
|
|
assert_eq!(
|
|
offline_proxy_settings_from_env(&env, super::SandboxNetworkIdentity::Offline),
|
|
super::OfflineProxySettings {
|
|
proxy_ports: vec![1081, 8080],
|
|
allow_local_binding: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_marker_request_mismatch_reason_ignores_proxy_drift_for_online_identity() {
|
|
let marker = super::SetupMarker {
|
|
version: super::SETUP_VERSION,
|
|
offline_username: "offline".to_string(),
|
|
online_username: "online".to_string(),
|
|
created_at: None,
|
|
proxy_ports: vec![3128],
|
|
allow_local_binding: false,
|
|
};
|
|
let desired = super::OfflineProxySettings {
|
|
proxy_ports: vec![1081, 8080],
|
|
allow_local_binding: true,
|
|
};
|
|
|
|
assert_eq!(
|
|
marker.request_mismatch_reason(super::SandboxNetworkIdentity::Online, &desired),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_marker_request_mismatch_reason_reports_offline_firewall_drift() {
|
|
let marker = super::SetupMarker {
|
|
version: super::SETUP_VERSION,
|
|
offline_username: "offline".to_string(),
|
|
online_username: "online".to_string(),
|
|
created_at: None,
|
|
proxy_ports: vec![3128],
|
|
allow_local_binding: false,
|
|
};
|
|
let desired = super::OfflineProxySettings {
|
|
proxy_ports: vec![1081, 8080],
|
|
allow_local_binding: true,
|
|
};
|
|
|
|
assert_eq!(
|
|
marker.request_mismatch_reason(super::SandboxNetworkIdentity::Offline, &desired),
|
|
Some(
|
|
"offline firewall settings changed (stored_ports=[3128], desired_ports=[1081, 8080], stored_allow_local_binding=false, desired_allow_local_binding=true)"
|
|
.to_string()
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_read_roots_excludes_configured_top_level_entries() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let user_profile = tmp.path();
|
|
let allowed_dir = user_profile.join("Documents");
|
|
let allowed_file = user_profile.join("settings.json");
|
|
let excluded_dir = user_profile.join(".ssh");
|
|
let excluded_tsh = user_profile.join(".tsh");
|
|
let excluded_case_variant = user_profile.join(".AWS");
|
|
|
|
fs::create_dir_all(&allowed_dir).expect("create allowed dir");
|
|
fs::write(&allowed_file, "safe").expect("create allowed file");
|
|
fs::create_dir_all(&excluded_dir).expect("create excluded dir");
|
|
fs::create_dir_all(&excluded_tsh).expect("create excluded tsh dir");
|
|
fs::create_dir_all(&excluded_case_variant).expect("create excluded case variant");
|
|
|
|
let roots = profile_read_roots(user_profile);
|
|
let actual: HashSet<PathBuf> = roots.into_iter().collect();
|
|
let expected: HashSet<PathBuf> = [allowed_dir, allowed_file].into_iter().collect();
|
|
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
#[test]
|
|
fn profile_read_roots_falls_back_to_profile_root_when_enumeration_fails() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let missing_profile = tmp.path().join("missing-user-profile");
|
|
|
|
let roots = profile_read_roots(&missing_profile);
|
|
|
|
assert_eq!(vec![missing_profile], roots);
|
|
}
|
|
|
|
#[test]
|
|
fn is_user_profile_root_exclusion_blocks_configured_children() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let user_profile = tmp.path().join("user-profile");
|
|
let documents = user_profile.join("Documents");
|
|
let app_data = user_profile.join("AppData");
|
|
let ssh_child = user_profile.join(".ssh").join("config");
|
|
let tsh_child = user_profile.join(".tsh").join("keys");
|
|
let other_root = tmp.path().join("other-root");
|
|
fs::create_dir_all(&documents).expect("create documents");
|
|
fs::create_dir_all(&app_data).expect("create app data");
|
|
fs::create_dir_all(&ssh_child).expect("create ssh child");
|
|
fs::create_dir_all(&tsh_child).expect("create tsh child");
|
|
fs::create_dir_all(&other_root).expect("create other root");
|
|
|
|
assert!(!super::is_user_profile_root_exclusion(
|
|
&documents,
|
|
&user_profile
|
|
));
|
|
assert!(!super::is_user_profile_root_exclusion(
|
|
&app_data,
|
|
&user_profile
|
|
));
|
|
assert!(super::is_user_profile_root_exclusion(
|
|
&ssh_child,
|
|
&user_profile
|
|
));
|
|
assert!(super::is_user_profile_root_exclusion(
|
|
&tsh_child,
|
|
&user_profile
|
|
));
|
|
assert!(!super::is_user_profile_root_exclusion(
|
|
&other_root,
|
|
&user_profile
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn is_ssh_config_dependency_root_blocks_config_dependencies() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let user_profile = tmp.path().join("user-profile");
|
|
let documents = user_profile.join("Documents");
|
|
let ssh_dir = user_profile.join(".ssh");
|
|
let key_dir = user_profile.join(".keys");
|
|
let include_dir = user_profile.join(".included");
|
|
let other_root = tmp.path().join("other-root");
|
|
fs::create_dir_all(&documents).expect("create documents");
|
|
fs::create_dir_all(&ssh_dir).expect("create .ssh");
|
|
fs::create_dir_all(&key_dir).expect("create key dir");
|
|
fs::create_dir_all(&include_dir).expect("create include dir");
|
|
fs::create_dir_all(&other_root).expect("create other root");
|
|
fs::write(
|
|
ssh_dir.join("config"),
|
|
"IdentityFile ~/.keys/id_ed25519\nInclude ~/.included/config\n",
|
|
)
|
|
.expect("write ssh config");
|
|
fs::write(key_dir.join("id_ed25519"), "").expect("write key");
|
|
fs::write(include_dir.join("config"), "User git\n").expect("write included config");
|
|
|
|
let dependency_paths = super::ssh_config_dependency_paths(&user_profile);
|
|
|
|
assert!(!super::is_ssh_config_dependency_root(
|
|
&documents,
|
|
&user_profile,
|
|
&dependency_paths
|
|
));
|
|
assert!(super::is_ssh_config_dependency_root(
|
|
&key_dir,
|
|
&user_profile,
|
|
&dependency_paths
|
|
));
|
|
assert!(super::is_ssh_config_dependency_root(
|
|
&include_dir.join("config"),
|
|
&user_profile,
|
|
&dependency_paths
|
|
));
|
|
assert!(!super::is_ssh_config_dependency_root(
|
|
&other_root,
|
|
&user_profile,
|
|
&dependency_paths
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn expand_user_profile_root_for_replaces_profile_root_with_children() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let user_profile = tmp.path().join("user-profile");
|
|
let documents = user_profile.join("Documents");
|
|
let excluded = user_profile.join(".local");
|
|
let other_root = tmp.path().join("other-root");
|
|
fs::create_dir_all(&documents).expect("create documents");
|
|
fs::create_dir_all(&excluded).expect("create excluded dir");
|
|
fs::create_dir_all(&other_root).expect("create other root");
|
|
|
|
let roots = super::expand_user_profile_root_for(
|
|
vec![user_profile.clone(), other_root.clone()],
|
|
&user_profile,
|
|
);
|
|
let actual: HashSet<PathBuf> = roots.into_iter().collect();
|
|
let expected: HashSet<PathBuf> = [documents, excluded, other_root].into_iter().collect();
|
|
|
|
assert_eq!(expected, actual);
|
|
}
|
|
|
|
#[test]
|
|
fn expanded_write_roots_still_drop_protected_codex_home() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let user_profile = tmp.path().join("user-profile");
|
|
let codex_home = user_profile.join("CodexHome");
|
|
let documents = user_profile.join("Documents");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::create_dir_all(&documents).expect("create documents");
|
|
|
|
let mut roots =
|
|
super::expand_user_profile_root_for(vec![user_profile.clone()], &user_profile);
|
|
let user_profile_key = super::canonical_path_key(&user_profile);
|
|
roots.retain(|root| super::canonical_path_key(root) != user_profile_key);
|
|
roots.retain(|root| !super::is_user_profile_root_exclusion(root, &user_profile));
|
|
let roots = super::filter_sensitive_write_roots(roots, &codex_home);
|
|
|
|
assert_eq!(vec![documents], roots);
|
|
}
|
|
|
|
#[test]
|
|
fn gather_read_roots_includes_helper_bin_dir() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
let permission_profile = PermissionProfile::read_only();
|
|
let workspace_roots = workspace_roots_for(command_cwd.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
|
|
let roots = gather_read_roots(&command_cwd, &permissions, &HashMap::new(), &codex_home);
|
|
let expected =
|
|
dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir");
|
|
|
|
assert!(roots.contains(&expected));
|
|
}
|
|
|
|
#[test]
|
|
fn workspace_write_roots_remain_readable() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
let writable_root = tmp.path().join("extra-write-root");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
fs::create_dir_all(&writable_root).expect("create writable root");
|
|
let writable_roots = vec![
|
|
AbsolutePathBuf::from_absolute_path(&writable_root).expect("absolute writable root"),
|
|
];
|
|
let permission_profile = workspace_write_profile(
|
|
&writable_roots,
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
);
|
|
let workspace_roots = workspace_roots_for(command_cwd.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
|
|
let roots = gather_read_roots(&command_cwd, &permissions, &HashMap::new(), &codex_home);
|
|
let expected_writable =
|
|
dunce::canonicalize(&writable_root).expect("canonical writable root");
|
|
|
|
assert!(roots.contains(&expected_writable));
|
|
}
|
|
|
|
#[test]
|
|
fn build_payload_roots_preserves_helper_roots_when_read_override_is_provided() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let workspace_root = tmp.path().join("workspace-root");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
let readable_root = tmp.path().join("docs");
|
|
fs::create_dir_all(&workspace_root).expect("create workspace root");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
fs::create_dir_all(&readable_root).expect("create readable root");
|
|
let permission_profile = PermissionProfile::read_only();
|
|
let workspace_roots = workspace_roots_for(workspace_root.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
|
|
let (read_roots, write_roots) = build_payload_roots(
|
|
&super::SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd: &command_cwd,
|
|
env_map: &HashMap::new(),
|
|
codex_home: &codex_home,
|
|
proxy_enforced: false,
|
|
},
|
|
&super::SetupRootOverrides {
|
|
read_roots: Some(vec![readable_root.clone()]),
|
|
read_roots_include_platform_defaults: true,
|
|
write_roots: None,
|
|
deny_read_paths: None,
|
|
deny_write_paths: None,
|
|
},
|
|
);
|
|
let expected_helper =
|
|
dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir");
|
|
let expected_cwd = dunce::canonicalize(&command_cwd).expect("canonical workspace");
|
|
let expected_readable =
|
|
dunce::canonicalize(&readable_root).expect("canonical readable root");
|
|
|
|
assert_eq!(write_roots, Vec::<PathBuf>::new());
|
|
assert!(read_roots.contains(&expected_helper));
|
|
assert!(!read_roots.contains(&expected_cwd));
|
|
assert!(read_roots.contains(&expected_readable));
|
|
assert!(
|
|
canonical_windows_platform_default_roots()
|
|
.into_iter()
|
|
.all(|path| read_roots.contains(&path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_payload_roots_replaces_full_read_policy_when_read_override_is_provided() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let workspace_root = tmp.path().join("workspace-root");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
let readable_root = tmp.path().join("docs");
|
|
fs::create_dir_all(&workspace_root).expect("create workspace root");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
fs::create_dir_all(&readable_root).expect("create readable root");
|
|
let permission_profile = PermissionProfile::read_only();
|
|
let workspace_roots = workspace_roots_for(workspace_root.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
|
|
let (read_roots, write_roots) = build_payload_roots(
|
|
&super::SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd: &command_cwd,
|
|
env_map: &HashMap::new(),
|
|
codex_home: &codex_home,
|
|
proxy_enforced: false,
|
|
},
|
|
&super::SetupRootOverrides {
|
|
read_roots: Some(vec![readable_root.clone()]),
|
|
read_roots_include_platform_defaults: false,
|
|
write_roots: None,
|
|
deny_read_paths: None,
|
|
deny_write_paths: None,
|
|
},
|
|
);
|
|
let expected_helper =
|
|
dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir");
|
|
let expected_cwd = dunce::canonicalize(&command_cwd).expect("canonical workspace");
|
|
let expected_readable =
|
|
dunce::canonicalize(&readable_root).expect("canonical readable root");
|
|
|
|
assert_eq!(write_roots, Vec::<PathBuf>::new());
|
|
assert!(read_roots.contains(&expected_helper));
|
|
assert!(!read_roots.contains(&expected_cwd));
|
|
assert!(read_roots.contains(&expected_readable));
|
|
assert!(
|
|
canonical_windows_platform_default_roots()
|
|
.into_iter()
|
|
.all(|path| !read_roots.contains(&path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn effective_write_roots_match_payload_filtering_for_overrides() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
let extra_root = tmp.path().join("extra-root");
|
|
let sandbox_root = super::sandbox_dir(&codex_home);
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
fs::create_dir_all(&extra_root).expect("create extra root");
|
|
fs::create_dir_all(&sandbox_root).expect("create sandbox root");
|
|
let permission_profile = workspace_write_profile(
|
|
&[],
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
);
|
|
let workspace_roots = workspace_roots_for(command_cwd.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
let override_roots = vec![
|
|
command_cwd.clone(),
|
|
extra_root.clone(),
|
|
codex_home.clone(),
|
|
sandbox_root.clone(),
|
|
];
|
|
let request = super::SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd: &command_cwd,
|
|
env_map: &HashMap::new(),
|
|
codex_home: &codex_home,
|
|
proxy_enforced: false,
|
|
};
|
|
let overrides = super::SetupRootOverrides {
|
|
read_roots: None,
|
|
read_roots_include_platform_defaults: false,
|
|
write_roots: Some(override_roots.clone()),
|
|
deny_read_paths: None,
|
|
deny_write_paths: None,
|
|
};
|
|
|
|
let effective_write_roots = super::effective_write_roots_for_setup(
|
|
&permissions,
|
|
&command_cwd,
|
|
&HashMap::new(),
|
|
&codex_home,
|
|
Some(&override_roots),
|
|
);
|
|
let (_read_roots, payload_write_roots) = build_payload_roots(&request, &overrides);
|
|
|
|
let expected_workspace = dunce::canonicalize(&command_cwd).expect("canonical workspace");
|
|
let expected_extra = dunce::canonicalize(&extra_root).expect("canonical extra root");
|
|
let forbidden_codex_home = dunce::canonicalize(&codex_home).expect("canonical codex home");
|
|
let forbidden_sandbox = dunce::canonicalize(&sandbox_root).expect("canonical sandbox root");
|
|
assert_eq!(effective_write_roots, payload_write_roots);
|
|
assert!(effective_write_roots.contains(&expected_workspace));
|
|
assert!(effective_write_roots.contains(&expected_extra));
|
|
assert!(!effective_write_roots.contains(&forbidden_codex_home));
|
|
assert!(!effective_write_roots.contains(&forbidden_sandbox));
|
|
}
|
|
|
|
#[test]
|
|
fn effective_write_roots_use_runtime_workspace_roots_for_workspace_root() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let workspace_root = tmp.path().join("workspace");
|
|
let command_cwd = workspace_root.join("subdir");
|
|
fs::create_dir_all(&codex_home).expect("create codex home");
|
|
fs::create_dir_all(&command_cwd).expect("create command cwd");
|
|
|
|
let permission_profile = workspace_write_profile(
|
|
&[],
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
);
|
|
let workspace_roots = workspace_roots_for(workspace_root.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
|
|
let effective_write_roots = super::effective_write_roots_for_setup(
|
|
&permissions,
|
|
&command_cwd,
|
|
&HashMap::new(),
|
|
&codex_home,
|
|
/*write_roots_override*/ None,
|
|
);
|
|
|
|
assert_eq!(
|
|
effective_write_roots,
|
|
vec![dunce::canonicalize(&workspace_root).expect("canonical workspace root")]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn payload_deny_write_paths_merge_explicit_and_protected_children() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
let extra_write_root = tmp.path().join("extra-write-root");
|
|
let command_git = command_cwd.join(".git");
|
|
let extra_codex = extra_write_root.join(".codex");
|
|
let explicit_deny = tmp.path().join("explicit-deny");
|
|
fs::create_dir_all(&command_git).expect("create command .git");
|
|
fs::create_dir_all(&extra_codex).expect("create extra .codex");
|
|
let writable_roots = vec![
|
|
AbsolutePathBuf::from_absolute_path(&extra_write_root).expect("absolute writable root"),
|
|
];
|
|
let permission_profile = workspace_write_profile(
|
|
&writable_roots,
|
|
/*exclude_tmpdir_env_var*/ true,
|
|
/*exclude_slash_tmp*/ true,
|
|
);
|
|
let workspace_roots = workspace_roots_for(command_cwd.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
let request = super::SandboxSetupRequest {
|
|
permissions: &permissions,
|
|
command_cwd: &command_cwd,
|
|
env_map: &HashMap::new(),
|
|
codex_home: &codex_home,
|
|
proxy_enforced: false,
|
|
};
|
|
|
|
let deny_write_paths =
|
|
super::build_payload_deny_write_paths(&request, Some(vec![explicit_deny.clone()]));
|
|
|
|
assert_eq!(
|
|
[
|
|
dunce::canonicalize(&command_git).expect("canonical command .git"),
|
|
dunce::canonicalize(&extra_codex).expect("canonical extra .codex"),
|
|
explicit_deny,
|
|
]
|
|
.into_iter()
|
|
.collect::<HashSet<PathBuf>>(),
|
|
deny_write_paths.into_iter().collect()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn full_read_roots_preserve_legacy_platform_defaults() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let codex_home = tmp.path().join("codex-home");
|
|
let command_cwd = tmp.path().join("workspace");
|
|
fs::create_dir_all(&command_cwd).expect("create workspace");
|
|
let permission_profile = PermissionProfile::read_only();
|
|
let workspace_roots = workspace_roots_for(command_cwd.as_path());
|
|
let permissions = permissions_for(&permission_profile, workspace_roots.as_slice());
|
|
|
|
let roots = gather_full_read_roots_for_permissions(
|
|
&command_cwd,
|
|
&permissions,
|
|
&HashMap::new(),
|
|
&codex_home,
|
|
);
|
|
|
|
assert!(
|
|
canonical_windows_platform_default_roots()
|
|
.into_iter()
|
|
.all(|path| roots.contains(&path))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_payload_deny_read_paths_preserves_explicit_paths() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let existing = tmp.path().join("secret.env");
|
|
let missing = tmp.path().join("future.env");
|
|
fs::write(&existing, "secret").expect("write existing");
|
|
|
|
assert_eq!(
|
|
super::build_payload_deny_read_paths(Some(vec![existing.clone(), missing.clone()])),
|
|
vec![existing, missing]
|
|
);
|
|
}
|
|
}
|