Support bearer tokens for app-server executor connections (#47648)

## Why

App-server clients need a way to connect to executors that require bearer authentication through `environment/add` and `environments.toml`.

## What changed

- Add optional `authBearerToken` to `environment/add` and `auth_bearer_token` to URL entries in `environments.toml`.
- Send the token as an `Authorization: Bearer` header on initial connections and reconnects, without automatic refresh. Require `wss://` or a loopback destination when a token is supplied.
- Redact tokens in debug output, mark connection headers sensitive, and omit source text from TOML parse errors.
- Preserve unauthenticated behavior when tokens are omitted, including requests with a null `authBearerToken`.

## Testing

Add integration coverage for authenticated and unauthenticated executors through both RPC and TOML configuration, including missing, null, incorrect, and malformed RPC tokens. Extend tests for reconnect authorization headers, token redaction, URL-only token configuration, and timeout preservation.

GitOrigin-RevId: 2a5d48a1846df1720e4a9b295968348378c287c6
This commit is contained in:
Ruslan Nigmatullin
2026-09-23 20:05:37 +00:00
committed by copyberry
parent e9b4c6967e
commit ad26b2520a
12 changed files with 262 additions and 16 deletions
+1
View File
@@ -3163,6 +3163,7 @@ dependencies = [
"codex-utils-home-dir",
"codex-utils-path-uri",
"codex-utils-pty",
"codex-utils-redacted-string",
"codex-utils-rustls-provider",
"codex-websocket-auth",
"codex-websocket-client",
@@ -2566,6 +2566,7 @@ mod tests {
let environment_add = ClientRequest::EnvironmentAdd {
request_id: request_id(),
params: v2::EnvironmentAddParams {
auth_bearer_token: None,
environment_id: "remote-a".to_string(),
exec_server_url: "ws://127.0.0.1:8765".to_string(),
connect_timeout_ms: None,
@@ -3819,11 +3820,13 @@ mod tests {
let request = ClientRequest::EnvironmentAdd {
request_id: RequestId::Integer(9),
params: v2::EnvironmentAddParams {
auth_bearer_token: Some("private-executor-token".into()),
environment_id: "remote-a".to_string(),
exec_server_url: "ws://127.0.0.1:8765".to_string(),
connect_timeout_ms: Some(300_000),
},
};
assert!(!format!("{request:?}").contains("private-executor-token"));
assert_eq!(
json!({
"method": "environment/add",
@@ -3831,7 +3834,8 @@ mod tests {
"params": {
"environmentId": "remote-a",
"execServerUrl": "ws://127.0.0.1:8765",
"connectTimeoutMs": 300000
"connectTimeoutMs": 300000,
"authBearerToken": "private-executor-token"
}
}),
serde_json::to_value(&request)?,
@@ -4356,6 +4360,7 @@ mod tests {
let request = ClientRequest::EnvironmentAdd {
request_id: RequestId::Integer(1),
params: v2::EnvironmentAddParams {
auth_bearer_token: None,
environment_id: "remote-a".to_string(),
exec_server_url: "ws://127.0.0.1:8765".to_string(),
connect_timeout_ms: None,
@@ -3,6 +3,7 @@ use crate::TS;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_utils_path_uri::LegacyAppPathString;
use codex_utils_path_uri::PathUri;
use codex_utils_redacted_string::RedactedString;
use serde::Deserialize;
use serde::Serialize;
@@ -37,6 +38,11 @@ impl From<&TurnEnvironmentSelection> for ThreadEnvironment {
pub struct EnvironmentAddParams {
pub environment_id: String,
pub exec_server_url: String,
/// Optional raw bearer token for executor authentication, including reconnects.
/// Requires a secure transport or a loopback destination.
#[ts(type = "string | null")]
#[ts(optional = nullable)]
pub auth_bearer_token: Option<RedactedString>,
/// Optional WebSocket connection timeout. The server default applies when omitted.
#[ts(type = "number | null")]
#[ts(optional = nullable)]
@@ -1,4 +1,5 @@
use super::*;
use codex_exec_server::RemoteEnvironmentOptions;
use std::time::Duration;
#[derive(Clone)]
@@ -17,12 +18,22 @@ impl EnvironmentRequestProcessor {
&self,
params: EnvironmentAddParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let options = RemoteEnvironmentOptions {
exec_server_url: params.exec_server_url,
connect_timeout: params.connect_timeout_ms.map(Duration::from_millis),
http_headers: params
.auth_bearer_token
.into_iter()
.map(|token| {
(
"Authorization".to_string(),
format!("Bearer {}", token.into_inner()),
)
})
.collect(),
};
self.environment_manager
.upsert_environment(
params.environment_id,
params.exec_server_url,
params.connect_timeout_ms.map(Duration::from_millis),
)
.upsert_environment_with_options(params.environment_id, options)
.map_err(|err| invalid_request(err.to_string()))?;
Ok(Some(EnvironmentAddResponse {}.into()))
}
@@ -164,6 +164,7 @@ async fn add_environment(
exec_server_url: &str,
) -> Result<()> {
let params = EnvironmentAddParams {
auth_bearer_token: None,
environment_id: environment_id.to_string(),
exec_server_url: exec_server_url.to_string(),
connect_timeout_ms: None,
@@ -221,3 +221,146 @@ fn exec_server_websocket_auth_rejects_invalid_configuration() -> Result<()> {
}
Ok(())
}
#[tokio::test]
async fn app_server_executor_auth_supports_tokens_and_legacy_configuration() -> Result<()> {
use app_test_support::TestAppServer;
use app_test_support::to_response;
use codex_app_server_protocol::EnvironmentInfoResponse;
use codex_app_server_protocol::RequestId;
#[derive(Debug, PartialEq)]
enum Source {
Rpc,
Toml,
}
tokio::time::timeout(Duration::from_secs(/*secs*/ 90), async {
let codex = codex_utils_cargo_bin::cargo_bin("codex")?;
for token in [None, Some("executor-capability-for-app-server")] {
let executor_home = TempDir::new()?;
let mut command = Command::new(&codex);
command.args(["exec-server", "--listen", "ws://127.0.0.1:0"]);
if let Some(token) = token {
command.args([
"--ws-auth",
"capability-token",
"--ws-token-sha256",
&format!("{:x}", Sha256::digest(token.as_bytes())),
]);
}
let mut executor = command
.env("CODEX_HOME", executor_home.path())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()?;
let mut lines =
BufReader::new(executor.stdout.take().context("executor stdout")?).lines();
let url = loop {
let line = lines.next_line().await?.context("executor exited")?;
if line.starts_with("ws://") {
break line;
}
};
for source in [Source::Rpc, Source::Toml] {
let home = TempDir::new()?;
let builder = TestAppServer::builder()
.with_program(&codex)
.with_plugin_startup_tasks()
.with_args(&["-c", "features.plugins=false", "app-server"])
.with_codex_home(home.path())
.without_auto_env()
.with_env_overrides(&[
("CODEX_EXEC_SERVER_URL", None),
("CODEX_EXEC_SERVER_NOISE_REGISTRY_URL", None),
]);
match source {
Source::Toml => {
let mut config =
format!("[[environments]]\nid = \"remote\"\nurl = {url:?}\n");
if let Some(token) = token {
config.push_str(&format!("auth_bearer_token = {token:?}\n"));
}
std::fs::write(home.path().join("environments.toml"), config)?;
}
Source::Rpc => {}
}
let mut app = builder.build().await?;
app.initialize().await?;
if source == Source::Rpc {
for credential in ["private-token\r\nInjected: true", "private-token\n"] {
let id = app
.send_raw_request(
"environment/add",
Some(json!({
"environmentId": "invalid", "execServerUrl": url,
"authBearerToken": credential,
})),
)
.await?;
let error = app
.read_stream_until_error_message(RequestId::Integer(id))
.await?;
assert!(!format!("{error:?}").contains("private-token"));
}
// Omitted and null tokens preserve old-client requests. They succeed
// with an unauthenticated executor and fail when authentication is enabled.
let credentials = match token {
Some(token) => vec![
None,
Some(serde_json::Value::Null),
Some(json!("wrong-token")),
Some(json!(token)),
],
None => vec![None, Some(serde_json::Value::Null)],
};
for credential in credentials {
let mut params = json!({
"environmentId": "remote", "execServerUrl": url,
});
if let Some(credential) = &credential {
params["authBearerToken"] = credential.clone();
}
let id = app
.send_raw_request("environment/add", Some(params))
.await?;
app.read_stream_until_response_message(RequestId::Integer(id))
.await?;
let id = app
.send_raw_request(
"environment/info",
Some(json!({"environmentId": "remote"})),
)
.await?;
if token.is_some()
&& credential.as_ref().and_then(serde_json::Value::as_str) != token
{
app.read_stream_until_error_message(RequestId::Integer(id))
.await?;
} else {
app.read_stream_until_response_message(RequestId::Integer(id))
.await?;
}
}
}
let id = app
.send_raw_request("environment/info", Some(json!({"environmentId": "remote"})))
.await?;
let response = app
.read_stream_until_response_message(RequestId::Integer(id))
.await?;
let info: EnvironmentInfoResponse = to_response(response)?;
assert!(
!info.shell.name.is_empty(),
"{source:?} should reach the executor"
);
}
executor.kill().await?;
}
Ok::<_, anyhow::Error>(())
})
.await?
}
+1
View File
@@ -36,6 +36,7 @@ codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-path-uri = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-redacted-string = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
codex-websocket-auth = { workspace = true }
codex-websocket-client = { workspace = true }
+3
View File
@@ -30,6 +30,9 @@ The CLI entrypoint supports:
Direct WebSocket listeners support app-server's opt-in auth flags, including `--ws-auth capability-token --ws-token-sha256 HEX`, token files, and signed bearer tokens; clients send `Authorization: Bearer TOKEN` on each connection.
Use a protected transport or TLS proxy for remote access; authentication is checked at connection time and these flags do not apply to stdio, remote registration, or forwarding.
App-server clients can supply the raw token through `environment/add.authBearerToken` or `auth_bearer_token` in an `environments.toml` URL entry; omission preserves unauthenticated behavior.
Tokens require `wss://` or a loopback destination, are redacted in diagnostics, and are reused on reconnect without automatic refresh.
## Remote connections
Remote mode registers the local exec-server with the environment registry,
@@ -17,7 +17,7 @@ use crate::environment_provider::normalize_exec_server_url;
///
/// Headers are sent only on the direct WebSocket upgrade request and subsequent
/// reconnects. The embedding host must derive them from trusted request or
/// session context; they are not exposed through the app-server protocol.
/// session context, including app-server environment registration or environment configuration.
#[derive(Clone, Eq, PartialEq)]
pub struct RemoteEnvironmentOptions {
/// Direct `ws://` or `wss://` endpoint for the remote environment.
@@ -39,7 +39,7 @@ impl std::fmt::Debug for RemoteEnvironmentOptions {
}
impl RemoteEnvironmentOptions {
pub(super) fn into_transport_params(
pub(crate) fn into_transport_params(
self,
) -> Result<ExecServerTransportParams, ExecServerError> {
let (exec_server_url, disabled) = normalize_exec_server_url(Some(self.exec_server_url));
@@ -86,7 +86,7 @@ impl RemoteEnvironmentOptions {
"exec-server WebSocket header `{header_name}` is controlled by the connection"
)));
}
let header_value = HeaderValue::from_str(&value).map_err(|_| {
let mut header_value = HeaderValue::from_str(&value).map_err(|_| {
ExecServerError::Protocol(format!(
"invalid value for exec-server WebSocket header `{header_name}`"
))
@@ -96,6 +96,7 @@ impl RemoteEnvironmentOptions {
"duplicate exec-server WebSocket header `{header_name}`"
)));
}
header_value.set_sensitive(true);
http_headers.insert(header_name, header_value);
}
@@ -210,6 +210,10 @@ async fn trusted_headers_are_sent_on_initial_websocket_and_session_reconnect() {
exec_server_url: websocket_url,
connect_timeout: Some(Duration::from_secs(1)),
http_headers: HashMap::from([
(
"authorization".to_string(),
"Bearer reconnect-capability".to_string(),
),
(
"x-account-id".to_string(),
"customer-account-456".to_string(),
@@ -246,6 +250,10 @@ async fn trusted_headers_are_sent_on_initial_websocket_and_session_reconnect() {
async fn accept_routing_headers_websocket(listener: &TcpListener) -> WebSocketStream<TcpStream> {
let (stream, _) = listener.accept().await.expect("listener should accept");
accept_hdr_async(stream, |request: &Request, response: Response| {
assert_eq!(
request.headers().get(http::header::AUTHORIZATION),
Some(&HeaderValue::from_static("Bearer reconnect-capability"))
);
assert_eq!(
request.headers().get("x-account-id"),
Some(&HeaderValue::from_static("customer-account-456"))
+73 -7
View File
@@ -4,7 +4,8 @@ use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use http::HeaderMap;
use crate::RemoteEnvironmentOptions;
use codex_utils_redacted_string::RedactedString;
use serde::Deserialize;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
@@ -38,6 +39,7 @@ struct EnvironmentsToml {
struct EnvironmentToml {
id: String,
url: Option<String>,
auth_bearer_token: Option<RedactedString>,
program: Option<String>,
args: Option<Vec<String>>,
env: Option<HashMap<String, String>>,
@@ -115,6 +117,7 @@ fn parse_environment_toml(
let EnvironmentToml {
id,
url,
auth_bearer_token,
program,
args,
env,
@@ -128,6 +131,11 @@ fn parse_environment_toml(
"environment `{id}` args, env, and cwd require program"
)));
}
if url.is_none() && auth_bearer_token.is_some() {
return Err(ExecServerError::Protocol(format!(
"environment `{id}` auth_bearer_token requires url"
)));
}
if url.is_none() && connect_timeout_sec.is_some() {
return Err(ExecServerError::Protocol(format!(
"environment `{id}` connect_timeout_sec requires url"
@@ -141,12 +149,28 @@ fn parse_environment_toml(
let transport_params = match (url, program) {
(Some(url), None) => {
let url = validate_websocket_url(url)?;
ExecServerTransportParams::WebSocketUrl {
websocket_url: url,
connect_timeout,
initialize_timeout,
http_headers: HeaderMap::new(),
let options = RemoteEnvironmentOptions {
exec_server_url: url,
connect_timeout: Some(connect_timeout),
http_headers: auth_bearer_token
.into_iter()
.map(|token| {
(
"Authorization".to_string(),
format!("Bearer {}", token.into_inner()),
)
})
.collect(),
};
let mut transport = options.into_transport_params()?;
if let ExecServerTransportParams::WebSocketUrl {
initialize_timeout: timeout,
..
} = &mut transport
{
*timeout = initialize_timeout;
}
transport
}
(None, Some(program)) => {
let program = program.trim().to_string();
@@ -305,7 +329,8 @@ fn load_environments_toml(path: &Path) -> Result<Option<EnvironmentsToml>, ExecS
};
toml::from_str(&contents)
.map_err(|err| {
.map_err(|mut err| {
err.set_input(/*input*/ None);
ExecServerError::Protocol(format!(
"failed to parse environment config `{}`: {err}",
path.display()
@@ -337,6 +362,47 @@ mod tests {
use super::*;
#[test]
fn auth_bearer_token_requires_websocket_and_preserves_timeouts() {
let config: EnvironmentsToml = toml::from_str(
r#"
[[environments]]
id = "remote"
url = "wss://executor.example"
auth_bearer_token = "private-token"
initialize_timeout_sec = 17
"#,
)
.unwrap();
assert!(!format!("{config:?}").contains("private-token"));
let provider = TomlEnvironmentProvider::new(config).unwrap();
let ExecServerTransportParams::WebSocketUrl {
http_headers,
initialize_timeout,
..
} = &provider.environments[0].1
else {
panic!("expected websocket")
};
assert_eq!(
http_headers[http::header::AUTHORIZATION],
"Bearer private-token"
);
assert!(http_headers[http::header::AUTHORIZATION].is_sensitive());
assert_eq!(*initialize_timeout, Duration::from_secs(/*secs*/ 17));
let error = TomlEnvironmentProvider::new(EnvironmentsToml {
environments: vec![EnvironmentToml {
id: "stdio".to_string(),
program: Some("executor".to_string()),
auth_bearer_token: Some("private-token".into()),
..Default::default()
}],
..Default::default()
})
.unwrap_err();
assert!(error.to_string().contains("auth_bearer_token requires url"));
}
#[tokio::test]
async fn toml_provider_includes_local_and_adds_configured_environments() {
let provider = TomlEnvironmentProvider::new(EnvironmentsToml {