mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## What changed Add `configured TUI mode` to the configuration details in human-readable and JSON doctor reports. Report `fullscreen` when `tui.fullscreen_transcript` is enabled and `scrollback` when it is disabled. ## Testing Add an integration test covering both configuration values in JSON output and update the human-readable report snapshot to include the mode. GitOrigin-RevId: beb62ddfa62a3516a64b5a69d5a41b257434f5d5
686 lines
25 KiB
Rust
686 lines
25 KiB
Rust
//! Black-box coverage for safe diagnostic execution and config error reporting.
|
|
|
|
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
#[cfg(unix)]
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use app_test_support::TestAppServer;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_config::loader::project_trust_key;
|
|
use codex_state::SqliteConfig;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_cargo_bin::copy_executable;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::io::AsyncBufReadExt;
|
|
use tokio::io::AsyncWriteExt;
|
|
use tokio::io::BufReader;
|
|
use tokio::net::TcpListener;
|
|
use tokio::time::timeout;
|
|
|
|
struct Fixture {
|
|
root: TempDir,
|
|
program: PathBuf,
|
|
workspace: PathBuf,
|
|
home: PathBuf,
|
|
marker: PathBuf,
|
|
path: OsString,
|
|
}
|
|
|
|
impl Fixture {
|
|
fn new() -> Result<Self> {
|
|
let root = TempDir::new()?;
|
|
let home = root.path().join("home");
|
|
std::fs::create_dir(&home)?;
|
|
// Cargo-built paths deliberately ignore npm provenance. Launch outside
|
|
// target/ so this fixture also exercises the packaged-install checks.
|
|
let program = root
|
|
.path()
|
|
.join(format!("codex{}", std::env::consts::EXE_SUFFIX));
|
|
let source = codex_utils_cargo_bin::cargo_bin("codex")?;
|
|
// Hard-link setup and teardown invalidate other tests' Rosetta translations.
|
|
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
|
{
|
|
copy_executable(&source, &program)?;
|
|
// Translate the fixture before the timed diagnostic command.
|
|
anyhow::ensure!(
|
|
std::process::Command::new(&program)
|
|
.env("CODEX_HOME", &home)
|
|
.arg("--version")
|
|
.output()?
|
|
.status
|
|
.success(),
|
|
"failed to prepare diagnostic test executable"
|
|
);
|
|
}
|
|
#[cfg(not(all(target_os = "macos", target_arch = "x86_64")))]
|
|
if std::fs::hard_link(&source, &program).is_err() {
|
|
copy_executable(&source, &program)?;
|
|
}
|
|
let workspace = root.path().join("workspace");
|
|
let bin = workspace.join("node_modules/.bin");
|
|
let marker = root.path().join("helper-ran");
|
|
std::fs::create_dir_all(&bin)?;
|
|
std::fs::create_dir_all(workspace.join(".git"))?;
|
|
std::fs::write(
|
|
home.join("config.toml"),
|
|
r#"
|
|
cli_auth_credentials_store = "file"
|
|
check_for_update_on_startup = false
|
|
model_provider = "local"
|
|
[analytics]
|
|
enabled = false
|
|
[model_providers.local]
|
|
name = "local test"
|
|
base_url = "http://127.0.0.1:9/v1"
|
|
wire_api = "responses"
|
|
"#,
|
|
)?;
|
|
for name in [
|
|
"zellij", "tmux", "which", "where", "npm", "rg", "git", "curl", "cmd.exe",
|
|
] {
|
|
#[cfg(unix)]
|
|
{
|
|
let executable = bin.join(name);
|
|
codex_utils_cargo_bin::write_executable(
|
|
&executable,
|
|
"#!/bin/sh\nprintf 'helper ran\\n' >> \"$CODEX_TEST_HELPER_MARKER\"\nexit 0\n",
|
|
)?;
|
|
}
|
|
#[cfg(windows)]
|
|
std::fs::write(
|
|
bin.join(format!("{name}.cmd")),
|
|
"@echo helper ran>>\"%CODEX_TEST_HELPER_MARKER%\"\r\n@exit /b 0\r\n",
|
|
)?;
|
|
}
|
|
// Windows selects rg.exe explicitly, so rg.cmd cannot satisfy discovery.
|
|
// An invalid image still counts as found: doctor must not execute it.
|
|
#[cfg(windows)]
|
|
std::fs::write(bin.join("rg.exe"), "not an executable image")?;
|
|
let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths(
|
|
&std::env::var_os("PATH").unwrap_or_default(),
|
|
)))?;
|
|
Ok(Self {
|
|
root,
|
|
program,
|
|
workspace,
|
|
home,
|
|
marker,
|
|
path,
|
|
})
|
|
}
|
|
|
|
fn command(&self) -> Result<assert_cmd::Command> {
|
|
let mut command = assert_cmd::Command::new(&self.program);
|
|
command
|
|
.current_dir(&self.workspace)
|
|
.env("CODEX_HOME", &self.home)
|
|
.env("HOME", self.root.path())
|
|
.env("PATH", &self.path)
|
|
.env("CODEX_TEST_HELPER_MARKER", &self.marker)
|
|
.env("CODEX_MANAGED_BY_NPM", "1")
|
|
.env(
|
|
"CODEX_MANAGED_PACKAGE_ROOT",
|
|
self.workspace.join("node_modules/@openai/codex"),
|
|
)
|
|
.env(
|
|
"CODEX_APP_SERVER_MANAGED_CONFIG_PATH",
|
|
self.home.join("managed_config.toml"),
|
|
)
|
|
.env("HTTPS_PROXY", "http://127.0.0.1:9")
|
|
.env("HTTP_PROXY", "http://127.0.0.1:9")
|
|
.env("ALL_PROXY", "http://127.0.0.1:9")
|
|
.env("https_proxy", "http://127.0.0.1:9")
|
|
.env("http_proxy", "http://127.0.0.1:9")
|
|
.env("all_proxy", "http://127.0.0.1:9")
|
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
|
.env("no_proxy", "127.0.0.1,localhost")
|
|
.env_remove("TMUX")
|
|
.env_remove("TMUX_PANE")
|
|
.env_remove("ZELLIJ_VERSION")
|
|
.env_remove("ZELLIJ_SESSION_NAME")
|
|
.env_remove("TERM_PROGRAM")
|
|
.env("TERM", "dumb")
|
|
.env("ZELLIJ", "0")
|
|
.timeout(Duration::from_secs(/*secs*/ 45));
|
|
Ok(command)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn startup_and_doctor_do_not_execute_path_helpers() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
// Non-TTY dumb-terminal startup exits immediately after terminal detection.
|
|
fixture.command()?.assert().failure();
|
|
assert!(!fixture.marker.exists(), "startup executed a PATH helper");
|
|
|
|
for args in [
|
|
vec!["doctor", "--json"],
|
|
vec!["doctor", "--json", "--feedback"],
|
|
] {
|
|
let mut command = fixture.command()?;
|
|
command
|
|
.args(args)
|
|
.env("TMUX", "test-tmux")
|
|
.env("TERM_PROGRAM", "tmux");
|
|
let output = command.output()?;
|
|
let report: Value = serde_json::from_slice(&output.stdout)?;
|
|
assert_eq!(
|
|
report["checks"]["installation"]["details"]["managed by npm"],
|
|
"true"
|
|
);
|
|
assert_eq!(report["checks"]["runtime.search"]["status"], "ok");
|
|
assert_eq!(
|
|
report["checks"]["runtime.search"]["details"]["search command path"],
|
|
fixture
|
|
.workspace
|
|
.join("node_modules/.bin")
|
|
.join(format!("rg{}", std::env::consts::EXE_SUFFIX))
|
|
.display()
|
|
.to_string()
|
|
);
|
|
assert_eq!(
|
|
report["checks"]["git.environment"]["summary"],
|
|
"git executable found; execution not verified"
|
|
);
|
|
assert!(!fixture.marker.exists(), "doctor executed a PATH helper");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn non_interactive_dumb_terminal_preserves_other_doctor_failures() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
let output = fixture
|
|
.command()?
|
|
.args(["doctor", "--json"])
|
|
// Explicit identity takes precedence over inherited terminal-specific variables.
|
|
.env("TERM_PROGRAM", "dumb")
|
|
.env_remove("TERMINFO")
|
|
.env_remove("TERMINFO_DIRS")
|
|
.output()?;
|
|
// The fixture's provider is unreachable, regardless of terminal capabilities.
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let report: Value = serde_json::from_slice(&output.stdout)?;
|
|
assert_eq!(report["checks"]["terminal.env"]["status"], "warning");
|
|
assert_eq!(report["overallStatus"], "fail");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_reports_failed_database_paths() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
let sqlite_home = fixture.root.path().join("sqlite");
|
|
std::fs::create_dir(&sqlite_home)?;
|
|
let sqlite = SqliteConfig::from_sqlite_home(AbsolutePathBuf::try_from(sqlite_home.clone())?);
|
|
let config_file = fixture.home.join("config.toml");
|
|
let config = std::fs::read_to_string(&config_file)?;
|
|
let sqlite_home = toml::Value::String(sqlite_home.display().to_string());
|
|
std::fs::write(
|
|
&config_file,
|
|
format!("sqlite_home = {sqlite_home}\n{config}"),
|
|
)?;
|
|
|
|
for (snapshot_name, failed_paths) in [
|
|
("doctor_failed_log_database", vec![sqlite.logs_db_path()]),
|
|
(
|
|
"doctor_failed_multiple_databases",
|
|
vec![sqlite.logs_db_path(), sqlite.goals_db_path()],
|
|
),
|
|
] {
|
|
for path in &failed_paths {
|
|
std::fs::write(path, "not a SQLite database")?;
|
|
}
|
|
let paths = failed_paths
|
|
.iter()
|
|
.map(|path| path.display().to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let noun = if failed_paths.len() == 1 {
|
|
"database"
|
|
} else {
|
|
"databases"
|
|
};
|
|
let expected_cause = format!("{paths} {noun} failed integrity check");
|
|
for args in [
|
|
vec!["doctor", "--json"],
|
|
vec!["doctor", "--json", "--feedback"],
|
|
] {
|
|
let output = fixture.command()?.args(args).output()?;
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let report: Value = serde_json::from_slice(&output.stdout)?;
|
|
assert_eq!(report["checks"]["state.paths"]["status"], "fail");
|
|
assert_eq!(
|
|
report["checks"]["state.paths"]["summary"],
|
|
"state database integrity check failed"
|
|
);
|
|
assert_eq!(
|
|
report["checks"]["state.paths"]["issues"][0]["cause"],
|
|
expected_cause
|
|
);
|
|
}
|
|
|
|
for summary_only in [false, true] {
|
|
let mut command = fixture.command()?;
|
|
command.args(["doctor", "--ascii", "--no-color"]);
|
|
if summary_only {
|
|
command.arg("--summary");
|
|
}
|
|
let output = command.output()?;
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
let state_row = stdout
|
|
.lines()
|
|
.filter(|line| {
|
|
line.starts_with(" [XX] state ")
|
|
|| (!summary_only
|
|
&& line.starts_with(" -> Move the damaged SQLite database aside"))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
.replace(&fixture.root.path().display().to_string(), "FIXTURE")
|
|
.replace('\\', "/");
|
|
if summary_only {
|
|
assert_eq!(
|
|
state_row,
|
|
format!(
|
|
" [XX] state {}",
|
|
expected_cause
|
|
.replace(&fixture.root.path().display().to_string(), "FIXTURE")
|
|
)
|
|
.replace('\\', "/")
|
|
);
|
|
} else {
|
|
insta::assert_snapshot!(snapshot_name, state_row);
|
|
}
|
|
}
|
|
for path in &failed_paths {
|
|
assert_eq!(std::fs::read_to_string(path)?, "not a SQLite database");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_redacts_failed_database_paths_in_json() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
let sqlite_home = fixture.root.path().join("doctor-secret-sqlite");
|
|
std::fs::create_dir(&sqlite_home)?;
|
|
let sqlite = SqliteConfig::from_sqlite_home(AbsolutePathBuf::try_from(sqlite_home.clone())?);
|
|
let config_file = fixture.home.join("config.toml");
|
|
let config = std::fs::read_to_string(&config_file)?;
|
|
let sqlite_home = toml::Value::String(sqlite_home.display().to_string());
|
|
std::fs::write(
|
|
&config_file,
|
|
format!("sqlite_home = {sqlite_home}\n{config}"),
|
|
)?;
|
|
std::fs::write(sqlite.logs_db_path(), "not a SQLite database")?;
|
|
|
|
let output = fixture
|
|
.command()?
|
|
.args(["doctor", "--json", "--feedback"])
|
|
.output()?;
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let stdout = String::from_utf8(output.stdout)?;
|
|
assert!(!stdout.contains("doctor-secret-sqlite"));
|
|
let report: Value = serde_json::from_str(&stdout)?;
|
|
assert_eq!(
|
|
report["checks"]["state.paths"]["summary"],
|
|
"state database integrity check failed"
|
|
);
|
|
assert_eq!(
|
|
report["checks"]["state.paths"]["issues"][0]["cause"],
|
|
"<redacted>"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_reports_only_safe_config_error_metadata() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
let user_config_file = fixture.home.join("config.toml");
|
|
let original = std::fs::read_to_string(&user_config_file)?;
|
|
// macOS temp directories can use a symlinked path; trust the canonical workspace.
|
|
let project_key = toml::Value::String(project_trust_key(&fixture.workspace));
|
|
let original = format!("{original}\n[projects.{project_key}]\ntrust_level = \"trusted\"\n");
|
|
let project_config_dir = fixture.workspace.join(".codex");
|
|
std::fs::create_dir(&project_config_dir)?;
|
|
for (snapshot_name, config_file, config) in [
|
|
(
|
|
"doctor_config_error_location",
|
|
user_config_file.clone(),
|
|
"custom_header = \"doctor-test-credential\" trailing\n".to_string(),
|
|
),
|
|
(
|
|
"doctor_config_invalid_data",
|
|
project_config_dir.join("config.toml"),
|
|
"custom_header = \"doctor-test-credential\" trailing\n".to_string(),
|
|
),
|
|
(
|
|
"doctor_config_not_found",
|
|
user_config_file.clone(),
|
|
original.replace(
|
|
"model_provider = \"local\"",
|
|
"model_provider = \"doctor-test-credential\"",
|
|
),
|
|
),
|
|
(
|
|
"doctor_config_invalid_data",
|
|
user_config_file.clone(),
|
|
original.replace(
|
|
"wire_api = \"responses\"",
|
|
"wire_api = \"responses\"\nhttp_headers = \"sk-proj-ABC123example\"",
|
|
),
|
|
),
|
|
] {
|
|
std::fs::write(&user_config_file, &original)?;
|
|
std::fs::write(&config_file, config)?;
|
|
for args in [
|
|
vec!["doctor", "--json"],
|
|
vec!["doctor", "--json", "--feedback"],
|
|
] {
|
|
let output = fixture.command()?.args(args).output()?;
|
|
assert_eq!(output.status.code(), Some(1));
|
|
let report: Value = serde_json::from_slice(&output.stdout)?;
|
|
let mut check = report["checks"]["config.load"].clone();
|
|
check
|
|
.as_object_mut()
|
|
.expect("config check")
|
|
.remove("durationMs");
|
|
if let Some(file) = check["details"]["file"].as_str() {
|
|
check["details"]["file"] = Value::String(
|
|
file.replace(
|
|
&fixture.root.path().canonicalize()?.display().to_string(),
|
|
"FIXTURE",
|
|
)
|
|
.replace(&fixture.root.path().display().to_string(), "FIXTURE")
|
|
.replace('\\', "/"),
|
|
);
|
|
}
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(!stdout.contains("doctor-test-credential"));
|
|
assert!(!stdout.contains("sk-proj-ABC123example"));
|
|
// Keep snapshots stable across serde_json feature sets in Cargo and Bazel.
|
|
check.sort_all_objects();
|
|
insta::assert_snapshot!(snapshot_name, serde_json::to_string_pretty(&check)?);
|
|
}
|
|
std::fs::remove_file(config_file)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_reports_configured_tui_mode() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
for (setting, expected) in [("true", "fullscreen"), ("false", "scrollback")] {
|
|
let output = fixture
|
|
.command()?
|
|
.args(["-c", &format!("tui.fullscreen_transcript={setting}")])
|
|
.args(["doctor", "--json"])
|
|
.output()?;
|
|
let report: Value = serde_json::from_slice(&output.stdout)?;
|
|
assert_eq!(
|
|
report["checks"]["config.load"]["details"]["configured TUI mode"],
|
|
expected
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_reports_configured_filesystem_paths() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
let config_file = fixture.home.join("config.toml");
|
|
let original = std::fs::read_to_string(&config_file)?;
|
|
let existing = fixture.workspace.join("probe-existing");
|
|
let missing = fixture.workspace.join("probe-missing");
|
|
std::fs::create_dir(&existing)?;
|
|
let existing_key = toml::Value::String(existing.display().to_string());
|
|
let missing_key = toml::Value::String(missing.display().to_string());
|
|
std::fs::write(
|
|
&config_file,
|
|
format!(
|
|
r#"default_permissions = "diagnostic"
|
|
{original}
|
|
[permissions.diagnostic]
|
|
extends = ":read-only"
|
|
[permissions.diagnostic.filesystem]
|
|
{existing_key} = "read"
|
|
{missing_key} = "write"
|
|
"#
|
|
),
|
|
)?;
|
|
let output = fixture.command()?.args(["doctor", "--json"]).output()?;
|
|
let report: Value = serde_json::from_slice(&output.stdout)?;
|
|
#[cfg(windows)]
|
|
assert_eq!(report["checks"]["sandbox.filesystem_paths"]["status"], "ok");
|
|
let mut details = report["checks"]["sandbox.filesystem_paths"]["details"].clone();
|
|
// Snapshot outcomes and provenance independently of helper startup speed.
|
|
details
|
|
.as_object_mut()
|
|
.expect("path details")
|
|
.retain(|key, _| !key.ends_with(" latency"));
|
|
let canonical_root = fixture.root.path().canonicalize()?;
|
|
for value in details.as_object_mut().expect("path details").values_mut() {
|
|
let detail = value
|
|
.as_str()
|
|
.expect("scalar path detail")
|
|
.replace(&canonical_root.display().to_string(), "FIXTURE")
|
|
.replace(&fixture.root.path().display().to_string(), "FIXTURE")
|
|
.replace('\\', "/");
|
|
*value = Value::String(detail);
|
|
}
|
|
let snapshot_name = if cfg!(windows) {
|
|
"doctor_configured_filesystem_paths_windows"
|
|
} else {
|
|
"doctor_configured_filesystem_paths"
|
|
};
|
|
insta::assert_snapshot!(snapshot_name, serde_json::to_string_pretty(&details)?);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn filesystem_probe_does_not_load_configuration() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
std::fs::write(fixture.home.join("config.toml"), "invalid TOML = [")?;
|
|
for (path, expected) in [
|
|
(&fixture.workspace, 0),
|
|
(&fixture.workspace.join("missing"), 2),
|
|
] {
|
|
let output = fixture
|
|
.command()?
|
|
.args(["doctor", "--probe-filesystem-path"])
|
|
.arg(path)
|
|
.output()?;
|
|
assert_eq!(
|
|
output.status.code(),
|
|
Some(if cfg!(windows) { 5 } else { expected })
|
|
);
|
|
assert!(output.stdout.is_empty());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn interactive_tmux_startup_does_not_execute_workspace_helpers() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
std::fs::create_dir(fixture.workspace.join(".codex"))?;
|
|
let command = fixture.command()?;
|
|
let mut env: std::collections::HashMap<String, String> = std::env::vars().collect();
|
|
for (key, value) in command.get_envs() {
|
|
let key = key.to_string_lossy().into_owned();
|
|
if let Some(value) = value {
|
|
env.insert(key, value.to_string_lossy().into_owned());
|
|
} else {
|
|
env.remove(&key);
|
|
}
|
|
}
|
|
env.insert("TERM".to_string(), "xterm-256color".to_string());
|
|
env.insert("TERM_PROGRAM".to_string(), "tmux".to_string());
|
|
env.insert("TMUX".to_string(), "test-tmux".to_string());
|
|
env.insert(
|
|
"CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT".to_string(),
|
|
"0".to_string(),
|
|
);
|
|
env.insert(
|
|
"GHOSTTY_RESOURCES_DIR".to_string(),
|
|
"/test/ghostty".to_string(),
|
|
);
|
|
let spawned = codex_utils_pty::spawn_pty_process(
|
|
fixture.program.to_str().unwrap(),
|
|
&["--no-daemon".to_string()],
|
|
&fixture.workspace,
|
|
&env,
|
|
/*arg0*/ &None,
|
|
codex_utils_pty::TerminalSize {
|
|
rows: 40,
|
|
cols: 120,
|
|
},
|
|
codex_utils_pty::ChildFds::Inherited(&[]),
|
|
)
|
|
.await?;
|
|
let session = spawned.session;
|
|
let mut output_rx = spawned.stdout_rx;
|
|
let writer = session.writer_sender();
|
|
let mut output = String::new();
|
|
let ansi = regex_lite::Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]")?;
|
|
let ready = timeout(Duration::from_secs(/*secs*/ 45), async {
|
|
while let Some(bytes) = output_rx.recv().await {
|
|
let chunk = String::from_utf8_lossy(&bytes);
|
|
output.push_str(&chunk);
|
|
if chunk.contains("\x1b[6n") {
|
|
writer.send(b"\x1b[1;1R".to_vec()).await?;
|
|
}
|
|
if chunk.contains("\x1b[c") {
|
|
writer.send(b"\x1b[?1;2c".to_vec()).await?;
|
|
}
|
|
// Ratatui can position over spaces instead of writing them.
|
|
let text: String = ansi
|
|
.replace_all(&output, "")
|
|
.chars()
|
|
.filter(|character| !character.is_whitespace())
|
|
.collect();
|
|
if text.contains("Trustthisfolder?") {
|
|
return Ok::<_, anyhow::Error>(());
|
|
}
|
|
}
|
|
anyhow::bail!("TUI exited before trust prompt")
|
|
})
|
|
.await;
|
|
ready.map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"trust prompt timed out: {}",
|
|
output.chars().take(/*n*/ 4000).collect::<String>()
|
|
)
|
|
})??;
|
|
// The trust screen discards pending input after its first draw. Retry the quit key
|
|
// until that drain has finished, and keep consuming output during normal shutdown.
|
|
let mut quit_retry = tokio::time::interval(Duration::from_millis(/*millis*/ 250));
|
|
let mut exit_rx = spawned.exit_rx;
|
|
timeout(Duration::from_secs(/*secs*/ 10), async {
|
|
loop {
|
|
tokio::select! {
|
|
result = &mut exit_rx => return result,
|
|
_ = quit_retry.tick() => {
|
|
let _ = writer.send(b"2".to_vec()).await;
|
|
}
|
|
Some(_) = output_rx.recv() => {}
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"TUI did not exit after declining directory trust: {}",
|
|
output.chars().take(/*n*/ 4000).collect::<String>()
|
|
)
|
|
})??;
|
|
assert!(
|
|
output.contains("\x1b[>5u"),
|
|
"expected safe Ghostty/tmux keyboard flags: {output}"
|
|
);
|
|
assert!(
|
|
!fixture.marker.exists(),
|
|
"interactive startup executed a workspace helper"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn feedback_with_logs_does_not_execute_path_helpers() -> Result<()> {
|
|
let fixture = Fixture::new()?;
|
|
let proxy = TcpListener::bind("127.0.0.1:0").await?;
|
|
let proxy_uri = format!("http://{}", proxy.local_addr()?);
|
|
// Reject all outbound traffic locally, including the final feedback upload.
|
|
let proxy_task = tokio::spawn(async move {
|
|
while let Ok((stream, _)) = proxy.accept().await {
|
|
let mut stream = BufReader::new(stream);
|
|
let mut request = String::new();
|
|
if stream.read_line(&mut request).await.is_ok() {
|
|
let _ = stream.get_mut().write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await;
|
|
}
|
|
}
|
|
});
|
|
let path = fixture.path.to_string_lossy();
|
|
let marker = fixture.marker.to_string_lossy();
|
|
let home = fixture.root.path().to_string_lossy();
|
|
let package_root = fixture.workspace.join("node_modules/@openai/codex");
|
|
let package_root = package_root.to_string_lossy();
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_program(&fixture.program)
|
|
.with_codex_home(&fixture.home)
|
|
// The CLI does not accept the standalone app-server's test-only flag.
|
|
// Disable plugins through real config; their safety test joins a full sync.
|
|
.with_plugin_startup_tasks()
|
|
.with_args(&["-c", "features.plugins=false", "app-server"])
|
|
.with_env_overrides(&[
|
|
("PATH", Some(path.as_ref())),
|
|
("HOME", Some(home.as_ref())),
|
|
("CODEX_TEST_HELPER_MARKER", Some(marker.as_ref())),
|
|
("CODEX_MANAGED_BY_NPM", Some("1")),
|
|
("CODEX_MANAGED_PACKAGE_ROOT", Some(package_root.as_ref())),
|
|
("ZELLIJ", Some("0")),
|
|
("ZELLIJ_VERSION", None),
|
|
("TMUX", None),
|
|
("TMUX_PANE", None),
|
|
("HTTP_PROXY", Some(&proxy_uri)),
|
|
("HTTPS_PROXY", Some(&proxy_uri)),
|
|
("ALL_PROXY", Some(&proxy_uri)),
|
|
("http_proxy", Some(&proxy_uri)),
|
|
("https_proxy", Some(&proxy_uri)),
|
|
("all_proxy", Some(&proxy_uri)),
|
|
("NO_PROXY", Some("127.0.0.1,localhost")),
|
|
("no_proxy", Some("127.0.0.1,localhost")),
|
|
])
|
|
.build_initialized_with_timeout(Duration::from_secs(/*secs*/ 30))
|
|
.await?;
|
|
let request_id = app_server
|
|
.send_raw_request(
|
|
"feedback/upload",
|
|
Some(json!({
|
|
"classification": "bug", "includeLogs": true
|
|
})),
|
|
)
|
|
.await?;
|
|
let error = timeout(
|
|
Duration::from_secs(/*secs*/ 45),
|
|
app_server.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert!(error.error.message.contains("failed to upload feedback"));
|
|
assert!(!fixture.marker.exists(), "feedback executed a PATH helper");
|
|
timeout(
|
|
Duration::from_secs(/*secs*/ 10),
|
|
app_server.shutdown_gracefully(),
|
|
)
|
|
.await??;
|
|
proxy_task.abort();
|
|
Ok(())
|
|
}
|