From 8b280367b1f1ffefd265d18b6a9059ca2364391f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 2 Feb 2026 20:08:08 -0800 Subject: [PATCH 01/13] Updated bug and feature templates (#10453) The current bug template uses CLI-specific instructions for getting the version. The current feature template doesn't ask the user to provide the Codex variant (surface) they are using. This PR addresses these problems. --- .github/ISSUE_TEMPLATE/2-bug-report.yml | 2 +- .github/ISSUE_TEMPLATE/4-feature-request.yml | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/2-bug-report.yml b/.github/ISSUE_TEMPLATE/2-bug-report.yml index 81651a6f3e..2d5ec2ad3f 100644 --- a/.github/ISSUE_TEMPLATE/2-bug-report.yml +++ b/.github/ISSUE_TEMPLATE/2-bug-report.yml @@ -19,7 +19,7 @@ body: id: version attributes: label: What version of Codex is running? - description: Copy the output of `codex --version` + description: (App) look in "About Codex" dialog; (CLI) use `codex --version`; (IDE Extension) look in extensions panel. validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/4-feature-request.yml b/.github/ISSUE_TEMPLATE/4-feature-request.yml index fea86edd7b..780fbfab36 100644 --- a/.github/ISSUE_TEMPLATE/4-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/4-feature-request.yml @@ -12,6 +12,13 @@ body: 1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one. 2. The Codex team will try to balance the varying needs of the community when prioritizing or rejecting new features. Not all features will be accepted. See [Contributing](https://github.com/openai/codex#contributing) for more details. + - type: input + id: variant + attributes: + label: What variant of Codex are you using? + description: (e.g., CLI, App, IDE Extension, Web) + validations: + required: true - type: textarea id: feature attributes: From bf87468c2bbccc99410000b22f76113596bf746e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 2 Feb 2026 20:35:50 -0800 Subject: [PATCH 02/13] Restore status after preamble (#10465) --- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ce4af0d2ff..56cebb6abb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2081,6 +2081,7 @@ impl ChatWidget { pub(crate) fn handle_exec_begin_now(&mut self, ev: ExecCommandBeginEvent) { // Ensure the status indicator is visible while the command runs. + self.bottom_pane.ensure_status_indicator(); self.running_commands.insert( ev.call_id.clone(), RunningCommand { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 00141a0963..45fafec194 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1830,6 +1830,25 @@ async fn streaming_final_answer_keeps_task_running_state() { assert!(!chat.bottom_pane.quit_shortcut_hint_visible()); } +#[tokio::test] +async fn exec_begin_restores_status_indicator_after_preamble() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.on_task_started(); + assert_eq!(chat.bottom_pane.status_indicator_visible(), true); + + chat.on_agent_message_delta("Preamble line\n".to_string()); + chat.on_commit_tick(); + drain_insert_history(&mut rx); + + assert_eq!(chat.bottom_pane.status_indicator_visible(), false); + assert_eq!(chat.bottom_pane.is_task_running(), true); + + begin_exec(&mut chat, "call-1", "echo hi"); + + assert_eq!(chat.bottom_pane.status_indicator_visible(), true); +} + #[tokio::test] async fn ctrl_c_shutdown_works_with_caps_lock() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; From 59707da8572bbbffa8060396eb09e6961776981e Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 2 Feb 2026 21:17:01 -0800 Subject: [PATCH 03/13] fix: clarify deprecation message for features.web_search (#10406) clarify that the new `web_search` is not a feature flag under `[features]` in the deprecation CTA --- codex-rs/core/src/features.rs | 2 +- codex-rs/core/tests/suite/deprecation_notice.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 954b3465b6..12789f2c43 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -358,7 +358,7 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option } fn web_search_details() -> &'static str { - "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` in config.toml." + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." } /// Keys accepted in `[features]` tables. diff --git a/codex-rs/core/tests/suite/deprecation_notice.rs b/codex-rs/core/tests/suite/deprecation_notice.rs index 06795cc51b..a8323fcd71 100644 --- a/codex-rs/core/tests/suite/deprecation_notice.rs +++ b/codex-rs/core/tests/suite/deprecation_notice.rs @@ -141,7 +141,9 @@ async fn emits_deprecation_notice_for_web_search_feature_flags() -> anyhow::Resu ); assert_eq!( details.as_deref(), - Some("Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` in config.toml."), + Some( + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." + ), ); Ok(()) @@ -176,7 +178,9 @@ async fn emits_deprecation_notice_for_disabled_web_search_feature_flag() -> anyh ); assert_eq!( details.as_deref(), - Some("Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` in config.toml."), + Some( + "Set `web_search` to `\"live\"`, `\"cached\"`, or `\"disabled\"` at the top level (or under a profile) in config.toml." + ), ); Ok(()) From 53d847406134bdb5b25c014a6b6fed5e484b28be Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 2 Feb 2026 22:47:38 -0800 Subject: [PATCH 04/13] Ignore remote_compact_trims_function_call_history_to_fit_context_window on windows (#10474) --- codex-rs/core/tests/suite/compact_remote.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 55985c9b7a..b0aff9efaa 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -222,6 +222,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { Ok(()) } +#[cfg_attr(target_os = "windows", ignore)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_trims_function_call_history_to_fit_context_window() -> Result<()> { skip_if_no_network!(Ok(())); From f956cc2a02150ab573f638f8843a3bf8a6ed5e62 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 2 Feb 2026 23:33:46 -0800 Subject: [PATCH 05/13] feat(linux-sandbox): vendor bubblewrap and wire it with FFI (#10413) ## Summary Vendor Bubblewrap into the repo and add minimal build plumbing in `codex-linux-sandbox` to compile/link it. ## Why We want to move Linux sandboxing toward Bubblewrap, but in a safe two-step rollout: 1) vendoring/build setup (this PR), 2) runtime integration (follow-up PR). ## Included - Add `codex-rs/vendor/bubblewrap` sources. - Add build-time FFI path in `codex-rs/linux-sandbox`. - Update `build.rs` rerun tracking for vendored files. - Small vendored compile warning fix (`sockaddr_nl` full init). follow up in https://github.com/openai/codex/pull/9938 --- codex-rs/Cargo.lock | 4 + codex-rs/linux-sandbox/Cargo.toml | 6 + codex-rs/linux-sandbox/build.rs | 115 + codex-rs/linux-sandbox/src/bwrap.rs | 291 ++ codex-rs/linux-sandbox/src/lib.rs | 9 +- codex-rs/linux-sandbox/src/linux_run_main.rs | 202 +- codex-rs/linux-sandbox/src/vendored_bwrap.rs | 54 + codex-rs/vendor/bubblewrap/.dir-locals.el | 1 + codex-rs/vendor/bubblewrap/.editorconfig | 5 + .../bubblewrap/.github/workflows/check.yml | 105 + codex-rs/vendor/bubblewrap/CODE-OF-CONDUCT.md | 3 + codex-rs/vendor/bubblewrap/COPYING | 481 +++ codex-rs/vendor/bubblewrap/LICENSE | 1 + codex-rs/vendor/bubblewrap/NEWS.md | 51 + codex-rs/vendor/bubblewrap/README.md | 255 ++ codex-rs/vendor/bubblewrap/SECURITY.md | 42 + codex-rs/vendor/bubblewrap/bind-mount.c | 598 +++ codex-rs/vendor/bubblewrap/bind-mount.h | 54 + codex-rs/vendor/bubblewrap/bubblewrap.c | 3641 +++++++++++++++++ codex-rs/vendor/bubblewrap/bubblewrap.jpg | Bin 0 -> 40239 bytes codex-rs/vendor/bubblewrap/bwrap.xml | 648 +++ codex-rs/vendor/bubblewrap/ci/builddeps.sh | 107 + .../vendor/bubblewrap/ci/enable-userns.sh | 6 + .../vendor/bubblewrap/completions/bash/bwrap | 80 + .../bubblewrap/completions/bash/meson.build | 35 + .../vendor/bubblewrap/completions/meson.build | 7 + .../vendor/bubblewrap/completions/zsh/_bwrap | 115 + .../bubblewrap/completions/zsh/meson.build | 7 + .../bubblewrap/demos/bubblewrap-shell.sh | 34 + .../vendor/bubblewrap/demos/flatpak-run.sh | 65 + codex-rs/vendor/bubblewrap/demos/flatpak.bpf | Bin 0 -> 744 bytes .../bubblewrap/demos/userns-block-fd.py | 39 + codex-rs/vendor/bubblewrap/meson.build | 171 + codex-rs/vendor/bubblewrap/meson_options.txt | 73 + codex-rs/vendor/bubblewrap/network.c | 199 + codex-rs/vendor/bubblewrap/network.h | 22 + .../bubblewrap/packaging/bubblewrap.spec | 48 + .../vendor/bubblewrap/release-checklist.md | 18 + .../vendor/bubblewrap/tests/libtest-core.sh | 190 + codex-rs/vendor/bubblewrap/tests/libtest.sh | 115 + codex-rs/vendor/bubblewrap/tests/meson.build | 72 + codex-rs/vendor/bubblewrap/tests/test-run.sh | 692 ++++ .../vendor/bubblewrap/tests/test-seccomp.py | 635 +++ .../bubblewrap/tests/test-specifying-pidns.sh | 28 + .../tests/test-specifying-userns.sh | 28 + codex-rs/vendor/bubblewrap/tests/test-utils.c | 247 ++ .../vendor/bubblewrap/tests/try-syscall.c | 180 + .../tests/use-as-subproject/.gitignore | 2 + .../bubblewrap/tests/use-as-subproject/README | 3 + .../use-as-subproject/assert-correct-rpath.py | 26 + .../tests/use-as-subproject/config.h | 1 + .../tests/use-as-subproject/dummy-config.h.in | 1 + .../tests/use-as-subproject/meson.build | 20 + codex-rs/vendor/bubblewrap/uncrustify.cfg | 136 + codex-rs/vendor/bubblewrap/uncrustify.sh | 2 + codex-rs/vendor/bubblewrap/utils.c | 1080 +++++ codex-rs/vendor/bubblewrap/utils.h | 217 + 57 files changed, 11261 insertions(+), 6 deletions(-) create mode 100644 codex-rs/linux-sandbox/build.rs create mode 100644 codex-rs/linux-sandbox/src/bwrap.rs create mode 100644 codex-rs/linux-sandbox/src/vendored_bwrap.rs create mode 100644 codex-rs/vendor/bubblewrap/.dir-locals.el create mode 100644 codex-rs/vendor/bubblewrap/.editorconfig create mode 100644 codex-rs/vendor/bubblewrap/.github/workflows/check.yml create mode 100644 codex-rs/vendor/bubblewrap/CODE-OF-CONDUCT.md create mode 100644 codex-rs/vendor/bubblewrap/COPYING create mode 120000 codex-rs/vendor/bubblewrap/LICENSE create mode 100644 codex-rs/vendor/bubblewrap/NEWS.md create mode 100644 codex-rs/vendor/bubblewrap/README.md create mode 100644 codex-rs/vendor/bubblewrap/SECURITY.md create mode 100644 codex-rs/vendor/bubblewrap/bind-mount.c create mode 100644 codex-rs/vendor/bubblewrap/bind-mount.h create mode 100644 codex-rs/vendor/bubblewrap/bubblewrap.c create mode 100644 codex-rs/vendor/bubblewrap/bubblewrap.jpg create mode 100644 codex-rs/vendor/bubblewrap/bwrap.xml create mode 100755 codex-rs/vendor/bubblewrap/ci/builddeps.sh create mode 100755 codex-rs/vendor/bubblewrap/ci/enable-userns.sh create mode 100644 codex-rs/vendor/bubblewrap/completions/bash/bwrap create mode 100644 codex-rs/vendor/bubblewrap/completions/bash/meson.build create mode 100644 codex-rs/vendor/bubblewrap/completions/meson.build create mode 100644 codex-rs/vendor/bubblewrap/completions/zsh/_bwrap create mode 100644 codex-rs/vendor/bubblewrap/completions/zsh/meson.build create mode 100755 codex-rs/vendor/bubblewrap/demos/bubblewrap-shell.sh create mode 100755 codex-rs/vendor/bubblewrap/demos/flatpak-run.sh create mode 100644 codex-rs/vendor/bubblewrap/demos/flatpak.bpf create mode 100755 codex-rs/vendor/bubblewrap/demos/userns-block-fd.py create mode 100644 codex-rs/vendor/bubblewrap/meson.build create mode 100644 codex-rs/vendor/bubblewrap/meson_options.txt create mode 100644 codex-rs/vendor/bubblewrap/network.c create mode 100644 codex-rs/vendor/bubblewrap/network.h create mode 100644 codex-rs/vendor/bubblewrap/packaging/bubblewrap.spec create mode 100644 codex-rs/vendor/bubblewrap/release-checklist.md create mode 100644 codex-rs/vendor/bubblewrap/tests/libtest-core.sh create mode 100644 codex-rs/vendor/bubblewrap/tests/libtest.sh create mode 100644 codex-rs/vendor/bubblewrap/tests/meson.build create mode 100755 codex-rs/vendor/bubblewrap/tests/test-run.sh create mode 100755 codex-rs/vendor/bubblewrap/tests/test-seccomp.py create mode 100755 codex-rs/vendor/bubblewrap/tests/test-specifying-pidns.sh create mode 100755 codex-rs/vendor/bubblewrap/tests/test-specifying-userns.sh create mode 100644 codex-rs/vendor/bubblewrap/tests/test-utils.c create mode 100644 codex-rs/vendor/bubblewrap/tests/try-syscall.c create mode 100644 codex-rs/vendor/bubblewrap/tests/use-as-subproject/.gitignore create mode 100644 codex-rs/vendor/bubblewrap/tests/use-as-subproject/README create mode 100755 codex-rs/vendor/bubblewrap/tests/use-as-subproject/assert-correct-rpath.py create mode 100644 codex-rs/vendor/bubblewrap/tests/use-as-subproject/config.h create mode 100644 codex-rs/vendor/bubblewrap/tests/use-as-subproject/dummy-config.h.in create mode 100644 codex-rs/vendor/bubblewrap/tests/use-as-subproject/meson.build create mode 100644 codex-rs/vendor/bubblewrap/uncrustify.cfg create mode 100755 codex-rs/vendor/bubblewrap/uncrustify.sh create mode 100644 codex-rs/vendor/bubblewrap/utils.c create mode 100644 codex-rs/vendor/bubblewrap/utils.h diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 992a710a12..40a95cc333 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1656,15 +1656,19 @@ dependencies = [ name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ + "cc", "clap", "codex-core", "codex-utils-absolute-path", "landlock", "libc", + "pkg-config", "pretty_assertions", "seccompiler", + "serde_json", "tempfile", "tokio", + "which", ] [[package]] diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 2b63d6b30f..3feb1eb7b8 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -22,6 +22,8 @@ codex-utils-absolute-path = { workspace = true } landlock = { workspace = true } libc = { workspace = true } seccompiler = { workspace = true } +serde_json = { workspace = true } +which = "8.0.0" [target.'cfg(target_os = "linux")'.dev-dependencies] pretty_assertions = { workspace = true } @@ -33,3 +35,7 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/codex-rs/linux-sandbox/build.rs b/codex-rs/linux-sandbox/build.rs new file mode 100644 index 0000000000..6b73e0e21f --- /dev/null +++ b/codex-rs/linux-sandbox/build.rs @@ -0,0 +1,115 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; + +fn main() { + // Tell rustc/clippy that this is an expected cfg value. + println!("cargo:rustc-check-cfg=cfg(vendored_bwrap_available)"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_ENABLE_FFI"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); + + // Rebuild if the vendored bwrap sources change. + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join("bubblewrap.c").display() + ); + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join("bind-mount.c").display() + ); + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join("network.c").display() + ); + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join("utils.c").display() + ); + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" { + return; + } + + // Opt-in: do not attempt to fetch/compile bwrap unless explicitly enabled. + let enable_ffi = matches!(env::var("CODEX_BWRAP_ENABLE_FFI"), Ok(value) if value == "1"); + if !enable_ffi { + return; + } + + if let Err(err) = try_build_vendored_bwrap() { + // Keep normal builds working even if the experiment fails. + println!("cargo:warning=build-time bubblewrap disabled: {err}"); + } +} + +fn try_build_vendored_bwrap() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); + let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; + + let libcap = pkg_config::Config::new() + .probe("libcap") + .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; + + let config_h = out_dir.join("config.h"); + std::fs::write( + &config_h, + r#"#pragma once +#define PACKAGE_STRING "bubblewrap built at codex build-time" +"#, + ) + .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; + + let mut build = cc::Build::new(); + build + .file(src_dir.join("bubblewrap.c")) + .file(src_dir.join("bind-mount.c")) + .file(src_dir.join("network.c")) + .file(src_dir.join("utils.c")) + .include(&out_dir) + .include(&src_dir) + .define("_GNU_SOURCE", None) + // Rename `main` so we can call it via FFI. + .define("main", Some("bwrap_main")); + + for include_path in libcap.include_paths { + build.include(include_path); + } + + build.compile("build_time_bwrap"); + println!("cargo:rustc-cfg=vendored_bwrap_available"); + Ok(()) +} + +/// Resolve the bubblewrap source directory used for build-time compilation. +/// +/// Priority: +/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. +/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. +fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { + if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { + let src_dir = PathBuf::from(path); + if src_dir.exists() { + return Ok(src_dir); + } + return Err(format!( + "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", + src_dir.display() + )); + } + + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + if vendor_dir.exists() { + return Ok(vendor_dir); + } + + Err(format!( + "expected vendored bubblewrap at {}, but it was not found.\n\ +Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", + vendor_dir.display() + )) +} diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs new file mode 100644 index 0000000000..c1e0732e08 --- /dev/null +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -0,0 +1,291 @@ +//! Bubblewrap-based filesystem sandboxing for Linux. +//! +//! This module mirrors the semantics used by the macOS Seatbelt sandbox: +//! - the filesystem is read-only by default, +//! - explicit writable roots are layered on top, and +//! - sensitive subpaths such as `.git` and `.codex` remain read-only even when +//! their parent root is writable. +//! +//! The overall Linux sandbox is composed of: +//! - seccomp + `PR_SET_NO_NEW_PRIVS` applied in-process, and +//! - bubblewrap used to construct the filesystem view before exec. +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_core::error::CodexErr; +use codex_core::error::Result; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::WritableRoot; + +/// Options that control how bubblewrap is invoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct BwrapOptions { + /// Whether to mount a fresh `/proc` inside the PID namespace. + /// + /// This is the secure default, but some restrictive container environments + /// deny `--proc /proc` even when PID namespaces are available. + pub mount_proc: bool, +} + +impl Default for BwrapOptions { + fn default() -> Self { + Self { mount_proc: true } + } +} + +/// Wrap a command with bubblewrap so the filesystem is read-only by default, +/// with explicit writable roots and read-only subpaths layered afterward. +/// +/// When the policy grants full disk write access, this returns `command` +/// unchanged so we avoid unnecessary sandboxing overhead. +pub(crate) fn create_bwrap_command_args( + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: &Path, + options: BwrapOptions, + bwrap_path: Option<&Path>, +) -> Result> { + if sandbox_policy.has_full_disk_write_access() { + return Ok(command); + } + + let bwrap_path = match bwrap_path { + Some(path) => { + if path.exists() { + path.to_path_buf() + } else { + return Err(CodexErr::UnsupportedOperation(format!( + "bubblewrap (bwrap) not found at configured path: {}", + path.display() + ))); + } + } + None => which::which("bwrap").map_err(|err| { + CodexErr::UnsupportedOperation(format!("bubblewrap (bwrap) not found on PATH: {err}")) + })?, + }; + + let mut args = Vec::new(); + args.push(path_to_string(&bwrap_path)); + args.extend(create_bwrap_flags(command, sandbox_policy, cwd, options)?); + Ok(args) +} + +/// Doc-hidden helper that builds bubblewrap arguments without a program path. +/// +/// This is intended for experiments where we call a build-time bubblewrap +/// `main` symbol via FFI rather than exec'ing the `bwrap` binary. The caller +/// is responsible for providing a suitable `argv[0]`. +#[doc(hidden)] +pub(crate) fn create_bwrap_command_args_vendored( + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: &Path, + options: BwrapOptions, +) -> Result> { + if sandbox_policy.has_full_disk_write_access() { + return Ok(command); + } + + create_bwrap_flags(command, sandbox_policy, cwd, options) +} + +/// Build the bubblewrap flags (everything after `argv[0]`). +fn create_bwrap_flags( + command: Vec, + sandbox_policy: &SandboxPolicy, + cwd: &Path, + options: BwrapOptions, +) -> Result> { + let mut args = Vec::new(); + args.push("--new-session".to_string()); + args.push("--die-with-parent".to_string()); + args.extend(create_filesystem_args(sandbox_policy, cwd)?); + // Isolate the PID namespace. + args.push("--unshare-pid".to_string()); + // Mount a fresh /proc unless the caller explicitly disables it. + if options.mount_proc { + args.push("--proc".to_string()); + args.push("/proc".to_string()); + } + args.push("--".to_string()); + args.extend(command); + Ok(args) +} + +/// Build the bubblewrap filesystem mounts for a given sandbox policy. +/// +/// The mount order is important: +/// 1. `--ro-bind / /` makes the entire filesystem read-only. +/// 2. `--bind ` re-enables writes for allowed roots. +/// 3. `--ro-bind ` re-applies read-only protections under +/// those writable roots so protected subpaths win. +/// 4. `--dev-bind /dev/null /dev/null` preserves the common sink even under a +/// read-only root. +fn create_filesystem_args(sandbox_policy: &SandboxPolicy, cwd: &Path) -> Result> { + let writable_roots = sandbox_policy.get_writable_roots_with_cwd(cwd); + ensure_mount_targets_exist(&writable_roots)?; + + let mut args = Vec::new(); + + // Read-only root, then selectively re-enable writes. + args.push("--ro-bind".to_string()); + args.push("/".to_string()); + args.push("/".to_string()); + + for writable_root in &writable_roots { + let root = writable_root.root.as_path(); + args.push("--bind".to_string()); + args.push(path_to_string(root)); + args.push(path_to_string(root)); + } + + // Re-apply read-only subpaths after the writable binds so they win. + let allowed_write_paths: Vec = writable_roots + .iter() + .map(|writable_root| writable_root.root.as_path().to_path_buf()) + .collect(); + + for subpath in collect_read_only_subpaths(&writable_roots) { + if let Some(symlink_path) = find_symlink_in_path(&subpath, &allowed_write_paths) { + args.push("--ro-bind".to_string()); + args.push("/dev/null".to_string()); + args.push(path_to_string(&symlink_path)); + continue; + } + + if !subpath.exists() { + if let Some(first_missing) = find_first_non_existent_component(&subpath) + && is_within_allowed_write_paths(&first_missing, &allowed_write_paths) + { + args.push("--ro-bind".to_string()); + args.push("/dev/null".to_string()); + args.push(path_to_string(&first_missing)); + } + continue; + } + + if is_within_allowed_write_paths(&subpath, &allowed_write_paths) { + args.push("--ro-bind".to_string()); + args.push(path_to_string(&subpath)); + args.push(path_to_string(&subpath)); + } + } + + // Ensure `/dev/null` remains usable regardless of the root bind. + args.push("--dev-bind".to_string()); + args.push("/dev/null".to_string()); + args.push("/dev/null".to_string()); + + Ok(args) +} + +/// Collect unique read-only subpaths across all writable roots. +fn collect_read_only_subpaths(writable_roots: &[WritableRoot]) -> Vec { + let mut subpaths: BTreeSet = BTreeSet::new(); + for writable_root in writable_roots { + for subpath in &writable_root.read_only_subpaths { + subpaths.insert(subpath.as_path().to_path_buf()); + } + } + subpaths.into_iter().collect() +} + +/// Validate that writable roots exist before constructing mounts. +/// +/// Bubblewrap requires bind mount targets to exist. We fail fast with a clear +/// error so callers can present an actionable message. +fn ensure_mount_targets_exist(writable_roots: &[WritableRoot]) -> Result<()> { + for writable_root in writable_roots { + let root = writable_root.root.as_path(); + if !root.exists() { + return Err(CodexErr::UnsupportedOperation(format!( + "Sandbox expected writable root {root}, but it does not exist.", + root = root.display() + ))); + } + } + Ok(()) +} + +fn path_to_string(path: &Path) -> String { + path.to_string_lossy().to_string() +} + +/// Returns true when `path` is under any allowed writable root. +fn is_within_allowed_write_paths(path: &Path, allowed_write_paths: &[PathBuf]) -> bool { + allowed_write_paths + .iter() + .any(|root| path.starts_with(root)) +} + +/// Find the first symlink along `target_path` that is also under a writable root. +/// +/// This blocks symlink replacement attacks where a protected path is a symlink +/// inside a writable root (e.g., `.codex -> ./decoy`). In that case we mount +/// `/dev/null` on the symlink itself to prevent rewiring it. +fn find_symlink_in_path(target_path: &Path, allowed_write_paths: &[PathBuf]) -> Option { + let mut current = PathBuf::new(); + + for component in target_path.components() { + use std::path::Component; + match component { + Component::RootDir => { + current.push(Path::new("/")); + continue; + } + Component::CurDir => continue, + Component::ParentDir => { + current.pop(); + continue; + } + Component::Normal(part) => current.push(part), + Component::Prefix(_) => continue, + } + + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(_) => break, + }; + + if metadata.file_type().is_symlink() + && is_within_allowed_write_paths(¤t, allowed_write_paths) + { + return Some(current); + } + } + + None +} + +/// Find the first missing path component while walking `target_path`. +/// +/// Mounting `/dev/null` on the first missing component prevents the sandboxed +/// process from creating the protected path hierarchy. +fn find_first_non_existent_component(target_path: &Path) -> Option { + let mut current = PathBuf::new(); + + for component in target_path.components() { + use std::path::Component; + match component { + Component::RootDir => { + current.push(Path::new("/")); + continue; + } + Component::CurDir => continue, + Component::ParentDir => { + current.pop(); + continue; + } + Component::Normal(part) => current.push(part), + Component::Prefix(_) => continue, + } + + if !current.exists() { + return Some(current); + } + } + + None +} diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 38ecb8cc21..3347f3f92d 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -1,9 +1,16 @@ +//! Linux sandbox helper entry point. +//! +//! On Linux, `codex-linux-sandbox` applies: +//! - in-process restrictions (`no_new_privs` + seccomp), and +//! - bubblewrap for filesystem isolation. +#[cfg(target_os = "linux")] +mod bwrap; #[cfg(target_os = "linux")] mod landlock; #[cfg(target_os = "linux")] mod linux_run_main; #[cfg(target_os = "linux")] -mod mounts; +mod vendored_bwrap; #[cfg(target_os = "linux")] pub fn run_main() -> ! { diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index c4b0767fb6..3f6cd3ac86 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -1,10 +1,19 @@ use clap::Parser; use std::ffi::CString; +use std::path::Path; use std::path::PathBuf; +use crate::bwrap::BwrapOptions; +use crate::bwrap::create_bwrap_command_args; +use crate::bwrap::create_bwrap_command_args_vendored; use crate::landlock::apply_sandbox_policy_to_current_thread; +use crate::vendored_bwrap::exec_vendored_bwrap; #[derive(Debug, Parser)] +/// CLI surface for the Linux sandbox helper. +/// +/// The type name remains `LandlockCommand` for compatibility with existing +/// wiring, but the filesystem sandbox now uses bubblewrap. pub struct LandlockCommand { /// It is possible that the cwd used in the context of the sandbox policy /// is different from the cwd of the process to spawn. @@ -14,26 +23,179 @@ pub struct LandlockCommand { #[arg(long = "sandbox-policy")] pub sandbox_policy: codex_core::protocol::SandboxPolicy, - /// Full command args to run under landlock. + /// Opt-in: use the bubblewrap-based Linux sandbox pipeline. + /// + /// When not set, we fall back to the legacy Landlock + mount pipeline. + #[arg(long = "use-bwrap-sandbox", hide = true, default_value_t = false)] + pub use_bwrap_sandbox: bool, + + /// Optional explicit path to the `bwrap` binary to use. + /// + /// When provided, this implies bubblewrap opt-in and avoids PATH lookups. + #[arg(long = "bwrap-path", hide = true)] + pub bwrap_path: Option, + + /// Experimental: call a build-time bubblewrap `main()` via FFI. + /// + /// This is opt-in and only works when the build script compiles bwrap. + #[arg(long = "use-vendored-bwrap", hide = true, default_value_t = false)] + pub use_vendored_bwrap: bool, + + /// Internal: apply seccomp and `no_new_privs` in the already-sandboxed + /// process, then exec the user command. + /// + /// This exists so we can run bubblewrap first (which may rely on setuid) + /// and only tighten with seccomp after the filesystem view is established. + #[arg(long = "apply-seccomp-then-exec", hide = true, default_value_t = false)] + pub apply_seccomp_then_exec: bool, + + /// When set, skip mounting a fresh `/proc` even though PID isolation is + /// still enabled. This is primarily intended for restrictive container + /// environments that deny `--proc /proc`. + #[arg(long = "no-proc", default_value_t = false)] + pub no_proc: bool, + + /// Full command args to run under the Linux sandbox helper. #[arg(trailing_var_arg = true)] pub command: Vec, } +/// Entry point for the Linux sandbox helper. +/// +/// The sequence is: +/// 1. When needed, wrap the command with bubblewrap to construct the +/// filesystem view. +/// 2. Apply in-process restrictions (no_new_privs + seccomp). +/// 3. `execvp` into the final command. pub fn run_main() -> ! { let LandlockCommand { sandbox_policy_cwd, sandbox_policy, + use_bwrap_sandbox, + bwrap_path, + use_vendored_bwrap, + apply_seccomp_then_exec, + no_proc, command, } = LandlockCommand::parse(); - - if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd) { - panic!("error running landlock: {e:?}"); - } + let use_bwrap_sandbox = use_bwrap_sandbox || bwrap_path.is_some() || use_vendored_bwrap; if command.is_empty() { panic!("No command specified to execute."); } + // Inner stage: apply seccomp/no_new_privs after bubblewrap has already + // established the filesystem view. + if apply_seccomp_then_exec { + if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd) + { + panic!("error applying Linux sandbox restrictions: {e:?}"); + } + exec_or_panic(command); + } + + let command = if sandbox_policy.has_full_disk_write_access() { + if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd) + { + panic!("error applying Linux sandbox restrictions: {e:?}"); + } + command + } else if use_bwrap_sandbox { + // Outer stage: bubblewrap first, then re-enter this binary in the + // sandboxed environment to apply seccomp. + let inner = build_inner_seccomp_command( + &sandbox_policy_cwd, + &sandbox_policy, + use_bwrap_sandbox, + bwrap_path.as_deref(), + command, + ); + let options = BwrapOptions { + mount_proc: !no_proc, + }; + if use_vendored_bwrap { + let mut argv0 = bwrap_path + .as_deref() + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| "bwrap".to_string()); + if argv0.is_empty() { + argv0 = "bwrap".to_string(); + } + + let mut argv = vec![argv0]; + argv.extend( + create_bwrap_command_args_vendored( + inner, + &sandbox_policy, + &sandbox_policy_cwd, + options, + ) + .unwrap_or_else(|err| { + panic!("error building build-time bubblewrap command: {err:?}") + }), + ); + exec_vendored_bwrap(argv); + } + ensure_bwrap_available(bwrap_path.as_deref()); + create_bwrap_command_args( + inner, + &sandbox_policy, + &sandbox_policy_cwd, + options, + bwrap_path.as_deref(), + ) + .unwrap_or_else(|err| panic!("error building bubblewrap command: {err:?}")) + } else { + // Legacy path: Landlock enforcement only. + if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &sandbox_policy_cwd) + { + panic!("error applying legacy Linux sandbox restrictions: {e:?}"); + } + command + }; + + exec_or_panic(command); +} + +/// Build the inner command that applies seccomp after bubblewrap. +fn build_inner_seccomp_command( + sandbox_policy_cwd: &Path, + sandbox_policy: &codex_core::protocol::SandboxPolicy, + use_bwrap_sandbox: bool, + bwrap_path: Option<&Path>, + command: Vec, +) -> Vec { + let current_exe = match std::env::current_exe() { + Ok(path) => path, + Err(err) => panic!("failed to resolve current executable path: {err}"), + }; + let policy_json = match serde_json::to_string(sandbox_policy) { + Ok(json) => json, + Err(err) => panic!("failed to serialize sandbox policy: {err}"), + }; + + let mut inner = vec![ + current_exe.to_string_lossy().to_string(), + "--sandbox-policy-cwd".to_string(), + sandbox_policy_cwd.to_string_lossy().to_string(), + "--sandbox-policy".to_string(), + policy_json, + ]; + if use_bwrap_sandbox { + inner.push("--use-bwrap-sandbox".to_string()); + inner.push("--apply-seccomp-then-exec".to_string()); + } + if let Some(bwrap_path) = bwrap_path { + inner.push("--bwrap-path".to_string()); + inner.push(bwrap_path.to_string_lossy().to_string()); + } + inner.push("--".to_string()); + inner.extend(command); + inner +} + +/// Exec the provided argv, panicking with context if it fails. +fn exec_or_panic(command: Vec) -> ! { #[expect(clippy::expect_used)] let c_command = CString::new(command[0].as_str()).expect("Failed to convert command to CString"); @@ -54,3 +216,33 @@ pub fn run_main() -> ! { let err = std::io::Error::last_os_error(); panic!("Failed to execvp {}: {err}", command[0].as_str()); } + +/// Ensure the `bwrap` binary is available when the sandbox needs it. +fn ensure_bwrap_available(bwrap_path: Option<&Path>) { + if let Some(path) = bwrap_path { + if path.exists() { + return; + } + panic!( + "bubblewrap (bwrap) is required for Linux filesystem sandboxing but was not found at the configured path: {}\n\ +Install it and retry. Examples:\n\ +- Debian/Ubuntu: apt-get install bubblewrap\n\ +- Fedora/RHEL: dnf install bubblewrap\n\ +- Arch: pacman -S bubblewrap\n\ +If you are running the Codex Node package, ensure bwrap is installed on the host system.", + path.display() + ); + } + if which::which("bwrap").is_ok() { + return; + } + + panic!( + "bubblewrap (bwrap) is required for Linux filesystem sandboxing but was not found on PATH.\n\ +Install it and retry. Examples:\n\ +- Debian/Ubuntu: apt-get install bubblewrap\n\ +- Fedora/RHEL: dnf install bubblewrap\n\ +- Arch: pacman -S bubblewrap\n\ +If you are running the Codex Node package, ensure bwrap is installed on the host system." + ); +} diff --git a/codex-rs/linux-sandbox/src/vendored_bwrap.rs b/codex-rs/linux-sandbox/src/vendored_bwrap.rs new file mode 100644 index 0000000000..ab4fb959ef --- /dev/null +++ b/codex-rs/linux-sandbox/src/vendored_bwrap.rs @@ -0,0 +1,54 @@ +//! Build-time bubblewrap entrypoint. +//! +//! This module is intentionally behind a build-time opt-in. When enabled, the +//! build script compiles bubblewrap's C sources and exposes a `bwrap_main` +//! symbol that we can call via FFI. + +#[cfg(vendored_bwrap_available)] +mod imp { + use std::ffi::CString; + use std::os::raw::c_char; + + unsafe extern "C" { + fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; + } + + /// Execute the build-time bubblewrap `main` function with the given argv. + pub(crate) fn exec_vendored_bwrap(argv: Vec) -> ! { + let mut cstrings: Vec = Vec::with_capacity(argv.len()); + for arg in &argv { + match CString::new(arg.as_str()) { + Ok(value) => cstrings.push(value), + Err(err) => panic!("failed to convert argv to CString: {err}"), + } + } + + let mut argv_ptrs: Vec<*const c_char> = cstrings.iter().map(|arg| arg.as_ptr()).collect(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: We provide a null-terminated argv vector whose pointers + // remain valid for the duration of the call. + let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }; + std::process::exit(exit_code); + } +} + +#[cfg(not(vendored_bwrap_available))] +mod imp { + /// Panics with a clear error when the build-time bwrap path is not enabled. + pub(crate) fn exec_vendored_bwrap(_argv: Vec) -> ! { + panic!( + "build-time bubblewrap is not available in this build.\n\ +Rebuild codex-linux-sandbox on Linux with CODEX_BWRAP_ENABLE_FFI=1.\n\ +Example:\n\ +- cd codex-rs && CODEX_BWRAP_ENABLE_FFI=1 cargo build -p codex-linux-sandbox\n\ +If this crate was already built without it, run:\n\ +- cargo clean -p codex-linux-sandbox\n\ +Notes:\n\ +- libcap headers must be available via pkg-config\n\ +- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)" + ); + } +} + +pub(crate) use imp::exec_vendored_bwrap; diff --git a/codex-rs/vendor/bubblewrap/.dir-locals.el b/codex-rs/vendor/bubblewrap/.dir-locals.el new file mode 100644 index 0000000000..3514040895 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/.dir-locals.el @@ -0,0 +1 @@ +((c-mode . ((indent-tabs-mode . nil) (c-file-style . "gnu")))) diff --git a/codex-rs/vendor/bubblewrap/.editorconfig b/codex-rs/vendor/bubblewrap/.editorconfig new file mode 100644 index 0000000000..89742b6981 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/.editorconfig @@ -0,0 +1,5 @@ +[*.[ch]] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true +indent_brace_style = gnu diff --git a/codex-rs/vendor/bubblewrap/.github/workflows/check.yml b/codex-rs/vendor/bubblewrap/.github/workflows/check.yml new file mode 100644 index 0000000000..8a747d52ca --- /dev/null +++ b/codex-rs/vendor/bubblewrap/.github/workflows/check.yml @@ -0,0 +1,105 @@ +name: CI checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + meson: + name: Build with Meson and gcc, and test + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Install build-dependencies + run: sudo ./ci/builddeps.sh + - name: Enable user namespaces + run: sudo ./ci/enable-userns.sh + - name: Create logs dir + run: mkdir test-logs + - name: setup + run: | + meson _build + env: + CFLAGS: >- + -O2 + -Wp,-D_FORTIFY_SOURCE=2 + -fsanitize=address + -fsanitize=undefined + - name: compile + run: ninja -C _build -v + - name: smoke-test + run: | + set -x + ./_build/bwrap --bind / / --tmpfs /tmp true + env: + ASAN_OPTIONS: detect_leaks=0 + - name: test + run: | + BWRAP_MUST_WORK=1 meson test -C _build + env: + ASAN_OPTIONS: detect_leaks=0 + - name: Collect overall test logs on failure + if: failure() + run: mv _build/meson-logs/testlog.txt test-logs/ || true + - name: install + run: | + DESTDIR="$(pwd)/DESTDIR" meson install -C _build + ( cd DESTDIR && find -ls ) + - name: dist + run: | + BWRAP_MUST_WORK=1 meson dist -C _build + - name: Collect dist test logs on failure + if: failure() + run: mv _build/meson-private/dist-build/meson-logs/testlog.txt test-logs/disttestlog.txt || true + - name: use as subproject + run: | + mkdir tests/use-as-subproject/subprojects + tar -C tests/use-as-subproject/subprojects -xf _build/meson-dist/bubblewrap-*.tar.xz + mv tests/use-as-subproject/subprojects/bubblewrap-* tests/use-as-subproject/subprojects/bubblewrap + ( cd tests/use-as-subproject && meson _build ) + ninja -C tests/use-as-subproject/_build -v + meson test -C tests/use-as-subproject/_build + DESTDIR="$(pwd)/DESTDIR-as-subproject" meson install -C tests/use-as-subproject/_build + ( cd DESTDIR-as-subproject && find -ls ) + test -x DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap + test ! -e DESTDIR-as-subproject/usr/local/bin/bwrap + test ! -e DESTDIR-as-subproject/usr/local/libexec/bwrap + tests/use-as-subproject/assert-correct-rpath.py DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap + - name: Upload test logs + uses: actions/upload-artifact@v4 + if: failure() || cancelled() + with: + name: test logs + path: test-logs + + clang: + name: Build with clang and analyze + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: + - cpp + steps: + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + - name: Check out + uses: actions/checkout@v4 + - name: Install build-dependencies + run: sudo ./ci/builddeps.sh --clang + - run: meson build -Dselinux=enabled + env: + CC: clang + CFLAGS: >- + -O2 + -Werror=unused-variable + - run: meson compile -C build + - name: CodeQL analysis + uses: github/codeql-action/analyze@v2 diff --git a/codex-rs/vendor/bubblewrap/CODE-OF-CONDUCT.md b/codex-rs/vendor/bubblewrap/CODE-OF-CONDUCT.md new file mode 100644 index 0000000000..8c417a6096 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/CODE-OF-CONDUCT.md @@ -0,0 +1,3 @@ +## The bubblewrap Project Community Code of Conduct + +The bubblewrap project follows the [Containers Community Code of Conduct](https://github.com/containers/common/blob/HEAD/CODE-OF-CONDUCT.md). diff --git a/codex-rs/vendor/bubblewrap/COPYING b/codex-rs/vendor/bubblewrap/COPYING new file mode 100644 index 0000000000..5bc8fb2c8f --- /dev/null +++ b/codex-rs/vendor/bubblewrap/COPYING @@ -0,0 +1,481 @@ + GNU LIBRARY GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + +[This is the first released version of the library GPL. It is + numbered 2 because it goes with version 2 of the ordinary GPL.] + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +Licenses are intended to guarantee your freedom to share and change +free software--to make sure the software is free for all its users. + + This license, the Library General Public License, applies to some +specially designated Free Software Foundation software, and to any +other libraries whose authors decide to use it. You can use it for +your libraries, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if +you distribute copies of the library, or if you modify it. + + For example, if you distribute copies of the library, whether gratis +or for a fee, you must give the recipients all the rights that we gave +you. You must make sure that they, too, receive or can get the source +code. If you link a program with the library, you must provide +complete object files to the recipients so that they can relink them +with the library, after making changes to the library and recompiling +it. And you must show them these terms so they know their rights. + + Our method of protecting your rights has two steps: (1) copyright +the library, and (2) offer you this license which gives you legal +permission to copy, distribute and/or modify the library. + + Also, for each distributor's protection, we want to make certain +that everyone understands that there is no warranty for this free +library. If the library is modified by someone else and passed on, we +want its recipients to know that what they have is not the original +version, so that any problems introduced by others will not reflect on +the original authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that companies distributing free +software will individually obtain patent licenses, thus in effect +transforming the program into proprietary software. To prevent this, +we have made it clear that any patent must be licensed for everyone's +free use or not licensed at all. + + Most GNU software, including some libraries, is covered by the ordinary +GNU General Public License, which was designed for utility programs. This +license, the GNU Library General Public License, applies to certain +designated libraries. This license is quite different from the ordinary +one; be sure to read it in full, and don't assume that anything in it is +the same as in the ordinary license. + + The reason we have a separate public license for some libraries is that +they blur the distinction we usually make between modifying or adding to a +program and simply using it. Linking a program with a library, without +changing the library, is in some sense simply using the library, and is +analogous to running a utility program or application program. However, in +a textual and legal sense, the linked executable is a combined work, a +derivative of the original library, and the ordinary General Public License +treats it as such. + + Because of this blurred distinction, using the ordinary General +Public License for libraries did not effectively promote software +sharing, because most developers did not use the libraries. We +concluded that weaker conditions might promote sharing better. + + However, unrestricted linking of non-free programs would deprive the +users of those programs of all benefit from the free status of the +libraries themselves. This Library General Public License is intended to +permit developers of non-free programs to use free libraries, while +preserving your freedom as a user of such programs to change the free +libraries that are incorporated in them. (We have not seen how to achieve +this as regards changes in header files, but we have achieved it as regards +changes in the actual functions of the Library.) The hope is that this +will lead to faster development of free libraries. + + The precise terms and conditions for copying, distribution and +modification follow. Pay close attention to the difference between a +"work based on the library" and a "work that uses the library". The +former contains code derived from the library, while the latter only +works together with the library. + + Note that it is possible for a library to be covered by the ordinary +General Public License rather than by this special one. + + GNU LIBRARY GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License Agreement applies to any software library which +contains a notice placed by the copyright holder or other authorized +party saying it may be distributed under the terms of this Library +General Public License (also called "this License"). Each licensee is +addressed as "you". + + A "library" means a collection of software functions and/or data +prepared so as to be conveniently linked with application programs +(which use some of those functions and data) to form executables. + + The "Library", below, refers to any such software library or work +which has been distributed under these terms. A "work based on the +Library" means either the Library or any derivative work under +copyright law: that is to say, a work containing the Library or a +portion of it, either verbatim or with modifications and/or translated +straightforwardly into another language. (Hereinafter, translation is +included without limitation in the term "modification".) + + "Source code" for a work means the preferred form of the work for +making modifications to it. For a library, complete source code means +all the source code for all modules it contains, plus any associated +interface definition files, plus the scripts used to control compilation +and installation of the library. + + Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running a program using the Library is not restricted, and output from +such a program is covered only if its contents constitute a work based +on the Library (independent of the use of the Library in a tool for +writing it). Whether that is true depends on what the Library does +and what the program that uses the Library does. + + 1. You may copy and distribute verbatim copies of the Library's +complete source code as you receive it, in any medium, provided that +you conspicuously and appropriately publish on each copy an +appropriate copyright notice and disclaimer of warranty; keep intact +all the notices that refer to this License and to the absence of any +warranty; and distribute a copy of this License along with the +Library. + + You may charge a fee for the physical act of transferring a copy, +and you may at your option offer warranty protection in exchange for a +fee. + + 2. You may modify your copy or copies of the Library or any portion +of it, thus forming a work based on the Library, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) The modified work must itself be a software library. + + b) You must cause the files modified to carry prominent notices + stating that you changed the files and the date of any change. + + c) You must cause the whole of the work to be licensed at no + charge to all third parties under the terms of this License. + + d) If a facility in the modified Library refers to a function or a + table of data to be supplied by an application program that uses + the facility, other than as an argument passed when the facility + is invoked, then you must make a good faith effort to ensure that, + in the event an application does not supply such function or + table, the facility still operates, and performs whatever part of + its purpose remains meaningful. + + (For example, a function in a library to compute square roots has + a purpose that is entirely well-defined independent of the + application. Therefore, Subsection 2d requires that any + application-supplied function or table used by this function must + be optional: if the application does not supply it, the square + root function must still compute square roots.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Library, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Library, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote +it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Library. + +In addition, mere aggregation of another work not based on the Library +with the Library (or with a work based on the Library) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may opt to apply the terms of the ordinary GNU General Public +License instead of this License to a given copy of the Library. To do +this, you must alter all the notices that refer to this License, so +that they refer to the ordinary GNU General Public License, version 2, +instead of to this License. (If a newer version than version 2 of the +ordinary GNU General Public License has appeared, then you can specify +that version instead if you wish.) Do not make any other change in +these notices. + + Once this change is made in a given copy, it is irreversible for +that copy, so the ordinary GNU General Public License applies to all +subsequent copies and derivative works made from that copy. + + This option is useful when you wish to copy part of the code of +the Library into a program that is not a library. + + 4. You may copy and distribute the Library (or a portion or +derivative of it, under Section 2) in object code or executable form +under the terms of Sections 1 and 2 above provided that you accompany +it with the complete corresponding machine-readable source code, which +must be distributed under the terms of Sections 1 and 2 above on a +medium customarily used for software interchange. + + If distribution of object code is made by offering access to copy +from a designated place, then offering equivalent access to copy the +source code from the same place satisfies the requirement to +distribute the source code, even though third parties are not +compelled to copy the source along with the object code. + + 5. A program that contains no derivative of any portion of the +Library, but is designed to work with the Library by being compiled or +linked with it, is called a "work that uses the Library". Such a +work, in isolation, is not a derivative work of the Library, and +therefore falls outside the scope of this License. + + However, linking a "work that uses the Library" with the Library +creates an executable that is a derivative of the Library (because it +contains portions of the Library), rather than a "work that uses the +library". The executable is therefore covered by this License. +Section 6 states terms for distribution of such executables. + + When a "work that uses the Library" uses material from a header file +that is part of the Library, the object code for the work may be a +derivative work of the Library even though the source code is not. +Whether this is true is especially significant if the work can be +linked without the Library, or if the work is itself a library. The +threshold for this to be true is not precisely defined by law. + + If such an object file uses only numerical parameters, data +structure layouts and accessors, and small macros and small inline +functions (ten lines or less in length), then the use of the object +file is unrestricted, regardless of whether it is legally a derivative +work. (Executables containing this object code plus portions of the +Library will still fall under Section 6.) + + Otherwise, if the work is a derivative of the Library, you may +distribute the object code for the work under the terms of Section 6. +Any executables containing that work also fall under Section 6, +whether or not they are linked directly with the Library itself. + + 6. As an exception to the Sections above, you may also compile or +link a "work that uses the Library" with the Library to produce a +work containing portions of the Library, and distribute that work +under terms of your choice, provided that the terms permit +modification of the work for the customer's own use and reverse +engineering for debugging such modifications. + + You must give prominent notice with each copy of the work that the +Library is used in it and that the Library and its use are covered by +this License. You must supply a copy of this License. If the work +during execution displays copyright notices, you must include the +copyright notice for the Library among them, as well as a reference +directing the user to the copy of this License. Also, you must do one +of these things: + + a) Accompany the work with the complete corresponding + machine-readable source code for the Library including whatever + changes were used in the work (which must be distributed under + Sections 1 and 2 above); and, if the work is an executable linked + with the Library, with the complete machine-readable "work that + uses the Library", as object code and/or source code, so that the + user can modify the Library and then relink to produce a modified + executable containing the modified Library. (It is understood + that the user who changes the contents of definitions files in the + Library will not necessarily be able to recompile the application + to use the modified definitions.) + + b) Accompany the work with a written offer, valid for at + least three years, to give the same user the materials + specified in Subsection 6a, above, for a charge no more + than the cost of performing this distribution. + + c) If distribution of the work is made by offering access to copy + from a designated place, offer equivalent access to copy the above + specified materials from the same place. + + d) Verify that the user has already received a copy of these + materials or that you have already sent this user a copy. + + For an executable, the required form of the "work that uses the +Library" must include any data and utility programs needed for +reproducing the executable from it. However, as a special exception, +the source code distributed need not include anything that is normally +distributed (in either source or binary form) with the major +components (compiler, kernel, and so on) of the operating system on +which the executable runs, unless that component itself accompanies +the executable. + + It may happen that this requirement contradicts the license +restrictions of other proprietary libraries that do not normally +accompany the operating system. Such a contradiction means you cannot +use both them and the Library together in an executable that you +distribute. + + 7. You may place library facilities that are a work based on the +Library side-by-side in a single library together with other library +facilities not covered by this License, and distribute such a combined +library, provided that the separate distribution of the work based on +the Library and of the other library facilities is otherwise +permitted, and provided that you do these two things: + + a) Accompany the combined library with a copy of the same work + based on the Library, uncombined with any other library + facilities. This must be distributed under the terms of the + Sections above. + + b) Give prominent notice with the combined library of the fact + that part of it is a work based on the Library, and explaining + where to find the accompanying uncombined form of the same work. + + 8. You may not copy, modify, sublicense, link with, or distribute +the Library except as expressly provided under this License. Any +attempt otherwise to copy, modify, sublicense, link with, or +distribute the Library is void, and will automatically terminate your +rights under this License. However, parties who have received copies, +or rights, from you under this License will not have their licenses +terminated so long as such parties remain in full compliance. + + 9. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Library or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Library (or any work based on the +Library), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Library or works based on it. + + 10. Each time you redistribute the Library (or any work based on the +Library), the recipient automatically receives a license from the +original licensor to copy, distribute, link with or modify the Library +subject to these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 11. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Library at all. For example, if a patent +license would not permit royalty-free redistribution of the Library by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Library. + +If any portion of this section is held invalid or unenforceable under any +particular circumstance, the balance of the section is intended to apply, +and the section as a whole is intended to apply in other circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 12. If the distribution and/or use of the Library is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Library under this License may add +an explicit geographical distribution limitation excluding those countries, +so that distribution is permitted only in or among countries not thus +excluded. In such case, this License incorporates the limitation as if +written in the body of this License. + + 13. The Free Software Foundation may publish revised and/or new +versions of the Library General Public License from time to time. +Such new versions will be similar in spirit to the present version, +but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Library +specifies a version number of this License which applies to it and +"any later version", you have the option of following the terms and +conditions either of that version or of any later version published by +the Free Software Foundation. If the Library does not specify a +license version number, you may choose any version ever published by +the Free Software Foundation. + + 14. If you wish to incorporate parts of the Library into other free +programs whose distribution conditions are incompatible with these, +write to the author to ask for permission. For software which is +copyrighted by the Free Software Foundation, write to the Free +Software Foundation; we sometimes make exceptions for this. Our +decision will be guided by the two goals of preserving the free status +of all derivatives of our free software and of promoting the sharing +and reuse of software generally. + + NO WARRANTY + + 15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO +WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. +EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY +KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE +LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME +THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN +WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY +AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU +FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR +CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE +LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING +RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A +FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF +SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Libraries + + If you develop a new library, and you want it to be of the greatest +possible use to the public, we recommend making it free software that +everyone can redistribute and change. You can do so by permitting +redistribution under these terms (or, alternatively, under the terms of the +ordinary General Public License). + + To apply these terms, attach the following notices to the library. It is +safest to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This library is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2 of the License, or (at your option) any later version. + + This library is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Library General Public License for more details. + + You should have received a copy of the GNU Library General Public + License along with this library; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +Also add information on how to contact you by electronic and paper mail. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the library, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the + library `Frob' (a library for tweaking knobs) written by James Random Hacker. + + , 1 April 1990 + Ty Coon, President of Vice + +That's all there is to it! diff --git a/codex-rs/vendor/bubblewrap/LICENSE b/codex-rs/vendor/bubblewrap/LICENSE new file mode 120000 index 0000000000..d24842f3cd --- /dev/null +++ b/codex-rs/vendor/bubblewrap/LICENSE @@ -0,0 +1 @@ +COPYING \ No newline at end of file diff --git a/codex-rs/vendor/bubblewrap/NEWS.md b/codex-rs/vendor/bubblewrap/NEWS.md new file mode 100644 index 0000000000..da232c4bd7 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/NEWS.md @@ -0,0 +1,51 @@ +bubblewrap 0.11.0 +================= + +Released: 2024-10-30 + +Dependencies: + + * Remove the Autotools build system. Meson ≥ 0.49.0 is now required + at build-time. (#625, Hugo Osvaldo Barrera) + + * For users of bash-completion, bash-completion ≥ 2.10 is recommended. + With older bash-completion, bubblewrap might install completions + outside its `${prefix}` unless overridden with `-Dbash_completion_dir=…`. + +Enhancements: + + * New `--overlay`, `--tmp-overlay`, `--ro-overlay` and `--overlay-src` + options allow creation of overlay mounts. + This feature is not available when bubblewrap is installed setuid. + (#412, #663; Ryan Hendrickson, William Manley, Simon McVittie) + + * New `--level-prefix` option produces output that can be parsed by + tools like `logger --prio-prefix` and `systemd-cat --level-prefix=1` + (#646, Simon McVittie) + +Bug fixes: + + * Handle `EINTR` when doing I/O on files or sockets (#657, Simon McVittie) + + * Don't make assumptions about alignment of socket control message data + (#637, Simon McVittie) + + * Silence some Meson deprecation warnings (#647, @Sertonix) + + * Update URLs in documentation to https (#566, @TotalCaesar659) + + * Improve tests' compatibility with busybox (#627, @Sertonix) + + * Improve compatibility with Meson < 1.3.0 (#664, Simon McVittie) + +Internal changes: + + * Consistently use `` for booleans (#660, Simon McVittie) + + * Avoid `-Wshadow` compiler warnings (#661, Simon McVittie) + + * Update Github Actions configuration (#658, Simon McVittie) + +---- + +See also diff --git a/codex-rs/vendor/bubblewrap/README.md b/codex-rs/vendor/bubblewrap/README.md new file mode 100644 index 0000000000..c16cd7d89a --- /dev/null +++ b/codex-rs/vendor/bubblewrap/README.md @@ -0,0 +1,255 @@ +Bubblewrap +========== + +Many container runtime tools like `systemd-nspawn`, `docker`, +etc. focus on providing infrastructure for system administrators and +orchestration tools (e.g. Kubernetes) to run containers. + +These tools are not suitable to give to unprivileged users, because it +is trivial to turn such access into a fully privileged root shell +on the host. + +User namespaces +--------------- + +There is an effort in the Linux kernel called +[user namespaces](https://www.google.com/search?q=user+namespaces+site%3Ahttps%3A%2F%2Flwn.net) +which attempts to allow unprivileged users to use container features. +While significant progress has been made, there are +[still concerns](https://lwn.net/Articles/673597/) about it, and +it is not available to unprivileged users in several production distributions +such as CentOS/Red Hat Enterprise Linux 7, Debian Jessie, etc. + +See for example +[CVE-2016-3135](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-3135) +which is a local root vulnerability introduced by userns. +[This March 2016 post](https://lkml.org/lkml/2016/3/9/555) has some +more discussion. + +Bubblewrap could be viewed as setuid implementation of a *subset* of +user namespaces. Emphasis on subset - specifically relevant to the +above CVE, bubblewrap does not allow control over iptables. + +The original bubblewrap code existed before user namespaces - it inherits code from +[xdg-app helper](https://cgit.freedesktop.org/xdg-app/xdg-app/tree/common/xdg-app-helper.c?id=4c3bf179e2e4a2a298cd1db1d045adaf3f564532) +which in turn distantly derives from +[linux-user-chroot](https://git.gnome.org/browse/linux-user-chroot). + +System security +--------------- + +The maintainers of this tool believe that it does not, even when used +in combination with typical software installed on that distribution, +allow privilege escalation. It may increase the ability of a logged +in user to perform denial of service attacks, however. + +In particular, bubblewrap uses `PR_SET_NO_NEW_PRIVS` to turn off +setuid binaries, which is the [traditional way](https://en.wikipedia.org/wiki/Chroot#Limitations) to get out of things +like chroots. + +Sandbox security +---------------- + +bubblewrap is a tool for constructing sandbox environments. +bubblewrap is not a complete, ready-made sandbox with a specific security +policy. + +Some of bubblewrap's use-cases want a security boundary between the sandbox +and the real system; other use-cases want the ability to change the layout of +the filesystem for processes inside the sandbox, but do not aim to be a +security boundary. +As a result, the level of protection between the sandboxed processes and +the host system is entirely determined by the arguments passed to +bubblewrap. + +Whatever program constructs the command-line arguments for bubblewrap +(often a larger framework like Flatpak, libgnome-desktop, sandwine +or an ad-hoc script) is responsible for defining its own security model, +and choosing appropriate bubblewrap command-line arguments to implement +that security model. + +Some aspects of sandbox security that require particular care are described +in the [Limitations](#limitations) section below. + +Users +----- + +This program can be shared by all container tools which perform +non-root operation, such as: + + - [Flatpak](https://www.flatpak.org) + - [rpm-ostree unprivileged](https://github.com/projectatomic/rpm-ostree/pull/209) + - [bwrap-oci](https://github.com/projectatomic/bwrap-oci) + +We would also like to see this be available in Kubernetes/OpenShift +clusters. Having the ability for unprivileged users to use container +features would make it significantly easier to do interactive +debugging scenarios and the like. + +Installation +------------ + +bubblewrap is available in the package repositories of the most Linux distributions +and can be installed from there. + +If you need to build bubblewrap from source, you can do this with meson: + +```sh +meson _builddir +meson compile -C _builddir +meson test -C _builddir +meson install -C _builddir +``` + +Usage +----- + +bubblewrap works by creating a new, completely empty, mount +namespace where the root is on a tmpfs that is invisible from the +host, and will be automatically cleaned up when the last process +exits. You can then use commandline options to construct the root +filesystem and process environment and command to run in the +namespace. + +There's a larger [demo script](./demos/bubblewrap-shell.sh) in the +source code, but here's a trimmed down version which runs +a new shell reusing the host's `/usr`. + +``` +bwrap \ + --ro-bind /usr /usr \ + --symlink usr/lib64 /lib64 \ + --proc /proc \ + --dev /dev \ + --unshare-pid \ + --new-session \ + bash +``` + +This is an incomplete example, but useful for purposes of +illustration. More often, rather than creating a container using the +host's filesystem tree, you want to target a chroot. There, rather +than creating the symlink `lib64 -> usr/lib64` in the tmpfs, you might +have already created it in the target rootfs. + +Sandboxing +---------- + +The goal of bubblewrap is to run an application in a sandbox, where it +has restricted access to parts of the operating system or user data +such as the home directory. + +bubblewrap always creates a new mount namespace, and the user can specify +exactly what parts of the filesystem should be visible in the sandbox. +Any such directories you specify mounted `nodev` by default, and can be made readonly. + +Additionally you can use these kernel features: + +User namespaces ([CLONE_NEWUSER](https://linux.die.net/man/2/clone)): This hides all but the current uid and gid from the +sandbox. You can also change what the value of uid/gid should be in the sandbox. + +IPC namespaces ([CLONE_NEWIPC](https://linux.die.net/man/2/clone)): The sandbox will get its own copy of all the +different forms of IPCs, like SysV shared memory and semaphores. + +PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/). + + +Network namespaces ([CLONE_NEWNET](https://linux.die.net/man/2/clone)): The sandbox will not see the network. Instead it will have its own network namespace with only a loopback device. + +UTS namespace ([CLONE_NEWUTS](https://linux.die.net/man/2/clone)): The sandbox will have its own hostname. + +Seccomp filters: You can pass in seccomp filters that limit which syscalls can be done in the sandbox. For more information, see [Seccomp](https://en.wikipedia.org/wiki/Seccomp). + +Limitations +----------- + +As noted in the [Sandbox security](#sandbox-security) section above, +the level of protection between the sandboxed processes and the host system +is entirely determined by the arguments passed to bubblewrap. +Some aspects that require special care are noted here. + +- If you are not filtering out `TIOCSTI` commands using seccomp filters, +argument `--new-session` is needed to protect against out-of-sandbox +command execution +(see [CVE-2017-5226](https://github.com/containers/bubblewrap/issues/142)). + +- Everything mounted into the sandbox can potentially be used to escalate +privileges. +For example, if you bind a D-Bus socket into the sandbox, it can be used to +execute commands via systemd. You can use +[xdg-dbus-proxy](https://github.com/flatpak/xdg-dbus-proxy) to filter +D-Bus communication. + +- Some applications deploy their own sandboxing mechanisms, and these can be +restricted by the constraints imposed by bubblewrap's sandboxing. +For example, some web browsers which configure their child proccesses via +seccomp to not have access to the filesystem. If you limit the syscalls and +don't allow the seccomp syscall, a browser cannot apply these restrictions. +Similarly, if these rules were compiled into a file that is not available in +the sandbox, the browser cannot load these rules from this file and cannot +apply these restrictions. + +Related project comparison: Firejail +------------------------------------ + +[Firejail](https://github.com/netblue30/firejail/tree/HEAD/src/firejail) +is similar to Flatpak before bubblewrap was split out in that it combines +a setuid tool with a lot of desktop-specific sandboxing features. For +example, Firejail knows about Pulseaudio, whereas bubblewrap does not. + +The bubblewrap authors believe it's much easier to audit a small +setuid program, and keep features such as Pulseaudio filtering as an +unprivileged process, as now occurs in Flatpak. + +Also, @cgwalters thinks trying to +[whitelist file paths](https://github.com/netblue30/firejail/blob/37a5a3545ef6d8d03dad8bbd888f53e13274c9e5/src/firejail/fs_whitelist.c#L176) +is a bad idea given the myriad ways users have to manipulate paths, +and the myriad ways in which system administrators may configure a +system. The bubblewrap approach is to only retain a few specific +Linux capabilities such as `CAP_SYS_ADMIN`, but to always access the +filesystem as the invoking uid. This entirely closes +[TOCTTOU attacks](https://cwe.mitre.org/data/definitions/367.html) and +such. + +Related project comparison: Sandstorm.io +---------------------------------------- + +[Sandstorm.io](https://sandstorm.io/) requires unprivileged user +namespaces to set up its sandbox, though it could easily be adapted +to operate in a setuid mode as well. @cgwalters believes their code is +fairly good, but it could still make sense to unify on bubblewrap. +However, @kentonv (of Sandstorm) feels that while this makes sense +in principle, the switching cost outweighs the practical benefits for +now. This decision could be re-evaluated in the future, but it is not +being actively pursued today. + +Related project comparison: runc/binctr +---------------------------------------- + +[runC](https://github.com/opencontainers/runc) is currently working on +supporting [rootless containers](https://github.com/opencontainers/runc/pull/774), +without needing `setuid` or any other privileges during installation of +runC (using unprivileged user namespaces rather than `setuid`), +creation, and management of containers. However, the standard mode of +using runC is similar to [systemd nspawn](https://www.freedesktop.org/software/systemd/man/systemd-nspawn.html) +in that it is tooling intended to be invoked by root. + +The bubblewrap authors believe that runc and systemd-nspawn are not +designed to be made setuid, and are distant from supporting such a mode. +However with rootless containers, runC will be able to fulfill certain usecases +that bubblewrap supports (with the added benefit of being a standardised and +complete OCI runtime). + +[binctr](https://github.com/jfrazelle/binctr) is just a wrapper for +runC, so inherits all of its design tradeoffs. + +What's with the name?! +---------------------- + +The name bubblewrap was chosen to convey that this +tool runs as the parent of the application (so wraps it in some sense) and creates +a protective layer (the sandbox) around it. + +![](bubblewrap.jpg) + +(Bubblewrap cat by [dancing_stupidity](https://www.flickr.com/photos/27549668@N03/)) diff --git a/codex-rs/vendor/bubblewrap/SECURITY.md b/codex-rs/vendor/bubblewrap/SECURITY.md new file mode 100644 index 0000000000..0ddfc6c873 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/SECURITY.md @@ -0,0 +1,42 @@ +## Security and Disclosure Information Policy for the bubblewrap Project + +The bubblewrap Project follows the [Security and Disclosure Information Policy](https://github.com/containers/common/blob/HEAD/SECURITY.md) for the Containers Projects. + +### System security + +If bubblewrap is setuid root, then the goal is that it does not allow +a malicious local user to do anything that would not have been possible +on a kernel that allows unprivileged users to create new user namespaces. +For example, [CVE-2020-5291](https://github.com/containers/bubblewrap/security/advisories/GHSA-j2qp-rvxj-43vj) +was treated as a security vulnerability in bubblewrap. + +If bubblewrap is not setuid root, then it is not a security boundary +between the user and the OS, because anything bubblewrap could do, a +malicious user could equally well do by writing their own tool equivalent +to bubblewrap. + +### Sandbox security + +bubblewrap is a toolkit for constructing sandbox environments. +bubblewrap is not a complete, ready-made sandbox with a specific security +policy. + +Some of bubblewrap's use-cases want a security boundary between the sandbox +and the real system; other use-cases want the ability to change the layout of +the filesystem for processes inside the sandbox, but do not aim to be a +security boundary. +As a result, the level of protection between the sandboxed processes and +the host system is entirely determined by the arguments passed to +bubblewrap. + +Whatever program constructs the command-line arguments for bubblewrap +(often a larger framework like Flatpak, libgnome-desktop, sandwine +or an ad-hoc script) is responsible for defining its own security model, +and choosing appropriate bubblewrap command-line arguments to implement +that security model. + +For example, +[CVE-2017-5226](https://github.com/flatpak/flatpak/security/advisories/GHSA-7gfv-rvfx-h87x) +(in which a Flatpak app could send input to a parent terminal using the +`TIOCSTI` ioctl) is considered to be a Flatpak vulnerability, not a +bubblewrap vulnerability. diff --git a/codex-rs/vendor/bubblewrap/bind-mount.c b/codex-rs/vendor/bubblewrap/bind-mount.c new file mode 100644 index 0000000000..a2e1ac6c40 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/bind-mount.c @@ -0,0 +1,598 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#include "config.h" + +#include + +#include "utils.h" +#include "bind-mount.h" + +static char * +skip_token (char *line, bool eat_whitespace) +{ + while (*line != ' ' && *line != '\n') + line++; + + if (eat_whitespace && *line == ' ') + line++; + + return line; +} + +static char * +unescape_inline (char *escaped) +{ + char *unescaped, *res; + const char *end; + + res = escaped; + end = escaped + strlen (escaped); + + unescaped = escaped; + while (escaped < end) + { + if (*escaped == '\\') + { + *unescaped++ = + ((escaped[1] - '0') << 6) | + ((escaped[2] - '0') << 3) | + ((escaped[3] - '0') << 0); + escaped += 4; + } + else + { + *unescaped++ = *escaped++; + } + } + *unescaped = 0; + return res; +} + +static bool +match_token (const char *token, const char *token_end, const char *str) +{ + while (token != token_end && *token == *str) + { + token++; + str++; + } + if (token == token_end) + return *str == 0; + + return false; +} + +static unsigned long +decode_mountoptions (const char *options) +{ + const char *token, *end_token; + int i; + unsigned long flags = 0; + static const struct { int flag; + const char *name; + } flags_data[] = { + { 0, "rw" }, + { MS_RDONLY, "ro" }, + { MS_NOSUID, "nosuid" }, + { MS_NODEV, "nodev" }, + { MS_NOEXEC, "noexec" }, + { MS_NOATIME, "noatime" }, + { MS_NODIRATIME, "nodiratime" }, + { MS_RELATIME, "relatime" }, + { 0, NULL } + }; + + token = options; + do + { + end_token = strchr (token, ','); + if (end_token == NULL) + end_token = token + strlen (token); + + for (i = 0; flags_data[i].name != NULL; i++) + { + if (match_token (token, end_token, flags_data[i].name)) + { + flags |= flags_data[i].flag; + break; + } + } + + if (*end_token != 0) + token = end_token + 1; + else + token = NULL; + } + while (token != NULL); + + return flags; +} + +typedef struct MountInfo MountInfo; +struct MountInfo { + char *mountpoint; + unsigned long options; +}; + +typedef MountInfo *MountTab; + +static void +mount_tab_free (MountTab tab) +{ + int i; + + for (i = 0; tab[i].mountpoint != NULL; i++) + free (tab[i].mountpoint); + free (tab); +} + +static inline void +cleanup_mount_tabp (void *p) +{ + void **pp = (void **) p; + + if (*pp) + mount_tab_free ((MountTab)*pp); +} + +#define cleanup_mount_tab __attribute__((cleanup (cleanup_mount_tabp))) + +typedef struct MountInfoLine MountInfoLine; +struct MountInfoLine { + const char *mountpoint; + const char *options; + bool covered; + int id; + int parent_id; + MountInfoLine *first_child; + MountInfoLine *next_sibling; +}; + +static unsigned int +count_lines (const char *data) +{ + unsigned int count = 0; + const char *p = data; + + while (*p != 0) + { + if (*p == '\n') + count++; + p++; + } + + /* If missing final newline, add one */ + if (p > data && *(p-1) != '\n') + count++; + + return count; +} + +static int +count_mounts (MountInfoLine *line) +{ + MountInfoLine *child; + int res = 0; + + if (!line->covered) + res += 1; + + child = line->first_child; + while (child != NULL) + { + res += count_mounts (child); + child = child->next_sibling; + } + + return res; +} + +static MountInfo * +collect_mounts (MountInfo *info, MountInfoLine *line) +{ + MountInfoLine *child; + + if (!line->covered) + { + info->mountpoint = xstrdup (line->mountpoint); + info->options = decode_mountoptions (line->options); + info ++; + } + + child = line->first_child; + while (child != NULL) + { + info = collect_mounts (info, child); + child = child->next_sibling; + } + + return info; +} + +static MountTab +parse_mountinfo (int proc_fd, + const char *root_mount) +{ + cleanup_free char *mountinfo = NULL; + cleanup_free MountInfoLine *lines = NULL; + cleanup_free MountInfoLine **by_id = NULL; + cleanup_mount_tab MountTab mount_tab = NULL; + MountInfo *end_tab; + int n_mounts; + char *line; + unsigned int i; + int max_id; + unsigned int n_lines; + int root; + + mountinfo = load_file_at (proc_fd, "self/mountinfo"); + if (mountinfo == NULL) + die_with_error ("Can't open /proc/self/mountinfo"); + + n_lines = count_lines (mountinfo); + lines = xcalloc (n_lines, sizeof (MountInfoLine)); + + max_id = 0; + line = mountinfo; + i = 0; + root = -1; + while (*line != 0) + { + int rc, consumed = 0; + unsigned int maj, min; + char *end; + char *rest; + char *mountpoint; + char *mountpoint_end; + char *options; + char *options_end; + char *next_line; + + assert (i < n_lines); + + end = strchr (line, '\n'); + if (end != NULL) + { + *end = 0; + next_line = end + 1; + } + else + next_line = line + strlen (line); + + rc = sscanf (line, "%d %d %u:%u %n", &lines[i].id, &lines[i].parent_id, &maj, &min, &consumed); + if (rc != 4) + die ("Can't parse mountinfo line"); + rest = line + consumed; + + rest = skip_token (rest, true); /* mountroot */ + mountpoint = rest; + rest = skip_token (rest, false); /* mountpoint */ + mountpoint_end = rest++; + options = rest; + rest = skip_token (rest, false); /* vfs options */ + options_end = rest; + + *mountpoint_end = 0; + lines[i].mountpoint = unescape_inline (mountpoint); + + *options_end = 0; + lines[i].options = options; + + if (lines[i].id > max_id) + max_id = lines[i].id; + if (lines[i].parent_id > max_id) + max_id = lines[i].parent_id; + + if (path_equal (lines[i].mountpoint, root_mount)) + root = i; + + i++; + line = next_line; + } + assert (i == n_lines); + + if (root == -1) + { + mount_tab = xcalloc (1, sizeof (MountInfo)); + return steal_pointer (&mount_tab); + } + + by_id = xcalloc (max_id + 1, sizeof (MountInfoLine*)); + for (i = 0; i < n_lines; i++) + by_id[lines[i].id] = &lines[i]; + + for (i = 0; i < n_lines; i++) + { + MountInfoLine *this = &lines[i]; + MountInfoLine *parent = by_id[this->parent_id]; + MountInfoLine **to_sibling; + MountInfoLine *sibling; + bool covered = false; + + if (!has_path_prefix (this->mountpoint, root_mount)) + continue; + + if (parent == NULL) + continue; + + if (strcmp (parent->mountpoint, this->mountpoint) == 0) + parent->covered = true; + + to_sibling = &parent->first_child; + sibling = parent->first_child; + while (sibling != NULL) + { + /* If this mountpoint is a path prefix of the sibling, + * say this->mp=/foo/bar and sibling->mp=/foo, then it is + * covered by the sibling, and we drop it. */ + if (has_path_prefix (this->mountpoint, sibling->mountpoint)) + { + covered = true; + break; + } + + /* If the sibling is a path prefix of this mount point, + * say this->mp=/foo and sibling->mp=/foo/bar, then the sibling + * is covered, and we drop it. + */ + if (has_path_prefix (sibling->mountpoint, this->mountpoint)) + *to_sibling = sibling->next_sibling; + else + to_sibling = &sibling->next_sibling; + sibling = sibling->next_sibling; + } + + if (covered) + continue; + + *to_sibling = this; + } + + n_mounts = count_mounts (&lines[root]); + mount_tab = xcalloc (n_mounts + 1, sizeof (MountInfo)); + + end_tab = collect_mounts (&mount_tab[0], &lines[root]); + assert (end_tab == &mount_tab[n_mounts]); + + return steal_pointer (&mount_tab); +} + +bind_mount_result +bind_mount (int proc_fd, + const char *src, + const char *dest, + bind_option_t options, + char **failing_path) +{ + bool readonly = (options & BIND_READONLY) != 0; + bool devices = (options & BIND_DEVICES) != 0; + bool recursive = (options & BIND_RECURSIVE) != 0; + unsigned long current_flags, new_flags; + cleanup_mount_tab MountTab mount_tab = NULL; + cleanup_free char *resolved_dest = NULL; + cleanup_free char *dest_proc = NULL; + cleanup_free char *oldroot_dest_proc = NULL; + cleanup_free char *kernel_case_combination = NULL; + cleanup_fd int dest_fd = -1; + int i; + + if (src) + { + if (mount (src, dest, NULL, MS_SILENT | MS_BIND | (recursive ? MS_REC : 0), NULL) != 0) + return BIND_MOUNT_ERROR_MOUNT; + } + + /* The mount operation will resolve any symlinks in the destination + path, so to find it in the mount table we need to do that too. */ + resolved_dest = realpath (dest, NULL); + if (resolved_dest == NULL) + return BIND_MOUNT_ERROR_REALPATH_DEST; + + dest_fd = TEMP_FAILURE_RETRY (open (resolved_dest, O_PATH | O_CLOEXEC)); + if (dest_fd < 0) + { + if (failing_path != NULL) + *failing_path = steal_pointer (&resolved_dest); + + return BIND_MOUNT_ERROR_REOPEN_DEST; + } + + /* If we are in a case-insensitive filesystem, mountinfo might contain a + * different case combination of the path we requested to mount. + * This is due to the fact that the kernel, as of the beginning of 2021, + * populates mountinfo with whatever case combination first appeared in the + * dcache; kernel developers plan to change this in future so that it + * reflects the on-disk encoding instead. + * To avoid throwing an error when this happens, we use readlink() result + * instead of the provided @root_mount, so that we can compare the mountinfo + * entries with the same case combination that the kernel is expected to + * use. */ + dest_proc = xasprintf ("/proc/self/fd/%d", dest_fd); + oldroot_dest_proc = get_oldroot_path (dest_proc); + kernel_case_combination = readlink_malloc (oldroot_dest_proc); + if (kernel_case_combination == NULL) + { + if (failing_path != NULL) + *failing_path = steal_pointer (&resolved_dest); + + return BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD; + } + + mount_tab = parse_mountinfo (proc_fd, kernel_case_combination); + if (mount_tab[0].mountpoint == NULL) + { + if (failing_path != NULL) + *failing_path = steal_pointer (&kernel_case_combination); + + errno = EINVAL; + return BIND_MOUNT_ERROR_FIND_DEST_MOUNT; + } + + assert (path_equal (mount_tab[0].mountpoint, kernel_case_combination)); + current_flags = mount_tab[0].options; + new_flags = current_flags | (devices ? 0 : MS_NODEV) | MS_NOSUID | (readonly ? MS_RDONLY : 0); + if (new_flags != current_flags && + mount ("none", resolved_dest, + NULL, MS_SILENT | MS_BIND | MS_REMOUNT | new_flags, NULL) != 0) + { + if (failing_path != NULL) + *failing_path = steal_pointer (&resolved_dest); + + return BIND_MOUNT_ERROR_REMOUNT_DEST; + } + + /* We need to work around the fact that a bind mount does not apply the flags, so we need to manually + * apply the flags to all submounts in the recursive case. + * Note: This does not apply the flags to mounts which are later propagated into this namespace. + */ + if (recursive) + { + for (i = 1; mount_tab[i].mountpoint != NULL; i++) + { + current_flags = mount_tab[i].options; + new_flags = current_flags | (devices ? 0 : MS_NODEV) | MS_NOSUID | (readonly ? MS_RDONLY : 0); + if (new_flags != current_flags && + mount ("none", mount_tab[i].mountpoint, + NULL, MS_SILENT | MS_BIND | MS_REMOUNT | new_flags, NULL) != 0) + { + /* If we can't read the mountpoint we can't remount it, but that should + be safe to ignore because its not something the user can access. */ + if (errno != EACCES) + { + if (failing_path != NULL) + *failing_path = xstrdup (mount_tab[i].mountpoint); + + return BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT; + } + } + } + } + + return BIND_MOUNT_SUCCESS; +} + +/** + * Return a string representing bind_mount_result, like strerror(). + * If want_errno_p is non-NULL, *want_errno_p is used to indicate whether + * it would make sense to print strerror(saved_errno). + */ +static char * +bind_mount_result_to_string (bind_mount_result res, + const char *failing_path, + bool *want_errno_p) +{ + char *string = NULL; + bool want_errno = true; + + switch (res) + { + case BIND_MOUNT_ERROR_MOUNT: + string = xstrdup ("Unable to mount source on destination"); + break; + + case BIND_MOUNT_ERROR_REALPATH_DEST: + string = xstrdup ("realpath(destination)"); + break; + + case BIND_MOUNT_ERROR_REOPEN_DEST: + string = xasprintf ("open(\"%s\", O_PATH)", failing_path); + break; + + case BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD: + string = xasprintf ("readlink(/proc/self/fd/N) for \"%s\"", failing_path); + break; + + case BIND_MOUNT_ERROR_FIND_DEST_MOUNT: + string = xasprintf ("Unable to find \"%s\" in mount table", failing_path); + want_errno = false; + break; + + case BIND_MOUNT_ERROR_REMOUNT_DEST: + string = xasprintf ("Unable to remount destination \"%s\" with correct flags", + failing_path); + break; + + case BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT: + string = xasprintf ("Unable to apply mount flags: remount \"%s\"", + failing_path); + break; + + case BIND_MOUNT_SUCCESS: + string = xstrdup ("Success"); + break; + + default: + string = xstrdup ("(unknown/invalid bind_mount_result)"); + break; + } + + if (want_errno_p != NULL) + *want_errno_p = want_errno; + + return string; +} + +void +die_with_bind_result (bind_mount_result res, + int saved_errno, + const char *failing_path, + const char *format, + ...) +{ + va_list args; + bool want_errno = true; + char *message; + + if (bwrap_level_prefix) + fprintf (stderr, "<%d>", LOG_ERR); + + fprintf (stderr, "bwrap: "); + + va_start (args, format); + vfprintf (stderr, format, args); + va_end (args); + + message = bind_mount_result_to_string (res, failing_path, &want_errno); + fprintf (stderr, ": %s", message); + /* message is leaked, but we're exiting unsuccessfully anyway, so ignore */ + + if (want_errno) + { + switch (res) + { + case BIND_MOUNT_ERROR_MOUNT: + case BIND_MOUNT_ERROR_REMOUNT_DEST: + case BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT: + fprintf (stderr, ": %s", mount_strerror (saved_errno)); + break; + + case BIND_MOUNT_ERROR_REALPATH_DEST: + case BIND_MOUNT_ERROR_REOPEN_DEST: + case BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD: + case BIND_MOUNT_ERROR_FIND_DEST_MOUNT: + case BIND_MOUNT_SUCCESS: + default: + fprintf (stderr, ": %s", strerror (saved_errno)); + } + } + + fprintf (stderr, "\n"); + exit (1); +} diff --git a/codex-rs/vendor/bubblewrap/bind-mount.h b/codex-rs/vendor/bubblewrap/bind-mount.h new file mode 100644 index 0000000000..8a361fbd5f --- /dev/null +++ b/codex-rs/vendor/bubblewrap/bind-mount.h @@ -0,0 +1,54 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#pragma once + +#include "utils.h" + +typedef enum { + BIND_READONLY = (1 << 0), + BIND_DEVICES = (1 << 2), + BIND_RECURSIVE = (1 << 3), +} bind_option_t; + +typedef enum +{ + BIND_MOUNT_SUCCESS = 0, + BIND_MOUNT_ERROR_MOUNT, + BIND_MOUNT_ERROR_REALPATH_DEST, + BIND_MOUNT_ERROR_REOPEN_DEST, + BIND_MOUNT_ERROR_READLINK_DEST_PROC_FD, + BIND_MOUNT_ERROR_FIND_DEST_MOUNT, + BIND_MOUNT_ERROR_REMOUNT_DEST, + BIND_MOUNT_ERROR_REMOUNT_SUBMOUNT, +} bind_mount_result; + +bind_mount_result bind_mount (int proc_fd, + const char *src, + const char *dest, + bind_option_t options, + char **failing_path); + +void die_with_bind_result (bind_mount_result res, + int saved_errno, + const char *failing_path, + const char *format, + ...) + __attribute__((__noreturn__)) + __attribute__((format (printf, 4, 5))); diff --git a/codex-rs/vendor/bubblewrap/bubblewrap.c b/codex-rs/vendor/bubblewrap/bubblewrap.c new file mode 100644 index 0000000000..69d319b7a3 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/bubblewrap.c @@ -0,0 +1,3641 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "utils.h" +#include "network.h" +#include "bind-mount.h" + +#ifndef CLONE_NEWCGROUP +#define CLONE_NEWCGROUP 0x02000000 /* New cgroup namespace */ +#endif + +/* We limit the size of a tmpfs to half the architecture's address space, + * to avoid hitting arbitrary limits in the kernel. + * For example, on at least one x86_64 machine, the actual limit seems to be + * 2^64 - 2^12. */ +#define MAX_TMPFS_BYTES ((size_t) (SIZE_MAX >> 1)) + +/* Globals to avoid having to use getuid(), since the uid/gid changes during runtime */ +static uid_t real_uid; +static gid_t real_gid; +static uid_t overflow_uid; +static gid_t overflow_gid; +static bool is_privileged; /* See acquire_privs() */ +static const char *argv0; +static const char *host_tty_dev; +static int proc_fd = -1; +static const char *opt_exec_label = NULL; +static const char *opt_file_label = NULL; +static bool opt_as_pid_1; + +static const char *opt_argv0 = NULL; +static const char *opt_chdir_path = NULL; +static bool opt_assert_userns_disabled = false; +static bool opt_disable_userns = false; +static bool opt_unshare_user = false; +static bool opt_unshare_user_try = false; +static bool opt_unshare_pid = false; +static bool opt_unshare_ipc = false; +static bool opt_unshare_net = false; +static bool opt_unshare_uts = false; +static bool opt_unshare_cgroup = false; +static bool opt_unshare_cgroup_try = false; +static bool opt_needs_devpts = false; +static bool opt_new_session = false; +static bool opt_die_with_parent = false; +static uid_t opt_sandbox_uid = -1; +static gid_t opt_sandbox_gid = -1; +static int opt_sync_fd = -1; +static int opt_block_fd = -1; +static int opt_userns_block_fd = -1; +static int opt_info_fd = -1; +static int opt_json_status_fd = -1; +static int opt_seccomp_fd = -1; +static const char *opt_sandbox_hostname = NULL; +static char *opt_args_data = NULL; /* owned */ +static int opt_userns_fd = -1; +static int opt_userns2_fd = -1; +static int opt_pidns_fd = -1; +static int opt_tmp_overlay_count = 0; +static int next_perms = -1; +static size_t next_size_arg = 0; +static int next_overlay_src_count = 0; + +#define CAP_TO_MASK_0(x) (1L << ((x) & 31)) +#define CAP_TO_MASK_1(x) CAP_TO_MASK_0(x - 32) + +typedef struct _NsInfo NsInfo; + +struct _NsInfo { + const char *name; + bool *do_unshare; + ino_t id; +}; + +static NsInfo ns_infos[] = { + {"cgroup", &opt_unshare_cgroup, 0}, + {"ipc", &opt_unshare_ipc, 0}, + {"mnt", NULL, 0}, + {"net", &opt_unshare_net, 0}, + {"pid", &opt_unshare_pid, 0}, + /* user namespace info omitted because it + * is not (yet) valid when we obtain the + * namespace info (get un-shared later) */ + {"uts", &opt_unshare_uts, 0}, + {NULL, NULL, 0} +}; + +typedef enum { + SETUP_BIND_MOUNT, + SETUP_RO_BIND_MOUNT, + SETUP_DEV_BIND_MOUNT, + SETUP_OVERLAY_MOUNT, + SETUP_TMP_OVERLAY_MOUNT, + SETUP_RO_OVERLAY_MOUNT, + SETUP_OVERLAY_SRC, + SETUP_MOUNT_PROC, + SETUP_MOUNT_DEV, + SETUP_MOUNT_TMPFS, + SETUP_MOUNT_MQUEUE, + SETUP_MAKE_DIR, + SETUP_MAKE_FILE, + SETUP_MAKE_BIND_FILE, + SETUP_MAKE_RO_BIND_FILE, + SETUP_MAKE_SYMLINK, + SETUP_REMOUNT_RO_NO_RECURSIVE, + SETUP_SET_HOSTNAME, + SETUP_CHMOD, +} SetupOpType; + +typedef enum { + NO_CREATE_DEST = (1 << 0), + ALLOW_NOTEXIST = (1 << 1), +} SetupOpFlag; + +typedef struct _SetupOp SetupOp; + +struct _SetupOp +{ + SetupOpType type; + const char *source; + const char *dest; + int fd; + SetupOpFlag flags; + int perms; + size_t size; /* number of bytes, zero means unset/default */ + SetupOp *next; +}; + +typedef struct _LockFile LockFile; + +struct _LockFile +{ + const char *path; + int fd; + LockFile *next; +}; + +enum { + PRIV_SEP_OP_DONE, + PRIV_SEP_OP_BIND_MOUNT, + PRIV_SEP_OP_OVERLAY_MOUNT, + PRIV_SEP_OP_PROC_MOUNT, + PRIV_SEP_OP_TMPFS_MOUNT, + PRIV_SEP_OP_DEVPTS_MOUNT, + PRIV_SEP_OP_MQUEUE_MOUNT, + PRIV_SEP_OP_REMOUNT_RO_NO_RECURSIVE, + PRIV_SEP_OP_SET_HOSTNAME, +}; + +typedef struct +{ + uint32_t op; + uint32_t flags; + uint32_t perms; + size_t size_arg; + uint32_t arg1_offset; + uint32_t arg2_offset; +} PrivSepOp; + +/* + * DEFINE_LINKED_LIST: + * @Type: A struct with a `Type *next` member + * @name: Used to form the names of variables and functions + * + * Define a global linked list of @Type structures, with pointers + * `NAMEs` to the head of the list and `last_NAME` to the tail of the + * list. + * + * A new zero-filled item can be allocated and appended to the list + * by calling `_NAME_append_new()`, which returns the new item. + */ +#define DEFINE_LINKED_LIST(Type, name) \ +static Type *name ## s = NULL; \ +static Type *last_ ## name = NULL; \ +\ +static inline Type * \ +_ ## name ## _append_new (void) \ +{ \ + Type *self = xcalloc (1, sizeof (Type)); \ +\ + if (last_ ## name != NULL) \ + last_ ## name ->next = self; \ + else \ + name ## s = self; \ +\ + last_ ## name = self; \ + return self; \ +} + +DEFINE_LINKED_LIST (SetupOp, op) + +static SetupOp * +setup_op_new (SetupOpType type) +{ + SetupOp *op = _op_append_new (); + + op->type = type; + op->fd = -1; + op->flags = 0; + return op; +} + +DEFINE_LINKED_LIST (LockFile, lock_file) + +static LockFile * +lock_file_new (const char *path) +{ + LockFile *lock = _lock_file_append_new (); + + lock->path = path; + return lock; +} + +typedef struct _SeccompProgram SeccompProgram; + +struct _SeccompProgram +{ + struct sock_fprog program; + SeccompProgram *next; +}; + +DEFINE_LINKED_LIST (SeccompProgram, seccomp_program) + +static SeccompProgram * +seccomp_program_new (int *fd) +{ + SeccompProgram *self = _seccomp_program_append_new (); + cleanup_free char *data = NULL; + size_t len; + + data = load_file_data (*fd, &len); + + if (data == NULL) + die_with_error ("Can't read seccomp data"); + + close (*fd); + *fd = -1; + + if (len % 8 != 0) + die ("Invalid seccomp data, must be multiple of 8"); + + self->program.len = len / 8; + self->program.filter = (struct sock_filter *) steal_pointer (&data); + return self; +} + +static void +seccomp_programs_apply (void) +{ + SeccompProgram *program; + + for (program = seccomp_programs; program != NULL; program = program->next) + { + if (prctl (PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program->program) != 0) + { + if (errno == EINVAL) + die ("Unable to set up system call filtering as requested: " + "prctl(PR_SET_SECCOMP) reported EINVAL. " + "(Hint: this requires a kernel configured with " + "CONFIG_SECCOMP and CONFIG_SECCOMP_FILTER.)"); + + die_with_error ("prctl(PR_SET_SECCOMP)"); + } + } +} + +static void +usage (int ecode, FILE *out) +{ + fprintf (out, "usage: %s [OPTIONS...] [--] COMMAND [ARGS...]\n\n", argv0 ? argv0 : "bwrap"); + + fprintf (out, + " --help Print this help\n" + " --version Print version\n" + " --args FD Parse NUL-separated args from FD\n" + " --argv0 VALUE Set argv[0] to the value VALUE before running the program\n" + " --level-prefix Prepend e.g. <3> to diagnostic messages\n" + " --unshare-all Unshare every namespace we support by default\n" + " --share-net Retain the network namespace (can only combine with --unshare-all)\n" + " --unshare-user Create new user namespace (may be automatically implied if not setuid)\n" + " --unshare-user-try Create new user namespace if possible else continue by skipping it\n" + " --unshare-ipc Create new ipc namespace\n" + " --unshare-pid Create new pid namespace\n" + " --unshare-net Create new network namespace\n" + " --unshare-uts Create new uts namespace\n" + " --unshare-cgroup Create new cgroup namespace\n" + " --unshare-cgroup-try Create new cgroup namespace if possible else continue by skipping it\n" + " --userns FD Use this user namespace (cannot combine with --unshare-user)\n" + " --userns2 FD After setup switch to this user namespace, only useful with --userns\n" + " --disable-userns Disable further use of user namespaces inside sandbox\n" + " --assert-userns-disabled Fail unless further use of user namespace inside sandbox is disabled\n" + " --pidns FD Use this pid namespace (as parent namespace if using --unshare-pid)\n" + " --uid UID Custom uid in the sandbox (requires --unshare-user or --userns)\n" + " --gid GID Custom gid in the sandbox (requires --unshare-user or --userns)\n" + " --hostname NAME Custom hostname in the sandbox (requires --unshare-uts)\n" + " --chdir DIR Change directory to DIR\n" + " --clearenv Unset all environment variables\n" + " --setenv VAR VALUE Set an environment variable\n" + " --unsetenv VAR Unset an environment variable\n" + " --lock-file DEST Take a lock on DEST while sandbox is running\n" + " --sync-fd FD Keep this fd open while sandbox is running\n" + " --bind SRC DEST Bind mount the host path SRC on DEST\n" + " --bind-try SRC DEST Equal to --bind but ignores non-existent SRC\n" + " --dev-bind SRC DEST Bind mount the host path SRC on DEST, allowing device access\n" + " --dev-bind-try SRC DEST Equal to --dev-bind but ignores non-existent SRC\n" + " --ro-bind SRC DEST Bind mount the host path SRC readonly on DEST\n" + " --ro-bind-try SRC DEST Equal to --ro-bind but ignores non-existent SRC\n" + " --bind-fd FD DEST Bind open directory or path fd on DEST\n" + " --ro-bind-fd FD DEST Bind open directory or path fd read-only on DEST\n" + " --remount-ro DEST Remount DEST as readonly; does not recursively remount\n" + " --overlay-src SRC Read files from SRC in the following overlay\n" + " --overlay RWSRC WORKDIR DEST Mount overlayfs on DEST, with RWSRC as the host path for writes and\n" + " WORKDIR an empty directory on the same filesystem as RWSRC\n" + " --tmp-overlay DEST Mount overlayfs on DEST, with writes going to an invisible tmpfs\n" + " --ro-overlay DEST Mount overlayfs read-only on DEST\n" + " --exec-label LABEL Exec label for the sandbox\n" + " --file-label LABEL File label for temporary sandbox content\n" + " --proc DEST Mount new procfs on DEST\n" + " --dev DEST Mount new dev on DEST\n" + " --tmpfs DEST Mount new tmpfs on DEST\n" + " --mqueue DEST Mount new mqueue on DEST\n" + " --dir DEST Create dir at DEST\n" + " --file FD DEST Copy from FD to destination DEST\n" + " --bind-data FD DEST Copy from FD to file which is bind-mounted on DEST\n" + " --ro-bind-data FD DEST Copy from FD to file which is readonly bind-mounted on DEST\n" + " --symlink SRC DEST Create symlink at DEST with target SRC\n" + " --seccomp FD Load and use seccomp rules from FD (not repeatable)\n" + " --add-seccomp-fd FD Load and use seccomp rules from FD (repeatable)\n" + " --block-fd FD Block on FD until some data to read is available\n" + " --userns-block-fd FD Block on FD until the user namespace is ready\n" + " --info-fd FD Write information about the running container to FD\n" + " --json-status-fd FD Write container status to FD as multiple JSON documents\n" + " --new-session Create a new terminal session\n" + " --die-with-parent Kills with SIGKILL child process (COMMAND) when bwrap or bwrap's parent dies.\n" + " --as-pid-1 Do not install a reaper process with PID=1\n" + " --cap-add CAP Add cap CAP when running as privileged user\n" + " --cap-drop CAP Drop cap CAP when running as privileged user\n" + " --perms OCTAL Set permissions of next argument (--bind-data, --file, etc.)\n" + " --size BYTES Set size of next argument (only for --tmpfs)\n" + " --chmod OCTAL PATH Change permissions of PATH (must already exist)\n" + ); + exit (ecode); +} + +/* If --die-with-parent was specified, use PDEATHSIG to ensure SIGKILL + * is sent to the current process when our parent dies. + */ +static void +handle_die_with_parent (void) +{ + if (opt_die_with_parent && prctl (PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) != 0) + die_with_error ("prctl"); +} + +static void +block_sigchild (void) +{ + sigset_t mask; + int status; + + sigemptyset (&mask); + sigaddset (&mask, SIGCHLD); + + if (sigprocmask (SIG_BLOCK, &mask, NULL) == -1) + die_with_error ("sigprocmask"); + + /* Reap any outstanding zombies that we may have inherited */ + while (waitpid (-1, &status, WNOHANG) > 0) + ; +} + +static void +unblock_sigchild (void) +{ + sigset_t mask; + + sigemptyset (&mask); + sigaddset (&mask, SIGCHLD); + + if (sigprocmask (SIG_UNBLOCK, &mask, NULL) == -1) + die_with_error ("sigprocmask"); +} + +/* Closes all fd:s except 0,1,2 and the passed in array of extra fds */ +static int +close_extra_fds (void *data, int fd) +{ + int *extra_fds = (int *) data; + int i; + + for (i = 0; extra_fds[i] != -1; i++) + if (fd == extra_fds[i]) + return 0; + + if (fd <= 2) + return 0; + + close (fd); + return 0; +} + +static int +propagate_exit_status (int status) +{ + if (WIFEXITED (status)) + return WEXITSTATUS (status); + + /* The process died of a signal, we can't really report that, but we + * can at least be bash-compatible. The bash manpage says: + * The return value of a simple command is its + * exit status, or 128+n if the command is + * terminated by signal n. + */ + if (WIFSIGNALED (status)) + return 128 + WTERMSIG (status); + + /* Weird? */ + return 255; +} + +static void +dump_info (int fd, const char *output, bool exit_on_error) +{ + size_t len = strlen (output); + if (write_to_fd (fd, output, len)) + { + if (exit_on_error) + die_with_error ("Write to info_fd"); + } +} + +static void +report_child_exit_status (int exitc, int setup_finished_fd) +{ + ssize_t s; + char data[2]; + cleanup_free char *output = NULL; + if (opt_json_status_fd == -1 || setup_finished_fd == -1) + return; + + s = TEMP_FAILURE_RETRY (read (setup_finished_fd, data, sizeof data)); + if (s == -1 && errno != EAGAIN) + die_with_error ("read eventfd"); + if (s != 1) // Is 0 if pipe closed before exec, is 2 if closed after exec. + return; + + output = xasprintf ("{ \"exit-code\": %i }\n", exitc); + dump_info (opt_json_status_fd, output, false); + close (opt_json_status_fd); + opt_json_status_fd = -1; + close (setup_finished_fd); +} + +/* This stays around for as long as the initial process in the app does + * and when that exits it exits, propagating the exit status. We do this + * by having pid 1 in the sandbox detect this exit and tell the monitor + * the exit status via a eventfd. We also track the exit of the sandbox + * pid 1 via a signalfd for SIGCHLD, and exit with an error in this case. + * This is to catch e.g. problems during setup. */ +static int +monitor_child (int event_fd, pid_t child_pid, int setup_finished_fd) +{ + int res; + uint64_t val; + ssize_t s; + int signal_fd; + sigset_t mask; + struct pollfd fds[2]; + int num_fds; + struct signalfd_siginfo fdsi; + int dont_close[] = {-1, -1, -1, -1}; + unsigned int j = 0; + int exitc; + pid_t died_pid; + int died_status; + + /* Close all extra fds in the monitoring process. + Any passed in fds have been passed on to the child anyway. */ + if (event_fd != -1) + dont_close[j++] = event_fd; + if (opt_json_status_fd != -1) + dont_close[j++] = opt_json_status_fd; + if (setup_finished_fd != -1) + dont_close[j++] = setup_finished_fd; + assert (j < sizeof(dont_close)/sizeof(*dont_close)); + fdwalk (proc_fd, close_extra_fds, dont_close); + + sigemptyset (&mask); + sigaddset (&mask, SIGCHLD); + + signal_fd = signalfd (-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK); + if (signal_fd == -1) + die_with_error ("Can't create signalfd"); + + num_fds = 1; + fds[0].fd = signal_fd; + fds[0].events = POLLIN; + if (event_fd != -1) + { + fds[1].fd = event_fd; + fds[1].events = POLLIN; + num_fds++; + } + + while (1) + { + fds[0].revents = fds[1].revents = 0; + res = poll (fds, num_fds, -1); + if (res == -1 && errno != EINTR) + die_with_error ("poll"); + + /* Always read from the eventfd first, if pid 2 died then pid 1 often + * dies too, and we could race, reporting that first and we'd lose + * the real exit status. */ + if (event_fd != -1) + { + s = read (event_fd, &val, 8); + if (s == -1 && errno != EINTR && errno != EAGAIN) + die_with_error ("read eventfd"); + else if (s == 8) + { + exitc = (int) val - 1; + report_child_exit_status (exitc, setup_finished_fd); + return exitc; + } + } + + /* We need to read the signal_fd, or it will keep polling as read, + * however we ignore the details as we get them from waitpid + * below anyway */ + s = read (signal_fd, &fdsi, sizeof (struct signalfd_siginfo)); + if (s == -1 && errno != EINTR && errno != EAGAIN) + die_with_error ("read signalfd"); + + /* We may actually get several sigchld compressed into one + SIGCHLD, so we have to handle all of them. */ + while ((died_pid = waitpid (-1, &died_status, WNOHANG)) > 0) + { + /* We may be getting sigchild from other children too. For instance if + someone created a child process, and then exec:ed bubblewrap. Ignore them */ + if (died_pid == child_pid) + { + exitc = propagate_exit_status (died_status); + report_child_exit_status (exitc, setup_finished_fd); + return exitc; + } + } + } + + die ("Should not be reached"); + + return 0; +} + +/* This is pid 1 in the app sandbox. It is needed because we're using + * pid namespaces, and someone has to reap zombies in it. We also detect + * when the initial process (pid 2) dies and report its exit status to + * the monitor so that it can return it to the original spawner. + * + * When there are no other processes in the sandbox the wait will return + * ECHILD, and we then exit pid 1 to clean up the sandbox. */ +static int +do_init (int event_fd, pid_t initial_pid) +{ + int initial_exit_status = 1; + LockFile *lock; + + for (lock = lock_files; lock != NULL; lock = lock->next) + { + int fd = TEMP_FAILURE_RETRY (open (lock->path, O_RDONLY | O_CLOEXEC)); + if (fd == -1) + die_with_error ("Unable to open lock file %s", lock->path); + + struct flock l = { + .l_type = F_RDLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0 + }; + + if (TEMP_FAILURE_RETRY (fcntl (fd, F_SETLK, &l)) < 0) + die_with_error ("Unable to lock file %s", lock->path); + + /* Keep fd open to hang on to lock */ + lock->fd = fd; + } + + /* Optionally bind our lifecycle to that of the caller */ + handle_die_with_parent (); + + seccomp_programs_apply (); + + while (true) + { + pid_t child; + int status; + + child = TEMP_FAILURE_RETRY (wait (&status)); + if (child == initial_pid) + { + initial_exit_status = propagate_exit_status (status); + + if(event_fd != -1) + { + uint64_t val; + int res UNUSED; + + val = initial_exit_status + 1; + res = TEMP_FAILURE_RETRY (write (event_fd, &val, 8)); + /* Ignore res, if e.g. the parent died and closed event_fd + we don't want to error out here */ + } + } + + if (child == -1 && errno != EINTR) + { + if (errno != ECHILD) + die_with_error ("init wait()"); + break; + } + } + + /* Close FDs. */ + for (lock = lock_files; lock != NULL; lock = lock->next) + { + if (lock->fd >= 0) + { + close (lock->fd); + lock->fd = -1; + } + } + + return initial_exit_status; +} + +#define CAP_TO_MASK_0(x) (1L << ((x) & 31)) +#define CAP_TO_MASK_1(x) CAP_TO_MASK_0(x - 32) + +/* Set if --cap-add or --cap-drop were used */ +static bool opt_cap_add_or_drop_used; +/* The capability set we'll target, used if above is true */ +static uint32_t requested_caps[2] = {0, 0}; + +/* low 32bit caps needed */ +/* CAP_SYS_PTRACE is needed to dereference the symlinks in /proc//ns/, see namespaces(7) */ +#define REQUIRED_CAPS_0 (CAP_TO_MASK_0 (CAP_SYS_ADMIN) | CAP_TO_MASK_0 (CAP_SYS_CHROOT) | CAP_TO_MASK_0 (CAP_NET_ADMIN) | CAP_TO_MASK_0 (CAP_SETUID) | CAP_TO_MASK_0 (CAP_SETGID) | CAP_TO_MASK_0 (CAP_SYS_PTRACE)) +/* high 32bit caps needed */ +#define REQUIRED_CAPS_1 0 + +static void +set_required_caps (void) +{ + struct __user_cap_header_struct hdr = { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct data[2] = { { 0 } }; + + /* Drop all non-require capabilities */ + data[0].effective = REQUIRED_CAPS_0; + data[0].permitted = REQUIRED_CAPS_0; + data[0].inheritable = 0; + data[1].effective = REQUIRED_CAPS_1; + data[1].permitted = REQUIRED_CAPS_1; + data[1].inheritable = 0; + if (capset (&hdr, data) < 0) + die_with_error ("capset failed"); +} + +static void +drop_all_caps (bool keep_requested_caps) +{ + struct __user_cap_header_struct hdr = { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct data[2] = { { 0 } }; + + if (keep_requested_caps) + { + /* Avoid calling capset() unless we need to; currently + * systemd-nspawn at least is known to install a seccomp + * policy denying capset() for dubious reasons. + * + */ + if (!opt_cap_add_or_drop_used && real_uid == 0) + { + assert (!is_privileged); + return; + } + data[0].effective = requested_caps[0]; + data[0].permitted = requested_caps[0]; + data[0].inheritable = requested_caps[0]; + data[1].effective = requested_caps[1]; + data[1].permitted = requested_caps[1]; + data[1].inheritable = requested_caps[1]; + } + + if (capset (&hdr, data) < 0) + { + /* While the above logic ensures we don't call capset() for the primary + * process unless configured to do so, we still try to drop privileges for + * the init process unconditionally. Since due to the systemd seccomp + * filter that will fail, let's just ignore it. + */ + if (errno == EPERM && real_uid == 0 && !is_privileged) + return; + else + die_with_error ("capset failed"); + } +} + +static bool +has_caps (void) +{ + struct __user_cap_header_struct hdr = { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct data[2] = { { 0 } }; + + if (capget (&hdr, data) < 0) + die_with_error ("capget failed"); + + return data[0].permitted != 0 || data[1].permitted != 0; +} + +/* Most of the code here is used both to add caps to the ambient capabilities + * and drop caps from the bounding set. Handle both cases here and add + * drop_cap_bounding_set/set_ambient_capabilities wrappers to facilitate its usage. + */ +static void +prctl_caps (uint32_t *caps, bool do_cap_bounding, bool do_set_ambient) +{ + unsigned long cap; + + /* We ignore both EINVAL and EPERM, as we are actually relying + * on PR_SET_NO_NEW_PRIVS to ensure the right capabilities are + * available. EPERM in particular can happen with old, buggy + * kernels. See: + * https://github.com/projectatomic/bubblewrap/pull/175#issuecomment-278051373 + * https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/security/commoncap.c?id=160da84dbb39443fdade7151bc63a88f8e953077 + */ + for (cap = 0; cap <= CAP_LAST_CAP; cap++) + { + bool keep = false; + if (cap < 32) + { + if (CAP_TO_MASK_0 (cap) & caps[0]) + keep = true; + } + else + { + if (CAP_TO_MASK_1 (cap) & caps[1]) + keep = true; + } + + if (keep && do_set_ambient) + { +#ifdef PR_CAP_AMBIENT + int res = prctl (PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, cap, 0, 0); + if (res == -1 && !(errno == EINVAL || errno == EPERM)) + die_with_error ("Adding ambient capability %ld", cap); +#else + /* We ignore the EINVAL that results from not having PR_CAP_AMBIENT + * in the current kernel at runtime, so also ignore not having it + * in the current kernel headers at compile-time */ +#endif + } + + if (!keep && do_cap_bounding) + { + int res = prctl (PR_CAPBSET_DROP, cap, 0, 0, 0); + if (res == -1 && !(errno == EINVAL || errno == EPERM)) + die_with_error ("Dropping capability %ld from bounds", cap); + } + } +} + +static void +drop_cap_bounding_set (bool drop_all) +{ + if (!drop_all) + prctl_caps (requested_caps, true, false); + else + { + uint32_t no_caps[2] = {0, 0}; + prctl_caps (no_caps, true, false); + } +} + +static void +set_ambient_capabilities (void) +{ + if (is_privileged) + return; + prctl_caps (requested_caps, false, true); +} + +/* This acquires the privileges that the bwrap will need it to work. + * If bwrap is not setuid, then this does nothing, and it relies on + * unprivileged user namespaces to be used. This case is + * "is_privileged = false". + * + * If bwrap is setuid, then we do things in phases. + * The first part is run as euid 0, but with fsuid as the real user. + * The second part, inside the child, is run as the real user but with + * capabilities. + * And finally we drop all capabilities. + * The reason for the above dance is to avoid having the setup phase + * being able to read files the user can't, while at the same time + * working around various kernel issues. See below for details. + */ +static void +acquire_privs (void) +{ + uid_t euid, new_fsuid; + + euid = geteuid (); + + /* Are we setuid ? */ + if (real_uid != euid) + { + if (euid != 0) + die ("Unexpected setuid user %d, should be 0", euid); + + is_privileged = true; + /* We want to keep running as euid=0 until at the clone() + * operation because doing so will make the user namespace be + * owned by root, which makes it not ptrace:able by the user as + * it otherwise would be. After that we will run fully as the + * user, which is necessary e.g. to be able to read from a fuse + * mount from the user. + * + * However, we don't want to accidentally mis-use euid=0 for + * escalated filesystem access before the clone(), so we set + * fsuid to the uid. + */ + if (setfsuid (real_uid) < 0) + die_with_error ("Unable to set fsuid"); + + /* setfsuid can't properly report errors, check that it worked (as per manpage) */ + new_fsuid = setfsuid (-1); + if (new_fsuid != real_uid) + die ("Unable to set fsuid (was %d)", (int)new_fsuid); + + /* We never need capabilities after execve(), so lets drop everything from the bounding set */ + drop_cap_bounding_set (true); + + /* Keep only the required capabilities for setup */ + set_required_caps (); + } + else if (real_uid != 0 && has_caps ()) + { + /* We have some capabilities in the non-setuid case, which should not happen. + Probably caused by the binary being setcap instead of setuid which we + don't support anymore */ + die ("Unexpected capabilities but not setuid, old file caps config?"); + } + else if (real_uid == 0) + { + /* If our uid is 0, default to inheriting all caps; the caller + * can drop them via --cap-drop. This is used by at least rpm-ostree. + * Note this needs to happen before the argument parsing of --cap-drop. + */ + struct __user_cap_header_struct hdr = { _LINUX_CAPABILITY_VERSION_3, 0 }; + struct __user_cap_data_struct data[2] = { { 0 } }; + + if (capget (&hdr, data) < 0) + die_with_error ("capget (for uid == 0) failed"); + + requested_caps[0] = data[0].effective; + requested_caps[1] = data[1].effective; + } + + /* Else, we try unprivileged user namespaces */ +} + +/* This is called once we're inside the namespace */ +static void +switch_to_user_with_privs (void) +{ + /* If we're in a new user namespace, we got back the bounding set, clear it again */ + if (opt_unshare_user || opt_userns_fd != -1) + drop_cap_bounding_set (false); + + /* If we switched to a new user namespace it may allow other uids/gids, so switch to the target one */ + if (opt_userns_fd != -1) + { + if (opt_sandbox_uid != real_uid && setuid (opt_sandbox_uid) < 0) + die_with_error ("unable to switch to uid %d", opt_sandbox_uid); + + if (opt_sandbox_gid != real_gid && setgid (opt_sandbox_gid) < 0) + die_with_error ("unable to switch to gid %d", opt_sandbox_gid); + } + + if (!is_privileged) + return; + + /* Tell kernel not clear capabilities when later dropping root uid */ + if (prctl (PR_SET_KEEPCAPS, 1, 0, 0, 0) < 0) + die_with_error ("prctl(PR_SET_KEEPCAPS) failed"); + + if (setuid (opt_sandbox_uid) < 0) + die_with_error ("unable to drop root uid"); + + /* Regain effective required capabilities from permitted */ + set_required_caps (); +} + +/* Call setuid() and use capset() to adjust capabilities */ +static void +drop_privs (bool keep_requested_caps, + bool already_changed_uid) +{ + assert (!keep_requested_caps || !is_privileged); + /* Drop root uid */ + if (is_privileged && !already_changed_uid && + setuid (opt_sandbox_uid) < 0) + die_with_error ("unable to drop root uid"); + + drop_all_caps (keep_requested_caps); + + /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ + if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) + die_with_error ("can't set dumpable"); +} + +static void +write_uid_gid_map (uid_t sandbox_uid, + uid_t parent_uid, + uid_t sandbox_gid, + uid_t parent_gid, + pid_t pid, + bool deny_groups, + bool map_root) +{ + cleanup_free char *uid_map = NULL; + cleanup_free char *gid_map = NULL; + cleanup_free char *dir = NULL; + cleanup_fd int dir_fd = -1; + uid_t old_fsuid = (uid_t)-1; + + if (pid == -1) + dir = xstrdup ("self"); + else + dir = xasprintf ("%d", pid); + + dir_fd = openat (proc_fd, dir, O_PATH); + if (dir_fd < 0) + die_with_error ("open /proc/%s failed", dir); + + if (map_root && parent_uid != 0 && sandbox_uid != 0) + uid_map = xasprintf ("0 %d 1\n" + "%d %d 1\n", overflow_uid, sandbox_uid, parent_uid); + else + uid_map = xasprintf ("%d %d 1\n", sandbox_uid, parent_uid); + + if (map_root && parent_gid != 0 && sandbox_gid != 0) + gid_map = xasprintf ("0 %d 1\n" + "%d %d 1\n", overflow_gid, sandbox_gid, parent_gid); + else + gid_map = xasprintf ("%d %d 1\n", sandbox_gid, parent_gid); + + /* We have to be root to be allowed to write to the uid map + * for setuid apps, so temporary set fsuid to 0 */ + if (is_privileged) + old_fsuid = setfsuid (0); + + if (write_file_at (dir_fd, "uid_map", uid_map) != 0) + die_with_error ("setting up uid map"); + + if (deny_groups && + write_file_at (dir_fd, "setgroups", "deny\n") != 0) + { + /* If /proc/[pid]/setgroups does not exist, assume we are + * running a linux kernel < 3.19, i.e. we live with the + * vulnerability known as CVE-2014-8989 in older kernels + * where setgroups does not exist. + */ + if (errno != ENOENT) + die_with_error ("error writing to setgroups"); + } + + if (write_file_at (dir_fd, "gid_map", gid_map) != 0) + die_with_error ("setting up gid map"); + + if (is_privileged) + { + setfsuid (old_fsuid); + if ((uid_t) setfsuid (-1) != real_uid) + die ("Unable to re-set fsuid"); + } +} + +static void +privileged_op (int privileged_op_socket, + uint32_t op, + uint32_t flags, + uint32_t perms, + size_t size_arg, + const char *arg1, + const char *arg2) +{ + bind_mount_result bind_result; + char *failing_path = NULL; + + if (privileged_op_socket != -1) + { + uint32_t buffer[2048]; /* 8k, but is int32 to guarantee nice alignment */ + PrivSepOp *op_buffer = (PrivSepOp *) buffer; + size_t buffer_size = sizeof (PrivSepOp); + uint32_t arg1_offset = 0, arg2_offset = 0; + + /* We're unprivileged, send this request to the privileged part */ + + if (arg1 != NULL) + { + arg1_offset = buffer_size; + buffer_size += strlen (arg1) + 1; + } + if (arg2 != NULL) + { + arg2_offset = buffer_size; + buffer_size += strlen (arg2) + 1; + } + + if (buffer_size >= sizeof (buffer)) + die ("privilege separation operation to large"); + + op_buffer->op = op; + op_buffer->flags = flags; + op_buffer->perms = perms; + op_buffer->size_arg = size_arg; + op_buffer->arg1_offset = arg1_offset; + op_buffer->arg2_offset = arg2_offset; + if (arg1 != NULL) + strcpy ((char *) buffer + arg1_offset, arg1); + if (arg2 != NULL) + strcpy ((char *) buffer + arg2_offset, arg2); + + if (TEMP_FAILURE_RETRY (write (privileged_op_socket, buffer, buffer_size)) != (ssize_t)buffer_size) + die ("Can't write to privileged_op_socket"); + + if (TEMP_FAILURE_RETRY (read (privileged_op_socket, buffer, 1)) != 1) + die ("Can't read from privileged_op_socket"); + + return; + } + + /* + * This runs a privileged request for the unprivileged setup + * code. Note that since the setup code is unprivileged it is not as + * trusted, so we need to verify that all requests only affect the + * child namespace as set up by the privileged parts of the setup, + * and that all the code is very careful about handling input. + * + * This means: + * * Bind mounts are safe, since we always use filesystem namespace. They + * must be recursive though, as otherwise you can use a non-recursive bind + * mount to access an otherwise over-mounted mountpoint. + * * Mounting proc, tmpfs, mqueue, devpts in the child namespace is assumed to + * be safe. + * * Remounting RO (even non-recursive) is safe because it decreases privileges. + * * sethostname() is safe only if we set up a UTS namespace + */ + switch (op) + { + case PRIV_SEP_OP_DONE: + break; + + case PRIV_SEP_OP_REMOUNT_RO_NO_RECURSIVE: + bind_result = bind_mount (proc_fd, NULL, arg2, BIND_READONLY, &failing_path); + + if (bind_result != BIND_MOUNT_SUCCESS) + die_with_bind_result (bind_result, errno, failing_path, + "Can't remount readonly on %s", arg2); + + assert (failing_path == NULL); /* otherwise we would have died */ + break; + + case PRIV_SEP_OP_BIND_MOUNT: + /* We always bind directories recursively, otherwise this would let us + access files that are otherwise covered on the host */ + bind_result = bind_mount (proc_fd, arg1, arg2, BIND_RECURSIVE | flags, &failing_path); + + if (bind_result != BIND_MOUNT_SUCCESS) + die_with_bind_result (bind_result, errno, failing_path, + "Can't bind mount %s on %s", arg1, arg2); + + assert (failing_path == NULL); /* otherwise we would have died */ + break; + + case PRIV_SEP_OP_PROC_MOUNT: + if (mount ("proc", arg1, "proc", MS_NOSUID | MS_NOEXEC | MS_NODEV, NULL) != 0) + die_with_mount_error ("Can't mount proc on %s", arg1); + break; + + case PRIV_SEP_OP_TMPFS_MOUNT: + { + cleanup_free char *mode = NULL; + + /* This check should be unnecessary since we checked this when parsing + * the --size option as well. However, better be safe than sorry. */ + if (size_arg > MAX_TMPFS_BYTES) + die_with_error ("Specified tmpfs size too large (%zu > %zu)", size_arg, MAX_TMPFS_BYTES); + + if (size_arg != 0) + mode = xasprintf ("mode=%#o,size=%zu", perms, size_arg); + else + mode = xasprintf ("mode=%#o", perms); + + cleanup_free char *opt = label_mount (mode, opt_file_label); + if (mount ("tmpfs", arg1, "tmpfs", MS_NOSUID | MS_NODEV, opt) != 0) + die_with_mount_error ("Can't mount tmpfs on %s", arg1); + break; + } + + case PRIV_SEP_OP_DEVPTS_MOUNT: + if (mount ("devpts", arg1, "devpts", MS_NOSUID | MS_NOEXEC, + "newinstance,ptmxmode=0666,mode=620") != 0) + die_with_mount_error ("Can't mount devpts on %s", arg1); + break; + + case PRIV_SEP_OP_MQUEUE_MOUNT: + if (mount ("mqueue", arg1, "mqueue", 0, NULL) != 0) + die_with_mount_error ("Can't mount mqueue on %s", arg1); + break; + + case PRIV_SEP_OP_OVERLAY_MOUNT: + if (mount ("overlay", arg2, "overlay", MS_MGC_VAL, arg1) != 0) + { + /* The standard message for ELOOP, "Too many levels of symbolic + * links", is not helpful here. */ + if (errno == ELOOP) + die ("Can't make overlay mount on %s with options %s: " + "Overlay directories may not overlap", + arg2, arg1); + die_with_mount_error ("Can't make overlay mount on %s with options %s", + arg2, arg1); + } + break; + + case PRIV_SEP_OP_SET_HOSTNAME: + /* This is checked at the start, but lets verify it here in case + something manages to send hacked priv-sep operation requests. */ + if (!opt_unshare_uts) + die ("Refusing to set hostname in original namespace"); + if (sethostname (arg1, strlen(arg1)) != 0) + die_with_error ("Can't set hostname to %s", arg1); + break; + + default: + die ("Unexpected privileged op %d", op); + } +} + +/* This is run unprivileged in the child namespace but can request + * some privileged operations (also in the child namespace) via the + * privileged_op_socket. + */ +static void +setup_newroot (bool unshare_pid, + int privileged_op_socket) +{ + SetupOp *op; + int tmp_overlay_idx = 0; + + for (op = ops; op != NULL; op = op->next) + { + cleanup_free char *source = NULL; + cleanup_free char *dest = NULL; + int source_mode = 0; + unsigned int i; + + if (op->source && + op->type != SETUP_MAKE_SYMLINK) + { + source = get_oldroot_path (op->source); + source_mode = get_file_mode (source); + if (source_mode < 0) + { + if (op->flags & ALLOW_NOTEXIST && errno == ENOENT) + continue; /* Ignore and move on */ + die_with_error("Can't get type of source %s", op->source); + } + } + + if (op->dest && + (op->flags & NO_CREATE_DEST) == 0) + { + unsigned parent_mode = 0755; + + /* If we're creating a file that is inaccessible by the owning group, + * try to achieve least-astonishment by creating parents also + * inaccessible by that group. */ + if (op->perms >= 0 && + (op->perms & 0070) == 0) + parent_mode &= ~0050U; + + /* The same, but for users other than the owner and group. */ + if (op->perms >= 0 && + (op->perms & 0007) == 0) + parent_mode &= ~0005U; + + dest = get_newroot_path (op->dest); + if (mkdir_with_parents (dest, parent_mode, false) != 0) + die_with_error ("Can't mkdir parents for %s", op->dest); + } + + switch (op->type) + { + case SETUP_RO_BIND_MOUNT: + case SETUP_DEV_BIND_MOUNT: + case SETUP_BIND_MOUNT: + if (source_mode == S_IFDIR) + { + if (ensure_dir (dest, 0755) != 0) + die_with_error ("Can't mkdir %s", op->dest); + } + else if (ensure_file (dest, 0444) != 0) + die_with_error ("Can't create file at %s", op->dest); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_BIND_MOUNT, + (op->type == SETUP_RO_BIND_MOUNT ? BIND_READONLY : 0) | + (op->type == SETUP_DEV_BIND_MOUNT ? BIND_DEVICES : 0), + 0, 0, source, dest); + + if (op->fd >= 0) + { + struct stat fd_st, mount_st; + + /* When using bind-fd, there is a race condition between resolving the fd as a magic symlink + * and mounting it, where someone could replace what is at the symlink target. Ideally + * we would not even resolve the symlink and directly bind-mount from the fd, but unfortunately + * we can't do that, because its not permitted to bind mount a fd from another user namespace. + * So, we resolve, mount and then compare fstat+stat to detect the race. */ + + if (fstat(op->fd, &fd_st) != 0) + die_with_error("Can't stat fd %d", op->fd); + if (lstat(dest, &mount_st) != 0) + die_with_error("Can't stat mount at %s", dest); + + if (fd_st.st_ino != mount_st.st_ino || + fd_st.st_dev != mount_st.st_dev) + die_with_error("Race condition binding dirfd"); + + close(op->fd); + op->fd = -1; + } + + break; + + case SETUP_OVERLAY_MOUNT: + case SETUP_RO_OVERLAY_MOUNT: + case SETUP_TMP_OVERLAY_MOUNT: + { + StringBuilder sb = {0}; + bool multi_src = false; + + if (ensure_dir (dest, 0755) != 0) + die_with_error ("Can't mkdir %s", op->dest); + + if (op->source != NULL) + { + strappend (&sb, "upperdir=/oldroot"); + strappend_escape_for_mount_options (&sb, op->source); + strappend (&sb, ",workdir=/oldroot"); + op = op->next; + strappend_escape_for_mount_options (&sb, op->source); + strappend (&sb, ","); + } + else if (op->type == SETUP_TMP_OVERLAY_MOUNT) + strappendf (&sb, "upperdir=/tmp-overlay-upper-%1$d,workdir=/tmp-overlay-work-%1$d,", + tmp_overlay_idx++); + + strappend (&sb, "lowerdir=/oldroot"); + while (op->next != NULL && op->next->type == SETUP_OVERLAY_SRC) + { + op = op->next; + if (multi_src) + strappend (&sb, ":/oldroot"); + strappend_escape_for_mount_options (&sb, op->source); + multi_src = true; + } + + strappend (&sb, ",userxattr"); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_OVERLAY_MOUNT, 0, 0, 0, sb.str, dest); + free (sb.str); + } + break; + + case SETUP_REMOUNT_RO_NO_RECURSIVE: + privileged_op (privileged_op_socket, + PRIV_SEP_OP_REMOUNT_RO_NO_RECURSIVE, 0, 0, 0, NULL, dest); + break; + + case SETUP_MOUNT_PROC: + if (ensure_dir (dest, 0755) != 0) + die_with_error ("Can't mkdir %s", op->dest); + + if (unshare_pid || opt_pidns_fd != -1) + { + /* Our own procfs */ + privileged_op (privileged_op_socket, + PRIV_SEP_OP_PROC_MOUNT, 0, 0, 0, + dest, NULL); + } + else + { + /* Use system procfs, as we share pid namespace anyway */ + privileged_op (privileged_op_socket, + PRIV_SEP_OP_BIND_MOUNT, 0, 0, 0, + "oldroot/proc", dest); + } + + /* There are a bunch of weird old subdirs of /proc that could potentially be + problematic (for instance /proc/sysrq-trigger lets you shut down the machine + if you have write access). We should not have access to these as a non-privileged + user, but lets cover them anyway just to make sure */ + static const char * const cover_proc_dirs[] = { "sys", "sysrq-trigger", "irq", "bus" }; + for (i = 0; i < N_ELEMENTS (cover_proc_dirs); i++) + { + cleanup_free char *subdir = strconcat3 (dest, "/", cover_proc_dirs[i]); + if (access (subdir, W_OK) < 0) + { + /* The file is already read-only or doesn't exist. */ + if (errno == EACCES || errno == ENOENT || errno == EROFS) + continue; + + die_with_error ("Can't access %s", subdir); + } + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_BIND_MOUNT, BIND_READONLY, 0, 0, + subdir, subdir); + } + + break; + + case SETUP_MOUNT_DEV: + if (ensure_dir (dest, 0755) != 0) + die_with_error ("Can't mkdir %s", op->dest); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_TMPFS_MOUNT, 0, 0755, 0, + dest, NULL); + + static const char *const devnodes[] = { "null", "zero", "full", "random", "urandom", "tty" }; + for (i = 0; i < N_ELEMENTS (devnodes); i++) + { + cleanup_free char *node_dest = strconcat3 (dest, "/", devnodes[i]); + cleanup_free char *node_src = strconcat ("/oldroot/dev/", devnodes[i]); + if (create_file (node_dest, 0444, NULL) != 0) + die_with_error ("Can't create file %s/%s", op->dest, devnodes[i]); + privileged_op (privileged_op_socket, + PRIV_SEP_OP_BIND_MOUNT, BIND_DEVICES, 0, 0, + node_src, node_dest); + } + + static const char *const stdionodes[] = { "stdin", "stdout", "stderr" }; + for (i = 0; i < N_ELEMENTS (stdionodes); i++) + { + cleanup_free char *target = xasprintf ("/proc/self/fd/%d", i); + cleanup_free char *node_dest = strconcat3 (dest, "/", stdionodes[i]); + if (symlink (target, node_dest) < 0) + die_with_error ("Can't create symlink %s/%s", op->dest, stdionodes[i]); + } + + /* /dev/fd and /dev/core - legacy, but both nspawn and docker do these */ + { cleanup_free char *dev_fd = strconcat (dest, "/fd"); + if (symlink ("/proc/self/fd", dev_fd) < 0) + die_with_error ("Can't create symlink %s", dev_fd); + } + { cleanup_free char *dev_core = strconcat (dest, "/core"); + if (symlink ("/proc/kcore", dev_core) < 0) + die_with_error ("Can't create symlink %s", dev_core); + } + + { + cleanup_free char *pts = strconcat (dest, "/pts"); + cleanup_free char *ptmx = strconcat (dest, "/ptmx"); + cleanup_free char *shm = strconcat (dest, "/shm"); + + if (mkdir (shm, 0755) == -1) + die_with_error ("Can't create %s/shm", op->dest); + + if (mkdir (pts, 0755) == -1) + die_with_error ("Can't create %s/devpts", op->dest); + privileged_op (privileged_op_socket, + PRIV_SEP_OP_DEVPTS_MOUNT, 0, 0, 0, pts, NULL); + + if (symlink ("pts/ptmx", ptmx) != 0) + die_with_error ("Can't make symlink at %s/ptmx", op->dest); + } + + /* If stdout is a tty, that means the sandbox can write to the + outside-sandbox tty. In that case we also create a /dev/console + that points to this tty device. This should not cause any more + access than we already have, and it makes ttyname() work in the + sandbox. */ + if (host_tty_dev != NULL && *host_tty_dev != 0) + { + cleanup_free char *src_tty_dev = strconcat ("/oldroot", host_tty_dev); + cleanup_free char *dest_console = strconcat (dest, "/console"); + + if (create_file (dest_console, 0444, NULL) != 0) + die_with_error ("creating %s/console", op->dest); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_BIND_MOUNT, BIND_DEVICES, 0, 0, + src_tty_dev, dest_console); + } + + break; + + case SETUP_MOUNT_TMPFS: + assert (dest != NULL); + assert (op->perms >= 0); + assert (op->perms <= 07777); + + if (ensure_dir (dest, 0755) != 0) + die_with_error ("Can't mkdir %s", op->dest); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_TMPFS_MOUNT, 0, op->perms, op->size, + dest, NULL); + break; + + case SETUP_MOUNT_MQUEUE: + if (ensure_dir (dest, 0755) != 0) + die_with_error ("Can't mkdir %s", op->dest); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_MQUEUE_MOUNT, 0, 0, 0, + dest, NULL); + break; + + case SETUP_MAKE_DIR: + assert (dest != NULL); + assert (op->perms >= 0); + assert (op->perms <= 07777); + + if (ensure_dir (dest, op->perms) != 0) + die_with_error ("Can't mkdir %s", op->dest); + + break; + + case SETUP_CHMOD: + assert (op->dest != NULL); + /* We used NO_CREATE_DEST so we have to use get_newroot_path() + * explicitly */ + assert (dest == NULL); + dest = get_newroot_path (op->dest); + assert (dest != NULL); + assert (op->perms >= 0); + assert (op->perms <= 07777); + + if (chmod (dest, op->perms) != 0) + die_with_error ("Can't chmod %#o %s", op->perms, op->dest); + + break; + + case SETUP_MAKE_FILE: + { + cleanup_fd int dest_fd = -1; + + assert (dest != NULL); + assert (op->perms >= 0); + assert (op->perms <= 07777); + + dest_fd = creat (dest, op->perms); + if (dest_fd == -1) + die_with_error ("Can't create file %s", op->dest); + + if (copy_file_data (op->fd, dest_fd) != 0) + die_with_error ("Can't write data to file %s", op->dest); + + close (op->fd); + op->fd = -1; + } + break; + + case SETUP_MAKE_BIND_FILE: + case SETUP_MAKE_RO_BIND_FILE: + { + cleanup_fd int dest_fd = -1; + char tempfile[] = "/bindfileXXXXXX"; + + assert (dest != NULL); + assert (op->perms >= 0); + assert (op->perms <= 07777); + + dest_fd = mkstemp (tempfile); + if (dest_fd == -1) + die_with_error ("Can't create tmpfile for %s", op->dest); + + if (fchmod (dest_fd, op->perms) != 0) + die_with_error ("Can't set mode %#o on file to be used for %s", + op->perms, op->dest); + + if (copy_file_data (op->fd, dest_fd) != 0) + die_with_error ("Can't write data to file %s", op->dest); + + close (op->fd); + op->fd = -1; + + assert (dest != NULL); + + if (ensure_file (dest, 0444) != 0) + die_with_error ("Can't create file at %s", op->dest); + + privileged_op (privileged_op_socket, + PRIV_SEP_OP_BIND_MOUNT, + (op->type == SETUP_MAKE_RO_BIND_FILE ? BIND_READONLY : 0), + 0, 0, tempfile, dest); + + /* Remove the file so we're sure the app can't get to it in any other way. + Its outside the container chroot, so it shouldn't be possible, but lets + make it really sure. */ + unlink (tempfile); + } + break; + + case SETUP_MAKE_SYMLINK: + assert (op->source != NULL); /* guaranteed by the constructor */ + if (symlink (op->source, dest) != 0) + { + if (errno == EEXIST) + { + cleanup_free char *existing = readlink_malloc (dest); + if (existing == NULL) + { + if (errno == EINVAL) + die ("Can't make symlink at %s: destination exists and is not a symlink", op->dest); + else + die_with_error ("Can't make symlink at %s: destination exists, and cannot read symlink target", op->dest); + } + + if (strcmp (existing, op->source) == 0) + break; + + die ("Can't make symlink at %s: existing destination is %s", op->dest, existing); + } + die_with_error ("Can't make symlink at %s", op->dest); + } + break; + + case SETUP_SET_HOSTNAME: + assert (op->dest != NULL); /* guaranteed by the constructor */ + privileged_op (privileged_op_socket, + PRIV_SEP_OP_SET_HOSTNAME, 0, 0, 0, + op->dest, NULL); + break; + + case SETUP_OVERLAY_SRC: /* handled by SETUP_OVERLAY_MOUNT */ + default: + die ("Unexpected type %d", op->type); + } + } + privileged_op (privileged_op_socket, + PRIV_SEP_OP_DONE, 0, 0, 0, NULL, NULL); +} + +/* Do not leak file descriptors already used by setup_newroot () */ +static void +close_ops_fd (void) +{ + SetupOp *op; + + for (op = ops; op != NULL; op = op->next) + { + if (op->fd != -1) + { + (void) close (op->fd); + op->fd = -1; + } + } +} + +/* We need to resolve relative symlinks in the sandbox before we + chroot so that absolute symlinks are handled correctly. We also + need to do this after we've switched to the real uid so that + e.g. paths on fuse mounts work */ +static void +resolve_symlinks_in_ops (void) +{ + SetupOp *op; + + for (op = ops; op != NULL; op = op->next) + { + const char *old_source; + + switch (op->type) + { + case SETUP_RO_BIND_MOUNT: + case SETUP_DEV_BIND_MOUNT: + case SETUP_BIND_MOUNT: + case SETUP_OVERLAY_SRC: + case SETUP_OVERLAY_MOUNT: + old_source = op->source; + op->source = realpath (old_source, NULL); + if (op->source == NULL) + { + if (op->flags & ALLOW_NOTEXIST && errno == ENOENT) + op->source = old_source; + else + die_with_error("Can't find source path %s", old_source); + } + break; + + case SETUP_RO_OVERLAY_MOUNT: + case SETUP_TMP_OVERLAY_MOUNT: + case SETUP_MOUNT_PROC: + case SETUP_MOUNT_DEV: + case SETUP_MOUNT_TMPFS: + case SETUP_MOUNT_MQUEUE: + case SETUP_MAKE_DIR: + case SETUP_MAKE_FILE: + case SETUP_MAKE_BIND_FILE: + case SETUP_MAKE_RO_BIND_FILE: + case SETUP_MAKE_SYMLINK: + case SETUP_REMOUNT_RO_NO_RECURSIVE: + case SETUP_SET_HOSTNAME: + case SETUP_CHMOD: + default: + break; + } + } +} + + +static const char * +resolve_string_offset (void *buffer, + size_t buffer_size, + uint32_t offset) +{ + if (offset == 0) + return NULL; + + if (offset > buffer_size) + die ("Invalid string offset %d (buffer size %zd)", offset, buffer_size); + + return (const char *) buffer + offset; +} + +static uint32_t +read_priv_sec_op (int read_socket, + void *buffer, + size_t buffer_size, + uint32_t *flags, + uint32_t *perms, + size_t *size_arg, + const char **arg1, + const char **arg2) +{ + const PrivSepOp *op = (const PrivSepOp *) buffer; + ssize_t rec_len; + + do + rec_len = read (read_socket, buffer, buffer_size - 1); + while (rec_len == -1 && errno == EINTR); + + if (rec_len < 0) + die_with_error ("Can't read from unprivileged helper"); + + if (rec_len == 0) + exit (1); /* Privileged helper died and printed error, so exit silently */ + + if ((size_t)rec_len < sizeof (PrivSepOp)) + die ("Invalid size %zd from unprivileged helper", rec_len); + + /* Guarantee zero termination of any strings */ + ((char *) buffer)[rec_len] = 0; + + *flags = op->flags; + *perms = op->perms; + *size_arg = op->size_arg; + *arg1 = resolve_string_offset (buffer, rec_len, op->arg1_offset); + *arg2 = resolve_string_offset (buffer, rec_len, op->arg2_offset); + + return op->op; +} + +static void __attribute__ ((noreturn)) +print_version_and_exit (void) +{ + printf ("%s\n", PACKAGE_STRING); + exit (0); +} + +static int +is_modifier_option (const char *option) +{ + return strcmp (option, "--perms") == 0 + || strcmp(option, "--size") == 0; +} + +static void +warn_only_last_option (const char *name) +{ + warn ("Only the last %s option will take effect", name); +} + +static void +make_setup_overlay_src_ops (const char *const *const argv) +{ + /* SETUP_OVERLAY_SRC is unlike other SETUP_* ops in that it exists to hold + * data for SETUP_{,TMP_,RO_}OVERLAY_MOUNT ops, not to be its own operation. + * This lets us reuse existing code paths to handle resolving the realpaths + * of each source, as no other operations involve multiple sources the way + * the *_OVERLAY_MOUNT ops do. + * + * While the --overlay-src arguments are expected to (directly) precede the + * --overlay argument, in bottom-to-top order, the SETUP_OVERLAY_SRC ops + * follow their corresponding *_OVERLAY_MOUNT op, in top-to-bottom order + * (the order in which overlayfs will want them). They are handled specially + * in setup_new_root () during the processing of *_OVERLAY_MOUNT. + */ + int i; + SetupOp *op; + + for (i = 1; i <= next_overlay_src_count; i++) + { + op = setup_op_new (SETUP_OVERLAY_SRC); + op->source = argv[1 - 2 * i]; + } + next_overlay_src_count = 0; +} + +static void +parse_args_recurse (int *argcp, + const char ***argvp, + bool in_file, + int *total_parsed_argc_p) +{ + SetupOp *op; + int argc = *argcp; + const char **argv = *argvp; + /* I can't imagine a case where someone wants more than this. + * If you do...you should be able to pass multiple files + * via a single tmpfs and linking them there, etc. + * + * We're adding this hardening due to precedent from + * http://googleprojectzero.blogspot.com/2014/08/the-poisoned-nul-byte-2014-edition.html + * + * I picked 9000 because the Internet told me to and it was hard to + * resist. + */ + static const int32_t MAX_ARGS = 9000; + + if (*total_parsed_argc_p > MAX_ARGS) + die ("Exceeded maximum number of arguments %u", MAX_ARGS); + + while (argc > 0) + { + const char *arg = argv[0]; + + if (strcmp (arg, "--help") == 0) + { + usage (EXIT_SUCCESS, stdout); + } + else if (strcmp (arg, "--version") == 0) + { + print_version_and_exit (); + } + else if (strcmp (arg, "--args") == 0) + { + int the_fd; + char *endptr; + const char *p, *data_end; + size_t data_len; + cleanup_free const char **data_argv = NULL; + const char **data_argv_copy; + int data_argc; + int i; + + if (in_file) + die ("--args not supported in arguments file"); + + if (argc < 2) + die ("--args takes an argument"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + /* opt_args_data is essentially a recursive argv array, which we must + * keep allocated until exit time, since its argv entries get used + * by the other cases in parse_args_recurse() when we recurse. */ + opt_args_data = load_file_data (the_fd, &data_len); + if (opt_args_data == NULL) + die_with_error ("Can't read --args data"); + (void) close (the_fd); + + data_end = opt_args_data + data_len; + data_argc = 0; + + p = opt_args_data; + while (p != NULL && p < data_end) + { + data_argc++; + (*total_parsed_argc_p)++; + if (*total_parsed_argc_p > MAX_ARGS) + die ("Exceeded maximum number of arguments %u", MAX_ARGS); + p = memchr (p, 0, data_end - p); + if (p != NULL) + p++; + } + + data_argv = xcalloc (data_argc + 1, sizeof (char *)); + + i = 0; + p = opt_args_data; + while (p != NULL && p < data_end) + { + /* Note: load_file_data always adds a nul terminator, so this is safe + * even for the last string. */ + data_argv[i++] = p; + p = memchr (p, 0, data_end - p); + if (p != NULL) + p++; + } + + data_argv_copy = data_argv; /* Don't change data_argv, we need to free it */ + parse_args_recurse (&data_argc, &data_argv_copy, true, total_parsed_argc_p); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--argv0") == 0) + { + if (argc < 2) + die ("--argv0 takes one argument"); + + if (opt_argv0 != NULL) + die ("--argv0 used multiple times"); + + opt_argv0 = argv[1]; + argv++; + argc--; + } + else if (strcmp (arg, "--level-prefix") == 0) + { + bwrap_level_prefix = true; + } + else if (strcmp (arg, "--unshare-all") == 0) + { + /* Keep this in order with the older (legacy) --unshare arguments, + * we use the --try variants of user and cgroup, since we want + * to support systems/kernels without support for those. + */ + opt_unshare_user_try = opt_unshare_ipc = opt_unshare_pid = + opt_unshare_uts = opt_unshare_cgroup_try = + opt_unshare_net = true; + } + /* Begin here the older individual --unshare variants */ + else if (strcmp (arg, "--unshare-user") == 0) + { + opt_unshare_user = true; + } + else if (strcmp (arg, "--unshare-user-try") == 0) + { + opt_unshare_user_try = true; + } + else if (strcmp (arg, "--unshare-ipc") == 0) + { + opt_unshare_ipc = true; + } + else if (strcmp (arg, "--unshare-pid") == 0) + { + opt_unshare_pid = true; + } + else if (strcmp (arg, "--unshare-net") == 0) + { + opt_unshare_net = true; + } + else if (strcmp (arg, "--unshare-uts") == 0) + { + opt_unshare_uts = true; + } + else if (strcmp (arg, "--unshare-cgroup") == 0) + { + opt_unshare_cgroup = true; + } + else if (strcmp (arg, "--unshare-cgroup-try") == 0) + { + opt_unshare_cgroup_try = true; + } + /* Begin here the newer --share variants */ + else if (strcmp (arg, "--share-net") == 0) + { + opt_unshare_net = false; + } + /* End --share variants, other arguments begin */ + else if (strcmp (arg, "--chdir") == 0) + { + if (argc < 2) + die ("--chdir takes one argument"); + + if (opt_chdir_path != NULL) + warn_only_last_option ("--chdir"); + + opt_chdir_path = argv[1]; + argv++; + argc--; + } + else if (strcmp (arg, "--disable-userns") == 0) + { + opt_disable_userns = true; + } + else if (strcmp (arg, "--assert-userns-disabled") == 0) + { + opt_assert_userns_disabled = true; + } + else if (strcmp (arg, "--remount-ro") == 0) + { + if (argc < 2) + die ("--remount-ro takes one argument"); + + op = setup_op_new (SETUP_REMOUNT_RO_NO_RECURSIVE); + op->dest = argv[1]; + + argv++; + argc--; + } + else if (strcmp(arg, "--bind") == 0 || + strcmp(arg, "--bind-try") == 0) + { + if (argc < 3) + die ("%s takes two arguments", arg); + + op = setup_op_new (SETUP_BIND_MOUNT); + op->source = argv[1]; + op->dest = argv[2]; + if (strcmp(arg, "--bind-try") == 0) + op->flags = ALLOW_NOTEXIST; + + argv += 2; + argc -= 2; + } + else if (strcmp(arg, "--ro-bind") == 0 || + strcmp(arg, "--ro-bind-try") == 0) + { + if (argc < 3) + die ("%s takes two arguments", arg); + + op = setup_op_new (SETUP_RO_BIND_MOUNT); + op->source = argv[1]; + op->dest = argv[2]; + if (strcmp(arg, "--ro-bind-try") == 0) + op->flags = ALLOW_NOTEXIST; + + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--dev-bind") == 0 || + strcmp (arg, "--dev-bind-try") == 0) + { + if (argc < 3) + die ("%s takes two arguments", arg); + + op = setup_op_new (SETUP_DEV_BIND_MOUNT); + op->source = argv[1]; + op->dest = argv[2]; + if (strcmp(arg, "--dev-bind-try") == 0) + op->flags = ALLOW_NOTEXIST; + + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--bind-fd") == 0 || + strcmp (arg, "--ro-bind-fd") == 0) + { + int src_fd; + char *endptr; + + if (argc < 3) + die ("--bind-fd takes two arguments"); + + src_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || src_fd < 0) + die ("Invalid fd: %s", argv[1]); + + if (strcmp(arg, "--ro-bind-fd") == 0) + op = setup_op_new (SETUP_RO_BIND_MOUNT); + else + op = setup_op_new (SETUP_BIND_MOUNT); + op->source = xasprintf ("/proc/self/fd/%d", src_fd); + op->fd = src_fd; + op->dest = argv[2]; + + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--overlay-src") == 0) + { + if (is_privileged) + die ("The --overlay-src option is not permitted in setuid mode"); + + next_overlay_src_count++; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--overlay") == 0) + { + SetupOp *workdir_op; + + if (is_privileged) + die ("The --overlay option is not permitted in setuid mode"); + + if (argc < 4) + die ("--overlay takes three arguments"); + + if (next_overlay_src_count < 1) + die ("--overlay requires at least one --overlay-src"); + + op = setup_op_new (SETUP_OVERLAY_MOUNT); + op->source = argv[1]; + workdir_op = setup_op_new (SETUP_OVERLAY_SRC); + workdir_op->source = argv[2]; + op->dest = argv[3]; + make_setup_overlay_src_ops (argv); + + argv += 3; + argc -= 3; + } + else if (strcmp (arg, "--tmp-overlay") == 0) + { + if (is_privileged) + die ("The --tmp-overlay option is not permitted in setuid mode"); + + if (argc < 2) + die ("--tmp-overlay takes an argument"); + + if (next_overlay_src_count < 1) + die ("--tmp-overlay requires at least one --overlay-src"); + + op = setup_op_new (SETUP_TMP_OVERLAY_MOUNT); + op->dest = argv[1]; + make_setup_overlay_src_ops (argv); + opt_tmp_overlay_count++; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--ro-overlay") == 0) + { + if (is_privileged) + die ("The --ro-overlay option is not permitted in setuid mode"); + + if (argc < 2) + die ("--ro-overlay takes an argument"); + + if (next_overlay_src_count < 2) + die ("--ro-overlay requires at least two --overlay-src"); + + op = setup_op_new (SETUP_RO_OVERLAY_MOUNT); + op->dest = argv[1]; + make_setup_overlay_src_ops (argv); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--proc") == 0) + { + if (argc < 2) + die ("--proc takes an argument"); + + op = setup_op_new (SETUP_MOUNT_PROC); + op->dest = argv[1]; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--exec-label") == 0) + { + if (argc < 2) + die ("--exec-label takes an argument"); + + if (opt_exec_label != NULL) + warn_only_last_option ("--exec-label"); + + opt_exec_label = argv[1]; + die_unless_label_valid (opt_exec_label); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--file-label") == 0) + { + if (argc < 2) + die ("--file-label takes an argument"); + + if (opt_file_label != NULL) + warn_only_last_option ("--file-label"); + + opt_file_label = argv[1]; + die_unless_label_valid (opt_file_label); + if (label_create_file (opt_file_label)) + die_with_error ("--file-label setup failed"); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--dev") == 0) + { + if (argc < 2) + die ("--dev takes an argument"); + + op = setup_op_new (SETUP_MOUNT_DEV); + op->dest = argv[1]; + opt_needs_devpts = true; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--tmpfs") == 0) + { + if (argc < 2) + die ("--tmpfs takes an argument"); + + op = setup_op_new (SETUP_MOUNT_TMPFS); + op->dest = argv[1]; + + /* We historically hard-coded the mode of a tmpfs as 0755. */ + if (next_perms >= 0) + op->perms = next_perms; + else + op->perms = 0755; + + next_perms = -1; + + /* If the option is unset, next_size_arg is zero, which results in + * the default tmpfs size. This is exactly what we want. */ + op->size = next_size_arg; + + next_size_arg = 0; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--mqueue") == 0) + { + if (argc < 2) + die ("--mqueue takes an argument"); + + op = setup_op_new (SETUP_MOUNT_MQUEUE); + op->dest = argv[1]; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--dir") == 0) + { + if (argc < 2) + die ("--dir takes an argument"); + + op = setup_op_new (SETUP_MAKE_DIR); + op->dest = argv[1]; + + /* We historically hard-coded the mode of a --dir as 0755. */ + if (next_perms >= 0) + op->perms = next_perms; + else + op->perms = 0755; + + next_perms = -1; + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--file") == 0) + { + int file_fd; + char *endptr; + + if (argc < 3) + die ("--file takes two arguments"); + + file_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || file_fd < 0) + die ("Invalid fd: %s", argv[1]); + + op = setup_op_new (SETUP_MAKE_FILE); + op->fd = file_fd; + op->dest = argv[2]; + + /* We historically hard-coded the mode of a --file as 0666. */ + if (next_perms >= 0) + op->perms = next_perms; + else + op->perms = 0666; + + next_perms = -1; + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--bind-data") == 0) + { + int file_fd; + char *endptr; + + if (argc < 3) + die ("--bind-data takes two arguments"); + + file_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || file_fd < 0) + die ("Invalid fd: %s", argv[1]); + + op = setup_op_new (SETUP_MAKE_BIND_FILE); + op->fd = file_fd; + op->dest = argv[2]; + + /* This is consistent with previous bubblewrap behaviour: + * before implementing --perms, we took the permissions + * given to us by mkstemp(), which are documented to be 0600. */ + if (next_perms >= 0) + op->perms = next_perms; + else + op->perms = 0600; + + next_perms = -1; + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--ro-bind-data") == 0) + { + int file_fd; + char *endptr; + + if (argc < 3) + die ("--ro-bind-data takes two arguments"); + + file_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || file_fd < 0) + die ("Invalid fd: %s", argv[1]); + + op = setup_op_new (SETUP_MAKE_RO_BIND_FILE); + op->fd = file_fd; + op->dest = argv[2]; + + /* This is consistent with previous bubblewrap behaviour: + * before implementing --perms, we took the permissions + * given to us by mkstemp(), which are documented to be 0600. */ + if (next_perms >= 0) + op->perms = next_perms; + else + op->perms = 0600; + + next_perms = -1; + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--symlink") == 0) + { + if (argc < 3) + die ("--symlink takes two arguments"); + + op = setup_op_new (SETUP_MAKE_SYMLINK); + op->source = argv[1]; + op->dest = argv[2]; + + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--lock-file") == 0) + { + if (argc < 2) + die ("--lock-file takes an argument"); + + (void) lock_file_new (argv[1]); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--sync-fd") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--sync-fd takes an argument"); + + if (opt_sync_fd != -1) + warn_only_last_option ("--sync-fd"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_sync_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--block-fd") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--block-fd takes an argument"); + + if (opt_block_fd != -1) + warn_only_last_option ("--block-fd"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_block_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--userns-block-fd") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--userns-block-fd takes an argument"); + + if (opt_userns_block_fd != -1) + warn_only_last_option ("--userns-block-fd"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_userns_block_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--info-fd") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--info-fd takes an argument"); + + if (opt_info_fd != -1) + warn_only_last_option ("--info-fd"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_info_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--json-status-fd") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--json-status-fd takes an argument"); + + if (opt_json_status_fd != -1) + warn_only_last_option ("--json-status-fd"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_json_status_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--seccomp") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--seccomp takes an argument"); + + if (seccomp_programs != NULL) + die ("--seccomp cannot be combined with --add-seccomp-fd"); + + if (opt_seccomp_fd != -1) + warn_only_last_option ("--seccomp"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_seccomp_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--add-seccomp-fd") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--add-seccomp-fd takes an argument"); + + if (opt_seccomp_fd != -1) + die ("--add-seccomp-fd cannot be combined with --seccomp"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + /* takes ownership of fd */ + seccomp_program_new (&the_fd); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--userns") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--userns takes an argument"); + + if (opt_userns_fd != -1) + warn_only_last_option ("--userns"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_userns_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--userns2") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--userns2 takes an argument"); + + if (opt_userns2_fd != -1) + warn_only_last_option ("--userns2"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_userns2_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--pidns") == 0) + { + int the_fd; + char *endptr; + + if (argc < 2) + die ("--pidns takes an argument"); + + if (opt_pidns_fd != -1) + warn_only_last_option ("--pidns"); + + the_fd = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_fd < 0) + die ("Invalid fd: %s", argv[1]); + + opt_pidns_fd = the_fd; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--clearenv") == 0) + { + xclearenv (); + } + else if (strcmp (arg, "--setenv") == 0) + { + if (argc < 3) + die ("--setenv takes two arguments"); + + xsetenv (argv[1], argv[2], 1); + + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--unsetenv") == 0) + { + if (argc < 2) + die ("--unsetenv takes an argument"); + + xunsetenv (argv[1]); + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--uid") == 0) + { + int the_uid; + char *endptr; + + if (argc < 2) + die ("--uid takes an argument"); + + if (opt_sandbox_uid != (uid_t)-1) + warn_only_last_option ("--uid"); + + the_uid = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_uid < 0) + die ("Invalid uid: %s", argv[1]); + + opt_sandbox_uid = the_uid; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--gid") == 0) + { + int the_gid; + char *endptr; + + if (argc < 2) + die ("--gid takes an argument"); + + if (opt_sandbox_gid != (gid_t)-1) + warn_only_last_option ("--gid"); + + the_gid = strtol (argv[1], &endptr, 10); + if (argv[1][0] == 0 || endptr[0] != 0 || the_gid < 0) + die ("Invalid gid: %s", argv[1]); + + opt_sandbox_gid = the_gid; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--hostname") == 0) + { + if (argc < 2) + die ("--hostname takes an argument"); + + if (opt_sandbox_hostname != NULL) + warn_only_last_option ("--hostname"); + + op = setup_op_new (SETUP_SET_HOSTNAME); + op->dest = argv[1]; + op->flags = NO_CREATE_DEST; + + opt_sandbox_hostname = argv[1]; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--new-session") == 0) + { + opt_new_session = true; + } + else if (strcmp (arg, "--die-with-parent") == 0) + { + opt_die_with_parent = true; + } + else if (strcmp (arg, "--as-pid-1") == 0) + { + opt_as_pid_1 = true; + } + else if (strcmp (arg, "--cap-add") == 0) + { + cap_value_t cap; + if (argc < 2) + die ("--cap-add takes an argument"); + + opt_cap_add_or_drop_used = true; + + if (strcasecmp (argv[1], "ALL") == 0) + { + requested_caps[0] = requested_caps[1] = 0xFFFFFFFF; + } + else + { + if (cap_from_name (argv[1], &cap) < 0) + die ("unknown cap: %s", argv[1]); + + if (cap < 32) + requested_caps[0] |= CAP_TO_MASK_0 (cap); + else + requested_caps[1] |= CAP_TO_MASK_1 (cap - 32); + } + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--cap-drop") == 0) + { + cap_value_t cap; + if (argc < 2) + die ("--cap-drop takes an argument"); + + opt_cap_add_or_drop_used = true; + + if (strcasecmp (argv[1], "ALL") == 0) + { + requested_caps[0] = requested_caps[1] = 0; + } + else + { + if (cap_from_name (argv[1], &cap) < 0) + die ("unknown cap: %s", argv[1]); + + if (cap < 32) + requested_caps[0] &= ~CAP_TO_MASK_0 (cap); + else + requested_caps[1] &= ~CAP_TO_MASK_1 (cap - 32); + } + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--perms") == 0) + { + unsigned long perms; + char *endptr = NULL; + + if (argc < 2) + die ("--perms takes an argument"); + + if (next_perms != -1) + die ("--perms given twice for the same action"); + + perms = strtoul (argv[1], &endptr, 8); + + if (argv[1][0] == '\0' + || endptr == NULL + || *endptr != '\0' + || perms > 07777) + die ("--perms takes an octal argument <= 07777"); + + next_perms = (int) perms; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--size") == 0) + { + unsigned long long size; + char *endptr = NULL; + + if (is_privileged) + die ("The --size option is not permitted in setuid mode"); + + if (argc < 2) + die ("--size takes an argument"); + + if (next_size_arg != 0) + die ("--size given twice for the same action"); + + errno = 0; /* reset errno so we can detect ERANGE from strtoull */ + + size = strtoull (argv[1], &endptr, 0); + + /* isdigit: Not only check that the first digit is not '\0', but + * simultaneously guard against negative numbers or preceding + * spaces. */ + if (errno != 0 /* from strtoull */ + || !isdigit(argv[1][0]) + || endptr == NULL + || *endptr != '\0' + || size == 0) + die ("--size takes a non-zero number of bytes"); + + if (size > MAX_TMPFS_BYTES) + die ("--size (for tmpfs) is limited to %zu", MAX_TMPFS_BYTES); + + next_size_arg = (size_t) size; + + argv += 1; + argc -= 1; + } + else if (strcmp (arg, "--chmod") == 0) + { + unsigned long perms; + char *endptr = NULL; + + if (argc < 3) + die ("--chmod takes two arguments"); + + perms = strtoul (argv[1], &endptr, 8); + + if (argv[1][0] == '\0' + || endptr == NULL + || *endptr != '\0' + || perms > 07777) + die ("--chmod takes an octal argument <= 07777"); + + op = setup_op_new (SETUP_CHMOD); + op->flags = NO_CREATE_DEST; + op->perms = (int) perms; + op->dest = argv[2]; + + argv += 2; + argc -= 2; + } + else if (strcmp (arg, "--") == 0) + { + argv += 1; + argc -= 1; + break; + } + else if (*arg == '-') + { + die ("Unknown option %s", arg); + } + else + { + break; + } + + /* If --perms was set for the current action but the current action + * didn't consume the setting, apparently --perms wasn't suitable for + * this action. */ + if (!is_modifier_option(arg) && next_perms >= 0) + die ("--perms must be followed by an option that creates a file"); + + /* Similarly for --size. */ + if (!is_modifier_option(arg) && next_size_arg != 0) + die ("--size must be followed by --tmpfs"); + + /* Similarly for --overlay-src. */ + if (strcmp (arg, "--overlay-src") != 0 && next_overlay_src_count > 0) + die ("--overlay-src must be followed by another --overlay-src or one of --overlay, --tmp-overlay, or --ro-overlay"); + + argv++; + argc--; + } + + *argcp = argc; + *argvp = argv; +} + +static void +parse_args (int *argcp, + const char ***argvp) +{ + int total_parsed_argc = *argcp; + + parse_args_recurse (argcp, argvp, false, &total_parsed_argc); + + if (next_overlay_src_count > 0) + die ("--overlay-src must be followed by another --overlay-src or one of --overlay, --tmp-overlay, or --ro-overlay"); +} + +static void +read_overflowids (void) +{ + cleanup_free char *uid_data = NULL; + cleanup_free char *gid_data = NULL; + + uid_data = load_file_at (AT_FDCWD, "/proc/sys/kernel/overflowuid"); + if (uid_data == NULL) + die_with_error ("Can't read /proc/sys/kernel/overflowuid"); + + overflow_uid = strtol (uid_data, NULL, 10); + if (overflow_uid == 0) + die ("Can't parse /proc/sys/kernel/overflowuid"); + + gid_data = load_file_at (AT_FDCWD, "/proc/sys/kernel/overflowgid"); + if (gid_data == NULL) + die_with_error ("Can't read /proc/sys/kernel/overflowgid"); + + overflow_gid = strtol (gid_data, NULL, 10); + if (overflow_gid == 0) + die ("Can't parse /proc/sys/kernel/overflowgid"); +} + +static void +namespace_ids_read (pid_t pid) +{ + cleanup_free char *dir = NULL; + cleanup_fd int ns_fd = -1; + NsInfo *info; + + dir = xasprintf ("%d/ns", pid); + ns_fd = TEMP_FAILURE_RETRY (openat (proc_fd, dir, O_PATH)); + + if (ns_fd < 0) + die_with_error ("open /proc/%s/ns failed", dir); + + for (info = ns_infos; info->name; info++) + { + bool *do_unshare = info->do_unshare; + struct stat st; + int r; + + /* if we don't unshare this ns, ignore it */ + if (do_unshare && *do_unshare == false) + continue; + + r = fstatat (ns_fd, info->name, &st, 0); + + /* if we can't get the information, ignore it */ + if (r != 0) + continue; + + info->id = st.st_ino; + } +} + +static void +namespace_ids_write (int fd, + bool in_json) +{ + NsInfo *info; + + for (info = ns_infos; info->name; info++) + { + cleanup_free char *output = NULL; + const char *indent; + uintmax_t nsid; + + nsid = (uintmax_t) info->id; + + /* if we don't have the information, we don't write it */ + if (nsid == 0) + continue; + + indent = in_json ? " " : "\n "; + output = xasprintf (",%s\"%s-namespace\": %ju", + indent, info->name, nsid); + + dump_info (fd, output, true); + } +} + +int +main (int argc, + char **argv) +{ + mode_t old_umask; + const char *base_path = NULL; + int clone_flags; + char *old_cwd = NULL; + pid_t pid; + int event_fd = -1; + int child_wait_fd = -1; + int setup_finished_pipe[] = {-1, -1}; + const char *new_cwd; + uid_t ns_uid; + gid_t ns_gid; + struct stat sbuf; + uint64_t val; + int res UNUSED; + cleanup_free char *args_data UNUSED = NULL; + int intermediate_pids_sockets[2] = {-1, -1}; + const char *exec_path = NULL; + int i; + struct sigaction sa = {}; + + /* Handle --version early on before we try to acquire/drop + * any capabilities so it works in a build environment; + * right now flatpak's build runs bubblewrap --version. + * https://github.com/projectatomic/bubblewrap/issues/185 + */ + if (argc == 2 && (strcmp (argv[1], "--version") == 0)) + print_version_and_exit (); + + /* Reset SIGCHILD to SIG_DFL allowing signalfd working propertly + * if the parent process had set SIGCHLD to SIG_IGN. */ + sigemptyset (&sa.sa_mask); + sa.sa_handler = SIG_DFL; + sigaction (SIGCHLD, &sa, NULL); + + real_uid = getuid (); + real_gid = getgid (); + + /* Get the (optional) privileges we need */ + acquire_privs (); + + /* Never gain any more privs during exec */ + if (prctl (PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0) + die_with_error ("prctl(PR_SET_NO_NEW_PRIVS) failed"); + + /* The initial code is run with high permissions + (i.e. CAP_SYS_ADMIN), so take lots of care. */ + + read_overflowids (); + + argv0 = argv[0]; + + if (isatty (1)) + host_tty_dev = ttyname (1); + + argv++; + argc--; + + if (argc <= 0) + usage (EXIT_FAILURE, stderr); + + parse_args (&argc, (const char ***) &argv); + + /* suck the args into a cleanup_free variable to control their lifecycle */ + args_data = opt_args_data; + opt_args_data = NULL; + + if ((requested_caps[0] || requested_caps[1]) && is_privileged) + die ("--cap-add in setuid mode can be used only by root"); + + if (opt_userns_block_fd != -1 && !opt_unshare_user) + die ("--userns-block-fd requires --unshare-user"); + + if (opt_userns_block_fd != -1 && opt_info_fd == -1) + die ("--userns-block-fd requires --info-fd"); + + if (opt_userns_fd != -1 && opt_unshare_user) + die ("--userns not compatible --unshare-user"); + + if (opt_userns_fd != -1 && opt_unshare_user_try) + die ("--userns not compatible --unshare-user-try"); + + if (opt_disable_userns && !opt_unshare_user) + die ("--disable-userns requires --unshare-user"); + + if (opt_disable_userns && opt_userns_block_fd != -1) + die ("--disable-userns is not compatible with --userns-block-fd"); + + /* Technically using setns() is probably safe even in the privileged + * case, because we got passed in a file descriptor to the + * namespace, and that can only be gotten if you have ptrace + * permissions against the target, and then you could do whatever to + * the namespace anyway. + * + * However, for practical reasons this isn't possible to use, + * because (as described in acquire_privs()) setuid bwrap causes + * root to own the namespaces that it creates, so you will not be + * able to access these namespaces anyway. So, best just not support + * it anyway. + */ + if (opt_userns_fd != -1 && is_privileged) + die ("--userns doesn't work in setuid mode"); + + if (opt_userns2_fd != -1 && is_privileged) + die ("--userns2 doesn't work in setuid mode"); + + /* We have to do this if we weren't installed setuid (and we're not + * root), so let's just DWIM */ + if (!is_privileged && getuid () != 0 && opt_userns_fd == -1) + opt_unshare_user = true; + +#ifdef ENABLE_REQUIRE_USERNS + /* In this build option, we require userns. */ + if (is_privileged && getuid () != 0 && opt_userns_fd == -1) + opt_unshare_user = true; +#endif + + if (opt_unshare_user_try && + stat ("/proc/self/ns/user", &sbuf) == 0) + { + bool disabled = false; + + /* RHEL7 has a kernel module parameter that lets you enable user namespaces */ + if (stat ("/sys/module/user_namespace/parameters/enable", &sbuf) == 0) + { + cleanup_free char *enable = NULL; + enable = load_file_at (AT_FDCWD, "/sys/module/user_namespace/parameters/enable"); + if (enable != NULL && enable[0] == 'N') + disabled = true; + } + + /* Check for max_user_namespaces */ + if (stat ("/proc/sys/user/max_user_namespaces", &sbuf) == 0) + { + cleanup_free char *max_user_ns = NULL; + max_user_ns = load_file_at (AT_FDCWD, "/proc/sys/user/max_user_namespaces"); + if (max_user_ns != NULL && strcmp(max_user_ns, "0\n") == 0) + disabled = true; + } + + /* Debian lets you disable *unprivileged* user namespaces. However this is not + a problem if we're privileged, and if we're not opt_unshare_user is true + already, and there is not much we can do, its just a non-working setup. */ + + if (!disabled) + opt_unshare_user = true; + } + + if (argc <= 0) + usage (EXIT_FAILURE, stderr); + + debug ("Creating root mount point"); + + if (opt_sandbox_uid == (uid_t)-1) + opt_sandbox_uid = real_uid; + if (opt_sandbox_gid == (gid_t)-1) + opt_sandbox_gid = real_gid; + + if (!opt_unshare_user && opt_userns_fd == -1 && opt_sandbox_uid != real_uid) + die ("Specifying --uid requires --unshare-user or --userns"); + + if (!opt_unshare_user && opt_userns_fd == -1 && opt_sandbox_gid != real_gid) + die ("Specifying --gid requires --unshare-user or --userns"); + + if (!opt_unshare_uts && opt_sandbox_hostname != NULL) + die ("Specifying --hostname requires --unshare-uts"); + + if (opt_as_pid_1 && !opt_unshare_pid) + die ("Specifying --as-pid-1 requires --unshare-pid"); + + if (opt_as_pid_1 && lock_files != NULL) + die ("Specifying --as-pid-1 and --lock-file is not permitted"); + + /* We need to read stuff from proc during the pivot_root dance, etc. + Lets keep a fd to it open */ + proc_fd = TEMP_FAILURE_RETRY (open ("/proc", O_PATH)); + if (proc_fd == -1) + die_with_error ("Can't open /proc"); + + /* We need *some* mountpoint where we can mount the root tmpfs. + * Because we use pivot_root, it won't appear to be mounted from + * the perspective of the sandboxed process, so we can use anywhere + * that is sure to exist, that is sure to not be a symlink controlled + * by someone malicious, and that we won't immediately need to + * access ourselves. */ + base_path = "/tmp"; + + debug ("creating new namespace"); + + if (opt_unshare_pid && !opt_as_pid_1) + { + event_fd = eventfd (0, EFD_CLOEXEC | EFD_NONBLOCK); + if (event_fd == -1) + die_with_error ("eventfd()"); + } + + /* We block sigchild here so that we can use signalfd in the monitor. */ + block_sigchild (); + + clone_flags = SIGCHLD | CLONE_NEWNS; + if (opt_unshare_user) + clone_flags |= CLONE_NEWUSER; + if (opt_unshare_pid && opt_pidns_fd == -1) + clone_flags |= CLONE_NEWPID; + if (opt_unshare_net) + clone_flags |= CLONE_NEWNET; + if (opt_unshare_ipc) + clone_flags |= CLONE_NEWIPC; + if (opt_unshare_uts) + clone_flags |= CLONE_NEWUTS; + if (opt_unshare_cgroup) + { + if (stat ("/proc/self/ns/cgroup", &sbuf)) + { + if (errno == ENOENT) + die ("Cannot create new cgroup namespace because the kernel does not support it"); + else + die_with_error ("stat on /proc/self/ns/cgroup failed"); + } + clone_flags |= CLONE_NEWCGROUP; + } + if (opt_unshare_cgroup_try) + { + opt_unshare_cgroup = !stat ("/proc/self/ns/cgroup", &sbuf); + if (opt_unshare_cgroup) + clone_flags |= CLONE_NEWCGROUP; + } + + child_wait_fd = eventfd (0, EFD_CLOEXEC); + if (child_wait_fd == -1) + die_with_error ("eventfd()"); + + /* Track whether pre-exec setup finished if we're reporting process exit */ + if (opt_json_status_fd != -1) + { + int ret; + ret = pipe2 (setup_finished_pipe, O_CLOEXEC); + if (ret == -1) + die_with_error ("pipe2()"); + } + + /* Switch to the custom user ns before the clone, gets us privs in that ns (assuming its a child of the current and thus allowed) */ + if (opt_userns_fd > 0 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) + { + if (errno == EINVAL) + die ("Joining the specified user namespace failed, it might not be a descendant of the current user namespace."); + die_with_error ("Joining specified user namespace failed"); + } + + /* Sometimes we have uninteresting intermediate pids during the setup, set up code to pass the real pid down */ + if (opt_pidns_fd != -1) + { + /* Mark us as a subreaper, this way we can get exit status from grandchildren */ + prctl (PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0); + create_pid_socketpair (intermediate_pids_sockets); + } + + pid = raw_clone (clone_flags, NULL); + if (pid == -1) + { + if (opt_unshare_user) + { + if (errno == EINVAL) + die ("Creating new namespace failed, likely because the kernel does not support user namespaces. bwrap must be installed setuid on such systems."); + else if (errno == EPERM && !is_privileged) + die ("No permissions to create a new namespace, likely because the kernel does not allow non-privileged user namespaces. On e.g. debian this can be enabled with 'sysctl kernel.unprivileged_userns_clone=1'."); + } + + if (errno == ENOSPC) + die ("Creating new namespace failed: nesting depth or /proc/sys/user/max_*_namespaces exceeded (ENOSPC)"); + + die_with_error ("Creating new namespace failed"); + } + + ns_uid = opt_sandbox_uid; + ns_gid = opt_sandbox_gid; + + if (pid != 0) + { + /* Parent, outside sandbox, privileged (initially) */ + + if (intermediate_pids_sockets[0] != -1) + { + close (intermediate_pids_sockets[1]); + pid = read_pid_from_socket (intermediate_pids_sockets[0]); + close (intermediate_pids_sockets[0]); + } + + /* Discover namespace ids before we drop privileges */ + namespace_ids_read (pid); + + if (is_privileged && opt_unshare_user && opt_userns_block_fd == -1) + { + /* We're running as euid 0, but the uid we want to map is + * not 0. This means we're not allowed to write this from + * the child user namespace, so we do it from the parent. + * + * Also, we map uid/gid 0 in the namespace (to overflowuid) + * if opt_needs_devpts is true, because otherwise the mount + * of devpts fails due to root not being mapped. + */ + write_uid_gid_map (ns_uid, real_uid, + ns_gid, real_gid, + pid, true, opt_needs_devpts); + } + + /* Initial launched process, wait for pid 1 or exec:ed command to exit */ + + if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + die_with_error ("Setting userns2 failed"); + + /* We don't need any privileges in the launcher, drop them immediately. */ + drop_privs (false, false); + + /* Optionally bind our lifecycle to that of the parent */ + handle_die_with_parent (); + + if (opt_info_fd != -1) + { + cleanup_free char *output = xasprintf ("{\n \"child-pid\": %i", pid); + dump_info (opt_info_fd, output, true); + namespace_ids_write (opt_info_fd, false); + dump_info (opt_info_fd, "\n}\n", true); + close (opt_info_fd); + } + if (opt_json_status_fd != -1) + { + cleanup_free char *output = xasprintf ("{ \"child-pid\": %i", pid); + dump_info (opt_json_status_fd, output, true); + namespace_ids_write (opt_json_status_fd, true); + dump_info (opt_json_status_fd, " }\n", true); + } + + if (opt_userns_block_fd != -1) + { + char b[1]; + (void) TEMP_FAILURE_RETRY (read (opt_userns_block_fd, b, 1)); + close (opt_userns_block_fd); + } + + /* Let child run now that the uid maps are set up */ + val = 1; + res = TEMP_FAILURE_RETRY (write (child_wait_fd, &val, 8)); + /* Ignore res, if e.g. the child died and closed child_wait_fd we don't want to error out here */ + close (child_wait_fd); + + return monitor_child (event_fd, pid, setup_finished_pipe[0]); + } + + if (opt_pidns_fd > 0) + { + if (setns (opt_pidns_fd, CLONE_NEWPID) != 0) + die_with_error ("Setting pidns failed"); + + /* fork to get the passed in pid ns */ + fork_intermediate_child (); + + /* We might both have specified an --pidns *and* --unshare-pid, so set up a new child pid namespace under the specified one */ + if (opt_unshare_pid) + { + if (unshare (CLONE_NEWPID)) + die_with_error ("unshare pid ns"); + + /* fork to get the new pid ns */ + fork_intermediate_child (); + } + + /* We're back, either in a child or grandchild, so message the actual pid to the monitor */ + + close (intermediate_pids_sockets[0]); + send_pid_on_socket (intermediate_pids_sockets[1]); + close (intermediate_pids_sockets[1]); + } + + /* Child, in sandbox, privileged in the parent or in the user namespace (if --unshare-user). + * + * Note that for user namespaces we run as euid 0 during clone(), so + * the child user namespace is owned by euid 0., This means that the + * regular user namespace parent (with uid != 0) doesn't have any + * capabilities in it, which is nice as we can't exploit those. In + * particular the parent user namespace doesn't have CAP_PTRACE + * which would otherwise allow the parent to hijack of the child + * after this point. + * + * Unfortunately this also means you can't ptrace the final + * sandboxed process from outside the sandbox either. + */ + + if (opt_info_fd != -1) + close (opt_info_fd); + + if (opt_json_status_fd != -1) + close (opt_json_status_fd); + + /* Wait for the parent to init uid/gid maps and drop caps */ + res = read (child_wait_fd, &val, 8); + close (child_wait_fd); + + /* At this point we can completely drop root uid, but retain the + * required permitted caps. This allow us to do full setup as + * the user uid, which makes e.g. fuse access work. + */ + switch_to_user_with_privs (); + + if (opt_unshare_net) + loopback_setup (); /* Will exit if unsuccessful */ + + ns_uid = opt_sandbox_uid; + ns_gid = opt_sandbox_gid; + if (!is_privileged && opt_unshare_user && opt_userns_block_fd == -1) + { + /* In the unprivileged case we have to write the uid/gid maps in + * the child, because we have no caps in the parent */ + + if (opt_needs_devpts) + { + /* This is a bit hacky, but we need to first map the real uid/gid to + 0, otherwise we can't mount the devpts filesystem because root is + not mapped. Later we will create another child user namespace and + map back to the real uid */ + ns_uid = 0; + ns_gid = 0; + } + + write_uid_gid_map (ns_uid, real_uid, + ns_gid, real_gid, + -1, true, false); + } + + old_umask = umask (0); + + /* Need to do this before the chroot, but after we're the real uid */ + resolve_symlinks_in_ops (); + + /* Mark everything as slave, so that we still + * receive mounts from the real root, but don't + * propagate mounts to the real root. */ + if (mount (NULL, "/", NULL, MS_SILENT | MS_SLAVE | MS_REC, NULL) < 0) + die_with_mount_error ("Failed to make / slave"); + + /* Create a tmpfs which we will use as / in the namespace */ + if (mount ("tmpfs", base_path, "tmpfs", MS_NODEV | MS_NOSUID, NULL) != 0) + die_with_mount_error ("Failed to mount tmpfs"); + + old_cwd = get_current_dir_name (); + + /* Chdir to the new root tmpfs mount. This will be the CWD during + the entire setup. Access old or new root via "oldroot" and "newroot". */ + if (chdir (base_path) != 0) + die_with_error ("chdir base_path"); + + /* We create a subdir "$base_path/newroot" for the new root, that + * way we can pivot_root to base_path, and put the old root at + * "$base_path/oldroot". This avoids problems accessing the oldroot + * dir if the user requested to bind mount something over / (or + * over /tmp, now that we use that for base_path). */ + + if (mkdir ("newroot", 0755)) + die_with_error ("Creating newroot failed"); + + if (mount ("newroot", "newroot", NULL, MS_SILENT | MS_MGC_VAL | MS_BIND | MS_REC, NULL) < 0) + die_with_mount_error ("setting up newroot bind"); + + if (mkdir ("oldroot", 0755)) + die_with_error ("Creating oldroot failed"); + + for (i = 0; i < opt_tmp_overlay_count; i++) + { + char *dirname; + dirname = xasprintf ("tmp-overlay-upper-%d", i); + if (mkdir (dirname, 0755)) + die_with_error ("Creating --tmp-overlay upperdir failed"); + free (dirname); + dirname = xasprintf ("tmp-overlay-work-%d", i); + if (mkdir (dirname, 0755)) + die_with_error ("Creating --tmp-overlay workdir failed"); + free (dirname); + } + + if (pivot_root (base_path, "oldroot")) + die_with_error ("pivot_root"); + + if (chdir ("/") != 0) + die_with_error ("chdir / (base path)"); + + if (is_privileged) + { + pid_t child; + int privsep_sockets[2]; + + if (socketpair (AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, privsep_sockets) != 0) + die_with_error ("Can't create privsep socket"); + + child = fork (); + if (child == -1) + die_with_error ("Can't fork unprivileged helper"); + + if (child == 0) + { + /* Unprivileged setup process */ + drop_privs (false, true); + close (privsep_sockets[0]); + setup_newroot (opt_unshare_pid, privsep_sockets[1]); + exit (0); + } + else + { + int status; + uint32_t buffer[2048]; /* 8k, but is int32 to guarantee nice alignment */ + uint32_t op, flags, perms; + size_t size_arg; + const char *arg1, *arg2; + cleanup_fd int unpriv_socket = -1; + + unpriv_socket = privsep_sockets[0]; + close (privsep_sockets[1]); + + do + { + op = read_priv_sec_op (unpriv_socket, buffer, sizeof (buffer), + &flags, &perms, &size_arg, &arg1, &arg2); + privileged_op (-1, op, flags, perms, size_arg, arg1, arg2); + if (TEMP_FAILURE_RETRY (write (unpriv_socket, buffer, 1)) != 1) + die ("Can't write to op_socket"); + } + while (op != PRIV_SEP_OP_DONE); + + TEMP_FAILURE_RETRY (waitpid (child, &status, 0)); + /* Continue post setup */ + } + } + else + { + setup_newroot (opt_unshare_pid, -1); + } + + close_ops_fd (); + + /* The old root better be rprivate or we will send unmount events to the parent namespace */ + if (mount ("oldroot", "oldroot", NULL, MS_SILENT | MS_REC | MS_PRIVATE, NULL) != 0) + die_with_mount_error ("Failed to make old root rprivate"); + + if (umount2 ("oldroot", MNT_DETACH)) + die_with_error ("unmount old root"); + + /* This is our second pivot. It's like we're a Silicon Valley startup flush + * with cash but short on ideas! + * + * We're aiming to make /newroot the real root, and get rid of /oldroot. To do + * that we need a temporary place to store it before we can unmount it. + */ + { cleanup_fd int oldrootfd = TEMP_FAILURE_RETRY (open ("/", O_DIRECTORY | O_RDONLY)); + if (oldrootfd < 0) + die_with_error ("can't open /"); + if (chdir ("/newroot") != 0) + die_with_error ("chdir /newroot"); + /* While the documentation claims that put_old must be underneath + * new_root, it is perfectly fine to use the same directory as the + * kernel checks only if old_root is accessible from new_root. + * + * Both runc and LXC are using this "alternative" method for + * setting up the root of the container: + * + * https://github.com/opencontainers/runc/blob/HEAD/libcontainer/rootfs_linux.go#L671 + * https://github.com/lxc/lxc/blob/HEAD/src/lxc/conf.c#L1121 + */ + if (pivot_root (".", ".") != 0) + die_with_error ("pivot_root(/newroot)"); + if (fchdir (oldrootfd) < 0) + die_with_error ("fchdir to oldroot"); + if (umount2 (".", MNT_DETACH) < 0) + die_with_error ("umount old root"); + if (chdir ("/") != 0) + die_with_error ("chdir /"); + } + + if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + die_with_error ("Setting userns2 failed"); + + if (opt_unshare_user && opt_userns_block_fd == -1 && + (ns_uid != opt_sandbox_uid || ns_gid != opt_sandbox_gid || + opt_disable_userns)) + { + /* Here we create a second level userns inside the first one. This is + used for one or more of these reasons: + + * The 1st level namespace has a different uid/gid than the + requested due to requirements of beeing root in the first + level due for mounting devpts (opt_needs_devpts). + + * To disable user namespaces we set max_user_namespaces and then + create the second namespace so that the sandbox cannot undo this + change. + */ + + if (opt_disable_userns) + { + cleanup_fd int sysctl_fd = -1; + + sysctl_fd = TEMP_FAILURE_RETRY (openat (proc_fd, "sys/user/max_user_namespaces", O_WRONLY)); + + if (sysctl_fd < 0) + die_with_error ("cannot open /proc/sys/user/max_user_namespaces"); + + if (write_to_fd (sysctl_fd, "1", 1) < 0) + die_with_error ("sysctl user.max_user_namespaces = 1"); + } + + if (unshare (CLONE_NEWUSER)) + die_with_error ("unshare user ns"); + + /* We're in a new user namespace, we got back the bounding set, clear it again */ + drop_cap_bounding_set (false); + + write_uid_gid_map (opt_sandbox_uid, ns_uid, + opt_sandbox_gid, ns_gid, + -1, false, false); + } + + if (opt_disable_userns || opt_assert_userns_disabled) + { + /* Verify that we can't make a new userns again */ + res = unshare (CLONE_NEWUSER); + + if (res == 0) + die ("creation of new user namespaces was not disabled as requested"); + } + + /* All privileged ops are done now, so drop caps we don't need */ + drop_privs (!is_privileged, true); + + if (opt_block_fd != -1) + { + char b[1]; + (void) TEMP_FAILURE_RETRY (read (opt_block_fd, b, 1)); + close (opt_block_fd); + } + + if (opt_seccomp_fd != -1) + { + assert (seccomp_programs == NULL); + /* takes ownership of fd */ + seccomp_program_new (&opt_seccomp_fd); + } + + umask (old_umask); + + new_cwd = "/"; + if (opt_chdir_path) + { + if (chdir (opt_chdir_path)) + die_with_error ("Can't chdir to %s", opt_chdir_path); + new_cwd = opt_chdir_path; + } + else if (chdir (old_cwd) == 0) + { + /* If the old cwd is mapped in the sandbox, go there */ + new_cwd = old_cwd; + } + else + { + /* If the old cwd is not mapped, go to home */ + const char *home = getenv ("HOME"); + if (home != NULL && + chdir (home) == 0) + new_cwd = home; + } + xsetenv ("PWD", new_cwd, 1); + free (old_cwd); + + if (opt_new_session && + setsid () == (pid_t) -1) + die_with_error ("setsid"); + + if (label_exec (opt_exec_label) == -1) + die_with_error ("label_exec %s", argv[0]); + + debug ("forking for child"); + + if (!opt_as_pid_1 && (opt_unshare_pid || lock_files != NULL || opt_sync_fd != -1)) + { + /* We have to have a pid 1 in the pid namespace, because + * otherwise we'll get a bunch of zombies as nothing reaps + * them. Alternatively if we're using sync_fd or lock_files we + * need some process to own these. + */ + + pid = fork (); + if (pid == -1) + die_with_error ("Can't fork for pid 1"); + + if (pid != 0) + { + drop_all_caps (false); + + /* Close fds in pid 1, except stdio and optionally event_fd + (for syncing pid 2 lifetime with monitor_child) and + opt_sync_fd (for syncing sandbox lifetime with outside + process). + Any other fds will been passed on to the child though. */ + { + int dont_close[3]; + int j = 0; + if (event_fd != -1) + dont_close[j++] = event_fd; + if (opt_sync_fd != -1) + dont_close[j++] = opt_sync_fd; + dont_close[j++] = -1; + fdwalk (proc_fd, close_extra_fds, dont_close); + } + + return do_init (event_fd, pid); + } + } + + debug ("launch executable %s", argv[0]); + + if (proc_fd != -1) + close (proc_fd); + + /* If we are using --as-pid-1 leak the sync fd into the sandbox. + --sync-fd will still work unless the container process doesn't close this file. */ + if (!opt_as_pid_1) + { + if (opt_sync_fd != -1) + close (opt_sync_fd); + } + + /* We want sigchild in the child */ + unblock_sigchild (); + + /* Optionally bind our lifecycle */ + handle_die_with_parent (); + + if (!is_privileged) + set_ambient_capabilities (); + + /* Should be the last thing before execve() so that filters don't + * need to handle anything above */ + seccomp_programs_apply (); + + if (setup_finished_pipe[1] != -1) + { + char data = 0; + res = write_to_fd (setup_finished_pipe[1], &data, 1); + /* Ignore res, if e.g. the parent died and closed setup_finished_pipe[0] + we don't want to error out here */ + } + + exec_path = argv[0]; + if (opt_argv0 != NULL) + argv[0] = (char *) opt_argv0; + + if (execvp (exec_path, argv) == -1) + { + if (setup_finished_pipe[1] != -1) + { + int saved_errno = errno; + char data = 0; + res = write_to_fd (setup_finished_pipe[1], &data, 1); + errno = saved_errno; + /* Ignore res, if e.g. the parent died and closed setup_finished_pipe[0] + we don't want to error out here */ + } + die_with_error ("execvp %s", exec_path); + } + + return 0; +} diff --git a/codex-rs/vendor/bubblewrap/bubblewrap.jpg b/codex-rs/vendor/bubblewrap/bubblewrap.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d3c243dface42da83cdec1be9bd93f5475120a9b GIT binary patch literal 40239 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 literal 0 HcmV?d00001 diff --git a/codex-rs/vendor/bubblewrap/bwrap.xml b/codex-rs/vendor/bubblewrap/bwrap.xml new file mode 100644 index 0000000000..f379f0fa46 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/bwrap.xml @@ -0,0 +1,648 @@ + + + + + + bwrap + Containers + + + Developer + Alexander + Larsson + + + Developer + Colin + Walters + + + + + + bwrap + 1 + User Commands + + + + bwrap + container setup utility + + + + +bwrap +OPTION +COMMAND + + + +Description + + bwrap is a unprivileged low-level sandboxing tool + (optionally setuid on older distributions). You + are unlikely to use it directly from the commandline, although that is possible. + + + It works by creating a new, completely empty, filesystem namespace where the root + is on a tmpfs that is invisible from the host, and which will be automatically + cleaned up when the last process exits. You can then use commandline options to + construct the root filesystem and process environment for the command to run in + the namespace. + + + By default, bwrap creates a new mount namespace for the sandbox. + Optionally it also sets up new user, ipc, pid, network and uts namespaces (but note the + user namespace is required if bwrap is not installed setuid root). + The application in the sandbox can be made to run with a different UID and GID. + + + If needed (e.g. when using a PID namespace) bwrap + is running a minimal pid 1 process in the sandbox that is + responsible for reaping zombies. It also detects when the initial + application process (pid 2) dies and reports its exit status back to + the original spawner. The pid 1 process exits to clean up the + sandbox when there are no other processes in the sandbox left. + + + +Options + + When options are used multiple times, the last option wins, unless otherwise + specified. + + General options: + + + + Print help and exit + + + + Print version + + + + + Parse nul-separated arguments from the given file descriptor. + This option can be used multiple times to parse options from + multiple sources. + + + + + Set argv[0] to the value VALUE before running the program + + + + + + Prefix each line of diagnostic output with a numeric severity + level enclosed in angle brackets. + The severity levels used are based on the constants used by + syslog3: + for example, <4> indicates a warning, + because LOG_WARNING has numeric value 4. + Numbers smaller than 4 indicate fatal errors, and numbers larger + than 4 indicate informational messages. + These prefixes can be parsed by tools compatible with + logger --prio-prefix (see + logger1) + or systemd-cat --level-prefix=1 (see + systemd-cat1). + + + + + Options related to kernel namespaces: + + + + Create a new user namespace + + + + Create a new user namespace if possible else skip it + + + + Create a new ipc namespace + + + + Create a new pid namespace + + + + Create a new network namespace + + + + Create a new uts namespace + + + + Create a new cgroup namespace + + + + Create a new cgroup namespace if possible else skip it + + + + Unshare all possible namespaces. Currently equivalent with: + + + + Retain the network namespace, overriding an earlier or + + + + Use an existing user namespace instead of creating a new one. The namespace must fulfil the permission requirements for setns(), which generally means that it must be a descendant of the currently active user namespace, owned by the same user. + This is incompatible with --unshare-user, and doesn't work in the setuid version of bubblewrap. + + + + After setting up the new namespace, switch into the specified namespace. For this to work the specified namespace must be a descendant of the user namespace used for the setup, so this is only useful in combination with --userns. + This is useful because sometimes bubblewrap itself creates nested user namespaces (to work around some kernel issues) and --userns2 can be used to enter these. + + + + + Prevent the process in the sandbox from creating further user namespaces, + so that it cannot rearrange the filesystem namespace or do other more + complex namespace modification. + This is currently implemented by setting the + user.max_user_namespaces sysctl to 1, and then + entering a nested user namespace which is unable to raise that limit + in the outer namespace. + This option requires , and doesn't work + in the setuid version of bubblewrap. + + + + + + Confirm that the process in the sandbox has been prevented from + creating further user namespaces, but without taking any particular + action to prevent that. For example, this can be combined with + to check that the given user namespace + has already been set up to prevent the creation of further user + namespaces. + + + + + Use an existing pid namespace instead of creating one. This is often used with --userns, because the pid namespace must be owned by the same user namespace that bwrap uses. + Note that this can be combined with --unshare-pid, and in that case it means that the sandbox will be in its own pid namespace, which is a child of the passed in one. + + + + Use a custom user id in the sandbox (requires ) + + + + Use a custom group id in the sandbox (requires ) + + + + Use a custom hostname in the sandbox (requires ) + + + Options about environment setup: + + + + Change directory to DIR + + + + Set an environment variable + + + + Unset an environment variable + + + + Unset all environment variables, except for + PWD and any that are subsequently set by + + + + Options for monitoring the sandbox from the outside: + + + + + Take a lock on DEST while the sandbox is running. + This option can be used multiple times to take locks on multiple files. + + + + + Keep this file descriptor open while the sandbox is running + + + + Filesystem related options. These are all operations that modify the filesystem directly, or + mounts stuff in the filesystem. These are applied in the order they are given as arguments. + + + Any missing parent directories that are required to create a specified destination are + automatically created as needed. Their permissions are normally set to 0755 + (rwxr-xr-x). However, if a option is in effect, and + it sets the permissions for group or other to zero, then newly-created + parent directories will also have their corresponding permission set to zero. + modifies the size of the created mount when preceding a + action; and + can be combined. + + + + + This option does nothing on its own, and must be followed + by one of the options that it affects. It sets the permissions + for the next operation to OCTAL. + Subsequent operations are not affected: for example, + --perms 0700 --tmpfs /a --tmpfs /b will mount + /a with permissions 0700, then return to + the default permissions for /b. + Note that and can be + combined: --perms 0700 --size 10485760 --tmpfs /s will apply + permissions as well as a maximum size to the created tmpfs. + + + + This option does nothing on its own, and must be followed + by --tmpfs. It sets the size in bytes for the next tmpfs. + For example, --size 10485760 --tmpfs /tmp will create a tmpfs + at /tmp of size 10MiB. Subsequent operations are not + affected: for example, + --size 10485760 --tmpfs /a --tmpfs /b will mount + /a with size 10MiB, then return to the default size for + /b. + Note that and can be + combined: --size 10485760 --perms 0700 --tmpfs /s will apply + permissions as well as a maximum size to the created tmpfs. + + + + Bind mount the host path SRC on DEST + + + + Equal to but ignores non-existent SRC + + + + Bind mount the host path SRC on DEST, allowing device access + + + + Equal to but ignores non-existent SRC + + + + Bind mount the host path SRC readonly on DEST + + + + Equal to but ignores non-existent SRC + + + + Remount the path DEST as readonly. It works only on the specified mount point, without changing any other mount point under the specified path + + + + + + This option does nothing on its own, and must be followed by one of + the other overlay options. It specifies a host + path from which files should be read if they aren't present in a + higher layer. + + + This option can be used multiple times to provide multiple sources. + The sources are overlaid in the order given, with the first source on + the command line at the bottom of the stack: if a given path to be + read exists in more than one source, the file is read from the last + such source specified. + + + (For readers familiar with overlayfs, note that this is the + reverse of the order used by the kernel's lowerdir + mount option.) + + + + + + + + + + + + + + Use overlayfs to mount the host paths specified by + RWSRC and all immediately preceding + on DEST. + DEST will contain the union of all the files + in all the layers. + + + With --overlay all writes will go to + RWSRC. Reads will come preferentially from + RWSRC, and then from any + paths. + WORKDIR must be an empty directory on the + same filesystem as RWSRC, and is used + internally by the kernel. + + + With --tmp-overlay all writes will go to + the tmpfs that hosts the sandbox root, in a location not accessible + from either the host or the child process. Writes will therefore not + be persisted across multiple runs. + + + With --ro-overlay the filesystem will be + mounted read-only. This option requires at least two + to precede it. + + + None of these options are available in the setuid version of + bubblewrap. Using --ro-overlay or providing + more than one requires a Linux kernel + version of 4.0 or later. + + + Due to limitations of overlayfs, no host directory given via + --overlay-src or + --overlay may be an ancestor of another, + after resolving symlinks. Depending on version, the Linux kernel may + or may not enforce this, but if not then overlayfs's behavior is + undefined. + + + For more information see the Overlay Filesystem documentation in the + Linux kernel at + https://www.kernel.org/doc/Documentation/filesystems/overlayfs.txt + + + + + + Mount procfs on DEST + + + + Mount new devtmpfs on DEST + + + + + Mount new tmpfs on DEST. + If the previous option was , it sets the + mode of the tmpfs. Otherwise, the tmpfs has mode 0755. + If the previous option was , it sets the + size in bytes of the tmpfs. Otherwise, the tmpfs has the default size. + + + + + Mount new mqueue on DEST + + + + + Create a directory at DEST. + If the directory already exists, its permissions are unmodified, + ignoring (use + if the permissions of an existing directory need to be changed). + If the directory is newly created and the previous option was + , it sets the mode of the directory. + Otherwise, newly-created directories have mode 0755. + + + + + + Copy from the file descriptor FD to + DEST. + If the previous option was , it sets the + mode of the new file. Otherwise, the file has mode 0666 + (note that this is not the same as ). + + + + + + Copy from the file descriptor FD to + a file which is bind-mounted on DEST. + If the previous option was , it sets the + mode of the new file. Otherwise, the file has mode 0600 + (note that this is not the same as ). + + + + + + Copy from the file descriptor FD to + a file which is bind-mounted read-only on + DEST. + If the previous option was , it sets the + mode of the new file. Otherwise, the file has mode 0600 + (note that this is not the same as ). + + + + + + Create a symlink at DEST with target + SRC. + Since version 0.9.0, it is not considered to be an error if + DEST already exists as a symbolic link and its + target is exactly SRC. + Before version 0.9.0, if DEST already + existed, this would be treated as an error (even if its target + was identical to SRC). + + + + + + + Set the permissions of PATH, which + must already exist, to OCTAL. + + + + + Lockdown options: + + + + + Load and use seccomp rules from FD. + The rules need to be in the form of a compiled cBPF program, + as generated by seccomp_export_bpf. + If this option is given more than once, only the last one is used. + Use if multiple seccomp programs + are needed. + + + + + + Load and use seccomp rules from FD. + The rules need to be in the form of a compiled cBPF program, + as generated by seccomp_export_bpf. + This option can be repeated, in which case all the seccomp + programs will be loaded in the order given (note that the kernel + will evaluate them in reverse order, so the last program on the + bwrap command-line is evaluated first). All of them, except + possibly the last, must allow use of the PR_SET_SECCOMP prctl. + This option cannot be combined with . + + + + + + Exec Label from the sandbox. On an SELinux system you can specify the SELinux + context for the sandbox process(s). + + + + + + File label for temporary sandbox content. On an SELinux system you can specify + the SELinux context for the sandbox content. + + + + + + Block the sandbox on reading from FD until some data is available. + + + + + + Do not initialize the user namespace but wait on FD until it is ready. This allow + external processes (like newuidmap/newgidmap) to setup the user namespace before it + is used by the sandbox process. + + + + + + Write information in JSON format about the sandbox to FD. + + + + + + Multiple JSON documents are written to FD, + one per line ("JSON lines" format). + Each line is a single JSON object. + After bwrap has started the child process inside the sandbox, + it writes an object with a child-pid member to the + (this duplicates the older ). + The corresponding value is the process ID of the child process in the pid namespace from + which bwrap was run. + If available, the namespace IDs are also included in the object with the child-pid; + again, this duplicates the older . + When the child process inside the sandbox exits, bwrap writes an object + with an exit-code member, and then closes the . The value + corresponding to exit-code is the exit status of the child, in the usual + shell encoding (n if it exited normally with status n, or 128+n if it was killed by signal n). + Other members may be added to those objects in future versions of bwrap, + and other JSON objects may be added before or after the current objects, so readers must + ignore members and objects that they do not understand. + + + + + + Create a new terminal session for the sandbox (calls setsid()). This + disconnects the sandbox from the controlling terminal which means + the sandbox can't for instance inject input into the terminal. + + Note: In a general sandbox, if you don't use --new-session, it is + recommended to use seccomp to disallow the TIOCSTI ioctl, otherwise + the application can feed keyboard input to the terminal + which can e.g. lead to out-of-sandbox command execution + (see CVE-2017-5226). + + + + + + Ensures child process (COMMAND) dies when bwrap's parent dies. Kills (SIGKILL) + all bwrap sandbox processes in sequence from parent to child + including COMMAND process when bwrap or bwrap's parent dies. + See prctl, PR_SET_PDEATHSIG. + + + + + + Do not create a process with PID=1 in the sandbox to reap child processes. + + + + + + Add the specified capability CAP, e.g. + CAP_DAC_READ_SEARCH, when running as privileged user. It accepts + the special value ALL to add all the permitted caps. + + + + + + Drop the specified capability when running as privileged user. It accepts + the special value ALL to drop all the caps. + + By default no caps are left in the sandboxed process. The + and + options are processed in the order they are specified on the + command line. Please be careful to the order they are specified. + + + + + + + Environment + + + + HOME + + Used as the cwd in the sandbox if has not been + explicitly specified and the current cwd is not present inside the sandbox. + The option can be used to override the value + that is used here. + + + + + + + Exit status + + + The bwrap command returns the exit status of the + initial application process (pid 2 in the sandbox). + + + + diff --git a/codex-rs/vendor/bubblewrap/ci/builddeps.sh b/codex-rs/vendor/bubblewrap/ci/builddeps.sh new file mode 100755 index 0000000000..5bca51e255 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/ci/builddeps.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Copyright 2021 Simon McVittie +# SPDX-License-Identifier: LGPL-2.0-or-later + +set -eux +set -o pipefail + +usage() { + if [ "${1-2}" -ne 0 ]; then + exec >&2 + fi + cat <&2 + usage 2 + ;; + esac +done + +# No more arguments please +for arg in "$@"; do + usage 2 +done + +if dpkg-vendor --derives-from Debian; then + apt-get -y update + apt-get -q -y install \ + build-essential \ + docbook-xml \ + docbook-xsl \ + libcap-dev \ + libselinux1-dev \ + libtool \ + meson \ + pkg-config \ + python3 \ + xsltproc \ + ${NULL+} + + if [ -n "${opt_clang}" ]; then + apt-get -y install clang + fi + + exit 0 +fi + +if command -v yum; then + yum -y install \ + 'pkgconfig(libselinux)' \ + /usr/bin/eu-readelf \ + docbook-style-xsl \ + gcc \ + git \ + libasan \ + libcap-devel \ + libtool \ + libtsan \ + libubsan \ + libxslt \ + make \ + meson \ + redhat-rpm-config \ + rsync \ + ${NULL+} + + if [ -n "${opt_clang}" ]; then + yum -y install clang + fi + + exit 0 +fi + +echo "Unknown distribution" >&2 +exit 1 + +# vim:set sw=4 sts=4 et: diff --git a/codex-rs/vendor/bubblewrap/ci/enable-userns.sh b/codex-rs/vendor/bubblewrap/ci/enable-userns.sh new file mode 100755 index 0000000000..9310e558b5 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/ci/enable-userns.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +echo "kernel.apparmor_restrict_unprivileged_userns = 0" > /etc/sysctl.d/99-userns.conf +sysctl --system diff --git a/codex-rs/vendor/bubblewrap/completions/bash/bwrap b/codex-rs/vendor/bubblewrap/completions/bash/bwrap new file mode 100644 index 0000000000..e7a523c285 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/completions/bash/bwrap @@ -0,0 +1,80 @@ +# shellcheck shell=bash + +# bash completion file for bubblewrap commands +# + +_bwrap() { + local cur prev words cword + _init_completion || return + + # Please keep sorted in LC_ALL=C order + local boolean_options=" + --as-pid-1 + --assert-userns-disabled + --clearenv + --disable-userns + --help + --new-session + --unshare-all + --unshare-cgroup + --unshare-cgroup-try + --unshare-ipc + --unshare-net + --unshare-pid + --unshare-user + --unshare-user-try + --unshare-uts + --version + " + + # Please keep sorted in LC_ALL=C order + local options_with_args=" + $boolean_optons + --add-seccomp-fd + --args + --argv0 + --bind + --bind-data + --block-fd + --cap-add + --cap-drop + --chdir + --chmod + --dev + --dev-bind + --die-with-parent + --dir + --exec-label + --file + --file-label + --gid + --hostname + --info-fd + --lock-file + --overlay + --overlay-src + --perms + --proc + --remount-ro + --ro-bind + --ro-overlay + --seccomp + --setenv + --size + --symlink + --sync-fd + --tmp-overlay + --uid + --unsetenv + --userns-block-fd + " + + if [[ "$cur" == -* ]]; then + COMPREPLY=( $( compgen -W "$boolean_options $options_with_args" -- "$cur" ) ) + fi + + return 0 +} +complete -F _bwrap bwrap + +# vim:set ft=bash: diff --git a/codex-rs/vendor/bubblewrap/completions/bash/meson.build b/codex-rs/vendor/bubblewrap/completions/bash/meson.build new file mode 100644 index 0000000000..323ad3973b --- /dev/null +++ b/codex-rs/vendor/bubblewrap/completions/bash/meson.build @@ -0,0 +1,35 @@ +bash_completion_dir = get_option('bash_completion_dir') + +if bash_completion_dir == '' + bash_completion = dependency( + 'bash-completion', + version : '>=2.0', + required : false, + ) + + if bash_completion.found() + if meson.version().version_compare('>=0.51.0') + bash_completion_dir = bash_completion.get_variable( + default_value: '', + pkgconfig: 'completionsdir', + pkgconfig_define: [ + 'datadir', get_option('prefix') / get_option('datadir'), + ], + ) + else + bash_completion_dir = bash_completion.get_pkgconfig_variable( + 'completionsdir', + default: '', + define_variable: [ + 'datadir', get_option('prefix') / get_option('datadir'), + ], + ) + endif + endif +endif + +if bash_completion_dir == '' + bash_completion_dir = get_option('datadir') / 'bash-completion' / 'completions' +endif + +install_data('bwrap', install_dir : bash_completion_dir) diff --git a/codex-rs/vendor/bubblewrap/completions/meson.build b/codex-rs/vendor/bubblewrap/completions/meson.build new file mode 100644 index 0000000000..958c90a2eb --- /dev/null +++ b/codex-rs/vendor/bubblewrap/completions/meson.build @@ -0,0 +1,7 @@ +if get_option('bash_completion').enabled() + subdir('bash') +endif + +if get_option('zsh_completion').enabled() + subdir('zsh') +endif diff --git a/codex-rs/vendor/bubblewrap/completions/zsh/_bwrap b/codex-rs/vendor/bubblewrap/completions/zsh/_bwrap new file mode 100644 index 0000000000..fbddda435e --- /dev/null +++ b/codex-rs/vendor/bubblewrap/completions/zsh/_bwrap @@ -0,0 +1,115 @@ +#compdef bwrap + +_bwrap_args_after_perms_size=( + # Please sort alphabetically (in LC_ALL=C order) by option name + '--tmpfs[Mount new tmpfs on DEST]:mount point for tmpfs:_files -/' +) + +_bwrap_args_after_perms=( + # Please sort alphabetically (in LC_ALL=C order) by option name + '--bind-data[Copy from FD to file which is bind-mounted on DEST]: :_guard "[0-9]#" "file descriptor to read content":destination:_files' + '--dir[Create dir at DEST]:directory to create:_files -/' + '--file[Copy from FD to destination DEST]: :_guard "[0-9]#" "file descriptor to read content from":destination:_files' + '--ro-bind-data[Copy from FD to file which is readonly bind-mounted on DEST]: :_guard "[0-9]#" "file descriptor to read content from":destination:_files' + '--size[Set size in bytes for next action argument]: :->after_perms_size' + '--tmpfs[Mount new tmpfs on DEST]:mount point for tmpfs:_files -/' +) + +_bwrap_args_after_size=( + # Please sort alphabetically (in LC_ALL=C order) by option name + '--perms[Set permissions for next action argument]: :_guard "[0-7]#" "permissions in octal": :->after_perms_size' + '--tmpfs[Mount new tmpfs on DEST]:mount point for tmpfs:_files -/' +) + +_bwrap_args=( + '*::arguments:_normal' + $_bwrap_args_after_perms + + # Please sort alphabetically (in LC_ALL=C order) by option name + '--add-seccomp-fd[Load and use seccomp rules from FD]: :_guard "[0-9]#" "file descriptor to read seccomp rules from"' + '--assert-userns-disabled[Fail unless further use of user namespace inside sandbox is disabled]' + '--args[Parse NUL-separated args from FD]: :_guard "[0-9]#" "file descriptor with NUL-separated arguments"' + '--argv0[Set argv0 to the value VALUE before running the program]:value:' + '--as-pid-1[Do not install a reaper process with PID=1]' + '--bind-try[Equal to --bind but ignores non-existent SRC]:source:_files:destination:_files' + '--bind[Bind mount the host path SRC on DEST]:source:_files:destination:_files' + '--block-fd[Block on FD until some data to read is available]: :_guard "[0-9]#" "file descriptor to block on"' + '--cap-add[Add cap CAP when running as privileged user]:capability to add:->caps' + '--cap-drop[Drop cap CAP when running as privileged user]:capability to add:->caps' + '--chdir[Change directory to DIR]:working directory for sandbox: _files -/' + '--chmod[Set permissions]: :_guard "[0-7]#" "permissions in octal":path to set permissions:_files' + '--clearenv[Unset all environment variables]' + '--dev-bind-try[Equal to --dev-bind but ignores non-existent SRC]:source:_files:destination:_files' + '--dev-bind[Bind mount the host path SRC on DEST, allowing device access]:source:_files:destination:_files' + '--dev[Mount new dev on DEST]:mount point for /dev:_files -/' + "--die-with-parent[Kills with SIGKILL child process (COMMAND) when bwrap or bwrap's parent dies.]" + '--disable-userns[Disable further use of user namespaces inside sandbox]' + '--exec-label[Exec label for the sandbox]:SELinux label:_selinux_contexts' + '--file-label[File label for temporary sandbox content]:SELinux label:_selinux_contexts' + '--gid[Custom gid in the sandbox (requires --unshare-user or --userns)]: :_guard "[0-9]#" "numeric group ID"' + '--help[Print help and exit]' + '--hostname[Custom hostname in the sandbox (requires --unshare-uts)]:hostname:' + '--info-fd[Write information about the running container to FD]: :_guard "[0-9]#" "file descriptor to write to"' + '--json-status-fd[Write container status to FD as multiple JSON documents]: :_guard "[0-9]#" "file descriptor to write to"' + '--lock-file[Take a lock on DEST while sandbox is running]:lock file:_files' + '--mqueue[Mount new mqueue on DEST]:mount point for mqueue:_files -/' + '--new-session[Create a new terminal session]' + '--perms[Set permissions for next action argument]: :_guard "[0-7]#" "permissions in octal": :->after_perms' + '--pidns[Use this user namespace (as parent namespace if using --unshare-pid)]: :' + '--proc[Mount new procfs on DEST]:mount point for procfs:_files -/' + '--remount-ro[Remount DEST as readonly; does not recursively remount]:mount point to remount read-only:_files' + '--ro-bind-try[Equal to --ro-bind but ignores non-existent SRC]:source:_files:destination:_files' + '--ro-bind[Bind mount the host path SRC readonly on DEST]:source:_files:destination:_files' + '--seccomp[Load and use seccomp rules from FD]: :_guard "[0-9]#" "file descriptor to read seccomp rules from"' + '--setenv[Set an environment variable]:variable to set:_parameters -g "*export*":value of variable: :' + '--size[Set size in bytes for next action argument]: :->after_size' + '--symlink[Create symlink at DEST with target SRC]:symlink target:_files:symlink to create:_files:' + '--sync-fd[Keep this fd open while sandbox is running]: :_guard "[0-9]#" "file descriptor to keep open"' + '--uid[Custom uid in the sandbox (requires --unshare-user or --userns)]: :_guard "[0-9]#" "numeric group ID"' + '(--clearenv)--unsetenv[Unset an environment variable]:variable to unset:_parameters -g "*export*"' + '--unshare-all[Unshare every namespace we support by default]' + '--unshare-cgroup-try[Create new cgroup namespace if possible else continue by skipping it]' + '--unshare-cgroup[Create new cgroup namespace]' + '--unshare-ipc[Create new ipc namespace]' + '--unshare-net[Create new network namespace]' + '--unshare-pid[Create new pid namespace]' + '(--userns --userns2)--unshare-user[Create new user namespace (may be automatically implied if not setuid)]' + '--unshare-user-try[Create new user namespace if possible else continue by skipping it]' + '--unshare-uts[Create new uts namespace]' + '(--unshare-user)--userns[Use this user namespace (cannot combine with --unshare-user)]: :' + '--userns-block-fd[Block on FD until the user namespace is ready]: :_guard "[0-9]#" "file descriptor to block on"' + '(--unshare-user)--userns2[After setup switch to this user namespace, only useful with --userns]: :' + '--version[Print version]' +) + +_bwrap() { + _arguments -S $_bwrap_args + case "$state" in + after_perms) + _values -S ' ' 'option' $_bwrap_args_after_perms + ;; + + after_size) + _values -S ' ' 'option' $_bwrap_args_after_size + ;; + + after_perms_size) + _values -S ' ' 'option' $_bwrap_args_after_perms_size + ;; + + caps) + # $ grep -E '#define\sCAP_\w+\s+[0-9]+' /usr/include/linux/capability.h | awk '{print $2}' | xargs echo + local all_caps=( + CAP_CHOWN CAP_DAC_OVERRIDE CAP_DAC_READ_SEARCH CAP_FOWNER CAP_FSETID \ + CAP_KILL CAP_SETGID CAP_SETUID CAP_SETPCAP CAP_LINUX_IMMUTABLE \ + CAP_NET_BIND_SERVICE CAP_NET_BROADCAST CAP_NET_ADMIN CAP_NET_RAW \ + CAP_IPC_LOCK CAP_IPC_OWNER CAP_SYS_MODULE CAP_SYS_RAWIO CAP_SYS_CHROOT \ + CAP_SYS_PTRACE CAP_SYS_PACCT CAP_SYS_ADMIN CAP_SYS_BOOT CAP_SYS_NICE \ + CAP_SYS_RESOURCE CAP_SYS_TIME CAP_SYS_TTY_CONFIG CAP_MKNOD CAP_LEASE \ + CAP_AUDIT_WRITE CAP_AUDIT_CONTROL CAP_SETFCAP CAP_MAC_OVERRIDE \ + CAP_MAC_ADMIN CAP_SYSLOG CAP_WAKE_ALARM CAP_BLOCK_SUSPEND CAP_AUDIT_READ + ) + _values 'caps' $all_caps + ;; + esac +} diff --git a/codex-rs/vendor/bubblewrap/completions/zsh/meson.build b/codex-rs/vendor/bubblewrap/completions/zsh/meson.build new file mode 100644 index 0000000000..7bda727c77 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/completions/zsh/meson.build @@ -0,0 +1,7 @@ +zsh_completion_dir = get_option('zsh_completion_dir') + +if zsh_completion_dir == '' + zsh_completion_dir = get_option('datadir') / 'zsh' / 'site-functions' +endif + +install_data('_bwrap', install_dir : zsh_completion_dir) diff --git a/codex-rs/vendor/bubblewrap/demos/bubblewrap-shell.sh b/codex-rs/vendor/bubblewrap/demos/bubblewrap-shell.sh new file mode 100755 index 0000000000..1cdb788f38 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/demos/bubblewrap-shell.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Use bubblewrap to run /bin/sh reusing the host OS binaries (/usr), but with +# separate /tmp, /home, /var, /run, and /etc. For /etc we just inherit the +# host's resolv.conf, and set up "stub" passwd/group files. Not sharing +# /home for example is intentional. If you wanted to, you could design +# a bwrap-using program that shared individual parts of /home, perhaps +# public content. +# +# Another way to build on this example is to remove --share-net to disable +# networking. +set -euo pipefail +(exec bwrap --ro-bind /usr /usr \ + --dir /tmp \ + --dir /var \ + --symlink ../tmp var/tmp \ + --proc /proc \ + --dev /dev \ + --ro-bind /etc/resolv.conf /etc/resolv.conf \ + --symlink usr/lib /lib \ + --symlink usr/lib64 /lib64 \ + --symlink usr/bin /bin \ + --symlink usr/sbin /sbin \ + --chdir / \ + --unshare-all \ + --share-net \ + --die-with-parent \ + --dir /run/user/$(id -u) \ + --setenv XDG_RUNTIME_DIR "/run/user/`id -u`" \ + --setenv PS1 "bwrap-demo$ " \ + --file 11 /etc/passwd \ + --file 12 /etc/group \ + /bin/sh) \ + 11< <(getent passwd $UID 65534) \ + 12< <(getent group $(id -g) 65534) diff --git a/codex-rs/vendor/bubblewrap/demos/flatpak-run.sh b/codex-rs/vendor/bubblewrap/demos/flatpak-run.sh new file mode 100755 index 0000000000..9149310440 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/demos/flatpak-run.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# For this to work you first have to run these commands: +# curl -O http://sdk.gnome.org/nightly/keys/nightly.gpg +# flatpak --user remote-add --gpg-key=nightly.gpg gnome-nightly http://sdk.gnome.org/nightly/repo/ +# flatpak --user install gnome-nightly org.gnome.Platform +# flatpak --user install gnome-nightly org.gnome.Weather + +mkdir -p ~/.var/app/org.gnome.Weather/cache ~/.var/app/org.gnome.Weather/config ~/.var/app/org.gnome.Weather/data + +( + exec bwrap \ + --ro-bind ~/.local/share/flatpak/runtime/org.gnome.Platform/x86_64/master/active/files /usr \ + --lock-file /usr/.ref \ + --ro-bind ~/.local/share/flatpak/app/org.gnome.Weather/x86_64/master/active/files/ /app \ + --lock-file /app/.ref \ + --dev /dev \ + --proc /proc \ + --dir /tmp \ + --symlink /tmp /var/tmp \ + --symlink /run /var/run \ + --symlink usr/lib /lib \ + --symlink usr/lib64 /lib64 \ + --symlink usr/bin /bin \ + --symlink usr/sbin /sbin \ + --symlink usr/etc /etc \ + --dir /run/user/`id -u` \ + --ro-bind /etc/machine-id /usr/etc/machine-id \ + --ro-bind /etc/resolv.conf /run/host/monitor/resolv.conf \ + --ro-bind /sys/block /sys/block \ + --ro-bind /sys/bus /sys/bus \ + --ro-bind /sys/class /sys/class \ + --ro-bind /sys/dev /sys/dev \ + --ro-bind /sys/devices /sys/devices \ + --dev-bind /dev/dri /dev/dri \ + --bind /tmp/.X11-unix/X0 /tmp/.X11-unix/X99 \ + --bind ~/.var/app/org.gnome.Weather ~/.var/app/org.gnome.Weather \ + --bind ~/.config/dconf ~/.config/dconf \ + --bind /run/user/`id -u`/dconf /run/user/`id -u`/dconf \ + --unshare-pid \ + --setenv XDG_RUNTIME_DIR "/run/user/`id -u`" \ + --setenv DISPLAY :99 \ + --setenv GI_TYPELIB_PATH /app/lib/girepository-1.0 \ + --setenv GST_PLUGIN_PATH /app/lib/gstreamer-1.0 \ + --setenv LD_LIBRARY_PATH /app/lib:/usr/lib/GL \ + --setenv DCONF_USER_CONFIG_DIR .config/dconf \ + --setenv PATH /app/bin:/usr/bin \ + --setenv XDG_CONFIG_DIRS /app/etc/xdg:/etc/xdg \ + --setenv XDG_DATA_DIRS /app/share:/usr/share \ + --setenv SHELL /bin/sh \ + --setenv XDG_CACHE_HOME ~/.var/app/org.gnome.Weather/cache \ + --setenv XDG_CONFIG_HOME ~/.var/app/org.gnome.Weather/config \ + --setenv XDG_DATA_HOME ~/.var/app/org.gnome.Weather/data \ + --file 10 /run/user/`id -u`/flatpak-info \ + --bind-data 11 /usr/etc/passwd \ + --bind-data 12 /usr/etc/group \ + --seccomp 13 \ + /bin/sh) \ + 11< <(getent passwd $UID 65534 ) \ + 12< <(getent group $(id -g) 65534) \ + 13< `dirname $0`/flatpak.bpf \ + 10<8`dn{6WJB7X$TP__CO`8xB38sjY4=>!&qKIP$;1*06I3NMm00Rfc z6|e!9A&UT)Q1U)KNYCVVzU%Yd8o&dz)g#*>^^G$x{MDB4aKXEQUav^v5 zCA<8VAGnn7xt2TJ$Zh_~El7FpCb8V0D%WYrHO~#ks^*>fjpqg9wdVz6 z#q)v@JTDl_o)?Uc=LKWQ^Mdip^MbMHIl*{IHtjB8vdz!Gy`C{gTjPk@fS*eR*!zDz zv(Nq4AZkMJ_86F_MOpRDY^mQq(;OH-4{0BF-+x;D@8&mnHrQ@7=+84nQS(hwkQ3zP zI5}A-D{BnPCp?s649O}P`8c_6t3@hPkCOHG_cP{ez^GD7`p-#YUsjU$Fv^k2BOJ>z zXENqWmKf1J78#WVDl*Sgnd60g#FEU?l|+XF=VOpbf%fo~jL!S}Kzni@vH4?y*!K#( LA8v|_#`$*(u?9EA literal 0 HcmV?d00001 diff --git a/codex-rs/vendor/bubblewrap/demos/userns-block-fd.py b/codex-rs/vendor/bubblewrap/demos/userns-block-fd.py new file mode 100755 index 0000000000..37a40f2f0e --- /dev/null +++ b/codex-rs/vendor/bubblewrap/demos/userns-block-fd.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +import os, select, subprocess, sys, json + +pipe_info = os.pipe() +userns_block = os.pipe() + +pid = os.fork() + +if pid != 0: + os.close(pipe_info[1]) + os.close(userns_block[0]) + + select.select([pipe_info[0]], [], []) + + data = json.load(os.fdopen(pipe_info[0])) + child_pid = str(data['child-pid']) + + subprocess.call(["newuidmap", child_pid, "0", str(os.getuid()), "1"]) + subprocess.call(["newgidmap", child_pid, "0", str(os.getgid()), "1"]) + + os.write(userns_block[1], b'1') +else: + os.close(pipe_info[0]) + os.close(userns_block[1]) + + os.set_inheritable(pipe_info[1], True) + os.set_inheritable(userns_block[0], True) + + args = ["bwrap", + "bwrap", + "--unshare-all", + "--unshare-user", + "--userns-block-fd", "%i" % userns_block[0], + "--info-fd", "%i" % pipe_info[1], + "--bind", "/", "/", + "cat", "/proc/self/uid_map"] + + os.execlp(*args) diff --git a/codex-rs/vendor/bubblewrap/meson.build b/codex-rs/vendor/bubblewrap/meson.build new file mode 100644 index 0000000000..78678d0973 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/meson.build @@ -0,0 +1,171 @@ +project( + 'bubblewrap', + 'c', + version : '0.11.0', + meson_version : '>=0.49.0', + default_options : [ + 'warning_level=2', + ], +) + +cc = meson.get_compiler('c') +add_project_arguments('-D_GNU_SOURCE', language : 'c') +common_include_directories = include_directories('.') + +# Keep this in sync with ostree, except remove -Wall (part of Meson +# warning_level 2) and -Werror=declaration-after-statement +add_project_arguments( + cc.get_supported_arguments([ + '-Werror=shadow', + '-Werror=empty-body', + '-Werror=strict-prototypes', + '-Werror=missing-prototypes', + '-Werror=implicit-function-declaration', + '-Werror=pointer-arith', + '-Werror=init-self', + '-Werror=missing-declarations', + '-Werror=return-type', + '-Werror=overflow', + '-Werror=int-conversion', + '-Werror=parenthesis', + '-Werror=incompatible-pointer-types', + '-Werror=misleading-indentation', + '-Werror=missing-include-dirs', + '-Werror=aggregate-return', + + # Extra warnings specific to bubblewrap + '-Werror=switch-default', + '-Wswitch-enum', + + # Deliberately not warning about these, ability to zero-initialize + # a struct is a feature + '-Wno-missing-field-initializers', + '-Wno-error=missing-field-initializers', + ]), + language : 'c', +) + +if ( + cc.has_argument('-Werror=format=2') + and cc.has_argument('-Werror=format-security') + and cc.has_argument('-Werror=format-nonliteral') +) + add_project_arguments([ + '-Werror=format=2', + '-Werror=format-security', + '-Werror=format-nonliteral', + ], language : 'c') +endif + +bash = find_program('bash', required : false) + +if get_option('python') == '' + python = find_program('python3') +else + python = find_program(get_option('python')) +endif + +libcap_dep = dependency('libcap', required : true) + +selinux_dep = dependency( + 'libselinux', + version : '>=2.1.9', + # if disabled, Meson will behave as though libselinux was not found + required : get_option('selinux'), +) + +cdata = configuration_data() +cdata.set_quoted( + 'PACKAGE_STRING', + '@0@ @1@'.format(meson.project_name(), meson.project_version()), +) + +if selinux_dep.found() + cdata.set('HAVE_SELINUX', 1) + if selinux_dep.version().version_compare('>=2.3') + cdata.set('HAVE_SELINUX_2_3', 1) + endif +endif + +if get_option('require_userns') + cdata.set('ENABLE_REQUIRE_USERNS', 1) +endif + +configure_file( + output : 'config.h', + configuration : cdata, +) + +if meson.is_subproject() and get_option('program_prefix') == '' + error('program_prefix option must be set when bwrap is a subproject') +endif + +if get_option('bwrapdir') != '' + bwrapdir = get_option('bwrapdir') +elif meson.is_subproject() + bwrapdir = get_option('libexecdir') +else + bwrapdir = get_option('bindir') +endif + +bwrap = executable( + get_option('program_prefix') + 'bwrap', + [ + 'bubblewrap.c', + 'bind-mount.c', + 'network.c', + 'utils.c', + ], + build_rpath : get_option('build_rpath'), + install : true, + install_dir : bwrapdir, + install_rpath : get_option('install_rpath'), + dependencies : [selinux_dep, libcap_dep], +) + +manpages_xsl = 'http://docbook.sourceforge.net/release/xsl/current/manpages/docbook.xsl' +xsltproc = find_program('xsltproc', required : get_option('man')) +build_man_page = false + +if xsltproc.found() and not meson.is_subproject() + if run_command([ + xsltproc, '--nonet', manpages_xsl, + ], check : false).returncode() == 0 + message('Docbook XSL found, man page enabled') + build_man_page = true + elif get_option('man').enabled() + error('Man page requested, but Docbook XSL stylesheets not found') + else + message('Docbook XSL not found, man page disabled automatically') + endif +endif + +if build_man_page + custom_target( + 'bwrap.1', + output : 'bwrap.1', + input : 'bwrap.xml', + command : [ + xsltproc, + '--nonet', + '--stringparam', 'man.output.quietly', '1', + '--stringparam', 'funcsynopsis.style', 'ansi', + '--stringparam', 'man.th.extra1.suppress', '1', + '--stringparam', 'man.authors.section.enabled', '0', + '--stringparam', 'man.copyright.section.enabled', '0', + '-o', '@OUTPUT@', + manpages_xsl, + '@INPUT@', + ], + install : true, + install_dir : get_option('mandir') / 'man1', + ) +endif + +if not meson.is_subproject() + subdir('completions') +endif + +if get_option('tests') + subdir('tests') +endif diff --git a/codex-rs/vendor/bubblewrap/meson_options.txt b/codex-rs/vendor/bubblewrap/meson_options.txt new file mode 100644 index 0000000000..5e25ee86f8 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/meson_options.txt @@ -0,0 +1,73 @@ +option( + 'bash_completion', + type : 'feature', + description : 'install bash completion script', + value : 'enabled', +) +option( + 'bash_completion_dir', + type : 'string', + description : 'install bash completion script in this directory', + value : '', +) +option( + 'bwrapdir', + type : 'string', + description : 'install bwrap in this directory [default: bindir, or libexecdir in subprojects]', +) +option( + 'build_rpath', + type : 'string', + description : 'set a RUNPATH or RPATH on the bwrap executable', +) +option( + 'install_rpath', + type : 'string', + description : 'set a RUNPATH or RPATH on the bwrap executable', +) +option( + 'man', + type : 'feature', + description : 'generate man pages', + value : 'auto', +) +option( + 'program_prefix', + type : 'string', + description : 'Prepend string to bwrap executable name, for use with subprojects', +) +option( + 'python', + type : 'string', + description : 'Path to Python 3, or empty to use python3', +) +option( + 'require_userns', + type : 'boolean', + description : 'require user namespaces by default when installed setuid', + value : false, +) +option( + 'selinux', + type : 'feature', + description : 'enable optional SELINUX support', + value : 'auto', +) +option( + 'tests', + type : 'boolean', + description : 'build tests', + value : true, +) +option( + 'zsh_completion', + type : 'feature', + description : 'install zsh completion script', + value : 'enabled', +) +option( + 'zsh_completion_dir', + type : 'string', + description : 'install zsh completion script in this directory', + value : '', +) diff --git a/codex-rs/vendor/bubblewrap/network.c b/codex-rs/vendor/bubblewrap/network.c new file mode 100644 index 0000000000..106e6d6e36 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/network.c @@ -0,0 +1,199 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#include "config.h" + +#include +#include +#include +#include +#include +#include + +#include "utils.h" +#include "network.h" + +static void * +add_rta (struct nlmsghdr *header, + int type, + size_t size) +{ + struct rtattr *rta; + size_t rta_size = RTA_LENGTH (size); + + rta = (struct rtattr *) ((char *) header + NLMSG_ALIGN (header->nlmsg_len)); + rta->rta_type = type; + rta->rta_len = rta_size; + + header->nlmsg_len = NLMSG_ALIGN (header->nlmsg_len) + rta_size; + + return RTA_DATA (rta); +} + +static int +rtnl_send_request (int rtnl_fd, + struct nlmsghdr *header) +{ + struct sockaddr_nl dst_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + ssize_t sent; + + sent = TEMP_FAILURE_RETRY (sendto (rtnl_fd, (void *) header, header->nlmsg_len, 0, + (struct sockaddr *) &dst_addr, sizeof (dst_addr))); + if (sent < 0) + return -1; + + return 0; +} + +static int +rtnl_read_reply (int rtnl_fd, + unsigned int seq_nr) +{ + char buffer[1024]; + ssize_t received; + struct nlmsghdr *rheader; + + while (1) + { + received = TEMP_FAILURE_RETRY (recv (rtnl_fd, buffer, sizeof (buffer), 0)); + if (received < 0) + return -1; + + rheader = (struct nlmsghdr *) buffer; + while (received >= NLMSG_HDRLEN) + { + if (rheader->nlmsg_seq != seq_nr) + return -1; + if ((pid_t)rheader->nlmsg_pid != getpid ()) + return -1; + if (rheader->nlmsg_type == NLMSG_ERROR) + { + uint32_t *err = NLMSG_DATA (rheader); + if (*err == 0) + return 0; + + return -1; + } + if (rheader->nlmsg_type == NLMSG_DONE) + return 0; + + rheader = NLMSG_NEXT (rheader, received); + } + } +} + +static int +rtnl_do_request (int rtnl_fd, + struct nlmsghdr *header) +{ + if (rtnl_send_request (rtnl_fd, header) != 0) + return -1; + + if (rtnl_read_reply (rtnl_fd, header->nlmsg_seq) != 0) + return -1; + + return 0; +} + +static struct nlmsghdr * +rtnl_setup_request (char *buffer, + int type, + int flags, + size_t size) +{ + struct nlmsghdr *header; + size_t len = NLMSG_LENGTH (size); + static uint32_t counter = 0; + + memset (buffer, 0, len); + + header = (struct nlmsghdr *) buffer; + header->nlmsg_len = len; + header->nlmsg_type = type; + header->nlmsg_flags = flags | NLM_F_REQUEST; + header->nlmsg_seq = counter++; + header->nlmsg_pid = getpid (); + + return header; +} + +void +loopback_setup (void) +{ + int r, if_loopback; + cleanup_fd int rtnl_fd = -1; + char buffer[1024]; + struct sockaddr_nl src_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct nlmsghdr *header; + struct ifaddrmsg *addmsg; + struct ifinfomsg *infomsg; + struct in_addr *ip_addr; + + src_addr.nl_pid = getpid (); + + if_loopback = (int) if_nametoindex ("lo"); + if (if_loopback <= 0) + die_with_error ("loopback: Failed to look up lo"); + + rtnl_fd = socket (PF_NETLINK, SOCK_RAW | SOCK_CLOEXEC, NETLINK_ROUTE); + if (rtnl_fd < 0) + die_with_error ("loopback: Failed to create NETLINK_ROUTE socket"); + + r = bind (rtnl_fd, (struct sockaddr *) &src_addr, sizeof (src_addr)); + if (r < 0) + die_with_error ("loopback: Failed to bind NETLINK_ROUTE socket"); + + header = rtnl_setup_request (buffer, RTM_NEWADDR, + NLM_F_CREATE | NLM_F_EXCL | NLM_F_ACK, + sizeof (struct ifaddrmsg)); + addmsg = NLMSG_DATA (header); + + addmsg->ifa_family = AF_INET; + addmsg->ifa_prefixlen = 8; + addmsg->ifa_flags = IFA_F_PERMANENT; + addmsg->ifa_scope = RT_SCOPE_HOST; + addmsg->ifa_index = if_loopback; + + ip_addr = add_rta (header, IFA_LOCAL, sizeof (*ip_addr)); + ip_addr->s_addr = htonl (INADDR_LOOPBACK); + + ip_addr = add_rta (header, IFA_ADDRESS, sizeof (*ip_addr)); + ip_addr->s_addr = htonl (INADDR_LOOPBACK); + + assert (header->nlmsg_len < sizeof (buffer)); + + if (rtnl_do_request (rtnl_fd, header) != 0) + die_with_error ("loopback: Failed RTM_NEWADDR"); + + header = rtnl_setup_request (buffer, RTM_NEWLINK, + NLM_F_ACK, + sizeof (struct ifinfomsg)); + infomsg = NLMSG_DATA (header); + + infomsg->ifi_family = AF_UNSPEC; + infomsg->ifi_type = 0; + infomsg->ifi_index = if_loopback; + infomsg->ifi_flags = IFF_UP; + infomsg->ifi_change = IFF_UP; + + assert (header->nlmsg_len < sizeof (buffer)); + + if (rtnl_do_request (rtnl_fd, header) != 0) + die_with_error ("loopback: Failed RTM_NEWLINK"); +} diff --git a/codex-rs/vendor/bubblewrap/network.h b/codex-rs/vendor/bubblewrap/network.h new file mode 100644 index 0000000000..3346b49660 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/network.h @@ -0,0 +1,22 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#pragma once + +void loopback_setup (void); diff --git a/codex-rs/vendor/bubblewrap/packaging/bubblewrap.spec b/codex-rs/vendor/bubblewrap/packaging/bubblewrap.spec new file mode 100644 index 0000000000..0a8001fe47 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/packaging/bubblewrap.spec @@ -0,0 +1,48 @@ +%global commit0 66d12bb23b04e201c5846e325f0b10930ed802f8 +%global shortcommit0 %(c=%{commit0}; echo ${c:0:7}) + +Summary: Core execution tool for unprivileged containers +Name: bubblewrap +Version: 0 +Release: 1%{?dist} +#VCS: git:https://github.com/projectatomic/bubblewrap +Source0: https://github.com/projectatomic/%{name}/archive/%{commit0}.tar.gz#/%{name}-%{shortcommit0}.tar.gz +License: LGPLv2+ +URL: https://github.com/projectatomic/bubblewrap + +BuildRequires: git +# We always run autogen.sh +BuildRequires: autoconf automake libtool +BuildRequires: libcap-devel +BuildRequires: pkgconfig(libselinux) +BuildRequires: libxslt +BuildRequires: docbook-style-xsl + +%description +Bubblewrap (/usr/bin/bwrap) is a core execution engine for unprivileged +containers that works as a setuid binary on kernels without +user namespaces. + +%prep +%autosetup -Sgit -n %{name}-%{version} + +%build +env NOCONFIGURE=1 ./autogen.sh +%configure --disable-silent-rules --with-priv-mode=none + +make %{?_smp_mflags} + +%install +make install DESTDIR=$RPM_BUILD_ROOT INSTALL="install -p -c" +find $RPM_BUILD_ROOT -name '*.la' -delete + +%files +%license COPYING +%doc README.md +%{_datadir}/bash-completion/completions/bwrap +%if (0%{?rhel} != 0 && 0%{?rhel} <= 7) +%attr(4755,root,root) %{_bindir}/bwrap +%else +%{_bindir}/bwrap +%endif +%{_mandir}/man1/* diff --git a/codex-rs/vendor/bubblewrap/release-checklist.md b/codex-rs/vendor/bubblewrap/release-checklist.md new file mode 100644 index 0000000000..5b2119c8af --- /dev/null +++ b/codex-rs/vendor/bubblewrap/release-checklist.md @@ -0,0 +1,18 @@ +bubblewrap release checklist +============================ + +* Collect release notes in `NEWS` +* Update version number in `meson.build` and release date in `NEWS` +* Commit the changes +* `meson dist -C ${builddir}` +* Do any final smoke-testing, e.g. update a package, install and test it +* `git evtag sign v$VERSION` + * Include the release notes from `NEWS` in the tag message +* `git push --atomic origin main v$VERSION` +* https://github.com/containers/bubblewrap/releases/new + * Fill in the new version's tag in the "Tag version" box + * Title: `$VERSION` + * Copy the release notes into the description + * Upload the tarball that you built with `meson dist` + * Get the `sha256sum` of the tarball and append it to the description + * `Publish release` diff --git a/codex-rs/vendor/bubblewrap/tests/libtest-core.sh b/codex-rs/vendor/bubblewrap/tests/libtest-core.sh new file mode 100644 index 0000000000..11df3a3749 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/libtest-core.sh @@ -0,0 +1,190 @@ +# Core source library for shell script tests; the +# canonical version lives in: +# +# https://github.com/ostreedev/ostree +# +# Known copies are in the following repos: +# +# - https://github.com/containers/bubblewrap +# - https://github.com/coreos/rpm-ostree +# +# Copyright (C) 2017 Colin Walters +# +# SPDX-License-Identifier: LGPL-2.0-or-later +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. + +fatal() { + echo $@ 1>&2; exit 1 +} +# fatal() is shorter to type, but retain this alias +assert_not_reached () { + fatal "$@" +} + +# Some tests look for specific English strings. Use a UTF-8 version +# of the C (POSIX) locale if we have one, or fall back to en_US.UTF-8 +# (https://sourceware.org/glibc/wiki/Proposals/C.UTF-8) +# +# If we can't find the locale command assume we have support for C.UTF-8 +# (e.g. musl based systems) +if type -p locale >/dev/null; then + export LC_ALL=$(locale -a | grep -iEe '^(C|en_US)\.(UTF-8|utf8)$' | head -n1 || true) + if [ -z "${LC_ALL}" ]; then fatal "Can't find suitable UTF-8 locale"; fi +else + export LC_ALL=C.UTF-8 +fi +# A GNU extension, used whenever LC_ALL is not C +unset LANGUAGE + +# This should really be the default IMO +export G_DEBUG=fatal-warnings + +assert_streq () { + test "$1" = "$2" || fatal "$1 != $2" +} + +assert_str_match () { + if ! echo "$1" | grep -E -q "$2"; then + fatal "$1 does not match regexp $2" + fi +} + +assert_not_streq () { + (! test "$1" = "$2") || fatal "$1 == $2" +} + +assert_has_file () { + test -f "$1" || fatal "Couldn't find '$1'" +} + +assert_has_dir () { + test -d "$1" || fatal "Couldn't find '$1'" +} + +# Dump ls -al + file contents to stderr, then fatal() +_fatal_print_file() { + file="$1" + shift + ls -al "$file" >&2 + sed -e 's/^/# /' < "$file" >&2 + fatal "$@" +} + +_fatal_print_files() { + file1="$1" + shift + file2="$1" + shift + ls -al "$file1" >&2 + sed -e 's/^/# /' < "$file1" >&2 + ls -al "$file2" >&2 + sed -e 's/^/# /' < "$file2" >&2 + fatal "$@" +} + +assert_not_has_file () { + if test -f "$1"; then + _fatal_print_file "$1" "File '$1' exists" + fi +} + +assert_not_file_has_content () { + fpath=$1 + shift + for re in "$@"; do + if grep -q -e "$re" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' matches regexp '$re'" + fi + done +} + +assert_not_has_dir () { + if test -d "$1"; then + fatal "Directory '$1' exists" + fi +} + +assert_file_has_content () { + fpath=$1 + shift + for re in "$@"; do + if ! grep -q -e "$re" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' doesn't match regexp '$re'" + fi + done +} + +assert_file_has_content_once () { + fpath=$1 + shift + for re in "$@"; do + if ! test $(grep -e "$re" "$fpath" | wc -l) = "1"; then + _fatal_print_file "$fpath" "File '$fpath' doesn't match regexp '$re' exactly once" + fi + done +} + +assert_file_has_content_literal () { + fpath=$1; shift + for s in "$@"; do + if ! grep -q -F -e "$s" "$fpath"; then + _fatal_print_file "$fpath" "File '$fpath' doesn't match fixed string list '$s'" + fi + done +} + +assert_file_has_mode () { + mode=$(stat -c '%a' $1) + if [ "$mode" != "$2" ]; then + fatal "File '$1' has wrong mode: expected $2, but got $mode" + fi +} + +assert_symlink_has_content () { + if ! test -L "$1"; then + fatal "File '$1' is not a symbolic link" + fi + if ! readlink "$1" | grep -q -e "$2"; then + _fatal_print_file "$1" "Symbolic link '$1' doesn't match regexp '$2'" + fi +} + +assert_file_empty() { + if test -s "$1"; then + _fatal_print_file "$1" "File '$1' is not empty" + fi +} + +assert_files_equal() { + if ! cmp "$1" "$2"; then + _fatal_print_files "$1" "$2" "File '$1' and '$2' is not equal" + fi +} + +# Use to skip all of these tests +skip() { + echo "1..0 # SKIP" "$@" + exit 0 +} + +report_err () { + local exit_status="$?" + { { local BASH_XTRACEFD=3; } 2> /dev/null + echo "Unexpected nonzero exit status $exit_status while running: $BASH_COMMAND" >&2 + } 3> /dev/null +} +trap report_err ERR diff --git a/codex-rs/vendor/bubblewrap/tests/libtest.sh b/codex-rs/vendor/bubblewrap/tests/libtest.sh new file mode 100644 index 0000000000..d55bb02bc2 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/libtest.sh @@ -0,0 +1,115 @@ +# shellcheck shell=bash + +# Source library for shell script tests. +# Add non-bubblewrap-specific code to libtest-core.sh instead. +# +# Copyright (C) 2017 Colin Walters +# SPDX-License-Identifier: LGPL-2.0-or-later +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., 59 Temple Place - Suite 330, +# Boston, MA 02111-1307, USA. + +set -e + +if [ -n "${G_TEST_SRCDIR:-}" ]; then + test_srcdir="${G_TEST_SRCDIR}/tests" +else + test_srcdir=$(dirname "$0") +fi + +if [ -n "${G_TEST_BUILDDIR:-}" ]; then + test_builddir="${G_TEST_BUILDDIR}/tests" +else + test_builddir=$(dirname "$0") +fi + +. "${test_srcdir}/libtest-core.sh" + +# Make sure /sbin/getpcaps etc. are in our PATH even if non-root +PATH="$PATH:/usr/sbin:/sbin" + +tempdir=$(mktemp -d /var/tmp/tap-test.XXXXXX) +touch "${tempdir}/.testtmp" +cleanup() { + if test -n "${TEST_SKIP_CLEANUP:-}"; then + echo "Skipping cleanup of ${tempdir}" + elif test -f "${tempdir}/.testtmp"; then + rm -rf "${tempdir}" + fi +} +trap cleanup EXIT +cd "${tempdir}" + +: "${BWRAP:=bwrap}" +if test -u "$(type -p ${BWRAP})"; then + bwrap_is_suid=true +fi + +FUSE_DIR= +for mp in $(grep " fuse[. ]" /proc/self/mounts | grep "user_id=$(id -u)" | awk '{print $2}'); do + if test -d "$mp"; then + echo "# Using $mp as test fuse mount" + FUSE_DIR="$mp" + break + fi +done + +if test "$(id -u)" = "0"; then + is_uidzero=true +else + is_uidzero=false +fi + +# This is supposed to be an otherwise readable file in an unreadable (by the user) dir +UNREADABLE=/root/.bashrc +if "${is_uidzero}" || test -x "$(dirname "$UNREADABLE")"; then + UNREADABLE= +fi + +# https://github.com/projectatomic/bubblewrap/issues/217 +# are we on a merged-/usr system? +if [ /lib -ef /usr/lib ]; then + BWRAP_RO_HOST_ARGS="--ro-bind /usr /usr + --ro-bind /etc /etc + --dir /var/tmp + --symlink usr/lib /lib + --symlink usr/lib64 /lib64 + --symlink usr/bin /bin + --symlink usr/sbin /sbin + --proc /proc + --dev /dev" +else + BWRAP_RO_HOST_ARGS="--ro-bind /usr /usr + --ro-bind /etc /etc + --ro-bind /bin /bin + --ro-bind-try /lib /lib + --ro-bind-try /lib64 /lib64 + --ro-bind-try /sbin /sbin + --ro-bind-try /nix/store /nix/store + --dir /var/tmp + --proc /proc + --dev /dev" +fi + +# Default arg, bind whole host fs to /, tmpfs on /tmp +RUN="${BWRAP} --bind / / --tmpfs /tmp" + +if [ -z "${BWRAP_MUST_WORK-}" ] && ! $RUN true; then + skip Seems like bwrap is not working at all. Maybe setuid is not working +fi + +extract_child_pid() { + grep child-pid "$1" | sed "s/^.*: \([0-9]*\).*/\1/" +} diff --git a/codex-rs/vendor/bubblewrap/tests/meson.build b/codex-rs/vendor/bubblewrap/tests/meson.build new file mode 100644 index 0000000000..87bf709e99 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/meson.build @@ -0,0 +1,72 @@ +test_programs = [ + ['test-utils', executable( + 'test-utils', + 'test-utils.c', + '../utils.c', + '../utils.h', + dependencies : [selinux_dep], + include_directories : common_include_directories, + )], +] + +executable( + 'try-syscall', + 'try-syscall.c', + override_options: ['b_sanitize=none'], +) + +test_scripts = [ + 'test-run.sh', + 'test-seccomp.py', + 'test-specifying-pidns.sh', + 'test-specifying-userns.sh', +] + +test_env = environment() +test_env.set('BWRAP', bwrap.full_path()) +test_env.set('G_TEST_BUILDDIR', meson.current_build_dir() / '..') +test_env.set('G_TEST_SRCDIR', meson.current_source_dir() / '..') + +foreach pair : test_programs + name = pair[0] + test_program = pair[1] + if meson.version().version_compare('>=0.50.0') + test( + name, + test_program, + env : test_env, + protocol : 'tap', + ) + else + test( + name, + test_program, + env : test_env, + ) + endif +endforeach + +foreach test_script : test_scripts + if test_script.endswith('.py') + interpreter = python + else + interpreter = bash + endif + + if meson.version().version_compare('>=0.50.0') + test( + test_script, + interpreter, + args : [files(test_script)], + env : test_env, + protocol : 'tap', + ) + else + test( + test_script, + interpreter, + args : [files(test_script)], + env : test_env, + ) + endif +endforeach diff --git a/codex-rs/vendor/bubblewrap/tests/test-run.sh b/codex-rs/vendor/bubblewrap/tests/test-run.sh new file mode 100755 index 0000000000..1003e8c4b1 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/test-run.sh @@ -0,0 +1,692 @@ +#!/usr/bin/env bash + +set -xeuo pipefail + +srcd=$(cd $(dirname "$0") && pwd) + +. ${srcd}/libtest.sh + +bn=$(basename "$0") + +test_count=0 +ok () { + test_count=$((test_count + 1)) + echo ok $test_count "$@" +} +ok_skip () { + ok "# SKIP" "$@" +} +done_testing () { + echo "1..$test_count" +} + +# Test help +${BWRAP} --help > help.txt +assert_file_has_content help.txt "usage: ${BWRAP}" +ok "Help works" + +for ALT in "" "--unshare-user-try" "--unshare-pid" "--unshare-user-try --unshare-pid"; do + # Test fuse fs as bind source + if [ "x$FUSE_DIR" != "x" ]; then + $RUN $ALT --proc /proc --dev /dev --bind $FUSE_DIR /tmp/foo true + ok "can bind-mount a FUSE directory with $ALT" + else + ok_skip "no FUSE support" + fi + # no --dev => no devpts => no map_root workaround + $RUN $ALT --proc /proc true + ok "can mount /proc with $ALT" + # No network + $RUN $ALT --unshare-net --proc /proc --dev /dev true + ok "can unshare network, create new /dev with $ALT" + # Unreadable file + echo -n "expect EPERM: " >&2 + + # Test caps when bwrap is not setuid + if test -n "${bwrap_is_suid:-}"; then + CAP="--cap-add ALL" + else + CAP="" + fi + + if ! cat /etc/shadow >/dev/null && + $RUN $CAP $ALT --unshare-net --proc /proc --bind /etc/shadow /tmp/foo cat /tmp/foo; then + assert_not_reached Could read /etc/shadow via /tmp/foo bind-mount + fi + + if ! cat /etc/shadow >/dev/null && + $RUN $CAP $ALT --unshare-net --proc /proc --bind /etc/shadow /tmp/foo cat /etc/shadow; then + assert_not_reached Could read /etc/shadow + fi + + ok "cannot read /etc/shadow with $ALT" + # Unreadable dir + if [ "x$UNREADABLE" != "x" ]; then + echo -n "expect EPERM: " >&2 + if $RUN $ALT --unshare-net --proc /proc --dev /dev --bind $UNREADABLE /tmp/foo cat /tmp/foo; then + assert_not_reached Could read $UNREADABLE + fi + ok "cannot read $UNREADABLE with $ALT" + else + ok_skip "not sure what unreadable file to use" + fi + + # bind dest in symlink (https://github.com/projectatomic/bubblewrap/pull/119) + $RUN $ALT --dir /tmp/dir --symlink dir /tmp/link --bind /etc /tmp/link true + ok "can bind a destination over a symlink" +done + +# Test symlink behaviour +rm -f ./symlink +$RUN --ro-bind / / --bind "$(pwd)" "$(pwd)" --symlink /dev/null "$(pwd)/symlink" true >&2 +readlink ./symlink > target.txt +assert_file_has_content target.txt /dev/null +ok "--symlink works" +$RUN --ro-bind / / --bind "$(pwd)" "$(pwd)" --symlink /dev/null "$(pwd)/symlink" true >&2 +ok "--symlink is idempotent" +if $RUN --ro-bind / / --bind "$(pwd)" "$(pwd)" --symlink /dev/full "$(pwd)/symlink" true 2>err.txt; then + fatal "creating a conflicting symlink should have failed" +else + assert_file_has_content err.txt "Can't make symlink .*: existing destination is /dev/null" +fi +ok "--symlink doesn't overwrite a conflicting symlink" + +# Test devices +$RUN --unshare-pid --dev /dev ls -al /dev/{stdin,stdout,stderr,null,random,urandom,fd,core} >/dev/null +ok "all expected devices were created" + +# Test --as-pid-1 +$RUN --unshare-pid --as-pid-1 --bind / / bash -c 'echo $$' > as_pid_1.txt +assert_file_has_content as_pid_1.txt "1" +ok "can run as pid 1" + +# Test --info-fd and --json-status-fd +if $RUN --unshare-all --info-fd 42 --json-status-fd 43 -- bash -c 'exit 42' 42>info.json 43>json-status.json 2>err.txt; then + fatal "should have been exit 42" +fi +assert_file_has_content info.json '"child-pid": [0-9]' +assert_file_has_content json-status.json '"child-pid": [0-9]' +assert_file_has_content_literal json-status.json '"exit-code": 42' +ok "info and json-status fd" + +DATA=$($RUN --proc /proc --unshare-all --info-fd 42 --json-status-fd 43 -- bash -c 'stat -L -c "%n %i" /proc/self/ns/*' 42>info.json 43>json-status.json 2>err.txt) + +for NS in "ipc" "mnt" "net" "pid" "uts"; do + + want=$(echo "$DATA" | grep "/proc/self/ns/$NS" | awk '{print $2}') + assert_file_has_content info.json "$want" + assert_file_has_content json-status.json "$want" +done + +ok "namespace id info in info and json-status fd" + +if ! command -v strace >/dev/null || ! strace -h | grep -v -e default | grep -e fault >/dev/null; then + ok_skip "no strace fault injection" +else + ! strace -o /dev/null -f -e trace=prctl -e fault=prctl:when=39 $RUN --die-with-parent --json-status-fd 42 true 42>json-status.json + assert_not_file_has_content json-status.json '"exit-code": [0-9]' + ok "pre-exec failure doesn't include exit-code in json-status" +fi + +notanexecutable=/ +$RUN --json-status-fd 42 $notanexecutable 42>json-status.json || true +assert_not_file_has_content json-status.json '"exit-code": [0-9]' +ok "exec failure doesn't include exit-code in json-status" + +# These tests require --unshare-user +if test -n "${bwrap_is_suid:-}"; then + ok_skip "no --cap-add support" + ok_skip "no --cap-add support" + ok_skip "no --disable-userns" +else + BWRAP_RECURSE="$BWRAP --unshare-user --uid 0 --gid 0 --cap-add ALL --bind / / --bind /proc /proc" + + # $BWRAP May be inaccessible due to the user namespace so use /proc/self/exe + $BWRAP_RECURSE -- /proc/self/exe --unshare-all --bind / / --bind /proc /proc echo hello > recursive_proc.txt + assert_file_has_content recursive_proc.txt "hello" + ok "can mount /proc recursively" + + $BWRAP_RECURSE -- /proc/self/exe --unshare-all ${BWRAP_RO_HOST_ARGS} findmnt > recursive-newroot.txt + assert_file_has_content recursive-newroot.txt "/usr" + ok "can pivot to new rootfs recursively" + + $BWRAP --dev-bind / / -- true + ! $BWRAP --assert-userns-disabled --dev-bind / / -- true + $BWRAP --unshare-user --disable-userns --dev-bind / / -- true + ! $BWRAP --unshare-user --disable-userns --dev-bind / / -- $BWRAP --dev-bind / / -- true + $BWRAP --unshare-user --disable-userns --dev-bind / / -- sh -c "echo 2 > /proc/sys/user/max_user_namespaces || true; ! $BWRAP --unshare-user --dev-bind / / -- true" + $BWRAP --unshare-user --disable-userns --dev-bind / / -- sh -c "echo 100 > /proc/sys/user/max_user_namespaces || true; ! $BWRAP --unshare-user --dev-bind / / -- true" + $BWRAP --unshare-user --disable-userns --dev-bind / / -- sh -c "! $BWRAP --unshare-user --dev-bind / / --assert-userns-disabled -- true" + + $BWRAP_RECURSE --dev-bind / / -- true + ! $BWRAP_RECURSE --assert-userns-disabled --dev-bind / / -- true + $BWRAP_RECURSE --unshare-user --disable-userns --dev-bind / / -- true + ! $BWRAP_RECURSE --unshare-user --disable-userns --dev-bind / / -- /proc/self/exe --dev-bind / / -- true + $BWRAP_RECURSE --unshare-user --disable-userns --dev-bind / / -- sh -c "echo 2 > /proc/sys/user/max_user_namespaces || true; ! $BWRAP --unshare-user --dev-bind / / -- true" + $BWRAP_RECURSE --unshare-user --disable-userns --dev-bind / / -- sh -c "echo 100 > /proc/sys/user/max_user_namespaces || true; ! $BWRAP --unshare-user --dev-bind / / -- true" + $BWRAP_RECURSE --unshare-user --disable-userns --dev-bind / / -- sh -c "! $BWRAP --unshare-user --dev-bind / / --assert-userns-disabled -- true" + + ok "can disable nested userns" +fi + +# Test error prefixing +if $RUN --unshare-pid --bind /source-enoent /dest true 2>err.txt; then + assert_not_reached "bound nonexistent source" +fi +assert_file_has_content err.txt "^bwrap: Can't find source path.*source-enoent" +ok "error prefixing" + +if ! ${is_uidzero}; then + # When invoked as non-root, check that by default we have no caps left + for OPT in "" "--unshare-user-try --as-pid-1" "--unshare-user-try" "--as-pid-1"; do + e=0 + $RUN $OPT --unshare-pid getpcaps 1 >&2 2> caps.test || e=$? + sed -e 's/^/# /' < caps.test >&2 + test "$e" = 0 + assert_not_file_has_content caps.test ': =.*cap' + done + ok "we have no caps as uid != 0" +else + capsh --print | sed -e 's/no-new-privs=0/no-new-privs=1/' > caps.expected + + for OPT in "" "--as-pid-1"; do + $RUN $OPT --unshare-pid capsh --print >caps.test + diff -u caps.expected caps.test + done + # And test that we can drop all, as well as specific caps + $RUN $OPT --cap-drop ALL --unshare-pid capsh --print >caps.test + assert_file_has_content caps.test 'Current: =$' + # Check for dropping kill/fowner (we assume all uid 0 callers have this) + # But we should still have net_bind_service for example + $RUN $OPT --cap-drop CAP_KILL --cap-drop CAP_FOWNER --unshare-pid capsh --print >caps.test + # capsh's output format changed from v2.29 -> drops are now indicated with -eip + if grep 'Current: =.*+eip$' caps.test; then + assert_not_file_has_content caps.test '^Current: =.*cap_kill.*+eip$' + assert_not_file_has_content caps.test '^Current: =.*cap_fowner.*+eip$' + assert_file_has_content caps.test '^Current: =.*cap_net_bind_service.*+eip$' + else + assert_file_has_content caps.test '^Current: =eip.*cap_kill.*-eip$' + assert_file_has_content caps.test '^Current: =eip.*cap_fowner.*-eip$' + assert_not_file_has_content caps.test '^Current: =.*cap_net_bind_service.*-eip$' + fi + ok "we have the expected caps as uid 0" +fi + +# Test --die-with-parent + +cat >lockf-n.py < test.args +printf '%s--dir\0/tmp/hello/world2\0' '' > test.args2 +printf '%s--dir\0/tmp/hello/world3\0' '' > test.args3 +$RUN --args 3 --args 4 --args 5 /bin/sh -c 'test -d /tmp/hello/world && test -d /tmp/hello/world2 && test -d /tmp/hello/world3' 3 bin/--inadvisable-executable-name-- +echo "echo hello" >> bin/--inadvisable-executable-name-- +chmod +x bin/--inadvisable-executable-name-- +PATH="${srcd}:$PATH" $RUN -- sh -c "echo hello" > stdout +assert_file_has_content stdout hello +ok "we can run with --" +PATH="$(pwd)/bin:$PATH" $RUN -- --inadvisable-executable-name-- > stdout +assert_file_has_content stdout hello +ok "we can run an inadvisable executable name with --" +if $RUN -- --dev-bind /dev /dev sh -c 'echo should not have run'; then + assert_not_reached "'--dev-bind' should have been interpreted as a (silly) executable name" +fi +ok "options like --dev-bind are defanged by --" + +if command -v mktemp > /dev/null; then + tempfile="$(mktemp /tmp/bwrap-test-XXXXXXXX)" + echo "hello" > "$tempfile" + $BWRAP --bind / / cat "$tempfile" > stdout + assert_file_has_content stdout hello + ok "bind-mount of / exposes real /tmp" + $BWRAP --bind / / --bind /tmp /tmp cat "$tempfile" > stdout + assert_file_has_content stdout hello + ok "bind-mount of /tmp exposes real /tmp" + if [ -d /mnt ] && [ ! -L /mnt ]; then + $BWRAP --bind / / --bind /tmp /mnt cat "/mnt/${tempfile#/tmp/}" > stdout + assert_file_has_content stdout hello + ok "bind-mount of /tmp onto /mnt exposes real /tmp" + else + ok_skip "/mnt does not exist or is a symlink" + fi +else + ok_skip "mktemp not found" + ok_skip "mktemp not found" + ok_skip "mktemp not found" +fi + +if $RUN test -d /tmp/oldroot; then + assert_not_reached "/tmp/oldroot should not be visible" +fi +if $RUN test -d /tmp/newroot; then + assert_not_reached "/tmp/newroot should not be visible" +fi + +echo "hello" > input.$$ +$BWRAP --bind / / --bind "$(pwd)" /tmp cat /tmp/input.$$ > stdout +assert_file_has_content stdout hello +if $BWRAP --bind / / --bind "$(pwd)" /tmp test -d /tmp/oldroot; then + assert_not_reached "/tmp/oldroot should not be visible" +fi +if $BWRAP --bind / / --bind "$(pwd)" /tmp test -d /tmp/newroot; then + assert_not_reached "/tmp/newroot should not be visible" +fi +ok "we can mount another directory onto /tmp" + +echo "hello" > input.$$ +$RUN --bind "$(pwd)" /tmp/here cat /tmp/here/input.$$ > stdout +assert_file_has_content stdout hello +if $RUN --bind "$(pwd)" /tmp/here test -d /tmp/oldroot; then + assert_not_reached "/tmp/oldroot should not be visible" +fi +if $RUN --bind "$(pwd)" /tmp/here test -d /tmp/newroot; then + assert_not_reached "/tmp/newroot should not be visible" +fi +ok "we can mount another directory inside /tmp" + +touch some-file +mkdir -p some-dir +rm -fr new-dir-mountpoint +rm -fr new-file-mountpoint +$RUN \ + --bind "$(pwd -P)/some-dir" "$(pwd -P)/new-dir-mountpoint" \ + --bind "$(pwd -P)/some-file" "$(pwd -P)/new-file-mountpoint" \ + true +command stat -c '%a' new-dir-mountpoint > new-dir-permissions +assert_file_has_content new-dir-permissions 755 +command stat -c '%a' new-file-mountpoint > new-file-permissions +assert_file_has_content new-file-permissions 444 +ok "Files and directories created as mount points have expected permissions" + + +if [ -S /dev/log ]; then + $RUN --bind / / --bind "$(realpath /dev/log)" "$(realpath /dev/log)" true + ok "Can bind-mount a socket (/dev/log) onto a socket" +else + ok_skip "- /dev/log is not a socket, cannot test bubblewrap#409" +fi + +mkdir -p dir-already-existed +chmod 0710 dir-already-existed +mkdir -p dir-already-existed2 +chmod 0754 dir-already-existed2 +rm -fr new-dir-default-perms +rm -fr new-dir-set-perms +$RUN \ + --perms 1741 --dir "$(pwd -P)/new-dir-set-perms" \ + --dir "$(pwd -P)/dir-already-existed" \ + --perms 0741 --dir "$(pwd -P)/dir-already-existed2" \ + --dir "$(pwd -P)/dir-chmod" \ + --chmod 1755 "$(pwd -P)/dir-chmod" \ + --dir "$(pwd -P)/new-dir-default-perms" \ + true +command stat -c '%a' new-dir-default-perms > new-dir-permissions +assert_file_has_content new-dir-permissions '^755$' +command stat -c '%a' new-dir-set-perms > new-dir-permissions +assert_file_has_content new-dir-permissions '^1741$' +command stat -c '%a' dir-already-existed > dir-permissions +assert_file_has_content dir-permissions '^710$' +command stat -c '%a' dir-already-existed2 > dir-permissions +assert_file_has_content dir-permissions '^754$' +command stat -c '%a' dir-chmod > dir-permissions +assert_file_has_content dir-permissions '^1755$' +ok "Directories created explicitly have expected permissions" + +rm -fr parent +rm -fr parent-of-1777 +rm -fr parent-of-0755 +rm -fr parent-of-0644 +rm -fr parent-of-0750 +rm -fr parent-of-0710 +rm -fr parent-of-0720 +rm -fr parent-of-0640 +rm -fr parent-of-0700 +rm -fr parent-of-0600 +rm -fr parent-of-0705 +rm -fr parent-of-0604 +rm -fr parent-of-0000 +$RUN \ + --dir "$(pwd -P)"/parent/dir \ + --perms 1777 --dir "$(pwd -P)"/parent-of-1777/dir \ + --perms 0755 --dir "$(pwd -P)"/parent-of-0755/dir \ + --perms 0644 --dir "$(pwd -P)"/parent-of-0644/dir \ + --perms 0750 --dir "$(pwd -P)"/parent-of-0750/dir \ + --perms 0710 --dir "$(pwd -P)"/parent-of-0710/dir \ + --perms 0720 --dir "$(pwd -P)"/parent-of-0720/dir \ + --perms 0640 --dir "$(pwd -P)"/parent-of-0640/dir \ + --perms 0700 --dir "$(pwd -P)"/parent-of-0700/dir \ + --perms 0600 --dir "$(pwd -P)"/parent-of-0600/dir \ + --perms 0705 --dir "$(pwd -P)"/parent-of-0705/dir \ + --perms 0604 --dir "$(pwd -P)"/parent-of-0604/dir \ + --perms 0000 --dir "$(pwd -P)"/parent-of-0000/dir \ + true +command stat -c '%a' parent > dir-permissions +assert_file_has_content dir-permissions '^755$' +command stat -c '%a' parent-of-1777 > dir-permissions +assert_file_has_content dir-permissions '^755$' +command stat -c '%a' parent-of-0755 > dir-permissions +assert_file_has_content dir-permissions '^755$' +command stat -c '%a' parent-of-0644 > dir-permissions +assert_file_has_content dir-permissions '^755$' +command stat -c '%a' parent-of-0750 > dir-permissions +assert_file_has_content dir-permissions '^750$' +command stat -c '%a' parent-of-0710 > dir-permissions +assert_file_has_content dir-permissions '^750$' +command stat -c '%a' parent-of-0720 > dir-permissions +assert_file_has_content dir-permissions '^750$' +command stat -c '%a' parent-of-0640 > dir-permissions +assert_file_has_content dir-permissions '^750$' +command stat -c '%a' parent-of-0700 > dir-permissions +assert_file_has_content dir-permissions '^700$' +command stat -c '%a' parent-of-0600 > dir-permissions +assert_file_has_content dir-permissions '^700$' +command stat -c '%a' parent-of-0705 > dir-permissions +assert_file_has_content dir-permissions '^705$' +command stat -c '%a' parent-of-0604 > dir-permissions +assert_file_has_content dir-permissions '^705$' +command stat -c '%a' parent-of-0000 > dir-permissions +assert_file_has_content dir-permissions '^700$' +chmod -R 0700 parent* +rm -fr parent* +ok "Directories created as parents have expected permissions" + +$RUN \ + --perms 01777 --tmpfs "$(pwd -P)" \ + cat /proc/self/mountinfo >&2 +$RUN \ + --perms 01777 --tmpfs "$(pwd -P)" \ + stat -c '%a' "$(pwd -P)" > dir-permissions +assert_file_has_content dir-permissions '^1777$' +$RUN \ + --tmpfs "$(pwd -P)" \ + stat -c '%a' "$(pwd -P)" > dir-permissions +assert_file_has_content dir-permissions '^755$' +ok "tmpfs has expected permissions" + +# 1048576 = 1 MiB +if test -n "${bwrap_is_suid:-}"; then + if $RUN --size 1048576 --tmpfs "$(pwd -P)" true; then + assert_not_reached "Should not allow --size --tmpfs when setuid" + fi + ok "--size --tmpfs is not allowed when setuid" +elif df --output=size --block-size=1K "$(pwd -P)" >/dev/null 2>/dev/null; then + $RUN \ + --size 1048576 --tmpfs "$(pwd -P)" \ + df --output=size --block-size=1K "$(pwd -P)" > dir-size + assert_file_has_content dir-size '^ *1024$' + $RUN \ + --size 1048576 --perms 01777 --tmpfs "$(pwd -P)" \ + stat -c '%a' "$(pwd -P)" > dir-permissions + assert_file_has_content dir-permissions '^1777$' + $RUN \ + --size 1048576 --perms 01777 --tmpfs "$(pwd -P)" \ + df --output=size --block-size=1K "$(pwd -P)" > dir-size + assert_file_has_content dir-size '^ *1024$' + $RUN \ + --perms 01777 --size 1048576 --tmpfs "$(pwd -P)" \ + stat -c '%a' "$(pwd -P)" > dir-permissions + assert_file_has_content dir-permissions '^1777$' + $RUN \ + --perms 01777 --size 1048576 --tmpfs "$(pwd -P)" \ + df --output=size --block-size=1K "$(pwd -P)" > dir-size + assert_file_has_content dir-size '^ *1024$' + ok "tmpfs has expected size" +else + $RUN --size 1048576 --tmpfs "$(pwd -P)" true + $RUN --perms 01777 --size 1048576 --tmpfs "$(pwd -P)" true + $RUN --size 1048576 --perms 01777 --tmpfs "$(pwd -P)" true + ok_skip "df is too old, cannot test --size --tmpfs fully" +fi + +$RUN \ + --file 0 /tmp/file \ + stat -c '%a' /tmp/file < /dev/null > file-permissions +assert_file_has_content file-permissions '^666$' +$RUN \ + --perms 0640 --file 0 /tmp/file \ + stat -c '%a' /tmp/file < /dev/null > file-permissions +assert_file_has_content file-permissions '^640$' +$RUN \ + --bind-data 0 /tmp/file \ + stat -c '%a' /tmp/file < /dev/null > file-permissions +assert_file_has_content file-permissions '^600$' +$RUN \ + --perms 0640 --bind-data 0 /tmp/file \ + stat -c '%a' /tmp/file < /dev/null > file-permissions +assert_file_has_content file-permissions '^640$' +$RUN \ + --ro-bind-data 0 /tmp/file \ + stat -c '%a' /tmp/file < /dev/null > file-permissions +assert_file_has_content file-permissions '^600$' +$RUN \ + --perms 0640 --ro-bind-data 0 /tmp/file \ + stat -c '%a' /tmp/file < /dev/null > file-permissions +assert_file_has_content file-permissions '^640$' +ok "files have expected permissions" + +if $RUN --size 0 --tmpfs /tmp/a true; then + assert_not_reached Zero tmpfs size allowed +fi +if $RUN --size 123bogus --tmpfs /tmp/a true; then + assert_not_reached Bogus tmpfs size allowed +fi +if $RUN --size '' --tmpfs /tmp/a true; then + assert_not_reached Empty tmpfs size allowed +fi +if $RUN --size -12345678 --tmpfs /tmp/a true; then + assert_not_reached Negative tmpfs size allowed +fi +if $RUN --size ' -12345678' --tmpfs /tmp/a true; then + assert_not_reached Negative tmpfs size with space allowed +fi +# This is 2^64 +if $RUN --size 18446744073709551616 --tmpfs /tmp/a true; then + assert_not_reached Overflowing tmpfs size allowed +fi +# This is 2^63 + 1; note that the current max size is SIZE_MAX/2 +if $RUN --size 9223372036854775809 --tmpfs /tmp/a true; then + assert_not_reached Too-large tmpfs size allowed +fi +ok "bogus tmpfs size not allowed" + +if $RUN --perms 0640 --perms 0640 --tmpfs /tmp/a true; then + assert_not_reached Multiple perms options allowed +fi +if $RUN --size 1048576 --size 1048576 --tmpfs /tmp/a true; then + assert_not_reached Multiple perms options allowed +fi +ok "--perms and --size only allowed once" + + +FOO= BAR=baz $RUN --setenv FOO bar sh -c 'echo "$FOO$BAR"' > stdout +assert_file_has_content stdout barbaz +FOO=wrong BAR=baz $RUN --setenv FOO bar sh -c 'echo "$FOO$BAR"' > stdout +assert_file_has_content stdout barbaz +FOO=wrong BAR=baz $RUN --unsetenv FOO sh -c 'printf "%s%s" "$FOO" "$BAR"' > stdout +printf baz > reference +assert_files_equal stdout reference +FOO=wrong BAR=wrong $RUN --clearenv /usr/bin/env > stdout +echo "PWD=$(pwd -P)" > reference +assert_files_equal stdout reference +ok "environment manipulation" + +$RUN sh -c 'echo $0' > stdout +assert_file_has_content stdout sh +$RUN --argv0 sh sh -c 'echo $0' > stdout +assert_file_has_content stdout sh +$RUN --argv0 right sh -c 'echo $0' > stdout +assert_file_has_content stdout right +ok "argv0 manipulation" + +echo "foobar" > file-data +$RUN --proc /proc --dev /dev --bind / / --bind-fd 100 /tmp cat /tmp/file-data 100< . > stdout +assert_file_has_content stdout foobar + +ok "bind-fd" + +$RUN --chdir / --chdir / true > stdout 2>&1 +assert_file_has_content stdout '^bwrap: Only the last --chdir option will take effect$' +ok "warning logged for redundant --chdir" + +$RUN --level-prefix --chdir / --chdir / true > stdout 2>&1 +assert_file_has_content stdout '^<4>bwrap: Only the last --chdir option will take effect$' +ok "--level-prefix" + +if test -n "${bwrap_is_suid:-}"; then + ok_skip "no --overlay support" + ok_skip "no --overlay support" + ok_skip "no --tmp-overlay support" + ok_skip "no --ro-overlay support" + ok_skip "no --overlay-src support" +else + mkdir lower1 lower2 upper work + printf 1 > lower1/a + printf 2 > lower1/b + printf 3 > lower2/b + printf 4 > upper/a + + # Check if unprivileged overlayfs is available + if ! unshare -rm mount -t overlay -o lowerdir=lower1,upperdir=upper,workdir=work,userxattr overlay lower2; then + ok_skip "no kernel support for unprivileged overlayfs" + ok_skip "no kernel support for unprivileged overlayfs" + ok_skip "no kernel support for unprivileged overlayfs" + ok_skip "no kernel support for unprivileged overlayfs" + ok_skip "no kernel support for unprivileged overlayfs" + else + + # Test --overlay + if $RUN --overlay upper work /tmp true 2>err.txt; then + assert_not_reached At least one --overlay-src not required + fi + assert_file_has_content err.txt "^bwrap: --overlay requires at least one --overlay-src" + $RUN --overlay-src lower1 --overlay upper work /tmp/x/y/z cat /tmp/x/y/z/a > stdout + assert_file_has_content stdout '^4$' + $RUN --overlay-src lower1 --overlay upper work /tmp/x/y/z cat /tmp/x/y/z/b > stdout + assert_file_has_content stdout '^2$' + $RUN --overlay-src lower1 --overlay-src lower2 --overlay upper work /tmp/x/y/z cat /tmp/x/y/z/a > stdout + assert_file_has_content stdout '^4$' + $RUN --overlay-src lower1 --overlay-src lower2 --overlay upper work /tmp/x/y/z cat /tmp/x/y/z/b > stdout + assert_file_has_content stdout '^3$' + $RUN --overlay-src lower1 --overlay-src lower2 --overlay upper work /tmp/x/y/z sh -c 'printf 5 > /tmp/x/y/z/b; cat /tmp/x/y/z/b' > stdout + assert_file_has_content stdout '^5$' + assert_file_has_content upper/b '^5$' + ok "--overlay" + + # Test --overlay path escaping + # Coincidentally, ":,\ is the face I make contemplating anyone who might + # need this functionality, not that that's going to stop me from supporting + # it. + mkdir 'lower ":,\' 'upper ":,\' 'work ":,\' + printf 1 > 'lower ":,\'/a + $RUN --overlay-src 'lower ":,\' --overlay 'upper ":,\' 'work ":,\' /tmp/x sh -c 'cat /tmp/x/a; printf 2 > /tmp/x/a; cat /tmp/x/a' > stdout + assert_file_has_content stdout '^12$' + assert_file_has_content 'lower ":,\'/a '^1$' + assert_file_has_content 'upper ":,\'/a '^2$' + ok "--overlay path escaping" + + # Test --tmp-overlay + printf 1 > lower1/a + printf 2 > lower1/b + printf 3 > lower2/b + if $RUN --tmp-overlay /tmp true 2>err.txt; then + assert_not_reached At least one --overlay-src not required + fi + assert_file_has_content err.txt "^bwrap: --tmp-overlay requires at least one --overlay-src" + $RUN --overlay-src lower1 --tmp-overlay /tmp/x/y/z cat /tmp/x/y/z/a > stdout + assert_file_has_content stdout '^1$' + $RUN --overlay-src lower1 --tmp-overlay /tmp/x/y/z cat /tmp/x/y/z/b > stdout + assert_file_has_content stdout '^2$' + $RUN --overlay-src lower1 --overlay-src lower2 --tmp-overlay /tmp/x/y/z cat /tmp/x/y/z/a > stdout + assert_file_has_content stdout '^1$' + $RUN --overlay-src lower1 --overlay-src lower2 --tmp-overlay /tmp/x/y/z cat /tmp/x/y/z/b > stdout + assert_file_has_content stdout '^3$' + $RUN --overlay-src lower1 --overlay-src lower2 --tmp-overlay /tmp/x/y/z sh -c 'printf 4 > /tmp/x/y/z/b; cat /tmp/x/y/z/b' > stdout + assert_file_has_content stdout '^4$' + $RUN --overlay-src lower1 --tmp-overlay /tmp/x --overlay-src lower2 --tmp-overlay /tmp/y sh -c 'cat /tmp/x/b; printf 4 > /tmp/x/b; cat /tmp/x/b; cat /tmp/y/b' > stdout + assert_file_has_content stdout '^243$' + assert_file_has_content lower1/b '^2$' + assert_file_has_content lower2/b '^3$' + ok "--tmp-overlay" + + # Test --ro-overlay + printf 1 > lower1/a + printf 2 > lower1/b + printf 3 > lower2/b + if $RUN --ro-overlay /tmp true 2>err.txt; then + assert_not_reached At least two --overlay-src not required + fi + assert_file_has_content err.txt "^bwrap: --ro-overlay requires at least two --overlay-src" + if $RUN --overlay-src lower1 --ro-overlay /tmp true 2>err.txt; then + assert_not_reached At least two --overlay-src not required + fi + assert_file_has_content err.txt "^bwrap: --ro-overlay requires at least two --overlay-src" + $RUN --overlay-src lower1 --overlay-src lower2 --ro-overlay /tmp/x/y/z cat /tmp/x/y/z/a > stdout + assert_file_has_content stdout '^1$' + $RUN --overlay-src lower1 --overlay-src lower2 --ro-overlay /tmp/x/y/z cat /tmp/x/y/z/b > stdout + assert_file_has_content stdout '^3$' + $RUN --overlay-src lower1 --overlay-src lower2 --ro-overlay /tmp/x/y/z sh -c 'printf 4 > /tmp/x/y/z/b; cat /tmp/x/y/z/b' > stdout + assert_file_has_content stdout '^3$' + ok "--ro-overlay" + + # Test --overlay-src restrictions + if $RUN --overlay-src /tmp true 2>err.txt; then + assert_not_reached Trailing --overlay-src allowed + fi + assert_file_has_content err.txt "^bwrap: --overlay-src must be followed by another --overlay-src or one of --overlay, --tmp-overlay, or --ro-overlay" + if $RUN --overlay-src /tmp --chdir / true 2>err.txt; then + assert_not_reached --overlay-src allowed to precede non-overlay options + fi + assert_file_has_content err.txt "^bwrap: --overlay-src must be followed by another --overlay-src or one of --overlay, --tmp-overlay, or --ro-overlay" + ok "--overlay-src restrictions" + + fi +fi + +done_testing diff --git a/codex-rs/vendor/bubblewrap/tests/test-seccomp.py b/codex-rs/vendor/bubblewrap/tests/test-seccomp.py new file mode 100755 index 0000000000..3ce02ef888 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/test-seccomp.py @@ -0,0 +1,635 @@ +#!/usr/bin/env python3 +# Copyright 2021 Simon McVittie +# SPDX-License-Identifier: LGPL-2.0-or-later + +import errno +import logging +import os +import subprocess +import sys +import tempfile +import termios +import unittest + +try: + import seccomp +except ImportError: + print('1..0 # SKIP cannot import seccomp Python module') + sys.exit(0) + + +# This is the @default set from systemd as of 2021-10-11 +DEFAULT_SET = set(''' +brk +cacheflush +clock_getres +clock_getres_time64 +clock_gettime +clock_gettime64 +clock_nanosleep +clock_nanosleep_time64 +execve +exit +exit_group +futex +futex_time64 +get_robust_list +get_thread_area +getegid +getegid32 +geteuid +geteuid32 +getgid +getgid32 +getgroups +getgroups32 +getpgid +getpgrp +getpid +getppid +getrandom +getresgid +getresgid32 +getresuid +getresuid32 +getrlimit +getsid +gettid +gettimeofday +getuid +getuid32 +membarrier +mmap +mmap2 +munmap +nanosleep +pause +prlimit64 +restart_syscall +rseq +rt_sigreturn +sched_getaffinity +sched_yield +set_robust_list +set_thread_area +set_tid_address +set_tls +sigreturn +time +ugetrlimit +'''.split()) + +# This is the @basic-io set from systemd +BASIC_IO_SET = set(''' +_llseek +close +close_range +dup +dup2 +dup3 +lseek +pread64 +preadv +preadv2 +pwrite64 +pwritev +pwritev2 +read +readv +write +writev +'''.split()) + +# This is the @filesystem-io set from systemd +FILESYSTEM_SET = set(''' +access +chdir +chmod +close +creat +faccessat +faccessat2 +fallocate +fchdir +fchmod +fchmodat +fcntl +fcntl64 +fgetxattr +flistxattr +fremovexattr +fsetxattr +fstat +fstat64 +fstatat64 +fstatfs +fstatfs64 +ftruncate +ftruncate64 +futimesat +getcwd +getdents +getdents64 +getxattr +inotify_add_watch +inotify_init +inotify_init1 +inotify_rm_watch +lgetxattr +link +linkat +listxattr +llistxattr +lremovexattr +lsetxattr +lstat +lstat64 +mkdir +mkdirat +mknod +mknodat +newfstatat +oldfstat +oldlstat +oldstat +open +openat +openat2 +readlink +readlinkat +removexattr +rename +renameat +renameat2 +rmdir +setxattr +stat +stat64 +statfs +statfs64 +statx +symlink +symlinkat +truncate +truncate64 +unlink +unlinkat +utime +utimensat +utimensat_time64 +utimes +'''.split()) + +# Miscellaneous syscalls used during process startup, at least on x86_64 +ALLOWED = DEFAULT_SET | BASIC_IO_SET | FILESYSTEM_SET | set(''' +arch_prctl +ioctl +madvise +mprotect +mremap +prctl +readdir +umask +'''.split()) + +# Syscalls we will try to use, expecting them to be either allowed or +# blocked by our allow and/or deny lists +TRY_SYSCALLS = [ + 'chmod', + 'chroot', + 'clone3', + 'ioctl TIOCNOTTY', + 'ioctl TIOCSTI CVE-2019-10063', + 'ioctl TIOCSTI', + 'listen', + 'prctl', +] + + +class Test(unittest.TestCase): + def setUp(self) -> None: + here = os.path.dirname(os.path.abspath(__file__)) + + if 'G_TEST_SRCDIR' in os.environ: + self.test_srcdir = os.getenv('G_TEST_SRCDIR') + '/tests' + else: + self.test_srcdir = here + + if 'G_TEST_BUILDDIR' in os.environ: + self.test_builddir = os.getenv('G_TEST_BUILDDIR') + '/tests' + else: + self.test_builddir = here + + self.bwrap = os.getenv('BWRAP', 'bwrap') + self.try_syscall = os.path.join(self.test_builddir, 'try-syscall') + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + 'true', + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=2, + ) + + if completed.returncode != 0: + raise unittest.SkipTest( + 'cannot run bwrap (does it need to be setuid?)' + ) + + def tearDown(self) -> None: + pass + + def test_no_seccomp(self) -> None: + for syscall in TRY_SYSCALLS: + print('# {} without seccomp'.format(syscall)) + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + self.try_syscall, syscall, + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=2, + ) + + if ( + syscall == 'ioctl TIOCSTI CVE-2019-10063' + and completed.returncode == errno.ENOENT + ): + print('# Cannot test 64-bit syscall parameter on 32-bit') + continue + + if syscall == 'clone3': + # If the kernel supports it, we didn't block it so + # it fails with EFAULT. If the kernel doesn't support it, + # it'll fail with ENOSYS instead. + self.assertIn( + completed.returncode, + (errno.ENOSYS, errno.EFAULT), + ) + elif syscall.startswith('ioctl') or syscall == 'listen': + self.assertEqual(completed.returncode, errno.EBADF) + else: + self.assertEqual(completed.returncode, errno.EFAULT) + + def test_seccomp_allowlist(self) -> None: + with tempfile.TemporaryFile() as allowlist_temp: + allowlist = seccomp.SyscallFilter(seccomp.ERRNO(errno.ENOSYS)) + + if os.uname().machine == 'x86_64': + # Allow Python and try-syscall to be different word sizes + allowlist.add_arch(seccomp.Arch.X86) + + for syscall in ALLOWED: + try: + allowlist.add_rule(seccomp.ALLOW, syscall) + except Exception as e: + print('# Cannot add {} to allowlist: {!r}'.format(syscall, e)) + + allowlist.export_bpf(allowlist_temp) + + for syscall in TRY_SYSCALLS: + print('# allowlist vs. {}'.format(syscall)) + allowlist_temp.seek(0, os.SEEK_SET) + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--seccomp', str(allowlist_temp.fileno()), + self.try_syscall, syscall, + ], + pass_fds=(allowlist_temp.fileno(),), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=2, + ) + + if ( + syscall == 'ioctl TIOCSTI CVE-2019-10063' + and completed.returncode == errno.ENOENT + ): + print('# Cannot test 64-bit syscall parameter on 32-bit') + continue + + if syscall.startswith('ioctl'): + # We allow this, so it is executed (and in this simple + # example, immediately fails) + self.assertEqual(completed.returncode, errno.EBADF) + elif syscall in ('chroot', 'listen', 'clone3'): + # We don't allow these, so they fail with ENOSYS. + # clone3 might also be failing with ENOSYS because + # the kernel genuinely doesn't support it. + self.assertEqual(completed.returncode, errno.ENOSYS) + else: + # We allow this, so it is executed (and in this simple + # example, immediately fails) + self.assertEqual(completed.returncode, errno.EFAULT) + + def test_seccomp_denylist(self) -> None: + with tempfile.TemporaryFile() as denylist_temp: + denylist = seccomp.SyscallFilter(seccomp.ALLOW) + + if os.uname().machine == 'x86_64': + # Allow Python and try-syscall to be different word sizes + denylist.add_arch(seccomp.Arch.X86) + + # Using ECONNREFUSED here because it's unlikely that any of + # these syscalls will legitimately fail with that code, so + # if they fail like this, it will be as a result of seccomp. + denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chmod') + denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chroot') + denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'prctl') + denylist.add_rule( + seccomp.ERRNO(errno.ECONNREFUSED), 'ioctl', + seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI), + ) + + denylist.export_bpf(denylist_temp) + + for syscall in TRY_SYSCALLS: + print('# denylist vs. {}'.format(syscall)) + denylist_temp.seek(0, os.SEEK_SET) + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--seccomp', str(denylist_temp.fileno()), + self.try_syscall, syscall, + ], + pass_fds=(denylist_temp.fileno(),), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=2, + ) + + if ( + syscall == 'ioctl TIOCSTI CVE-2019-10063' + and completed.returncode == errno.ENOENT + ): + print('# Cannot test 64-bit syscall parameter on 32-bit') + continue + + if syscall == 'clone3': + # If the kernel supports it, we didn't block it so + # it fails with EFAULT. If the kernel doesn't support it, + # it'll fail with ENOSYS instead. + self.assertIn( + completed.returncode, + (errno.ENOSYS, errno.EFAULT), + ) + elif syscall in ('ioctl TIOCNOTTY', 'listen'): + # Not on the denylist + self.assertEqual(completed.returncode, errno.EBADF) + else: + # We blocked all of these + self.assertEqual(completed.returncode, errno.ECONNREFUSED) + + def test_seccomp_stacked(self, allowlist_first=False) -> None: + with tempfile.TemporaryFile( + ) as allowlist_temp, tempfile.TemporaryFile( + ) as denylist_temp: + # This filter is a simplified version of what Flatpak wants + + allowlist = seccomp.SyscallFilter(seccomp.ERRNO(errno.ENOSYS)) + denylist = seccomp.SyscallFilter(seccomp.ALLOW) + + if os.uname().machine == 'x86_64': + # Allow Python and try-syscall to be different word sizes + allowlist.add_arch(seccomp.Arch.X86) + denylist.add_arch(seccomp.Arch.X86) + + for syscall in ALLOWED: + try: + allowlist.add_rule(seccomp.ALLOW, syscall) + except Exception as e: + print('# Cannot add {} to allowlist: {!r}'.format(syscall, e)) + + denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chmod') + denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'chroot') + denylist.add_rule( + seccomp.ERRNO(errno.ECONNREFUSED), 'ioctl', + seccomp.Arg(1, seccomp.MASKED_EQ, 0xffffffff, termios.TIOCSTI), + ) + + # All seccomp programs except the last must allow prctl(), + # because otherwise we wouldn't be able to add the remaining + # seccomp programs. We document that the last program can + # block prctl, so test that. + if allowlist_first: + denylist.add_rule(seccomp.ERRNO(errno.ECONNREFUSED), 'prctl') + + allowlist.export_bpf(allowlist_temp) + denylist.export_bpf(denylist_temp) + + for syscall in TRY_SYSCALLS: + print('# stacked vs. {}'.format(syscall)) + allowlist_temp.seek(0, os.SEEK_SET) + denylist_temp.seek(0, os.SEEK_SET) + + if allowlist_first: + fds = [allowlist_temp.fileno(), denylist_temp.fileno()] + else: + fds = [denylist_temp.fileno(), allowlist_temp.fileno()] + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--add-seccomp-fd', str(fds[0]), + '--add-seccomp-fd', str(fds[1]), + self.try_syscall, syscall, + ], + pass_fds=fds, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=2, + ) + + if ( + syscall == 'ioctl TIOCSTI CVE-2019-10063' + and completed.returncode == errno.ENOENT + ): + print('# Cannot test 64-bit syscall parameter on 32-bit') + continue + + if syscall == 'ioctl TIOCNOTTY': + # Not denied by the denylist, and allowed by the allowlist + self.assertEqual(completed.returncode, errno.EBADF) + elif syscall in ('clone3', 'listen'): + # We didn't deny these, so the denylist has no effect + # and we fall back to the allowlist, which doesn't + # include them either. + # clone3 might also be failing with ENOSYS because + # the kernel genuinely doesn't support it. + self.assertEqual(completed.returncode, errno.ENOSYS) + elif syscall == 'chroot': + # This is denied by the denylist *and* not allowed by + # the allowlist. The result depends which one we added + # first: the most-recently-added filter "wins". + if allowlist_first: + self.assertEqual( + completed.returncode, + errno.ECONNREFUSED, + ) + else: + self.assertEqual(completed.returncode, errno.ENOSYS) + elif syscall == 'prctl': + # We can only put this on the denylist if the denylist + # is the last to be added. + if allowlist_first: + self.assertEqual( + completed.returncode, + errno.ECONNREFUSED, + ) + else: + self.assertEqual(completed.returncode, errno.EFAULT) + else: + # chmod is allowed by the allowlist but blocked by the + # denylist. Denying takes precedence over allowing, + # regardless of order. + self.assertEqual(completed.returncode, errno.ECONNREFUSED) + + def test_seccomp_stacked_allowlist_first(self) -> None: + self.test_seccomp_stacked(allowlist_first=True) + + def test_seccomp_invalid(self) -> None: + with tempfile.TemporaryFile( + ) as allowlist_temp, tempfile.TemporaryFile( + ) as denylist_temp: + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--add-seccomp-fd', '-1', + 'true', + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self.assertIn(b'bwrap: Invalid fd: -1\n', completed.stderr) + self.assertEqual(completed.returncode, 1) + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--seccomp', '0a', + 'true', + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self.assertIn(b'bwrap: Invalid fd: 0a\n', completed.stderr) + self.assertEqual(completed.returncode, 1) + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--add-seccomp-fd', str(denylist_temp.fileno()), + '--seccomp', str(allowlist_temp.fileno()), + 'true', + ], + pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self.assertIn( + b'bwrap: --seccomp cannot be combined with --add-seccomp-fd\n', + completed.stderr, + ) + self.assertEqual(completed.returncode, 1) + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--seccomp', str(allowlist_temp.fileno()), + '--add-seccomp-fd', str(denylist_temp.fileno()), + 'true', + ], + pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self.assertIn( + b'--add-seccomp-fd cannot be combined with --seccomp', + completed.stderr, + ) + self.assertEqual(completed.returncode, 1) + + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--add-seccomp-fd', str(allowlist_temp.fileno()), + '--add-seccomp-fd', str(allowlist_temp.fileno()), + 'true', + ], + pass_fds=(allowlist_temp.fileno(), allowlist_temp.fileno()), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self.assertIn( + b"bwrap: Can't read seccomp data: ", + completed.stderr, + ) + self.assertEqual(completed.returncode, 1) + + allowlist_temp.write(b'\x01') + allowlist_temp.seek(0, os.SEEK_SET) + completed = subprocess.run( + [ + self.bwrap, + '--ro-bind', '/', '/', + '--add-seccomp-fd', str(denylist_temp.fileno()), + '--add-seccomp-fd', str(allowlist_temp.fileno()), + 'true', + ], + pass_fds=(allowlist_temp.fileno(), denylist_temp.fileno()), + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + self.assertIn( + b'bwrap: Invalid seccomp data, must be multiple of 8\n', + completed.stderr, + ) + self.assertEqual(completed.returncode, 1) + + +def main(): + logging.basicConfig(level=logging.DEBUG) + + try: + from tap.runner import TAPTestRunner + except ImportError: + TAPTestRunner = None # type: ignore + + if TAPTestRunner is not None: + runner = TAPTestRunner() + runner.set_stream(True) + unittest.main(testRunner=runner) + else: + print('# tap.runner not available, using simple TAP output') + print('1..1') + program = unittest.main(exit=False) + if program.result.wasSuccessful(): + print('ok 1 - %r' % program.result) + else: + print('not ok 1 - %r' % program.result) + sys.exit(1) + +if __name__ == '__main__': + main() diff --git a/codex-rs/vendor/bubblewrap/tests/test-specifying-pidns.sh b/codex-rs/vendor/bubblewrap/tests/test-specifying-pidns.sh new file mode 100755 index 0000000000..de38b977fd --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/test-specifying-pidns.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -xeuo pipefail + +srcd=$(cd $(dirname "$0") && pwd) +. "${srcd}/libtest.sh" + +echo "1..1" + +# This test needs user namespaces +if test -n "${bwrap_is_suid:-}"; then + echo "ok - # SKIP no setuid support for --unshare-user" +else + mkfifo donepipe + $RUN --info-fd 42 --unshare-user --unshare-pid sh -c 'readlink /proc/self/ns/pid > sandbox-pidns; cat < donepipe' >/dev/null 42>info.json & + while ! test -f sandbox-pidns; do sleep 1; done + SANDBOX1PID=$(extract_child_pid info.json) + + ASAN_OPTIONS=detect_leaks=0 LSAN_OPTIONS=detect_leaks=0 \ + $RUN --userns 11 --pidns 12 readlink /proc/self/ns/pid > sandbox2-pidns 11< /proc/$SANDBOX1PID/ns/user 12< /proc/$SANDBOX1PID/ns/pid + echo foo > donepipe + + assert_files_equal sandbox-pidns sandbox2-pidns + + rm donepipe info.json sandbox-pidns + + echo "ok - Test --pidns" +fi diff --git a/codex-rs/vendor/bubblewrap/tests/test-specifying-userns.sh b/codex-rs/vendor/bubblewrap/tests/test-specifying-userns.sh new file mode 100755 index 0000000000..07c5b4a347 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/test-specifying-userns.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash + +set -xeuo pipefail + +srcd=$(cd $(dirname "$0") && pwd) +. "${srcd}/libtest.sh" + +echo "1..1" + +# This test needs user namespaces +if test -n "${bwrap_is_suid:-}"; then + echo "ok - # SKIP no setuid support for --unshare-user" +else + mkfifo donepipe + + $RUN --info-fd 42 --unshare-user sh -c 'readlink /proc/self/ns/user > sandbox-userns; cat < donepipe' >/dev/null 42>info.json & + while ! test -f sandbox-userns; do sleep 1; done + SANDBOX1PID=$(extract_child_pid info.json) + + $RUN --userns 11 readlink /proc/self/ns/user > sandbox2-userns 11< /proc/$SANDBOX1PID/ns/user + echo foo > donepipe + + assert_files_equal sandbox-userns sandbox2-userns + + rm donepipe info.json sandbox-userns + + echo "ok - Test --userns" +fi diff --git a/codex-rs/vendor/bubblewrap/tests/test-utils.c b/codex-rs/vendor/bubblewrap/tests/test-utils.c new file mode 100644 index 0000000000..64f42ff18c --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/test-utils.c @@ -0,0 +1,247 @@ +/* + * Copyright © 2019-2021 Collabora Ltd. + * + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + */ + +#include +#include +#include + +#include "utils.h" + +/* A small implementation of TAP */ +static unsigned int test_number = 0; + +__attribute__((format(printf, 1, 2))) +static void +ok (const char *format, ...) +{ + va_list ap; + + printf ("ok %u - ", ++test_number); + va_start (ap, format); + vprintf (format, ap); + va_end (ap); + printf ("\n"); +} + +/* for simplicity we always die immediately on failure */ +#define not_ok(fmt, ...) die (fmt, ## __VA_ARGS__) + +/* approximately GLib-compatible helper macros */ +#define g_test_message(fmt, ...) printf ("# " fmt "\n", ## __VA_ARGS__) +#define g_assert_cmpstr(left_expr, op, right_expr) \ + do { \ + const char *left = (left_expr); \ + const char *right = (right_expr); \ + if (strcmp0 (left, right) op 0) \ + ok ("%s (\"%s\") %s %s (\"%s\")", #left_expr, left, #op, #right_expr, right); \ + else \ + not_ok ("expected %s (\"%s\") %s %s (\"%s\")", \ + #left_expr, left, #op, #right_expr, right); \ + } while (0) +#define g_assert_cmpint(left_expr, op, right_expr) \ + do { \ + intmax_t left = (left_expr); \ + intmax_t right = (right_expr); \ + if (left op right) \ + ok ("%s (%ji) %s %s (%ji)", #left_expr, left, #op, #right_expr, right); \ + else \ + not_ok ("expected %s (%ji) %s %s (%ji)", \ + #left_expr, left, #op, #right_expr, right); \ + } while (0) +#define g_assert_cmpuint(left_expr, op, right_expr) \ + do { \ + uintmax_t left = (left_expr); \ + uintmax_t right = (right_expr); \ + if (left op right) \ + ok ("%s (%ju) %s %s (%ju)", #left_expr, left, #op, #right_expr, right); \ + else \ + not_ok ("expected %s (%ju) %s %s (%ju)", \ + #left_expr, left, #op, #right_expr, right); \ + } while (0) +#define g_assert_true(expr) \ + do { \ + if ((expr)) \ + ok ("%s", #expr); \ + else \ + not_ok ("expected %s to be true", #expr); \ + } while (0) +#define g_assert_false(expr) \ + do { \ + if (!(expr)) \ + ok ("!(%s)", #expr); \ + else \ + not_ok ("expected %s to be false", #expr); \ + } while (0) +#define g_assert_null(expr) \ + do { \ + if ((expr) == NULL) \ + ok ("%s was null", #expr); \ + else \ + not_ok ("expected %s to be null", #expr); \ + } while (0) +#define g_assert_nonnull(expr) \ + do { \ + if ((expr) != NULL) \ + ok ("%s wasn't null", #expr); \ + else \ + not_ok ("expected %s to be non-null", #expr); \ + } while (0) + +static int +strcmp0 (const char *left, + const char *right) +{ + if (left == right) + return 0; + + if (left == NULL) + return -1; + + if (right == NULL) + return 1; + + return strcmp (left, right); +} + +static void +test_n_elements (void) +{ + int three[] = { 1, 2, 3 }; + g_assert_cmpuint (N_ELEMENTS (three), ==, 3); +} + +static void +test_strconcat (void) +{ + const char *a = "aaa"; + const char *b = "bbb"; + char *ab = strconcat (a, b); + g_assert_cmpstr (ab, ==, "aaabbb"); + free (ab); +} + +static void +test_strconcat3 (void) +{ + const char *a = "aaa"; + const char *b = "bbb"; + const char *c = "ccc"; + char *abc = strconcat3 (a, b, c); + g_assert_cmpstr (abc, ==, "aaabbbccc"); + free (abc); +} + +static void +test_has_prefix (void) +{ + g_assert_true (has_prefix ("foo", "foo")); + g_assert_true (has_prefix ("foobar", "foo")); + g_assert_false (has_prefix ("foobar", "fool")); + g_assert_false (has_prefix ("foo", "fool")); + g_assert_true (has_prefix ("foo", "")); + g_assert_true (has_prefix ("", "")); + g_assert_false (has_prefix ("", "no")); + g_assert_false (has_prefix ("yes", "no")); +} + +static void +test_has_path_prefix (void) +{ + static const struct + { + const char *str; + const char *prefix; + bool expected; + } tests[] = + { + { "/run/host/usr", "/run/host", true }, + { "/run/host/usr", "/run/host/", true }, + { "/run/host", "/run/host", true }, + { "////run///host////usr", "//run//host", true }, + { "////run///host////usr", "//run//host////", true }, + { "/run/hostage", "/run/host", false }, + /* Any number of leading slashes is ignored, even zero */ + { "foo/bar", "/foo", true }, + { "/foo/bar", "foo", true }, + }; + size_t i; + + for (i = 0; i < N_ELEMENTS (tests); i++) + { + const char *str = tests[i].str; + const char *prefix = tests[i].prefix; + bool expected = tests[i].expected; + + if (expected) + g_test_message ("%s should have path prefix %s", str, prefix); + else + g_test_message ("%s should not have path prefix %s", str, prefix); + + if (expected) + g_assert_true (has_path_prefix (str, prefix)); + else + g_assert_false (has_path_prefix (str, prefix)); + } +} + +static void +test_string_builder (void) +{ + StringBuilder sb = {0}; + + strappend (&sb, "aaa"); + g_assert_cmpstr (sb.str, ==, "aaa"); + strappend (&sb, "bbb"); + g_assert_cmpstr (sb.str, ==, "aaabbb"); + strappendf (&sb, "c%dc%s", 9, "x"); + g_assert_cmpstr (sb.str, ==, "aaabbbc9cx"); + strappend_escape_for_mount_options (&sb, "/path :,\\"); + g_assert_cmpstr (sb.str, ==, "aaabbbc9cx/path \\:\\,\\\\"); + strappend (&sb, "zzz"); + g_assert_cmpstr (sb.str, ==, "aaabbbc9cx/path \\:\\,\\\\zzz"); + + free (sb.str); + sb = (StringBuilder){0}; + + strappend_escape_for_mount_options (&sb, "aaa"); + g_assert_cmpstr (sb.str, ==, "aaa"); + + free (sb.str); + sb = (StringBuilder){0}; + + strappend_escape_for_mount_options (&sb, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + g_assert_cmpstr (sb.str, ==, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + + free (sb.str); +} + +int +main (int argc UNUSED, + char **argv UNUSED) +{ + setvbuf (stdout, NULL, _IONBF, 0); + test_n_elements (); + test_strconcat (); + test_strconcat3 (); + test_has_prefix (); + test_has_path_prefix (); + test_string_builder (); + printf ("1..%u\n", test_number); + return 0; +} diff --git a/codex-rs/vendor/bubblewrap/tests/try-syscall.c b/codex-rs/vendor/bubblewrap/tests/try-syscall.c new file mode 100644 index 0000000000..6f2f112c6e --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/try-syscall.c @@ -0,0 +1,180 @@ +/* + * Copyright 2021 Simon McVittie + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Try one or more system calls that might have been blocked by a + * seccomp filter. Return the last value of errno seen. + * + * In general, we pass a bad fd or pointer to each syscall that will + * accept one, so that it will fail with EBADF or EFAULT without side-effects. + * + * This helper is used for regression tests in both bubblewrap and flatpak. + * Please keep both copies in sync. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_MIPS_SIM) +# if _MIPS_SIM == _ABIO32 +# define MISSING_SYSCALL_BASE 4000 +# elif _MIPS_SIM == _ABI64 +# define MISSING_SYSCALL_BASE 5000 +# elif _MIPS_SIM == _ABIN32 +# define MISSING_SYSCALL_BASE 6000 +# else +# error "Unknown MIPS ABI" +# endif +#endif + +#if defined(__ia64__) +# define MISSING_SYSCALL_BASE 1024 +#endif + +#if defined(__alpha__) +# define MISSING_SYSCALL_BASE 110 +#endif + +#if defined(__x86_64__) && defined(__ILP32__) +# define MISSING_SYSCALL_BASE 0x40000000 +#endif + +/* + * MISSING_SYSCALL_BASE: + * + * Number to add to the syscall numbers of recently-added syscalls + * to get the appropriate syscall for the current ABI. + */ +#ifndef MISSING_SYSCALL_BASE +# define MISSING_SYSCALL_BASE 0 +#endif + +#ifndef __NR_clone3 +# define __NR_clone3 (MISSING_SYSCALL_BASE + 435) +#endif + +/* + * The size of clone3's parameter (as of 2021) + */ +#define SIZEOF_STRUCT_CLONE_ARGS ((size_t) 88) + +/* + * An invalid pointer that will cause syscalls to fail with EFAULT + */ +#define WRONG_POINTER ((char *) 1) + +#ifndef PR_GET_CHILD_SUBREAPER +#define PR_GET_CHILD_SUBREAPER 37 +#endif + +int +main (int argc, char **argv) +{ + int errsv = 0; + int i; + + for (i = 1; i < argc; i++) + { + const char *arg = argv[i]; + + if (strcmp (arg, "print-errno-values") == 0) + { + printf ("EBADF=%d\n", EBADF); + printf ("EFAULT=%d\n", EFAULT); + printf ("ENOENT=%d\n", ENOENT); + printf ("ENOSYS=%d\n", ENOSYS); + printf ("EPERM=%d\n", EPERM); + } + else if (strcmp (arg, "chmod") == 0) + { + /* If not blocked by seccomp, this will fail with EFAULT */ + if (chmod (WRONG_POINTER, 0700) != 0) + { + errsv = errno; + perror (arg); + } + } + else if (strcmp (arg, "chroot") == 0) + { + /* If not blocked by seccomp, this will fail with EFAULT */ + if (chroot (WRONG_POINTER) != 0) + { + errsv = errno; + perror (arg); + } + } + else if (strcmp (arg, "clone3") == 0) + { + /* If not blocked by seccomp, this will fail with EFAULT */ + if (syscall (__NR_clone3, WRONG_POINTER, SIZEOF_STRUCT_CLONE_ARGS) != 0) + { + errsv = errno; + perror (arg); + } + } + else if (strcmp (arg, "ioctl TIOCNOTTY") == 0) + { + /* If not blocked by seccomp, this will fail with EBADF */ + if (ioctl (-1, TIOCNOTTY) != 0) + { + errsv = errno; + perror (arg); + } + } + else if (strcmp (arg, "ioctl TIOCSTI") == 0) + { + /* If not blocked by seccomp, this will fail with EBADF */ + if (ioctl (-1, TIOCSTI, WRONG_POINTER) != 0) + { + errsv = errno; + perror (arg); + } + } +#ifdef __LP64__ + else if (strcmp (arg, "ioctl TIOCSTI CVE-2019-10063") == 0) + { + unsigned long not_TIOCSTI = (0x123UL << 32) | (unsigned long) TIOCSTI; + + /* If not blocked by seccomp, this will fail with EBADF */ + if (syscall (__NR_ioctl, -1, not_TIOCSTI, WRONG_POINTER) != 0) + { + errsv = errno; + perror (arg); + } + } +#endif + else if (strcmp (arg, "listen") == 0) + { + /* If not blocked by seccomp, this will fail with EBADF */ + if (listen (-1, 42) != 0) + { + errsv = errno; + perror (arg); + } + } + else if (strcmp (arg, "prctl") == 0) + { + /* If not blocked by seccomp, this will fail with EFAULT */ + if (prctl (PR_GET_CHILD_SUBREAPER, WRONG_POINTER, 0, 0, 0) != 0) + { + errsv = errno; + perror (arg); + } + } + else + { + fprintf (stderr, "Unsupported syscall \"%s\"\n", arg); + errsv = ENOENT; + } + } + + return errsv; +} diff --git a/codex-rs/vendor/bubblewrap/tests/use-as-subproject/.gitignore b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/.gitignore new file mode 100644 index 0000000000..371a7d97bb --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/.gitignore @@ -0,0 +1,2 @@ +/_build/ +/subprojects/ diff --git a/codex-rs/vendor/bubblewrap/tests/use-as-subproject/README b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/README new file mode 100644 index 0000000000..97d2e88eb1 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/README @@ -0,0 +1,3 @@ +This is a simple example of a project that uses bubblewrap as a +subproject. The intention is that if this project can successfully build +bubblewrap as a subproject, then so could Flatpak. diff --git a/codex-rs/vendor/bubblewrap/tests/use-as-subproject/assert-correct-rpath.py b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/assert-correct-rpath.py new file mode 100755 index 0000000000..10b0947ca6 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/assert-correct-rpath.py @@ -0,0 +1,26 @@ +#!/usr/bin/python3 +# Copyright 2022 Collabora Ltd. +# SPDX-License-Identifier: LGPL-2.0-or-later + +import subprocess +import sys + +if __name__ == '__main__': + completed = subprocess.run( + ['objdump', '-T', '-x', sys.argv[1]], + stdout=subprocess.PIPE, + ) + stdout = completed.stdout + assert stdout is not None + seen_rpath = False + + for line in stdout.splitlines(): + words = line.strip().split() + + if words and words[0] in (b'RPATH', b'RUNPATH'): + print(line.decode(errors='backslashreplace')) + assert len(words) == 2, words + assert words[1] == b'${ORIGIN}/../lib', words + seen_rpath = True + + assert seen_rpath diff --git a/codex-rs/vendor/bubblewrap/tests/use-as-subproject/config.h b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/config.h new file mode 100644 index 0000000000..4a99af4ddb --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/config.h @@ -0,0 +1 @@ +#error Should not use superproject config.h to compile bubblewrap diff --git a/codex-rs/vendor/bubblewrap/tests/use-as-subproject/dummy-config.h.in b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/dummy-config.h.in new file mode 100644 index 0000000000..1d1e56a8c6 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/dummy-config.h.in @@ -0,0 +1 @@ +#error Should not use superproject generated config.h to compile bubblewrap diff --git a/codex-rs/vendor/bubblewrap/tests/use-as-subproject/meson.build b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/meson.build new file mode 100644 index 0000000000..bc4781cd0e --- /dev/null +++ b/codex-rs/vendor/bubblewrap/tests/use-as-subproject/meson.build @@ -0,0 +1,20 @@ +project( + 'use-bubblewrap-as-subproject', + 'c', + version : '0', + meson_version : '>=0.49.0', +) + +configure_file( + output : 'config.h', + input : 'dummy-config.h.in', + configuration : configuration_data(), +) + +subproject( + 'bubblewrap', + default_options : [ + 'install_rpath=${ORIGIN}/../lib', + 'program_prefix=not-flatpak-', + ], +) diff --git a/codex-rs/vendor/bubblewrap/uncrustify.cfg b/codex-rs/vendor/bubblewrap/uncrustify.cfg new file mode 100644 index 0000000000..6d3ad56518 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/uncrustify.cfg @@ -0,0 +1,136 @@ +newlines lf + +input_tab_size 8 +output_tab_size 8 + +string_escape_char 92 +string_escape_char2 0 + +# indenting +indent_columns 2 +indent_with_tabs 0 +indent_align_string True +indent_brace 2 +indent_braces false +indent_braces_no_func True +indent_func_call_param false +indent_func_def_param false +indent_func_proto_param false +indent_switch_case 0 +indent_case_brace 2 +indent_paren_close 1 + +# spacing +sp_arith Add +sp_assign Add +sp_enum_assign Add +sp_bool Add +sp_compare Add +sp_inside_paren Remove +sp_inside_fparens Remove +sp_func_def_paren Force +sp_func_proto_paren Force +sp_paren_paren Remove +sp_balance_nested_parens False +sp_paren_brace Remove +sp_before_square Remove +sp_before_squares Remove +sp_inside_square Remove +sp_before_ptr_star Add +sp_between_ptr_star Remove +sp_after_comma Add +sp_before_comma Remove +sp_after_cast Add +sp_sizeof_paren Add +sp_not Remove +sp_inv Remove +sp_addr Remove +sp_member Remove +sp_deref Remove +sp_sign Remove +sp_incdec Remove +sp_attribute_paren remove +sp_macro Force +sp_func_call_paren Force +sp_func_call_user_paren Remove +set func_call_user _ N_ C_ g_autoptr g_auto +sp_brace_typedef add +sp_cond_colon add +sp_cond_question add +sp_defined_paren remove + +# alignment +align_keep_tabs False +align_with_tabs False +align_on_tabstop False +align_number_left True +align_func_params True +align_var_def_span 0 +align_var_def_amp_style 1 +align_var_def_colon true +align_enum_equ_span 0 +align_var_struct_span 2 +align_var_def_star_style 2 +align_var_def_amp_style 2 +align_typedef_span 2 +align_typedef_func 0 +align_typedef_star_style 2 +align_typedef_amp_style 2 + +# newlines +nl_assign_leave_one_liners True +nl_enum_leave_one_liners False +nl_func_leave_one_liners False +nl_if_leave_one_liners False +nl_end_of_file Add +nl_assign_brace Remove +nl_func_var_def_blk 1 +nl_fcall_brace Add +nl_enum_brace Remove +nl_struct_brace Force +nl_union_brace Force +nl_if_brace Force +nl_brace_else Force +nl_elseif_brace Force +nl_else_brace Add +nl_for_brace Force +nl_while_brace Force +nl_do_brace Force +nl_brace_while Force +nl_switch_brace Force +nl_before_case True +nl_after_case False +nl_func_type_name Force +nl_func_proto_type_name Remove +nl_func_paren Remove +nl_func_decl_start Remove +nl_func_decl_args Force +nl_func_decl_end Remove +nl_fdef_brace Force +nl_after_return False +nl_define_macro False +nl_create_if_one_liner False +nl_create_for_one_liner False +nl_create_while_one_liner False +nl_after_semicolon True +nl_multi_line_cond true + +# mod +# I'd like these to be remove, but that removes brackets in if { if { foo } }, which i dislike +# Not clear what to do about that... +mod_full_brace_for Remove +mod_full_brace_if Remove +mod_full_brace_if_chain True +mod_full_brace_while Remove +mod_full_brace_do Remove +mod_full_brace_nl 3 +mod_paren_on_return Remove + +# line splitting +#code_width = 78 +ls_for_split_full True +ls_func_split_full True + +# positioning +pos_bool Trail +pos_conditional Trail diff --git a/codex-rs/vendor/bubblewrap/uncrustify.sh b/codex-rs/vendor/bubblewrap/uncrustify.sh new file mode 100755 index 0000000000..7c9080b199 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/uncrustify.sh @@ -0,0 +1,2 @@ +#!/bin/sh +uncrustify -c uncrustify.cfg --no-backup `git ls-tree --name-only -r HEAD | grep \\\.[ch]$` diff --git a/codex-rs/vendor/bubblewrap/utils.c b/codex-rs/vendor/bubblewrap/utils.c new file mode 100644 index 0000000000..51875aea9a --- /dev/null +++ b/codex-rs/vendor/bubblewrap/utils.c @@ -0,0 +1,1080 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ +#include "config.h" + +#include "utils.h" +#include +#include +#include +#include +#include +#ifdef HAVE_SELINUX +#include +#endif + +#ifndef HAVE_SELINUX_2_3 +/* libselinux older than 2.3 weren't const-correct */ +#define setexeccon(x) setexeccon ((security_context_t) x) +#define setfscreatecon(x) setfscreatecon ((security_context_t) x) +#define security_check_context(x) security_check_context ((security_context_t) x) +#endif + +bool bwrap_level_prefix = false; + +__attribute__((format(printf, 2, 0))) static void +bwrap_logv (int severity, + const char *format, + va_list args, + const char *detail) +{ + if (bwrap_level_prefix) + fprintf (stderr, "<%d>", severity); + + fprintf (stderr, "bwrap: "); + vfprintf (stderr, format, args); + + if (detail != NULL) + fprintf (stderr, ": %s", detail); + + fprintf (stderr, "\n"); +} + +void +bwrap_log (int severity, + const char *format, ...) +{ + va_list args; + + va_start (args, format); + bwrap_logv (severity, format, args, NULL); + va_end (args); +} + +void +die_with_error (const char *format, ...) +{ + va_list args; + int errsv; + + errsv = errno; + + va_start (args, format); + bwrap_logv (LOG_ERR, format, args, strerror (errsv)); + va_end (args); + + exit (1); +} + +void +die_with_mount_error (const char *format, ...) +{ + va_list args; + int errsv; + + errsv = errno; + + va_start (args, format); + bwrap_logv (LOG_ERR, format, args, mount_strerror (errsv)); + va_end (args); + + exit (1); +} + +void +die (const char *format, ...) +{ + va_list args; + + va_start (args, format); + bwrap_logv (LOG_ERR, format, args, NULL); + va_end (args); + + exit (1); +} + +void +die_unless_label_valid (UNUSED const char *label) +{ +#ifdef HAVE_SELINUX + if (is_selinux_enabled () == 1) + { + if (security_check_context (label) < 0) + die_with_error ("invalid label %s", label); + return; + } +#endif + die ("labeling not supported on this system"); +} + +void +die_oom (void) +{ + fputs ("Out of memory\n", stderr); + exit (1); +} + +/* Fork, return in child, exiting the previous parent */ +void +fork_intermediate_child (void) +{ + int pid = fork (); + if (pid == -1) + die_with_error ("Can't fork for --pidns"); + + /* Parent is an process not needed */ + if (pid != 0) + exit (0); +} + +void * +xmalloc (size_t size) +{ + void *res = malloc (size); + + if (res == NULL) + die_oom (); + return res; +} + +void * +xcalloc (size_t nmemb, size_t size) +{ + void *res = calloc (nmemb, size); + + if (res == NULL) + die_oom (); + return res; +} + +void * +xrealloc (void *ptr, size_t size) +{ + void *res; + + assert (size != 0); + + res = realloc (ptr, size); + + if (res == NULL) + die_oom (); + return res; +} + +char * +xstrdup (const char *str) +{ + char *res; + + assert (str != NULL); + + res = strdup (str); + if (res == NULL) + die_oom (); + + return res; +} + +void +strfreev (char **str_array) +{ + if (str_array) + { + int i; + + for (i = 0; str_array[i] != NULL; i++) + free (str_array[i]); + + free (str_array); + } +} + +/* Compares if str has a specific path prefix. This differs + from a regular prefix in two ways. First of all there may + be multiple slashes separating the path elements, and + secondly, if a prefix is matched that has to be en entire + path element. For instance /a/prefix matches /a/prefix/foo/bar, + but not /a/prefixfoo/bar. */ +bool +has_path_prefix (const char *str, + const char *prefix) +{ + while (true) + { + /* Skip consecutive slashes to reach next path + element */ + while (*str == '/') + str++; + while (*prefix == '/') + prefix++; + + /* No more prefix path elements? Done! */ + if (*prefix == 0) + return true; + + /* Compare path element */ + while (*prefix != 0 && *prefix != '/') + { + if (*str != *prefix) + return false; + str++; + prefix++; + } + + /* Matched prefix path element, + must be entire str path element */ + if (*str != '/' && *str != 0) + return false; + } +} + +bool +path_equal (const char *path1, + const char *path2) +{ + while (true) + { + /* Skip consecutive slashes to reach next path + element */ + while (*path1 == '/') + path1++; + while (*path2 == '/') + path2++; + + /* No more prefix path elements? Done! */ + if (*path1 == 0 || *path2 == 0) + return *path1 == 0 && *path2 == 0; + + /* Compare path element */ + while (*path1 != 0 && *path1 != '/') + { + if (*path1 != *path2) + return false; + path1++; + path2++; + } + + /* Matched path1 path element, must be entire path element */ + if (*path2 != '/' && *path2 != 0) + return false; + } +} + + +bool +has_prefix (const char *str, + const char *prefix) +{ + return strncmp (str, prefix, strlen (prefix)) == 0; +} + +void +xclearenv (void) +{ + if (clearenv () != 0) + die_with_error ("clearenv failed"); +} + +void +xsetenv (const char *name, const char *value, int overwrite) +{ + if (setenv (name, value, overwrite)) + die ("setenv failed"); +} + +void +xunsetenv (const char *name) +{ + if (unsetenv (name)) + die ("unsetenv failed"); +} + +char * +strconcat (const char *s1, + const char *s2) +{ + size_t len = 0; + char *res; + + if (s1) + len += strlen (s1); + if (s2) + len += strlen (s2); + + res = xmalloc (len + 1); + *res = 0; + if (s1) + strcat (res, s1); + if (s2) + strcat (res, s2); + + return res; +} + +char * +strconcat3 (const char *s1, + const char *s2, + const char *s3) +{ + size_t len = 0; + char *res; + + if (s1) + len += strlen (s1); + if (s2) + len += strlen (s2); + if (s3) + len += strlen (s3); + + res = xmalloc (len + 1); + *res = 0; + if (s1) + strcat (res, s1); + if (s2) + strcat (res, s2); + if (s3) + strcat (res, s3); + + return res; +} + +char * +xasprintf (const char *format, + ...) +{ + char *buffer = NULL; + va_list args; + + va_start (args, format); + if (vasprintf (&buffer, format, args) == -1) + die_oom (); + va_end (args); + + return buffer; +} + +int +fdwalk (int proc_fd, int (*cb)(void *data, + int fd), void *data) +{ + int open_max; + int fd; + int dfd; + int res = 0; + DIR *d; + + dfd = TEMP_FAILURE_RETRY (openat (proc_fd, "self/fd", O_DIRECTORY | O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOCTTY)); + if (dfd == -1) + return res; + + if ((d = fdopendir (dfd))) + { + struct dirent *de; + + while ((de = readdir (d))) + { + long l; + char *e = NULL; + + if (de->d_name[0] == '.') + continue; + + errno = 0; + l = strtol (de->d_name, &e, 10); + if (errno != 0 || !e || *e) + continue; + + fd = (int) l; + + if ((long) fd != l) + continue; + + if (fd == dirfd (d)) + continue; + + if ((res = cb (data, fd)) != 0) + break; + } + + closedir (d); + return res; + } + + open_max = sysconf (_SC_OPEN_MAX); + + for (fd = 0; fd < open_max; fd++) + if ((res = cb (data, fd)) != 0) + break; + + return res; +} + +/* Sets errno on error (!= 0), ENOSPC on short write */ +int +write_to_fd (int fd, + const char *content, + ssize_t len) +{ + ssize_t res; + + while (len > 0) + { + res = write (fd, content, len); + if (res < 0 && errno == EINTR) + continue; + if (res <= 0) + { + if (res == 0) /* Unexpected short write, should not happen when writing to a file */ + errno = ENOSPC; + return -1; + } + len -= res; + content += res; + } + + return 0; +} + +/* Sets errno on error (!= 0), ENOSPC on short write */ +int +write_file_at (int dfd, + const char *path, + const char *content) +{ + int fd; + bool res; + int errsv; + + fd = TEMP_FAILURE_RETRY (openat (dfd, path, O_RDWR | O_CLOEXEC, 0)); + if (fd == -1) + return -1; + + res = 0; + if (content) + res = write_to_fd (fd, content, strlen (content)); + + errsv = errno; + close (fd); + errno = errsv; + + return res; +} + +/* Sets errno on error (!= 0), ENOSPC on short write */ +int +create_file (const char *path, + mode_t mode, + const char *content) +{ + int fd; + int res; + int errsv; + + fd = TEMP_FAILURE_RETRY (creat (path, mode)); + if (fd == -1) + return -1; + + res = 0; + if (content) + res = write_to_fd (fd, content, strlen (content)); + + errsv = errno; + close (fd); + errno = errsv; + + return res; +} + +int +ensure_file (const char *path, + mode_t mode) +{ + struct stat buf; + + /* We check this ahead of time, otherwise + the create file will fail in the read-only + case with EROFS instead of EEXIST. + + We're trying to set up a mount point for a non-directory, so any + non-directory, non-symlink is acceptable - it doesn't necessarily + have to be a regular file. */ + if (stat (path, &buf) == 0 && + !S_ISDIR (buf.st_mode) && + !S_ISLNK (buf.st_mode)) + return 0; + + if (create_file (path, mode, NULL) != 0 && errno != EEXIST) + return -1; + + return 0; +} + + +#define BUFSIZE 8192 +/* Sets errno on error (!= 0), ENOSPC on short write */ +int +copy_file_data (int sfd, + int dfd) +{ + char buffer[BUFSIZE]; + ssize_t bytes_read; + + while (true) + { + bytes_read = read (sfd, buffer, BUFSIZE); + if (bytes_read == -1) + { + if (errno == EINTR) + continue; + + return -1; + } + + if (bytes_read == 0) + break; + + if (write_to_fd (dfd, buffer, bytes_read) != 0) + return -1; + } + + return 0; +} + +/* Sets errno on error (!= 0), ENOSPC on short write */ +int +copy_file (const char *src_path, + const char *dst_path, + mode_t mode) +{ + int sfd; + int dfd; + int res; + int errsv; + + sfd = TEMP_FAILURE_RETRY (open (src_path, O_CLOEXEC | O_RDONLY)); + if (sfd == -1) + return -1; + + dfd = TEMP_FAILURE_RETRY (creat (dst_path, mode)); + if (dfd == -1) + { + errsv = errno; + close (sfd); + errno = errsv; + return -1; + } + + res = copy_file_data (sfd, dfd); + + errsv = errno; + close (sfd); + close (dfd); + errno = errsv; + + return res; +} + +/* Sets errno on error (== NULL), + * Always ensures terminating zero */ +char * +load_file_data (int fd, + size_t *size) +{ + cleanup_free char *data = NULL; + ssize_t data_read; + ssize_t data_len; + ssize_t res; + + data_read = 0; + data_len = 4080; + data = xmalloc (data_len); + + do + { + if (data_len == data_read + 1) + { + if (data_len > SSIZE_MAX / 2) + { + errno = EFBIG; + return NULL; + } + + data_len *= 2; + data = xrealloc (data, data_len); + } + + do + res = read (fd, data + data_read, data_len - data_read - 1); + while (res < 0 && errno == EINTR); + + if (res < 0) + return NULL; + + data_read += res; + } + while (res > 0); + + data[data_read] = 0; + + if (size) + *size = (size_t) data_read; + + return steal_pointer (&data); +} + +/* Sets errno on error (== NULL), + * Always ensures terminating zero */ +char * +load_file_at (int dfd, + const char *path) +{ + int fd; + char *data; + int errsv; + + fd = TEMP_FAILURE_RETRY (openat (dfd, path, O_CLOEXEC | O_RDONLY)); + if (fd == -1) + return NULL; + + data = load_file_data (fd, NULL); + + errsv = errno; + close (fd); + errno = errsv; + + return data; +} + +/* Sets errno on error (< 0) */ +int +get_file_mode (const char *pathname) +{ + struct stat buf; + + if (stat (pathname, &buf) != 0) + return -1; + + return buf.st_mode & S_IFMT; +} + +int +ensure_dir (const char *path, + mode_t mode) +{ + struct stat buf; + + /* We check this ahead of time, otherwise + the mkdir call can fail in the read-only + case with EROFS instead of EEXIST on some + filesystems (such as NFS) */ + if (stat (path, &buf) == 0) + { + if (!S_ISDIR (buf.st_mode)) + { + errno = ENOTDIR; + return -1; + } + + return 0; + } + + if (mkdir (path, mode) == -1 && errno != EEXIST) + return -1; + + return 0; +} + + +/* Sets errno on error (!= 0) */ +int +mkdir_with_parents (const char *pathname, + mode_t mode, + bool create_last) +{ + cleanup_free char *fn = NULL; + char *p; + + if (pathname == NULL || *pathname == '\0') + { + errno = EINVAL; + return -1; + } + + fn = xstrdup (pathname); + + p = fn; + while (*p == '/') + p++; + + do + { + while (*p && *p != '/') + p++; + + if (!*p) + p = NULL; + else + *p = '\0'; + + if (!create_last && p == NULL) + break; + + if (ensure_dir (fn, mode) != 0) + return -1; + + if (p) + { + *p++ = '/'; + while (*p && *p == '/') + p++; + } + } + while (p); + + return 0; +} + +/* Send an ucred with current pid/uid/gid over a socket, it can be + read back with read_pid_from_socket(), and then the kernel has + translated it between namespaces as needed. */ +void +send_pid_on_socket (int sockfd) +{ + char buf[1] = { 0 }; + struct msghdr msg = {}; + struct iovec iov = { buf, sizeof (buf) }; + const ssize_t control_len_snd = CMSG_SPACE(sizeof(struct ucred)); + _Alignas(struct cmsghdr) char control_buf_snd[control_len_snd]; + struct cmsghdr *cmsg; + struct ucred cred; + + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control_buf_snd; + msg.msg_controllen = control_len_snd; + + cmsg = CMSG_FIRSTHDR(&msg); + cmsg->cmsg_level = SOL_SOCKET; + cmsg->cmsg_type = SCM_CREDENTIALS; + cmsg->cmsg_len = CMSG_LEN(sizeof(struct ucred)); + + cred.pid = getpid (); + cred.uid = geteuid (); + cred.gid = getegid (); + memcpy (CMSG_DATA (cmsg), &cred, sizeof (cred)); + + if (TEMP_FAILURE_RETRY (sendmsg (sockfd, &msg, 0)) < 0) + die_with_error ("Can't send pid"); +} + +void +create_pid_socketpair (int sockets[2]) +{ + int enable = 1; + + if (socketpair (AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0, sockets) != 0) + die_with_error ("Can't create intermediate pids socket"); + + if (setsockopt (sockets[0], SOL_SOCKET, SO_PASSCRED, &enable, sizeof (enable)) < 0) + die_with_error ("Can't set SO_PASSCRED"); +} + +int +read_pid_from_socket (int sockfd) +{ + char recv_buf[1] = { 0 }; + struct msghdr msg = {}; + struct iovec iov = { recv_buf, sizeof (recv_buf) }; + const ssize_t control_len_rcv = CMSG_SPACE(sizeof(struct ucred)); + _Alignas(struct cmsghdr) char control_buf_rcv[control_len_rcv]; + struct cmsghdr* cmsg; + + msg.msg_iov = &iov; + msg.msg_iovlen = 1; + msg.msg_control = control_buf_rcv; + msg.msg_controllen = control_len_rcv; + + if (TEMP_FAILURE_RETRY (recvmsg (sockfd, &msg, 0)) < 0) + die_with_error ("Can't read pid from socket"); + + if (msg.msg_controllen <= 0) + die ("Unexpected short read from pid socket"); + + for (cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) + { + const unsigned payload_len = cmsg->cmsg_len - CMSG_LEN(0); + if (cmsg->cmsg_level == SOL_SOCKET && + cmsg->cmsg_type == SCM_CREDENTIALS && + payload_len == sizeof(struct ucred)) + { + struct ucred cred; + + memcpy (&cred, CMSG_DATA (cmsg), sizeof (cred)); + return cred.pid; + } + } + die ("No pid returned on socket"); +} + +/* Sets errno on error (== NULL), + * Always ensures terminating zero */ +char * +readlink_malloc (const char *pathname) +{ + size_t size = 50; + ssize_t n; + cleanup_free char *value = NULL; + + do + { + if (size > SIZE_MAX / 2) + die ("Symbolic link target pathname too long"); + size *= 2; + value = xrealloc (value, size); + n = readlink (pathname, value, size - 1); + if (n < 0) + return NULL; + } + while (size - 2 < (size_t)n); + + value[n] = 0; + return steal_pointer (&value); +} + +char * +get_oldroot_path (const char *path) +{ + while (*path == '/') + path++; + return strconcat ("/oldroot/", path); +} + +char * +get_newroot_path (const char *path) +{ + while (*path == '/') + path++; + return strconcat ("/newroot/", path); +} + +int +raw_clone (unsigned long flags, + void *child_stack) +{ +#if defined(__s390__) || defined(__CRIS__) + /* On s390 and cris the order of the first and second arguments + * of the raw clone() system call is reversed. */ + return (int) syscall (__NR_clone, child_stack, flags); +#else + return (int) syscall (__NR_clone, flags, child_stack); +#endif +} + +int +pivot_root (const char * new_root, const char * put_old) +{ +#ifdef __NR_pivot_root + return syscall (__NR_pivot_root, new_root, put_old); +#else + errno = ENOSYS; + return -1; +#endif +} + +char * +label_mount (const char *opt, UNUSED const char *mount_label) +{ +#ifdef HAVE_SELINUX + if (mount_label) + { + if (opt) + return xasprintf ("%s,context=\"%s\"", opt, mount_label); + else + return xasprintf ("context=\"%s\"", mount_label); + } +#endif + if (opt) + return xstrdup (opt); + return NULL; +} + +int +label_create_file (UNUSED const char *file_label) +{ +#ifdef HAVE_SELINUX + if (is_selinux_enabled () > 0 && file_label) + return setfscreatecon (file_label); +#endif + return 0; +} + +int +label_exec (UNUSED const char *exec_label) +{ +#ifdef HAVE_SELINUX + if (is_selinux_enabled () > 0 && exec_label) + return setexeccon (exec_label); +#endif + return 0; +} + +/* + * Like strerror(), but specialized for a failed mount(2) call. + */ +const char * +mount_strerror (int errsv) +{ + switch (errsv) + { + case ENOSPC: + /* "No space left on device" misleads users into thinking there + * is some sort of disk-space problem, but mount(2) uses that + * errno value to mean something more like "limit exceeded". */ + return ("Limit exceeded (ENOSPC). " + "(Hint: Check that /proc/sys/fs/mount-max is sufficient, " + "typically 100000)"); + + default: + return strerror (errsv); + } +} + +/* + * Return a + b if it would not overflow. + * Die with an "out of memory" error if it would. + */ +static size_t +xadd (size_t a, size_t b) +{ +#if defined(__GNUC__) && __GNUC__ >= 5 + size_t result; + if (__builtin_add_overflow (a, b, &result)) + die_oom (); + return result; +#else + if (a > SIZE_MAX - b) + die_oom (); + + return a + b; +#endif +} + +/* + * Return a * b if it would not overflow. + * Die with an "out of memory" error if it would. + */ +static size_t +xmul (size_t a, size_t b) +{ +#if defined(__GNUC__) && __GNUC__ >= 5 + size_t result; + if (__builtin_mul_overflow (a, b, &result)) + die_oom (); + return result; +#else + if (b != 0 && a > SIZE_MAX / b) + die_oom (); + + return a * b; +#endif +} + +void +strappend (StringBuilder *dest, const char *src) +{ + size_t len = strlen (src); + size_t new_offset = xadd (dest->offset, len); + + if (new_offset >= dest->size) + { + dest->size = xmul (xadd (new_offset, 1), 2); + dest->str = xrealloc (dest->str, dest->size); + } + + /* Preserves the invariant that dest->str is always null-terminated, even + * though the offset is positioned at the null byte for the next write. + */ + strncpy (dest->str + dest->offset, src, len + 1); + dest->offset = new_offset; +} + +__attribute__((format (printf, 2, 3))) +void +strappendf (StringBuilder *dest, const char *fmt, ...) +{ + va_list args; + int len; + size_t new_offset; + + va_start (args, fmt); + len = vsnprintf (dest->str + dest->offset, dest->size - dest->offset, fmt, args); + va_end (args); + if (len < 0) + die_with_error ("vsnprintf"); + new_offset = xadd (dest->offset, len); + if (new_offset >= dest->size) + { + dest->size = xmul (xadd (new_offset, 1), 2); + dest->str = xrealloc (dest->str, dest->size); + va_start (args, fmt); + len = vsnprintf (dest->str + dest->offset, dest->size - dest->offset, fmt, args); + va_end (args); + if (len < 0) + die_with_error ("vsnprintf"); + } + + dest->offset = new_offset; +} + +void +strappend_escape_for_mount_options (StringBuilder *dest, const char *src) +{ + bool unescaped = true; + + for (;;) + { + if (dest->offset == dest->size) + { + dest->size = MAX (64, xmul (dest->size, 2)); + dest->str = xrealloc (dest->str, dest->size); + } + switch (*src) + { + case '\0': + dest->str[dest->offset] = '\0'; + return; + + case '\\': + case ',': + case ':': + if (unescaped) + { + dest->str[dest->offset++] = '\\'; + unescaped = false; + continue; + } + /* else fall through */ + + default: + dest->str[dest->offset++] = *src; + unescaped = true; + break; + } + src++; + } +} diff --git a/codex-rs/vendor/bubblewrap/utils.h b/codex-rs/vendor/bubblewrap/utils.h new file mode 100644 index 0000000000..079fe7c936 --- /dev/null +++ b/codex-rs/vendor/bubblewrap/utils.h @@ -0,0 +1,217 @@ +/* bubblewrap + * Copyright (C) 2016 Alexander Larsson + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library. If not, see . + * + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if 0 +#define debug(...) bwrap_log (LOG_DEBUG, __VA_ARGS__) +#else +#define debug(...) +#endif + +#define UNUSED __attribute__((__unused__)) + +#define N_ELEMENTS(arr) (sizeof (arr) / sizeof ((arr)[0])) + +#ifndef TEMP_FAILURE_RETRY +#define TEMP_FAILURE_RETRY(expression) \ + (__extension__ \ + ({ long int __result; \ + do __result = (long int) (expression); \ + while (__result == -1L && errno == EINTR); \ + __result; })) +#endif + +#define PIPE_READ_END 0 +#define PIPE_WRITE_END 1 + +#ifndef PR_SET_CHILD_SUBREAPER +#define PR_SET_CHILD_SUBREAPER 36 +#endif + +extern bool bwrap_level_prefix; + +void bwrap_log (int severity, + const char *format, + ...) __attribute__((format (printf, 2, 3))); +#define warn(...) bwrap_log (LOG_WARNING, __VA_ARGS__) + +void die_with_error (const char *format, + ...) __attribute__((__noreturn__)) __attribute__((format (printf, 1, 2))); +void die_with_mount_error (const char *format, + ...) __attribute__((__noreturn__)) __attribute__((format (printf, 1, 2))); +void die (const char *format, + ...) __attribute__((__noreturn__)) __attribute__((format (printf, 1, 2))); +void die_oom (void) __attribute__((__noreturn__)); +void die_unless_label_valid (const char *label); + +void fork_intermediate_child (void); + +void *xmalloc (size_t size); +void *xcalloc (size_t nmemb, size_t size); +void *xrealloc (void *ptr, + size_t size); +char *xstrdup (const char *str); +void strfreev (char **str_array); +void xclearenv (void); +void xsetenv (const char *name, + const char *value, + int overwrite); +void xunsetenv (const char *name); +char *strconcat (const char *s1, + const char *s2); +char *strconcat3 (const char *s1, + const char *s2, + const char *s3); +char * xasprintf (const char *format, + ...) __attribute__((format (printf, 1, 2))); +bool has_prefix (const char *str, + const char *prefix); +bool has_path_prefix (const char *str, + const char *prefix); +bool path_equal (const char *path1, + const char *path2); +int fdwalk (int proc_fd, + int (*cb)(void *data, + int fd), + void *data); +char *load_file_data (int fd, + size_t *size); +char *load_file_at (int dirfd, + const char *path); +int write_file_at (int dirfd, + const char *path, + const char *content); +int write_to_fd (int fd, + const char *content, + ssize_t len); +int copy_file_data (int sfd, + int dfd); +int copy_file (const char *src_path, + const char *dst_path, + mode_t mode); +int create_file (const char *path, + mode_t mode, + const char *content); +int ensure_file (const char *path, + mode_t mode); +int ensure_dir (const char *path, + mode_t mode); +int get_file_mode (const char *pathname); +int mkdir_with_parents (const char *pathname, + mode_t mode, + bool create_last); +void create_pid_socketpair (int sockets[2]); +void send_pid_on_socket (int socket); +int read_pid_from_socket (int socket); +char *get_oldroot_path (const char *path); +char *get_newroot_path (const char *path); +char *readlink_malloc (const char *pathname); + +/* syscall wrappers */ +int raw_clone (unsigned long flags, + void *child_stack); +int pivot_root (const char *new_root, + const char *put_old); +char *label_mount (const char *opt, + const char *mount_label); +int label_exec (const char *exec_label); +int label_create_file (const char *file_label); + +const char *mount_strerror (int errsv); + +static inline void +cleanup_freep (void *p) +{ + void **pp = (void **) p; + + if (*pp) + free (*pp); +} + +static inline void +cleanup_strvp (void *p) +{ + void **pp = (void **) p; + + strfreev (*pp); +} + +static inline void +cleanup_fdp (int *fdp) +{ + int fd; + + assert (fdp); + + fd = *fdp; + if (fd != -1) + (void) close (fd); +} + +#define cleanup_free __attribute__((cleanup (cleanup_freep))) +#define cleanup_fd __attribute__((cleanup (cleanup_fdp))) +#define cleanup_strv __attribute__((cleanup (cleanup_strvp))) + +static inline void * +steal_pointer (void *pp) +{ + void **ptr = (void **) pp; + void *ref; + + ref = *ptr; + *ptr = NULL; + + return ref; +} + +/* type safety */ +#define steal_pointer(pp) \ + (0 ? (*(pp)) : (steal_pointer) (pp)) + +typedef struct _StringBuilder StringBuilder; + +struct _StringBuilder +{ + char * str; + size_t size; + size_t offset; +}; + +void strappend (StringBuilder *dest, + const char *src); +void strappendf (StringBuilder *dest, + const char *fmt, + ...); +void strappend_escape_for_mount_options (StringBuilder *dest, + const char *src); From 9257d8451cf6b2deba871ed560ca7fb28ce069ef Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 3 Feb 2026 00:14:39 -0800 Subject: [PATCH 06/13] feat(secrets): add codex-secrets crate (#10142) ## Summary This introduces the first working foundation for Codex managed secrets: a small Rust crate that can securely store and retrieve secrets locally. Concretely, it adds a `codex-secrets` crate that: - encrypts a local secrets file using `age` - generates a high-entropy encryption key - stores that key in the OS keyring ## What this enables - A secure local persistence model for secrets - A clean, isolated place for future provider backends - A clear boundary: Codex can become a credential broker without putting plaintext secrets in config files ## Implementation details - New crate: `codex-rs/secrets/` - Encryption: `age` with scrypt recipient/identity - Key generation: `OsRng` (32 random bytes) - Key storage: OS keyring via `codex-keyring-store` ## Testing - `cd codex-rs && just fmt` - `cd codex-rs && cargo test -p codex-secrets` --- codex-rs/Cargo.lock | 542 ++++++++++++++++++++++++++++++++-- codex-rs/Cargo.toml | 5 +- codex-rs/secrets/Cargo.toml | 25 ++ codex-rs/secrets/src/lib.rs | 243 +++++++++++++++ codex-rs/secrets/src/local.rs | 411 ++++++++++++++++++++++++++ 5 files changed, 1197 insertions(+), 29 deletions(-) create mode 100644 codex-rs/secrets/Cargo.toml create mode 100644 codex-rs/secrets/src/lib.rs create mode 100644 codex-rs/secrets/src/local.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 40a95cc333..5fc804f2f2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -175,6 +175,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -186,6 +196,49 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "age" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf640be7658959746f1f0f2faab798f6098a9436a8e18e148d18bc9875e13c4b" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hmac", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom 7.1.3", + "pin-project", + "rand 0.8.5", + "rust-embed", + "scrypt", + "sha2", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "io_tee", + "nom 7.1.3", + "rand 0.8.5", + "secrecy", + "sha2", +] + [[package]] name = "ahash" version = "0.8.12" @@ -330,7 +383,7 @@ name = "app_test_support" version = "0.0.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "codex-app-server-protocol", "codex-core", @@ -685,6 +738,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -697,6 +756,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "beef" version = "0.5.2" @@ -716,7 +790,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.1", "shlex", "syn 2.0.114", ] @@ -919,6 +993,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chardetng" version = "0.1.17" @@ -958,6 +1056,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1090,7 +1189,7 @@ dependencies = [ "app_test_support", "async-trait", "axum", - "base64", + "base64 0.22.1", "chrono", "codex-app-server-protocol", "codex-arg0", @@ -1309,7 +1408,7 @@ name = "codex-cloud-requirements" version = "0.0.0" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "codex-backend-client", "codex-core", "codex-otel", @@ -1328,7 +1427,7 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", - "base64", + "base64 0.22.1", "chrono", "clap", "codex-cloud-tasks-client", @@ -1391,7 +1490,7 @@ dependencies = [ "assert_matches", "async-channel", "async-trait", - "base64", + "base64 0.22.1", "chardetng", "chrono", "clap", @@ -1689,7 +1788,7 @@ name = "codex-login" version = "0.0.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "codex-app-server-protocol", "codex-core", @@ -1892,6 +1991,25 @@ dependencies = [ "which", ] +[[package]] +name = "codex-secrets" +version = "0.0.0" +dependencies = [ + "age", + "anyhow", + "base64 0.22.1", + "codex-keyring-store", + "keyring", + "pretty_assertions", + "rand 0.9.2", + "schemars 0.8.22", + "serde", + "serde_json", + "sha2", + "tempfile", + "tracing", +] + [[package]] name = "codex-state" version = "0.0.0" @@ -1933,7 +2051,7 @@ dependencies = [ "anyhow", "arboard", "assert_matches", - "base64", + "base64 0.22.1", "chrono", "clap", "codex-ansi-escape", @@ -2049,7 +2167,7 @@ dependencies = [ name = "codex-utils-image" version = "0.0.0" dependencies = [ - "base64", + "base64 0.22.1", "codex-utils-cache", "image", "tempfile", @@ -2102,7 +2220,7 @@ name = "codex-windows-sandbox" version = "0.0.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "chrono", "codex-protocol", "codex-utils-absolute-path", @@ -2253,6 +2371,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -2285,7 +2412,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "base64", + "base64 0.22.1", "codex-core", "codex-protocol", "codex-utils-absolute-path", @@ -2475,6 +2602,32 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling" version = "0.21.3" @@ -3124,6 +3277,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -3135,6 +3294,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -3195,6 +3363,50 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "flume" version = "0.11.1" @@ -3847,7 +4059,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3867,6 +4079,72 @@ dependencies = [ "windows-registry", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.114", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -4194,6 +4472,25 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + [[package]] name = "inventory" version = "0.3.21" @@ -4203,6 +4500,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipconfig" version = "0.3.2" @@ -5068,7 +5371,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -5278,6 +5581,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.75" @@ -5403,7 +5712,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" dependencies = [ - "base64", + "base64 0.22.1", "const-hex", "opentelemetry", "opentelemetry_sdk", @@ -5554,6 +5863,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5696,6 +6015,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5813,6 +6143,28 @@ dependencies = [ "toml_edit 0.23.10+spec-1.0.0", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -5943,7 +6295,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.6.2", "thiserror 2.0.18", @@ -5963,7 +6315,7 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", @@ -6109,7 +6461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" dependencies = [ "ahash", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "chrono", "const_format", @@ -6189,7 +6541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" dependencies = [ "ahash", - "base64", + "base64 0.22.1", "chrono", "const_format", "httpdate", @@ -6593,7 +6945,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-channel", @@ -6663,7 +7015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528d42f8176e6e5e71ea69182b17d1d0a19a6b3b894b564678b74cd7cab13cfa" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "chrono", "futures", @@ -6729,12 +7081,52 @@ name = "runfiles" version = "0.1.0" source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81" +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.114", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -6858,6 +7250,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7001,6 +7402,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + [[package]] name = "sdd" version = "3.0.10" @@ -7016,6 +7428,15 @@ dependencies = [ "libc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -7071,6 +7492,21 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -7315,7 +7751,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64", + "base64 0.22.1", "chrono", "hex", "indexmap 1.9.3", @@ -7603,7 +8039,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -7681,7 +8117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "byteorder", "bytes", @@ -7726,7 +8162,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "byteorder", "chrono", @@ -8546,7 +8982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "http 1.4.0", "http-body", @@ -8849,6 +9285,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.1", +] + [[package]] name = "typenum" version = "1.19.0" @@ -8881,6 +9326,25 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicase" version = "2.9.0" @@ -8955,6 +9419,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -8973,7 +9447,7 @@ version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" dependencies = [ - "base64", + "base64 0.22.1", "der", "log", "native-tls", @@ -8990,7 +9464,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" dependencies = [ - "base64", + "base64 0.22.1", "http 1.4.0", "httparse", "log", @@ -9956,7 +10430,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http 1.4.0", @@ -10019,6 +10493,18 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "xdg-home" version = "1.3.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2ea3624d38..22f53644f8 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "cli", "common", "core", + "secrets", "exec", "exec-server", "execpolicy", @@ -79,6 +80,7 @@ codex-cli = { path = "cli"} codex-client = { path = "codex-client" } codex-common = { path = "common" } codex-core = { path = "core" } +codex-secrets = { path = "secrets" } codex-exec = { path = "exec" } codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -114,6 +116,7 @@ exec_server_test_support = { path = "exec-server/tests/common" } mcp_test_support = { path = "mcp-server/tests/common" } # External +age = "0.11.1" allocative = "0.3.3" ansi-to-tui = "7.0.0" anyhow = "1" @@ -291,7 +294,7 @@ unwrap_used = "deny" # cargo-shear cannot see the platform-specific openssl-sys usage, so we # silence the false positive here instead of deleting a real dependency. [workspace.metadata.cargo-shear] -ignored = ["icu_provider", "openssl-sys", "codex-utils-readiness"] +ignored = ["icu_provider", "openssl-sys", "codex-utils-readiness", "codex-secrets"] [profile.release] lto = "fat" diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml new file mode 100644 index 0000000000..de45af50a1 --- /dev/null +++ b/codex-rs/secrets/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "codex-secrets" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +age = { workspace = true } +anyhow = { workspace = true } +base64 = { workspace = true } +codex-keyring-store = { workspace = true } +rand = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +keyring = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs new file mode 100644 index 0000000000..a45860d8b5 --- /dev/null +++ b/codex-rs/secrets/src/lib.rs @@ -0,0 +1,243 @@ +use std::fmt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use codex_keyring_store::DefaultKeyringStore; +use codex_keyring_store::KeyringStore; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; + +mod local; + +pub use local::LocalSecretsBackend; + +const KEYRING_SERVICE: &str = "codex"; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SecretName(String); + +impl SecretName { + pub fn new(raw: &str) -> Result { + let trimmed = raw.trim(); + anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty"); + anyhow::ensure!( + trimmed + .chars() + .all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'), + "secret name must contain only A-Z, 0-9, or _" + ); + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl fmt::Display for SecretName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SecretScope { + Global, + Environment(String), +} + +impl SecretScope { + pub fn environment(environment_id: impl Into) -> Result { + let env_id = environment_id.into(); + let trimmed = env_id.trim(); + anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty"); + Ok(Self::Environment(trimmed.to_string())) + } + + pub fn canonical_key(&self, name: &SecretName) -> String { + // Stable, env-safe identifier used as the on-disk map key. + match self { + Self::Global => format!("global/{}", name.as_str()), + Self::Environment(environment_id) => { + format!("env/{environment_id}/{}", name.as_str()) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SecretListEntry { + pub scope: SecretScope, + pub name: SecretName, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum SecretsBackendKind { + #[default] + Local, +} + +pub trait SecretsBackend: Send + Sync { + fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>; + fn get(&self, scope: &SecretScope, name: &SecretName) -> Result>; + fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result; + fn list(&self, scope_filter: Option<&SecretScope>) -> Result>; +} + +#[derive(Clone)] +pub struct SecretsManager { + backend: Arc, +} + +impl SecretsManager { + pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self { + let backend: Arc = match backend_kind { + SecretsBackendKind::Local => { + let keyring_store: Arc = Arc::new(DefaultKeyringStore); + Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)) + } + }; + Self { backend } + } + + pub fn new_with_keyring_store( + codex_home: PathBuf, + backend_kind: SecretsBackendKind, + keyring_store: Arc, + ) -> Self { + let backend: Arc = match backend_kind { + SecretsBackendKind::Local => { + Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)) + } + }; + Self { backend } + } + + pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { + self.backend.set(scope, name, value) + } + + pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result> { + self.backend.get(scope, name) + } + + pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { + self.backend.delete(scope, name) + } + + pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { + self.backend.list(scope_filter) + } +} + +pub fn environment_id_from_cwd(cwd: &Path) -> String { + if let Some(repo_root) = get_git_repo_root(cwd) + && let Some(name) = repo_root.file_name() + { + let name = name.to_string_lossy().trim().to_string(); + if !name.is_empty() { + return name; + } + } + + let canonical = cwd + .canonicalize() + .unwrap_or_else(|_| cwd.to_path_buf()) + .to_string_lossy() + .into_owned(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let short = hex.get(..12).unwrap_or(hex.as_str()); + format!("cwd-{short}") +} + +fn get_git_repo_root(base_dir: &Path) -> Option { + let mut dir = base_dir.to_path_buf(); + + loop { + if dir.join(".git").exists() { + return Some(dir); + } + + if !dir.pop() { + break; + } + } + + None +} + +pub(crate) fn compute_keyring_account(codex_home: &Path) -> String { + let canonical = codex_home + .canonicalize() + .unwrap_or_else(|_| codex_home.to_path_buf()) + .to_string_lossy() + .into_owned(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let short = hex.get(..16).unwrap_or(hex.as_str()); + format!("secrets|{short}") +} + +pub(crate) fn keyring_service() -> &'static str { + KEYRING_SERVICE +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_keyring_store::tests::MockKeyringStore; + use pretty_assertions::assert_eq; + + #[test] + fn environment_id_fallback_has_cwd_prefix() { + let dir = tempfile::tempdir().expect("tempdir"); + let env_id = environment_id_from_cwd(dir.path()); + let canonical = dir + .path() + .canonicalize() + .expect("tempdir canonical path should exist") + .to_string_lossy() + .into_owned(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let short = hex.get(..12).expect("digest has at least 12 chars"); + assert_eq!(env_id, format!("cwd-{short}")); + } + + #[test] + fn manager_round_trips_local_backend() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let keyring = Arc::new(MockKeyringStore::default()); + let manager = SecretsManager::new_with_keyring_store( + codex_home.path().to_path_buf(), + SecretsBackendKind::Local, + keyring, + ); + let scope = SecretScope::Global; + let name = SecretName::new("GITHUB_TOKEN")?; + + manager.set(&scope, &name, "token-1")?; + assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string())); + + let listed = manager.list(None)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].name, name); + + assert!(manager.delete(&scope, &name)?); + assert_eq!(manager.get(&scope, &name)?, None); + Ok(()) + } +} diff --git a/codex-rs/secrets/src/local.rs b/codex-rs/secrets/src/local.rs new file mode 100644 index 0000000000..127fc84c56 --- /dev/null +++ b/codex-rs/secrets/src/local.rs @@ -0,0 +1,411 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::sync::atomic::compiler_fence; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use age::decrypt; +use age::encrypt; +use age::scrypt::Identity as ScryptIdentity; +use age::scrypt::Recipient as ScryptRecipient; +use age::secrecy::ExposeSecret; +use age::secrecy::SecretString; +use anyhow::Context; +use anyhow::Result; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use codex_keyring_store::KeyringStore; +use rand::TryRngCore; +use rand::rngs::OsRng; +use serde::Deserialize; +use serde::Serialize; +use tracing::warn; + +use super::SecretListEntry; +use super::SecretName; +use super::SecretScope; +use super::SecretsBackend; +use super::compute_keyring_account; +use super::keyring_service; + +const SECRETS_VERSION: u8 = 1; +const LOCAL_SECRETS_FILENAME: &str = "local.age"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct SecretsFile { + version: u8, + secrets: BTreeMap, +} + +impl SecretsFile { + fn new_empty() -> Self { + Self { + version: SECRETS_VERSION, + secrets: BTreeMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct LocalSecretsBackend { + codex_home: PathBuf, + keyring_store: Arc, +} + +impl LocalSecretsBackend { + pub fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + Self { + codex_home, + keyring_store, + } + } + + pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { + anyhow::ensure!(!value.is_empty(), "secret value must not be empty"); + let canonical_key = scope.canonical_key(name); + let mut file = self.load_file()?; + file.secrets.insert(canonical_key, value.to_string()); + self.save_file(&file) + } + + pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result> { + let canonical_key = scope.canonical_key(name); + let file = self.load_file()?; + Ok(file.secrets.get(&canonical_key).cloned()) + } + + pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { + let canonical_key = scope.canonical_key(name); + let mut file = self.load_file()?; + let removed = file.secrets.remove(&canonical_key).is_some(); + if removed { + self.save_file(&file)?; + } + Ok(removed) + } + + pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { + let file = self.load_file()?; + let mut entries = Vec::new(); + for canonical_key in file.secrets.keys() { + let Some(entry) = parse_canonical_key(canonical_key) else { + warn!("skipping invalid canonical secret key: {canonical_key}"); + continue; + }; + if let Some(scope) = scope_filter + && entry.scope != *scope + { + continue; + } + entries.push(entry); + } + Ok(entries) + } + + fn secrets_dir(&self) -> PathBuf { + self.codex_home.join("secrets") + } + + fn secrets_path(&self) -> PathBuf { + self.secrets_dir().join(LOCAL_SECRETS_FILENAME) + } + + fn load_file(&self) -> Result { + let path = self.secrets_path(); + if !path.exists() { + return Ok(SecretsFile::new_empty()); + } + + let ciphertext = fs::read(&path) + .with_context(|| format!("failed to read secrets file at {}", path.display()))?; + let passphrase = self.load_or_create_passphrase()?; + let plaintext = decrypt_with_passphrase(&ciphertext, &passphrase)?; + let mut parsed: SecretsFile = serde_json::from_slice(&plaintext).with_context(|| { + format!( + "failed to deserialize decrypted secrets file at {}", + path.display() + ) + })?; + if parsed.version == 0 { + parsed.version = SECRETS_VERSION; + } + anyhow::ensure!( + parsed.version <= SECRETS_VERSION, + "secrets file version {} is newer than supported version {}", + parsed.version, + SECRETS_VERSION + ); + Ok(parsed) + } + + fn save_file(&self, file: &SecretsFile) -> Result<()> { + let dir = self.secrets_dir(); + fs::create_dir_all(&dir) + .with_context(|| format!("failed to create secrets dir {}", dir.display()))?; + + let passphrase = self.load_or_create_passphrase()?; + let plaintext = serde_json::to_vec(file).context("failed to serialize secrets file")?; + let ciphertext = encrypt_with_passphrase(&plaintext, &passphrase)?; + let path = self.secrets_path(); + write_file_atomically(&path, &ciphertext)?; + Ok(()) + } + + fn load_or_create_passphrase(&self) -> Result { + let account = compute_keyring_account(&self.codex_home); + let loaded = self + .keyring_store + .load(keyring_service(), &account) + .map_err(|err| anyhow::anyhow!(err.message())) + .with_context(|| format!("failed to load secrets key from keyring for {account}"))?; + match loaded { + Some(existing) => Ok(SecretString::from(existing)), + None => { + // Generate a high-entropy key and persist it in the OS keyring. + // This keeps secrets out of plaintext config while remaining + // fully local/offline for the MVP. + let generated = generate_passphrase()?; + self.keyring_store + .save(keyring_service(), &account, generated.expose_secret()) + .map_err(|err| anyhow::anyhow!(err.message())) + .context("failed to persist secrets key in keyring")?; + Ok(generated) + } + } + } +} + +impl SecretsBackend for LocalSecretsBackend { + fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> { + LocalSecretsBackend::set(self, scope, name, value) + } + + fn get(&self, scope: &SecretScope, name: &SecretName) -> Result> { + LocalSecretsBackend::get(self, scope, name) + } + + fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result { + LocalSecretsBackend::delete(self, scope, name) + } + + fn list(&self, scope_filter: Option<&SecretScope>) -> Result> { + LocalSecretsBackend::list(self, scope_filter) + } +} + +fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<()> { + let dir = path.parent().with_context(|| { + format!( + "failed to compute parent directory for secrets file at {}", + path.display() + ) + })?; + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + let tmp_path = dir.join(format!( + ".{LOCAL_SECRETS_FILENAME}.tmp-{}-{nonce}", + std::process::id() + )); + + { + let mut tmp_file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&tmp_path) + .with_context(|| { + format!( + "failed to create temp secrets file at {}", + tmp_path.display() + ) + })?; + tmp_file.write_all(contents).with_context(|| { + format!( + "failed to write temp secrets file at {}", + tmp_path.display() + ) + })?; + tmp_file.sync_all().with_context(|| { + format!("failed to sync temp secrets file at {}", tmp_path.display()) + })?; + } + + match fs::rename(&tmp_path, path) { + Ok(()) => Ok(()), + Err(initial_error) => { + #[cfg(target_os = "windows")] + { + if path.exists() { + fs::remove_file(path).with_context(|| { + format!( + "failed to remove existing secrets file at {} before replace", + path.display() + ) + })?; + fs::rename(&tmp_path, path).with_context(|| { + format!( + "failed to replace secrets file at {} with {}", + path.display(), + tmp_path.display() + ) + })?; + return Ok(()); + } + } + + let _ = fs::remove_file(&tmp_path); + Err(initial_error).with_context(|| { + format!( + "failed to atomically replace secrets file at {} with {}", + path.display(), + tmp_path.display() + ) + }) + } + } +} + +fn generate_passphrase() -> Result { + let mut bytes = [0_u8; 32]; + let mut rng = OsRng; + rng.try_fill_bytes(&mut bytes) + .context("failed to generate random secrets key")?; + // Base64 keeps the keyring payload ASCII-safe without reducing entropy. + let encoded = BASE64_STANDARD.encode(bytes); + wipe_bytes(&mut bytes); + Ok(SecretString::from(encoded)) +} + +fn wipe_bytes(bytes: &mut [u8]) { + for byte in bytes { + // Volatile writes make it much harder for the compiler to elide the wipe. + // SAFETY: `byte` is a valid mutable reference into `bytes`. + unsafe { std::ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); +} + +fn encrypt_with_passphrase(plaintext: &[u8], passphrase: &SecretString) -> Result> { + let recipient = ScryptRecipient::new(passphrase.clone()); + encrypt(&recipient, plaintext).context("failed to encrypt secrets file") +} + +fn decrypt_with_passphrase(ciphertext: &[u8], passphrase: &SecretString) -> Result> { + let identity = ScryptIdentity::new(passphrase.clone()); + decrypt(&identity, ciphertext).context("failed to decrypt secrets file") +} + +fn parse_canonical_key(canonical_key: &str) -> Option { + let mut parts = canonical_key.split('/'); + let scope_kind = parts.next()?; + match scope_kind { + "global" => { + let name = parts.next()?; + if parts.next().is_some() { + return None; + } + let name = SecretName::new(name).ok()?; + Some(SecretListEntry { + scope: SecretScope::Global, + name, + }) + } + "env" => { + let environment_id = parts.next()?; + let name = parts.next()?; + if parts.next().is_some() { + return None; + } + let name = SecretName::new(name).ok()?; + let scope = SecretScope::environment(environment_id.to_string()).ok()?; + Some(SecretListEntry { scope, name }) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_keyring_store::tests::MockKeyringStore; + use keyring::Error as KeyringError; + use pretty_assertions::assert_eq; + + #[test] + fn load_file_rejects_newer_schema_versions() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let keyring = Arc::new(MockKeyringStore::default()); + let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring); + + let file = SecretsFile { + version: SECRETS_VERSION + 1, + secrets: BTreeMap::new(), + }; + backend.save_file(&file)?; + + let error = backend + .load_file() + .expect_err("must reject newer schema version"); + assert!( + error.to_string().contains("newer than supported version"), + "unexpected error: {error:#}" + ); + Ok(()) + } + + #[test] + fn set_fails_when_keyring_is_unavailable() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let keyring = Arc::new(MockKeyringStore::default()); + let account = compute_keyring_account(codex_home.path()); + keyring.set_error( + &account, + KeyringError::Invalid("error".into(), "load".into()), + ); + + let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring); + let scope = SecretScope::Global; + let name = SecretName::new("TEST_SECRET")?; + let error = backend + .set(&scope, &name, "secret-value") + .expect_err("must fail when keyring load fails"); + assert!( + error + .to_string() + .contains("failed to load secrets key from keyring"), + "unexpected error: {error:#}" + ); + Ok(()) + } + + #[test] + fn save_file_does_not_leave_temp_files() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let keyring = Arc::new(MockKeyringStore::default()); + let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring); + + let scope = SecretScope::Global; + let name = SecretName::new("TEST_SECRET")?; + backend.set(&scope, &name, "one")?; + backend.set(&scope, &name, "two")?; + + let secrets_dir = backend.secrets_dir(); + let entries = fs::read_dir(&secrets_dir) + .with_context(|| format!("failed to read {}", secrets_dir.display()))? + .collect::>>() + .with_context(|| format!("failed to enumerate {}", secrets_dir.display()))?; + + let filenames: Vec = entries + .into_iter() + .filter_map(|entry| entry.file_name().to_str().map(ToString::to_string)) + .collect(); + assert_eq!(filenames, vec![LOCAL_SECRETS_FILENAME.to_string()]); + assert_eq!(backend.get(&scope, &name)?, Some("two".to_string())); + Ok(()) + } +} From d2394a2494651b5adeddc940b6c309fd407ac1e3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Feb 2026 11:31:57 +0000 Subject: [PATCH 07/13] chore: nuke chat/completions API (#10157) --- .../schema/json/ClientRequest.json | 4 +- .../schema/json/EventMsg.json | 4 +- .../schema/json/ServerNotification.json | 4 +- .../codex_app_server_protocol.schemas.json | 8 +- .../json/v1/ForkConversationResponse.json | 4 +- .../json/v1/ResumeConversationParams.json | 4 +- .../json/v1/ResumeConversationResponse.json | 4 +- .../v1/SessionConfiguredNotification.json | 4 +- .../RawResponseItemCompletedNotification.json | 4 +- .../schema/json/v2/ThreadResumeParams.json | 4 +- .../typescript/FunctionCallOutputPayload.ts | 3 +- codex-rs/codex-api/README.md | 4 +- codex-rs/codex-api/src/common.rs | 2 +- .../src/endpoint/{chat.rs => aggregate.rs} | 159 +--- codex-rs/codex-api/src/endpoint/compact.rs | 35 +- codex-rs/codex-api/src/endpoint/mod.rs | 2 +- codex-rs/codex-api/src/endpoint/responses.rs | 1 - codex-rs/codex-api/src/lib.rs | 5 +- codex-rs/codex-api/src/provider.rs | 3 +- codex-rs/codex-api/src/requests/chat.rs | 494 ------------ codex-rs/codex-api/src/requests/mod.rs | 3 - codex-rs/codex-api/src/sse/chat.rs | 717 ------------------ codex-rs/codex-api/src/sse/mod.rs | 1 - codex-rs/codex-api/tests/clients.rs | 35 +- codex-rs/common/src/oss.rs | 39 +- codex-rs/core/config.schema.json | 13 +- codex-rs/core/src/client.rs | 85 +-- codex-rs/core/src/codex.rs | 30 - codex-rs/core/src/config/mod.rs | 96 ++- codex-rs/core/src/lib.rs | 2 - codex-rs/core/src/model_provider_info.rs | 68 +- codex-rs/core/src/tools/spec.rs | 57 -- .../core/tests/chat_completions_payload.rs | 338 --------- codex-rs/core/tests/chat_completions_sse.rs | 466 ------------ codex-rs/core/tests/suite/cli_stream.rs | 41 +- codex-rs/core/tests/suite/rmcp_client.rs | 194 ----- codex-rs/exec/src/cli.rs | 2 +- codex-rs/exec/src/lib.rs | 18 +- codex-rs/mcp-server/tests/common/lib.rs | 2 +- .../tests/common/mock_model_server.rs | 6 +- codex-rs/mcp-server/tests/common/responses.rs | 97 +-- codex-rs/mcp-server/tests/suite/codex_tool.rs | 29 +- codex-rs/ollama/src/client.rs | 7 +- codex-rs/ollama/src/lib.rs | 62 +- codex-rs/protocol/src/models.rs | 8 +- codex-rs/tui/src/app.rs | 12 - codex-rs/tui/src/cli.rs | 2 +- codex-rs/tui/src/lib.rs | 10 - codex-rs/tui/src/oss_selection.rs | 7 - 49 files changed, 268 insertions(+), 2931 deletions(-) rename codex-rs/codex-api/src/endpoint/{chat.rs => aggregate.rs} (54%) delete mode 100644 codex-rs/codex-api/src/requests/chat.rs delete mode 100644 codex-rs/codex-api/src/sse/chat.rs delete mode 100644 codex-rs/core/tests/chat_completions_payload.rs delete mode 100644 codex-rs/core/tests/chat_completions_sse.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6f5ebcdf0f..518d9aaf34 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -526,7 +526,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -1410,7 +1410,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/EventMsg.json b/codex-rs/app-server-protocol/schema/json/EventMsg.json index 3e73bdc8b6..976e76adcf 100644 --- a/codex-rs/app-server-protocol/schema/json/EventMsg.json +++ b/codex-rs/app-server-protocol/schema/json/EventMsg.json @@ -2857,7 +2857,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -3689,7 +3689,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5d9e719d8f..4d47fa7388 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3477,7 +3477,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -4729,7 +4729,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5a5ddd1011..282a6ef429 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4965,7 +4965,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -6629,7 +6629,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" @@ -11151,7 +11151,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -12588,7 +12588,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json index ad251a461f..58302b4a2c 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ForkConversationResponse.json @@ -2857,7 +2857,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -3689,7 +3689,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json index 41284b009b..1261306e61 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationParams.json @@ -144,7 +144,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -530,7 +530,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json index abd5beb211..ce13d0715d 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/ResumeConversationResponse.json @@ -2857,7 +2857,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -3689,7 +3689,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json index 69c2c4b8b0..c77ccc1f90 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v1/SessionConfiguredNotification.json @@ -2857,7 +2857,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -3689,7 +3689,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index aecb8c2b58..1b307c9b89 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -111,7 +111,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -417,7 +417,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 52567cec82..cc05de490a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -120,7 +120,7 @@ ] }, "FunctionCallOutputPayload": { - "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses/Chat Completions APIs understand.", + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`content` preserves the historical plain-string payload so downstream integrations (tests, logging, etc.) can keep treating tool output as `String`. When an MCP server returns richer data we additionally populate `content_items` with the structured form that the Responses API understands.", "properties": { "content": { "type": "string" @@ -433,7 +433,7 @@ ] }, "id": { - "description": "Set when using the chat completions API.", + "description": "Legacy id field retained for compatibility with older payloads.", "type": [ "string", "null" diff --git a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts index 776369b10f..94370f582d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts +++ b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputPayload.ts @@ -9,7 +9,6 @@ import type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentI * `content` preserves the historical plain-string payload so downstream * integrations (tests, logging, etc.) can keep treating tool output as * `String`. When an MCP server returns richer data we additionally populate - * `content_items` with the structured form that the Responses/Chat - * Completions APIs understand. + * `content_items` with the structured form that the Responses API understands. */ export type FunctionCallOutputPayload = { content: string, content_items: Array | null, success: boolean | null, }; diff --git a/codex-rs/codex-api/README.md b/codex-rs/codex-api/README.md index 98db0bec65..c1f7d230c0 100644 --- a/codex-rs/codex-api/README.md +++ b/codex-rs/codex-api/README.md @@ -2,7 +2,7 @@ Typed clients for Codex/OpenAI APIs built on top of the generic transport in `codex-client`. -- Hosts the request/response models and prompt helpers for Responses, Chat Completions, and Compact APIs. +- Hosts the request/response models and prompt helpers for Responses and Compact APIs. - Owns provider configuration (base URLs, headers, query params), auth header injection, retry tuning, and stream idle settings. - Parses SSE streams into `ResponseEvent`/`ResponseStream`, including rate-limit snapshots and API-specific error mapping. - Serves as the wire-level layer consumed by `codex-core`; higher layers handle auth refresh and business logic. @@ -11,7 +11,7 @@ Typed clients for Codex/OpenAI APIs built on top of the generic transport in `co The public interface of this crate is intentionally small and uniform: -- **Prompted endpoints (Chat + Responses)** +- **Prompted endpoints (Responses)** - Input: a single `Prompt` plus endpoint-specific options. - `Prompt` (re-exported as `codex_api::Prompt`) carries: - `instructions: String` – the fully-resolved system prompt for this turn. diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 9a7aab9973..a9127644f1 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -13,7 +13,7 @@ use std::task::Context; use std::task::Poll; use tokio::sync::mpsc; -/// Canonical prompt input for Chat and Responses endpoints. +/// Canonical prompt input for Responses endpoints. #[derive(Debug, Clone)] pub struct Prompt { /// Fully-resolved system instructions for this turn. diff --git a/codex-rs/codex-api/src/endpoint/chat.rs b/codex-rs/codex-api/src/endpoint/aggregate.rs similarity index 54% rename from codex-rs/codex-api/src/endpoint/chat.rs rename to codex-rs/codex-api/src/endpoint/aggregate.rs index b183597d65..ac0cee9040 100644 --- a/codex-rs/codex-api/src/endpoint/chat.rs +++ b/codex-rs/codex-api/src/endpoint/aggregate.rs @@ -1,111 +1,21 @@ -use crate::ChatRequest; -use crate::auth::AuthProvider; -use crate::common::Prompt as ApiPrompt; use crate::common::ResponseEvent; use crate::common::ResponseStream; -use crate::endpoint::streaming::StreamingClient; use crate::error::ApiError; -use crate::provider::Provider; -use crate::provider::WireApi; -use crate::sse::chat::spawn_chat_stream; -use crate::telemetry::SseTelemetry; -use codex_client::HttpTransport; -use codex_client::RequestCompression; -use codex_client::RequestTelemetry; use codex_protocol::models::ContentItem; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::SessionSource; use futures::Stream; -use http::HeaderMap; -use serde_json::Value; use std::collections::VecDeque; use std::pin::Pin; -use std::sync::Arc; use std::task::Context; use std::task::Poll; -pub struct ChatClient { - streaming: StreamingClient, -} - -impl ChatClient { - pub fn new(transport: T, provider: Provider, auth: A) -> Self { - Self { - streaming: StreamingClient::new(transport, provider, auth), - } - } - - pub fn with_telemetry( - self, - request: Option>, - sse: Option>, - ) -> Self { - Self { - streaming: self.streaming.with_telemetry(request, sse), - } - } - - pub async fn stream_request(&self, request: ChatRequest) -> Result { - self.stream(request.body, request.headers).await - } - - pub async fn stream_prompt( - &self, - model: &str, - prompt: &ApiPrompt, - conversation_id: Option, - session_source: Option, - ) -> Result { - use crate::requests::ChatRequestBuilder; - - let request = - ChatRequestBuilder::new(model, &prompt.instructions, &prompt.input, &prompt.tools) - .conversation_id(conversation_id) - .session_source(session_source) - .build(self.streaming.provider())?; - - self.stream_request(request).await - } - - fn path(&self) -> &'static str { - match self.streaming.provider().wire { - WireApi::Chat => "chat/completions", - _ => "responses", - } - } - - pub async fn stream( - &self, - body: Value, - extra_headers: HeaderMap, - ) -> Result { - self.streaming - .stream( - self.path(), - body, - extra_headers, - RequestCompression::None, - spawn_chat_stream, - None, - ) - .await - } -} - -#[derive(Copy, Clone, Eq, PartialEq)] -pub enum AggregateMode { - AggregatedOnly, - Streaming, -} - /// Stream adapter that merges token deltas into a single assistant message per turn. pub struct AggregatedStream { inner: ResponseStream, cumulative: String, cumulative_reasoning: String, pending: VecDeque, - mode: AggregateMode, } impl Stream for AggregatedStream { @@ -122,7 +32,7 @@ impl Stream for AggregatedStream { match Pin::new(&mut this.inner).poll_next(cx) { Poll::Pending => return Poll::Pending, Poll::Ready(None) => return Poll::Ready(None), - Poll::Ready(Some(Err(e))) => return Poll::Ready(Some(Err(e))), + Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err))), Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))) => { let is_assistant_message = matches!( &item, @@ -130,29 +40,16 @@ impl Stream for AggregatedStream { ); if is_assistant_message { - match this.mode { - AggregateMode::AggregatedOnly => { - if this.cumulative.is_empty() - && let ResponseItem::Message { content, .. } = &item - && let Some(text) = content.iter().find_map(|c| match c { - ContentItem::OutputText { text } => Some(text), - _ => None, - }) - { - this.cumulative.push_str(text); - } - continue; - } - AggregateMode::Streaming => { - if this.cumulative.is_empty() { - return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone( - item, - )))); - } else { - continue; - } - } + if this.cumulative.is_empty() + && let ResponseItem::Message { content, .. } = &item + && let Some(text) = content.iter().find_map(|c| match c { + ContentItem::OutputText { text } => Some(text), + _ => None, + }) + { + this.cumulative.push_str(text); } + continue; } return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone(item)))); @@ -216,35 +113,20 @@ impl Stream for AggregatedStream { token_usage, }))); } - Poll::Ready(Some(Ok(ResponseEvent::Created))) => { - continue; - } + Poll::Ready(Some(Ok(ResponseEvent::Created))) => continue, Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))) => { this.cumulative.push_str(&delta); - if matches!(this.mode, AggregateMode::Streaming) { - return Poll::Ready(Some(Ok(ResponseEvent::OutputTextDelta(delta)))); - } else { - continue; - } + continue; } Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { delta, - content_index, + content_index: _, }))) => { this.cumulative_reasoning.push_str(&delta); - if matches!(this.mode, AggregateMode::Streaming) { - return Poll::Ready(Some(Ok(ResponseEvent::ReasoningContentDelta { - delta, - content_index, - }))); - } else { - continue; - } - } - Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => continue, - Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded { .. }))) => { continue; } + Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryDelta { .. }))) => continue, + Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded { .. }))) => continue, Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))) => { return Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))); } @@ -255,28 +137,21 @@ impl Stream for AggregatedStream { pub trait AggregateStreamExt { fn aggregate(self) -> AggregatedStream; - - fn streaming_mode(self) -> ResponseStream; } impl AggregateStreamExt for ResponseStream { fn aggregate(self) -> AggregatedStream { - AggregatedStream::new(self, AggregateMode::AggregatedOnly) - } - - fn streaming_mode(self) -> ResponseStream { - self + AggregatedStream::new(self) } } impl AggregatedStream { - fn new(inner: ResponseStream, mode: AggregateMode) -> Self { + fn new(inner: ResponseStream) -> Self { AggregatedStream { inner, cumulative: String::new(), cumulative_reasoning: String::new(), pending: VecDeque::new(), - mode, } } } diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index 2b02ebd0f0..8edba7c597 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -36,12 +36,9 @@ impl CompactClient { self } - fn path(&self) -> Result<&'static str, ApiError> { + fn path(&self) -> &'static str { match self.provider.wire { - WireApi::Compact | WireApi::Responses => Ok("responses/compact"), - WireApi::Chat => Err(ApiError::Stream( - "compact endpoint requires responses wire api".to_string(), - )), + WireApi::Compact | WireApi::Responses => "responses/compact", } } @@ -50,7 +47,7 @@ impl CompactClient { body: serde_json::Value, extra_headers: HeaderMap, ) -> Result, ApiError> { - let path = self.path()?; + let path = self.path(); let builder = || { let mut req = self.provider.build_request(Method::POST, path); req.headers.extend(extra_headers.clone()); @@ -139,24 +136,14 @@ mod tests { } } - #[tokio::test] - async fn errors_when_wire_is_chat() { - let client = CompactClient::new(DummyTransport, provider(WireApi::Chat), DummyAuth); - let input = CompactionInput { - model: "gpt-test", - input: &[], - instructions: "inst", - }; - let err = client - .compact_input(&input, HeaderMap::new()) - .await - .expect_err("expected wire mismatch to fail"); + #[test] + fn path_is_responses_compact_for_supported_wire_apis() { + let responses_client = + CompactClient::new(DummyTransport, provider(WireApi::Responses), DummyAuth); + assert_eq!(responses_client.path(), "responses/compact"); - match err { - ApiError::Stream(msg) => { - assert_eq!(msg, "compact endpoint requires responses wire api"); - } - other => panic!("unexpected error: {other:?}"), - } + let compact_client = + CompactClient::new(DummyTransport, provider(WireApi::Compact), DummyAuth); + assert_eq!(compact_client.path(), "responses/compact"); } } diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 2fa116c089..2bb524b227 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,4 +1,4 @@ -pub mod chat; +pub mod aggregate; pub mod compact; pub mod models; pub mod responses; diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 4aded33911..11ff60754f 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -111,7 +111,6 @@ impl ResponsesClient { fn path(&self) -> &'static str { match self.streaming.provider().wire { WireApi::Responses | WireApi::Compact => "responses", - WireApi::Chat => "chat/completions", } } diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 2e340970ca..6a2490c5a6 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -22,8 +22,7 @@ pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::create_text_param_for_request; -pub use crate::endpoint::chat::AggregateStreamExt; -pub use crate::endpoint::chat::ChatClient; +pub use crate::endpoint::aggregate::AggregateStreamExt; pub use crate::endpoint::compact::CompactClient; pub use crate::endpoint::models::ModelsClient; pub use crate::endpoint::responses::ResponsesClient; @@ -34,8 +33,6 @@ pub use crate::error::ApiError; pub use crate::provider::Provider; pub use crate::provider::WireApi; pub use crate::provider::is_azure_responses_wire_base_url; -pub use crate::requests::ChatRequest; -pub use crate::requests::ChatRequestBuilder; pub use crate::requests::ResponsesRequest; pub use crate::requests::ResponsesRequestBuilder; pub use crate::sse::stream_from_fixture; diff --git a/codex-rs/codex-api/src/provider.rs b/codex-rs/codex-api/src/provider.rs index d7e7c19416..fab3ed75e9 100644 --- a/codex-rs/codex-api/src/provider.rs +++ b/codex-rs/codex-api/src/provider.rs @@ -12,7 +12,6 @@ use url::Url; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WireApi { Responses, - Chat, Compact, } @@ -182,7 +181,7 @@ mod tests { } assert!(!is_azure_responses_wire_base_url( - WireApi::Chat, + WireApi::Compact, "Azure", Some("https://foo.openai.azure.com/openai") )); diff --git a/codex-rs/codex-api/src/requests/chat.rs b/codex-rs/codex-api/src/requests/chat.rs deleted file mode 100644 index 60a591e5c2..0000000000 --- a/codex-rs/codex-api/src/requests/chat.rs +++ /dev/null @@ -1,494 +0,0 @@ -use crate::error::ApiError; -use crate::provider::Provider; -use crate::requests::headers::build_conversation_headers; -use crate::requests::headers::insert_header; -use crate::requests::headers::subagent_header; -use codex_protocol::models::ContentItem; -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::SessionSource; -use http::HeaderMap; -use serde_json::Value; -use serde_json::json; -use std::collections::HashMap; - -/// Assembled request body plus headers for Chat Completions streaming calls. -pub struct ChatRequest { - pub body: Value, - pub headers: HeaderMap, -} - -pub struct ChatRequestBuilder<'a> { - model: &'a str, - instructions: &'a str, - input: &'a [ResponseItem], - tools: &'a [Value], - conversation_id: Option, - session_source: Option, -} - -impl<'a> ChatRequestBuilder<'a> { - pub fn new( - model: &'a str, - instructions: &'a str, - input: &'a [ResponseItem], - tools: &'a [Value], - ) -> Self { - Self { - model, - instructions, - input, - tools, - conversation_id: None, - session_source: None, - } - } - - pub fn conversation_id(mut self, id: Option) -> Self { - self.conversation_id = id; - self - } - - pub fn session_source(mut self, source: Option) -> Self { - self.session_source = source; - self - } - - pub fn build(self, _provider: &Provider) -> Result { - let mut messages = Vec::::new(); - messages.push(json!({"role": "system", "content": self.instructions})); - - let input = self.input; - let mut reasoning_by_anchor_index: HashMap = HashMap::new(); - let mut last_emitted_role: Option<&str> = None; - for item in input { - match item { - ResponseItem::Message { role, .. } => last_emitted_role = Some(role.as_str()), - ResponseItem::FunctionCall { .. } | ResponseItem::LocalShellCall { .. } => { - last_emitted_role = Some("assistant") - } - ResponseItem::FunctionCallOutput { .. } => last_emitted_role = Some("tool"), - ResponseItem::Reasoning { .. } | ResponseItem::Other => {} - ResponseItem::CustomToolCall { .. } => {} - ResponseItem::CustomToolCallOutput { .. } => {} - ResponseItem::WebSearchCall { .. } => {} - ResponseItem::GhostSnapshot { .. } => {} - ResponseItem::Compaction { .. } => {} - } - } - - let mut last_user_index: Option = None; - for (idx, item) in input.iter().enumerate() { - if let ResponseItem::Message { role, .. } = item - && role == "user" - { - last_user_index = Some(idx); - } - } - - if !matches!(last_emitted_role, Some("user")) { - for (idx, item) in input.iter().enumerate() { - if let Some(u_idx) = last_user_index - && idx <= u_idx - { - continue; - } - - if let ResponseItem::Reasoning { - content: Some(items), - .. - } = item - { - let mut text = String::new(); - for entry in items { - match entry { - ReasoningItemContent::ReasoningText { text: segment } - | ReasoningItemContent::Text { text: segment } => { - text.push_str(segment) - } - } - } - if text.trim().is_empty() { - continue; - } - - let mut attached = false; - if idx > 0 - && let ResponseItem::Message { role, .. } = &input[idx - 1] - && role == "assistant" - { - reasoning_by_anchor_index - .entry(idx - 1) - .and_modify(|v| v.push_str(&text)) - .or_insert(text.clone()); - attached = true; - } - - if !attached && idx + 1 < input.len() { - match &input[idx + 1] { - ResponseItem::FunctionCall { .. } - | ResponseItem::LocalShellCall { .. } => { - reasoning_by_anchor_index - .entry(idx + 1) - .and_modify(|v| v.push_str(&text)) - .or_insert(text.clone()); - } - ResponseItem::Message { role, .. } if role == "assistant" => { - reasoning_by_anchor_index - .entry(idx + 1) - .and_modify(|v| v.push_str(&text)) - .or_insert(text.clone()); - } - _ => {} - } - } - } - } - } - - let mut last_assistant_text: Option = None; - - for (idx, item) in input.iter().enumerate() { - match item { - ResponseItem::Message { role, content, .. } => { - let mut text = String::new(); - let mut items: Vec = Vec::new(); - let mut saw_image = false; - - for c in content { - match c { - ContentItem::InputText { text: t } - | ContentItem::OutputText { text: t } => { - text.push_str(t); - items.push(json!({"type":"text","text": t})); - } - ContentItem::InputImage { image_url } => { - saw_image = true; - items.push( - json!({"type":"image_url","image_url": {"url": image_url}}), - ); - } - } - } - - if role == "assistant" { - if let Some(prev) = &last_assistant_text - && prev == &text - { - continue; - } - last_assistant_text = Some(text.clone()); - } - - let content_value = if role == "assistant" { - json!(text) - } else if saw_image { - json!(items) - } else { - json!(text) - }; - - let mut msg = json!({"role": role, "content": content_value}); - if role == "assistant" - && let Some(reasoning) = reasoning_by_anchor_index.get(&idx) - && let Some(obj) = msg.as_object_mut() - { - obj.insert("reasoning".to_string(), json!(reasoning)); - } - messages.push(msg); - } - ResponseItem::FunctionCall { - name, - arguments, - call_id, - .. - } => { - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - let tool_call = json!({ - "id": call_id, - "type": "function", - "function": { - "name": name, - "arguments": arguments, - } - }); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::LocalShellCall { - id, - call_id: _, - status, - action, - } => { - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - let tool_call = json!({ - "id": id.clone().unwrap_or_default(), - "type": "local_shell_call", - "status": status, - "action": action, - }); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::FunctionCallOutput { call_id, output } => { - let content_value = if let Some(items) = &output.content_items { - let mapped: Vec = items - .iter() - .map(|it| match it { - FunctionCallOutputContentItem::InputText { text } => { - json!({"type":"text","text": text}) - } - FunctionCallOutputContentItem::InputImage { image_url } => { - json!({"type":"image_url","image_url": {"url": image_url}}) - } - }) - .collect(); - json!(mapped) - } else { - json!(output.content) - }; - - messages.push(json!({ - "role": "tool", - "tool_call_id": call_id, - "content": content_value, - })); - } - ResponseItem::CustomToolCall { - id, - call_id: _, - name, - input, - status: _, - } => { - let tool_call = json!({ - "id": id, - "type": "custom", - "custom": { - "name": name, - "input": input, - } - }); - let reasoning = reasoning_by_anchor_index.get(&idx).map(String::as_str); - push_tool_call_message(&mut messages, tool_call, reasoning); - } - ResponseItem::CustomToolCallOutput { call_id, output } => { - messages.push(json!({ - "role": "tool", - "tool_call_id": call_id, - "content": output, - })); - } - ResponseItem::GhostSnapshot { .. } => { - continue; - } - ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::Other - | ResponseItem::Compaction { .. } => { - continue; - } - } - } - - let payload = json!({ - "model": self.model, - "messages": messages, - "stream": true, - "tools": self.tools, - }); - - let mut headers = build_conversation_headers(self.conversation_id); - if let Some(subagent) = subagent_header(&self.session_source) { - insert_header(&mut headers, "x-openai-subagent", &subagent); - } - - Ok(ChatRequest { - body: payload, - headers, - }) - } -} - -fn push_tool_call_message(messages: &mut Vec, tool_call: Value, reasoning: Option<&str>) { - // Chat Completions requires that tool calls are grouped into a single assistant message - // (with `tool_calls: [...]`) followed by tool role responses. - if let Some(Value::Object(obj)) = messages.last_mut() - && obj.get("role").and_then(Value::as_str) == Some("assistant") - && obj.get("content").is_some_and(Value::is_null) - && let Some(tool_calls) = obj.get_mut("tool_calls").and_then(Value::as_array_mut) - { - tool_calls.push(tool_call); - if let Some(reasoning) = reasoning { - if let Some(Value::String(existing)) = obj.get_mut("reasoning") { - if !existing.is_empty() { - existing.push('\n'); - } - existing.push_str(reasoning); - } else { - obj.insert( - "reasoning".to_string(), - Value::String(reasoning.to_string()), - ); - } - } - return; - } - - let mut msg = json!({ - "role": "assistant", - "content": null, - "tool_calls": [tool_call], - }); - if let Some(reasoning) = reasoning - && let Some(obj) = msg.as_object_mut() - { - obj.insert("reasoning".to_string(), json!(reasoning)); - } - messages.push(msg); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::provider::RetryConfig; - use crate::provider::WireApi; - use codex_protocol::models::FunctionCallOutputPayload; - use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::SubAgentSource; - use http::HeaderValue; - use pretty_assertions::assert_eq; - use std::time::Duration; - - fn provider() -> Provider { - Provider { - name: "openai".to_string(), - base_url: "https://api.openai.com/v1".to_string(), - query_params: None, - wire: WireApi::Chat, - headers: HeaderMap::new(), - retry: RetryConfig { - max_attempts: 1, - base_delay: Duration::from_millis(10), - retry_429: false, - retry_5xx: true, - retry_transport: true, - }, - stream_idle_timeout: Duration::from_secs(1), - } - } - - #[test] - fn attaches_conversation_and_subagent_headers() { - let prompt_input = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "hi".to_string(), - }], - end_turn: None, - phase: None, - }]; - let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[]) - .conversation_id(Some("conv-1".into())) - .session_source(Some(SessionSource::SubAgent(SubAgentSource::Review))) - .build(&provider()) - .expect("request"); - - assert_eq!( - req.headers.get("session_id"), - Some(&HeaderValue::from_static("conv-1")) - ); - assert_eq!( - req.headers.get("x-openai-subagent"), - Some(&HeaderValue::from_static("review")) - ); - } - - #[test] - fn groups_consecutive_tool_calls_into_a_single_assistant_message() { - let prompt_input = vec![ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "read these".to_string(), - }], - end_turn: None, - phase: None, - }, - ResponseItem::FunctionCall { - id: None, - name: "read_file".to_string(), - arguments: r#"{"path":"a.txt"}"#.to_string(), - call_id: "call-a".to_string(), - }, - ResponseItem::FunctionCall { - id: None, - name: "read_file".to_string(), - arguments: r#"{"path":"b.txt"}"#.to_string(), - call_id: "call-b".to_string(), - }, - ResponseItem::FunctionCall { - id: None, - name: "read_file".to_string(), - arguments: r#"{"path":"c.txt"}"#.to_string(), - call_id: "call-c".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-a".to_string(), - output: FunctionCallOutputPayload { - content: "A".to_string(), - ..Default::default() - }, - }, - ResponseItem::FunctionCallOutput { - call_id: "call-b".to_string(), - output: FunctionCallOutputPayload { - content: "B".to_string(), - ..Default::default() - }, - }, - ResponseItem::FunctionCallOutput { - call_id: "call-c".to_string(), - output: FunctionCallOutputPayload { - content: "C".to_string(), - ..Default::default() - }, - }, - ]; - - let req = ChatRequestBuilder::new("gpt-test", "inst", &prompt_input, &[]) - .build(&provider()) - .expect("request"); - - let messages = req - .body - .get("messages") - .and_then(|v| v.as_array()) - .expect("messages array"); - // system + user + assistant(tool_calls=[...]) + 3 tool outputs - assert_eq!(messages.len(), 6); - - assert_eq!(messages[0]["role"], "system"); - assert_eq!(messages[1]["role"], "user"); - - let tool_calls_msg = &messages[2]; - assert_eq!(tool_calls_msg["role"], "assistant"); - assert_eq!(tool_calls_msg["content"], serde_json::Value::Null); - let tool_calls = tool_calls_msg["tool_calls"] - .as_array() - .expect("tool_calls array"); - assert_eq!(tool_calls.len(), 3); - assert_eq!(tool_calls[0]["id"], "call-a"); - assert_eq!(tool_calls[1]["id"], "call-b"); - assert_eq!(tool_calls[2]["id"], "call-c"); - - assert_eq!(messages[3]["role"], "tool"); - assert_eq!(messages[3]["tool_call_id"], "call-a"); - assert_eq!(messages[4]["role"], "tool"); - assert_eq!(messages[4]["tool_call_id"], "call-b"); - assert_eq!(messages[5]["role"], "tool"); - assert_eq!(messages[5]["tool_call_id"], "call-c"); - } -} diff --git a/codex-rs/codex-api/src/requests/mod.rs b/codex-rs/codex-api/src/requests/mod.rs index f0ab23a25f..35fecf9a92 100644 --- a/codex-rs/codex-api/src/requests/mod.rs +++ b/codex-rs/codex-api/src/requests/mod.rs @@ -1,8 +1,5 @@ -pub mod chat; pub(crate) mod headers; pub mod responses; -pub use chat::ChatRequest; -pub use chat::ChatRequestBuilder; pub use responses::ResponsesRequest; pub use responses::ResponsesRequestBuilder; diff --git a/codex-rs/codex-api/src/sse/chat.rs b/codex-rs/codex-api/src/sse/chat.rs deleted file mode 100644 index 4b78845285..0000000000 --- a/codex-rs/codex-api/src/sse/chat.rs +++ /dev/null @@ -1,717 +0,0 @@ -use crate::common::ResponseEvent; -use crate::common::ResponseStream; -use crate::error::ApiError; -use crate::telemetry::SseTelemetry; -use codex_client::StreamResponse; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::models::ResponseItem; -use eventsource_stream::Eventsource; -use futures::Stream; -use futures::StreamExt; -use std::collections::HashMap; -use std::collections::HashSet; -use std::sync::Arc; -use std::sync::OnceLock; -use std::time::Duration; -use tokio::sync::mpsc; -use tokio::time::Instant; -use tokio::time::timeout; -use tracing::debug; -use tracing::trace; - -pub(crate) fn spawn_chat_stream( - stream_response: StreamResponse, - idle_timeout: Duration, - telemetry: Option>, - _turn_state: Option>>, -) -> ResponseStream { - let (tx_event, rx_event) = mpsc::channel::>(1600); - tokio::spawn(async move { - process_chat_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await; - }); - ResponseStream { rx_event } -} - -/// Processes Server-Sent Events from the legacy Chat Completions streaming API. -/// -/// The upstream protocol terminates a streaming response with a final sentinel event -/// (`data: [DONE]`). Historically, some of our test stubs have emitted `data: DONE` -/// (without brackets) instead. -/// -/// `eventsource_stream` delivers these sentinels as regular events rather than signaling -/// end-of-stream. If we try to parse them as JSON, we log and skip them, then keep -/// polling for more events. -/// -/// On servers that keep the HTTP connection open after emitting the sentinel (notably -/// wiremock on Windows), skipping the sentinel means we never emit `ResponseEvent::Completed`. -/// Higher-level workflows/tests that wait for completion before issuing subsequent model -/// calls will then stall, which shows up as "expected N requests, got 1" verification -/// failures in the mock server. -pub async fn process_chat_sse( - stream: S, - tx_event: mpsc::Sender>, - idle_timeout: Duration, - telemetry: Option>, -) where - S: Stream> + Unpin, -{ - let mut stream = stream.eventsource(); - - #[derive(Default, Debug)] - struct ToolCallState { - id: Option, - name: Option, - arguments: String, - } - - let mut tool_calls: HashMap = HashMap::new(); - let mut tool_call_order: Vec = Vec::new(); - let mut tool_call_order_seen: HashSet = HashSet::new(); - let mut tool_call_index_by_id: HashMap = HashMap::new(); - let mut next_tool_call_index = 0usize; - let mut last_tool_call_index: Option = None; - let mut assistant_item: Option = None; - let mut reasoning_item: Option = None; - let mut completed_sent = false; - - async fn flush_and_complete( - tx_event: &mpsc::Sender>, - reasoning_item: &mut Option, - assistant_item: &mut Option, - ) { - if let Some(reasoning) = reasoning_item.take() { - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone(reasoning))) - .await; - } - - if let Some(assistant) = assistant_item.take() { - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone(assistant))) - .await; - } - - let _ = tx_event - .send(Ok(ResponseEvent::Completed { - response_id: String::new(), - token_usage: None, - })) - .await; - } - - loop { - let start = Instant::now(); - let response = timeout(idle_timeout, stream.next()).await; - if let Some(t) = telemetry.as_ref() { - t.on_sse_poll(&response, start.elapsed()); - } - let sse = match response { - Ok(Some(Ok(sse))) => sse, - Ok(Some(Err(e))) => { - let _ = tx_event.send(Err(ApiError::Stream(e.to_string()))).await; - return; - } - Ok(None) => { - if !completed_sent { - flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await; - } - return; - } - Err(_) => { - let _ = tx_event - .send(Err(ApiError::Stream("idle timeout waiting for SSE".into()))) - .await; - return; - } - }; - - trace!("SSE event: {}", sse.data); - - let data = sse.data.trim(); - - if data.is_empty() { - continue; - } - - if data == "[DONE]" || data == "DONE" { - if !completed_sent { - flush_and_complete(&tx_event, &mut reasoning_item, &mut assistant_item).await; - } - return; - } - - let value: serde_json::Value = match serde_json::from_str(data) { - Ok(val) => val, - Err(err) => { - debug!( - "Failed to parse ChatCompletions SSE event: {err}, data: {}", - data - ); - continue; - } - }; - - let Some(choices) = value.get("choices").and_then(|c| c.as_array()) else { - continue; - }; - - for choice in choices { - if let Some(delta) = choice.get("delta") { - if let Some(reasoning) = delta.get("reasoning") { - if let Some(text) = reasoning.as_str() { - append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()) - .await; - } else if let Some(text) = reasoning.get("text").and_then(|v| v.as_str()) { - append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()) - .await; - } else if let Some(text) = reasoning.get("content").and_then(|v| v.as_str()) { - append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()) - .await; - } - } - - if let Some(content) = delta.get("content") { - if content.is_array() { - for item in content.as_array().unwrap_or(&vec![]) { - if let Some(text) = item.get("text").and_then(|t| t.as_str()) { - append_assistant_text( - &tx_event, - &mut assistant_item, - text.to_string(), - ) - .await; - } - } - } else if let Some(text) = content.as_str() { - append_assistant_text(&tx_event, &mut assistant_item, text.to_string()) - .await; - } - } - - if let Some(tool_call_values) = delta.get("tool_calls").and_then(|c| c.as_array()) { - for tool_call in tool_call_values { - let mut index = tool_call - .get("index") - .and_then(serde_json::Value::as_u64) - .map(|i| i as usize); - - let mut call_id_for_lookup = None; - if let Some(call_id) = tool_call.get("id").and_then(|i| i.as_str()) { - call_id_for_lookup = Some(call_id.to_string()); - if let Some(existing) = tool_call_index_by_id.get(call_id) { - index = Some(*existing); - } - } - - if index.is_none() && call_id_for_lookup.is_none() { - index = last_tool_call_index; - } - - let index = index.unwrap_or_else(|| { - while tool_calls.contains_key(&next_tool_call_index) { - next_tool_call_index += 1; - } - let idx = next_tool_call_index; - next_tool_call_index += 1; - idx - }); - - let call_state = tool_calls.entry(index).or_default(); - if tool_call_order_seen.insert(index) { - tool_call_order.push(index); - } - - if let Some(id) = tool_call.get("id").and_then(|i| i.as_str()) { - call_state.id.get_or_insert_with(|| id.to_string()); - tool_call_index_by_id.entry(id.to_string()).or_insert(index); - } - - if let Some(func) = tool_call.get("function") { - if let Some(fname) = func.get("name").and_then(|n| n.as_str()) - && !fname.is_empty() - { - call_state.name.get_or_insert_with(|| fname.to_string()); - } - if let Some(arguments) = func.get("arguments").and_then(|a| a.as_str()) - { - call_state.arguments.push_str(arguments); - } - } - - last_tool_call_index = Some(index); - } - } - } - - if let Some(message) = choice.get("message") - && let Some(reasoning) = message.get("reasoning") - { - if let Some(text) = reasoning.as_str() { - append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()).await; - } else if let Some(text) = reasoning.get("text").and_then(|v| v.as_str()) { - append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()).await; - } else if let Some(text) = reasoning.get("content").and_then(|v| v.as_str()) { - append_reasoning_text(&tx_event, &mut reasoning_item, text.to_string()).await; - } - } - - let finish_reason = choice.get("finish_reason").and_then(|r| r.as_str()); - if finish_reason == Some("stop") { - if let Some(reasoning) = reasoning_item.take() { - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone(reasoning))) - .await; - } - - if let Some(assistant) = assistant_item.take() { - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone(assistant))) - .await; - } - if !completed_sent { - let _ = tx_event - .send(Ok(ResponseEvent::Completed { - response_id: String::new(), - token_usage: None, - })) - .await; - completed_sent = true; - } - continue; - } - - if finish_reason == Some("length") { - let _ = tx_event.send(Err(ApiError::ContextWindowExceeded)).await; - return; - } - - if finish_reason == Some("tool_calls") { - if let Some(reasoning) = reasoning_item.take() { - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemDone(reasoning))) - .await; - } - - for index in tool_call_order.drain(..) { - let Some(state) = tool_calls.remove(&index) else { - continue; - }; - tool_call_order_seen.remove(&index); - let ToolCallState { - id, - name, - arguments, - } = state; - let Some(name) = name else { - debug!("Skipping tool call at index {index} because name is missing"); - continue; - }; - let item = ResponseItem::FunctionCall { - id: None, - name, - arguments, - call_id: id.unwrap_or_else(|| format!("tool-call-{index}")), - }; - let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; - } - } - } - } -} - -async fn append_assistant_text( - tx_event: &mpsc::Sender>, - assistant_item: &mut Option, - text: String, -) { - if assistant_item.is_none() { - let item = ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![], - end_turn: None, - phase: None, - }; - *assistant_item = Some(item.clone()); - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemAdded(item))) - .await; - } - - if let Some(ResponseItem::Message { content, .. }) = assistant_item { - content.push(ContentItem::OutputText { text: text.clone() }); - let _ = tx_event - .send(Ok(ResponseEvent::OutputTextDelta(text.clone()))) - .await; - } -} - -async fn append_reasoning_text( - tx_event: &mpsc::Sender>, - reasoning_item: &mut Option, - text: String, -) { - if reasoning_item.is_none() { - let item = ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![]), - encrypted_content: None, - }; - *reasoning_item = Some(item.clone()); - let _ = tx_event - .send(Ok(ResponseEvent::OutputItemAdded(item))) - .await; - } - - if let Some(ResponseItem::Reasoning { - content: Some(content), - .. - }) = reasoning_item - { - let content_index = content.len() as i64; - content.push(ReasoningItemContent::ReasoningText { text: text.clone() }); - - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta { - delta: text.clone(), - content_index, - })) - .await; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use assert_matches::assert_matches; - use codex_protocol::models::ResponseItem; - use futures::TryStreamExt; - use serde_json::json; - use tokio::sync::mpsc; - use tokio_util::io::ReaderStream; - - fn build_body(events: &[serde_json::Value]) -> String { - let mut body = String::new(); - for e in events { - body.push_str(&format!("event: message\ndata: {e}\n\n")); - } - body - } - - /// Regression test: the stream should complete when we see a `[DONE]` sentinel. - /// - /// This is important for tests/mocks that don't immediately close the underlying - /// connection after emitting the sentinel. - #[tokio::test] - async fn completes_on_done_sentinel_without_json() { - let events = collect_events("event: message\ndata: [DONE]\n\n").await; - assert_matches!(&events[..], [ResponseEvent::Completed { .. }]); - } - - async fn collect_events(body: &str) -> Vec { - let reader = ReaderStream::new(std::io::Cursor::new(body.to_string())) - .map_err(|err| codex_client::TransportError::Network(err.to_string())); - let (tx, mut rx) = mpsc::channel::>(16); - tokio::spawn(process_chat_sse( - reader, - tx, - Duration::from_millis(1000), - None, - )); - - let mut out = Vec::new(); - while let Some(ev) = rx.recv().await { - out.push(ev.expect("stream error")); - } - out - } - - #[tokio::test] - async fn concatenates_tool_call_arguments_across_deltas() { - let delta_name = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "index": 0, - "function": { "name": "do_a" } - }] - } - }] - }); - - let delta_args_1 = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "function": { "arguments": "{ \"foo\":" } - }] - } - }] - }); - - let delta_args_2 = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "function": { "arguments": "1}" } - }] - } - }] - }); - - let finish = json!({ - "choices": [{ - "finish_reason": "tool_calls" - }] - }); - - let body = build_body(&[delta_name, delta_args_1, delta_args_2, finish]); - let events = collect_events(&body).await; - assert_matches!( - &events[..], - [ - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }), - ResponseEvent::Completed { .. } - ] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}" - ); - } - - #[tokio::test] - async fn emits_multiple_tool_calls() { - let delta_a = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "function": { "name": "do_a", "arguments": "{\"foo\":1}" } - }] - } - }] - }); - - let delta_b = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_b", - "function": { "name": "do_b", "arguments": "{\"bar\":2}" } - }] - } - }] - }); - - let finish = json!({ - "choices": [{ - "finish_reason": "tool_calls" - }] - }); - - let body = build_body(&[delta_a, delta_b, finish]); - let events = collect_events(&body).await; - assert_matches!( - &events[..], - [ - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), - ResponseEvent::Completed { .. } - ] if call_a == "call_a" && name_a == "do_a" && args_a == "{\"foo\":1}" && call_b == "call_b" && name_b == "do_b" && args_b == "{\"bar\":2}" - ); - } - - #[tokio::test] - async fn emits_tool_calls_for_multiple_choices() { - let payload = json!({ - "choices": [ - { - "delta": { - "tool_calls": [{ - "id": "call_a", - "index": 0, - "function": { "name": "do_a", "arguments": "{}" } - }] - }, - "finish_reason": "tool_calls" - }, - { - "delta": { - "tool_calls": [{ - "id": "call_b", - "index": 0, - "function": { "name": "do_b", "arguments": "{}" } - }] - }, - "finish_reason": "tool_calls" - } - ] - }); - - let body = build_body(&[payload]); - let events = collect_events(&body).await; - assert_matches!( - &events[..], - [ - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_a, name: name_a, arguments: args_a, .. }), - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id: call_b, name: name_b, arguments: args_b, .. }), - ResponseEvent::Completed { .. } - ] if call_a == "call_a" && name_a == "do_a" && args_a == "{}" && call_b == "call_b" && name_b == "do_b" && args_b == "{}" - ); - } - - #[tokio::test] - async fn merges_tool_calls_by_index_when_id_missing_on_subsequent_deltas() { - let delta_with_id = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "id": "call_a", - "function": { "name": "do_a", "arguments": "{ \"foo\":" } - }] - } - }] - }); - - let delta_without_id = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "index": 0, - "function": { "arguments": "1}" } - }] - } - }] - }); - - let finish = json!({ - "choices": [{ - "finish_reason": "tool_calls" - }] - }); - - let body = build_body(&[delta_with_id, delta_without_id, finish]); - let events = collect_events(&body).await; - assert_matches!( - &events[..], - [ - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, arguments, .. }), - ResponseEvent::Completed { .. } - ] if call_id == "call_a" && name == "do_a" && arguments == "{ \"foo\":1}" - ); - } - - #[tokio::test] - async fn preserves_tool_call_name_when_empty_deltas_arrive() { - let delta_with_name = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "function": { "name": "do_a" } - }] - } - }] - }); - - let delta_with_empty_name = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "function": { "name": "", "arguments": "{}" } - }] - } - }] - }); - - let finish = json!({ - "choices": [{ - "finish_reason": "tool_calls" - }] - }); - - let body = build_body(&[delta_with_name, delta_with_empty_name, finish]); - let events = collect_events(&body).await; - assert_matches!( - &events[..], - [ - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { name, arguments, .. }), - ResponseEvent::Completed { .. } - ] if name == "do_a" && arguments == "{}" - ); - } - - #[tokio::test] - async fn emits_tool_calls_even_when_content_and_reasoning_present() { - let delta_content_and_tools = json!({ - "choices": [{ - "delta": { - "content": [{"text": "hi"}], - "reasoning": "because", - "tool_calls": [{ - "id": "call_a", - "function": { "name": "do_a", "arguments": "{}" } - }] - } - }] - }); - - let finish = json!({ - "choices": [{ - "finish_reason": "tool_calls" - }] - }); - - let body = build_body(&[delta_content_and_tools, finish]); - let events = collect_events(&body).await; - - assert_matches!( - &events[..], - [ - ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }), - ResponseEvent::ReasoningContentDelta { .. }, - ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }), - ResponseEvent::OutputTextDelta(delta), - ResponseEvent::OutputItemDone(ResponseItem::Reasoning { .. }), - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { call_id, name, .. }), - ResponseEvent::OutputItemDone(ResponseItem::Message { .. }), - ResponseEvent::Completed { .. } - ] if delta == "hi" && call_id == "call_a" && name == "do_a" - ); - } - - #[tokio::test] - async fn drops_partial_tool_calls_on_stop_finish_reason() { - let delta_tool = json!({ - "choices": [{ - "delta": { - "tool_calls": [{ - "id": "call_a", - "function": { "name": "do_a", "arguments": "{}" } - }] - } - }] - }); - - let finish_stop = json!({ - "choices": [{ - "finish_reason": "stop" - }] - }); - - let body = build_body(&[delta_tool, finish_stop]); - let events = collect_events(&body).await; - - assert!(!events.iter().any(|ev| { - matches!( - ev, - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { .. }) - ) - })); - assert_matches!(events.last(), Some(ResponseEvent::Completed { .. })); - } -} diff --git a/codex-rs/codex-api/src/sse/mod.rs b/codex-rs/codex-api/src/sse/mod.rs index e3ab770c43..cb689afc1d 100644 --- a/codex-rs/codex-api/src/sse/mod.rs +++ b/codex-rs/codex-api/src/sse/mod.rs @@ -1,4 +1,3 @@ -pub mod chat; pub mod responses; pub use responses::process_sse; diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 3040fb8724..96923ffe2f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -6,7 +6,6 @@ use anyhow::Result; use async_trait::async_trait; use bytes::Bytes; use codex_api::AuthProvider; -use codex_api::ChatClient; use codex_api::Provider; use codex_api::ResponsesClient; use codex_api::ResponsesOptions; @@ -195,34 +194,6 @@ data: {"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{ } } -#[tokio::test] -async fn chat_client_uses_chat_completions_path_for_chat_wire() -> Result<()> { - let state = RecordingState::default(); - let transport = RecordingTransport::new(state.clone()); - let client = ChatClient::new(transport, provider("openai", WireApi::Chat), NoAuth); - - let body = serde_json::json!({ "echo": true }); - let _stream = client.stream(body, HeaderMap::new()).await?; - - let requests = state.take_stream_requests(); - assert_path_ends_with(&requests, "/chat/completions"); - Ok(()) -} - -#[tokio::test] -async fn chat_client_uses_responses_path_for_responses_wire() -> Result<()> { - let state = RecordingState::default(); - let transport = RecordingTransport::new(state.clone()); - let client = ChatClient::new(transport, provider("openai", WireApi::Responses), NoAuth); - - let body = serde_json::json!({ "echo": true }); - let _stream = client.stream(body, HeaderMap::new()).await?; - - let requests = state.take_stream_requests(); - assert_path_ends_with(&requests, "/responses"); - Ok(()) -} - #[tokio::test] async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()> { let state = RecordingState::default(); @@ -240,10 +211,10 @@ async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()> } #[tokio::test] -async fn responses_client_uses_chat_path_for_chat_wire() -> Result<()> { +async fn responses_client_uses_responses_path_for_compact_wire() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); - let client = ResponsesClient::new(transport, provider("openai", WireApi::Chat), NoAuth); + let client = ResponsesClient::new(transport, provider("openai", WireApi::Compact), NoAuth); let body = serde_json::json!({ "echo": true }); let _stream = client @@ -251,7 +222,7 @@ async fn responses_client_uses_chat_path_for_chat_wire() -> Result<()> { .await?; let requests = state.take_stream_requests(); - assert_path_ends_with(&requests, "/chat/completions"); + assert_path_ends_with(&requests, "/responses"); Ok(()) } diff --git a/codex-rs/common/src/oss.rs b/codex-rs/common/src/oss.rs index f686bb6016..a44a6a7d32 100644 --- a/codex-rs/common/src/oss.rs +++ b/codex-rs/common/src/oss.rs @@ -1,52 +1,18 @@ //! OSS provider utilities shared between TUI and exec. use codex_core::LMSTUDIO_OSS_PROVIDER_ID; -use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; -use codex_core::WireApi; use codex_core::config::Config; -use codex_core::protocol::DeprecationNoticeEvent; -use std::io; /// Returns the default model for a given OSS provider. pub fn get_default_model_for_oss_provider(provider_id: &str) -> Option<&'static str> { match provider_id { LMSTUDIO_OSS_PROVIDER_ID => Some(codex_lmstudio::DEFAULT_OSS_MODEL), - OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL), + OLLAMA_OSS_PROVIDER_ID => Some(codex_ollama::DEFAULT_OSS_MODEL), _ => None, } } -/// Returns a deprecation notice if Ollama doesn't support the responses wire API. -pub async fn ollama_chat_deprecation_notice( - config: &Config, -) -> io::Result> { - if config.model_provider_id != OLLAMA_OSS_PROVIDER_ID - || config.model_provider.wire_api != WireApi::Responses - { - return Ok(None); - } - - if let Some(detection) = codex_ollama::detect_wire_api(&config.model_provider).await? - && detection.wire_api == WireApi::Chat - { - let version_suffix = detection - .version - .as_ref() - .map(|version| format!(" (version {version})")) - .unwrap_or_default(); - let summary = format!( - "Your Ollama server{version_suffix} doesn't support the Responses API. Either update Ollama or set `oss_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"` (or `model_provider = \"{OLLAMA_CHAT_PROVIDER_ID}\"`) in your config.toml to use the \"chat\" wire API. Support for the \"chat\" wire API is deprecated and will soon be removed." - ); - return Ok(Some(DeprecationNoticeEvent { - summary, - details: None, - })); - } - - Ok(None) -} - /// Ensures the specified OSS provider is ready (models downloaded, service reachable). pub async fn ensure_oss_provider_ready( provider_id: &str, @@ -58,7 +24,8 @@ pub async fn ensure_oss_provider_ready( .await .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; } - OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => { + OLLAMA_OSS_PROVIDER_ID => { + codex_ollama::ensure_responses_supported(&config.model_provider).await?; codex_ollama::ensure_oss_ready(config) .await .map_err(|e| std::io::Error::other(format!("OSS setup failed: {e}")))?; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2315f75860..0a048db693 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -464,7 +464,7 @@ "$ref": "#/definitions/WireApi" } ], - "default": "chat", + "default": "responses", "description": "Which wire protocol this provider expects." } }, @@ -1087,7 +1087,7 @@ "type": "string" }, "WireApi": { - "description": "Wire protocol that the provider speaks. Most third-party services only implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI itself (and a handful of others) additionally expose the more modern *Responses* API. The two protocols use different request/response shapes and *cannot* be auto-detected at runtime, therefore each provider entry must declare which one it expects.", + "description": "Wire protocol that the provider speaks.", "oneOf": [ { "description": "The Responses API exposed by OpenAI at `/v1/responses`.", @@ -1095,13 +1095,6 @@ "responses" ], "type": "string" - }, - { - "description": "Regular Chat Completions compatible with `/v1/chat/completions`.", - "enum": [ - "chat" - ], - "type": "string" } ] } @@ -1423,7 +1416,7 @@ "type": "array" }, "oss_provider": { - "description": "Preferred OSS provider for local models, e.g. \"lmstudio\", \"ollama\", or \"ollama-chat\".", + "description": "Preferred OSS provider for local models, e.g. \"lmstudio\" or \"ollama\".", "type": "string" }, "otel": { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 37fef163af..953b11b8f8 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -8,8 +8,6 @@ use crate::api_bridge::auth_provider_from_auth; use crate::api_bridge::map_api_error; use crate::auth::UnauthorizedRecovery; use crate::turn_metadata::build_turn_metadata_header; -use codex_api::AggregateStreamExt; -use codex_api::ChatClient as ApiChatClient; use codex_api::CompactClient as ApiCompactClient; use codex_api::CompactionInput as ApiCompactionInput; use codex_api::Prompt as ApiPrompt; @@ -17,7 +15,6 @@ use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; use codex_api::ResponseAppendWsRequest; use codex_api::ResponseCreateWsRequest; -use codex_api::ResponseStream as ApiResponseStream; use codex_api::ResponsesClient as ApiResponsesClient; use codex_api::ResponsesOptions as ApiResponsesOptions; use codex_api::ResponsesWebsocketClient as ApiWebSocketResponsesClient; @@ -69,7 +66,6 @@ use crate::features::Feature; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; -use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; use crate::transport_manager::TransportManager; @@ -310,11 +306,7 @@ impl ModelClientSession { .and_then(|cache| cache.header.clone()) } - /// Streams a single model turn using either the Responses or Chat - /// Completions wire API, depending on the configured provider. - /// - /// For Chat providers, the underlying stream is optionally aggregated - /// based on the `show_raw_agent_reasoning` flag in the config. + /// Streams a single model turn using the configured Responses transport. pub async fn stream(&mut self, prompt: &Prompt) -> Result { let wire_api = self.state.provider.wire_api; match wire_api { @@ -328,21 +320,6 @@ impl ModelClientSession { self.stream_responses_api(prompt).await } } - WireApi::Chat => { - let api_stream = self.stream_chat_completions(prompt).await?; - - if self.state.config.show_raw_agent_reasoning { - Ok(map_response_stream( - api_stream.streaming_mode(), - self.state.otel_manager.clone(), - )) - } else { - Ok(map_response_stream( - api_stream.aggregate(), - self.state.otel_manager.clone(), - )) - } - } } } @@ -544,64 +521,6 @@ impl ModelClientSession { } } - /// Streams a turn via the OpenAI Chat Completions API. - /// - /// This path is only used when the provider is configured with - /// `WireApi::Chat`; it does not support `output_schema` today. - async fn stream_chat_completions(&self, prompt: &Prompt) -> Result { - if prompt.output_schema.is_some() { - return Err(CodexErr::UnsupportedOperation( - "output_schema is not supported for Chat Completions API".to_string(), - )); - } - - let auth_manager = self.state.auth_manager.clone(); - let instructions = prompt.base_instructions.text.clone(); - let tools_json = create_tools_json_for_chat_completions_api(&prompt.tools)?; - let api_prompt = build_api_prompt(prompt, instructions, tools_json); - let conversation_id = self.state.conversation_id.to_string(); - let session_source = self.state.session_source.clone(); - - let mut auth_recovery = auth_manager - .as_ref() - .map(super::auth::AuthManager::unauthorized_recovery); - loop { - let auth = match auth_manager.as_ref() { - Some(manager) => manager.auth().await, - None => None, - }; - let api_provider = self - .state - .provider - .to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?; - let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?; - let transport = ReqwestTransport::new(build_reqwest_client()); - let (request_telemetry, sse_telemetry) = self.build_streaming_telemetry(); - let client = ApiChatClient::new(transport, api_provider, api_auth) - .with_telemetry(Some(request_telemetry), Some(sse_telemetry)); - - let stream_result = client - .stream_prompt( - &self.state.model_info.slug, - &api_prompt, - Some(conversation_id.clone()), - Some(session_source.clone()), - ) - .await; - - match stream_result { - Ok(stream) => return Ok(stream), - Err(ApiError::Transport(TransportError::Http { status, .. })) - if status == StatusCode::UNAUTHORIZED => - { - handle_unauthorized(status, &mut auth_recovery).await?; - continue; - } - Err(err) => return Err(map_api_error(err)), - } - } - } - /// Streams a turn via the OpenAI Responses API. /// /// Handles SSE fixtures, reasoning summaries, verbosity, and the @@ -709,7 +628,7 @@ impl ModelClientSession { } } - /// Builds request and SSE telemetry for streaming API calls (Chat/Responses). + /// Builds request and SSE telemetry for streaming API calls. fn build_streaming_telemetry(&self) -> (Arc, Arc) { let telemetry = Arc::new(ApiTelemetry::new(self.state.otel_manager.clone())); let request_telemetry: Arc = telemetry.clone(); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ba84b6b432..d6d4d93b57 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3,9 +3,7 @@ use std::collections::HashSet; use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; -use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; use crate::AuthManager; use crate::CodexAuth; @@ -96,7 +94,6 @@ use tracing::trace_span; use tracing::warn; use crate::ModelProviderInfo; -use crate::WireApi; use crate::client::ModelClient; use crate::client::ModelClientSession; use crate::client_common::Prompt; @@ -130,7 +127,6 @@ use crate::mentions::build_connector_slug_counts; use crate::mentions::build_skill_name_counts; use crate::mentions::collect_explicit_app_paths; use crate::mentions::collect_tool_mentions_from_messages; -use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; use crate::project_doc::get_user_instructions; use crate::proposed_plan_parser::ProposedPlanParser; use crate::proposed_plan_parser::ProposedPlanSegment; @@ -243,31 +239,6 @@ pub struct CodexSpawnOk { pub(crate) const INITIAL_SUBMIT_ID: &str = ""; pub(crate) const SUBMISSION_CHANNEL_CAPACITY: usize = 64; -static CHAT_WIRE_API_DEPRECATION_EMITTED: AtomicBool = AtomicBool::new(false); - -fn maybe_push_chat_wire_api_deprecation( - config: &Config, - post_session_configured_events: &mut Vec, -) { - if config.model_provider.wire_api != WireApi::Chat { - return; - } - - if CHAT_WIRE_API_DEPRECATION_EMITTED - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - post_session_configured_events.push(Event { - id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { - summary: CHAT_WIRE_API_DEPRECATION_SUMMARY.to_string(), - details: None, - }), - }); -} impl Codex { /// Spawn a new [`Codex`] and initialize the session. @@ -865,7 +836,6 @@ impl Session { }), }); } - maybe_push_chat_wire_api_deprecation(&config, &mut post_session_configured_events); maybe_push_unstable_features_warning(&config, &mut post_session_configured_events); let auth = auth.as_ref(); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8a53f388b9..bafdb4b59e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -32,9 +32,10 @@ use crate::features::FeatureOverrides; use crate::features::Features; use crate::features::FeaturesToml; use crate::git_info::resolve_root_git_project_for_trust; +use crate::model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; -use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_ID; +use crate::model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; @@ -757,14 +758,20 @@ pub fn set_project_trust_level( pub fn set_default_oss_provider(codex_home: &Path, provider: &str) -> std::io::Result<()> { // Validate that the provider is one of the known OSS providers match provider { - LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID | OLLAMA_CHAT_PROVIDER_ID => { + LMSTUDIO_OSS_PROVIDER_ID | OLLAMA_OSS_PROVIDER_ID => { // Valid provider, continue } + LEGACY_OLLAMA_CHAT_PROVIDER_ID => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + OLLAMA_CHAT_PROVIDER_REMOVED_ERROR, + )); + } _ => { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!( - "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID}" + "Invalid OSS provider '{provider}'. Must be one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}" ), )); } @@ -985,7 +992,7 @@ pub struct ConfigToml { pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, - /// Preferred OSS provider for local models, e.g. "lmstudio", "ollama", or "ollama-chat". + /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". pub oss_provider: Option, } @@ -1411,10 +1418,12 @@ impl Config { let model_provider = model_providers .get(&model_provider_id) .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("Model provider `{model_provider_id}` not found"), - ) + let message = if model_provider_id == LEGACY_OLLAMA_CHAT_PROVIDER_ID { + OLLAMA_CHAT_PROVIDER_REMOVED_ERROR.to_string() + } else { + format!("Model provider `{model_provider_id}` not found") + }; + std::io::Error::new(std::io::ErrorKind::NotFound, message) })? .clone(); @@ -3576,7 +3585,7 @@ model = "gpt-5.1-codex" cfg: ConfigToml, model_provider_map: HashMap, openai_provider: ModelProviderInfo, - openai_chat_completions_provider: ModelProviderInfo, + openai_custom_provider: ModelProviderInfo, } impl PrecedenceTestFixture { @@ -3658,11 +3667,11 @@ profile = "gpt3" [analytics] enabled = true -[model_providers.openai-chat-completions] -name = "OpenAI using Chat Completions" +[model_providers.openai-custom] +name = "OpenAI custom" base_url = "https://api.openai.com/v1" env_key = "OPENAI_API_KEY" -wire_api = "chat" +wire_api = "responses" request_max_retries = 4 # retry failed HTTP requests stream_max_retries = 10 # retry dropped SSE streams stream_idle_timeout_ms = 300000 # 5m idle timeout @@ -3676,7 +3685,7 @@ model_reasoning_summary = "detailed" [profiles.gpt3] model = "gpt-3.5-turbo" -model_provider = "openai-chat-completions" +model_provider = "openai-custom" [profiles.zdr] model = "o3" @@ -3707,11 +3716,11 @@ model_verbosity = "high" let codex_home_temp_dir = TempDir::new().unwrap(); - let openai_chat_completions_provider = ModelProviderInfo { - name: "OpenAI using Chat Completions".to_string(), + let openai_custom_provider = ModelProviderInfo { + name: "OpenAI custom".to_string(), base_url: Some("https://api.openai.com/v1".to_string()), env_key: Some("OPENAI_API_KEY".to_string()), - wire_api: crate::WireApi::Chat, + wire_api: crate::WireApi::Responses, env_key_instructions: None, experimental_bearer_token: None, query_params: None, @@ -3725,10 +3734,7 @@ model_verbosity = "high" }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); - model_provider_map.insert( - "openai-chat-completions".to_string(), - openai_chat_completions_provider.clone(), - ); + model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); model_provider_map }; @@ -3743,7 +3749,7 @@ model_verbosity = "high" cfg, model_provider_map, openai_provider, - openai_chat_completions_provider, + openai_custom_provider, }) } @@ -3864,8 +3870,8 @@ model_verbosity = "high" review_model: None, model_context_window: None, model_auto_compact_token_limit: None, - model_provider_id: "openai-chat-completions".to_string(), - model_provider: fixture.openai_chat_completions_provider.clone(), + model_provider_id: "openai-custom".to_string(), + model_provider: fixture.openai_custom_provider.clone(), approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()), enforce_residency: Constrained::allow_any(None), @@ -4266,6 +4272,50 @@ trust_level = "trusted" Ok(()) } + #[test] + fn test_set_default_oss_provider_rejects_legacy_ollama_chat_provider() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path(); + + let result = set_default_oss_provider(codex_home, LEGACY_OLLAMA_CHAT_PROVIDER_ID); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert!( + error + .to_string() + .contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR) + ); + + Ok(()) + } + + #[test] + fn test_load_config_rejects_legacy_ollama_chat_provider_with_helpful_error() + -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cfg = ConfigToml { + model_provider: Some(LEGACY_OLLAMA_CHAT_PROVIDER_ID.to_string()), + ..Default::default() + }; + + let result = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.path().to_path_buf(), + ); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::NotFound); + assert!( + error + .to_string() + .contains(OLLAMA_CHAT_PROVIDER_REMOVED_ERROR) + ); + + Ok(()) + } + #[test] fn test_untrusted_project_gets_workspace_write_sandbox() -> anyhow::Result<()> { let config_with_untrusted = r#" diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1f81f3eab2..c9d67e19f1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -61,12 +61,10 @@ pub mod token_data; mod truncate; mod unified_exec; pub mod windows_sandbox; -pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; pub use model_provider_info::DEFAULT_LMSTUDIO_PORT; pub use model_provider_info::DEFAULT_OLLAMA_PORT; pub use model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; pub use model_provider_info::ModelProviderInfo; -pub use model_provider_info::OLLAMA_CHAT_PROVIDER_ID; pub use model_provider_info::OLLAMA_OSS_PROVIDER_ID; pub use model_provider_info::WireApi; pub use model_provider_info::built_in_model_providers; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 6ba913489e..126389193d 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -28,25 +28,33 @@ const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; const MAX_STREAM_MAX_RETRIES: u64 = 100; /// Hard cap for user-configured `request_max_retries`. const MAX_REQUEST_MAX_RETRIES: u64 = 100; -pub const CHAT_WIRE_API_DEPRECATION_SUMMARY: &str = r#"Support for the "chat" wire API is deprecated and will soon be removed. Update your model provider definition in config.toml to use wire_api = "responses"."#; const OPENAI_PROVIDER_NAME: &str = "OpenAI"; +const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782"; +pub(crate) const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat"; +pub(crate) const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782"; -/// Wire protocol that the provider speaks. Most third-party services only -/// implement the classic OpenAI Chat Completions JSON schema, whereas OpenAI -/// itself (and a handful of others) additionally expose the more modern -/// *Responses* API. The two protocols use different request/response shapes -/// and *cannot* be auto-detected at runtime, therefore each provider entry -/// must declare which one it expects. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +/// Wire protocol that the provider speaks. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum WireApi { /// The Responses API exposed by OpenAI at `/v1/responses`. - Responses, - - /// Regular Chat Completions compatible with `/v1/chat/completions`. #[default] - Chat, + Responses, +} + +impl<'de> Deserialize<'de> for WireApi { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "responses" => Ok(Self::Responses), + "chat" => Err(serde::de::Error::custom(CHAT_WIRE_API_REMOVED_ERROR)), + _ => Err(serde::de::Error::unknown_variant(&value, &["responses"])), + } + } } /// Serializable representation of a provider definition. @@ -163,7 +171,6 @@ impl ModelProviderInfo { query_params: self.query_params.clone(), wire: match self.wire_api { WireApi::Responses => ApiWireApi::Responses, - WireApi::Chat => ApiWireApi::Chat, }, headers, retry, @@ -172,12 +179,11 @@ impl ModelProviderInfo { } pub(crate) fn is_azure_responses_endpoint(&self) -> bool { - let wire = match self.wire_api { - WireApi::Responses => ApiWireApi::Responses, - WireApi::Chat => ApiWireApi::Chat, - }; - - is_azure_responses_wire_base_url(wire, &self.name, self.base_url.as_deref()) + is_azure_responses_wire_base_url( + ApiWireApi::Responses, + &self.name, + self.base_url.as_deref(), + ) } /// If `env_key` is Some, returns the API key for this provider if present @@ -277,7 +283,6 @@ pub const DEFAULT_OLLAMA_PORT: u16 = 11434; pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio"; pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama"; -pub const OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat"; /// Built-in default provider list. pub fn built_in_model_providers() -> HashMap { @@ -293,10 +298,6 @@ pub fn built_in_model_providers() -> HashMap { OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses), ), - ( - OLLAMA_CHAT_PROVIDER_ID, - create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Chat), - ), ( LMSTUDIO_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses), @@ -363,7 +364,7 @@ base_url = "http://localhost:11434/v1" env_key: None, env_key_instructions: None, experimental_bearer_token: None, - wire_api: WireApi::Chat, + wire_api: WireApi::Responses, query_params: None, http_headers: None, env_http_headers: None, @@ -392,7 +393,7 @@ query_params = { api-version = "2025-04-01-preview" } env_key: Some("AZURE_OPENAI_API_KEY".into()), env_key_instructions: None, experimental_bearer_token: None, - wire_api: WireApi::Chat, + wire_api: WireApi::Responses, query_params: Some(maplit::hashmap! { "api-version".to_string() => "2025-04-01-preview".to_string(), }), @@ -424,7 +425,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } env_key: Some("API_KEY".into()), env_key_instructions: None, experimental_bearer_token: None, - wire_api: WireApi::Chat, + wire_api: WireApi::Responses, query_params: None, http_headers: Some(maplit::hashmap! { "X-Example-Header".to_string() => "example-value".to_string(), @@ -442,4 +443,17 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); assert_eq!(expected_provider, provider); } + + #[test] + fn test_deserialize_chat_wire_api_shows_helpful_error() { + let provider_toml = r#" +name = "OpenAI using Chat Completions" +base_url = "https://api.openai.com/v1" +env_key = "OPENAI_API_KEY" +wire_api = "chat" + "#; + + let err = toml::from_str::(provider_toml).unwrap_err(); + assert!(err.to_string().contains(CHAT_WIRE_API_REMOVED_ERROR)); + } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index c08feeef9a..bd37e8bcc0 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1048,42 +1048,6 @@ pub fn create_tools_json_for_responses_api( Ok(tools_json) } -/// Returns JSON values that are compatible with Function Calling in the -/// Chat Completions API: -/// https://platform.openai.com/docs/guides/function-calling?api-mode=chat -pub(crate) fn create_tools_json_for_chat_completions_api( - tools: &[ToolSpec], -) -> crate::error::Result> { - // We start with the JSON for the Responses API and than rewrite it to match - // the chat completions tool call format. - let responses_api_tools_json = create_tools_json_for_responses_api(tools)?; - let tools_json = responses_api_tools_json - .into_iter() - .filter_map(|mut tool| { - if tool.get("type") != Some(&serde_json::Value::String("function".to_string())) { - return None; - } - - if let Some(map) = tool.as_object_mut() { - let name = map - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or_default() - .to_string(); - // Remove "type" field as it is not needed in chat completions. - map.remove("type"); - Some(json!({ - "type": "function", - "name": name, - "function": map, - })) - } else { - None - } - }) - .collect::>(); - Ok(tools_json) -} pub(crate) fn mcp_tool_to_openai_tool( fully_qualified_name: String, @@ -2578,26 +2542,5 @@ Examples of valid command strings: }, })] ); - - let tools_json = create_tools_json_for_chat_completions_api(&tools).unwrap(); - - assert_eq!( - tools_json, - vec![json!({ - "type": "function", - "name": "demo", - "function": { - "name": "demo", - "description": "A demo tool", - "strict": false, - "parameters": { - "type": "object", - "properties": { - "foo": { "type": "string" } - }, - }, - } - })] - ); } } diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs deleted file mode 100644 index 2772091441..0000000000 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ /dev/null @@ -1,338 +0,0 @@ -#![allow(clippy::expect_used)] - -use std::sync::Arc; - -use codex_app_server_protocol::AuthMode; -use codex_core::ContentItem; -use codex_core::LocalShellAction; -use codex_core::LocalShellExecAction; -use codex_core::LocalShellStatus; -use codex_core::ModelClient; -use codex_core::ModelProviderInfo; -use codex_core::Prompt; -use codex_core::ResponseItem; -use codex_core::TransportManager; -use codex_core::WireApi; -use codex_core::models_manager::manager::ModelsManager; -use codex_otel::OtelManager; -use codex_protocol::ThreadId; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::protocol::SessionSource; -use core_test_support::load_default_config_for_test; -use core_test_support::skip_if_no_network; -use futures::StreamExt; -use serde_json::Value; -use tempfile::TempDir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; - -async fn run_request(input: Vec) -> Value { - let server = MockServer::start().await; - - let template = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw( - "data: {\"choices\":[{\"delta\":{}}]}\n\ndata: [DONE]\n\n", - "text/event-stream", - ); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .respond_with(template) - .expect(1) - .mount(&server) - .await; - - let provider = ModelProviderInfo { - name: "mock".into(), - base_url: Some(format!("{}/v1", server.uri())), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Chat, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(5_000), - requires_openai_auth: false, - supports_websockets: false, - }; - - let codex_home = match TempDir::new() { - Ok(dir) => dir, - Err(e) => panic!("failed to create TempDir: {e}"), - }; - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider_id = provider.name.clone(); - config.model_provider = provider.clone(); - config.show_raw_agent_reasoning = true; - let effort = config.model_reasoning_effort; - let summary = config.model_reasoning_summary; - let config = Arc::new(config); - - let conversation_id = ThreadId::new(); - let model = ModelsManager::get_model_offline(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); - let otel_manager = OtelManager::new( - conversation_id, - model.as_str(), - model_info.slug.as_str(), - None, - Some("test@test.com".to_string()), - Some(AuthMode::ApiKey), - false, - "test".to_string(), - SessionSource::Exec, - ); - - let mut client_session = ModelClient::new( - Arc::clone(&config), - None, - model_info, - otel_manager, - provider, - effort, - summary, - conversation_id, - SessionSource::Exec, - TransportManager::new(), - ) - .new_session(None); - - let mut prompt = Prompt::default(); - prompt.input = input; - - let mut stream = match client_session.stream(&prompt).await { - Ok(s) => s, - Err(e) => panic!("stream chat failed: {e}"), - }; - while let Some(event) = stream.next().await { - if let Err(e) = event { - panic!("stream event error: {e}"); - } - } - - let all_requests = server.received_requests().await.expect("received requests"); - let requests: Vec<_> = all_requests - .iter() - .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) - .collect(); - let request = requests - .first() - .unwrap_or_else(|| panic!("expected POST request to /chat/completions")); - match request.body_json() { - Ok(v) => v, - Err(e) => panic!("invalid json body: {e}"), - } -} - -fn user_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } -} - -fn assistant_message(text: &str) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: text.to_string(), - }], - end_turn: None, - phase: None, - } -} - -fn reasoning_item(text: &str) -> ResponseItem { - ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: text.to_string(), - }]), - encrypted_content: None, - } -} - -fn function_call() -> ResponseItem { - ResponseItem::FunctionCall { - id: None, - name: "f".to_string(), - arguments: "{}".to_string(), - call_id: "c1".to_string(), - } -} - -fn local_shell_call() -> ResponseItem { - ResponseItem::LocalShellCall { - id: Some("id1".to_string()), - call_id: None, - status: LocalShellStatus::InProgress, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["echo".to_string()], - timeout_ms: Some(1_000), - working_directory: None, - env: None, - user: None, - }), - } -} - -fn messages_from(body: &Value) -> Vec { - match body["messages"].as_array() { - Some(arr) => arr.clone(), - None => panic!("messages array missing"), - } -} - -fn first_assistant(messages: &[Value]) -> &Value { - match messages.iter().find(|msg| msg["role"] == "assistant") { - Some(v) => v, - None => panic!("assistant message not present"), - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn omits_reasoning_when_none_present() { - skip_if_no_network!(); - - let body = run_request(vec![user_message("u1"), assistant_message("a1")]).await; - let messages = messages_from(&body); - let assistant = first_assistant(&messages); - - assert_eq!(assistant["content"], Value::String("a1".into())); - assert!(assistant.get("reasoning").is_none()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attaches_reasoning_to_previous_assistant() { - skip_if_no_network!(); - - let body = run_request(vec![ - user_message("u1"), - assistant_message("a1"), - reasoning_item("rA"), - ]) - .await; - let messages = messages_from(&body); - let assistant = first_assistant(&messages); - - assert_eq!(assistant["content"], Value::String("a1".into())); - assert_eq!(assistant["reasoning"], Value::String("rA".into())); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attaches_reasoning_to_function_call_anchor() { - skip_if_no_network!(); - - let body = run_request(vec![ - user_message("u1"), - reasoning_item("rFunc"), - function_call(), - ]) - .await; - let messages = messages_from(&body); - let assistant = first_assistant(&messages); - - assert_eq!(assistant["reasoning"], Value::String("rFunc".into())); - let tool_calls = match assistant["tool_calls"].as_array() { - Some(arr) => arr, - None => panic!("tool call list missing"), - }; - assert_eq!(tool_calls.len(), 1); - assert_eq!(tool_calls[0]["type"], Value::String("function".into())); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn attaches_reasoning_to_local_shell_call() { - skip_if_no_network!(); - - let body = run_request(vec![ - user_message("u1"), - reasoning_item("rShell"), - local_shell_call(), - ]) - .await; - let messages = messages_from(&body); - let assistant = first_assistant(&messages); - - assert_eq!(assistant["reasoning"], Value::String("rShell".into())); - assert_eq!( - assistant["tool_calls"][0]["type"], - Value::String("local_shell_call".into()) - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn drops_reasoning_when_last_role_is_user() { - skip_if_no_network!(); - - let body = run_request(vec![ - assistant_message("aPrev"), - reasoning_item("rHist"), - user_message("uNew"), - ]) - .await; - let messages = messages_from(&body); - assert!(messages.iter().all(|msg| msg.get("reasoning").is_none())); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn ignores_reasoning_before_last_user() { - skip_if_no_network!(); - - let body = run_request(vec![ - user_message("u1"), - assistant_message("a1"), - user_message("u2"), - reasoning_item("rAfterU1"), - ]) - .await; - let messages = messages_from(&body); - assert!(messages.iter().all(|msg| msg.get("reasoning").is_none())); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn skips_empty_reasoning_segments() { - skip_if_no_network!(); - - let body = run_request(vec![ - user_message("u1"), - assistant_message("a1"), - reasoning_item(""), - reasoning_item(" "), - ]) - .await; - let messages = messages_from(&body); - let assistant = first_assistant(&messages); - assert!(assistant.get("reasoning").is_none()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn suppresses_duplicate_assistant_messages() { - skip_if_no_network!(); - - let body = run_request(vec![assistant_message("dup"), assistant_message("dup")]).await; - let messages = messages_from(&body); - let assistant_messages: Vec<_> = messages - .iter() - .filter(|msg| msg["role"] == "assistant") - .collect(); - assert_eq!(assistant_messages.len(), 1); - assert_eq!( - assistant_messages[0]["content"], - Value::String("dup".into()) - ); -} diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs deleted file mode 100644 index 98e2fd9f0f..0000000000 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ /dev/null @@ -1,466 +0,0 @@ -use assert_matches::assert_matches; -use codex_core::AuthManager; -use std::sync::Arc; -use tracing_test::traced_test; - -use codex_core::CodexAuth; -use codex_core::ContentItem; -use codex_core::ModelClient; -use codex_core::ModelProviderInfo; -use codex_core::Prompt; -use codex_core::ResponseEvent; -use codex_core::ResponseItem; -use codex_core::TransportManager; -use codex_core::WireApi; -use codex_core::models_manager::manager::ModelsManager; -use codex_otel::OtelManager; -use codex_protocol::ThreadId; -use codex_protocol::models::ReasoningItemContent; -use codex_protocol::protocol::SessionSource; -use core_test_support::load_default_config_for_test; -use core_test_support::skip_if_no_network; -use futures::StreamExt; -use tempfile::TempDir; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; - -async fn run_stream(sse_body: &str) -> Vec { - run_stream_with_bytes(sse_body.as_bytes()).await -} - -async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { - let server = MockServer::start().await; - - let template = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_bytes(sse_body.to_vec()); - - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .respond_with(template) - .expect(1) - .mount(&server) - .await; - - let provider = ModelProviderInfo { - name: "mock".into(), - base_url: Some(format!("{}/v1", server.uri())), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - wire_api: WireApi::Chat, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: Some(5_000), - requires_openai_auth: false, - supports_websockets: false, - }; - - let codex_home = match TempDir::new() { - Ok(dir) => dir, - Err(e) => panic!("failed to create TempDir: {e}"), - }; - let mut config = load_default_config_for_test(&codex_home).await; - config.model_provider_id = provider.name.clone(); - config.model_provider = provider.clone(); - config.show_raw_agent_reasoning = true; - let effort = config.model_reasoning_effort; - let summary = config.model_reasoning_summary; - let config = Arc::new(config); - - let conversation_id = ThreadId::new(); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); - let auth_mode = auth_manager.get_auth_mode(); - let model = ModelsManager::get_model_offline(config.model.as_deref()); - let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); - let otel_manager = OtelManager::new( - conversation_id, - model.as_str(), - model_info.slug.as_str(), - None, - Some("test@test.com".to_string()), - auth_mode, - false, - "test".to_string(), - SessionSource::Exec, - ); - - let mut client = ModelClient::new( - Arc::clone(&config), - None, - model_info, - otel_manager, - provider, - effort, - summary, - conversation_id, - SessionSource::Exec, - TransportManager::new(), - ) - .new_session(None); - - let mut prompt = Prompt::default(); - prompt.input = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "hello".to_string(), - }], - end_turn: None, - phase: None, - }]; - - let mut stream = match client.stream(&prompt).await { - Ok(s) => s, - Err(e) => panic!("stream chat failed: {e}"), - }; - let mut events = Vec::new(); - while let Some(event) = stream.next().await { - match event { - Ok(ev) => events.push(ev), - // We still collect the error to exercise telemetry and complete the task. - Err(_e) => break, - } - } - events -} - -fn assert_message(item: &ResponseItem, expected: &str) { - if let ResponseItem::Message { content, .. } = item { - let text = content.iter().find_map(|part| match part { - ContentItem::OutputText { text } | ContentItem::InputText { text } => Some(text), - _ => None, - }); - let Some(text) = text else { - panic!("message missing text: {item:?}"); - }; - assert_eq!(text, expected); - } else { - panic!("expected message item, got: {item:?}"); - } -} - -fn assert_reasoning(item: &ResponseItem, expected: &str) { - if let ResponseItem::Reasoning { - content: Some(parts), - .. - } = item - { - let mut combined = String::new(); - for part in parts { - match part { - ReasoningItemContent::ReasoningText { text } - | ReasoningItemContent::Text { text } => combined.push_str(text), - } - } - assert_eq!(combined, expected); - } else { - panic!("expected reasoning item, got: {item:?}"); - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn streams_text_without_reasoning() { - skip_if_no_network!(); - - let sse = concat!( - "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n", - "data: {\"choices\":[{\"delta\":{}}]}\n\n", - "data: [DONE]\n\n", - ); - - let events = run_stream(sse).await; - assert_eq!(events.len(), 4, "unexpected events: {events:?}"); - - match &events[0] { - ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {} - other => panic!("expected initial assistant item, got {other:?}"), - } - - match &events[1] { - ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "hi"), - other => panic!("expected text delta, got {other:?}"), - } - - match &events[2] { - ResponseEvent::OutputItemDone(item) => assert_message(item, "hi"), - other => panic!("expected terminal message, got {other:?}"), - } - - assert_matches!(events[3], ResponseEvent::Completed { .. }); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn streams_reasoning_from_string_delta() { - skip_if_no_network!(); - - let sse = concat!( - "data: {\"choices\":[{\"delta\":{\"reasoning\":\"think1\"}}]}\n\n", - "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\n", - "data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n", - ); - - let events = run_stream(sse).await; - assert_eq!(events.len(), 7, "unexpected events: {events:?}"); - - match &events[0] { - ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {} - other => panic!("expected initial reasoning item, got {other:?}"), - } - - match &events[1] { - ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => { - assert_eq!(delta, "think1"); - assert_eq!(content_index, &0); - } - other => panic!("expected reasoning delta, got {other:?}"), - } - - match &events[2] { - ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {} - other => panic!("expected initial message item, got {other:?}"), - } - - match &events[3] { - ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "ok"), - other => panic!("expected text delta, got {other:?}"), - } - - match &events[4] { - ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "think1"), - other => panic!("expected terminal reasoning, got {other:?}"), - } - - match &events[5] { - ResponseEvent::OutputItemDone(item) => assert_message(item, "ok"), - other => panic!("expected terminal message, got {other:?}"), - } - - assert_matches!(events[6], ResponseEvent::Completed { .. }); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn streams_reasoning_from_object_delta() { - skip_if_no_network!(); - - let sse = concat!( - "data: {\"choices\":[{\"delta\":{\"reasoning\":{\"text\":\"partA\"}}}]}\n\n", - "data: {\"choices\":[{\"delta\":{\"reasoning\":{\"content\":\"partB\"}}}]}\n\n", - "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n", - "data: {\"choices\":[{\"delta\":{} ,\"finish_reason\":\"stop\"}]}\n\n", - ); - - let events = run_stream(sse).await; - assert_eq!(events.len(), 8, "unexpected events: {events:?}"); - - match &events[0] { - ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {} - other => panic!("expected initial reasoning item, got {other:?}"), - } - - match &events[1] { - ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => { - assert_eq!(delta, "partA"); - assert_eq!(content_index, &0); - } - other => panic!("expected reasoning delta, got {other:?}"), - } - - match &events[2] { - ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => { - assert_eq!(delta, "partB"); - assert_eq!(content_index, &1); - } - other => panic!("expected reasoning delta, got {other:?}"), - } - - match &events[3] { - ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {} - other => panic!("expected initial message item, got {other:?}"), - } - - match &events[4] { - ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "answer"), - other => panic!("expected text delta, got {other:?}"), - } - - match &events[5] { - ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "partApartB"), - other => panic!("expected terminal reasoning, got {other:?}"), - } - - match &events[6] { - ResponseEvent::OutputItemDone(item) => assert_message(item, "answer"), - other => panic!("expected terminal message, got {other:?}"), - } - - assert_matches!(events[7], ResponseEvent::Completed { .. }); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn streams_reasoning_from_final_message() { - skip_if_no_network!(); - - let sse = "data: {\"choices\":[{\"message\":{\"reasoning\":\"final-cot\"},\"finish_reason\":\"stop\"}]}\n\n"; - - let events = run_stream(sse).await; - assert_eq!(events.len(), 4, "unexpected events: {events:?}"); - - match &events[0] { - ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {} - other => panic!("expected initial reasoning item, got {other:?}"), - } - - match &events[1] { - ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => { - assert_eq!(delta, "final-cot"); - assert_eq!(content_index, &0); - } - other => panic!("expected reasoning delta, got {other:?}"), - } - - match &events[2] { - ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "final-cot"), - other => panic!("expected reasoning item, got {other:?}"), - } - - assert_matches!(events[3], ResponseEvent::Completed { .. }); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn streams_reasoning_before_tool_call() { - skip_if_no_network!(); - - let sse = concat!( - "data: {\"choices\":[{\"delta\":{\"reasoning\":\"pre-tool\"}}]}\n\n", - "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"run\",\"arguments\":\"{}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n", - ); - - let events = run_stream(sse).await; - assert_eq!(events.len(), 5, "unexpected events: {events:?}"); - - match &events[0] { - ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {} - other => panic!("expected initial reasoning item, got {other:?}"), - } - - match &events[1] { - ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => { - assert_eq!(delta, "pre-tool"); - assert_eq!(content_index, &0); - } - other => panic!("expected reasoning delta, got {other:?}"), - } - - match &events[2] { - ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "pre-tool"), - other => panic!("expected reasoning item, got {other:?}"), - } - - match &events[3] { - ResponseEvent::OutputItemDone(ResponseItem::FunctionCall { - name, - arguments, - call_id, - .. - }) => { - assert_eq!(name, "run"); - assert_eq!(arguments, "{}"); - assert_eq!(call_id, "call_1"); - } - other => panic!("expected function call, got {other:?}"), - } - - assert_matches!(events[4], ResponseEvent::Completed { .. }); -} - -#[tokio::test] -#[traced_test] -async fn chat_sse_emits_failed_on_parse_error() { - skip_if_no_network!(); - - let sse_body = concat!("data: not-json\n\n", "data: [DONE]\n\n"); - - let _ = run_stream(sse_body).await; - - logs_assert(|lines: &[&str]| { - lines - .iter() - .find(|line| { - line.contains("codex.api_request") && line.contains("http.response.status_code=200") - }) - .map(|_| Ok(())) - .unwrap_or(Err("cannot find codex.api_request event".to_string())) - }); - - logs_assert(|lines: &[&str]| { - lines - .iter() - .find(|line| { - line.contains("codex.sse_event") - && line.contains("error.message") - && line.contains("expected ident at line 1 column 2") - }) - .map(|_| Ok(())) - .unwrap_or(Err("cannot find SSE event".to_string())) - }); -} - -#[tokio::test] -#[traced_test] -async fn chat_sse_done_chunk_emits_event() { - skip_if_no_network!(); - - let sse_body = "data: [DONE]\n\n"; - - let _ = run_stream(sse_body).await; - - logs_assert(|lines: &[&str]| { - lines - .iter() - .find(|line| line.contains("codex.sse_event") && line.contains("event.kind=message")) - .map(|_| Ok(())) - .unwrap_or(Err("cannot find SSE event".to_string())) - }); -} - -#[tokio::test] -#[traced_test] -async fn chat_sse_emits_error_on_invalid_utf8() { - skip_if_no_network!(); - - let _ = run_stream_with_bytes(b"data: \x80\x80\n\n").await; - - logs_assert(|lines: &[&str]| { - lines - .iter() - .find(|line| { - line.contains("codex.sse_event") - && line.contains("error.message") - && line.contains("UTF8 error: invalid utf-8 sequence of 1 bytes from index 0") - }) - .map(|_| Ok(())) - .unwrap_or(Err("cannot find SSE event".to_string())) - }); -} diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 9ca2c6bd68..291f596e27 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -4,15 +4,12 @@ use codex_core::auth::CODEX_API_KEY_ENV_VAR; use codex_core::protocol::GitInfo; use codex_utils_cargo_bin::find_resource; use core_test_support::fs_wait; +use core_test_support::responses; use core_test_support::skip_if_no_network; use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; -use wiremock::Mock; use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; fn repo_root() -> std::path::PathBuf { #[expect(clippy::expect_used)] @@ -24,41 +21,28 @@ fn cli_responses_fixture() -> std::path::PathBuf { find_resource!("tests/cli_responses_fixture.sse").expect("failed to resolve fixture path") } -/// Tests streaming chat completions through the CLI using a mock server. -/// This test: -/// 1. Sets up a mock server that simulates OpenAI's chat completions API -/// 2. Configures codex to use this mock server via a custom provider -/// 3. Sends a simple "hello?" prompt and verifies the streamed response -/// 4. Ensures the response is received exactly once and contains "hi" +/// Tests streaming the Responses API through the CLI using a mock server. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn chat_mode_stream_cli() { +async fn responses_mode_stream_cli() { skip_if_no_network!(); let server = MockServer::start().await; let repo_root = repo_root(); - let sse = concat!( - "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n", - "data: {\"choices\":[{\"delta\":{}}]}\n\n", - "data: [DONE]\n\n" - ); - Mock::given(method("POST")) - .and(path("/v1/chat/completions")) - .respond_with( - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse, "text/event-stream"), - ) - .expect(1) - .mount(&server) - .await; + let sse = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "hi"), + responses::ev_completed("resp-1"), + ]); + let resp_mock = responses::mount_sse_once(&server, sse).await; let home = TempDir::new().unwrap(); let provider_override = format!( - "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"chat\" }}", + "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}", server.uri() ); let bin = codex_utils_cargo_bin::cargo_bin("codex").unwrap(); let mut cmd = AssertCommand::new(bin); + cmd.timeout(Duration::from_secs(30)); cmd.arg("exec") .arg("--skip-git-repo-check") .arg("-c") @@ -81,7 +65,8 @@ async fn chat_mode_stream_cli() { let hi_lines = stdout.lines().filter(|line| line.trim() == "hi").count(); assert_eq!(hi_lines, 1, "Expected exactly one line with 'hi'"); - server.verify().await; + let request = resp_mock.single_request(); + assert_eq!(request.path(), "/v1/responses"); // Verify a new session rollout was created and is discoverable via list_conversations let provider_filter = vec!["mock".to_string()]; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cdf6a1adc5..b5664c2e72 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -329,200 +329,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial(mcp_test_value)] -async fn stdio_image_completions_round_trip() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - - let call_id = "img-cc-1"; - let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image"); - - let tool_call = json!({ - "choices": [ - { - "delta": { - "tool_calls": [ - { - "id": call_id, - "type": "function", - "function": {"name": tool_name, "arguments": "{}"} - } - ] - }, - "finish_reason": "tool_calls" - } - ] - }); - let sse_tool_call = format!( - "data: {}\n\ndata: [DONE]\n\n", - serde_json::to_string(&tool_call)? - ); - - let final_assistant = json!({ - "choices": [ - { - "delta": {"content": "rmcp image tool completed successfully."}, - "finish_reason": "stop" - } - ] - }); - let sse_final = format!( - "data: {}\n\ndata: [DONE]\n\n", - serde_json::to_string(&final_assistant)? - ); - - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - struct ChatSeqResponder { - num_calls: AtomicUsize, - bodies: Vec, - } - impl wiremock::Respond for ChatSeqResponder { - fn respond(&self, _: &wiremock::Request) -> wiremock::ResponseTemplate { - let idx = self.num_calls.fetch_add(1, Ordering::SeqCst); - match self.bodies.get(idx) { - Some(body) => wiremock::ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_string(body.clone()), - None => panic!("no chat completion response for index {idx}"), - } - } - } - - let chat_seq = ChatSeqResponder { - num_calls: AtomicUsize::new(0), - bodies: vec![sse_tool_call, sse_final], - }; - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path("/v1/chat/completions")) - .respond_with(chat_seq) - .expect(2) - .mount(&server) - .await; - - let rmcp_test_server_bin = stdio_server_bin()?; - - let fixture = test_codex() - .with_config(move |config| { - config.model_provider.wire_api = codex_core::WireApi::Chat; - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - server_name.to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: Some(HashMap::from([( - "MCP_TEST_IMAGE_DATA_URL".to_string(), - OPENAI_PNG.to_string(), - )])), - env_vars: Vec::new(), - cwd: None, - }, - enabled: true, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }) - .build(&server) - .await?; - let session_model = fixture.session_configured.model.clone(); - - fixture - .codex - .submit(Op::UserTurn { - items: vec![UserInput::Text { - text: "call the rmcp image tool".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - cwd: fixture.cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::ReadOnly, - model: session_model, - effort: None, - summary: ReasoningSummary::Auto, - collaboration_mode: None, - personality: None, - }) - .await?; - - let begin_event = wait_for_event(&fixture.codex, |ev| { - matches!(ev, EventMsg::McpToolCallBegin(_)) - }) - .await; - let EventMsg::McpToolCallBegin(begin) = begin_event else { - unreachable!("begin"); - }; - assert_eq!( - begin, - McpToolCallBeginEvent { - call_id: call_id.to_string(), - invocation: McpInvocation { - server: server_name.to_string(), - tool: "image".to_string(), - arguments: Some(json!({})), - }, - }, - ); - - let end_event = wait_for_event(&fixture.codex, |ev| { - matches!(ev, EventMsg::McpToolCallEnd(_)) - }) - .await; - let EventMsg::McpToolCallEnd(end) = end_event else { - unreachable!("end"); - }; - assert!(end.result.as_ref().is_ok(), "tool call should succeed"); - - wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - - // Chat Completions assertion: the second POST should include a tool role message - // with an array `content` containing an item with the expected data URL. - let all_requests = server.received_requests().await.expect("requests captured"); - let requests: Vec<_> = all_requests - .iter() - .filter(|req| req.method == "POST" && req.url.path().ends_with("/chat/completions")) - .collect(); - assert!(requests.len() >= 2, "expected two chat completion calls"); - let second = requests[1]; - let body: Value = serde_json::from_slice(&second.body)?; - let messages = body - .get("messages") - .and_then(Value::as_array) - .cloned() - .expect("messages array"); - let tool_msg = messages - .iter() - .find(|m| { - m.get("role") == Some(&json!("tool")) && m.get("tool_call_id") == Some(&json!(call_id)) - }) - .cloned() - .expect("tool message present"); - assert_eq!( - tool_msg, - json!({ - "role": "tool", - "tool_call_id": call_id, - "content": [{"type": "image_url", "image_url": {"url": OPENAI_PNG}}] - }) - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[serial(mcp_test_value)] async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 2980370de7..c27c39f010 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -30,7 +30,7 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, - /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). + /// Specify which local provider to use (lmstudio or ollama). /// If not specified with --oss, will use config default or show selection. #[arg(long = "local-provider")] pub oss_provider: Option, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 74f056ba4d..a6ded3927a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -16,11 +16,9 @@ pub use cli::ReviewArgs; use codex_cloud_requirements::cloud_requirements_loader; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; -use codex_common::oss::ollama_chat_deprecation_notice; use codex_core::AuthManager; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; use codex_core::NewThread; -use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::ThreadManager; use codex_core::auth::enforce_login_restrictions; @@ -219,7 +217,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any Some(provider) } else { return Err(anyhow::anyhow!( - "No default OSS provider configured. Use --local-provider=provider or set oss_provider to one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID}, {OLLAMA_CHAT_PROVIDER_ID} in config.toml" + "No default OSS provider configured. Use --local-provider=provider or set oss_provider to one of: {LMSTUDIO_OSS_PROVIDER_ID}, {OLLAMA_OSS_PROVIDER_ID} in config.toml" )); } } else { @@ -273,14 +271,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any std::process::exit(1); } - let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { - Ok(notice) => notice, - Err(err) => { - tracing::warn!(?err, "Failed to detect Ollama wire API"); - None - } - }; - let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, false) })) { @@ -313,12 +303,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any last_message_file.clone(), )), }; - if let Some(notice) = ollama_chat_support_notice { - event_processor.process_event(Event { - id: String::new(), - msg: EventMsg::DeprecationNotice(notice), - }); - } if oss { // We're in the oss section, so provider_id should be Some diff --git a/codex-rs/mcp-server/tests/common/lib.rs b/codex-rs/mcp-server/tests/common/lib.rs index c2c3757efc..d2ed896ce1 100644 --- a/codex-rs/mcp-server/tests/common/lib.rs +++ b/codex-rs/mcp-server/tests/common/lib.rs @@ -6,7 +6,7 @@ pub use core_test_support::format_with_current_shell; pub use core_test_support::format_with_current_shell_display_non_login; pub use core_test_support::format_with_current_shell_non_login; pub use mcp_process::McpProcess; -pub use mock_model_server::create_mock_chat_completions_server; +pub use mock_model_server::create_mock_responses_server; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_command_sse_response; diff --git a/codex-rs/mcp-server/tests/common/mock_model_server.rs b/codex-rs/mcp-server/tests/common/mock_model_server.rs index be7f3eb5b3..a1cec2a22f 100644 --- a/codex-rs/mcp-server/tests/common/mock_model_server.rs +++ b/codex-rs/mcp-server/tests/common/mock_model_server.rs @@ -9,8 +9,8 @@ use wiremock::matchers::method; use wiremock::matchers::path; /// Create a mock server that will provide the responses, in order, for -/// requests to the `/v1/chat/completions` endpoint. -pub async fn create_mock_chat_completions_server(responses: Vec) -> MockServer { +/// requests to the `/v1/responses` endpoint. +pub async fn create_mock_responses_server(responses: Vec) -> MockServer { let server = MockServer::start().await; let num_calls = responses.len(); @@ -20,7 +20,7 @@ pub async fn create_mock_chat_completions_server(responses: Vec) -> Mock }; Mock::given(method("POST")) - .and(path("/v1/chat/completions")) + .and(path("/v1/responses")) .respond_with(seq_responder) .expect(num_calls as u64) .mount(&server) diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index 0a9183c043..48a575a4c6 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -1,96 +1,47 @@ -use serde_json::json; use std::path::Path; +use core_test_support::responses; +use serde_json::json; + pub fn create_shell_command_sse_response( command: Vec, workdir: Option<&Path>, timeout_ms: Option, call_id: &str, ) -> anyhow::Result { - // The `arguments` for the `shell_command` tool is a serialized JSON object. let command_str = shlex::try_join(command.iter().map(String::as_str))?; - let tool_call_arguments = serde_json::to_string(&json!({ + let arguments = serde_json::to_string(&json!({ "command": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), - "timeout_ms": timeout_ms + "timeout_ms": timeout_ms, }))?; - let tool_call = json!({ - "choices": [ - { - "delta": { - "tool_calls": [ - { - "id": call_id, - "function": { - "name": "shell_command", - "arguments": tool_call_arguments - } - } - ] - }, - "finish_reason": "tool_calls" - } - ] - }); - - let sse = format!( - "data: {}\n\ndata: DONE\n\n", - serde_json::to_string(&tool_call)? - ); - Ok(sse) + let response_id = format!("resp-{call_id}"); + Ok(responses::sse(vec![ + responses::ev_response_created(&response_id), + responses::ev_function_call(call_id, "shell_command", &arguments), + responses::ev_completed(&response_id), + ])) } pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result { - let assistant_message = json!({ - "choices": [ - { - "delta": { - "content": message - }, - "finish_reason": "stop" - } - ] - }); - - let sse = format!( - "data: {}\n\ndata: DONE\n\n", - serde_json::to_string(&assistant_message)? - ); - Ok(sse) + let response_id = "resp-final"; + Ok(responses::sse(vec![ + responses::ev_response_created(response_id), + responses::ev_assistant_message("msg-final", message), + responses::ev_completed(response_id), + ])) } pub fn create_apply_patch_sse_response( patch_content: &str, call_id: &str, ) -> anyhow::Result { - // Use shell_command to call apply_patch with heredoc format let command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); - let tool_call_arguments = serde_json::to_string(&json!({ - "command": command - }))?; - - let tool_call = json!({ - "choices": [ - { - "delta": { - "tool_calls": [ - { - "id": call_id, - "function": { - "name": "shell_command", - "arguments": tool_call_arguments - } - } - ] - }, - "finish_reason": "tool_calls" - } - ] - }); - - let sse = format!( - "data: {}\n\ndata: DONE\n\n", - serde_json::to_string(&tool_call)? - ); - Ok(sse) + let arguments = serde_json::to_string(&json!({ "command": command }))?; + let response_id = format!("resp-{call_id}"); + Ok(responses::sse(vec![ + responses::ev_response_created(&response_id), + responses::ev_function_call(call_id, "shell_command", &arguments), + responses::ev_completed(&response_id), + ])) } diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 15836b9057..edf2f1b028 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -25,7 +25,7 @@ use core_test_support::skip_if_no_network; use mcp_test_support::McpProcess; use mcp_test_support::create_apply_patch_sse_response; use mcp_test_support::create_final_assistant_message_sse_response; -use mcp_test_support::create_mock_chat_completions_server; +use mcp_test_support::create_mock_responses_server; use mcp_test_support::create_shell_command_sse_response; use mcp_test_support::format_with_current_shell; @@ -87,7 +87,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { ]) .await?; - // Send a "codex" tool request, which should hit the completions endpoint. + // Send a "codex" tool request, which should hit the responses endpoint. // In turn, it should reply with a tool call, which the MCP should forward // as an elicitation. let codex_request_id = mcp_process @@ -349,10 +349,8 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { #![expect(clippy::expect_used, clippy::unwrap_used)] let server = - create_mock_chat_completions_server(vec![create_final_assistant_message_sse_response( - "Enjoy!", - )?]) - .await; + create_mock_responses_server(vec![create_final_assistant_message_sse_response("Enjoy!")?]) + .await; // Run `codex mcp` with a specific config.toml. let codex_home = TempDir::new()?; @@ -360,7 +358,7 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { let mut mcp_process = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; - // Send a "codex" tool request, which should hit the completions endpoint. + // Send a "codex" tool request, which should hit the responses endpoint. let codex_request_id = mcp_process .send_codex_tool_call(CodexToolCallParam { prompt: "How are you?".to_string(), @@ -400,18 +398,23 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { let requests = server.received_requests().await.unwrap(); let request = requests[0].body_json::()?; - let instructions = request["messages"][0]["content"].as_str().unwrap(); + let instructions = request["instructions"] + .as_str() + .expect("responses request should include instructions"); assert!(instructions.starts_with("You are a helpful assistant.")); - let developer_messages: Vec<&serde_json::Value> = request["messages"] + let developer_messages: Vec<&serde_json::Value> = request["input"] .as_array() - .unwrap() + .expect("responses request should include input items") .iter() .filter(|msg| msg.get("role").and_then(|role| role.as_str()) == Some("developer")) .collect(); let developer_contents: Vec<&str> = developer_messages .iter() - .filter_map(|msg| msg.get("content").and_then(|value| value.as_str())) + .filter_map(|msg| msg.get("content").and_then(serde_json::Value::as_array)) + .flat_map(|content| content.iter()) + .filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text")) + .filter_map(|span| span.get("text").and_then(serde_json::Value::as_str)) .collect(); assert!( developer_contents @@ -469,7 +472,7 @@ pub struct McpHandle { } async fn create_mcp_process(responses: Vec) -> anyhow::Result { - let server = create_mock_chat_completions_server(responses).await; + let server = create_mock_responses_server(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let mut mcp_process = McpProcess::new(codex_home.path()).await?; @@ -499,7 +502,7 @@ model_provider = "mock_provider" [model_providers.mock_provider] name = "Mock provider for test" base_url = "{server_uri}/v1" -wire_api = "chat" +wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 "# diff --git a/codex-rs/ollama/src/client.rs b/codex-rs/ollama/src/client.rs index 4f603c68b3..a0ab2d04fb 100644 --- a/codex-rs/ollama/src/client.rs +++ b/codex-rs/ollama/src/client.rs @@ -13,7 +13,6 @@ use crate::url::base_url_to_host_root; use crate::url::is_openai_compatible_base_url; use codex_core::ModelProviderInfo; use codex_core::OLLAMA_OSS_PROVIDER_ID; -use codex_core::WireApi; use codex_core::config::Config; const OLLAMA_CONNECTION_ERROR: &str = "No running Ollama server detected. Start it with: `ollama serve` (after installing). Install instructions: https://github.com/ollama/ollama?tab=readme-ov-file#ollama"; @@ -49,7 +48,7 @@ impl OllamaClient { #[cfg(test)] async fn try_from_provider_with_base_url(base_url: &str) -> io::Result { let provider = - codex_core::create_oss_provider_with_base_url(base_url, codex_core::WireApi::Chat); + codex_core::create_oss_provider_with_base_url(base_url, codex_core::WireApi::Responses); Self::try_from_provider(&provider).await } @@ -60,9 +59,7 @@ impl OllamaClient { .base_url .as_ref() .expect("oss provider must have a base_url"); - let uses_openai_compat = is_openai_compatible_base_url(base_url) - || matches!(provider.wire_api, WireApi::Chat) - && is_openai_compatible_base_url(base_url); + let uses_openai_compat = is_openai_compatible_base_url(base_url); let host_root = base_url_to_host_root(base_url); let client = reqwest::Client::builder() .connect_timeout(std::time::Duration::from_secs(5)) diff --git a/codex-rs/ollama/src/lib.rs b/codex-rs/ollama/src/lib.rs index b049f0a482..02f3754580 100644 --- a/codex-rs/ollama/src/lib.rs +++ b/codex-rs/ollama/src/lib.rs @@ -5,7 +5,6 @@ mod url; pub use client::OllamaClient; use codex_core::ModelProviderInfo; -use codex_core::WireApi; use codex_core::config::Config; pub use pull::CliProgressReporter; pub use pull::PullEvent; @@ -16,11 +15,6 @@ use semver::Version; /// Default OSS model to use when `--oss` is passed without an explicit `-m`. pub const DEFAULT_OSS_MODEL: &str = "gpt-oss:20b"; -pub struct WireApiDetection { - pub wire_api: WireApi, - pub version: Option, -} - /// Prepare the local OSS environment when `--oss` is selected. /// /// - Ensures a local Ollama server is reachable. @@ -58,60 +52,46 @@ fn min_responses_version() -> Version { Version::new(0, 13, 4) } -fn wire_api_for_version(version: &Version) -> WireApi { - if *version == Version::new(0, 0, 0) || *version >= min_responses_version() { - WireApi::Responses - } else { - WireApi::Chat - } +fn supports_responses(version: &Version) -> bool { + *version == Version::new(0, 0, 0) || *version >= min_responses_version() } -/// Detect which wire API the running Ollama server supports based on its version. -/// Returns `Ok(None)` when the version endpoint is missing or unparsable; callers -/// should keep the configured default in that case. -pub async fn detect_wire_api( - provider: &ModelProviderInfo, -) -> std::io::Result> { +/// Ensure the running Ollama server is new enough to support the Responses API. +/// +/// Returns `Ok(())` when the version endpoint is missing or unparsable. +pub async fn ensure_responses_supported(provider: &ModelProviderInfo) -> std::io::Result<()> { let client = crate::OllamaClient::try_from_provider(provider).await?; let Some(version) = client.fetch_version().await? else { - return Ok(None); + return Ok(()); }; - let wire_api = wire_api_for_version(&version); + if supports_responses(&version) { + return Ok(()); + } - Ok(Some(WireApiDetection { - wire_api, - version: Some(version), - })) + let min = min_responses_version(); + Err(std::io::Error::other(format!( + "Ollama {version} is too old. Codex requires Ollama {min} or newer." + ))) } #[cfg(test)] mod tests { use super::*; - use pretty_assertions::assert_eq; #[test] - fn test_wire_api_for_version_dev_zero_keeps_responses() { - assert_eq!( - wire_api_for_version(&Version::new(0, 0, 0)), - WireApi::Responses - ); + fn supports_responses_for_dev_zero() { + assert!(supports_responses(&Version::new(0, 0, 0))); } #[test] - fn test_wire_api_for_version_before_cutoff_is_chat() { - assert_eq!(wire_api_for_version(&Version::new(0, 13, 3)), WireApi::Chat); + fn does_not_support_responses_before_cutoff() { + assert!(!supports_responses(&Version::new(0, 13, 3))); } #[test] - fn test_wire_api_for_version_at_or_after_cutoff_is_responses() { - assert_eq!( - wire_api_for_version(&Version::new(0, 13, 4)), - WireApi::Responses - ); - assert_eq!( - wire_api_for_version(&Version::new(0, 14, 0)), - WireApi::Responses - ); + fn supports_responses_at_or_after_cutoff() { + assert!(supports_responses(&Version::new(0, 13, 4))); + assert!(supports_responses(&Version::new(0, 14, 0))); } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 74cc9a53f1..fe63666e38 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -109,7 +109,7 @@ pub enum ResponseItem { encrypted_content: Option, }, LocalShellCall { - /// Set when using the chat completions API. + /// Legacy id field retained for compatibility with older payloads. #[serde(default, skip_serializing)] #[ts(skip)] id: Option, @@ -125,8 +125,7 @@ pub enum ResponseItem { name: String, // The Responses API returns the function call arguments as a *string* that contains // JSON, not as an already‑parsed object. We keep it as a raw string here and let - // Session::handle_function_call parse it into a Value. This exactly matches the - // Chat Completions + Responses API behavior. + // Session::handle_function_call parse it into a Value. arguments: String, call_id: String, }, @@ -786,8 +785,7 @@ pub enum FunctionCallOutputContentItem { /// `content` preserves the historical plain-string payload so downstream /// integrations (tests, logging, etc.) can keep treating tool output as /// `String`. When an MCP server returns richer data we additionally populate -/// `content_items` with the structured form that the Responses/Chat -/// Completions APIs understand. +/// `content_items` with the structured form that the Responses API understands. #[derive(Debug, Default, Clone, PartialEq, JsonSchema, TS)] pub struct FunctionCallOutputPayload { pub content: String, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 48ef0a772d..0160e8f23d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -48,7 +48,6 @@ use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::protocol::AskForApproval; -use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; @@ -184,15 +183,6 @@ fn emit_skill_load_warnings(app_event_tx: &AppEventSender, errors: &[SkillErrorI } } -fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option) { - let Some(DeprecationNoticeEvent { summary, details }) = notice else { - return; - }; - app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( - crate::history_cell::new_deprecation_notice(summary, details), - ))); -} - fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) { let mut disabled_folders = Vec::new(); @@ -926,12 +916,10 @@ impl App { session_selection: SessionSelection, feedback: codex_feedback::CodexFeedback, is_first_run: bool, - ollama_chat_support_notice: Option, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); - emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); emit_project_config_warnings(&app_event_tx, &config); tui.set_notification_method(config.tui_notification_method); diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 8308f1c2a6..e6880437e6 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -58,7 +58,7 @@ pub struct Cli { #[arg(long = "oss", default_value_t = false)] pub oss: bool, - /// Specify which local provider to use (lmstudio, ollama, or ollama-chat). + /// Specify which local provider to use (lmstudio or ollama). /// If not specified with --oss, will use config default or show selection. #[arg(long = "local-provider")] pub oss_provider: Option, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 862f93eacf..2e5aee5c38 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -11,7 +11,6 @@ use codex_app_server_protocol::AuthMode; use codex_cloud_requirements::cloud_requirements_loader; use codex_common::oss::ensure_oss_provider_ready; use codex_common::oss::get_default_model_for_oss_provider; -use codex_common::oss::ollama_chat_deprecation_notice; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::INTERACTIVE_SESSION_SOURCES; @@ -508,14 +507,6 @@ async fn run_ratatui_app( } else { initial_config }; - - let ollama_chat_support_notice = match ollama_chat_deprecation_notice(&config).await { - Ok(notice) => notice, - Err(err) => { - tracing::warn!(?err, "Failed to detect Ollama wire API"); - None - } - }; let mut missing_session_exit = |id_str: &str, action: &str| { error!("Error finding conversation path: {id_str}"); restore(); @@ -701,7 +692,6 @@ async fn run_ratatui_app( session_selection, feedback, should_show_trust_screen, // Proxy to: is it a first run in this directory? - ollama_chat_support_notice, ) .await; diff --git a/codex-rs/tui/src/oss_selection.rs b/codex-rs/tui/src/oss_selection.rs index 7267919109..c73b4e332a 100644 --- a/codex-rs/tui/src/oss_selection.rs +++ b/codex-rs/tui/src/oss_selection.rs @@ -4,7 +4,6 @@ use std::sync::LazyLock; use codex_core::DEFAULT_LMSTUDIO_PORT; use codex_core::DEFAULT_OLLAMA_PORT; use codex_core::LMSTUDIO_OSS_PROVIDER_ID; -use codex_core::OLLAMA_CHAT_PROVIDER_ID; use codex_core::OLLAMA_OSS_PROVIDER_ID; use codex_core::config::set_default_oss_provider; use crossterm::event::Event; @@ -75,12 +74,6 @@ static OSS_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { key: KeyCode::Char('o'), provider_id: OLLAMA_OSS_PROVIDER_ID, }, - SelectOption { - label: Line::from(vec!["Ollama (".into(), "c".underlined(), "hat)".into()]), - description: "Local Ollama server (chat wire API, default port 11434)", - key: KeyCode::Char('c'), - provider_id: OLLAMA_CHAT_PROVIDER_ID, - }, ] }); From 88598b9402cb41306f8c120e0accb9a057301518 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Feb 2026 12:43:09 +0000 Subject: [PATCH 08/13] feat: drop wire_api from clients (#10498) --- codex-rs/codex-api/src/endpoint/compact.rs | 19 ++++--------- codex-rs/codex-api/src/endpoint/models.rs | 2 -- codex-rs/codex-api/src/endpoint/responses.rs | 5 +--- codex-rs/codex-api/src/lib.rs | 1 - codex-rs/codex-api/src/provider.rs | 27 +++--------------- codex-rs/codex-api/src/requests/responses.rs | 2 -- codex-rs/codex-api/tests/clients.rs | 28 ++++--------------- .../codex-api/tests/models_integration.rs | 2 -- codex-rs/codex-api/tests/sse_end_to_end.rs | 8 ++---- codex-rs/core/src/model_provider_info.rs | 10 +------ 10 files changed, 19 insertions(+), 85 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index 8edba7c597..b7166779a2 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -3,7 +3,6 @@ use crate::auth::add_auth_headers; use crate::common::CompactionInput; use crate::error::ApiError; use crate::provider::Provider; -use crate::provider::WireApi; use crate::telemetry::run_with_request_telemetry; use codex_client::HttpTransport; use codex_client::RequestTelemetry; @@ -37,9 +36,7 @@ impl CompactClient { } fn path(&self) -> &'static str { - match self.provider.wire { - WireApi::Compact | WireApi::Responses => "responses/compact", - } + "responses/compact" } pub async fn compact( @@ -118,12 +115,11 @@ mod tests { } } - fn provider(wire: WireApi) -> Provider { + fn provider() -> Provider { Provider { name: "test".to_string(), base_url: "https://example.com/v1".to_string(), query_params: None, - wire, headers: HeaderMap::new(), retry: RetryConfig { max_attempts: 1, @@ -137,13 +133,8 @@ mod tests { } #[test] - fn path_is_responses_compact_for_supported_wire_apis() { - let responses_client = - CompactClient::new(DummyTransport, provider(WireApi::Responses), DummyAuth); - assert_eq!(responses_client.path(), "responses/compact"); - - let compact_client = - CompactClient::new(DummyTransport, provider(WireApi::Compact), DummyAuth); - assert_eq!(compact_client.path(), "responses/compact"); + fn path_is_responses_compact() { + let client = CompactClient::new(DummyTransport, provider(), DummyAuth); + assert_eq!(client.path(), "responses/compact"); } } diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index 9f6083dc89..fa4dddd325 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -83,7 +83,6 @@ impl ModelsClient { mod tests { use super::*; use crate::provider::RetryConfig; - use crate::provider::WireApi; use async_trait::async_trait; use codex_client::Request; use codex_client::Response; @@ -149,7 +148,6 @@ mod tests { name: "test".to_string(), base_url: base_url.to_string(), query_params: None, - wire: WireApi::Responses, headers: HeaderMap::new(), retry: RetryConfig { max_attempts: 1, diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 11ff60754f..2555ab5e63 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -6,7 +6,6 @@ use crate::common::TextControls; use crate::endpoint::streaming::StreamingClient; use crate::error::ApiError; use crate::provider::Provider; -use crate::provider::WireApi; use crate::requests::ResponsesRequest; use crate::requests::ResponsesRequestBuilder; use crate::requests::responses::Compression; @@ -109,9 +108,7 @@ impl ResponsesClient { } fn path(&self) -> &'static str { - match self.streaming.provider().wire { - WireApi::Responses | WireApi::Compact => "responses", - } + "responses" } pub async fn stream( diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 6a2490c5a6..b0c70084d4 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -31,7 +31,6 @@ pub use crate::endpoint::responses_websocket::ResponsesWebsocketClient; pub use crate::endpoint::responses_websocket::ResponsesWebsocketConnection; pub use crate::error::ApiError; pub use crate::provider::Provider; -pub use crate::provider::WireApi; pub use crate::provider::is_azure_responses_wire_base_url; pub use crate::requests::ResponsesRequest; pub use crate::requests::ResponsesRequestBuilder; diff --git a/codex-rs/codex-api/src/provider.rs b/codex-rs/codex-api/src/provider.rs index fab3ed75e9..81a168ffd7 100644 --- a/codex-rs/codex-api/src/provider.rs +++ b/codex-rs/codex-api/src/provider.rs @@ -8,13 +8,6 @@ use std::collections::HashMap; use std::time::Duration; use url::Url; -/// Wire-level APIs supported by a `Provider`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum WireApi { - Responses, - Compact, -} - /// High-level retry configuration for a provider. /// /// This is converted into a `RetryPolicy` used by `codex-client` to drive @@ -51,7 +44,6 @@ pub struct Provider { pub name: String, pub base_url: String, pub query_params: Option>, - pub wire: WireApi, pub headers: HeaderMap, pub retry: RetryConfig, pub stream_idle_timeout: Duration, @@ -94,7 +86,7 @@ impl Provider { } pub fn is_azure_responses_endpoint(&self) -> bool { - is_azure_responses_wire_base_url(self.wire.clone(), &self.name, Some(&self.base_url)) + is_azure_responses_wire_base_url(&self.name, Some(&self.base_url)) } pub fn websocket_url_for_path(&self, path: &str) -> Result { @@ -111,11 +103,7 @@ impl Provider { } } -pub fn is_azure_responses_wire_base_url(wire: WireApi, name: &str, base_url: Option<&str>) -> bool { - if wire != WireApi::Responses { - return false; - } - +pub fn is_azure_responses_wire_base_url(name: &str, base_url: Option<&str>) -> bool { if name.eq_ignore_ascii_case("azure") { return true; } @@ -156,13 +144,12 @@ mod tests { for base_url in positive_cases { assert!( - is_azure_responses_wire_base_url(WireApi::Responses, "test", Some(base_url)), + is_azure_responses_wire_base_url("test", Some(base_url)), "expected {base_url} to be detected as Azure" ); } assert!(is_azure_responses_wire_base_url( - WireApi::Responses, "Azure", Some("https://example.com") )); @@ -175,15 +162,9 @@ mod tests { for base_url in negative_cases { assert!( - !is_azure_responses_wire_base_url(WireApi::Responses, "test", Some(base_url)), + !is_azure_responses_wire_base_url("test", Some(base_url)), "expected {base_url} not to be detected as Azure" ); } - - assert!(!is_azure_responses_wire_base_url( - WireApi::Compact, - "Azure", - Some("https://foo.openai.azure.com/openai") - )); } } diff --git a/codex-rs/codex-api/src/requests/responses.rs b/codex-rs/codex-api/src/requests/responses.rs index a0e90db066..201e638d01 100644 --- a/codex-rs/codex-api/src/requests/responses.rs +++ b/codex-rs/codex-api/src/requests/responses.rs @@ -191,7 +191,6 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { mod tests { use super::*; use crate::provider::RetryConfig; - use crate::provider::WireApi; use codex_protocol::protocol::SubAgentSource; use http::HeaderValue; use pretty_assertions::assert_eq; @@ -202,7 +201,6 @@ mod tests { name: name.to_string(), base_url: base_url.to_string(), query_params: None, - wire: WireApi::Responses, headers: HeaderMap::new(), retry: RetryConfig { max_attempts: 1, diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 96923ffe2f..4ccd42f604 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -9,7 +9,6 @@ use codex_api::AuthProvider; use codex_api::Provider; use codex_api::ResponsesClient; use codex_api::ResponsesOptions; -use codex_api::WireApi; use codex_api::requests::responses::Compression; use codex_client::HttpTransport; use codex_client::Request; @@ -118,12 +117,11 @@ impl AuthProvider for StaticAuth { } } -fn provider(name: &str, wire: WireApi) -> Provider { +fn provider(name: &str) -> Provider { Provider { name: name.to_string(), base_url: "https://example.com/v1".to_string(), query_params: None, - wire, headers: HeaderMap::new(), retry: codex_api::provider::RetryConfig { max_attempts: 1, @@ -195,26 +193,10 @@ data: {"id":"resp-1","output":[{"type":"message","role":"assistant","content":[{ } #[tokio::test] -async fn responses_client_uses_responses_path_for_responses_wire() -> Result<()> { +async fn responses_client_uses_responses_path() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); - let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), NoAuth); - - let body = serde_json::json!({ "echo": true }); - let _stream = client - .stream(body, HeaderMap::new(), Compression::None, None) - .await?; - - let requests = state.take_stream_requests(); - assert_path_ends_with(&requests, "/responses"); - Ok(()) -} - -#[tokio::test] -async fn responses_client_uses_responses_path_for_compact_wire() -> Result<()> { - let state = RecordingState::default(); - let transport = RecordingTransport::new(state.clone()); - let client = ResponsesClient::new(transport, provider("openai", WireApi::Compact), NoAuth); + let client = ResponsesClient::new(transport, provider("openai"), NoAuth); let body = serde_json::json!({ "echo": true }); let _stream = client @@ -231,7 +213,7 @@ async fn streaming_client_adds_auth_headers() -> Result<()> { let state = RecordingState::default(); let transport = RecordingTransport::new(state.clone()); let auth = StaticAuth::new("secret-token", "acct-1"); - let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), auth); + let client = ResponsesClient::new(transport, provider("openai"), auth); let body = serde_json::json!({ "model": "gpt-test" }); let _stream = client @@ -266,7 +248,7 @@ async fn streaming_client_adds_auth_headers() -> Result<()> { async fn streaming_client_retries_on_transport_error() -> Result<()> { let transport = FlakyTransport::new(); - let mut provider = provider("openai", WireApi::Responses); + let mut provider = provider("openai"); provider.retry.max_attempts = 2; let client = ResponsesClient::new(transport.clone(), provider, NoAuth); diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 2145be6542..8442133b4d 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -2,7 +2,6 @@ use codex_api::AuthProvider; use codex_api::ModelsClient; use codex_api::provider::Provider; use codex_api::provider::RetryConfig; -use codex_api::provider::WireApi; use codex_client::ReqwestTransport; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::ModelInfo; @@ -34,7 +33,6 @@ fn provider(base_url: &str) -> Provider { name: "test".to_string(), base_url: base_url.to_string(), query_params: None, - wire: WireApi::Responses, headers: HeaderMap::new(), retry: RetryConfig { max_attempts: 1, diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index eb9d7993f8..a92b6be5d4 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -8,7 +8,6 @@ use codex_api::AuthProvider; use codex_api::Provider; use codex_api::ResponseEvent; use codex_api::ResponsesClient; -use codex_api::WireApi; use codex_api::requests::responses::Compression; use codex_client::HttpTransport; use codex_client::Request; @@ -61,12 +60,11 @@ impl AuthProvider for NoAuth { } } -fn provider(name: &str, wire: WireApi) -> Provider { +fn provider(name: &str) -> Provider { Provider { name: name.to_string(), base_url: "https://example.com/v1".to_string(), query_params: None, - wire, headers: HeaderMap::new(), retry: codex_api::provider::RetryConfig { max_attempts: 1, @@ -122,7 +120,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> let body = build_responses_body(vec![item1, item2, completed]); let transport = FixtureSseTransport::new(body); - let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), NoAuth); + let client = ResponsesClient::new(transport, provider("openai"), NoAuth); let mut stream = client .stream( @@ -192,7 +190,7 @@ async fn responses_stream_aggregates_output_text_deltas() -> Result<()> { let body = build_responses_body(vec![delta1, delta2, completed]); let transport = FixtureSseTransport::new(body); - let client = ResponsesClient::new(transport, provider("openai", WireApi::Responses), NoAuth); + let client = ResponsesClient::new(transport, provider("openai"), NoAuth); let stream = client .stream( diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 126389193d..211822f48f 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -8,7 +8,6 @@ use crate::auth::AuthMode; use crate::error::EnvVarError; use codex_api::Provider as ApiProvider; -use codex_api::WireApi as ApiWireApi; use codex_api::is_azure_responses_wire_base_url; use codex_api::provider::RetryConfig as ApiRetryConfig; use http::HeaderMap; @@ -169,9 +168,6 @@ impl ModelProviderInfo { name: self.name.clone(), base_url, query_params: self.query_params.clone(), - wire: match self.wire_api { - WireApi::Responses => ApiWireApi::Responses, - }, headers, retry, stream_idle_timeout: self.stream_idle_timeout(), @@ -179,11 +175,7 @@ impl ModelProviderInfo { } pub(crate) fn is_azure_responses_endpoint(&self) -> bool { - is_azure_responses_wire_base_url( - ApiWireApi::Responses, - &self.name, - self.base_url.as_deref(), - ) + is_azure_responses_wire_base_url(&self.name, self.base_url.as_deref()) } /// If `env_key` is Some, returns the API key for this provider if present From d5e7248958b9c9b82cd71f3b33cd9d3b16224100 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Feb 2026 14:08:09 +0000 Subject: [PATCH 09/13] feat: clean codex-api part 1 (#10501) --- codex-rs/codex-api/src/auth.rs | 16 ++- codex-rs/codex-api/src/endpoint/compact.rs | 67 +++------- codex-rs/codex-api/src/endpoint/mod.rs | 2 +- codex-rs/codex-api/src/endpoint/models.rs | 50 +++---- codex-rs/codex-api/src/endpoint/responses.rs | 48 ++++--- .../src/endpoint/responses_websocket.rs | 18 +-- codex-rs/codex-api/src/endpoint/session.rs | 126 ++++++++++++++++++ codex-rs/codex-api/src/endpoint/streaming.rs | 95 ------------- 8 files changed, 208 insertions(+), 214 deletions(-) create mode 100644 codex-rs/codex-api/src/endpoint/session.rs delete mode 100644 codex-rs/codex-api/src/endpoint/streaming.rs diff --git a/codex-rs/codex-api/src/auth.rs b/codex-rs/codex-api/src/auth.rs index 6c26963cba..f649062db1 100644 --- a/codex-rs/codex-api/src/auth.rs +++ b/codex-rs/codex-api/src/auth.rs @@ -1,4 +1,6 @@ use codex_client::Request; +use http::HeaderMap; +use http::HeaderValue; /// Provides bearer and account identity information for API requests. /// @@ -12,16 +14,20 @@ pub trait AuthProvider: Send + Sync { } } -pub(crate) fn add_auth_headers(auth: &A, mut req: Request) -> Request { +pub(crate) fn add_auth_headers_to_header_map(auth: &A, headers: &mut HeaderMap) { if let Some(token) = auth.bearer_token() - && let Ok(header) = format!("Bearer {token}").parse() + && let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}")) { - let _ = req.headers.insert(http::header::AUTHORIZATION, header); + let _ = headers.insert(http::header::AUTHORIZATION, header); } if let Some(account_id) = auth.account_id() - && let Ok(header) = account_id.parse() + && let Ok(header) = HeaderValue::from_str(&account_id) { - let _ = req.headers.insert("ChatGPT-Account-ID", header); + let _ = headers.insert("ChatGPT-Account-ID", header); } +} + +pub(crate) fn add_auth_headers(auth: &A, mut req: Request) -> Request { + add_auth_headers_to_header_map(auth, &mut req.headers); req } diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index b7166779a2..44a56a11a7 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -1,9 +1,8 @@ use crate::auth::AuthProvider; -use crate::auth::add_auth_headers; use crate::common::CompactionInput; +use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; -use crate::telemetry::run_with_request_telemetry; use codex_client::HttpTransport; use codex_client::RequestTelemetry; use codex_protocol::models::ResponseItem; @@ -14,28 +13,23 @@ use serde_json::to_value; use std::sync::Arc; pub struct CompactClient { - transport: T, - provider: Provider, - auth: A, - request_telemetry: Option>, + session: EndpointSession, } impl CompactClient { pub fn new(transport: T, provider: Provider, auth: A) -> Self { Self { - transport, - provider, - auth, - request_telemetry: None, + session: EndpointSession::new(transport, provider, auth), } } - pub fn with_telemetry(mut self, request: Option>) -> Self { - self.request_telemetry = request; - self + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } } - fn path(&self) -> &'static str { + fn path() -> &'static str { "responses/compact" } @@ -44,21 +38,10 @@ impl CompactClient { body: serde_json::Value, extra_headers: HeaderMap, ) -> Result, ApiError> { - let path = self.path(); - let builder = || { - let mut req = self.provider.build_request(Method::POST, path); - req.headers.extend(extra_headers.clone()); - req.body = Some(body.clone()); - add_auth_headers(&self.auth, req) - }; - - let resp = run_with_request_telemetry( - self.provider.retry.to_policy(), - self.request_telemetry.clone(), - builder, - |req| self.transport.execute(req), - ) - .await?; + let resp = self + .session + .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .await?; let parsed: CompactHistoryResponse = serde_json::from_slice(&resp.body).map_err(|e| ApiError::Stream(e.to_string()))?; Ok(parsed.output) @@ -83,14 +66,11 @@ struct CompactHistoryResponse { #[cfg(test)] mod tests { use super::*; - use crate::provider::RetryConfig; use async_trait::async_trait; use codex_client::Request; use codex_client::Response; use codex_client::StreamResponse; use codex_client::TransportError; - use http::HeaderMap; - use std::time::Duration; #[derive(Clone, Default)] struct DummyTransport; @@ -115,26 +95,11 @@ mod tests { } } - fn provider() -> Provider { - Provider { - name: "test".to_string(), - base_url: "https://example.com/v1".to_string(), - query_params: None, - headers: HeaderMap::new(), - retry: RetryConfig { - max_attempts: 1, - base_delay: Duration::from_millis(1), - retry_429: false, - retry_5xx: true, - retry_transport: true, - }, - stream_idle_timeout: Duration::from_secs(1), - } - } - #[test] fn path_is_responses_compact() { - let client = CompactClient::new(DummyTransport, provider(), DummyAuth); - assert_eq!(client.path(), "responses/compact"); + assert_eq!( + CompactClient::::path(), + "responses/compact" + ); } } diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 2bb524b227..23579ffcf1 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -3,4 +3,4 @@ pub mod compact; pub mod models; pub mod responses; pub mod responses_websocket; -mod streaming; +mod session; diff --git a/codex-rs/codex-api/src/endpoint/models.rs b/codex-rs/codex-api/src/endpoint/models.rs index fa4dddd325..5d1c5fb12e 100644 --- a/codex-rs/codex-api/src/endpoint/models.rs +++ b/codex-rs/codex-api/src/endpoint/models.rs @@ -1,8 +1,7 @@ use crate::auth::AuthProvider; -use crate::auth::add_auth_headers; +use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; -use crate::telemetry::run_with_request_telemetry; use codex_client::HttpTransport; use codex_client::RequestTelemetry; use codex_protocol::openai_models::ModelInfo; @@ -13,53 +12,42 @@ use http::header::ETAG; use std::sync::Arc; pub struct ModelsClient { - transport: T, - provider: Provider, - auth: A, - request_telemetry: Option>, + session: EndpointSession, } impl ModelsClient { pub fn new(transport: T, provider: Provider, auth: A) -> Self { Self { - transport, - provider, - auth, - request_telemetry: None, + session: EndpointSession::new(transport, provider, auth), } } - pub fn with_telemetry(mut self, request: Option>) -> Self { - self.request_telemetry = request; - self + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } } - fn path(&self) -> &'static str { + fn path() -> &'static str { "models" } + fn append_client_version_query(req: &mut codex_client::Request, client_version: &str) { + let separator = if req.url.contains('?') { '&' } else { '?' }; + req.url = format!("{}{}client_version={client_version}", req.url, separator); + } + pub async fn list_models( &self, client_version: &str, extra_headers: HeaderMap, ) -> Result<(Vec, Option), ApiError> { - let builder = || { - let mut req = self.provider.build_request(Method::GET, self.path()); - req.headers.extend(extra_headers.clone()); - - let separator = if req.url.contains('?') { '&' } else { '?' }; - req.url = format!("{}{}client_version={client_version}", req.url, separator); - - add_auth_headers(&self.auth, req) - }; - - let resp = run_with_request_telemetry( - self.provider.retry.to_policy(), - self.request_telemetry.clone(), - builder, - |req| self.transport.execute(req), - ) - .await?; + let resp = self + .session + .execute_with(Method::GET, Self::path(), extra_headers, None, |req| { + Self::append_client_version_query(req, client_version); + }) + .await?; let header_etag = resp .headers diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 2555ab5e63..6a74ad69c3 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -3,7 +3,7 @@ use crate::common::Prompt as ApiPrompt; use crate::common::Reasoning; use crate::common::ResponseStream; use crate::common::TextControls; -use crate::endpoint::streaming::StreamingClient; +use crate::endpoint::session::EndpointSession; use crate::error::ApiError; use crate::provider::Provider; use crate::requests::ResponsesRequest; @@ -16,13 +16,16 @@ use codex_client::RequestCompression; use codex_client::RequestTelemetry; use codex_protocol::protocol::SessionSource; use http::HeaderMap; +use http::HeaderValue; +use http::Method; use serde_json::Value; use std::sync::Arc; use std::sync::OnceLock; use tracing::instrument; pub struct ResponsesClient { - streaming: StreamingClient, + session: EndpointSession, + sse_telemetry: Option>, } #[derive(Default)] @@ -42,7 +45,8 @@ pub struct ResponsesOptions { impl ResponsesClient { pub fn new(transport: T, provider: Provider, auth: A) -> Self { Self { - streaming: StreamingClient::new(transport, provider, auth), + session: EndpointSession::new(transport, provider, auth), + sse_telemetry: None, } } @@ -52,7 +56,8 @@ impl ResponsesClient { sse: Option>, ) -> Self { Self { - streaming: self.streaming.with_telemetry(request, sse), + session: self.session.with_request_telemetry(request), + sse_telemetry: sse, } } @@ -102,12 +107,12 @@ impl ResponsesClient { .store_override(store_override) .extra_headers(extra_headers) .compression(compression) - .build(self.streaming.provider())?; + .build(self.session.provider())?; self.stream_request(request, turn_state).await } - fn path(&self) -> &'static str { + fn path() -> &'static str { "responses" } @@ -118,20 +123,33 @@ impl ResponsesClient { compression: Compression, turn_state: Option>>, ) -> Result { - let compression = match compression { + let request_compression = match compression { Compression::None => RequestCompression::None, Compression::Zstd => RequestCompression::Zstd, }; - self.streaming - .stream( - self.path(), - body, + let stream_response = self + .session + .stream_with( + Method::POST, + Self::path(), extra_headers, - compression, - spawn_response_stream, - turn_state, + Some(body), + |req| { + req.headers.insert( + http::header::ACCEPT, + HeaderValue::from_static("text/event-stream"), + ); + req.compression = request_compression; + }, ) - .await + .await?; + + Ok(spawn_response_stream( + stream_response, + self.session.provider().stream_idle_timeout, + self.sse_telemetry.clone(), + turn_state, + )) } } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 2a7d8726f1..cac686dd02 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,4 +1,5 @@ use crate::auth::AuthProvider; +use crate::auth::add_auth_headers_to_header_map; use crate::common::ResponseEvent; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; @@ -11,7 +12,6 @@ use codex_client::TransportError; use futures::SinkExt; use futures::StreamExt; use http::HeaderMap; -use http::HeaderValue; use serde_json::Value; use std::sync::Arc; use std::sync::OnceLock; @@ -134,7 +134,7 @@ impl ResponsesWebsocketClient { let mut headers = self.provider.headers.clone(); headers.extend(extra_headers); - apply_auth_headers(&mut headers, &self.auth); + add_auth_headers_to_header_map(&self.auth, &mut headers); let (stream, server_reasoning_included) = connect_websocket(ws_url, headers, turn_state).await?; @@ -147,20 +147,6 @@ impl ResponsesWebsocketClient { } } -// TODO (pakrym): share with /auth -fn apply_auth_headers(headers: &mut HeaderMap, auth: &impl AuthProvider) { - if let Some(token) = auth.bearer_token() - && let Ok(header) = HeaderValue::from_str(&format!("Bearer {token}")) - { - let _ = headers.insert(http::header::AUTHORIZATION, header); - } - if let Some(account_id) = auth.account_id() - && let Ok(header) = HeaderValue::from_str(&account_id) - { - let _ = headers.insert("ChatGPT-Account-ID", header); - } -} - async fn connect_websocket( url: Url, headers: HeaderMap, diff --git a/codex-rs/codex-api/src/endpoint/session.rs b/codex-rs/codex-api/src/endpoint/session.rs new file mode 100644 index 0000000000..a6cd7bfe37 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/session.rs @@ -0,0 +1,126 @@ +use crate::auth::AuthProvider; +use crate::auth::add_auth_headers; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::telemetry::run_with_request_telemetry; +use codex_client::HttpTransport; +use codex_client::Request; +use codex_client::RequestTelemetry; +use codex_client::Response; +use codex_client::StreamResponse; +use http::HeaderMap; +use http::Method; +use serde_json::Value; +use std::sync::Arc; + +pub(crate) struct EndpointSession { + transport: T, + provider: Provider, + auth: A, + request_telemetry: Option>, +} + +impl EndpointSession { + pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self { + Self { + transport, + provider, + auth, + request_telemetry: None, + } + } + + pub(crate) fn with_request_telemetry( + mut self, + request: Option>, + ) -> Self { + self.request_telemetry = request; + self + } + + pub(crate) fn provider(&self) -> &Provider { + &self.provider + } + + fn make_request( + &self, + method: &Method, + path: &str, + extra_headers: &HeaderMap, + body: Option<&Value>, + ) -> Request { + let mut req = self.provider.build_request(method.clone(), path); + req.headers.extend(extra_headers.clone()); + if let Some(body) = body { + req.body = Some(body.clone()); + } + add_auth_headers(&self.auth, req) + } + + pub(crate) async fn execute( + &self, + method: Method, + path: &str, + extra_headers: HeaderMap, + body: Option, + ) -> Result { + self.execute_with(method, path, extra_headers, body, |_| {}) + .await + } + + pub(crate) async fn execute_with( + &self, + method: Method, + path: &str, + extra_headers: HeaderMap, + body: Option, + configure: C, + ) -> Result + where + C: Fn(&mut Request), + { + let make_request = || { + let mut req = self.make_request(&method, path, &extra_headers, body.as_ref()); + configure(&mut req); + req + }; + + let response = run_with_request_telemetry( + self.provider.retry.to_policy(), + self.request_telemetry.clone(), + make_request, + |req| self.transport.execute(req), + ) + .await?; + + Ok(response) + } + + pub(crate) async fn stream_with( + &self, + method: Method, + path: &str, + extra_headers: HeaderMap, + body: Option, + configure: C, + ) -> Result + where + C: Fn(&mut Request), + { + let make_request = || { + let mut req = self.make_request(&method, path, &extra_headers, body.as_ref()); + configure(&mut req); + req + }; + + let stream = run_with_request_telemetry( + self.provider.retry.to_policy(), + self.request_telemetry.clone(), + make_request, + |req| self.transport.stream(req), + ) + .await?; + + Ok(stream) + } +} diff --git a/codex-rs/codex-api/src/endpoint/streaming.rs b/codex-rs/codex-api/src/endpoint/streaming.rs deleted file mode 100644 index 15d4c077a0..0000000000 --- a/codex-rs/codex-api/src/endpoint/streaming.rs +++ /dev/null @@ -1,95 +0,0 @@ -use crate::auth::AuthProvider; -use crate::auth::add_auth_headers; -use crate::common::ResponseStream; -use crate::error::ApiError; -use crate::provider::Provider; -use crate::telemetry::SseTelemetry; -use crate::telemetry::run_with_request_telemetry; -use codex_client::HttpTransport; -use codex_client::RequestCompression; -use codex_client::RequestTelemetry; -use codex_client::StreamResponse; -use http::HeaderMap; -use http::Method; -use serde_json::Value; -use std::sync::Arc; -use std::sync::OnceLock; -use std::time::Duration; - -pub(crate) struct StreamingClient { - transport: T, - provider: Provider, - auth: A, - request_telemetry: Option>, - sse_telemetry: Option>, -} - -type StreamSpawner = fn( - StreamResponse, - Duration, - Option>, - Option>>, -) -> ResponseStream; - -impl StreamingClient { - pub(crate) fn new(transport: T, provider: Provider, auth: A) -> Self { - Self { - transport, - provider, - auth, - request_telemetry: None, - sse_telemetry: None, - } - } - - pub(crate) fn with_telemetry( - mut self, - request: Option>, - sse: Option>, - ) -> Self { - self.request_telemetry = request; - self.sse_telemetry = sse; - self - } - - pub(crate) fn provider(&self) -> &Provider { - &self.provider - } - - pub(crate) async fn stream( - &self, - path: &str, - body: Value, - extra_headers: HeaderMap, - compression: RequestCompression, - spawner: StreamSpawner, - turn_state: Option>>, - ) -> Result { - let builder = || { - let mut req = self.provider.build_request(Method::POST, path); - req.headers.extend(extra_headers.clone()); - req.headers.insert( - http::header::ACCEPT, - http::HeaderValue::from_static("text/event-stream"), - ); - req.body = Some(body.clone()); - req.compression = compression; - add_auth_headers(&self.auth, req) - }; - - let stream_response = run_with_request_telemetry( - self.provider.retry.to_policy(), - self.request_telemetry.clone(), - builder, - |req| self.transport.stream(req), - ) - .await?; - - Ok(spawner( - stream_response, - self.provider.stream_idle_timeout, - self.sse_telemetry.clone(), - turn_state, - )) - } -} From 944541e93640a744f1a7aed2f214d2fa567f5cad Mon Sep 17 00:00:00 2001 From: gt-oai Date: Tue, 3 Feb 2026 14:58:33 +0000 Subject: [PATCH 10/13] Add more detail to 401 error (#10508) Add the error.message if it exists, the body otherwise. Truncate body to 1k characters. Print the cf-ray and the requestId. **Before:** Screenshot 2026-02-03 at 13 15 28 **After:** Screenshot 2026-02-03 at 13 15 38 --- codex-rs/core/src/api_bridge.rs | 26 ++++--- codex-rs/core/src/client.rs | 29 +++----- codex-rs/core/src/error.rs | 117 ++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 32 deletions(-) diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 86aeaedda0..f7aeb570bd 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -28,6 +28,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { status, body: message, url: None, + cf_ray: None, request_id: None, }), ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), @@ -89,13 +90,14 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { CodexErr::RetryLimit(RetryLimitReachedError { status, - request_id: extract_request_id(headers.as_ref()), + request_id: extract_request_tracking_id(headers.as_ref()), }) } else { CodexErr::UnexpectedStatus(UnexpectedResponseError { status, body: body_text, url, + cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER), request_id: extract_request_id(headers.as_ref()), }) } @@ -115,6 +117,9 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { const MODEL_CAP_MODEL_HEADER: &str = "x-codex-model-cap-model"; const MODEL_CAP_RESET_AFTER_HEADER: &str = "x-codex-model-cap-reset-after-seconds"; +const REQUEST_ID_HEADER: &str = "x-request-id"; +const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; +const CF_RAY_HEADER: &str = "cf-ray"; #[cfg(test)] mod tests { @@ -149,15 +154,20 @@ mod tests { } } +fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option { + extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER)) +} + fn extract_request_id(headers: Option<&HeaderMap>) -> Option { + extract_header(headers, REQUEST_ID_HEADER) + .or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER)) +} + +fn extract_header(headers: Option<&HeaderMap>, name: &str) -> Option { headers.and_then(|map| { - ["cf-ray", "x-request-id", "x-oai-request-id"] - .iter() - .find_map(|name| { - map.get(*name) - .and_then(|v| v.to_str().ok()) - .map(str::to_string) - }) + map.get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) }) } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 953b11b8f8..fcc308f380 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -567,10 +567,10 @@ impl ModelClientSession { Ok(stream) => { return Ok(map_response_stream(stream, self.state.otel_manager.clone())); } - Err(ApiError::Transport(TransportError::Http { status, .. })) - if status == StatusCode::UNAUTHORIZED => - { - handle_unauthorized(status, &mut auth_recovery).await?; + Err(ApiError::Transport( + unauthorized_transport @ TransportError::Http { status, .. }, + )) if status == StatusCode::UNAUTHORIZED => { + handle_unauthorized(unauthorized_transport, &mut auth_recovery).await?; continue; } Err(err) => return Err(map_api_error(err)), @@ -606,10 +606,10 @@ impl ModelClientSession { .await { Ok(connection) => connection, - Err(ApiError::Transport(TransportError::Http { status, .. })) - if status == StatusCode::UNAUTHORIZED => - { - handle_unauthorized(status, &mut auth_recovery).await?; + Err(ApiError::Transport( + unauthorized_transport @ TransportError::Http { status, .. }, + )) if status == StatusCode::UNAUTHORIZED => { + handle_unauthorized(unauthorized_transport, &mut auth_recovery).await?; continue; } Err(err) => return Err(map_api_error(err)), @@ -780,7 +780,7 @@ where /// When refresh succeeds, the caller should retry the API call; otherwise /// the mapped `CodexErr` is returned to the caller. async fn handle_unauthorized( - status: StatusCode, + transport: TransportError, auth_recovery: &mut Option, ) -> Result<()> { if let Some(recovery) = auth_recovery @@ -793,16 +793,7 @@ async fn handle_unauthorized( }; } - Err(map_unauthorized_status(status)) -} - -fn map_unauthorized_status(status: StatusCode) -> CodexErr { - map_api_error(ApiError::Transport(TransportError::Http { - status, - url: None, - headers: None, - body: None, - })) + Err(map_api_error(ApiError::Transport(transport))) } struct ApiTelemetry { diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 684d968162..889335824e 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -286,13 +286,42 @@ pub struct UnexpectedResponseError { pub status: StatusCode, pub body: String, pub url: Option, + pub cf_ray: Option, pub request_id: Option, } const CLOUDFLARE_BLOCKED_MESSAGE: &str = "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; +const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000; impl UnexpectedResponseError { + fn display_body(&self) -> String { + if let Some(message) = self.extract_error_message() { + return message; + } + + let trimmed_body = self.body.trim(); + if trimmed_body.is_empty() { + return "Unknown error".to_string(); + } + + truncate_with_ellipsis(trimmed_body, UNEXPECTED_RESPONSE_BODY_MAX_BYTES) + } + + fn extract_error_message(&self) -> Option { + let json = serde_json::from_str::(&self.body).ok()?; + let message = json + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str)?; + let message = message.trim(); + if message.is_empty() { + None + } else { + Some(message.to_string()) + } + } + fn friendly_message(&self) -> Option { if self.status != StatusCode::FORBIDDEN { return None; @@ -307,6 +336,9 @@ impl UnexpectedResponseError { if let Some(url) = &self.url { message.push_str(&format!(", url: {url}")); } + if let Some(cf_ray) = &self.cf_ray { + message.push_str(&format!(", cf-ray: {cf_ray}")); + } if let Some(id) = &self.request_id { message.push_str(&format!(", request id: {id}")); } @@ -321,11 +353,14 @@ impl std::fmt::Display for UnexpectedResponseError { write!(f, "{friendly}") } else { let status = self.status; - let body = &self.body; + let body = self.display_body(); let mut message = format!("unexpected status {status}: {body}"); if let Some(url) = &self.url { message.push_str(&format!(", url: {url}")); } + if let Some(cf_ray) = &self.cf_ray { + message.push_str(&format!(", cf-ray: {cf_ray}")); + } if let Some(id) = &self.request_id { message.push_str(&format!(", request id: {id}")); } @@ -335,6 +370,21 @@ impl std::fmt::Display for UnexpectedResponseError { } impl std::error::Error for UnexpectedResponseError {} + +fn truncate_with_ellipsis(text: &str, max_bytes: usize) -> String { + if text.len() <= max_bytes { + return text.to_string(); + } + + let mut cut = max_bytes; + while !text.is_char_boundary(cut) { + cut = cut.saturating_sub(1); + } + let mut truncated = text[..cut].to_string(); + truncated.push_str("..."); + truncated +} + #[derive(Debug)] pub struct RetryLimitReachedError { pub status: StatusCode, @@ -952,15 +1002,14 @@ mod tests { body: "Cloudflare error: Sorry, you have been blocked" .to_string(), url: Some("http://example.com/blocked".to_string()), - request_id: Some("ray-id".to_string()), + cf_ray: Some("ray-id".to_string()), + request_id: None, }; let status = StatusCode::FORBIDDEN.to_string(); let url = "http://example.com/blocked"; assert_eq!( err.to_string(), - format!( - "{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, request id: ray-id" - ) + format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id") ); } @@ -970,6 +1019,7 @@ mod tests { status: StatusCode::FORBIDDEN, body: "plain text error".to_string(), url: Some("http://example.com/plain".to_string()), + cf_ray: None, request_id: None, }; let status = StatusCode::FORBIDDEN.to_string(); @@ -980,6 +1030,63 @@ mod tests { ); } + #[test] + fn unexpected_status_prefers_error_message_when_present() { + let err = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"# + .to_string(), + url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), + cf_ray: None, + request_id: Some("req-123".to_string()), + }; + let status = StatusCode::UNAUTHORIZED.to_string(); + assert_eq!( + err.to_string(), + format!( + "unexpected status {status}: Workspace is not authorized in this region., url: https://chatgpt.com/backend-api/codex/responses, request id: req-123" + ) + ); + } + + #[test] + fn unexpected_status_truncates_long_body_with_ellipsis() { + let long_body = "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES + 10); + let err = UnexpectedResponseError { + status: StatusCode::BAD_GATEWAY, + body: long_body, + url: Some("http://example.com/long".to_string()), + cf_ray: None, + request_id: Some("req-long".to_string()), + }; + let status = StatusCode::BAD_GATEWAY.to_string(); + let expected_body = format!("{}...", "x".repeat(UNEXPECTED_RESPONSE_BODY_MAX_BYTES)); + assert_eq!( + err.to_string(), + format!( + "unexpected status {status}: {expected_body}, url: http://example.com/long, request id: req-long" + ) + ); + } + + #[test] + fn unexpected_status_includes_cf_ray_and_request_id() { + let err = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "plain text error".to_string(), + url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), + cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()), + request_id: Some("req-xyz".to_string()), + }; + let status = StatusCode::UNAUTHORIZED.to_string(); + assert_eq!( + err.to_string(), + format!( + "unexpected status {status}: plain text error, url: https://chatgpt.com/backend-api/codex/responses, cf-ray: 9c81f9f18f2fa49d-LHR, request id: req-xyz" + ) + ); + } + #[test] fn usage_limit_reached_includes_hours_and_minutes() { let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); From ed778f9017e7df1412dc908fe3859ff316433655 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 3 Feb 2026 15:34:28 +0000 Subject: [PATCH 11/13] Avoid redundant transactional check before inserting dynamic tools (#10521) Summary - remove the extra transaction guard that checked for existing dynamic tools per thread before inserting new ones - insert each tool record with `ON CONFLICT(thread_id, position) DO NOTHING` to ignore duplicates instead of pre-querying - simplify execution to use the shared pool directly and avoid unneeded commits Testing - Not run (not requested) --- codex-rs/state/src/runtime.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 3b37b6d424..9a750f1d5d 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -418,17 +418,7 @@ ON CONFLICT(id) DO UPDATE SET if tools.is_empty() { return Ok(()); } - let mut tx = self.pool.begin().await?; let thread_id = thread_id.to_string(); - let existing: Option = - sqlx::query_scalar("SELECT 1 FROM thread_dynamic_tools WHERE thread_id = ? LIMIT 1") - .bind(thread_id.as_str()) - .fetch_optional(&mut *tx) - .await?; - if existing.is_some() { - tx.commit().await?; - return Ok(()); - } for (idx, tool) in tools.iter().enumerate() { let position = i64::try_from(idx).unwrap_or(i64::MAX); let input_schema = serde_json::to_string(&tool.input_schema)?; @@ -441,6 +431,7 @@ INSERT INTO thread_dynamic_tools ( description, input_schema ) VALUES (?, ?, ?, ?, ?) +ON CONFLICT(thread_id, position) DO NOTHING "#, ) .bind(thread_id.as_str()) @@ -448,10 +439,9 @@ INSERT INTO thread_dynamic_tools ( .bind(tool.name.as_str()) .bind(tool.description.as_str()) .bind(input_schema) - .execute(&mut *tx) + .execute(self.pool.as_ref()) .await?; } - tx.commit().await?; Ok(()) } From 1634db667755c3207b68b48ec69b2b3bb1c870d5 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 3 Feb 2026 09:08:04 -0800 Subject: [PATCH 12/13] chore: update bytes crate in response to security advisory (#10525) While here, remove one advisory from `deny.toml` that has been addressed (it was showing up as a warning). --- codex-rs/Cargo.lock | 4 ++-- codex-rs/deny.toml | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5fc804f2f2..2596361529 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -911,9 +911,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "bytestring" diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 3e260ac24e..0a4a08bd89 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -73,7 +73,6 @@ ignore = [ { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, - { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via rama-tls-rustls used by codex-network-proxy; no safe upgrade until rama removes the dependency" }, # TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+. { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" }, ] From db95926c1578efec66fd27ce9469ad172622fe0f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 3 Feb 2026 09:09:20 -0800 Subject: [PATCH 13/13] fix: make $PWD/.agent read-only like $PWD/.codex --- codex-rs/protocol/src/protocol.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index bf193cca9c..140d3931e0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -589,13 +589,18 @@ impl SandboxPolicy { } subpaths.push(top_level_git); } - #[allow(clippy::expect_used)] - let top_level_codex = writable_root - .join(".codex") - .expect(".codex is a valid relative path"); - if top_level_codex.as_path().is_dir() { - subpaths.push(top_level_codex); + + // Make .agents/skills and .codex/config.toml and + // related files read-only to the agent, by default. + for subdir in &[".agents", ".codex"] { + #[allow(clippy::expect_used)] + let top_level_codex = + writable_root.join(subdir).expect("valid relative path"); + if top_level_codex.as_path().is_dir() { + subpaths.push(top_level_codex); + } } + WritableRoot { root: writable_root, read_only_subpaths: subpaths,