diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index bd70e9a81e..17d54f214a 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -46,4 +46,6 @@ jobs: path-to-document: https://github.com/openai/codex/blob/main/docs/CLA.md path-to-signatures: signatures/cla.json branch: cla-signatures - allowlist: dependabot[bot] + allowlist: | + codex + dependabot[bot] diff --git a/.github/workflows/close-stale-contributor-prs.yml b/.github/workflows/close-stale-contributor-prs.yml index b3cb7fb44e..e01bc3881d 100644 --- a/.github/workflows/close-stale-contributor-prs.yml +++ b/.github/workflows/close-stale-contributor-prs.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close inactive PRs from contributors - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 5d3103d7b2..0bd91ca53b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -95,8 +95,8 @@ jobs: run: working-directory: codex-rs env: - # Speed up repeated builds across CI runs by caching compiled objects. - RUSTC_WRAPPER: sccache + # Speed up repeated builds across CI runs by caching compiled objects (non-Windows). + USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G @@ -170,12 +170,14 @@ jobs: # Install and restore sccache cache - name: Install sccache + if: ${{ env.USE_SCCACHE == 'true' }} uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: sccache version: 0.7.5 - name: Configure sccache backend + if: ${{ env.USE_SCCACHE == 'true' }} shell: bash run: | set -euo pipefail @@ -188,8 +190,13 @@ jobs: echo "Using sccache local disk + actions/cache fallback" fi + - name: Enable sccache wrapper + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + - name: Restore sccache cache (fallback) - if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }} + if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} id: cache_sccache_restore uses: actions/cache/restore@v4 with: @@ -274,7 +281,7 @@ jobs: key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true' + if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true uses: actions/cache/save@v4 with: @@ -282,12 +289,12 @@ jobs: key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} - name: sccache stats - if: always() + if: always() && env.USE_SCCACHE == 'true' continue-on-error: true run: sccache --show-stats || true - name: sccache summary - if: always() + if: always() && env.USE_SCCACHE == 'true' shell: bash run: | { @@ -326,7 +333,8 @@ jobs: run: working-directory: codex-rs env: - RUSTC_WRAPPER: sccache + # Speed up repeated builds across CI runs by caching compiled objects (non-Windows). + USE_SCCACHE: ${{ startsWith(matrix.runner, 'windows') && 'false' || 'true' }} CARGO_INCREMENTAL: "0" SCCACHE_CACHE_SIZE: 10G @@ -370,12 +378,14 @@ jobs: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - name: Install sccache + if: ${{ env.USE_SCCACHE == 'true' }} uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: sccache version: 0.7.5 - name: Configure sccache backend + if: ${{ env.USE_SCCACHE == 'true' }} shell: bash run: | set -euo pipefail @@ -388,8 +398,13 @@ jobs: echo "Using sccache local disk + actions/cache fallback" fi + - name: Enable sccache wrapper + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" + - name: Restore sccache cache (fallback) - if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }} + if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} id: cache_sccache_restore uses: actions/cache/restore@v4 with: @@ -424,7 +439,7 @@ jobs: key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true' + if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true uses: actions/cache/save@v4 with: @@ -432,12 +447,12 @@ jobs: key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} - name: sccache stats - if: always() + if: always() && env.USE_SCCACHE == 'true' continue-on-error: true run: sccache --show-stats || true - name: sccache summary - if: always() + if: always() && env.USE_SCCACHE == 'true' shell: bash run: | { diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3460faf5d7..7c905cc723 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -130,7 +130,7 @@ image = { version = "^0.25.8", default-features = false } indexmap = "2.12.0" insta = "1.43.2" itertools = "0.14.0" -keyring = "3.6" +keyring = { version = "3.6", default-features = false } landlock = "0.4.1" lazy_static = "1" libc = "0.2.175" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ab732c910c..4d8f43778c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -40,12 +40,7 @@ eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } indexmap = { workspace = true } -keyring = { workspace = true, features = [ - "apple-native", - "crypto-rust", - "linux-native-async-persistent", - "windows-native", -] } +keyring = { workspace = true, features = ["crypto-rust"] } libc = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } @@ -90,9 +85,11 @@ wildmatch = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] landlock = { workspace = true } seccompiler = { workspace = true } +keyring = { workspace = true, features = ["linux-native-async-persistent"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" +keyring = { workspace = true, features = ["apple-native"] } # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] @@ -102,6 +99,12 @@ openssl-sys = { workspace = true, features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { workspace = true, features = ["vendored"] } +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { workspace = true, features = ["windows-native"] } + +[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] +keyring = { workspace = true, features = ["sync-secret-service"] } + [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 5b9578e759..a60db89d83 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -673,7 +673,9 @@ async fn process_chat_sse( } // Emit end-of-turn when finish_reason signals completion. - if let Some(finish_reason) = choice.get("finish_reason").and_then(|v| v.as_str()) { + if let Some(finish_reason) = choice.get("finish_reason").and_then(|v| v.as_str()) + && !finish_reason.is_empty() + { match finish_reason { "tool_calls" if fn_call_state.active => { // First, flush the terminal raw reasoning so UIs can finalize diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index a628e0d320..9494ffcdf4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -136,7 +136,7 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) { } fn is_shell_tool_name(name: &str) -> bool { - matches!(name, "shell" | "container.exec") + matches!(name, "shell" | "container.exec" | "shell_command") } #[derive(Deserialize)] diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 56a4547526..33dc42b936 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -112,7 +112,7 @@ impl ToolCallRuntime { fn abort_message(call: &ToolCall, secs: f32) -> String { match call.tool_name.as_str() { - "shell" | "container.exec" | "local_shell" | "unified_exec" => { + "shell" | "container.exec" | "local_shell" | "shell_command" | "unified_exec" => { format!("Wall time: {secs:.1} seconds\naborted by user") } _ => format!("aborted by user after {secs:.1}s"), diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 237b2db377..44d637b9e5 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -788,6 +788,59 @@ Output: Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shell_command_output_is_structured() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut builder = test_codex().with_config(|config| { + config.features.enable(Feature::ShellCommandTool); + }); + let test = builder.build(&server).await?; + + let call_id = "shell-command"; + let args = json!({ + "command": "echo shell command", + "timeout_ms": 1_000, + }); + let responses = vec![ + sse(vec![ + json!({"type": "response.created", "response": {"id": "resp-1"}}), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_assistant_message("msg-1", "shell_command done"), + ev_completed("resp-2"), + ]), + ]; + let mock = mount_sse_sequence(&server, responses).await; + + test.submit_turn_with_policy( + "run the shell_command script in the user's shell", + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let req = mock + .last_request() + .expect("shell_command output request recorded"); + let output_item = req.function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("shell_command output string"); + + let expected_pattern = r"(?s)^Exit code: 0 +Wall time: [0-9]+(?:\.[0-9]+)? seconds +Output: +shell command +?$"; + assert_regex_match(expected_pattern, output); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn local_shell_call_output_is_structured() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/keyring-store/Cargo.toml b/codex-rs/keyring-store/Cargo.toml index f662e5d4ff..932693de50 100644 --- a/codex-rs/keyring-store/Cargo.toml +++ b/codex-rs/keyring-store/Cargo.toml @@ -7,10 +7,17 @@ version = { workspace = true } workspace = true [dependencies] -keyring = { workspace = true, features = [ - "apple-native", - "crypto-rust", - "linux-native-async-persistent", - "windows-native", -] } +keyring = { workspace = true, features = ["crypto-rust"] } tracing = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native-async-persistent"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { workspace = true, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { workspace = true, features = ["windows-native"] } + +[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] +keyring = { workspace = true, features = ["sync-secret-service"] } diff --git a/codex-rs/process-hardening/Cargo.toml b/codex-rs/process-hardening/Cargo.toml index 7294b6e268..2ba4b0d5ca 100644 --- a/codex-rs/process-hardening/Cargo.toml +++ b/codex-rs/process-hardening/Cargo.toml @@ -11,11 +11,4 @@ path = "src/lib.rs" workspace = true [dependencies] -[target.'cfg(target_os = "linux")'.dependencies] -libc = { workspace = true } - -[target.'cfg(target_os = "android")'.dependencies] -libc = { workspace = true } - -[target.'cfg(target_os = "macos")'.dependencies] libc = { workspace = true } diff --git a/codex-rs/process-hardening/src/lib.rs b/codex-rs/process-hardening/src/lib.rs index a787b4097d..0a624fb387 100644 --- a/codex-rs/process-hardening/src/lib.rs +++ b/codex-rs/process-hardening/src/lib.rs @@ -10,6 +10,10 @@ pub fn pre_main_hardening() { #[cfg(target_os = "macos")] pre_main_hardening_macos(); + // On FreeBSD and OpenBSD, apply similar hardening to Linux/macOS: + #[cfg(any(target_os = "freebsd", target_os = "openbsd"))] + pre_main_hardening_bsd(); + #[cfg(windows)] pre_main_hardening_windows(); } @@ -20,7 +24,13 @@ const PRCTL_FAILED_EXIT_CODE: i32 = 5; #[cfg(target_os = "macos")] const PTRACE_DENY_ATTACH_FAILED_EXIT_CODE: i32 = 6; -#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "freebsd", + target_os = "openbsd" +))] const SET_RLIMIT_CORE_FAILED_EXIT_CODE: i32 = 7; #[cfg(any(target_os = "linux", target_os = "android"))] @@ -57,6 +67,27 @@ pub(crate) fn pre_main_hardening_linux() { } } +#[cfg(any(target_os = "freebsd", target_os = "openbsd"))] +pub(crate) fn pre_main_hardening_bsd() { + // FreeBSD/OpenBSD: set RLIMIT_CORE to 0 and clear LD_* env vars + set_core_file_size_limit_to_zero(); + + let ld_keys: Vec = std::env::vars() + .filter_map(|(key, _)| { + if key.starts_with("LD_") { + Some(key) + } else { + None + } + }) + .collect(); + for key in ld_keys { + unsafe { + std::env::remove_var(key); + } + } +} + #[cfg(target_os = "macos")] pub(crate) fn pre_main_hardening_macos() { // Prevent debuggers from attaching to this process. diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index d810f69e4a..903fd5f230 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -160,6 +160,19 @@ fn local_image_error_placeholder( } } +fn invalid_image_error_placeholder( + path: &std::path::Path, + error: impl std::fmt::Display, +) -> ContentItem { + ContentItem::InputText { + text: format!( + "Image located at `{}` is invalid: {}", + path.display(), + error + ), + } +} + impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { @@ -273,9 +286,10 @@ impl From> for ResponseInputItem { image_url: image.into_data_url(), }, Err(err) => { - tracing::warn!("Failed to resize image {}: {}", path.display(), err); if matches!(&err, ImageProcessingError::Read { .. }) { local_image_error_placeholder(&path, &err) + } else if err.is_invalid_image() { + invalid_image_error_placeholder(&path, &err) } else { match std::fs::read(&path) { Ok(bytes) => { @@ -391,6 +405,7 @@ impl Serialize for FunctionCallOutputPayload { where S: Serializer, { + tracing::error!("Payload: {:?}", self); if let Some(items) = &self.content_items { items.serialize(serializer) } else { @@ -478,7 +493,7 @@ fn convert_content_blocks_to_items( ) -> Option> { let mut saw_image = false; let mut items = Vec::with_capacity(blocks.len()); - + tracing::warn!("Blocks: {:?}", blocks); for block in blocks { match block { ContentBlock::TextContent(text) => { diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 68ef4509b3..5c3f1dc0c8 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -16,12 +16,7 @@ codex-keyring-store = { workspace = true } codex-protocol = { workspace = true } dirs = { workspace = true } futures = { workspace = true, default-features = false, features = ["std"] } -keyring = { workspace = true, features = [ - "apple-native", - "crypto-rust", - "linux-native-async-persistent", - "windows-native", -] } +keyring = { workspace = true, features = ["crypto-rust"] } mcp-types = { path = "../mcp-types" } oauth2 = "5" reqwest = { version = "0.12", default-features = false, features = [ @@ -63,3 +58,14 @@ escargot = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { workspace = true, features = ["linux-native-async-persistent"] } + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { workspace = true, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { workspace = true, features = ["windows-native"] } + +[target.'cfg(any(target_os = "freebsd", target_os = "openbsd"))'.dependencies] +keyring = { workspace = true, features = ["sync-secret-service"] } diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index cd913b00d4..0a7faa4fcb 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -247,6 +247,16 @@ impl TextArea { } if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => { self.delete_backward_word() }, + KeyEvent { + code: KeyCode::Char(c), + modifiers, + .. + } if modifiers.contains(KeyModifiers::ALT) + && modifiers.contains(KeyModifiers::CONTROL) => + { + // AltGr on many keyboards reports as Ctrl+Alt; treat it as a literal char. + self.insert_str(&c.to_string()); + }, KeyEvent { code: KeyCode::Backspace, modifiers: KeyModifiers::ALT, @@ -1454,6 +1464,17 @@ mod tests { assert_eq!(t.cursor(), 3); } + #[test] + fn altgr_ctrl_alt_char_inserts_literal() { + let mut t = ta_with(""); + t.input(KeyEvent::new( + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT, + )); + assert_eq!(t.text(), "c"); + assert_eq!(t.cursor(), 1); + } + #[test] fn cursor_vertical_movement_across_lines_and_bounds() { let mut t = ta_with("short\nloooooooooong\nmid"); diff --git a/codex-rs/utils/image/src/error.rs b/codex-rs/utils/image/src/error.rs index ffd0a7850e..6bd055115d 100644 --- a/codex-rs/utils/image/src/error.rs +++ b/codex-rs/utils/image/src/error.rs @@ -1,3 +1,4 @@ +use image::ImageError; use image::ImageFormat; use std::path::PathBuf; use thiserror::Error; @@ -23,3 +24,15 @@ pub enum ImageProcessingError { source: image::ImageError, }, } + +impl ImageProcessingError { + pub fn is_invalid_image(&self) -> bool { + matches!( + self, + ImageProcessingError::Decode { + source: ImageError::Decoding(_), + .. + } + ) + } +}