Compare commits

..

2 Commits

Author SHA1 Message Date
celia-oai
41a653fd81 fix tests 2026-03-20 16:59:57 -07:00
celia-oai
703ed17fb6 changes 2026-03-20 16:29:57 -07:00
26 changed files with 321 additions and 749 deletions

10
codex-rs/Cargo.lock generated
View File

@@ -1572,7 +1572,6 @@ version = "0.0.0"
dependencies = [
"anyhow",
"codex-apply-patch",
"codex-fs-ops",
"codex-linux-sandbox",
"codex-shell-escalation",
"codex-utils-home-dir",
@@ -1864,7 +1863,6 @@ dependencies = [
"codex-execpolicy",
"codex-features",
"codex-file-search",
"codex-fs-ops",
"codex-git",
"codex-hooks",
"codex-login",
@@ -2121,14 +2119,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-fs-ops"
version = "0.0.0"
dependencies = [
"pretty_assertions",
"tempfile",
]
[[package]]
name = "codex-git"
version = "0.0.0"

View File

@@ -10,6 +10,8 @@ members = [
"debug-client",
"apply-patch",
"arg0",
"feedback",
"features",
"codex-backend-openapi-models",
"cloud-requirements",
"cloud-tasks",
@@ -27,9 +29,6 @@ members = [
"exec-server",
"execpolicy",
"execpolicy-legacy",
"feedback",
"features",
"fs-ops",
"keyring-store",
"file-search",
"linux-sandbox",
@@ -112,10 +111,9 @@ codex-exec = { path = "exec" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-features = { path = "features" }
codex-feedback = { path = "feedback" }
codex-features = { path = "features" }
codex-file-search = { path = "file-search" }
codex-fs-ops = { path = "fs-ops" }
codex-git = { path = "utils/git" }
codex-hooks = { path = "hooks" }
codex-keyring-store = { path = "keyring-store" }

View File

@@ -14,7 +14,6 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
codex-apply-patch = { workspace = true }
codex-fs-ops = { workspace = true }
codex-linux-sandbox = { workspace = true }
codex-shell-escalation = { workspace = true }
codex-utils-home-dir = { workspace = true }

View File

@@ -4,7 +4,6 @@ use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
use codex_utils_home_dir::find_codex_home;
#[cfg(unix)]
use std::os::unix::fs::symlink;
@@ -106,12 +105,6 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
};
std::process::exit(exit_code);
}
if argv1 == CODEX_CORE_FS_OPS_ARG1 {
let mut stdin = std::io::stdin();
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
codex_fs_ops::run_from_args_and_exit(args, &mut stdin, &mut stdout, &mut stderr);
}
// This modifies the environment, which is not thread-safe, so do this
// before creating any threads/the Tokio runtime.

View File

@@ -883,6 +883,24 @@ mod tests {
account_id: Option<&str>,
access_token: &str,
refresh_token: &str,
) -> serde_json::Value {
chatgpt_auth_json_with_last_refresh(
plan_type,
chatgpt_user_id,
account_id,
access_token,
refresh_token,
"2025-01-01T00:00:00Z",
)
}
fn chatgpt_auth_json_with_last_refresh(
plan_type: &str,
chatgpt_user_id: Option<&str>,
account_id: Option<&str>,
access_token: &str,
refresh_token: &str,
last_refresh: &str,
) -> serde_json::Value {
chatgpt_auth_json_with_mode(
plan_type,
@@ -890,6 +908,7 @@ mod tests {
account_id,
access_token,
refresh_token,
last_refresh,
None,
)
}
@@ -900,6 +919,7 @@ mod tests {
account_id: Option<&str>,
access_token: &str,
refresh_token: &str,
last_refresh: &str,
auth_mode: Option<&str>,
) -> serde_json::Value {
let header = json!({ "alg": "none", "typ": "JWT" });
@@ -925,7 +945,7 @@ mod tests {
"refresh_token": refresh_token,
"account_id": account_id,
},
"last_refresh": "2025-01-01T00:00:00Z",
"last_refresh": last_refresh,
});
if let Some(auth_mode) = auth_mode {
auth_json["auth_mode"] = serde_json::Value::String(auth_mode.to_string());
@@ -1262,24 +1282,43 @@ enabled = false
#[tokio::test]
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload() {
let auth = managed_auth_context(
"business",
Some("user-12345"),
Some("account-12345"),
"stale-access-token",
"test-refresh-token",
);
let auth_home = tempdir().expect("tempdir");
write_auth_json(
auth._home.path(),
chatgpt_auth_json(
auth_home.path(),
chatgpt_auth_json_with_last_refresh(
"business",
Some("user-12345"),
Some("account-12345"),
"stale-access-token",
"test-refresh-token",
// Keep auth "fresh" so the first request hits unauthorized recovery
// instead of AuthManager::auth() proactively reloading from disk.
"3025-01-01T00:00:00Z",
),
)
.expect("write initial auth");
let auth_manager = Arc::new(AuthManager::new(
auth_home.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
));
write_auth_json(
auth_home.path(),
chatgpt_auth_json_with_last_refresh(
"business",
Some("user-12345"),
Some("account-12345"),
"fresh-access-token",
"test-refresh-token",
"3025-01-01T00:00:00Z",
),
)
.expect("write refreshed auth");
let auth = ManagedAuthContext {
_home: auth_home,
manager: auth_manager,
};
let fetcher = Arc::new(TokenFetcher {
expected_token: "fresh-access-token".to_string(),
@@ -1314,24 +1353,41 @@ enabled = false
#[tokio::test]
async fn fetch_cloud_requirements_recovers_after_unauthorized_reload_updates_cache_identity() {
let auth = managed_auth_context(
"business",
Some("user-12345"),
Some("account-12345"),
"stale-access-token",
"test-refresh-token",
);
let auth_home = tempdir().expect("tempdir");
write_auth_json(
auth._home.path(),
chatgpt_auth_json(
auth_home.path(),
chatgpt_auth_json_with_last_refresh(
"business",
Some("user-12345"),
Some("account-12345"),
"stale-access-token",
"test-refresh-token",
"3025-01-01T00:00:00Z",
),
)
.expect("write initial auth");
let auth_manager = Arc::new(AuthManager::new(
auth_home.path().to_path_buf(),
false,
AuthCredentialsStoreMode::File,
));
write_auth_json(
auth_home.path(),
chatgpt_auth_json_with_last_refresh(
"business",
Some("user-99999"),
Some("account-12345"),
"fresh-access-token",
"test-refresh-token",
"3025-01-01T00:00:00Z",
),
)
.expect("write refreshed auth");
let auth = ManagedAuthContext {
_home: auth_home,
manager: auth_manager,
};
let fetcher = Arc::new(TokenFetcher {
expected_token: "fresh-access-token".to_string(),
@@ -1432,6 +1488,7 @@ enabled = false
Some("account-12345"),
"test-access-token",
"test-refresh-token",
"2025-01-01T00:00:00Z",
Some("chatgptAuthTokens"),
),
)

View File

@@ -39,7 +39,6 @@ codex-login = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-fs-ops = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-hooks = { workspace = true }

View File

@@ -431,6 +431,9 @@
"plugins": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
@@ -2037,6 +2040,9 @@
"plugins": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},

View File

@@ -319,62 +319,6 @@ pub(crate) async fn execute_exec_request(
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<ExecToolCallOutput> {
let PreparedExecRequest {
params,
sandbox,
file_system_sandbox_policy,
network_sandbox_policy,
} = prepare_exec_request(exec_request);
let start = Instant::now();
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
stdout_stream,
after_spawn,
)
.await;
let duration = start.elapsed();
Ok(normalize_exec_result(raw_output_result, sandbox, duration)?.to_utf8_lossy_output())
}
pub(crate) async fn execute_exec_request_raw_output(
exec_request: ExecRequest,
sandbox_policy: &SandboxPolicy,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<ExecToolCallRawOutput> {
let PreparedExecRequest {
params,
sandbox,
file_system_sandbox_policy,
network_sandbox_policy,
} = prepare_exec_request(exec_request);
let start = Instant::now();
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
stdout_stream,
after_spawn,
)
.await;
let duration = start.elapsed();
normalize_exec_result(raw_output_result, sandbox, duration)
}
struct PreparedExecRequest {
params: ExecParams,
sandbox: SandboxType,
file_system_sandbox_policy: FileSystemSandboxPolicy,
network_sandbox_policy: NetworkSandboxPolicy,
}
fn prepare_exec_request(exec_request: ExecRequest) -> PreparedExecRequest {
let ExecRequest {
command,
cwd,
@@ -394,24 +338,33 @@ fn prepare_exec_request(exec_request: ExecRequest) -> PreparedExecRequest {
} = exec_request;
let _ = _sandbox_policy_from_env;
PreparedExecRequest {
params: ExecParams {
command,
cwd,
expiration,
capture_policy,
env,
network,
sandbox_permissions,
windows_sandbox_level,
windows_sandbox_private_desktop,
justification,
arg0,
},
let params = ExecParams {
command,
cwd,
expiration,
capture_policy,
env,
network: network.clone(),
sandbox_permissions,
windows_sandbox_level,
windows_sandbox_private_desktop,
justification,
arg0,
};
let start = Instant::now();
let raw_output_result = exec(
params,
sandbox,
sandbox_policy,
&file_system_sandbox_policy,
network_sandbox_policy,
file_system_sandbox_policy,
}
stdout_stream,
after_spawn,
)
.await;
let duration = start.elapsed();
finalize_exec_result(raw_output_result, sandbox, duration)
}
#[cfg(target_os = "windows")]
@@ -605,25 +558,19 @@ async fn exec_windows_sandbox(
})
}
fn normalize_exec_result(
fn finalize_exec_result(
raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr>,
sandbox_type: SandboxType,
duration: Duration,
) -> Result<ExecToolCallRawOutput> {
) -> Result<ExecToolCallOutput> {
match raw_output_result {
Ok(raw_output) => {
let RawExecToolCallOutput {
exit_status,
stdout,
stderr,
aggregated_output,
#[cfg_attr(target_os = "windows", allow(unused_mut))]
mut timed_out,
} = raw_output;
#[allow(unused_mut)]
let mut timed_out = raw_output.timed_out;
#[cfg(target_family = "unix")]
{
if let Some(signal) = exit_status.signal() {
if let Some(signal) = raw_output.exit_status.signal() {
if signal == TIMEOUT_CODE {
timed_out = true;
} else {
@@ -632,12 +579,15 @@ fn normalize_exec_result(
}
}
let mut exit_code = exit_status.code().unwrap_or(-1);
let mut exit_code = raw_output.exit_status.code().unwrap_or(-1);
if timed_out {
exit_code = EXEC_TIMEOUT_EXIT_CODE;
}
let exec_output = ExecToolCallRawOutput {
let stdout = raw_output.stdout.from_utf8_lossy();
let stderr = raw_output.stderr.from_utf8_lossy();
let aggregated_output = raw_output.aggregated_output.from_utf8_lossy();
let exec_output = ExecToolCallOutput {
exit_code,
stdout,
stderr,
@@ -648,14 +598,13 @@ fn normalize_exec_result(
if timed_out {
return Err(CodexErr::Sandbox(SandboxErr::Timeout {
output: Box::new(exec_output.to_utf8_lossy_output()),
output: Box::new(exec_output),
}));
}
let string_output = exec_output.to_utf8_lossy_output();
if is_likely_sandbox_denied(sandbox_type, &string_output) {
if is_likely_sandbox_denied(sandbox_type, &exec_output) {
return Err(CodexErr::Sandbox(SandboxErr::Denied {
output: Box::new(string_output),
output: Box::new(exec_output),
network_policy_decision: None,
}));
}
@@ -847,16 +796,6 @@ pub struct ExecToolCallOutput {
pub timed_out: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct ExecToolCallRawOutput {
pub exit_code: i32,
pub stdout: StreamOutput<Vec<u8>>,
pub stderr: StreamOutput<Vec<u8>>,
pub aggregated_output: StreamOutput<Vec<u8>>,
pub duration: Duration,
pub timed_out: bool,
}
impl Default for ExecToolCallOutput {
fn default() -> Self {
Self {
@@ -870,19 +809,6 @@ impl Default for ExecToolCallOutput {
}
}
impl ExecToolCallRawOutput {
fn to_utf8_lossy_output(&self) -> ExecToolCallOutput {
ExecToolCallOutput {
exit_code: self.exit_code,
stdout: self.stdout.from_utf8_lossy(),
stderr: self.stderr.from_utf8_lossy(),
aggregated_output: self.aggregated_output.from_utf8_lossy(),
duration: self.duration,
timed_out: self.timed_out,
}
}
}
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
async fn exec(
params: ExecParams,

View File

@@ -121,7 +121,6 @@ pub mod default_client {
pub mod project_doc;
mod rollout;
pub(crate) mod safety;
mod sandboxed_fs;
pub mod seatbelt;
pub mod shell;
pub mod shell_snapshot;

View File

@@ -1,160 +0,0 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec::ExecCapturePolicy;
use crate::exec::ExecExpiration;
use crate::exec::ExecToolCallRawOutput;
use crate::exec::execute_exec_request_raw_output;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxPermissions;
use crate::sandboxing::merge_permission_profiles;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxablePreference;
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
use codex_fs_ops::READ_FILE_OPERATION_ARG2;
use codex_protocol::models::PermissionProfile;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
/// Reads the contents of the specified file subject to the sandbox constraints
/// imposed by the provided session and turn context.
///
/// Note that this function is comparable to `cat FILE`, though unlike `cat
/// FILE`, this function verifies that FILE is a regular file before reading,
/// which means that if you pass `/dev/zero` as the path, it will error (rather
/// than hang forever).
pub(crate) async fn read_file(
path: &AbsolutePathBuf,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
) -> Result<Vec<u8>, SandboxedFsError> {
let output = perform_operation(
SandboxedFsOperation::Read { path: path.clone() },
session,
turn,
)
.await?;
Ok(output.stdout.text)
}
/// Operations supported by the [CODEX_CORE_FS_OPS_ARG1] sandbox helper.
enum SandboxedFsOperation {
Read { path: AbsolutePathBuf },
}
async fn perform_operation(
operation: SandboxedFsOperation,
session: &Arc<Session>,
turn: &Arc<TurnContext>,
) -> Result<ExecToolCallRawOutput, SandboxedFsError> {
let exe = std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe {
message: error.to_string(),
})?;
let additional_permissions = effective_granted_permissions(session).await;
let sandbox_manager = crate::sandboxing::SandboxManager::new();
let attempt = SandboxAttempt {
sandbox: sandbox_manager.select_initial(
&turn.file_system_sandbox_policy,
turn.network_sandbox_policy,
SandboxablePreference::Auto,
turn.windows_sandbox_level,
/*has_managed_network_requirements*/ false,
),
policy: &turn.sandbox_policy,
file_system_policy: &turn.file_system_sandbox_policy,
network_policy: turn.network_sandbox_policy,
enforce_managed_network: false,
manager: &sandbox_manager,
sandbox_cwd: &turn.cwd,
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
use_legacy_landlock: turn.features.use_legacy_landlock(),
windows_sandbox_level: turn.windows_sandbox_level,
windows_sandbox_private_desktop: turn.config.permissions.windows_sandbox_private_desktop,
};
let args = match operation {
SandboxedFsOperation::Read { ref path } => vec![
CODEX_CORE_FS_OPS_ARG1.to_string(),
READ_FILE_OPERATION_ARG2.to_string(),
path.to_string_lossy().to_string(),
],
};
// `FullBuffer` reads ignore exec expiration, but `ExecRequest` still requires
// an `expiration` field, so keep a placeholder timeout here until that API
// changes.
let ignored_expiration = Duration::from_secs(30);
let exec_request = attempt
.env_for(
CommandSpec {
program: exe.to_string_lossy().to_string(),
args,
cwd: turn.cwd.clone(),
env: HashMap::new(),
expiration: ExecExpiration::Timeout(ignored_expiration),
capture_policy: ExecCapturePolicy::FullBuffer,
sandbox_permissions: SandboxPermissions::UseDefault,
additional_permissions,
justification: None,
},
/*network*/ None,
)
.map_err(|error| SandboxedFsError::ProcessFailed {
exit_code: -1,
message: error.to_string(),
})?;
let effective_policy = exec_request.sandbox_policy.clone();
let output = execute_exec_request_raw_output(
exec_request,
&effective_policy,
/*stdout_stream*/ None,
/*after_spawn*/ None,
)
.await
.map_err(|error| SandboxedFsError::ProcessFailed {
exit_code: 1,
message: error.to_string(),
})?;
if output.exit_code == 0 {
Ok(output)
} else {
Err(parse_helper_failure(
output.exit_code,
&output.stderr.text,
&output.stdout.text,
))
}
}
async fn effective_granted_permissions(session: &Session) -> Option<PermissionProfile> {
let granted_session_permissions = session.granted_session_permissions().await;
let granted_turn_permissions = session.granted_turn_permissions().await;
merge_permission_profiles(
granted_session_permissions.as_ref(),
granted_turn_permissions.as_ref(),
)
}
fn parse_helper_failure(exit_code: i32, stderr: &[u8], stdout: &[u8]) -> SandboxedFsError {
let stderr = String::from_utf8_lossy(stderr);
let stdout = String::from_utf8_lossy(stdout);
let message = if !stderr.trim().is_empty() {
stderr.trim().to_string()
} else if !stdout.trim().is_empty() {
stdout.trim().to_string()
} else {
"no error details emitted".to_string()
};
SandboxedFsError::ProcessFailed { exit_code, message }
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum SandboxedFsError {
#[error("failed to determine codex executable: {message}")]
ResolveExe { message: String },
#[error("sandboxed fs helper exited with code {exit_code}: {message}")]
ProcessFailed { exit_code: i32, message: String },
}

View File

@@ -14,7 +14,6 @@ use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::protocol::EventMsg;
use crate::protocol::ViewImageToolCallEvent;
use crate::sandboxed_fs;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -94,6 +93,36 @@ impl ToolHandler for ViewImageHandler {
AbsolutePathBuf::try_from(turn.resolve_path(Some(args.path))).map_err(|error| {
FunctionCallError::RespondToModel(format!("unable to resolve image path: {error}"))
})?;
let metadata = turn
.environment
.get_filesystem()
.get_metadata(&abs_path)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"unable to locate image at `{}`: {error}",
abs_path.display()
))
})?;
if !metadata.is_file {
return Err(FunctionCallError::RespondToModel(format!(
"image path `{}` is not a file",
abs_path.display()
)));
}
let file_bytes = turn
.environment
.get_filesystem()
.read_file(&abs_path)
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
"unable to read image at `{}`: {error}",
abs_path.display()
))
})?;
let event_path = abs_path.to_path_buf();
let can_request_original_detail =
@@ -106,24 +135,14 @@ impl ToolHandler for ViewImageHandler {
PromptImageMode::ResizeToFit
};
let image_detail = use_original_detail.then_some(ImageDetail::Original);
let image_bytes = sandboxed_fs::read_file(&abs_path, &session, &turn)
.await
.map_err(|error| {
let full_error = format!(
"unable to read image file `{path}`: {error:?}",
path = abs_path.display()
);
FunctionCallError::RespondToModel(full_error)
})?;
let image = load_for_prompt_bytes(abs_path.as_path(), image_bytes, image_mode).map_err(
|error| {
let image =
load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| {
FunctionCallError::RespondToModel(format!(
"unable to process image at `{}`: {error}",
abs_path.display()
))
},
)?;
})?;
let image_url = image.into_data_url();
session

View File

@@ -33,6 +33,7 @@ use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::with_cached_approval;
use codex_features::Feature;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
@@ -226,7 +227,9 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
&req.cwd,
&req.explicit_env_overrides,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
let command = if matches!(session_shell.shell_type, ShellType::PowerShell)
&& ctx.session.features().enabled(Feature::PowershellUtf8)
{
prefix_powershell_script_with_utf8(&command)
} else {
command

View File

@@ -36,6 +36,7 @@ use crate::unified_exec::NoopSpawnLifecycle;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcess;
use crate::unified_exec::UnifiedExecProcessManager;
use codex_features::Feature;
use codex_network_proxy::NetworkProxy;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReviewDecision;
@@ -199,7 +200,9 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
&req.cwd,
&req.explicit_env_overrides,
);
let command = if matches!(session_shell.shell_type, ShellType::PowerShell) {
let command = if matches!(session_shell.shell_type, ShellType::PowerShell)
&& ctx.session.features().enabled(Feature::PowershellUtf8)
{
prefix_powershell_script_with_utf8(&command)
} else {
command

View File

@@ -381,6 +381,116 @@ async fn refreshes_token_when_last_refresh_is_stale() -> Result<()> {
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
let ctx = RefreshTokenTestContext::new(&server)?;
let stale_refresh = Utc::now() - Duration::days(9);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens),
last_refresh: Some(stale_refresh),
};
ctx.write_auth(&initial_auth)?;
let fresh_refresh = Utc::now() - Duration::days(1);
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
let disk_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(disk_tokens.clone()),
last_refresh: Some(fresh_refresh),
};
save_auth(
ctx.codex_home.path(),
&disk_auth,
AuthCredentialsStoreMode::File,
)?;
let cached_auth = ctx
.auth_manager
.auth()
.await
.context("auth should reload from disk")?;
let cached = cached_auth
.get_token_data()
.context("token data should reload from disk")?;
assert_eq!(cached, disk_tokens);
let stored = ctx.load_auth()?;
assert_eq!(stored, disk_auth);
let requests = server.received_requests().await.unwrap_or_default();
assert!(requests.is_empty(), "expected no refresh token requests");
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
"error": {
"code": "refresh_token_expired"
}
})))
.expect(0)
.mount(&server)
.await;
let ctx = RefreshTokenTestContext::new(&server)?;
let stale_refresh = Utc::now() - Duration::days(9);
let initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN);
let initial_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(initial_tokens),
last_refresh: Some(stale_refresh),
};
ctx.write_auth(&initial_auth)?;
let fresh_refresh = Utc::now() - Duration::days(1);
let disk_tokens = build_tokens("disk-access-token", "disk-refresh-token");
let disk_auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(disk_tokens.clone()),
last_refresh: Some(fresh_refresh),
};
save_auth(
ctx.codex_home.path(),
&disk_auth,
AuthCredentialsStoreMode::File,
)?;
let cached_auth = ctx
.auth_manager
.auth()
.await
.context("auth should reload from disk")?;
let cached = cached_auth
.get_token_data()
.context("token data should reload from disk")?;
assert_eq!(cached, disk_tokens);
let stored = ctx.load_auth()?;
assert_eq!(stored, disk_auth);
server.verify().await;
Ok(())
}
#[serial_test::serial(auth_refresh)]
#[tokio::test]
async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Result<()> {

View File

@@ -1,6 +1,7 @@
use std::time::Duration;
use anyhow::Result;
use codex_features::Feature;
use core_test_support::assert_regex_match;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -250,7 +251,16 @@ async fn shell_command_times_out_with_timeout_ms() -> anyhow::Result<()> {
async fn unicode_output(login: bool) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?;
#[allow(clippy::expect_used)]
let harness = shell_command_harness_with(|builder| {
builder.with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::PowershellUtf8)
.expect("test config should allow feature update");
})
})
.await?;
// We use a child process on windows instead of a direct builtin like 'echo' to ensure that Powershell
// config is actually being set correctly.
@@ -276,7 +286,16 @@ async fn unicode_output(login: bool) -> anyhow::Result<()> {
async fn unicode_output_with_newlines(login: bool) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let harness = shell_command_harness_with(|builder| builder.with_model("gpt-5.2")).await?;
#[allow(clippy::expect_used)]
let harness = shell_command_harness_with(|builder| {
builder.with_model("gpt-5.2").with_config(|config| {
config
.features
.enable(Feature::PowershellUtf8)
.expect("test config should allow feature update");
})
})
.await?;
let call_id = "unicode_output";
mount_shell_responses_with_timeout(

View File

@@ -3,7 +3,6 @@
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::CodexAuth;
use codex_core::config::Constrained;
use codex_exec_server::CreateDirectoryOptions;
use codex_features::Feature;
use codex_protocol::config_types::ReasoningSummary;
@@ -15,15 +14,9 @@ use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::ReasoningEffortPreset;
use codex_protocol::openai_models::TruncationPolicyConfig;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::FileSystemSpecialPath;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
@@ -1153,10 +1146,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("output text present");
let expected_message = format!(
r#"unable to read image file `{path}`: ProcessFailed {{ exit_code: 1, message: "error: `{path}` is not a regular file" }}"#,
path = abs_path.display()
);
let expected_message = format!("image path `{}` is not a file", abs_path.display());
assert_eq!(output_text, expected_message);
assert!(
@@ -1311,10 +1301,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("output text present");
let expected_prefix = format!(
"unable to read image file `{path}`:",
path = abs_path.display()
);
let expected_prefix = format!("unable to locate image at `{}`:", abs_path.display());
assert!(
output_text.starts_with(&expected_prefix),
"expected error to start with `{expected_prefix}` but got `{output_text}`"
@@ -1328,109 +1315,6 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_respects_filesystem_sandbox() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let sandbox_policy_for_config = SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: Vec::new(),
},
network_access: false,
};
let mut builder = test_codex().with_config({
let sandbox_policy_for_config = sandbox_policy_for_config.clone();
move |config| {
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
config.permissions.file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
},
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::CurrentWorkingDirectory,
},
access: FileSystemAccessMode::Read,
},
]);
}
});
let TestCodex {
codex,
config,
cwd,
session_configured,
..
} = builder.build(&server).await?;
let outside_dir = tempfile::tempdir()?;
let abs_path = outside_dir.path().join("blocked.png");
let image = ImageBuffer::from_pixel(256, 128, Rgba([10u8, 20, 30, 255]));
image.save(&abs_path)?;
let call_id = "view-image-sandbox-denied";
let arguments = serde_json::json!({ "path": abs_path }).to_string();
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "view_image", &arguments),
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
let second_response = sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let mock = responses::mount_sse_once(&server, second_response).await;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "please attach the outside image".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
let request = mock.single_request();
assert!(
request.inputs_of_type("input_image").is_empty(),
"sandbox-denied image should not produce an input_image message"
);
let output_text = request
.function_call_output_content_and_success(call_id)
.and_then(|(content, _)| content)
.expect("output text present");
let expected_prefix = format!("unable to read image file `{}`:", abs_path.display());
assert!(
output_text.starts_with(&expected_prefix),
"expected sandbox denial prefix `{expected_prefix}` but got `{output_text}`"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -132,6 +132,8 @@ pub enum Feature {
ChildAgentsMd,
/// Allow the model to request `detail: "original"` image outputs on supported models.
ImageDetailOriginal,
/// Enforce UTF8 output in Powershell.
PowershellUtf8,
/// Compress request bodies (zstd) when sending streaming requests to codex-backend.
EnableRequestCompression,
/// Enable collab tools.
@@ -687,6 +689,18 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Removed,
default_enabled: false,
},
FeatureSpec {
id: Feature::PowershellUtf8,
key: "powershell_utf8",
#[cfg(windows)]
stage: Stage::Stable,
#[cfg(windows)]
default_enabled: true,
#[cfg(not(windows))]
stage: Stage::UnderDevelopment,
#[cfg(not(windows))]
default_enabled: false,
},
FeatureSpec {
id: Feature::EnableRequestCompression,
key: "enable_request_compression",

View File

@@ -1,6 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "fs-ops",
crate_name = "codex_fs_ops",
)

View File

@@ -1,16 +0,0 @@
[package]
name = "codex-fs-ops"
edition.workspace = true
license.workspace = true
version.workspace = true
[lib]
name = "codex_fs_ops"
path = "src/lib.rs"
[lints]
workspace = true
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View File

@@ -1,37 +0,0 @@
use crate::constants::READ_FILE_OPERATION_ARG2;
use std::ffi::OsString;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FsCommand {
ReadFile { path: PathBuf },
}
pub fn parse_command_from_args(
mut args: impl Iterator<Item = OsString>,
) -> Result<FsCommand, String> {
let Some(operation) = args.next() else {
return Err("missing operation".to_string());
};
let Some(operation) = operation.to_str() else {
return Err("operation must be valid UTF-8".to_string());
};
let Some(path) = args.next() else {
return Err(format!("missing path for operation `{operation}`"));
};
if args.next().is_some() {
return Err(format!(
"unexpected extra arguments for operation `{operation}`"
));
}
let path = PathBuf::from(path);
match operation {
READ_FILE_OPERATION_ARG2 => Ok(FsCommand::ReadFile { path }),
_ => Err(format!("unsupported filesystem operation `{operation}`")),
}
}
#[cfg(test)]
#[path = "command_tests.rs"]
mod tests;

View File

@@ -1,21 +0,0 @@
use super::FsCommand;
use super::READ_FILE_OPERATION_ARG2;
use super::parse_command_from_args;
use pretty_assertions::assert_eq;
#[test]
fn parse_read_command() {
let command = parse_command_from_args(
[READ_FILE_OPERATION_ARG2, "/tmp/example.png"]
.into_iter()
.map(Into::into),
)
.expect("command should parse");
assert_eq!(
command,
FsCommand::ReadFile {
path: "/tmp/example.png".into(),
}
);
}

View File

@@ -1,8 +0,0 @@
/// Special argv[1] flag used when the Codex executable self-invokes to run the
/// internal sandbox-backed filesystem helper path.
pub const CODEX_CORE_FS_OPS_ARG1: &str = "--codex-run-as-fs-ops";
/// When passed as argv[2] to the Codex filesystem helper, it should be followed
/// by a single path argument, and the helper will read the contents of the file
/// at that path and write it to stdout.
pub const READ_FILE_OPERATION_ARG2: &str = "read";

View File

@@ -1,13 +0,0 @@
//! The codex-fs-ops crate provides a helper binary for performing various
//! filesystem operations when `codex` is invoked with `--codex-run-as-fs-ops`
//! as the first argument. By exposing this functionality via a CLI, this makes
//! it possible to execute the CLI within a sandboxed context in order to ensure
//! the filesystem restrictions of the sandbox are honored.
mod command;
mod constants;
mod runner;
pub use constants::CODEX_CORE_FS_OPS_ARG1;
pub use constants::READ_FILE_OPERATION_ARG2;
pub use runner::run_from_args_and_exit;

View File

@@ -1,66 +0,0 @@
use crate::command::FsCommand;
use crate::command::parse_command_from_args;
use std::ffi::OsString;
use std::io::Read;
use std::io::Write;
/// Runs the fs-ops helper with the given arguments and I/O streams.
pub fn run_from_args_and_exit(
args: impl Iterator<Item = OsString>,
stdin: &mut impl Read,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> ! {
let exit_code = match run_from_args(args, stdin, stdout, stderr) {
Ok(()) => 0,
Err(_) => {
// Discard the specific error, since we already wrote it to stderr.
1
}
};
std::process::exit(exit_code);
}
/// Testable version of `run_from_args_and_exit` that returns a Result instead
/// of exiting the process.
fn run_from_args(
args: impl Iterator<Item = OsString>,
stdin: &mut impl Read,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> std::io::Result<()> {
try_run_from_args(args, stdin, stdout).inspect_err(|error| {
writeln!(stderr, "error: {error}").ok();
})
}
fn try_run_from_args(
args: impl Iterator<Item = OsString>,
_stdin: &mut impl Read,
stdout: &mut impl Write,
) -> std::io::Result<()> {
let command = parse_command_from_args(args)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?;
match command {
FsCommand::ReadFile { path } => {
let mut file = std::fs::File::open(&path)?;
if !file.metadata()?.is_file() {
let error_message = format!(
"`{path}` is not a regular file",
path = path.to_string_lossy()
);
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
error_message,
));
}
std::io::copy(&mut file, stdout).map(|_| ())
}
}
}
#[cfg(test)]
#[path = "runner_tests.rs"]
mod tests;

View File

@@ -1,114 +0,0 @@
use super::run_from_args;
use crate::READ_FILE_OPERATION_ARG2;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn run_from_args_streams_file_bytes_to_stdout() {
let tempdir = tempdir().expect("tempdir");
let path = tempdir.path().join("image.bin");
let expected = b"hello\x00world".to_vec();
std::fs::write(&path, &expected).expect("write test file");
let mut stdin = std::io::empty();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
run_from_args(
[
READ_FILE_OPERATION_ARG2,
path.to_str().expect("utf-8 test path"),
]
.into_iter()
.map(Into::into),
&mut stdin,
&mut stdout,
&mut stderr,
)
.expect("read should succeed");
assert_eq!(stdout, expected);
assert_eq!(stderr, Vec::<u8>::new());
}
#[test]
#[cfg(unix)]
fn rejects_path_that_is_not_a_regular_file() {
let path = std::path::PathBuf::from("/dev/zero");
let mut stdin = std::io::empty();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let error = run_from_args(
[
READ_FILE_OPERATION_ARG2,
path.to_str().expect("utf-8 test path"),
]
.into_iter()
.map(Into::into),
&mut stdin,
&mut stdout,
&mut stderr,
)
.expect_err(
r#"reading a non-regular file should fail or else
the user risks hanging the process by trying
to read from something like /dev/zero"#,
);
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(stdout, Vec::<u8>::new());
assert_eq!(
"error: `/dev/zero` is not a regular file\n",
String::from_utf8_lossy(&stderr),
);
}
#[test]
fn read_reports_directory_error() {
let tempdir = tempdir().expect("tempdir");
let mut stdin = std::io::empty();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let error = run_from_args(
[
READ_FILE_OPERATION_ARG2,
tempdir.path().to_str().expect("utf-8 test path"),
]
.into_iter()
.map(Into::into),
&mut stdin,
&mut stdout,
&mut stderr,
)
.expect_err("reading a directory should fail");
#[cfg(not(windows))]
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
#[cfg(windows)]
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
}
#[test]
fn run_from_args_serializes_errors_to_stderr() {
let tempdir = tempdir().expect("tempdir");
let missing = tempdir.path().join("missing.txt");
let mut stdin = std::io::empty();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let result = run_from_args(
[
READ_FILE_OPERATION_ARG2,
missing.to_str().expect("utf-8 test path"),
]
.into_iter()
.map(Into::into),
&mut stdin,
&mut stdout,
&mut stderr,
);
assert!(result.is_err(), "missing file should fail");
assert_eq!(stdout, Vec::<u8>::new());
}

View File

@@ -1090,10 +1090,13 @@ impl AuthManager {
}
/// Current cached auth (clone). May be `None` if not logged in or load failed.
/// Refreshes cached ChatGPT tokens if they are stale before returning.
/// For stale managed ChatGPT auth, first performs a guarded reload and then
/// refreshes only if the on-disk auth is unchanged.
pub async fn auth(&self) -> Option<CodexAuth> {
let auth = self.auth_cached()?;
if let Err(err) = self.refresh_if_stale(&auth).await {
if Self::is_stale_for_proactive_refresh(&auth)
&& let Err(err) = self.refresh_token().await
{
tracing::error!("Failed to refresh token: {}", err);
return Some(auth);
}
@@ -1320,30 +1323,21 @@ impl AuthManager {
self.auth_cached().as_ref().map(CodexAuth::auth_mode)
}
async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result<bool, RefreshTokenError> {
fn is_stale_for_proactive_refresh(auth: &CodexAuth) -> bool {
let chatgpt_auth = match auth {
CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth,
_ => return Ok(false),
_ => return false,
};
let auth_dot_json = match chatgpt_auth.current_auth_json() {
Some(auth_dot_json) => auth_dot_json,
None => return Ok(false),
};
let tokens = match auth_dot_json.tokens {
Some(tokens) => tokens,
None => return Ok(false),
None => return false,
};
let last_refresh = match auth_dot_json.last_refresh {
Some(last_refresh) => last_refresh,
None => return Ok(false),
None => return false,
};
if last_refresh >= Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) {
return Ok(false);
}
self.refresh_and_persist_chatgpt_token(chatgpt_auth, tokens.refresh_token)
.await?;
Ok(true)
last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL)
}
async fn refresh_external_auth(