mirror of
https://github.com/openai/codex.git
synced 2026-04-03 20:43:54 +00:00
Compare commits
1 Commits
pr16638
...
mstar/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
847781add8 |
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String>,
|
||||
/// Optional local-to-remote path rewrite for outbound exec-server requests.
|
||||
pub exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
/// Shared config used to initialize app-server runtime.
|
||||
pub config: Arc<Config>,
|
||||
/// 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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Optional local-to-remote path rewrite for outbound exec-server requests.
|
||||
pub exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
/// Shared base config used to initialize core components.
|
||||
pub config: Arc<Config>,
|
||||
/// CLI config overrides that are already parsed into TOML values.
|
||||
@@ -337,9 +345,10 @@ pub async fn start(args: InProcessStartArgs) -> IoResult<InProcessClientHandle>
|
||||
.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::<ProcessorCommand>(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(),
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Remote exec-server program to run via `ssh <SSH_TARGET>`.
|
||||
///
|
||||
/// Defaults to `codex-exec-server`.
|
||||
#[arg(long = "exec-server-program", value_name = "PROGRAM")]
|
||||
exec_server_program: Option<String>,
|
||||
|
||||
/// 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<PathBuf>,
|
||||
|
||||
/// Remote path prefix that replaces `--exec-server-local-root`.
|
||||
#[arg(long = "exec-server-remote-root", value_name = "PATH")]
|
||||
exec_server_remote_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<F>(
|
||||
@@ -1199,8 +1217,7 @@ fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result<Str
|
||||
|
||||
async fn run_interactive_tui(
|
||||
mut interactive: TuiCli,
|
||||
remote: Option<String>,
|
||||
remote_auth_token_env: Option<String>,
|
||||
remote_options: InteractiveRemoteOptions,
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
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<bool> {
|
||||
@@ -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");
|
||||
|
||||
282
codex-rs/cli/src/remote_exec.rs
Normal file
282
codex-rs/cli/src/remote_exec.rs
Normal file
@@ -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<Mutex<Vec<u8>>>,
|
||||
stderr_reader: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl RemoteExecServerSshTunnel {
|
||||
pub(crate) async fn launch(
|
||||
ssh_target: String,
|
||||
exec_server_program: Option<String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
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<String>,
|
||||
) -> anyhow::Result<Self> {
|
||||
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<Mutex<Vec<u8>>>,
|
||||
) -> 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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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<RemoteExecPathTranslation>,
|
||||
// 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<Self, ExecServerError> {
|
||||
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<ExecResponse, ExecServerError> {
|
||||
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<FsReadFileResponse, ExecServerError> {
|
||||
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<FsWriteFileResponse, ExecServerError> {
|
||||
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<FsCreateDirectoryResponse, ExecServerError> {
|
||||
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<FsGetMetadataResponse, ExecServerError> {
|
||||
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<FsReadDirectoryResponse, ExecServerError> {
|
||||
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<FsRemoveResponse, ExecServerError> {
|
||||
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<FsCopyResponse, ExecServerError> {
|
||||
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<RemoteExecPathTranslation>,
|
||||
) -> Result<Self, ExecServerError> {
|
||||
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<AbsolutePathBuf, ExecServerError> {
|
||||
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<RpcCallError> 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RemoteExecPathTranslation>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
path_translation: Option<RemoteExecPathTranslation>,
|
||||
current_environment: OnceCell<Arc<Environment>>,
|
||||
}
|
||||
|
||||
@@ -28,10 +30,16 @@ impl EnvironmentManager {
|
||||
pub fn new(exec_server_url: Option<String>) -> 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<String>,
|
||||
path_translation: Option<RemoteExecPathTranslation>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Environment {
|
||||
exec_server_url: Option<String>,
|
||||
@@ -87,6 +104,18 @@ impl std::fmt::Debug for Environment {
|
||||
|
||||
impl Environment {
|
||||
pub async fn create(exec_server_url: Option<String>) -> Result<Self, ExecServerError> {
|
||||
Self::create_with_args(EnvironmentCreateArgs {
|
||||
exec_server_url,
|
||||
path_translation: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn create_with_args(args: EnvironmentCreateArgs) -> Result<Self, ExecServerError> {
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String>,
|
||||
remote_app_server_auth_token: Option<String>,
|
||||
embedded_exec_server_url: Option<String>,
|
||||
embedded_exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
/// Set when the user confirms an update; propagated on exit.
|
||||
pub(crate) pending_update_action: Option<UpdateAction>,
|
||||
|
||||
@@ -3510,6 +3513,8 @@ impl App {
|
||||
should_prompt_windows_sandbox_nux_at_startup: bool,
|
||||
remote_app_server_url: Option<String>,
|
||||
remote_app_server_auth_token: Option<String>,
|
||||
embedded_exec_server_url: Option<String>,
|
||||
embedded_exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
) -> Result<AppExitInfo> {
|
||||
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(),
|
||||
|
||||
@@ -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<String>,
|
||||
exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
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<InProcessAppServerClient> {
|
||||
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<String>,
|
||||
exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
},
|
||||
Remote {
|
||||
websocket_url: String,
|
||||
auth_token: Option<String>,
|
||||
@@ -357,8 +365,13 @@ async fn start_app_server(
|
||||
feedback: codex_feedback::CodexFeedback,
|
||||
) -> color_eyre::Result<AppServerClient> {
|
||||
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<AppServerSession> {
|
||||
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<F, Fut>(
|
||||
arg0_paths: Arg0DispatchPaths,
|
||||
exec_server_url: Option<String>,
|
||||
exec_server_path_translation: Option<RemoteExecPathTranslation>,
|
||||
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<String>,
|
||||
remote_auth_token: Option<String>,
|
||||
exec_server_url: Option<String>,
|
||||
exec_server_path_translation: Option<(PathBuf, PathBuf)>,
|
||||
) -> std::io::Result<AppExitInfo> {
|
||||
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<InProcessAppServerClient> {
|
||||
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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user