mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Allow trusted symlinks beneath CODEX_HOME on macOS (#42716)
## What changed - Add the macOS-only `allow_symlinked_codex_home` user setting. When enabled in the execution host's `$CODEX_HOME/config.toml`, writable roots at or beneath that home may traverse symlinks. - Keep the exception disabled by default and prevent project config, command-line overrides, and ignored user config from enabling it. Other writable roots remain subject to symlink checks. - Propagate the resolved setting through local, interactive, and exec-server sandbox paths, including `CODEX_HOME` aliases. - Explain the opt-out in symlink rejection errors, including that it trusts targets outside `CODEX_HOME` and targets that change between commands. ## Testing - Add macOS coverage for shell commands, patches, filesystem helpers, process execution, and interactive startup with enabled, disabled, aliased, and out-of-scope homes. GitOrigin-RevId: 99fcdf611200c9e1b7713cf06f7fdea5bfa7f089
This commit is contained in:
@@ -569,6 +569,10 @@ pub async fn run_main_with_transport_options(
|
||||
}
|
||||
};
|
||||
config.auth_config().validate()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home(
|
||||
codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home),
|
||||
);
|
||||
let code_mode_session_provider: Option<Arc<dyn CodeModeSessionProvider>> =
|
||||
match &runtime_options.code_mode_host_transport {
|
||||
CodeModeHostTransport::Local => None,
|
||||
|
||||
@@ -1881,6 +1881,13 @@ async fn run_exec_server_command(
|
||||
)
|
||||
.await?;
|
||||
let (_otel, telemetry) = exec_server_telemetry::init(Some(&config));
|
||||
#[cfg(target_os = "macos")]
|
||||
let runtime_paths = runtime_paths.with_allowed_symlinked_codex_home(
|
||||
codex_config::allowed_symlinked_codex_home(
|
||||
&config.config_layer_stack,
|
||||
&config.codex_home,
|
||||
),
|
||||
);
|
||||
run_remote_exec_server(
|
||||
cmd,
|
||||
base_url,
|
||||
@@ -1903,6 +1910,14 @@ async fn run_exec_server_command(
|
||||
config_result.ok()
|
||||
};
|
||||
let (_otel, telemetry) = exec_server_telemetry::init(config.as_ref());
|
||||
#[cfg(target_os = "macos")]
|
||||
let runtime_paths =
|
||||
runtime_paths.with_allowed_symlinked_codex_home(config.as_ref().and_then(|config| {
|
||||
codex_config::allowed_symlinked_codex_home(
|
||||
&config.config_layer_stack,
|
||||
&config.codex_home,
|
||||
)
|
||||
}));
|
||||
let http_client_factory = config
|
||||
.as_ref()
|
||||
.map(Config::http_client_factory)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Resolves the host's symlink opt-out from its already-loaded user config.
|
||||
|
||||
use crate::ConfigLayerSource;
|
||||
use crate::ConfigLayerStack;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
/// Returns the opted-in home without consulting project config or rereading config files.
|
||||
pub fn allowed_symlinked_codex_home(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
codex_home: &AbsolutePathBuf,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let enabled = config_layer_stack
|
||||
.layers_low_to_high()
|
||||
.find(|layer| matches!(layer.name, ConfigLayerSource::User { profile: None, .. }))?
|
||||
.config
|
||||
.get("allow_symlinked_codex_home")?
|
||||
.as_bool()?;
|
||||
if !enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Preserve a configured alias only when lexical normalization did not change its target.
|
||||
let alias = std::env::var_os("CODEX_HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.and_then(|path| AbsolutePathBuf::from_absolute_path(path).ok())
|
||||
.filter(|path| path.canonicalize().ok().as_ref() == Some(codex_home));
|
||||
Some(alias.unwrap_or_else(|| codex_home.clone()))
|
||||
}
|
||||
@@ -204,6 +204,13 @@ pub struct ConfigToml {
|
||||
/// Sandbox mode to use.
|
||||
pub sandbox_mode: Option<SandboxMode>,
|
||||
|
||||
/// Allow macOS sandbox writable roots at or beneath CODEX_HOME to traverse
|
||||
/// symlinks. Read only from the host's user config at startup; defaults to false.
|
||||
/// This grants no write access by itself, but trusts symlink targets even if
|
||||
/// they change between commands or lie outside CODEX_HOME.
|
||||
/// This setting has no effect on Linux or Windows.
|
||||
pub allow_symlinked_codex_home: Option<bool>,
|
||||
|
||||
/// Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`.
|
||||
pub sandbox_workspace_write: Option<SandboxWorkspaceWrite>,
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ mod browser_computer_use_requirements;
|
||||
mod browser_use;
|
||||
mod cloud_config_bundle;
|
||||
mod cloud_config_layers;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod codex_home_symlink;
|
||||
mod computer_use;
|
||||
mod config_layer_source;
|
||||
mod config_requirements;
|
||||
@@ -66,6 +68,8 @@ pub use cloud_config_layers::CloudConfigFragmentSource;
|
||||
pub use cloud_config_layers::CloudConfigLayerError;
|
||||
pub use cloud_config_layers::cloud_config_layers_from_fragments;
|
||||
pub use codex_execpolicy::RequirementsExecPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use codex_home_symlink::allowed_symlinked_codex_home;
|
||||
pub use codex_protocol::config_types::ProfileV2Name;
|
||||
pub use codex_protocol::config_types::ProfileV2NameParseError;
|
||||
pub use codex_protocol::config_types::ToolExposureSurface;
|
||||
|
||||
@@ -5806,6 +5806,10 @@
|
||||
"description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"allow_symlinked_codex_home": {
|
||||
"description": "Allow macOS sandbox writable roots at or beneath CODEX_HOME to traverse symlinks. Read only from the host's user config at startup; defaults to false. This grants no write access by itself, but trusts symlink targets even if they change between commands or lie outside CODEX_HOME. This setting has no effect on Linux or Windows.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"analytics": {
|
||||
"allOf": [
|
||||
{
|
||||
|
||||
@@ -37,9 +37,7 @@ use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
pub(crate) struct ToolOrchestrator {
|
||||
sandbox: SandboxManager,
|
||||
}
|
||||
pub(crate) struct ToolOrchestrator;
|
||||
|
||||
pub(crate) struct OrchestratorRunResult<Out> {
|
||||
pub output: Out,
|
||||
@@ -48,9 +46,7 @@ pub(crate) struct OrchestratorRunResult<Out> {
|
||||
|
||||
impl ToolOrchestrator {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sandbox: SandboxManager::new(),
|
||||
}
|
||||
Self
|
||||
}
|
||||
|
||||
async fn run_attempt<Rq, Out, T>(
|
||||
@@ -145,6 +141,14 @@ impl ToolOrchestrator {
|
||||
let mut already_approved = false;
|
||||
|
||||
let environment = tool.turn_environment(req);
|
||||
let sandbox_manager = SandboxManager::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let sandbox_manager = sandbox_manager.with_allowed_symlinked_codex_home(
|
||||
environment
|
||||
.environment
|
||||
.local_runtime_paths()
|
||||
.and_then(|paths| paths.allowed_symlinked_codex_home.clone()),
|
||||
);
|
||||
let sandbox_config = environment.config();
|
||||
let owner_network_policy = sandbox_config.network_policy.is_some();
|
||||
if owner_network_policy
|
||||
@@ -264,14 +268,14 @@ impl ToolOrchestrator {
|
||||
let sandbox_preference = tool.sandbox_preference();
|
||||
let sandbox_requested = match sandbox_override {
|
||||
SandboxOverride::BypassSandboxFirstAttempt => false,
|
||||
SandboxOverride::NoOverride => self.sandbox.should_sandbox(
|
||||
SandboxOverride::NoOverride => sandbox_manager.should_sandbox(
|
||||
&permissions,
|
||||
sandbox_preference,
|
||||
managed_network_active,
|
||||
),
|
||||
};
|
||||
let initial_sandbox = if sandbox_requested && !executor_managed_process_sandbox {
|
||||
self.sandbox.select_initial(
|
||||
sandbox_manager.select_initial(
|
||||
&permissions,
|
||||
sandbox_preference,
|
||||
sandbox_config.windows_sandbox_level,
|
||||
@@ -291,7 +295,7 @@ impl ToolOrchestrator {
|
||||
permissions: &permissions,
|
||||
exec_server_permissions: permission_profile,
|
||||
enforce_managed_network: managed_network_active,
|
||||
manager: &self.sandbox,
|
||||
manager: &sandbox_manager,
|
||||
sandbox_cwd: &sandbox_policy_cwd,
|
||||
workspace_roots,
|
||||
codex_linux_sandbox_exe: turn_ctx.config.codex_linux_sandbox_exe.as_ref(),
|
||||
@@ -438,14 +442,14 @@ impl ToolOrchestrator {
|
||||
}
|
||||
|
||||
let retry_sandbox_requested = !unsandboxed_allowed
|
||||
&& self.sandbox.should_sandbox(
|
||||
&& sandbox_manager.should_sandbox(
|
||||
&permissions,
|
||||
sandbox_preference,
|
||||
managed_network_active,
|
||||
);
|
||||
let retry_sandbox = if retry_sandbox_requested && !executor_managed_process_sandbox
|
||||
{
|
||||
self.sandbox.select_initial(
|
||||
sandbox_manager.select_initial(
|
||||
&permissions,
|
||||
sandbox_preference,
|
||||
sandbox_config.windows_sandbox_level,
|
||||
@@ -465,7 +469,7 @@ impl ToolOrchestrator {
|
||||
permissions: &permissions,
|
||||
exec_server_permissions: permission_profile,
|
||||
enforce_managed_network: managed_network_active,
|
||||
manager: &self.sandbox,
|
||||
manager: &sandbox_manager,
|
||||
sandbox_cwd: &sandbox_policy_cwd,
|
||||
workspace_roots,
|
||||
codex_linux_sandbox_exe: retry_codex_linux_sandbox_exe,
|
||||
|
||||
@@ -84,7 +84,7 @@ fn approval_sandbox_permissions(
|
||||
|
||||
pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
req: &crate::tools::runtimes::unified_exec::UnifiedExecRequest,
|
||||
_attempt: &SandboxAttempt<'_>,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
ctx: &ToolCtx,
|
||||
exec_request: ExecRequest,
|
||||
shell_zsh_path: &std::path::Path,
|
||||
@@ -128,6 +128,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
.to_abs_path()
|
||||
.map_err(|err| ToolError::Rejected(err.to_string()))?;
|
||||
let command_executor = CoreShellCommandExecutor {
|
||||
sandbox_manager: attempt.manager.clone(),
|
||||
command: exec_request.command.clone(),
|
||||
cwd,
|
||||
permission_profile: exec_request.permission_profile.clone(),
|
||||
@@ -565,6 +566,7 @@ fn commands_for_intercepted_exec_policy(
|
||||
// TODO(anp): Capture these Windows and Landlock settings from
|
||||
// TurnEnvironment::sandbox_context when preparing this executor, preserving its snapshot.
|
||||
struct CoreShellCommandExecutor {
|
||||
sandbox_manager: SandboxManager,
|
||||
command: Vec<String>,
|
||||
cwd: AbsolutePathBuf,
|
||||
permission_profile: PermissionProfile,
|
||||
@@ -757,7 +759,7 @@ impl CoreShellCommandExecutor {
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
|
||||
let sandbox_manager = SandboxManager::new();
|
||||
let sandbox_manager = &self.sandbox_manager;
|
||||
let sandbox = sandbox_manager.select_initial(
|
||||
permission_profile,
|
||||
SandboxablePreference::Auto,
|
||||
|
||||
@@ -358,6 +358,7 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re
|
||||
let workdir = test_sandbox_cwd();
|
||||
let network = test_credential_broker_network_proxy().await?;
|
||||
let executor = CoreShellCommandExecutor {
|
||||
sandbox_manager: codex_sandboxing::SandboxManager::new(),
|
||||
command: Vec::new(),
|
||||
cwd: workdir.clone(),
|
||||
permission_profile: PermissionProfile::workspace_write(),
|
||||
|
||||
@@ -686,12 +686,15 @@ async fn symlinked_writable_root_reports_sandbox_failure_instead_of_session_corr
|
||||
-> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let home_path = home.path().display().to_string();
|
||||
let canonical_home_path = home.path().canonicalize()?.display().to_string();
|
||||
let visualization_target = home.path().join("visualization-target");
|
||||
std::fs::create_dir(&visualization_target)?;
|
||||
let visualization_root = home.path().join("visualizations");
|
||||
create_directory_symlink(&visualization_target, &visualization_root);
|
||||
|
||||
let mut builder = test_codex().with_home(home).with_config(move |config| {
|
||||
config.project_doc_max_bytes = 1;
|
||||
let mut file_system_policy = FileSystemSandboxPolicy::read_only();
|
||||
file_system_policy.entries.push(FileSystemSandboxEntry::new(
|
||||
config.cwd.join("private.txt").into(),
|
||||
@@ -727,6 +730,13 @@ async fn symlinked_writable_root_reports_sandbox_failure_instead_of_session_corr
|
||||
!error.contains("Session data under"),
|
||||
"sandbox preparation failure should not be diagnosed as session corruption: {error}"
|
||||
);
|
||||
let error = error
|
||||
.replace(&canonical_home_path, "$CODEX_HOME")
|
||||
.replace(&home_path, "$CODEX_HOME");
|
||||
insta::assert_snapshot!(error, @"
|
||||
Fatal error: Failed to initialize session: failed to load AGENTS.md instructions for environment `local`: failed to prepare fs sandbox: failed to prepare Seatbelt sandbox: writable root $CODEX_HOME/visualizations contains symlink component $CODEX_HOME/visualizations; symlinked writable roots are not supported.
|
||||
If this writable root is at or beneath CODEX_HOME and you trust its symlink targets, set `allow_symlinked_codex_home = true` at the top level of `$CODEX_HOME/config.toml` (normally `~/.codex/config.toml`) on the execution host, then restart Codex or its executor. This opt-out trusts targets outside CODEX_HOME and targets changed between commands. It does not apply to other writable roots.
|
||||
");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -129,6 +129,10 @@ impl FileSystemSandboxRunner {
|
||||
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
|
||||
let helper = &self.runtime_paths.codex_self_exe;
|
||||
let sandbox_manager = SandboxManager::for_file_system_helpers();
|
||||
#[cfg(target_os = "macos")]
|
||||
let sandbox_manager = sandbox_manager.with_allowed_symlinked_codex_home(
|
||||
self.runtime_paths.allowed_symlinked_codex_home.clone(),
|
||||
);
|
||||
let sandbox = sandbox_manager.select_initial(
|
||||
permission_profile,
|
||||
SandboxablePreference::Require,
|
||||
|
||||
@@ -185,6 +185,9 @@ pub(crate) async fn prepare_exec_request_with_telemetry(
|
||||
network_policy,
|
||||
);
|
||||
let sandbox_manager = SandboxManager::new();
|
||||
#[cfg(target_os = "macos")]
|
||||
let sandbox_manager = sandbox_manager
|
||||
.with_allowed_symlinked_codex_home(runtime_paths.allowed_symlinked_codex_home.clone());
|
||||
let sandbox = sandbox_manager.select_initial(
|
||||
&permissions,
|
||||
SandboxablePreference::Require,
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
/// Runtime paths needed by exec-server child processes.
|
||||
/// Paths and sandbox settings initialized when creating an executor.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ExecServerRuntimePaths {
|
||||
/// Stable path to the Codex executable used to launch hidden helper modes.
|
||||
@@ -10,6 +10,9 @@ pub struct ExecServerRuntimePaths {
|
||||
/// Path to the Linux sandbox helper alias used when the platform sandbox
|
||||
/// needs to re-enter Codex by argv0.
|
||||
pub codex_linux_sandbox_exe: Option<AbsolutePathBuf>,
|
||||
/// User-config opt-out of writable-root symlink checks beneath this host's home.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl ExecServerRuntimePaths {
|
||||
@@ -33,8 +36,20 @@ impl ExecServerRuntimePaths {
|
||||
Ok(Self {
|
||||
codex_self_exe: absolute_path(codex_self_exe)?,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?,
|
||||
#[cfg(target_os = "macos")]
|
||||
allowed_symlinked_codex_home: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Applies the symlink opt-in resolved by the execution host's config loader.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn with_allowed_symlinked_codex_home(
|
||||
mut self,
|
||||
allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
|
||||
) -> Self {
|
||||
self.allowed_symlinked_codex_home = allowed_symlinked_codex_home;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn absolute_path(path: PathBuf) -> std::io::Result<AbsolutePathBuf> {
|
||||
|
||||
@@ -102,7 +102,13 @@ impl ExecServerHarness {
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::inherit());
|
||||
command.kill_on_drop(true);
|
||||
command.env("CODEX_HOME", codex_home.path());
|
||||
if !command
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.any(|(key, value)| key == "CODEX_HOME" && value.is_some())
|
||||
{
|
||||
command.env("CODEX_HOME", codex_home.path());
|
||||
}
|
||||
let mut child = command.spawn()?;
|
||||
|
||||
let websocket_url = read_listen_url_from_stdout(&mut child).await?;
|
||||
|
||||
@@ -222,13 +222,32 @@ fn maybe_run_exec_server_from_test_binary(guard: Option<&TestBinaryDispatchGuard
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let exit_code = match runtime.block_on(codex_exec_server::run_main_with_telemetry(
|
||||
&listen_url,
|
||||
runtime_paths,
|
||||
ExecServerTelemetry::default(),
|
||||
http_client_factory,
|
||||
request_dispatch_mode,
|
||||
)) {
|
||||
let exit_code = match runtime.block_on(async {
|
||||
#[cfg(target_os = "macos")]
|
||||
let runtime_paths = {
|
||||
let home = codex_utils_home_dir::find_codex_home()?;
|
||||
let config = codex_config::loader::load_config_layers_state(
|
||||
&codex_exec_server::LocalFileSystem::unsandboxed(),
|
||||
home.as_path(),
|
||||
/*cwd*/ None,
|
||||
&[],
|
||||
codex_config::LoaderOverrides::default(),
|
||||
&codex_config::NoopThreadConfigLoader,
|
||||
)
|
||||
.await?;
|
||||
runtime_paths.with_allowed_symlinked_codex_home(
|
||||
codex_config::allowed_symlinked_codex_home(&config, &home),
|
||||
)
|
||||
};
|
||||
codex_exec_server::run_main_with_telemetry(
|
||||
&listen_url,
|
||||
runtime_paths,
|
||||
ExecServerTelemetry::default(),
|
||||
http_client_factory,
|
||||
request_dispatch_mode,
|
||||
)
|
||||
.await
|
||||
}) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
eprintln!("exec-server failed: {error}");
|
||||
|
||||
@@ -102,6 +102,78 @@ async fn create_process_context(use_remote: bool) -> Result<ProcessContext> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn codex_home_symlink_opt_out_respects_host_config_and_scope() -> Result<()> {
|
||||
use codex_exec_server::WriteFileOptions;
|
||||
use common::exec_server::exec_server_with_env;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let workspace = TempDir::new()?;
|
||||
let home = TempDir::new()?;
|
||||
let target = TempDir::new()?;
|
||||
let alias = home.path().join("visualizations");
|
||||
let other_alias = workspace.path().join(".codex/visualizations");
|
||||
std::fs::create_dir(workspace.path().join(".codex"))?;
|
||||
symlink(target.path(), &alias)?;
|
||||
symlink(target.path(), &other_alias)?;
|
||||
std::fs::write(
|
||||
workspace.path().join(".codex/config.toml"),
|
||||
"allow_symlinked_codex_home = true\n",
|
||||
)?;
|
||||
|
||||
for enabled in [None, Some(false), Some(true)] {
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
enabled.map_or_else(String::new, |enabled| {
|
||||
format!("allow_symlinked_codex_home = {enabled}\n")
|
||||
}),
|
||||
)?;
|
||||
let mut server = exec_server_with_env([("CODEX_HOME", home.path())], &[]).await?;
|
||||
let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?;
|
||||
for root in [alias.as_path(), other_alias.as_path(), workspace.path()] {
|
||||
let mut policy = FileSystemSandboxPolicy::read_only();
|
||||
policy.entries.push(FileSystemSandboxEntry::new(
|
||||
PathUri::from_host_native_path(root)?.into(),
|
||||
FileSystemAccessMode::Write,
|
||||
));
|
||||
let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd(
|
||||
PermissionProfile::from_runtime_permissions(
|
||||
&policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
),
|
||||
PathUri::from_host_native_path(workspace.path())?,
|
||||
);
|
||||
let result = environment
|
||||
.get_filesystem()
|
||||
.write_file(
|
||||
&PathUri::from_host_native_path(root.join("output"))?,
|
||||
b"written".to_vec(),
|
||||
WriteFileOptions::default(),
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
result.is_ok(),
|
||||
root == workspace.path() || (enabled == Some(true) && root == alias),
|
||||
"root={root:?}, enabled={enabled:?}: {result:?}"
|
||||
);
|
||||
if let Err(error) = result {
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("symlinked writable roots are not supported"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
server.shutdown().await?;
|
||||
}
|
||||
assert_eq!(std::fs::read(target.path().join("output"))?, b"written");
|
||||
assert_eq!(std::fs::read(workspace.path().join("output"))?, b"written");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test_case(false, false, false, false, "bash"; "local_pipe")]
|
||||
#[test_case(false, true, false, false, "bash"; "local_tty")]
|
||||
|
||||
@@ -626,6 +626,10 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
|
||||
arg0_paths.codex_self_exe.clone(),
|
||||
arg0_paths.codex_linux_sandbox_exe.clone(),
|
||||
)?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home(
|
||||
codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home),
|
||||
);
|
||||
let state_db = codex_core::init_state_db(&config).await;
|
||||
let environment_manager = if run_loader_overrides.ignore_user_config {
|
||||
EnvironmentManager::from_env(Some(local_runtime_paths), config.http_client_factory())
|
||||
|
||||
@@ -15,6 +15,110 @@ use std::process::Output;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn codex_home_symlink_opt_out_uses_loaded_user_config() -> anyhow::Result<()> {
|
||||
use core_test_support::responses;
|
||||
use core_test_support::test_codex_exec::test_codex_exec;
|
||||
use serde_json::json;
|
||||
|
||||
core_test_support::skip_if_sandbox!(Ok(()));
|
||||
for (home_env, root_alias, enabled, ignore_user_config) in [
|
||||
("user-home", Some("user-home"), true, false),
|
||||
("home-link/../.codex", Some(".codex"), false, false),
|
||||
("home-link/../.codex", None, true, false),
|
||||
("user-home", Some("user-home"), true, true),
|
||||
] {
|
||||
let test = test_codex_exec();
|
||||
let home = test.home_path().join(".codex");
|
||||
let project_home = test.cwd_path().join(".codex");
|
||||
let child = test.home_path().join("child");
|
||||
for directory in [&home, &project_home, &child] {
|
||||
fs::create_dir(directory)?;
|
||||
}
|
||||
symlink(&home, test.cwd_path().join("user-home"))?;
|
||||
symlink(&child, test.cwd_path().join("home-link"))?;
|
||||
fs::write(
|
||||
home.join("config.toml"),
|
||||
format!("allow_symlinked_codex_home = {enabled}\n"),
|
||||
)?;
|
||||
// The checkout must not enable the opt-out, even when `..` follows a symlink.
|
||||
fs::write(
|
||||
project_home.join("config.toml"),
|
||||
"allow_symlinked_codex_home = true\n",
|
||||
)?;
|
||||
let target = TempDir::new()?;
|
||||
for root in [&home, &project_home] {
|
||||
symlink(target.path(), root.join("visualizations"))?;
|
||||
}
|
||||
let writable_home =
|
||||
root_alias.map_or_else(|| home.clone(), |alias| test.cwd_path().join(alias));
|
||||
let visualizations = writable_home.join("visualizations");
|
||||
let shell_args = json!({
|
||||
"cmd": "printf shell > shell.txt",
|
||||
"workdir": visualizations,
|
||||
"shell": "/bin/sh",
|
||||
"login": false,
|
||||
})
|
||||
.to_string();
|
||||
let patch = format!(
|
||||
"*** Begin Patch\n*** Add File: {}/patch.txt\n+patched\n*** End Patch",
|
||||
visualizations.display()
|
||||
);
|
||||
let server = responses::start_mock_server().await;
|
||||
let should_run = enabled && !ignore_user_config;
|
||||
let mock = responses::mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_function_call("shell", "exec_command", &shell_args),
|
||||
responses::ev_apply_patch_custom_tool_call("patch", &patch),
|
||||
responses::ev_completed("response-1"),
|
||||
]),
|
||||
responses::sse(vec![responses::ev_completed("response-2")]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
let mut command = test.cmd_with_server(&server);
|
||||
command
|
||||
.env_remove("CODEX_API_KEY")
|
||||
.env_remove("OPENAI_API_KEY")
|
||||
.env_remove("CODEX_ACCESS_TOKEN")
|
||||
.env("CODEX_HOME", home_env)
|
||||
.args(["--skip-git-repo-check", "--sandbox", "workspace-write", "--add-dir"])
|
||||
.arg(&visualizations)
|
||||
.args(["-c", "approvals_reviewer=\"user\"", "-c", "features.shell_snapshot_v2=false"])
|
||||
// Session overrides cannot authorize the host's exception either.
|
||||
.args(["-c", "allow_symlinked_codex_home=true"])
|
||||
.args(["-c", "model_provider=\"test\"", "-c"])
|
||||
.arg(format!(
|
||||
"model_providers.test={{name=\"test\",base_url={:?},wire_api=\"responses\",requires_openai_auth=false,supports_websockets=false}}",
|
||||
format!("{}/v1", server.uri())
|
||||
))
|
||||
.arg("write the files");
|
||||
if ignore_user_config {
|
||||
command.arg("--ignore-user-config");
|
||||
}
|
||||
let output = command.output()?;
|
||||
assert!(output.status.success(), "{output:?}");
|
||||
assert_eq!(mock.requests().len(), 2);
|
||||
if should_run {
|
||||
assert_eq!(fs::read(target.path().join("shell.txt"))?, b"shell");
|
||||
assert_eq!(fs::read(target.path().join("patch.txt"))?, b"patched\n");
|
||||
} else {
|
||||
let shell_output = mock
|
||||
.function_call_output_text("shell")
|
||||
.expect("shell tool output");
|
||||
assert!(
|
||||
shell_output.contains("symlinked writable roots are not supported"),
|
||||
"{shell_output}"
|
||||
);
|
||||
assert!(!target.path().join("shell.txt").exists());
|
||||
assert!(!target.path().join("patch.txt").exists());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_fixture() -> (TempDir, AbsolutePathBuf) {
|
||||
let temp_dir = TempDir::new().expect("create temporary directory");
|
||||
let workspace = temp_dir.path().join("workspace");
|
||||
|
||||
@@ -275,10 +275,12 @@ impl std::error::Error for SandboxTransformError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Clone, Default)]
|
||||
pub struct SandboxManager {
|
||||
#[cfg(target_os = "macos")]
|
||||
seatbelt_profile: MacosSeatbeltProfile,
|
||||
#[cfg(target_os = "macos")]
|
||||
allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
impl SandboxManager {
|
||||
@@ -291,9 +293,22 @@ impl SandboxManager {
|
||||
Self {
|
||||
#[cfg(target_os = "macos")]
|
||||
seatbelt_profile: MacosSeatbeltProfile::FileSystemHelper,
|
||||
#[cfg(target_os = "macos")]
|
||||
allowed_symlinked_codex_home: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allows otherwise-authorized writable roots beneath the opted-in user home
|
||||
/// to follow symlinks, including targets outside that home.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn with_allowed_symlinked_codex_home(
|
||||
mut self,
|
||||
allowed_symlinked_codex_home: Option<AbsolutePathBuf>,
|
||||
) -> Self {
|
||||
self.allowed_symlinked_codex_home = allowed_symlinked_codex_home;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn select_initial(
|
||||
&self,
|
||||
permission_profile: &PermissionProfile,
|
||||
@@ -395,6 +410,7 @@ impl SandboxManager {
|
||||
extra_allow_unix_sockets: &[],
|
||||
},
|
||||
self.seatbelt_profile,
|
||||
self.allowed_symlinked_codex_home.as_ref(),
|
||||
)
|
||||
.map_err(|err| match err {
|
||||
SeatbeltPreparationError::FileSystem(message) => {
|
||||
|
||||
@@ -447,19 +447,34 @@ fn normalize_top_level_alias_for_sandbox(
|
||||
|
||||
fn normalize_writable_root_for_sandbox(
|
||||
root: AbsolutePathBuf,
|
||||
allowed_symlinked_codex_home: Option<&AbsolutePathBuf>,
|
||||
) -> Result<NormalizedWritableRoot, SeatbeltPreparationError> {
|
||||
if let Some(symlink) = nested_symlink_component(root.as_path()) {
|
||||
let allow_symlinks = allowed_symlinked_codex_home.is_some_and(|home| {
|
||||
root.as_path().starts_with(home.as_path())
|
||||
|| normalize_path_for_sandbox(home.as_path())
|
||||
.is_some_and(|target| root.as_path().starts_with(target.as_path()))
|
||||
});
|
||||
if !allow_symlinks && let Some(symlink) = nested_symlink_component(root.as_path()) {
|
||||
return Err(SeatbeltPreparationError::FileSystem(format!(
|
||||
"writable root {} contains symlink component {}; symlinked writable roots are not supported",
|
||||
"writable root {} contains symlink component {}; symlinked writable roots are not supported.\n\
|
||||
If this writable root is at or beneath CODEX_HOME and you trust its symlink targets, \
|
||||
set `allow_symlinked_codex_home = true` at the top level of `$CODEX_HOME/config.toml` \
|
||||
(normally `~/.codex/config.toml`) on the execution host, then restart Codex or its executor. \
|
||||
This opt-out trusts targets outside CODEX_HOME and targets changed between commands. \
|
||||
It does not apply to other writable roots.",
|
||||
root.display(),
|
||||
symlink.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Resolve only top-level system aliases such as `/tmp -> /private/tmp`.
|
||||
// Deeper components can be mutated by an already-running sandboxed process,
|
||||
// so following them here would turn a path check into a new authority grant.
|
||||
let normalized = normalize_top_level_alias_for_sandbox(root)?;
|
||||
let normalized = if allow_symlinks {
|
||||
// This explicit opt-out trusts the current target, including targets
|
||||
// outside CODEX_HOME and links changed by an earlier command.
|
||||
normalize_path_for_sandbox(root.as_path()).unwrap_or(root)
|
||||
} else {
|
||||
// Otherwise preserve mutable components instead of granting their targets.
|
||||
normalize_top_level_alias_for_sandbox(root)?
|
||||
};
|
||||
|
||||
let metadata = match std::fs::symlink_metadata(normalized.as_path()) {
|
||||
Ok(metadata) => metadata,
|
||||
@@ -483,6 +498,7 @@ fn normalize_writable_root_for_sandbox(
|
||||
fn build_seatbelt_access_policy(
|
||||
access_kind: SeatbeltAccessKind,
|
||||
roots: Vec<SeatbeltAccessRoot>,
|
||||
allowed_symlinked_codex_home: Option<&AbsolutePathBuf>,
|
||||
) -> Result<(String, Vec<(String, PathBuf)>), SeatbeltPreparationError> {
|
||||
let mut policy_components = Vec::new();
|
||||
let mut root_anchor_denies = Vec::new();
|
||||
@@ -500,7 +516,10 @@ fn build_seatbelt_access_policy(
|
||||
(root, SeatbeltPathMatch::Subpath)
|
||||
}
|
||||
SeatbeltAccessKind::Write => {
|
||||
match normalize_writable_root_for_sandbox(access_root.root)? {
|
||||
match normalize_writable_root_for_sandbox(
|
||||
access_root.root,
|
||||
allowed_symlinked_codex_home,
|
||||
)? {
|
||||
NormalizedWritableRoot::Subpath(root) => (root, SeatbeltPathMatch::Subpath),
|
||||
NormalizedWritableRoot::Literal(root) => (root, SeatbeltPathMatch::Literal),
|
||||
}
|
||||
@@ -844,13 +863,18 @@ pub struct CreateSeatbeltCommandArgsParams<'a> {
|
||||
pub fn create_seatbelt_command_args(
|
||||
args: CreateSeatbeltCommandArgsParams<'_>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
create_seatbelt_command_args_with_profile(args, MacosSeatbeltProfile::Process)
|
||||
.map_err(|err| err.to_string())
|
||||
create_seatbelt_command_args_with_profile(
|
||||
args,
|
||||
MacosSeatbeltProfile::Process,
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn create_seatbelt_command_args_with_profile(
|
||||
args: CreateSeatbeltCommandArgsParams<'_>,
|
||||
profile: MacosSeatbeltProfile,
|
||||
allowed_symlinked_codex_home: Option<&AbsolutePathBuf>,
|
||||
) -> Result<Vec<String>, SeatbeltPreparationError> {
|
||||
let CreateSeatbeltCommandArgsParams {
|
||||
command,
|
||||
@@ -868,6 +892,10 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
|
||||
file_system_sandbox_policy.get_unreadable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let writable_roots = file_system_sandbox_policy
|
||||
.get_writable_roots_with_cwd_preserving_mutable_paths(sandbox_policy_cwd);
|
||||
let allowed_symlinked_codex_home = allowed_symlinked_codex_home
|
||||
.cloned()
|
||||
.map(normalize_top_level_alias_for_sandbox)
|
||||
.transpose()?;
|
||||
// Protect ancestors of read-only paths so renaming a writable directory
|
||||
// cannot move its descendants outside their policy carveouts.
|
||||
let mut protected_ancestors = BTreeSet::new();
|
||||
@@ -908,6 +936,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
|
||||
excluded_subpaths: unreadable_roots.clone(),
|
||||
protected_metadata_names: Vec::new(),
|
||||
}],
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)?
|
||||
}
|
||||
} else {
|
||||
@@ -925,6 +954,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
|
||||
excluded_subpaths: root.read_only_subpaths,
|
||||
})
|
||||
.collect(),
|
||||
allowed_symlinked_codex_home.as_ref(),
|
||||
)?
|
||||
};
|
||||
|
||||
@@ -943,6 +973,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
|
||||
excluded_subpaths: unreadable_roots,
|
||||
protected_metadata_names: Vec::new(),
|
||||
}],
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)?;
|
||||
(
|
||||
format!("; allow read-only file operations\n{policy}"),
|
||||
@@ -965,6 +996,7 @@ pub(crate) fn create_seatbelt_command_args_with_profile(
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)?;
|
||||
if policy.is_empty() {
|
||||
(String::new(), params)
|
||||
|
||||
@@ -218,6 +218,7 @@ fn filesystem_helper_platform_defaults_do_not_grant_applications_directory() {
|
||||
extra_allow_unix_sockets: &[],
|
||||
},
|
||||
profile,
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)
|
||||
.expect("build restricted seatbelt command");
|
||||
|
||||
@@ -297,6 +298,7 @@ fn process_platform_defaults_allow_scratch_without_granting_it_to_filesystem_hel
|
||||
extra_allow_unix_sockets: &[],
|
||||
},
|
||||
profile,
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)
|
||||
.expect("build restricted seatbelt command")
|
||||
};
|
||||
@@ -1021,6 +1023,7 @@ fn preferences_access_requires_unrestricted_reads() {
|
||||
extra_allow_unix_sockets: &[],
|
||||
},
|
||||
profile,
|
||||
/*allowed_symlinked_codex_home*/ None,
|
||||
)
|
||||
.expect("build seatbelt policy");
|
||||
let policy = seatbelt_policy_arg(&args);
|
||||
|
||||
@@ -360,6 +360,10 @@ pub(super) async fn run_main_inner(
|
||||
))
|
||||
.await??
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
let local_runtime_paths = local_runtime_paths.with_allowed_symlinked_codex_home(
|
||||
codex_config::allowed_symlinked_codex_home(&config.config_layer_stack, &config.codex_home),
|
||||
);
|
||||
let environment_manager = Arc::new(
|
||||
prepared_environment_manager
|
||||
.build(Some(local_runtime_paths), config.http_client_factory())
|
||||
|
||||
@@ -14,6 +14,8 @@ use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use anyhow::ensure;
|
||||
#[cfg(target_os = "macos")]
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Full startup continues after the composer first appears and can be slower under Rosetta in CI.
|
||||
@@ -27,7 +29,7 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R
|
||||
let codex_home = tempfile::tempdir()?;
|
||||
write_test_config(codex_home.path(), &repo_root)?;
|
||||
|
||||
let mut terminal = PtyCodex::start(&repo_root, codex_home)?;
|
||||
let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?;
|
||||
terminal.wait_for_startup()?;
|
||||
|
||||
let startup_output_len = terminal.output.len();
|
||||
@@ -45,6 +47,101 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn interactive_startup_honors_codex_home_symlink_opt_out() -> Result<()> {
|
||||
use core_test_support::responses;
|
||||
use wiremock::matchers::body_string_contains;
|
||||
|
||||
core_test_support::skip_if_sandbox!(Ok(()));
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let workspace_path = workspace.path().canonicalize()?;
|
||||
let codex_home = tempfile::tempdir()?;
|
||||
let target = tempfile::tempdir()?;
|
||||
let visualizations = codex_home.path().join("visualizations");
|
||||
std::os::unix::fs::symlink(target.path(), &visualizations)?;
|
||||
write_test_config(codex_home.path(), &workspace_path)?;
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
// Automatic thread-title requests must not consume the tool responses.
|
||||
let _title_mock = responses::mount_sse_once_match(
|
||||
&server,
|
||||
body_string_contains(r#"\"thread_source\":\"system\""#),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("title", r#"{"title":"Symlink probe"}"#),
|
||||
responses::ev_completed("title"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let _write_mock = responses::mount_sse_once_match(
|
||||
&server,
|
||||
body_string_contains(r#"\"thread_source\":\"user\""#),
|
||||
responses::sse(vec![
|
||||
responses::ev_exec_command_call_with_args(
|
||||
"write",
|
||||
&serde_json::json!({
|
||||
"cmd": "printf ready > ready.txt",
|
||||
"workdir": visualizations,
|
||||
"shell": "/bin/sh",
|
||||
"login": false,
|
||||
}),
|
||||
),
|
||||
responses::ev_completed("startup"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let _completion_mock = responses::mount_sse_once_match(
|
||||
&server,
|
||||
body_string_contains(r#"\"thread_source\":\"user\""#),
|
||||
responses::sse(vec![
|
||||
responses::ev_assistant_message("ready", "symlink-opt-out-ready"),
|
||||
responses::ev_completed("complete"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let base_url = server.uri();
|
||||
let visualizations = toml::Value::String(visualizations.display().to_string());
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
std::fs::write(
|
||||
config_path,
|
||||
format!(
|
||||
"allow_symlinked_codex_home = true\n\
|
||||
sandbox_mode = \"workspace-write\"\n\
|
||||
approval_policy = \"never\"\n\
|
||||
{config}\n\
|
||||
[sandbox_workspace_write]\nwritable_roots = [{visualizations}]\n\
|
||||
[model_providers.test]\nname = \"Mock\"\n\
|
||||
base_url = \"{base_url}/v1\"\nwire_api = \"responses\"\n\
|
||||
requires_openai_auth = false\nsupports_websockets = false\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
// A fresh home has no daemon. Writing through the link exercises the embedded server's
|
||||
// local executor and its sandbox policy.
|
||||
let mut terminal = PtyCodex::start(
|
||||
&workspace_path,
|
||||
codex_home,
|
||||
&["-c", "model_provider=\"test\"", "Write ready.txt"],
|
||||
)?;
|
||||
terminal.wait_for_startup()?;
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
while Instant::now() < deadline {
|
||||
terminal.read_output(Duration::from_millis(/*millis*/ 50))?;
|
||||
if terminal.screen_contains("symlink-opt-out-ready") {
|
||||
assert_eq!(std::fs::read(target.path().join("ready.txt"))?, b"ready");
|
||||
return Ok(());
|
||||
}
|
||||
if terminal.child.try_wait()?.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bail!(
|
||||
"interactive startup did not honor the symlink opt-out; screen:\n{}",
|
||||
terminal.screen_contents(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) struct PtyCodex {
|
||||
master: File,
|
||||
child: Child,
|
||||
@@ -57,7 +154,11 @@ pub(super) struct PtyCodex {
|
||||
}
|
||||
|
||||
impl PtyCodex {
|
||||
pub(super) fn start(repo_root: &Path, codex_home: TempDir) -> Result<Self> {
|
||||
pub(super) fn start(
|
||||
repo_root: &Path,
|
||||
codex_home: TempDir,
|
||||
extra_args: &[&str],
|
||||
) -> Result<Self> {
|
||||
let mut master_fd = -1;
|
||||
let mut slave_fd = -1;
|
||||
let mut window_size = libc::winsize {
|
||||
@@ -92,6 +193,7 @@ impl PtyCodex {
|
||||
let codex = codex_utils_cargo_bin::cargo_bin("codex-tui")
|
||||
.or_else(|_| codex_utils_cargo_bin::cargo_bin("codex"))?;
|
||||
let child = Command::new(codex)
|
||||
.args(extra_args)
|
||||
.arg("--no-alt-screen")
|
||||
.arg("-C")
|
||||
.arg(repo_root)
|
||||
|
||||
@@ -130,7 +130,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
|
||||
}
|
||||
Ok::<_, anyhow::Error>(methods)
|
||||
});
|
||||
let mut terminal = PtyCodex::start(&repo_root, codex_home)?;
|
||||
let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?;
|
||||
terminal.wait_for_startup()?;
|
||||
let mut disconnect_tx = Some(disconnect_tx);
|
||||
let mut restore_tx = Some(restore_tx);
|
||||
|
||||
Reference in New Issue
Block a user