mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
## What changed - Add `--chatgpt-token-stdin` for remote HTTPS registration and optional `--chatgpt-account-id`, which must match the token's account claim when present. Read a bounded, newline-terminated token from a pipe without persisting it to `auth.json`. - Load managed configuration using the supplied account, enforce account restrictions, and preserve configured policy-server authentication. Skip ChatGPT policy lookup only for known plans that do not require managed configuration. - Allow `--exit-on-stdin-close` to cancel pending policy loading, and redact the registration bearer from registry diagnostics. - Reject incompatible stdin credential options, including flags placed on opposite sides of `forward`, and disallow ChatGPT token stdin authentication with `forward`. ## Testing Add coverage for account-claim validation, policy selection, policy-server sign-in requirements, shutdown during policy loading, credential flag conflicts, and bearer redaction. GitOrigin-RevId: b6b50565e1f150b96edf3cee796909a991f1b47f
338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
//! Exercises required HTTPS policy through real app-server startup and session RPCs.
|
|
|
|
use super::https_mock::tls_listener;
|
|
use anyhow::Result;
|
|
use app_test_support::TestAppServer;
|
|
use codex_app_server_protocol::LoginAccountResponse;
|
|
use codex_app_server_protocol::LogoutAccountResponse;
|
|
use codex_app_server_protocol::ThreadResumeParams;
|
|
use codex_app_server_protocol::ThreadResumeResponse;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::UserInput;
|
|
use codex_config::test_support::CloudConfigBundleFixture;
|
|
use core_test_support::responses;
|
|
use core_test_support::skip_if_no_network;
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
use tempfile::tempdir;
|
|
use tokio::net::TcpStream;
|
|
use tokio_util::task::AbortOnDropHandle;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn config_server_settings_are_fetched_and_applied_at_startup() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let setup = ConfigServerSetup::new().await?;
|
|
let mut app = setup.start_app().await?;
|
|
assert_eq!(
|
|
setup.config_server.received_requests().await.unwrap().len(),
|
|
1
|
|
);
|
|
let started = app
|
|
.start_thread(ThreadStartParams {
|
|
model_provider: Some("openai".into()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
assert_eq!(started.model_provider, "company");
|
|
setup
|
|
.assert_turn_uses_managed_instructions(&mut app, &started.thread.id)
|
|
.await?;
|
|
app.shutdown_gracefully().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn config_server_settings_survive_openai_login_and_logout() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let setup = ConfigServerSetup::new().await?;
|
|
let mut app = setup.start_app().await?;
|
|
let login = app
|
|
.send_login_account_api_key_request("test-api-key")
|
|
.await?;
|
|
let _: LoginAccountResponse = app.read_response(login).await?;
|
|
let logout = app.send_logout_account_request().await?;
|
|
let _: LogoutAccountResponse = app.read_response(logout).await?;
|
|
let started = app.start_thread(ThreadStartParams::default()).await?;
|
|
setup
|
|
.assert_turn_uses_managed_instructions(&mut app, &started.thread.id)
|
|
.await?;
|
|
app.shutdown_gracefully().await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn config_server_settings_are_cached_across_restart_and_resume() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
let setup = ConfigServerSetup::new().await?;
|
|
let mut app = setup.start_app().await?;
|
|
let started = app.start_thread(ThreadStartParams::default()).await?;
|
|
setup
|
|
.assert_turn_uses_managed_instructions(&mut app, &started.thread.id)
|
|
.await?;
|
|
app.shutdown_gracefully().await?;
|
|
|
|
let mut app = setup.start_app().await?;
|
|
let resumed = app
|
|
.send_thread_resume_request(ThreadResumeParams {
|
|
thread_id: started.thread.id,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let resumed: ThreadResumeResponse = app.read_response(resumed).await?;
|
|
setup
|
|
.assert_turn_uses_managed_instructions(&mut app, &resumed.thread.id)
|
|
.await?;
|
|
assert_eq!(
|
|
setup.config_server.received_requests().await.unwrap().len(),
|
|
1
|
|
);
|
|
app.shutdown_gracefully().await?;
|
|
Ok(())
|
|
}
|
|
|
|
struct ConfigServerSetup {
|
|
home: TempDir,
|
|
config_server: MockServer,
|
|
inference: MockServer,
|
|
ca_path: PathBuf,
|
|
_tls: AbortOnDropHandle<()>,
|
|
}
|
|
|
|
impl ConfigServerSetup {
|
|
async fn new() -> Result<Self> {
|
|
let home = tempdir()?;
|
|
let config_server = MockServer::start().await;
|
|
let inference = responses::start_mock_server().await;
|
|
let requirements = format!(
|
|
r#"additional_developer_instructions = 'Company requirements'
|
|
model_provider = 'company'
|
|
|
|
[model_providers.company]
|
|
name = 'Company'
|
|
base_url = {:?}
|
|
env_key = 'CONFIG_SERVER_TEST_KEY'
|
|
wire_api = 'responses'
|
|
"#,
|
|
inference.uri()
|
|
);
|
|
let bundle = CloudConfigBundleFixture::enterprise_config(
|
|
"developer_instructions = 'Company defaults'",
|
|
)
|
|
.add_enterprise_requirement(requirements)
|
|
.into_bundle();
|
|
Mock::given(method("GET"))
|
|
.and(path("/team/codex/config/bundle"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(bundle))
|
|
.mount(&config_server)
|
|
.await;
|
|
|
|
let (origin, ca_path, tls) = https_mock_server(home.path(), &config_server).await?;
|
|
let base_url = format!("{origin}/team/");
|
|
std::fs::write(
|
|
home.path().join("requirements.toml"),
|
|
format!("[config_server]\nbase_url = {base_url:?}\n"),
|
|
)?;
|
|
std::fs::write(
|
|
home.path().join("config.toml"),
|
|
"model = 'gpt-5.5'\napproval_policy = 'never'\nsandbox_mode = 'read-only'\n[features]\napps = false\n",
|
|
)?;
|
|
Ok(Self {
|
|
home,
|
|
config_server,
|
|
inference,
|
|
ca_path,
|
|
_tls: tls,
|
|
})
|
|
}
|
|
|
|
async fn start_app(&self) -> Result<TestAppServer> {
|
|
let ca_path = self.ca_path.to_string_lossy();
|
|
let env = [
|
|
("CODEX_CA_CERTIFICATE", Some(ca_path.as_ref())),
|
|
("CONFIG_SERVER_TEST_KEY", Some("test-key")),
|
|
("NO_PROXY", Some("127.0.0.1,localhost")),
|
|
];
|
|
TestAppServer::builder()
|
|
.with_codex_home(self.home.path())
|
|
.with_args(&["--strict-config"])
|
|
.with_env_overrides(&env)
|
|
.build_initialized()
|
|
.await
|
|
}
|
|
|
|
async fn assert_turn_uses_managed_instructions(
|
|
&self,
|
|
app: &mut TestAppServer,
|
|
thread_id: &str,
|
|
) -> Result<()> {
|
|
let recorded = responses::mount_sse_once(
|
|
&self.inference,
|
|
responses::sse(vec![
|
|
responses::ev_assistant_message("reply", "Policy applied"),
|
|
responses::ev_completed("response"),
|
|
]),
|
|
)
|
|
.await;
|
|
let completed = app
|
|
.start_turn_and_wait_for_completion(TurnStartParams {
|
|
thread_id: thread_id.into(),
|
|
input: vec![UserInput::Text {
|
|
text: "Hello".into(),
|
|
text_elements: vec![],
|
|
}],
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
assert_eq!(completed.turn.status, TurnStatus::Completed);
|
|
let instructions = recorded
|
|
.single_request()
|
|
.message_input_texts("developer")
|
|
.join("\n");
|
|
assert!(instructions.contains("Company defaults"));
|
|
assert!(instructions.contains("Company requirements"));
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
enum StdinTokenStartup {
|
|
SkipPolicy,
|
|
LoadPolicy,
|
|
RejectAccount,
|
|
CloseDuringPolicy,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test_case::test_case("pro", Some("endor"), StdinTokenStartup::SkipPolicy; "personal_skips_policy")]
|
|
#[test_case::test_case("enterprise", Some("endor"), StdinTokenStartup::LoadPolicy; "enterprise_loads_policy")]
|
|
#[test_case::test_case("pro", None, StdinTokenStartup::LoadPolicy; "unbound_account_loads_policy")]
|
|
#[test_case::test_case("unknown", Some("endor"), StdinTokenStartup::LoadPolicy; "unknown_plan_loads_policy")]
|
|
#[test_case::test_case("pro", Some("alderaan"), StdinTokenStartup::RejectAccount; "mismatched_account_never_registers")]
|
|
#[test_case::test_case("enterprise", Some("endor"), StdinTokenStartup::CloseDuringPolicy; "stdin_close_cancels_pending_policy")]
|
|
#[tokio::test]
|
|
async fn stdin_token_startup(
|
|
plan: &str,
|
|
claimed_id: Option<&str>,
|
|
expected: StdinTokenStartup,
|
|
) -> Result<()> {
|
|
use StdinTokenStartup::*;
|
|
use anyhow::Context;
|
|
use std::process::Stdio;
|
|
use std::time::Duration;
|
|
use tokio::io::AsyncWriteExt;
|
|
use wiremock::matchers::header;
|
|
|
|
skip_if_no_network!(Ok(()));
|
|
let home = tempdir()?;
|
|
std::fs::write(
|
|
home.path().join("requirements.toml"),
|
|
"allowed_chatgpt_workspaces = ['endor']",
|
|
)?;
|
|
let registry = MockServer::start().await;
|
|
let (origin, ca, _tls) = https_mock_server(home.path(), ®istry).await?;
|
|
let timeout = Duration::from_secs(60);
|
|
let mut claims = app_test_support::ChatGptIdTokenClaims::new().plan_type(plan);
|
|
claims.chatgpt_account_id = claimed_id.map(str::to_owned);
|
|
let token = app_test_support::encode_id_token(&claims)?;
|
|
let authenticated = |route| {
|
|
Mock::given(path(route))
|
|
.and(header("authorization", format!("Bearer {token}")))
|
|
.and(header("chatgpt-account-id", "endor"))
|
|
};
|
|
let policy_response = match expected {
|
|
LoadPolicy => ResponseTemplate::new(200).set_body_json(serde_json::json!({})),
|
|
CloseDuringPolicy => ResponseTemplate::new(200).set_delay(timeout),
|
|
SkipPolicy | RejectAccount => ResponseTemplate::new(400)
|
|
.set_body_json(serde_json::json!({"detail": "No active workspace"})),
|
|
};
|
|
let policy = authenticated("/api/codex/config/bundle")
|
|
.respond_with(policy_response)
|
|
.expect(u64::from(matches!(
|
|
expected,
|
|
LoadPolicy | CloseDuringPolicy
|
|
)))
|
|
.mount_as_scoped(®istry)
|
|
.await;
|
|
let register = authenticated("/cloud/environment/env/register")
|
|
.respond_with(ResponseTemplate::new(200).set_delay(timeout))
|
|
.expect(u64::from(matches!(expected, SkipPolicy | LoadPolicy)))
|
|
.mount_as_scoped(®istry)
|
|
.await;
|
|
let mut child = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.current_dir(home.path())
|
|
.env("CODEX_HOME", home.path())
|
|
.env("CODEX_CA_CERTIFICATE", ca)
|
|
.env("NO_PROXY", "127.0.0.1")
|
|
.args(["-c", &format!("chatgpt_base_url={origin:?}")])
|
|
.arg("exec-server")
|
|
.args(["--remote", &origin])
|
|
.args(["--environment-id", "env"])
|
|
.args(["--chatgpt-token-stdin", "--exit-on-stdin-close"])
|
|
.args(["--chatgpt-account-id", "endor"])
|
|
.stdin(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.kill_on_drop(true)
|
|
.spawn()?;
|
|
let mut stdin = child.stdin.take().context("exec-server stdin")?;
|
|
stdin.write_all(format!("{token}\n").as_bytes()).await?;
|
|
let (pending, exit_timeout) = match expected {
|
|
RejectAccount => {
|
|
let output = tokio::time::timeout(timeout, child.wait_with_output()).await??;
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
String::from_utf8_lossy(&output.stderr)
|
|
.contains("--chatgpt-account-id does not match the token")
|
|
);
|
|
assert!(
|
|
registry
|
|
.received_requests()
|
|
.await
|
|
.context("request recording")?
|
|
.is_empty()
|
|
);
|
|
return Ok(());
|
|
}
|
|
CloseDuringPolicy => (&policy, Duration::from_secs(5)),
|
|
SkipPolicy | LoadPolicy => (®ister, timeout),
|
|
};
|
|
tokio::time::timeout(timeout, pending.wait_until_satisfied()).await?;
|
|
drop(stdin);
|
|
assert!(
|
|
tokio::time::timeout(exit_timeout, child.wait())
|
|
.await??
|
|
.success()
|
|
);
|
|
assert!(!home.path().join("auth.json").exists());
|
|
Ok(())
|
|
}
|
|
|
|
async fn https_mock_server(
|
|
home: &Path,
|
|
policy: &MockServer,
|
|
) -> Result<(String, PathBuf, AbortOnDropHandle<()>)> {
|
|
let (listener, acceptor, ca_path) = tls_listener(home).await?;
|
|
let origin_url = format!("https://{}", listener.local_addr()?);
|
|
let origin = *policy.address();
|
|
let tls = AbortOnDropHandle::new(tokio::spawn(async move {
|
|
while let Ok((stream, _)) = listener.accept().await {
|
|
let acceptor = acceptor.clone();
|
|
tokio::spawn(async move {
|
|
let mut stream = acceptor.accept(stream).await?;
|
|
let mut upstream = TcpStream::connect(origin).await?;
|
|
tokio::io::copy_bidirectional(&mut stream, &mut upstream).await?;
|
|
Ok::<_, std::io::Error>(())
|
|
});
|
|
}
|
|
}));
|
|
Ok((origin_url, ca_path, tls))
|
|
}
|