Compare commits

..

17 Commits

Author SHA1 Message Date
Fouad Matin
2c3874d5fd fix 2025-09-11 11:16:46 -07:00
Fouad Matin
23b4a8c027 fix 2025-09-11 11:12:02 -07:00
Fouad Matin
1bafe46731 fix 2025-09-11 10:29:46 -07:00
Fouad Matin
3e773fbcfc Merge branch 'main' into add-enterprise-config 2025-09-11 10:17:46 -07:00
jif-oai
114ce9ff4d NIT unified exec (#3479)
Fix the default value of the experimental flag of unified_exec
2025-09-11 16:19:12 +00:00
Eric Traut
e13b35ecb0 Simplify auth flow and reconcile differences between ChatGPT and API Key auth (#3189)
This PR does the following:
* Adds the ability to paste or type an API key.
* Removes the `preferred_auth_method` config option. The last login
method is always persisted in auth.json, so this isn't needed.
* If OPENAI_API_KEY env variable is defined, the value is used to
prepopulate the new UI. The env variable is otherwise ignored by the
CLI.
* Adds a new MCP server entry point "login_api_key" so we can implement
this same API key behavior for the VS Code extension.
<img width="473" height="140" alt="Screenshot 2025-09-04 at 3 51 04 PM"
src="https://github.com/user-attachments/assets/c11bbd5b-8a4d-4d71-90fd-34130460f9d9"
/>
<img width="726" height="254" alt="Screenshot 2025-09-04 at 3 51 32 PM"
src="https://github.com/user-attachments/assets/6cc76b34-309a-4387-acbc-15ee5c756db9"
/>
2025-09-11 09:16:34 -07:00
Jeremy Rose
377af75730 apply-patch: sort replacements and add regression tests (#3425)
- Ensure replacements are applied in index order for determinism.
- Add tests for addition chunk followed by removal and worktree-aware
helper.

This fixes a panic I observed.

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
2025-09-11 09:07:03 -07:00
Michael Bolin
86e0f31a7e chore: rust-release.yml should update the latest-alpha-cli branch (#3458)
This updates `rust-release.yml` so that the last step of creating a
release entails updating the `latest-alpha-cli` branch to point to the
tag used to create the latest release. This will facilitate building
automation to identify the most recent alpha release of Codex CLI
(though note this branch could also point to an official release, as it
is implemented today).

This introduces a new job, `update-branch`, which depends on the
`release` job. I made it separate from the `release` job because
`update-branch` needs the `contents: write` permission, so this limits
the amount of work we do with that permission.

Note I also created a branch protection rule for `latest-alpha-cli`
that:

- specifies repository admins as the only members of the bypass list
- only those with bypass permissions can create, update, or delete this
branch
- this branch requires a linear history
- note that force pushes _are_ allowed

This is the first step in fixing
https://github.com/openai/codex/issues/3098.
2025-09-11 08:06:28 -07:00
Michael Bolin
8f837f1093 fix: add check to ensure output of generate_mcp_types.py matches codex-rs/mcp-types/src/lib.rs (#3450)
As a follow-up to https://github.com/openai/codex/pull/3439, this adds a
CI job to ensure the codegen script has to be updated in order to change
`codex-rs/mcp-types/src/lib.rs`.
2025-09-10 23:31:28 -07:00
Fouad Matin
82982f1466 Merge branch 'main' into add-enterprise-config 2025-09-10 13:53:02 -07:00
Fouad Matin
8d9369a650 add: log file 2025-09-10 01:28:41 -07:00
Fouad Matin
4a6a63d6c4 fix: PR feedback 2025-09-09 17:55:51 -07:00
Fouad Matin
6e772e993a - 2025-09-09 15:55:29 -07:00
Fouad Matin
848bc6c94a Merge branch 'main' into add-enterprise-config 2025-09-05 16:59:10 -07:00
Fouad Matin
eab3d8e328 - 2025-09-04 22:25:35 -07:00
Fouad Matin
609d69d3ba - 2025-09-04 13:13:42 -07:00
Fouad Matin
dd2c9ed676 add: admin config 2025-09-04 10:37:31 -07:00
43 changed files with 1773 additions and 766 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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"

View File

@@ -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

View File

@@ -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"] }

View File

@@ -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?;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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"] }

View 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: &timestamp_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(())
}

View File

@@ -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

View File

@@ -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: &params.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 perturn 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, ..

View File

@@ -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);

View 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)
}

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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.");
}
}

View File

@@ -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(),

View File

@@ -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",
);
}

View File

@@ -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
View 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

View File

@@ -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,

View File

@@ -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, &params.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 {

View File

@@ -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.

View File

@@ -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);

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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

View 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())

View File

@@ -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())

View File

@@ -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)?;

View File

@@ -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)]

View 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()))
}

View File

@@ -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
))
}
}

View File

@@ -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!(
" Youre 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);
}
}
}

View File

@@ -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());
});
}
_ => {}
}
}
}

View File

@@ -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"
```

View File

@@ -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:

View File

@@ -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). |