Merge 113fb78518 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-03-25 16:54:00 -07:00
committed by GitHub
356 changed files with 11918 additions and 5885 deletions
+2
View File
@@ -971,6 +971,7 @@
"jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}",
"jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}",
"js-sys_0.3.85": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}",
"jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}",
"keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}",
"kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}",
"kqueue_1.1.1": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dhat\",\"req\":\"^0.3.2\"},{\"name\":\"kqueue-sys\",\"req\":\"^1.0.4\"},{\"name\":\"libc\",\"req\":\"^0.2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"}],\"features\":{}}",
@@ -1281,6 +1282,7 @@
"simd-adler32_0.3.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}",
"simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}",
"similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}",
"simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}",
"siphasher_1.0.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}",
"slab_0.4.12": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"smallvec_1.15.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bincode\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"bincode1\",\"package\":\"bincode\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unty\",\"optional\":true,\"req\":\"^0.0.4\"}],\"features\":{\"const_generics\":[],\"const_new\":[\"const_generics\"],\"debugger_visualizer\":[],\"drain_filter\":[],\"drain_keep_rest\":[\"drain_filter\"],\"impl_bincode\":[\"bincode\",\"unty\"],\"may_dangle\":[],\"specialization\":[],\"union\":[],\"write\":[]}}",
+190 -19
View File
@@ -380,7 +380,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -391,7 +391,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -1384,6 +1384,22 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21"
[[package]]
name = "codex-analytics"
version = "0.0.0"
dependencies = [
"codex-git-utils",
"codex-login",
"codex-plugin",
"codex-protocol",
"pretty_assertions",
"serde",
"serde_json",
"sha1",
"tokio",
"tracing",
]
[[package]]
name = "codex-ansi-escape"
version = "0.0.0"
@@ -1444,6 +1460,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -1456,8 +1473,11 @@ dependencies = [
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-pty",
"constant_time_eq",
"core_test_support",
"futures",
"hmac",
"jsonwebtoken",
"opentelemetry",
"opentelemetry_sdk",
"owo-colors",
@@ -1467,6 +1487,7 @@ dependencies = [
"serde",
"serde_json",
"serial_test",
"sha2",
"shlex",
"tempfile",
"time",
@@ -1509,6 +1530,7 @@ dependencies = [
"anyhow",
"clap",
"codex-experimental-api-macros",
"codex-git-utils",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
@@ -1643,7 +1665,7 @@ dependencies = [
"clap",
"codex-connectors",
"codex-core",
"codex-git",
"codex-git-utils",
"codex-utils-cargo-bin",
"codex-utils-cli",
"pretty_assertions",
@@ -1768,6 +1790,7 @@ dependencies = [
"codex-client",
"codex-cloud-tasks-client",
"codex-core",
"codex-git-utils",
"codex-login",
"codex-tui",
"codex-utils-cli",
@@ -1794,7 +1817,7 @@ dependencies = [
"async-trait",
"chrono",
"codex-backend-client",
"codex-git",
"codex-git-utils",
"diffy",
"serde",
"serde_json",
@@ -1827,6 +1850,7 @@ dependencies = [
"futures",
"multimap",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_path_to_error",
@@ -1866,6 +1890,7 @@ dependencies = [
"chardetng",
"chrono",
"clap",
"codex-analytics",
"codex-api",
"codex-app-server-protocol",
"codex-apply-patch",
@@ -1875,22 +1900,24 @@ dependencies = [
"codex-code-mode",
"codex-config",
"codex-connectors",
"codex-core-skills",
"codex-exec-server",
"codex-execpolicy",
"codex-features",
"codex-file-search",
"codex-git",
"codex-git-utils",
"codex-hooks",
"codex-instructions",
"codex-login",
"codex-network-proxy",
"codex-otel",
"codex-plugin",
"codex-protocol",
"codex-rmcp-client",
"codex-rollout",
"codex-sandboxing",
"codex-secrets",
"codex-shell-command",
"codex-shell-escalation",
"codex-skills",
"codex-state",
"codex-terminal-detection",
"codex-test-macros",
@@ -1899,6 +1926,9 @@ dependencies = [
"codex-utils-cargo-bin",
"codex-utils-home-dir",
"codex-utils-image",
"codex-utils-output-truncation",
"codex-utils-path",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-stream-parser",
@@ -1937,7 +1967,6 @@ dependencies = [
"seccompiler",
"serde",
"serde_json",
"serde_yaml",
"serial_test",
"sha1",
"shlex",
@@ -1946,7 +1975,6 @@ dependencies = [
"test-case",
"test-log",
"thiserror 2.0.18",
"time",
"tokio",
"tokio-tungstenite",
"tokio-util",
@@ -1967,6 +1995,35 @@ dependencies = [
"zstd",
]
[[package]]
name = "codex-core-skills"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-analytics",
"codex-app-server-protocol",
"codex-config",
"codex-instructions",
"codex-login",
"codex-otel",
"codex-protocol",
"codex-skills",
"codex-utils-absolute-path",
"codex-utils-plugins",
"dirs",
"dunce",
"pretty_assertions",
"serde",
"serde_json",
"serde_yaml",
"shlex",
"tempfile",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
"zip",
]
[[package]]
name = "codex-debug-client"
version = "0.0.0"
@@ -1993,6 +2050,7 @@ dependencies = [
"codex-cloud-requirements",
"codex-core",
"codex-feedback",
"codex-git-utils",
"codex-otel",
"codex-protocol",
"codex-utils-absolute-path",
@@ -2133,10 +2191,12 @@ dependencies = [
]
[[package]]
name = "codex-git"
name = "codex-git-utils"
version = "0.0.0"
dependencies = [
"assert_matches",
"codex-utils-absolute-path",
"futures",
"once_cell",
"pretty_assertions",
"regex",
@@ -2144,6 +2204,7 @@ dependencies = [
"serde",
"tempfile",
"thiserror 2.0.18",
"tokio",
"ts-rs",
"walkdir",
]
@@ -2166,6 +2227,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-instructions"
version = "0.0.0"
dependencies = [
"codex-protocol",
"pretty_assertions",
"serde",
]
[[package]]
name = "codex-keyring-store"
version = "0.0.0"
@@ -2375,6 +2445,15 @@ dependencies = [
"zip",
]
[[package]]
name = "codex-plugin"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"codex-utils-plugins",
"thiserror 2.0.18",
]
[[package]]
name = "codex-process-hardening"
version = "0.0.0"
@@ -2389,9 +2468,10 @@ version = "0.0.0"
dependencies = [
"anyhow",
"codex-execpolicy",
"codex-git",
"codex-git-utils",
"codex-utils-absolute-path",
"codex-utils-image",
"codex-utils-string",
"icu_decimal",
"icu_locale_core",
"icu_provider",
@@ -2460,6 +2540,31 @@ dependencies = [
"which 8.0.0",
]
[[package]]
name = "codex-rollout"
version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
"codex-state",
"codex-utils-path",
"codex-utils-string",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"time",
"tokio",
"tracing",
"uuid",
]
[[package]]
name = "codex-sandboxing"
version = "0.0.0"
@@ -2484,6 +2589,7 @@ dependencies = [
"age",
"anyhow",
"base64 0.22.1",
"codex-git-utils",
"codex-keyring-store",
"keyring",
"pretty_assertions",
@@ -2554,6 +2660,7 @@ dependencies = [
"anyhow",
"chrono",
"clap",
"codex-git-utils",
"codex-protocol",
"dirs",
"log",
@@ -2620,6 +2727,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -2713,6 +2821,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-login",
"codex-otel",
"codex-protocol",
@@ -2880,6 +2989,34 @@ dependencies = [
"codex-ollama",
]
[[package]]
name = "codex-utils-output-truncation"
version = "0.0.0"
dependencies = [
"codex-protocol",
"codex-utils-string",
"pretty_assertions",
]
[[package]]
name = "codex-utils-path"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"dunce",
"pretty_assertions",
"tempfile",
]
[[package]]
name = "codex-utils-plugins"
version = "0.0.0"
dependencies = [
"serde",
"serde_json",
"tempfile",
]
[[package]]
name = "codex-utils-pty"
version = "0.0.0"
@@ -2949,6 +3086,13 @@ dependencies = [
"regex-lite",
]
[[package]]
name = "codex-utils-template"
version = "0.0.0"
dependencies = [
"pretty_assertions",
]
[[package]]
name = "codex-v8-poc"
version = "0.0.0"
@@ -3836,7 +3980,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4081,7 +4225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5526,7 +5670,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5646,6 +5790,21 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "keyring"
version = "3.6.3"
@@ -6289,7 +6448,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6933,7 +7092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8355,7 +8514,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9136,6 +9295,18 @@ version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "siphasher"
version = "1.0.2"
@@ -9788,7 +9959,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -11234,7 +11405,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+24 -3
View File
@@ -1,5 +1,6 @@
[workspace]
members = [
"analytics",
"backend-client",
"ansi-escape",
"async-utils",
@@ -24,7 +25,9 @@ members = [
"shell-escalation",
"skills",
"core",
"core-skills",
"hooks",
"instructions",
"secrets",
"exec",
"exec-server",
@@ -40,6 +43,7 @@ members = [
"ollama",
"process-hardening",
"protocol",
"rollout",
"rmcp-client",
"responses-api-proxy",
"sandboxing",
@@ -50,7 +54,7 @@ members = [
"v8-poc",
"utils/absolute-path",
"utils/cargo-bin",
"utils/git",
"git-utils",
"utils/cache",
"utils/image",
"utils/json-to-toml",
@@ -65,8 +69,12 @@ members = [
"utils/sleep-inhibitor",
"utils/approval-presets",
"utils/oss",
"utils/output-truncation",
"utils/path-utils",
"utils/plugins",
"utils/fuzzy-match",
"utils/stream-parser",
"utils/template",
"codex-client",
"codex-api",
"state",
@@ -74,6 +82,7 @@ members = [
"codex-experimental-api-macros",
"test-macros",
"package-manager",
"plugin",
"artifacts",
]
resolver = "2"
@@ -91,6 +100,7 @@ license = "Apache-2.0"
# Internal
app_test_support = { path = "app-server/tests/common" }
codex-ansi-escape = { path = "ansi-escape" }
codex-analytics = { path = "analytics" }
codex-api = { path = "codex-api" }
codex-artifacts = { path = "artifacts" }
codex-code-mode = { path = "code-mode" }
@@ -110,6 +120,7 @@ codex-cloud-requirements = { path = "cloud-requirements" }
codex-connectors = { path = "connectors" }
codex-config = { path = "config" }
codex-core = { path = "core" }
codex-core-skills = { path = "core-skills" }
codex-exec = { path = "exec" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
@@ -117,8 +128,9 @@ codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-feedback = { path = "feedback" }
codex-features = { path = "features" }
codex-file-search = { path = "file-search" }
codex-git = { path = "utils/git" }
codex-git-utils = { path = "git-utils" }
codex-hooks = { path = "hooks" }
codex-instructions = { path = "instructions" }
codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
@@ -127,8 +139,10 @@ codex-mcp-server = { path = "mcp-server" }
codex-network-proxy = { path = "network-proxy" }
codex-ollama = { path = "ollama" }
codex-otel = { path = "otel" }
codex-plugin = { path = "plugin" }
codex-process-hardening = { path = "process-hardening" }
codex-protocol = { path = "protocol" }
codex-rollout = { path = "rollout" }
codex-responses-api-proxy = { path = "responses-api-proxy" }
codex-rmcp-client = { path = "rmcp-client" }
codex-sandboxing = { path = "sandboxing" }
@@ -154,12 +168,16 @@ codex-utils-home-dir = { path = "utils/home-dir" }
codex-utils-image = { path = "utils/image" }
codex-utils-json-to-toml = { path = "utils/json-to-toml" }
codex-utils-oss = { path = "utils/oss" }
codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
codex-utils-plugins = { path = "utils/plugins" }
codex-utils-pty = { path = "utils/pty" }
codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
codex-utils-stream-parser = { path = "utils/stream-parser" }
codex-utils-template = { path = "utils/template" }
codex-utils-string = { path = "utils/string" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
core_test_support = { path = "core/tests/common" }
@@ -186,6 +204,7 @@ chrono = "0.4.43"
clap = "4"
clap_complete = "4"
color-eyre = "0.6.3"
constant_time_eq = "0.3.1"
crossbeam-channel = "0.5.15"
crossterm = "0.28.1"
csv = "1.3.1"
@@ -204,6 +223,7 @@ flate2 = "1.1.4"
futures = { version = "0.3", default-features = false }
gethostname = "1.1.0"
globset = "0.4"
hmac = "0.12.1"
http = "1.3.1"
icu_decimal = "2.1"
icu_locale_core = "2.1"
@@ -216,6 +236,7 @@ indexmap = "2.12.0"
insta = "1.46.3"
inventory = "0.3.19"
itertools = "0.14.0"
jsonwebtoken = "9.3.1"
keyring = { version = "3.6", default-features = false }
landlock = "0.4.4"
lazy_static = "1"
@@ -377,7 +398,7 @@ ignored = [
"icu_provider",
"openssl-sys",
"codex-utils-readiness",
"codex-secrets",
"codex-utils-template",
"codex-v8-poc",
]
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "analytics",
crate_name = "codex_analytics",
)
+30
View File
@@ -0,0 +1,30 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-analytics"
version.workspace = true
[lib]
doctest = false
name = "codex_analytics"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-git-utils = { workspace = true }
codex-login = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
sha1 = { workspace = true }
tokio = { workspace = true, features = [
"macros",
"rt-multi-thread",
] }
tracing = { workspace = true, features = ["log"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
@@ -1,9 +1,9 @@
use crate::AuthManager;
use crate::config::Config;
use crate::default_client::create_client;
use crate::git_info::collect_git_info;
use crate::git_info::get_git_repo_root;
use crate::plugins::PluginTelemetryMetadata;
use codex_git_utils::collect_git_info;
use codex_git_utils::get_git_repo_root;
use codex_login::AuthManager;
use codex_login::default_client::create_client;
use codex_login::default_client::originator;
use codex_plugin::PluginTelemetryMetadata;
use codex_protocol::protocol::SkillScope;
use serde::Serialize;
use sha1::Digest;
@@ -17,13 +17,13 @@ use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Clone)]
pub(crate) struct TrackEventsContext {
pub(crate) model_slug: String,
pub(crate) thread_id: String,
pub(crate) turn_id: String,
pub struct TrackEventsContext {
pub model_slug: String,
pub thread_id: String,
pub turn_id: String,
}
pub(crate) fn build_track_events_context(
pub fn build_track_events_context(
model_slug: String,
thread_id: String,
turn_id: String,
@@ -36,24 +36,24 @@ pub(crate) fn build_track_events_context(
}
#[derive(Clone, Debug)]
pub(crate) struct SkillInvocation {
pub(crate) skill_name: String,
pub(crate) skill_scope: SkillScope,
pub(crate) skill_path: PathBuf,
pub(crate) invocation_type: InvocationType,
pub struct SkillInvocation {
pub skill_name: String,
pub skill_scope: SkillScope,
pub skill_path: PathBuf,
pub invocation_type: InvocationType,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum InvocationType {
pub enum InvocationType {
Explicit,
Implicit,
}
pub(crate) struct AppInvocation {
pub(crate) connector_id: Option<String>,
pub(crate) app_name: Option<String>,
pub(crate) invocation_type: Option<InvocationType>,
pub struct AppInvocation {
pub connector_id: Option<String>,
pub app_name: Option<String>,
pub invocation_type: Option<InvocationType>,
}
#[derive(Clone)]
@@ -66,38 +66,38 @@ pub(crate) struct AnalyticsEventsQueue {
#[derive(Clone)]
pub struct AnalyticsEventsClient {
queue: AnalyticsEventsQueue,
config: Arc<Config>,
analytics_enabled: Option<bool>,
}
impl AnalyticsEventsQueue {
pub(crate) fn new(auth_manager: Arc<AuthManager>) -> Self {
pub(crate) fn new(auth_manager: Arc<AuthManager>, base_url: String) -> Self {
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
tokio::spawn(async move {
while let Some(job) = receiver.recv().await {
match job {
TrackEventsJob::SkillInvocations(job) => {
send_track_skill_invocations(&auth_manager, job).await;
send_track_skill_invocations(&auth_manager, &base_url, job).await;
}
TrackEventsJob::AppMentioned(job) => {
send_track_app_mentioned(&auth_manager, job).await;
send_track_app_mentioned(&auth_manager, &base_url, job).await;
}
TrackEventsJob::AppUsed(job) => {
send_track_app_used(&auth_manager, job).await;
send_track_app_used(&auth_manager, &base_url, job).await;
}
TrackEventsJob::PluginUsed(job) => {
send_track_plugin_used(&auth_manager, job).await;
send_track_plugin_used(&auth_manager, &base_url, job).await;
}
TrackEventsJob::PluginInstalled(job) => {
send_track_plugin_installed(&auth_manager, job).await;
send_track_plugin_installed(&auth_manager, &base_url, job).await;
}
TrackEventsJob::PluginUninstalled(job) => {
send_track_plugin_uninstalled(&auth_manager, job).await;
send_track_plugin_uninstalled(&auth_manager, &base_url, job).await;
}
TrackEventsJob::PluginEnabled(job) => {
send_track_plugin_enabled(&auth_manager, job).await;
send_track_plugin_enabled(&auth_manager, &base_url, job).await;
}
TrackEventsJob::PluginDisabled(job) => {
send_track_plugin_disabled(&auth_manager, job).await;
send_track_plugin_disabled(&auth_manager, &base_url, job).await;
}
}
}
@@ -147,60 +147,51 @@ impl AnalyticsEventsQueue {
}
impl AnalyticsEventsClient {
pub fn new(config: Arc<Config>, auth_manager: Arc<AuthManager>) -> Self {
pub fn new(
auth_manager: Arc<AuthManager>,
base_url: String,
analytics_enabled: Option<bool>,
) -> Self {
Self {
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)),
config,
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url),
analytics_enabled,
}
}
pub(crate) fn track_skill_invocations(
pub fn track_skill_invocations(
&self,
tracking: TrackEventsContext,
invocations: Vec<SkillInvocation>,
) {
track_skill_invocations(
&self.queue,
Arc::clone(&self.config),
self.analytics_enabled,
Some(tracking),
invocations,
);
}
pub(crate) fn track_app_mentioned(
&self,
tracking: TrackEventsContext,
mentions: Vec<AppInvocation>,
) {
pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
track_app_mentioned(
&self.queue,
Arc::clone(&self.config),
self.analytics_enabled,
Some(tracking),
mentions,
);
}
pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app);
pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
track_app_used(&self.queue, self.analytics_enabled, Some(tracking), app);
}
pub(crate) fn track_plugin_used(
&self,
tracking: TrackEventsContext,
plugin: PluginTelemetryMetadata,
) {
track_plugin_used(
&self.queue,
Arc::clone(&self.config),
Some(tracking),
plugin,
);
pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
track_plugin_used(&self.queue, self.analytics_enabled, Some(tracking), plugin);
}
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
track_plugin_management(
&self.queue,
Arc::clone(&self.config),
self.analytics_enabled,
PluginManagementEventType::Installed,
plugin,
);
@@ -209,7 +200,7 @@ impl AnalyticsEventsClient {
pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) {
track_plugin_management(
&self.queue,
Arc::clone(&self.config),
self.analytics_enabled,
PluginManagementEventType::Uninstalled,
plugin,
);
@@ -218,7 +209,7 @@ impl AnalyticsEventsClient {
pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) {
track_plugin_management(
&self.queue,
Arc::clone(&self.config),
self.analytics_enabled,
PluginManagementEventType::Enabled,
plugin,
);
@@ -227,7 +218,7 @@ impl AnalyticsEventsClient {
pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) {
track_plugin_management(
&self.queue,
Arc::clone(&self.config),
self.analytics_enabled,
PluginManagementEventType::Disabled,
plugin,
);
@@ -246,31 +237,31 @@ enum TrackEventsJob {
}
struct TrackSkillInvocationsJob {
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: TrackEventsContext,
invocations: Vec<SkillInvocation>,
}
struct TrackAppMentionedJob {
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: TrackEventsContext,
mentions: Vec<AppInvocation>,
}
struct TrackAppUsedJob {
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: TrackEventsContext,
app: AppInvocation,
}
struct TrackPluginUsedJob {
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: TrackEventsContext,
plugin: PluginTelemetryMetadata,
}
struct TrackPluginManagementJob {
config: Arc<Config>,
analytics_enabled: Option<bool>,
plugin: PluginTelemetryMetadata,
}
@@ -379,11 +370,11 @@ struct CodexPluginUsedEventRequest {
pub(crate) fn track_skill_invocations(
queue: &AnalyticsEventsQueue,
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: Option<TrackEventsContext>,
invocations: Vec<SkillInvocation>,
) {
if config.analytics_enabled == Some(false) {
if analytics_enabled == Some(false) {
return;
}
let Some(tracking) = tracking else {
@@ -393,7 +384,7 @@ pub(crate) fn track_skill_invocations(
return;
}
let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob {
config,
analytics_enabled,
tracking,
invocations,
});
@@ -402,11 +393,11 @@ pub(crate) fn track_skill_invocations(
pub(crate) fn track_app_mentioned(
queue: &AnalyticsEventsQueue,
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: Option<TrackEventsContext>,
mentions: Vec<AppInvocation>,
) {
if config.analytics_enabled == Some(false) {
if analytics_enabled == Some(false) {
return;
}
let Some(tracking) = tracking else {
@@ -416,7 +407,7 @@ pub(crate) fn track_app_mentioned(
return;
}
let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob {
config,
analytics_enabled,
tracking,
mentions,
});
@@ -425,11 +416,11 @@ pub(crate) fn track_app_mentioned(
pub(crate) fn track_app_used(
queue: &AnalyticsEventsQueue,
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: Option<TrackEventsContext>,
app: AppInvocation,
) {
if config.analytics_enabled == Some(false) {
if analytics_enabled == Some(false) {
return;
}
let Some(tracking) = tracking else {
@@ -439,7 +430,7 @@ pub(crate) fn track_app_used(
return;
}
let job = TrackEventsJob::AppUsed(TrackAppUsedJob {
config,
analytics_enabled,
tracking,
app,
});
@@ -448,11 +439,11 @@ pub(crate) fn track_app_used(
pub(crate) fn track_plugin_used(
queue: &AnalyticsEventsQueue,
config: Arc<Config>,
analytics_enabled: Option<bool>,
tracking: Option<TrackEventsContext>,
plugin: PluginTelemetryMetadata,
) {
if config.analytics_enabled == Some(false) {
if analytics_enabled == Some(false) {
return;
}
let Some(tracking) = tracking else {
@@ -462,7 +453,7 @@ pub(crate) fn track_plugin_used(
return;
}
let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob {
config,
analytics_enabled,
tracking,
plugin,
});
@@ -471,14 +462,17 @@ pub(crate) fn track_plugin_used(
fn track_plugin_management(
queue: &AnalyticsEventsQueue,
config: Arc<Config>,
analytics_enabled: Option<bool>,
event_type: PluginManagementEventType,
plugin: PluginTelemetryMetadata,
) {
if config.analytics_enabled == Some(false) {
if analytics_enabled == Some(false) {
return;
}
let job = TrackPluginManagementJob { config, plugin };
let job = TrackPluginManagementJob {
analytics_enabled,
plugin,
};
let job = match event_type {
PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job),
PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job),
@@ -488,9 +482,13 @@ fn track_plugin_management(
queue.try_send(job);
}
async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) {
async fn send_track_skill_invocations(
auth_manager: &AuthManager,
base_url: &str,
job: TrackSkillInvocationsJob,
) {
let TrackSkillInvocationsJob {
config,
analytics_enabled,
tracking,
invocations,
} = job;
@@ -525,7 +523,7 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil
thread_id: Some(tracking.thread_id.clone()),
invoke_type: Some(invocation.invocation_type),
model_slug: Some(tracking.model_slug.clone()),
product_client_id: Some(crate::default_client::originator().value),
product_client_id: Some(originator().value),
repo_url,
skill_scope: Some(skill_scope.to_string()),
},
@@ -533,12 +531,16 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil
));
}
send_track_events(auth_manager, config, events).await;
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
}
async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMentionedJob) {
async fn send_track_app_mentioned(
auth_manager: &AuthManager,
base_url: &str,
job: TrackAppMentionedJob,
) {
let TrackAppMentionedJob {
config,
analytics_enabled,
tracking,
mentions,
} = job;
@@ -553,12 +555,12 @@ async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMenti
})
.collect::<Vec<_>>();
send_track_events(auth_manager, config, events).await;
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
}
async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) {
async fn send_track_app_used(auth_manager: &AuthManager, base_url: &str, job: TrackAppUsedJob) {
let TrackAppUsedJob {
config,
analytics_enabled,
tracking,
app,
} = job;
@@ -568,12 +570,16 @@ async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) {
event_params,
})];
send_track_events(auth_manager, config, events).await;
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
}
async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsedJob) {
async fn send_track_plugin_used(
auth_manager: &AuthManager,
base_url: &str,
job: TrackPluginUsedJob,
) {
let TrackPluginUsedJob {
config,
analytics_enabled,
tracking,
plugin,
} = job;
@@ -582,31 +588,52 @@ async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsed
event_params: codex_plugin_used_metadata(&tracking, plugin),
})];
send_track_events(auth_manager, config, events).await;
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
}
async fn send_track_plugin_installed(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
send_track_plugin_management_event(auth_manager, job, "codex_plugin_installed").await;
async fn send_track_plugin_installed(
auth_manager: &AuthManager,
base_url: &str,
job: TrackPluginManagementJob,
) {
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_installed").await;
}
async fn send_track_plugin_uninstalled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
send_track_plugin_management_event(auth_manager, job, "codex_plugin_uninstalled").await;
async fn send_track_plugin_uninstalled(
auth_manager: &AuthManager,
base_url: &str,
job: TrackPluginManagementJob,
) {
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_uninstalled")
.await;
}
async fn send_track_plugin_enabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
send_track_plugin_management_event(auth_manager, job, "codex_plugin_enabled").await;
async fn send_track_plugin_enabled(
auth_manager: &AuthManager,
base_url: &str,
job: TrackPluginManagementJob,
) {
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_enabled").await;
}
async fn send_track_plugin_disabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
send_track_plugin_management_event(auth_manager, job, "codex_plugin_disabled").await;
async fn send_track_plugin_disabled(
auth_manager: &AuthManager,
base_url: &str,
job: TrackPluginManagementJob,
) {
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_disabled").await;
}
async fn send_track_plugin_management_event(
auth_manager: &AuthManager,
base_url: &str,
job: TrackPluginManagementJob,
event_type: &'static str,
) {
let TrackPluginManagementJob { config, plugin } = job;
let TrackPluginManagementJob {
analytics_enabled,
plugin,
} = job;
let event_params = codex_plugin_metadata(plugin);
let event = CodexPluginEventRequest {
event_type,
@@ -620,7 +647,7 @@ async fn send_track_plugin_management_event(
_ => unreachable!("unknown plugin management event type"),
}];
send_track_events(auth_manager, config, events).await;
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
}
fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata {
@@ -629,7 +656,7 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code
thread_id: Some(tracking.thread_id.clone()),
turn_id: Some(tracking.turn_id.clone()),
app_name: app.app_name,
product_client_id: Some(crate::default_client::originator().value),
product_client_id: Some(originator().value),
invoke_type: app.invocation_type,
model_slug: Some(tracking.model_slug.clone()),
}
@@ -654,7 +681,7 @@ fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata
.map(|connector_id| connector_id.0)
.collect()
}),
product_client_id: Some(crate::default_client::originator().value),
product_client_id: Some(originator().value),
}
}
@@ -672,9 +699,13 @@ fn codex_plugin_used_metadata(
async fn send_track_events(
auth_manager: &AuthManager,
config: Arc<Config>,
analytics_enabled: Option<bool>,
base_url: &str,
events: Vec<TrackEventRequest>,
) {
if analytics_enabled == Some(false) {
return;
}
if events.is_empty() {
return;
}
@@ -692,7 +723,7 @@ async fn send_track_events(
return;
};
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let base_url = base_url.trim_end_matches('/');
let url = format!("{base_url}/codex/analytics-events/events");
let payload = TrackEventsRequest { events };
@@ -11,10 +11,11 @@ use super::codex_app_metadata;
use super::codex_plugin_metadata;
use super::codex_plugin_used_metadata;
use super::normalize_path_for_skill_id;
use crate::plugins::AppConnectorId;
use crate::plugins::PluginCapabilitySummary;
use crate::plugins::PluginId;
use crate::plugins::PluginTelemetryMetadata;
use codex_login::default_client::originator;
use codex_plugin::AppConnectorId;
use codex_plugin::PluginCapabilitySummary;
use codex_plugin::PluginId;
use codex_plugin::PluginTelemetryMetadata;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashSet;
@@ -109,7 +110,7 @@ fn app_mentioned_event_serializes_expected_shape() {
"thread_id": "thread-1",
"turn_id": "turn-1",
"app_name": "Calendar",
"product_client_id": crate::default_client::originator().value,
"product_client_id": originator().value,
"invoke_type": "explicit",
"model_slug": "gpt-5"
}
@@ -147,7 +148,7 @@ fn app_used_event_serializes_expected_shape() {
"thread_id": "thread-2",
"turn_id": "turn-2",
"app_name": "Google Drive",
"product_client_id": crate::default_client::originator().value,
"product_client_id": originator().value,
"invoke_type": "implicit",
"model_slug": "gpt-5"
}
@@ -210,7 +211,7 @@ fn plugin_used_event_serializes_expected_shape() {
"has_skills": true,
"mcp_server_count": 2,
"connector_ids": ["calendar", "drive"],
"product_client_id": crate::default_client::originator().value,
"product_client_id": originator().value,
"thread_id": "thread-3",
"turn_id": "turn-3",
"model_slug": "gpt-5"
@@ -239,7 +240,7 @@ fn plugin_management_event_serializes_expected_shape() {
"has_skills": true,
"mcp_server_count": 2,
"connector_ids": ["calendar", "drive"],
"product_client_id": crate::default_client::originator().value
"product_client_id": originator().value
}
})
);
+8
View File
@@ -0,0 +1,8 @@
mod analytics_client;
pub use analytics_client::AnalyticsEventsClient;
pub use analytics_client::AppInvocation;
pub use analytics_client::InvocationType;
pub use analytics_client::SkillInvocation;
pub use analytics_client::TrackEventsContext;
pub use analytics_client::build_track_events_context;
+415 -85
View File
@@ -85,17 +85,128 @@ impl From<InProcessServerEvent> for AppServerEvent {
}
fn event_requires_delivery(event: &InProcessServerEvent) -> bool {
// These terminal events drive surface shutdown/completion state. Dropping
// them under backpressure can leave exec/TUI waiting forever even though
// the underlying turn has already ended.
// These transcript and terminal events must remain lossless. Dropping
// streamed assistant text or the authoritative completed item can leave
// the TUI with permanently corrupted markdown, while dropping completion
// notifications can leave surfaces waiting forever.
match event {
InProcessServerEvent::ServerNotification(notification) => {
server_notification_requires_delivery(notification)
}
_ => false,
}
}
/// Returns `true` for notifications that must survive backpressure.
///
/// Transcript events (`AgentMessageDelta`, `PlanDelta`, reasoning deltas) and
/// the authoritative `ItemCompleted` / `TurnCompleted` form the lossless tier
/// of the event stream. Dropping any of these corrupts the visible assistant
/// output or leaves surfaces waiting for a completion signal that already
/// fired. Everything else (`CommandExecutionOutputDelta`, progress, etc.) is
/// best-effort and may be dropped with only cosmetic impact.
///
/// Both the in-process and remote transports delegate to this function so the
/// classification stays in sync.
pub(crate) fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
matches!(
event,
InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::TurnCompleted(_),
)
notification,
ServerNotification::TurnCompleted(_)
| ServerNotification::ItemCompleted(_)
| ServerNotification::AgentMessageDelta(_)
| ServerNotification::PlanDelta(_)
| ServerNotification::ReasoningSummaryTextDelta(_)
| ServerNotification::ReasoningTextDelta(_)
)
}
/// Outcome of attempting to forward a single event to the consumer channel.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ForwardEventResult {
/// The event was delivered (or intentionally dropped); the stream is healthy.
Continue,
/// The consumer channel is closed; the caller should stop producing events.
DisableStream,
}
/// Forwards a single in-process event to the consumer, respecting the
/// lossless/best-effort split.
///
/// Lossless events (transcript deltas, item/turn completions) block until the
/// consumer drains capacity. Best-effort events use `try_send` and increment
/// `skipped_events` on failure. When a lag marker needs to be flushed before a
/// lossless event, the flush itself blocks so the marker is never lost.
///
/// If a dropped event is a `ServerRequest`, `reject_server_request` is called
/// so the server does not wait for a response that will never come.
async fn forward_in_process_event<F>(
event_tx: &mpsc::Sender<InProcessServerEvent>,
skipped_events: &mut usize,
event: InProcessServerEvent,
mut reject_server_request: F,
) -> ForwardEventResult
where
F: FnMut(ServerRequest),
{
if *skipped_events > 0 {
if event_requires_delivery(&event) {
// Surface lag before the lossless event, but do not let the lag marker itself cause
// us to drop the transcript/completion notification the caller is blocked on.
if event_tx
.send(InProcessServerEvent::Lagged {
skipped: *skipped_events,
})
.await
.is_err()
{
return ForwardEventResult::DisableStream;
}
*skipped_events = 0;
} else {
match event_tx.try_send(InProcessServerEvent::Lagged {
skipped: *skipped_events,
}) {
Ok(()) => {
*skipped_events = 0;
}
Err(mpsc::error::TrySendError::Full(_)) => {
*skipped_events = skipped_events.saturating_add(1);
warn!("dropping in-process app-server event because consumer queue is full");
if let InProcessServerEvent::ServerRequest(request) = event {
reject_server_request(request);
}
return ForwardEventResult::Continue;
}
Err(mpsc::error::TrySendError::Closed(_)) => {
return ForwardEventResult::DisableStream;
}
}
}
}
if event_requires_delivery(&event) {
// Block until the consumer catches up for transcript/completion notifications; this
// preserves the visible assistant output even when the queue is otherwise saturated.
if event_tx.send(event).await.is_err() {
return ForwardEventResult::DisableStream;
}
return ForwardEventResult::Continue;
}
match event_tx.try_send(event) {
Ok(()) => ForwardEventResult::Continue,
Err(mpsc::error::TrySendError::Full(event)) => {
*skipped_events = skipped_events.saturating_add(1);
warn!("dropping in-process app-server event because consumer queue is full");
if let InProcessServerEvent::ServerRequest(request) = event {
reject_server_request(request);
}
ForwardEventResult::Continue
}
Err(mpsc::error::TrySendError::Closed(_)) => ForwardEventResult::DisableStream,
}
}
/// Layered error for [`InProcessAppServerClient::request_typed`].
///
/// This keeps transport failures, server-side JSON-RPC failures, and response
@@ -366,83 +477,26 @@ impl InProcessAppServerClient {
continue;
}
if skipped_events > 0 {
if event_requires_delivery(&event) {
// Surface lag before the terminal event, but
// do not let the lag marker itself cause us to
// drop the completion/abort notification that
// the caller is blocked on.
if event_tx
.send(InProcessServerEvent::Lagged {
skipped: skipped_events,
})
.await
.is_err()
{
event_stream_enabled = false;
continue;
}
skipped_events = 0;
} else {
match event_tx.try_send(InProcessServerEvent::Lagged {
skipped: skipped_events,
}) {
Ok(()) => {
skipped_events = 0;
}
Err(mpsc::error::TrySendError::Full(_)) => {
skipped_events = skipped_events.saturating_add(1);
warn!(
"dropping in-process app-server event because consumer queue is full"
);
if let InProcessServerEvent::ServerRequest(request) = event {
let _ = request_sender.fail_server_request(
request.id().clone(),
JSONRPCErrorError {
code: -32001,
message: "in-process app-server event queue is full".to_string(),
data: None,
},
);
}
continue;
}
Err(mpsc::error::TrySendError::Closed(_)) => {
event_stream_enabled = false;
continue;
}
}
}
}
if event_requires_delivery(&event) {
// Block until the consumer catches up for
// terminal notifications; this preserves the
// completion signal even when the queue is
// otherwise saturated.
if event_tx.send(event).await.is_err() {
event_stream_enabled = false;
}
continue;
}
match event_tx.try_send(event) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(event)) => {
skipped_events = skipped_events.saturating_add(1);
warn!("dropping in-process app-server event because consumer queue is full");
if let InProcessServerEvent::ServerRequest(request) = event {
let _ = request_sender.fail_server_request(
request.id().clone(),
JSONRPCErrorError {
code: -32001,
message: "in-process app-server event queue is full".to_string(),
data: None,
},
);
}
}
Err(mpsc::error::TrySendError::Closed(_)) => {
match forward_in_process_event(
&event_tx,
&mut skipped_events,
event,
|request| {
let _ = request_sender.fail_server_request(
request.id().clone(),
JSONRPCErrorError {
code: -32001,
message: "in-process app-server event queue is full"
.to_string(),
data: None,
},
);
},
)
.await
{
ForwardEventResult::Continue => {}
ForwardEventResult::DisableStream => {
event_stream_enabled = false;
}
}
@@ -933,6 +987,53 @@ mod tests {
.expect("message should send");
}
fn command_execution_output_delta_notification(delta: &str) -> ServerNotification {
ServerNotification::CommandExecutionOutputDelta(
codex_app_server_protocol::CommandExecutionOutputDeltaNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: delta.to_string(),
},
)
}
fn agent_message_delta_notification(delta: &str) -> ServerNotification {
ServerNotification::AgentMessageDelta(
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: delta.to_string(),
},
)
}
fn item_completed_notification(text: &str) -> ServerNotification {
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item: codex_app_server_protocol::ThreadItem::AgentMessage {
id: "item".to_string(),
text: text.to_string(),
phase: None,
memory_citation: None,
},
})
}
fn turn_completed_notification() -> ServerNotification {
ServerNotification::TurnCompleted(codex_app_server_protocol::TurnCompletedNotification {
thread_id: "thread".to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn".to_string(),
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
},
})
}
fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs {
RemoteAppServerConnectArgs {
websocket_url,
@@ -1043,6 +1144,94 @@ mod tests {
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn forward_in_process_event_preserves_transcript_notifications_under_backpressure() {
let (event_tx, mut event_rx) = mpsc::channel(1);
event_tx
.send(InProcessServerEvent::ServerNotification(
command_execution_output_delta_notification("stdout-1"),
))
.await
.expect("initial event should enqueue");
let mut skipped_events = 0usize;
let result = forward_in_process_event(
&event_tx,
&mut skipped_events,
InProcessServerEvent::ServerNotification(command_execution_output_delta_notification(
"stdout-2",
)),
|_| {},
)
.await;
assert_eq!(result, ForwardEventResult::Continue);
assert_eq!(skipped_events, 1);
let receive_task = tokio::spawn(async move {
let mut events = Vec::new();
for _ in 0..5 {
events.push(
timeout(Duration::from_secs(2), event_rx.recv())
.await
.expect("event should arrive before timeout")
.expect("event stream should stay open"),
);
}
events
});
for notification in [
agent_message_delta_notification("hello"),
item_completed_notification("hello"),
turn_completed_notification(),
] {
let result = forward_in_process_event(
&event_tx,
&mut skipped_events,
InProcessServerEvent::ServerNotification(notification),
|_| {},
)
.await;
assert_eq!(result, ForwardEventResult::Continue);
}
assert_eq!(skipped_events, 0);
let events = receive_task
.await
.expect("receiver task should join successfully");
assert!(matches!(
&events[0],
InProcessServerEvent::ServerNotification(
ServerNotification::CommandExecutionOutputDelta(notification)
) if notification.delta == "stdout-1"
));
assert!(matches!(
&events[1],
InProcessServerEvent::Lagged { skipped: 1 }
));
assert!(matches!(
&events[2],
InProcessServerEvent::ServerNotification(ServerNotification::AgentMessageDelta(
notification
)) if notification.delta == "hello"
));
assert!(matches!(
&events[3],
InProcessServerEvent::ServerNotification(ServerNotification::ItemCompleted(
notification
)) if matches!(
&notification.item,
codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } if text == "hello"
)
));
assert!(matches!(
&events[4],
InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted(
notification
)) if notification.turn.status == codex_app_server_protocol::TurnStatus::Completed
));
}
#[tokio::test]
async fn remote_typed_request_roundtrip_works() {
let websocket_url = start_test_remote_server(|mut websocket| async move {
@@ -1207,6 +1396,107 @@ mod tests {
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn remote_backpressure_preserves_transcript_notifications() {
let (done_tx, done_rx) = tokio::sync::oneshot::channel();
let websocket_url = start_test_remote_server(|mut websocket| async move {
expect_remote_initialize(&mut websocket).await;
for notification in [
command_execution_output_delta_notification("stdout-1"),
command_execution_output_delta_notification("stdout-2"),
agent_message_delta_notification("hello"),
item_completed_notification("hello"),
turn_completed_notification(),
] {
write_websocket_message(
&mut websocket,
JSONRPCMessage::Notification(
serde_json::from_value(
serde_json::to_value(notification)
.expect("notification should serialize"),
)
.expect("notification should convert to JSON-RPC"),
),
)
.await;
}
let _ = done_rx.await;
})
.await;
let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url,
client_name: "codex-app-server-client-test".to_string(),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
})
.await
.expect("remote client should connect");
let first_event = timeout(Duration::from_secs(2), client.next_event())
.await
.expect("first event should arrive before timeout")
.expect("event stream should stay open");
assert!(matches!(
first_event,
AppServerEvent::ServerNotification(ServerNotification::CommandExecutionOutputDelta(
notification
)) if notification.delta == "stdout-1"
));
let mut remaining_events = Vec::new();
for _ in 0..4 {
remaining_events.push(
timeout(Duration::from_secs(2), client.next_event())
.await
.expect("event should arrive before timeout")
.expect("event stream should stay open"),
);
}
let mut transcript_event_names = Vec::new();
for event in &remaining_events {
match event {
AppServerEvent::Lagged { skipped: 1 } => {}
AppServerEvent::ServerNotification(
ServerNotification::CommandExecutionOutputDelta(notification),
) if notification.delta == "stdout-2" => {}
AppServerEvent::ServerNotification(ServerNotification::AgentMessageDelta(
notification,
)) if notification.delta == "hello" => {
transcript_event_names.push("agent_message_delta");
}
AppServerEvent::ServerNotification(ServerNotification::ItemCompleted(
notification,
)) if matches!(
&notification.item,
codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } if text == "hello"
) =>
{
transcript_event_names.push("item_completed");
}
AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(
notification,
)) if notification.turn.status
== codex_app_server_protocol::TurnStatus::Completed =>
{
transcript_event_names.push("turn_completed");
}
_ => panic!("unexpected remaining event: {event:?}"),
}
}
assert_eq!(
transcript_event_names,
vec!["agent_message_delta", "item_completed", "turn_completed"]
);
done_tx
.send(())
.expect("server completion signal should send");
client.shutdown().await.expect("shutdown should complete");
}
#[tokio::test]
async fn remote_server_request_resolution_roundtrip_works() {
let websocket_url = start_test_remote_server(|mut websocket| async move {
@@ -1446,7 +1736,7 @@ mod tests {
}
#[test]
fn event_requires_delivery_marks_terminal_events() {
fn event_requires_delivery_marks_transcript_and_terminal_events() {
assert!(event_requires_delivery(
&InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::TurnCompleted(
@@ -1462,9 +1752,49 @@ mod tests {
)
)
));
assert!(event_requires_delivery(
&InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::AgentMessageDelta(
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "hello".to_string(),
}
)
)
));
assert!(event_requires_delivery(
&InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item: codex_app_server_protocol::ThreadItem::AgentMessage {
id: "item".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
},
}
)
)
));
assert!(!event_requires_delivery(&InProcessServerEvent::Lagged {
skipped: 1
}));
assert!(!event_requires_delivery(
&InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::CommandExecutionOutputDelta(
codex_app_server_protocol::CommandExecutionOutputDeltaNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "stdout".to_string(),
}
)
)
));
}
#[tokio::test]
+42 -4
View File
@@ -21,6 +21,7 @@ use crate::RequestResult;
use crate::SHUTDOWN_TIMEOUT;
use crate::TypedRequestError;
use crate::request_method_name;
use crate::server_notification_requires_delivery;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientNotification;
use codex_app_server_protocol::ClientRequest;
@@ -854,11 +855,11 @@ async fn reject_if_server_request_dropped(
fn event_requires_delivery(event: &AppServerEvent) -> bool {
match event {
AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(_)) => true,
AppServerEvent::ServerNotification(notification) => {
server_notification_requires_delivery(notification)
}
AppServerEvent::Disconnected { .. } => true,
AppServerEvent::Lagged { .. }
| AppServerEvent::ServerNotification(_)
| AppServerEvent::ServerRequest(_) => false,
AppServerEvent::Lagged { .. } | AppServerEvent::ServerRequest(_) => false,
}
}
@@ -905,3 +906,40 @@ async fn write_jsonrpc_message(
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_requires_delivery_marks_transcript_and_disconnect_events() {
assert!(event_requires_delivery(
&AppServerEvent::ServerNotification(ServerNotification::AgentMessageDelta(
codex_app_server_protocol::AgentMessageDeltaNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item_id: "item".to_string(),
delta: "hello".to_string(),
},
),)
));
assert!(event_requires_delivery(
&AppServerEvent::ServerNotification(ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
item: codex_app_server_protocol::ThreadItem::Plan {
id: "item".to_string(),
text: "step".to_string(),
},
}
),)
));
assert!(event_requires_delivery(&AppServerEvent::Disconnected {
message: "closed".to_string(),
}));
assert!(!event_requires_delivery(&AppServerEvent::Lagged {
skipped: 1
}));
}
}
+2 -1
View File
@@ -14,8 +14,9 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-protocol = { workspace = true }
codex-experimental-api-macros = { workspace = true }
codex-git-utils = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
@@ -524,6 +524,21 @@
],
"type": "object"
},
"ExperimentalFeatureEnablementSetParams": {
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"type": "object"
},
"ExperimentalFeatureListParams": {
"properties": {
"cursor": {
@@ -781,6 +796,36 @@
],
"type": "object"
},
"FsUnwatchParams": {
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"type": "object"
},
"FsWatchParams": {
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"type": "object"
},
"FsWriteFileParams": {
"description": "Write a file on the host filesystem.",
"properties": {
@@ -4000,6 +4045,54 @@
"title": "Fs/copyRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/watch"
],
"title": "Fs/watchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsWatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/watchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/unwatch"
],
"title": "Fs/unwatchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsUnwatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4216,6 +4309,30 @@
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"experimentalFeature/enablement/set"
],
"title": "ExperimentalFeature/enablement/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExperimentalFeatureEnablementSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/enablement/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AccountLoginCompletedNotification": {
"properties": {
"error": {
@@ -998,6 +1002,27 @@
],
"type": "object"
},
"FsChangedNotification": {
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"type": "object"
},
"FuzzyFileSearchMatchType": {
"enum": [
"file",
@@ -4394,6 +4419,26 @@
"title": "App/list/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fs/changed"
],
"title": "Fs/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Fs/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"AdditionalFileSystemPermissions": {
"properties": {
"read": {
@@ -883,6 +887,54 @@
"title": "Fs/copyRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"fs/watch"
],
"title": "Fs/watchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsWatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/watchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"fs/unwatch"
],
"title": "Fs/unwatchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsUnwatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1099,6 +1151,30 @@
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"experimentalFeature/enablement/set"
],
"title": "ExperimentalFeature/enablement/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/enablement/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -2257,6 +2333,14 @@
"InitializeResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"codexHome": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Absolute path to the server's $CODEX_HOME directory."
},
"platformFamily": {
"description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.",
"type": "string"
@@ -2270,6 +2354,7 @@
}
},
"required": [
"codexHome",
"platformFamily",
"platformOs",
"userAgent"
@@ -4053,6 +4138,26 @@
"title": "App/list/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fs/changed"
],
"title": "Fs/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/FsChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Fs/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -7168,6 +7273,40 @@
],
"type": "object"
},
"ExperimentalFeatureEnablementSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetParams",
"type": "object"
},
"ExperimentalFeatureEnablementSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Feature enablement entries updated by this request.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetResponse",
"type": "object"
},
"ExperimentalFeatureListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -7444,6 +7583,29 @@
],
"type": "string"
},
"FsChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"title": "FsChangedNotification",
"type": "object"
},
"FsCopyParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Copy a file or directory tree on the host filesystem.",
@@ -7698,6 +7860,70 @@
"title": "FsRemoveResponse",
"type": "object"
},
"FsUnwatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"title": "FsUnwatchParams",
"type": "object"
},
"FsUnwatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful response for `fs/unwatch`.",
"title": "FsUnwatchResponse",
"type": "object"
},
"FsWatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"title": "FsWatchParams",
"type": "object"
},
"FsWatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Created watch handle returned by `fs/watch`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
}
],
"description": "Canonicalized path associated with the watch."
},
"watchId": {
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
"type": "string"
}
},
"required": [
"path",
"watchId"
],
"title": "FsWatchResponse",
"type": "object"
},
"FsWriteFileParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a file on the host filesystem.",
@@ -1417,6 +1417,54 @@
"title": "Fs/copyRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/watch"
],
"title": "Fs/watchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsWatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/watchRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"fs/unwatch"
],
"title": "Fs/unwatchRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsUnwatchParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Fs/unwatchRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1633,6 +1681,30 @@
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"experimentalFeature/enablement/set"
],
"title": "ExperimentalFeature/enablement/setRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExperimentalFeatureEnablementSetParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/enablement/setRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3761,6 +3833,40 @@
],
"type": "object"
},
"ExperimentalFeatureEnablementSetParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetParams",
"type": "object"
},
"ExperimentalFeatureEnablementSetResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Feature enablement entries updated by this request.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetResponse",
"type": "object"
},
"ExperimentalFeatureListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4037,6 +4143,29 @@
],
"type": "string"
},
"FsChangedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"title": "FsChangedNotification",
"type": "object"
},
"FsCopyParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Copy a file or directory tree on the host filesystem.",
@@ -4291,6 +4420,70 @@
"title": "FsRemoveResponse",
"type": "object"
},
"FsUnwatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"title": "FsUnwatchParams",
"type": "object"
},
"FsUnwatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful response for `fs/unwatch`.",
"title": "FsUnwatchResponse",
"type": "object"
},
"FsWatchParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"title": "FsWatchParams",
"type": "object"
},
"FsWatchResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Created watch handle returned by `fs/watch`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Canonicalized path associated with the watch."
},
"watchId": {
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
"type": "string"
}
},
"required": [
"path",
"watchId"
],
"title": "FsWatchResponse",
"type": "object"
},
"FsWriteFileParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Write a file on the host filesystem.",
@@ -8540,6 +8733,26 @@
"title": "App/list/updatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fs/changed"
],
"title": "Fs/changedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FsChangedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Fs/changedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -1,6 +1,20 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"codexHome": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute path to the server's $CODEX_HOME directory."
},
"platformFamily": {
"description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.",
"type": "string"
@@ -14,6 +28,7 @@
}
},
"required": [
"codexHome",
"platformFamily",
"platformOs",
"userAgent"
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetParams",
"type": "object"
}
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enablement": {
"additionalProperties": {
"type": "boolean"
},
"description": "Feature enablement entries updated by this request.",
"type": "object"
}
},
"required": [
"enablement"
],
"title": "ExperimentalFeatureEnablementSetResponse",
"type": "object"
}
@@ -0,0 +1,29 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Filesystem watch notification emitted for `fs/watch` subscribers.",
"properties": {
"changedPaths": {
"description": "File or directory paths associated with this event.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"changedPaths",
"watchId"
],
"title": "FsChangedNotification",
"type": "object"
}
@@ -0,0 +1,15 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Stop filesystem watch notifications for a prior `fs/watch`.",
"properties": {
"watchId": {
"description": "Watch identifier returned by `fs/watch`.",
"type": "string"
}
},
"required": [
"watchId"
],
"title": "FsUnwatchParams",
"type": "object"
}
@@ -0,0 +1,6 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "Successful response for `fs/unwatch`.",
"title": "FsUnwatchResponse",
"type": "object"
}
@@ -0,0 +1,25 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Start filesystem watch notifications for an absolute path.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Absolute file or directory path to watch."
}
},
"required": [
"path"
],
"title": "FsWatchParams",
"type": "object"
}
@@ -0,0 +1,30 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"description": "Created watch handle returned by `fs/watch`.",
"properties": {
"path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Canonicalized path associated with the watch."
},
"watchId": {
"description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.",
"type": "string"
}
},
"required": [
"path",
"watchId"
],
"title": "FsWatchResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -1,8 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "./AbsolutePathBuf";
export type InitializeResponse = { userAgent: string,
/**
* Absolute path to the server's $CODEX_HOME directory.
*/
codexHome: AbsolutePathBuf,
/**
* Platform family for the running app-server target, for example
* `"unix"` or `"windows"`.
@@ -15,6 +15,7 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica
import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification";
import type { ErrorNotification } from "./v2/ErrorNotification";
import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification";
import type { FsChangedNotification } from "./v2/FsChangedNotification";
import type { HookCompletedNotification } from "./v2/HookCompletedNotification";
import type { HookStartedNotification } from "./v2/HookStartedNotification";
import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification";
@@ -56,4 +57,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification };
@@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExperimentalFeatureEnablementSetParams = {
/**
* Process-wide runtime feature enablement keyed by canonical feature name.
*
* Only named features are updated. Omitted features are left unchanged.
* Send an empty map for a no-op.
*/
enablement: { [key in string]?: boolean }, };
@@ -0,0 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExperimentalFeatureEnablementSetResponse = {
/**
* Feature enablement entries updated by this request.
*/
enablement: { [key in string]?: boolean }, };
@@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Filesystem watch notification emitted for `fs/watch` subscribers.
*/
export type FsChangedNotification = {
/**
* Watch identifier returned by `fs/watch`.
*/
watchId: string,
/**
* File or directory paths associated with this event.
*/
changedPaths: Array<AbsolutePathBuf>, };
@@ -0,0 +1,12 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Stop filesystem watch notifications for a prior `fs/watch`.
*/
export type FsUnwatchParams = {
/**
* Watch identifier returned by `fs/watch`.
*/
watchId: string, };
@@ -0,0 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Successful response for `fs/unwatch`.
*/
export type FsUnwatchResponse = Record<string, never>;
@@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Start filesystem watch notifications for an absolute path.
*/
export type FsWatchParams = {
/**
* Absolute file or directory path to watch.
*/
path: AbsolutePathBuf, };
@@ -0,0 +1,17 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
/**
* Created watch handle returned by `fs/watch`.
*/
export type FsWatchResponse = {
/**
* Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.
*/
watchId: string,
/**
* Canonicalized path associated with the watch.
*/
path: AbsolutePathBuf, };
@@ -81,6 +81,8 @@ export type { DynamicToolSpec } from "./DynamicToolSpec";
export type { ErrorNotification } from "./ErrorNotification";
export type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
export type { ExperimentalFeature } from "./ExperimentalFeature";
export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams";
export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse";
export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams";
export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse";
export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
@@ -97,6 +99,7 @@ export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaN
export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams";
export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse";
export type { FileUpdateChange } from "./FileUpdateChange";
export type { FsChangedNotification } from "./FsChangedNotification";
export type { FsCopyParams } from "./FsCopyParams";
export type { FsCopyResponse } from "./FsCopyResponse";
export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams";
@@ -110,6 +113,10 @@ export type { FsReadFileParams } from "./FsReadFileParams";
export type { FsReadFileResponse } from "./FsReadFileResponse";
export type { FsRemoveParams } from "./FsRemoveParams";
export type { FsRemoveResponse } from "./FsRemoveResponse";
export type { FsUnwatchParams } from "./FsUnwatchParams";
export type { FsUnwatchResponse } from "./FsUnwatchResponse";
export type { FsWatchParams } from "./FsWatchParams";
export type { FsWatchResponse } from "./FsWatchResponse";
export type { FsWriteFileParams } from "./FsWriteFileParams";
export type { FsWriteFileResponse } from "./FsWriteFileResponse";
export type { GetAccountParams } from "./GetAccountParams";
+1
View File
@@ -4,6 +4,7 @@ mod jsonrpc_lite;
mod protocol;
mod schema_fixtures;
pub use codex_git_utils::GitSha;
pub use experimental_api::*;
pub use export::GenerateTsOptions;
pub use export::generate_internal_json_schema;
@@ -14,16 +14,6 @@ use serde::Serialize;
use strum_macros::Display;
use ts_rs::TS;
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
#[ts(type = "string")]
pub struct GitSha(pub String);
impl GitSha {
pub fn new(sha: &str) -> Self {
Self(sha.to_string())
}
}
/// Authentication mode for OpenAI-backed providers.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
@@ -336,6 +326,14 @@ client_request_definitions! {
params: v2::FsCopyParams,
response: v2::FsCopyResponse,
},
FsWatch => "fs/watch" {
params: v2::FsWatchParams,
response: v2::FsWatchResponse,
},
FsUnwatch => "fs/unwatch" {
params: v2::FsUnwatchParams,
response: v2::FsUnwatchResponse,
},
SkillsConfigWrite => "skills/config/write" {
params: v2::SkillsConfigWriteParams,
response: v2::SkillsConfigWriteResponse,
@@ -394,6 +392,10 @@ client_request_definitions! {
params: v2::ExperimentalFeatureListParams,
response: v2::ExperimentalFeatureListResponse,
},
ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" {
params: v2::ExperimentalFeatureEnablementSetParams,
response: v2::ExperimentalFeatureEnablementSetResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -909,6 +911,7 @@ server_notification_definitions! {
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
FsChanged => "fs/changed" (v2::FsChangedNotification),
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
@@ -1488,6 +1491,27 @@ mod tests {
Ok(())
}
#[test]
fn serialize_fs_watch() -> Result<()> {
let request = ClientRequest::FsWatch {
request_id: RequestId::Integer(10),
params: v2::FsWatchParams {
path: absolute_path("tmp/repo/.git"),
},
};
assert_eq!(
json!({
"method": "fs/watch",
"id": 10,
"params": {
"path": absolute_path_string("tmp/repo/.git")
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_list_experimental_features() -> Result<()> {
let request = ClientRequest::ExperimentalFeatureList {
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
@@ -21,7 +22,6 @@ use serde::Serialize;
use ts_rs::TS;
use crate::protocol::common::AuthMode;
use crate::protocol::common::GitSha;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -56,6 +56,8 @@ pub struct InitializeCapabilities {
#[serde(rename_all = "camelCase")]
pub struct InitializeResponse {
pub user_agent: String,
/// Absolute path to the server's $CODEX_HOME directory.
pub codex_home: AbsolutePathBuf,
/// Platform family for the running app-server target, for example
/// `"unix"` or `"windows"`.
pub platform_family: String,
@@ -1926,6 +1926,25 @@ pub struct ExperimentalFeatureListResponse {
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeatureEnablementSetParams {
/// Process-wide runtime feature enablement keyed by canonical feature name.
///
/// Only named features are updated. Omitted features are left unchanged.
/// Send an empty map for a no-op.
pub enablement: std::collections::BTreeMap<String, bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeatureEnablementSetResponse {
/// Feature enablement entries updated by this request.
pub enablement: std::collections::BTreeMap<String, bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2301,6 +2320,52 @@ pub struct FsCopyParams {
#[ts(export_to = "v2/")]
pub struct FsCopyResponse {}
/// Start filesystem watch notifications for an absolute path.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsWatchParams {
/// Absolute file or directory path to watch.
pub path: AbsolutePathBuf,
}
/// Created watch handle returned by `fs/watch`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsWatchResponse {
/// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.
pub watch_id: String,
/// Canonicalized path associated with the watch.
pub path: AbsolutePathBuf,
}
/// Stop filesystem watch notifications for a prior `fs/watch`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsUnwatchParams {
/// Watch identifier returned by `fs/watch`.
pub watch_id: String,
}
/// Successful response for `fs/unwatch`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsUnwatchResponse {}
/// Filesystem watch notification emitted for `fs/watch` subscribers.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsChangedNotification {
/// Watch identifier returned by `fs/watch`.
pub watch_id: String,
/// File or directory paths associated with this event.
pub changed_paths: Vec<AbsolutePathBuf>,
}
/// PTY size in character cells for `command/exec` PTY sessions.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -6497,6 +6562,33 @@ mod tests {
assert_eq!(decoded, response);
}
#[test]
fn fs_changed_notification_round_trips() {
let notification = FsChangedNotification {
watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(),
changed_paths: vec![
absolute_path("tmp/repo/.git/HEAD"),
absolute_path("tmp/repo/.git/FETCH_HEAD"),
],
};
let value = serde_json::to_value(&notification).expect("serialize fs/changed notification");
assert_eq!(
value,
json!({
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
"changedPaths": [
absolute_path_string("tmp/repo/.git/HEAD"),
absolute_path_string("tmp/repo/.git/FETCH_HEAD"),
],
})
);
let decoded = serde_json::from_value::<FsChangedNotification>(value)
.expect("deserialize fs/changed notification");
assert_eq!(decoded, notification);
}
#[test]
fn command_exec_params_default_optional_streaming_flags() {
let params = serde_json::from_value::<CommandExecParams>(json!({
+5
View File
@@ -34,6 +34,7 @@ codex-cloud-requirements = { workspace = true }
codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-otel = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
@@ -52,10 +53,14 @@ codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
constant_time_eq = { workspace = true }
futures = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
time = { workspace = true }
toml = { workspace = true }
+36 -1
View File
@@ -34,6 +34,15 @@ When running with `--listen ws://IP:PORT`, the same listener also serves basic H
Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads.
Security note:
- Loopback websocket listeners (`ws://127.0.0.1:PORT`) remain appropriate for localhost and SSH port-forwarding workflows.
- Non-loopback websocket listeners currently allow unauthenticated connections by default during rollout. If you expose one remotely, configure websocket auth explicitly now.
- Supported auth modes are app-server flags:
- `--ws-auth capability-token --ws-token-file /absolute/path`
- `--ws-auth signed-bearer-token --ws-shared-secret-file /absolute/path` for HMAC-signed JWT/JWS bearer tokens, with optional `--ws-issuer`, `--ws-audience`, `--ws-max-clock-skew-seconds`
- Clients present the credential as `Authorization: Bearer <token>` during the websocket handshake. Auth is enforced before JSON-RPC `initialize`.
Tracing/log output:
- `RUST_LOG` controls log filtering/verbosity.
@@ -75,7 +84,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
## Initialization
Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services plus `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error.
Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services, `codexHome` for the server's Codex home directory, and `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error.
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
@@ -159,8 +168,12 @@ Example with notification opt-out:
- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path.
- `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`.
- `fs/copy` — copy between absolute paths; directory copies require `recursive: true`.
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path; returns a `watchId` and canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
@@ -795,6 +808,28 @@ All filesystem paths in this section must be absolute.
- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`.
- `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped.
### Example: Filesystem watch
`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations.
```json
{ "method": "fs/watch", "id": 44, "params": {
"path": "/Users/me/project/.git/HEAD"
} }
{ "id": 44, "result": {
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
"path": "/Users/me/project/.git/HEAD"
} }
{ "method": "fs/changed", "params": {
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1",
"changedPaths": ["/Users/me/project/.git/HEAD"]
} }
{ "method": "fs/unwatch", "id": 45, "params": {
"watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1"
} }
{ "id": 45, "result": {} }
```
## Events
Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications.
@@ -1,6 +1,7 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_api::apply_runtime_feature_enablement;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
@@ -202,6 +203,8 @@ use codex_core::config::types::McpServerTransportConfig;
use codex_core::config_loader::CloudRequirementsLoadError;
use codex_core::config_loader::CloudRequirementsLoadErrorCode;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::load_config_layers_state;
use codex_core::default_client::set_default_client_residency_requirement;
use codex_core::error::CodexErr;
use codex_core::error::Result as CodexResult;
@@ -213,7 +216,6 @@ use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_name_by_id;
use codex_core::find_thread_names_by_ids;
use codex_core::find_thread_path_by_id_str;
use codex_core::git_info::git_diff_to_remote;
use codex_core::mcp::auth::discover_supported_scopes;
use codex_core::mcp::auth::resolve_oauth_scopes;
use codex_core::mcp::collect_mcp_snapshot;
@@ -243,6 +245,7 @@ use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
use codex_feedback::CodexFeedback;
use codex_git_utils::git_diff_to_remote;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
@@ -281,8 +284,10 @@ use codex_state::StateRuntime;
use codex_state::ThreadMetadata;
use codex_state::ThreadMetadataBuilder;
use codex_state::log_db::LogDbLayer;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_json_to_toml::json_to_toml;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsStr;
@@ -371,7 +376,8 @@ pub(crate) struct CodexMessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
cli_overrides: Vec<(String, TomlValue)>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
@@ -422,7 +428,8 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) arg0_paths: Arg0DispatchPaths,
pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
pub(crate) runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
pub(crate) feedback: CodexFeedback,
pub(crate) log_db: Option<LogDbLayer>,
@@ -486,6 +493,7 @@ impl CodexMessageProcessor {
arg0_paths,
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
feedback,
log_db,
@@ -497,6 +505,7 @@ impl CodexMessageProcessor {
arg0_paths,
config,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
active_login: Arc::new(Mutex::new(None)),
pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())),
@@ -517,7 +526,7 @@ impl CodexMessageProcessor {
) -> Result<Config, JSONRPCErrorError> {
let cloud_requirements = self.current_cloud_requirements();
let mut config = codex_core::config::ConfigBuilder::default()
.cli_overrides(self.cli_overrides.clone())
.cli_overrides(self.current_cli_overrides())
.fallback_cwd(fallback_cwd)
.cloud_requirements(cloud_requirements)
.build()
@@ -527,6 +536,7 @@ impl CodexMessageProcessor {
message: format!("failed to reload config: {err}"),
data: None,
})?;
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone();
Ok(config)
@@ -539,6 +549,20 @@ impl CodexMessageProcessor {
.unwrap_or_default()
}
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
/// If a client sends `developer_instructions: null` during a mode switch,
/// use the built-in instructions for that mode.
fn normalize_turn_start_collaboration_mode(
@@ -884,7 +908,8 @@ impl CodexMessageProcessor {
}
ClientRequest::ConfigRead { .. }
| ClientRequest::ConfigValueWrite { .. }
| ClientRequest::ConfigBatchWrite { .. } => {
| ClientRequest::ConfigBatchWrite { .. }
| ClientRequest::ExperimentalFeatureEnablementSet { .. } => {
warn!("Config request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::FsReadFile { .. }
@@ -893,7 +918,9 @@ impl CodexMessageProcessor {
| ClientRequest::FsGetMetadata { .. }
| ClientRequest::FsReadDirectory { .. }
| ClientRequest::FsRemove { .. }
| ClientRequest::FsCopy { .. } => {
| ClientRequest::FsCopy { .. }
| ClientRequest::FsWatch { .. }
| ClientRequest::FsUnwatch { .. } => {
warn!("Filesystem request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::ConfigRequirementsRead { .. } => {
@@ -1083,7 +1110,7 @@ impl CodexMessageProcessor {
let cloud_requirements = self.cloud_requirements.clone();
let chatgpt_base_url = self.config.chatgpt_base_url.clone();
let codex_home = self.config.codex_home.clone();
let cli_overrides = self.cli_overrides.clone();
let cli_overrides = self.current_cli_overrides();
let auth_url = server.auth_url.clone();
tokio::spawn(async move {
let (success, error_msg) = match tokio::time::timeout(
@@ -1271,11 +1298,9 @@ impl CodexMessageProcessor {
self.config.chatgpt_base_url.clone(),
self.config.codex_home.clone(),
);
sync_default_client_residency_requirement(
&self.cli_overrides,
self.cloud_requirements.as_ref(),
)
.await;
let cli_overrides = self.current_cli_overrides();
sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref())
.await;
self.outgoing
.send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {})
@@ -1378,7 +1403,12 @@ impl CodexMessageProcessor {
requires_openai_auth: Some(false),
}
} else {
match self.auth_manager.auth().await {
let auth = if do_refresh {
self.auth_manager.auth_cached()
} else {
self.auth_manager.auth().await
};
match auth {
Some(auth) => {
let permanent_refresh_failure =
self.auth_manager.refresh_failure_for_auth(&auth).is_some();
@@ -1623,7 +1653,7 @@ impl CodexMessageProcessor {
return;
}
let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone());
let cwd = cwd.unwrap_or_else(|| self.config.cwd.to_path_buf());
let mut env = create_env(
&self.config.permissions.shell_environment_policy,
/*thread_id*/ None,
@@ -1890,8 +1920,8 @@ impl CodexMessageProcessor {
personality,
);
typesafe_overrides.ephemeral = ephemeral;
let cli_overrides = self.cli_overrides.clone();
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let listener_task_context = ListenerTaskContext {
thread_manager: Arc::clone(&self.thread_manager),
thread_state_manager: self.thread_state_manager.clone(),
@@ -1901,10 +1931,12 @@ impl CodexMessageProcessor {
codex_home: self.config.codex_home.clone(),
};
let request_trace = request_context.request_trace();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let thread_start_task = async move {
Self::thread_start_task(
listener_task_context,
cli_overrides,
runtime_feature_enablement,
cloud_requirements,
request_id,
config,
@@ -1931,6 +1963,13 @@ impl CodexMessageProcessor {
}
}
pub(crate) async fn cancel_active_login(&self) {
let mut guard = self.active_login.lock().await;
if let Some(active_login) = guard.take() {
drop(active_login);
}
}
pub(crate) async fn clear_all_thread_listeners(&self) {
self.thread_state_manager.clear_all_listeners().await;
}
@@ -1970,6 +2009,7 @@ impl CodexMessageProcessor {
async fn thread_start_task(
listener_task_context: ListenerTaskContext,
cli_overrides: Vec<(String, TomlValue)>,
runtime_feature_enablement: BTreeMap<String, bool>,
cloud_requirements: CloudRequirementsLoader,
request_id: ConnectionRequestId,
config_overrides: Option<HashMap<String, serde_json::Value>>,
@@ -1986,6 +2026,7 @@ impl CodexMessageProcessor {
typesafe_overrides,
&cloud_requirements,
&listener_task_context.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -3488,13 +3529,16 @@ impl CodexMessageProcessor {
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let config = match derive_config_for_cwd(
&self.cli_overrides,
&cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
&cloud_requirements,
&self.config.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -4030,13 +4074,16 @@ impl CodexMessageProcessor {
typesafe_overrides.ephemeral = ephemeral.then_some(true);
// Derive a Config using the same logic as new conversation, honoring overrides if provided.
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let config = match derive_config_for_cwd(
&self.cli_overrides,
&cli_overrides,
request_overrides,
typesafe_overrides,
history_cwd,
&cloud_requirements,
&self.config.codex_home,
&runtime_feature_enablement,
)
.await
{
@@ -5425,7 +5472,7 @@ impl CodexMessageProcessor {
per_cwd_extra_user_roots,
} = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.clone()]
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
@@ -5470,13 +5517,63 @@ impl CodexMessageProcessor {
}
};
let skills_manager = self.thread_manager.skills_manager();
let plugins_manager = self.thread_manager.plugins_manager();
let cli_overrides = self.current_cli_overrides();
let mut data = Vec::new();
for cwd in cwds {
let extra_roots = extra_roots_by_cwd
.get(&cwd)
.map_or(&[][..], std::vec::Vec::as_slice);
let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) {
Ok(path) => path,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: errors_to_info(&[codex_core::skills::SkillError {
path: error_path,
message: err.to_string(),
}]),
});
continue;
}
};
let config_layer_stack = match load_config_layers_state(
&self.config.codex_home,
Some(cwd_abs),
&cli_overrides,
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: errors_to_info(&[codex_core::skills::SkillError {
path: error_path,
message: err.to_string(),
}]),
});
continue;
}
};
let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack(
&config_layer_stack,
config.features.enabled(Feature::Plugins),
);
let skills_input = codex_core::skills::SkillsLoadInput::new(
cwd.clone(),
effective_skill_roots,
config_layer_stack,
config.bundled_skills_enabled(),
);
let outcome = skills_manager
.skills_for_cwd_with_extra_user_roots(&cwd, &config, force_reload, extra_roots)
.skills_for_cwd_with_extra_user_roots(&skills_input, force_reload, extra_roots)
.await;
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);
@@ -7215,12 +7312,13 @@ impl CodexMessageProcessor {
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
};
let config = Arc::clone(&self.config);
let cli_overrides = self.cli_overrides.clone();
let cloud_requirements = self.current_cloud_requirements();
let command_cwd = params
.cwd
.map(PathBuf::from)
.unwrap_or_else(|| config.cwd.clone());
.unwrap_or_else(|| config.cwd.to_path_buf());
let cli_overrides = self.current_cli_overrides();
let runtime_feature_enablement = self.current_runtime_feature_enablement();
let outgoing = Arc::clone(&self.outgoing);
let connection_id = request_id.connection_id;
@@ -7235,6 +7333,7 @@ impl CodexMessageProcessor {
Some(command_cwd.clone()),
&cloud_requirements,
&config.codex_home,
&runtime_feature_enablement,
)
.await;
let setup_result = match derived_config {
@@ -7242,7 +7341,7 @@ impl CodexMessageProcessor {
let setup_request = WindowsSandboxSetupRequest {
mode,
policy: config.permissions.sandbox_policy.get().clone(),
policy_cwd: config.cwd.clone(),
policy_cwd: config.cwd.to_path_buf(),
command_cwd,
env_map: std::env::vars().collect(),
codex_home: config.codex_home.clone(),
@@ -7864,6 +7963,7 @@ async fn derive_config_from_params(
typesafe_overrides: ConfigOverrides,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
@@ -7876,13 +7976,15 @@ async fn derive_config_from_params(
)
.collect::<Vec<_>>();
codex_core::config::ConfigBuilder::default()
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.cloud_requirements(cloud_requirements.clone())
.build()
.await
.await?;
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
Ok(config)
}
async fn derive_config_for_cwd(
@@ -7892,6 +7994,7 @@ async fn derive_config_for_cwd(
cwd: Option<PathBuf>,
cloud_requirements: &CloudRequirementsLoader,
codex_home: &Path,
runtime_feature_enablement: &BTreeMap<String, bool>,
) -> std::io::Result<Config> {
let merged_cli_overrides = cli_overrides
.iter()
@@ -7904,14 +8007,16 @@ async fn derive_config_for_cwd(
)
.collect::<Vec<_>>();
codex_core::config::ConfigBuilder::default()
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.cli_overrides(merged_cli_overrides)
.harness_overrides(typesafe_overrides)
.fallback_cwd(cwd)
.cloud_requirements(cloud_requirements.clone())
.build()
.await
.await?;
apply_runtime_feature_enablement(&mut config, runtime_feature_enablement);
Ok(config)
}
async fn read_history_cwd_from_state_db(
@@ -8224,7 +8329,7 @@ fn extract_conversation_summary(
fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
ConversationGitInfo {
sha: git_info.commit_hash.clone(),
sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()),
branch: git_info.branch.clone(),
origin_url: git_info.repository_url.clone(),
}
@@ -9,7 +9,7 @@ use codex_core::mcp::auth::McpOAuthLoginSupport;
use codex_core::mcp::auth::oauth_login_support;
use codex_core::mcp::auth::resolve_oauth_scopes;
use codex_core::mcp::auth::should_retry_without_scopes;
use codex_rmcp_client::perform_oauth_login;
use codex_rmcp_client::perform_oauth_login_silent;
use tracing::warn;
use super::CodexMessageProcessor;
@@ -45,7 +45,7 @@ impl CodexMessageProcessor {
let notification_name = name.clone();
tokio::spawn(async move {
let first_attempt = perform_oauth_login(
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
@@ -60,7 +60,7 @@ impl CodexMessageProcessor {
let final_result = match first_attempt {
Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => {
perform_oauth_login(
perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
+30 -34
View File
@@ -734,23 +734,21 @@ mod tests {
access: ReadOnlyAccess::FullAccess,
network_access: false,
};
ExecRequest {
command: vec!["cmd".to_string()],
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
expiration: ExecExpiration::DefaultTimeout,
capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool,
sandbox: SandboxType::WindowsRestrictedToken,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
justification: None,
arg0: None,
}
ExecRequest::new(
vec!["cmd".to_string()],
PathBuf::from("."),
HashMap::new(),
/*network*/ None,
ExecExpiration::DefaultTimeout,
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::WindowsRestrictedToken,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
/*arg0*/ None,
)
}
#[tokio::test]
@@ -848,23 +846,21 @@ mod tests {
outgoing: Arc::new(OutgoingMessageSender::new(tx)),
request_id: request_id.clone(),
process_id: Some("proc-100".to_string()),
exec_request: ExecRequest {
command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
cwd: PathBuf::from("."),
env: HashMap::new(),
network: None,
expiration: ExecExpiration::Cancellation(CancellationToken::new()),
capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool,
sandbox: SandboxType::None,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault,
sandbox_policy: sandbox_policy.clone(),
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
justification: None,
arg0: None,
},
exec_request: ExecRequest::new(
vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()],
PathBuf::from("."),
HashMap::new(),
/*network*/ None,
ExecExpiration::Cancellation(CancellationToken::new()),
codex_core::exec::ExecCapturePolicy::ShellTool,
SandboxType::None,
WindowsSandboxLevel::Disabled,
/*windows_sandbox_private_desktop*/ false,
sandbox_policy.clone(),
FileSystemSandboxPolicy::from(&sandbox_policy),
NetworkSandboxPolicy::from(&sandbox_policy),
/*arg0*/ None,
),
started_network_proxy: None,
tty: false,
stream_stdin: false,
+268 -15
View File
@@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::SandboxMode;
use codex_core::AnalyticsEventsClient;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigService;
use codex_core::config::ConfigServiceError;
use codex_core::config_loader::CloudRequirementsLoader;
@@ -24,15 +27,27 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem
use codex_core::plugins::PluginId;
use codex_core::plugins::collect_plugin_enabled_candidates;
use codex_core::plugins::installed_plugin_telemetry_metadata;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use toml::Value as TomlValue;
use tracing::warn;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"plugins",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[async_trait]
pub(crate) trait UserConfigReloader: Send + Sync {
async fn reload_user_config(&self);
@@ -56,7 +71,8 @@ impl UserConfigReloader for ThreadManager {
#[derive(Clone)]
pub(crate) struct ConfigApi {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
user_config_reloader: Arc<dyn UserConfigReloader>,
@@ -66,7 +82,8 @@ pub(crate) struct ConfigApi {
impl ConfigApi {
pub(crate) fn new(
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
cli_overrides: Arc<RwLock<Vec<(String, TomlValue)>>>,
runtime_feature_enablement: Arc<RwLock<BTreeMap<String, bool>>>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
user_config_reloader: Arc<dyn UserConfigReloader>,
@@ -75,6 +92,7 @@ impl ConfigApi {
Self {
codex_home,
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
user_config_reloader,
@@ -83,24 +101,87 @@ impl ConfigApi {
}
fn config_service(&self) -> ConfigService {
let cloud_requirements = self
.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default();
ConfigService::new(
self.codex_home.clone(),
self.cli_overrides.clone(),
self.current_cli_overrides(),
self.loader_overrides.clone(),
cloud_requirements,
self.current_cloud_requirements(),
)
}
fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> {
self.cli_overrides
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_runtime_feature_enablement(&self) -> BTreeMap<String, bool> {
self.runtime_feature_enablement
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
fn current_cloud_requirements(&self) -> CloudRequirementsLoader {
self.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default()
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(self.codex_home.clone())
.cli_overrides(self.current_cli_overrides())
.loader_overrides(self.loader_overrides.clone())
.fallback_cwd(fallback_cwd)
.cloud_requirements(self.current_cloud_requirements())
.build()
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to resolve feature override precedence: {err}"),
data: None,
})?;
apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement());
Ok(config)
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
self.config_service().read(params).await.map_err(map_error)
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self
.config_service()
.read(params)
.await
.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}
pub(crate) async fn config_requirements_read(
@@ -154,6 +235,68 @@ impl ConfigApi {
Ok(response)
}
pub(crate) async fn set_experimental_feature_enablement(
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
),
data: None,
});
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
});
}
if enablement.is_empty() {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
{
let mut runtime_feature_enablement =
self.runtime_feature_enablement
.write()
.map_err(|_| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "failed to update feature enablement".to_string(),
data: None,
})?;
runtime_feature_enablement.extend(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
);
}
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.user_config_reloader.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap<String, bool>) {
for (plugin_id, enabled) in pending_changes {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
@@ -170,6 +313,49 @@ impl ConfigApi {
}
}
pub(crate) fn protected_feature_keys(
config_layer_stack: &codex_core::config_loader::ConfigLayerStack,
) -> BTreeSet<String> {
let mut protected_features = config_layer_stack
.effective_config()
.get("features")
.and_then(toml::Value::as_table)
.map(|features| features.keys().cloned().collect::<BTreeSet<_>>())
.unwrap_or_default();
if let Some(feature_requirements) = config_layer_stack
.requirements_toml()
.feature_requirements
.as_ref()
{
protected_features.extend(feature_requirements.entries.keys().cloned());
}
protected_features
}
pub(crate) fn apply_runtime_feature_enablement(
config: &mut Config,
runtime_feature_enablement: &BTreeMap<String, bool>,
) {
let protected_features = protected_feature_keys(&config.config_layer_stack);
for (name, enabled) in runtime_feature_enablement {
if protected_features.contains(name) {
continue;
}
let Some(feature) = feature_for_key(name) else {
continue;
};
if let Err(err) = config.features.set_enabled(feature, *enabled) {
warn!(
feature = name,
error = %err,
"failed to apply runtime feature enablement"
);
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
@@ -264,7 +450,10 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) ->
mod tests {
use super::*;
use codex_core::AnalyticsEventsClient;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml;
use codex_features::Feature;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -392,6 +581,66 @@ mod tests {
);
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\napps = false\n",
)
.expect("write config");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), false)]),
);
assert!(config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(
codex_core::config_loader::FeatureRequirementsToml {
entries: BTreeMap::from([("apps".to_string(), false)]),
},
),
..Default::default()
}))
}))
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), true)]),
);
assert!(!config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn batch_write_reloads_user_config_when_requested() {
let codex_home = TempDir::new().expect("create temp dir");
@@ -404,17 +653,21 @@ mod tests {
.await
.expect("load analytics config"),
);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let config_api = ConfigApi::new(
codex_home.path().to_path_buf(),
Vec::new(),
Arc::new(RwLock::new(Vec::new())),
Arc::new(RwLock::new(BTreeMap::new())),
LoaderOverrides::default(),
Arc::new(RwLock::new(CloudRequirementsLoader::default())),
reloader.clone(),
AnalyticsEventsClient::new(
analytics_config,
codex_core::test_support::auth_manager_from_auth(
codex_core::CodexAuth::from_api_key("test"),
),
auth_manager,
analytics_config
.chatgpt_base_url
.trim_end_matches('/')
.to_string(),
analytics_config.analytics_enabled,
),
);
+2 -2
View File
@@ -159,7 +159,7 @@ impl FsApi {
}
}
fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
pub(crate) fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
@@ -167,7 +167,7 @@ fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
}
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
+379
View File
@@ -0,0 +1,379 @@
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::FsChangedNotification;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsUnwatchResponse;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_core::file_watcher::FileWatcher;
use codex_core::file_watcher::FileWatcherEvent;
use codex_core::file_watcher::FileWatcherSubscriber;
use codex_core::file_watcher::Receiver;
use codex_core::file_watcher::WatchPath;
use codex_core::file_watcher::WatchRegistration;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::collections::HashSet;
use std::hash::Hash;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
#[cfg(test)]
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::time::Instant;
use tracing::warn;
use uuid::Uuid;
const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200);
struct DebouncedReceiver {
rx: Receiver,
interval: Duration,
changed_paths: HashSet<PathBuf>,
next_allowance: Option<Instant>,
}
impl DebouncedReceiver {
fn new(rx: Receiver, interval: Duration) -> Self {
Self {
rx,
interval,
changed_paths: HashSet::new(),
next_allowance: None,
}
}
async fn recv(&mut self) -> Option<FileWatcherEvent> {
while self.changed_paths.is_empty() {
self.changed_paths.extend(self.rx.recv().await?.paths);
}
let next_allowance = *self
.next_allowance
.get_or_insert_with(|| Instant::now() + self.interval);
loop {
tokio::select! {
event = self.rx.recv() => self.changed_paths.extend(event?.paths),
_ = tokio::time::sleep_until(next_allowance) => break,
}
}
Some(FileWatcherEvent {
paths: self.changed_paths.drain().collect(),
})
}
}
#[derive(Clone)]
pub(crate) struct FsWatchManager {
outgoing: Arc<OutgoingMessageSender>,
file_watcher: Arc<FileWatcher>,
state: Arc<AsyncMutex<FsWatchState>>,
}
#[derive(Default)]
struct FsWatchState {
entries: HashMap<WatchKey, WatchEntry>,
}
struct WatchEntry {
terminate_tx: oneshot::Sender<oneshot::Sender<()>>,
_subscriber: FileWatcherSubscriber,
_registration: WatchRegistration,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct WatchKey {
connection_id: ConnectionId,
watch_id: String,
}
impl FsWatchManager {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>) -> Self {
let file_watcher = match FileWatcher::new() {
Ok(file_watcher) => Arc::new(file_watcher),
Err(err) => {
warn!("filesystem watch manager falling back to noop core watcher: {err}");
Arc::new(FileWatcher::noop())
}
};
Self::new_with_file_watcher(outgoing, file_watcher)
}
fn new_with_file_watcher(
outgoing: Arc<OutgoingMessageSender>,
file_watcher: Arc<FileWatcher>,
) -> Self {
Self {
outgoing,
file_watcher,
state: Arc::new(AsyncMutex::new(FsWatchState::default())),
}
}
pub(crate) async fn watch(
&self,
connection_id: ConnectionId,
params: FsWatchParams,
) -> Result<FsWatchResponse, JSONRPCErrorError> {
let watch_id = Uuid::now_v7().to_string();
let outgoing = self.outgoing.clone();
let (subscriber, rx) = self.file_watcher.add_subscriber();
let watch_root = params.path.to_path_buf().clone();
let registration = subscriber.register_paths(vec![WatchPath {
path: params.path.to_path_buf(),
recursive: false,
}]);
let (terminate_tx, terminate_rx) = oneshot::channel();
self.state.lock().await.entries.insert(
WatchKey {
connection_id,
watch_id: watch_id.clone(),
},
WatchEntry {
terminate_tx,
_subscriber: subscriber,
_registration: registration,
},
);
let task_watch_id = watch_id.clone();
tokio::spawn(async move {
let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE);
tokio::pin!(terminate_rx);
loop {
let event = tokio::select! {
biased;
_ = &mut terminate_rx => break,
event = rx.recv() => match event {
Some(event) => event,
None => break,
},
};
let mut changed_paths = event
.paths
.into_iter()
.filter_map(|path| {
match AbsolutePathBuf::resolve_path_against_base(&path, &watch_root) {
Ok(path) => Some(path),
Err(err) => {
warn!(
"failed to normalize watch event path ({}) for {}: {err}",
path.display(),
watch_root.display()
);
None
}
}
})
.collect::<Vec<_>>();
changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path()));
if !changed_paths.is_empty() {
outgoing
.send_server_notification_to_connection_and_wait(
connection_id,
ServerNotification::FsChanged(FsChangedNotification {
watch_id: task_watch_id.clone(),
changed_paths,
}),
)
.await;
}
}
});
Ok(FsWatchResponse {
watch_id,
path: params.path,
})
}
pub(crate) async fn unwatch(
&self,
connection_id: ConnectionId,
params: FsUnwatchParams,
) -> Result<FsUnwatchResponse, JSONRPCErrorError> {
let watch_key = WatchKey {
connection_id,
watch_id: params.watch_id,
};
let entry = self.state.lock().await.entries.remove(&watch_key);
if let Some(entry) = entry {
// Wait for the oneshot to be destroyed by the task to ensure that no notifications
// are send after the unwatch response.
let (done_tx, done_rx) = oneshot::channel();
let _ = entry.terminate_tx.send(done_tx);
let _ = done_rx.await;
}
Ok(FsUnwatchResponse {})
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
let mut state = self.state.lock().await;
state
.entries
.extract_if(|key, _| key.connection_id == connection_id)
.count();
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Version;
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
assert!(
path.is_absolute(),
"path must be absolute: {}",
path.display()
);
AbsolutePathBuf::try_from(path).expect("path should be absolute")
}
fn manager_with_noop_watcher() -> FsWatchManager {
const OUTGOING_BUFFER: usize = 1;
let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER);
FsWatchManager::new_with_file_watcher(
Arc::new(OutgoingMessageSender::new(tx)),
Arc::new(FileWatcher::noop()),
)
}
#[tokio::test]
async fn watch_returns_a_v7_id_and_tracks_the_owner_scoped_entry() {
let temp_dir = TempDir::new().expect("temp dir");
let head_path = temp_dir.path().join("HEAD");
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
let manager = manager_with_noop_watcher();
let path = absolute_path(head_path);
let response = manager
.watch(ConnectionId(1), FsWatchParams { path: path.clone() })
.await
.expect("watch should succeed");
assert_eq!(response.path, path);
let watch_id = Uuid::parse_str(&response.watch_id).expect("watch id should be a UUID");
assert_eq!(watch_id.get_version(), Some(Version::SortRand));
let state = manager.state.lock().await;
assert_eq!(
state.entries.keys().cloned().collect::<HashSet<_>>(),
HashSet::from([WatchKey {
connection_id: ConnectionId(1),
watch_id: response.watch_id,
}])
);
}
#[tokio::test]
async fn unwatch_is_scoped_to_the_connection_that_created_the_watch() {
let temp_dir = TempDir::new().expect("temp dir");
let head_path = temp_dir.path().join("HEAD");
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
let manager = manager_with_noop_watcher();
let response = manager
.watch(
ConnectionId(1),
FsWatchParams {
path: absolute_path(head_path),
},
)
.await
.expect("watch should succeed");
let watch_key = WatchKey {
connection_id: ConnectionId(1),
watch_id: response.watch_id.clone(),
};
manager
.unwatch(
ConnectionId(2),
FsUnwatchParams {
watch_id: response.watch_id.clone(),
},
)
.await
.expect("foreign unwatch should be a no-op");
assert!(manager.state.lock().await.entries.contains_key(&watch_key));
manager
.unwatch(
ConnectionId(1),
FsUnwatchParams {
watch_id: response.watch_id,
},
)
.await
.expect("owner unwatch should succeed");
assert!(!manager.state.lock().await.entries.contains_key(&watch_key));
}
#[tokio::test]
async fn connection_closed_removes_only_that_connections_watches() {
let temp_dir = TempDir::new().expect("temp dir");
let head_path = temp_dir.path().join("HEAD");
let fetch_head_path = temp_dir.path().join("FETCH_HEAD");
let packed_refs_path = temp_dir.path().join("packed-refs");
std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD");
std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD");
std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs");
let manager = manager_with_noop_watcher();
let response_1 = manager
.watch(
ConnectionId(1),
FsWatchParams {
path: absolute_path(head_path),
},
)
.await
.expect("first watch should succeed");
let response_2 = manager
.watch(
ConnectionId(1),
FsWatchParams {
path: absolute_path(fetch_head_path),
},
)
.await
.expect("second watch should succeed");
let response_3 = manager
.watch(
ConnectionId(2),
FsWatchParams {
path: absolute_path(packed_refs_path),
},
)
.await
.expect("third watch should succeed");
manager.connection_closed(ConnectionId(1)).await;
assert_eq!(
manager
.state
.lock()
.await
.entries
.keys()
.cloned()
.collect::<HashSet<_>>(),
HashSet::from([WatchKey {
connection_id: ConnectionId(2),
watch_id: response_3.watch_id,
}])
);
assert_ne!(response_1.watch_id, response_2.watch_id);
}
}
+1
View File
@@ -458,6 +458,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
}
processor.clear_runtime_references();
processor.cancel_active_login().await;
processor.connection_closed(IN_PROCESS_CONNECTION_ID).await;
processor.clear_all_thread_listeners().await;
processor.drain_background_tasks().await;
+8
View File
@@ -27,6 +27,7 @@ use crate::transport::CHANNEL_CAPACITY;
use crate::transport::ConnectionState;
use crate::transport::OutboundConnectionState;
use crate::transport::TransportEvent;
use crate::transport::auth::policy_from_settings;
use crate::transport::route_outgoing_envelope;
use crate::transport::start_stdio_connection;
use crate::transport::start_websocket_acceptor;
@@ -67,6 +68,7 @@ mod error_code;
mod external_agent_config_api;
mod filters;
mod fs_api;
mod fs_watch;
mod fuzzy_file_search;
pub mod in_process;
mod message_processor;
@@ -80,6 +82,9 @@ mod transport;
pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
pub use crate::error_code::INVALID_PARAMS_ERROR_CODE;
pub use crate::transport::AppServerTransport;
pub use crate::transport::auth::AppServerWebsocketAuthArgs;
pub use crate::transport::auth::AppServerWebsocketAuthSettings;
pub use crate::transport::auth::WebsocketAuthCliMode;
const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT";
@@ -336,6 +341,7 @@ pub async fn run_main(
default_analytics_enabled,
AppServerTransport::Stdio,
SessionSource::VSCode,
AppServerWebsocketAuthSettings::default(),
)
.await
}
@@ -347,6 +353,7 @@ pub async fn run_main_with_transport(
default_analytics_enabled: bool,
transport: AppServerTransport,
session_source: SessionSource,
auth: AppServerWebsocketAuthSettings,
) -> IoResult<()> {
let (transport_event_tx, mut transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
@@ -374,6 +381,7 @@ pub async fn run_main_with_transport(
bind_address,
transport_event_tx.clone(),
shutdown_token.clone(),
policy_from_settings(&auth)?,
)
.await?;
TransportRuntime::WebSocket {
+6
View File
@@ -1,5 +1,6 @@
use clap::Parser;
use codex_app_server::AppServerTransport;
use codex_app_server::AppServerWebsocketAuthArgs;
use codex_app_server::run_main_with_transport;
use codex_arg0::Arg0DispatchPaths;
use codex_arg0::arg0_dispatch_or_else;
@@ -31,6 +32,9 @@ struct AppServerArgs {
value_parser = SessionSource::from_startup_arg
)]
session_source: SessionSource,
#[command(flatten)]
auth: AppServerWebsocketAuthArgs,
}
fn main() -> anyhow::Result<()> {
@@ -43,6 +47,7 @@ fn main() -> anyhow::Result<()> {
};
let transport = args.listen;
let session_source = args.session_source;
let auth = args.auth.try_into_settings()?;
run_main_with_transport(
arg0_paths,
@@ -51,6 +56,7 @@ fn main() -> anyhow::Result<()> {
/*default_analytics_enabled*/ false,
transport,
session_source,
auth,
)
.await?;
Ok(())
+116 -3
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::Arc;
@@ -8,9 +9,11 @@ use std::sync::atomic::Ordering;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
@@ -28,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::FsCopyParams;
@@ -36,6 +40,8 @@ use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
@@ -152,6 +158,7 @@ pub(crate) struct MessageProcessor {
external_agent_config_api: ExternalAgentConfigApi,
fs_api: FsApi,
auth_manager: Arc<AuthManager>,
fs_watch_manager: FsWatchManager,
config: Arc<Config>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
@@ -215,12 +222,17 @@ impl MessageProcessor {
auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let analytics_events_client =
AnalyticsEventsClient::new(Arc::clone(&config), Arc::clone(&auth_manager));
let analytics_events_client = AnalyticsEventsClient::new(
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
config.analytics_enabled,
);
thread_manager
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
let cli_overrides = Arc::new(RwLock::new(cli_overrides));
let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new()));
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
auth_manager: auth_manager.clone(),
@@ -229,6 +241,7 @@ impl MessageProcessor {
arg0_paths,
config: Arc::clone(&config),
cli_overrides: cli_overrides.clone(),
runtime_feature_enablement: runtime_feature_enablement.clone(),
cloud_requirements: cloud_requirements.clone(),
feedback,
log_db,
@@ -241,6 +254,7 @@ impl MessageProcessor {
let config_api = ConfigApi::new(
config.codex_home.clone(),
cli_overrides,
runtime_feature_enablement,
loader_overrides,
cloud_requirements,
thread_manager,
@@ -248,6 +262,7 @@ impl MessageProcessor {
);
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
let fs_api = FsApi::default();
let fs_watch_manager = FsWatchManager::new(outgoing.clone());
Self {
outgoing,
@@ -256,6 +271,7 @@ impl MessageProcessor {
external_agent_config_api,
fs_api,
auth_manager,
fs_watch_manager,
config,
config_warnings: Arc::new(config_warnings),
}
@@ -451,6 +467,10 @@ impl MessageProcessor {
self.codex_message_processor.drain_background_tasks().await;
}
pub(crate) async fn cancel_active_login(&self) {
self.codex_message_processor.cancel_active_login().await;
}
pub(crate) async fn clear_all_thread_listeners(&self) {
self.codex_message_processor
.clear_all_thread_listeners()
@@ -463,6 +483,7 @@ impl MessageProcessor {
pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) {
self.outgoing.connection_closed(connection_id).await;
self.fs_watch_manager.connection_closed(connection_id).await;
self.codex_message_processor
.connection_closed(connection_id)
.await;
@@ -579,8 +600,21 @@ impl MessageProcessor {
}
let user_agent = get_codex_user_agent();
let codex_home = match self.config.codex_home.clone().try_into() {
Ok(codex_home) => codex_home,
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("Invalid CODEX_HOME: {err}"),
data: None,
};
self.outgoing.send_error(connection_request_id, error).await;
return;
}
};
let response = InitializeResponse {
user_agent,
codex_home,
platform_family: std::env::consts::FAMILY.to_string(),
platform_os: std::env::consts::OS.to_string(),
};
@@ -675,6 +709,16 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => {
self.handle_experimental_feature_enablement_set(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigRequirementsRead {
request_id,
params: _,
@@ -755,6 +799,28 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::FsWatch { request_id, params } => {
self.handle_fs_watch(
ConnectionRequestId {
connection_id,
request_id,
},
connection_id,
params,
)
.await;
}
ClientRequest::FsUnwatch { request_id, params } => {
self.handle_fs_unwatch(
ConnectionRequestId {
connection_id,
request_id,
},
connection_id,
params,
)
.await;
}
other => {
// Box the delegated future so this wrapper's async state machine does not
// inline the full `CodexMessageProcessor::process_request` future, which
@@ -801,7 +867,30 @@ impl MessageProcessor {
request_id: ConnectionRequestId,
params: ConfigBatchWriteParams,
) {
match self.config_api.batch_write(params).await {
self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await)
.await;
}
async fn handle_experimental_feature_enablement_set(
&self,
request_id: ConnectionRequestId,
params: ExperimentalFeatureEnablementSetParams,
) {
self.handle_config_mutation_result(
request_id,
self.config_api
.set_experimental_feature_enablement(params)
.await,
)
.await;
}
async fn handle_config_mutation_result<T: serde::Serialize>(
&self,
request_id: ConnectionRequestId,
result: std::result::Result<T, JSONRPCErrorError>,
) {
match result {
Ok(response) => {
self.codex_message_processor.clear_plugin_related_caches();
self.codex_message_processor
@@ -906,6 +995,30 @@ impl MessageProcessor {
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_watch(
&self,
request_id: ConnectionRequestId,
connection_id: ConnectionId,
params: FsWatchParams,
) {
match self.fs_watch_manager.watch(connection_id, params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_fs_unwatch(
&self,
request_id: ConnectionRequestId,
connection_id: ConnectionId,
params: FsUnwatchParams,
) {
match self.fs_watch_manager.unwatch(connection_id, params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
}
#[cfg(test)]
+30 -4
View File
@@ -1,3 +1,8 @@
pub(crate) mod auth;
use self::auth::WebsocketAuthPolicy;
use self::auth::authorize_upgrade;
use self::auth::should_warn_about_unauthenticated_non_loopback_listener;
use crate::error_code::OVERLOADED_ERROR_CODE;
use crate::message_processor::ConnectionSessionState;
use crate::outgoing_message::ConnectionId;
@@ -12,6 +17,7 @@ use axum::extract::State;
use axum::extract::ws::Message as WebSocketMessage;
use axum::extract::ws::WebSocket;
use axum::extract::ws::WebSocketUpgrade;
use axum::http::HeaderMap;
use axum::http::Request;
use axum::http::StatusCode;
use axum::http::header::ORIGIN;
@@ -83,7 +89,7 @@ fn print_websocket_startup_banner(addr: SocketAddr) {
);
} else {
eprintln!(
" {note_label} this is a raw WS server; consider running behind TLS/auth for real remote use"
" {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use"
);
}
}
@@ -92,6 +98,7 @@ fn print_websocket_startup_banner(addr: SocketAddr) {
struct WebSocketListenerState {
transport_event_tx: mpsc::Sender<TransportEvent>,
connection_counter: Arc<AtomicU64>,
auth_policy: Arc<WebsocketAuthPolicy>,
}
async fn health_check_handler() -> StatusCode {
@@ -118,12 +125,23 @@ async fn websocket_upgrade_handler(
websocket: WebSocketUpgrade,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
State(state): State<WebSocketListenerState>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) {
warn!(
%peer_addr,
message = err.message(),
"rejecting websocket client during upgrade"
);
return (err.status_code(), err.message()).into_response();
}
let connection_id = ConnectionId(state.connection_counter.fetch_add(1, Ordering::Relaxed));
info!(%peer_addr, "websocket client connected");
websocket.on_upgrade(move |stream| async move {
run_websocket_connection(connection_id, stream, state.transport_event_tx).await;
})
websocket
.on_upgrade(move |stream| async move {
run_websocket_connection(connection_id, stream, state.transport_event_tx).await;
})
.into_response()
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -333,7 +351,14 @@ pub(crate) async fn start_websocket_acceptor(
bind_address: SocketAddr,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
auth_policy: WebsocketAuthPolicy,
) -> IoResult<JoinHandle<()>> {
if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) {
warn!(
%bind_address,
"starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release"
);
}
let listener = TcpListener::bind(bind_address).await?;
let local_addr = listener.local_addr()?;
print_websocket_startup_banner(local_addr);
@@ -347,6 +372,7 @@ pub(crate) async fn start_websocket_acceptor(
.with_state(WebSocketListenerState {
transport_event_tx,
connection_counter: Arc::new(AtomicU64::new(1)),
auth_policy: Arc::new(auth_policy),
});
let server = axum::serve(
listener,
+583
View File
@@ -0,0 +1,583 @@
use anyhow::Context;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use clap::Args;
use clap::ValueEnum;
use codex_utils_absolute_path::AbsolutePathBuf;
use constant_time_eq::constant_time_eq_32;
use jsonwebtoken::Algorithm;
use jsonwebtoken::DecodingKey;
use jsonwebtoken::Validation;
use jsonwebtoken::decode;
use serde::Deserialize;
use sha2::Digest;
use sha2::Sha256;
use std::io;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::path::Path;
use std::path::PathBuf;
use time::OffsetDateTime;
const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30;
const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32;
const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header";
#[derive(Debug, Clone, Default, PartialEq, Eq, Args)]
pub struct AppServerWebsocketAuthArgs {
/// Websocket auth mode for non-loopback listeners.
#[arg(long = "ws-auth", value_name = "MODE", value_enum)]
pub ws_auth: Option<WebsocketAuthCliMode>,
/// Absolute path to the capability-token file.
#[arg(long = "ws-token-file", value_name = "PATH")]
pub ws_token_file: Option<PathBuf>,
/// Absolute path to the shared secret file for signed JWT bearer tokens.
#[arg(long = "ws-shared-secret-file", value_name = "PATH")]
pub ws_shared_secret_file: Option<PathBuf>,
/// Expected issuer for signed JWT bearer tokens.
#[arg(long = "ws-issuer", value_name = "ISSUER")]
pub ws_issuer: Option<String>,
/// Expected audience for signed JWT bearer tokens.
#[arg(long = "ws-audience", value_name = "AUDIENCE")]
pub ws_audience: Option<String>,
/// Maximum clock skew when validating signed JWT bearer tokens.
#[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")]
pub ws_max_clock_skew_seconds: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum WebsocketAuthCliMode {
CapabilityToken,
SignedBearerToken,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppServerWebsocketAuthSettings {
pub config: Option<AppServerWebsocketAuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppServerWebsocketAuthConfig {
CapabilityToken {
token_file: AbsolutePathBuf,
},
SignedBearerToken {
shared_secret_file: AbsolutePathBuf,
issuer: Option<String>,
audience: Option<String>,
max_clock_skew_seconds: u64,
},
}
#[derive(Clone, Debug, Default)]
pub(crate) struct WebsocketAuthPolicy {
pub(crate) mode: Option<WebsocketAuthMode>,
}
#[derive(Clone, Debug)]
pub(crate) enum WebsocketAuthMode {
CapabilityToken {
token_sha256: [u8; 32],
},
SignedBearerToken {
shared_secret: Vec<u8>,
issuer: Option<String>,
audience: Option<String>,
max_clock_skew_seconds: i64,
},
}
#[derive(Debug)]
pub(crate) struct WebsocketAuthError {
status_code: StatusCode,
message: &'static str,
}
#[derive(Deserialize)]
struct JwtClaims {
exp: i64,
nbf: Option<i64>,
iss: Option<String>,
aud: Option<JwtAudienceClaim>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum JwtAudienceClaim {
Single(String),
Multiple(Vec<String>),
}
impl WebsocketAuthError {
pub(crate) fn status_code(&self) -> StatusCode {
self.status_code
}
pub(crate) fn message(&self) -> &'static str {
self.message
}
}
impl AppServerWebsocketAuthArgs {
pub fn try_into_settings(self) -> anyhow::Result<AppServerWebsocketAuthSettings> {
let normalize = |value: Option<String>| {
value.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
};
let config = match self.ws_auth {
Some(WebsocketAuthCliMode::CapabilityToken) => {
if self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
|| self.ws_max_clock_skew_seconds.is_some()
{
anyhow::bail!(
"`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`"
);
}
let token_file = self.ws_token_file.context(
"`--ws-token-file` is required when `--ws-auth capability-token` is set",
)?;
Some(AppServerWebsocketAuthConfig::CapabilityToken {
token_file: absolute_path_arg("--ws-token-file", token_file)?,
})
}
Some(WebsocketAuthCliMode::SignedBearerToken) => {
if self.ws_token_file.is_some() {
anyhow::bail!(
"`--ws-token-file` requires `--ws-auth capability-token`, not `signed-bearer-token`"
);
}
let shared_secret_file = self.ws_shared_secret_file.context(
"`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set",
)?;
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file: absolute_path_arg(
"--ws-shared-secret-file",
shared_secret_file,
)?,
issuer: normalize(self.ws_issuer),
audience: normalize(self.ws_audience),
max_clock_skew_seconds: self
.ws_max_clock_skew_seconds
.unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS),
})
}
None => {
if self.ws_token_file.is_some()
|| self.ws_shared_secret_file.is_some()
|| self.ws_issuer.is_some()
|| self.ws_audience.is_some()
|| self.ws_max_clock_skew_seconds.is_some()
{
anyhow::bail!(
"websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`"
);
}
None
}
};
Ok(AppServerWebsocketAuthSettings { config })
}
}
pub(crate) fn policy_from_settings(
settings: &AppServerWebsocketAuthSettings,
) -> io::Result<WebsocketAuthPolicy> {
let mode = match settings.config.as_ref() {
Some(AppServerWebsocketAuthConfig::CapabilityToken { token_file }) => {
let token = read_trimmed_secret(token_file.as_ref())?;
Some(WebsocketAuthMode::CapabilityToken {
token_sha256: sha256_digest(token.as_bytes()),
})
}
Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file,
issuer,
audience,
max_clock_skew_seconds,
}) => {
let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes();
validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?;
let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| {
io::Error::new(
ErrorKind::InvalidInput,
"websocket auth clock skew must fit in a signed 64-bit integer",
)
})?;
Some(WebsocketAuthMode::SignedBearerToken {
shared_secret,
issuer: issuer.clone(),
audience: audience.clone(),
max_clock_skew_seconds,
})
}
None => None,
};
Ok(WebsocketAuthPolicy { mode })
}
pub(crate) fn should_warn_about_unauthenticated_non_loopback_listener(
bind_address: SocketAddr,
policy: &WebsocketAuthPolicy,
) -> bool {
!bind_address.ip().is_loopback() && policy.mode.is_none()
}
pub(crate) fn authorize_upgrade(
headers: &HeaderMap,
policy: &WebsocketAuthPolicy,
) -> Result<(), WebsocketAuthError> {
let Some(mode) = policy.mode.as_ref() else {
return Ok(());
};
let token = bearer_token_from_headers(headers)?;
match mode {
WebsocketAuthMode::CapabilityToken { token_sha256 } => {
let actual_sha256 = sha256_digest(token.as_bytes());
if constant_time_eq_32(token_sha256, &actual_sha256) {
Ok(())
} else {
Err(unauthorized("invalid websocket bearer token"))
}
}
WebsocketAuthMode::SignedBearerToken {
shared_secret,
issuer,
audience,
max_clock_skew_seconds,
} => verify_signed_bearer_token(
token,
shared_secret,
issuer.as_deref(),
audience.as_deref(),
*max_clock_skew_seconds,
),
}
}
fn verify_signed_bearer_token(
token: &str,
shared_secret: &[u8],
issuer: Option<&str>,
audience: Option<&str>,
max_clock_skew_seconds: i64,
) -> Result<(), WebsocketAuthError> {
let claims = decode_jwt_claims(token, shared_secret)?;
validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds)
}
fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result<JwtClaims, WebsocketAuthError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.required_spec_claims.clear();
validation.validate_exp = false;
validation.validate_nbf = false;
validation.validate_aud = false;
decode::<JwtClaims>(token, &DecodingKey::from_secret(shared_secret), &validation)
.map(|token_data| token_data.claims)
.map_err(|_| unauthorized("invalid websocket jwt"))
}
fn validate_jwt_claims(
claims: &JwtClaims,
issuer: Option<&str>,
audience: Option<&str>,
max_clock_skew_seconds: i64,
) -> Result<(), WebsocketAuthError> {
let now = OffsetDateTime::now_utc().unix_timestamp();
if now > claims.exp.saturating_add(max_clock_skew_seconds) {
return Err(unauthorized("expired websocket jwt"));
}
if let Some(nbf) = claims.nbf
&& now < nbf.saturating_sub(max_clock_skew_seconds)
{
return Err(unauthorized("websocket jwt is not valid yet"));
}
if let Some(expected_issuer) = issuer
&& claims.iss.as_deref() != Some(expected_issuer)
{
return Err(unauthorized("websocket jwt issuer mismatch"));
}
if let Some(expected_audience) = audience
&& !audience_matches(claims.aud.as_ref(), expected_audience)
{
return Err(unauthorized("websocket jwt audience mismatch"));
}
Ok(())
}
fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool {
match audience {
Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience,
Some(JwtAudienceClaim::Multiple(actual)) => {
actual.iter().any(|audience| audience == expected_audience)
}
None => false,
}
}
fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> {
let raw_header = headers
.get(AUTHORIZATION)
.ok_or_else(|| unauthorized("missing websocket bearer token"))?;
let header = raw_header
.to_str()
.map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?;
let Some((scheme, token)) = header.split_once(' ') else {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
};
if !scheme.eq_ignore_ascii_case("Bearer") {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
}
let token = token.trim();
if token.is_empty() {
return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE));
}
Ok(token)
}
fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> {
if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES {
return Err(io::Error::new(
ErrorKind::InvalidInput,
format!(
"signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes",
path.display()
),
));
}
Ok(())
}
fn read_trimmed_secret(path: &std::path::Path) -> io::Result<String> {
let raw = std::fs::read_to_string(path).map_err(|err| {
io::Error::new(
err.kind(),
format!(
"failed to read websocket auth secret {}: {err}",
path.display()
),
)
})?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(io::Error::new(
ErrorKind::InvalidInput,
format!("websocket auth secret {} must not be empty", path.display()),
));
}
Ok(trimmed.to_string())
}
fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result<AbsolutePathBuf> {
AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path"))
}
fn sha256_digest(input: &[u8]) -> [u8; 32] {
let mut digest = [0u8; 32];
digest.copy_from_slice(&Sha256::digest(input));
digest
}
fn unauthorized(message: &'static str) -> WebsocketAuthError {
WebsocketAuthError {
status_code: StatusCode::UNAUTHORIZED,
message,
}
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use hmac::Hmac;
use hmac::Mac;
use serde_json::json;
type HmacSha256 = Hmac<Sha256>;
fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
let payload = format!("{header}.{claims_segment}");
let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap();
mac.update(payload.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{payload}.{signature}")
}
#[test]
fn warns_about_unauthenticated_non_loopback_listener() {
let policy = WebsocketAuthPolicy::default();
assert!(should_warn_about_unauthenticated_non_loopback_listener(
"0.0.0.0:8765".parse().unwrap(),
&policy,
));
assert!(!should_warn_about_unauthenticated_non_loopback_listener(
"127.0.0.1:8765".parse().unwrap(),
&policy,
));
assert!(!should_warn_about_unauthenticated_non_loopback_listener(
"0.0.0.0:8765".parse().unwrap(),
&WebsocketAuthPolicy {
mode: Some(WebsocketAuthMode::CapabilityToken {
token_sha256: [0u8; 32],
}),
},
));
}
#[test]
fn capability_token_args_require_token_file() {
let err = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::CapabilityToken),
..Default::default()
}
.try_into_settings()
.expect_err("capability-token mode should require a token file");
assert!(
err.to_string().contains("--ws-token-file"),
"unexpected error: {err}"
);
}
#[test]
fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() {
let err = AppServerWebsocketAuthArgs {
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
..Default::default()
}
.try_into_settings()
.expect_err("mode-specific flags should require --ws-auth");
assert!(
err.to_string().contains("websocket auth flags require"),
"unexpected error: {err}"
);
}
#[test]
fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() {
let settings = AppServerWebsocketAuthArgs {
ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken),
ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")),
ws_issuer: Some(" issuer ".to_string()),
ws_audience: Some(" ".to_string()),
..Default::default()
}
.try_into_settings()
.expect("signed bearer args should parse");
assert_eq!(
settings,
AppServerWebsocketAuthSettings {
config: Some(AppServerWebsocketAuthConfig::SignedBearerToken {
shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret")
.expect("absolute path"),
issuer: Some("issuer".to_string()),
audience: None,
max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS,
}),
}
);
}
#[test]
fn signed_bearer_token_verification_rejects_tampering() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
}),
);
let tampered = token.replace(".eyJleHAi", ".eyJleHBi");
let err = verify_signed_bearer_token(&tampered, shared_secret, None, None, 30)
.expect_err("tampered jwt should fail");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_token_verification_accepts_valid_token() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "issuer",
"aud": "audience",
}),
);
verify_signed_bearer_token(&token, shared_secret, Some("issuer"), Some("audience"), 30)
.expect("valid signed token should verify");
}
#[test]
fn signed_bearer_token_verification_accepts_multiple_audiences() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"aud": ["other-audience", "audience"],
}),
);
verify_signed_bearer_token(&token, shared_secret, None, Some("audience"), 30)
.expect("jwt audience arrays should verify");
}
#[test]
fn signed_bearer_token_verification_rejects_alg_none_tokens() {
let claims_segment = URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
}))
.unwrap(),
);
let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#);
let token = format!("{header_segment}.{claims_segment}.");
let err =
verify_signed_bearer_token(&token, b"0123456789abcdef0123456789abcdef", None, None, 30)
.expect_err("alg=none jwt should be rejected");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn signed_bearer_token_verification_rejects_missing_exp() {
let shared_secret = b"0123456789abcdef0123456789abcdef";
let token = signed_token(
shared_secret,
json!({
"iss": "issuer",
}),
);
let err = verify_signed_bearer_token(&token, shared_secret, None, None, 30)
.expect_err("jwt without exp should be rejected");
assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED);
}
#[test]
fn validate_signed_bearer_secret_rejects_short_secret() {
let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short")
.expect_err("short shared secret should be rejected");
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(
err.to_string().contains("must be at least 32 bytes"),
"unexpected error: {err}"
);
}
}
@@ -31,6 +31,8 @@ use codex_app_server_protocol::FsGetMetadataParams;
use codex_app_server_protocol::FsReadDirectoryParams;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAuthStatusParams;
@@ -463,6 +465,16 @@ impl McpProcess {
self.send_request("experimentalFeature/list", params).await
}
/// Send an `experimentalFeature/enablement/set` JSON-RPC request.
pub async fn send_experimental_feature_enablement_set_request(
&mut self,
params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("experimentalFeature/enablement/set", params)
.await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -790,6 +802,19 @@ impl McpProcess {
self.send_request("fs/copy", params).await
}
pub async fn send_fs_watch_request(&mut self, params: FsWatchParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/watch", params).await
}
pub async fn send_fs_unwatch_request(
&mut self,
params: FsUnwatchParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("fs/unwatch", params).await
}
/// Send an `account/logout` JSON-RPC request.
pub async fn send_logout_account_request(&mut self) -> anyhow::Result<i64> {
self.send_request("account/logout", /*params*/ None).await
@@ -2,6 +2,8 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
@@ -12,12 +14,16 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use futures::SinkExt;
use futures::StreamExt;
use hmac::Hmac;
use hmac::Mac;
use reqwest::StatusCode;
use serde_json::json;
use sha2::Sha256;
use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use tempfile::TempDir;
use time::OffsetDateTime;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
@@ -29,15 +35,17 @@ use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Error as WebSocketError;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
use tokio_tungstenite::tungstenite::http::header::ORIGIN;
pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
type HmacSha256 = Hmac<Sha256>;
#[tokio::test]
async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> {
@@ -112,46 +120,26 @@ async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Resul
}
#[tokio::test]
async fn websocket_transport_rejects_requests_with_origin_header() -> Result<()> {
async fn websocket_transport_rejects_browser_origin_without_auth() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
let client = reqwest::Client::new();
let deadline = Instant::now() + Duration::from_secs(10);
let healthz = loop {
match client
.get(format!("http://{bind_addr}/healthz"))
.header(ORIGIN.as_str(), "https://example.com")
.send()
.await
.with_context(|| format!("failed to GET http://{bind_addr}/healthz with Origin header"))
{
Ok(response) => break response,
Err(err) => {
if Instant::now() >= deadline {
bail!("failed to GET http://{bind_addr}/healthz with Origin header: {err}");
}
sleep(Duration::from_millis(50)).await;
}
}
};
assert_eq!(healthz.status(), StatusCode::FORBIDDEN);
let mut ws = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws, 1, "ws_loopback_client").await?;
let init = read_response_for_id(&mut ws, 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
drop(ws);
let url = format!("ws://{bind_addr}");
let mut request = url.into_client_request()?;
request
.headers_mut()
.insert(ORIGIN, HeaderValue::from_static("https://example.com"));
match connect_async(request).await {
Err(WebSocketError::Http(response)) => {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
Ok(_) => bail!("expected websocket handshake with Origin header to be rejected"),
Err(err) => bail!("expected HTTP rejection for Origin header, got {err}"),
}
assert_websocket_connect_rejected_with_headers(
bind_addr,
None,
Some("https://evil.example"),
StatusCode::FORBIDDEN,
)
.await?;
process
.kill()
@@ -160,12 +148,205 @@ async fn websocket_transport_rejects_requests_with_origin_header() -> Result<()>
Ok(())
}
#[tokio::test]
async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let token_file = codex_home.path().join("app-server-token");
std::fs::write(&token_file, "super-secret-token\n")?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let auth_args = vec![
"--ws-auth".to_string(),
"capability-token".to_string(),
"--ws-token-file".to_string(),
token_file.display().to_string(),
];
let (mut process, bind_addr) =
spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?;
assert_websocket_connect_rejected(bind_addr, None).await?;
assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?;
let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?;
send_initialize_request(&mut ws, 1, "ws_auth_client").await?;
let init = read_response_for_id(&mut ws, 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let shared_secret_file = codex_home.path().join("app-server-signing-secret");
let shared_secret = "0123456789abcdef0123456789abcdef";
std::fs::write(&shared_secret_file, format!("{shared_secret}\n"))?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let auth_args = vec![
"--ws-auth".to_string(),
"signed-bearer-token".to_string(),
"--ws-shared-secret-file".to_string(),
shared_secret_file.display().to_string(),
"--ws-issuer".to_string(),
"codex-enroller".to_string(),
"--ws-audience".to_string(),
"codex-app-server".to_string(),
"--ws-max-clock-skew-seconds".to_string(),
"1".to_string(),
];
let (mut process, bind_addr) =
spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?;
let expired_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() - 30,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(expired_token.as_str())).await?;
let malformed_token = "not-a-jwt";
assert_websocket_connect_rejected(bind_addr, Some(malformed_token)).await?;
let not_yet_valid_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"nbf": OffsetDateTime::now_utc().unix_timestamp() + 30,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(not_yet_valid_token.as_str())).await?;
let wrong_issuer_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "someone-else",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(wrong_issuer_token.as_str())).await?;
let wrong_audience_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "codex-enroller",
"aud": "wrong-audience",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(wrong_audience_token.as_str())).await?;
let wrong_signature_token = signed_bearer_token(
b"fedcba9876543210fedcba9876543210",
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
assert_websocket_connect_rejected(bind_addr, Some(wrong_signature_token.as_str())).await?;
let valid_token = signed_bearer_token(
shared_secret.as_bytes(),
json!({
"exp": OffsetDateTime::now_utc().unix_timestamp() + 60,
"iss": "codex-enroller",
"aud": "codex-app-server",
}),
)?;
let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?;
send_initialize_request(&mut ws, 1, "ws_signed_auth_client").await?;
let init = read_response_for_id(&mut ws, 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
#[tokio::test]
async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
let shared_secret_file = codex_home.path().join("app-server-signing-secret");
std::fs::write(&shared_secret_file, "too-short\n")?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let output = run_websocket_server_to_completion_with_args(
codex_home.path(),
"ws://127.0.0.1:0",
&[
"--ws-auth".to_string(),
"signed-bearer-token".to_string(),
"--ws-shared-secret-file".to_string(),
shared_secret_file.display().to_string(),
],
)
.await?;
assert!(
!output.status.success(),
"short shared secret should fail websocket server startup"
);
let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?;
assert!(
stderr.contains("must be at least 32 bytes"),
"unexpected stderr: {stderr}"
);
Ok(())
}
#[tokio::test]
async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_default() -> Result<()>
{
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let (mut process, bind_addr) =
spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]).await?;
let mut ws = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws, 1, "ws_non_loopback_default_client").await?;
let init = read_response_for_id(&mut ws, 1).await?;
assert_eq!(init.id, RequestId::Integer(1));
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> {
spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await
}
pub(super) async fn spawn_websocket_server_with_args(
codex_home: &Path,
listen_url: &str,
extra_args: &[String],
) -> Result<(Child, SocketAddr)> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find app-server binary")?;
let mut cmd = Command::new(program);
cmd.arg("--listen")
.arg("ws://127.0.0.1:0")
.arg(listen_url)
.args(extra_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
@@ -230,10 +411,18 @@ pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child,
}
pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result<WsClient> {
let url = format!("ws://{bind_addr}");
connect_websocket_with_bearer(bind_addr, None).await
}
pub(super) async fn connect_websocket_with_bearer(
bind_addr: SocketAddr,
bearer_token: Option<&str>,
) -> Result<WsClient> {
let url = format!("ws://{}", connectable_bind_addr(bind_addr));
let request = websocket_request(url.as_str(), bearer_token, None)?;
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match connect_async(&url).await {
match connect_async(request.clone()).await {
Ok((stream, _response)) => return Ok(stream),
Err(err) => {
if Instant::now() >= deadline {
@@ -245,23 +434,83 @@ pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result<WsClient>
}
}
async fn assert_websocket_connect_rejected(
bind_addr: SocketAddr,
bearer_token: Option<&str>,
) -> Result<()> {
assert_websocket_connect_rejected_with_headers(
bind_addr,
bearer_token,
None,
StatusCode::UNAUTHORIZED,
)
.await
}
async fn assert_websocket_connect_rejected_with_headers(
bind_addr: SocketAddr,
bearer_token: Option<&str>,
origin: Option<&str>,
expected_status: StatusCode,
) -> Result<()> {
let url = format!("ws://{}", connectable_bind_addr(bind_addr));
let request = websocket_request(url.as_str(), bearer_token, origin)?;
match connect_async(request).await {
Ok((_stream, response)) => {
bail!(
"expected websocket handshake rejection, got {}",
response.status()
)
}
Err(WsError::Http(response)) => {
assert_eq!(response.status(), expected_status);
Ok(())
}
Err(err) => bail!("expected http rejection during websocket handshake: {err}"),
}
}
async fn run_websocket_server_to_completion_with_args(
codex_home: &Path,
listen_url: &str,
extra_args: &[String],
) -> Result<std::process::Output> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find app-server binary")?;
let mut cmd = Command::new(program);
cmd.arg("--listen")
.arg(listen_url)
.args(extra_args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env("CODEX_HOME", codex_home)
.env("RUST_LOG", "debug");
timeout(Duration::from_secs(10), cmd.output())
.await
.context("timed out waiting for websocket app-server to exit")?
.context("failed to run websocket app-server")
}
async fn http_get(
client: &reqwest::Client,
bind_addr: SocketAddr,
path: &str,
) -> Result<reqwest::Response> {
let connectable_bind_addr = connectable_bind_addr(bind_addr);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match client
.get(format!("http://{bind_addr}{path}"))
.get(format!("http://{connectable_bind_addr}{path}"))
.send()
.await
.with_context(|| format!("failed to GET http://{bind_addr}{path}"))
.with_context(|| format!("failed to GET http://{connectable_bind_addr}{path}"))
{
Ok(response) => return Ok(response),
Err(err) => {
if Instant::now() >= deadline {
bail!("failed to GET http://{bind_addr}{path}: {err}");
bail!("failed to GET http://{connectable_bind_addr}{path}: {err}");
}
sleep(Duration::from_millis(50)).await;
}
@@ -269,6 +518,30 @@ async fn http_get(
}
}
fn websocket_request(
url: &str,
bearer_token: Option<&str>,
origin: Option<&str>,
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>> {
let mut request = url
.into_client_request()
.context("failed to create websocket request")?;
if let Some(bearer_token) = bearer_token {
request.headers_mut().insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {bearer_token}"))
.context("invalid bearer token header")?,
);
}
if let Some(origin) = origin {
request.headers_mut().insert(
ORIGIN,
HeaderValue::from_str(origin).context("invalid origin header")?,
);
}
Ok(request)
}
pub(super) async fn send_initialize_request(
stream: &mut WsClient,
id: i64,
@@ -459,3 +732,25 @@ stream_max_retries = 0
),
)
}
fn connectable_bind_addr(bind_addr: SocketAddr) -> SocketAddr {
match bind_addr {
SocketAddr::V4(addr) if addr.ip().is_unspecified() => {
SocketAddr::from(([127, 0, 0, 1], addr.port()))
}
SocketAddr::V6(addr) if addr.ip().is_unspecified() => {
SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], addr.port()))
}
_ => bind_addr,
}
}
fn signed_bearer_token(shared_secret: &[u8], claims: serde_json::Value) -> Result<String> {
let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?);
let payload = format!("{header_segment}.{claims_segment}");
let mut mac = HmacSha256::new_from_slice(shared_secret).context("failed to create hmac")?;
mac.update(payload.as_bytes());
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
Ok(format!("{payload}.{signature}"))
}
@@ -3,16 +3,24 @@ use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::config::ConfigBuilder;
use codex_features::FEATURES;
use codex_features::Stage;
use pretty_assertions::assert_eq;
use serde::de::DeserializeOwned;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let actual = to_response::<ExperimentalFeatureListResponse>(response)?;
let actual = read_response::<ExperimentalFeatureListResponse>(&mut mcp, request_id).await?;
let expected_data = FEATURES
.iter()
.map(|spec| {
@@ -82,3 +84,240 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu
assert_eq!(actual, expected);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads()
-> Result<()> {
let codex_home = TempDir::new()?;
let project_cwd = codex_home.path().join("project");
std::fs::create_dir_all(&project_cwd)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let actual =
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([("apps".to_string(), true)]),
}
);
for cwd in [None, Some(project_cwd.display().to_string())] {
let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(true))
);
}
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\napps = false\n",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let actual =
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([("apps".to_string(), true)]),
}
);
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(false))
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
let actual = set_experimental_feature_enablement(
&mut mcp,
BTreeMap::from([
("plugins".to_string(), true),
("tool_search".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
]),
)
.await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::from([
("plugins".to_string(), true),
("tool_search".to_string(), true),
("tool_suggest".to_string(), true),
("tool_call_mcp_elicitation".to_string(), false),
]),
}
);
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("plugins")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_search")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_suggest")),
Some(&json!(true))
);
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("tool_call_mcp_elicitation")),
Some(&json!(false))
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)]))
.await?;
let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?;
assert_eq!(
actual,
ExperimentalFeatureEnablementSetResponse {
enablement: BTreeMap::new(),
}
);
let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?;
assert_eq!(
config
.additional
.get("features")
.and_then(|features| features.get("apps")),
Some(&json!(true))
);
Ok(())
}
#[tokio::test]
async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement: BTreeMap::from([("personality".to_string(), true)]),
})
.await?;
let JSONRPCError { error, .. } = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(error.code, -32600);
assert!(
error
.message
.contains("unsupported feature enablement `personality`"),
"{}",
error.message
);
assert!(
error
.message
.contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"),
"{}",
error.message
);
Ok(())
}
async fn set_experimental_feature_enablement(
mcp: &mut McpProcess,
enablement: BTreeMap<String, bool>,
) -> Result<ExperimentalFeatureEnablementSetResponse> {
let request_id = mcp
.send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams {
enablement,
})
.await?;
read_response(mcp, request_id).await
}
async fn read_config(mcp: &mut McpProcess, cwd: Option<String>) -> Result<ConfigReadResponse> {
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: false,
cwd,
})
.await?;
read_response(mcp, request_id).await
}
async fn read_response<T: DeserializeOwned>(mcp: &mut McpProcess, request_id: i64) -> Result<T> {
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
+198
View File
@@ -4,11 +4,15 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsChangedNotification;
use codex_app_server_protocol::FsCopyParams;
use codex_app_server_protocol::FsGetMetadataResponse;
use codex_app_server_protocol::FsReadDirectoryEntry;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -17,6 +21,8 @@ use std::path::PathBuf;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
use uuid::Uuid;
use uuid::Version;
#[cfg(unix)]
use std::os::unix::fs::symlink;
@@ -611,3 +617,195 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications()
-> Result<()> {
let codex_home = TempDir::new()?;
let git_dir = codex_home.path().join("repo").join(".git");
let fetch_head = git_dir.join("FETCH_HEAD");
std::fs::create_dir_all(&git_dir)?;
std::fs::write(&fetch_head, "old\n")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_request_id = mcp
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
path: absolute_path(git_dir.clone()),
})
.await?;
let watch_response: FsWatchResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
)
.await??,
)?;
assert_eq!(watch_response.path, absolute_path(git_dir.clone()));
let watch_id = Uuid::parse_str(&watch_response.watch_id)?;
assert_eq!(watch_id.get_version(), Some(Version::SortRand));
std::fs::write(&fetch_head, "updated\n")?;
// Kernel file watching is not reliable in every sandboxed test environment.
// Keep validating notification shape when the backend does emit, but do not
// fail the whole suite if no OS event arrives.
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
assert_eq!(changed.watch_id, watch_response.watch_id.clone());
assert_eq!(
changed.changed_paths,
vec![absolute_path(fetch_head.clone())]
);
}
while timeout(
Duration::from_millis(200),
mcp.read_stream_until_notification_message("fs/changed"),
)
.await
.is_ok()
{}
let unwatch_request_id = mcp
.send_fs_unwatch_request(FsUnwatchParams {
watch_id: watch_response.watch_id,
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)),
)
.await??;
std::fs::write(git_dir.join("packed-refs"), "refs\n")?;
let maybe_notification = timeout(
Duration::from_millis(1500),
mcp.read_stream_until_notification_message("fs/changed"),
)
.await;
assert!(
maybe_notification.is_err(),
"fs/unwatch should stop future change notifications"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> {
let codex_home = TempDir::new()?;
let git_dir = codex_home.path().join("repo").join(".git");
let head_path = git_dir.join("HEAD");
std::fs::create_dir_all(&git_dir)?;
std::fs::write(&head_path, "ref: refs/heads/main\n")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_request_id = mcp
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
path: absolute_path(head_path.clone()),
})
.await?;
let watch_response: FsWatchResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
)
.await??,
)?;
assert_eq!(watch_response.path, absolute_path(head_path.clone()));
replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?;
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
assert_eq!(
changed,
FsChangedNotification {
watch_id: watch_response.watch_id,
changed_paths: vec![absolute_path(head_path.clone())],
}
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_allows_missing_file_targets() -> Result<()> {
let codex_home = TempDir::new()?;
let git_dir = codex_home.path().join("repo").join(".git");
let fetch_head = git_dir.join("FETCH_HEAD");
std::fs::create_dir_all(&git_dir)?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_request_id = mcp
.send_fs_watch_request(codex_app_server_protocol::FsWatchParams {
path: absolute_path(fetch_head.clone()),
})
.await?;
let watch_response: FsWatchResponse = to_response(
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)),
)
.await??,
)?;
assert_eq!(watch_response.path, absolute_path(fetch_head.clone()));
replace_file_atomically(&fetch_head, "origin/main\n")?;
if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? {
assert_eq!(
changed,
FsChangedNotification {
watch_id: watch_response.watch_id,
changed_paths: vec![absolute_path(fetch_head.clone())],
}
);
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_watch_rejects_relative_paths() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = initialized_mcp(&codex_home).await?;
let watch_id = mcp
.send_raw_request("fs/watch", Some(json!({ "path": "relative-path" })))
.await?;
expect_error_message(
&mut mcp,
watch_id,
"Invalid request: AbsolutePathBuf deserialized without a base path",
)
.await?;
Ok(())
}
fn fs_changed_notification(notification: JSONRPCNotification) -> Result<FsChangedNotification> {
let params = notification
.params
.context("fs/changed notification should include params")?;
Ok(serde_json::from_value::<FsChangedNotification>(params)?)
}
async fn maybe_fs_changed_notification(
mcp: &mut McpProcess,
) -> Result<Option<FsChangedNotification>> {
match timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("fs/changed"),
)
.await
{
Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)),
Err(_) => Ok(None),
}
}
fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> {
let temp_path = path.with_extension("lock");
std::fs::write(&temp_path, contents)?;
std::fs::rename(temp_path, path)?;
Ok(())
}
@@ -14,6 +14,7 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cargo_bin::cargo_bin;
use core_test_support::fs_wait;
use pretty_assertions::assert_eq;
@@ -30,6 +31,7 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -48,11 +50,13 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
};
let InitializeResponse {
user_agent,
codex_home: response_codex_home,
platform_family,
platform_os,
} = to_response::<InitializeResponse>(response)?;
assert!(user_agent.starts_with("codex_vscode/"));
assert_eq!(response_codex_home, expected_codex_home);
assert_eq!(platform_family, std::env::consts::FAMILY);
assert_eq!(platform_os, std::env::consts::OS);
Ok(())
@@ -63,6 +67,7 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
@@ -88,11 +93,13 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> {
};
let InitializeResponse {
user_agent,
codex_home: response_codex_home,
platform_family,
platform_os,
} = to_response::<InitializeResponse>(response)?;
assert!(user_agent.starts_with("codex_originator_via_env_var/"));
assert_eq!(response_codex_home, expected_codex_home);
assert_eq!(platform_family, std::env::consts::FAMILY);
assert_eq!(platform_os, std::env::consts::OS);
Ok(())
@@ -23,6 +23,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::RolloutItem;
@@ -959,7 +960,7 @@ async fn thread_list_includes_git_info() -> Result<()> {
create_minimal_config(codex_home.path())?;
let git_info = CoreGitInfo {
commit_hash: Some("abc123".to_string()),
commit_hash: Some(GitSha::new("abc123")),
branch: Some("main".to_string()),
repository_url: Some("https://example.com/repo.git".to_string()),
};
@@ -20,6 +20,7 @@ use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_core::state_db::reconcile_rollout;
use codex_git_utils::GitSha;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as RolloutGitInfo;
use codex_state::StateRuntime;
@@ -378,7 +379,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> {
"Thread preview",
Some("mock_provider"),
Some(RolloutGitInfo {
commit_hash: Some("abc123".to_string()),
commit_hash: Some(GitSha::new("abc123")),
branch: Some("feature/sidebar-pr".to_string()),
repository_url: Some("git@example.com:openai/codex.git".to_string()),
}),
+1 -1
View File
@@ -17,7 +17,7 @@ codex-utils-cargo-bin = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
codex-git = { workspace = true }
codex-git-utils = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
+4 -2
View File
@@ -2,6 +2,8 @@ use std::path::PathBuf;
use clap::Parser;
use codex_core::config::Config;
use codex_git_utils::ApplyGitRequest;
use codex_git_utils::apply_git_patch;
use codex_utils_cli::CliConfigOverrides;
use crate::chatgpt_token::init_chatgpt_token_from_auth;
@@ -57,13 +59,13 @@ pub async fn apply_diff_from_task(
async fn apply_diff(diff: &str, cwd: Option<PathBuf>) -> anyhow::Result<()> {
let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()));
let req = codex_git::ApplyGitRequest {
let req = ApplyGitRequest {
cwd,
diff: diff.to_string(),
revert: false,
preflight: false,
};
let res = codex_git::apply_git_patch(&req)?;
let res = apply_git_patch(&req)?;
if res.exit_code != 0 {
anyhow::bail!(
"Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}",
+2 -2
View File
@@ -260,7 +260,7 @@ async fn run_command_under_sandbox(
PathBuf::from("/usr/bin/sandbox-exec"),
args,
/*arg0*/ None,
cwd,
cwd.to_path_buf(),
network_policy,
env,
|env_map| {
@@ -293,7 +293,7 @@ async fn run_command_under_sandbox(
codex_linux_sandbox_exe,
args,
Some("codex-linux-sandbox"),
cwd,
cwd.to_path_buf(),
network_policy,
env,
|env_map| {
+139 -42
View File
@@ -208,6 +208,10 @@ struct ResumeCommand {
#[arg(long = "all", default_value_t = false)]
all: bool,
/// Include non-interactive sessions in the resume picker and --last selection.
#[arg(long = "include-non-interactive", default_value_t = false)]
include_non_interactive: bool,
#[clap(flatten)]
remote: InteractiveRemoteOptions,
@@ -349,6 +353,9 @@ struct AppServerCommand {
/// See https://developers.openai.com/codex/config-advanced/#metrics for more details.
#[arg(long = "analytics-default-enabled")]
analytics_default_enabled: bool,
#[command(flatten)]
auth: codex_app_server::AppServerWebsocketAuthArgs,
}
#[derive(Debug, clap::Subcommand)]
@@ -639,49 +646,59 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
mcp_cli.run().await?;
}
Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand {
None => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?;
let transport = app_server_cli.listen;
codex_app_server::run_main_with_transport(
arg0_paths.clone(),
root_config_overrides,
codex_core::config_loader::LoaderOverrides::default(),
app_server_cli.analytics_default_enabled,
transport,
codex_protocol::protocol::SessionSource::VSCode,
)
.await?;
Some(Subcommand::AppServer(app_server_cli)) => {
let AppServerCommand {
subcommand,
listen,
analytics_default_enabled,
auth,
} = app_server_cli;
match subcommand {
None => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?;
let transport = listen;
let auth = auth.try_into_settings()?;
codex_app_server::run_main_with_transport(
arg0_paths.clone(),
root_config_overrides,
codex_core::config_loader::LoaderOverrides::default(),
analytics_default_enabled,
transport,
codex_protocol::protocol::SessionSource::VSCode,
auth,
)
.await?;
}
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
"app-server generate-ts",
)?;
let options = codex_app_server_protocol::GenerateTsOptions {
experimental_api: gen_cli.experimental,
..Default::default()
};
codex_app_server_protocol::generate_ts_with_options(
&gen_cli.out_dir,
gen_cli.prettier.as_deref(),
options,
)?;
}
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
"app-server generate-json-schema",
)?;
codex_app_server_protocol::generate_json_with_experimental(
&gen_cli.out_dir,
gen_cli.experimental,
)?;
}
Some(AppServerSubcommand::GenerateInternalJsonSchema(gen_cli)) => {
codex_app_server_protocol::generate_internal_json_schema(&gen_cli.out_dir)?;
}
}
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
"app-server generate-ts",
)?;
let options = codex_app_server_protocol::GenerateTsOptions {
experimental_api: gen_cli.experimental,
..Default::default()
};
codex_app_server_protocol::generate_ts_with_options(
&gen_cli.out_dir,
gen_cli.prettier.as_deref(),
options,
)?;
}
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
reject_remote_mode_for_subcommand(
root_remote.as_deref(),
"app-server generate-json-schema",
)?;
codex_app_server_protocol::generate_json_with_experimental(
&gen_cli.out_dir,
gen_cli.experimental,
)?;
}
Some(AppServerSubcommand::GenerateInternalJsonSchema(gen_cli)) => {
codex_app_server_protocol::generate_internal_json_schema(&gen_cli.out_dir)?;
}
},
}
#[cfg(target_os = "macos")]
Some(Subcommand::App(app_cli)) => {
reject_remote_mode_for_subcommand(root_remote.as_deref(), "app")?;
@@ -691,6 +708,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
session_id,
last,
all,
include_non_interactive,
remote,
config_overrides,
})) => {
@@ -700,6 +718,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
session_id,
last,
all,
include_non_interactive,
config_overrides,
);
let exit_info = run_interactive_tui(
@@ -1169,6 +1188,7 @@ fn finalize_resume_interactive(
session_id: Option<String>,
last: bool,
show_all: bool,
include_non_interactive: bool,
resume_cli: TuiCli,
) -> TuiCli {
// Start with the parsed interactive CLI so resume shares the same
@@ -1178,6 +1198,7 @@ fn finalize_resume_interactive(
interactive.resume_last = last;
interactive.resume_session_id = resume_session_id;
interactive.resume_show_all = show_all;
interactive.resume_include_non_interactive = include_non_interactive;
// Merge resume-scoped flags and overrides with highest precedence.
merge_interactive_cli_flags(&mut interactive, resume_cli);
@@ -1290,6 +1311,7 @@ mod tests {
session_id,
last,
all,
include_non_interactive,
remote: _,
config_overrides: resume_cli,
}) = subcommand.expect("resume present")
@@ -1303,6 +1325,7 @@ mod tests {
session_id,
last,
all,
include_non_interactive,
resume_cli,
)
}
@@ -1498,6 +1521,15 @@ mod tests {
assert!(interactive.resume_show_all);
}
#[test]
fn resume_include_non_interactive_flag_sets_source_filter_override() {
let interactive =
finalize_resume_from_args(["codex", "resume", "--include-non-interactive"].as_ref());
assert!(interactive.resume_picker);
assert!(interactive.resume_include_non_interactive);
}
#[test]
fn resume_merges_option_flags_and_full_auto() {
let interactive = finalize_resume_from_args(
@@ -1682,6 +1714,71 @@ mod tests {
assert!(parse_result.is_err());
}
#[test]
fn app_server_capability_token_flags_parse() {
let app_server = app_server_from_args(
[
"codex",
"app-server",
"--ws-auth",
"capability-token",
"--ws-token-file",
"/tmp/codex-token",
]
.as_ref(),
);
assert_eq!(
app_server.auth.ws_auth,
Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken)
);
assert_eq!(
app_server.auth.ws_token_file,
Some(PathBuf::from("/tmp/codex-token"))
);
}
#[test]
fn app_server_signed_bearer_flags_parse() {
let app_server = app_server_from_args(
[
"codex",
"app-server",
"--ws-auth",
"signed-bearer-token",
"--ws-shared-secret-file",
"/tmp/codex-secret",
"--ws-issuer",
"issuer",
"--ws-audience",
"audience",
"--ws-max-clock-skew-seconds",
"9",
]
.as_ref(),
);
assert_eq!(
app_server.auth.ws_auth,
Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken)
);
assert_eq!(
app_server.auth.ws_shared_secret_file,
Some(PathBuf::from("/tmp/codex-secret"))
);
assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer"));
assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience"));
assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9));
}
#[test]
fn app_server_rejects_removed_insecure_non_loopback_flag() {
let parse_result = MultitoolCli::try_parse_from([
"codex",
"app-server",
"--allow-unauthenticated-non-loopback-ws",
]);
assert!(parse_result.is_err());
}
#[test]
fn features_enable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"])
+1 -1
View File
@@ -25,4 +25,4 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2.0.17"
codex-backend-client = { path = "../backend-client", optional = true }
codex-git = { workspace = true }
codex-git-utils = { workspace = true }
+4 -2
View File
@@ -16,6 +16,8 @@ use chrono::Utc;
use codex_backend_client as backend;
use codex_backend_client::CodeTaskDetailsResponseExt;
use codex_git_utils::ApplyGitRequest;
use codex_git_utils::apply_git_patch;
#[derive(Clone)]
pub struct HttpClient {
@@ -459,13 +461,13 @@ mod api {
});
}
let req = codex_git::ApplyGitRequest {
let req = ApplyGitRequest {
cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()),
diff: diff.clone(),
revert: false,
preflight,
};
let r = codex_git::apply_git_patch(&req)
let r = apply_git_patch(&req)
.map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?;
let status = if r.exit_code == 0 {
+1 -1
View File
@@ -27,4 +27,4 @@ pub use mock::MockClient;
#[cfg(feature = "online")]
pub use http::HttpClient;
// Reusable apply engine now lives in the shared crate `codex-git`.
// Reusable apply engine now lives in the shared crate `codex-git-utils`.
+1
View File
@@ -22,6 +22,7 @@ codex-cloud-tasks-client = { path = "../cloud-tasks-client", features = [
] }
codex-client = { workspace = true }
codex-core = { path = "../core" }
codex-git-utils = { workspace = true }
codex-login = { path = "../login" }
codex-tui = { path = "../tui" }
codex-utils-cli = { workspace = true }
+4 -2
View File
@@ -10,6 +10,8 @@ pub use cli::Cli;
use anyhow::anyhow;
use chrono::Utc;
use codex_cloud_tasks_client::TaskStatus;
use codex_git_utils::current_branch_name;
use codex_git_utils::default_branch_name;
use owo_colors::OwoColorize;
use owo_colors::Stream;
use std::cmp::Ordering;
@@ -119,11 +121,11 @@ struct RealGitInfo;
#[async_trait::async_trait]
impl GitInfoProvider for RealGitInfo {
async fn default_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::default_branch_name(path).await
default_branch_name(path).await
}
async fn current_branch_name(&self, path: &std::path::Path) -> Option<String> {
codex_core::git_info::current_branch_name(path).await
current_branch_name(path).await
}
}
+1
View File
@@ -14,6 +14,7 @@ codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
multimap = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_path_to_error = { workspace = true }
+7
View File
@@ -5,7 +5,9 @@ mod diagnostics;
mod fingerprint;
mod merge;
mod overrides;
mod project_root_markers;
mod requirements_exec_policy;
mod skills_config;
mod state;
pub const CONFIG_TOML_FILE: &str = "config.toml";
@@ -46,12 +48,17 @@ pub use diagnostics::io_error_from_config_error;
pub use fingerprint::version_for_toml;
pub use merge::merge_toml_values;
pub use overrides::build_cli_overrides_layer;
pub use project_root_markers::default_project_root_markers;
pub use project_root_markers::project_root_markers_from_config;
pub use requirements_exec_policy::RequirementsExecPolicy;
pub use requirements_exec_policy::RequirementsExecPolicyDecisionToml;
pub use requirements_exec_policy::RequirementsExecPolicyParseError;
pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml;
pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml;
pub use requirements_exec_policy::RequirementsExecPolicyToml;
pub use skills_config::BundledSkillsConfig;
pub use skills_config::SkillConfig;
pub use skills_config::SkillsConfig;
pub use state::ConfigLayerEntry;
pub use state::ConfigLayerStack;
pub use state::ConfigLayerStackOrdering;
@@ -0,0 +1,50 @@
use std::io;
use toml::Value as TomlValue;
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
/// Reads `project_root_markers` from a merged `config.toml` [toml::Value].
///
/// Invariants:
/// - If `project_root_markers` is not specified, returns `Ok(None)`.
/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where
/// `markers` is a `Vec<String>` (including `Ok(Some(Vec::new()))` for an
/// empty array, which indicates that root detection should be disabled).
/// - Returns an error if `project_root_markers` is specified but is not an
/// array of strings.
pub fn project_root_markers_from_config(config: &TomlValue) -> io::Result<Option<Vec<String>>> {
let Some(table) = config.as_table() else {
return Ok(None);
};
let Some(markers_value) = table.get("project_root_markers") else {
return Ok(None);
};
let TomlValue::Array(entries) = markers_value else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"project_root_markers must be an array of strings",
));
};
if entries.is_empty() {
return Ok(Some(Vec::new()));
}
let mut markers = Vec::new();
for entry in entries {
let Some(marker) = entry.as_str() else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"project_root_markers must be an array of strings",
));
};
markers.push(marker.to_string());
}
Ok(Some(markers))
}
pub fn default_project_root_markers() -> Vec<String> {
DEFAULT_PROJECT_ROOT_MARKERS
.iter()
.map(ToString::to_string)
.collect()
}
+53
View File
@@ -0,0 +1,53 @@
//! Skill-related configuration types shared across crates.
use codex_utils_absolute_path::AbsolutePathBuf;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
const fn default_enabled() -> bool {
true
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SkillConfig {
/// Path-based selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<AbsolutePathBuf>,
/// Name-based selector.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SkillsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bundled: Option<BundledSkillsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config: Vec<SkillConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct BundledSkillsConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
}
impl Default for BundledSkillsConfig {
fn default() -> Self {
Self { enabled: true }
}
}
impl TryFrom<toml::Value> for SkillsConfig {
type Error = toml::de::Error;
fn try_from(value: toml::Value) -> Result<Self, Self::Error> {
SkillsConfig::deserialize(value)
}
}
+15
View File
@@ -0,0 +1,15 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "core-skills",
crate_name = "codex_core_skills",
compile_data = glob(
include = ["**"],
exclude = [
"**/* *",
"BUILD.bazel",
"Cargo.toml",
],
allow_empty = True,
),
)
+40
View File
@@ -0,0 +1,40 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-core-skills"
version.workspace = true
[lib]
doctest = false
name = "codex_core_skills"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-analytics = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-config = { workspace = true }
codex-instructions = { workspace = true }
codex-login = { workspace = true }
codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-skills = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-plugins = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
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 }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
@@ -3,34 +3,32 @@ use std::path::Path;
use std::path::PathBuf;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::SkillConfig;
use codex_config::SkillsConfig;
use tracing::warn;
use crate::config::types::SkillConfig;
use crate::config::types::SkillsConfig;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::skills::SkillMetadata;
use crate::SkillMetadata;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub(crate) enum SkillConfigRuleSelector {
pub enum SkillConfigRuleSelector {
Name(String),
Path(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct SkillConfigRule {
pub struct SkillConfigRule {
pub selector: SkillConfigRuleSelector,
pub enabled: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
pub(crate) struct SkillConfigRules {
pub struct SkillConfigRules {
pub entries: Vec<SkillConfigRule>,
}
pub(crate) fn skill_config_rules_from_stack(
config_layer_stack: &ConfigLayerStack,
) -> SkillConfigRules {
pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> SkillConfigRules {
let mut entries = Vec::new();
for layer in config_layer_stack.get_layers(
ConfigLayerStackOrdering::LowestPrecedenceFirst,
@@ -71,7 +69,7 @@ pub(crate) fn skill_config_rules_from_stack(
SkillConfigRules { entries }
}
pub(crate) fn resolve_disabled_skill_paths(
pub fn resolve_disabled_skill_paths(
skills: &[SkillMetadata],
rules: &SkillConfigRules,
) -> HashSet<PathBuf> {
@@ -0,0 +1,30 @@
use crate::SkillMetadata;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillDependencyInfo {
pub skill_name: String,
pub name: String,
pub description: Option<String>,
}
pub fn collect_env_var_dependencies(
mentioned_skills: &[SkillMetadata],
) -> Vec<SkillDependencyInfo> {
let mut dependencies = Vec::new();
for skill in mentioned_skills {
let Some(skill_dependencies) = &skill.dependencies else {
continue;
};
for tool in &skill_dependencies.tools {
if tool.r#type != "env_var" || tool.value.is_empty() {
continue;
}
dependencies.push(SkillDependencyInfo {
skill_name: skill.name.clone(),
name: tool.value.clone(),
description: tool.description.clone(),
});
}
}
dependencies
}
@@ -2,26 +2,26 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use crate::analytics_client::AnalyticsEventsClient;
use crate::analytics_client::InvocationType;
use crate::analytics_client::SkillInvocation;
use crate::analytics_client::TrackEventsContext;
use crate::instructions::SkillInstructions;
use crate::mention_syntax::TOOL_MENTION_SIGIL;
use crate::mentions::build_skill_name_counts;
use crate::skills::SkillMetadata;
use crate::SkillMetadata;
use crate::build_skill_name_counts;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::InvocationType;
use codex_analytics::SkillInvocation;
use codex_analytics::TrackEventsContext;
use codex_instructions::SkillInstructions;
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;
#[derive(Debug, Default)]
pub(crate) struct SkillInjections {
pub(crate) items: Vec<ResponseItem>,
pub(crate) warnings: Vec<String>,
pub struct SkillInjections {
pub items: Vec<ResponseItem>,
pub warnings: Vec<String>,
}
pub(crate) async fn build_skill_injections(
pub async fn build_skill_injections(
mentioned_skills: &[SkillMetadata],
otel: Option<&SessionTelemetry>,
analytics_client: &AnalyticsEventsClient,
@@ -97,7 +97,7 @@ fn emit_skill_injected_metric(
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
pub(crate) fn collect_explicit_skill_mentions(
pub fn collect_explicit_skill_mentions(
inputs: &[UserInput],
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
@@ -159,7 +159,7 @@ struct SkillSelectionContext<'a> {
connector_slug_counts: &'a HashMap<String, usize>,
}
pub(crate) struct ToolMentions<'a> {
pub struct ToolMentions<'a> {
names: HashSet<&'a str>,
paths: HashSet<&'a str>,
plain_names: HashSet<&'a str>,
@@ -170,17 +170,17 @@ impl<'a> ToolMentions<'a> {
self.names.is_empty() && self.paths.is_empty()
}
pub(crate) fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
pub fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
self.plain_names.iter().copied()
}
pub(crate) fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
pub fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
self.paths.iter().copied()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ToolMentionKind {
pub enum ToolMentionKind {
App,
Mcp,
Plugin,
@@ -194,7 +194,7 @@ const PLUGIN_PATH_PREFIX: &str = "plugin://";
const SKILL_PATH_PREFIX: &str = "skill://";
const SKILL_FILENAME: &str = "SKILL.md";
pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind {
pub fn tool_kind_for_path(path: &str) -> ToolMentionKind {
if path.starts_with(APP_PATH_PREFIX) {
ToolMentionKind::App
} else if path.starts_with(MCP_PATH_PREFIX) {
@@ -213,12 +213,12 @@ fn is_skill_filename(path: &str) -> bool {
file_name.eq_ignore_ascii_case(SKILL_FILENAME)
}
pub(crate) fn app_id_from_path(path: &str) -> Option<&str> {
pub fn app_id_from_path(path: &str) -> Option<&str> {
path.strip_prefix(APP_PATH_PREFIX)
.filter(|value| !value.is_empty())
}
pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> {
pub fn plugin_config_name_from_path(path: &str) -> Option<&str> {
path.strip_prefix(PLUGIN_PATH_PREFIX)
.filter(|value| !value.is_empty())
}
@@ -232,11 +232,11 @@ pub(crate) fn normalize_skill_path(path: &str) -> &str {
/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a
/// resource path is present, it is captured for exact path matching while also tracking
/// the name for fallback matching.
pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL)
}
pub(crate) fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
let text_bytes = text.as_bytes();
let mut mentioned_names: HashSet<&str> = HashSet::new();
let mut mentioned_paths: HashSet<&str> = HashSet::new();
@@ -2,13 +2,8 @@ use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::analytics_client::InvocationType;
use crate::analytics_client::SkillInvocation;
use crate::analytics_client::build_track_events_context;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::skills::SkillLoadOutcome;
use crate::skills::SkillMetadata;
use crate::SkillLoadOutcome;
use crate::SkillMetadata;
pub(crate) fn build_implicit_skill_path_indexes(
skills: Vec<SkillMetadata>,
@@ -31,14 +26,12 @@ pub(crate) fn build_implicit_skill_path_indexes(
(by_scripts_dir, by_skill_doc_path)
}
fn detect_implicit_skill_invocation_for_command(
pub fn detect_implicit_skill_invocation_for_command(
outcome: &SkillLoadOutcome,
turn_context: &TurnContext,
command: &str,
workdir: Option<&str>,
workdir: &Path,
) -> Option<SkillMetadata> {
let workdir = turn_context.resolve_path(workdir.map(str::to_owned));
let workdir = normalize_path(workdir.as_path());
let workdir = normalize_path(workdir);
let tokens = tokenize_command(command);
if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), workdir.as_path())
@@ -46,82 +39,12 @@ fn detect_implicit_skill_invocation_for_command(
return Some(candidate);
}
if let Some(candidate) = detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path()) {
return Some(candidate);
}
None
}
pub(crate) async fn maybe_emit_implicit_skill_invocation(
sess: &Session,
turn_context: &TurnContext,
command: &str,
workdir: Option<&str>,
) {
let Some(candidate) = detect_implicit_skill_invocation_for_command(
&turn_context.turn_skills.outcome,
turn_context,
command,
workdir,
) else {
return;
};
let invocation = SkillInvocation {
skill_name: candidate.name,
skill_scope: candidate.scope,
skill_path: candidate.path_to_skills_md,
invocation_type: InvocationType::Implicit,
};
let skill_scope = match invocation.skill_scope {
codex_protocol::protocol::SkillScope::User => "user",
codex_protocol::protocol::SkillScope::Repo => "repo",
codex_protocol::protocol::SkillScope::System => "system",
codex_protocol::protocol::SkillScope::Admin => "admin",
};
let skill_path = invocation.skill_path.to_string_lossy();
let skill_name = invocation.skill_name.clone();
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
let inserted = {
let mut seen_skills = turn_context
.turn_skills
.implicit_invocation_seen_skills
.lock()
.await;
seen_skills.insert(seen_key)
};
if !inserted {
return;
}
turn_context.session_telemetry.counter(
"codex.skill.injected",
/*inc*/ 1,
&[
("status", "ok"),
("skill", skill_name.as_str()),
("invoke_type", "implicit"),
],
);
sess.services
.analytics_events_client
.track_skill_invocations(
build_track_events_context(
turn_context.model_info.slug.clone(),
sess.conversation_id.to_string(),
turn_context.sub_id.clone(),
),
vec![invocation],
);
detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path())
}
fn tokenize_command(command: &str) -> Vec<String> {
shlex::split(command).unwrap_or_else(|| {
command
.split_whitespace()
.map(std::string::ToString::to_string)
.collect()
})
shlex::split(command)
.unwrap_or_else(|| command.split_whitespace().map(str::to_string).collect())
}
fn script_run_token(tokens: &[String]) -> Option<&str> {
@@ -137,12 +60,9 @@ fn script_run_token(tokens: &[String]) -> Option<&str> {
return None;
}
let mut script_token: Option<&str> = None;
let mut script_token = None;
for token in tokens.iter().skip(1) {
if token == "--" {
continue;
}
if token.starts_with('-') {
if token == "--" || token.starts_with('-') {
continue;
}
script_token = Some(token.as_str());
@@ -1,22 +1,22 @@
pub(crate) mod config_rules;
pub mod config_rules;
mod env_var_dependencies;
pub mod injection;
pub(crate) mod invocation_utils;
pub mod loader;
pub mod manager;
mod mention_counts;
pub mod model;
pub mod remote;
pub mod render;
pub mod system;
pub(crate) use env_var_dependencies::collect_env_var_dependencies;
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
pub(crate) use injection::SkillInjections;
pub(crate) use injection::build_skill_injections;
pub(crate) use injection::collect_explicit_skill_mentions;
pub use env_var_dependencies::SkillDependencyInfo;
pub use env_var_dependencies::collect_env_var_dependencies;
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
pub use invocation_utils::detect_implicit_skill_invocation_for_command;
pub use manager::SkillsLoadInput;
pub use manager::SkillsManager;
pub use mention_counts::build_skill_name_counts;
pub use model::SkillError;
pub use model::SkillLoadOutcome;
pub use model::SkillMetadata;
@@ -1,19 +1,18 @@
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::config_loader::default_project_root_markers;
use crate::config_loader::merge_toml_values;
use crate::config_loader::project_root_markers_from_config;
use crate::plugins::plugin_namespace_for_skill_path;
use crate::skills::model::SkillDependencies;
use crate::skills::model::SkillError;
use crate::skills::model::SkillInterface;
use crate::skills::model::SkillLoadOutcome;
use crate::skills::model::SkillManagedNetworkOverride;
use crate::skills::model::SkillMetadata;
use crate::skills::model::SkillPolicy;
use crate::skills::model::SkillToolDependency;
use crate::skills::system::system_cache_root_dir;
use crate::model::SkillDependencies;
use crate::model::SkillError;
use crate::model::SkillInterface;
use crate::model::SkillLoadOutcome;
use crate::model::SkillManagedNetworkOverride;
use crate::model::SkillMetadata;
use crate::model::SkillPolicy;
use crate::model::SkillToolDependency;
use crate::system::system_cache_root_dir;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::default_project_root_markers;
use codex_config::merge_toml_values;
use codex_config::project_root_markers_from_config;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
use codex_protocol::models::NetworkPermissions;
@@ -21,6 +20,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_plugins::plugin_namespace_for_skill_path;
use dirs::home_dir;
use dunce::canonicalize as canonicalize_path;
use serde::Deserialize;
@@ -35,9 +35,6 @@ use std::path::PathBuf;
use toml::Value as TomlValue;
use tracing::error;
#[cfg(test)]
use crate::config::Config;
#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
#[serde(default)]
@@ -176,12 +173,12 @@ impl fmt::Display for SkillParseError {
impl Error for SkillParseError {}
pub(crate) struct SkillRoot {
pub(crate) path: PathBuf,
pub(crate) scope: SkillScope,
pub struct SkillRoot {
pub path: PathBuf,
pub scope: SkillScope,
}
pub(crate) fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
pub fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
where
I: IntoIterator<Item = SkillRoot>,
{
@@ -1,14 +1,9 @@
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::ConfigToml;
use crate::config::ProjectConfig;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use codex_config::CONFIG_TOML_FILE;
use codex_protocol::config_types::TrustLevel;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::MacOsAutomationPermission;
use codex_protocol::models::MacOsContactsPermission;
@@ -19,53 +14,109 @@ use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::Path;
use tempfile::TempDir;
use toml::Value as TomlValue;
const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex";
async fn make_config(codex_home: &TempDir) -> Config {
struct TestConfig {
cwd: PathBuf,
config_layer_stack: ConfigLayerStack,
}
async fn make_config(codex_home: &TempDir) -> TestConfig {
make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await
}
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config {
let trust_root = cwd
.ancestors()
.find(|ancestor| ancestor.join(".git").exists())
.map(Path::to_path_buf)
.unwrap_or_else(|| cwd.clone());
fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
toml::to_string(&ConfigToml {
projects: Some(HashMap::from([(
trust_root.to_string_lossy().to_string(),
ProjectConfig {
trust_level: Some(TrustLevel::Trusted),
},
)])),
..Default::default()
})
.expect("serialize config"),
)
.unwrap();
let harness_overrides = ConfigOverrides {
cwd: Some(cwd),
..Default::default()
};
ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(harness_overrides)
.build()
.await
.expect("defaults for test should always succeed")
fn config_file(path: PathBuf) -> AbsolutePathBuf {
AbsolutePathBuf::from_absolute_path(path).expect("config file path should be absolute")
}
fn load_skills_for_test(config: &Config) -> SkillLoadOutcome {
fn project_layers_for_cwd(cwd: &Path) -> Vec<ConfigLayerEntry> {
let cwd_dir = if cwd.is_dir() {
cwd.to_path_buf()
} else {
cwd.parent()
.expect("file cwd should have a parent directory")
.to_path_buf()
};
let project_root = cwd_dir
.ancestors()
.find(|ancestor| ancestor.join(".git").exists())
.unwrap_or(cwd_dir.as_path())
.to_path_buf();
let mut layers = cwd_dir
.ancestors()
.scan(false, |done, dir| {
if *done {
None
} else {
if dir == project_root {
*done = true;
}
Some(dir.to_path_buf())
}
})
.collect::<Vec<_>>();
layers.reverse();
layers
.into_iter()
.filter_map(|dir| {
let dot_codex = dir.join(REPO_ROOT_CONFIG_DIR_NAME);
dot_codex.is_dir().then(|| {
ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::from_absolute_path(dot_codex)
.expect("project .codex path should be absolute"),
},
TomlValue::Table(toml::map::Map::new()),
)
})
})
.collect()
}
async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig {
let user_config_path = codex_home.path().join(CONFIG_TOML_FILE);
let system_config_path = codex_home.path().join("etc/codex/config.toml");
fs::create_dir_all(
system_config_path
.parent()
.expect("system config path should have a parent"),
)
.expect("create fake system config dir");
let mut layers = vec![
ConfigLayerEntry::new(
ConfigLayerSource::System {
file: config_file(system_config_path),
},
TomlValue::Table(toml::map::Map::new()),
),
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_file(user_config_path),
},
TomlValue::Table(toml::map::Map::new()),
),
];
layers.extend(project_layers_for_cwd(&cwd));
TestConfig {
cwd,
config_layer_stack: ConfigLayerStack::new(
layers,
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack"),
}
}
fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome {
// Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`.
super::load_skills_from_roots(super::skill_roots_with_home_dir(
&config.config_layer_stack,
@@ -5,61 +5,67 @@ use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use codex_config::ConfigLayerStack;
use codex_protocol::protocol::Product;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::AbsolutePathBuf;
use toml::Value as TomlValue;
use tracing::info;
use tracing::warn;
use crate::config::Config;
use crate::config::types::SkillsConfig;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use crate::plugins::PluginsManager;
use crate::skills::SkillLoadOutcome;
use crate::skills::build_implicit_skill_path_indexes;
use crate::skills::config_rules::SkillConfigRules;
use crate::skills::config_rules::resolve_disabled_skill_paths;
use crate::skills::config_rules::skill_config_rules_from_stack;
use crate::skills::loader::SkillRoot;
use crate::skills::loader::load_skills_from_roots;
use crate::skills::loader::skill_roots;
use crate::skills::system::install_system_skills;
use crate::skills::system::uninstall_system_skills;
use crate::SkillLoadOutcome;
use crate::build_implicit_skill_path_indexes;
use crate::config_rules::SkillConfigRules;
use crate::config_rules::resolve_disabled_skill_paths;
use crate::config_rules::skill_config_rules_from_stack;
use crate::loader::SkillRoot;
use crate::loader::load_skills_from_roots;
use crate::loader::skill_roots;
use crate::system::install_system_skills;
use crate::system::uninstall_system_skills;
use codex_config::SkillsConfig;
#[derive(Debug, Clone)]
pub struct SkillsLoadInput {
pub cwd: PathBuf,
pub effective_skill_roots: Vec<PathBuf>,
pub config_layer_stack: ConfigLayerStack,
pub bundled_skills_enabled: bool,
}
impl SkillsLoadInput {
pub fn new(
cwd: PathBuf,
effective_skill_roots: Vec<PathBuf>,
config_layer_stack: ConfigLayerStack,
bundled_skills_enabled: bool,
) -> Self {
Self {
cwd,
effective_skill_roots,
config_layer_stack,
bundled_skills_enabled,
}
}
}
pub struct SkillsManager {
codex_home: PathBuf,
plugins_manager: Arc<PluginsManager>,
restriction_product: Option<Product>,
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, SkillLoadOutcome>>,
}
impl SkillsManager {
pub fn new(
codex_home: PathBuf,
plugins_manager: Arc<PluginsManager>,
bundled_skills_enabled: bool,
) -> Self {
Self::new_with_restriction_product(
codex_home,
plugins_manager,
bundled_skills_enabled,
Some(Product::Codex),
)
pub fn new(codex_home: PathBuf, bundled_skills_enabled: bool) -> Self {
Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex))
}
pub fn new_with_restriction_product(
codex_home: PathBuf,
plugins_manager: Arc<PluginsManager>,
bundled_skills_enabled: bool,
restriction_product: Option<Product>,
) -> Self {
let manager = Self {
codex_home,
plugins_manager,
restriction_product,
cache_by_cwd: RwLock::new(HashMap::new()),
cache_by_config: RwLock::new(HashMap::new()),
@@ -80,9 +86,9 @@ impl SkillsManager {
/// This path uses a cache keyed by the effective skill-relevant config state rather than just
/// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen
/// to share a directory.
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
let roots = self.skill_roots_for_config(config);
let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack);
pub fn skills_for_config(&self, input: &SkillsLoadInput) -> SkillLoadOutcome {
let roots = self.skill_roots_for_config(input);
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
let cache_key = config_skills_cache_key(&roots, &skill_config_rules);
if let Some(outcome) = self.cached_outcome_for_config(&cache_key) {
return outcome;
@@ -97,14 +103,13 @@ impl SkillsManager {
outcome
}
pub(crate) fn skill_roots_for_config(&self, config: &Config) -> Vec<SkillRoot> {
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
pub fn skill_roots_for_config(&self, input: &SkillsLoadInput) -> Vec<SkillRoot> {
let mut roots = skill_roots(
&config.config_layer_stack,
&config.cwd,
loaded_plugins.effective_skill_roots(),
&input.config_layer_stack,
input.cwd.as_path(),
input.effective_skill_roots.clone(),
);
if !config.bundled_skills_enabled() {
if !input.bundled_skills_enabled {
roots.retain(|root| root.scope != SkillScope::System);
}
roots
@@ -112,74 +117,34 @@ impl SkillsManager {
pub async fn skills_for_cwd(
&self,
cwd: &Path,
config: &Config,
input: &SkillsLoadInput,
force_reload: bool,
) -> SkillLoadOutcome {
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(input.cwd.as_path()) {
return outcome;
}
self.skills_for_cwd_with_extra_user_roots(cwd, config, force_reload, &[])
self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[])
.await
}
pub async fn skills_for_cwd_with_extra_user_roots(
&self,
cwd: &Path,
config: &Config,
input: &SkillsLoadInput,
force_reload: bool,
extra_user_roots: &[PathBuf],
) -> SkillLoadOutcome {
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(input.cwd.as_path()) {
return outcome;
}
let normalized_extra_user_roots = normalize_extra_user_roots(extra_user_roots);
let cwd_abs = match AbsolutePathBuf::try_from(cwd) {
Ok(cwd_abs) => cwd_abs,
Err(err) => {
return SkillLoadOutcome {
errors: vec![crate::skills::model::SkillError {
path: cwd.to_path_buf(),
message: err.to_string(),
}],
..Default::default()
};
}
};
let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
let config_layer_stack = match load_config_layers_state(
&self.codex_home,
Some(cwd_abs),
&cli_overrides,
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
return SkillLoadOutcome {
errors: vec![crate::skills::model::SkillError {
path: cwd.to_path_buf(),
message: err.to_string(),
}],
..Default::default()
};
}
};
let loaded_plugins = self
.plugins_manager
.plugins_for_config_with_force_reload(config, force_reload);
let mut roots = skill_roots(
&config_layer_stack,
cwd,
loaded_plugins.effective_skill_roots(),
&input.config_layer_stack,
input.cwd.as_path(),
input.effective_skill_roots.clone(),
);
if !bundled_skills_enabled_from_stack(&config_layer_stack) {
if !bundled_skills_enabled_from_stack(&input.config_layer_stack) {
roots.retain(|root| root.scope != SkillScope::System);
}
roots.extend(
@@ -191,13 +156,13 @@ impl SkillsManager {
scope: SkillScope::User,
}),
);
let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack);
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
let mut cache = self
.cache_by_cwd
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
cache.insert(cwd.to_path_buf(), outcome.clone());
cache.insert(input.cwd.clone(), outcome.clone());
outcome
}
@@ -206,7 +171,7 @@ impl SkillsManager {
roots: Vec<SkillRoot>,
skill_config_rules: &SkillConfigRules,
) -> SkillLoadOutcome {
let outcome = crate::skills::filter_skill_load_outcome_for_product(
let outcome = crate::filter_skill_load_outcome_for_product(
load_skills_from_roots(roots),
self.restriction_product,
);
@@ -261,8 +226,8 @@ struct ConfigSkillsCacheKey {
skill_config_rules: SkillConfigRules,
}
pub(crate) fn bundled_skills_enabled_from_stack(
config_layer_stack: &crate::config_loader::ConfigLayerStack,
pub fn bundled_skills_enabled_from_stack(
config_layer_stack: &codex_config::ConfigLayerStack,
) -> bool {
let effective_config = config_layer_stack.effective_config();
let Some(skills_value) = effective_config
@@ -1,15 +1,15 @@
use super::*;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::PluginsManager;
use crate::skills::SkillMetadata;
use crate::skills::config_rules::resolve_disabled_skill_paths;
use crate::skills::config_rules::skill_config_rules_from_stack;
use crate::SkillMetadata;
use crate::config_rules::resolve_disabled_skill_paths;
use crate::config_rules::skill_config_rules_from_stack;
use codex_app_server_protocol::ConfigLayerSource;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirementsToml;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -64,6 +64,77 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata {
}
}
fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntry {
let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE))
.expect("user config path should be absolute");
ConfigLayerEntry::new(
ConfigLayerSource::User { file: config_path },
toml::from_str(config_toml).expect("user layer toml"),
)
}
fn config_stack(codex_home: &TempDir, user_config_toml: &str) -> ConfigLayerStack {
ConfigLayerStack::new(
vec![user_config_layer(codex_home, user_config_toml)],
Default::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack")
}
fn config_stack_with_session_flags(
codex_home: &TempDir,
user_config_toml: &str,
session_flags_toml: &str,
) -> ConfigLayerStack {
ConfigLayerStack::new(
vec![
user_config_layer(codex_home, user_config_toml),
ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(session_flags_toml).expect("session layer toml"),
),
],
Default::default(),
ConfigRequirementsToml::default(),
)
.expect("valid config layer stack")
}
fn path_toggle_config(path: &std::path::Path, enabled: bool) -> String {
format!(
r#"[[skills.config]]
path = "{}"
enabled = {enabled}
"#,
path.display()
)
}
fn name_toggle_config(name: &str, enabled: bool) -> String {
format!(
r#"[[skills.config]]
name = "{name}"
enabled = {enabled}
"#
)
}
fn skills_for_config_with_stack(
skills_manager: &SkillsManager,
cwd: &TempDir,
config_layer_stack: &ConfigLayerStack,
effective_skill_roots: &[PathBuf],
) -> SkillLoadOutcome {
let skills_input = SkillsLoadInput::new(
cwd.path().to_path_buf(),
effective_skill_roots.to_vec(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(config_layer_stack),
);
skills_manager.skills_for_config(&skills_input)
}
#[test]
fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
let codex_home = tempfile::tempdir().expect("tempdir");
@@ -72,9 +143,7 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n")
.expect("write stale system skill");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let _skills_manager =
SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, false);
let _skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false);
assert!(
!codex_home.path().join("skills/.system").exists(),
@@ -86,22 +155,11 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
async fn skills_for_config_reuses_cache_for_same_effective_config() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
let cfg = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("defaults for test should always succeed");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
let config_layer_stack = config_stack(&codex_home, "");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
write_user_skill(&codex_home, "a", "skill-a", "from a");
let outcome1 = skills_manager.skills_for_config(&cfg);
let outcome1 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
assert!(
outcome1.skills.iter().any(|s| s.name == "skill-a"),
"expected skill-a to be discovered"
@@ -110,7 +168,7 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() {
// Write a new skill after the first call; the second call should reuse the config-aware cache
// entry because the effective skill config is unchanged.
write_user_skill(&codex_home, "b", "skill-b", "from b");
let outcome2 = skills_manager.skills_for_config(&cfg);
let outcome2 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
assert_eq!(outcome2.errors, outcome1.errors);
assert_eq!(outcome2.skills, outcome1.skills);
}
@@ -127,39 +185,23 @@ async fn skills_for_config_disables_plugin_skills_by_name() {
"sample-search",
"search sample data",
);
fs::write(
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
r#"[features]
plugins = true
[[skills.config]]
name = "sample:sample-search"
enabled = false
[plugins."sample@test"]
enabled = true
"#,
)
.expect("write config");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("load config");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(
codex_home.path().to_path_buf(),
plugins_manager,
config.bundled_skills_enabled(),
let config_layer_stack = config_stack(
&codex_home,
&name_toggle_config("sample:sample-search", false),
);
let plugin_skill_root = skill_path
.parent()
.and_then(std::path::Path::parent)
.expect("plugin skill should live under a skills root")
.to_path_buf();
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
let outcome = skills_manager.skills_for_config(&config);
let outcome = skills_for_config_with_stack(
&skills_manager,
&cwd,
&config_layer_stack,
&[plugin_skill_root],
);
let skill = outcome
.skills
.iter()
@@ -182,27 +224,21 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
let codex_home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("tempdir");
let extra_root = tempfile::tempdir().expect("tempdir");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("defaults for test should always succeed");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
let _ = skills_manager.skills_for_config(&config);
let config_layer_stack = config_stack(&codex_home, "");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
write_user_skill(&extra_root, "x", "extra-skill", "from extra root");
let extra_root_path = extra_root.path().to_path_buf();
let base_input = SkillsLoadInput::new(
cwd.path().to_path_buf(),
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
);
let outcome_with_extra = skills_manager
.skills_for_cwd_with_extra_user_roots(
cwd.path(),
&config,
&base_input,
true,
std::slice::from_ref(&extra_root_path),
)
@@ -222,9 +258,13 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
// The cwd-only API returns the current cached entry for this cwd, even when that entry
// was produced with extra roots.
let outcome_without_extra = skills_manager
.skills_for_cwd(cwd.path(), &config, false)
.await;
let base_input = SkillsLoadInput::new(
cwd.path().to_path_buf(),
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
);
let outcome_without_extra = skills_manager.skills_for_cwd(&base_input, false).await;
assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills);
assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors);
}
@@ -240,29 +280,8 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
"---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n",
)
.expect("write bundled skill");
fs::write(
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
"[skills.bundled]\nenabled = false\n",
)
.expect("write config");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("load config");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(
codex_home.path().to_path_buf(),
plugins_manager,
config.bundled_skills_enabled(),
);
let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false);
// Recreate the cached bundled skill after startup cleanup so this assertion exercises
// root selection rather than relying on directory removal succeeding.
@@ -273,7 +292,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
)
.expect("rewrite bundled skill");
let outcome = skills_manager.skills_for_config(&config);
let outcome = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
assert!(
outcome
.skills
@@ -294,29 +313,23 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
let cwd = tempfile::tempdir().expect("tempdir");
let extra_root_a = tempfile::tempdir().expect("tempdir");
let extra_root_b = tempfile::tempdir().expect("tempdir");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("defaults for test should always succeed");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
let _ = skills_manager.skills_for_config(&config);
let config_layer_stack = config_stack(&codex_home, "");
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]);
write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a");
write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b");
let extra_root_a_path = extra_root_a.path().to_path_buf();
let base_input = SkillsLoadInput::new(
cwd.path().to_path_buf(),
Vec::new(),
config_layer_stack.clone(),
bundled_skills_enabled_from_stack(&config_layer_stack),
);
let outcome_a = skills_manager
.skills_for_cwd_with_extra_user_roots(
cwd.path(),
&config,
&base_input,
true,
std::slice::from_ref(&extra_root_a_path),
)
@@ -337,8 +350,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
let extra_root_b_path = extra_root_b.path().to_path_buf();
let outcome_b = skills_manager
.skills_for_cwd_with_extra_user_roots(
cwd.path(),
&config,
&base_input,
false,
std::slice::from_ref(&extra_root_b_path),
)
@@ -358,8 +370,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
let outcome_reloaded = skills_manager
.skills_for_cwd_with_extra_user_roots(
cwd.path(),
&config,
&base_input,
true,
std::slice::from_ref(&extra_root_b_path),
)
@@ -399,25 +410,11 @@ fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() {
.expect("user config path should be absolute");
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
toml::from_str(&format!(
r#"[[skills.config]]
path = "{}"
enabled = false
"#,
skill_path.display()
))
.expect("user layer toml"),
toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"),
);
let session_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(&format!(
r#"[[skills.config]]
path = "{}"
enabled = true
"#,
skill_path.display()
))
.expect("session layer toml"),
toml::from_str(&path_toggle_config(&skill_path, true)).expect("session layer toml"),
);
let stack = ConfigLayerStack::new(
vec![user_layer, session_layer],
@@ -443,25 +440,11 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill(
.expect("user config path should be absolute");
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
toml::from_str(&format!(
r#"[[skills.config]]
path = "{}"
enabled = true
"#,
skill_path.display()
))
.expect("user layer toml"),
toml::from_str(&path_toggle_config(&skill_path, true)).expect("user layer toml"),
);
let session_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(&format!(
r#"[[skills.config]]
path = "{}"
enabled = false
"#,
skill_path.display()
))
.expect("session layer toml"),
toml::from_str(&path_toggle_config(&skill_path, false)).expect("session layer toml"),
);
let stack = ConfigLayerStack::new(
vec![user_layer, session_layer],
@@ -487,13 +470,7 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() {
.expect("user config path should be absolute");
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
toml::from_str(
r#"[[skills.config]]
name = "github:yeet"
enabled = false
"#,
)
.expect("user layer toml"),
toml::from_str(&name_toggle_config("github:yeet", false)).expect("user layer toml"),
);
let stack = ConfigLayerStack::new(
vec![user_layer],
@@ -519,24 +496,11 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() {
.expect("user config path should be absolute");
let user_layer = ConfigLayerEntry::new(
ConfigLayerSource::User { file: user_file },
toml::from_str(&format!(
r#"[[skills.config]]
path = "{}"
enabled = false
"#,
skill_path.display()
))
.expect("user layer toml"),
toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"),
);
let session_layer = ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(
r#"[[skills.config]]
name = "github:yeet"
enabled = true
"#,
)
.expect("session layer toml"),
toml::from_str(&name_toggle_config("github:yeet", true)).expect("session layer toml"),
);
let stack = ConfigLayerStack::new(
vec![user_layer, session_layer],
@@ -565,58 +529,20 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill()
"---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n",
)
.expect("write skill");
fs::write(
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
format!(
r#"[[skills.config]]
path = "{}"
enabled = false
"#,
skill_path.display()
),
)
.expect("write config");
let parent_config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
})
.build()
.await
.expect("load parent config");
let role_path = codex_home.path().join("enable-role.toml");
fs::write(
&role_path,
format!(
r#"[[skills.config]]
path = "{}"
enabled = true
"#,
skill_path.display()
),
)
.expect("write role config");
let mut child_config = parent_config.clone();
child_config.agent_roles.insert(
"custom".to_string(),
crate::config::AgentRoleConfig {
description: None,
config_file: Some(role_path),
nickname_candidates: None,
},
let disabled_skill_config = path_toggle_config(&skill_path, false);
let enabled_skill_config = path_toggle_config(&skill_path, true);
let parent_stack = config_stack(&codex_home, &disabled_skill_config);
let child_stack =
config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config);
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true);
let parent_input = SkillsLoadInput::new(
cwd.path().to_path_buf(),
Vec::new(),
parent_stack.clone(),
bundled_skills_enabled_from_stack(&parent_stack),
);
crate::agent::role::apply_role_to_config(&mut child_config, Some("custom"))
.await
.expect("custom role should apply");
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
let parent_outcome = skills_manager
.skills_for_cwd(cwd.path(), &parent_config, true)
.await;
let parent_outcome = skills_manager.skills_for_cwd(&parent_input, true).await;
let parent_skill = parent_outcome
.skills
.iter()
@@ -624,7 +550,7 @@ enabled = true
.expect("demo skill should be discovered");
assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false);
let child_outcome = skills_manager.skills_for_config(&child_config);
let child_outcome = skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]);
let child_skill = child_outcome
.skills
.iter()
@@ -0,0 +1,24 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use super::SkillMetadata;
/// Counts how often each skill name appears (exact and ASCII-lowercase), excluding disabled paths.
pub fn build_skill_name_counts(
skills: &[SkillMetadata],
disabled_paths: &HashSet<PathBuf>,
) -> (HashMap<String, usize>, HashMap<String, usize>) {
let mut exact_counts: HashMap<String, usize> = HashMap::new();
let mut lower_counts: HashMap<String, usize> = HashMap::new();
for skill in skills {
if disabled_paths.contains(&skill.path_to_skills_md) {
continue;
}
*exact_counts.entry(skill.name.clone()).or_insert(0) += 1;
*lower_counts
.entry(skill.name.to_ascii_lowercase())
.or_insert(0) += 1;
}
(exact_counts, lower_counts)
}
@@ -6,9 +6,8 @@ use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use crate::auth::CodexAuth;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30);
@@ -88,13 +87,13 @@ struct RemoteSkill {
}
pub async fn list_remote_skills(
config: &Config,
chatgpt_base_url: String,
auth: Option<&CodexAuth>,
scope: RemoteSkillScope,
product_surface: RemoteSkillProductSurface,
enabled: Option<bool>,
) -> Result<Vec<RemoteSkillSummary>> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let base_url = chatgpt_base_url.trim_end_matches('/');
let auth = ensure_chatgpt_auth(auth)?;
let url = format!("{base_url}/hazelnuts");
@@ -146,14 +145,15 @@ pub async fn list_remote_skills(
}
pub async fn export_remote_skill(
config: &Config,
chatgpt_base_url: String,
codex_home: PathBuf,
auth: Option<&CodexAuth>,
skill_id: &str,
) -> Result<RemoteSkillDownloadResult> {
let auth = ensure_chatgpt_auth(auth)?;
let client = build_reqwest_client();
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let base_url = chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/hazelnuts/{skill_id}/export");
let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT);
@@ -181,7 +181,7 @@ pub async fn export_remote_skill(
anyhow::bail!("Downloaded remote skill payload is not a zip archive");
}
let output_dir = config.codex_home.join("skills").join(skill_id);
let output_dir = codex_home.join("skills").join(skill_id);
tokio::fs::create_dir_all(&output_dir)
.await
.context("Failed to create downloaded skills directory")?;
@@ -1,4 +1,4 @@
use crate::skills::model::SkillMetadata;
use crate::model::SkillMetadata;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
+9 -10
View File
@@ -27,6 +27,7 @@ bm25 = { workspace = true }
chardetng = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, features = ["derive"] }
codex-analytics = { workspace = true }
codex-api = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-apply-patch = { workspace = true }
@@ -34,19 +35,21 @@ codex-async-utils = { workspace = true }
codex-code-mode = { workspace = true }
codex-connectors = { workspace = true }
codex-config = { workspace = true }
codex-core-skills = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }
codex-shell-command = { workspace = true }
codex-skills = { workspace = true }
codex-execpolicy = { workspace = true }
codex-file-search = { workspace = true }
codex-git = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-instructions = { workspace = true }
codex-network-proxy = { workspace = true }
codex-otel = { workspace = true }
codex-artifacts = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-rollout = { workspace = true }
codex-rmcp-client = { workspace = true }
codex-sandboxing = { workspace = true }
codex-state = { workspace = true }
@@ -55,6 +58,9 @@ codex-utils-absolute-path = { workspace = true }
codex-utils-cache = { workspace = true }
codex-utils-image = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-output-truncation = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-plugins = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-secrets = { workspace = true }
@@ -87,19 +93,12 @@ rmcp = { workspace = true, default-features = false, features = [
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sha1 = { workspace = true }
shlex = { workspace = true }
similar = { workspace = true }
tempfile = { workspace = true }
test-log = { workspace = true }
thiserror = { workspace = true }
time = { workspace = true, features = [
"formatting",
"parsing",
"local-offset",
"macros",
] }
tokio = { workspace = true, features = [
"io-std",
"macros",
+9 -5
View File
@@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb
Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux.
They can continue to use the legacy Landlock path when the split filesystem
policy is sandbox-equivalent to the legacy model after `cwd` resolution.
Split filesystem policies that need direct `FileSystemSandboxPolicy`
enforcement, such as read-only or denied carveouts under a broader writable
root, automatically route through bubblewrap. The legacy Landlock path is used
@@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as
`C:\ProgramData`. When it is `false`, those extra system roots are omitted.
The unelevated restricted-token backend still supports the legacy full-read
Windows model only. Restricted read-only policies continue to fail closed there
instead of running with weaker read enforcement.
Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also
supports a narrow split-filesystem subset: full-read split policies whose
writable roots still match the legacy `WorkspaceWrite` root set, but add extra
read-only carveouts under those writable roots.
New `[permissions]` / split filesystem policies remain supported on Windows
only when they round-trip through the legacy `SandboxPolicy` model without
changing semantics. Richer split-only carveouts still fail closed instead of
running with weaker enforcement.
changing semantics. Policies that would require direct read restriction,
explicit unreadable carveouts, reopened writable descendants under read-only
carveouts, different writable root sets, or split carveout support in the
elevated setup/runner backend still fail closed instead of running with weaker
enforcement.
### All Platforms
+6
View File
@@ -485,6 +485,9 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_search": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
@@ -2099,6 +2102,9 @@
"tool_call_mcp_elicitation": {
"type": "boolean"
},
"tool_search": {
"type": "boolean"
},
"tool_suggest": {
"type": "boolean"
},
+1 -1
View File
@@ -252,7 +252,7 @@ mod reload {
fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides {
ConfigOverrides {
cwd: Some(config.cwd.clone()),
cwd: Some(config.cwd.to_path_buf()),
model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
+7 -3
View File
@@ -1,9 +1,10 @@
use super::*;
use crate::SkillsManager;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config_loader::ConfigLayerStackOrdering;
use crate::plugins::PluginsManager;
use crate::skills::SkillsManager;
use crate::skills_load_input_from_config;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Verbosity;
use codex_protocol::openai_models::ReasoningEffort;
@@ -629,8 +630,11 @@ enabled = false
.expect("custom role should apply");
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager, true);
let outcome = skills_manager.skills_for_config(&config);
let skills_manager = SkillsManager::new(home.path().to_path_buf(), true);
let plugin_outcome = plugins_manager.plugins_for_config(&config);
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let outcome = skills_manager.skills_for_config(&skills_input);
let skill = outcome
.skills
.iter()
+65 -3
View File
@@ -1,10 +1,72 @@
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_app_server_protocol::AppInfo;
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
pub(crate) fn render_apps_section() -> String {
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
if !connectors
.iter()
.any(|connector| connector.is_accessible && connector.is_enabled)
{
return None;
}
let body = format!(
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
);
format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}")
Some(format!(
"{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}"
))
}
#[cfg(test)]
mod tests {
use super::*;
fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible,
is_enabled,
plugin_display_names: Vec::new(),
}
}
#[test]
fn omits_apps_section_without_accessible_and_enabled_apps() {
assert_eq!(render_apps_section(&[]), None);
assert_eq!(
render_apps_section(&[connector(
"calendar", /*is_accessible*/ true, /*is_enabled*/ false
)]),
None
);
assert_eq!(
render_apps_section(&[connector(
"calendar", /*is_accessible*/ false, /*is_enabled*/ true
)]),
None
);
}
#[test]
fn renders_apps_section_with_an_accessible_and_enabled_app() {
let rendered = render_apps_section(&[connector(
"calendar", /*is_accessible*/ true, /*is_enabled*/ true,
)])
.expect("expected apps section");
assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG));
assert!(rendered.contains("## Apps (Connectors)"));
assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG));
}
}
+146 -77
View File
@@ -12,10 +12,6 @@ use crate::SandboxState;
use crate::agent::AgentControl;
use crate::agent::AgentStatus;
use crate::agent::agent_status_from_event;
use crate::analytics_client::AnalyticsEventsClient;
use crate::analytics_client::AppInvocation;
use crate::analytics_client::InvocationType;
use crate::analytics_client::build_track_events_context;
use crate::apps::render_apps_section;
use crate::auth_env_telemetry::collect_auth_env_telemetry;
use crate::commit_attribution::commit_message_trailer_instruction;
@@ -33,26 +29,31 @@ use crate::models_manager::manager::ModelsManager;
use crate::models_manager::manager::RefreshStrategy;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
use crate::path_utils::normalize_for_native_workdir;
use crate::realtime_conversation::RealtimeConversationManager;
use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio;
use crate::realtime_conversation::handle_close as handle_realtime_conversation_close;
use crate::realtime_conversation::handle_start as handle_realtime_conversation_start;
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
use crate::render_skills_section;
use crate::rollout::session_index;
use crate::skills::render_skills_section;
use crate::skills_load_input_from_config;
use crate::stream_events_utils::HandleOutputCtx;
use crate::stream_events_utils::handle_non_tool_response_item;
use crate::stream_events_utils::handle_output_item_done;
use crate::stream_events_utils::last_assistant_message_from_item;
use crate::stream_events_utils::raw_assistant_output_text_from_item;
use crate::stream_events_utils::record_completed_response_item;
use crate::truncate::TruncationPolicy;
use crate::turn_metadata::TurnMetadataState;
use crate::util::error_or_panic;
use async_channel::Receiver;
use async_channel::Sender;
use chrono::Local;
use chrono::Utc;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppInvocation;
use codex_analytics::InvocationType;
use codex_analytics::build_track_events_context;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_exec_server::Environment;
@@ -117,6 +118,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_terminal_detection::user_agent;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
use codex_utils_stream_parser::ProposedPlanSegment;
@@ -233,9 +235,16 @@ pub(crate) struct PreviousTurnSettings {
pub(crate) realtime_active: Option<bool>,
}
use crate::SkillError;
use crate::SkillInjections;
use crate::SkillLoadOutcome;
use crate::SkillMetadata;
use crate::SkillsManager;
use crate::build_skill_injections;
use crate::collect_env_var_dependencies;
use crate::collect_explicit_skill_mentions;
use crate::exec_policy::ExecPolicyUpdateError;
use crate::feedback_tags;
use crate::git_info::get_git_repo_root;
use crate::guardian::GuardianReviewSessionManager;
use crate::hook_runtime::PendingInputHookDisposition;
use crate::hook_runtime::inspect_pending_input;
@@ -243,6 +252,9 @@ use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::record_pending_input;
use crate::hook_runtime::run_pending_session_start_hooks;
use crate::hook_runtime::run_user_prompt_submit_hooks;
use crate::injection::ToolMentionKind;
use crate::injection::app_id_from_path;
use crate::injection::tool_kind_for_path;
use crate::instructions::UserInstructions;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::McpManager;
@@ -300,6 +312,7 @@ use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WarningEvent;
use crate::resolve_skill_dependencies_for_turn;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
use crate::rollout::map_session_init_error;
@@ -308,18 +321,6 @@ use crate::rollout::policy::EventPersistenceMode;
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
use crate::shell;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillError;
use crate::skills::SkillInjections;
use crate::skills::SkillLoadOutcome;
use crate::skills::SkillMetadata;
use crate::skills::SkillsManager;
use crate::skills::build_skill_injections;
use crate::skills::collect_env_var_dependencies;
use crate::skills::collect_explicit_skill_mentions;
use crate::skills::injection::ToolMentionKind;
use crate::skills::injection::app_id_from_path;
use crate::skills::injection::tool_kind_for_path;
use crate::skills::resolve_skill_dependencies_for_turn;
use crate::skills_watcher::SkillsWatcher;
use crate::skills_watcher::SkillsWatcherEvent;
use crate::state::ActiveTurn;
@@ -350,6 +351,7 @@ use crate::unified_exec::UnifiedExecProcessManager;
use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_git_utils::get_git_repo_root;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_otel::metrics::names::THREAD_STARTED_METRIC;
@@ -471,7 +473,10 @@ impl Codex {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let loaded_skills = skills_manager.skills_for_config(&config);
let plugin_outcome = plugins_manager.plugins_for_config(&config);
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
let loaded_skills = skills_manager.skills_for_config(&skills_input);
for err in &loaded_skills.errors {
error!(
@@ -835,10 +840,10 @@ pub(crate) struct TurnContext {
pub(crate) reasoning_summary: ReasoningSummaryConfig,
pub(crate) session_source: SessionSource,
pub(crate) environment: Arc<Environment>,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
/// The session's absolute working directory. All relative paths provided
/// by the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
pub(crate) cwd: PathBuf,
pub(crate) cwd: AbsolutePathBuf,
pub(crate) current_date: Option<String>,
pub(crate) timezone: Option<String>,
pub(crate) app_server_client_name: Option<String>,
@@ -979,7 +984,7 @@ impl TurnContext {
pub(crate) fn resolve_path(&self, path: Option<String>) -> PathBuf {
path.as_ref()
.map(PathBuf::from)
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
.map_or_else(|| self.cwd.to_path_buf(), |p| self.cwd.as_path().join(p))
}
pub(crate) fn compact_prompt(&self) -> &str {
@@ -992,7 +997,7 @@ impl TurnContext {
TurnContextItem {
turn_id: Some(self.sub_id.clone()),
trace_id: self.trace_id.clone(),
cwd: self.cwd.clone(),
cwd: self.cwd.to_path_buf(),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
approval_policy: self.approval_policy.value(),
@@ -1007,7 +1012,7 @@ impl TurnContext {
user_instructions: self.user_instructions.clone(),
developer_instructions: self.developer_instructions.clone(),
final_output_json_schema: self.final_output_json_schema.clone(),
truncation_policy: Some(self.truncation_policy.into()),
truncation_policy: Some(self.truncation_policy),
}
}
@@ -1068,14 +1073,11 @@ pub(crate) struct SessionConfiguration {
network_sandbox_policy: NetworkSandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
/// Working directory that should be treated as the *root* of the
/// Absolute working directory that should be treated as the *root* of the
/// session. All relative paths supplied by the model as well as the
/// execution sandbox are resolved against this directory **instead**
/// of the process-wide current working directory. CLI front-ends are
/// expected to expand this to an absolute path before sending the
/// `ConfigureSession` operation so that the business-logic layer can
/// operate deterministically.
cwd: PathBuf,
/// execution sandbox are resolved against this directory **instead** of
/// the process-wide current working directory.
cwd: AbsolutePathBuf,
/// Directory containing all Codex state for this session.
codex_home: PathBuf,
/// Optional user-facing name for the thread, updated during the session.
@@ -1107,7 +1109,7 @@ impl SessionConfiguration {
approval_policy: self.approval_policy.value(),
approvals_reviewer: self.approvals_reviewer,
sandbox_policy: self.sandbox_policy.get().clone(),
cwd: self.cwd.clone(),
cwd: self.cwd.to_path_buf(),
ephemeral: self.original_config_do_not_use.ephemeral,
reasoning_effort: self.collaboration_mode.reasoning_effort(),
personality: self.personality,
@@ -1150,11 +1152,23 @@ impl SessionConfiguration {
if let Some(windows_sandbox_level) = updates.windows_sandbox_level {
next_configuration.windows_sandbox_level = windows_sandbox_level;
}
let mut cwd_changed = false;
if let Some(cwd) = updates.cwd.clone() {
next_configuration.cwd = cwd;
cwd_changed = true;
}
let absolute_cwd = updates
.cwd
.as_ref()
.map(|cwd| {
AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir(
cwd.as_path(),
))
.unwrap_or_else(|e| {
warn!("failed to normalize update cwd: {cwd:?}: {e}");
self.cwd.clone()
})
})
.unwrap_or_else(|| self.cwd.clone());
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
next_configuration.cwd = absolute_cwd;
if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) {
// Preserve richer split policies across cwd-only updates; only
// rederive when the session is already using the legacy bridge.
@@ -1351,8 +1365,6 @@ impl Session {
let auth_manager_for_context = auth_manager;
let provider_for_context = provider;
let session_telemetry_for_context = session_telemetry;
let per_turn_config = Arc::new(per_turn_config);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &models_manager.try_list_models().unwrap_or_default(),
@@ -1372,10 +1384,12 @@ impl Session {
.with_agent_roles(per_turn_config.agent_roles.clone());
let cwd = session_configuration.cwd.clone();
let per_turn_config = Arc::new(per_turn_config);
let turn_metadata_state = Arc::new(TurnMetadataState::new(
conversation_id.to_string(),
sub_id.clone(),
cwd.clone(),
cwd.to_path_buf(),
session_configuration.sandbox_policy.get(),
session_configuration.windows_sandbox_level,
));
@@ -1447,13 +1461,6 @@ impl Session {
session_configuration.collaboration_mode.model(),
session_configuration.provider
);
if !session_configuration.cwd.is_absolute() {
return Err(anyhow::anyhow!(
"cwd is not absolute: {:?}",
session_configuration.cwd
));
}
let forked_from_id = initial_history.forked_from_id();
let (conversation_id, rollout_params) = match &initial_history {
@@ -1570,7 +1577,7 @@ impl Session {
})?;
let rollout_path = rollout_recorder
.as_ref()
.map(|rec| rec.rollout_path.clone());
.map(|rec| rec.rollout_path().to_path_buf());
let mut post_session_configured_events = Vec::<Event>::new();
@@ -1723,7 +1730,7 @@ impl Session {
ShellSnapshot::start_snapshotting(
config.codex_home.clone(),
conversation_id,
session_configuration.cwd.clone(),
session_configuration.cwd.to_path_buf(),
&mut default_shell,
session_telemetry.clone(),
)
@@ -1841,8 +1848,9 @@ impl Session {
shell_zsh_path: config.zsh_path.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
analytics_events_client: AnalyticsEventsClient::new(
Arc::clone(&config),
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
config.analytics_enabled,
),
hooks,
rollout: Mutex::new(rollout_recorder),
@@ -1854,7 +1862,6 @@ impl Session {
session_telemetry,
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
mcp_manager: Arc::clone(&mcp_manager),
@@ -1922,7 +1929,7 @@ impl Session {
approval_policy: session_configuration.approval_policy.value(),
approvals_reviewer: session_configuration.approvals_reviewer,
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
cwd: session_configuration.cwd.clone(),
cwd: session_configuration.cwd.to_path_buf(),
reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(),
history_log_id,
history_entry_count,
@@ -1943,7 +1950,7 @@ impl Session {
let sandbox_state = SandboxState {
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: session_configuration.cwd.clone(),
sandbox_cwd: session_configuration.cwd.to_path_buf(),
use_legacy_landlock: config.features.use_legacy_landlock(),
};
let mut required_mcp_servers: Vec<String> = mcp_servers
@@ -2215,15 +2222,6 @@ impl Session {
self.persist_rollout_items(&rollout_items).await;
}
// Append the current session's initial context after the reconstructed history.
let initial_context = self.build_initial_context(&turn_context).await;
self.record_conversation_items(&turn_context, &initial_context)
.await;
{
let mut state = self.state.lock().await;
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
}
// Forked threads should remain file-backed immediately after startup.
self.ensure_rollout_materialized().await;
@@ -2416,7 +2414,7 @@ impl Session {
let sandbox_state = SandboxState {
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
sandbox_cwd: per_turn_config.cwd.clone(),
sandbox_cwd: per_turn_config.cwd.to_path_buf(),
use_legacy_landlock: per_turn_config.features.use_legacy_landlock(),
};
if let Err(e) = self
@@ -2439,10 +2437,16 @@ impl Session {
&per_turn_config,
)
.await;
let plugin_outcome = self
.services
.plugins_manager
.plugins_for_config(&per_turn_config);
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let skills_outcome = Arc::new(
self.services
.skills_manager
.skills_for_config(&per_turn_config),
.skills_for_config(&skills_input),
);
let mut turn_context: TurnContext = Self::make_turn_context(
self.conversation_id,
@@ -3537,7 +3541,16 @@ impl Session {
}
}
if turn_context.apps_enabled() {
developer_sections.push(render_apps_section());
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
let accessible_and_enabled_connectors =
connectors::list_accessible_and_enabled_connectors_from_manager(
&mcp_connection_manager,
&turn_context.config,
)
.await;
if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) {
developer_sections.push(apps_section);
}
}
let implicit_skills = turn_context
.turn_skills
@@ -4138,7 +4151,7 @@ impl Session {
let sandbox_state = SandboxState {
sandbox_policy: turn_context.sandbox_policy.get().clone(),
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
sandbox_cwd: turn_context.cwd.clone(),
sandbox_cwd: turn_context.cwd.to_path_buf(),
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
};
{
@@ -4488,8 +4501,14 @@ mod handlers {
use crate::codex::SessionSettingsUpdate;
use crate::codex::SteerInputError;
use crate::SkillError;
use crate::codex::spawn_review_thread;
use crate::config::Config;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::load_config_layers_state;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::collect_mcp_snapshot_from_manager;
@@ -4919,17 +4938,70 @@ mod handlers {
) {
let cwds = if cwds.is_empty() {
let state = sess.state.lock().await;
vec![state.session_configuration.cwd.clone()]
vec![state.session_configuration.cwd.to_path_buf()]
} else {
cwds
};
let skills_manager = &sess.services.skills_manager;
let plugins_manager = &sess.services.plugins_manager;
let config = sess.get_config().await;
let codex_home = sess.codex_home().await;
let mut skills = Vec::new();
let empty_cli_overrides: &[(String, toml::Value)] = &[];
for cwd in cwds {
let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) {
Ok(path) => path,
Err(err) => {
let message = err.to_string();
let cwd_for_entry = cwd.clone();
skills.push(SkillsListEntry {
cwd: cwd_for_entry.clone(),
skills: Vec::new(),
errors: super::errors_to_info(&[SkillError {
path: cwd_for_entry,
message,
}]),
});
continue;
}
};
let config_layer_stack = match load_config_layers_state(
&codex_home,
Some(cwd_abs),
empty_cli_overrides,
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
)
.await
{
Ok(config_layer_stack) => config_layer_stack,
Err(err) => {
let message = err.to_string();
let cwd_for_entry = cwd.clone();
skills.push(SkillsListEntry {
cwd: cwd_for_entry.clone(),
skills: Vec::new(),
errors: super::errors_to_info(&[SkillError {
path: cwd_for_entry,
message,
}]),
});
continue;
}
};
let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack(
&config_layer_stack,
config.features.enabled(Feature::Plugins),
);
let skills_input = crate::SkillsLoadInput::new(
cwd.clone(),
effective_skill_roots,
config_layer_stack,
config.bundled_skills_enabled(),
);
let outcome = skills_manager
.skills_for_cwd(&cwd, config.as_ref(), force_reload)
.skills_for_cwd(&skills_input, force_reload)
.await;
let errors = super::errors_to_info(&outcome.errors);
let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths);
@@ -5363,7 +5435,7 @@ async fn spawn_review_thread(
let turn_metadata_state = Arc::new(TurnMetadataState::new(
sess.conversation_id.to_string(),
review_turn_id.clone(),
parent_turn_context.cwd.clone(),
parent_turn_context.cwd.to_path_buf(),
parent_turn_context.sandbox_policy.get(),
parent_turn_context.windows_sandbox_level,
));
@@ -5852,7 +5924,7 @@ pub(crate) async fn run_turn(
let stop_request = codex_hooks::StopRequest {
session_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
transcript_path: sess.hook_transcript_path().await,
model: turn_context.model_info.slug.clone(),
permission_mode: stop_hook_permission_mode,
@@ -5902,7 +5974,7 @@ pub(crate) async fn run_turn(
.hooks()
.dispatch(HookPayload {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
client: turn_context.app_server_client_name.clone(),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterAgent {
@@ -6458,10 +6530,7 @@ pub(crate) async fn built_tools(
None
};
let auth = sess.services.auth_manager.auth().await;
let discoverable_tools = if apps_enabled
&& turn_context.tools_config.search_tool
&& turn_context.tools_config.tool_suggest
{
let discoverable_tools = if apps_enabled && turn_context.tools_config.tool_suggest {
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
match connectors::list_tool_suggest_discoverable_tools_with_auth(
&turn_context.config,
@@ -62,7 +62,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -77,7 +77,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
@@ -101,7 +101,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
let mut previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -116,7 +116,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let turn_id = previous_context_item
.turn_id
@@ -851,7 +851,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -866,7 +866,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let previous_turn_id = previous_context_item
.turn_id
@@ -923,7 +923,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
serde_json::to_value(Some(TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -938,7 +938,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
}))
.expect("serialize expected reference context item")
);
@@ -952,7 +952,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -967,7 +967,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let previous_turn_id = previous_context_item
.turn_id
@@ -1058,7 +1058,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
let current_context_item = TurnContextItem {
turn_id: Some(current_turn_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1073,7 +1073,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let rollout_items = vec![
@@ -1160,7 +1160,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1175,7 +1175,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let previous_turn_id = previous_context_item
.turn_id
@@ -1304,7 +1304,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1319,7 +1319,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let previous_turn_id = previous_context_item
.turn_id
+2 -3
View File
@@ -22,7 +22,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::user_input::UserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value;
use std::time::Duration;
use tokio::sync::Mutex;
@@ -518,7 +517,7 @@ async fn handle_patch_approval(
let change_count = changes.len();
let maybe_files = changes
.keys()
.map(|path| AbsolutePathBuf::from_absolute_path(parent_ctx.cwd.join(path)).ok())
.map(|path| parent_ctx.cwd.join(path).ok())
.collect::<Option<Vec<_>>>();
if let Some(files) = maybe_files {
let review_cancel = cancel_token.child_token();
@@ -554,7 +553,7 @@ async fn handle_patch_approval(
Arc::clone(parent_ctx),
GuardianApprovalRequest::ApplyPatch {
id: approval_id.clone(),
cwd: parent_ctx.cwd.clone(),
cwd: parent_ctx.cwd.to_path_buf(),
files,
change_count,
patch,
+75 -41
View File
@@ -80,6 +80,7 @@ use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::Submission;
use codex_protocol::protocol::W3cTraceContext;
use core_test_support::PathBufExt;
use core_test_support::context_snapshot;
use core_test_support::context_snapshot::ContextSnapshotOptions;
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
@@ -1115,18 +1116,12 @@ async fn recompute_token_usage_updates_model_context_window() {
#[tokio::test]
async fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context().await;
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
session
.record_initial_history(InitialHistory::Forked(rollout_items))
.await;
let reconstruction_turn = session.new_default_turn().await;
expected.extend(
session
.build_initial_context(reconstruction_turn.as_ref())
.await,
);
let history = session.state.lock().await.clone_history();
assert_eq!(expected, history.raw_items());
}
@@ -1244,7 +1239,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
let request = first_forked_request.single_request();
let snapshot = context_snapshot::format_labeled_requests_snapshot(
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
"First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.",
&[("First Forked Turn Request", &request)],
&ContextSnapshotOptions::default()
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
@@ -1272,7 +1267,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
let previous_context_item = TurnContextItem {
turn_id: Some(turn_context.sub_id.clone()),
trace_id: turn_context.trace_id.clone(),
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
current_date: turn_context.current_date.clone(),
timezone: turn_context.timezone.clone(),
approval_policy: turn_context.approval_policy.value(),
@@ -1287,7 +1282,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: Some(turn_context.truncation_policy.into()),
truncation_policy: Some(turn_context.truncation_policy),
};
let turn_id = previous_context_item
.turn_id
@@ -1309,7 +1304,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
text_elements: Vec::new(),
},
)),
RolloutItem::TurnContext(previous_context_item),
RolloutItem::TurnContext(previous_context_item.clone()),
RolloutItem::EventMsg(EventMsg::TurnComplete(
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
@@ -1322,6 +1317,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
.record_initial_history(InitialHistory::Forked(rollout_items))
.await;
let history = session.clone_history().await;
assert_eq!(
session.previous_turn_settings().await,
Some(PreviousTurnSettings {
@@ -1329,6 +1325,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
realtime_active: Some(turn_context.realtime_active),
})
);
assert_eq!(history.raw_items(), &[]);
assert_eq!(
serde_json::to_value(session.reference_context_item().await)
.expect("serialize fork reference context item"),
serde_json::to_value(Some(previous_context_item))
.expect("serialize expected reference context item")
);
}
#[tokio::test]
@@ -2280,10 +2283,9 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o
let original_cwd = project_root.join("subdir");
let docs_dir = original_cwd.join("docs");
std::fs::create_dir_all(&docs_dir).expect("create docs dir");
let docs_dir =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs");
let docs_dir = docs_dir.abs();
session_configuration.cwd = original_cwd;
session_configuration.cwd = original_cwd.abs();
session_configuration.sandbox_policy =
codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
@@ -2339,7 +2341,10 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
let parent_outcome = session
.services
.skills_manager
.skills_for_cwd(&parent_config.cwd, &parent_config, true)
.skills_for_cwd(
&crate::skills_load_input_from_config(&parent_config, Vec::new()),
true,
)
.await;
let parent_skill = parent_outcome
.skills
@@ -2405,10 +2410,9 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
let original_cwd = project_root.join("subdir");
let docs_dir = original_cwd.join("docs");
std::fs::create_dir_all(&docs_dir).expect("create docs dir");
let docs_dir =
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs");
let docs_dir = docs_dir.abs();
session_configuration.cwd = original_cwd;
session_configuration.cwd = original_cwd.abs();
session_configuration.sandbox_policy =
codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
@@ -2442,6 +2446,36 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
);
}
#[tokio::test]
async fn session_update_settings_keeps_runtime_cwds_absolute() {
let (session, turn_context) = make_session_and_context().await;
let updated_cwd = turn_context
.cwd
.join("project")
.expect("resolve project dir");
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
session
.update_settings(SessionSettingsUpdate {
cwd: Some(PathBuf::from("project")),
..Default::default()
})
.await
.expect("cwd update should succeed");
let session_cwd = {
let state = session.state.lock().await;
state.session_configuration.cwd.clone()
};
let config = session.get_config().await;
let next_turn = session.new_default_turn().await;
assert_eq!(session_cwd, updated_cwd);
assert_eq!(config.cwd, turn_context.cwd);
assert_eq!(next_turn.cwd, updated_cwd);
assert_eq!(next_turn.config.cwd, updated_cwd);
}
#[tokio::test]
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let codex_home = tempfile::tempdir().expect("create temp dir");
@@ -2506,11 +2540,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
Arc::clone(&plugins_manager),
true,
));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let result = Session::new(
session_configuration,
Arc::clone(&config),
@@ -2611,11 +2641,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let state = SessionState::new(session_configuration.clone());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
Arc::clone(&plugins_manager),
true,
));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let network_approval = Arc::new(NetworkApprovalService::default());
let environment = Arc::new(
codex_exec_server::Environment::create(None)
@@ -2637,8 +2663,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
shell_zsh_path: None,
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
analytics_events_client: AnalyticsEventsClient::new(
Arc::clone(&config),
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
config.analytics_enabled,
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
@@ -2653,7 +2680,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
@@ -2682,7 +2708,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
config.js_repl_node_module_dirs.clone(),
));
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let plugin_outcome = services
.plugins_manager
.plugins_for_config(&per_turn_config);
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input =
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
let turn_context = Session::make_turn_context(
conversation_id,
Some(Arc::clone(&auth_manager)),
@@ -3056,7 +3088,7 @@ async fn user_turn_updates_approvals_reviewer() {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
cwd: config.cwd.clone(),
cwd: config.cwd.to_path_buf(),
approval_policy: config.permissions.approval_policy.value(),
approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent),
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
@@ -3446,11 +3478,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
let state = SessionState::new(session_configuration.clone());
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
let skills_manager = Arc::new(SkillsManager::new(
config.codex_home.clone(),
Arc::clone(&plugins_manager),
true,
));
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
let network_approval = Arc::new(NetworkApprovalService::default());
let environment = Arc::new(
codex_exec_server::Environment::create(None)
@@ -3472,8 +3500,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
shell_zsh_path: None,
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
analytics_events_client: AnalyticsEventsClient::new(
Arc::clone(&config),
Arc::clone(&auth_manager),
config.chatgpt_base_url.trim_end_matches('/').to_string(),
config.analytics_enabled,
),
hooks: Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
@@ -3488,7 +3517,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
session_telemetry: session_telemetry.clone(),
models_manager: Arc::clone(&models_manager),
tool_approvals: Mutex::new(ApprovalStore::default()),
execve_session_approvals: RwLock::new(HashMap::new()),
skills_manager,
plugins_manager,
mcp_manager,
@@ -3517,7 +3545,13 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
config.js_repl_node_module_dirs.clone(),
));
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let plugin_outcome = services
.plugins_manager
.plugins_for_config(&per_turn_config);
let effective_skill_roots = plugin_outcome.effective_skill_roots();
let skills_input =
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
let turn_context = Arc::new(Session::make_turn_context(
conversation_id,
Some(Arc::clone(&auth_manager)),
@@ -5058,7 +5092,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
"echo hi".to_string(),
]
},
cwd: turn_context.cwd.clone(),
cwd: turn_context.cwd.to_path_buf(),
expiration: timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
env: HashMap::new(),

Some files were not shown because too many files have changed in this diff Show More