diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 394efe7c16..86152a09f0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1462,6 +1462,7 @@ dependencies = [ "codex-app-server-protocol", "codex-arg0", "codex-core", + "codex-exec-server", "codex-feedback", "codex-protocol", "futures", @@ -2686,6 +2687,7 @@ dependencies = [ "codex-cli", "codex-cloud-requirements", "codex-core", + "codex-exec-server", "codex-features", "codex-feedback", "codex-file-search", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index a0b98c0fec..d245e617f9 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -16,6 +16,7 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-core = { workspace = true } +codex-exec-server = { workspace = true } codex-feedback = { workspace = true } codex-protocol = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 39768820de..ed7d322647 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -43,6 +43,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; +use codex_exec_server::RemoteExecPathTranslation; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use serde::de::DeserializeOwned; @@ -258,6 +259,10 @@ impl Error for TypedRequestError { pub struct InProcessClientStartArgs { /// Resolved argv0 dispatch paths used by command execution internals. pub arg0_paths: Arg0DispatchPaths, + /// Optional websocket URL for a remote exec-server used by the embedded app-server. + pub exec_server_url: Option, + /// Optional local-to-remote path rewrite for outbound exec-server requests. + pub exec_server_path_translation: Option, /// Shared config used to initialize app-server runtime. pub config: Arc, /// CLI config overrides that are already parsed into TOML values. @@ -312,6 +317,8 @@ impl InProcessClientStartArgs { let initialize = self.initialize_params(); InProcessStartArgs { arg0_paths: self.arg0_paths, + exec_server_url: self.exec_server_url, + exec_server_path_translation: self.exec_server_path_translation, config: self.config, cli_overrides: self.cli_overrides, loader_overrides: self.loader_overrides, @@ -888,6 +895,8 @@ mod tests { ) -> InProcessAppServerClient { InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), + exec_server_url: None, + exec_server_path_translation: None, config: Arc::new(build_test_config().await), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), @@ -1885,11 +1894,17 @@ mod tests { } #[tokio::test] - async fn runtime_start_args_leave_manager_bootstrap_to_app_server() { + async fn runtime_start_args_forward_exec_server_config_to_app_server() { let config = Arc::new(build_test_config().await); + let exec_server_path_translation = RemoteExecPathTranslation { + local_root: std::path::PathBuf::from("/local/codex"), + remote_root: std::path::PathBuf::from("/remote/codex"), + }; let runtime_args = InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), + exec_server_url: Some("ws://127.0.0.1:9999/".to_string()), + exec_server_path_translation: Some(exec_server_path_translation.clone()), config: config.clone(), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), @@ -1907,6 +1922,14 @@ mod tests { .into_runtime_start_args(); assert_eq!(runtime_args.config, config); + assert_eq!( + runtime_args.exec_server_url, + Some("ws://127.0.0.1:9999/".to_string()) + ); + assert_eq!( + runtime_args.exec_server_path_translation, + Some(exec_server_path_translation) + ); } #[tokio::test] diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c5ac53866f..ac529c2a0a 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -2880,7 +2880,7 @@ mod tests { use pretty_assertions::assert_eq; use rmcp::model::Content; use serde_json::Value as JsonValue; - use serde_json::json; + use std::time::Duration; use tokio::sync::Mutex; use tokio::sync::mpsc; diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 4f79b1445a..11953e3ebd 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -79,6 +79,7 @@ use codex_core::config::Config; use codex_core::config_loader::CloudRequirementsLoader; use codex_core::config_loader::LoaderOverrides; use codex_exec_server::EnvironmentManager; +use codex_exec_server::RemoteExecPathTranslation; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use tokio::sync::mpsc; @@ -106,6 +107,13 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b pub struct InProcessStartArgs { /// Resolved argv0 dispatch paths used by command execution internals. pub arg0_paths: Arg0DispatchPaths, + /// Optional websocket URL for a remote exec-server backing the embedded runtime. + /// + /// When omitted, app-server falls back to `CODEX_EXEC_SERVER_URL` and then + /// to a local execution environment. + pub exec_server_url: Option, + /// Optional local-to-remote path rewrite for outbound exec-server requests. + pub exec_server_path_translation: Option, /// Shared base config used to initialize core components. pub config: Arc, /// CLI config overrides that are already parsed into TOML values. @@ -337,9 +345,10 @@ pub async fn start(args: InProcessStartArgs) -> IoResult .await?; if let Err(error) = initialize_response { let _ = client.shutdown().await; + let error_message = error.message; return Err(IoError::new( ErrorKind::InvalidData, - format!("in-process initialize failed: {}", error.message), + format!("in-process initialize failed: {error_message}"), )); } client.notify(ClientNotification::Initialized)?; @@ -381,11 +390,18 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { let processor_outgoing = Arc::clone(&outgoing_message_sender); let (processor_tx, mut processor_rx) = mpsc::channel::(channel_capacity); let mut processor_handle = tokio::spawn(async move { + let mut environment_manager = match args.exec_server_url { + Some(exec_server_url) => EnvironmentManager::new(Some(exec_server_url)), + None => EnvironmentManager::from_env(), + }; + if let Some(path_translation) = args.exec_server_path_translation { + environment_manager = environment_manager.with_path_translation(path_translation); + } let mut processor = MessageProcessor::new(MessageProcessorArgs { outgoing: Arc::clone(&processor_outgoing), arg0_paths: args.arg0_paths, config: args.config, - environment_manager: Arc::new(EnvironmentManager::from_env()), + environment_manager: Arc::new(environment_manager), cli_overrides: args.cli_overrides, loader_overrides: args.loader_overrides, cloud_requirements: args.cloud_requirements, @@ -712,6 +728,8 @@ mod tests { ) -> InProcessClientHandle { let args = InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), + exec_server_url: None, + exec_server_path_translation: None, config: Arc::new(build_test_config().await), cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 082965b3d2..f965eae74f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -39,6 +39,7 @@ mod app_cmd; #[cfg(target_os = "macos")] mod desktop_app; mod mcp_cmd; +mod remote_exec; #[cfg(not(windows))] mod wsl_paths; @@ -535,6 +536,101 @@ struct InteractiveRemoteOptions { /// a remote app server websocket. #[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")] remote_auth_token_env: Option, + + /// Launch `codex-exec-server` on this SSH target and use it for execution + /// while keeping the TUI and app-server local. + /// + /// Example: `codex --exec-server-ssh dev`. + #[arg(long = "exec-server-ssh", value_name = "SSH_TARGET")] + exec_server_ssh: Option, + + /// Remote exec-server program to run via `ssh `. + /// + /// Defaults to `codex-exec-server`. + #[arg(long = "exec-server-program", value_name = "PROGRAM")] + exec_server_program: Option, + + /// Local path prefix to rewrite before sending exec-server cwd/file requests. + /// + /// Use this with `--exec-server-remote-root` when the local checkout path + /// differs from the remote checkout path. + #[arg(long = "exec-server-local-root", value_name = "PATH")] + exec_server_local_root: Option, + + /// Remote path prefix that replaces `--exec-server-local-root`. + #[arg(long = "exec-server-remote-root", value_name = "PATH")] + exec_server_remote_root: Option, +} + +impl InteractiveRemoteOptions { + fn with_fallback(self, fallback: Self) -> Self { + Self { + remote: self.remote.or(fallback.remote), + remote_auth_token_env: self + .remote_auth_token_env + .or(fallback.remote_auth_token_env), + exec_server_ssh: self.exec_server_ssh.or(fallback.exec_server_ssh), + exec_server_program: self.exec_server_program.or(fallback.exec_server_program), + exec_server_local_root: self + .exec_server_local_root + .or(fallback.exec_server_local_root), + exec_server_remote_root: self + .exec_server_remote_root + .or(fallback.exec_server_remote_root), + } + } + + fn validate_for_interactive_tui(&self) -> anyhow::Result<()> { + if self.remote.is_some() && self.exec_server_ssh.is_some() { + anyhow::bail!( + "`--remote` cannot be combined with `--exec-server-ssh`; \ + use one remote mode per session" + ); + } + if self.remote_auth_token_env.is_some() && self.remote.is_none() { + anyhow::bail!("`--remote-auth-token-env` requires `--remote`"); + } + if self.exec_server_program.is_some() && self.exec_server_ssh.is_none() { + anyhow::bail!("`--exec-server-program` requires `--exec-server-ssh`"); + } + if self.exec_server_local_root.is_some() != self.exec_server_remote_root.is_some() { + anyhow::bail!( + "`--exec-server-local-root` and `--exec-server-remote-root` must be provided together" + ); + } + if (self.exec_server_local_root.is_some() || self.exec_server_remote_root.is_some()) + && self.exec_server_ssh.is_none() + { + anyhow::bail!( + "`--exec-server-local-root` and `--exec-server-remote-root` require `--exec-server-ssh`" + ); + } + if let Some(local_root) = &self.exec_server_local_root + && !local_root.is_absolute() + { + anyhow::bail!("`--exec-server-local-root` must be absolute"); + } + if let Some(remote_root) = &self.exec_server_remote_root + && !remote_root.is_absolute() + { + anyhow::bail!("`--exec-server-remote-root` must be absolute"); + } + Ok(()) + } + + fn exec_server_program(&self) -> Option { + self.exec_server_ssh.as_ref().map(|_| { + self.exec_server_program + .clone() + .unwrap_or_else(|| crate::remote_exec::DEFAULT_EXEC_SERVER_PROGRAM.to_string()) + }) + } + + fn exec_server_path_translation(&self) -> Option<(PathBuf, PathBuf)> { + self.exec_server_local_root + .clone() + .zip(self.exec_server_remote_root.clone()) + } } impl FeatureToggles { @@ -611,8 +707,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { // Fold --enable/--disable into config overrides so they flow to all subcommands. let toggle_overrides = feature_toggles.to_overrides()?; root_config_overrides.raw_overrides.extend(toggle_overrides); - let root_remote = remote.remote; - let root_remote_auth_token_env = remote.remote_auth_token_env; + let root_remote_options = remote; match subcommand { None => { @@ -620,21 +715,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = run_interactive_tui( - interactive, - root_remote.clone(), - root_remote_auth_token_env.clone(), - arg0_paths.clone(), - ) - .await?; + let exit_info = + run_interactive_tui(interactive, root_remote_options.clone(), arg0_paths.clone()) + .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "exec", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "exec")?; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -642,11 +729,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::Review(review_args)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "review", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "review")?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; exec_cli.command = Some(ExecCommand::Review(review_args)); prepend_config_flags( @@ -656,19 +739,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::McpServer) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "mcp-server", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "mcp-server")?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "mcp", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "mcp")?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; @@ -681,8 +756,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { auth, } = app_server_cli; reject_remote_mode_for_app_server_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), + &root_remote_options, subcommand.as_ref(), )?; match subcommand { @@ -724,11 +798,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } #[cfg(target_os = "macos")] Some(Subcommand::App(app_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "app", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "app")?; app_cmd::run_app(app_cli).await?; } Some(Subcommand::Resume(ResumeCommand { @@ -750,10 +820,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ); let exit_info = run_interactive_tui( interactive, - remote.remote.or(root_remote.clone()), - remote - .remote_auth_token_env - .or(root_remote_auth_token_env.clone()), + remote.with_fallback(root_remote_options.clone()), arg0_paths.clone(), ) .await?; @@ -776,21 +843,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ); let exit_info = run_interactive_tui( interactive, - remote.remote.or(root_remote.clone()), - remote - .remote_auth_token_env - .or(root_remote_auth_token_env.clone()), + remote.with_fallback(root_remote_options.clone()), arg0_paths.clone(), ) .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "login", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "login")?; prepend_config_flags( &mut login_cli.config_overrides, root_config_overrides.clone(), @@ -822,11 +882,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::Logout(mut logout_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "logout", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "logout")?; prepend_config_flags( &mut logout_cli.config_overrides, root_config_overrides.clone(), @@ -834,19 +890,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_logout(logout_cli.config_overrides).await; } Some(Subcommand::Completion(completion_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "completion", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "completion")?; print_completion(completion_cli); } Some(Subcommand::Cloud(mut cloud_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "cloud", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "cloud")?; prepend_config_flags( &mut cloud_cli.config_overrides, root_config_overrides.clone(), @@ -856,11 +904,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { SandboxCommand::Macos(mut seatbelt_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox macos", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "sandbox macos")?; prepend_config_flags( &mut seatbelt_cli.config_overrides, root_config_overrides.clone(), @@ -872,11 +916,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox linux", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "sandbox linux")?; prepend_config_flags( &mut landlock_cli.config_overrides, root_config_overrides.clone(), @@ -888,11 +928,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox windows", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "sandbox windows")?; prepend_config_flags( &mut windows_cli.config_overrides, root_config_overrides.clone(), @@ -906,38 +942,22 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { }, Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::AppServer(cmd) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "debug app-server", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "debug app-server")?; run_debug_app_server_command(cmd).await?; } DebugSubcommand::ClearMemories => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "debug clear-memories", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "debug clear-memories")?; run_debug_clear_memories_command(&root_config_overrides, &interactive).await?; } }, Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { ExecpolicySubcommand::Check(cmd) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "execpolicy check", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "execpolicy check")?; run_execpolicycheck(cmd)? } }, Some(Subcommand::Apply(mut apply_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "apply", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "apply")?; prepend_config_flags( &mut apply_cli.config_overrides, root_config_overrides.clone(), @@ -945,31 +965,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_apply_command(apply_cli, /*cwd*/ None).await?; } Some(Subcommand::ResponsesApiProxy(args)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "responses-api-proxy", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "responses-api-proxy")?; tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } Some(Subcommand::StdioToUds(cmd)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "stdio-to-uds", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "stdio-to-uds")?; let socket_path = cmd.socket_path; tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) .await??; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "features list", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "features list")?; // Respect root-level `-c` overrides plus top-level flags like `--profile`. let mut cli_kv_overrides = root_config_overrides .parse_overrides() @@ -1012,19 +1020,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "features enable", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "features enable")?; enable_feature_in_config(&interactive, &feature).await?; } FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "features disable", - )?; + reject_remote_mode_for_subcommand(&root_remote_options, "features disable")?; disable_feature_in_config(&interactive, &feature).await?; } }, @@ -1144,26 +1144,44 @@ fn prepend_config_flags( } fn reject_remote_mode_for_subcommand( - remote: Option<&str>, - remote_auth_token_env: Option<&str>, + remote_options: &InteractiveRemoteOptions, subcommand: &str, ) -> anyhow::Result<()> { - if let Some(remote) = remote { + if let Some(remote) = &remote_options.remote { anyhow::bail!( "`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`" ); } - if remote_auth_token_env.is_some() { + if remote_options.remote_auth_token_env.is_some() { anyhow::bail!( "`--remote-auth-token-env` is only supported for interactive TUI commands, not `codex {subcommand}`" ); } + if let Some(ssh_target) = &remote_options.exec_server_ssh { + anyhow::bail!( + "`--exec-server-ssh {ssh_target}` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } + if remote_options.exec_server_program.is_some() { + anyhow::bail!( + "`--exec-server-program` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } + if remote_options.exec_server_local_root.is_some() { + anyhow::bail!( + "`--exec-server-local-root` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } + if remote_options.exec_server_remote_root.is_some() { + anyhow::bail!( + "`--exec-server-remote-root` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } Ok(()) } fn reject_remote_mode_for_app_server_subcommand( - remote: Option<&str>, - remote_auth_token_env: Option<&str>, + remote_options: &InteractiveRemoteOptions, subcommand: Option<&AppServerSubcommand>, ) -> anyhow::Result<()> { let subcommand_name = match subcommand { @@ -1174,7 +1192,7 @@ fn reject_remote_mode_for_app_server_subcommand( "app-server generate-internal-json-schema" } }; - reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) + reject_remote_mode_for_subcommand(remote_options, subcommand_name) } fn read_remote_auth_token_from_env_var_with( @@ -1199,8 +1217,7 @@ fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result, - remote_auth_token_env: Option, + remote_options: InteractiveRemoteOptions, arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { if let Some(prompt) = interactive.prompt.take() { @@ -1226,29 +1243,56 @@ async fn run_interactive_tui( } } - let normalized_remote = remote + if let Err(err) = remote_options.validate_for_interactive_tui() { + return Ok(AppExitInfo::fatal(err.to_string())); + } + + let normalized_remote = remote_options + .remote .as_deref() .map(codex_tui::normalize_remote_addr) .transpose() .map_err(std::io::Error::other)?; - if remote_auth_token_env.is_some() && normalized_remote.is_none() { - return Ok(AppExitInfo::fatal( - "`--remote-auth-token-env` requires `--remote`.", - )); - } - let remote_auth_token = remote_auth_token_env + let remote_auth_token = remote_options + .remote_auth_token_env .as_deref() .map(read_remote_auth_token_from_env_var) .transpose() .map_err(std::io::Error::other)?; - codex_tui::run_main( + + let exec_server_tunnel = match remote_options.exec_server_ssh.clone() { + Some(ssh_target) => { + match crate::remote_exec::RemoteExecServerSshTunnel::launch( + ssh_target, + remote_options.exec_server_program(), + ) + .await + { + Ok(tunnel) => Some(tunnel), + Err(err) => { + return Ok(AppExitInfo::fatal(format!( + "Failed to launch remote exec-server over SSH: {err}" + ))); + } + } + } + None => None, + }; + let exec_server_url = exec_server_tunnel + .as_ref() + .map(|tunnel| tunnel.websocket_url().to_string()); + let exit_info = codex_tui::run_main( interactive, arg0_paths, codex_core::config_loader::LoaderOverrides::default(), normalized_remote, remote_auth_token, + exec_server_url, + remote_options.exec_server_path_translation(), ) - .await + .await; + drop(exec_server_tunnel); + exit_info } fn confirm(prompt: &str) -> std::io::Result { @@ -1775,11 +1819,68 @@ mod tests { assert_eq!(remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); } + #[test] + fn exec_server_ssh_flag_parses_for_interactive_root() { + let cli = + MultitoolCli::try_parse_from(["codex", "--exec-server-ssh", "dev"]).expect("parse"); + + assert_eq!(cli.remote.exec_server_ssh.as_deref(), Some("dev")); + assert_eq!(cli.remote.exec_server_program.as_deref(), None); + } + + #[test] + fn exec_server_program_flag_parses_for_resume_subcommand() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "resume", + "--exec-server-ssh", + "dev", + "--exec-server-program", + "~/code/codex/codex-rs/target/debug/codex-exec-server", + ]) + .expect("parse"); + let Subcommand::Resume(ResumeCommand { remote, .. }) = + cli.subcommand.expect("resume present") + else { + panic!("expected resume subcommand"); + }; + + assert_eq!(remote.exec_server_ssh.as_deref(), Some("dev")); + assert_eq!( + remote.exec_server_program.as_deref(), + Some("~/code/codex/codex-rs/target/debug/codex-exec-server") + ); + } + + #[test] + fn exec_server_root_mapping_flags_parse_for_interactive_root() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--exec-server-ssh", + "dev", + "--exec-server-local-root", + "/Users/starr/code/worktrees/codex-remote-exec-devserver", + "--exec-server-remote-root", + "/home/dev-user/code/codex", + ]) + .expect("parse"); + + assert_eq!( + cli.remote.exec_server_path_translation(), + Some(( + PathBuf::from("/Users/starr/code/worktrees/codex-remote-exec-devserver"), + PathBuf::from("/home/dev-user/code/codex"), + )) + ); + } + #[test] fn reject_remote_mode_for_non_interactive_subcommands() { let err = reject_remote_mode_for_subcommand( - Some("127.0.0.1:4500"), - /*remote_auth_token_env*/ None, + &InteractiveRemoteOptions { + remote: Some("127.0.0.1:4500".to_string()), + ..InteractiveRemoteOptions::default() + }, "exec", ) .expect_err("non-interactive subcommands should reject --remote"); @@ -1792,8 +1893,10 @@ mod tests { #[test] fn reject_remote_auth_token_env_for_non_interactive_subcommands() { let err = reject_remote_mode_for_subcommand( - /*remote*/ None, - Some("CODEX_REMOTE_AUTH_TOKEN"), + &InteractiveRemoteOptions { + remote_auth_token_env: Some("CODEX_REMOTE_AUTH_TOKEN".to_string()), + ..InteractiveRemoteOptions::default() + }, "exec", ) .expect_err("non-interactive subcommands should reject --remote-auth-token-env"); @@ -1803,6 +1906,76 @@ mod tests { ); } + #[test] + fn reject_exec_server_ssh_for_non_interactive_subcommands() { + let err = reject_remote_mode_for_subcommand( + &InteractiveRemoteOptions { + exec_server_ssh: Some("dev".to_string()), + ..InteractiveRemoteOptions::default() + }, + "exec", + ) + .expect_err("non-interactive subcommands should reject --exec-server-ssh"); + + assert!( + err.to_string() + .contains("only supported for interactive TUI commands") + ); + } + + #[test] + fn interactive_remote_options_reject_combining_remote_and_exec_server_ssh() { + let err = InteractiveRemoteOptions { + remote: Some("ws://127.0.0.1:4500".to_string()), + exec_server_ssh: Some("dev".to_string()), + ..InteractiveRemoteOptions::default() + } + .validate_for_interactive_tui() + .expect_err("remote app-server and remote exec-server SSH should conflict"); + + assert!(err.to_string().contains("cannot be combined")); + } + + #[test] + fn interactive_remote_options_use_default_exec_server_program_with_ssh_target() { + let remote = InteractiveRemoteOptions { + exec_server_ssh: Some("dev".to_string()), + ..InteractiveRemoteOptions::default() + }; + + assert_eq!( + remote.exec_server_program().as_deref(), + Some("codex-exec-server") + ); + } + + #[test] + fn interactive_remote_options_require_paired_exec_server_root_mapping() { + let err = InteractiveRemoteOptions { + exec_server_ssh: Some("dev".to_string()), + exec_server_local_root: Some(PathBuf::from("/tmp/local")), + ..InteractiveRemoteOptions::default() + } + .validate_for_interactive_tui() + .expect_err("missing remote root should fail validation"); + + assert!(err.to_string().contains("must be provided together")); + } + + #[test] + fn interactive_remote_options_require_absolute_exec_server_root_mapping() { + let err = InteractiveRemoteOptions { + exec_server_ssh: Some("dev".to_string()), + exec_server_local_root: Some(PathBuf::from("relative/local")), + exec_server_remote_root: Some(PathBuf::from("/tmp/remote")), + ..InteractiveRemoteOptions::default() + } + .validate_for_interactive_tui() + .expect_err("relative local root should fail validation"); + + assert!(err.to_string().contains("must be absolute")); + } + #[test] fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() { let subcommand = @@ -1810,8 +1983,10 @@ mod tests { out_dir: PathBuf::from("/tmp/out"), }); let err = reject_remote_mode_for_app_server_subcommand( - /*remote*/ None, - Some("CODEX_REMOTE_AUTH_TOKEN"), + &InteractiveRemoteOptions { + remote_auth_token_env: Some("CODEX_REMOTE_AUTH_TOKEN".to_string()), + ..InteractiveRemoteOptions::default() + }, Some(&subcommand), ) .expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env"); diff --git a/codex-rs/cli/src/remote_exec.rs b/codex-rs/cli/src/remote_exec.rs new file mode 100644 index 0000000000..8be98ba360 --- /dev/null +++ b/codex-rs/cli/src/remote_exec.rs @@ -0,0 +1,282 @@ +use std::io::ErrorKind; +use std::io::Read; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; +use std::process::Child; +use std::process::Command; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; +use std::thread; +use std::thread::JoinHandle; +use std::time::Duration; +use std::time::Instant; + +pub(crate) const DEFAULT_EXEC_SERVER_PROGRAM: &str = "codex-exec-server"; + +const LOOPBACK_HOST: &str = "127.0.0.1"; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(100); +const PROBE_IO_TIMEOUT: Duration = Duration::from_millis(300); +const SHUTDOWN_WAIT_TIMEOUT: Duration = Duration::from_secs(2); +const STDERR_TAIL_MAX_BYTES: usize = 16 * 1024; + +const WEBSOCKET_PROBE_REQUEST: &[u8] = b"\ +GET / HTTP/1.1\r\n\ +Host: 127.0.0.1\r\n\ +Connection: Upgrade\r\n\ +Upgrade: websocket\r\n\ +Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ +Sec-WebSocket-Version: 13\r\n\ +\r\n"; + +pub(crate) struct RemoteExecServerSshTunnel { + websocket_url: String, + child: Child, + stderr_tail: Arc>>, + stderr_reader: Option>, +} + +impl RemoteExecServerSshTunnel { + pub(crate) async fn launch( + ssh_target: String, + exec_server_program: Option, + ) -> anyhow::Result { + tokio::task::spawn_blocking(move || Self::launch_blocking(ssh_target, exec_server_program)) + .await? + } + + pub(crate) fn websocket_url(&self) -> &str { + &self.websocket_url + } + + fn launch_blocking( + ssh_target: String, + exec_server_program: Option, + ) -> anyhow::Result { + let ssh_target = ssh_target.trim().to_string(); + if ssh_target.is_empty() { + anyhow::bail!("`--exec-server-ssh` requires a non-empty SSH target"); + } + + let exec_server_program = exec_server_program + .unwrap_or_else(|| DEFAULT_EXEC_SERVER_PROGRAM.to_string()) + .trim() + .to_string(); + if exec_server_program.is_empty() { + anyhow::bail!("`--exec-server-program` requires a non-empty command"); + } + + let listener = TcpListener::bind((LOOPBACK_HOST, 0))?; + let local_port = listener.local_addr()?.port(); + drop(listener); + + let websocket_url = format!("ws://{LOOPBACK_HOST}:{local_port}"); + let forward_spec = format!("{LOOPBACK_HOST}:{local_port}:{LOOPBACK_HOST}:{local_port}"); + + let mut command = build_ssh_command( + &ssh_target, + &exec_server_program, + &forward_spec, + &websocket_url, + ); + let mut child = command.spawn().map_err(|err| { + anyhow::anyhow!("failed to start `ssh {ssh_target}` for remote exec-server: {err}") + })?; + + let stderr_tail = Arc::new(Mutex::new(Vec::new())); + let stderr_reader = child + .stderr + .take() + .map(|stderr| spawn_stderr_reader(stderr, Arc::clone(&stderr_tail))); + + let mut tunnel = Self { + websocket_url, + child, + stderr_tail, + stderr_reader, + }; + + if let Err(err) = tunnel.wait_until_ready(local_port) { + tunnel.terminate(); + return Err(err); + } + + Ok(tunnel) + } + + fn wait_until_ready(&mut self, local_port: u16) -> anyhow::Result<()> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + while Instant::now() < deadline { + if let Some(status) = self.child.try_wait()? { + let stderr_tail = self.stderr_tail(); + if status.success() { + anyhow::bail!( + "remote exec-server command exited before startup completed{stderr_tail}" + ); + } + anyhow::bail!( + "remote exec-server ssh command failed with status {status}{stderr_tail}" + ); + } + + if probe_websocket(local_port).is_ok() { + return Ok(()); + } + + thread::sleep(STARTUP_POLL_INTERVAL); + } + + anyhow::bail!( + "timed out waiting for remote exec-server to accept websocket connections at `{}`{}", + self.websocket_url, + self.stderr_tail() + ); + } + + fn stderr_tail(&self) -> String { + let Ok(stderr_tail) = self.stderr_tail.lock() else { + return String::new(); + }; + if stderr_tail.is_empty() { + return String::new(); + } + format!("; stderr: {}", String::from_utf8_lossy(&stderr_tail).trim()) + } + + fn terminate(&mut self) { + let _ = self.child.kill(); + let deadline = Instant::now() + SHUTDOWN_WAIT_TIMEOUT; + while Instant::now() < deadline { + match self.child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => thread::sleep(STARTUP_POLL_INTERVAL), + Err(_) => break, + } + } + let _ = self.child.wait(); + if let Some(stderr_reader) = self.stderr_reader.take() { + let _ = stderr_reader.join(); + } + } +} + +impl Drop for RemoteExecServerSshTunnel { + fn drop(&mut self) { + self.terminate(); + } +} + +fn build_ssh_command( + ssh_target: &str, + exec_server_program: &str, + forward_spec: &str, + websocket_url: &str, +) -> Command { + let mut command = Command::new("ssh"); + command + .arg("-T") + .arg("-o") + .arg("BatchMode=yes") + .arg("-o") + .arg("ExitOnForwardFailure=yes") + .arg("-L") + .arg(forward_spec) + .arg(ssh_target) + .arg(exec_server_program) + .arg("--listen") + .arg(websocket_url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + command +} + +fn probe_websocket(local_port: u16) -> std::io::Result<()> { + let mut stream = TcpStream::connect((LOOPBACK_HOST, local_port))?; + stream.set_read_timeout(Some(PROBE_IO_TIMEOUT))?; + stream.set_write_timeout(Some(PROBE_IO_TIMEOUT))?; + stream.write_all(WEBSOCKET_PROBE_REQUEST)?; + + let mut response = [0_u8; 128]; + let bytes_read = stream.read(&mut response)?; + if bytes_read == 0 { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "websocket probe received an empty response", + )); + } + if response[..bytes_read].starts_with(b"HTTP/1.1 101") { + return Ok(()); + } + + Err(std::io::Error::new( + ErrorKind::InvalidData, + format!( + "websocket probe returned `{}`", + String::from_utf8_lossy(&response[..bytes_read]).trim_end() + ), + )) +} + +fn spawn_stderr_reader( + mut stderr: impl Read + Send + 'static, + stderr_tail: Arc>>, +) -> JoinHandle<()> { + thread::spawn(move || { + let mut buffer = [0_u8; 1024]; + loop { + let bytes_read = match stderr.read(&mut buffer) { + Ok(0) => break, + Ok(bytes_read) => bytes_read, + Err(_) => break, + }; + let Ok(mut stderr_tail) = stderr_tail.lock() else { + break; + }; + stderr_tail.extend_from_slice(&buffer[..bytes_read]); + if stderr_tail.len() > STDERR_TAIL_MAX_BYTES { + let trim_to = stderr_tail.len() - STDERR_TAIL_MAX_BYTES; + stderr_tail.drain(..trim_to); + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::build_ssh_command; + use pretty_assertions::assert_eq; + + #[test] + fn build_ssh_command_forwards_loopback_port_and_exec_server_program() { + let command = build_ssh_command( + "dev", + "codex-exec-server", + "127.0.0.1:9876:127.0.0.1:9876", + "ws://127.0.0.1:9876", + ); + + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + assert_eq!( + args, + vec![ + "-T", + "-o", + "BatchMode=yes", + "-o", + "ExitOnForwardFailure=yes", + "-L", + "127.0.0.1:9876:127.0.0.1:9876", + "dev", + "codex-exec-server", + "--listen", + "ws://127.0.0.1:9876", + ] + ); + } +} diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 3c71dfa19a..80b31b1c5c 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -3,9 +3,8 @@ `codex-exec-server` is a small standalone JSON-RPC server for spawning and controlling subprocesses through `codex-utils-pty`. -This PR intentionally lands only the standalone binary, client, wire protocol, -and docs. Exec and filesystem methods are stubbed server-side here and are -implemented in follow-up PRs. +This crate provides the standalone binary, client, wire protocol, and +exec/filesystem handlers used by remote executor sessions. It currently provides: @@ -13,8 +12,18 @@ It currently provides: - a Rust client: `ExecServerClient` - a small protocol module with shared request/response types -This crate is intentionally narrow. It is not wired into the main Codex CLI or -unified-exec in this PR; it is only the standalone transport layer. +This crate is intentionally narrow. The main Codex TUI can connect its embedded +app-server to a remote exec-server over SSH with: + +```bash +codex --exec-server-ssh dev \ + --exec-server-local-root /Users/starr/code/worktrees/codex-remote-exec-devserver \ + --exec-server-remote-root /home/dev-user/code/codex +``` + +For custom remote binary locations, use `--exec-server-program` as well. The +root-mapping flags are only needed when the local and remote checkout paths +differ. ## Transport @@ -36,7 +45,7 @@ Each connection follows this sequence: 1. Send `initialize`. 2. Wait for the `initialize` response. 3. Send `initialized`. -4. Call exec or filesystem RPCs once the follow-up implementation PRs land. +4. Call exec or filesystem RPCs. If the server receives any notification other than `initialized`, it replies with an error using request id `-1`. diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index d73f6cf95c..c1624c944f 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -18,6 +20,7 @@ use codex_app_server_protocol::FsRemoveResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::FsWriteFileResponse; use codex_app_server_protocol::JSONRPCNotification; +use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::watch; @@ -28,6 +31,7 @@ use tracing::debug; use crate::ProcessId; use crate::client_api::ExecServerClientConnectOptions; +use crate::client_api::RemoteExecPathTranslation; use crate::client_api::RemoteExecServerConnectArgs; use crate::connection::JsonRpcConnection; use crate::protocol::EXEC_CLOSED_METHOD; @@ -91,6 +95,7 @@ impl RemoteExecServerConnectArgs { client_name, connect_timeout: CONNECT_TIMEOUT, initialize_timeout: INITIALIZE_TIMEOUT, + path_translation: None, } } } @@ -109,6 +114,7 @@ pub(crate) struct Session { struct Inner { client: RpcClient, + path_translation: Option, // The remote transport delivers one shared notification stream for every // process on the connection. Keep a local process_id -> session registry so // we can turn those connection-global notifications into process wakeups @@ -162,6 +168,7 @@ impl ExecServerClient { ) -> Result { let websocket_url = args.websocket_url.clone(); let connect_timeout = args.connect_timeout; + let path_translation = args.path_translation.clone(); let (stream, _) = timeout(connect_timeout, connect_async(websocket_url.as_str())) .await .map_err(|_| ExecServerError::WebSocketConnectTimeout { @@ -179,6 +186,7 @@ impl ExecServerClient { format!("exec-server websocket {websocket_url}"), ), args.into(), + path_translation, ) .await } @@ -208,6 +216,10 @@ impl ExecServerClient { } pub async fn exec(&self, params: ExecParams) -> Result { + let params = ExecParams { + cwd: self.translate_path_buf(params.cwd), + ..params + }; self.inner .client .call(EXEC_METHOD, ¶ms) @@ -261,6 +273,9 @@ impl ExecServerClient { &self, params: FsReadFileParams, ) -> Result { + let params = FsReadFileParams { + path: self.translate_absolute_path(params.path)?, + }; self.inner .client .call(FS_READ_FILE_METHOD, ¶ms) @@ -272,6 +287,10 @@ impl ExecServerClient { &self, params: FsWriteFileParams, ) -> Result { + let params = FsWriteFileParams { + path: self.translate_absolute_path(params.path)?, + ..params + }; self.inner .client .call(FS_WRITE_FILE_METHOD, ¶ms) @@ -283,6 +302,10 @@ impl ExecServerClient { &self, params: FsCreateDirectoryParams, ) -> Result { + let params = FsCreateDirectoryParams { + path: self.translate_absolute_path(params.path)?, + ..params + }; self.inner .client .call(FS_CREATE_DIRECTORY_METHOD, ¶ms) @@ -294,6 +317,9 @@ impl ExecServerClient { &self, params: FsGetMetadataParams, ) -> Result { + let params = FsGetMetadataParams { + path: self.translate_absolute_path(params.path)?, + }; self.inner .client .call(FS_GET_METADATA_METHOD, ¶ms) @@ -305,6 +331,9 @@ impl ExecServerClient { &self, params: FsReadDirectoryParams, ) -> Result { + let params = FsReadDirectoryParams { + path: self.translate_absolute_path(params.path)?, + }; self.inner .client .call(FS_READ_DIRECTORY_METHOD, ¶ms) @@ -316,6 +345,10 @@ impl ExecServerClient { &self, params: FsRemoveParams, ) -> Result { + let params = FsRemoveParams { + path: self.translate_absolute_path(params.path)?, + ..params + }; self.inner .client .call(FS_REMOVE_METHOD, ¶ms) @@ -324,6 +357,11 @@ impl ExecServerClient { } pub async fn fs_copy(&self, params: FsCopyParams) -> Result { + let params = FsCopyParams { + source_path: self.translate_absolute_path(params.source_path)?, + destination_path: self.translate_absolute_path(params.destination_path)?, + recursive: params.recursive, + }; self.inner .client .call(FS_COPY_METHOD, ¶ms) @@ -353,6 +391,7 @@ impl ExecServerClient { async fn connect( connection: JsonRpcConnection, options: ExecServerClientConnectOptions, + path_translation: Option, ) -> Result { let (rpc_client, mut events_rx) = RpcClient::new(connection); let inner = Arc::new_cyclic(|weak| { @@ -386,6 +425,7 @@ impl ExecServerClient { Inner { client: rpc_client, + path_translation, sessions: ArcSwap::from_pointee(HashMap::new()), sessions_write_lock: Mutex::new(()), reader_task, @@ -404,6 +444,33 @@ impl ExecServerClient { .await .map_err(ExecServerError::Json) } + + fn translate_path_buf(&self, path: PathBuf) -> PathBuf { + translate_path(self.inner.path_translation.as_ref(), path.as_path()) + } + + fn translate_absolute_path( + &self, + path: AbsolutePathBuf, + ) -> Result { + let translated = self.translate_path_buf(path.to_path_buf()); + AbsolutePathBuf::from_absolute_path(translated.as_path()).map_err(|err| { + ExecServerError::Protocol(format!( + "failed to translate path `{}` for remote exec-server: {err}", + path.display() + )) + }) + } +} + +fn translate_path(path_translation: Option<&RemoteExecPathTranslation>, path: &Path) -> PathBuf { + let Some(path_translation) = path_translation else { + return path.to_path_buf(); + }; + let Ok(suffix) = path.strip_prefix(path_translation.local_root.as_path()) else { + return path.to_path_buf(); + }; + path_translation.remote_root.join(suffix) } impl From for ExecServerError { @@ -628,6 +695,9 @@ async fn handle_server_notification( #[cfg(test)] mod tests { + use std::path::Path; + use std::path::PathBuf; + use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -643,7 +713,9 @@ mod tests { use super::ExecServerClient; use super::ExecServerClientConnectOptions; + use super::translate_path; use crate::ProcessId; + use crate::client_api::RemoteExecPathTranslation; use crate::connection::JsonRpcConnection; use crate::protocol::EXEC_EXITED_METHOD; use crate::protocol::EXEC_OUTPUT_DELTA_METHOD; @@ -718,6 +790,7 @@ mod tests { "test-exec-server-client".to_string(), ), ExecServerClientConnectOptions::default(), + /*path_translation*/ None, ) .await .expect("client should connect"); @@ -777,4 +850,24 @@ mod tests { drop(client); server.await.expect("server task should finish"); } + + #[test] + fn translate_path_rewrites_local_root_prefix() { + let path_translation = RemoteExecPathTranslation { + local_root: PathBuf::from("/Users/starr/code/worktrees/codex-remote-exec-devserver"), + remote_root: PathBuf::from("/home/dev-user/code/codex"), + }; + + assert_eq!( + translate_path( + Some(&path_translation), + Path::new("/Users/starr/code/worktrees/codex-remote-exec-devserver/codex-rs") + ), + PathBuf::from("/home/dev-user/code/codex/codex-rs") + ); + assert_eq!( + translate_path(Some(&path_translation), Path::new("/tmp/codex")), + PathBuf::from("/tmp/codex") + ); + } } diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index 6e89763416..ecd8be56cf 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::time::Duration; /// Connection options for any exec-server client transport. @@ -14,4 +15,12 @@ pub struct RemoteExecServerConnectArgs { pub client_name: String, pub connect_timeout: Duration, pub initialize_timeout: Duration, + pub path_translation: Option, +} + +/// Local-to-remote root rewrite applied to outbound exec-server paths. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteExecPathTranslation { + pub local_root: PathBuf, + pub remote_root: PathBuf, } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 0518612c38..65544585e0 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -5,6 +5,7 @@ use tokio::sync::OnceCell; use crate::ExecServerClient; use crate::ExecServerError; use crate::RemoteExecServerConnectArgs; +use crate::client_api::RemoteExecPathTranslation; use crate::file_system::ExecutorFileSystem; use crate::local_file_system::LocalFileSystem; use crate::local_process::LocalProcess; @@ -21,6 +22,7 @@ pub trait ExecutorEnvironment: Send + Sync { #[derive(Debug, Default)] pub struct EnvironmentManager { exec_server_url: Option, + path_translation: Option, current_environment: OnceCell>, } @@ -28,10 +30,16 @@ impl EnvironmentManager { pub fn new(exec_server_url: Option) -> Self { Self { exec_server_url: normalize_exec_server_url(exec_server_url), + path_translation: None, current_environment: OnceCell::new(), } } + pub fn with_path_translation(mut self, path_translation: RemoteExecPathTranslation) -> Self { + self.path_translation = Some(path_translation); + self + } + pub fn from_env() -> Self { Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok()) } @@ -44,7 +52,11 @@ impl EnvironmentManager { self.current_environment .get_or_try_init(|| async { Ok(Arc::new( - Environment::create(self.exec_server_url.clone()).await?, + Environment::create_with_args(EnvironmentCreateArgs { + exec_server_url: self.exec_server_url.clone(), + path_translation: self.path_translation.clone(), + }) + .await?, )) }) .await @@ -52,6 +64,11 @@ impl EnvironmentManager { } } +struct EnvironmentCreateArgs { + exec_server_url: Option, + path_translation: Option, +} + #[derive(Clone)] pub struct Environment { exec_server_url: Option, @@ -87,6 +104,18 @@ impl std::fmt::Debug for Environment { impl Environment { pub async fn create(exec_server_url: Option) -> Result { + Self::create_with_args(EnvironmentCreateArgs { + exec_server_url, + path_translation: None, + }) + .await + } + + async fn create_with_args(args: EnvironmentCreateArgs) -> Result { + let EnvironmentCreateArgs { + exec_server_url, + path_translation, + } = args; let exec_server_url = normalize_exec_server_url(exec_server_url); let remote_exec_server_client = if let Some(url) = &exec_server_url { Some( @@ -95,6 +124,7 @@ impl Environment { client_name: "codex-environment".to_string(), connect_timeout: std::time::Duration::from_secs(5), initialize_timeout: std::time::Duration::from_secs(5), + path_translation, }) .await?, ) @@ -158,15 +188,19 @@ mod tests { use std::sync::Arc; use super::Environment; + use super::EnvironmentCreateArgs; use super::EnvironmentManager; use crate::ProcessId; use pretty_assertions::assert_eq; #[tokio::test] async fn create_without_remote_exec_server_url_does_not_connect() { - let environment = Environment::create(/*exec_server_url*/ None) - .await - .expect("create environment"); + let environment = Environment::create_with_args(EnvironmentCreateArgs { + exec_server_url: None, + path_translation: None, + }) + .await + .expect("create environment"); assert_eq!(environment.exec_server_url(), None); assert!(environment.remote_exec_server_client.is_none()); diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 77834c6143..6b3f7fa602 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -16,6 +16,7 @@ mod server; pub use client::ExecServerClient; pub use client::ExecServerError; pub use client_api::ExecServerClientConnectOptions; +pub use client_api::RemoteExecPathTranslation; pub use client_api::RemoteExecServerConnectArgs; pub use codex_app_server_protocol::FsCopyParams; pub use codex_app_server_protocol::FsCopyResponse; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ae1285be4d..38db7d725b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -432,6 +432,8 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .collect(); let in_process_start_args = InProcessClientStartArgs { arg0_paths, + exec_server_url: None, + exec_server_path_translation: None, config: std::sync::Arc::new(config.clone()), cli_overrides: run_cli_overrides, loader_overrides: run_loader_overrides, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b005ad1c90..c732ce6522 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -32,6 +32,7 @@ codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-requirements = { workspace = true } codex-core = { workspace = true } +codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-file-search = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index add9e63741..225db38c83 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -95,6 +95,7 @@ use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_ use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; #[cfg(target_os = "windows")] use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_exec_server::RemoteExecPathTranslation; use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_protocol::ThreadId; @@ -984,6 +985,8 @@ pub(crate) struct App { feedback_audience: FeedbackAudience, remote_app_server_url: Option, remote_app_server_auth_token: Option, + embedded_exec_server_url: Option, + embedded_exec_server_path_translation: Option, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -3510,6 +3513,8 @@ impl App { should_prompt_windows_sandbox_nux_at_startup: bool, remote_app_server_url: Option, remote_app_server_auth_token: Option, + embedded_exec_server_url: Option, + embedded_exec_server_path_translation: Option, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -3727,6 +3732,8 @@ impl App { feedback_audience, remote_app_server_url, remote_app_server_auth_token, + embedded_exec_server_url, + embedded_exec_server_path_translation, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -3979,7 +3986,12 @@ impl App { websocket_url, auth_token: self.remote_app_server_auth_token.clone(), }, - None => crate::AppServerTarget::Embedded, + None => crate::AppServerTarget::Embedded { + exec_server_url: self.embedded_exec_server_url.clone(), + exec_server_path_translation: self + .embedded_exec_server_path_translation + .clone(), + }, }, ) .await @@ -8972,6 +8984,8 @@ guardian_approval = true feedback_audience: FeedbackAudience::External, remote_app_server_url: None, remote_app_server_auth_token: None, + embedded_exec_server_url: None, + embedded_exec_server_path_translation: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), @@ -9026,6 +9040,8 @@ guardian_approval = true feedback_audience: FeedbackAudience::External, remote_app_server_url: None, remote_app_server_auth_token: None, + embedded_exec_server_url: None, + embedded_exec_server_path_translation: None, pending_update_action: None, pending_shutdown_exit_thread_id: None, windows_sandbox: WindowsSandboxState::default(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index df52f28fa5..4a670e9378 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -40,6 +40,7 @@ use codex_core::path_utils; use codex_core::read_session_meta_line; use codex_core::state_db::get_state_db; use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_exec_server::RemoteExecPathTranslation; use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -228,6 +229,8 @@ pub use public_widgets::composer_input::ComposerInput; async fn start_embedded_app_server( arg0_paths: Arg0DispatchPaths, + exec_server_url: Option, + exec_server_path_translation: Option, config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, @@ -236,6 +239,8 @@ async fn start_embedded_app_server( ) -> color_eyre::Result { start_embedded_app_server_with( arg0_paths, + exec_server_url, + exec_server_path_translation, config, cli_kv_overrides, loader_overrides, @@ -248,7 +253,10 @@ async fn start_embedded_app_server( #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AppServerTarget { - Embedded, + Embedded { + exec_server_url: Option, + exec_server_path_translation: Option, + }, Remote { websocket_url: String, auth_token: Option, @@ -357,8 +365,13 @@ async fn start_app_server( feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { match target { - AppServerTarget::Embedded => start_embedded_app_server( + AppServerTarget::Embedded { + exec_server_url, + exec_server_path_translation, + } => start_embedded_app_server( arg0_paths, + exec_server_url.clone(), + exec_server_path_translation.clone(), config, cli_kv_overrides, loader_overrides, @@ -395,11 +408,20 @@ pub(crate) async fn start_app_server_for_picker( pub(crate) async fn start_embedded_app_server_for_picker( config: &Config, ) -> color_eyre::Result { - start_app_server_for_picker(config, &AppServerTarget::Embedded).await + start_app_server_for_picker( + config, + &AppServerTarget::Embedded { + exec_server_url: None, + exec_server_path_translation: None, + }, + ) + .await } async fn start_embedded_app_server_with( arg0_paths: Arg0DispatchPaths, + exec_server_url: Option, + exec_server_path_translation: Option, config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, loader_overrides: LoaderOverrides, @@ -423,6 +445,8 @@ where .collect(); let client = start_client(InProcessClientStartArgs { arg0_paths, + exec_server_url, + exec_server_path_translation, config: Arc::new(config), cli_overrides: cli_kv_overrides, loader_overrides, @@ -592,18 +616,28 @@ pub async fn run_main( loader_overrides: LoaderOverrides, remote: Option, remote_auth_token: Option, + exec_server_url: Option, + exec_server_path_translation: Option<(PathBuf, PathBuf)>, ) -> std::io::Result { let remote_url = remote; if let (Some(websocket_url), Some(_)) = (remote_url.as_deref(), remote_auth_token.as_ref()) { validate_remote_auth_token_transport(websocket_url).map_err(std::io::Error::other)?; } - let app_server_target = remote_url - .clone() - .map(|websocket_url| AppServerTarget::Remote { + let app_server_target = remote_url.clone().map_or( + AppServerTarget::Embedded { + exec_server_url, + exec_server_path_translation: exec_server_path_translation.map( + |(local_root, remote_root)| RemoteExecPathTranslation { + local_root, + remote_root, + }, + ), + }, + |websocket_url| AppServerTarget::Remote { websocket_url, auth_token: remote_auth_token.clone(), - }) - .unwrap_or(AppServerTarget::Embedded); + }, + ); let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -787,7 +821,7 @@ pub async fn run_main( } } - if matches!(app_server_target, AppServerTarget::Embedded) { + if matches!(app_server_target, AppServerTarget::Embedded { .. }) { #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.clone(), @@ -1331,6 +1365,19 @@ async fn run_ratatui_app( return Err(err); } }; + let embedded_exec_server_url = match &app_server_target { + AppServerTarget::Embedded { + exec_server_url, .. + } => exec_server_url.clone(), + AppServerTarget::Remote { .. } => None, + }; + let embedded_exec_server_path_translation = match &app_server_target { + AppServerTarget::Embedded { + exec_server_path_translation, + .. + } => exec_server_path_translation.clone(), + AppServerTarget::Remote { .. } => None, + }; let app_result = App::run( &mut tui, @@ -1347,6 +1394,8 @@ async fn run_ratatui_app( should_prompt_windows_sandbox_nux_at_startup, remote_url, remote_auth_token, + embedded_exec_server_url, + embedded_exec_server_path_translation, ) .await; @@ -1673,6 +1722,8 @@ mod tests { ) -> color_eyre::Result { start_embedded_app_server( Arg0DispatchPaths::default(), + None, + None, config, Vec::new(), LoaderOverrides::default(), @@ -1924,6 +1975,8 @@ mod tests { let config = build_config(&temp_dir).await?; let result = start_embedded_app_server_with( Arg0DispatchPaths::default(), + /*exec_server_url*/ None, + /*exec_server_path_translation*/ None, config, Vec::new(), LoaderOverrides::default(), diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 8e0d817081..d2a240a39d 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -28,6 +28,8 @@ fn main() -> anyhow::Result<()> { codex_core::config_loader::LoaderOverrides::default(), /*remote*/ None, /*remote_auth_token*/ None, + /*exec_server_url*/ None, + /*exec_server_path_translation*/ None, ) .await?; let token_usage = exit_info.token_usage; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index e442eebb19..b2057cbc81 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -917,6 +917,8 @@ mod tests { .unwrap(); let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), + exec_server_url: None, + exec_server_path_translation: None, config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: Default::default(),