diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index e1a08359b2..df41b39a00 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -674,8 +674,6 @@ "byteorder_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"}],\"features\":{\"default\":[\"std\"],\"i128\":[],\"std\":[]}}", "bytes_1.11.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"extra-platforms\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.60\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "bytestring_1.5.0": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"}],\"features\":{\"serde\":[\"dep:serde_core\"]}}", - "bzip2-sys_0.1.13+1.0.8": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"}],\"features\":{\"__disabled\":[],\"static\":[]}}", - "bzip2_0.5.2": "{\"dependencies\":[{\"name\":\"bzip2-sys\",\"optional\":true,\"req\":\"^0.1.13\"},{\"default_features\":false,\"features\":[\"rust-allocator\",\"semver-prefix\"],\"name\":\"libbz2-rs-sys\",\"optional\":true,\"req\":\"^0.1.3\"},{\"features\":[\"quickcheck1\"],\"kind\":\"dev\",\"name\":\"partial-io\",\"req\":\"^0.5.4\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"}],\"features\":{\"default\":[\"dep:bzip2-sys\"],\"libbz2-rs-sys\":[\"dep:libbz2-rs-sys\",\"bzip2-sys?/__disabled\"],\"static\":[\"bzip2-sys?/static\"]}}", "cached_0.56.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.6\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"cached_proc_macro\",\"optional\":true,\"req\":\"^0.25.0\"},{\"name\":\"cached_proc_macro_types\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"copy_dir\",\"req\":\"^0.1.3\"},{\"name\":\"directories\",\"optional\":true,\"req\":\"^6.0\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"googletest\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"r2d2\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"r2d2\"],\"name\":\"redis\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"rmp-serde\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"name\":\"sled\",\"optional\":true,\"req\":\"^0.34\"},{\"kind\":\"dev\",\"name\":\"smartstring\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"time\",\"sync\",\"parking_lot\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\"}],\"features\":{\"ahash\":[\"dep:ahash\",\"hashbrown/default\"],\"async\":[\"futures\",\"tokio\",\"async-trait\"],\"async_tokio_rt_multi_thread\":[\"async\",\"tokio/rt-multi-thread\"],\"default\":[\"proc_macro\",\"ahash\"],\"disk_store\":[\"sled\",\"serde\",\"rmp-serde\",\"directories\"],\"proc_macro\":[\"cached_proc_macro\",\"cached_proc_macro_types\"],\"redis_ahash\":[\"redis_store\",\"redis/ahash\"],\"redis_async_std\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/async-std-comp\",\"redis/tls\",\"redis/async-std-tls-comp\"],\"redis_connection_manager\":[\"redis_store\",\"redis/connection-manager\"],\"redis_store\":[\"redis\",\"r2d2\",\"serde\",\"serde_json\"],\"redis_tokio\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/tokio-comp\",\"redis/tls\",\"redis/tokio-native-tls-comp\"],\"wasm\":[]}}", "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "cached_proc_macro_types_0.1.1": "{\"dependencies\":[],\"features\":{}}", @@ -770,7 +768,6 @@ "deadpool_0.12.3": "{\"dependencies\":[{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.0\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"config\",\"req\":\"^0.15\"},{\"features\":[\"html_reports\",\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"deadpool-runtime\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"name\":\"num_cpus\",\"req\":\"^1.11.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"req\":\"^1.5\"},{\"features\":[\"macros\",\"rt\",\"rt-multi-thread\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.5.0\"}],\"features\":{\"default\":[\"managed\",\"unmanaged\"],\"managed\":[],\"rt_async-std_1\":[\"deadpool-runtime/async-std_1\"],\"rt_tokio_1\":[\"deadpool-runtime/tokio_1\"],\"unmanaged\":[]}}", "debugid_0.8.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.85\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.37\"},{\"name\":\"uuid\",\"req\":\"^1.0.0\"}],\"features\":{}}", "debugserver-types_0.5.0": "{\"dependencies\":[{\"name\":\"schemafy\",\"req\":\"^0.5.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{}}", - "deflate64_0.1.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.7.1\"}],\"features\":{}}", "der-parser_10.0.0": "{\"dependencies\":[{\"name\":\"asn1-rs\",\"req\":\"^0.7\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3.0\"}],\"features\":{\"as_bitvec\":[\"bitvec\"],\"bigint\":[\"num-bigint\"],\"default\":[\"std\"],\"serialize\":[\"std\",\"cookie-factory\"],\"std\":[],\"unstable\":[]}}", "der_0.7.10": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9.2\"},{\"name\":\"der_derive\",\"optional\":true,\"req\":\"^0.7.2\"},{\"name\":\"flagset\",\"optional\":true,\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"features\":[\"alloc\"],\"name\":\"pem-rfc7468\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"arbitrary\":[\"dep:arbitrary\",\"const-oid?/arbitrary\",\"std\"],\"bytes\":[\"dep:bytes\",\"alloc\"],\"derive\":[\"dep:der_derive\"],\"oid\":[\"dep:const-oid\"],\"pem\":[\"dep:pem-rfc7468\",\"alloc\",\"zeroize\"],\"real\":[],\"std\":[\"alloc\"]}}", "deranged_0.5.5": "{\"dependencies\":[{\"name\":\"deranged-macros\",\"optional\":true,\"req\":\"=0.3.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"powerfmt\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.86\"}],\"features\":{\"alloc\":[],\"default\":[],\"macros\":[\"dep:deranged-macros\"],\"num\":[\"dep:num-traits\"],\"powerfmt\":[\"dep:powerfmt\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\"],\"rand09\":[\"dep:rand09\"],\"serde\":[\"dep:serde_core\"]}}", @@ -1026,8 +1023,6 @@ "lru_0.12.5": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"scoped_threadpool\",\"req\":\"0.1.*\"},{\"kind\":\"dev\",\"name\":\"stats_alloc\",\"req\":\"0.1.*\"}],\"features\":{\"default\":[\"hashbrown\"],\"nightly\":[\"hashbrown\",\"hashbrown/nightly\"]}}", "lru_0.16.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"scoped_threadpool\",\"req\":\"0.1.*\"},{\"kind\":\"dev\",\"name\":\"stats_alloc\",\"req\":\"0.1.*\"}],\"features\":{\"default\":[\"hashbrown\"],\"nightly\":[\"hashbrown\",\"hashbrown/nightly\"]}}", "lsp-types_0.94.1": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.34\"},{\"name\":\"serde_json\",\"req\":\"^1.0.50\"},{\"name\":\"serde_repr\",\"req\":\"^0.1\"},{\"features\":[\"serde\"],\"name\":\"url\",\"req\":\"^2.0.0\"}],\"features\":{\"default\":[],\"proposed\":[]}}", - "lzma-rs_0.3.0": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.9.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"rust-lzma\",\"req\":\"^0.5\"}],\"features\":{\"enable_logging\":[\"env_logger\",\"log\"],\"raw_decoder\":[],\"stream\":[]}}", - "lzma-sys_0.1.20": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.34\"},{\"name\":\"libc\",\"req\":\"^0.2.51\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.14\"}],\"features\":{\"static\":[]}}", "mach2_0.4.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"}],\"features\":{\"default\":[],\"unstable\":[]}}", "maplit_1.0.2": "{\"dependencies\":[],\"features\":{}}", "matchers_0.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"syntax\",\"dfa-build\",\"dfa-search\"],\"name\":\"regex-automata\",\"req\":\"^0.4\"}],\"features\":{\"unicode\":[\"regex-automata/unicode\"]}}", @@ -1603,7 +1598,6 @@ "x509-parser_0.18.1": "{\"dependencies\":[{\"features\":[\"datetime\"],\"name\":\"asn1-rs\",\"req\":\"^0.7.0\"},{\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"data-encoding\",\"req\":\"^2.2.1\"},{\"features\":[\"bigint\"],\"name\":\"der-parser\",\"req\":\"^10.0\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"nom\",\"req\":\"^7.0\"},{\"features\":[\"crypto\",\"x509\",\"x962\"],\"name\":\"oid-registry\",\"req\":\"^0.8.1\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17.12\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.35\"}],\"features\":{\"default\":[],\"validate\":[],\"verify\":[\"ring\"],\"verify-aws\":[\"aws-lc-rs\"]}}", "xattr_1.6.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.150\",\"target\":\"cfg(any(target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^1.0.0\",\"target\":\"cfg(any(target_os = \\\"android\\\", target_os = \\\"linux\\\", target_os = \\\"macos\\\", target_os = \\\"hurd\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"default\":[\"unsupported\"],\"unsupported\":[]}}", "xdg-home_1.3.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{}}", - "xz2_0.1.7": "{\"dependencies\":[{\"name\":\"futures\",\"optional\":true,\"req\":\"^0.1.26\"},{\"name\":\"lzma-sys\",\"req\":\"^0.1.18\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"tokio-core\",\"req\":\"^0.1.17\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.12\"}],\"features\":{\"static\":[\"lzma-sys/static\"],\"tokio\":[\"tokio-io\",\"futures\"]}}", "yaml-rust_0.4.5": "{\"dependencies\":[{\"name\":\"linked-hash-map\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^0.9\"}],\"features\":{}}", "yansi_1.0.1": "{\"dependencies\":[{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4.11\"}],\"features\":{\"_nightly\":[],\"alloc\":[],\"default\":[\"std\"],\"detect-env\":[\"std\"],\"detect-tty\":[\"is-terminal\",\"std\"],\"hyperlink\":[\"std\"],\"std\":[\"alloc\"]}}", "yasna_0.5.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.1\"}],\"features\":{\"default\":[],\"std\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e69f32072d..902c534da5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1051,25 +1051,6 @@ dependencies = [ "bytes", ] -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "cached" version = "0.56.0" @@ -1386,6 +1367,7 @@ dependencies = [ "tracing", "tungstenite", "url", + "wasm-bindgen-futures", "wiremock", ] @@ -1683,6 +1665,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "wasm-bindgen-futures", "zstd", ] @@ -1763,6 +1746,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "shlex", "tokio", "tokio-util", "tracing", @@ -1879,6 +1863,7 @@ dependencies = [ "image", "indexmap 2.13.0", "insta", + "js-sys", "landlock", "libc", "maplit", @@ -2928,7 +2913,11 @@ name = "codex-wasm-harness" version = "0.0.0" dependencies = [ "async-trait", + "codex-code-mode", "codex-core", + "codex-exec-server", + "codex-features", + "codex-protocol", "js-sys", "pretty_assertions", "serde", @@ -3611,12 +3600,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "deflate64" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" - [[package]] name = "der" version = "0.7.10" @@ -5916,27 +5899,6 @@ dependencies = [ "url", ] -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "mach2" version = "0.4.3" @@ -11894,15 +11856,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yaml-rust" version = "0.4.5" @@ -12113,28 +12066,15 @@ version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" dependencies = [ - "aes", "arbitrary", - "bzip2", - "constant_time_eq", "crc32fast", "crossbeam-utils", - "deflate64", "displaydoc", "flate2", - "getrandom 0.3.4", - "hmac", "indexmap 2.13.0", - "lzma-rs", "memchr", - "pbkdf2", - "sha1", "thiserror 2.0.18", - "time", - "xz2", - "zeroize", "zopfli", - "zstd", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 85c15e5b46..4931a38bb3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -343,7 +343,7 @@ web-sys = "0.3.85" webbrowser = "1.0" which = "8" wildmatch = "2.6.1" -zip = "2.4.2" +zip = { version = "2.4.2", default-features = false, features = ["deflate"] } zstd = "0.13" wiremock = "0.6" diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 2fad46c2af..8d08700976 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -19,6 +19,8 @@ workspace = true anyhow = { workspace = true } similar = { workspace = true } thiserror = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 519671798d..03e8259fbe 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -15,10 +15,10 @@ use crate::ApplyPatchFileChange; use crate::ApplyPatchFileUpdate; use crate::IoError; use crate::MaybeApplyPatchVerified; -use crate::parser::Hunk; -use crate::parser::ParseError; -use crate::parser::parse_patch; use crate::unified_diff_from_chunks; +use super::parser::Hunk; +use super::parser::ParseError; +use super::parser::parse_patch; use std::str::Utf8Error; use tree_sitter::LanguageError; diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 3041c4b2cd..a37391aa5c 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -1,1074 +1,5 @@ -mod invocation; -mod parser; -mod seek_sequence; -mod standalone_executable; +#[cfg_attr(target_arch = "wasm32", path = "wasm.rs")] +#[cfg_attr(not(target_arch = "wasm32"), path = "native.rs")] +mod imp; -use std::collections::HashMap; -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Context; -use anyhow::Result; -pub use parser::Hunk; -pub use parser::ParseError; -use parser::ParseError::*; -use parser::UpdateFileChunk; -pub use parser::parse_patch; -use similar::TextDiff; -use thiserror::Error; - -pub use invocation::maybe_parse_apply_patch_verified; -pub use standalone_executable::main; - -use crate::invocation::ExtractHeredocError; - -/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. -pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); - -/// Special argv[1] flag used when the Codex executable self-invokes to run the -/// internal `apply_patch` path. -/// -/// Although this constant lives in `codex-apply-patch` (to avoid forcing -/// `codex-arg0` to depend on `codex-core`), it is part of the "codex core" -/// process-invocation contract between the apply-patch runtime and the arg0 -/// dispatcher. -pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; - -#[derive(Debug, Error, PartialEq)] -pub enum ApplyPatchError { - #[error(transparent)] - ParseError(#[from] ParseError), - #[error(transparent)] - IoError(#[from] IoError), - /// Error that occurs while computing replacements when applying patch chunks - #[error("{0}")] - ComputeReplacements(String), - /// A raw patch body was provided without an explicit `apply_patch` invocation. - #[error( - "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" - )] - ImplicitInvocation, -} - -impl From for ApplyPatchError { - fn from(err: std::io::Error) -> Self { - ApplyPatchError::IoError(IoError { - context: "I/O error".to_string(), - source: err, - }) - } -} - -impl From<&std::io::Error> for ApplyPatchError { - fn from(err: &std::io::Error) -> Self { - ApplyPatchError::IoError(IoError { - context: "I/O error".to_string(), - source: std::io::Error::new(err.kind(), err.to_string()), - }) - } -} - -#[derive(Debug, Error)] -#[error("{context}: {source}")] -pub struct IoError { - context: String, - #[source] - source: std::io::Error, -} - -impl PartialEq for IoError { - fn eq(&self, other: &Self) -> bool { - self.context == other.context && self.source.to_string() == other.source.to_string() - } -} - -/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument -/// parsed into hunks. -#[derive(Debug, PartialEq)] -pub struct ApplyPatchArgs { - pub patch: String, - pub hunks: Vec, - pub workdir: Option, -} - -#[derive(Debug, PartialEq)] -pub enum ApplyPatchFileChange { - Add { - content: String, - }, - Delete { - content: String, - }, - Update { - unified_diff: String, - move_path: Option, - /// new_content that will result after the unified_diff is applied. - new_content: String, - }, -} - -#[derive(Debug, PartialEq)] -pub enum MaybeApplyPatchVerified { - /// `argv` corresponded to an `apply_patch` invocation, and these are the - /// resulting proposed file changes. - Body(ApplyPatchAction), - /// `argv` could not be parsed to determine whether it corresponds to an - /// `apply_patch` invocation. - ShellParseError(ExtractHeredocError), - /// `argv` corresponded to an `apply_patch` invocation, but it could not - /// be fulfilled due to the specified error. - CorrectnessError(ApplyPatchError), - /// `argv` decidedly did not correspond to an `apply_patch` invocation. - NotApplyPatch, -} - -/// ApplyPatchAction is the result of parsing an `apply_patch` command. By -/// construction, all paths should be absolute paths. -#[derive(Debug, PartialEq)] -pub struct ApplyPatchAction { - changes: HashMap, - - /// The raw patch argument that can be used with `apply_patch` as an exec - /// call. i.e., if the original arg was parsed in "lenient" mode with a - /// heredoc, this should be the value without the heredoc wrapper. - pub patch: String, - - /// The working directory that was used to resolve relative paths in the patch. - pub cwd: PathBuf, -} - -impl ApplyPatchAction { - pub fn is_empty(&self) -> bool { - self.changes.is_empty() - } - - /// Returns the changes that would be made by applying the patch. - pub fn changes(&self) -> &HashMap { - &self.changes - } - - /// Should be used exclusively for testing. (Not worth the overhead of - /// creating a feature flag for this.) - pub fn new_add_for_test(path: &Path, content: String) -> Self { - if !path.is_absolute() { - panic!("path must be absolute"); - } - - #[expect(clippy::expect_used)] - let filename = path - .file_name() - .expect("path should not be empty") - .to_string_lossy(); - let patch = format!( - r#"*** Begin Patch -*** Update File: {filename} -@@ -+ {content} -*** End Patch"#, - ); - let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]); - #[expect(clippy::expect_used)] - Self { - changes, - cwd: path - .parent() - .expect("path should have parent") - .to_path_buf(), - patch, - } - } -} - -/// Applies the patch and prints the result to stdout/stderr. -pub fn apply_patch( - patch: &str, - stdout: &mut impl std::io::Write, - stderr: &mut impl std::io::Write, -) -> Result<(), ApplyPatchError> { - let hunks = match parse_patch(patch) { - Ok(source) => source.hunks, - Err(e) => { - match &e { - InvalidPatchError(message) => { - writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?; - } - InvalidHunkError { - message, - line_number, - } => { - writeln!( - stderr, - "Invalid patch hunk on line {line_number}: {message}" - ) - .map_err(ApplyPatchError::from)?; - } - } - return Err(ApplyPatchError::ParseError(e)); - } - }; - - apply_hunks(&hunks, stdout, stderr)?; - - Ok(()) -} - -/// Applies hunks and continues to update stdout/stderr -pub fn apply_hunks( - hunks: &[Hunk], - stdout: &mut impl std::io::Write, - stderr: &mut impl std::io::Write, -) -> Result<(), ApplyPatchError> { - let _existing_paths: Vec<&Path> = hunks - .iter() - .filter_map(|hunk| match hunk { - Hunk::AddFile { .. } => { - // The file is being added, so it doesn't exist yet. - None - } - Hunk::DeleteFile { path } => Some(path.as_path()), - Hunk::UpdateFile { - path, move_path, .. - } => match move_path { - Some(move_path) => { - if std::fs::metadata(move_path) - .map(|m| m.is_file()) - .unwrap_or(false) - { - Some(move_path.as_path()) - } else { - None - } - } - None => Some(path.as_path()), - }, - }) - .collect::>(); - - // Delegate to a helper that applies each hunk to the filesystem. - match apply_hunks_to_files(hunks) { - Ok(affected) => { - print_summary(&affected, stdout).map_err(ApplyPatchError::from)?; - Ok(()) - } - Err(err) => { - let msg = err.to_string(); - writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?; - if let Some(io) = err.downcast_ref::() { - Err(ApplyPatchError::from(io)) - } else { - Err(ApplyPatchError::IoError(IoError { - context: msg, - source: std::io::Error::other(err), - })) - } - } - } -} - -/// Applies each parsed patch hunk to the filesystem. -/// Returns an error if any of the changes could not be applied. -/// Tracks file paths affected by applying a patch. -pub struct AffectedPaths { - pub added: Vec, - pub modified: Vec, - pub deleted: Vec, -} - -/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted. -/// Returns an error if the patch could not be applied. -fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { - if hunks.is_empty() { - anyhow::bail!("No files were modified."); - } - - let mut added: Vec = Vec::new(); - let mut modified: Vec = Vec::new(); - let mut deleted: Vec = Vec::new(); - for hunk in hunks { - match hunk { - Hunk::AddFile { path, contents } => { - if let Some(parent) = path.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", path.display()) - })?; - } - std::fs::write(path, contents) - .with_context(|| format!("Failed to write file {}", path.display()))?; - added.push(path.clone()); - } - Hunk::DeleteFile { path } => { - std::fs::remove_file(path) - .with_context(|| format!("Failed to delete file {}", path.display()))?; - deleted.push(path.clone()); - } - Hunk::UpdateFile { - path, - move_path, - chunks, - } => { - let AppliedPatch { new_contents, .. } = - derive_new_contents_from_chunks(path, chunks)?; - if let Some(dest) = move_path { - if let Some(parent) = dest.parent() - && !parent.as_os_str().is_empty() - { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", dest.display()) - })?; - } - std::fs::write(dest, new_contents) - .with_context(|| format!("Failed to write file {}", dest.display()))?; - std::fs::remove_file(path) - .with_context(|| format!("Failed to remove original {}", path.display()))?; - modified.push(dest.clone()); - } else { - std::fs::write(path, new_contents) - .with_context(|| format!("Failed to write file {}", path.display()))?; - modified.push(path.clone()); - } - } - } - } - Ok(AffectedPaths { - added, - modified, - deleted, - }) -} - -struct AppliedPatch { - original_contents: String, - new_contents: String, -} - -/// Return *only* the new file contents (joined into a single `String`) after -/// applying the chunks to the file at `path`. -fn derive_new_contents_from_chunks( - path: &Path, - chunks: &[UpdateFileChunk], -) -> std::result::Result { - let original_contents = match std::fs::read_to_string(path) { - Ok(contents) => contents, - Err(err) => { - return Err(ApplyPatchError::IoError(IoError { - context: format!("Failed to read file to update {}", path.display()), - source: err, - })); - } - }; - - let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); - - // Drop the trailing empty element that results from the final newline so - // that line counts match the behaviour of standard `diff`. - if original_lines.last().is_some_and(String::is_empty) { - original_lines.pop(); - } - - let replacements = compute_replacements(&original_lines, path, chunks)?; - let new_lines = apply_replacements(original_lines, &replacements); - let mut new_lines = new_lines; - if !new_lines.last().is_some_and(String::is_empty) { - new_lines.push(String::new()); - } - let new_contents = new_lines.join("\n"); - Ok(AppliedPatch { - original_contents, - new_contents, - }) -} - -/// Compute a list of replacements needed to transform `original_lines` into the -/// new lines, given the patch `chunks`. Each replacement is returned as -/// `(start_index, old_len, new_lines)`. -fn compute_replacements( - original_lines: &[String], - path: &Path, - chunks: &[UpdateFileChunk], -) -> std::result::Result)>, ApplyPatchError> { - let mut replacements: Vec<(usize, usize, Vec)> = Vec::new(); - let mut line_index: usize = 0; - - for chunk in chunks { - // If a chunk has a `change_context`, we use seek_sequence to find it, then - // adjust our `line_index` to continue from there. - if let Some(ctx_line) = &chunk.change_context { - if let Some(idx) = seek_sequence::seek_sequence( - original_lines, - std::slice::from_ref(ctx_line), - line_index, - /*eof*/ false, - ) { - line_index = idx + 1; - } else { - return Err(ApplyPatchError::ComputeReplacements(format!( - "Failed to find context '{}' in {}", - ctx_line, - path.display() - ))); - } - } - - if chunk.old_lines.is_empty() { - // Pure addition (no old lines). We'll add them at the end or just - // before the final empty line if one exists. - let insertion_idx = if original_lines.last().is_some_and(String::is_empty) { - original_lines.len() - 1 - } else { - original_lines.len() - }; - replacements.push((insertion_idx, 0, chunk.new_lines.clone())); - continue; - } - - // Otherwise, try to match the existing lines in the file with the old lines - // from the chunk. If found, schedule that region for replacement. - // Attempt to locate the `old_lines` verbatim within the file. In many - // real‑world diffs the last element of `old_lines` is an *empty* string - // representing the terminating newline of the region being replaced. - // This sentinel is not present in `original_lines` because we strip the - // trailing empty slice emitted by `split('\n')`. If a direct search - // fails and the pattern ends with an empty string, retry without that - // final element so that modifications touching the end‑of‑file can be - // located reliably. - - let mut pattern: &[String] = &chunk.old_lines; - let mut found = - seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file); - - let mut new_slice: &[String] = &chunk.new_lines; - - if found.is_none() && pattern.last().is_some_and(String::is_empty) { - // Retry without the trailing empty line which represents the final - // newline in the file. - pattern = &pattern[..pattern.len() - 1]; - if new_slice.last().is_some_and(String::is_empty) { - new_slice = &new_slice[..new_slice.len() - 1]; - } - - found = seek_sequence::seek_sequence( - original_lines, - pattern, - line_index, - chunk.is_end_of_file, - ); - } - - if let Some(start_idx) = found { - replacements.push((start_idx, pattern.len(), new_slice.to_vec())); - line_index = start_idx + pattern.len(); - } else { - return Err(ApplyPatchError::ComputeReplacements(format!( - "Failed to find expected lines in {}:\n{}", - path.display(), - chunk.old_lines.join("\n"), - ))); - } - } - - replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx)); - - Ok(replacements) -} - -/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`, -/// returning the modified file contents as a vector of lines. -fn apply_replacements( - mut lines: Vec, - replacements: &[(usize, usize, Vec)], -) -> Vec { - // We must apply replacements in descending order so that earlier replacements - // don't shift the positions of later ones. - for (start_idx, old_len, new_segment) in replacements.iter().rev() { - let start_idx = *start_idx; - let old_len = *old_len; - - // Remove old lines. - for _ in 0..old_len { - if start_idx < lines.len() { - lines.remove(start_idx); - } - } - - // Insert new lines. - for (offset, new_line) in new_segment.iter().enumerate() { - lines.insert(start_idx + offset, new_line.clone()); - } - } - - lines -} - -/// Intended result of a file update for apply_patch. -#[derive(Debug, Eq, PartialEq)] -pub struct ApplyPatchFileUpdate { - unified_diff: String, - content: String, -} - -pub fn unified_diff_from_chunks( - path: &Path, - chunks: &[UpdateFileChunk], -) -> std::result::Result { - unified_diff_from_chunks_with_context(path, chunks, /*context*/ 1) -} - -pub fn unified_diff_from_chunks_with_context( - path: &Path, - chunks: &[UpdateFileChunk], - context: usize, -) -> std::result::Result { - let AppliedPatch { - original_contents, - new_contents, - } = derive_new_contents_from_chunks(path, chunks)?; - let text_diff = TextDiff::from_lines(&original_contents, &new_contents); - let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); - Ok(ApplyPatchFileUpdate { - unified_diff, - content: new_contents, - }) -} - -/// Print the summary of changes in git-style format. -/// Write a summary of changes to the given writer. -pub fn print_summary( - affected: &AffectedPaths, - out: &mut impl std::io::Write, -) -> std::io::Result<()> { - writeln!(out, "Success. Updated the following files:")?; - for path in &affected.added { - writeln!(out, "A {}", path.display())?; - } - for path in &affected.modified { - writeln!(out, "M {}", path.display())?; - } - for path in &affected.deleted { - writeln!(out, "D {}", path.display())?; - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::fs; - use std::string::ToString; - use tempfile::tempdir; - - /// Helper to construct a patch with the given body. - fn wrap_patch(body: &str) -> String { - format!("*** Begin Patch\n{body}\n*** End Patch") - } - - #[test] - fn test_add_file_hunk_creates_file_with_contents() { - let dir = tempdir().unwrap(); - let path = dir.path().join("add.txt"); - let patch = wrap_patch(&format!( - r#"*** Add File: {} -+ab -+cd"#, - path.display() - )); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - // Verify expected stdout and stderr outputs. - let stdout_str = String::from_utf8(stdout).unwrap(); - let stderr_str = String::from_utf8(stderr).unwrap(); - let expected_out = format!( - "Success. Updated the following files:\nA {}\n", - path.display() - ); - assert_eq!(stdout_str, expected_out); - assert_eq!(stderr_str, ""); - let contents = fs::read_to_string(path).unwrap(); - assert_eq!(contents, "ab\ncd\n"); - } - - #[test] - fn test_delete_file_hunk_removes_file() { - let dir = tempdir().unwrap(); - let path = dir.path().join("del.txt"); - fs::write(&path, "x").unwrap(); - let patch = wrap_patch(&format!("*** Delete File: {}", path.display())); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - let stdout_str = String::from_utf8(stdout).unwrap(); - let stderr_str = String::from_utf8(stderr).unwrap(); - let expected_out = format!( - "Success. Updated the following files:\nD {}\n", - path.display() - ); - assert_eq!(stdout_str, expected_out); - assert_eq!(stderr_str, ""); - assert!(!path.exists()); - } - - #[test] - fn test_update_file_hunk_modifies_content() { - let dir = tempdir().unwrap(); - let path = dir.path().join("update.txt"); - fs::write(&path, "foo\nbar\n").unwrap(); - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ - foo --bar -+baz"#, - path.display() - )); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - // Validate modified file contents and expected stdout/stderr. - let stdout_str = String::from_utf8(stdout).unwrap(); - let stderr_str = String::from_utf8(stderr).unwrap(); - let expected_out = format!( - "Success. Updated the following files:\nM {}\n", - path.display() - ); - assert_eq!(stdout_str, expected_out); - assert_eq!(stderr_str, ""); - let contents = fs::read_to_string(&path).unwrap(); - assert_eq!(contents, "foo\nbaz\n"); - } - - #[test] - fn test_update_file_hunk_can_move_file() { - let dir = tempdir().unwrap(); - let src = dir.path().join("src.txt"); - let dest = dir.path().join("dst.txt"); - fs::write(&src, "line\n").unwrap(); - let patch = wrap_patch(&format!( - r#"*** Update File: {} -*** Move to: {} -@@ --line -+line2"#, - src.display(), - dest.display() - )); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - // Validate move semantics and expected stdout/stderr. - let stdout_str = String::from_utf8(stdout).unwrap(); - let stderr_str = String::from_utf8(stderr).unwrap(); - let expected_out = format!( - "Success. Updated the following files:\nM {}\n", - dest.display() - ); - assert_eq!(stdout_str, expected_out); - assert_eq!(stderr_str, ""); - assert!(!src.exists()); - let contents = fs::read_to_string(&dest).unwrap(); - assert_eq!(contents, "line2\n"); - } - - /// Verify that a single `Update File` hunk with multiple change chunks can update different - /// parts of a file and that the file is listed only once in the summary. - #[test] - fn test_multiple_update_chunks_apply_to_single_file() { - // Start with a file containing four lines. - let dir = tempdir().unwrap(); - let path = dir.path().join("multi.txt"); - fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap(); - // Construct an update patch with two separate change chunks. - // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`. - // The second chunk uses `baz` as context and transforms `qux` into `QUX`. - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ - foo --bar -+BAR -@@ - baz --qux -+QUX"#, - path.display() - )); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - let stdout_str = String::from_utf8(stdout).unwrap(); - let stderr_str = String::from_utf8(stderr).unwrap(); - let expected_out = format!( - "Success. Updated the following files:\nM {}\n", - path.display() - ); - assert_eq!(stdout_str, expected_out); - assert_eq!(stderr_str, ""); - let contents = fs::read_to_string(&path).unwrap(); - assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n"); - } - - /// A more involved `Update File` hunk that exercises additions, deletions and - /// replacements in separate chunks that appear in non‑adjacent parts of the - /// file. Verifies that all edits are applied and that the summary lists the - /// file only once. - #[test] - fn test_update_file_hunk_interleaved_changes() { - let dir = tempdir().unwrap(); - let path = dir.path().join("interleaved.txt"); - - // Original file: six numbered lines. - fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap(); - - // Patch performs: - // • Replace `b` → `B` - // • Replace `e` → `E` (using surrounding context) - // • Append new line `g` at the end‑of‑file - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ - a --b -+B -@@ - c - d --e -+E -@@ - f -+g -*** End of File"#, - path.display() - )); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - - let stdout_str = String::from_utf8(stdout).unwrap(); - let stderr_str = String::from_utf8(stderr).unwrap(); - - let expected_out = format!( - "Success. Updated the following files:\nM {}\n", - path.display() - ); - assert_eq!(stdout_str, expected_out); - assert_eq!(stderr_str, ""); - - let contents = fs::read_to_string(&path).unwrap(); - assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); - } - - #[test] - fn test_pure_addition_chunk_followed_by_removal() { - let dir = tempdir().unwrap(); - let path = dir.path().join("panic.txt"); - fs::write(&path, "line1\nline2\nline3\n").unwrap(); - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ -+after-context -+second-line -@@ - line1 --line2 --line3 -+line2-replacement"#, - path.display() - )); - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - let contents = fs::read_to_string(path).unwrap(); - assert_eq!( - contents, - "line1\nline2-replacement\nafter-context\nsecond-line\n" - ); - } - - /// Ensure that patches authored with ASCII characters can update lines that - /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING - /// HYPHEN). Historically `git apply` succeeds in such scenarios but our - /// internal matcher failed requiring an exact byte-for-byte match. The - /// fuzzy-matching pass that normalises common punctuation should now bridge - /// the gap. - #[test] - fn test_update_line_with_unicode_dash() { - let dir = tempdir().unwrap(); - let path = dir.path().join("unicode.py"); - - // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}). - let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n"; - std::fs::write(&path, original).unwrap(); - - // Patch uses plain ASCII dash / hyphen. - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ --import asyncio # local import - avoids top-level dep -+import asyncio # HELLO"#, - path.display() - )); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - - // File should now contain the replaced comment. - let expected = "import asyncio # HELLO\n"; - let contents = std::fs::read_to_string(&path).unwrap(); - assert_eq!(contents, expected); - - // Ensure success summary lists the file as modified. - let stdout_str = String::from_utf8(stdout).unwrap(); - let expected_out = format!( - "Success. Updated the following files:\nM {}\n", - path.display() - ); - assert_eq!(stdout_str, expected_out); - - // No stderr expected. - assert_eq!(String::from_utf8(stderr).unwrap(), ""); - } - - #[test] - fn test_unified_diff() { - // Start with a file containing four lines. - let dir = tempdir().unwrap(); - let path = dir.path().join("multi.txt"); - fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap(); - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ - foo --bar -+BAR -@@ - baz --qux -+QUX"#, - path.display() - )); - let patch = parse_patch(&patch).unwrap(); - - let update_file_chunks = match patch.hunks.as_slice() { - [Hunk::UpdateFile { chunks, .. }] => chunks, - _ => panic!("Expected a single UpdateFile hunk"), - }; - let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap(); - let expected_diff = r#"@@ -1,4 +1,4 @@ - foo --bar -+BAR - baz --qux -+QUX -"#; - let expected = ApplyPatchFileUpdate { - unified_diff: expected_diff.to_string(), - content: "foo\nBAR\nbaz\nQUX\n".to_string(), - }; - assert_eq!(expected, diff); - } - - #[test] - fn test_unified_diff_first_line_replacement() { - // Replace the very first line of the file. - let dir = tempdir().unwrap(); - let path = dir.path().join("first.txt"); - fs::write(&path, "foo\nbar\nbaz\n").unwrap(); - - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ --foo -+FOO - bar -"#, - path.display() - )); - - let patch = parse_patch(&patch).unwrap(); - let chunks = match patch.hunks.as_slice() { - [Hunk::UpdateFile { chunks, .. }] => chunks, - _ => panic!("Expected a single UpdateFile hunk"), - }; - - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); - let expected_diff = r#"@@ -1,2 +1,2 @@ --foo -+FOO - bar -"#; - let expected = ApplyPatchFileUpdate { - unified_diff: expected_diff.to_string(), - content: "FOO\nbar\nbaz\n".to_string(), - }; - assert_eq!(expected, diff); - } - - #[test] - fn test_unified_diff_last_line_replacement() { - // Replace the very last line of the file. - let dir = tempdir().unwrap(); - let path = dir.path().join("last.txt"); - fs::write(&path, "foo\nbar\nbaz\n").unwrap(); - - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ - foo - bar --baz -+BAZ -"#, - path.display() - )); - - let patch = parse_patch(&patch).unwrap(); - let chunks = match patch.hunks.as_slice() { - [Hunk::UpdateFile { chunks, .. }] => chunks, - _ => panic!("Expected a single UpdateFile hunk"), - }; - - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); - let expected_diff = r#"@@ -2,2 +2,2 @@ - bar --baz -+BAZ -"#; - let expected = ApplyPatchFileUpdate { - unified_diff: expected_diff.to_string(), - content: "foo\nbar\nBAZ\n".to_string(), - }; - assert_eq!(expected, diff); - } - - #[test] - fn test_unified_diff_insert_at_eof() { - // Insert a new line at end‑of‑file. - let dir = tempdir().unwrap(); - let path = dir.path().join("insert.txt"); - fs::write(&path, "foo\nbar\nbaz\n").unwrap(); - - let patch = wrap_patch(&format!( - r#"*** Update File: {} -@@ -+quux -*** End of File -"#, - path.display() - )); - - let patch = parse_patch(&patch).unwrap(); - let chunks = match patch.hunks.as_slice() { - [Hunk::UpdateFile { chunks, .. }] => chunks, - _ => panic!("Expected a single UpdateFile hunk"), - }; - - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); - let expected_diff = r#"@@ -3 +3,2 @@ - baz -+quux -"#; - let expected = ApplyPatchFileUpdate { - unified_diff: expected_diff.to_string(), - content: "foo\nbar\nbaz\nquux\n".to_string(), - }; - assert_eq!(expected, diff); - } - - #[test] - fn test_unified_diff_interleaved_changes() { - // Original file with six lines. - let dir = tempdir().unwrap(); - let path = dir.path().join("interleaved.txt"); - fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap(); - - // Patch replaces two separate lines and appends a new one at EOF using - // three distinct chunks. - let patch_body = format!( - r#"*** Update File: {} -@@ - a --b -+B -@@ - d --e -+E -@@ - f -+g -*** End of File"#, - path.display() - ); - let patch = wrap_patch(&patch_body); - - // Extract chunks then build the unified diff. - let parsed = parse_patch(&patch).unwrap(); - let chunks = match parsed.hunks.as_slice() { - [Hunk::UpdateFile { chunks, .. }] => chunks, - _ => panic!("Expected a single UpdateFile hunk"), - }; - - let diff = unified_diff_from_chunks(&path, chunks).unwrap(); - - let expected_diff = r#"@@ -1,6 +1,7 @@ - a --b -+B - c - d --e -+E - f -+g -"#; - - let expected = ApplyPatchFileUpdate { - unified_diff: expected_diff.to_string(), - content: "a\nB\nc\nd\nE\nf\ng\n".to_string(), - }; - - assert_eq!(expected, diff); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); - let contents = fs::read_to_string(path).unwrap(); - assert_eq!( - contents, - r#"a -B -c -d -E -f -g -"# - ); - } - - #[test] - fn test_apply_patch_fails_on_write_error() { - let dir = tempdir().unwrap(); - let path = dir.path().join("readonly.txt"); - fs::write(&path, "before\n").unwrap(); - let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_readonly(true); - fs::set_permissions(&path, perms).unwrap(); - - let patch = wrap_patch(&format!( - "*** Update File: {}\n@@\n-before\n+after\n*** End Patch", - path.display() - )); - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - let result = apply_patch(&patch, &mut stdout, &mut stderr); - assert!(result.is_err()); - } -} +pub use imp::*; diff --git a/codex-rs/apply-patch/src/native.rs b/codex-rs/apply-patch/src/native.rs new file mode 100644 index 0000000000..a66e5db071 --- /dev/null +++ b/codex-rs/apply-patch/src/native.rs @@ -0,0 +1,1074 @@ +mod invocation; +mod parser; +mod seek_sequence; +mod standalone_executable; + +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +pub use parser::Hunk; +pub use parser::ParseError; +use parser::ParseError::*; +use parser::UpdateFileChunk; +pub use parser::parse_patch; +use similar::TextDiff; +use thiserror::Error; + +pub use invocation::maybe_parse_apply_patch_verified; +pub use standalone_executable::main; + +use self::invocation::ExtractHeredocError; + +/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. +pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); + +/// Special argv[1] flag used when the Codex executable self-invokes to run the +/// internal `apply_patch` path. +/// +/// Although this constant lives in `codex-apply-patch` (to avoid forcing +/// `codex-arg0` to depend on `codex-core`), it is part of the "codex core" +/// process-invocation contract between the apply-patch runtime and the arg0 +/// dispatcher. +pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; + +#[derive(Debug, Error, PartialEq)] +pub enum ApplyPatchError { + #[error(transparent)] + ParseError(#[from] ParseError), + #[error(transparent)] + IoError(#[from] IoError), + /// Error that occurs while computing replacements when applying patch chunks + #[error("{0}")] + ComputeReplacements(String), + /// A raw patch body was provided without an explicit `apply_patch` invocation. + #[error( + "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" + )] + ImplicitInvocation, +} + +impl From for ApplyPatchError { + fn from(err: std::io::Error) -> Self { + ApplyPatchError::IoError(IoError { + context: "I/O error".to_string(), + source: err, + }) + } +} + +impl From<&std::io::Error> for ApplyPatchError { + fn from(err: &std::io::Error) -> Self { + ApplyPatchError::IoError(IoError { + context: "I/O error".to_string(), + source: std::io::Error::new(err.kind(), err.to_string()), + }) + } +} + +#[derive(Debug, Error)] +#[error("{context}: {source}")] +pub struct IoError { + context: String, + #[source] + source: std::io::Error, +} + +impl PartialEq for IoError { + fn eq(&self, other: &Self) -> bool { + self.context == other.context && self.source.to_string() == other.source.to_string() + } +} + +/// Both the raw PATCH argument to `apply_patch` as well as the PATCH argument +/// parsed into hunks. +#[derive(Debug, PartialEq)] +pub struct ApplyPatchArgs { + pub patch: String, + pub hunks: Vec, + pub workdir: Option, +} + +#[derive(Debug, PartialEq)] +pub enum ApplyPatchFileChange { + Add { + content: String, + }, + Delete { + content: String, + }, + Update { + unified_diff: String, + move_path: Option, + /// new_content that will result after the unified_diff is applied. + new_content: String, + }, +} + +#[derive(Debug, PartialEq)] +pub enum MaybeApplyPatchVerified { + /// `argv` corresponded to an `apply_patch` invocation, and these are the + /// resulting proposed file changes. + Body(ApplyPatchAction), + /// `argv` could not be parsed to determine whether it corresponds to an + /// `apply_patch` invocation. + ShellParseError(ExtractHeredocError), + /// `argv` corresponded to an `apply_patch` invocation, but it could not + /// be fulfilled due to the specified error. + CorrectnessError(ApplyPatchError), + /// `argv` decidedly did not correspond to an `apply_patch` invocation. + NotApplyPatch, +} + +/// ApplyPatchAction is the result of parsing an `apply_patch` command. By +/// construction, all paths should be absolute paths. +#[derive(Debug, PartialEq)] +pub struct ApplyPatchAction { + changes: HashMap, + + /// The raw patch argument that can be used with `apply_patch` as an exec + /// call. i.e., if the original arg was parsed in "lenient" mode with a + /// heredoc, this should be the value without the heredoc wrapper. + pub patch: String, + + /// The working directory that was used to resolve relative paths in the patch. + pub cwd: PathBuf, +} + +impl ApplyPatchAction { + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + /// Returns the changes that would be made by applying the patch. + pub fn changes(&self) -> &HashMap { + &self.changes + } + + /// Should be used exclusively for testing. (Not worth the overhead of + /// creating a feature flag for this.) + pub fn new_add_for_test(path: &Path, content: String) -> Self { + if !path.is_absolute() { + panic!("path must be absolute"); + } + + #[expect(clippy::expect_used)] + let filename = path + .file_name() + .expect("path should not be empty") + .to_string_lossy(); + let patch = format!( + r#"*** Begin Patch +*** Update File: {filename} +@@ ++ {content} +*** End Patch"#, + ); + let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]); + #[expect(clippy::expect_used)] + Self { + changes, + cwd: path + .parent() + .expect("path should have parent") + .to_path_buf(), + patch, + } + } +} + +/// Applies the patch and prints the result to stdout/stderr. +pub fn apply_patch( + patch: &str, + stdout: &mut impl std::io::Write, + stderr: &mut impl std::io::Write, +) -> Result<(), ApplyPatchError> { + let hunks = match parse_patch(patch) { + Ok(source) => source.hunks, + Err(e) => { + match &e { + InvalidPatchError(message) => { + writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?; + } + InvalidHunkError { + message, + line_number, + } => { + writeln!( + stderr, + "Invalid patch hunk on line {line_number}: {message}" + ) + .map_err(ApplyPatchError::from)?; + } + } + return Err(ApplyPatchError::ParseError(e)); + } + }; + + apply_hunks(&hunks, stdout, stderr)?; + + Ok(()) +} + +/// Applies hunks and continues to update stdout/stderr +pub fn apply_hunks( + hunks: &[Hunk], + stdout: &mut impl std::io::Write, + stderr: &mut impl std::io::Write, +) -> Result<(), ApplyPatchError> { + let _existing_paths: Vec<&Path> = hunks + .iter() + .filter_map(|hunk| match hunk { + Hunk::AddFile { .. } => { + // The file is being added, so it doesn't exist yet. + None + } + Hunk::DeleteFile { path } => Some(path.as_path()), + Hunk::UpdateFile { + path, move_path, .. + } => match move_path { + Some(move_path) => { + if std::fs::metadata(move_path) + .map(|m| m.is_file()) + .unwrap_or(false) + { + Some(move_path.as_path()) + } else { + None + } + } + None => Some(path.as_path()), + }, + }) + .collect::>(); + + // Delegate to a helper that applies each hunk to the filesystem. + match apply_hunks_to_files(hunks) { + Ok(affected) => { + print_summary(&affected, stdout).map_err(ApplyPatchError::from)?; + Ok(()) + } + Err(err) => { + let msg = err.to_string(); + writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?; + if let Some(io) = err.downcast_ref::() { + Err(ApplyPatchError::from(io)) + } else { + Err(ApplyPatchError::IoError(IoError { + context: msg, + source: std::io::Error::other(err), + })) + } + } + } +} + +/// Applies each parsed patch hunk to the filesystem. +/// Returns an error if any of the changes could not be applied. +/// Tracks file paths affected by applying a patch. +pub struct AffectedPaths { + pub added: Vec, + pub modified: Vec, + pub deleted: Vec, +} + +/// Apply the hunks to the filesystem, returning which files were added, modified, or deleted. +/// Returns an error if the patch could not be applied. +fn apply_hunks_to_files(hunks: &[Hunk]) -> anyhow::Result { + if hunks.is_empty() { + anyhow::bail!("No files were modified."); + } + + let mut added: Vec = Vec::new(); + let mut modified: Vec = Vec::new(); + let mut deleted: Vec = Vec::new(); + for hunk in hunks { + match hunk { + Hunk::AddFile { path, contents } => { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directories for {}", path.display()) + })?; + } + std::fs::write(path, contents) + .with_context(|| format!("Failed to write file {}", path.display()))?; + added.push(path.clone()); + } + Hunk::DeleteFile { path } => { + std::fs::remove_file(path) + .with_context(|| format!("Failed to delete file {}", path.display()))?; + deleted.push(path.clone()); + } + Hunk::UpdateFile { + path, + move_path, + chunks, + } => { + let AppliedPatch { new_contents, .. } = + derive_new_contents_from_chunks(path, chunks)?; + if let Some(dest) = move_path { + if let Some(parent) = dest.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directories for {}", dest.display()) + })?; + } + std::fs::write(dest, new_contents) + .with_context(|| format!("Failed to write file {}", dest.display()))?; + std::fs::remove_file(path) + .with_context(|| format!("Failed to remove original {}", path.display()))?; + modified.push(dest.clone()); + } else { + std::fs::write(path, new_contents) + .with_context(|| format!("Failed to write file {}", path.display()))?; + modified.push(path.clone()); + } + } + } + } + Ok(AffectedPaths { + added, + modified, + deleted, + }) +} + +struct AppliedPatch { + original_contents: String, + new_contents: String, +} + +/// Return *only* the new file contents (joined into a single `String`) after +/// applying the chunks to the file at `path`. +fn derive_new_contents_from_chunks( + path: &Path, + chunks: &[UpdateFileChunk], +) -> std::result::Result { + let original_contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) => { + return Err(ApplyPatchError::IoError(IoError { + context: format!("Failed to read file to update {}", path.display()), + source: err, + })); + } + }; + + let mut original_lines: Vec = original_contents.split('\n').map(String::from).collect(); + + // Drop the trailing empty element that results from the final newline so + // that line counts match the behaviour of standard `diff`. + if original_lines.last().is_some_and(String::is_empty) { + original_lines.pop(); + } + + let replacements = compute_replacements(&original_lines, path, chunks)?; + let new_lines = apply_replacements(original_lines, &replacements); + let mut new_lines = new_lines; + if !new_lines.last().is_some_and(String::is_empty) { + new_lines.push(String::new()); + } + let new_contents = new_lines.join("\n"); + Ok(AppliedPatch { + original_contents, + new_contents, + }) +} + +/// Compute a list of replacements needed to transform `original_lines` into the +/// new lines, given the patch `chunks`. Each replacement is returned as +/// `(start_index, old_len, new_lines)`. +fn compute_replacements( + original_lines: &[String], + path: &Path, + chunks: &[UpdateFileChunk], +) -> std::result::Result)>, ApplyPatchError> { + let mut replacements: Vec<(usize, usize, Vec)> = Vec::new(); + let mut line_index: usize = 0; + + for chunk in chunks { + // If a chunk has a `change_context`, we use seek_sequence to find it, then + // adjust our `line_index` to continue from there. + if let Some(ctx_line) = &chunk.change_context { + if let Some(idx) = seek_sequence::seek_sequence( + original_lines, + std::slice::from_ref(ctx_line), + line_index, + /*eof*/ false, + ) { + line_index = idx + 1; + } else { + return Err(ApplyPatchError::ComputeReplacements(format!( + "Failed to find context '{}' in {}", + ctx_line, + path.display() + ))); + } + } + + if chunk.old_lines.is_empty() { + // Pure addition (no old lines). We'll add them at the end or just + // before the final empty line if one exists. + let insertion_idx = if original_lines.last().is_some_and(String::is_empty) { + original_lines.len() - 1 + } else { + original_lines.len() + }; + replacements.push((insertion_idx, 0, chunk.new_lines.clone())); + continue; + } + + // Otherwise, try to match the existing lines in the file with the old lines + // from the chunk. If found, schedule that region for replacement. + // Attempt to locate the `old_lines` verbatim within the file. In many + // real‑world diffs the last element of `old_lines` is an *empty* string + // representing the terminating newline of the region being replaced. + // This sentinel is not present in `original_lines` because we strip the + // trailing empty slice emitted by `split('\n')`. If a direct search + // fails and the pattern ends with an empty string, retry without that + // final element so that modifications touching the end‑of‑file can be + // located reliably. + + let mut pattern: &[String] = &chunk.old_lines; + let mut found = + seek_sequence::seek_sequence(original_lines, pattern, line_index, chunk.is_end_of_file); + + let mut new_slice: &[String] = &chunk.new_lines; + + if found.is_none() && pattern.last().is_some_and(String::is_empty) { + // Retry without the trailing empty line which represents the final + // newline in the file. + pattern = &pattern[..pattern.len() - 1]; + if new_slice.last().is_some_and(String::is_empty) { + new_slice = &new_slice[..new_slice.len() - 1]; + } + + found = seek_sequence::seek_sequence( + original_lines, + pattern, + line_index, + chunk.is_end_of_file, + ); + } + + if let Some(start_idx) = found { + replacements.push((start_idx, pattern.len(), new_slice.to_vec())); + line_index = start_idx + pattern.len(); + } else { + return Err(ApplyPatchError::ComputeReplacements(format!( + "Failed to find expected lines in {}:\n{}", + path.display(), + chunk.old_lines.join("\n"), + ))); + } + } + + replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx)); + + Ok(replacements) +} + +/// Apply the `(start_index, old_len, new_lines)` replacements to `original_lines`, +/// returning the modified file contents as a vector of lines. +fn apply_replacements( + mut lines: Vec, + replacements: &[(usize, usize, Vec)], +) -> Vec { + // We must apply replacements in descending order so that earlier replacements + // don't shift the positions of later ones. + for (start_idx, old_len, new_segment) in replacements.iter().rev() { + let start_idx = *start_idx; + let old_len = *old_len; + + // Remove old lines. + for _ in 0..old_len { + if start_idx < lines.len() { + lines.remove(start_idx); + } + } + + // Insert new lines. + for (offset, new_line) in new_segment.iter().enumerate() { + lines.insert(start_idx + offset, new_line.clone()); + } + } + + lines +} + +/// Intended result of a file update for apply_patch. +#[derive(Debug, Eq, PartialEq)] +pub struct ApplyPatchFileUpdate { + unified_diff: String, + content: String, +} + +pub fn unified_diff_from_chunks( + path: &Path, + chunks: &[UpdateFileChunk], +) -> std::result::Result { + unified_diff_from_chunks_with_context(path, chunks, /*context*/ 1) +} + +pub fn unified_diff_from_chunks_with_context( + path: &Path, + chunks: &[UpdateFileChunk], + context: usize, +) -> std::result::Result { + let AppliedPatch { + original_contents, + new_contents, + } = derive_new_contents_from_chunks(path, chunks)?; + let text_diff = TextDiff::from_lines(&original_contents, &new_contents); + let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); + Ok(ApplyPatchFileUpdate { + unified_diff, + content: new_contents, + }) +} + +/// Print the summary of changes in git-style format. +/// Write a summary of changes to the given writer. +pub fn print_summary( + affected: &AffectedPaths, + out: &mut impl std::io::Write, +) -> std::io::Result<()> { + writeln!(out, "Success. Updated the following files:")?; + for path in &affected.added { + writeln!(out, "A {}", path.display())?; + } + for path in &affected.modified { + writeln!(out, "M {}", path.display())?; + } + for path in &affected.deleted { + writeln!(out, "D {}", path.display())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + use std::string::ToString; + use tempfile::tempdir; + + /// Helper to construct a patch with the given body. + fn wrap_patch(body: &str) -> String { + format!("*** Begin Patch\n{body}\n*** End Patch") + } + + #[test] + fn test_add_file_hunk_creates_file_with_contents() { + let dir = tempdir().unwrap(); + let path = dir.path().join("add.txt"); + let patch = wrap_patch(&format!( + r#"*** Add File: {} ++ab ++cd"#, + path.display() + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + // Verify expected stdout and stderr outputs. + let stdout_str = String::from_utf8(stdout).unwrap(); + let stderr_str = String::from_utf8(stderr).unwrap(); + let expected_out = format!( + "Success. Updated the following files:\nA {}\n", + path.display() + ); + assert_eq!(stdout_str, expected_out); + assert_eq!(stderr_str, ""); + let contents = fs::read_to_string(path).unwrap(); + assert_eq!(contents, "ab\ncd\n"); + } + + #[test] + fn test_delete_file_hunk_removes_file() { + let dir = tempdir().unwrap(); + let path = dir.path().join("del.txt"); + fs::write(&path, "x").unwrap(); + let patch = wrap_patch(&format!("*** Delete File: {}", path.display())); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + let stdout_str = String::from_utf8(stdout).unwrap(); + let stderr_str = String::from_utf8(stderr).unwrap(); + let expected_out = format!( + "Success. Updated the following files:\nD {}\n", + path.display() + ); + assert_eq!(stdout_str, expected_out); + assert_eq!(stderr_str, ""); + assert!(!path.exists()); + } + + #[test] + fn test_update_file_hunk_modifies_content() { + let dir = tempdir().unwrap(); + let path = dir.path().join("update.txt"); + fs::write(&path, "foo\nbar\n").unwrap(); + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo +-bar ++baz"#, + path.display() + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + // Validate modified file contents and expected stdout/stderr. + let stdout_str = String::from_utf8(stdout).unwrap(); + let stderr_str = String::from_utf8(stderr).unwrap(); + let expected_out = format!( + "Success. Updated the following files:\nM {}\n", + path.display() + ); + assert_eq!(stdout_str, expected_out); + assert_eq!(stderr_str, ""); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "foo\nbaz\n"); + } + + #[test] + fn test_update_file_hunk_can_move_file() { + let dir = tempdir().unwrap(); + let src = dir.path().join("src.txt"); + let dest = dir.path().join("dst.txt"); + fs::write(&src, "line\n").unwrap(); + let patch = wrap_patch(&format!( + r#"*** Update File: {} +*** Move to: {} +@@ +-line ++line2"#, + src.display(), + dest.display() + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + // Validate move semantics and expected stdout/stderr. + let stdout_str = String::from_utf8(stdout).unwrap(); + let stderr_str = String::from_utf8(stderr).unwrap(); + let expected_out = format!( + "Success. Updated the following files:\nM {}\n", + dest.display() + ); + assert_eq!(stdout_str, expected_out); + assert_eq!(stderr_str, ""); + assert!(!src.exists()); + let contents = fs::read_to_string(&dest).unwrap(); + assert_eq!(contents, "line2\n"); + } + + /// Verify that a single `Update File` hunk with multiple change chunks can update different + /// parts of a file and that the file is listed only once in the summary. + #[test] + fn test_multiple_update_chunks_apply_to_single_file() { + // Start with a file containing four lines. + let dir = tempdir().unwrap(); + let path = dir.path().join("multi.txt"); + fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap(); + // Construct an update patch with two separate change chunks. + // The first chunk uses the line `foo` as context and transforms `bar` into `BAR`. + // The second chunk uses `baz` as context and transforms `qux` into `QUX`. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo +-bar ++BAR +@@ + baz +-qux ++QUX"#, + path.display() + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + let stdout_str = String::from_utf8(stdout).unwrap(); + let stderr_str = String::from_utf8(stderr).unwrap(); + let expected_out = format!( + "Success. Updated the following files:\nM {}\n", + path.display() + ); + assert_eq!(stdout_str, expected_out); + assert_eq!(stderr_str, ""); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "foo\nBAR\nbaz\nQUX\n"); + } + + /// A more involved `Update File` hunk that exercises additions, deletions and + /// replacements in separate chunks that appear in non‑adjacent parts of the + /// file. Verifies that all edits are applied and that the summary lists the + /// file only once. + #[test] + fn test_update_file_hunk_interleaved_changes() { + let dir = tempdir().unwrap(); + let path = dir.path().join("interleaved.txt"); + + // Original file: six numbered lines. + fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap(); + + // Patch performs: + // • Replace `b` → `B` + // • Replace `e` → `E` (using surrounding context) + // • Append new line `g` at the end‑of‑file + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + a +-b ++B +@@ + c + d +-e ++E +@@ + f ++g +*** End of File"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + let stdout_str = String::from_utf8(stdout).unwrap(); + let stderr_str = String::from_utf8(stderr).unwrap(); + + let expected_out = format!( + "Success. Updated the following files:\nM {}\n", + path.display() + ); + assert_eq!(stdout_str, expected_out); + assert_eq!(stderr_str, ""); + + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "a\nB\nc\nd\nE\nf\ng\n"); + } + + #[test] + fn test_pure_addition_chunk_followed_by_removal() { + let dir = tempdir().unwrap(); + let path = dir.path().join("panic.txt"); + fs::write(&path, "line1\nline2\nline3\n").unwrap(); + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++after-context ++second-line +@@ + line1 +-line2 +-line3 ++line2-replacement"#, + path.display() + )); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + let contents = fs::read_to_string(path).unwrap(); + assert_eq!( + contents, + "line1\nline2-replacement\nafter-context\nsecond-line\n" + ); + } + + /// Ensure that patches authored with ASCII characters can update lines that + /// contain typographic Unicode punctuation (e.g. EN DASH, NON-BREAKING + /// HYPHEN). Historically `git apply` succeeds in such scenarios but our + /// internal matcher failed requiring an exact byte-for-byte match. The + /// fuzzy-matching pass that normalises common punctuation should now bridge + /// the gap. + #[test] + fn test_update_line_with_unicode_dash() { + let dir = tempdir().unwrap(); + let path = dir.path().join("unicode.py"); + + // Original line contains EN DASH (\u{2013}) and NON-BREAKING HYPHEN (\u{2011}). + let original = "import asyncio # local import \u{2013} avoids top\u{2011}level dep\n"; + std::fs::write(&path, original).unwrap(); + + // Patch uses plain ASCII dash / hyphen. + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ +-import asyncio # local import - avoids top-level dep ++import asyncio # HELLO"#, + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + + // File should now contain the replaced comment. + let expected = "import asyncio # HELLO\n"; + let contents = std::fs::read_to_string(&path).unwrap(); + assert_eq!(contents, expected); + + // Ensure success summary lists the file as modified. + let stdout_str = String::from_utf8(stdout).unwrap(); + let expected_out = format!( + "Success. Updated the following files:\nM {}\n", + path.display() + ); + assert_eq!(stdout_str, expected_out); + + // No stderr expected. + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + } + + #[test] + fn test_unified_diff() { + // Start with a file containing four lines. + let dir = tempdir().unwrap(); + let path = dir.path().join("multi.txt"); + fs::write(&path, "foo\nbar\nbaz\nqux\n").unwrap(); + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo +-bar ++BAR +@@ + baz +-qux ++QUX"#, + path.display() + )); + let patch = parse_patch(&patch).unwrap(); + + let update_file_chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + let diff = unified_diff_from_chunks(&path, update_file_chunks).unwrap(); + let expected_diff = r#"@@ -1,4 +1,4 @@ + foo +-bar ++BAR + baz +-qux ++QUX +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nBAR\nbaz\nQUX\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_unified_diff_first_line_replacement() { + // Replace the very first line of the file. + let dir = tempdir().unwrap(); + let path = dir.path().join("first.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ +-foo ++FOO + bar +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -1,2 +1,2 @@ +-foo ++FOO + bar +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "FOO\nbar\nbaz\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_unified_diff_last_line_replacement() { + // Replace the very last line of the file. + let dir = tempdir().unwrap(); + let path = dir.path().join("last.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ + foo + bar +-baz ++BAZ +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -2,2 +2,2 @@ + bar +-baz ++BAZ +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nbar\nBAZ\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_unified_diff_insert_at_eof() { + // Insert a new line at end‑of‑file. + let dir = tempdir().unwrap(); + let path = dir.path().join("insert.txt"); + fs::write(&path, "foo\nbar\nbaz\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {} +@@ ++quux +*** End of File +"#, + path.display() + )); + + let patch = parse_patch(&patch).unwrap(); + let chunks = match patch.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + let expected_diff = r#"@@ -3 +3,2 @@ + baz ++quux +"#; + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "foo\nbar\nbaz\nquux\n".to_string(), + }; + assert_eq!(expected, diff); + } + + #[test] + fn test_unified_diff_interleaved_changes() { + // Original file with six lines. + let dir = tempdir().unwrap(); + let path = dir.path().join("interleaved.txt"); + fs::write(&path, "a\nb\nc\nd\ne\nf\n").unwrap(); + + // Patch replaces two separate lines and appends a new one at EOF using + // three distinct chunks. + let patch_body = format!( + r#"*** Update File: {} +@@ + a +-b ++B +@@ + d +-e ++E +@@ + f ++g +*** End of File"#, + path.display() + ); + let patch = wrap_patch(&patch_body); + + // Extract chunks then build the unified diff. + let parsed = parse_patch(&patch).unwrap(); + let chunks = match parsed.hunks.as_slice() { + [Hunk::UpdateFile { chunks, .. }] => chunks, + _ => panic!("Expected a single UpdateFile hunk"), + }; + + let diff = unified_diff_from_chunks(&path, chunks).unwrap(); + + let expected_diff = r#"@@ -1,6 +1,7 @@ + a +-b ++B + c + d +-e ++E + f ++g +"#; + + let expected = ApplyPatchFileUpdate { + unified_diff: expected_diff.to_string(), + content: "a\nB\nc\nd\nE\nf\ng\n".to_string(), + }; + + assert_eq!(expected, diff); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + apply_patch(&patch, &mut stdout, &mut stderr).unwrap(); + let contents = fs::read_to_string(path).unwrap(); + assert_eq!( + contents, + r#"a +B +c +d +E +f +g +"# + ); + } + + #[test] + fn test_apply_patch_fails_on_write_error() { + let dir = tempdir().unwrap(); + let path = dir.path().join("readonly.txt"); + fs::write(&path, "before\n").unwrap(); + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&path, perms).unwrap(); + + let patch = wrap_patch(&format!( + "*** Update File: {}\n@@\n-before\n+after\n*** End Patch", + path.display() + )); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let result = apply_patch(&patch, &mut stdout, &mut stderr); + assert!(result.is_err()); + } +} diff --git a/codex-rs/apply-patch/src/wasm.rs b/codex-rs/apply-patch/src/wasm.rs new file mode 100644 index 0000000000..12eda50cc3 --- /dev/null +++ b/codex-rs/apply-patch/src/wasm.rs @@ -0,0 +1,205 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use thiserror::Error; + +pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); +pub const CODEX_CORE_APPLY_PATCH_ARG1: &str = "--codex-run-as-apply-patch"; + +const APPLY_PATCH_UNAVAILABLE: &str = "apply_patch is unavailable on wasm32"; + +#[derive(Debug, PartialEq, Clone, Error)] +pub enum ParseError { + #[error("invalid patch: {0}")] + InvalidPatchError(String), + #[error("invalid hunk at line {line_number}, {message}")] + InvalidHunkError { message: String, line_number: usize }, +} + +#[derive(Debug, PartialEq, Clone)] +#[allow(clippy::enum_variant_names)] +pub enum Hunk { + AddFile { + path: PathBuf, + contents: String, + }, + DeleteFile { + path: PathBuf, + }, + UpdateFile { + path: PathBuf, + move_path: Option, + chunks: Vec, + }, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct UpdateFileChunk { + pub change_context: Option, + pub old_lines: Vec, + pub new_lines: Vec, + pub is_end_of_file: bool, +} + +#[derive(Debug, Error)] +#[error("{context}: {source}")] +pub struct IoError { + context: String, + #[source] + source: std::io::Error, +} + +impl PartialEq for IoError { + fn eq(&self, other: &Self) -> bool { + self.context == other.context && self.source.to_string() == other.source.to_string() + } +} + +#[derive(Debug, Error, PartialEq)] +pub enum ApplyPatchError { + #[error(transparent)] + ParseError(#[from] ParseError), + #[error(transparent)] + IoError(#[from] IoError), + #[error("{0}")] + ComputeReplacements(String), + #[error( + "patch detected without explicit call to apply_patch. Rerun as [\"apply_patch\", \"\"]" + )] + ImplicitInvocation, +} + +impl From for ApplyPatchError { + fn from(err: std::io::Error) -> Self { + Self::IoError(IoError { + context: "I/O error".to_string(), + source: err, + }) + } +} + +impl From<&std::io::Error> for ApplyPatchError { + fn from(err: &std::io::Error) -> Self { + Self::IoError(IoError { + context: "I/O error".to_string(), + source: std::io::Error::new(err.kind(), err.to_string()), + }) + } +} + +#[derive(Debug, PartialEq)] +pub struct ApplyPatchArgs { + pub patch: String, + pub hunks: Vec, + pub workdir: Option, +} + +#[derive(Debug, PartialEq)] +pub enum ApplyPatchFileChange { + Add { + content: String, + }, + Delete { + content: String, + }, + Update { + unified_diff: String, + move_path: Option, + new_content: String, + }, +} + +#[derive(Debug, PartialEq)] +pub enum ExtractHeredocError { + Unsupported, +} + +impl std::fmt::Display for ExtractHeredocError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{APPLY_PATCH_UNAVAILABLE}") + } +} + +impl std::error::Error for ExtractHeredocError {} + +#[derive(Debug, PartialEq)] +pub enum MaybeApplyPatchVerified { + Body(ApplyPatchAction), + ShellParseError(ExtractHeredocError), + CorrectnessError(ApplyPatchError), + NotApplyPatch, +} + +#[derive(Debug, PartialEq)] +pub struct ApplyPatchAction { + changes: HashMap, + pub patch: String, + pub cwd: PathBuf, +} + +impl ApplyPatchAction { + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + pub fn changes(&self) -> &HashMap { + &self.changes + } + + pub fn new_add_for_test(path: &Path, content: String) -> Self { + let changes = HashMap::from([(path.to_path_buf(), ApplyPatchFileChange::Add { content })]); + Self { + changes, + patch: String::new(), + cwd: path + .parent() + .unwrap_or_else(|| Path::new(".")) + .to_path_buf(), + } + } +} + +#[derive(Debug, PartialEq)] +pub struct ApplyPatchFileUpdate { + pub unified_diff: String, + pub content: String, +} + +pub fn parse_patch(_patch: &str) -> Result { + Err(ParseError::InvalidPatchError( + APPLY_PATCH_UNAVAILABLE.to_string(), + )) +} + +pub fn unified_diff_from_chunks( + _path: &Path, + _chunks: &[UpdateFileChunk], +) -> Result { + Err(ApplyPatchError::ComputeReplacements( + APPLY_PATCH_UNAVAILABLE.to_string(), + )) +} + +pub fn maybe_parse_apply_patch_verified(argv: &[String], _cwd: &Path) -> MaybeApplyPatchVerified { + match argv.first().map(String::as_str) { + Some("apply_patch" | "applypatch") => MaybeApplyPatchVerified::CorrectnessError( + ApplyPatchError::ComputeReplacements(APPLY_PATCH_UNAVAILABLE.to_string()), + ), + _ => MaybeApplyPatchVerified::NotApplyPatch, + } +} + +pub fn apply_patch( + _patch: &str, + _stdout: &mut impl std::io::Write, + _stderr: &mut impl std::io::Write, +) -> Result<(), ApplyPatchError> { + Err(ApplyPatchError::ComputeReplacements( + APPLY_PATCH_UNAVAILABLE.to_string(), + )) +} + +pub fn main() -> ! { + panic!("{APPLY_PATCH_UNAVAILABLE}"); +} diff --git a/codex-rs/async-utils/Cargo.toml b/codex-rs/async-utils/Cargo.toml index 891af17a5f..789f1bf4d1 100644 --- a/codex-rs/async-utils/Cargo.toml +++ b/codex-rs/async-utils/Cargo.toml @@ -9,8 +9,13 @@ workspace = true [dependencies] async-trait.workspace = true -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } tokio-util.workspace = true +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { workspace = true, features = ["macros", "rt", "time"] } + [dev-dependencies] pretty_assertions.workspace = true diff --git a/codex-rs/async-utils/src/lib.rs b/codex-rs/async-utils/src/lib.rs index bd880ae1fb..0b6d6bb615 100644 --- a/codex-rs/async-utils/src/lib.rs +++ b/codex-rs/async-utils/src/lib.rs @@ -7,13 +7,15 @@ pub enum CancelErr { Cancelled, } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait OrCancelExt: Sized { type Output; async fn or_cancel(self, token: &CancellationToken) -> Result; } +#[cfg(not(target_arch = "wasm32"))] #[async_trait] impl OrCancelExt for F where @@ -30,6 +32,22 @@ where } } +#[cfg(target_arch = "wasm32")] +#[async_trait(?Send)] +impl OrCancelExt for F +where + F: Future, +{ + type Output = F::Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result { + tokio::select! { + _ = token.cancelled() => Err(CancelErr::Cancelled), + res = self => Ok(res), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 707f0e788f..7fa40c9c7b 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -16,6 +16,7 @@ workspace = true async-trait = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +shlex = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["rt"] } tracing = { workspace = true } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index aa6697d6a7..7cacce75b5 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -9,7 +9,7 @@ const CODE_MODE_ONLY_PREFACE: &str = "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. -- All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. +- If nested tools are enabled for this session, they are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. - Nested tool methods take either a string or an object as their input argument. - Nested tools return either an object or a string, based on the description. - Runs raw JavaScript -- no Node, no file system, no network access, no console. @@ -89,6 +89,7 @@ pub fn parse_exec_source(input: &str) -> Result { let rest = lines.next().unwrap_or_default(); let trimmed = first_line.trim_start(); let Some(pragma) = trimmed.strip_prefix(CODE_MODE_PRAGMA_PREFIX) else { + args.code = normalize_exec_source(&args.code)?; return Ok(args); }; @@ -149,12 +150,58 @@ pub fn parse_exec_source(input: &str) -> Result { ); } - args.code = rest.to_string(); + args.code = normalize_exec_source(rest)?; args.yield_time_ms = pragma.yield_time_ms; args.max_output_tokens = pragma.max_output_tokens; Ok(args) } +fn normalize_exec_source(code: &str) -> Result { + let trimmed = code.trim(); + if trimmed.starts_with("```") { + return Err( + "exec expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// @exec: ...`).".to_string(), + ); + } + let Ok(value) = serde_json::from_str::(trimmed) else { + return Ok(code.to_string()); + }; + match value { + JsonValue::Object(object) => { + if object.len() == 1 + && let Some(JsonValue::String(source)) = object.get("code") + { + return Ok(source.clone()); + } + if let Some(command) = object + .get("command") + .or_else(|| object.get("cmd")) + .and_then(JsonValue::as_str) + && let Some(source) = extract_node_dash_e_source(command) + { + return Ok(source); + } + Err( + "exec is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// @exec: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences.".to_string(), + ) + } + JsonValue::String(_) => Err( + "exec is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// @exec: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences.".to_string(), + ), + _ => Ok(code.to_string()), + } +} + +fn extract_node_dash_e_source(command: &str) -> Option { + let parts = shlex::split(command)?; + match parts.as_slice() { + [program, flag, source] if program == "node" && (flag == "-e" || flag == "--eval") => { + Some(source.clone()) + } + _ => None, + } +} + pub fn is_code_mode_nested_tool(tool_name: &str) -> bool { tool_name != crate::PUBLIC_TOOL_NAME && tool_name != crate::WAIT_TOOL_NAME } @@ -185,6 +232,8 @@ pub fn build_exec_tool_description( .collect::>() .join("\n\n"); sections.push(nested_tool_reference); + } else { + sections.push("No nested tools are enabled for this session. Use plain JavaScript together with the built-in helpers such as `text(...)`.".to_string()); } sections.join("\n\n") @@ -510,6 +559,51 @@ mod tests { ); } + #[test] + fn parse_exec_source_unwraps_code_json_object() { + let parsed = parse_exec_source("{\"code\":\"text('hi')\"}").expect("expected parse"); + assert_eq!( + parsed, + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + } + ); + } + + #[test] + fn parse_exec_source_unwraps_node_dash_e_command_object() { + let parsed = parse_exec_source("{\"command\":\"node -e \\\"text('hi')\\\"\"}") + .expect("expected parse"); + assert_eq!( + parsed, + ParsedExecSource { + code: "text('hi')".to_string(), + yield_time_ms: None, + max_output_tokens: None, + } + ); + } + + #[test] + fn parse_exec_source_rejects_non_code_json_object_input() { + let err = parse_exec_source("{\"command\":\"which node\"}").expect_err("expected error"); + assert_eq!( + err, + "exec is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// @exec: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences." + ); + } + + #[test] + fn parse_exec_source_rejects_markdown_fences() { + let err = parse_exec_source("```js\ntext('hi')\n```").expect_err("expected error"); + assert_eq!( + err, + "exec expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// @exec: ...`)." + ); + } + #[test] fn normalize_identifier_rewrites_invalid_characters() { assert_eq!( @@ -558,4 +652,10 @@ mod tests { ); assert!(description.contains("### `foo` (`foo`)")); } + + #[test] + fn code_mode_only_description_mentions_absent_nested_tools() { + let description = build_exec_tool_description(&[], /*code_mode_only*/ true); + assert!(description.contains("No nested tools are enabled for this session")); + } } diff --git a/codex-rs/code-mode/src/service_wasm.rs b/codex-rs/code-mode/src/service_wasm.rs index 59dc9c1c7d..a398a6b5b2 100644 --- a/codex-rs/code-mode/src/service_wasm.rs +++ b/codex-rs/code-mode/src/service_wasm.rs @@ -10,7 +10,8 @@ use crate::ExecuteRequest; use crate::RuntimeResponse; use crate::WaitRequest; -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait CodeModeTurnHost: Send + Sync { async fn invoke_tool( &self, @@ -22,7 +23,8 @@ pub trait CodeModeTurnHost: Send + Sync { async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait CodeModeRuntime: Send + Sync { async fn stored_values(&self) -> HashMap; @@ -92,7 +94,8 @@ pub trait CodeModeTurnWorkerHandle: Send {} impl CodeModeTurnWorkerHandle for CodeModeTurnWorker {} -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl CodeModeRuntime for CodeModeService { async fn stored_values(&self) -> HashMap { CodeModeService::stored_values(self).await diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index bf2cdb6407..1a6bfa81f8 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -9,7 +9,6 @@ async-trait = { workspace = true } bytes = { workspace = true } codex-client = { workspace = true } codex-protocol = { workspace = true } -codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } http = { workspace = true } serde = { workspace = true, features = ["derive"] } @@ -31,9 +30,13 @@ wiremock = { workspace = true } reqwest = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +codex-utils-rustls-provider = { workspace = true } tokio = { workspace = true, features = ["net"] } tokio-tungstenite = { workspace = true } tungstenite = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = { workspace = true } + [lints] workspace = true diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index bf6d755060..358b6691ff 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -19,16 +19,22 @@ use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; +#[cfg(not(target_arch = "wasm32"))] use tokio::time::Instant; +#[cfg(not(target_arch = "wasm32"))] use tokio::time::timeout; +#[cfg(not(target_arch = "wasm32"))] use tokio_util::io::ReaderStream; use tracing::debug; use tracing::trace; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::spawn_local; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; const OPENAI_MODEL_HEADER: &str = "openai-model"; /// Streams SSE events from an on-disk fixture for tests. +#[cfg(not(target_arch = "wasm32"))] pub fn stream_from_fixture( path: impl AsRef, idle_timeout: Duration, @@ -45,7 +51,7 @@ pub fn stream_from_fixture( let reader = std::io::Cursor::new(content); let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); let (tx_event, rx_event) = mpsc::channel::>(1600); - tokio::spawn(process_sse( + spawn_sse_task(process_sse( Box::pin(stream), tx_event, idle_timeout, @@ -54,6 +60,16 @@ pub fn stream_from_fixture( Ok(ResponseStream { rx_event }) } +#[cfg(target_arch = "wasm32")] +pub fn stream_from_fixture( + _path: impl AsRef, + _idle_timeout: Duration, +) -> Result { + Err(ApiError::Stream( + "SSE fixtures are unavailable on wasm32".to_string(), + )) +} + pub fn spawn_response_stream( stream_response: StreamResponse, idle_timeout: Duration, @@ -84,7 +100,7 @@ pub fn spawn_response_stream( let _ = turn_state.set(header_value.to_string()); } let (tx_event, rx_event) = mpsc::channel::>(1600); - tokio::spawn(async move { + spawn_sse_task(async move { if let Some(model) = server_model { let _ = tx_event.send(Ok(ResponseEvent::ServerModel(model))).await; } @@ -105,6 +121,73 @@ pub fn spawn_response_stream( ResponseStream { rx_event } } +#[cfg(not(target_arch = "wasm32"))] +fn spawn_sse_task(task: impl std::future::Future + Send + 'static) { + tokio::spawn(task); +} + +#[cfg(target_arch = "wasm32")] +fn spawn_sse_task(task: impl std::future::Future + 'static) { + spawn_local(task); +} + +#[cfg(not(target_arch = "wasm32"))] +async fn next_sse_event( + stream: &mut S, + idle_timeout: Duration, +) -> Result< + Option>>, + tokio::time::error::Elapsed, +> +where + S: futures::Stream< + Item = Result< + eventsource_stream::Event, + eventsource_stream::EventStreamError, + >, + > + Unpin, +{ + timeout(idle_timeout, stream.next()).await +} + +#[cfg(target_arch = "wasm32")] +async fn next_sse_event( + stream: &mut S, + idle_timeout: Duration, +) -> Result< + Option>>, + tokio::time::error::Elapsed, +> +where + S: futures::Stream< + Item = Result< + eventsource_stream::Event, + eventsource_stream::EventStreamError, + >, + > + Unpin, +{ + let _ = idle_timeout; + Ok(stream.next().await) +} + +#[cfg(not(target_arch = "wasm32"))] +fn sse_poll_start() -> Instant { + Instant::now() +} + +#[cfg(target_arch = "wasm32")] +fn sse_poll_start() {} + +#[cfg(not(target_arch = "wasm32"))] +fn sse_poll_duration_since(start: Instant) -> Duration { + start.elapsed() +} + +#[cfg(target_arch = "wasm32")] +fn sse_poll_duration_since((): ()) -> Duration { + Duration::ZERO +} + #[derive(Debug, Deserialize)] #[allow(dead_code)] struct Error { @@ -365,10 +448,10 @@ pub async fn process_sse( let mut last_server_model: Option = None; loop { - let start = Instant::now(); - let response = timeout(idle_timeout, stream.next()).await; + let start = sse_poll_start(); + let response = next_sse_event(&mut stream, idle_timeout).await; if let Some(t) = telemetry.as_ref() { - t.on_sse_poll(&response, start.elapsed()); + t.on_sse_poll(&response, sse_poll_duration_since(start)); } let sse = match response { Ok(Some(Ok(sse))) => sse, diff --git a/codex-rs/codex-api/src/telemetry.rs b/codex-rs/codex-api/src/telemetry.rs index 12fa2defb4..e4039e5213 100644 --- a/codex-rs/codex-api/src/telemetry.rs +++ b/codex-rs/codex-api/src/telemetry.rs @@ -1,3 +1,4 @@ +#[cfg(not(target_arch = "wasm32"))] use crate::error::ApiError; use codex_client::Request; use codex_client::RequestTelemetry; @@ -10,6 +11,7 @@ use http::StatusCode; use std::future::Future; use std::sync::Arc; use std::time::Duration; +#[cfg(not(target_arch = "wasm32"))] use tokio::time::Instant; #[cfg(not(target_arch = "wasm32"))] use tokio_tungstenite::tungstenite::Error; @@ -56,6 +58,24 @@ fn http_status(err: &TransportError) -> Option { } } +#[cfg(not(target_arch = "wasm32"))] +fn request_start_time() -> Instant { + Instant::now() +} + +#[cfg(target_arch = "wasm32")] +fn request_start_time() {} + +#[cfg(not(target_arch = "wasm32"))] +fn request_duration_since(start: Instant) -> Duration { + start.elapsed() +} + +#[cfg(target_arch = "wasm32")] +fn request_duration_since((): ()) -> Duration { + Duration::ZERO +} + impl WithStatus for Response { fn status(&self) -> StatusCode { self.status @@ -85,14 +105,14 @@ where let telemetry = telemetry.clone(); let send = send.clone(); async move { - let start = Instant::now(); + let start = request_start_time(); let result = send(req).await; if let Some(t) = telemetry.as_ref() { let (status, err) = match &result { Ok(resp) => (Some(resp.status()), None), Err(err) => (http_status(err), Some(err)), }; - t.on_request(attempt, status, err, start.elapsed()); + t.on_request(attempt, status, err, request_duration_since(start)); } result } diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 2ef31ac826..7783d52369 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -11,20 +11,25 @@ eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } opentelemetry = { workspace = true } -rand = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } -rustls = { workspace = true } -rustls-native-certs = { workspace = true } -rustls-pki-types = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] } tracing = { workspace = true } tracing-opentelemetry = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] codex-utils-rustls-provider = { workspace = true } +rand = { workspace = true } +rustls = { workspace = true } +rustls-native-certs = { workspace = true } +rustls-pki-types = { workspace = true } zstd = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = { workspace = true } + [lints] workspace = true diff --git a/codex-rs/codex-client/src/custom_ca_wasm.rs b/codex-rs/codex-client/src/custom_ca_wasm.rs new file mode 100644 index 0000000000..8f9dfb47f2 --- /dev/null +++ b/codex-rs/codex-client/src/custom_ca_wasm.rs @@ -0,0 +1,37 @@ +use std::io; + +use thiserror::Error; + +pub const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +pub const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; + +#[derive(Debug, Error)] +pub enum BuildCustomCaTransportError { + #[error("failed to build HTTP client while using browser transport defaults: {0}")] + BuildClientWithSystemRoots(#[source] reqwest::Error), +} + +impl From for io::Error { + fn from(error: BuildCustomCaTransportError) -> Self { + io::Error::other(error) + } +} + +pub fn build_reqwest_client_with_custom_ca( + builder: reqwest::ClientBuilder, +) -> Result { + builder + .build() + .map_err(BuildCustomCaTransportError::BuildClientWithSystemRoots) +} + +pub fn build_reqwest_client_for_subprocess_tests( + builder: reqwest::ClientBuilder, +) -> Result { + build_reqwest_client_with_custom_ca(builder) +} + +pub fn maybe_build_rustls_client_config_with_custom_ca() +-> Result, BuildCustomCaTransportError> { + Ok(None) +} diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/codex-client/src/default_client.rs index 56b3ce4b16..66085f4276 100644 --- a/codex-rs/codex-client/src/default_client.rs +++ b/codex-rs/codex-client/src/default_client.rs @@ -115,6 +115,7 @@ impl CodexRequestBuilder { match self.builder.headers(headers).send().await { Ok(response) => { + #[cfg(not(target_arch = "wasm32"))] tracing::debug!( method = %self.method, url = %self.url, @@ -123,6 +124,14 @@ impl CodexRequestBuilder { version = ?response.version(), "Request completed" ); + #[cfg(target_arch = "wasm32")] + tracing::debug!( + method = %self.method, + url = %self.url, + status = %response.status(), + headers = ?response.headers(), + "Request completed" + ); Ok(response) } diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 93dd81506f..cb7f81110f 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,3 +1,4 @@ +#[cfg_attr(target_arch = "wasm32", path = "custom_ca_wasm.rs")] mod custom_ca; mod default_client; mod error; diff --git a/codex-rs/codex-client/src/retry.rs b/codex-rs/codex-client/src/retry.rs index c7bdd34b1e..cadf7b592a 100644 --- a/codex-rs/codex-client/src/retry.rs +++ b/codex-rs/codex-client/src/retry.rs @@ -1,5 +1,6 @@ use crate::error::TransportError; use crate::request::Request; +#[cfg(not(target_arch = "wasm32"))] use rand::Rng; use std::future::Future; use std::time::Duration; @@ -42,10 +43,20 @@ pub fn backoff(base: Duration, attempt: u64) -> Duration { let exp = 2u64.saturating_pow(attempt as u32 - 1); let millis = base.as_millis() as u64; let raw = millis.saturating_mul(exp); - let jitter: f64 = rand::rng().random_range(0.9..1.1); + let jitter = jitter_multiplier(); Duration::from_millis((raw as f64 * jitter) as u64) } +#[cfg(not(target_arch = "wasm32"))] +fn jitter_multiplier() -> f64 { + rand::rng().random_range(0.9..1.1) +} + +#[cfg(target_arch = "wasm32")] +fn jitter_multiplier() -> f64 { + 1.0 +} + pub async fn run_with_retry( policy: RetryPolicy, mut make_req: impl FnMut() -> Request, diff --git a/codex-rs/codex-client/src/sse.rs b/codex-rs/codex-client/src/sse.rs index f3aba3a2c5..dafdaa972e 100644 --- a/codex-rs/codex-client/src/sse.rs +++ b/codex-rs/codex-client/src/sse.rs @@ -4,7 +4,10 @@ use eventsource_stream::Eventsource; use futures::StreamExt; use tokio::sync::mpsc; use tokio::time::Duration; +#[cfg(not(target_arch = "wasm32"))] use tokio::time::timeout; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::spawn_local; /// Minimal SSE helper that forwards raw `data:` frames as UTF-8 strings. /// @@ -14,13 +17,13 @@ pub fn sse_stream( idle_timeout: Duration, tx: mpsc::Sender>, ) { - tokio::spawn(async move { + spawn_sse_task(async move { let mut stream = stream .map(|res| res.map_err(|e| StreamError::Stream(e.to_string()))) .eventsource(); loop { - match timeout(idle_timeout, stream.next()).await { + match next_sse_event(&mut stream, idle_timeout).await { Ok(Some(Ok(ev))) => { if tx.send(Ok(ev.data.clone())).await.is_err() { return; @@ -46,3 +49,46 @@ pub fn sse_stream( } }); } + +#[cfg(not(target_arch = "wasm32"))] +async fn next_sse_event( + stream: &mut S, + idle_timeout: Duration, +) -> Result< + Option>>, + tokio::time::error::Elapsed, +> +where + S: futures::Stream< + Item = Result>, + > + Unpin, +{ + timeout(idle_timeout, stream.next()).await +} + +#[cfg(target_arch = "wasm32")] +async fn next_sse_event( + stream: &mut S, + idle_timeout: Duration, +) -> Result< + Option>>, + tokio::time::error::Elapsed, +> +where + S: futures::Stream< + Item = Result>, + > + Unpin, +{ + let _ = idle_timeout; + Ok(stream.next().await) +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn_sse_task(task: impl std::future::Future + Send + 'static) { + tokio::spawn(task); +} + +#[cfg(target_arch = "wasm32")] +fn spawn_sse_task(task: impl std::future::Future + 'static) { + spawn_local(task); +} diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/codex-client/src/transport.rs index a2e6c66f5d..b964c3730c 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/codex-client/src/transport.rs @@ -7,7 +7,10 @@ use crate::request::Response; use async_trait::async_trait; use bytes::Bytes; use futures::StreamExt; +#[cfg(not(target_arch = "wasm32"))] use futures::stream::BoxStream; +#[cfg(target_arch = "wasm32")] +use futures::stream::LocalBoxStream; use http::HeaderMap; use http::Method; use http::StatusCode; @@ -15,15 +18,20 @@ use tracing::Level; use tracing::enabled; use tracing::trace; +#[cfg(not(target_arch = "wasm32"))] pub type ByteStream = BoxStream<'static, Result>; +#[cfg(target_arch = "wasm32")] +pub type ByteStream = LocalBoxStream<'static, Result>; + pub struct StreamResponse { pub status: StatusCode, pub headers: HeaderMap, pub bytes: ByteStream, } -#[async_trait] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait HttpTransport: Send + Sync { async fn execute(&self, req: Request) -> Result; async fn stream(&self, req: Request) -> Result; @@ -75,11 +83,7 @@ impl ReqwestTransport { let compression_start = std::time::Instant::now(); let (compressed, content_encoding) = match compression { RequestCompression::None => unreachable!("guarded by compression != None"), - RequestCompression::Zstd => ( - zstd::stream::encode_all(std::io::Cursor::new(json), 3) - .map_err(|err| TransportError::Build(err.to_string()))?, - http::HeaderValue::from_static("zstd"), - ), + RequestCompression::Zstd => encode_zstd(json)?, }; let post_compression_bytes = compressed.len(); let compression_duration = compression_start.elapsed(); @@ -119,7 +123,24 @@ impl ReqwestTransport { } } -#[async_trait] +#[cfg(not(target_arch = "wasm32"))] +fn encode_zstd(json: Vec) -> Result<(Vec, http::HeaderValue), TransportError> { + Ok(( + zstd::stream::encode_all(std::io::Cursor::new(json), 3) + .map_err(|err| TransportError::Build(err.to_string()))?, + http::HeaderValue::from_static("zstd"), + )) +} + +#[cfg(target_arch = "wasm32")] +fn encode_zstd(_json: Vec) -> Result<(Vec, http::HeaderValue), TransportError> { + Err(TransportError::Build( + "zstd request compression is unavailable on wasm32".to_string(), + )) +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] impl HttpTransport for ReqwestTransport { async fn execute(&self, req: Request) -> Result { if enabled!(Level::TRACE) { diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index f1bd2dba16..21196d3cfd 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -20,11 +20,13 @@ serde_json = { workspace = true } serde_path_to_error = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["fs"] } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["fs"] } + [dev-dependencies] anyhow = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 899114d6d7..f99f151cc6 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -19,6 +19,16 @@ use toml_edit::Item; use toml_edit::Table; use toml_edit::Value; +#[cfg(not(target_arch = "wasm32"))] +async fn read_config_contents(path: &Path) -> io::Result { + tokio::fs::read_to_string(path).await +} + +#[cfg(target_arch = "wasm32")] +async fn read_config_contents(path: &Path) -> io::Result { + std::fs::read_to_string(path) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TextPosition { pub line: usize, @@ -169,7 +179,7 @@ where let Some(path) = config_path_for_layer(layer, config_toml_file) else { continue; }; - let contents = match tokio::fs::read_to_string(&path).await { + let contents = match read_config_contents(&path).await { Ok(contents) => contents, Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => { diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 35c9ed0a57..aa3d53ef10 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -30,11 +30,16 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } shlex = { workspace = true } -tokio = { workspace = true, features = ["fs", "macros", "rt"] } toml = { workspace = true } tracing = { workspace = true } zip = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["fs", "macros", "rt"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + [dev-dependencies] pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index b31885b8c5..3770a4a86f 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -13,7 +13,16 @@ use codex_otel::SessionTelemetry; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; -use tokio::fs; + +#[cfg(not(target_arch = "wasm32"))] +async fn read_skill_contents(path: &std::path::Path) -> std::io::Result { + tokio::fs::read_to_string(path).await +} + +#[cfg(target_arch = "wasm32")] +async fn read_skill_contents(path: &std::path::Path) -> std::io::Result { + std::fs::read_to_string(path) +} #[derive(Debug, Default)] pub struct SkillInjections { @@ -38,7 +47,7 @@ pub async fn build_skill_injections( let mut invocations = Vec::new(); for skill in mentioned_skills { - match fs::read_to_string(&skill.path_to_skills_md).await { + match read_skill_contents(&skill.path_to_skills_md).await { Ok(contents) => { emit_skill_injected_metric(otel, skill, "ok"); invocations.push(SkillInvocation { diff --git a/codex-rs/core-skills/src/remote.rs b/codex-rs/core-skills/src/remote.rs index 2dc620b864..150e5cedc6 100644 --- a/codex-rs/core-skills/src/remote.rs +++ b/codex-rs/core-skills/src/remote.rs @@ -11,6 +11,38 @@ use codex_login::default_client::build_reqwest_client; const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(not(target_arch = "wasm32"))] +async fn create_dir_all(path: &Path) -> std::io::Result<()> { + tokio::fs::create_dir_all(path).await +} + +#[cfg(target_arch = "wasm32")] +async fn create_dir_all(path: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(path) +} + +#[cfg(not(target_arch = "wasm32"))] +async fn extract_zip_async( + zip_bytes: Vec, + output_dir: PathBuf, + prefix_candidates: Vec, +) -> Result<()> { + tokio::task::spawn_blocking(move || { + extract_zip_to_dir(zip_bytes, &output_dir, &prefix_candidates) + }) + .await + .context("Zip extraction task failed")? +} + +#[cfg(target_arch = "wasm32")] +async fn extract_zip_async( + zip_bytes: Vec, + output_dir: PathBuf, + prefix_candidates: Vec, +) -> Result<()> { + extract_zip_to_dir(zip_bytes, &output_dir, &prefix_candidates) +} + // Low-level client for the remote skill API. This is intentionally kept around for // future wiring, but it is not used yet by any active product surface. @@ -182,18 +214,14 @@ pub async fn export_remote_skill( } let output_dir = codex_home.join("skills").join(skill_id); - tokio::fs::create_dir_all(&output_dir) + create_dir_all(&output_dir) .await .context("Failed to create downloaded skills directory")?; let zip_bytes = body.to_vec(); let output_dir_clone = output_dir.clone(); let prefix_candidates = vec![skill_id.to_string()]; - tokio::task::spawn_blocking(move || { - extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates) - }) - .await - .context("Zip extraction task failed")??; + extract_zip_async(zip_bytes, output_dir_clone, prefix_candidates).await?; Ok(RemoteSkillDownloadResult { id: skill_id.to_string(), diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 280f6b382e..0babc95dd0 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -79,15 +79,8 @@ indexmap = { workspace = true } libc = { workspace = true } notify = { workspace = true } once_cell = { workspace = true } -rand = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } -rmcp = { workspace = true, default-features = false, features = [ - "base64", - "macros", - "schemars", - "server", -] } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -97,20 +90,13 @@ similar = { workspace = true } tempfile = { workspace = true } test-log = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = [ - "io-std", - "macros", - "process", - "rt-multi-thread", - "signal", -] } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } tokio-util = { workspace = true, features = ["rt"] } toml = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } uuid = { workspace = true, features = ["serde", "v4", "v5"] } -which = { workspace = true } wildmatch = { workspace = true } zip = { workspace = true } @@ -124,7 +110,19 @@ core-foundation = "0.9" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] codex-rollout = { workspace = true } codex-state = { workspace = true } +rand = { workspace = true } +rmcp = { workspace = true, default-features = false, features = [ + "base64", + "macros", + "schemars", + "server", +] } tokio-tungstenite = { workspace = true } +tokio = { workspace = true, features = ["io-std", "process", "rt-multi-thread", "signal"] } +which = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +js-sys = { workspace = true } # Build OpenSSL from source for musl builds. [target.x86_64-unknown-linux-musl.dependencies] diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index c522acd1c1..26df6340d5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -29,6 +29,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; +#[cfg(not(target_arch = "wasm32"))] use codex_state::DirectionalThreadSpawnEdgeStatus; use serde::Serialize; use std::collections::HashMap; @@ -352,11 +353,19 @@ impl AgentControl { let Ok(resumed_thread) = state.get_thread(resumed_thread_id).await else { return Ok(resumed_thread_id); }; + #[cfg(target_arch = "wasm32")] + { + return Ok(resumed_thread_id); + } + + #[cfg(not(target_arch = "wasm32"))] let Some(state_db_ctx) = resumed_thread.state_db() else { return Ok(resumed_thread_id); }; + #[cfg(not(target_arch = "wasm32"))] let mut resume_queue = VecDeque::from([(thread_id, root_depth)]); + #[cfg(not(target_arch = "wasm32"))] while let Some((parent_thread_id, parent_depth)) = resume_queue.pop_front() { let child_ids = match state_db_ctx .list_thread_spawn_children_with_status( @@ -617,6 +626,7 @@ impl AgentControl { /// agent and any live descendants reached from the in-memory tree. pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; + #[cfg(not(target_arch = "wasm32"))] if let Ok(thread) = state.get_thread(agent_id).await && let Some(state_db_ctx) = thread.state_db() && let Err(err) = state_db_ctx @@ -1045,9 +1055,16 @@ impl AgentControl { let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else { return; }; + #[cfg(target_arch = "wasm32")] + { + return; + } + + #[cfg(not(target_arch = "wasm32"))] let Some(state_db_ctx) = thread.state_db() else { return; }; + #[cfg(not(target_arch = "wasm32"))] if let Err(err) = state_db_ctx .upsert_thread_spawn_edge( parent_thread_id, diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index f78c8d08bc..3294393035 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -4,7 +4,6 @@ use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use rand::prelude::IndexedRandom; use std::collections::HashMap; use std::collections::HashSet; use std::collections::hash_map::Entry; @@ -13,6 +12,22 @@ use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; +#[cfg(not(target_arch = "wasm32"))] +fn choose_item(items: &[T]) -> Option<&T> { + use rand::prelude::IndexedRandom; + + items.choose(&mut rand::rng()) +} + +#[cfg(target_arch = "wasm32")] +fn choose_item(items: &[T]) -> Option<&T> { + if items.is_empty() { + return None; + } + let index = (js_sys::Math::random() * items.len() as f64).floor() as usize; + items.get(index.min(items.len().saturating_sub(1))) +} + /// This structure is used to add some limits on the multi-agent capabilities for Codex. In /// the current implementation, it limits: /// * Total number of sub-agents (i.e. threads) per user session @@ -215,7 +230,7 @@ impl AgentRegistry { .map(|name| format_agent_nickname(name, active_agents.nickname_reset_count)) .filter(|name| !active_agents.used_agent_nicknames.contains(name)) .collect(); - if let Some(name) = available_names.choose(&mut rand::rng()) { + if let Some(name) = choose_item(&available_names) { name.clone() } else { active_agents.used_agent_nicknames.clear(); @@ -227,10 +242,7 @@ impl AgentRegistry { &[], ); } - format_agent_nickname( - names.choose(&mut rand::rng())?, - active_agents.nickname_reset_count, - ) + format_agent_nickname(choose_item(names)?, active_agents.nickname_reset_count) } }; active_agents diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index b7d7b55ab1..bf05c378f9 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -88,7 +88,7 @@ async fn load_role_layer_toml( let role_config_toml: TomlValue = toml::from_str(&role_config_contents)?; (role_config_toml, config.codex_home.as_path()) } else { - let role_config_contents = tokio::fs::read_to_string(config_file).await?; + let role_config_contents = crate::async_fs::read_to_string(config_file).await?; let role_config_base = config_file .parent() .ok_or(anyhow!("No corresponding config content"))?; diff --git a/codex-rs/core/src/async_fs.rs b/codex-rs/core/src/async_fs.rs new file mode 100644 index 0000000000..b816991a93 --- /dev/null +++ b/codex-rs/core/src/async_fs.rs @@ -0,0 +1,62 @@ +use std::io; +use std::path::Path; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn read(path: impl AsRef) -> io::Result> { + tokio::fs::read(path).await +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn read(path: impl AsRef) -> io::Result> { + std::fs::read(path) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn read_to_string(path: impl AsRef) -> io::Result { + tokio::fs::read_to_string(path).await +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn read_to_string(path: impl AsRef) -> io::Result { + std::fs::read_to_string(path) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> io::Result<()> { + tokio::fs::write(path, contents).await +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> io::Result<()> { + std::fs::write(path, contents) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn create_dir_all(path: impl AsRef) -> io::Result<()> { + tokio::fs::create_dir_all(path).await +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn create_dir_all(path: impl AsRef) -> io::Result<()> { + std::fs::create_dir_all(path) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn metadata(path: impl AsRef) -> io::Result { + tokio::fs::metadata(path).await +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn metadata(path: impl AsRef) -> io::Result { + std::fs::metadata(path) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) async fn try_exists(path: impl AsRef) -> io::Result { + tokio::fs::try_exists(path).await +} + +#[cfg(target_arch = "wasm32")] +pub(crate) async fn try_exists(path: impl AsRef) -> io::Result { + Ok(path.as_ref().exists()) +} diff --git a/codex-rs/core/src/async_runtime.rs b/codex-rs/core/src/async_runtime.rs new file mode 100644 index 0000000000..7defcc00d7 --- /dev/null +++ b/codex-rs/core/src/async_runtime.rs @@ -0,0 +1,21 @@ +use std::future::Future; + +use tokio::task::JoinHandle; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn spawn(future: F) -> JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(future) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn spawn(future: F) -> JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + tokio::task::spawn_local(future) +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e34b48f88e..f884d00dd8 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1470,6 +1470,7 @@ fn build_responses_headers( headers } +#[cfg(not(target_arch = "wasm32"))] fn map_response_stream( api_stream: S, session_telemetry: SessionTelemetry, @@ -1483,7 +1484,7 @@ where let (tx_event, rx_event) = mpsc::channel::>(1600); let (tx_last_response, rx_last_response) = oneshot::channel::(); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { let mut logged_error = false; let mut tx_last_response = Some(tx_last_response); let mut items_added: Vec = Vec::new(); @@ -1552,6 +1553,87 @@ where (ResponseStream { rx_event }, rx_last_response) } +#[cfg(target_arch = "wasm32")] +fn map_response_stream( + api_stream: S, + session_telemetry: SessionTelemetry, +) -> (ResponseStream, oneshot::Receiver) +where + S: futures::Stream> + Unpin + 'static, +{ + let (tx_event, rx_event) = mpsc::channel::>(1600); + let (tx_last_response, rx_last_response) = oneshot::channel::(); + + crate::async_runtime::spawn(async move { + let mut logged_error = false; + let mut tx_last_response = Some(tx_last_response); + let mut items_added: Vec = Vec::new(); + let mut api_stream = api_stream; + while let Some(event) = api_stream.next().await { + match event { + Ok(ResponseEvent::OutputItemDone(item)) => { + items_added.push(item.clone()); + if tx_event + .send(Ok(ResponseEvent::OutputItemDone(item))) + .await + .is_err() + { + return; + } + } + Ok(ResponseEvent::Completed { + response_id, + token_usage, + }) => { + if let Some(usage) = &token_usage { + session_telemetry.sse_event_completed( + usage.input_tokens, + usage.output_tokens, + Some(usage.cached_input_tokens), + Some(usage.reasoning_output_tokens), + usage.total_tokens, + ); + } + let _ = tx_event + .send(Ok(ResponseEvent::Completed { + response_id: response_id.clone(), + token_usage: token_usage.clone(), + })) + .await; + if let Some(tx_last_response) = tx_last_response.take() { + let _ = tx_last_response.send(LastResponse { + response_id, + items_added, + }); + } + return; + } + Ok(event) => { + if tx_event.send(Ok(event)).await.is_err() { + return; + } + } + Err(err) => { + if !logged_error { + tracing::warn!("error while forwarding model stream event: {err:#}"); + logged_error = true; + } + let _ = tx_event.send(Err(map_api_error(err))).await; + return; + } + } + } + let _ = tx_event + .send(Err(CodexErr::Stream( + "model stream closed unexpectedly".into(), + None, + ))) + .await; + }); + + (ResponseStream { rx_event }, rx_last_response) +} + /// Handles a 401 response by optionally refreshing ChatGPT tokens once. /// /// When refresh succeeds, the caller should retry the API call; otherwise diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a26e2e50e7..04f05139a6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -134,12 +134,6 @@ use futures::future::BoxFuture; use futures::future::Shared; use futures::prelude::*; use futures::stream::FuturesOrdered; -use rmcp::model::ListResourceTemplatesResult; -use rmcp::model::ListResourcesResult; -use rmcp::model::PaginatedRequestParams; -use rmcp::model::ReadResourceRequestParams; -use rmcp::model::ReadResourceResult; -use rmcp::model::RequestId; use serde_json; use serde_json::Value; use tokio::sync::Mutex; @@ -270,6 +264,12 @@ use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only; +use crate::mcp_types::ListResourceTemplatesResult; +use crate::mcp_types::ListResourcesResult; +use crate::mcp_types::PaginatedRequestParams; +use crate::mcp_types::ReadResourceRequestParams; +use crate::mcp_types::ReadResourceResult; +use crate::mcp_types::RequestId; use crate::memories; use crate::mentions::build_connector_slug_counts; use crate::mentions::build_skill_name_counts; @@ -520,6 +520,7 @@ impl Codex { warn!("{message}"); config.startup_warnings.push(message); } + #[cfg(not(target_arch = "wasm32"))] if config.features.enabled(Feature::CodeMode) && let Err(err) = resolve_compatible_node(config.js_repl_node_path.as_deref()).await { @@ -673,7 +674,7 @@ impl Codex { // This task will run until Op::Shutdown is received. let session_for_loop = Arc::clone(&session); - let session_loop_handle = tokio::spawn(async move { + let session_loop_handle = crate::async_runtime::spawn(async move { submission_loop(session_for_loop, config, rx_sub) .instrument(info_span!("session_loop", thread_id = %thread_id)) .await; @@ -1338,7 +1339,7 @@ impl Session { fn start_skills_watcher_listener(self: &Arc) { let mut rx = self.services.skills_watcher.subscribe(); let weak_sess = Arc::downgrade(self); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { loop { match rx.recv().await { Ok(SkillsWatcherEvent::SkillsChanged { .. }) => { @@ -3233,14 +3234,7 @@ impl Session { "Overwriting existing pending elicitation for server_name: {server_name}, request_id: {request_id}" ); } - let id = match request_id { - rmcp::model::NumberOrString::String(value) => { - codex_protocol::mcp::RequestId::String(value.to_string()) - } - rmcp::model::NumberOrString::Number(value) => { - codex_protocol::mcp::RequestId::Integer(value) - } - }; + let id = request_id.clone(); let event = EventMsg::ElicitationRequest(ElicitationRequestEvent { turn_id: params.turn_id, server_name, @@ -4797,7 +4791,7 @@ mod handlers { sess.active_turn_context_and_cancellation_token().await { let session = Arc::clone(sess); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { execute_user_shell_command( session, turn_context, @@ -4842,12 +4836,7 @@ mod handlers { content, meta, }; - let request_id = match request_id { - ProtocolRequestId::String(value) => { - rmcp::model::NumberOrString::String(std::sync::Arc::from(value)) - } - ProtocolRequestId::Integer(value) => rmcp::model::NumberOrString::Number(value), - }; + let request_id = request_id; if let Err(err) = sess .resolve_elicitation(server_name, request_id, response) .await @@ -4940,7 +4929,7 @@ mod handlers { pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { let id = sess.conversation_id; let config = Arc::clone(config); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await { warn!("failed to append to message history: {e}"); } @@ -4957,7 +4946,7 @@ mod handlers { let config = Arc::clone(config); let sess_clone = Arc::clone(sess); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { // Run lookup in blocking thread because it does file IO + locking. let entry_opt = tokio::task::spawn_blocking(move || { crate::message_history::lookup(log_id, offset, &config) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 453f59c322..c200168ed0 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -112,7 +112,7 @@ pub(crate) async fn run_codex_thread_interactive( // context when the later legacy RequestUserInput approval event only carries // a call_id plus approval question metadata. let pending_mcp_invocations = Arc::new(Mutex::new(HashMap::::new())); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { forward_events( codex_for_events, tx_sub, @@ -126,7 +126,7 @@ pub(crate) async fn run_codex_thread_interactive( // Forward ops from the caller to the sub-agent. let codex_for_ops = Arc::clone(&codex); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { forward_ops(codex_for_ops, rx_ops, cancel_token_ops).await; }); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c479f9f1ab..ff4c787dda 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -155,6 +155,9 @@ const RESERVED_MODEL_PROVIDER_IDS: [&str; 3] = [ ]; fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option { + #[cfg(target_arch = "wasm32")] + let raw = std::env::var("CODEX_SQLITE_HOME").ok()?; + #[cfg(not(target_arch = "wasm32"))] let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?; let trimmed = raw.trim(); if trimmed.is_empty() { @@ -634,15 +637,25 @@ impl ConfigBuilder { cloud_requirements, fallback_cwd, } = self; - let codex_home = codex_home.map_or_else(find_codex_home, std::io::Result::Ok)?; + let codex_home = codex_home + .map_or_else(find_codex_home, std::io::Result::Ok) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("resolve codex_home failed: {error}")) + })?; let cli_overrides = cli_overrides.unwrap_or_default(); let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); let cwd = match cwd_override { - Some(path) => AbsolutePathBuf::relative_to_current_dir(path)?, - None => AbsolutePathBuf::current_dir()?, - }; + Some(path) if path.is_absolute() => { + AbsolutePathBuf::try_from(normalize_for_native_workdir(path)) + } + Some(path) => AbsolutePathBuf::relative_to_current_dir(path), + None => AbsolutePathBuf::current_dir(), + } + .map_err(|error| { + std::io::Error::new(error.kind(), format!("resolve cwd failed: {error}")) + })?; harness_overrides.cwd = Some(cwd.to_path_buf()); let config_layer_stack = load_config_layers_state( &codex_home, @@ -651,7 +664,10 @@ impl ConfigBuilder { loader_overrides, cloud_requirements, ) - .await?; + .await + .map_err(|error| { + std::io::Error::new(error.kind(), format!("load config layers failed: {error}")) + })?; let merged_toml = config_layer_stack.effective_config(); // Note that each layer in ConfigLayerStack should have resolved @@ -679,6 +695,12 @@ impl ConfigBuilder { codex_home, config_layer_stack, ) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("assemble final config from merged layers failed: {error}"), + ) + }) } } @@ -1964,6 +1986,168 @@ pub(crate) fn resolve_web_search_mode_for_turn( } impl Config { + /// Build a session config from the default config shape plus explicit host + /// paths, without reading config files from disk. + pub fn load_embedded_defaults(codex_home: PathBuf, cwd: PathBuf) -> std::io::Result { + #[cfg(target_arch = "wasm32")] + { + return Self::load_embedded_defaults_wasm(codex_home, cwd); + } + + #[cfg(not(target_arch = "wasm32"))] + { + let overrides = ConfigOverrides { + cwd: Some(cwd), + ..ConfigOverrides::default() + }; + Self::load_config_with_layer_stack( + ConfigToml::default(), + overrides, + codex_home, + ConfigLayerStack::default(), + ) + } + } + + #[cfg(target_arch = "wasm32")] + fn load_embedded_defaults_wasm(codex_home: PathBuf, cwd: PathBuf) -> std::io::Result { + let cfg = ConfigToml::default(); + let model_provider_id = cfg + .model_provider + .clone() + .unwrap_or_else(|| OPENAI_PROVIDER_ID.to_string()); + let model_providers = built_in_model_providers(/*openai_base_url*/ None); + 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"), + ) + })? + .clone(); + let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir(cwd.as_path())) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("resolve session cwd failed: {error}")) + })?; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let file_system_sandbox_policy = FileSystemSandboxPolicy::from(&sandbox_policy); + + let features = ManagedFeatures::from_configured(Features::with_defaults(), None)?; + + Ok(Self { + config_layer_stack: ConfigLayerStack::default(), + startup_warnings: Vec::new(), + model: cfg.model, + service_tier: cfg.service_tier, + review_model: cfg.review_model, + model_context_window: cfg.model_context_window, + model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, + model_provider_id, + model_provider, + personality: cfg.personality.or(Some(Personality::Pragmatic)), + permissions: Permissions { + approval_policy: Constrained::allow_any(AskForApproval::Never), + sandbox_policy: Constrained::allow_any(sandbox_policy.clone()), + file_system_sandbox_policy, + network_sandbox_policy: NetworkSandboxPolicy::Restricted, + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + windows_sandbox_private_desktop: true, + }, + approvals_reviewer: ApprovalsReviewer::User, + enforce_residency: Constrained::allow_any(/*initial_value*/ None), + hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), + show_raw_agent_reasoning: cfg.show_raw_agent_reasoning.unwrap_or(false), + user_instructions: None, + base_instructions: None, + developer_instructions: cfg.developer_instructions, + guardian_developer_instructions: None, + compact_prompt: cfg.compact_prompt, + commit_attribution: cfg.commit_attribution, + notify: cfg.notify, + tui_notifications: Notifications::default(), + tui_notification_method: NotificationMethod::default(), + animations: true, + show_tooltips: true, + model_availability_nux: ModelAvailabilityNuxConfig::default(), + tui_alternate_screen: AltScreenMode::Auto, + tui_status_line: None, + tui_terminal_title: None, + tui_theme: None, + cwd: resolved_cwd, + cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), + mcp_servers: Constrained::allow_any(HashMap::new()), + mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(), + mcp_oauth_callback_port: cfg.mcp_oauth_callback_port, + mcp_oauth_callback_url: cfg.mcp_oauth_callback_url, + model_providers, + project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES), + project_doc_fallback_filenames: cfg.project_doc_fallback_filenames.unwrap_or_default(), + tool_output_token_limit: cfg.tool_output_token_limit, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, + agent_roles: BTreeMap::new(), + memories: cfg.memories.unwrap_or_default().into(), + codex_home: codex_home.clone(), + sqlite_home: codex_home.clone(), + log_dir: codex_home.join("log"), + history: cfg.history.unwrap_or_default(), + ephemeral: true, + file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), + codex_self_exe: None, + codex_linux_sandbox_exe: None, + main_execve_wrapper_exe: None, + js_repl_node_path: None, + js_repl_node_module_dirs: Vec::new(), + zsh_path: None, + model_reasoning_effort: cfg.model_reasoning_effort, + plan_mode_reasoning_effort: cfg.plan_mode_reasoning_effort, + model_reasoning_summary: cfg.model_reasoning_summary, + model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, + model_catalog: None, + model_verbosity: cfg.model_verbosity, + chatgpt_base_url: cfg + .chatgpt_base_url + .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + realtime_audio: RealtimeAudioConfig::default(), + experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url, + experimental_realtime_ws_model: cfg.experimental_realtime_ws_model, + realtime: cfg + .realtime + .map_or_else(RealtimeConfig::default, |realtime| RealtimeConfig { + version: realtime.version.unwrap_or_default(), + session_type: realtime.session_type.unwrap_or_default(), + }), + experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, + experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, + experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, + forced_chatgpt_workspace_id: cfg.forced_chatgpt_workspace_id, + forced_login_method: cfg.forced_login_method, + include_apply_patch_tool: false, + web_search_mode: Constrained::allow_any(WebSearchMode::Cached), + web_search_config: None, + use_experimental_unified_exec_tool: false, + background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, + ghost_snapshot: GhostSnapshotConfig::default(), + features, + suppress_unstable_features_warning: false, + active_profile: None, + active_project: ProjectConfig { trust_level: None }, + windows_wsl_setup_acknowledged: false, + notices: Notice::default(), + check_for_update_on_startup: true, + disable_paste_burst: false, + analytics_enabled: Some(true), + feedback_enabled: true, + tool_suggest: ToolSuggestConfig::default(), + otel: OtelConfig::default(), + }) + } + #[cfg(test)] fn load_from_base_config_with_overrides( cfg: ConfigToml, @@ -2067,7 +2251,13 @@ impl Config { }, feature_overrides, ); - let features = ManagedFeatures::from_configured(configured_features, feature_requirements)?; + let features = ManagedFeatures::from_configured(configured_features, feature_requirements) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("resolve managed features failed: {error}"), + ) + })?; let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); @@ -2088,11 +2278,20 @@ impl Config { current } } - }))?; + })) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("resolve session cwd failed: {error}")) + })?; let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) - .collect::, _>>()?; + .collect::, _>>() + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("normalize additional writable roots failed: {error}"), + ) + })?; let active_project = cfg .get_active_project(resolved_cwd.as_path()) .unwrap_or(ProjectConfig { trust_level: None }); @@ -2125,8 +2324,26 @@ impl Config { None => WindowsSandboxLevel::from_features(&features), }; let memories_root = memory_root(&codex_home); - std::fs::create_dir_all(&memories_root)?; - let memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + #[cfg(not(target_arch = "wasm32"))] + std::fs::create_dir_all(&memories_root).map_err(|error| { + std::io::Error::new( + error.kind(), + format!( + "create memories root {} failed: {error}", + memories_root.display() + ), + ) + })?; + let memories_root = + AbsolutePathBuf::from_absolute_path(&memories_root).map_err(|error| { + std::io::Error::new( + error.kind(), + format!( + "normalize memories root {} failed: {error}", + memories_root.display() + ), + ) + })?; if !additional_writable_roots .iter() .any(|existing| existing == &memories_root) @@ -2157,17 +2374,36 @@ impl Config { "default_permissions requires a named permissions profile", ) })?; - let profile = resolve_permission_profile(permissions, default_permissions)?; + let profile = + resolve_permission_profile(permissions, default_permissions).map_err(|error| { + std::io::Error::new( + error.kind(), + format!( + "resolve permissions profile `{default_permissions}` failed: {error}" + ), + ) + })?; let configured_network_proxy_config = network_proxy_config_from_profile_network(profile.network.as_ref()); - let (mut file_system_sandbox_policy, network_sandbox_policy) = - compile_permission_profile( - permissions, - default_permissions, - &mut startup_warnings, - )?; + let (mut file_system_sandbox_policy, network_sandbox_policy) = compile_permission_profile( + permissions, + default_permissions, + &mut startup_warnings, + ) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("compile permissions profile `{default_permissions}` failed: {error}"), + ) + })?; let mut sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path()) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("project filesystem policy conversion failed: {error}"), + ) + })?; if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy .with_additional_writable_roots( @@ -2175,7 +2411,15 @@ impl Config { &additional_writable_roots, ); sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path()) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!( + "project filesystem policy conversion after writable roots failed: {error}" + ), + ) + })?; } ( configured_network_proxy_config, @@ -2244,12 +2488,18 @@ impl Config { let web_search_config = resolve_web_search_config(&cfg, &config_profile); let agent_roles = - agent_roles::load_agent_roles(&cfg, &config_layer_stack, &mut startup_warnings)?; + agent_roles::load_agent_roles(&cfg, &config_layer_stack, &mut startup_warnings) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("load agent roles failed: {error}")) + })?; let openai_base_url = cfg .openai_base_url .clone() .filter(|value| !value.is_empty()); + #[cfg(target_arch = "wasm32")] + let openai_base_url_from_env = None; + #[cfg(not(target_arch = "wasm32"))] let openai_base_url_from_env = std::env::var(OPENAI_BASE_URL_ENV_VAR) .ok() .filter(|value| !value.is_empty()); @@ -2409,8 +2659,17 @@ impl Config { .model_instructions_file .as_ref() .or(cfg.model_instructions_file.as_ref()); + #[cfg(target_arch = "wasm32")] + let file_base_instructions = None; + #[cfg(not(target_arch = "wasm32"))] let file_base_instructions = - Self::try_read_non_empty_file(model_instructions_path, "model instructions file")?; + Self::try_read_non_empty_file(model_instructions_path, "model instructions file") + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("load base instructions failed: {error}"), + ) + })?; let base_instructions = base_instructions.or(file_base_instructions); let developer_instructions = developer_instructions.or(cfg.developer_instructions); let guardian_developer_instructions = guardian_developer_instructions_from_requirements( @@ -2429,10 +2688,16 @@ impl Config { .experimental_compact_prompt_file .as_ref() .or(cfg.experimental_compact_prompt_file.as_ref()); + #[cfg(target_arch = "wasm32")] + let file_compact_prompt = None; + #[cfg(not(target_arch = "wasm32"))] let file_compact_prompt = Self::try_read_non_empty_file( experimental_compact_prompt_path, "experimental compact prompt file", - )?; + ) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("load compact prompt failed: {error}")) + })?; let compact_prompt = compact_prompt.or(file_compact_prompt); let js_repl_node_path = js_repl_node_path_override .or(config_profile.js_repl_node_path.map(Into::into)) @@ -2460,7 +2725,10 @@ impl Config { .model_catalog_json .clone() .or(cfg.model_catalog_json.clone()), - )?; + ) + .map_err(|error| { + std::io::Error::new(error.kind(), format!("load model catalog failed: {error}")) + })?; let log_dir = cfg .log_dir @@ -2471,6 +2739,9 @@ impl Config { p.push("log"); p }); + #[cfg(target_arch = "wasm32")] + let sqlite_home = codex_home.to_path_buf(); + #[cfg(not(target_arch = "wasm32"))] let sqlite_home = cfg .sqlite_home .as_ref() @@ -2484,19 +2755,37 @@ impl Config { approval_policy, &mut constrained_approval_policy, &mut startup_warnings, - )?; + ) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("apply approval policy constraints failed: {error}"), + ) + })?; apply_requirement_constrained_value( "sandbox_mode", sandbox_policy, &mut constrained_sandbox_policy, &mut startup_warnings, - )?; + ) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("apply sandbox policy constraints failed: {error}"), + ) + })?; apply_requirement_constrained_value( "web_search_mode", web_search_mode, &mut constrained_web_search_mode, &mut startup_warnings, - )?; + ) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("apply web search constraints failed: {error}"), + ) + })?; let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; @@ -2520,12 +2809,21 @@ impl Config { } else { err } + }) + .map_err(|error| { + std::io::Error::new( + error.kind(), + format!("build network proxy configuration failed: {error}"), + ) })?; let network = if has_network_requirements { Some(network) } else { network.enabled().then_some(network) }; + #[cfg(target_arch = "wasm32")] + let helper_readable_roots = Vec::new(); + #[cfg(not(target_arch = "wasm32"))] let helper_readable_roots = get_readable_roots_required_for_codex_runtime( &codex_home, zsh_path.as_ref(), diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 93b59cf5f5..59142c5e0f 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -137,7 +137,7 @@ impl NetworkProxySpec { None => builder.policy_decider(|_request| async { // In restricted sandbox modes, allowlist misses should ask for // explicit network approval instead of hard-denying. - NetworkDecision::ask("not_allowed") + Ok(NetworkDecision::ask("not_allowed")) }), }; } diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 279af666c1..fab398657c 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -434,7 +434,7 @@ async fn create_empty_user_layer( } = resolve_symlink_write_paths(config_toml.as_path()) .map_err(|err| ConfigServiceError::io("failed to resolve user config path", err))?; let toml_value = match read_path { - Some(path) => match tokio::fs::read_to_string(&path).await { + Some(path) => match crate::async_fs::read_to_string(&path).await { Ok(contents) => toml::from_str(&contents).map_err(|e| { ConfigServiceError::toml("failed to parse existing user config.toml", e) })?, diff --git a/codex-rs/core/src/config_loader_wasm.rs b/codex-rs/core/src/config_loader_wasm.rs new file mode 100644 index 0000000000..5040970649 --- /dev/null +++ b/codex-rs/core/src/config_loader_wasm.rs @@ -0,0 +1,140 @@ +use crate::config::ConfigToml; +use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigRequirementsWithSources; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use std::io; +use std::path::Path; +use toml::Value as TomlValue; + +pub use codex_config::AppRequirementToml; +pub use codex_config::AppsRequirementsToml; +pub use codex_config::CloudRequirementsLoadError; +pub use codex_config::CloudRequirementsLoadErrorCode; +pub use codex_config::CloudRequirementsLoader; +pub use codex_config::ConfigError; +pub use codex_config::ConfigLayerEntry; +pub use codex_config::ConfigLayerStack; +pub use codex_config::ConfigLayerStackOrdering; +pub use codex_config::ConfigLoadError; +pub use codex_config::ConfigRequirements; +pub use codex_config::ConfigRequirementsToml; +pub use codex_config::ConstrainedWithSource; +pub use codex_config::FeatureRequirementsToml; +pub use codex_config::LoaderOverrides; +pub use codex_config::McpServerIdentity; +pub use codex_config::McpServerRequirement; +pub use codex_config::NetworkConstraints; +pub use codex_config::NetworkDomainPermissionToml; +pub use codex_config::NetworkDomainPermissionsToml; +pub use codex_config::NetworkRequirementsToml; +pub use codex_config::NetworkUnixSocketPermissionToml; +pub use codex_config::NetworkUnixSocketPermissionsToml; +pub use codex_config::RequirementSource; +pub use codex_config::ResidencyRequirement; +pub use codex_config::SandboxModeRequirement; +pub use codex_config::Sourced; +pub use codex_config::TextPosition; +pub use codex_config::TextRange; +pub use codex_config::WebSearchModeRequirement; +pub(crate) use codex_config::build_cli_overrides_layer; +pub(crate) use codex_config::config_error_from_toml; +pub use codex_config::default_project_root_markers; +pub use codex_config::format_config_error; +pub use codex_config::format_config_error_with_source; +pub(crate) use codex_config::io_error_from_config_error; +pub use codex_config::merge_toml_values; +pub use codex_config::project_root_markers_from_config; + +pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { + codex_config::first_layer_config_error::(layers, CONFIG_TOML_FILE).await +} + +pub(crate) async fn first_layer_config_error_from_entries( + layers: &[ConfigLayerEntry], +) -> Option { + codex_config::first_layer_config_error_from_entries::(layers, CONFIG_TOML_FILE) + .await +} + +pub async fn load_config_layers_state( + _codex_home: &Path, + cwd: Option, + cli_overrides: &[(String, TomlValue)], + _overrides: LoaderOverrides, + cloud_requirements: CloudRequirementsLoader, +) -> io::Result { + let mut requirements_toml = ConfigRequirementsWithSources::default(); + if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? { + requirements_toml.merge_unset_fields(RequirementSource::CloudRequirements, requirements); + } + + let mut layers = Vec::::new(); + if !cli_overrides.is_empty() { + let cli_layer = build_cli_overrides_layer(cli_overrides); + let base_dir = cwd + .as_ref() + .map(AbsolutePathBuf::as_path) + .unwrap_or_else(|| Path::new(".")); + let _ = AbsolutePathBuf::from_absolute_path(base_dir)?; + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + resolve_relative_paths_in_config_toml(cli_layer, base_dir)?, + )); + } + + let requirements = requirements_toml + .clone() + .try_into() + .map_err(io::Error::other)?; + ConfigLayerStack::new(layers, requirements, requirements_toml.into_toml()) +} + +pub(crate) fn resolve_relative_paths_in_config_toml( + value_from_config_toml: TomlValue, + base_dir: &Path, +) -> io::Result { + let _guard = AbsolutePathBufGuard::new(base_dir); + let Ok(resolved) = value_from_config_toml.clone().try_into::() else { + return Ok(value_from_config_toml); + }; + drop(_guard); + + let resolved_value = TomlValue::try_from(resolved).map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to serialize resolved config: {err}"), + ) + })?; + + Ok(copy_shape_from_original( + &value_from_config_toml, + &resolved_value, + )) +} + +fn copy_shape_from_original(original: &TomlValue, resolved: &TomlValue) -> TomlValue { + match (original, resolved) { + (TomlValue::Table(original_table), TomlValue::Table(resolved_table)) => { + let mut table = toml::map::Map::new(); + for (key, original_value) in original_table { + let resolved_value = resolved_table.get(key).unwrap_or(original_value); + table.insert( + key.clone(), + copy_shape_from_original(original_value, resolved_value), + ); + } + TomlValue::Table(table) + } + (TomlValue::Array(original_array), TomlValue::Array(resolved_array)) => { + let mut items = Vec::new(); + for (index, original_value) in original_array.iter().enumerate() { + let resolved_value = resolved_array.get(index).unwrap_or(original_value); + items.push(copy_shape_from_original(original_value, resolved_value)); + } + TomlValue::Array(items) + } + (_, resolved_value) => resolved_value.clone(), + } +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index a15138f6fb..4bccafb6fb 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -19,7 +19,6 @@ use codex_connectors::DirectoryListResponse; use codex_login::token_data::TokenData; use codex_protocol::protocol::SandboxPolicy; use codex_tools::DiscoverableTool; -use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde::de::DeserializeOwned; use tracing::warn; @@ -42,6 +41,7 @@ use crate::mcp::auth::compute_auth_statuses; use crate::mcp::with_codex_apps_mcp; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_connection_manager::codex_apps_tools_cache_key; +use crate::mcp_types::ToolAnnotations; use crate::plugins::AppConnectorId; use crate::plugins::PluginsManager; use crate::plugins::list_tool_suggest_discoverable_plugins; diff --git a/codex-rs/core/src/exec_policy_wasm.rs b/codex-rs/core/src/exec_policy_wasm.rs new file mode 100644 index 0000000000..ace19ca8bd --- /dev/null +++ b/codex-rs/core/src/exec_policy_wasm.rs @@ -0,0 +1,168 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use codex_execpolicy::Decision; +use codex_execpolicy::NetworkRuleProtocol; +use codex_execpolicy::Policy; +use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::permissions::FileSystemSandboxKind; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use thiserror::Error; + +use crate::config::Config; +use crate::config_loader::ConfigLayerStack; +use crate::sandboxing::SandboxPermissions; +use crate::tools::sandboxing::ExecApprovalRequirement; + +pub(crate) fn child_uses_parent_exec_policy(parent_config: &Config, child_config: &Config) -> bool { + parent_config.config_layer_stack.requirements().exec_policy + == child_config.config_layer_stack.requirements().exec_policy +} + +pub(crate) fn prompt_is_rejected_by_policy( + approval_policy: AskForApproval, + _prompt_is_rule: bool, +) -> Option<&'static str> { + match approval_policy { + AskForApproval::Never => { + Some("approval required by policy, but AskForApproval is set to Never") + } + AskForApproval::OnFailure + | AskForApproval::OnRequest + | AskForApproval::UnlessTrusted + | AskForApproval::Granular(_) => None, + } +} + +#[derive(Debug, Error)] +pub enum ExecPolicyError { + #[error("exec policy is unavailable in wasm")] + Unsupported, + + #[error("failed to parse rules file {path}: {source}")] + ParsePolicy { + path: String, + source: codex_execpolicy::Error, + }, +} + +#[derive(Debug, Error)] +pub enum ExecPolicyUpdateError { + #[error("exec policy updates are unavailable in wasm")] + Unsupported, +} + +pub(crate) struct ExecApprovalRequest<'a> { + pub(crate) command: &'a [String], + pub(crate) approval_policy: AskForApproval, + pub(crate) sandbox_policy: &'a SandboxPolicy, + pub(crate) file_system_sandbox_policy: &'a FileSystemSandboxPolicy, + pub(crate) sandbox_permissions: SandboxPermissions, + pub(crate) prefix_rule: Option>, +} + +pub(crate) struct ExecPolicyManager { + policy: Arc, +} + +impl ExecPolicyManager { + pub(crate) fn new(policy: Arc) -> Self { + Self { policy } + } + + pub(crate) async fn load(_config_stack: &ConfigLayerStack) -> Result { + Ok(Self::default()) + } + + pub(crate) fn current(&self) -> Arc { + Arc::clone(&self.policy) + } + + pub(crate) fn compiled_network_domains(&self) -> (Vec, Vec) { + (Vec::new(), Vec::new()) + } + + pub(crate) async fn create_exec_approval_requirement_for_command( + &self, + _req: ExecApprovalRequest<'_>, + ) -> ExecApprovalRequirement { + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: None, + } + } + + pub(crate) async fn amend( + &self, + _amendment: &ExecPolicyAmendment, + ) -> Result<(), ExecPolicyUpdateError> { + Ok(()) + } + + pub(crate) fn requirement_for( + &self, + _request: &ExecApprovalRequest<'_>, + ) -> Option { + None + } + + pub(crate) async fn append_amendment_and_update( + &self, + _codex_home: &std::path::Path, + _amendment: &ExecPolicyAmendment, + ) -> Result<(), ExecPolicyUpdateError> { + Ok(()) + } + + pub(crate) async fn append_network_rule_and_update( + &self, + _codex_home: &std::path::Path, + _host: &str, + _protocol: NetworkRuleProtocol, + _decision: Decision, + _justification: Option, + ) -> Result<(), ExecPolicyUpdateError> { + Ok(()) + } +} + +impl Default for ExecPolicyManager { + fn default() -> Self { + Self::new(Arc::new(Policy::empty())) + } +} + +pub async fn check_execpolicy_for_warnings( + _config_stack: &ConfigLayerStack, +) -> Option { + None +} + +pub fn format_exec_policy_error_with_source(error: &ExecPolicyError) -> String { + error.to_string() +} + +pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result { + Ok(ExecPolicyManager::load(config_stack) + .await? + .current() + .as_ref() + .clone()) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_decision_for_unmatched_command( + _decision: &Decision, + _approval_policy: AskForApproval, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox_permissions: SandboxPermissions, + _prefix_rule: Option<&[String]>, + _command: &[String], + _cwd: Option<&PathBuf>, + _windows_sandbox_kind: Option, +) -> Option { + None +} diff --git a/codex-rs/core/src/exec_wasm.rs b/codex-rs/core/src/exec_wasm.rs new file mode 100644 index 0000000000..e637062b3a --- /dev/null +++ b/codex-rs/core/src/exec_wasm.rs @@ -0,0 +1,235 @@ +use async_channel::Sender; +use codex_network_proxy::NetworkProxy; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxTransformError; +use codex_sandboxing::SandboxType; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tokio_util::sync::CancellationToken; + +use crate::error::CodexErr; +use crate::error::Result; +use crate::protocol::Event; +use crate::sandboxing::ExecRequest; +use crate::sandboxing::SandboxPermissions; + +pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000; +pub const IO_DRAIN_TIMEOUT_MS: u64 = 2_000; +pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; + +#[derive(Debug)] +pub struct ExecParams { + pub command: Vec, + pub cwd: PathBuf, + pub expiration: ExecExpiration, + pub capture_policy: ExecCapturePolicy, + pub env: HashMap, + pub network: Option, + pub sandbox_permissions: SandboxPermissions, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, + pub justification: Option, + pub arg0: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsRestrictedTokenFilesystemOverlay { + pub(crate) additional_deny_write_paths: Vec, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ExecCapturePolicy { + #[default] + ShellTool, + FullBuffer, +} + +#[derive(Clone, Debug)] +pub enum ExecExpiration { + Timeout(Duration), + DefaultTimeout, + Cancellation(CancellationToken), +} + +impl From> for ExecExpiration { + fn from(timeout_ms: Option) -> Self { + timeout_ms.map_or(ExecExpiration::DefaultTimeout, |timeout_ms| { + ExecExpiration::Timeout(Duration::from_millis(timeout_ms)) + }) + } +} + +impl From for ExecExpiration { + fn from(timeout_ms: u64) -> Self { + ExecExpiration::Timeout(Duration::from_millis(timeout_ms)) + } +} + +impl ExecExpiration { + pub(crate) async fn wait(self) { + match self { + ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await, + ExecExpiration::DefaultTimeout => { + tokio::time::sleep(Duration::from_millis(DEFAULT_EXEC_COMMAND_TIMEOUT_MS)).await + } + ExecExpiration::Cancellation(cancel) => cancel.cancelled().await, + } + } + + pub(crate) fn timeout_ms(&self) -> Option { + match self { + ExecExpiration::Timeout(duration) => Some(duration.as_millis() as u64), + ExecExpiration::DefaultTimeout => Some(DEFAULT_EXEC_COMMAND_TIMEOUT_MS), + ExecExpiration::Cancellation(_) => None, + } + } +} + +#[derive(Clone)] +pub struct StdoutStream { + pub sub_id: String, + pub call_id: String, + pub tx_event: Sender, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamOutput { + pub text: T, + pub truncated_after_lines: Option, +} + +impl StreamOutput { + pub fn new(text: T) -> Self { + Self { + text, + truncated_after_lines: None, + } + } +} + +impl StreamOutput> { + pub fn from_utf8_lossy(&self) -> StreamOutput { + StreamOutput { + text: String::from_utf8_lossy(&self.text).into_owned(), + truncated_after_lines: self.truncated_after_lines, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExecToolCallOutput { + pub exit_code: i32, + pub stdout: StreamOutput, + pub stderr: StreamOutput, + pub aggregated_output: StreamOutput, + pub duration: Duration, + pub timed_out: bool, +} + +impl Default for ExecToolCallOutput { + fn default() -> Self { + Self { + exit_code: 0, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::ZERO, + timed_out: false, + } + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn process_exec_tool_call( + _params: ExecParams, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _network_sandbox_policy: NetworkSandboxPolicy, + _sandbox_cwd: &Path, + _codex_linux_sandbox_exe: &Option, + _use_legacy_landlock: bool, + _stdout_stream: Option, +) -> Result { + Err(CodexErr::UnsupportedOperation( + "process execution is unavailable in wasm".to_string(), + )) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_exec_request( + params: ExecParams, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + _sandbox_cwd: &Path, + _codex_linux_sandbox_exe: &Option, + _use_legacy_landlock: bool, +) -> Result { + Ok(ExecRequest::new( + params.command, + params.cwd, + params.env, + params.network, + params.expiration, + params.capture_policy, + SandboxType::None, + params.windows_sandbox_level, + params.windows_sandbox_private_desktop, + sandbox_policy.clone(), + file_system_sandbox_policy.clone(), + network_sandbox_policy, + params.arg0, + )) +} + +pub(crate) async fn execute_exec_request( + _exec_request: ExecRequest, + _effective_policy: &SandboxPolicy, + _stdout_stream: Option, + _after_spawn: Option>, +) -> Result { + Err(CodexErr::UnsupportedOperation( + "process execution is unavailable in wasm".to_string(), + )) +} + +pub(crate) fn is_likely_sandbox_denied( + _sandbox_type: SandboxType, + _exec_output: &ExecToolCallOutput, +) -> bool { + false +} + +pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox_permissions: SandboxPermissions, +) -> Option { + None +} + +pub(crate) fn resolve_windows_restricted_token_filesystem_overlay( + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox_permissions: SandboxPermissions, +) -> std::result::Result, String> { + Ok(None) +} + +impl From for CodexErr { + fn from(err: SandboxTransformError) -> Self { + match err { + SandboxTransformError::MissingLinuxSandboxExecutable => { + CodexErr::LandlockSandboxExecutableNotProvided + } + SandboxTransformError::SeatbeltUnavailable => { + CodexErr::UnsupportedOperation("seatbelt sandbox is unavailable".to_string()) + } + other => CodexErr::UnsupportedOperation(other.to_string()), + } + } +} diff --git a/codex-rs/core/src/landlock_wasm.rs b/codex-rs/core/src/landlock_wasm.rs new file mode 100644 index 0000000000..e06d3d15fb --- /dev/null +++ b/codex-rs/core/src/landlock_wasm.rs @@ -0,0 +1 @@ +// Browser/wasm builds never launch a native sandbox helper. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d8e9efea3d..254466c174 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -9,12 +9,16 @@ pub mod api_bridge; mod apply_patch; mod apps; mod arc_monitor; +mod async_fs; +mod async_runtime; pub use codex_login as auth; mod auth_env_telemetry; mod client; mod client_common; pub mod codex; +#[cfg_attr(target_arch = "wasm32", path = "realtime_context_wasm.rs")] mod realtime_context; +#[cfg_attr(target_arch = "wasm32", path = "realtime_conversation_wasm.rs")] mod realtime_conversation; pub use codex::SteerInputError; mod codex_thread; @@ -26,6 +30,7 @@ mod codex_delegate; mod command_canonicalization; mod commit_attribution; pub mod config; +#[cfg_attr(target_arch = "wasm32", path = "config_loader_wasm.rs")] pub mod config_loader; pub mod connectors; mod context_manager; @@ -33,8 +38,10 @@ mod contextual_user_message; pub use codex_utils_path::env; mod environment_context; pub mod error; +#[cfg_attr(target_arch = "wasm32", path = "exec_wasm.rs")] pub mod exec; pub mod exec_env; +#[cfg_attr(target_arch = "wasm32", path = "exec_policy_wasm.rs")] mod exec_policy; pub mod external_agent_config; pub mod file_watcher; @@ -44,10 +51,13 @@ mod git_info_tests; mod guardian; mod hook_runtime; pub mod instructions; +#[cfg_attr(target_arch = "wasm32", path = "landlock_wasm.rs")] pub mod landlock; pub mod mcp; +#[cfg_attr(target_arch = "wasm32", path = "mcp_connection_manager_wasm.rs")] mod mcp_connection_manager; mod mcp_tool_approval_templates; +mod mcp_types; pub mod models_manager; mod network_policy_decision; pub mod network_proxy_loader; @@ -57,12 +67,16 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; +#[cfg_attr(target_arch = "wasm32", path = "memories_wasm.rs")] mod memories; pub mod mention_syntax; +#[cfg_attr(target_arch = "wasm32", path = "message_history_wasm.rs")] pub mod message_history; mod model_provider_info; +mod monotonic_time; pub mod utils; pub use utils::path_utils; +#[cfg_attr(target_arch = "wasm32", path = "personality_migration_wasm.rs")] pub mod personality_migration; pub mod plugins; mod provider_auth; @@ -144,13 +158,16 @@ mod default_client_forwarding; pub mod default_client { pub use super::default_client_forwarding::*; } +#[cfg_attr(target_arch = "wasm32", path = "project_doc_wasm.rs")] pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; mod session_rollout_init_error; pub mod shell; +#[cfg_attr(target_arch = "wasm32", path = "shell_snapshot_wasm.rs")] pub mod shell_snapshot; +#[cfg_attr(target_arch = "wasm32", path = "spawn_wasm.rs")] pub mod spawn; pub mod state_db_bridge; pub mod state_db { @@ -216,4 +233,5 @@ pub use file_watcher::FileWatcherEvent; pub use turn_metadata::build_turn_metadata_header; pub mod compact; pub mod memory_trace; +#[cfg_attr(target_arch = "wasm32", path = "otel_init_wasm.rs")] pub mod otel_init; diff --git a/codex-rs/core/src/mcp_connection_manager_wasm.rs b/codex-rs/core/src/mcp_connection_manager_wasm.rs new file mode 100644 index 0000000000..aea38eb188 --- /dev/null +++ b/codex-rs/core/src/mcp_connection_manager_wasm.rs @@ -0,0 +1,214 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use async_channel::Sender; +use codex_config::Constrained; +use codex_protocol::approvals::ElicitationRequest; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::McpStartupFailure; +use codex_protocol::protocol::SandboxPolicy; +use codex_rmcp_client::ElicitationResponse; +use codex_rmcp_client::OAuthCredentialsStoreMode; +use serde::Deserialize; +use serde::Serialize; +use tokio_util::sync::CancellationToken; + +use crate::config::types::McpServerConfig; +use crate::mcp::ToolPluginProvenance; +use crate::mcp::auth::McpAuthStatusEntry; +use crate::mcp_types::ListResourceTemplatesResult; +use crate::mcp_types::ListResourcesResult; +use crate::mcp_types::PaginatedRequestParams; +use crate::mcp_types::ReadResourceRequestParams; +use crate::mcp_types::ReadResourceResult; +use crate::mcp_types::RequestId; +use crate::mcp_types::Resource; +use crate::mcp_types::ResourceTemplate; +use crate::mcp_types::Tool; + +pub const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +pub const MCP_SANDBOX_STATE_CAPABILITY: &str = "codex/sandbox-state"; +pub const MCP_SANDBOX_STATE_METHOD: &str = "codex/sandbox-state/update"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CodexAppsToolsCacheKey { + account_id: Option, + chatgpt_user_id: Option, + is_workspace_account: bool, +} + +pub(crate) fn codex_apps_tools_cache_key( + auth: Option<&crate::CodexAuth>, +) -> CodexAppsToolsCacheKey { + let token_data = auth.and_then(|auth| auth.get_token_data().ok()); + let account_id = token_data + .as_ref() + .and_then(|token_data| token_data.account_id.clone()); + let chatgpt_user_id = token_data + .as_ref() + .and_then(|token_data| token_data.id_token.chatgpt_user_id.clone()); + let is_workspace_account = token_data + .as_ref() + .is_some_and(|token_data| token_data.id_token.is_workspace_account()); + + CodexAppsToolsCacheKey { + account_id, + chatgpt_user_id, + is_workspace_account, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ToolInfo { + pub(crate) server_name: String, + pub(crate) tool_name: String, + pub(crate) tool_namespace: String, + pub(crate) tool: Tool, + pub(crate) connector_id: Option, + pub(crate) connector_name: Option, + #[serde(default)] + pub(crate) plugin_display_names: Vec, + pub(crate) connector_description: Option, +} + +pub(crate) fn filter_non_codex_apps_mcp_tools_only( + mcp_tools: &HashMap, +) -> HashMap { + mcp_tools.clone() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxState { + pub sandbox_policy: SandboxPolicy, + pub codex_linux_sandbox_exe: Option, + pub sandbox_cwd: PathBuf, + #[serde(default)] + pub use_legacy_landlock: bool, +} + +pub(crate) struct McpConnectionManager; + +impl McpConnectionManager { + pub(crate) fn new_uninitialized(_approval_policy: &Constrained) -> Self { + Self + } + + pub(crate) fn has_servers(&self) -> bool { + false + } + + pub(crate) fn server_origin(&self, _server_name: &str) -> Option<&str> { + None + } + + pub fn set_approval_policy(&self, _approval_policy: &Constrained) {} + + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] + pub async fn new( + _mcp_servers: &HashMap, + _store_mode: OAuthCredentialsStoreMode, + _auth_entries: HashMap, + _approval_policy: &Constrained, + _tx_event: Sender, + _initial_sandbox_state: SandboxState, + _codex_home: PathBuf, + _codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + _tool_plugin_provenance: ToolPluginProvenance, + ) -> (Self, CancellationToken) { + (Self, CancellationToken::new()) + } + + pub async fn resolve_elicitation( + &self, + _server_name: String, + _id: RequestId, + _response: ElicitationResponse, + ) -> Result<()> { + Err(anyhow!("MCP elicitations are unavailable on wasm32")) + } + + pub(crate) async fn wait_for_server_ready( + &self, + _server_name: &str, + _timeout: Duration, + ) -> bool { + false + } + + pub(crate) async fn required_startup_failures( + &self, + required_servers: &[String], + ) -> Vec { + required_servers + .iter() + .map(|server_name| McpStartupFailure { + server: server_name.clone(), + error: "MCP is unavailable on wasm32".to_string(), + }) + .collect() + } + + pub async fn list_all_tools(&self) -> HashMap { + HashMap::new() + } + + pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { + Ok(HashMap::new()) + } + + pub async fn list_all_resources(&self) -> HashMap> { + HashMap::new() + } + + pub async fn list_all_resource_templates(&self) -> HashMap> { + HashMap::new() + } + + pub async fn call_tool( + &self, + _server: &str, + _tool: &str, + _arguments: Option, + _meta: Option, + ) -> Result { + Err(anyhow!("MCP tool calls are unavailable on wasm32")) + } + + pub async fn list_resources( + &self, + _server: &str, + _params: Option, + ) -> Result { + Err(anyhow!("MCP resources are unavailable on wasm32")) + } + + pub async fn list_resource_templates( + &self, + _server: &str, + _params: Option, + ) -> Result { + Err(anyhow!("MCP resources are unavailable on wasm32")) + } + + pub async fn read_resource( + &self, + _server: &str, + _params: ReadResourceRequestParams, + ) -> Result { + Err(anyhow!("MCP resources are unavailable on wasm32")) + } + + pub async fn parse_tool_name(&self, _tool_name: &str) -> Option<(String, String)> { + None + } + + pub async fn notify_sandbox_state_change(&self, _sandbox_state: &SandboxState) -> Result<()> { + Ok(()) + } +} diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f0b1fcb1c8..16c5e1194c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -29,6 +29,8 @@ use crate::guardian::routes_approval_to_guardian; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template; +use crate::mcp_types::RequestId; +use crate::mcp_types::ToolAnnotations; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; @@ -51,7 +53,6 @@ use codex_protocol::request_user_input::RequestUserInputQuestionOption; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; -use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde::Serialize; use std::path::Path; @@ -792,9 +793,8 @@ async fn maybe_request_mcp_tool_approval( question.question = mcp_tool_approval_question_text(question.question, monitor_reason.as_deref()); if tool_call_mcp_elicitation_enabled { - let request_id = rmcp::model::RequestId::String( - format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(), - ); + let request_id = + RequestId::String(format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}")); let params = build_mcp_tool_approval_elicitation_request( sess.as_ref(), turn_context.as_ref(), @@ -1015,7 +1015,7 @@ pub(crate) async fn lookup_mcp_tool_metadata( connector_name: tool_info.connector_name, connector_description, tool_title: tool_info.tool.title, - tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned), + tool_description: tool_info.tool.description.map(Into::into), codex_apps_meta: tool_info .tool .meta diff --git a/codex-rs/core/src/mcp_types.rs b/codex-rs/core/src/mcp_types.rs new file mode 100644 index 0000000000..2576977f60 --- /dev/null +++ b/codex-rs/core/src/mcp_types.rs @@ -0,0 +1,154 @@ +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::ListResourceTemplatesResult; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::ListResourcesResult; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::PaginatedRequestParams; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::ReadResourceRequestParams; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::ReadResourceResult; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::RequestId; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::Resource; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::ResourceTemplate; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::Tool; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use rmcp::model::ToolAnnotations; + +#[cfg(target_arch = "wasm32")] +pub(crate) use codex_protocol::mcp::RequestId; +#[cfg(target_arch = "wasm32")] +use schemars::JsonSchema; +#[cfg(target_arch = "wasm32")] +use serde::Deserialize; +#[cfg(target_arch = "wasm32")] +use serde::Serialize; + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ToolAnnotations { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destructive_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub open_world_hint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read_only_hint: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Tool { + #[serde(default)] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub input_schema: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_schema: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icons: Option>, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Resource { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(default)] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default)] + pub uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icons: Option>, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ResourceTemplate { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub annotations: Option, + #[serde(default)] + pub uri_template: String, + #[serde(default)] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub(crate) struct PaginatedRequestParams { + #[serde(default)] + pub meta: Option, + #[serde(default)] + pub cursor: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ListResourcesResult { + #[serde(default)] + pub resources: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ListResourceTemplatesResult { + #[serde(default)] + pub resource_templates: Vec, + #[serde(default)] + pub next_cursor: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadResourceRequestParams { + #[serde(default)] + pub uri: String, + #[serde(default)] + pub meta: Option, +} + +#[cfg(target_arch = "wasm32")] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReadResourceResult { + #[serde(default)] + pub contents: Vec, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, +} diff --git a/codex-rs/core/src/memories_wasm.rs b/codex-rs/core/src/memories_wasm.rs new file mode 100644 index 0000000000..2a487c1be2 --- /dev/null +++ b/codex-rs/core/src/memories_wasm.rs @@ -0,0 +1,52 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use codex_protocol::ThreadId; +use codex_protocol::memory_citation::MemoryCitation; + +use crate::codex::Session; +use crate::config::Config; + +pub(crate) mod citations { + use super::*; + + pub fn parse_memory_citation(_citations: Vec) -> Option { + None + } + + pub fn get_thread_id_from_citations(_citations: Vec) -> Vec { + Vec::new() + } +} + +pub(crate) mod prompts { + use super::*; + + pub(crate) async fn build_memory_tool_developer_instructions( + _codex_home: &Path, + ) -> Option { + None + } +} + +pub(crate) mod usage { + use crate::tools::context::ToolInvocation; + + pub(crate) async fn emit_metric_for_tool_read(_invocation: &ToolInvocation, _success: bool) {} +} + +pub(crate) fn start_memories_startup_task( + _sess: &Arc, + _config: Arc, + _session_source: &codex_protocol::protocol::SessionSource, +) { +} + +pub(crate) async fn clear_memory_root_contents(memory_root: &Path) -> std::io::Result<()> { + crate::async_fs::create_dir_all(memory_root).await +} + +pub fn memory_root(codex_home: &Path) -> PathBuf { + codex_home.join("memories") +} diff --git a/codex-rs/core/src/memory_trace.rs b/codex-rs/core/src/memory_trace.rs index 2e613e6713..5913d87d40 100644 --- a/codex-rs/core/src/memory_trace.rs +++ b/codex-rs/core/src/memory_trace.rs @@ -93,7 +93,7 @@ async fn prepare_trace(index: usize, path: &Path) -> Result { } async fn load_trace_text(path: &Path) -> Result { - let raw = tokio::fs::read(path).await?; + let raw = crate::async_fs::read(path).await?; Ok(decode_trace_bytes(&raw)) } diff --git a/codex-rs/core/src/message_history_wasm.rs b/codex-rs/core/src/message_history_wasm.rs new file mode 100644 index 0000000000..76e8ba2d12 --- /dev/null +++ b/codex-rs/core/src/message_history_wasm.rs @@ -0,0 +1,24 @@ +use crate::config::Config; +use codex_protocol::ThreadId; + +pub struct HistoryEntry { + pub session_id: String, + pub ts: u64, + pub text: String, +} + +pub async fn append_entry( + _text: &str, + _conversation_id: &ThreadId, + _config: &Config, +) -> crate::error::Result<()> { + Ok(()) +} + +pub async fn history_metadata(_config: &Config) -> (u64, usize) { + (0, 0) +} + +pub fn lookup(_log_id: u64, _offset: usize, _config: &Config) -> Option { + None +} diff --git a/codex-rs/core/src/models_manager/cache.rs b/codex-rs/core/src/models_manager/cache.rs index 812716d29c..a0c5a6cb37 100644 --- a/codex-rs/core/src/models_manager/cache.rs +++ b/codex-rs/core/src/models_manager/cache.rs @@ -7,7 +7,6 @@ use std::io; use std::io::ErrorKind; use std::path::PathBuf; use std::time::Duration; -use tokio::fs; use tracing::error; use tracing::info; @@ -102,7 +101,7 @@ impl ModelsCacheManager { } async fn load(&self) -> io::Result> { - match fs::read(&self.cache_path).await { + match crate::async_fs::read(&self.cache_path).await { Ok(contents) => { let cache = serde_json::from_slice(&contents) .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; @@ -115,11 +114,11 @@ impl ModelsCacheManager { async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()> { if let Some(parent) = self.cache_path.parent() { - fs::create_dir_all(parent).await?; + crate::async_fs::create_dir_all(parent).await?; } let json = serde_json::to_vec_pretty(cache) .map_err(|err| io::Error::new(ErrorKind::InvalidData, err.to_string()))?; - fs::write(&self.cache_path, json).await + crate::async_fs::write(&self.cache_path, json).await } #[cfg(test)] diff --git a/codex-rs/core/src/monotonic_time.rs b/codex-rs/core/src/monotonic_time.rs new file mode 100644 index 0000000000..034af545d9 --- /dev/null +++ b/codex-rs/core/src/monotonic_time.rs @@ -0,0 +1,40 @@ +use std::time::Duration; + +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use std::time::Instant; + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)] +pub(crate) struct Instant { + millis_since_epoch: f64, +} + +#[cfg(target_arch = "wasm32")] +impl Instant { + pub(crate) fn now() -> Self { + Self { + millis_since_epoch: js_sys::Date::now(), + } + } + + pub(crate) fn elapsed(self) -> Duration { + Self::now().saturating_duration_since(self) + } + + pub(crate) fn duration_since(self, earlier: Self) -> Duration { + self.saturating_duration_since(earlier) + } + + pub(crate) fn saturating_duration_since(self, earlier: Self) -> Duration { + duration_from_millis((self.millis_since_epoch - earlier.millis_since_epoch).max(0.0)) + } +} + +#[cfg(target_arch = "wasm32")] +fn duration_from_millis(millis: f64) -> Duration { + if !millis.is_finite() || millis <= 0.0 { + return Duration::ZERO; + } + + Duration::from_secs_f64(millis / 1_000.0) +} diff --git a/codex-rs/core/src/otel_init_wasm.rs b/codex-rs/core/src/otel_init_wasm.rs new file mode 100644 index 0000000000..ade22ebea9 --- /dev/null +++ b/codex-rs/core/src/otel_init_wasm.rs @@ -0,0 +1,16 @@ +use crate::config::Config; +use codex_otel::OtelProvider; +use std::error::Error; + +pub fn build_provider( + _config: &Config, + _service_version: &str, + _service_name_override: Option<&str>, + _default_analytics_enabled: bool, +) -> Result, Box> { + Ok(None) +} + +pub fn codex_export_filter(_meta: &tracing::Metadata<'_>) -> bool { + false +} diff --git a/codex-rs/core/src/personality_migration_wasm.rs b/codex-rs/core/src/personality_migration_wasm.rs new file mode 100644 index 0000000000..a3e71a1a6e --- /dev/null +++ b/codex-rs/core/src/personality_migration_wasm.rs @@ -0,0 +1,19 @@ +use std::io; +use std::path::Path; + +use toml::Value as TomlValue; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PersonalityMigrationStatus { + Applied, + SkippedMarker, + SkippedExplicitPersonality, + SkippedNoSessions, +} + +pub async fn maybe_migrate_personality( + _codex_home: &Path, + _config_toml: &TomlValue, +) -> io::Result { + Ok(PersonalityMigrationStatus::SkippedNoSessions) +} diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 59bc99b238..32244c0544 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1019,7 +1019,7 @@ impl PluginsManager { let config = config.clone(); let manager = Arc::clone(self); - tokio::spawn(async move { + crate::async_runtime::spawn(async move { let auth = auth_manager.auth().await; if let Err(err) = manager .featured_plugin_ids_for_config(&config, auth.as_ref()) diff --git a/codex-rs/core/src/plugins/startup_sync.rs b/codex-rs/core/src/plugins/startup_sync.rs index ceeb1d0f81..86c8ebd807 100644 --- a/codex-rs/core/src/plugins/startup_sync.rs +++ b/codex-rs/core/src/plugins/startup_sync.rs @@ -163,7 +163,7 @@ pub(super) fn start_startup_remote_plugin_sync_once( return; } - tokio::spawn(async move { + crate::async_runtime::spawn(async move { if marker_path.is_file() { return; } @@ -236,9 +236,9 @@ async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> { let marker_path = startup_remote_plugin_sync_marker_path(codex_home); if let Some(parent) = marker_path.parent() { - tokio::fs::create_dir_all(parent).await?; + crate::async_fs::create_dir_all(parent).await?; } - tokio::fs::write(marker_path, b"ok\n").await + crate::async_fs::write(marker_path, b"ok\n").await } fn prepare_curated_repo_parent_and_temp_dir(repo_path: &Path) -> Result { diff --git a/codex-rs/core/src/project_doc_wasm.rs b/codex-rs/core/src/project_doc_wasm.rs new file mode 100644 index 0000000000..ffe853abd1 --- /dev/null +++ b/codex-rs/core/src/project_doc_wasm.rs @@ -0,0 +1,20 @@ +use std::io; +use std::path::PathBuf; + +use crate::config::Config; + +pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str = ""; +pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md"; +pub const LOCAL_PROJECT_DOC_FILENAME: &str = "AGENTS.override.md"; + +pub(crate) async fn get_user_instructions(_config: &Config) -> Option { + None +} + +pub async fn read_project_docs(_config: &Config) -> io::Result> { + Ok(None) +} + +pub fn discover_project_doc_paths(_config: &Config) -> io::Result> { + Ok(Vec::new()) +} diff --git a/codex-rs/core/src/realtime_context_wasm.rs b/codex-rs/core/src/realtime_context_wasm.rs new file mode 100644 index 0000000000..1072af5b45 --- /dev/null +++ b/codex-rs/core/src/realtime_context_wasm.rs @@ -0,0 +1,8 @@ +use crate::codex::Session; + +pub(crate) async fn build_realtime_startup_context( + _sess: &Session, + _budget_tokens: usize, +) -> Option { + None +} diff --git a/codex-rs/core/src/realtime_conversation_wasm.rs b/codex-rs/core/src/realtime_conversation_wasm.rs new file mode 100644 index 0000000000..7f3db55299 --- /dev/null +++ b/codex-rs/core/src/realtime_conversation_wasm.rs @@ -0,0 +1,67 @@ +use std::sync::Arc; + +use crate::codex::Session; +use crate::error::Result as CodexResult; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::ConversationTextParams; +use tokio::sync::Mutex; + +#[derive(Default)] +pub(crate) struct RealtimeConversationManager { + active_handoff: Mutex>, +} + +impl RealtimeConversationManager { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) async fn running_state(&self) -> Option<()> { + None + } + + pub(crate) async fn handoff_out(&self, _output_text: String) -> CodexResult<()> { + Ok(()) + } + + pub(crate) async fn handoff_complete(&self) -> CodexResult<()> { + Ok(()) + } + + pub(crate) async fn active_handoff_id(&self) -> Option { + self.active_handoff.lock().await.clone() + } + + pub(crate) async fn clear_active_handoff(&self) { + *self.active_handoff.lock().await = None; + } + + pub(crate) async fn shutdown(&self) -> CodexResult<()> { + Ok(()) + } +} + +pub(crate) async fn handle_start( + _sess: &Arc, + _sub_id: String, + _params: ConversationStartParams, +) -> CodexResult<()> { + Ok(()) +} + +pub(crate) async fn handle_audio( + _sess: &Arc, + _sub_id: String, + _params: ConversationAudioParams, +) { +} + +pub(crate) async fn handle_text( + _sess: &Arc, + _sub_id: String, + _params: ConversationTextParams, +) { +} + +pub(crate) async fn handle_close(_sess: &Arc, _sub_id: String) {} diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 2bb2798f7d..0caca1c325 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -81,7 +81,6 @@ mod wasm { use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; @@ -99,6 +98,7 @@ mod wasm { ] }); + pub use codex_protocol::protocol::SessionMeta; pub use session_index::append_thread_name; pub use session_index::find_thread_name_by_id; pub use session_index::find_thread_path_by_name_str; @@ -138,6 +138,11 @@ mod wasm { pub mod policy { pub use super::EventPersistenceMode; + use codex_protocol::models::ResponseItem; + + pub fn should_persist_response_item_for_memories(_item: &ResponseItem) -> bool { + false + } } #[derive(Clone)] @@ -466,7 +471,20 @@ mod wasm { crate::error::CodexErr::Fatal(err.to_string()) } - pub(crate) mod truncation {} + pub(crate) mod truncation { + use codex_protocol::protocol::RolloutItem; + + pub(crate) fn user_message_positions_in_rollout(_items: &[RolloutItem]) -> Vec { + Vec::new() + } + + pub(crate) fn truncate_rollout_before_nth_user_message_from_start( + items: &[RolloutItem], + _n_from_start: usize, + ) -> Vec { + items.to_vec() + } + } } #[cfg(not(target_arch = "wasm32"))] diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 326d864f1e..b57eb2550e 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -157,11 +157,17 @@ impl SessionStartupPrewarmHandle { impl Session { pub(crate) async fn schedule_startup_prewarm(self: &Arc, base_instructions: String) { + #[cfg(target_arch = "wasm32")] + { + let _ = base_instructions; + return; + } + let session_telemetry = self.services.session_telemetry.clone(); let websocket_connect_timeout = self.provider().await.websocket_connect_timeout(); let started_at = Instant::now(); let startup_prewarm_session = Arc::clone(self); - let startup_prewarm = tokio::spawn(async move { + let startup_prewarm = crate::async_runtime::spawn(async move { let result = schedule_startup_prewarm_inner(startup_prewarm_session, base_instructions).await; let status = if result.is_ok() { "ready" } else { "failed" }; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 1943712565..8fc24b40bb 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -162,6 +162,16 @@ fn file_exists(path: &PathBuf) -> Option { } } +#[cfg(not(target_arch = "wasm32"))] +fn resolve_binary_path(binary_name: &str) -> Option { + which::which(binary_name).ok() +} + +#[cfg(target_arch = "wasm32")] +fn resolve_binary_path(_binary_name: &str) -> Option { + None +} + fn get_shell_path( shell_type: ShellType, provided_path: Option<&PathBuf>, @@ -183,7 +193,7 @@ fn get_shell_path( return Some(default_shell_path); } - if let Ok(path) = which::which(binary_name) { + if let Some(path) = resolve_binary_path(binary_name) { return Some(path); } diff --git a/codex-rs/core/src/shell_snapshot_wasm.rs b/codex-rs/core/src/shell_snapshot_wasm.rs new file mode 100644 index 0000000000..1456603266 --- /dev/null +++ b/codex-rs/core/src/shell_snapshot_wasm.rs @@ -0,0 +1,45 @@ +use crate::shell::Shell; +use codex_otel::SessionTelemetry; +use codex_protocol::ThreadId; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::watch; + +#[derive(Debug, Clone)] +pub struct ShellSnapshot { + pub path: PathBuf, + pub cwd: PathBuf, +} + +impl ShellSnapshot { + pub fn start_snapshotting( + _codex_home: PathBuf, + _session_id: ThreadId, + _session_cwd: PathBuf, + shell: &mut Shell, + _session_telemetry: SessionTelemetry, + ) -> watch::Sender>> { + let (tx, rx) = watch::channel(None); + shell.shell_snapshot = rx; + tx + } + + pub fn refresh_snapshot( + _codex_home: PathBuf, + _session_id: ThreadId, + _session_cwd: PathBuf, + _shell: Shell, + shell_snapshot_tx: watch::Sender>>, + _session_telemetry: SessionTelemetry, + ) { + let _ = shell_snapshot_tx.send(None); + } +} + +pub async fn cleanup_stale_snapshots( + _codex_home: &Path, + _active_session_id: ThreadId, +) -> std::io::Result<()> { + Ok(()) +} diff --git a/codex-rs/core/src/spawn_wasm.rs b/codex-rs/core/src/spawn_wasm.rs new file mode 100644 index 0000000000..7c098347bb --- /dev/null +++ b/codex-rs/core/src/spawn_wasm.rs @@ -0,0 +1,30 @@ +use codex_network_proxy::NetworkProxy; +use codex_protocol::permissions::NetworkSandboxPolicy; +use std::collections::HashMap; +use std::path::PathBuf; + +pub const CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR: &str = "CODEX_SANDBOX_NETWORK_DISABLED"; +pub const CODEX_SANDBOX_ENV_VAR: &str = "CODEX_SANDBOX"; + +#[derive(Debug, Clone, Copy)] +pub enum StdioPolicy { + RedirectForShellTool, + Inherit, +} + +pub(crate) struct SpawnChildRequest<'a> { + pub program: PathBuf, + pub args: Vec, + pub arg0: Option<&'a str>, + pub cwd: PathBuf, + pub network_sandbox_policy: NetworkSandboxPolicy, + pub network: Option<&'a NetworkProxy>, + pub stdio_policy: StdioPolicy, + pub env: HashMap, +} + +pub(crate) async fn spawn_child_async(_request: SpawnChildRequest<'_>) -> std::io::Result<()> { + Err(std::io::Error::other( + "process spawning is unavailable in wasm", + )) +} diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 07d040ca21..d1eb8d1936 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -14,10 +14,10 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; -use rmcp::model::RequestId; use tokio::sync::oneshot; use crate::codex::TurnContext; +use crate::mcp_types::RequestId; use crate::protocol::ReviewDecision; use crate::protocol::TokenUsage; use crate::tasks::SessionTask; diff --git a/codex-rs/core/src/state_db_bridge.rs b/codex-rs/core/src/state_db_bridge.rs index b160a54301..8c8ff8f859 100644 --- a/codex-rs/core/src/state_db_bridge.rs +++ b/codex-rs/core/src/state_db_bridge.rs @@ -64,6 +64,12 @@ pub mod wasm { #[derive(Debug, Clone, PartialEq, Eq)] pub struct LogEntry; + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct ThreadMetadataStub { + pub agent_nickname: Option, + pub agent_role: Option, + } + pub async fn init(_config: &Config) -> Option { None } @@ -183,6 +189,26 @@ pub mod wasm { ) -> bool { false } + + impl StateRuntimeStub { + pub async fn clear_memory_data(&self) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn record_stage1_output_usage( + &self, + _thread_ids: &[ThreadId], + ) -> anyhow::Result { + Ok(0) + } + + pub async fn get_thread( + &self, + _id: ThreadId, + ) -> anyhow::Result> { + Ok(None) + } + } } #[cfg(target_arch = "wasm32")] diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index cd77f1d5a3..e7cacf2468 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -115,9 +115,9 @@ async fn save_image_generation_result( })?; let path = image_generation_artifact_path(codex_home, session_id, call_id); if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; + crate::async_fs::create_dir_all(parent).await?; } - tokio::fs::write(&path, bytes).await?; + crate::async_fs::write(&path, bytes).await?; Ok(path) } diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index a2d94bdc0a..418060229f 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -11,7 +11,8 @@ use tokio_util::sync::CancellationToken; #[derive(Clone, Copy, Default)] pub(crate) struct CompactTask; -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl SessionTask for CompactTask { fn kind(&self) -> TaskKind { TaskKind::Compact diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs index 6028c311cc..5bd1cbe0d2 100644 --- a/codex-rs/core/src/tasks/ghost_snapshot.rs +++ b/codex-rs/core/src/tasks/ghost_snapshot.rs @@ -26,7 +26,8 @@ pub(crate) struct GhostSnapshotTask { const SNAPSHOT_WARNING_THRESHOLD: Duration = Duration::from_secs(240); -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl SessionTask for GhostSnapshotTask { fn kind(&self) -> TaskKind { TaskKind::Regular @@ -43,7 +44,7 @@ impl SessionTask for GhostSnapshotTask { _input: Vec, cancellation_token: CancellationToken, ) -> Option { - tokio::task::spawn(async move { + crate::async_runtime::spawn(async move { let token = self.token; let warnings_enabled = !ctx.ghost_snapshot.disable_warnings; // Channel used to signal when the snapshot work has finished so the @@ -56,7 +57,7 @@ impl SessionTask for GhostSnapshotTask { // Fire a generic warning if the snapshot is still running after // three minutes; this helps users discover large untracked files // that might need to be added to .gitignore. - tokio::task::spawn(async move { + crate::async_runtime::spawn(async move { tokio::select! { _ = tokio::time::sleep(SNAPSHOT_WARNING_THRESHOLD) => { session_for_warning.session diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 0f14e233c7..c8aa1c05de 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -7,7 +7,6 @@ mod user_shell; use std::sync::Arc; use std::time::Duration; -use std::time::Instant; use async_trait::async_trait; use tokio::select; @@ -29,6 +28,7 @@ use crate::hook_runtime::inspect_pending_input; use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::models_manager::manager::ModelsManager; +use crate::monotonic_time::Instant; use crate::protocol::EventMsg; use crate::protocol::TurnAbortReason; use crate::protocol::TurnAbortedEvent; @@ -125,7 +125,8 @@ impl SessionTaskContext { /// intentionally small: implementers identify themselves via /// [`SessionTask::kind`], perform their work in [`SessionTask::run`], and may /// release resources in [`SessionTask::abort`]. -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub(crate) trait SessionTask: Send + Sync + 'static { /// Describes the type of work the task performs so the session can /// surface it in telemetry and UI. @@ -227,7 +228,7 @@ impl Session { turn.id = %turn_context.sub_id, model = %turn_context.model_info.slug, ); - let handle = tokio::spawn( + let handle = crate::async_runtime::spawn( async move { let ctx_for_finish = Arc::clone(&ctx); let last_agent_message = task_for_run @@ -456,11 +457,16 @@ impl Session { if should_clear_active_turn { let session = Arc::clone(self); + #[cfg(not(target_arch = "wasm32"))] let _scheduler = tokio::task::spawn_blocking(move || { tokio::runtime::Handle::current().block_on(async move { session.maybe_start_turn_for_pending_work().await; }); }); + #[cfg(target_arch = "wasm32")] + let _scheduler = crate::async_runtime::spawn(async move { + session.maybe_start_turn_for_pending_work().await; + }); } } diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index 171c0adaa3..06f1a31d04 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -25,7 +25,8 @@ impl RegularTask { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl SessionTask for RegularTask { fn kind(&self) -> TaskKind { TaskKind::Regular diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index e0c6033483..c0ce7e31d9 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -48,7 +48,8 @@ impl ReviewTask { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl SessionTask for ReviewTask { fn kind(&self) -> TaskKind { TaskKind::Review diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index 7fd2ea07e8..6749f54f31 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -25,7 +25,8 @@ impl UndoTask { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl SessionTask for UndoTask { fn kind(&self) -> TaskKind { TaskKind::Regular diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index b90717e715..b3afe55c28 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -62,7 +62,8 @@ impl UserShellCommandTask { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl SessionTask for UserShellCommandTask { fn kind(&self) -> TaskKind { TaskKind::Regular diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 9e2131deda..aeec627c6a 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -86,6 +86,12 @@ impl Drop for TempCodexHomeGuard { } fn build_skills_watcher(skills_manager: Arc) -> Arc { + #[cfg(target_arch = "wasm32")] + { + let _ = skills_manager; + return Arc::new(SkillsWatcher::noop()); + } + if should_use_test_thread_manager_behavior() && let Ok(handle) = Handle::try_current() && handle.runtime_flavor() == RuntimeFlavor::CurrentThread diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 3f77216c16..cd7e400b20 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use crate::function_tool::FunctionCallError; +use crate::monotonic_time::Instant; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -32,7 +33,7 @@ impl CodeModeExecuteHandler { .code_mode_service .stored_values() .await; - let started_at = std::time::Instant::now(); + let started_at = Instant::now(); let response = exec .session .services @@ -53,7 +54,8 @@ impl CodeModeExecuteHandler { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for CodeModeExecuteHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index ec896d2828..6c27601be8 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -20,6 +20,7 @@ use crate::client_common::tools::ToolSpec; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::monotonic_time::Instant; use crate::tools::ToolRouter; use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; @@ -114,7 +115,8 @@ struct CoreTurnHost { tool_runtime: ToolCallRuntime, } -#[async_trait::async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] impl CodeModeTurnHost for CoreTurnHost { async fn invoke_tool( &self, @@ -155,7 +157,7 @@ pub(super) async fn handle_runtime_response( exec: &ExecContext, response: RuntimeResponse, max_output_tokens: Option, - started_at: std::time::Instant, + started_at: Instant, ) -> Result { let script_status = format_script_status(&response); @@ -247,6 +249,12 @@ fn truncate_code_mode_result( pub(super) async fn build_enabled_tools( exec: &ExecContext, ) -> Vec { + #[cfg(target_arch = "wasm32")] + { + let _ = exec; + return Vec::new(); + } + let router = build_nested_router(exec).await; let mut out = router .specs() diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index f319985a88..2c63125664 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use serde::Deserialize; use crate::function_tool::FunctionCallError; +use crate::monotonic_time::Instant; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -39,7 +40,8 @@ where }) } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for CodeModeWaitHandler { type Output = FunctionToolOutput; @@ -60,7 +62,7 @@ impl ToolHandler for CodeModeWaitHandler { ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => { let args: ExecWaitArgs = parse_arguments(&arguments)?; let exec = ExecContext { session, turn }; - let started_at = std::time::Instant::now(); + let started_at = Instant::now(); let response = exec .session .services diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 8bdaea059d..6410ce0c4a 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -130,7 +130,8 @@ async fn effective_patch_permissions( ) } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for ApplyPatchHandler { type Output = ApplyPatchToolOutput; diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 23285bc907..2ce4b31fa1 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -20,7 +20,8 @@ use tracing::warn; pub struct DynamicToolHandler; -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for DynamicToolHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 18e0df25c4..a9c11f524a 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -10,7 +10,8 @@ use crate::tools::registry::ToolKind; use codex_protocol::mcp::CallToolResult; pub struct McpHandler; -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for McpHandler { type Output = CallToolResult; diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs index 02253d1080..77140f6cd2 100644 --- a/codex-rs/core/src/tools/handlers/mcp_resource.rs +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -6,13 +6,6 @@ use std::time::Instant; use async_trait::async_trait; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::function_call_output_content_items_to_text; -use rmcp::model::ListResourceTemplatesResult; -use rmcp::model::ListResourcesResult; -use rmcp::model::PaginatedRequestParams; -use rmcp::model::ReadResourceRequestParams; -use rmcp::model::ReadResourceResult; -use rmcp::model::Resource; -use rmcp::model::ResourceTemplate; use serde::Deserialize; use serde::Serialize; use serde::de::DeserializeOwned; @@ -21,6 +14,13 @@ use serde_json::Value; use crate::codex::Session; use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; +use crate::mcp_types::ListResourceTemplatesResult; +use crate::mcp_types::ListResourcesResult; +use crate::mcp_types::PaginatedRequestParams; +use crate::mcp_types::ReadResourceRequestParams; +use crate::mcp_types::ReadResourceResult; +use crate::mcp_types::Resource; +use crate::mcp_types::ResourceTemplate; use crate::protocol::EventMsg; use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; @@ -178,7 +178,8 @@ struct ReadResourcePayload { result: ReadResourceResult, } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for McpResourceHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 86f2175be6..ec27c2a2be 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,19 +1,27 @@ +#[cfg(not(target_arch = "wasm32"))] pub(crate) mod agent_jobs; pub mod apply_patch; mod dynamic; +#[cfg(not(target_arch = "wasm32"))] mod js_repl; +#[cfg(not(target_arch = "wasm32"))] mod list_dir; mod mcp; mod mcp_resource; +#[cfg(not(target_arch = "wasm32"))] pub(crate) mod multi_agents; +#[cfg(not(target_arch = "wasm32"))] pub(crate) mod multi_agents_common; +#[cfg(not(target_arch = "wasm32"))] pub(crate) mod multi_agents_v2; mod plan; mod request_permissions; mod request_user_input; mod shell; mod test_sync; +#[cfg(not(target_arch = "wasm32"))] mod tool_search; +#[cfg(not(target_arch = "wasm32"))] mod tool_suggest; pub(crate) mod unified_exec; mod view_image; @@ -37,11 +45,16 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; +#[cfg(not(target_arch = "wasm32"))] pub use js_repl::JsReplHandler; +#[cfg(not(target_arch = "wasm32"))] pub use js_repl::JsReplResetHandler; +#[cfg(not(target_arch = "wasm32"))] pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; @@ -50,10 +63,31 @@ pub(crate) use request_user_input::request_user_input_tool_description; pub use shell::ShellCommandHandler; pub use shell::ShellHandler; pub use test_sync::TestSyncHandler; +#[cfg(target_arch = "wasm32")] +pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: u64 = 30_000; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use multi_agents_common::MAX_WAIT_TIMEOUT_MS; +#[cfg(target_arch = "wasm32")] +pub(crate) const MAX_WAIT_TIMEOUT_MS: u64 = 3_600_000; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use multi_agents_common::MIN_WAIT_TIMEOUT_MS; +#[cfg(target_arch = "wasm32")] +pub(crate) const MIN_WAIT_TIMEOUT_MS: u64 = 10_000; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOL_SEARCH_DEFAULT_LIMIT: usize = 8; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +#[cfg(not(target_arch = "wasm32"))] pub use tool_search::ToolSearchHandler; +#[cfg(not(target_arch = "wasm32"))] pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME; +#[cfg(target_arch = "wasm32")] +pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; +#[cfg(not(target_arch = "wasm32"))] pub use tool_suggest::ToolSuggestHandler; pub use unified_exec::UnifiedExecHandler; pub use view_image::ViewImageHandler; diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index af8dc2c310..3422d2d9f6 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -93,7 +93,8 @@ At most one step can be in_progress at a time. }) }); -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for PlanHandler { type Output = PlanToolOutput; diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 4df8328e70..a00c851f3d 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -17,7 +17,8 @@ pub(crate) fn request_permissions_tool_description() -> String { pub struct RequestPermissionsHandler; -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for RequestPermissionsHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index 4d95a2c20d..a7ec3f31b3 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -55,7 +55,8 @@ pub struct RequestUserInputHandler { pub default_mode_request_user_input: bool, } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for RequestUserInputHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 7f97f419d9..ba9726df56 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -178,7 +178,8 @@ impl From for ShellCommandHandler { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for ShellHandler { type Output = FunctionToolOutput; @@ -279,7 +280,8 @@ impl ToolHandler for ShellHandler { } } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for ShellCommandHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/test_sync.rs b/codex-rs/core/src/tools/handlers/test_sync.rs index 2d6e351488..c0cf762ee8 100644 --- a/codex-rs/core/src/tools/handlers/test_sync.rs +++ b/codex-rs/core/src/tools/handlers/test_sync.rs @@ -54,7 +54,8 @@ fn barrier_map() -> &'static tokio::sync::Mutex> { BARRIERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new())) } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for TestSyncHandler { type Output = FunctionToolOutput; diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index c68ac8589c..213b75340b 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -12,7 +12,6 @@ use codex_tools::DiscoverableTool; use codex_tools::DiscoverableToolAction; use codex_tools::DiscoverableToolType; use codex_tools::filter_tool_suggest_discoverable_tools_for_client; -use rmcp::model::RequestId; use serde::Deserialize; use serde::Serialize; use serde_json::json; @@ -21,6 +20,7 @@ use tracing::warn; use crate::connectors; use crate::function_tool::FunctionCallError; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use crate::mcp_types::RequestId; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b512a878b6..15945b4f7c 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -86,7 +86,8 @@ fn default_tty() -> bool { false } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for UnifiedExecHandler { type Output = ExecCommandToolOutput; diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index f4015a7624..c4a8a9c173 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -37,7 +37,8 @@ enum ViewImageDetail { Original, } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl ToolHandler for ViewImageHandler { type Output = ViewImageOutput; diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index beb2930b22..98b0da2e65 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1957,13 +1957,23 @@ pub(crate) fn resolve_node(config_path: Option<&Path>) -> Option { return Some(path.to_path_buf()); } - if let Ok(path) = which::which("node") { + if let Some(path) = resolve_node_from_path() { return Some(path); } None } +#[cfg(not(target_arch = "wasm32"))] +fn resolve_node_from_path() -> Option { + which::which("node").ok() +} + +#[cfg(target_arch = "wasm32")] +fn resolve_node_from_path() -> Option { + None +} + #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/js_repl_wasm.rs b/codex-rs/core/src/tools/js_repl_wasm.rs new file mode 100644 index 0000000000..a6224efe62 --- /dev/null +++ b/codex-rs/core/src/tools/js_repl_wasm.rs @@ -0,0 +1,59 @@ +use std::fmt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::function_tool::FunctionCallError; +use serde::Deserialize; + +pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:"; + +#[derive(Default)] +pub(crate) struct JsReplManager; + +impl JsReplManager { + pub async fn interrupt_turn_exec(&self, _turn_id: &str) -> Result { + Ok(false) + } +} + +pub(crate) struct JsReplHandle; + +impl fmt::Debug for JsReplHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("JsReplHandle").finish_non_exhaustive() + } +} + +impl JsReplHandle { + pub(crate) fn with_node_path( + _node_path: Option, + _node_module_dirs: Vec, + ) -> Self { + Self + } + + pub(crate) async fn manager(&self) -> Result, FunctionCallError> { + Err(FunctionCallError::RespondToModel( + "js_repl is unavailable on wasm32".to_string(), + )) + } + + pub(crate) fn manager_if_initialized(&self) -> Option> { + None + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JsReplArgs { + pub code: String, + #[serde(default)] + pub timeout_ms: Option, +} + +pub(crate) async fn resolve_compatible_node( + _config_path: Option<&Path>, +) -> Result { + Err("js_repl is unavailable on wasm32".to_string()) +} diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index e866d1f5f7..3f384a909a 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -2,6 +2,7 @@ pub mod code_mode; pub mod context; pub mod events; pub(crate) mod handlers; +#[cfg_attr(target_arch = "wasm32", path = "js_repl_wasm.rs")] pub mod js_repl; pub(crate) mod network_approval; pub mod orchestrator; diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index d3c526e99e..27f19e677a 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -530,16 +530,26 @@ pub(crate) fn build_network_policy_decider( network_approval: Arc, network_policy_decider_session: Arc>>, ) -> Arc { + #[cfg(target_arch = "wasm32")] + { + let _ = network_approval; + let _ = network_policy_decider_session; + return Arc::new(move |_request: NetworkPolicyRequest| async move { + Ok(NetworkDecision::ask("not_allowed")) + }); + } + + #[cfg(not(target_arch = "wasm32"))] Arc::new(move |request: NetworkPolicyRequest| { let network_approval = Arc::clone(&network_approval); let network_policy_decider_session = Arc::clone(&network_policy_decider_session); async move { let Some(session) = network_policy_decider_session.read().await.upgrade() else { - return NetworkDecision::ask("not_allowed"); + return Ok(NetworkDecision::ask("not_allowed")); }; - network_approval + Ok(network_approval .handle_inline_policy_request(session, request) - .await + .await) } }) } diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 0cc0989fb9..d7eabd8064 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::time::Instant; use tokio::sync::RwLock; use tokio_util::either::Either; @@ -14,6 +13,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::error::CodexErr; use crate::function_tool::FunctionCallError; +use crate::monotonic_time::Instant; use crate::tools::context::AbortedToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolPayload; @@ -95,7 +95,7 @@ impl ToolCallRuntime { ); let handle: AbortOnDropHandle> = - AbortOnDropHandle::new(tokio::spawn(async move { + AbortOnDropHandle::new(crate::async_runtime::spawn(async move { tokio::select! { _ = cancellation_token.cancelled() => { let secs = started.elapsed().as_secs_f32().max(0.1); diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 0e77caac72..1c0828e3ab 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use std::time::Instant; use crate::client_common::tools::ToolSpec; use crate::function_tool::FunctionCallError; @@ -9,6 +8,7 @@ use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::run_post_tool_use_hooks; use crate::hook_runtime::run_pre_tool_use_hooks; use crate::memories::usage::emit_metric_for_tool_read; +use crate::monotonic_time::Instant; use crate::protocol::SandboxPolicy; use crate::sandbox_tags::sandbox_tag; use crate::tools::context::FunctionToolOutput; @@ -35,7 +35,8 @@ pub enum ToolKind { Mcp, } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] pub trait ToolHandler: Send + Sync { type Output: ToolOutput + 'static; @@ -112,7 +113,8 @@ pub(crate) struct PostToolUsePayload { pub(crate) tool_response: Value, } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] trait AnyToolHandler: Send + Sync { fn matches_kind(&self, payload: &ToolPayload) -> bool; @@ -133,7 +135,8 @@ trait AnyToolHandler: Send + Sync { ) -> Result; } -#[async_trait] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] impl AnyToolHandler for T where T: ToolHandler, diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 646c1d1387..eca5edede9 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -18,11 +18,12 @@ use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_tools::ConfiguredToolSpec; use codex_tools::DiscoverableTool; -use rmcp::model::Tool; use std::collections::HashMap; use std::sync::Arc; use tracing::instrument; +use crate::mcp_types::Tool; + pub use crate::tools::context::ToolCallSource; #[derive(Clone, Debug)] diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 65a3981cca..3b27e208cc 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -13,6 +13,7 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ApprovalFuture; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::Sandboxable; @@ -29,7 +30,6 @@ use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxablePreference; use codex_utils_absolute_path::AbsolutePathBuf; -use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -138,7 +138,7 @@ impl Approvable for ApplyPatchRuntime { &'a mut self, req: &'a ApplyPatchRequest, ctx: ApprovalCtx<'a>, - ) -> BoxFuture<'a, ReviewDecision> { + ) -> ApprovalFuture<'a, ReviewDecision> { let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 3916a86301..b0bc619311 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -25,6 +25,7 @@ use crate::tools::runtimes::build_sandbox_command; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ApprovalFuture; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -38,7 +39,6 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxablePreference; -use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -143,7 +143,7 @@ impl Approvable for ShellRuntime { &'a mut self, req: &'a ShellRequest, ctx: ApprovalCtx<'a>, - ) -> BoxFuture<'a, ReviewDecision> { + ) -> ApprovalFuture<'a, ReviewDecision> { let keys = self.approval_keys(req); let command = req.command.clone(); let cwd = req.cwd.clone(); diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 9211fb08a7..58d4501e08 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -23,6 +23,7 @@ use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::shell::zsh_fork_backend; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; +use crate::tools::sandboxing::ApprovalFuture; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -41,7 +42,6 @@ use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxablePreference; -use futures::future::BoxFuture; use std::collections::HashMap; use std::path::PathBuf; @@ -119,7 +119,7 @@ impl Approvable for UnifiedExecRuntime<'_> { &'b mut self, req: &'b UnifiedExecRequest, ctx: ApprovalCtx<'b>, - ) -> BoxFuture<'b, ReviewDecision> { + ) -> ApprovalFuture<'b, ReviewDecision> { let keys = self.approval_keys(req); let session = ctx.session; let turn = ctx.turn; diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 5b7e950192..aaaa0cb10d 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -28,7 +28,10 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use futures::Future; +#[cfg(not(target_arch = "wasm32"))] use futures::future::BoxFuture; +#[cfg(target_arch = "wasm32")] +use futures::future::LocalBoxFuture; use serde::Serialize; use std::collections::HashMap; use std::fmt::Debug; @@ -36,6 +39,11 @@ use std::hash::Hash; use std::path::Path; use std::sync::Arc; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) type ApprovalFuture<'a, T> = BoxFuture<'a, T>; +#[cfg(target_arch = "wasm32")] +pub(crate) type ApprovalFuture<'a, T> = LocalBoxFuture<'a, T>; + #[derive(Clone, Default, Debug)] pub(crate) struct ApprovalStore { // Store serialized keys for generic caching across requests. @@ -281,7 +289,7 @@ pub(crate) trait Approvable { &'a mut self, req: &'a Req, ctx: ApprovalCtx<'a>, - ) -> BoxFuture<'a, ReviewDecision>; + ) -> ApprovalFuture<'a, ReviewDecision>; } pub(crate) trait Sandboxable { diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d0614fc35b..c2f89adabc 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2,6 +2,7 @@ use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; +use crate::mcp_types::Tool; use crate::original_image_detail::can_request_original_image_detail; use crate::shell::Shell; use crate::shell::ShellType; @@ -11,11 +12,15 @@ use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT; use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME; use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME; +#[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::agent_jobs::BatchJobHandler; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; use crate::tools::handlers::apply_patch::create_apply_patch_json_tool; +#[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; +#[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; +#[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; use crate::tools::handlers::request_permissions_tool_description; use crate::tools::handlers::request_user_input_tool_description; @@ -78,6 +83,7 @@ use codex_tools::create_wait_agent_tool_v2; use codex_tools::create_wait_tool; use codex_tools::create_write_stdin_tool; use codex_tools::dynamic_tool_to_responses_api_tool; +#[cfg(not(target_arch = "wasm32"))] use codex_tools::mcp_tool_to_responses_api_tool; use codex_tools::tool_spec_to_code_mode_tool_definition; use codex_utils_absolute_path::AbsolutePathBuf; @@ -386,7 +392,7 @@ fn push_tool_spec( #[cfg(test)] pub(crate) fn build_specs( config: &ToolsConfig, - mcp_tools: Option>, + mcp_tools: Option>, app_tools: Option>, dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { @@ -401,7 +407,7 @@ pub(crate) fn build_specs( pub(crate) fn build_specs_with_discoverable_tools( config: &ToolsConfig, - mcp_tools: Option>, + mcp_tools: Option>, app_tools: Option>, discoverable_tools: Option>, dynamic_tools: &[DynamicToolSpec], @@ -410,8 +416,11 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::JsReplHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::JsReplResetHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; @@ -421,20 +430,33 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::ShellCommandHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::TestSyncHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::ToolSearchHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::ToolSuggestHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::handlers::ViewImageHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents::CloseAgentHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents::ResumeAgentHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents::SendInputHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents::SpawnAgentHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents::WaitAgentHandler; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_v2::AssignTaskHandler as AssignTaskHandlerV2; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; + #[cfg(not(target_arch = "wasm32"))] use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; use std::sync::Arc; @@ -453,10 +475,13 @@ pub(crate) fn build_specs_with_discoverable_tools( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); + #[cfg(not(target_arch = "wasm32"))] let tool_suggest_handler = Arc::new(ToolSuggestHandler); let code_mode_handler = Arc::new(CodeModeExecuteHandler); let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); + #[cfg(not(target_arch = "wasm32"))] let js_repl_handler = Arc::new(JsReplHandler); + #[cfg(not(target_arch = "wasm32"))] let js_repl_reset_handler = Arc::new(JsReplResetHandler); let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled; @@ -587,6 +612,7 @@ pub(crate) fn build_specs_with_discoverable_tools( ); builder.register_handler("update_plan", plan_handler); + #[cfg(not(target_arch = "wasm32"))] if config.js_repl_enabled { push_tool_spec( &mut builder, @@ -626,6 +652,7 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("request_permissions", request_permissions_handler); } + #[cfg(not(target_arch = "wasm32"))] if config.search_tool && let Some(app_tools) = app_tools { @@ -649,6 +676,7 @@ pub(crate) fn build_specs_with_discoverable_tools( } } + #[cfg(not(target_arch = "wasm32"))] if config.tool_suggest && let Some(discoverable_tools) = discoverable_tools .as_ref() @@ -683,6 +711,7 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } + #[cfg(not(target_arch = "wasm32"))] if config .experimental_supported_tools .iter() @@ -773,6 +802,7 @@ pub(crate) fn build_specs_with_discoverable_tools( ); builder.register_handler("view_image", view_image_handler); + #[cfg(not(target_arch = "wasm32"))] if config.collab_tools { if config.multi_agent_v2 { push_tool_spec( @@ -874,6 +904,7 @@ pub(crate) fn build_specs_with_discoverable_tools( } } + #[cfg(not(target_arch = "wasm32"))] if config.agent_jobs_tools { let agent_jobs_handler = Arc::new(BatchJobHandler); push_tool_spec( @@ -894,8 +925,9 @@ pub(crate) fn build_specs_with_discoverable_tools( } } + #[cfg(not(target_arch = "wasm32"))] if let Some(mcp_tools) = mcp_tools { - let mut entries: Vec<(String, rmcp::model::Tool)> = mcp_tools.into_iter().collect(); + let mut entries: Vec<(String, Tool)> = mcp_tools.into_iter().collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); for (name, tool) in entries.into_iter() { diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index c68f16e451..d3eda7b9c8 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -1,5 +1,4 @@ use std::time::Duration; -use std::time::Instant; use codex_otel::metrics::names::TURN_TTFM_DURATION_METRIC; use codex_otel::metrics::names::TURN_TTFT_DURATION_METRIC; @@ -9,6 +8,7 @@ use tokio::sync::Mutex; use crate::ResponseEvent; use crate::codex::TurnContext; +use crate::monotonic_time::Instant; use crate::stream_events_utils::raw_assistant_output_text_from_item; pub(crate) async fn record_turn_ttft_metric(turn_context: &TurnContext, event: &ResponseEvent) { diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 934b6ed30a..9910087f14 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -4,11 +4,11 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; -use std::time::Instant; use super::TurnTimingState; use super::response_item_records_turn_ttft; use crate::ResponseEvent; +use crate::monotonic_time::Instant; #[tokio::test] async fn turn_timing_state_records_ttft_only_once_per_turn() { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index eb25f4ddc8..8bdb62eca6 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -30,8 +30,6 @@ use std::sync::Weak; use codex_network_proxy::NetworkProxy; use codex_protocol::models::PermissionProfile; -use rand::Rng; -use rand::rng; use tokio::sync::Mutex; use crate::codex::Session; @@ -45,6 +43,18 @@ mod process; mod process_manager; mod process_state; +#[cfg(not(target_arch = "wasm32"))] +fn random_hex_nibble() -> u8 { + use rand::Rng; + + rand::rng().random_range(0..16) +} + +#[cfg(target_arch = "wasm32")] +fn random_hex_nibble() -> u8 { + (js_sys::Math::random() * 16.0).floor() as u8 +} + pub(crate) fn set_deterministic_process_ids_for_tests(enabled: bool) { process_manager::set_deterministic_process_ids_for_tests(enabled); } @@ -163,9 +173,8 @@ pub(crate) fn resolve_max_tokens(max_tokens: Option) -> usize { } pub(crate) fn generate_chunk_id() -> String { - let mut rng = rng(); (0..6) - .map(|_| format!("{:x}", rng.random_range(0..16))) + .map(|_| format!("{:x}", random_hex_nibble())) .collect() } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 2d992507bd..6498b8c7df 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1,4 +1,3 @@ -use rand::Rng; use std::cmp::Reverse; use std::collections::HashMap; use std::collections::HashSet; @@ -70,6 +69,18 @@ const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ /// must not be toggled. static FORCE_DETERMINISTIC_PROCESS_IDS: AtomicBool = AtomicBool::new(false); +#[cfg(not(target_arch = "wasm32"))] +fn random_process_id() -> i32 { + use rand::Rng; + + rand::rng().random_range(1_000..100_000) +} + +#[cfg(target_arch = "wasm32")] +fn random_process_id() -> i32 { + 1_000 + (js_sys::Math::random() * 99_000.0).floor() as i32 +} + pub(super) fn set_deterministic_process_ids_for_tests(enabled: bool) { FORCE_DETERMINISTIC_PROCESS_IDS.store(enabled, Ordering::Relaxed); } @@ -123,7 +134,7 @@ impl UnifiedExecProcessManager { .unwrap_or(1000) } else { // production mode → random - rand::rng().random_range(1_000..100_000) + random_process_id() }; if store.reserved_process_ids.contains(&process_id) { diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 04d973c86b..1a6712beab 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use std::time::Duration; use codex_protocol::ThreadId; -use rand::Rng; use tracing::error; use crate::auth_env_telemetry::AuthEnvTelemetry; @@ -12,6 +11,18 @@ use crate::parse_command::shlex_join; const INITIAL_DELAY_MS: u64 = 200; const BACKOFF_FACTOR: f64 = 2.0; +#[cfg(not(target_arch = "wasm32"))] +fn random_f64_in_range(start: f64, end: f64) -> f64 { + use rand::Rng; + + rand::rng().random_range(start..end) +} + +#[cfg(target_arch = "wasm32")] +fn random_f64_in_range(start: f64, end: f64) -> f64 { + start + (end - start) * js_sys::Math::random() +} + /// Emit structured feedback metadata as key/value pairs. /// /// This logs a tracing event with `target: "feedback_tags"`. If @@ -204,7 +215,7 @@ pub(crate) fn emit_feedback_auth_recovery_tags( pub fn backoff(attempt: u64) -> Duration { let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32); let base = (INITIAL_DELAY_MS as f64 * exp) as u64; - let jitter = rand::rng().random_range(0.9..1.1); + let jitter = random_f64_in_range(0.9, 1.1); Duration::from_millis((base as f64 * jitter) as u64) } diff --git a/codex-rs/exec-server/src/wasm.rs b/codex-rs/exec-server/src/wasm.rs index 85444a7e2f..e679a33f10 100644 --- a/codex-rs/exec-server/src/wasm.rs +++ b/codex-rs/exec-server/src/wasm.rs @@ -228,6 +228,14 @@ pub struct Environment { filesystem: Arc, } +impl std::fmt::Debug for Environment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Environment") + .field("exec_server_url", &self.exec_server_url) + .finish_non_exhaustive() + } +} + impl Default for Environment { fn default() -> Self { Self { diff --git a/codex-rs/git-utils/src/info_wasm.rs b/codex-rs/git-utils/src/info_wasm.rs new file mode 100644 index 0000000000..c7028b6174 --- /dev/null +++ b/codex-rs/git-utils/src/info_wasm.rs @@ -0,0 +1,81 @@ +use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use crate::GitSha; + +#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] +pub struct GitInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_url: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GitDiffToRemote { + pub sha: GitSha, + pub diff: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommitLogEntry { + pub sha: String, + pub timestamp: i64, + pub subject: String, +} + +pub async fn collect_git_info(_cwd: &Path) -> Option { + None +} + +pub async fn current_branch_name(_cwd: &Path) -> Option { + None +} + +pub async fn default_branch_name(_cwd: &Path) -> Option { + None +} + +pub async fn get_git_remote_urls(_cwd: &Path) -> Option> { + None +} + +pub async fn get_git_remote_urls_assume_git_repo(_cwd: &Path) -> Option> { + None +} + +pub fn get_git_repo_root(_base_dir: &Path) -> Option { + None +} + +pub async fn get_has_changes(_cwd: &Path) -> Option { + None +} + +pub async fn get_head_commit_hash(_cwd: &Path) -> Option { + None +} + +pub async fn git_diff_to_remote(_cwd: &Path) -> Option { + None +} + +pub async fn local_git_branches(_cwd: &Path) -> Vec { + Vec::new() +} + +pub async fn recent_commits(_cwd: &Path, _limit: usize) -> Vec { + Vec::new() +} + +pub fn resolve_root_git_project_for_trust(_cwd: &Path) -> Option { + None +} diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index af0aea15fb..d74fc82dd6 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -7,6 +7,8 @@ mod errors; mod ghost_commits; #[cfg(not(target_arch = "wasm32"))] mod info; +#[cfg(target_arch = "wasm32")] +mod info_wasm; mod operations; mod platform; @@ -60,6 +62,36 @@ pub use info::local_git_branches; pub use info::recent_commits; #[cfg(not(target_arch = "wasm32"))] pub use info::resolve_root_git_project_for_trust; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::CommitLogEntry; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::GitDiffToRemote; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::GitInfo; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::collect_git_info; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::current_branch_name; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::default_branch_name; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::get_git_remote_urls; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::get_git_remote_urls_assume_git_repo; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::get_git_repo_root; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::get_has_changes; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::get_head_commit_hash; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::git_diff_to_remote; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::local_git_branches; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::recent_commits; +#[cfg(target_arch = "wasm32")] +pub use info_wasm::resolve_root_git_project_for_trust; pub use platform::create_symlink; use schemars::JsonSchema; use serde::Deserialize; diff --git a/codex-rs/hooks/Cargo.toml b/codex-rs/hooks/Cargo.toml index aeb91e7fe0..0034e65356 100644 --- a/codex-rs/hooks/Cargo.toml +++ b/codex-rs/hooks/Cargo.toml @@ -22,6 +22,8 @@ regex = { workspace = true } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { workspace = true, features = ["io-util", "process", "time"] } [dev-dependencies] diff --git a/codex-rs/hooks/src/events_wasm/mod.rs b/codex-rs/hooks/src/events_wasm/mod.rs new file mode 100644 index 0000000000..003c005861 --- /dev/null +++ b/codex-rs/hooks/src/events_wasm/mod.rs @@ -0,0 +1,153 @@ +pub mod post_tool_use { + use std::path::PathBuf; + + use codex_protocol::ThreadId; + use codex_protocol::protocol::HookCompletedEvent; + use serde_json::Value; + + #[derive(Debug, Clone)] + pub struct PostToolUseRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub tool_use_id: String, + pub command: String, + pub tool_response: Value, + } + + #[derive(Debug)] + pub struct PostToolUseOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, + pub additional_contexts: Vec, + pub feedback_message: Option, + } +} + +pub mod pre_tool_use { + use std::path::PathBuf; + + use codex_protocol::ThreadId; + use codex_protocol::protocol::HookCompletedEvent; + + #[derive(Debug, Clone)] + pub struct PreToolUseRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub tool_name: String, + pub tool_use_id: String, + pub command: String, + } + + #[derive(Debug)] + pub struct PreToolUseOutcome { + pub hook_events: Vec, + pub should_block: bool, + pub block_reason: Option, + } +} + +pub mod session_start { + use std::path::PathBuf; + + use codex_protocol::ThreadId; + use codex_protocol::protocol::HookCompletedEvent; + + #[derive(Debug, Clone, Copy)] + pub enum SessionStartSource { + Startup, + Resume, + } + + impl SessionStartSource { + pub fn as_str(self) -> &'static str { + match self { + Self::Startup => "startup", + Self::Resume => "resume", + } + } + } + + #[derive(Debug, Clone)] + pub struct SessionStartRequest { + pub session_id: ThreadId, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub source: SessionStartSource, + } + + #[derive(Debug)] + pub struct SessionStartOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, + pub additional_contexts: Vec, + } +} + +pub mod stop { + use std::path::PathBuf; + + use codex_protocol::ThreadId; + use codex_protocol::items::HookPromptFragment; + use codex_protocol::protocol::HookCompletedEvent; + + #[derive(Debug, Clone)] + pub struct StopRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub stop_hook_active: bool, + pub last_assistant_message: Option, + } + + #[derive(Debug)] + pub struct StopOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, + pub should_block: bool, + pub block_reason: Option, + pub continuation_fragments: Vec, + } +} + +pub mod user_prompt_submit { + use std::path::PathBuf; + + use codex_protocol::ThreadId; + use codex_protocol::protocol::HookCompletedEvent; + + #[derive(Debug, Clone)] + pub struct UserPromptSubmitRequest { + pub session_id: ThreadId, + pub turn_id: String, + pub cwd: PathBuf, + pub transcript_path: Option, + pub model: String, + pub permission_mode: String, + pub prompt: String, + } + + #[derive(Debug)] + pub struct UserPromptSubmitOutcome { + pub hook_events: Vec, + pub should_stop: bool, + pub stop_reason: Option, + pub additional_contexts: Vec, + } +} diff --git a/codex-rs/hooks/src/legacy_notify_wasm.rs b/codex-rs/hooks/src/legacy_notify_wasm.rs new file mode 100644 index 0000000000..581a560d5c --- /dev/null +++ b/codex-rs/hooks/src/legacy_notify_wasm.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use serde::Serialize; + +use crate::Hook; +use crate::HookEvent; +use crate::HookPayload; +use crate::HookResult; + +/// Legacy notify payload appended as the final argv argument for backward compatibility. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +enum UserNotification { + #[serde(rename_all = "kebab-case")] + AgentTurnComplete { + thread_id: String, + turn_id: String, + cwd: String, + #[serde(skip_serializing_if = "Option::is_none")] + client: Option, + input_messages: Vec, + last_assistant_message: Option, + }, +} + +pub fn legacy_notify_json(payload: &HookPayload) -> Result { + match &payload.hook_event { + HookEvent::AfterAgent { event } => { + serde_json::to_string(&UserNotification::AgentTurnComplete { + thread_id: event.thread_id.to_string(), + turn_id: event.turn_id.clone(), + cwd: payload.cwd.display().to_string(), + client: payload.client.clone(), + input_messages: event.input_messages.clone(), + last_assistant_message: event.last_assistant_message.clone(), + }) + } + HookEvent::AfterToolUse { .. } => Err(serde_json::Error::io(std::io::Error::other( + "legacy notify payload is only supported for after_agent", + ))), + } +} + +pub fn notify_hook(_argv: Vec) -> Hook { + Hook { + name: "legacy_notify".to_string(), + func: Arc::new(|_| Box::pin(async { HookResult::Success })), + } +} diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 16334a6476..d58ed9e8f4 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -1,6 +1,10 @@ +#[cfg(not(target_arch = "wasm32"))] mod engine; +#[cfg_attr(target_arch = "wasm32", path = "events_wasm/mod.rs")] pub mod events; +#[cfg_attr(target_arch = "wasm32", path = "legacy_notify_wasm.rs")] mod legacy_notify; +#[cfg_attr(target_arch = "wasm32", path = "registry_wasm.rs")] mod registry; mod schema; mod types; diff --git a/codex-rs/hooks/src/registry_wasm.rs b/codex-rs/hooks/src/registry_wasm.rs new file mode 100644 index 0000000000..d918a4bb20 --- /dev/null +++ b/codex-rs/hooks/src/registry_wasm.rs @@ -0,0 +1,170 @@ +use codex_config::ConfigLayerStack; + +use crate::events::post_tool_use::PostToolUseOutcome; +use crate::events::post_tool_use::PostToolUseRequest; +use crate::events::pre_tool_use::PreToolUseOutcome; +use crate::events::pre_tool_use::PreToolUseRequest; +use crate::events::session_start::SessionStartOutcome; +use crate::events::session_start::SessionStartRequest; +use crate::events::stop::StopOutcome; +use crate::events::stop::StopRequest; +use crate::events::user_prompt_submit::UserPromptSubmitOutcome; +use crate::events::user_prompt_submit::UserPromptSubmitRequest; +use crate::types::Hook; +use crate::types::HookEvent; +use crate::types::HookPayload; +use crate::types::HookResponse; + +#[derive(Default, Clone)] +pub struct HooksConfig { + pub legacy_notify_argv: Option>, + pub feature_enabled: bool, + pub config_layer_stack: Option, + pub shell_program: Option, + pub shell_args: Vec, +} + +#[derive(Clone, Default)] +pub struct Hooks { + after_agent: Vec, + after_tool_use: Vec, +} + +impl Hooks { + pub fn new(config: HooksConfig) -> Self { + let after_agent = config + .legacy_notify_argv + .filter(|argv| !argv.is_empty() && !argv[0].is_empty()) + .map(crate::notify_hook) + .into_iter() + .collect(); + Self { + after_agent, + after_tool_use: Vec::new(), + } + } + + pub fn startup_warnings(&self) -> &[String] { + &[] + } + + fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] { + match hook_event { + HookEvent::AfterAgent { .. } => &self.after_agent, + HookEvent::AfterToolUse { .. } => &self.after_tool_use, + } + } + + pub async fn dispatch(&self, hook_payload: HookPayload) -> Vec { + let hooks = self.hooks_for_event(&hook_payload.hook_event); + let mut outcomes = Vec::with_capacity(hooks.len()); + for hook in hooks { + let outcome = hook.execute(&hook_payload).await; + let should_abort_operation = outcome.result.should_abort_operation(); + outcomes.push(outcome); + if should_abort_operation { + break; + } + } + outcomes + } + + pub fn preview_session_start( + &self, + _request: &SessionStartRequest, + ) -> Vec { + Vec::new() + } + + pub fn preview_pre_tool_use( + &self, + _request: &PreToolUseRequest, + ) -> Vec { + Vec::new() + } + + pub fn preview_post_tool_use( + &self, + _request: &PostToolUseRequest, + ) -> Vec { + Vec::new() + } + + pub async fn run_session_start( + &self, + _request: SessionStartRequest, + _turn_id: Option, + ) -> SessionStartOutcome { + SessionStartOutcome { + hook_events: Vec::new(), + should_stop: false, + stop_reason: None, + additional_contexts: Vec::new(), + } + } + + pub async fn run_pre_tool_use(&self, _request: PreToolUseRequest) -> PreToolUseOutcome { + PreToolUseOutcome { + hook_events: Vec::new(), + should_block: false, + block_reason: None, + } + } + + pub async fn run_post_tool_use(&self, _request: PostToolUseRequest) -> PostToolUseOutcome { + PostToolUseOutcome { + hook_events: Vec::new(), + should_stop: false, + stop_reason: None, + additional_contexts: Vec::new(), + feedback_message: None, + } + } + + pub fn preview_user_prompt_submit( + &self, + _request: &UserPromptSubmitRequest, + ) -> Vec { + Vec::new() + } + + pub async fn run_user_prompt_submit( + &self, + _request: UserPromptSubmitRequest, + ) -> UserPromptSubmitOutcome { + UserPromptSubmitOutcome { + hook_events: Vec::new(), + should_stop: false, + stop_reason: None, + additional_contexts: Vec::new(), + } + } + + pub fn preview_stop( + &self, + _request: &StopRequest, + ) -> Vec { + Vec::new() + } + + pub async fn run_stop(&self, _request: StopRequest) -> StopOutcome { + StopOutcome { + hook_events: Vec::new(), + should_stop: false, + stop_reason: None, + should_block: false, + block_reason: None, + continuation_fragments: Vec::new(), + } + } +} + +pub fn command_from_argv(argv: &[String]) -> Option { + let (program, args) = argv.split_first()?; + if program.is_empty() { + return None; + } + let mut command = std::process::Command::new(program); + command.args(args); + Some(command) +} diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 92ed39a6ce..d93a5af007 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -14,19 +14,23 @@ chrono = { workspace = true, features = ["serde"] } codex-app-server-protocol = { workspace = true } codex-client = { workspace = true } codex-config = { workspace = true } -codex-keyring-store = { workspace = true } codex-protocol = { workspace = true } -codex-terminal-detection = { workspace = true } codex-utils-template = { workspace = true } once_cell = { workspace = true } -os_info = { workspace = true } -rand = { workspace = true } -reqwest = { workspace = true, features = ["json", "blocking"] } +reqwest = { workspace = true, features = ["json"] } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +codex-keyring-store = { workspace = true } +codex-terminal-detection = { workspace = true } +os_info = { workspace = true } +rand = { workspace = true } +reqwest = { workspace = true, features = ["blocking"] } +sha2 = { workspace = true } tiny_http = { workspace = true } tokio = { workspace = true, features = [ "io-std", @@ -35,7 +39,6 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -tracing = { workspace = true } url = { workspace = true } urlencoding = { workspace = true } webbrowser = { workspace = true } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index a8033cf888..dd7f813624 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,42 +1,87 @@ +#[cfg(not(target_arch = "wasm32"))] pub mod auth; +#[cfg(not(target_arch = "wasm32"))] pub mod token_data; +#[cfg(not(target_arch = "wasm32"))] mod device_code_auth; +#[cfg(not(target_arch = "wasm32"))] mod pkce; +#[cfg(not(target_arch = "wasm32"))] mod server; +#[cfg(target_arch = "wasm32")] +mod wasm; + +#[cfg(not(target_arch = "wasm32"))] pub use codex_client::BuildCustomCaTransportError as BuildLoginHttpClientError; +#[cfg(not(target_arch = "wasm32"))] pub use device_code_auth::DeviceCode; +#[cfg(not(target_arch = "wasm32"))] pub use device_code_auth::complete_device_code_login; +#[cfg(not(target_arch = "wasm32"))] pub use device_code_auth::request_device_code; +#[cfg(not(target_arch = "wasm32"))] pub use device_code_auth::run_device_code_login; +#[cfg(not(target_arch = "wasm32"))] pub use server::LoginServer; +#[cfg(not(target_arch = "wasm32"))] pub use server::ServerOptions; +#[cfg(not(target_arch = "wasm32"))] pub use server::ShutdownHandle; +#[cfg(not(target_arch = "wasm32"))] pub use server::run_login_server; +#[cfg(not(target_arch = "wasm32"))] pub use auth::AuthConfig; +#[cfg(not(target_arch = "wasm32"))] pub use auth::AuthCredentialsStoreMode; +#[cfg(not(target_arch = "wasm32"))] pub use auth::AuthDotJson; +#[cfg(not(target_arch = "wasm32"))] pub use auth::AuthManager; +#[cfg(not(target_arch = "wasm32"))] pub use auth::CLIENT_ID; +#[cfg(not(target_arch = "wasm32"))] pub use auth::CODEX_API_KEY_ENV_VAR; +#[cfg(not(target_arch = "wasm32"))] pub use auth::CodexAuth; +#[cfg(not(target_arch = "wasm32"))] pub use auth::ExternalAuthChatgptMetadata; +#[cfg(not(target_arch = "wasm32"))] pub use auth::ExternalAuthRefreshContext; +#[cfg(not(target_arch = "wasm32"))] pub use auth::ExternalAuthRefreshReason; +#[cfg(not(target_arch = "wasm32"))] pub use auth::ExternalAuthRefresher; +#[cfg(not(target_arch = "wasm32"))] pub use auth::ExternalAuthTokens; +#[cfg(not(target_arch = "wasm32"))] pub use auth::OPENAI_API_KEY_ENV_VAR; +#[cfg(not(target_arch = "wasm32"))] pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +#[cfg(not(target_arch = "wasm32"))] pub use auth::RefreshTokenError; +#[cfg(not(target_arch = "wasm32"))] pub use auth::UnauthorizedRecovery; +#[cfg(not(target_arch = "wasm32"))] pub use auth::default_client; +#[cfg(not(target_arch = "wasm32"))] pub use auth::enforce_login_restrictions; +#[cfg(not(target_arch = "wasm32"))] pub use auth::load_auth_dot_json; +#[cfg(not(target_arch = "wasm32"))] pub use auth::login_with_api_key; +#[cfg(not(target_arch = "wasm32"))] pub use auth::logout; +#[cfg(not(target_arch = "wasm32"))] pub use auth::read_openai_api_key_from_env; +#[cfg(not(target_arch = "wasm32"))] pub use auth::save_auth; +#[cfg(not(target_arch = "wasm32"))] pub use codex_app_server_protocol::AuthMode; +#[cfg(not(target_arch = "wasm32"))] pub use token_data::TokenData; + +#[cfg(target_arch = "wasm32")] +pub use wasm::*; diff --git a/codex-rs/login/src/wasm.rs b/codex-rs/login/src/wasm.rs new file mode 100644 index 0000000000..762a5d660d --- /dev/null +++ b/codex-rs/login/src/wasm.rs @@ -0,0 +1,624 @@ +use async_trait::async_trait; +use chrono::DateTime; +use chrono::Utc; +pub use codex_app_server_protocol::AuthMode; +use codex_client::BuildCustomCaTransportError; +use codex_client::CodexHttpClient; +use codex_protocol::config_types::ModelProviderAuthInfo; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::RwLock; +use thiserror::Error; + +pub mod token_data { + use serde::Deserialize; + use serde::Serialize; + + #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] + pub struct TokenData { + pub id_token: IdTokenInfo, + pub access_token: String, + pub refresh_token: String, + pub account_id: Option, + } + + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] + pub struct IdTokenInfo { + pub email: Option, + pub chatgpt_plan_type: Option, + pub chatgpt_user_id: Option, + pub chatgpt_account_id: Option, + pub raw_jwt: String, + } + + impl IdTokenInfo { + pub fn is_workspace_account(&self) -> bool { + matches!( + self.chatgpt_plan_type, + Some(PlanType::Known(plan)) if plan.is_workspace_account() + ) + } + } + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] + #[serde(untagged)] + pub enum PlanType { + Known(KnownPlan), + Unknown(String), + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[serde(rename_all = "lowercase")] + pub enum KnownPlan { + Free, + Go, + Plus, + Pro, + Team, + #[serde(rename = "self_serve_business_usage_based")] + SelfServeBusinessUsageBased, + Business, + #[serde(rename = "enterprise_cbp_usage_based")] + EnterpriseCbpUsageBased, + #[serde(alias = "hc")] + Enterprise, + Edu, + } + + impl KnownPlan { + pub fn is_workspace_account(self) -> bool { + matches!( + self, + Self::Team + | Self::SelfServeBusinessUsageBased + | Self::Business + | Self::EnterpriseCbpUsageBased + | Self::Enterprise + | Self::Edu + ) + } + } +} + +pub mod auth { + use super::*; + + pub const CLIENT_ID: &str = "codex-wasm"; + pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; + pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; + + #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] + #[serde(rename_all = "lowercase")] + pub enum AuthCredentialsStoreMode { + #[default] + File, + Keyring, + Auto, + Ephemeral, + } + + #[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Default)] + pub struct AuthDotJson { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, + } + + #[derive(Debug, Clone, PartialEq, Eq, Error)] + #[error("{message}")] + pub struct RefreshTokenFailedError { + pub reason: RefreshTokenFailedReason, + pub message: String, + } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum RefreshTokenFailedReason { + Expired, + Exhausted, + Revoked, + Other, + } + + #[derive(Debug, Error)] + pub enum RefreshTokenError { + #[error("{0}")] + Permanent(#[from] RefreshTokenFailedError), + #[error(transparent)] + Transient(#[from] std::io::Error), + } + + #[derive(Debug, Clone)] + pub enum CodexAuth { + ApiKey(ApiKeyAuth), + Chatgpt(ChatgptAuth), + ChatgptAuthTokens(ChatgptAuthTokens), + } + + #[derive(Debug, Clone)] + pub struct ApiKeyAuth { + api_key: String, + } + + #[derive(Debug, Clone, Default)] + pub struct ChatgptAuth { + pub token_data: Option, + } + + #[derive(Debug, Clone, Default)] + pub struct ChatgptAuthTokens { + pub token_data: Option, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct ExternalAuthTokens { + pub access_token: String, + pub chatgpt_metadata: Option, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct ExternalAuthChatgptMetadata { + pub account_id: String, + pub plan_type: Option, + } + + impl ExternalAuthTokens { + pub fn access_token_only(access_token: impl Into) -> Self { + Self { + access_token: access_token.into(), + chatgpt_metadata: None, + } + } + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum ExternalAuthRefreshReason { + Unauthorized, + } + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct ExternalAuthRefreshContext { + pub reason: ExternalAuthRefreshReason, + pub previous_account_id: Option, + } + + #[async_trait] + pub trait ExternalAuthRefresher: Send + Sync { + async fn resolve(&self) -> std::io::Result> { + Ok(None) + } + + async fn refresh( + &self, + _context: ExternalAuthRefreshContext, + ) -> std::io::Result; + } + + #[derive(Debug)] + pub struct UnauthorizedRecovery { + done: bool, + } + + #[derive(Debug, Clone, Copy)] + pub struct UnauthorizedRecoveryStepResult { + auth_state_changed: Option, + } + + impl UnauthorizedRecoveryStepResult { + pub fn auth_state_changed(&self) -> Option { + self.auth_state_changed + } + } + + impl UnauthorizedRecovery { + pub fn has_next(&self) -> bool { + !self.done + } + + pub fn mode_name(&self) -> &'static str { + "managed" + } + + pub fn step_name(&self) -> &'static str { + "done" + } + + pub fn unavailable_reason(&self) -> &'static str { + if self.done { + "recovery_exhausted" + } else { + "ready" + } + } + + pub async fn next(&mut self) -> Result { + self.done = true; + Ok(UnauthorizedRecoveryStepResult { + auth_state_changed: Some(false), + }) + } + } + + #[derive(Debug, Clone, Default)] + pub struct AuthConfig; + + #[derive(Debug)] + pub struct AuthManager { + auth: RwLock>, + enable_codex_api_key_env: bool, + } + + impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.api_auth_mode() == other.api_auth_mode() + } + } + + impl CodexAuth { + pub fn from_api_key(api_key: &str) -> Self { + Self::ApiKey(ApiKeyAuth { + api_key: api_key.to_string(), + }) + } + + pub fn create_dummy_chatgpt_auth_for_testing() -> Self { + Self::Chatgpt(ChatgptAuth::default()) + } + + pub fn auth_mode(&self) -> AuthMode { + match self { + Self::ApiKey(_) => AuthMode::ApiKey, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, + } + } + + pub fn api_auth_mode(&self) -> AuthMode { + match self { + Self::ApiKey(_) => AuthMode::ApiKey, + Self::Chatgpt(_) => AuthMode::Chatgpt, + Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens, + } + } + + pub fn is_api_key_auth(&self) -> bool { + matches!(self, Self::ApiKey(_)) + } + + pub fn is_chatgpt_auth(&self) -> bool { + matches!(self, Self::Chatgpt(_)) + } + + pub fn is_external_chatgpt_tokens(&self) -> bool { + matches!(self, Self::ChatgptAuthTokens(_)) + } + + pub fn api_key(&self) -> Option<&str> { + match self { + Self::ApiKey(auth) => Some(auth.api_key.as_str()), + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => None, + } + } + + pub fn get_token_data(&self) -> Result { + match self { + Self::ApiKey(_) => Err(std::io::Error::other("Token data is not available.")), + Self::Chatgpt(auth) => auth + .token_data + .clone() + .ok_or_else(|| std::io::Error::other("Token data is not available.")), + Self::ChatgptAuthTokens(auth) => auth + .token_data + .clone() + .ok_or_else(|| std::io::Error::other("Token data is not available.")), + } + } + + pub fn get_token(&self) -> Result { + match self { + Self::ApiKey(auth) => Ok(auth.api_key.clone()), + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => { + Ok(self.get_token_data()?.access_token) + } + } + } + + pub fn get_account_id(&self) -> Option { + self.get_current_token_data() + .and_then(|token| token.account_id) + } + + pub fn get_account_email(&self) -> Option { + self.get_current_token_data() + .and_then(|token| token.id_token.email) + } + + pub fn get_current_auth_json(&self) -> Option { + match self { + Self::ApiKey(auth) => Some(AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some(auth.api_key.clone()), + tokens: None, + last_refresh: None, + }), + Self::Chatgpt(auth) => auth.token_data.clone().map(|tokens| AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(tokens), + last_refresh: None, + }), + Self::ChatgptAuthTokens(auth) => { + auth.token_data.clone().map(|tokens| AuthDotJson { + auth_mode: Some(AuthMode::ChatgptAuthTokens), + openai_api_key: None, + tokens: Some(tokens), + last_refresh: None, + }) + } + } + } + + fn get_current_token_data(&self) -> Option { + match self { + Self::ApiKey(_) => None, + Self::Chatgpt(auth) => auth.token_data.clone(), + Self::ChatgptAuthTokens(auth) => auth.token_data.clone(), + } + } + } + + impl AuthManager { + pub fn new( + _codex_home: PathBuf, + enable_codex_api_key_env: bool, + _auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> Self { + let auth = + read_openai_api_key_from_env().map(|api_key| CodexAuth::from_api_key(&api_key)); + Self { + auth: RwLock::new(auth), + enable_codex_api_key_env, + } + } + + pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { + Arc::new(Self { + auth: RwLock::new(Some(auth)), + enable_codex_api_key_env: false, + }) + } + + pub fn from_auth_for_testing_with_home(auth: CodexAuth, _codex_home: PathBuf) -> Arc { + Self::from_auth_for_testing(auth) + } + + pub fn external_bearer_only(_config: ModelProviderAuthInfo) -> Arc { + Arc::new(Self { + auth: RwLock::new(None), + enable_codex_api_key_env: false, + }) + } + + pub fn auth_cached(&self) -> Option { + self.auth.read().ok().and_then(|auth| auth.clone()) + } + + pub async fn auth(&self) -> Option { + self.auth_cached() + } + + pub fn auth_mode(&self) -> Option { + self.auth_cached().map(|auth| auth.auth_mode()) + } + + pub fn codex_api_key_env_enabled(&self) -> bool { + self.enable_codex_api_key_env + } + + pub fn shared( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> Arc { + Arc::new(Self::new( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + )) + } + + pub fn unauthorized_recovery(self: &Arc) -> UnauthorizedRecovery { + UnauthorizedRecovery { done: false } + } + } + + pub mod default_client { + use super::*; + use reqwest::header::HeaderMap; + use reqwest::header::HeaderValue; + + pub const DEFAULT_ORIGINATOR: &str = "codex_wasm"; + pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = + "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; + pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency"; + + pub use codex_config::ResidencyRequirement; + + #[derive(Debug, Clone)] + pub struct Originator { + pub value: String, + pub header_value: HeaderValue, + } + + pub fn originator() -> Originator { + Originator { + value: DEFAULT_ORIGINATOR.to_string(), + header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR), + } + } + + pub fn is_first_party_originator(originator_value: &str) -> bool { + originator_value == DEFAULT_ORIGINATOR + } + + pub fn is_first_party_chat_originator(originator_value: &str) -> bool { + originator_value == "codex_chatgpt_desktop" + } + + pub fn set_default_originator(_value: String) -> Result<(), super::SetOriginatorError> { + Ok(()) + } + + pub fn set_default_client_residency_requirement( + _enforce_residency: Option, + ) { + } + + pub fn get_codex_user_agent() -> String { + format!("{DEFAULT_ORIGINATOR}/{}", env!("CARGO_PKG_VERSION")) + } + + pub fn create_client() -> CodexHttpClient { + CodexHttpClient::new(build_reqwest_client()) + } + + pub fn build_reqwest_client() -> reqwest::Client { + try_build_reqwest_client().unwrap_or_else(|_| reqwest::Client::new()) + } + + pub fn try_build_reqwest_client() -> Result { + let headers = default_headers(); + codex_client::build_reqwest_client_with_custom_ca( + reqwest::Client::builder() + .user_agent(get_codex_user_agent()) + .default_headers(headers), + ) + } + + pub fn default_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("originator", originator().header_value); + headers + } + + pub use codex_client::CodexRequestBuilder; + } + + #[derive(Debug)] + pub enum SetOriginatorError { + InvalidHeaderValue, + AlreadyInitialized, + } + + pub fn read_openai_api_key_from_env() -> Option { + std::env::var(OPENAI_API_KEY_ENV_VAR).ok() + } + + pub fn load_auth_dot_json(_codex_home: &Path) -> std::io::Result> { + Ok(None) + } + + pub fn save_auth(_codex_home: &Path, _auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + Ok(()) + } + + pub fn logout( + _codex_home: &Path, + _auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> std::io::Result<()> { + Ok(()) + } + + pub async fn login_with_api_key( + codex_home: PathBuf, + api_key: String, + _auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> std::io::Result { + let auth = AuthDotJson { + auth_mode: Some(AuthMode::ApiKey), + openai_api_key: Some(api_key), + tokens: None, + last_refresh: None, + }; + save_auth(&codex_home, &auth)?; + Ok(auth) + } + + pub fn enforce_login_restrictions( + _forced_login_method: Option, + _auth_mode: Option, + ) -> std::io::Result<()> { + Ok(()) + } +} + +pub use auth::AuthConfig; +pub use auth::AuthCredentialsStoreMode; +pub use auth::AuthDotJson; +pub use auth::AuthManager; +pub use auth::CLIENT_ID; +pub use auth::CODEX_API_KEY_ENV_VAR; +pub use auth::CodexAuth; +pub use auth::ExternalAuthChatgptMetadata; +pub use auth::ExternalAuthRefreshContext; +pub use auth::ExternalAuthRefreshReason; +pub use auth::ExternalAuthRefresher; +pub use auth::ExternalAuthTokens; +pub use auth::OPENAI_API_KEY_ENV_VAR; +pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +pub use auth::RefreshTokenError; +pub use auth::UnauthorizedRecovery; +pub use auth::default_client; +pub use auth::enforce_login_restrictions; +pub use auth::load_auth_dot_json; +pub use auth::login_with_api_key; +pub use auth::logout; +pub use auth::read_openai_api_key_from_env; +pub use auth::save_auth; +pub use token_data::TokenData; + +pub type BuildLoginHttpClientError = BuildCustomCaTransportError; + +#[derive(Debug, Clone)] +pub struct DeviceCode; + +#[derive(Debug, Clone)] +pub struct LoginServer; + +#[derive(Debug, Clone)] +pub struct ServerOptions; + +#[derive(Debug, Clone)] +pub struct ShutdownHandle; + +pub async fn complete_device_code_login(_device_code: DeviceCode) -> std::io::Result { + Err(std::io::Error::other( + "device code login is unavailable on wasm32", + )) +} + +pub async fn request_device_code() -> std::io::Result { + Err(std::io::Error::other( + "device code login is unavailable on wasm32", + )) +} + +pub async fn run_device_code_login() -> std::io::Result { + Err(std::io::Error::other( + "device code login is unavailable on wasm32", + )) +} + +pub async fn run_login_server(_options: ServerOptions) -> std::io::Result { + Err(std::io::Error::other( + "login server is unavailable on wasm32", + )) +} diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 274e5bce22..1a42bdfc41 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -20,8 +20,10 @@ libc = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } -which = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } tempfile = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +which = { workspace = true } diff --git a/codex-rs/secrets/Cargo.toml b/codex-rs/secrets/Cargo.toml index 7ca634b251..d12824c7c8 100644 --- a/codex-rs/secrets/Cargo.toml +++ b/codex-rs/secrets/Cargo.toml @@ -8,18 +8,20 @@ license.workspace = true workspace = true [dependencies] -age = { workspace = true } anyhow = { workspace = true } +regex = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +tracing = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +age = { workspace = true } base64 = { workspace = true } codex-git-utils = { workspace = true } codex-keyring-store = { workspace = true } rand = { workspace = true } -regex = { workspace = true } -schemars = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } -tracing = { workspace = true } [dev-dependencies] keyring = { workspace = true } diff --git a/codex-rs/secrets/src/lib.rs b/codex-rs/secrets/src/lib.rs index 280c723d36..a37391aa5c 100644 --- a/codex-rs/secrets/src/lib.rs +++ b/codex-rs/secrets/src/lib.rs @@ -1,230 +1,5 @@ -use std::fmt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; +#[cfg_attr(target_arch = "wasm32", path = "wasm.rs")] +#[cfg_attr(not(target_arch = "wasm32"), path = "native.rs")] +mod imp; -use anyhow::Result; -use codex_git_utils::get_git_repo_root; -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; -mod sanitizer; - -pub use local::LocalSecretsBackend; -pub use sanitizer::redact_secrets; - -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}") -} - -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(/*scope_filter*/ 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(()) - } -} +pub use imp::*; diff --git a/codex-rs/secrets/src/native.rs b/codex-rs/secrets/src/native.rs new file mode 100644 index 0000000000..280c723d36 --- /dev/null +++ b/codex-rs/secrets/src/native.rs @@ -0,0 +1,230 @@ +use std::fmt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Result; +use codex_git_utils::get_git_repo_root; +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; +mod sanitizer; + +pub use local::LocalSecretsBackend; +pub use sanitizer::redact_secrets; + +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}") +} + +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(/*scope_filter*/ 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/wasm.rs b/codex-rs/secrets/src/wasm.rs new file mode 100644 index 0000000000..d810d11707 --- /dev/null +++ b/codex-rs/secrets/src/wasm.rs @@ -0,0 +1,134 @@ +use anyhow::Result; + +pub fn redact_secrets(text: &str) -> String { + text.to_string() +} + +pub fn environment_id_from_cwd(_cwd: &std::path::Path) -> String { + "wasm".to_string() +} + +pub fn keyring_service() -> &'static str { + "codex" +} + +pub fn compute_keyring_account(_codex_home: &std::path::Path) -> String { + "secrets|wasm".to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SecretName(String); + +impl SecretName { + pub fn new(raw: &str) -> Result { + Ok(Self(raw.trim().to_string())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl std::fmt::Display for SecretName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::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 { + Ok(Self::Environment(environment_id.into())) + } + + pub fn canonical_key(&self, name: &SecretName) -> String { + 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, + serde::Serialize, + serde::Deserialize, + schemars::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<()> { + Ok(()) + } + + fn get(&self, _scope: &SecretScope, _name: &SecretName) -> Result> { + Ok(None) + } + + fn delete(&self, _scope: &SecretScope, _name: &SecretName) -> Result { + Ok(false) + } + + fn list(&self, _scope_filter: Option<&SecretScope>) -> Result> { + Ok(Vec::new()) + } +} + +#[derive(Default, Clone)] +pub struct LocalSecretsBackend; + +#[derive(Clone, Default)] +pub struct SecretsManager; + +impl SecretsManager { + pub fn new(_codex_home: std::path::PathBuf, _backend_kind: SecretsBackendKind) -> Self { + Self + } + + pub fn new_with_keyring_store( + _codex_home: std::path::PathBuf, + _backend_kind: SecretsBackendKind, + _keyring_store: std::sync::Arc, + ) -> Self { + Self + } + + pub fn set(&self, _scope: &SecretScope, _name: &SecretName, _value: &str) -> Result<()> { + Ok(()) + } + + pub fn get(&self, _scope: &SecretScope, _name: &SecretName) -> Result> { + Ok(None) + } + + pub fn delete(&self, _scope: &SecretScope, _name: &SecretName) -> Result { + Ok(false) + } + + pub fn list(&self, _scope_filter: Option<&SecretScope>) -> Result> { + Ok(Vec::new()) + } +} diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index 2df9843f02..11abae8c6d 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -16,9 +16,11 @@ regex = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } shlex = { workspace = true } +url = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } -url = { workspace = true } which = { workspace = true } [dev-dependencies] diff --git a/codex-rs/shell-command/src/bash_wasm.rs b/codex-rs/shell-command/src/bash_wasm.rs new file mode 100644 index 0000000000..c703c429ad --- /dev/null +++ b/codex-rs/shell-command/src/bash_wasm.rs @@ -0,0 +1,35 @@ +use std::path::PathBuf; + +use crate::shell_detect::ShellType; +use crate::shell_detect::detect_shell_type; + +pub fn try_parse_shell(_shell_lc_arg: &str) -> Option<()> { + None +} + +pub fn try_parse_word_only_commands_sequence(_tree: &(), _src: &str) -> Option>> { + None +} + +pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> { + let [shell, flag, script] = command else { + return None; + }; + if !matches!(flag.as_str(), "-lc" | "-c") + || !matches!( + detect_shell_type(&PathBuf::from(shell)), + Some(ShellType::Zsh) | Some(ShellType::Bash) | Some(ShellType::Sh) + ) + { + return None; + } + Some((shell, script)) +} + +pub fn parse_shell_lc_plain_commands(_command: &[String]) -> Option>> { + None +} + +pub fn parse_shell_lc_single_command_prefix(_command: &[String]) -> Option> { + None +} diff --git a/codex-rs/shell-command/src/lib.rs b/codex-rs/shell-command/src/lib.rs index 215c30fd7d..481557af71 100644 --- a/codex-rs/shell-command/src/lib.rs +++ b/codex-rs/shell-command/src/lib.rs @@ -2,9 +2,11 @@ mod shell_detect; +#[cfg_attr(target_arch = "wasm32", path = "bash_wasm.rs")] pub mod bash; pub mod command_safety; pub mod parse_command; +#[cfg_attr(target_arch = "wasm32", path = "powershell_wasm.rs")] pub mod powershell; pub use command_safety::is_dangerous_command; diff --git a/codex-rs/shell-command/src/powershell_wasm.rs b/codex-rs/shell-command/src/powershell_wasm.rs new file mode 100644 index 0000000000..1399a0a421 --- /dev/null +++ b/codex-rs/shell-command/src/powershell_wasm.rs @@ -0,0 +1,70 @@ +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::shell_detect::ShellType; +use crate::shell_detect::detect_shell_type; + +const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"]; + +pub const UTF8_OUTPUT_PREFIX: &str = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n"; + +pub fn prefix_powershell_script_with_utf8(command: &[String]) -> Vec { + let Some((_, script)) = extract_powershell_command(command) else { + return command.to_vec(); + }; + + let trimmed = script.trim_start(); + let script = if trimmed.starts_with(UTF8_OUTPUT_PREFIX) { + script.to_string() + } else { + format!("{UTF8_OUTPUT_PREFIX}{script}") + }; + + let mut command: Vec = command[..(command.len() - 1)] + .iter() + .map(std::string::ToString::to_string) + .collect(); + command.push(script); + command +} + +pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { + if command.len() < 3 { + return None; + } + + let shell = &command[0]; + if !matches!( + detect_shell_type(&PathBuf::from(shell)), + Some(ShellType::PowerShell) + ) { + return None; + } + + let mut i = 1usize; + while i + 1 < command.len() { + let flag = &command[i]; + if !POWERSHELL_FLAGS.contains(&flag.to_ascii_lowercase().as_str()) { + return None; + } + if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") { + let script = &command[i + 1]; + return Some((shell, script)); + } + i += 1; + } + None +} + +pub(crate) fn try_find_powershellish_executable_blocking() -> Option { + None +} + +pub fn try_find_powershell_executable_blocking() -> Option { + None +} + +pub fn try_find_pwsh_executable_blocking() -> Option { + None +} diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 32e3c44267..b608586408 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -63,7 +63,7 @@ impl AbsolutePathBuf { #[cfg(not(target_arch = "wasm32"))] let absolute_path = expanded.absolutize()?; #[cfg(target_arch = "wasm32")] - let absolute_path = normalize_absolute_path(if expanded.is_absolute() { + let absolute_path = normalize_absolute_path(if is_effectively_absolute(&expanded) { expanded } else { join_against_base(expanded, &std::env::current_dir()?)? @@ -79,6 +79,11 @@ impl AbsolutePathBuf { /// Construct an absolute path from `path`, resolving relative paths against /// the process current working directory. pub fn relative_to_current_dir>(path: P) -> std::io::Result { + let path = path.as_ref(); + if path.is_absolute() { + return Self::from_absolute_path(path); + } + Self::resolve_path_against_base(path, std::env::current_dir()?) } @@ -119,7 +124,7 @@ impl AbsolutePathBuf { #[cfg(target_arch = "wasm32")] fn join_against_base(path: PathBuf, base_path: &Path) -> std::io::Result { - if path.is_absolute() { + if is_effectively_absolute(&path) { return Ok(path); } if !base_path.is_absolute() { @@ -131,6 +136,19 @@ fn join_against_base(path: PathBuf, base_path: &Path) -> std::io::Result bool { + path.is_absolute() + || path.has_root() + || matches!( + path.components().next(), + Some(Component::RootDir | Component::Prefix(_)) + ) + || path + .to_string_lossy() + .starts_with(std::path::MAIN_SEPARATOR) +} + #[cfg(target_arch = "wasm32")] fn normalize_absolute_path(path: PathBuf) -> std::borrow::Cow<'static, Path> { let mut prefix: Option = None; @@ -304,6 +322,15 @@ mod tests { Ok(()) } + #[test] + fn relative_to_current_dir_keeps_absolute_path() -> std::io::Result<()> { + let absolute_dir = tempdir()?; + let absolute_path = absolute_dir.path().join("file.txt"); + let abs_path_buf = AbsolutePathBuf::relative_to_current_dir(&absolute_path)?; + assert_eq!(abs_path_buf.as_path(), absolute_path.as_path()); + Ok(()) + } + #[test] fn guard_used_in_deserialization() { let temp_dir = tempdir().expect("base dir"); diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index cf98ac4904..1f9b06b74b 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -9,6 +9,9 @@ workspace = true [dependencies] anyhow = { workspace = true } +tokio = { workspace = true, features = ["sync"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] portable-pty = { workspace = true } tokio = { workspace = true, features = ["io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index 87898ddf71..fbadfa8351 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -1,6 +1,10 @@ +#[cfg_attr(target_arch = "wasm32", path = "pipe_wasm.rs")] pub mod pipe; +#[cfg_attr(target_arch = "wasm32", path = "process_wasm.rs")] mod process; +#[cfg_attr(target_arch = "wasm32", path = "process_group_wasm.rs")] pub mod process_group; +#[cfg_attr(target_arch = "wasm32", path = "pty_wasm.rs")] pub mod pty; #[cfg(test)] mod tests; diff --git a/codex-rs/utils/pty/src/pipe_wasm.rs b/codex-rs/utils/pty/src/pipe_wasm.rs new file mode 100644 index 0000000000..f5ebf025c8 --- /dev/null +++ b/codex-rs/utils/pty/src/pipe_wasm.rs @@ -0,0 +1,55 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Result; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use crate::process::ProcessHandle; +use crate::process::SpawnedProcess; + +fn unavailable_spawned_process() -> SpawnedProcess { + let (stdout_tx, stdout_rx) = mpsc::channel(1); + let (stderr_tx, stderr_rx) = mpsc::channel(1); + let (exit_tx, exit_rx) = oneshot::channel(); + drop(stdout_tx); + drop(stderr_tx); + let _ = exit_tx.send(1); + SpawnedProcess { + session: ProcessHandle::unavailable(), + stdout_rx, + stderr_rx, + exit_rx, + } +} + +pub async fn spawn_process( + _program: &str, + _args: &[String], + _cwd: &Path, + _env: &HashMap, + _arg0: &Option, +) -> Result { + anyhow::bail!("pipe process execution is unavailable on wasm32"); +} + +pub async fn spawn_process_no_stdin( + _program: &str, + _args: &[String], + _cwd: &Path, + _env: &HashMap, + _arg0: &Option, +) -> Result { + anyhow::bail!("pipe process execution is unavailable on wasm32"); +} + +pub async fn spawn_process_no_stdin_with_inherited_fds( + _program: &str, + _args: &[String], + _cwd: &Path, + _env: &HashMap, + _arg0: &Option, + _inherited_fds: &[i32], +) -> Result { + Ok(unavailable_spawned_process()) +} diff --git a/codex-rs/utils/pty/src/process_group_wasm.rs b/codex-rs/utils/pty/src/process_group_wasm.rs new file mode 100644 index 0000000000..c283dbd48b --- /dev/null +++ b/codex-rs/utils/pty/src/process_group_wasm.rs @@ -0,0 +1,29 @@ +use std::io; + +pub fn set_parent_death_signal(_parent_pid: i32) -> io::Result<()> { + Ok(()) +} + +pub fn detach_from_tty() -> io::Result<()> { + Ok(()) +} + +pub fn set_process_group() -> io::Result<()> { + Ok(()) +} + +pub fn kill_process_group_by_pid(_pid: u32) -> io::Result<()> { + Ok(()) +} + +pub fn terminate_process_group(_process_group_id: u32) -> io::Result { + Ok(false) +} + +pub fn kill_process_group(_process_group_id: u32) -> io::Result<()> { + Ok(()) +} + +pub fn kill_child_process_group(_child: &mut T) -> io::Result<()> { + Ok(()) +} diff --git a/codex-rs/utils/pty/src/process_wasm.rs b/codex-rs/utils/pty/src/process_wasm.rs new file mode 100644 index 0000000000..d0f6caf8b3 --- /dev/null +++ b/codex-rs/utils/pty/src/process_wasm.rs @@ -0,0 +1,82 @@ +use core::fmt; +use std::io; + +use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalSize { + pub rows: u16, + pub cols: u16, +} + +impl Default for TerminalSize { + fn default() -> Self { + Self { rows: 24, cols: 80 } + } +} + +pub struct ProcessHandle { + writer_tx: mpsc::Sender>, +} + +impl fmt::Debug for ProcessHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ProcessHandle").finish() + } +} + +impl ProcessHandle { + pub(crate) fn unavailable() -> Self { + let (writer_tx, writer_rx) = mpsc::channel(1); + drop(writer_rx); + Self { writer_tx } + } + + pub fn writer_sender(&self) -> mpsc::Sender> { + self.writer_tx.clone() + } + + pub fn has_exited(&self) -> bool { + true + } + + pub fn exit_code(&self) -> Option { + Some(1) + } + + pub fn resize(&self, _size: TerminalSize) -> anyhow::Result<()> { + anyhow::bail!("PTY resize is unavailable on wasm32"); + } + + pub fn close_stdin(&self) {} + + pub fn request_terminate(&self) {} + + pub fn terminate(&self) {} +} + +pub fn combine_output_receivers( + _stdout_rx: mpsc::Receiver>, + _stderr_rx: mpsc::Receiver>, +) -> broadcast::Receiver> { + let (tx, rx) = broadcast::channel(1); + drop(tx); + rx +} + +#[derive(Debug)] +pub struct SpawnedProcess { + pub session: ProcessHandle, + pub stdout_rx: mpsc::Receiver>, + pub stderr_rx: mpsc::Receiver>, + pub exit_rx: oneshot::Receiver, +} + +pub type ExecCommandSession = ProcessHandle; +pub type SpawnedPty = SpawnedProcess; + +pub(crate) trait ChildTerminator: Send + Sync { + fn kill(&mut self) -> io::Result<()>; +} diff --git a/codex-rs/utils/pty/src/pty_wasm.rs b/codex-rs/utils/pty/src/pty_wasm.rs new file mode 100644 index 0000000000..033f0103fd --- /dev/null +++ b/codex-rs/utils/pty/src/pty_wasm.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Result; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +use crate::process::ProcessHandle; +use crate::process::SpawnedProcess; +use crate::process::TerminalSize; + +fn unavailable_spawned_process() -> SpawnedProcess { + let (stdout_tx, stdout_rx) = mpsc::channel(1); + let (stderr_tx, stderr_rx) = mpsc::channel(1); + let (exit_tx, exit_rx) = oneshot::channel(); + drop(stdout_tx); + drop(stderr_tx); + let _ = exit_tx.send(1); + SpawnedProcess { + session: ProcessHandle::unavailable(), + stdout_rx, + stderr_rx, + exit_rx, + } +} + +pub fn conpty_supported() -> bool { + false +} + +pub async fn spawn_process( + _program: &str, + _args: &[String], + _cwd: &Path, + _env: &HashMap, + _arg0: &Option, + _size: TerminalSize, +) -> Result { + anyhow::bail!("PTY execution is unavailable on wasm32"); +} + +pub async fn spawn_process_with_inherited_fds( + _program: &str, + _args: &[String], + _cwd: &Path, + _env: &HashMap, + _arg0: &Option, + _size: TerminalSize, + _inherited_fds: &[i32], +) -> Result { + Ok(unavailable_spawned_process()) +} diff --git a/codex-rs/wasm-harness/Cargo.toml b/codex-rs/wasm-harness/Cargo.toml index eaa5428e28..d1bfefd7c6 100644 --- a/codex-rs/wasm-harness/Cargo.toml +++ b/codex-rs/wasm-harness/Cargo.toml @@ -12,15 +12,17 @@ path = "src/lib.rs" [lints] workspace = true -[features] -real-core = ["dep:codex-core"] - [dependencies] async-trait = { workspace = true } -codex-core = { workspace = true, optional = true } +codex-code-mode = { workspace = true } +codex-core = { workspace = true } +codex-exec-server = { workspace = true } +codex-features = { workspace = true } +codex-protocol = { workspace = true } js-sys = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tokio = { workspace = true, features = ["rt"] } wasm-bindgen = { workspace = true } wasm-bindgen-futures = { workspace = true } web-sys = { workspace = true, features = [ diff --git a/codex-rs/wasm-harness/README.md b/codex-rs/wasm-harness/README.md index 33c9c7b357..aca411b6cb 100644 --- a/codex-rs/wasm-harness/README.md +++ b/codex-rs/wasm-harness/README.md @@ -2,71 +2,50 @@ This crate is the first browser-facing seam for a Codex harness prototype. -It now exposes two layers: +It exposes one browser-facing layer: -- `EmbeddedHarness`: a pure Rust library API that downstream Rust/web repos can - depend on directly. -- `BrowserCodex`: a `wasm_bindgen` adapter that preserves the current browser - demo API. +- `BrowserCodex`: a `wasm_bindgen` adapter that starts a real + `codex-core::CodexThread`, calls `CodexThread::submit(Op::UserTurn { ... })`, + and streams real Codex protocol events back to JavaScript. -It does not yet call `codex-core::run_turn` or `RegularTask::run`. Instead, it -establishes the intended browser API shape: - -- submit a prompt from JavaScript; -- stream Codex-shaped turn events back to the page; and -- resolve after a `turn_complete` event. - -The sampler is currently implemented in Rust/WASM. The demo page uses a -deterministic local response by default, or passes the user's API key into the -WASM facade so Rust can make a direct browser `fetch` request to the Responses -API. +The demo page passes the user's API key into the WASM facade so Rust can make +browser `fetch` requests to the Responses API through the wasm-compatible +Codex client stack. The API key field is for local prototype use only: it stores the key in browser `localStorage` and sends it directly from the page. A production browser integration should use a proxy or an ephemeral-token flow instead of persisting long-lived API keys in the page origin. -The next step is to replace the callback boundary with a real model transport -and then wire the facade to the Codex turn loop after host services are -injectable. +The remaining work is to replace the current browser-only host shims with more +complete browser implementations for persistence, richer tools, and other +host-heavy services. ## Library Boundary -The intended downstream integration point is the pure Rust API: - -- `EmbeddedHarness` -- `ResponsesClient` -- `ToolExecutor` -- `EventSink` -- `HarnessConfig` - -That allows downstream webapps to: +The intended downstream integration point is the crate itself. Downstream +webapps can: - depend on `codex-wasm-harness` from a Git branch or local path; -- supply their own browser/runtime implementations for transport, tools, event - rendering, or persistence; and +- construct `BrowserCodex` from JavaScript or wrap the crate with their own + browser bindings; +- supply their own browser/runtime implementations for host services such as + code execution, event rendering, or persistence; and - keep app-specific browser glue out of the Codex repo. -The current `BrowserCodex` type remains a thin compatibility wrapper around -that library API so the demo page keeps working. - -The `real-core` feature is an explicit compile probe for depending on -`codex-core`. It currently does not build for `wasm32-unknown-unknown`; the -first blocker is native Tokio/Mio networking pulled through the host-heavy -`codex-core` dependency graph. - ## Current Limitations -This is a boundary prototype, not a port of `codex-core` yet. +This is a minimal browser port of the Codex turn loop, not full desktop Codex. -- It does not construct a `Session` or `TurnContext`. -- It does not call `RegularTask::run` or `run_turn`. -- It does not expose native Codex tools. -- It emits Codex-shaped events, but not the full protocol event set. +- It uses the real `CodexThread` turn path, but many host-heavy services are + still compiled into degraded wasm implementations. +- It currently relies on a single injected browser code executor for code mode. +- Native shell, PTY, MCP, plugin runtime, and filesystem-backed persistence are + not available in the browser prototype. -The immediate implementation value is that the browser API and demo page can be -iterated independently while the host-heavy Codex services are moved behind -browser-compatible traits. +The immediate implementation value is that downstream browser work can now +build on the real Codex harness boundary instead of a custom TypeScript or Rust +turn loop. ## Build Sketch diff --git a/codex-rs/wasm-harness/examples/browser/index.html b/codex-rs/wasm-harness/examples/browser/index.html index 4bda60c630..7cae344054 100644 --- a/codex-rs/wasm-harness/examples/browser/index.html +++ b/codex-rs/wasm-harness/examples/browser/index.html @@ -161,8 +161,9 @@

Codex WASM harness prototype

- This page exercises the browser boundary: submit a prompt, stream - Codex-shaped events from WASM, and resolve when the turn completes. + This page exercises the real Codex browser boundary: submit a prompt, + stream Codex protocol events from WASM, and resolve when the turn + completes.

@@ -193,7 +194,7 @@
@@ -201,9 +202,9 @@