Wire remote app-server auth through the client (#14853)

For app-server websocket auth, support the two server-side mechanisms
from
PR #14847:

- `--ws-auth capability-token --ws-token-file /abs/path`
- `--ws-auth signed-bearer-token --ws-shared-secret-file /abs/path`
  with optional `--ws-issuer`, `--ws-audience`, and
  `--ws-max-clock-skew-seconds`

On the client side, add interactive remote support via:

- `--remote ws://host:port` or `--remote wss://host:port`
- `--remote-auth-token-env <ENV_VAR>`

Codex reads the bearer token from the named environment variable and
sends it
as `Authorization: Bearer <token>` during the websocket handshake.
Remote auth
tokens are only allowed for `wss://` URLs or loopback `ws://` URLs.

Testing:
- tested both auth methods manually to confirm connection success and
rejection for both auth types
This commit is contained in:
Eric Traut
2026-03-25 22:17:03 -06:00
committed by GitHub
parent b565f05d79
commit 1ff39b6fa8
7 changed files with 469 additions and 47 deletions

View File

@@ -530,6 +530,11 @@ struct InteractiveRemoteOptions {
/// Accepted forms: `ws://host:port` or `wss://host:port`.
#[arg(long = "remote", value_name = "ADDR")]
remote: Option<String>,
/// Name of the environment variable containing the bearer token to send to
/// a remote app server websocket.
#[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")]
remote_auth_token_env: Option<String>,
}
impl FeatureToggles {
@@ -607,6 +612,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
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;
match subcommand {
None => {
@@ -614,12 +620,21 @@ 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(), arg0_paths.clone()).await?;
let exit_info = run_interactive_tui(
interactive,
root_remote.clone(),
root_remote_auth_token_env.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(), "exec")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"exec",
)?;
prepend_config_flags(
&mut exec_cli.config_overrides,
root_config_overrides.clone(),
@@ -627,7 +642,11 @@ 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(), "review")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"review",
)?;
let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?;
exec_cli.command = Some(ExecCommand::Review(review_args));
prepend_config_flags(
@@ -637,11 +656,19 @@ 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(), "mcp-server")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"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(), "mcp")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"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?;
@@ -653,9 +680,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
analytics_default_enabled,
auth,
} = app_server_cli;
reject_remote_mode_for_app_server_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
subcommand.as_ref(),
)?;
match subcommand {
None => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?;
let transport = listen;
let auth = auth.try_into_settings()?;
codex_app_server::run_main_with_transport(
@@ -670,10 +701,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
}
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
"app-server generate-ts",
)?;
let options = codex_app_server_protocol::GenerateTsOptions {
experimental_api: gen_cli.experimental,
..Default::default()
@@ -685,10 +712,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
)?;
}
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
"app-server generate-json-schema",
)?;
codex_app_server_protocol::generate_json_with_experimental(
&gen_cli.out_dir,
gen_cli.experimental,
@@ -701,7 +724,11 @@ 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(), "app")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"app",
)?;
app_cmd::run_app(app_cli).await?;
}
Some(Subcommand::Resume(ResumeCommand {
@@ -724,6 +751,9 @@ 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()),
arg0_paths.clone(),
)
.await?;
@@ -747,13 +777,20 @@ 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()),
arg0_paths.clone(),
)
.await?;
handle_app_exit(exit_info)?;
}
Some(Subcommand::Login(mut login_cli)) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "login")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"login",
)?;
prepend_config_flags(
&mut login_cli.config_overrides,
root_config_overrides.clone(),
@@ -785,7 +822,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
}
}
Some(Subcommand::Logout(mut logout_cli)) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "logout")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"logout",
)?;
prepend_config_flags(
&mut logout_cli.config_overrides,
root_config_overrides.clone(),
@@ -793,11 +834,19 @@ 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(), "completion")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"completion",
)?;
print_completion(completion_cli);
}
Some(Subcommand::Cloud(mut cloud_cli)) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "cloud")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"cloud",
)?;
prepend_config_flags(
&mut cloud_cli.config_overrides,
root_config_overrides.clone(),
@@ -807,7 +856,11 @@ 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(), "sandbox macos")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"sandbox macos",
)?;
prepend_config_flags(
&mut seatbelt_cli.config_overrides,
root_config_overrides.clone(),
@@ -819,7 +872,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
}
SandboxCommand::Linux(mut landlock_cli) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox linux")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"sandbox linux",
)?;
prepend_config_flags(
&mut landlock_cli.config_overrides,
root_config_overrides.clone(),
@@ -831,7 +888,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
}
SandboxCommand::Windows(mut windows_cli) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox windows")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"sandbox windows",
)?;
prepend_config_flags(
&mut windows_cli.config_overrides,
root_config_overrides.clone(),
@@ -845,22 +906,38 @@ 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(), "debug app-server")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"debug app-server",
)?;
run_debug_app_server_command(cmd).await?;
}
DebugSubcommand::ClearMemories => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug clear-memories")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"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(), "execpolicy check")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"execpolicy check",
)?;
run_execpolicycheck(cmd)?
}
},
Some(Subcommand::Apply(mut apply_cli)) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "apply")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"apply",
)?;
prepend_config_flags(
&mut apply_cli.config_overrides,
root_config_overrides.clone(),
@@ -868,19 +945,31 @@ 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(), "responses-api-proxy")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"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(), "stdio-to-uds")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"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(), "features list")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"features list",
)?;
// Respect root-level `-c` overrides plus top-level flags like `--profile`.
let mut cli_kv_overrides = root_config_overrides
.parse_overrides()
@@ -923,11 +1012,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
}
}
FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "features enable")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"features enable",
)?;
enable_feature_in_config(&interactive, &feature).await?;
}
FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "features disable")?;
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
root_remote_auth_token_env.as_deref(),
"features disable",
)?;
disable_feature_in_config(&interactive, &feature).await?;
}
},
@@ -1046,18 +1143,64 @@ fn prepend_config_flags(
.splice(0..0, cli_config_overrides.raw_overrides);
}
fn reject_remote_mode_for_subcommand(remote: Option<&str>, subcommand: &str) -> anyhow::Result<()> {
fn reject_remote_mode_for_subcommand(
remote: Option<&str>,
remote_auth_token_env: Option<&str>,
subcommand: &str,
) -> anyhow::Result<()> {
if let Some(remote) = remote {
anyhow::bail!(
"`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`"
);
}
if remote_auth_token_env.is_some() {
anyhow::bail!(
"`--remote-auth-token-env` 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>,
subcommand: Option<&AppServerSubcommand>,
) -> anyhow::Result<()> {
let subcommand_name = match subcommand {
None => "app-server",
Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts",
Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema",
Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => {
"app-server generate-internal-json-schema"
}
};
reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name)
}
fn read_remote_auth_token_from_env_var_with<F>(
env_var_name: &str,
get_var: F,
) -> anyhow::Result<String>
where
F: FnOnce(&str) -> Result<String, std::env::VarError>,
{
let auth_token = get_var(env_var_name)
.map_err(|_| anyhow::anyhow!("environment variable `{env_var_name}` is not set"))?;
let auth_token = auth_token.trim().to_string();
if auth_token.is_empty() {
anyhow::bail!("environment variable `{env_var_name}` is empty");
}
Ok(auth_token)
}
fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result<String> {
read_remote_auth_token_from_env_var_with(env_var_name, |name| std::env::var(name))
}
async fn run_interactive_tui(
mut interactive: TuiCli,
remote: Option<String>,
remote_auth_token_env: Option<String>,
arg0_paths: Arg0DispatchPaths,
) -> std::io::Result<AppExitInfo> {
if let Some(prompt) = interactive.prompt.take() {
@@ -1089,17 +1232,28 @@ async fn run_interactive_tui(
.map(codex_tui_app_server::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`.",
));
}
if normalized_remote.is_some() && !use_app_server_tui {
return Ok(AppExitInfo::fatal(
"`--remote` requires the `tui_app_server` feature flag to be enabled.",
));
}
if use_app_server_tui {
let remote_auth_token = remote_auth_token_env
.as_deref()
.map(read_remote_auth_token_from_env_var)
.transpose()
.map_err(std::io::Error::other)?;
codex_tui_app_server::run_main(
into_app_server_tui_cli(interactive),
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
normalized_remote,
remote_auth_token,
)
.await
.map(into_legacy_app_exit_info)
@@ -1661,6 +1815,22 @@ mod tests {
assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500"));
}
#[test]
fn remote_auth_token_env_flag_parses_for_interactive_root() {
let cli = MultitoolCli::try_parse_from([
"codex",
"--remote-auth-token-env",
"CODEX_REMOTE_AUTH_TOKEN",
"--remote",
"ws://127.0.0.1:4500",
])
.expect("parse");
assert_eq!(
cli.remote.remote_auth_token_env.as_deref(),
Some("CODEX_REMOTE_AUTH_TOKEN")
);
}
#[test]
fn remote_flag_parses_for_resume_subcommand() {
let cli =
@@ -1676,7 +1846,7 @@ mod tests {
#[test]
fn reject_remote_mode_for_non_interactive_subcommands() {
let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), "exec")
let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), None, "exec")
.expect_err("non-interactive subcommands should reject --remote");
assert!(
err.to_string()
@@ -1684,6 +1854,59 @@ mod tests {
);
}
#[test]
fn reject_remote_auth_token_env_for_non_interactive_subcommands() {
let err = reject_remote_mode_for_subcommand(None, Some("CODEX_REMOTE_AUTH_TOKEN"), "exec")
.expect_err("non-interactive subcommands should reject --remote-auth-token-env");
assert!(
err.to_string()
.contains("only supported for interactive TUI commands")
);
}
#[test]
fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() {
let subcommand =
AppServerSubcommand::GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand {
out_dir: PathBuf::from("/tmp/out"),
});
let err = reject_remote_mode_for_app_server_subcommand(
None,
Some("CODEX_REMOTE_AUTH_TOKEN"),
Some(&subcommand),
)
.expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env");
assert!(err.to_string().contains("generate-internal-json-schema"));
}
#[test]
fn read_remote_auth_token_from_env_var_reports_missing_values() {
let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| {
Err(std::env::VarError::NotPresent)
})
.expect_err("missing env vars should be rejected");
assert!(err.to_string().contains("is not set"));
}
#[test]
fn read_remote_auth_token_from_env_var_trims_values() {
let auth_token =
read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| {
Ok(" bearer-token ".to_string())
})
.expect("env var should parse");
assert_eq!(auth_token, "bearer-token");
}
#[test]
fn read_remote_auth_token_from_env_var_rejects_empty_values() {
let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| {
Ok(" \n\t ".to_string())
})
.expect_err("empty env vars should be rejected");
assert!(err.to_string().contains("is empty"));
}
#[test]
fn app_server_listen_websocket_url_parses() {
let app_server = app_server_from_args(