From 48f3386b474d2a86019bbea823d39fb5bd1a9f9a Mon Sep 17 00:00:00 2001 From: Ankur Goyal Date: Mon, 31 Aug 2026 22:33:15 -0700 Subject: [PATCH] wip --- Cargo.lock | 98 +++++++++++- Cargo.toml | 3 +- src/acp.rs | 415 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 15 ++ tests/cli.rs | 32 ++++ 5 files changed, 554 insertions(+), 9 deletions(-) create mode 100644 src/acp.rs diff --git a/Cargo.lock b/Cargo.lock index c028e6c5..32c41b7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,7 +50,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "rand 0.10.2", - "sha1", + "sha1 0.11.0", "smallvec", "tokio", "tokio-util", @@ -583,6 +583,7 @@ dependencies = [ "strip-ansi-escapes", "tempfile", "tokio", + "tokio-tungstenite", "toml", "tracing-subscriber", "unicode-width 0.1.14", @@ -1016,6 +1017,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "deranged" version = "0.5.8" @@ -1182,7 +1189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2067,7 +2074,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2428,7 +2435,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2459,10 +2466,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", - "rand_chacha", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -2484,6 +2501,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2493,6 +2520,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -2699,7 +2735,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2983,6 +3019,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -3208,7 +3255,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3368,6 +3415,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -3563,6 +3622,23 @@ dependencies = [ "termcolor", ] +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.19", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.1" @@ -3647,6 +3723,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3845,7 +3927,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 3ac098f8..3d681a2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,8 @@ yaml_serde = "0.10" toml = "0.8" sha2 = "0.10.8" strip-ansi-escapes = "0.2.0" -tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "process", "net", "signal", "sync"] } +tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "process", "net", "signal", "sync", "io-util"] } +tokio-tungstenite = "0.26.2" tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-width = "0.1.13" dialoguer = { version = "0.11", features = ["fuzzy-select"] } diff --git a/src/acp.rs b/src/acp.rs new file mode 100644 index 00000000..b72babf0 --- /dev/null +++ b/src/acp.rs @@ -0,0 +1,415 @@ +use std::{ + ffi::{OsStr, OsString}, + net::SocketAddr, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, +}; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::{Args, Subcommand}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::{TcpListener, TcpStream}, + process::{Child, Command}, +}; +use tokio_tungstenite::{ + accept_hdr_async, + tungstenite::{ + handshake::server::{ErrorResponse, Request, Response}, + http::{HeaderName, HeaderValue, StatusCode}, + Message, + }, +}; + +use crate::{ + args::BaseArgs, + ui::{print_command_status, CommandStatus}, +}; + +const ACP_PATH: &str = "/acp"; +const ACP_CONNECTION_ID_HEADER: &str = "acp-connection-id"; +const CODEX_ACP_PACKAGE: &str = "@agentclientprotocol/codex-acp"; +const INTERNAL_CODEX_SHIM_ENV: &str = "BT_INTERNAL_ACP_CODEX_SHIM"; + +#[derive(Debug, Clone, Args)] +#[command(after_help = "\ +Examples: + bt acp codex --no-auth + bt acp codex --no-auth --listen 127.0.0.1:5001 + bt acp codex --no-auth --trace --project test-project + bt acp codex --no-auth --adapter ./node_modules/@agentclientprotocol/codex-acp/dist/index.js +")] +pub struct AcpArgs { + #[command(subcommand)] + command: AcpCommand, +} + +#[derive(Debug, Clone, Subcommand)] +enum AcpCommand { + /// Serve Codex through the ACP WebSocket transport. + Codex(CodexArgs), +} + +#[derive(Debug, Clone, Args)] +struct CodexArgs { + /// Explicitly allow unauthenticated local access. Only valid with a loopback listener. + #[arg(long, env = "BT_ACP_NO_AUTH", default_value_t = false)] + no_auth: bool, + + /// Address for the ACP endpoint. The prototype only permits loopback. + #[arg(long, env = "BT_ACP_LISTEN", default_value = "127.0.0.1:5001")] + listen: SocketAddr, + + /// Node.js executable used to run codex-acp's bundled JavaScript entry point. + #[arg(long, env = "BT_ACP_NODE", default_value = "node")] + node: OsString, + + /// Path to codex-acp's bundled dist/index.js. By default it is resolved from the current project. + #[arg(long, env = "BT_ACP_CODEX_ADAPTER")] + adapter: Option, + + /// Codex executable used by codex-acp. + #[arg(long, env = "CODEX_PATH", default_value = "codex")] + codex: OsString, + + /// Capture the Codex run with Braintrust's existing managed tracing hooks. + #[arg(long, default_value_t = false)] + trace: bool, +} + +#[derive(Debug, Clone, Args)] +#[command(trailing_var_arg = true)] +pub struct CodexAppServerArgs { + #[arg( + long, + env = INTERNAL_CODEX_SHIM_ENV, + hide = true, + hide_env = true, + default_value_t = false + )] + internal_codex_shim: bool, + + #[arg(allow_hyphen_values = true)] + args: Vec, +} + +#[derive(Clone)] +struct AgentLaunch { + program: OsString, + args: Vec, + env: Vec<(OsString, OsString)>, + remove_braintrust_api_key: bool, +} + +impl AgentLaunch { + fn spawn(&self) -> Result { + let mut command = Command::new(&self.program); + command + .args(&self.args) + .envs(self.env.iter().cloned()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + if self.remove_braintrust_api_key { + command.env_remove("BRAINTRUST_API_KEY"); + } + command.spawn().with_context(|| { + format!( + "failed to launch ACP agent with {}", + self.program.to_string_lossy() + ) + }) + } +} + +pub async fn run(base: BaseArgs, args: AcpArgs) -> Result<()> { + match args.command { + AcpCommand::Codex(args) => run_codex(base, args).await, + } +} + +async fn run_codex(base: BaseArgs, args: CodexArgs) -> Result<()> { + if !args.no_auth { + bail!( + "an ACP access mode is required; for local development pass --no-auth (loopback only)" + ); + } + if !args.listen.ip().is_loopback() { + bail!("--no-auth only permits a loopback listener; use 127.0.0.1 or ::1"); + } + + let adapter = resolve_codex_adapter(&args.node, args.adapter.as_deref()).await?; + let launch = codex_launch(&base, &args, adapter)?; + let listener = TcpListener::bind(args.listen) + .await + .with_context(|| format!("failed to listen on {}", args.listen))?; + let address = listener.local_addr()?; + print_command_status( + CommandStatus::Success, + &format!("ACP WebSocket endpoint listening at ws://{address}{ACP_PATH}"), + ); + + let launch = Arc::new(launch); + loop { + let (stream, peer) = listener.accept().await?; + let launch = Arc::clone(&launch); + tokio::spawn(async move { + if let Err(error) = serve_connection(stream, launch).await { + print_command_status( + CommandStatus::Warning, + &format!("ACP connection from {peer} ended: {error:#}"), + ); + } + }); + } +} + +async fn resolve_codex_adapter(node: &OsStr, configured: Option<&Path>) -> Result { + if let Some(path) = configured { + if !path.is_file() { + bail!("codex-acp adapter does not exist: {}", path.display()); + } + return Ok(path.to_path_buf()); + } + + let script = format!("require.resolve({CODEX_ACP_PACKAGE:?})"); + let output = Command::new(node) + .args([OsStr::new("-p"), OsStr::new(&script)]) + .output() + .await + .with_context(|| { + format!( + "failed to run Node.js executable {}", + node.to_string_lossy() + ) + })?; + if !output.status.success() { + bail!( + "could not resolve {CODEX_ACP_PACKAGE} from the current project; install it locally or pass --adapter " + ); + } + let path = + String::from_utf8(output.stdout).context("Node.js returned a non-UTF-8 codex-acp path")?; + let path = PathBuf::from(path.trim()); + if !path.is_file() { + bail!( + "resolved codex-acp adapter does not exist: {}", + path.display() + ); + } + Ok(path) +} + +fn codex_launch(base: &BaseArgs, args: &CodexArgs, adapter: PathBuf) -> Result { + let mut env = vec![( + OsString::from("CODEX_PATH"), + if args.trace { + std::env::current_exe() + .context("failed to resolve the bt executable for Codex tracing")? + .into_os_string() + } else { + args.codex.clone() + }, + )]; + let mut remove_braintrust_api_key = false; + if args.trace { + env.push(( + OsString::from(INTERNAL_CODEX_SHIM_ENV), + OsString::from("true"), + )); + env.push((OsString::from("CODEX_BIN"), args.codex.clone())); + append_trace_environment(&mut env, base); + remove_braintrust_api_key = base.profile.is_some(); + } + Ok(AgentLaunch { + program: args.node.clone(), + args: vec![adapter.into_os_string()], + env, + remove_braintrust_api_key, + }) +} + +fn append_trace_environment(env: &mut Vec<(OsString, OsString)>, base: &BaseArgs) { + for (name, value) in [ + ("BRAINTRUST_PROFILE", base.profile.as_deref()), + ("BRAINTRUST_ORG_NAME", base.org_name.as_deref()), + ("BRAINTRUST_DEFAULT_PROJECT", base.project.as_deref()), + ("BRAINTRUST_API_URL", base.api_url.as_deref()), + ("BRAINTRUST_APP_URL", base.app_url.as_deref()), + ] { + if let Some(value) = value { + env.push((OsString::from(name), OsString::from(value))); + } + } + if let Some(path) = base.ca_cert() { + env.push(( + OsString::from("BRAINTRUST_CA_CERT"), + path.as_os_str().to_os_string(), + )); + } + if base.no_input { + env.push(( + OsString::from("BRAINTRUST_NO_INPUT"), + OsString::from("true"), + )); + } +} + +pub async fn run_codex_app_server(base: BaseArgs, args: CodexAppServerArgs) -> Result<()> { + if !args.internal_codex_shim { + bail!("the internal app-server command may only be launched by `bt acp codex --trace`"); + } + let mut agent_args = vec![OsString::from("app-server")]; + agent_args.extend(args.args); + bt_daemon::run_trace( + bt_daemon::TraceArgs { + command: bt_daemon::TraceCommand::Run(bt_daemon::RunArgs { + source: bt_daemon::RunSource::Codex, + agent_args, + }), + }, + crate::trace_host::context(base), + ) + .await +} + +async fn serve_connection(stream: TcpStream, launch: Arc) -> Result<()> { + let connection_id = uuid::Uuid::new_v4().to_string(); + let response_connection_id = connection_id.clone(); + let socket = accept_hdr_async(stream, move |request: &Request, mut response: Response| { + if request.uri().path() != ACP_PATH { + return Err(error_response( + StatusCode::NOT_FOUND, + format!("ACP WebSocket endpoint is {ACP_PATH}"), + )); + } + response.headers_mut().insert( + HeaderName::from_static(ACP_CONNECTION_ID_HEADER), + HeaderValue::from_str(&response_connection_id) + .expect("UUID connection id is a valid header value"), + ); + Ok(response) + }) + .await + .context("ACP WebSocket handshake failed")?; + + let mut child = launch.spawn()?; + let mut child_stdin = child + .stdin + .take() + .ok_or_else(|| anyhow!("ACP agent stdin was not piped"))?; + let child_stdout = child + .stdout + .take() + .ok_or_else(|| anyhow!("ACP agent stdout was not piped"))?; + let mut lines = BufReader::new(child_stdout).lines(); + let (mut writer, mut reader) = socket.split(); + let mut initialized = false; + + loop { + tokio::select! { + line = lines.next_line() => { + match line.context("failed to read ACP agent output")? { + Some(line) if line.trim().is_empty() => {} + Some(line) => { + serde_json::from_str::(&line) + .context("ACP agent emitted invalid JSON-RPC")?; + writer.send(Message::Text(line.into())).await?; + } + None => break, + } + } + message = reader.next() => { + let Some(message) = message else { + break; + }; + match message? { + Message::Text(text) => { + let value: Value = serde_json::from_str(&text) + .context("ACP client sent invalid JSON-RPC")?; + if !initialized { + if value.get("method").and_then(Value::as_str) != Some("initialize") { + let id = value.get("id").cloned().unwrap_or(Value::Null); + writer.send(Message::Text(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32002, + "message": "initialize must be the first ACP message", + }, + }).to_string().into())).await?; + break; + } + initialized = true; + } + child_stdin.write_all(text.as_bytes()).await?; + child_stdin.write_all(b"\n").await?; + child_stdin.flush().await?; + } + Message::Binary(_) => {} + Message::Ping(payload) => writer.send(Message::Pong(payload)).await?, + Message::Close(_) => break, + _ => {} + } + } + } + } + + drop(child_stdin); + let _ = child.start_kill(); + let _ = child.wait().await; + let _ = connection_id; + Ok(()) +} + +fn error_response(status: StatusCode, body: String) -> ErrorResponse { + tokio_tungstenite::tungstenite::http::Response::builder() + .status(status) + .body(Some(body)) + .expect("static ACP error response is valid") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn configured_adapter_must_exist() { + let error = resolve_codex_adapter( + OsStr::new("node"), + Some(Path::new("/definitely/missing/codex-acp.js")), + ) + .await + .expect_err("missing adapter should fail"); + assert!(error.to_string().contains("does not exist")); + } + + #[test] + fn codex_trace_launch_uses_internal_bt_shim() -> Result<()> { + let adapter = PathBuf::from("/tmp/codex-acp.js"); + let args = CodexArgs { + no_auth: true, + listen: "127.0.0.1:5001".parse()?, + node: OsString::from("node"), + adapter: Some(adapter.clone()), + codex: OsString::from("codex"), + trace: true, + }; + let launch = codex_launch(&BaseArgs::default(), &args, adapter.clone())?; + assert_eq!(launch.program, OsString::from("node")); + assert_eq!(launch.args, vec![adapter.into_os_string()]); + assert!(launch + .env + .iter() + .any(|(name, value)| name == INTERNAL_CODEX_SHIM_ENV && value == "true")); + assert!(launch + .env + .iter() + .any(|(name, value)| name == "CODEX_BIN" && value == "codex")); + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 4060a8be..cf28755b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use clap::{parser::ValueSource, ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand}; use std::ffi::{OsStr, OsString}; +mod acp; mod args; mod auth; #[allow(dead_code)] @@ -84,6 +85,7 @@ Data & evaluation sync Synchronize project logs between Braintrust and local NDJSON files Additional + acp Serve local ACP agents to remote clients docs Manage workflow docs for coding agents trace Manage coding-agent tracing setup Configure Braintrust setup flows (deprecated: use curl -fsSL https://braintrust.dev/wizard/setup.sh | sh) @@ -128,6 +130,8 @@ struct Cli { #[derive(Debug, Subcommand)] enum Commands { + /// Serve local ACP agents to remote clients + Acp(CLIArgs), /// Initialize .bt config directory and files Init(CLIArgs), /// Configure Braintrust setup flows @@ -180,6 +184,9 @@ enum Commands { Status(CLIArgs), /// Manage coding-agent tracing Trace(CLIArgs), + #[command(name = "app-server", hide = true)] + /// Internal Codex app-server entry point used by `bt acp codex --trace`. + AcpCodexAppServer(CLIArgs), // /// View and modify config // Config(CLIArgs), } @@ -187,6 +194,7 @@ enum Commands { impl Commands { fn base(&self) -> &LoginBaseArgs { match self { + Commands::Acp(cmd) => &cmd.base, Commands::Init(cmd) => &cmd.base, Commands::Setup(cmd) => &cmd.base, Commands::Docs(cmd) => &cmd.base, @@ -213,11 +221,13 @@ impl Commands { Commands::Switch(cmd) => &cmd.base, Commands::Status(cmd) => &cmd.base, Commands::Trace(cmd) => &cmd.base, + Commands::AcpCodexAppServer(cmd) => &cmd.base, } } fn base_mut(&mut self) -> &mut LoginBaseArgs { match self { + Commands::Acp(cmd) => &mut cmd.base, Commands::Init(cmd) => &mut cmd.base, Commands::Setup(cmd) => &mut cmd.base, Commands::Docs(cmd) => &mut cmd.base, @@ -244,6 +254,7 @@ impl Commands { Commands::Switch(cmd) => &mut cmd.base, Commands::Status(cmd) => &mut cmd.base, Commands::Trace(cmd) => &mut cmd.base, + Commands::AcpCodexAppServer(cmd) => &mut cmd.base, } } @@ -352,6 +363,7 @@ fn try_main() -> Result<()> { let command_result: Result<()> = runtime.block_on(async move { match cli.command { + Commands::Acp(cmd) => acp::run(cmd.base, cmd.args).await?, Commands::Login(cmd) => auth::run_login_command(cmd.base.into(), cmd.args).await?, Commands::Logout(cmd) => auth::run_logout_command(cmd.base, cmd.args)?, Commands::Profiles(cmd) => profiles::run(cmd.base, cmd.args)?, @@ -388,6 +400,9 @@ fn try_main() -> Result<()> { Commands::Trace(cmd) => { bt_daemon::run_trace(cmd.args, trace_host::context(cmd.base)).await? } + Commands::AcpCodexAppServer(cmd) => { + acp::run_codex_app_server(cmd.base, cmd.args).await? + } } Ok(()) }); diff --git a/tests/cli.rs b/tests/cli.rs index 5430134a..4d3877da 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -155,6 +155,38 @@ fn global_quiet_flag_still_parses_for_other_commands() { bt_command().args(["status", "--quiet"]).assert().success(); } +#[test] +fn acp_codex_help_describes_transport_and_tracing_options() { + bt_command() + .args(["acp", "codex", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--listen")) + .stdout(predicate::str::contains("--adapter")) + .stdout(predicate::str::contains("--no-auth")) + .stdout(predicate::str::contains("--trace")); +} + +#[test] +fn acp_codex_requires_an_explicit_access_mode() { + bt_command() + .args(["acp", "codex"]) + .assert() + .failure() + .stderr(predicate::str::contains("an ACP access mode is required")); +} + +#[test] +fn acp_codex_no_auth_rejects_non_loopback_listeners() { + bt_command() + .args(["acp", "codex", "--no-auth", "--listen", "0.0.0.0:5001"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "--no-auth only permits a loopback listener", + )); +} + #[test] fn quiet_flag_still_parses_for_setup_subcommands() { bt_command()