mirror of
https://github.com/openai/codex.git
synced 2026-02-05 08:23:41 +00:00
Compare commits
17 Commits
patch-squa
...
add-enterp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3874d5fd | ||
|
|
23b4a8c027 | ||
|
|
1bafe46731 | ||
|
|
3e773fbcfc | ||
|
|
114ce9ff4d | ||
|
|
e13b35ecb0 | ||
|
|
377af75730 | ||
|
|
86e0f31a7e | ||
|
|
8f837f1093 | ||
|
|
82982f1466 | ||
|
|
8d9369a650 | ||
|
|
4a6a63d6c4 | ||
|
|
6e772e993a | ||
|
|
848bc6c94a | ||
|
|
eab3d8e328 | ||
|
|
609d69d3ba | ||
|
|
dd2c9ed676 |
2
.github/workflows/rust-ci.yml
vendored
2
.github/workflows/rust-ci.yml
vendored
@@ -62,6 +62,8 @@ jobs:
|
||||
components: rustfmt
|
||||
- name: cargo fmt
|
||||
run: cargo fmt -- --config imports_granularity=Item --check
|
||||
- name: Verify codegen for mcp-types
|
||||
run: ./mcp-types/check_lib_rs.py
|
||||
|
||||
cargo_shear:
|
||||
name: cargo shear
|
||||
|
||||
19
.github/workflows/rust-release.yml
vendored
19
.github/workflows/rust-release.yml
vendored
@@ -219,3 +219,22 @@ jobs:
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
config: .github/dotslash-config.json
|
||||
|
||||
update-branch:
|
||||
name: Update latest-alpha-cli branch
|
||||
permissions:
|
||||
contents: write
|
||||
needs: release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Update latest-alpha-cli branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api \
|
||||
repos/${GITHUB_REPOSITORY}/git/refs/heads/latest-alpha-cli \
|
||||
-X PATCH \
|
||||
-f sha="${GITHUB_SHA}" \
|
||||
-f force=true
|
||||
|
||||
21
codex-rs/Cargo.lock
generated
21
codex-rs/Cargo.lock
generated
@@ -561,7 +561,6 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -615,6 +614,7 @@ dependencies = [
|
||||
"codex-apply-patch",
|
||||
"codex-mcp-client",
|
||||
"codex-protocol",
|
||||
"core-foundation 0.9.4",
|
||||
"core_test_support",
|
||||
"dirs",
|
||||
"env-flags",
|
||||
@@ -653,6 +653,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"which",
|
||||
"whoami",
|
||||
"wildmatch",
|
||||
"wiremock",
|
||||
]
|
||||
@@ -2545,6 +2546,7 @@ checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5100,6 +5102,12 @@ dependencies = [
|
||||
"wit-bindgen-rt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.100"
|
||||
@@ -5228,6 +5236,17 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wildmatch"
|
||||
version = "2.4.0"
|
||||
|
||||
@@ -733,6 +733,8 @@ fn compute_replacements(
|
||||
}
|
||||
}
|
||||
|
||||
replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx));
|
||||
|
||||
Ok(replacements)
|
||||
}
|
||||
|
||||
@@ -1216,6 +1218,33 @@ PATCH"#,
|
||||
assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pure_addition_chunk_followed_by_removal() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("panic.txt");
|
||||
fs::write(&path, "line1\nline2\nline3\n").unwrap();
|
||||
let patch = wrap_patch(&format!(
|
||||
r#"*** Update File: {}
|
||||
@@
|
||||
+after-context
|
||||
+second-line
|
||||
@@
|
||||
line1
|
||||
-line2
|
||||
-line3
|
||||
+line2-replacement"#,
|
||||
path.display()
|
||||
));
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
apply_patch(&patch, &mut stdout, &mut stderr).unwrap();
|
||||
let contents = fs::read_to_string(path).unwrap();
|
||||
assert_eq!(
|
||||
contents,
|
||||
"line1\nline2-replacement\nafter-context\nsecond-line\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ensure that patches authored with ASCII characters can update lines that
|
||||
/// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING
|
||||
/// HYPHEN). Historically `git apply` succeeds in such scenarios but our
|
||||
|
||||
@@ -11,7 +11,6 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-protocol = { path = "../protocol" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
@@ -2,8 +2,11 @@ use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::audit_admin_run_without_prompt;
|
||||
|
||||
use crate::chatgpt_token::init_chatgpt_token_from_auth;
|
||||
use crate::get_task::GetTaskResponse;
|
||||
@@ -31,6 +34,28 @@ pub async fn run_apply_command(
|
||||
ConfigOverrides::default(),
|
||||
)?;
|
||||
|
||||
if config
|
||||
.admin_danger_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.needs_prompt())
|
||||
{
|
||||
anyhow::bail!(ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE);
|
||||
}
|
||||
|
||||
audit_admin_run_without_prompt(
|
||||
&config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: false,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
init_chatgpt_token_from_auth(&config.codex_home).await?;
|
||||
|
||||
let task_response = get_task(&config, apply_cli.task_id).await?;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use codex_core::CodexAuth;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::RwLock;
|
||||
@@ -20,7 +19,7 @@ pub fn set_chatgpt_token_data(value: TokenData) {
|
||||
|
||||
/// Initialize the ChatGPT token from auth.json file
|
||||
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> {
|
||||
let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT)?;
|
||||
let auth = CodexAuth::from_codex_home(codex_home)?;
|
||||
if let Some(auth) = auth {
|
||||
let token_data = auth.get_token_data().await?;
|
||||
set_chatgpt_token_data(token_data);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::audit_admin_run_without_prompt;
|
||||
use codex_core::exec_env::create_env;
|
||||
use codex_core::landlock::spawn_command_under_linux_sandbox;
|
||||
use codex_core::seatbelt::spawn_command_under_seatbelt;
|
||||
@@ -75,6 +78,28 @@ async fn run_command_under_sandbox(
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
if config
|
||||
.admin_danger_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.needs_prompt())
|
||||
{
|
||||
anyhow::bail!(ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE);
|
||||
}
|
||||
|
||||
audit_admin_run_without_prompt(
|
||||
&config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: false,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let stdio_policy = StdioPolicy::Inherit;
|
||||
let env = create_env(&config.shell_environment_policy);
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::auth::CLIENT_ID;
|
||||
use codex_core::auth::OPENAI_API_KEY_ENV_VAR;
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_core::auth::logout;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::audit_admin_run_without_prompt;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::run_login_server;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
|
||||
@@ -25,7 +26,7 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
let config = load_config_or_exit(cli_config_overrides);
|
||||
let config = load_config_or_exit(cli_config_overrides).await;
|
||||
|
||||
match login_with_chatgpt(config.codex_home).await {
|
||||
Ok(_) => {
|
||||
@@ -43,7 +44,7 @@ pub async fn run_login_with_api_key(
|
||||
cli_config_overrides: CliConfigOverrides,
|
||||
api_key: String,
|
||||
) -> ! {
|
||||
let config = load_config_or_exit(cli_config_overrides);
|
||||
let config = load_config_or_exit(cli_config_overrides).await;
|
||||
|
||||
match login_with_api_key(&config.codex_home, &api_key) {
|
||||
Ok(_) => {
|
||||
@@ -58,21 +59,13 @@ pub async fn run_login_with_api_key(
|
||||
}
|
||||
|
||||
pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
let config = load_config_or_exit(cli_config_overrides);
|
||||
let config = load_config_or_exit(cli_config_overrides).await;
|
||||
|
||||
match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) {
|
||||
match CodexAuth::from_codex_home(&config.codex_home) {
|
||||
Ok(Some(auth)) => match auth.mode {
|
||||
AuthMode::ApiKey => match auth.get_token().await {
|
||||
Ok(api_key) => {
|
||||
eprintln!("Logged in using an API key - {}", safe_format_key(&api_key));
|
||||
|
||||
if let Ok(env_api_key) = env::var(OPENAI_API_KEY_ENV_VAR)
|
||||
&& env_api_key == api_key
|
||||
{
|
||||
eprintln!(
|
||||
" API loaded from OPENAI_API_KEY environment variable or .env file"
|
||||
);
|
||||
}
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -97,7 +90,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
}
|
||||
|
||||
pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
let config = load_config_or_exit(cli_config_overrides);
|
||||
let config = load_config_or_exit(cli_config_overrides).await;
|
||||
|
||||
match logout(&config.codex_home) {
|
||||
Ok(true) => {
|
||||
@@ -115,7 +108,7 @@ pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
|
||||
async fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
|
||||
let cli_overrides = match cli_config_overrides.parse_overrides() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
@@ -125,13 +118,42 @@ fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config {
|
||||
};
|
||||
|
||||
let config_overrides = ConfigOverrides::default();
|
||||
match Config::load_with_cli_overrides(cli_overrides, config_overrides) {
|
||||
let config = match Config::load_with_cli_overrides(cli_overrides, config_overrides) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("Error loading configuration: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
if config
|
||||
.admin_danger_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.needs_prompt())
|
||||
{
|
||||
eprintln!("{ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if let Err(err) = audit_admin_run_without_prompt(
|
||||
&config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: false,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("{err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
fn safe_format_key(key: &str) -> String {
|
||||
|
||||
@@ -5,8 +5,11 @@ use codex_common::CliConfigOverrides;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::audit_admin_run_without_prompt;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::Submission;
|
||||
@@ -36,11 +39,31 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
|
||||
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
|
||||
|
||||
if config
|
||||
.admin_danger_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.needs_prompt())
|
||||
{
|
||||
anyhow::bail!(ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE);
|
||||
}
|
||||
|
||||
audit_admin_run_without_prompt(
|
||||
&config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: false,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Use conversation_manager API to start a conversation
|
||||
let conversation_manager = ConversationManager::new(AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
config.preferred_auth_method,
|
||||
));
|
||||
let conversation_manager =
|
||||
ConversationManager::new(AuthManager::shared(config.codex_home.clone()));
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
|
||||
@@ -56,12 +56,16 @@ tree-sitter-bash = "0.25.0"
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
which = "6"
|
||||
wildmatch = "2.4.0"
|
||||
whoami = "1"
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
landlock = "0.4.1"
|
||||
seccompiler = "0.5.0"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.9"
|
||||
|
||||
# Build OpenSSL from source for musl builds.
|
||||
[target.x86_64-unknown-linux-musl.dependencies]
|
||||
openssl-sys = { version = "*", features = ["vendored"] }
|
||||
|
||||
348
codex-rs/core/src/admin_controls.rs
Normal file
348
codex-rs/core/src/admin_controls.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use chrono::Utc;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs::OpenOptions;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
pub const ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE: &str = "Running Codex with --dangerously-bypass-approvals-and-sandbox or --sandbox danger-full-access has been disabled by your administrator. Please contact your system administrator or try: codex --full-auto";
|
||||
|
||||
pub const ADMIN_DANGEROUS_SANDBOX_DISABLED_PROMPT_LINES: &[&str] = &[
|
||||
"Running Codex with --dangerously-bypass-approvals-and-sandbox or",
|
||||
"--sandbox danger-full-access has been disabled by your administrator.",
|
||||
"\nPlease contact your system administrator or try with sandboxed mode:",
|
||||
"codex --full-auto",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct AdminConfigToml {
|
||||
#[serde(default)]
|
||||
pub disallow_dangerous_sandbox: Option<bool>,
|
||||
|
||||
#[serde(default)]
|
||||
pub disallow_dangerously_bypass_approvals_and_sandbox: Option<bool>,
|
||||
|
||||
#[serde(default)]
|
||||
pub allow_danger_with_reason: Option<bool>,
|
||||
|
||||
#[serde(default)]
|
||||
pub audit: Option<AdminAuditToml>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct AdminAuditToml {
|
||||
pub endpoint: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub dangerous_sandbox: Option<bool>,
|
||||
|
||||
#[serde(default)]
|
||||
pub all_commands: Option<bool>,
|
||||
|
||||
#[serde(default)]
|
||||
pub audit_log_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct AdminControls {
|
||||
pub disallow_dangerous_sandbox: bool,
|
||||
pub disallow_dangerously_bypass_approvals_and_sandbox: bool,
|
||||
pub allow_danger_with_reason: bool,
|
||||
pub audit: Option<AdminAudit>,
|
||||
}
|
||||
|
||||
impl AdminControls {
|
||||
pub fn from_toml(admin: Option<AdminConfigToml>) -> Self {
|
||||
let mut controls = AdminControls::default();
|
||||
|
||||
if let Some(section) = admin {
|
||||
if let Some(value) = section.disallow_dangerous_sandbox {
|
||||
controls.disallow_dangerous_sandbox = value;
|
||||
}
|
||||
|
||||
if let Some(value) = section.disallow_dangerously_bypass_approvals_and_sandbox {
|
||||
controls.disallow_dangerously_bypass_approvals_and_sandbox = value;
|
||||
}
|
||||
|
||||
if let Some(value) = section.allow_danger_with_reason {
|
||||
controls.allow_danger_with_reason = value;
|
||||
}
|
||||
|
||||
controls.audit = section.audit.and_then(AdminAudit::from_toml);
|
||||
}
|
||||
|
||||
controls
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdminAudit {
|
||||
pub endpoint: String,
|
||||
pub events: AdminAuditEvents,
|
||||
pub audit_log_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AdminAudit {
|
||||
fn from_toml(config: AdminAuditToml) -> Option<Self> {
|
||||
let AdminAuditToml {
|
||||
endpoint,
|
||||
dangerous_sandbox,
|
||||
all_commands,
|
||||
audit_log_file,
|
||||
} = config;
|
||||
|
||||
let endpoint = endpoint?.trim().to_string();
|
||||
if endpoint.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let events = AdminAuditEvents {
|
||||
dangerous_sandbox: dangerous_sandbox.unwrap_or(false),
|
||||
all_commands: all_commands.unwrap_or(false),
|
||||
};
|
||||
|
||||
Some(Self {
|
||||
endpoint,
|
||||
events,
|
||||
audit_log_file: audit_log_file
|
||||
.as_deref()
|
||||
.and_then(resolve_admin_audit_log_file),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_admin_audit_log_file(path: &str) -> Option<PathBuf> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(home) = trimmed.strip_prefix("~/") {
|
||||
let mut expanded = dirs::home_dir()?;
|
||||
expanded.push(home);
|
||||
Some(expanded)
|
||||
} else if trimmed == "~" {
|
||||
dirs::home_dir()
|
||||
} else {
|
||||
Some(PathBuf::from(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct AdminAuditEvents {
|
||||
pub dangerous_sandbox: bool,
|
||||
pub all_commands: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct AdminDangerPrompt {
|
||||
pub sandbox: bool,
|
||||
pub dangerously_bypass: bool,
|
||||
}
|
||||
|
||||
impl AdminDangerPrompt {
|
||||
pub fn needs_prompt(&self) -> bool {
|
||||
self.sandbox || self.dangerously_bypass
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AdminAuditContext<'a> {
|
||||
pub sandbox_policy: &'a SandboxPolicy,
|
||||
pub approval_policy: &'a AskForApproval,
|
||||
pub cwd: &'a Path,
|
||||
pub command: Option<&'a [String]>,
|
||||
pub dangerously_bypass_requested: bool,
|
||||
pub dangerous_mode_justification: Option<&'a str>,
|
||||
pub record_command_event: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum AdminAuditEventKind {
|
||||
CommandInvoked,
|
||||
DangerousSandbox,
|
||||
}
|
||||
|
||||
impl AdminAuditEventKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::CommandInvoked => "command-invoked",
|
||||
Self::DangerousSandbox => "dangerous-sandbox",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AdminAuditPayload<'a> {
|
||||
event: &'a str,
|
||||
sandbox_mode: &'a str,
|
||||
username: String,
|
||||
dangerously_bypass_requested: bool,
|
||||
timestamp: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
justification: Option<&'a str>,
|
||||
approval_policy: &'a str,
|
||||
cwd: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
command: Option<&'a [String]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct AdminAuditEventDetails<'a> {
|
||||
sandbox_mode: &'a str,
|
||||
dangerously_bypass_requested: bool,
|
||||
justification: Option<&'a str>,
|
||||
approval_policy: &'a str,
|
||||
cwd: &'a str,
|
||||
command: Option<&'a [String]>,
|
||||
}
|
||||
|
||||
pub async fn maybe_post_admin_audit_events(
|
||||
admin_controls: &AdminControls,
|
||||
context: AdminAuditContext<'_>,
|
||||
) {
|
||||
let Some(audit) = admin_controls.audit.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !audit.events.all_commands && !audit.events.dangerous_sandbox {
|
||||
return;
|
||||
}
|
||||
|
||||
let sandbox_mode = context.sandbox_policy.to_string();
|
||||
let approval_policy_display = context.approval_policy.to_string();
|
||||
let cwd_display = context.cwd.display().to_string();
|
||||
let command_args = context.command;
|
||||
let client = Client::new();
|
||||
|
||||
let event_details = AdminAuditEventDetails {
|
||||
sandbox_mode: &sandbox_mode,
|
||||
dangerously_bypass_requested: context.dangerously_bypass_requested,
|
||||
justification: context.dangerous_mode_justification,
|
||||
approval_policy: &approval_policy_display,
|
||||
cwd: &cwd_display,
|
||||
command: command_args,
|
||||
};
|
||||
|
||||
if audit.events.all_commands && context.record_command_event {
|
||||
post_admin_audit_event(
|
||||
&client,
|
||||
&audit.endpoint,
|
||||
audit.audit_log_file.as_deref(),
|
||||
AdminAuditEventKind::CommandInvoked,
|
||||
event_details,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let dangerous_requested = context.dangerously_bypass_requested
|
||||
|| matches!(context.sandbox_policy, SandboxPolicy::DangerFullAccess);
|
||||
|
||||
if dangerous_requested && audit.events.dangerous_sandbox {
|
||||
post_admin_audit_event(
|
||||
&client,
|
||||
&audit.endpoint,
|
||||
audit.audit_log_file.as_deref(),
|
||||
AdminAuditEventKind::DangerousSandbox,
|
||||
event_details,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn audit_admin_run_with_prompt(
|
||||
admin_controls: &AdminControls,
|
||||
context: AdminAuditContext<'_>,
|
||||
prompt_result: Result<Option<String>, io::Error>,
|
||||
) -> Result<Option<String>, io::Error> {
|
||||
let (justification, prompt_error) = match prompt_result {
|
||||
Ok(reason) => (reason, None),
|
||||
Err(err) => (None, Some(err)),
|
||||
};
|
||||
|
||||
maybe_post_admin_audit_events(
|
||||
admin_controls,
|
||||
AdminAuditContext {
|
||||
dangerous_mode_justification: justification.as_deref(),
|
||||
..context
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
if let Some(err) = prompt_error {
|
||||
Err(err)
|
||||
} else {
|
||||
Ok(justification)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn audit_admin_run_without_prompt(
|
||||
admin_controls: &AdminControls,
|
||||
context: AdminAuditContext<'_>,
|
||||
) -> io::Result<()> {
|
||||
maybe_post_admin_audit_events(admin_controls, context).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_admin_audit_event(
|
||||
client: &Client,
|
||||
endpoint: &str,
|
||||
audit_log_file: Option<&Path>,
|
||||
kind: AdminAuditEventKind,
|
||||
details: AdminAuditEventDetails<'_>,
|
||||
) {
|
||||
let username = whoami::username();
|
||||
let timestamp_string;
|
||||
let payload = {
|
||||
timestamp_string = Utc::now().to_rfc3339();
|
||||
AdminAuditPayload {
|
||||
event: kind.label(),
|
||||
sandbox_mode: details.sandbox_mode,
|
||||
username,
|
||||
dangerously_bypass_requested: details.dangerously_bypass_requested,
|
||||
timestamp: ×tamp_string,
|
||||
justification: details.justification,
|
||||
approval_policy: details.approval_policy,
|
||||
cwd: details.cwd,
|
||||
command: details.command,
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = client.post(endpoint).json(&payload).send().await {
|
||||
tracing::warn!("Failed to POST admin audit event {}: {err}", kind.label());
|
||||
}
|
||||
|
||||
if let Some(path) = audit_log_file
|
||||
&& let Err(err) = append_admin_audit_log(path, &payload).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"Failed to write admin audit event {} to {}: {err}",
|
||||
kind.label(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn append_admin_audit_log(path: &Path, payload: &AdminAuditPayload<'_>) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(path)
|
||||
.await?;
|
||||
|
||||
let mut json_line = serde_json::to_vec(payload).map_err(io::Error::other)?;
|
||||
json_line.push(b'\n');
|
||||
file.write_all(&json_line).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -70,13 +70,9 @@ impl CodexAuth {
|
||||
Ok(access)
|
||||
}
|
||||
|
||||
/// Loads the available auth information from the auth.json or
|
||||
/// OPENAI_API_KEY environment variable.
|
||||
pub fn from_codex_home(
|
||||
codex_home: &Path,
|
||||
preferred_auth_method: AuthMode,
|
||||
) -> std::io::Result<Option<CodexAuth>> {
|
||||
load_auth(codex_home, true, preferred_auth_method)
|
||||
/// Loads the available auth information from the auth.json.
|
||||
pub fn from_codex_home(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
|
||||
load_auth(codex_home)
|
||||
}
|
||||
|
||||
pub async fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
|
||||
@@ -193,10 +189,11 @@ impl CodexAuth {
|
||||
|
||||
pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
|
||||
|
||||
fn read_openai_api_key_from_env() -> Option<String> {
|
||||
pub fn read_openai_api_key_from_env() -> Option<String> {
|
||||
env::var(OPENAI_API_KEY_ENV_VAR)
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||
@@ -214,7 +211,7 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes an `auth.json` that contains only the API key. Intended for CLI use.
|
||||
/// Writes an `auth.json` that contains only the API key.
|
||||
pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
|
||||
let auth_dot_json = AuthDotJson {
|
||||
openai_api_key: Some(api_key.to_string()),
|
||||
@@ -224,28 +221,11 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<(
|
||||
write_auth_json(&get_auth_file(codex_home), &auth_dot_json)
|
||||
}
|
||||
|
||||
fn load_auth(
|
||||
codex_home: &Path,
|
||||
include_env_var: bool,
|
||||
preferred_auth_method: AuthMode,
|
||||
) -> std::io::Result<Option<CodexAuth>> {
|
||||
// First, check to see if there is a valid auth.json file. If not, we fall
|
||||
// back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable
|
||||
// (if it is set).
|
||||
fn load_auth(codex_home: &Path) -> std::io::Result<Option<CodexAuth>> {
|
||||
let auth_file = get_auth_file(codex_home);
|
||||
let client = crate::default_client::create_client();
|
||||
let auth_dot_json = match try_read_auth_json(&auth_file) {
|
||||
Ok(auth) => auth,
|
||||
// If auth.json does not exist, try to read the OPENAI_API_KEY from the
|
||||
// environment variable.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => {
|
||||
return match read_openai_api_key_from_env() {
|
||||
Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))),
|
||||
None => Ok(None),
|
||||
};
|
||||
}
|
||||
// Though if auth.json exists but is malformed, do not fall back to the
|
||||
// env var because the user may be expecting to use AuthMode::ChatGPT.
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
@@ -257,32 +237,11 @@ fn load_auth(
|
||||
last_refresh,
|
||||
} = auth_dot_json;
|
||||
|
||||
// If the auth.json has an API key AND does not appear to be on a plan that
|
||||
// should prefer AuthMode::ChatGPT, use AuthMode::ApiKey.
|
||||
// Prefer AuthMode.ApiKey if it's set in the auth.json.
|
||||
if let Some(api_key) = &auth_json_api_key {
|
||||
// Should any of these be AuthMode::ChatGPT with the api_key set?
|
||||
// Does AuthMode::ChatGPT indicate that there is an auth.json that is
|
||||
// "refreshable" even if we are using the API key for auth?
|
||||
match &tokens {
|
||||
Some(tokens) => {
|
||||
if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) {
|
||||
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
|
||||
} else {
|
||||
// Ignore the API key and fall through to ChatGPT auth.
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// We have an API key but no tokens in the auth.json file.
|
||||
// Perhaps the user ran `codex login --api-key <KEY>` or updated
|
||||
// auth.json by hand. Either way, let's assume they are trying
|
||||
// to use their API key.
|
||||
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
|
||||
}
|
||||
}
|
||||
return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client)));
|
||||
}
|
||||
|
||||
// For the AuthMode::ChatGPT variant, perhaps neither api_key nor
|
||||
// openai_api_key should exist?
|
||||
Ok(Some(CodexAuth {
|
||||
api_key: None,
|
||||
mode: AuthMode::ChatGPT,
|
||||
@@ -412,7 +371,6 @@ use std::sync::RwLock;
|
||||
/// Internal cached auth state.
|
||||
#[derive(Clone, Debug)]
|
||||
struct CachedAuth {
|
||||
preferred_auth_mode: AuthMode,
|
||||
auth: Option<CodexAuth>,
|
||||
}
|
||||
|
||||
@@ -468,9 +426,7 @@ mod tests {
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
..
|
||||
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
} = super::load_auth(codex_home.path()).unwrap().unwrap();
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
@@ -499,88 +455,6 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Even if the OPENAI_API_KEY is set in auth.json, if the plan is not in
|
||||
/// [`TokenData::is_plan_that_should_use_api_key`], it should use
|
||||
/// [`AuthMode::ChatGPT`].
|
||||
#[tokio::test]
|
||||
async fn pro_account_with_api_key_still_uses_chatgpt_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
let fake_jwt = write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
chatgpt_plan_type: "pro".to_string(),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
..
|
||||
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(None, api_key);
|
||||
assert_eq!(AuthMode::ChatGPT, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().unwrap();
|
||||
let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist");
|
||||
assert_eq!(
|
||||
&AuthDotJson {
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: IdTokenInfo {
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)),
|
||||
raw_jwt: fake_jwt,
|
||||
},
|
||||
access_token: "test-access-token".to_string(),
|
||||
refresh_token: "test-refresh-token".to_string(),
|
||||
account_id: None,
|
||||
}),
|
||||
last_refresh: Some(
|
||||
DateTime::parse_from_rfc3339(LAST_REFRESH)
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
),
|
||||
},
|
||||
auth_dot_json
|
||||
)
|
||||
}
|
||||
|
||||
/// If the OPENAI_API_KEY is set in auth.json and it is an enterprise
|
||||
/// account, then it should use [`AuthMode::ApiKey`].
|
||||
#[tokio::test]
|
||||
async fn enterprise_account_with_api_key_uses_apikey_auth() {
|
||||
let codex_home = tempdir().unwrap();
|
||||
write_auth_file(
|
||||
AuthFileParams {
|
||||
openai_api_key: Some("sk-test-key".to_string()),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
},
|
||||
codex_home.path(),
|
||||
)
|
||||
.expect("failed to write auth file");
|
||||
|
||||
let CodexAuth {
|
||||
api_key,
|
||||
mode,
|
||||
auth_dot_json,
|
||||
auth_file: _,
|
||||
..
|
||||
} = super::load_auth(codex_home.path(), false, AuthMode::ChatGPT)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(Some("sk-test-key".to_string()), api_key);
|
||||
assert_eq!(AuthMode::ApiKey, mode);
|
||||
|
||||
let guard = auth_dot_json.lock().expect("should unwrap");
|
||||
assert!(guard.is_none(), "auth_dot_json should be None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loads_api_key_from_auth_json() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -591,9 +465,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let auth = super::load_auth(dir.path(), false, AuthMode::ChatGPT)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let auth = super::load_auth(dir.path()).unwrap().unwrap();
|
||||
assert_eq!(auth.mode, AuthMode::ApiKey);
|
||||
assert_eq!(auth.api_key, Some("sk-test-key".to_string()));
|
||||
|
||||
@@ -683,26 +555,17 @@ impl AuthManager {
|
||||
/// preferred auth method. Errors loading auth are swallowed; `auth()` will
|
||||
/// simply return `None` in that case so callers can treat it as an
|
||||
/// unauthenticated state.
|
||||
pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self {
|
||||
let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode)
|
||||
.ok()
|
||||
.flatten();
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
let auth = CodexAuth::from_codex_home(&codex_home).ok().flatten();
|
||||
Self {
|
||||
codex_home,
|
||||
inner: RwLock::new(CachedAuth {
|
||||
preferred_auth_mode,
|
||||
auth,
|
||||
}),
|
||||
inner: RwLock::new(CachedAuth { auth }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an AuthManager with a specific CodexAuth, for testing only.
|
||||
pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
|
||||
let preferred_auth_mode = auth.mode;
|
||||
let cached = CachedAuth {
|
||||
preferred_auth_mode,
|
||||
auth: Some(auth),
|
||||
};
|
||||
let cached = CachedAuth { auth: Some(auth) };
|
||||
Arc::new(Self {
|
||||
codex_home: PathBuf::new(),
|
||||
inner: RwLock::new(cached),
|
||||
@@ -714,21 +577,10 @@ impl AuthManager {
|
||||
self.inner.read().ok().and_then(|c| c.auth.clone())
|
||||
}
|
||||
|
||||
/// Preferred auth method used when (re)loading.
|
||||
pub fn preferred_auth_method(&self) -> AuthMode {
|
||||
self.inner
|
||||
.read()
|
||||
.map(|c| c.preferred_auth_mode)
|
||||
.unwrap_or(AuthMode::ApiKey)
|
||||
}
|
||||
|
||||
/// Force a reload using the existing preferred auth method. Returns
|
||||
/// Force a reload of the auth information from auth.json. Returns
|
||||
/// whether the auth value changed.
|
||||
pub fn reload(&self) -> bool {
|
||||
let preferred = self.preferred_auth_method();
|
||||
let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred)
|
||||
.ok()
|
||||
.flatten();
|
||||
let new_auth = CodexAuth::from_codex_home(&self.codex_home).ok().flatten();
|
||||
if let Ok(mut guard) = self.inner.write() {
|
||||
let changed = !AuthManager::auths_equal(&guard.auth, &new_auth);
|
||||
guard.auth = new_auth;
|
||||
@@ -747,8 +599,8 @@ impl AuthManager {
|
||||
}
|
||||
|
||||
/// Convenience constructor returning an `Arc` wrapper.
|
||||
pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc<Self> {
|
||||
Arc::new(Self::new(codex_home, preferred_auth_mode))
|
||||
pub fn shared(codex_home: PathBuf) -> Arc<Self> {
|
||||
Arc::new(Self::new(codex_home))
|
||||
}
|
||||
|
||||
/// Attempt to refresh the current auth token (if any). On success, reload
|
||||
|
||||
@@ -46,7 +46,9 @@ use crate::apply_patch::convert_apply_patch_to_protocol;
|
||||
use crate::client::ModelClient;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::config::AdminAuditContext;
|
||||
use crate::config::Config;
|
||||
use crate::config::maybe_post_admin_audit_events;
|
||||
use crate::config_types::ShellEnvironmentPolicy;
|
||||
use crate::conversation_history::ConversationHistory;
|
||||
use crate::environment_context::EnvironmentContext;
|
||||
@@ -278,6 +280,7 @@ struct State {
|
||||
pub(crate) struct Session {
|
||||
conversation_id: ConversationId,
|
||||
tx_event: Sender<Event>,
|
||||
config: Arc<Config>,
|
||||
|
||||
/// Manager for external MCP servers/tools.
|
||||
mcp_connection_manager: McpConnectionManager,
|
||||
@@ -468,6 +471,7 @@ impl Session {
|
||||
tools_config: ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &config.model_family,
|
||||
approval_policy,
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
include_plan_tool: config.include_plan_tool,
|
||||
include_apply_patch_tool: config.include_apply_patch_tool,
|
||||
include_web_search_request: config.tools_web_search_request,
|
||||
@@ -485,6 +489,7 @@ impl Session {
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
tx_event: tx_event.clone(),
|
||||
config: config.clone(),
|
||||
mcp_connection_manager,
|
||||
session_manager: ExecSessionManager::default(),
|
||||
unified_exec_manager: UnifiedExecSessionManager::default(),
|
||||
@@ -853,12 +858,38 @@ impl Session {
|
||||
self.on_exec_command_begin(turn_diff_tracker, begin_ctx.clone())
|
||||
.await;
|
||||
|
||||
let ExecInvokeArgs {
|
||||
params,
|
||||
sandbox_type,
|
||||
sandbox_policy,
|
||||
approval_policy,
|
||||
codex_linux_sandbox_exe,
|
||||
stdout_stream,
|
||||
record_admin_command_event,
|
||||
} = exec_args;
|
||||
|
||||
if record_admin_command_event {
|
||||
maybe_post_admin_audit_events(
|
||||
&self.config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy,
|
||||
approval_policy,
|
||||
cwd: ¶ms.cwd,
|
||||
command: Some(params.command.as_slice()),
|
||||
dangerously_bypass_requested: matches!(approval_policy, AskForApproval::Never),
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let result = process_exec_tool_call(
|
||||
exec_args.params,
|
||||
exec_args.sandbox_type,
|
||||
exec_args.sandbox_policy,
|
||||
exec_args.codex_linux_sandbox_exe,
|
||||
exec_args.stdout_stream,
|
||||
params,
|
||||
sandbox_type,
|
||||
sandbox_policy,
|
||||
codex_linux_sandbox_exe,
|
||||
stdout_stream,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1147,6 +1178,7 @@ async fn submission_loop(
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &effective_family,
|
||||
approval_policy: new_approval_policy,
|
||||
sandbox_policy: new_sandbox_policy.clone(),
|
||||
include_plan_tool: config.include_plan_tool,
|
||||
include_apply_patch_tool: config.include_apply_patch_tool,
|
||||
include_web_search_request: config.tools_web_search_request,
|
||||
@@ -1184,18 +1216,26 @@ async fn submission_loop(
|
||||
{
|
||||
warn!("failed to persist overrides: {e:#}");
|
||||
}
|
||||
|
||||
if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() {
|
||||
sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new(
|
||||
cwd,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
// Shell is not configurable from turn to turn
|
||||
None,
|
||||
))])
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Op::UserInput { items } => {
|
||||
submit_user_input(
|
||||
turn_context.cwd.clone(),
|
||||
turn_context.approval_policy,
|
||||
turn_context.sandbox_policy.clone(),
|
||||
&sess,
|
||||
&turn_context,
|
||||
sub.id.clone(),
|
||||
items,
|
||||
)
|
||||
.await;
|
||||
// attempt to inject input into current task
|
||||
if let Err(items) = sess.inject_input(items) {
|
||||
// no current task, spawn a new one
|
||||
let task =
|
||||
AgentTask::spawn(sess.clone(), Arc::clone(&turn_context), sub.id, items);
|
||||
sess.set_task(task);
|
||||
}
|
||||
}
|
||||
Op::UserTurn {
|
||||
items,
|
||||
@@ -1240,6 +1280,7 @@ async fn submission_loop(
|
||||
tools_config: ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy,
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
include_plan_tool: config.include_plan_tool,
|
||||
include_apply_patch_tool: config.include_apply_patch_tool,
|
||||
include_web_search_request: config.tools_web_search_request,
|
||||
@@ -1256,16 +1297,11 @@ async fn submission_loop(
|
||||
shell_environment_policy: turn_context.shell_environment_policy.clone(),
|
||||
cwd,
|
||||
};
|
||||
submit_user_input(
|
||||
fresh_turn_context.cwd.clone(),
|
||||
fresh_turn_context.approval_policy,
|
||||
fresh_turn_context.sandbox_policy.clone(),
|
||||
&sess,
|
||||
&Arc::new(fresh_turn_context),
|
||||
sub.id.clone(),
|
||||
items,
|
||||
)
|
||||
.await;
|
||||
// TODO: record the new environment context in the conversation history
|
||||
// no current task, spawn a new one with the per‑turn context
|
||||
let task =
|
||||
AgentTask::spawn(sess.clone(), Arc::new(fresh_turn_context), sub.id, items);
|
||||
sess.set_task(task);
|
||||
}
|
||||
}
|
||||
Op::ExecApproval { id, decision } => match decision {
|
||||
@@ -2448,8 +2484,10 @@ pub struct ExecInvokeArgs<'a> {
|
||||
pub params: ExecParams,
|
||||
pub sandbox_type: SandboxType,
|
||||
pub sandbox_policy: &'a SandboxPolicy,
|
||||
pub approval_policy: &'a AskForApproval,
|
||||
pub codex_linux_sandbox_exe: &'a Option<PathBuf>,
|
||||
pub stdout_stream: Option<StdoutStream>,
|
||||
pub record_admin_command_event: bool,
|
||||
}
|
||||
|
||||
fn maybe_translate_shell_command(
|
||||
@@ -2639,6 +2677,7 @@ async fn handle_container_exec_with_params(
|
||||
params: params.clone(),
|
||||
sandbox_type,
|
||||
sandbox_policy: &turn_context.sandbox_policy,
|
||||
approval_policy: &turn_context.approval_policy,
|
||||
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
stdout_stream: if exec_command_context.apply_patch.is_some() {
|
||||
None
|
||||
@@ -2649,6 +2688,7 @@ async fn handle_container_exec_with_params(
|
||||
tx_event: sess.tx_event.clone(),
|
||||
})
|
||||
},
|
||||
record_admin_command_event: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -2776,6 +2816,7 @@ async fn handle_sandbox_error(
|
||||
params,
|
||||
sandbox_type: SandboxType::None,
|
||||
sandbox_policy: &turn_context.sandbox_policy,
|
||||
approval_policy: &turn_context.approval_policy,
|
||||
codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe,
|
||||
stdout_stream: if exec_command_context.apply_patch.is_some() {
|
||||
None
|
||||
@@ -2786,6 +2827,7 @@ async fn handle_sandbox_error(
|
||||
tx_event: sess.tx_event.clone(),
|
||||
})
|
||||
},
|
||||
record_admin_command_event: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
@@ -2827,30 +2869,6 @@ async fn handle_sandbox_error(
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_user_input(
|
||||
cwd: PathBuf,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
sub_id: String,
|
||||
items: Vec<InputItem>,
|
||||
) {
|
||||
sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new(
|
||||
Some(cwd),
|
||||
Some(approval_policy),
|
||||
Some(sandbox_policy),
|
||||
// Shell is not configurable from turn to turn
|
||||
None,
|
||||
))])
|
||||
.await;
|
||||
if let Err(items) = sess.inject_input(items) {
|
||||
// no current task, spawn a new one
|
||||
let task = AgentTask::spawn(Arc::clone(sess), Arc::clone(turn_context), sub_id, items);
|
||||
sess.set_task(task);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_exec_output_str(exec_output: &ExecToolCallOutput) -> String {
|
||||
let ExecToolCallOutput {
|
||||
aggregated_output, ..
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use crate::admin_controls::AdminControls;
|
||||
use crate::config_profile::ConfigProfile;
|
||||
use crate::config_types::History;
|
||||
use crate::config_types::McpServerConfig;
|
||||
@@ -19,7 +21,6 @@ use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::mcp_protocol::Tools;
|
||||
use codex_protocol::mcp_protocol::UserSavedConfig;
|
||||
use dirs::home_dir;
|
||||
@@ -167,9 +168,6 @@ pub struct Config {
|
||||
|
||||
pub tools_web_search_request: bool,
|
||||
|
||||
/// If set to `true`, the API key will be signed with the `originator` header.
|
||||
pub preferred_auth_method: AuthMode,
|
||||
|
||||
pub use_experimental_streamable_shell_tool: bool,
|
||||
|
||||
/// If set to `true`, used only the experimental unified exec tool.
|
||||
@@ -185,6 +183,10 @@ pub struct Config {
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
pub disable_paste_burst: bool,
|
||||
|
||||
pub admin_controls: AdminControls,
|
||||
|
||||
pub admin_danger_prompt: Option<AdminDangerPrompt>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -204,7 +206,7 @@ impl Config {
|
||||
let codex_home = find_codex_home()?;
|
||||
|
||||
// Step 1: parse `config.toml` into a generic JSON value.
|
||||
let mut root_value = load_config_as_toml(&codex_home)?;
|
||||
let mut root_value = crate::config_loader::load_config_as_toml(&codex_home)?;
|
||||
|
||||
// Step 2: apply the `-c` overrides.
|
||||
for (path, value) in cli_overrides.into_iter() {
|
||||
@@ -227,7 +229,7 @@ pub fn load_config_as_toml_with_cli_overrides(
|
||||
codex_home: &Path,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<ConfigToml> {
|
||||
let mut root_value = load_config_as_toml(codex_home)?;
|
||||
let mut root_value = crate::config_loader::load_config_as_toml(codex_home)?;
|
||||
|
||||
for (path, value) in cli_overrides.into_iter() {
|
||||
apply_toml_override(&mut root_value, &path, value);
|
||||
@@ -241,28 +243,12 @@ pub fn load_config_as_toml_with_cli_overrides(
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Read `CODEX_HOME/config.toml` and return it as a generic TOML value. Returns
|
||||
/// an empty TOML table when the file does not exist.
|
||||
pub fn load_config_as_toml(codex_home: &Path) -> std::io::Result<TomlValue> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
match std::fs::read_to_string(&config_path) {
|
||||
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
|
||||
Ok(val) => Ok(val),
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse config.toml: {e}");
|
||||
Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::info!("config.toml not found, using defaults");
|
||||
Ok(TomlValue::Table(Default::default()))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read config.toml: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use crate::config_loader::load_config_as_toml;
|
||||
|
||||
pub use crate::admin_controls::AdminAuditContext;
|
||||
pub use crate::admin_controls::audit_admin_run_with_prompt;
|
||||
pub use crate::admin_controls::audit_admin_run_without_prompt;
|
||||
pub use crate::admin_controls::maybe_post_admin_audit_events;
|
||||
|
||||
fn set_project_trusted_inner(doc: &mut DocumentMut, project_path: &Path) -> anyhow::Result<()> {
|
||||
// Ensure we render a human-friendly structure:
|
||||
@@ -398,6 +384,10 @@ fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
|
||||
}
|
||||
}
|
||||
|
||||
pub use crate::admin_controls::AdminAuditEvents;
|
||||
pub use crate::admin_controls::AdminConfigToml;
|
||||
pub use crate::admin_controls::AdminDangerPrompt;
|
||||
|
||||
/// Base config deserialized from ~/.codex/config.toml.
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct ConfigToml {
|
||||
@@ -494,9 +484,6 @@ pub struct ConfigToml {
|
||||
|
||||
pub projects: Option<HashMap<String, ProjectConfig>>,
|
||||
|
||||
/// If set to `true`, the API key will be signed with the `originator` header.
|
||||
pub preferred_auth_method: Option<AuthMode>,
|
||||
|
||||
/// Nested tools section for feature toggles
|
||||
pub tools: Option<ToolsToml>,
|
||||
|
||||
@@ -504,6 +491,9 @@ pub struct ConfigToml {
|
||||
/// All characters are inserted as they are received, and no buffering
|
||||
/// or placeholder replacement will occur for fast keypress bursts.
|
||||
pub disable_paste_burst: Option<bool>,
|
||||
|
||||
/// Administrative controls configured by the enterprise administrator.
|
||||
pub admin: Option<AdminConfigToml>,
|
||||
}
|
||||
|
||||
impl From<ConfigToml> for UserSavedConfig {
|
||||
@@ -692,6 +682,44 @@ impl Config {
|
||||
|
||||
let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode);
|
||||
|
||||
let admin_controls = AdminControls::from_toml(cfg.admin.clone());
|
||||
|
||||
let resolved_approval_policy = approval_policy
|
||||
.or(config_profile.approval_policy)
|
||||
.or(cfg.approval_policy)
|
||||
.unwrap_or_else(AskForApproval::default);
|
||||
|
||||
let sandbox_is_dangerous = sandbox_policy.has_full_network_access();
|
||||
let dangerously_bypass_requested =
|
||||
matches!(sandbox_policy, SandboxPolicy::DangerFullAccess)
|
||||
&& resolved_approval_policy == AskForApproval::Never;
|
||||
|
||||
let mut admin_danger_prompt = AdminDangerPrompt::default();
|
||||
|
||||
if sandbox_is_dangerous && admin_controls.disallow_dangerous_sandbox {
|
||||
if admin_controls.allow_danger_with_reason {
|
||||
admin_danger_prompt.sandbox = true;
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if dangerously_bypass_requested
|
||||
&& admin_controls.disallow_dangerously_bypass_approvals_and_sandbox
|
||||
{
|
||||
if admin_controls.allow_danger_with_reason {
|
||||
admin_danger_prompt.dangerously_bypass = true;
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut model_providers = built_in_model_providers();
|
||||
// Merge user-defined providers into the built-in list.
|
||||
for (key, provider) in cfg.model_providers.into_iter() {
|
||||
@@ -796,10 +824,7 @@ impl Config {
|
||||
model_provider_id,
|
||||
model_provider,
|
||||
cwd: resolved_cwd,
|
||||
approval_policy: approval_policy
|
||||
.or(config_profile.approval_policy)
|
||||
.or(cfg.approval_policy)
|
||||
.unwrap_or_else(AskForApproval::default),
|
||||
approval_policy: resolved_approval_policy,
|
||||
sandbox_policy,
|
||||
shell_environment_policy,
|
||||
notify: cfg.notify,
|
||||
@@ -837,16 +862,19 @@ impl Config {
|
||||
include_plan_tool: include_plan_tool.unwrap_or(false),
|
||||
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
|
||||
tools_web_search_request,
|
||||
preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT),
|
||||
use_experimental_streamable_shell_tool: cfg
|
||||
.experimental_use_exec_command_tool
|
||||
.unwrap_or(false),
|
||||
use_experimental_unified_exec_tool: cfg
|
||||
.experimental_use_unified_exec_tool
|
||||
.unwrap_or(true),
|
||||
.unwrap_or(false),
|
||||
include_view_image_tool,
|
||||
active_profile: active_profile_name,
|
||||
disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false),
|
||||
admin_controls,
|
||||
admin_danger_prompt: admin_danger_prompt
|
||||
.needs_prompt()
|
||||
.then_some(admin_danger_prompt),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -1217,12 +1245,13 @@ model_verbosity = "high"
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
tools_web_search_request: false,
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
use_experimental_unified_exec_tool: true,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("o3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
admin_controls: AdminControls::default(),
|
||||
admin_danger_prompt: None,
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -1275,12 +1304,13 @@ model_verbosity = "high"
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
tools_web_search_request: false,
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
use_experimental_unified_exec_tool: true,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("gpt3".to_string()),
|
||||
disable_paste_burst: false,
|
||||
admin_controls: AdminControls::default(),
|
||||
admin_danger_prompt: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -1348,12 +1378,13 @@ model_verbosity = "high"
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
tools_web_search_request: false,
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
use_experimental_unified_exec_tool: true,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("zdr".to_string()),
|
||||
disable_paste_burst: false,
|
||||
admin_controls: AdminControls::default(),
|
||||
admin_danger_prompt: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
@@ -1407,12 +1438,13 @@ model_verbosity = "high"
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
tools_web_search_request: false,
|
||||
preferred_auth_method: AuthMode::ChatGPT,
|
||||
use_experimental_streamable_shell_tool: false,
|
||||
use_experimental_unified_exec_tool: true,
|
||||
use_experimental_unified_exec_tool: false,
|
||||
include_view_image_tool: true,
|
||||
active_profile: Some("gpt5".to_string()),
|
||||
disable_paste_burst: false,
|
||||
admin_controls: AdminControls::default(),
|
||||
admin_danger_prompt: None,
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt5_profile_config, gpt5_profile_config);
|
||||
|
||||
180
codex-rs/core/src/config_loader.rs
Normal file
180
codex-rs/core/src/config_loader.rs
Normal file
@@ -0,0 +1,180 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use base64::Engine;
|
||||
#[cfg(target_os = "macos")]
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use dirs::home_dir;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const CONFIG_TOML_FILE: &str = "config.toml";
|
||||
|
||||
pub fn load_config_as_toml(codex_home: &Path) -> io::Result<TomlValue> {
|
||||
let user_config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let global_config_path = home_dir().map(|mut path| {
|
||||
path.push(".codex");
|
||||
path.push("global.toml");
|
||||
path
|
||||
});
|
||||
let system_config_path = PathBuf::from("/etc/opt/codex/config.toml");
|
||||
|
||||
thread::scope(|scope| {
|
||||
let user_handle = scope.spawn(|| read_config_from_path(&user_config_path, true));
|
||||
let global_handle = scope.spawn(move || match global_config_path {
|
||||
Some(path) => read_config_from_path(&path, false),
|
||||
None => Ok(None),
|
||||
});
|
||||
let system_handle = scope.spawn(move || read_config_from_path(&system_config_path, false));
|
||||
let managed_handle = scope.spawn(load_managed_admin_config);
|
||||
|
||||
let user_config = join_config_result(user_handle, "user config.toml")?;
|
||||
let global_config = join_config_result(global_handle, "~/.codex/global.toml")?;
|
||||
let system_config = join_config_result(system_handle, "/etc/opt/codex/config.toml")?;
|
||||
let managed_config = join_config_result(managed_handle, "managed preferences")?;
|
||||
|
||||
let mut merged = user_config.unwrap_or_else(default_empty_table);
|
||||
|
||||
for overlay in [global_config, system_config, managed_config]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
merge_toml_values(&mut merged, &overlay);
|
||||
}
|
||||
|
||||
Ok(merged)
|
||||
})
|
||||
}
|
||||
|
||||
fn default_empty_table() -> TomlValue {
|
||||
TomlValue::Table(Default::default())
|
||||
}
|
||||
|
||||
fn join_config_result(
|
||||
handle: thread::ScopedJoinHandle<'_, io::Result<Option<TomlValue>>>,
|
||||
label: &str,
|
||||
) -> io::Result<Option<TomlValue>> {
|
||||
match handle.join() {
|
||||
Ok(result) => result,
|
||||
Err(panic) => {
|
||||
if let Some(msg) = panic.downcast_ref::<&str>() {
|
||||
tracing::error!("Configuration loader for {label} panicked: {msg}");
|
||||
} else if let Some(msg) = panic.downcast_ref::<String>() {
|
||||
tracing::error!("Configuration loader for {label} panicked: {msg}");
|
||||
} else {
|
||||
tracing::error!("Configuration loader for {label} panicked");
|
||||
}
|
||||
Err(io::Error::other(format!(
|
||||
"Failed to load {label} configuration"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_config_from_path(path: &Path, log_missing_as_info: bool) -> io::Result<Option<TomlValue>> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to parse {}: {err}", path.display());
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
},
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
if log_missing_as_info {
|
||||
tracing::info!("{} not found, using defaults", path.display());
|
||||
} else {
|
||||
tracing::debug!("{} not found", path.display());
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to read {}: {err}", path.display());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_toml_values(base: &mut TomlValue, overlay: &TomlValue) {
|
||||
if let TomlValue::Table(overlay_table) = overlay
|
||||
&& let TomlValue::Table(base_table) = base
|
||||
{
|
||||
for (key, value) in overlay_table {
|
||||
if let Some(existing) = base_table.get_mut(key) {
|
||||
merge_toml_values(existing, value);
|
||||
} else {
|
||||
base_table.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
*base = overlay.clone();
|
||||
}
|
||||
|
||||
fn load_managed_admin_config() -> io::Result<Option<TomlValue>> {
|
||||
load_managed_admin_config_impl()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn load_managed_admin_config_impl() -> io::Result<Option<TomlValue>> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::CFString;
|
||||
use core_foundation::string::CFStringRef;
|
||||
use std::ffi::c_void;
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFPreferencesCopyAppValue(key: CFStringRef, application_id: CFStringRef) -> *mut c_void;
|
||||
}
|
||||
|
||||
const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex";
|
||||
const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64";
|
||||
|
||||
let application_id = CFString::new(MANAGED_PREFERENCES_APPLICATION_ID);
|
||||
let key = CFString::new(MANAGED_PREFERENCES_CONFIG_KEY);
|
||||
|
||||
let value_ref = unsafe {
|
||||
CFPreferencesCopyAppValue(
|
||||
key.as_concrete_TypeRef(),
|
||||
application_id.as_concrete_TypeRef(),
|
||||
)
|
||||
};
|
||||
|
||||
if value_ref.is_null() {
|
||||
tracing::debug!(
|
||||
"Managed preferences for {} key {} not found",
|
||||
MANAGED_PREFERENCES_APPLICATION_ID,
|
||||
MANAGED_PREFERENCES_CONFIG_KEY
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let value = unsafe { CFString::wrap_under_create_rule(value_ref as _) };
|
||||
let contents = value.to_string();
|
||||
let trimmed = contents.trim();
|
||||
|
||||
let decoded = BASE64_STANDARD.decode(trimmed.as_bytes()).map_err(|err| {
|
||||
tracing::error!("Failed to decode managed preferences as base64: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
})?;
|
||||
|
||||
let decoded_str = String::from_utf8(decoded).map_err(|err| {
|
||||
tracing::error!("Managed preferences base64 contents were not valid UTF-8: {err}");
|
||||
io::Error::new(io::ErrorKind::InvalidData, err)
|
||||
})?;
|
||||
|
||||
match toml::from_str::<TomlValue>(&decoded_str) {
|
||||
Ok(parsed) => Ok(Some(parsed)),
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to parse managed preferences TOML: {err}");
|
||||
Err(io::Error::new(io::ErrorKind::InvalidData, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn load_managed_admin_config_impl() -> io::Result<Option<TomlValue>> {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
pub mod admin_controls;
|
||||
mod apply_patch;
|
||||
pub mod auth;
|
||||
pub mod bash;
|
||||
@@ -17,6 +18,7 @@ pub mod token_data;
|
||||
pub use codex_conversation::CodexConversation;
|
||||
pub mod config;
|
||||
pub mod config_edit;
|
||||
pub mod config_loader;
|
||||
pub mod config_profile;
|
||||
pub mod config_types;
|
||||
mod conversation_history;
|
||||
|
||||
@@ -80,7 +80,10 @@ pub struct ModelProviderInfo {
|
||||
/// the connection as lost.
|
||||
pub stream_idle_timeout_ms: Option<u64>,
|
||||
|
||||
/// Whether this provider requires some form of standard authentication (API key, ChatGPT token).
|
||||
/// Does this provider require an OpenAI API Key or ChatGPT login token? If true,
|
||||
/// user is presented with login screen on first run, and login preference and token/key
|
||||
/// are stored in auth.json. If false (which is the default), login screen is skipped,
|
||||
/// and API key (if needed) comes from the "env_key" environment variable.
|
||||
#[serde(default)]
|
||||
pub requires_openai_auth: bool,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::collections::HashMap;
|
||||
use crate::model_family::ModelFamily;
|
||||
use crate::plan_tool::PLAN_TOOL;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::tool_apply_patch::ApplyPatchToolType;
|
||||
use crate::tool_apply_patch::create_apply_patch_freeform_tool;
|
||||
use crate::tool_apply_patch::create_apply_patch_json_tool;
|
||||
@@ -57,7 +58,7 @@ pub(crate) enum OpenAiTool {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfigShellToolType {
|
||||
DefaultShell,
|
||||
ShellWithRequest,
|
||||
ShellWithRequest { sandbox_policy: SandboxPolicy },
|
||||
LocalShell,
|
||||
StreamableShell,
|
||||
}
|
||||
@@ -75,6 +76,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub(crate) struct ToolsConfigParams<'a> {
|
||||
pub(crate) model_family: &'a ModelFamily,
|
||||
pub(crate) approval_policy: AskForApproval,
|
||||
pub(crate) sandbox_policy: SandboxPolicy,
|
||||
pub(crate) include_plan_tool: bool,
|
||||
pub(crate) include_apply_patch_tool: bool,
|
||||
pub(crate) include_web_search_request: bool,
|
||||
@@ -88,6 +90,7 @@ impl ToolsConfig {
|
||||
let ToolsConfigParams {
|
||||
model_family,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
include_plan_tool,
|
||||
include_apply_patch_tool,
|
||||
include_web_search_request,
|
||||
@@ -103,7 +106,9 @@ impl ToolsConfig {
|
||||
ConfigShellToolType::DefaultShell
|
||||
};
|
||||
if matches!(approval_policy, AskForApproval::OnRequest) && !use_streamable_shell_tool {
|
||||
shell_type = ConfigShellToolType::ShellWithRequest;
|
||||
shell_type = ConfigShellToolType::ShellWithRequest {
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
let apply_patch_tool_type = match model_family.apply_patch_tool_type {
|
||||
@@ -246,9 +251,7 @@ fn create_unified_exec_tool() -> OpenAiTool {
|
||||
})
|
||||
}
|
||||
|
||||
const SHELL_TOOL_DESCRIPTION: &str = r#"Runs a shell command and returns its output"#;
|
||||
|
||||
fn create_shell_tool_for_sandbox() -> OpenAiTool {
|
||||
fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"command".to_string(),
|
||||
@@ -260,29 +263,79 @@ fn create_shell_tool_for_sandbox() -> OpenAiTool {
|
||||
properties.insert(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Working directory to execute the command in.".to_string()),
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Timeout for the command in milliseconds.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"with_escalated_permissions".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Request escalated permissions, only for when a command would otherwise be blocked by the sandbox.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Required if and only if with_escalated_permissions == true. One sentence explaining why escalation is needed (e.g., write outside CWD, network fetch, git commit).".to_string()),
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
let description = SHELL_TOOL_DESCRIPTION.to_string();
|
||||
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
properties.insert(
|
||||
"with_escalated_permissions".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let description = match sandbox_policy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
network_access,
|
||||
..
|
||||
} => {
|
||||
let network_line = if !network_access {
|
||||
"\n - Commands that require network access"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"
|
||||
The shell tool is used to execute shell commands.
|
||||
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges:
|
||||
- Types of actions that require escalated privileges:
|
||||
- Writing files other than those in the writable roots (see the environment context for the allowed directories){network_line}
|
||||
- Examples of commands that require escalated privileges:
|
||||
- git commit
|
||||
- npm install or pnpm install
|
||||
- cargo build
|
||||
- cargo test
|
||||
- When invoking a command that will require escalated privileges:
|
||||
- Provide the with_escalated_permissions parameter with the boolean value true
|
||||
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#,
|
||||
)
|
||||
}
|
||||
SandboxPolicy::DangerFullAccess => {
|
||||
"Runs a shell command and returns its output.".to_string()
|
||||
}
|
||||
SandboxPolicy::ReadOnly => {
|
||||
r#"
|
||||
The shell tool is used to execute shell commands.
|
||||
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions:
|
||||
- Types of actions that require escalated privileges:
|
||||
- Writing files
|
||||
- Applying patches
|
||||
- Examples of commands that require escalated privileges:
|
||||
- apply_patch
|
||||
- git commit
|
||||
- npm install or pnpm install
|
||||
- cargo build
|
||||
- cargo test
|
||||
- When invoking a command that will require escalated privileges:
|
||||
- Provide the with_escalated_permissions parameter with the boolean value true
|
||||
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
OpenAiTool::Function(ResponsesApiTool {
|
||||
name: "shell".to_string(),
|
||||
@@ -295,6 +348,7 @@ fn create_shell_tool_for_sandbox() -> OpenAiTool {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_view_image_tool() -> OpenAiTool {
|
||||
// Support only local filesystem path.
|
||||
let mut properties = BTreeMap::new();
|
||||
@@ -535,8 +589,8 @@ pub(crate) fn get_openai_tools(
|
||||
ConfigShellToolType::DefaultShell => {
|
||||
tools.push(create_shell_tool());
|
||||
}
|
||||
ConfigShellToolType::ShellWithRequest => {
|
||||
tools.push(create_shell_tool_for_sandbox());
|
||||
ConfigShellToolType::ShellWithRequest { sandbox_policy } => {
|
||||
tools.push(create_shell_tool_for_sandbox(sandbox_policy));
|
||||
}
|
||||
ConfigShellToolType::LocalShell => {
|
||||
tools.push(OpenAiTool::LocalShell {});
|
||||
@@ -632,6 +686,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: true,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -653,6 +708,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: true,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -674,6 +730,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -779,6 +836,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: false,
|
||||
@@ -856,6 +914,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -918,6 +977,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -975,6 +1035,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -1035,6 +1096,7 @@ mod tests {
|
||||
let config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_family: &model_family,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
include_plan_tool: false,
|
||||
include_apply_patch_tool: false,
|
||||
include_web_search_request: true,
|
||||
@@ -1088,7 +1150,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_shell_tool_for_sandbox_workspace_write() {
|
||||
let tool = super::create_shell_tool_for_sandbox();
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec!["workspace".into()],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
let tool = super::create_shell_tool_for_sandbox(&sandbox_policy);
|
||||
let OpenAiTool::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
@@ -1097,13 +1165,26 @@ mod tests {
|
||||
};
|
||||
assert_eq!(name, "shell");
|
||||
|
||||
let expected = super::SHELL_TOOL_DESCRIPTION;
|
||||
let expected = r#"
|
||||
The shell tool is used to execute shell commands.
|
||||
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges:
|
||||
- Types of actions that require escalated privileges:
|
||||
- Writing files other than those in the writable roots (see the environment context for the allowed directories)
|
||||
- Commands that require network access
|
||||
- Examples of commands that require escalated privileges:
|
||||
- git commit
|
||||
- npm install or pnpm install
|
||||
- cargo build
|
||||
- cargo test
|
||||
- When invoking a command that will require escalated privileges:
|
||||
- Provide the with_escalated_permissions parameter with the boolean value true
|
||||
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#;
|
||||
assert_eq!(description, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_tool_for_sandbox_readonly() {
|
||||
let tool = super::create_shell_tool_for_sandbox();
|
||||
let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly);
|
||||
let OpenAiTool::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
@@ -1112,13 +1193,27 @@ mod tests {
|
||||
};
|
||||
assert_eq!(name, "shell");
|
||||
|
||||
let expected = super::SHELL_TOOL_DESCRIPTION;
|
||||
let expected = r#"
|
||||
The shell tool is used to execute shell commands.
|
||||
- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions:
|
||||
- Types of actions that require escalated privileges:
|
||||
- Writing files
|
||||
- Applying patches
|
||||
- Examples of commands that require escalated privileges:
|
||||
- apply_patch
|
||||
- git commit
|
||||
- npm install or pnpm install
|
||||
- cargo build
|
||||
- cargo test
|
||||
- When invoking a command that will require escalated privileges:
|
||||
- Provide the with_escalated_permissions parameter with the boolean value true
|
||||
- Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#;
|
||||
assert_eq!(description, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_tool_for_sandbox_danger_full_access() {
|
||||
let tool = super::create_shell_tool_for_sandbox();
|
||||
let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::DangerFullAccess);
|
||||
let OpenAiTool::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
@@ -1127,7 +1222,6 @@ mod tests {
|
||||
};
|
||||
assert_eq!(name, "shell");
|
||||
|
||||
let expected = super::SHELL_TOOL_DESCRIPTION;
|
||||
assert_eq!(description, expected);
|
||||
assert_eq!(description, "Runs a shell command and returns its output.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)]
|
||||
pub struct TokenData {
|
||||
/// Flat info parsed from the JWT in auth.json.
|
||||
@@ -22,36 +20,6 @@ pub struct TokenData {
|
||||
pub account_id: Option<String>,
|
||||
}
|
||||
|
||||
impl TokenData {
|
||||
/// Returns true if this is a plan that should use the traditional
|
||||
/// "metered" billing via an API key.
|
||||
pub(crate) fn should_use_api_key(
|
||||
&self,
|
||||
preferred_auth_method: AuthMode,
|
||||
is_openai_email: bool,
|
||||
) -> bool {
|
||||
if preferred_auth_method == AuthMode::ApiKey {
|
||||
return true;
|
||||
}
|
||||
// If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey.
|
||||
if is_openai_email {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.id_token
|
||||
.chatgpt_plan_type
|
||||
.as_ref()
|
||||
.is_none_or(|plan| plan.is_plan_that_should_use_api_key())
|
||||
}
|
||||
|
||||
pub fn is_openai_email(&self) -> bool {
|
||||
self.id_token
|
||||
.email
|
||||
.as_deref()
|
||||
.is_some_and(|email| email.trim().to_ascii_lowercase().ends_with("@openai.com"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Flat subset of useful claims in id_token from auth.json.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub struct IdTokenInfo {
|
||||
@@ -80,19 +48,6 @@ pub(crate) enum PlanType {
|
||||
}
|
||||
|
||||
impl PlanType {
|
||||
fn is_plan_that_should_use_api_key(&self) -> bool {
|
||||
match self {
|
||||
Self::Known(known) => {
|
||||
use KnownPlan::*;
|
||||
!matches!(known, Free | Plus | Pro | Team)
|
||||
}
|
||||
Self::Unknown(_) => {
|
||||
// Unknown plans should use the API key.
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
match self {
|
||||
Self::Known(known) => format!("{known:?}").to_lowercase(),
|
||||
|
||||
@@ -8,7 +8,6 @@ use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::load_sse_fixture_with_id;
|
||||
use core_test_support::wait_for_event;
|
||||
@@ -277,25 +276,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
"content": [{ "type": "input_text", "text": "hello" }]
|
||||
}
|
||||
]);
|
||||
let input_array = request_body
|
||||
.get("input")
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.expect("input array in request body");
|
||||
let filtered: Vec<serde_json::Value> = input_array
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
let text = item
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|o| o.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
!text.contains("<environment_context>")
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(serde_json::json!(filtered), expected_input);
|
||||
assert_eq!(request_body["input"], expected_input);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -507,79 +488,6 @@ async fn chatgpt_auth_sends_correct_request() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prefers_chatgpt_token_when_config_prefers_chatgpt() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
println!(
|
||||
"Skipping test because it cannot execute when network is disabled in a Codex sandbox."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock server
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let first = ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_raw(sse_completed("resp1"), "text/event-stream");
|
||||
|
||||
// Expect ChatGPT base path and correct headers
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.and(header_regex("Authorization", r"Bearer Access-123"))
|
||||
.and(header_regex("chatgpt-account-id", r"acc-123"))
|
||||
.respond_with(first)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let model_provider = ModelProviderInfo {
|
||||
base_url: Some(format!("{}/v1", server.uri())),
|
||||
..built_in_model_providers()["openai"].clone()
|
||||
};
|
||||
|
||||
// Init session
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
// Write auth.json that contains both API key and ChatGPT tokens for a plan that should prefer ChatGPT.
|
||||
let _jwt = write_auth_json(
|
||||
&codex_home,
|
||||
Some("sk-test-key"),
|
||||
"pro",
|
||||
"Access-123",
|
||||
Some("acc-123"),
|
||||
);
|
||||
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
config.preferred_auth_method = AuthMode::ChatGPT;
|
||||
|
||||
let auth_manager =
|
||||
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
|
||||
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {e}"),
|
||||
};
|
||||
let conversation_manager = ConversationManager::new(auth_manager);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
..
|
||||
} = conversation_manager
|
||||
.new_conversation(config)
|
||||
.await
|
||||
.expect("create new conversation");
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: "hello".into(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() {
|
||||
@@ -624,14 +532,12 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
|
||||
|
||||
let mut config = load_default_config_for_test(&codex_home);
|
||||
config.model_provider = model_provider;
|
||||
config.preferred_auth_method = AuthMode::ApiKey;
|
||||
|
||||
let auth_manager =
|
||||
match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) {
|
||||
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {e}"),
|
||||
};
|
||||
let auth_manager = match CodexAuth::from_codex_home(codex_home.path()) {
|
||||
Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth),
|
||||
Ok(None) => panic!("No CodexAuth found in codex_home"),
|
||||
Err(e) => panic!("Failed to load CodexAuth: {e}"),
|
||||
};
|
||||
let conversation_manager = ConversationManager::new(auth_manager);
|
||||
let NewConversation {
|
||||
conversation: codex,
|
||||
@@ -968,6 +874,34 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
assert_eq!(requests.len(), 3, "expected 3 requests (one per turn)");
|
||||
|
||||
// Replace full-array compare with tail-only raw JSON compare using a single hard-coded value.
|
||||
let r3_tail_expected = json!([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type":"input_text","text":"U1"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type":"output_text","text":"Hey there!\n"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type":"input_text","text":"U2"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type":"output_text","text":"Hey there!\n"}]
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type":"input_text","text":"U3"}]
|
||||
}
|
||||
]);
|
||||
|
||||
let r3_input_array = requests[2]
|
||||
.body_json::<serde_json::Value>()
|
||||
.unwrap()
|
||||
@@ -975,60 +909,12 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
.and_then(|v| v.as_array())
|
||||
.cloned()
|
||||
.expect("r3 missing input array");
|
||||
// We only assert on the last 5 items of the input history for request 3.
|
||||
// With per-turn environment context injected, the last 5 should be:
|
||||
// [env_ctx, U2, assistant("Hey there!\n"), env_ctx, U3]
|
||||
let actual_tail = &r3_input_array[r3_input_array.len() - 5..];
|
||||
|
||||
// env_ctx 1
|
||||
assert_eq!(actual_tail[0]["type"], serde_json::json!("message"));
|
||||
assert_eq!(actual_tail[0]["role"], serde_json::json!("user"));
|
||||
let env_text_1 = &actual_tail[0]["content"][0]["text"];
|
||||
assert!(
|
||||
env_text_1
|
||||
.as_str()
|
||||
.expect("env text should be string")
|
||||
.contains("<environment_context>")
|
||||
);
|
||||
|
||||
// U2
|
||||
// skipping earlier context and developer messages
|
||||
let tail_len = r3_tail_expected.as_array().unwrap().len();
|
||||
let actual_tail = &r3_input_array[r3_input_array.len() - tail_len..];
|
||||
assert_eq!(
|
||||
actual_tail[1],
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "U2" } ]
|
||||
})
|
||||
);
|
||||
|
||||
// assistant response
|
||||
assert_eq!(
|
||||
actual_tail[2],
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [ { "type": "output_text", "text": "Hey there!\n" } ]
|
||||
})
|
||||
);
|
||||
|
||||
// env_ctx 2
|
||||
assert_eq!(actual_tail[3]["type"], serde_json::json!("message"));
|
||||
assert_eq!(actual_tail[3]["role"], serde_json::json!("user"));
|
||||
let env_text_2 = &actual_tail[3]["content"][0]["text"];
|
||||
assert!(
|
||||
env_text_2
|
||||
.as_str()
|
||||
.expect("env text should be string")
|
||||
.contains("<environment_context>")
|
||||
);
|
||||
|
||||
// U3
|
||||
assert_eq!(
|
||||
actual_tail[4],
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "U3" } ]
|
||||
})
|
||||
serde_json::Value::Array(actual_tail.to_vec()),
|
||||
r3_tail_expected,
|
||||
"request 3 tail mismatch",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -191,8 +191,7 @@ async fn prompt_tools_are_consistent_across_requests() {
|
||||
let expected_instructions: &str = include_str!("../../prompt.md");
|
||||
// our internal implementation is responsible for keeping tools in sync
|
||||
// with the OpenAI schema, so we just verify the tool presence here
|
||||
let expected_tools_names: &[&str] =
|
||||
&["unified_exec", "update_plan", "apply_patch", "view_image"];
|
||||
let expected_tools_names: &[&str] = &["shell", "update_plan", "apply_patch", "view_image"];
|
||||
let body0 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body0["instructions"],
|
||||
@@ -272,7 +271,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
|
||||
let shell = default_user_shell().await;
|
||||
|
||||
let expected_env_text_init = format!(
|
||||
let expected_env_text = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
@@ -285,28 +284,13 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
None => String::new(),
|
||||
}
|
||||
);
|
||||
// Per-turn environment context omits the shell tag.
|
||||
let expected_env_text_turn = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>on-request</approval_policy>
|
||||
<sandbox_mode>read-only</sandbox_mode>
|
||||
<network_access>restricted</network_access>
|
||||
</environment_context>"#,
|
||||
cwd.path().to_string_lossy(),
|
||||
);
|
||||
let expected_ui_text =
|
||||
"<user_instructions>\n\nbe consistent and helpful\n\n</user_instructions>";
|
||||
|
||||
let expected_env_msg_init = serde_json::json!({
|
||||
let expected_env_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_init } ]
|
||||
});
|
||||
let expected_env_msg_turn = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_turn } ]
|
||||
"content": [ { "type": "input_text", "text": expected_env_text } ]
|
||||
});
|
||||
let expected_ui_msg = serde_json::json!({
|
||||
"type": "message",
|
||||
@@ -322,12 +306,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
let body1 = requests[0].body_json::<serde_json::Value>().unwrap();
|
||||
assert_eq!(
|
||||
body1["input"],
|
||||
serde_json::json!([
|
||||
expected_ui_msg,
|
||||
expected_env_msg_init,
|
||||
expected_env_msg_turn,
|
||||
expected_user_message_1
|
||||
])
|
||||
serde_json::json!([expected_ui_msg, expected_env_msg, expected_user_message_1])
|
||||
);
|
||||
|
||||
let expected_user_message_2 = serde_json::json!({
|
||||
@@ -339,7 +318,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
let expected_body2 = serde_json::json!(
|
||||
[
|
||||
body1["input"].as_array().unwrap().as_slice(),
|
||||
[expected_env_msg_turn, expected_user_message_2].as_slice(),
|
||||
[expected_user_message_2].as_slice(),
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
@@ -567,24 +546,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() {
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
let expected_env_text_2 = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<approval_policy>never</approval_policy>
|
||||
<sandbox_mode>workspace-write</sandbox_mode>
|
||||
<network_access>enabled</network_access>
|
||||
</environment_context>"#,
|
||||
new_cwd.path().to_string_lossy()
|
||||
);
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
|
||||
});
|
||||
let expected_body2 = serde_json::json!(
|
||||
[
|
||||
body1["input"].as_array().unwrap().as_slice(),
|
||||
[expected_env_msg_2, expected_user_message_2].as_slice(),
|
||||
[expected_user_message_2].as_slice(),
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
|
||||
22
codex-rs/docs/global.toml
Normal file
22
codex-rs/docs/global.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
# Example administrator configuration applied to every Codex user on this host.
|
||||
|
||||
[admin]
|
||||
# Disallow --sandbox danger-full-access and --dangerously-bypass-approvals-and-sandbox.
|
||||
disallow_dangerous_sandbox = true
|
||||
disallow_dangerously_bypass_approvals_and_sandbox = true
|
||||
|
||||
# Permit end users to proceed after providing a justification, which will be
|
||||
# logged via the admin audit hook below.
|
||||
allow_danger_with_reason = true
|
||||
|
||||
[admin.audit]
|
||||
# Optional webhook to receive audit events whenever Codex commands run.
|
||||
endpoint = "http://localhost:5000/collect"
|
||||
# Optional file path to append audit entries as JSON Lines. Paths starting with
|
||||
# `~/` expand to the current user's home directory.
|
||||
# audit_log_file = "~/Library/Logs/com.openai.codex/codex_audit.jsonl"
|
||||
# Record executions that request danger-full-access sandboxing.
|
||||
dangerous_sandbox = true
|
||||
# Emit audit entries for every Codex invocation (set to false to log only
|
||||
# dangerous sandbox usage).
|
||||
all_commands = true
|
||||
@@ -12,8 +12,11 @@ use codex_core::AuthManager;
|
||||
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::audit_admin_run_without_prompt;
|
||||
use codex_core::git_info::get_git_repo_root;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
@@ -162,6 +165,30 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
};
|
||||
|
||||
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
|
||||
|
||||
if config
|
||||
.admin_danger_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.needs_prompt())
|
||||
{
|
||||
eprintln!("{ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
audit_admin_run_without_prompt(
|
||||
&config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: dangerously_bypass_approvals_and_sandbox,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))?;
|
||||
let mut event_processor: Box<dyn EventProcessor> = if json_mode {
|
||||
Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone()))
|
||||
} else {
|
||||
@@ -187,10 +214,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let conversation_manager = ConversationManager::new(AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
config.preferred_auth_method,
|
||||
));
|
||||
let conversation_manager =
|
||||
ConversationManager::new(AuthManager::shared(config.codex_home.clone()));
|
||||
let NewConversation {
|
||||
conversation_id: _,
|
||||
conversation,
|
||||
|
||||
@@ -10,8 +10,10 @@ use codex_core::Cursor as RolloutCursor;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::SessionMeta;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::auth::CLIENT_ID;
|
||||
use codex_core::auth::get_auth_file;
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_core::auth::try_read_auth_json;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
@@ -23,12 +25,14 @@ use codex_core::exec_env::create_env;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::git_info::git_diff_to_remote;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecApprovalRequestEvent;
|
||||
use codex_core::protocol::InputItem as CoreInputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_login::ServerOptions as LoginServerOptions;
|
||||
use codex_login::ShutdownHandle;
|
||||
use codex_login::run_login_server;
|
||||
@@ -39,7 +43,6 @@ use codex_protocol::mcp_protocol::ApplyPatchApprovalParams;
|
||||
use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse;
|
||||
use codex_protocol::mcp_protocol::ArchiveConversationParams;
|
||||
use codex_protocol::mcp_protocol::ArchiveConversationResponse;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::mcp_protocol::AuthStatusChangeNotification;
|
||||
use codex_protocol::mcp_protocol::ClientRequest;
|
||||
use codex_protocol::mcp_protocol::ConversationId;
|
||||
@@ -57,6 +60,8 @@ use codex_protocol::mcp_protocol::InterruptConversationParams;
|
||||
use codex_protocol::mcp_protocol::InterruptConversationResponse;
|
||||
use codex_protocol::mcp_protocol::ListConversationsParams;
|
||||
use codex_protocol::mcp_protocol::ListConversationsResponse;
|
||||
use codex_protocol::mcp_protocol::LoginApiKeyParams;
|
||||
use codex_protocol::mcp_protocol::LoginApiKeyResponse;
|
||||
use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification;
|
||||
use codex_protocol::mcp_protocol::LoginChatGptResponse;
|
||||
use codex_protocol::mcp_protocol::NewConversationParams;
|
||||
@@ -172,6 +177,9 @@ impl CodexMessageProcessor {
|
||||
ClientRequest::GitDiffToRemote { request_id, params } => {
|
||||
self.git_diff_to_origin(request_id, params.cwd).await;
|
||||
}
|
||||
ClientRequest::LoginApiKey { request_id, params } => {
|
||||
self.login_api_key(request_id, params).await;
|
||||
}
|
||||
ClientRequest::LoginChatGpt { request_id } => {
|
||||
self.login_chatgpt(request_id).await;
|
||||
}
|
||||
@@ -199,6 +207,39 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) {
|
||||
{
|
||||
let mut guard = self.active_login.lock().await;
|
||||
if let Some(active) = guard.take() {
|
||||
active.drop();
|
||||
}
|
||||
}
|
||||
|
||||
match login_with_api_key(&self.config.codex_home, ¶ms.api_key) {
|
||||
Ok(()) => {
|
||||
self.auth_manager.reload();
|
||||
self.outgoing
|
||||
.send_response(request_id, LoginApiKeyResponse {})
|
||||
.await;
|
||||
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: self.auth_manager.auth().map(|auth| auth.mode),
|
||||
};
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::AuthStatusChange(payload))
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to save api key: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn login_chatgpt(&mut self, request_id: RequestId) {
|
||||
let config = self.config.as_ref();
|
||||
|
||||
@@ -352,7 +393,7 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
|
||||
// Send auth status change notification reflecting the current auth mode
|
||||
// after logout (which may fall back to API key via env var).
|
||||
// after logout.
|
||||
let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode);
|
||||
let payload = AuthStatusChangeNotification {
|
||||
auth_method: current_auth_method,
|
||||
@@ -367,7 +408,6 @@ impl CodexMessageProcessor {
|
||||
request_id: RequestId,
|
||||
params: codex_protocol::mcp_protocol::GetAuthStatusParams,
|
||||
) {
|
||||
let preferred_auth_method: AuthMode = self.auth_manager.preferred_auth_method();
|
||||
let include_token = params.include_token.unwrap_or(false);
|
||||
let do_refresh = params.refresh_token.unwrap_or(false);
|
||||
|
||||
@@ -375,6 +415,11 @@ impl CodexMessageProcessor {
|
||||
tracing::warn!("failed to refresh token while getting auth status: {err}");
|
||||
}
|
||||
|
||||
// Determine whether auth is required based on the active model provider.
|
||||
// If a custom provider is configured with `requires_openai_auth == false`,
|
||||
// then no auth step is required; otherwise, default to requiring auth.
|
||||
let requires_openai_auth = Some(self.config.model_provider.requires_openai_auth);
|
||||
|
||||
let response = match self.auth_manager.auth() {
|
||||
Some(auth) => {
|
||||
let (reported_auth_method, token_opt) = match auth.get_token().await {
|
||||
@@ -390,14 +435,14 @@ impl CodexMessageProcessor {
|
||||
};
|
||||
codex_protocol::mcp_protocol::GetAuthStatusResponse {
|
||||
auth_method: reported_auth_method,
|
||||
preferred_auth_method,
|
||||
auth_token: token_opt,
|
||||
requires_openai_auth,
|
||||
}
|
||||
}
|
||||
None => codex_protocol::mcp_protocol::GetAuthStatusResponse {
|
||||
auth_method: None,
|
||||
preferred_auth_method,
|
||||
auth_token: None,
|
||||
requires_openai_auth,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -856,6 +901,23 @@ impl CodexMessageProcessor {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let controls = &self.config.admin_controls;
|
||||
let sandbox_policy = self.config.sandbox_policy.clone();
|
||||
let approval_policy = self.config.approval_policy;
|
||||
let sandbox_is_dangerous = sandbox_policy.has_full_network_access();
|
||||
let dangerously_bypass_requested =
|
||||
matches!(sandbox_policy, SandboxPolicy::DangerFullAccess)
|
||||
&& approval_policy == AskForApproval::Never;
|
||||
|
||||
if (sandbox_is_dangerous && controls.disallow_dangerous_sandbox)
|
||||
|| (dangerously_bypass_requested
|
||||
&& controls.disallow_dangerously_bypass_approvals_and_sandbox)
|
||||
{
|
||||
self.outgoing
|
||||
.send_admin_warning(Some(&request_id), ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Submit user input to the conversation.
|
||||
let _ = conversation
|
||||
.submit(Op::UserInput {
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::patch_approval::handle_patch_approval_request;
|
||||
use codex_core::CodexConversation;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE;
|
||||
use codex_core::config::Config as CodexConfig;
|
||||
use codex_core::protocol::AgentMessageEvent;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
@@ -44,6 +45,11 @@ pub async fn run_codex_tool_session(
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, ConversationId>>>,
|
||||
) {
|
||||
let admin_prompt_required = config
|
||||
.admin_danger_prompt
|
||||
.as_ref()
|
||||
.is_some_and(|prompt| prompt.needs_prompt());
|
||||
|
||||
let NewConversation {
|
||||
conversation_id,
|
||||
conversation,
|
||||
@@ -77,6 +83,12 @@ pub async fn run_codex_tool_session(
|
||||
)
|
||||
.await;
|
||||
|
||||
if admin_prompt_required {
|
||||
outgoing
|
||||
.send_admin_warning(Some(&id), ADMIN_DANGEROUS_SANDBOX_DISABLED_MESSAGE)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Use the original MCP request ID as the `sub_id` for the Codex submission so that
|
||||
// any events emitted for this tool-call can be correlated with the
|
||||
// originating `tools/call` request.
|
||||
|
||||
@@ -6,8 +6,10 @@ use std::io::Result as IoResult;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::audit_admin_run_without_prompt;
|
||||
|
||||
use mcp_types::JSONRPCMessage;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
@@ -97,6 +99,20 @@ pub async fn run_main(
|
||||
std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}"))
|
||||
})?;
|
||||
|
||||
audit_admin_run_without_prompt(
|
||||
&config.admin_controls,
|
||||
AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: false,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Task: process incoming messages.
|
||||
let processor_handle = tokio::spawn({
|
||||
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
|
||||
|
||||
@@ -56,8 +56,7 @@ impl MessageProcessor {
|
||||
config: Arc<Config>,
|
||||
) -> Self {
|
||||
let outgoing = Arc::new(outgoing);
|
||||
let auth_manager =
|
||||
AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
|
||||
let auth_manager = AuthManager::shared(config.codex_home.clone());
|
||||
let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone()));
|
||||
let codex_message_processor = CodexMessageProcessor::new(
|
||||
auth_manager,
|
||||
|
||||
@@ -11,9 +11,14 @@ use mcp_types::JSONRPCMessage;
|
||||
use mcp_types::JSONRPCNotification;
|
||||
use mcp_types::JSONRPCRequest;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
use mcp_types::LoggingLevel;
|
||||
use mcp_types::LoggingMessageNotification;
|
||||
use mcp_types::LoggingMessageNotificationParams;
|
||||
use mcp_types::ModelContextProtocolNotification;
|
||||
use mcp_types::RequestId;
|
||||
use mcp_types::Result;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
@@ -125,6 +130,31 @@ impl OutgoingMessageSender {
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn send_admin_warning(&self, request_id: Option<&RequestId>, message: &str) {
|
||||
let data = if let Some(id) = request_id {
|
||||
json!({
|
||||
"message": message,
|
||||
"requestId": id,
|
||||
})
|
||||
} else {
|
||||
json!({ "message": message })
|
||||
};
|
||||
|
||||
let params = serde_json::to_value(LoggingMessageNotificationParams {
|
||||
data,
|
||||
level: LoggingLevel::Warning,
|
||||
logger: Some("codex-admin".to_string()),
|
||||
})
|
||||
.ok();
|
||||
|
||||
let notification = OutgoingNotification {
|
||||
method: LoggingMessageNotification::METHOD.to_string(),
|
||||
params,
|
||||
};
|
||||
|
||||
self.send_notification(notification).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
|
||||
let _ = self
|
||||
.sender
|
||||
|
||||
@@ -18,6 +18,7 @@ use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
use codex_protocol::mcp_protocol::InterruptConversationParams;
|
||||
use codex_protocol::mcp_protocol::ListConversationsParams;
|
||||
use codex_protocol::mcp_protocol::LoginApiKeyParams;
|
||||
use codex_protocol::mcp_protocol::NewConversationParams;
|
||||
use codex_protocol::mcp_protocol::RemoveConversationListenerParams;
|
||||
use codex_protocol::mcp_protocol::ResumeConversationParams;
|
||||
@@ -318,6 +319,15 @@ impl McpProcess {
|
||||
self.send_request("resumeConversation", params).await
|
||||
}
|
||||
|
||||
/// Send a `loginApiKey` JSON-RPC request.
|
||||
pub async fn send_login_api_key_request(
|
||||
&mut self,
|
||||
params: LoginApiKeyParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("loginApiKey", params).await
|
||||
}
|
||||
|
||||
/// Send a `loginChatGpt` JSON-RPC request.
|
||||
pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result<i64> {
|
||||
self.send_request("loginChatGpt", None).await
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use std::path::Path;
|
||||
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_protocol::mcp_protocol::AuthMode;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusResponse;
|
||||
use codex_protocol::mcp_protocol::LoginApiKeyParams;
|
||||
use codex_protocol::mcp_protocol::LoginApiKeyResponse;
|
||||
use mcp_test_support::McpProcess;
|
||||
use mcp_test_support::to_response;
|
||||
use mcp_types::JSONRPCResponse;
|
||||
@@ -36,10 +37,29 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) {
|
||||
let request_id = mcp
|
||||
.send_login_api_key_request(LoginApiKeyParams {
|
||||
api_key: api_key.to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("send loginApiKey: {e}"));
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("loginApiKey timeout: {e}"))
|
||||
.unwrap_or_else(|e| panic!("loginApiKey response: {e}"));
|
||||
let _: LoginApiKeyResponse =
|
||||
to_response(resp).unwrap_or_else(|e| panic!("deserialize login response: {e}"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_no_auth() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}"));
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)])
|
||||
.await
|
||||
@@ -72,8 +92,7 @@ async fn get_auth_status_no_auth() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_api_key() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
|
||||
create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}"));
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
@@ -83,6 +102,8 @@ async fn get_auth_status_with_api_key() {
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
login_with_api_key_via_request(&mut mcp, "sk-test-key").await;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
@@ -101,14 +122,12 @@ async fn get_auth_status_with_api_key() {
|
||||
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
|
||||
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
|
||||
assert_eq!(status.auth_token, Some("sk-test-key".to_string()));
|
||||
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_api_key_no_include_token() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key");
|
||||
create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}"));
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
@@ -118,6 +137,8 @@ async fn get_auth_status_with_api_key_no_include_token() {
|
||||
.expect("init timeout")
|
||||
.expect("init failed");
|
||||
|
||||
login_with_api_key_via_request(&mut mcp, "sk-test-key").await;
|
||||
|
||||
// Build params via struct so None field is omitted in wire JSON.
|
||||
let params = GetAuthStatusParams {
|
||||
include_token: None,
|
||||
@@ -138,5 +159,4 @@ async fn get_auth_status_with_api_key_no_include_token() {
|
||||
let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status");
|
||||
assert_eq!(status.auth_method, Some(AuthMode::ApiKey));
|
||||
assert!(status.auth_token.is_none(), "token must be omitted");
|
||||
assert_eq!(status.preferred_auth_method, AuthMode::ChatGPT);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_protocol::mcp_protocol::CancelLoginChatGptParams;
|
||||
use codex_protocol::mcp_protocol::CancelLoginChatGptResponse;
|
||||
use codex_protocol::mcp_protocol::GetAuthStatusParams;
|
||||
@@ -95,7 +95,7 @@ async fn logout_chatgpt_removes_auth() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn login_and_cancel_chatgpt() {
|
||||
let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}"));
|
||||
create_config_toml(codex_home.path()).expect("write config.toml");
|
||||
create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}"));
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path())
|
||||
.await
|
||||
|
||||
21
codex-rs/mcp-types/check_lib_rs.py
Executable file
21
codex-rs/mcp-types/check_lib_rs.py
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
crate_dir = Path(__file__).resolve().parent
|
||||
generator = crate_dir / "generate_mcp_types.py"
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(generator), "--check"],
|
||||
cwd=crate_dir,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -5,15 +5,19 @@ import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
from dataclasses import (
|
||||
dataclass,
|
||||
)
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from shutil import copy2
|
||||
|
||||
# Helper first so it is defined when other functions call it.
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
SCHEMA_VERSION = "2025-06-18"
|
||||
JSONRPC_VERSION = "2.0"
|
||||
|
||||
@@ -43,16 +47,31 @@ def main() -> int:
|
||||
default_schema_file = (
|
||||
Path(__file__).resolve().parent / "schema" / SCHEMA_VERSION / "schema.json"
|
||||
)
|
||||
default_lib_rs = Path(__file__).resolve().parent / "src/lib.rs"
|
||||
parser.add_argument(
|
||||
"schema_file",
|
||||
nargs="?",
|
||||
default=default_schema_file,
|
||||
help="schema.json file to process",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Regenerate lib.rs in a sandbox and ensure the checked-in file matches",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
schema_file = args.schema_file
|
||||
schema_file = Path(args.schema_file)
|
||||
crate_dir = Path(__file__).resolve().parent
|
||||
|
||||
lib_rs = Path(__file__).resolve().parent / "src/lib.rs"
|
||||
if args.check:
|
||||
return run_check(schema_file, crate_dir, default_lib_rs)
|
||||
|
||||
generate_lib_rs(schema_file, default_lib_rs, fmt=True)
|
||||
return 0
|
||||
|
||||
|
||||
def generate_lib_rs(schema_file: Path, lib_rs: Path, fmt: bool) -> None:
|
||||
lib_rs.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
global DEFINITIONS # Allow helper functions to access the schema.
|
||||
|
||||
@@ -117,9 +136,7 @@ fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
|
||||
|
||||
for req_name in CLIENT_REQUEST_TYPE_NAMES:
|
||||
defn = definitions[req_name]
|
||||
method_const = (
|
||||
defn.get("properties", {}).get("method", {}).get("const", req_name)
|
||||
)
|
||||
method_const = defn.get("properties", {}).get("method", {}).get("const", req_name)
|
||||
payload_type = f"<{req_name} as ModelContextProtocolRequest>::Params"
|
||||
try_from_impl_lines.append(f' "{method_const}" => {{\n')
|
||||
try_from_impl_lines.append(
|
||||
@@ -128,9 +145,7 @@ fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
|
||||
try_from_impl_lines.append(
|
||||
f" let params: {payload_type} = serde_json::from_value(params_json)?;\n"
|
||||
)
|
||||
try_from_impl_lines.append(
|
||||
f" Ok(ClientRequest::{req_name}(params))\n"
|
||||
)
|
||||
try_from_impl_lines.append(f" Ok(ClientRequest::{req_name}(params))\n")
|
||||
try_from_impl_lines.append(" },\n")
|
||||
|
||||
try_from_impl_lines.append(
|
||||
@@ -144,9 +159,7 @@ fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
|
||||
|
||||
# Generate TryFrom for ServerNotification
|
||||
notif_impl_lines: list[str] = []
|
||||
notif_impl_lines.append(
|
||||
"impl TryFrom<JSONRPCNotification> for ServerNotification {\n"
|
||||
)
|
||||
notif_impl_lines.append("impl TryFrom<JSONRPCNotification> for ServerNotification {\n")
|
||||
notif_impl_lines.append(" type Error = serde_json::Error;\n")
|
||||
notif_impl_lines.append(
|
||||
" fn try_from(n: JSONRPCNotification) -> std::result::Result<Self, Self::Error> {\n"
|
||||
@@ -155,9 +168,7 @@ fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
|
||||
|
||||
for notif_name in SERVER_NOTIFICATION_TYPE_NAMES:
|
||||
n_def = definitions[notif_name]
|
||||
method_const = (
|
||||
n_def.get("properties", {}).get("method", {}).get("const", notif_name)
|
||||
)
|
||||
method_const = n_def.get("properties", {}).get("method", {}).get("const", notif_name)
|
||||
payload_type = f"<{notif_name} as ModelContextProtocolNotification>::Params"
|
||||
notif_impl_lines.append(f' "{method_const}" => {{\n')
|
||||
# params may be optional
|
||||
@@ -167,9 +178,7 @@ fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
|
||||
notif_impl_lines.append(
|
||||
f" let params: {payload_type} = serde_json::from_value(params_json)?;\n"
|
||||
)
|
||||
notif_impl_lines.append(
|
||||
f" Ok(ServerNotification::{notif_name}(params))\n"
|
||||
)
|
||||
notif_impl_lines.append(f" Ok(ServerNotification::{notif_name}(params))\n")
|
||||
notif_impl_lines.append(" },\n")
|
||||
|
||||
notif_impl_lines.append(
|
||||
@@ -185,13 +194,70 @@ fn default_jsonrpc() -> String {{ JSONRPC_VERSION.to_owned() }}
|
||||
for chunk in out:
|
||||
f.write(chunk)
|
||||
|
||||
subprocess.check_call(
|
||||
["cargo", "fmt", "--", "--config", "imports_granularity=Item"],
|
||||
cwd=lib_rs.parent.parent,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if fmt:
|
||||
subprocess.check_call(
|
||||
["cargo", "fmt", "--", "--config", "imports_granularity=Item"],
|
||||
cwd=lib_rs.parent.parent,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
def run_check(schema_file: Path, crate_dir: Path, checked_in_lib: Path) -> int:
|
||||
config_path = crate_dir.parent / "rustfmt.toml"
|
||||
eprint(f"Running --check with schema {schema_file}")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
eprint(f"Created temporary workspace at {tmp_path}")
|
||||
manifest_path = tmp_path / "Cargo.toml"
|
||||
eprint(f"Copying Cargo.toml into {manifest_path}")
|
||||
copy2(crate_dir / "Cargo.toml", manifest_path)
|
||||
manifest_text = manifest_path.read_text(encoding="utf-8")
|
||||
manifest_text = manifest_text.replace(
|
||||
"version = { workspace = true }",
|
||||
'version = "0.0.0"',
|
||||
)
|
||||
manifest_text = manifest_text.replace("\n[lints]\nworkspace = true\n", "\n")
|
||||
manifest_path.write_text(manifest_text, encoding="utf-8")
|
||||
src_dir = tmp_path / "src"
|
||||
src_dir.mkdir(parents=True, exist_ok=True)
|
||||
eprint(f"Generating lib.rs into {src_dir}")
|
||||
generated_lib = src_dir / "lib.rs"
|
||||
|
||||
generate_lib_rs(schema_file, generated_lib, fmt=False)
|
||||
|
||||
eprint("Formatting generated lib.rs with rustfmt")
|
||||
subprocess.check_call(
|
||||
[
|
||||
"rustfmt",
|
||||
"--config-path",
|
||||
str(config_path),
|
||||
str(generated_lib),
|
||||
],
|
||||
cwd=tmp_path,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
eprint("Comparing generated lib.rs with checked-in version")
|
||||
checked_in_contents = checked_in_lib.read_text(encoding="utf-8")
|
||||
generated_contents = generated_lib.read_text(encoding="utf-8")
|
||||
|
||||
if checked_in_contents == generated_contents:
|
||||
eprint("lib.rs matches checked-in version")
|
||||
return 0
|
||||
|
||||
diff = unified_diff(
|
||||
checked_in_contents.splitlines(keepends=True),
|
||||
generated_contents.splitlines(keepends=True),
|
||||
fromfile=str(checked_in_lib),
|
||||
tofile=str(generated_lib),
|
||||
)
|
||||
diff_text = "".join(diff)
|
||||
eprint("Generated lib.rs does not match the checked-in version. Diff:")
|
||||
if diff_text:
|
||||
eprint(diff_text, end="")
|
||||
eprint("Re-run generate_mcp_types.py without --check to update src/lib.rs.")
|
||||
return 1
|
||||
|
||||
|
||||
def add_definition(name: str, definition: dict[str, Any], out: list[str]) -> None:
|
||||
@@ -421,15 +487,11 @@ def define_untagged_enum(name: str, type_list: list[str], out: list[str]) -> Non
|
||||
case "integer":
|
||||
out.append(" Integer(i64),\n")
|
||||
case _:
|
||||
raise ValueError(
|
||||
f"Unknown type in untagged enum: {simple_type} in {name}"
|
||||
)
|
||||
raise ValueError(f"Unknown type in untagged enum: {simple_type} in {name}")
|
||||
out.append("}\n\n")
|
||||
|
||||
|
||||
def define_any_of(
|
||||
name: str, list_of_refs: list[Any], description: str | None = None
|
||||
) -> list[str]:
|
||||
def define_any_of(name: str, list_of_refs: list[Any], description: str | None = None) -> list[str]:
|
||||
"""Generate a Rust enum for a JSON-Schema `anyOf` union.
|
||||
|
||||
For most types we simply map each `$ref` inside the `anyOf` list to a
|
||||
@@ -494,9 +556,7 @@ def define_any_of(
|
||||
if name == "ClientRequest":
|
||||
payload_type = f"<{ref_name} as ModelContextProtocolRequest>::Params"
|
||||
else:
|
||||
payload_type = (
|
||||
f"<{ref_name} as ModelContextProtocolNotification>::Params"
|
||||
)
|
||||
payload_type = f"<{ref_name} as ModelContextProtocolNotification>::Params"
|
||||
|
||||
# Determine the wire value for `method` so we can annotate the
|
||||
# variant appropriately. If for some reason the schema does not
|
||||
@@ -504,9 +564,7 @@ def define_any_of(
|
||||
# least compile (although deserialization will likely fail).
|
||||
request_def = DEFINITIONS.get(ref_name, {})
|
||||
method_const = (
|
||||
request_def.get("properties", {})
|
||||
.get("method", {})
|
||||
.get("const", ref_name)
|
||||
request_def.get("properties", {}).get("method", {}).get("const", ref_name)
|
||||
)
|
||||
|
||||
out.append(f' #[serde(rename = "{method_const}")]\n')
|
||||
@@ -556,7 +614,7 @@ def map_type(
|
||||
if type_prop == "string":
|
||||
if const_prop := typedef.get("const", None):
|
||||
assert isinstance(const_prop, str)
|
||||
return f'&\'static str = "{const_prop }"'
|
||||
return f'&\'static str = "{const_prop}"'
|
||||
else:
|
||||
return "String"
|
||||
elif type_prop == "integer":
|
||||
@@ -632,7 +690,7 @@ def rust_prop_name(name: str, is_optional: bool) -> RustProp:
|
||||
serde_annotations.append('skip_serializing_if = "Option::is_none"')
|
||||
|
||||
if serde_annotations:
|
||||
serde_str = f'#[serde({", ".join(serde_annotations)})]'
|
||||
serde_str = f"#[serde({', '.join(serde_annotations)})]"
|
||||
else:
|
||||
serde_str = None
|
||||
return RustProp(prop_name, serde_str)
|
||||
@@ -640,9 +698,7 @@ def rust_prop_name(name: str, is_optional: bool) -> RustProp:
|
||||
|
||||
def to_snake_case(name: str) -> str:
|
||||
"""Convert a camelCase or PascalCase name to snake_case."""
|
||||
snake_case = name[0].lower() + "".join(
|
||||
"_" + c.lower() if c.isupper() else c for c in name[1:]
|
||||
)
|
||||
snake_case = name[0].lower() + "".join("_" + c.lower() if c.isupper() else c for c in name[1:])
|
||||
if snake_case != name:
|
||||
return snake_case
|
||||
else:
|
||||
@@ -678,5 +734,9 @@ def emit_doc_comment(text: str | None, out: list[str]) -> None:
|
||||
out.append(f"/// {line.rstrip()}\n")
|
||||
|
||||
|
||||
def eprint(*args: Any, **kwargs: Any) -> None:
|
||||
print(*args, file=sys.stderr, **kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -31,6 +31,8 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginApiKeyParams::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginApiKeyResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?;
|
||||
codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?;
|
||||
|
||||
@@ -126,6 +126,11 @@ pub enum ClientRequest {
|
||||
request_id: RequestId,
|
||||
params: GitDiffToRemoteParams,
|
||||
},
|
||||
LoginApiKey {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
params: LoginApiKeyParams,
|
||||
},
|
||||
LoginChatGpt {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
@@ -288,6 +293,16 @@ pub struct ArchiveConversationResponse {}
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoveConversationSubscriptionResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginApiKeyParams {
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginApiKeyResponse {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LoginChatGptResponse {
|
||||
@@ -367,9 +382,14 @@ pub struct ExecArbitraryCommandResponse {
|
||||
pub struct GetAuthStatusResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_method: Option<AuthMode>,
|
||||
pub preferred_auth_method: AuthMode,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_token: Option<String>,
|
||||
|
||||
// Indicates that auth method must be valid to use the server.
|
||||
// This can be false if using a custom provider that is configured
|
||||
// with requires_openai_auth == false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requires_openai_auth: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)]
|
||||
|
||||
65
codex-rs/tui/src/admin_prompt.rs
Normal file
65
codex-rs/tui/src/admin_prompt.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
use codex_core::config::Config;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Write;
|
||||
use std::io::{self};
|
||||
|
||||
pub fn prompt_for_admin_danger_reason(config: &Config) -> std::io::Result<Option<String>> {
|
||||
let Some(prompt) = config.admin_danger_prompt.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if !prompt.needs_prompt() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(io::Error::other(
|
||||
"Administrator requires a justification for dangerous sandbox usage, but stdin is not interactive.",
|
||||
));
|
||||
}
|
||||
|
||||
let red = "\x1b[31m";
|
||||
let reset = "\x1b[0m";
|
||||
let blue = "\x1b[34m";
|
||||
let mut stderr = io::stderr();
|
||||
writeln!(stderr)?;
|
||||
writeln!(
|
||||
stderr,
|
||||
"{red}╔══════════════════════════════════════════════════════════════╗{reset}"
|
||||
)?;
|
||||
writeln!(
|
||||
stderr,
|
||||
"{red}║ DANGEROUS USAGE WARNING ║{reset}"
|
||||
)?;
|
||||
writeln!(
|
||||
stderr,
|
||||
"{red}╚══════════════════════════════════════════════════════════════╝{reset}"
|
||||
)?;
|
||||
for line in codex_core::admin_controls::ADMIN_DANGEROUS_SANDBOX_DISABLED_PROMPT_LINES {
|
||||
writeln!(stderr, "{red}{line}{reset}")?;
|
||||
}
|
||||
writeln!(stderr)?;
|
||||
write!(
|
||||
stderr,
|
||||
"{red}?{reset} {blue}Or provide a justification to continue anyway:{reset} "
|
||||
)?;
|
||||
stderr.flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let justification = input.trim();
|
||||
if justification.is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Justification is required by your administrator to proceed with dangerous usage.",
|
||||
));
|
||||
}
|
||||
if justification.len() < 4 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"Justification must be at least 4 characters long and is required by your administrator to proceed with dangerous usage.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(justification.to_string()))
|
||||
}
|
||||
@@ -8,9 +8,11 @@ use codex_core::AuthManager;
|
||||
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_core::config::AdminAuditContext;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ConfigToml;
|
||||
use codex_core::config::audit_admin_run_with_prompt;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
@@ -25,6 +27,7 @@ use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
mod admin_prompt;
|
||||
mod app;
|
||||
mod app_backtrack;
|
||||
mod app_event;
|
||||
@@ -67,6 +70,7 @@ mod updates;
|
||||
|
||||
pub use cli::Cli;
|
||||
|
||||
use crate::admin_prompt::prompt_for_admin_danger_reason;
|
||||
use crate::onboarding::TrustDirectorySelection;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
use crate::onboarding::onboarding_screen::run_onboarding_app;
|
||||
@@ -154,6 +158,24 @@ pub async fn run_main(
|
||||
}
|
||||
};
|
||||
|
||||
let admin_audit_context = AdminAuditContext {
|
||||
sandbox_policy: &config.sandbox_policy,
|
||||
approval_policy: &config.approval_policy,
|
||||
cwd: &config.cwd,
|
||||
command: None,
|
||||
dangerously_bypass_requested: cli.dangerously_bypass_approvals_and_sandbox,
|
||||
dangerous_mode_justification: None,
|
||||
record_command_event: false,
|
||||
};
|
||||
|
||||
let _admin_override_reason = audit_admin_run_with_prompt(
|
||||
&config.admin_controls,
|
||||
admin_audit_context,
|
||||
prompt_for_admin_danger_reason(&config),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))?;
|
||||
|
||||
// we load config.toml here to determine project state.
|
||||
#[allow(clippy::print_stderr)]
|
||||
let config_toml = {
|
||||
@@ -308,7 +330,7 @@ async fn run_ratatui_app(
|
||||
..
|
||||
} = cli;
|
||||
|
||||
let auth_manager = AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method);
|
||||
let auth_manager = AuthManager::shared(config.codex_home.clone());
|
||||
let login_status = get_login_status(&config);
|
||||
let should_show_onboarding =
|
||||
should_show_onboarding(login_status, &config, should_show_trust_screen);
|
||||
@@ -392,7 +414,7 @@ fn get_login_status(config: &Config) -> LoginStatus {
|
||||
// Reading the OpenAI API key is an async operation because it may need
|
||||
// to refresh the token. Block on it.
|
||||
let codex_home = config.codex_home.clone();
|
||||
match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) {
|
||||
match CodexAuth::from_codex_home(&codex_home) {
|
||||
Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode),
|
||||
Ok(None) => LoginStatus::NotAuthenticated,
|
||||
Err(err) => {
|
||||
@@ -460,60 +482,28 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
|
||||
return false;
|
||||
}
|
||||
|
||||
match login_status {
|
||||
LoginStatus::NotAuthenticated => true,
|
||||
LoginStatus::AuthMode(method) => method != config.preferred_auth_method,
|
||||
}
|
||||
login_status == LoginStatus::NotAuthenticated
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_config(preferred: AuthMode) -> Config {
|
||||
let mut cfg = Config::load_from_base_config_with_overrides(
|
||||
fn make_config() -> Config {
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
std::env::temp_dir(),
|
||||
)
|
||||
.expect("load default config");
|
||||
cfg.preferred_auth_method = preferred;
|
||||
cfg
|
||||
.expect("load default config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_not_authenticated() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
let cfg = make_config();
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::NotAuthenticated,
|
||||
&cfg
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shows_login_when_api_key_but_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_api_key_and_prefers_api_key() {
|
||||
let cfg = make_config(AuthMode::ApiKey);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ApiKey),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hides_login_when_chatgpt_and_prefers_chatgpt() {
|
||||
let cfg = make_config(AuthMode::ChatGPT);
|
||||
assert!(!should_show_login_screen(
|
||||
LoginStatus::AuthMode(AuthMode::ChatGPT),
|
||||
&cfg
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::auth::CLIENT_ID;
|
||||
use codex_core::auth::login_with_api_key;
|
||||
use codex_core::auth::read_openai_api_key_from_env;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::ShutdownHandle;
|
||||
use codex_login::run_login_server;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Color;
|
||||
@@ -15,6 +20,9 @@ use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Block;
|
||||
use ratatui::widgets::BorderType;
|
||||
use ratatui::widgets::Borders;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
@@ -38,8 +46,14 @@ pub(crate) enum SignInState {
|
||||
ChatGptContinueInBrowser(ContinueInBrowserState),
|
||||
ChatGptSuccessMessage,
|
||||
ChatGptSuccess,
|
||||
EnvVarMissing,
|
||||
EnvVarFound,
|
||||
ApiKeyEntry(ApiKeyInputState),
|
||||
ApiKeyConfigured,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct ApiKeyInputState {
|
||||
value: String,
|
||||
prepopulated_from_env: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -59,6 +73,10 @@ impl Drop for ContinueInBrowserState {
|
||||
|
||||
impl KeyboardHandler for AuthModeWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) {
|
||||
if self.handle_api_key_entry_key_event(&key_event) {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.highlighted_mode = AuthMode::ChatGPT;
|
||||
@@ -69,7 +87,7 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
KeyCode::Char('1') => {
|
||||
self.start_chatgpt_login();
|
||||
}
|
||||
KeyCode::Char('2') => self.verify_api_key(),
|
||||
KeyCode::Char('2') => self.start_api_key_entry(),
|
||||
KeyCode::Enter => {
|
||||
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
|
||||
match sign_in_state {
|
||||
@@ -78,12 +96,9 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
self.start_chatgpt_login();
|
||||
}
|
||||
AuthMode::ApiKey => {
|
||||
self.verify_api_key();
|
||||
self.start_api_key_entry();
|
||||
}
|
||||
},
|
||||
SignInState::EnvVarMissing => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
|
||||
}
|
||||
SignInState::ChatGptSuccessMessage => {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
|
||||
}
|
||||
@@ -101,6 +116,10 @@ impl KeyboardHandler for AuthModeWidget {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
let _ = self.handle_api_key_entry_paste(pasted);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -111,7 +130,6 @@ pub(crate) struct AuthModeWidget {
|
||||
pub sign_in_state: Arc<RwLock<SignInState>>,
|
||||
pub codex_home: PathBuf,
|
||||
pub login_status: LoginStatus,
|
||||
pub preferred_auth_method: AuthMode,
|
||||
pub auth_manager: Arc<AuthManager>,
|
||||
}
|
||||
|
||||
@@ -129,24 +147,6 @@ impl AuthModeWidget {
|
||||
"".into(),
|
||||
];
|
||||
|
||||
// If the user is already authenticated but the method differs from their
|
||||
// preferred auth method, show a brief explanation.
|
||||
if let LoginStatus::AuthMode(current) = self.login_status
|
||||
&& current != self.preferred_auth_method
|
||||
{
|
||||
let to_label = |mode: AuthMode| match mode {
|
||||
AuthMode::ApiKey => "API key",
|
||||
AuthMode::ChatGPT => "ChatGPT",
|
||||
};
|
||||
let msg = format!(
|
||||
" You’re currently using {} while your preferred method is {}.",
|
||||
to_label(current),
|
||||
to_label(self.preferred_auth_method)
|
||||
);
|
||||
lines.push(msg.into());
|
||||
lines.push("".into());
|
||||
}
|
||||
|
||||
let create_mode_item = |idx: usize,
|
||||
selected_mode: AuthMode,
|
||||
text: &str,
|
||||
@@ -175,29 +175,17 @@ impl AuthModeWidget {
|
||||
|
||||
vec![line1, line2]
|
||||
};
|
||||
let chatgpt_label = if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT))
|
||||
{
|
||||
"Continue using ChatGPT"
|
||||
} else {
|
||||
"Sign in with ChatGPT"
|
||||
};
|
||||
|
||||
lines.extend(create_mode_item(
|
||||
0,
|
||||
AuthMode::ChatGPT,
|
||||
chatgpt_label,
|
||||
"Sign in with ChatGPT",
|
||||
"Usage included with Plus, Pro, and Team plans",
|
||||
));
|
||||
let api_key_label = if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey))
|
||||
{
|
||||
"Continue using API key"
|
||||
} else {
|
||||
"Provide your own API key"
|
||||
};
|
||||
lines.extend(create_mode_item(
|
||||
1,
|
||||
AuthMode::ApiKey,
|
||||
api_key_label,
|
||||
"Provide your own API key",
|
||||
"Pay for what you use",
|
||||
));
|
||||
lines.push("".into());
|
||||
@@ -282,26 +270,213 @@ impl AuthModeWidget {
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec!["✓ Using OPENAI_API_KEY".fg(Color::Green).into()];
|
||||
fn render_api_key_configured(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
"✓ API key configured".fg(Color::Green).into(),
|
||||
"".into(),
|
||||
" Codex will use usage-based billing with your API key.".into(),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
" To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment"
|
||||
.fg(Color::Cyan)
|
||||
.into(),
|
||||
"".into(),
|
||||
" Press Enter to return".dim().into(),
|
||||
];
|
||||
fn render_api_key_entry(&self, area: Rect, buf: &mut Buffer, state: &ApiKeyInputState) {
|
||||
let [intro_area, input_area, footer_area] = Layout::vertical([
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(2),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
Paragraph::new(lines)
|
||||
let mut intro_lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
"> ".into(),
|
||||
"Use your own OpenAI API key for usage-based billing".bold(),
|
||||
]),
|
||||
"".into(),
|
||||
" Paste or type your API key below. It will be stored locally in auth.json.".into(),
|
||||
"".into(),
|
||||
];
|
||||
if state.prepopulated_from_env {
|
||||
intro_lines.push(" Detected OPENAI_API_KEY environment variable.".into());
|
||||
intro_lines.push(
|
||||
" Paste a different key if you prefer to use another account."
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
intro_lines.push("".into());
|
||||
}
|
||||
Paragraph::new(intro_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
.render(intro_area, buf);
|
||||
|
||||
let content_line: Line = if state.value.is_empty() {
|
||||
vec!["Paste or type your API key".dim()].into()
|
||||
} else {
|
||||
Line::from(state.value.clone())
|
||||
};
|
||||
Paragraph::new(content_line)
|
||||
.wrap(Wrap { trim: false })
|
||||
.block(
|
||||
Block::default()
|
||||
.title("API key")
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(Color::Cyan)),
|
||||
)
|
||||
.render(input_area, buf);
|
||||
|
||||
let mut footer_lines: Vec<Line> = vec![
|
||||
" Press Enter to save".dim().into(),
|
||||
" Press Esc to go back".dim().into(),
|
||||
];
|
||||
if let Some(error) = &self.error {
|
||||
footer_lines.push("".into());
|
||||
footer_lines.push(error.as_str().red().into());
|
||||
}
|
||||
Paragraph::new(footer_lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(footer_area, buf);
|
||||
}
|
||||
|
||||
fn handle_api_key_entry_key_event(&mut self, key_event: &KeyEvent) -> bool {
|
||||
let mut should_save: Option<String> = None;
|
||||
let mut should_request_frame = false;
|
||||
|
||||
{
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(state) = &mut *guard {
|
||||
match key_event.code {
|
||||
KeyCode::Esc => {
|
||||
*guard = SignInState::PickMode;
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let trimmed = state.value.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
self.error = Some("API key cannot be empty".to_string());
|
||||
should_request_frame = true;
|
||||
} else {
|
||||
should_save = Some(trimmed);
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
} else {
|
||||
state.value.pop();
|
||||
}
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if !key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
if state.prepopulated_from_env {
|
||||
state.value.clear();
|
||||
state.prepopulated_from_env = false;
|
||||
}
|
||||
state.value.push(c);
|
||||
self.error = None;
|
||||
should_request_frame = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// handled; let guard drop before potential save
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(api_key) = should_save {
|
||||
self.save_api_key(api_key);
|
||||
} else if should_request_frame {
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_api_key_entry_paste(&mut self, pasted: String) -> bool {
|
||||
let trimmed = pasted.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(state) = &mut *guard {
|
||||
if state.prepopulated_from_env {
|
||||
state.value = trimmed.to_string();
|
||||
state.prepopulated_from_env = false;
|
||||
} else {
|
||||
state.value.push_str(trimmed);
|
||||
}
|
||||
self.error = None;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
drop(guard);
|
||||
self.request_frame.schedule_frame();
|
||||
true
|
||||
}
|
||||
|
||||
fn start_api_key_entry(&mut self) {
|
||||
self.error = None;
|
||||
let prefill_from_env = read_openai_api_key_from_env();
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
match &mut *guard {
|
||||
SignInState::ApiKeyEntry(state) => {
|
||||
if state.value.is_empty() {
|
||||
if let Some(prefill) = prefill_from_env.clone() {
|
||||
state.value = prefill;
|
||||
state.prepopulated_from_env = true;
|
||||
} else {
|
||||
state.prepopulated_from_env = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
|
||||
value: prefill_from_env.clone().unwrap_or_default(),
|
||||
prepopulated_from_env: prefill_from_env.is_some(),
|
||||
});
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn save_api_key(&mut self, api_key: String) {
|
||||
match login_with_api_key(&self.codex_home, &api_key) {
|
||||
Ok(()) => {
|
||||
self.error = None;
|
||||
self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey);
|
||||
self.auth_manager.reload();
|
||||
*self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured;
|
||||
}
|
||||
Err(err) => {
|
||||
self.error = Some(format!("Failed to save API key: {err}"));
|
||||
let mut guard = self.sign_in_state.write().unwrap();
|
||||
if let SignInState::ApiKeyEntry(existing) = &mut *guard {
|
||||
if existing.value.is_empty() {
|
||||
existing.value.push_str(&api_key);
|
||||
}
|
||||
existing.prepopulated_from_env = false;
|
||||
} else {
|
||||
*guard = SignInState::ApiKeyEntry(ApiKeyInputState {
|
||||
value: api_key,
|
||||
prepopulated_from_env: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn start_chatgpt_login(&mut self) {
|
||||
@@ -354,18 +529,6 @@ impl AuthModeWidget {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
|
||||
fn verify_api_key(&mut self) {
|
||||
if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) {
|
||||
// We already have an API key configured (e.g., from auth.json or env),
|
||||
// so mark this step complete immediately.
|
||||
*self.sign_in_state.write().unwrap() = SignInState::EnvVarFound;
|
||||
} else {
|
||||
*self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing;
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for AuthModeWidget {
|
||||
@@ -373,10 +536,10 @@ impl StepStateProvider for AuthModeWidget {
|
||||
let sign_in_state = self.sign_in_state.read().unwrap();
|
||||
match &*sign_in_state {
|
||||
SignInState::PickMode
|
||||
| SignInState::EnvVarMissing
|
||||
| SignInState::ApiKeyEntry(_)
|
||||
| SignInState::ChatGptContinueInBrowser(_)
|
||||
| SignInState::ChatGptSuccessMessage => StepState::InProgress,
|
||||
SignInState::ChatGptSuccess | SignInState::EnvVarFound => StepState::Complete,
|
||||
SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,11 +560,11 @@ impl WidgetRef for AuthModeWidget {
|
||||
SignInState::ChatGptSuccess => {
|
||||
self.render_chatgpt_success(area, buf);
|
||||
}
|
||||
SignInState::EnvVarMissing => {
|
||||
self.render_env_var_missing(area, buf);
|
||||
SignInState::ApiKeyEntry(state) => {
|
||||
self.render_api_key_entry(area, buf, state);
|
||||
}
|
||||
SignInState::EnvVarFound => {
|
||||
self.render_env_var_found(area, buf);
|
||||
SignInState::ApiKeyConfigured => {
|
||||
self.render_api_key_configured(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ enum Step {
|
||||
|
||||
pub(crate) trait KeyboardHandler {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent);
|
||||
fn handle_paste(&mut self, _pasted: String) {}
|
||||
}
|
||||
|
||||
pub(crate) enum StepState {
|
||||
@@ -69,7 +70,6 @@ impl OnboardingScreen {
|
||||
auth_manager,
|
||||
config,
|
||||
} = args;
|
||||
let preferred_auth_method = config.preferred_auth_method;
|
||||
let cwd = config.cwd.clone();
|
||||
let codex_home = config.codex_home.clone();
|
||||
let mut steps: Vec<Step> = vec![Step::Welcome(WelcomeWidget {
|
||||
@@ -84,7 +84,6 @@ impl OnboardingScreen {
|
||||
codex_home: codex_home.clone(),
|
||||
login_status,
|
||||
auth_manager,
|
||||
preferred_auth_method,
|
||||
}))
|
||||
}
|
||||
let is_git_repo = get_git_repo_root(&cwd).is_some();
|
||||
@@ -194,6 +193,17 @@ impl KeyboardHandler for OnboardingScreen {
|
||||
};
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
if pasted.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(active_step) = self.current_steps_mut().into_iter().last() {
|
||||
active_step.handle_paste(pasted);
|
||||
}
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &OnboardingScreen {
|
||||
@@ -263,6 +273,14 @@ impl KeyboardHandler for Step {
|
||||
Step::TrustDirectory(widget) => widget.handle_key_event(key_event),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, pasted: String) {
|
||||
match self {
|
||||
Step::Welcome(_) => {}
|
||||
Step::Auth(widget) => widget.handle_paste(pasted),
|
||||
Step::TrustDirectory(widget) => widget.handle_paste(pasted),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StepStateProvider for Step {
|
||||
@@ -312,12 +330,14 @@ pub(crate) async fn run_onboarding_app(
|
||||
TuiEvent::Key(key_event) => {
|
||||
onboarding_screen.handle_key_event(key_event);
|
||||
}
|
||||
TuiEvent::Paste(text) => {
|
||||
onboarding_screen.handle_paste(text);
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
let _ = tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&onboarding_screen, frame.area());
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ Run Codex head-less in pipelines. Example GitHub Action step:
|
||||
- name: Update changelog via Codex
|
||||
run: |
|
||||
npm install -g @openai/codex
|
||||
export OPENAI_API_KEY="${{ secrets.OPENAI_KEY }}"
|
||||
codex login --api-key "${{ secrets.OPENAI_KEY }}"
|
||||
codex exec --full-auto "update CHANGELOG for next release"
|
||||
```
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
## Usage-based billing alternative: Use an OpenAI API key
|
||||
|
||||
If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API key by setting it as an environment variable:
|
||||
If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API key:
|
||||
|
||||
```shell
|
||||
export OPENAI_API_KEY="your-api-key-here"
|
||||
codex login --api-key "your-api-key-here"
|
||||
```
|
||||
|
||||
This key must, at minimum, have write access to the Responses API.
|
||||
@@ -18,36 +18,6 @@ If you've used the Codex CLI before with usage-based billing via an API key and
|
||||
2. Delete `~/.codex/auth.json` (on Windows: `C:\\Users\\USERNAME\\.codex\\auth.json`)
|
||||
3. Run `codex login` again
|
||||
|
||||
## Forcing a specific auth method (advanced)
|
||||
|
||||
You can explicitly choose which authentication Codex should prefer when both are available.
|
||||
|
||||
- To always use your API key (even when ChatGPT auth exists), set:
|
||||
|
||||
```toml
|
||||
# ~/.codex/config.toml
|
||||
preferred_auth_method = "apikey"
|
||||
```
|
||||
|
||||
Or override ad-hoc via CLI:
|
||||
|
||||
```bash
|
||||
codex --config preferred_auth_method="apikey"
|
||||
```
|
||||
|
||||
- To prefer ChatGPT auth (default), set:
|
||||
|
||||
```toml
|
||||
# ~/.codex/config.toml
|
||||
preferred_auth_method = "chatgpt"
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- When `preferred_auth_method = "apikey"` and an API key is available, the login screen is skipped.
|
||||
- When `preferred_auth_method = "chatgpt"` (default), Codex prefers ChatGPT auth if present; if only an API key is present, it will use the API key. Certain account types may also require API-key mode.
|
||||
- To check which auth method is being used during a session, use the `/status` command in the TUI.
|
||||
|
||||
## Connecting on a "Headless" Machine
|
||||
|
||||
Today, the login process entails running a server on `localhost:1455`. If you are on a "headless" server, such as a Docker container or are `ssh`'d into a remote machine, loading `localhost:1455` in the browser on your local machine will not automatically connect to the webserver running on the _headless_ machine, so you must use one of the following workarounds:
|
||||
|
||||
@@ -612,5 +612,4 @@ Options that are specific to the TUI.
|
||||
| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
|
||||
| `responses_originator_header_internal_override` | string | Override `originator` header value. |
|
||||
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
|
||||
| `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). |
|
||||
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
|
||||
|
||||
Reference in New Issue
Block a user