mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
Merge remote-tracking branch 'origin/main' into pakrym/pr-15695-merge-main
# Conflicts: # codex-rs/core/src/tools/spec_tests.rs
This commit is contained in:
Generated
+2
@@ -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\":[]}}",
|
||||
|
||||
Generated
+109
-2
@@ -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"
|
||||
@@ -1457,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",
|
||||
@@ -1468,6 +1487,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"tempfile",
|
||||
"time",
|
||||
@@ -1830,6 +1850,7 @@ dependencies = [
|
||||
"futures",
|
||||
"multimap",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
@@ -1869,6 +1890,7 @@ dependencies = [
|
||||
"chardetng",
|
||||
"chrono",
|
||||
"clap",
|
||||
"codex-analytics",
|
||||
"codex-api",
|
||||
"codex-app-server-protocol",
|
||||
"codex-apply-patch",
|
||||
@@ -1878,14 +1900,17 @@ dependencies = [
|
||||
"codex-code-mode",
|
||||
"codex-config",
|
||||
"codex-connectors",
|
||||
"codex-core-skills",
|
||||
"codex-exec-server",
|
||||
"codex-execpolicy",
|
||||
"codex-features",
|
||||
"codex-git-utils",
|
||||
"codex-hooks",
|
||||
"codex-instructions",
|
||||
"codex-login",
|
||||
"codex-network-proxy",
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
"codex-protocol",
|
||||
"codex-rmcp-client",
|
||||
"codex-rollout",
|
||||
@@ -1893,7 +1918,6 @@ dependencies = [
|
||||
"codex-secrets",
|
||||
"codex-shell-command",
|
||||
"codex-shell-escalation",
|
||||
"codex-skills",
|
||||
"codex-state",
|
||||
"codex-terminal-detection",
|
||||
"codex-test-macros",
|
||||
@@ -1904,6 +1928,7 @@ dependencies = [
|
||||
"codex-utils-image",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-path",
|
||||
"codex-utils-plugins",
|
||||
"codex-utils-pty",
|
||||
"codex-utils-readiness",
|
||||
"codex-utils-stream-parser",
|
||||
@@ -1942,7 +1967,6 @@ dependencies = [
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"shlex",
|
||||
@@ -1971,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"
|
||||
@@ -2174,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"
|
||||
@@ -2383,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"
|
||||
@@ -2937,6 +3008,15 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-plugins"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-utils-pty"
|
||||
version = "0.0.0"
|
||||
@@ -5710,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"
|
||||
@@ -9200,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"
|
||||
|
||||
@@ -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",
|
||||
@@ -68,6 +71,7 @@ members = [
|
||||
"utils/oss",
|
||||
"utils/output-truncation",
|
||||
"utils/path-utils",
|
||||
"utils/plugins",
|
||||
"utils/fuzzy-match",
|
||||
"utils/stream-parser",
|
||||
"utils/template",
|
||||
@@ -78,6 +82,7 @@ members = [
|
||||
"codex-experimental-api-macros",
|
||||
"test-macros",
|
||||
"package-manager",
|
||||
"plugin",
|
||||
"artifacts",
|
||||
]
|
||||
resolver = "2"
|
||||
@@ -95,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" }
|
||||
@@ -114,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" }
|
||||
@@ -123,6 +130,7 @@ codex-features = { path = "features" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
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" }
|
||||
@@ -131,6 +139,7 @@ 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" }
|
||||
@@ -161,6 +170,7 @@ 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" }
|
||||
@@ -194,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"
|
||||
@@ -212,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"
|
||||
@@ -224,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"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "analytics",
|
||||
crate_name = "codex_analytics",
|
||||
)
|
||||
@@ -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 }
|
||||
+135
-104
@@ -1,9 +1,9 @@
|
||||
use crate::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::create_client;
|
||||
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 };
|
||||
|
||||
+9
-8
@@ -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
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -934,6 +988,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,
|
||||
@@ -1044,6 +1145,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!(
|
||||
¬ification.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 {
|
||||
@@ -1208,6 +1397,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!(
|
||||
¬ification.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 {
|
||||
@@ -1447,7 +1737,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(
|
||||
@@ -1463,9 +1753,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]
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -203,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;
|
||||
@@ -282,6 +284,7 @@ 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;
|
||||
@@ -5509,13 +5512,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);
|
||||
|
||||
@@ -450,6 +450,8 @@ 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;
|
||||
@@ -651,6 +653,7 @@ 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(),
|
||||
Arc::new(RwLock::new(Vec::new())),
|
||||
@@ -659,10 +662,12 @@ mod tests {
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -81,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";
|
||||
|
||||
@@ -337,6 +341,7 @@ pub async fn run_main(
|
||||
default_analytics_enabled,
|
||||
AppServerTransport::Stdio,
|
||||
SessionSource::VSCode,
|
||||
AppServerWebsocketAuthSettings::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -348,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);
|
||||
@@ -375,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 {
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -222,8 +222,11 @@ 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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
+139
-42
@@ -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"])
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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 }
|
||||
+10
-12
@@ -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();
|
||||
+10
-90
@@ -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>,
|
||||
{
|
||||
+96
-45
@@ -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
|
||||
+152
-226
@@ -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;
|
||||
|
||||
@@ -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,17 +35,19 @@ 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-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 }
|
||||
@@ -57,6 +60,7 @@ 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 }
|
||||
@@ -89,7 +93,6 @@ 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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -12,7 +12,7 @@ pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
|
||||
}
|
||||
|
||||
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."
|
||||
);
|
||||
Some(format!(
|
||||
"{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}"
|
||||
|
||||
+92
-25
@@ -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;
|
||||
@@ -39,8 +35,9 @@ use crate::realtime_conversation::handle_audio as handle_realtime_conversation_a
|
||||
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;
|
||||
@@ -53,6 +50,10 @@ 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;
|
||||
@@ -234,6 +235,14 @@ 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::guardian::GuardianReviewSessionManager;
|
||||
@@ -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;
|
||||
@@ -472,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!(
|
||||
@@ -1844,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),
|
||||
@@ -2433,10 +2438,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,
|
||||
@@ -4491,8 +4502,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;
|
||||
@@ -4928,11 +4945,64 @@ mod handlers {
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -6461,10 +6531,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,
|
||||
|
||||
@@ -2381,7 +2381,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
|
||||
@@ -2577,11 +2580,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),
|
||||
@@ -2682,11 +2681,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)
|
||||
@@ -2708,8 +2703,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(),
|
||||
@@ -2753,7 +2749,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)),
|
||||
@@ -3517,11 +3519,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)
|
||||
@@ -3543,8 +3541,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(),
|
||||
@@ -3588,7 +3587,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)),
|
||||
|
||||
@@ -429,11 +429,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
CollaborationModesConfig::default(),
|
||||
));
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
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 mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
|
||||
|
||||
@@ -2857,7 +2857,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn bundled_skills_enabled(&self) -> bool {
|
||||
crate::skills::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack)
|
||||
crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -801,17 +801,9 @@ impl Notice {
|
||||
pub(crate) const TABLE_KEY: &'static str = "notice";
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
pub use codex_config::BundledSkillsConfig;
|
||||
pub use codex_config::SkillConfig;
|
||||
pub use codex_config::SkillsConfig;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -820,29 +812,6 @@ pub struct PluginConfig {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
|
||||
@@ -52,10 +52,12 @@ pub use codex_config::TextRange;
|
||||
pub use codex_config::WebSearchModeRequirement;
|
||||
pub(crate) use codex_config::build_cli_overrides_layer;
|
||||
pub(crate) use codex_config::config_error_from_toml;
|
||||
pub use codex_config::default_project_root_markers;
|
||||
pub use codex_config::format_config_error;
|
||||
pub use codex_config::format_config_error_with_source;
|
||||
pub(crate) use codex_config::io_error_from_config_error;
|
||||
pub use codex_config::merge_toml_values;
|
||||
pub use codex_config::project_root_markers_from_config;
|
||||
#[cfg(test)]
|
||||
pub(crate) use codex_config::version_for_toml;
|
||||
|
||||
@@ -67,8 +69,6 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||
|
||||
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
|
||||
codex_config::first_layer_config_error::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
||||
}
|
||||
@@ -285,6 +285,11 @@ pub async fn load_config_layers_state(
|
||||
));
|
||||
}
|
||||
if let Some(config) = managed_config_from_mdm {
|
||||
// As a general rule, config from MDM should _not_ include relative
|
||||
// paths, starting with `./`, but a path starting with `~/` _is_ a
|
||||
// supported use case. Because resolve_relative_paths_in_config_toml()
|
||||
// relies on AbsolutePathBufGuard to resolve `~/`, we must supply a
|
||||
// value for base_dir, so codex_home is as good a value as any.
|
||||
let managed_config =
|
||||
resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
@@ -524,55 +529,6 @@ async fn load_requirements_from_legacy_scheme(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads `project_root_markers` from the [toml::Value] produced by merging
|
||||
/// `config.toml` from the config layers in the stack preceding
|
||||
/// [ConfigLayerSource::Project].
|
||||
///
|
||||
/// 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(crate) 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(crate) fn default_project_root_markers() -> Vec<String> {
|
||||
DEFAULT_PROJECT_ROOT_MARKERS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ProjectTrustContext {
|
||||
project_root: AbsolutePathBuf,
|
||||
project_root_key: String,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use codex_instructions::AGENTS_MD_FRAGMENT;
|
||||
use codex_instructions::ContextualUserFragmentDefinition;
|
||||
use codex_instructions::SKILL_FRAGMENT;
|
||||
use codex_protocol::items::HookPromptItem;
|
||||
use codex_protocol::items::parse_hook_prompt_fragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
|
||||
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
|
||||
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
@@ -16,64 +14,11 @@ pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub(crate) fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub(crate) fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
USER_SHELL_COMMAND_OPEN_TAG,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
pub use codex_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use codex_instructions::UserInstructions;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
mod analytics_client;
|
||||
pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
@@ -62,18 +61,44 @@ pub use text_encoding::bytes_to_string_smart;
|
||||
mod mcp_tool_call;
|
||||
mod memories;
|
||||
pub mod mention_syntax;
|
||||
mod mentions;
|
||||
pub mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod utils;
|
||||
pub use utils::path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod plugins;
|
||||
pub(crate) mod mentions {
|
||||
pub(crate) use crate::plugins::build_connector_slug_counts;
|
||||
pub(crate) use crate::plugins::build_skill_name_counts;
|
||||
pub(crate) use crate::plugins::collect_explicit_app_ids;
|
||||
pub(crate) use crate::plugins::collect_explicit_plugin_mentions;
|
||||
pub(crate) use crate::plugins::collect_tool_mentions_from_messages;
|
||||
}
|
||||
mod sandbox_tags;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod session_startup_prewarm;
|
||||
mod shell_detect;
|
||||
pub mod skills;
|
||||
pub(crate) use skills::SkillError;
|
||||
pub(crate) use skills::SkillInjections;
|
||||
pub(crate) use skills::SkillLoadOutcome;
|
||||
pub(crate) use skills::SkillMetadata;
|
||||
pub(crate) use skills::SkillsLoadInput;
|
||||
pub(crate) use skills::SkillsManager;
|
||||
pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::config_rules;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::loader;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::model;
|
||||
pub(crate) use skills::render_skills_section;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
@@ -108,9 +133,9 @@ pub type NewConversation = NewThread;
|
||||
#[deprecated(note = "use CodexThread")]
|
||||
pub type CodexConversation = CodexThread;
|
||||
// Re-export common auth types for workspace consumers
|
||||
pub use analytics_client::AnalyticsEventsClient;
|
||||
pub use auth::AuthManager;
|
||||
pub use auth::CodexAuth;
|
||||
pub use codex_analytics::AnalyticsEventsClient;
|
||||
mod default_client_forwarding;
|
||||
|
||||
/// Default Codex HTTP client headers and reqwest construction.
|
||||
@@ -127,7 +152,6 @@ pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
pub mod skills;
|
||||
pub mod spawn;
|
||||
pub mod state_db_bridge;
|
||||
pub use codex_rollout::state_db;
|
||||
|
||||
@@ -15,6 +15,7 @@ use super::auth::McpOAuthLoginSupport;
|
||||
use super::auth::oauth_login_support;
|
||||
use super::auth::resolve_oauth_scopes;
|
||||
use super::auth::should_retry_without_scopes;
|
||||
use crate::SkillMetadata;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
@@ -24,8 +25,7 @@ use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::default_client::is_first_party_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::model::SkillToolDependency;
|
||||
use crate::model::SkillToolDependency;
|
||||
use codex_features::Feature;
|
||||
|
||||
const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::skills::model::SkillDependencies;
|
||||
use crate::model::SkillDependencies;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -8,9 +8,6 @@ use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use tracing::error;
|
||||
|
||||
use crate::analytics_client::AppInvocation;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::arc_monitor::ArcMonitorOutcome;
|
||||
use crate::arc_monitor::monitor_action;
|
||||
use crate::codex::Session;
|
||||
@@ -32,6 +29,9 @@ use crate::protocol::McpInvocation;
|
||||
use crate::protocol::McpToolCallBeginEvent;
|
||||
use crate::protocol::McpToolCallEndEvent;
|
||||
use crate::state_db;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
// Default plaintext sigil for tools.
|
||||
pub const TOOL_MENTION_SIGIL: char = '$';
|
||||
// Plugins use `@` in linked plaintext outside TUI.
|
||||
pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@';
|
||||
pub use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
||||
pub use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::LoadedPlugin;
|
||||
use super::PluginLoadOutcome;
|
||||
use super::PluginManifestPaths;
|
||||
use super::curated_plugins_repo_path;
|
||||
use super::load_plugin_manifest;
|
||||
@@ -20,14 +22,12 @@ use super::remote::fetch_remote_featured_plugin_ids;
|
||||
use super::remote::fetch_remote_plugin_status;
|
||||
use super::remote::uninstall_remote_plugin;
|
||||
use super::startup_sync::start_startup_remote_plugin_sync_once;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use super::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use super::store::PluginStore;
|
||||
use super::store::PluginStoreError;
|
||||
use super::sync_openai_plugins_repo;
|
||||
use crate::AuthManager;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::SkillMetadata;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigService;
|
||||
@@ -37,15 +37,21 @@ use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::skills::SkillMetadata;
|
||||
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::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 codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_features::Feature;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_plugin::prompt_safe_plugin_description;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -73,7 +79,6 @@ const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
|
||||
std::time::Duration::from_secs(60 * 60 * 3);
|
||||
|
||||
@@ -114,9 +119,6 @@ fn featured_plugin_ids_cache_key(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AppConnectorId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
@@ -185,89 +187,6 @@ pub struct ConfiguredMarketplaceListOutcome {
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin {
|
||||
pub config_name: String,
|
||||
pub manifest_name: Option<String>,
|
||||
pub manifest_description: Option<String>,
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn is_active(&self) -> bool {
|
||||
self.enabled && self.error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginCapabilitySummary {
|
||||
pub config_name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_skills: bool,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub app_connector_ids: Vec<AppConnectorId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
pub plugin_id: PluginId,
|
||||
pub capability_summary: Option<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl PluginTelemetryMetadata {
|
||||
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.clone(),
|
||||
capability_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCapabilitySummary {
|
||||
fn from_plugin(plugin: &LoadedPlugin) -> Option<Self> {
|
||||
if !plugin.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
|
||||
mcp_server_names.sort_unstable();
|
||||
|
||||
let summary = Self {
|
||||
config_name: plugin.config_name.clone(),
|
||||
display_name: plugin
|
||||
.manifest_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
|
||||
(summary.has_skills
|
||||
|| !summary.mcp_server_names.is_empty()
|
||||
|| !summary.app_connector_ids.is_empty())
|
||||
.then_some(summary)
|
||||
}
|
||||
|
||||
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
|
||||
PluginId::parse(&self.config_name)
|
||||
.ok()
|
||||
.map(|plugin_id| PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
capability_summary: Some(self.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetail) -> Self {
|
||||
let has_skills = value.skills.iter().any(|skill| {
|
||||
@@ -286,95 +205,6 @@ impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
|
||||
let description = description?
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if description.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
description
|
||||
.chars()
|
||||
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginLoadOutcome {
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl Default for PluginLoadOutcome {
|
||||
fn default() -> Self {
|
||||
Self::from_plugins(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginLoadOutcome {
|
||||
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
|
||||
let capability_summaries = plugins
|
||||
.iter()
|
||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
let mut skill_roots: Vec<PathBuf> = self
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
|
||||
.collect();
|
||||
skill_roots.sort_unstable();
|
||||
skill_roots.dedup();
|
||||
skill_roots
|
||||
}
|
||||
|
||||
pub fn effective_mcp_servers(&self) -> HashMap<String, McpServerConfig> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for (name, config) in &plugin.mcp_servers {
|
||||
mcp_servers
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| config.clone());
|
||||
}
|
||||
}
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
|
||||
let mut apps = Vec::new();
|
||||
let mut seen_connector_ids = std::collections::HashSet::new();
|
||||
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for connector_id in &plugin.apps {
|
||||
if seen_connector_ids.insert(connector_id.clone()) {
|
||||
apps.push(connector_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin] {
|
||||
&self.plugins
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RemotePluginSyncResult {
|
||||
/// Plugin ids newly installed into the local plugin cache.
|
||||
@@ -575,6 +405,19 @@ impl PluginsManager {
|
||||
*cached_enabled_outcome = None;
|
||||
}
|
||||
|
||||
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
|
||||
pub fn effective_skill_roots_for_layer_stack(
|
||||
&self,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
plugins_feature_enabled: bool,
|
||||
) -> Vec<PathBuf> {
|
||||
if !plugins_feature_enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product)
|
||||
.effective_skill_roots()
|
||||
}
|
||||
|
||||
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
|
||||
match self.cached_enabled_outcome.read() {
|
||||
Ok(cache) => cache.clone(),
|
||||
@@ -1174,7 +1017,7 @@ impl PluginsManager {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key);
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
self.start_curated_repo_sync(configured_curated_plugin_ids);
|
||||
start_startup_remote_plugin_sync_once(
|
||||
Arc::clone(self),
|
||||
@@ -1341,7 +1184,7 @@ impl PluginUninstallError {
|
||||
|
||||
fn log_plugin_load_errors(outcome: &PluginLoadOutcome) {
|
||||
for plugin in outcome
|
||||
.plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.error.is_some())
|
||||
{
|
||||
@@ -1413,16 +1256,6 @@ pub(crate) fn load_plugins_from_layer_stack(
|
||||
PluginLoadOutcome::from_plugins(plugins)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(manifest) = load_plugin_manifest(ancestor) {
|
||||
return Some(manifest.name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn refresh_curated_plugin_cache(
|
||||
codex_home: &Path,
|
||||
plugin_version: &str,
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::LoadedPlugin;
|
||||
use crate::plugins::MarketplacePluginInstallPolicy;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
@@ -26,6 +28,8 @@ use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
@@ -130,7 +134,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins,
|
||||
outcome.plugins(),
|
||||
vec![LoadedPlugin {
|
||||
config_name: "sample@test".to_string(),
|
||||
manifest_name: Some("sample".to_string()),
|
||||
@@ -220,10 +224,10 @@ enabled = true
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].disabled_skill_paths,
|
||||
outcome.plugins()[0].disabled_skill_paths,
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
assert!(!outcome.plugins[0].has_enabled_skills);
|
||||
assert!(!outcome.plugins()[0].has_enabled_skills);
|
||||
assert!(outcome.capability_summaries().is_empty());
|
||||
}
|
||||
|
||||
@@ -256,8 +260,8 @@ enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
|
||||
assert!(outcome.plugins[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins[0].has_enabled_skills);
|
||||
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins()[0].has_enabled_skills);
|
||||
assert_eq!(
|
||||
outcome.capability_summaries(),
|
||||
&[PluginCapabilitySummary {
|
||||
@@ -338,7 +342,7 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].manifest_description.as_deref(),
|
||||
outcome.plugins()[0].manifest_description.as_deref(),
|
||||
Some("Plugin that\n includes the sample\tserver")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -373,7 +377,7 @@ fn capability_summary_truncates_overlong_plugin_descriptions() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].manifest_description.as_deref(),
|
||||
outcome.plugins()[0].manifest_description.as_deref(),
|
||||
Some(too_long.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -453,14 +457,14 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].skill_roots,
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![
|
||||
plugin_root.join("custom-skills"),
|
||||
plugin_root.join("skills")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].mcp_servers,
|
||||
outcome.plugins()[0].mcp_servers,
|
||||
HashMap::from([(
|
||||
"custom".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -483,7 +487,7 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].apps,
|
||||
outcome.plugins()[0].apps,
|
||||
vec![AppConnectorId("connector_custom".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -559,11 +563,11 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].skill_roots,
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![plugin_root.join("skills")]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].mcp_servers,
|
||||
outcome.plugins()[0].mcp_servers,
|
||||
HashMap::from([(
|
||||
"default".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -586,7 +590,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].apps,
|
||||
outcome.plugins()[0].apps,
|
||||
vec![AppConnectorId("connector_default".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -618,7 +622,7 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins,
|
||||
outcome.plugins(),
|
||||
vec![LoadedPlugin {
|
||||
config_name: "sample@test".to_string(),
|
||||
manifest_name: None,
|
||||
@@ -805,24 +809,6 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_namespace_for_skill_path_uses_manifest_name() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(&skill_path, "---\ndescription: search\n---\n");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(&skill_path),
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_returns_empty_when_feature_disabled() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -880,9 +866,9 @@ fn load_plugins_rejects_invalid_plugin_keys() {
|
||||
codex_home.path(),
|
||||
);
|
||||
|
||||
assert_eq!(outcome.plugins.len(), 1);
|
||||
assert_eq!(outcome.plugins().len(), 1);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].error.as_deref(),
|
||||
outcome.plugins()[0].error.as_deref(),
|
||||
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
|
||||
);
|
||||
assert!(outcome.effective_skill_roots().is_empty());
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::PluginManifestInterface;
|
||||
use super::load_plugin_manifest;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::injection::ToolMentionKind;
|
||||
use crate::injection::app_id_from_path;
|
||||
use crate::injection::extract_tool_mentions_with_sigil;
|
||||
use crate::injection::plugin_config_name_from_path;
|
||||
use crate::injection::tool_kind_for_path;
|
||||
use crate::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
||||
use crate::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::extract_tool_mentions_with_sigil;
|
||||
use crate::skills::injection::plugin_config_name_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
|
||||
use super::PluginCapabilitySummary;
|
||||
|
||||
pub(crate) struct CollectedToolMentions {
|
||||
pub(crate) plain_names: HashSet<String>,
|
||||
@@ -102,23 +101,7 @@ pub(crate) fn collect_explicit_plugin_mentions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) 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)
|
||||
}
|
||||
pub(crate) use crate::build_skill_name_counts;
|
||||
|
||||
pub(crate) fn build_connector_slug_counts(
|
||||
connectors: &[connectors::AppInfo],
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::config::types::McpServerConfig;
|
||||
|
||||
mod discoverable;
|
||||
mod injection;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod mentions;
|
||||
mod remote;
|
||||
mod render;
|
||||
mod startup_sync;
|
||||
@@ -11,31 +14,35 @@ mod store;
|
||||
pub(crate) mod test_support;
|
||||
mod toggles;
|
||||
|
||||
pub use codex_plugin::AppConnectorId;
|
||||
pub use codex_plugin::EffectiveSkillRoots;
|
||||
pub use codex_plugin::PluginCapabilitySummary;
|
||||
pub use codex_plugin::PluginId;
|
||||
pub use codex_plugin::PluginIdError;
|
||||
pub use codex_plugin::PluginTelemetryMetadata;
|
||||
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
|
||||
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplaceListOutcome;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
pub use manager::PluginDetail;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallOutcome;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginReadOutcome;
|
||||
pub use manager::PluginReadRequest;
|
||||
pub use manager::PluginRemoteSyncError;
|
||||
pub use manager::PluginTelemetryMetadata;
|
||||
pub use manager::PluginUninstallError;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::RemotePluginSyncResult;
|
||||
pub use manager::installed_plugin_telemetry_metadata;
|
||||
pub use manager::load_plugin_apps;
|
||||
pub use manager::load_plugin_mcp_servers;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub use manager::plugin_telemetry_metadata_from_root;
|
||||
pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
@@ -53,5 +60,10 @@ pub(crate) use render::render_plugins_section;
|
||||
pub(crate) use startup_sync::curated_plugins_repo_path;
|
||||
pub(crate) use startup_sync::read_curated_plugins_sha;
|
||||
pub(crate) use startup_sync::sync_openai_plugins_repo;
|
||||
pub use store::PluginId;
|
||||
pub use toggles::collect_plugin_enabled_candidates;
|
||||
|
||||
pub(crate) use mentions::build_connector_slug_counts;
|
||||
pub(crate) use mentions::build_skill_name_counts;
|
||||
pub(crate) use mentions::collect_explicit_app_ids;
|
||||
pub(crate) use mentions::collect_explicit_plugin_mentions;
|
||||
pub(crate) use mentions::collect_tool_mentions_from_messages;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::manifest::PLUGIN_MANIFEST_PATH;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
@@ -9,53 +11,6 @@ use std::path::PathBuf;
|
||||
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginIdError {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginId {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
|
||||
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(PluginIdError::Invalid)?;
|
||||
Ok(Self {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
|
||||
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
};
|
||||
if plugin_name.is_empty() || marketplace_name.is_empty() {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
}
|
||||
|
||||
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => {
|
||||
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_key(&self) -> String {
|
||||
format!("{}@{}", self.plugin_name, self.marketplace_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallResult {
|
||||
pub plugin_id: PluginId,
|
||||
@@ -221,21 +176,6 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
|
||||
.map(|_| plugin_name)
|
||||
}
|
||||
|
||||
fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
|
||||
if segment.is_empty() {
|
||||
return Err(format!("invalid {kind}: must not be empty"));
|
||||
}
|
||||
if !segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_plugin::PluginId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use tracing::warn;
|
||||
|
||||
pub use codex_core_skills::SkillDependencyInfo;
|
||||
pub use codex_core_skills::SkillError;
|
||||
pub use codex_core_skills::SkillLoadOutcome;
|
||||
pub use codex_core_skills::SkillMetadata;
|
||||
pub use codex_core_skills::SkillPolicy;
|
||||
pub use codex_core_skills::SkillsLoadInput;
|
||||
pub use codex_core_skills::SkillsManager;
|
||||
pub use codex_core_skills::build_skill_name_counts;
|
||||
pub use codex_core_skills::collect_env_var_dependencies;
|
||||
pub use codex_core_skills::config_rules;
|
||||
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
|
||||
pub use codex_core_skills::filter_skill_load_outcome_for_product;
|
||||
pub use codex_core_skills::injection;
|
||||
pub use codex_core_skills::injection::SkillInjections;
|
||||
pub use codex_core_skills::injection::build_skill_injections;
|
||||
pub use codex_core_skills::injection::collect_explicit_skill_mentions;
|
||||
pub use codex_core_skills::loader;
|
||||
pub use codex_core_skills::manager;
|
||||
pub use codex_core_skills::model;
|
||||
pub use codex_core_skills::remote;
|
||||
pub use codex_core_skills::render;
|
||||
pub use codex_core_skills::render_skills_section;
|
||||
pub use codex_core_skills::system;
|
||||
|
||||
pub(crate) fn skills_load_input_from_config(
|
||||
config: &Config,
|
||||
effective_skill_roots: Vec<PathBuf>,
|
||||
) -> SkillsLoadInput {
|
||||
SkillsLoadInput::new(
|
||||
config.cwd.clone().to_path_buf(),
|
||||
effective_skill_roots,
|
||||
config.config_layer_stack.clone(),
|
||||
config.bundled_skills_enabled(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dependency| {
|
||||
let requirement = dependency.description.as_ref().map_or_else(
|
||||
|| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set.",
|
||||
dependency.skill_name, dependency.name
|
||||
)
|
||||
},
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dependency.skill_name, dependency.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dependency.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question: format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only."
|
||||
),
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = sess
|
||||
.request_user_input(
|
||||
turn_context,
|
||||
format!("skill-deps-{}", turn_context.sub_id),
|
||||
RequestUserInputArgs { questions },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: &Path,
|
||||
) {
|
||||
let Some(candidate) = detect_implicit_skill_invocation_for_command(
|
||||
turn_context.turn_skills.outcome.as_ref(),
|
||||
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 {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SkillDependencyInfo {
|
||||
pub(crate) skill_name: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve required dependency values (session cache, then env vars),
|
||||
/// and prompt the UI for any missing ones.
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
continue;
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
}
|
||||
}
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) 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" {
|
||||
continue;
|
||||
}
|
||||
if tool.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
dependencies.push(SkillDependencyInfo {
|
||||
skill_name: skill.name.clone(),
|
||||
name: tool.value.clone(),
|
||||
description: tool.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
dependencies
|
||||
}
|
||||
|
||||
/// Prompt via request_user_input to gather missing env vars.
|
||||
pub(crate) async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dep| {
|
||||
let requirement = dep.description.as_ref().map_or_else(
|
||||
|| format!("The skill \"{}\" requires \"{}\" to be set.", dep.skill_name, dep.name),
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dep.skill_name, dep.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
let question = format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only.",
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dep.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question,
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let args = RequestUserInputArgs { questions };
|
||||
let call_id = format!("skill-deps-{}", turn_context.sub_id);
|
||||
let response = sess
|
||||
.request_user_input(turn_context, call_id, args)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use tokio::runtime::Handle;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::SkillsManager;
|
||||
use crate::config::Config;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
use crate::file_watcher::FileWatcherSubscriber;
|
||||
@@ -15,7 +16,8 @@ use crate::file_watcher::Receiver;
|
||||
use crate::file_watcher::ThrottledWatchReceiver;
|
||||
use crate::file_watcher::WatchPath;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills_load_input_from_config;
|
||||
|
||||
#[cfg(not(test))]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
@@ -56,9 +58,13 @@ impl SkillsWatcher {
|
||||
&self,
|
||||
config: &Config,
|
||||
skills_manager: &SkillsManager,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> WatchRegistration {
|
||||
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 roots = skills_manager
|
||||
.skill_roots_for_config(config)
|
||||
.skill_roots_for_config(&skills_input)
|
||||
.into_iter()
|
||||
.map(|root| WatchPath {
|
||||
path: root.path,
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::sync::Arc;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::SkillsManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
@@ -12,7 +12,6 @@ use crate::mcp::McpManager;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
@@ -20,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_otel::SessionTelemetry;
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::OPENAI_PROVIDER_ID;
|
||||
use crate::SkillsManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnArgs;
|
||||
@@ -22,7 +23,6 @@ use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::truncation;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
@@ -231,7 +231,6 @@ impl ThreadManager {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
config.bundled_skills_enabled(),
|
||||
restriction_product,
|
||||
));
|
||||
@@ -297,7 +296,6 @@ impl ThreadManager {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
restriction_product,
|
||||
));
|
||||
@@ -835,9 +833,11 @@ impl ThreadManagerState {
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let watch_registration = self
|
||||
.skills_watcher
|
||||
.register_config(&config, self.skills_manager.as_ref());
|
||||
let watch_registration = self.skills_watcher.register_config(
|
||||
&config,
|
||||
self.skills_manager.as_ref(),
|
||||
self.plugins_manager.as_ref(),
|
||||
);
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
} = Codex::spawn(CodexSpawnArgs {
|
||||
|
||||
@@ -10,7 +10,6 @@ pub(crate) mod multi_agents;
|
||||
pub(crate) mod multi_agents_common;
|
||||
pub(crate) mod multi_agents_v2;
|
||||
mod plan;
|
||||
mod read_file;
|
||||
mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod shell;
|
||||
@@ -46,7 +45,6 @@ pub use list_dir::ListDirHandler;
|
||||
pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use request_permissions::RequestPermissionsHandler;
|
||||
pub(crate) use request_permissions::request_permissions_tool_description;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct ReadFileHandler;
|
||||
|
||||
const MAX_LINE_LENGTH: usize = 500;
|
||||
const TAB_WIDTH: usize = 4;
|
||||
|
||||
// TODO(jif) add support for block comments
|
||||
const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"];
|
||||
|
||||
/// JSON arguments accepted by the `read_file` tool handler.
|
||||
#[derive(Deserialize)]
|
||||
struct ReadFileArgs {
|
||||
/// Absolute path to the file that will be read.
|
||||
file_path: String,
|
||||
/// 1-indexed line number to start reading from; defaults to 1.
|
||||
#[serde(default = "defaults::offset")]
|
||||
offset: usize,
|
||||
/// Maximum number of lines to return; defaults to 2000.
|
||||
#[serde(default = "defaults::limit")]
|
||||
limit: usize,
|
||||
/// Determines whether the handler reads a simple slice or indentation-aware block.
|
||||
#[serde(default)]
|
||||
mode: ReadMode,
|
||||
/// Optional indentation configuration used when `mode` is `Indentation`.
|
||||
#[serde(default)]
|
||||
indentation: Option<IndentationArgs>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ReadMode {
|
||||
#[default]
|
||||
Slice,
|
||||
Indentation,
|
||||
}
|
||||
/// Additional configuration for indentation-aware reads.
|
||||
#[derive(Deserialize, Clone)]
|
||||
struct IndentationArgs {
|
||||
/// Optional explicit anchor line; defaults to `offset` when omitted.
|
||||
#[serde(default)]
|
||||
anchor_line: Option<usize>,
|
||||
/// Maximum indentation depth to collect; `0` means unlimited.
|
||||
#[serde(default = "defaults::max_levels")]
|
||||
max_levels: usize,
|
||||
/// Whether to include sibling blocks at the same indentation level.
|
||||
#[serde(default = "defaults::include_siblings")]
|
||||
include_siblings: bool,
|
||||
/// Whether to include header lines above the anchor block. This made on a best effort basis.
|
||||
#[serde(default = "defaults::include_header")]
|
||||
include_header: bool,
|
||||
/// Optional hard cap on returned lines; defaults to the global `limit`.
|
||||
#[serde(default)]
|
||||
max_lines: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct LineRecord {
|
||||
number: usize,
|
||||
raw: String,
|
||||
display: String,
|
||||
indent: usize,
|
||||
}
|
||||
|
||||
impl LineRecord {
|
||||
fn trimmed(&self) -> &str {
|
||||
self.raw.trim_start()
|
||||
}
|
||||
|
||||
fn is_blank(&self) -> bool {
|
||||
self.trimmed().is_empty()
|
||||
}
|
||||
|
||||
fn is_comment(&self) -> bool {
|
||||
COMMENT_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| self.raw.trim().starts_with(prefix))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ReadFileHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation { payload, .. } = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"read_file handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let args: ReadFileArgs = parse_arguments(&arguments)?;
|
||||
|
||||
let ReadFileArgs {
|
||||
file_path,
|
||||
offset,
|
||||
limit,
|
||||
mode,
|
||||
indentation,
|
||||
} = args;
|
||||
|
||||
if offset == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"offset must be a 1-indexed line number".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if limit == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"limit must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let path = PathBuf::from(&file_path);
|
||||
if !path.is_absolute() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"file_path must be an absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let collected = match mode {
|
||||
ReadMode::Slice => slice::read(&path, offset, limit).await?,
|
||||
ReadMode::Indentation => {
|
||||
let indentation = indentation.unwrap_or_default();
|
||||
indentation::read_block(&path, offset, limit, indentation).await?
|
||||
}
|
||||
};
|
||||
Ok(FunctionToolOutput::from_text(
|
||||
collected.join("\n"),
|
||||
Some(true),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
mod slice {
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::handlers::read_file::format_line;
|
||||
use std::path::Path;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
|
||||
pub async fn read(
|
||||
path: &Path,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<String>, FunctionCallError> {
|
||||
let file = File::open(path).await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
|
||||
})?;
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut collected = Vec::new();
|
||||
let mut seen = 0usize;
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
|
||||
})?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if buffer.last() == Some(&b'\n') {
|
||||
buffer.pop();
|
||||
if buffer.last() == Some(&b'\r') {
|
||||
buffer.pop();
|
||||
}
|
||||
}
|
||||
|
||||
seen += 1;
|
||||
|
||||
if seen < offset {
|
||||
continue;
|
||||
}
|
||||
|
||||
if collected.len() == limit {
|
||||
break;
|
||||
}
|
||||
|
||||
let formatted = format_line(&buffer);
|
||||
collected.push(format!("L{seen}: {formatted}"));
|
||||
|
||||
if collected.len() == limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if seen < offset {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"offset exceeds file length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(collected)
|
||||
}
|
||||
}
|
||||
|
||||
mod indentation {
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::handlers::read_file::IndentationArgs;
|
||||
use crate::tools::handlers::read_file::LineRecord;
|
||||
use crate::tools::handlers::read_file::TAB_WIDTH;
|
||||
use crate::tools::handlers::read_file::format_line;
|
||||
use crate::tools::handlers::read_file::trim_empty_lines;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::BufReader;
|
||||
|
||||
pub async fn read_block(
|
||||
path: &Path,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
options: IndentationArgs,
|
||||
) -> Result<Vec<String>, FunctionCallError> {
|
||||
let anchor_line = options.anchor_line.unwrap_or(offset);
|
||||
if anchor_line == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"anchor_line must be a 1-indexed line number".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let guard_limit = options.max_lines.unwrap_or(limit);
|
||||
if guard_limit == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"max_lines must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let collected = collect_file_lines(path).await?;
|
||||
if collected.is_empty() || anchor_line > collected.len() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"anchor_line exceeds file length".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let anchor_index = anchor_line - 1;
|
||||
let effective_indents = compute_effective_indents(&collected);
|
||||
let anchor_indent = effective_indents[anchor_index];
|
||||
|
||||
// Compute the min indent
|
||||
let min_indent = if options.max_levels == 0 {
|
||||
0
|
||||
} else {
|
||||
anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
|
||||
};
|
||||
|
||||
// Cap requested lines by guard_limit and file length
|
||||
let final_limit = limit.min(guard_limit).min(collected.len());
|
||||
|
||||
if final_limit == 1 {
|
||||
return Ok(vec![format!(
|
||||
"L{}: {}",
|
||||
collected[anchor_index].number, collected[anchor_index].display
|
||||
)]);
|
||||
}
|
||||
|
||||
// Cursors
|
||||
let mut i: isize = anchor_index as isize - 1; // up (inclusive)
|
||||
let mut j: usize = anchor_index + 1; // down (inclusive)
|
||||
let mut i_counter_min_indent = 0;
|
||||
let mut j_counter_min_indent = 0;
|
||||
|
||||
let mut out = VecDeque::with_capacity(limit);
|
||||
out.push_back(&collected[anchor_index]);
|
||||
|
||||
while out.len() < final_limit {
|
||||
let mut progressed = 0;
|
||||
|
||||
// Up.
|
||||
if i >= 0 {
|
||||
let iu = i as usize;
|
||||
if effective_indents[iu] >= min_indent {
|
||||
out.push_front(&collected[iu]);
|
||||
progressed += 1;
|
||||
i -= 1;
|
||||
|
||||
// We do not include the siblings (not applied to comments).
|
||||
if effective_indents[iu] == min_indent && !options.include_siblings {
|
||||
let allow_header_comment =
|
||||
options.include_header && collected[iu].is_comment();
|
||||
let can_take_line = allow_header_comment || i_counter_min_indent == 0;
|
||||
|
||||
if can_take_line {
|
||||
i_counter_min_indent += 1;
|
||||
} else {
|
||||
// This line shouldn't have been taken.
|
||||
out.pop_front();
|
||||
progressed -= 1;
|
||||
i = -1; // consider using Option<usize> or a control flag instead of a sentinel
|
||||
}
|
||||
}
|
||||
|
||||
// Short-cut.
|
||||
if out.len() >= final_limit {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Stop moving up.
|
||||
i = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Down.
|
||||
if j < collected.len() {
|
||||
let ju = j;
|
||||
if effective_indents[ju] >= min_indent {
|
||||
out.push_back(&collected[ju]);
|
||||
progressed += 1;
|
||||
j += 1;
|
||||
|
||||
// We do not include the siblings (applied to comments).
|
||||
if effective_indents[ju] == min_indent && !options.include_siblings {
|
||||
if j_counter_min_indent > 0 {
|
||||
// This line shouldn't have been taken.
|
||||
out.pop_back();
|
||||
progressed -= 1;
|
||||
j = collected.len();
|
||||
}
|
||||
j_counter_min_indent += 1;
|
||||
}
|
||||
} else {
|
||||
// Stop moving down.
|
||||
j = collected.len();
|
||||
}
|
||||
}
|
||||
|
||||
if progressed == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Trim empty lines
|
||||
trim_empty_lines(&mut out);
|
||||
|
||||
Ok(out
|
||||
.into_iter()
|
||||
.map(|record| format!("L{}: {}", record.number, record.display))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn collect_file_lines(path: &Path) -> Result<Vec<LineRecord>, FunctionCallError> {
|
||||
let file = File::open(path).await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
|
||||
})?;
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut buffer = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
let mut number = 0usize;
|
||||
|
||||
loop {
|
||||
buffer.clear();
|
||||
let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
|
||||
})?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
if buffer.last() == Some(&b'\n') {
|
||||
buffer.pop();
|
||||
if buffer.last() == Some(&b'\r') {
|
||||
buffer.pop();
|
||||
}
|
||||
}
|
||||
|
||||
number += 1;
|
||||
let raw = String::from_utf8_lossy(&buffer).into_owned();
|
||||
let indent = measure_indent(&raw);
|
||||
let display = format_line(&buffer);
|
||||
lines.push(LineRecord {
|
||||
number,
|
||||
raw,
|
||||
display,
|
||||
indent,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
|
||||
let mut effective = Vec::with_capacity(records.len());
|
||||
let mut previous_indent = 0usize;
|
||||
for record in records {
|
||||
if record.is_blank() {
|
||||
effective.push(previous_indent);
|
||||
} else {
|
||||
previous_indent = record.indent;
|
||||
effective.push(previous_indent);
|
||||
}
|
||||
}
|
||||
effective
|
||||
}
|
||||
|
||||
fn measure_indent(line: &str) -> usize {
|
||||
line.chars()
|
||||
.take_while(|c| matches!(c, ' ' | '\t'))
|
||||
.map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
|
||||
.sum()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_line(bytes: &[u8]) -> String {
|
||||
let decoded = String::from_utf8_lossy(bytes);
|
||||
if decoded.len() > MAX_LINE_LENGTH {
|
||||
take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string()
|
||||
} else {
|
||||
decoded.into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) {
|
||||
while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) {
|
||||
out.pop_front();
|
||||
}
|
||||
while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) {
|
||||
out.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
mod defaults {
|
||||
use super::*;
|
||||
|
||||
impl Default for IndentationArgs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
anchor_line: None,
|
||||
max_levels: max_levels(),
|
||||
include_siblings: include_siblings(),
|
||||
include_header: include_header(),
|
||||
max_lines: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn offset() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
pub fn limit() -> usize {
|
||||
2000
|
||||
}
|
||||
|
||||
pub fn max_levels() -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
pub fn include_siblings() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub fn include_header() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "read_file_tests.rs"]
|
||||
mod tests;
|
||||
@@ -11,9 +11,9 @@ use crate::exec_env::create_env;
|
||||
use crate::exec_policy::ExecApprovalRequest;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::maybe_emit_implicit_skill_invocation;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::shell::Shell;
|
||||
use crate::skills::maybe_emit_implicit_skill_invocation;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -288,11 +288,12 @@ impl ToolHandler for ShellCommandHandler {
|
||||
let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?;
|
||||
let params: ShellCommandToolCallParams =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
let workdir = turn.resolve_path(params.workdir.clone());
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
¶ms.command,
|
||||
params.workdir.as_deref(),
|
||||
&workdir,
|
||||
)
|
||||
.await;
|
||||
let prefix_rule = params.prefix_rule.clone();
|
||||
|
||||
@@ -252,45 +252,90 @@ async fn verify_tool_suggestion_completed(
|
||||
auth: Option<&crate::CodexAuth>,
|
||||
) -> bool {
|
||||
match tool {
|
||||
DiscoverableTool::Connector(connector) => {
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
&turn.config,
|
||||
auth,
|
||||
&mcp_tools,
|
||||
);
|
||||
verified_connector_suggestion_completed(
|
||||
connector.id.as_str(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
|
||||
connector.id
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
DiscoverableTool::Connector(connector) => refresh_missing_suggested_connectors(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
std::slice::from_ref(&connector.id),
|
||||
connector.id.as_str(),
|
||||
)
|
||||
.await
|
||||
.is_some_and(|accessible_connectors| {
|
||||
verified_connector_suggestion_completed(connector.id.as_str(), &accessible_connectors)
|
||||
}),
|
||||
DiscoverableTool::Plugin(plugin) => {
|
||||
session.reload_user_config_layer().await;
|
||||
let config = session.get_config().await;
|
||||
verified_plugin_suggestion_completed(
|
||||
let completed = verified_plugin_suggestion_completed(
|
||||
plugin.id.as_str(),
|
||||
config.as_ref(),
|
||||
session.services.plugins_manager.as_ref(),
|
||||
);
|
||||
let _ = refresh_missing_suggested_connectors(
|
||||
session,
|
||||
turn,
|
||||
auth,
|
||||
&plugin.app_connector_ids,
|
||||
plugin.id.as_str(),
|
||||
)
|
||||
.await;
|
||||
completed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn refresh_missing_suggested_connectors(
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
auth: Option<&crate::CodexAuth>,
|
||||
expected_connector_ids: &[String],
|
||||
tool_id: &str,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
if expected_connector_ids.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
if all_suggested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
|
||||
return Some(accessible_connectors);
|
||||
}
|
||||
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
&turn.config,
|
||||
auth,
|
||||
&mcp_tools,
|
||||
);
|
||||
Some(accessible_connectors)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after tool suggestion for {tool_id}: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn all_suggested_connectors_picked_up(
|
||||
expected_connector_ids: &[String],
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> bool {
|
||||
expected_connector_ids.iter().all(|connector_id| {
|
||||
verified_connector_suggestion_completed(connector_id, accessible_connectors)
|
||||
})
|
||||
}
|
||||
|
||||
fn verified_connector_suggestion_completed(
|
||||
tool_id: &str,
|
||||
accessible_connectors: &[AppInfo],
|
||||
|
||||
@@ -235,6 +235,34 @@ fn verified_connector_suggestion_completed_requires_accessible_connector() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_suggested_connectors_picked_up_requires_every_expected_connector() {
|
||||
let accessible_connectors = vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".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: true,
|
||||
is_enabled: false,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert!(all_suggested_connectors_picked_up(
|
||||
&["calendar".to_string()],
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(!all_suggested_connectors_picked_up(
|
||||
&["calendar".to_string(), "gmail".to_string()],
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::maybe_emit_implicit_skill_invocation;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TerminalInteractionEvent;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::get_shell_by_model_provided_path;
|
||||
use crate::skills::maybe_emit_implicit_skill_invocation;
|
||||
use crate::tools::context::ExecCommandToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -147,11 +147,12 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
let cwd = resolve_workdir_base_path(&arguments, context.turn.cwd.as_path())?;
|
||||
let args: ExecCommandArgs =
|
||||
parse_arguments_with_base_path(&arguments, cwd.as_path())?;
|
||||
let workdir = context.turn.resolve_path(args.workdir.clone());
|
||||
maybe_emit_implicit_skill_invocation(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
context.turn.as_ref(),
|
||||
&args.cmd,
|
||||
args.workdir.as_deref(),
|
||||
&workdir,
|
||||
)
|
||||
.await;
|
||||
let process_id = manager.allocate_process_id().await;
|
||||
|
||||
@@ -4,9 +4,9 @@ Module: runtimes
|
||||
Concrete ToolRuntime implementations for specific tools. Each runtime stays
|
||||
small and focused and reuses the orchestrator for approvals + sandbox + retry.
|
||||
*/
|
||||
use crate::SkillMetadata;
|
||||
use crate::path_utils;
|
||||
use crate::shell::Shell;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_sandboxing::SandboxCommand;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::ShellRequest;
|
||||
use crate::SkillMetadata;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecCapturePolicy;
|
||||
@@ -12,7 +13,7 @@ use crate::sandboxing::ExecOptions;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::shell::ShellType;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills_load_input_from_config;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::runtimes::build_sandbox_command;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
@@ -487,11 +488,19 @@ impl CoreShellActionProvider {
|
||||
/// any skills.
|
||||
async fn find_skill(&self, program: &AbsolutePathBuf) -> Option<SkillMetadata> {
|
||||
let force_reload = false;
|
||||
let turn_config = self.turn.config.as_ref();
|
||||
let plugin_outcome = self
|
||||
.session
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(turn_config, effective_skill_roots);
|
||||
let skills_outcome = self
|
||||
.session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&self.turn.cwd, self.turn.config.as_ref(), force_reload)
|
||||
.skills_for_cwd(&skills_input, force_reload)
|
||||
.await;
|
||||
|
||||
let program_path = program.as_path();
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::evaluate_intercepted_exec_policy;
|
||||
use super::extract_shell_script;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
use crate::SkillMetadata;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::Constrained;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -19,7 +20,6 @@ use crate::protocol::GranularApprovalConfig;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::skills::SkillMetadata;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Evaluation;
|
||||
use codex_execpolicy::PolicyParser;
|
||||
|
||||
@@ -390,8 +390,7 @@ impl ToolsConfig {
|
||||
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
|
||||
let include_search_tool =
|
||||
model_info.supports_search_tool && features.enabled(Feature::ToolSearch);
|
||||
let include_tool_suggest = include_search_tool
|
||||
&& features.enabled(Feature::ToolSuggest)
|
||||
let include_tool_suggest = features.enabled(Feature::ToolSuggest)
|
||||
&& features.enabled(Feature::Apps)
|
||||
&& features.enabled(Feature::Plugins);
|
||||
let include_original_image_detail = can_request_original_image_detail(features, model_info);
|
||||
@@ -2046,111 +2045,6 @@ fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_read_file_tool() -> ToolSpec {
|
||||
let indentation_properties = BTreeMap::from([
|
||||
(
|
||||
"anchor_line".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Anchor line to center the indentation lookup on (defaults to offset)."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_levels".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How many parent indentation levels (smaller indents) to include.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"include_siblings".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"When true, include additional blocks that share the anchor indentation."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"include_header".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Include doc comments or attributes directly above the selected block."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_lines".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Hard cap on the number of lines returned when using indentation mode."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"file_path".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Absolute path to the file".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"offset".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"The line number to start reading from. Must be 1 or greater.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"limit".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The maximum number of lines to return.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"mode".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \
|
||||
to expand around an anchor line."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"indentation".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: indentation_properties,
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "read_file".to_string(),
|
||||
description:
|
||||
"Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["file_path".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_list_dir_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
@@ -2704,7 +2598,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::McpHandler;
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
@@ -2975,20 +2868,6 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
builder.register_handler("apply_patch", apply_patch_handler);
|
||||
}
|
||||
|
||||
if config
|
||||
.experimental_supported_tools
|
||||
.contains(&"read_file".to_string())
|
||||
{
|
||||
let read_file_handler = Arc::new(ReadFileHandler);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_read_file_tool(),
|
||||
/*supports_parallel_tool_calls*/ true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler("read_file", read_file_handler);
|
||||
}
|
||||
|
||||
if config
|
||||
.experimental_supported_tools
|
||||
.iter()
|
||||
|
||||
@@ -2048,6 +2048,8 @@ async fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::ToolSearch);
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.disable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
@@ -2079,6 +2081,54 @@ async fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_can_be_registered_without_search_tool() {
|
||||
let model_info = ModelInfo {
|
||||
supports_search_tool: false,
|
||||
..search_capable_model_info().await
|
||||
};
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::Plugins);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
None,
|
||||
None,
|
||||
Some(vec![discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
)]),
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAME]);
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert!(
|
||||
description.contains(
|
||||
"You've already tried to find a matching available tool for the user's request"
|
||||
)
|
||||
);
|
||||
assert!(description.contains("This includes `tool_search` (if available) and other means."));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
@@ -2333,7 +2383,14 @@ async fn tool_suggest_description_lists_discoverable_tools() {
|
||||
assert!(
|
||||
description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
|
||||
);
|
||||
assert!(
|
||||
description.contains(
|
||||
"You've already tried to find a matching available tool for the user's request"
|
||||
)
|
||||
);
|
||||
assert!(description.contains("This includes `tool_search` (if available) and other means."));
|
||||
assert!(description.contains("DO NOT explore or recommend tools that are not on this list."));
|
||||
assert!(!description.contains("tool_search fails to find a good match"));
|
||||
let JsonSchema::Object { required, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
};
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list.
|
||||
|
||||
Use this ONLY when:
|
||||
- There's no available tool to handle the user's request
|
||||
- And tool_search fails to find a good match
|
||||
- You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means.
|
||||
- AND the user's request strongly matches one of the discoverable tools listed below.
|
||||
|
||||
Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.
|
||||
@@ -14,12 +13,13 @@ Discoverable tools:
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Match the user's request against the discoverable tools list above.
|
||||
2. If one tool clearly fits, call `tool_suggest` with:
|
||||
1. Ensure all possible means have been exhausted to find an existing available tool but none of them matches the request intent.
|
||||
2. Match the user's request against the discoverable tools list above.
|
||||
3. If one tool clearly fits, call `tool_suggest` with:
|
||||
- `tool_type`: `connector` or `plugin`
|
||||
- `action_type`: `install` or `enable`
|
||||
- `tool_id`: exact id from the discoverable tools list above
|
||||
- `suggest_reason`: concise one-line user-facing reason this tool can help with the current request
|
||||
3. After the suggestion flow completes:
|
||||
4. After the suggestion flow completes:
|
||||
- if the user finished the install or enable flow, continue by searching again or using the newly available tool
|
||||
- if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to.
|
||||
|
||||
@@ -129,6 +129,7 @@ mod subagent_notifications;
|
||||
mod text_encoding_fix;
|
||||
mod tool_harness;
|
||||
mod tool_parallelism;
|
||||
mod tool_suggest;
|
||||
mod tools;
|
||||
mod truncation;
|
||||
mod turn_state;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::types::ToolSuggestDiscoverable;
|
||||
use codex_core::config::types::ToolSuggestDiscoverableType;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use serde_json::Value;
|
||||
|
||||
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||
const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
|
||||
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
|
||||
|
||||
fn tool_names(body: &Value) -> Vec<String> {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.map(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.filter_map(|tool| {
|
||||
tool.get("name")
|
||||
.or_else(|| tool.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn function_tool_description(body: &Value, name: &str) -> Option<String> {
|
||||
body.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|tools| {
|
||||
tools.iter().find_map(|tool| {
|
||||
if tool.get("name").and_then(Value::as_str) == Some(name) {
|
||||
tool.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Apps)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Plugins)
|
||||
.expect("test config should allow feature update");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::ToolSuggest)
|
||||
.expect("test config should allow feature update");
|
||||
config.chatgpt_base_url = apps_base_url.to_string();
|
||||
config.model = Some("gpt-5-codex".to_string());
|
||||
config.tool_suggest.discoverables = vec![ToolSuggestDiscoverable {
|
||||
kind: ToolSuggestDiscoverableType::Connector,
|
||||
id: DISCOVERABLE_GMAIL_ID.to_string(),
|
||||
}];
|
||||
|
||||
let mut model_catalog: ModelsResponse =
|
||||
serde_json::from_str(include_str!("../../models.json")).expect("valid models.json");
|
||||
let model = model_catalog
|
||||
.models
|
||||
.iter_mut()
|
||||
.find(|model| model.slug == "gpt-5-codex")
|
||||
.expect("gpt-5-codex exists in bundled models.json");
|
||||
model.supports_search_tool = false;
|
||||
config.model_catalog = Some(model_catalog);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount(&server).await?;
|
||||
let mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(move |config| {
|
||||
configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str())
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn_with_policies(
|
||||
"list tools",
|
||||
AskForApproval::Never,
|
||||
SandboxPolicy::DangerFullAccess,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let body = mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(
|
||||
!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME),
|
||||
"tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}"
|
||||
);
|
||||
assert!(
|
||||
tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME),
|
||||
"tools list should include {TOOL_SUGGEST_TOOL_NAME}: {tools:?}"
|
||||
);
|
||||
|
||||
let description =
|
||||
function_tool_description(&body, TOOL_SUGGEST_TOOL_NAME).expect("description");
|
||||
assert!(
|
||||
description.contains(
|
||||
"You've already tried to find a matching available tool for the user's request"
|
||||
)
|
||||
);
|
||||
assert!(description.contains("This includes `tool_search` (if available) and other means."));
|
||||
assert!(!description.contains("tool_search fails to find a good match"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -717,7 +717,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::Apps,
|
||||
key: "apps",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ToolSearch,
|
||||
@@ -729,13 +729,13 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::ToolSuggest,
|
||||
key: "tool_suggest",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Plugins,
|
||||
key: "plugins",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ImageGeneration,
|
||||
@@ -787,7 +787,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::ToolCallMcpElicitation,
|
||||
key: "tool_call_mcp_elicitation",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: true,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Personality,
|
||||
|
||||
@@ -115,9 +115,9 @@ fn request_permissions_tool_is_under_development() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_is_stable_and_enabled_by_default() {
|
||||
fn tool_suggest_is_stable_and_disabled_by_default() {
|
||||
assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable);
|
||||
assert_eq!(Feature::ToolSuggest.default_enabled(), true);
|
||||
assert_eq!(Feature::ToolSuggest.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -145,9 +145,9 @@ fn image_generation_is_under_development() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() {
|
||||
fn tool_call_mcp_elicitation_is_stable_and_disabled_by_default() {
|
||||
assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable);
|
||||
assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true);
|
||||
assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "instructions",
|
||||
crate_name = "codex_instructions",
|
||||
compile_data = glob(
|
||||
include = ["**"],
|
||||
exclude = [
|
||||
"BUILD.bazel",
|
||||
"Cargo.toml",
|
||||
],
|
||||
allow_empty = True,
|
||||
) + [
|
||||
"//codex-rs:node-version.txt",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-instructions"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_instructions"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
@@ -0,0 +1,61 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
@@ -0,0 +1,11 @@
|
||||
//! User and skill instruction payloads and contextual user fragment markers for Codex prompts.
|
||||
|
||||
mod fragment;
|
||||
mod user_instructions;
|
||||
|
||||
pub use fragment::AGENTS_MD_FRAGMENT;
|
||||
pub use fragment::ContextualUserFragmentDefinition;
|
||||
pub use fragment::SKILL_FRAGMENT;
|
||||
pub use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub use user_instructions::UserInstructions;
|
||||
+7
-8
@@ -3,20 +3,21 @@ use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
|
||||
use crate::contextual_user_message::SKILL_FRAGMENT;
|
||||
use crate::fragment::AGENTS_MD_FRAGMENT;
|
||||
use crate::fragment::AGENTS_MD_START_MARKER;
|
||||
use crate::fragment::SKILL_FRAGMENT;
|
||||
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct UserInstructions {
|
||||
pub struct UserInstructions {
|
||||
pub directory: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub(crate) fn serialize_to_text(&self) -> String {
|
||||
pub fn serialize_to_text(&self) -> String {
|
||||
format!(
|
||||
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
|
||||
prefix = AGENTS_MD_FRAGMENT.start_marker(),
|
||||
@@ -35,14 +36,12 @@ impl From<UserInstructions> for ResponseItem {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "skill_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct SkillInstructions {
|
||||
pub struct SkillInstructions {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl SkillInstructions {}
|
||||
|
||||
impl From<SkillInstructions> for ResponseItem {
|
||||
fn from(si: SkillInstructions) -> Self {
|
||||
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(
|
||||
+4
@@ -1,7 +1,11 @@
|
||||
use super::*;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use crate::fragment::AGENTS_MD_FRAGMENT;
|
||||
use crate::fragment::SKILL_FRAGMENT;
|
||||
|
||||
#[test]
|
||||
fn test_user_instructions() {
|
||||
let user_instructions = UserInstructions {
|
||||
@@ -0,0 +1,15 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "plugin",
|
||||
crate_name = "codex_plugin",
|
||||
compile_data = glob(
|
||||
include = ["**"],
|
||||
exclude = [
|
||||
"**/* *",
|
||||
"BUILD.bazel",
|
||||
"Cargo.toml",
|
||||
],
|
||||
allow_empty = True,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-plugin"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
name = "codex_plugin"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Shared plugin identifiers and telemetry-facing summaries.
|
||||
|
||||
pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
pub use codex_utils_plugins::mention_syntax;
|
||||
pub use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
|
||||
mod load_outcome;
|
||||
mod plugin_id;
|
||||
|
||||
pub use load_outcome::EffectiveSkillRoots;
|
||||
pub use load_outcome::LoadedPlugin;
|
||||
pub use load_outcome::PluginLoadOutcome;
|
||||
pub use load_outcome::prompt_safe_plugin_description;
|
||||
pub use plugin_id::PluginId;
|
||||
pub use plugin_id::PluginIdError;
|
||||
pub use plugin_id::validate_plugin_segment;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AppConnectorId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginCapabilitySummary {
|
||||
pub config_name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_skills: bool,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub app_connector_ids: Vec<AppConnectorId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
pub plugin_id: PluginId,
|
||||
pub capability_summary: Option<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl PluginTelemetryMetadata {
|
||||
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.clone(),
|
||||
capability_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCapabilitySummary {
|
||||
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
|
||||
PluginId::parse(&self.config_name)
|
||||
.ok()
|
||||
.map(|plugin_id| PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
capability_summary: Some(self.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::AppConnectorId;
|
||||
use crate::PluginCapabilitySummary;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
/// A plugin that was loaded from disk, including merged MCP server definitions.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin<M> {
|
||||
pub config_name: String,
|
||||
pub manifest_name: Option<String>,
|
||||
pub manifest_description: Option<String>,
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, M>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<M> LoadedPlugin<M> {
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.enabled && self.error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_capability_summary_from_loaded<M>(
|
||||
plugin: &LoadedPlugin<M>,
|
||||
) -> Option<PluginCapabilitySummary> {
|
||||
if !plugin.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
|
||||
mcp_server_names.sort_unstable();
|
||||
|
||||
let summary = PluginCapabilitySummary {
|
||||
config_name: plugin.config_name.clone(),
|
||||
display_name: plugin
|
||||
.manifest_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
|
||||
(summary.has_skills
|
||||
|| !summary.mcp_server_names.is_empty()
|
||||
|| !summary.app_connector_ids.is_empty())
|
||||
.then_some(summary)
|
||||
}
|
||||
|
||||
/// Normalizes plugin descriptions for inclusion in model-facing capability summaries.
|
||||
pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
|
||||
let description = description?
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if description.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
description
|
||||
.chars()
|
||||
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Outcome of loading configured plugins (skills roots, MCP, apps, errors).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginLoadOutcome<M> {
|
||||
plugins: Vec<LoadedPlugin<M>>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl<M: Clone> Default for PluginLoadOutcome<M> {
|
||||
fn default() -> Self {
|
||||
Self::from_plugins(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Clone> PluginLoadOutcome<M> {
|
||||
pub fn from_plugins(plugins: Vec<LoadedPlugin<M>>) -> Self {
|
||||
let capability_summaries = plugins
|
||||
.iter()
|
||||
.filter_map(plugin_capability_summary_from_loaded)
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
let mut skill_roots: Vec<PathBuf> = self
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
|
||||
.collect();
|
||||
skill_roots.sort_unstable();
|
||||
skill_roots.dedup();
|
||||
skill_roots
|
||||
}
|
||||
|
||||
pub fn effective_mcp_servers(&self) -> HashMap<String, M> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for (name, config) in &plugin.mcp_servers {
|
||||
mcp_servers
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| config.clone());
|
||||
}
|
||||
}
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
|
||||
let mut apps = Vec::new();
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for connector_id in &plugin.apps {
|
||||
if seen_connector_ids.insert(connector_id.clone()) {
|
||||
apps.push(connector_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin<M>] {
|
||||
&self.plugins
|
||||
}
|
||||
}
|
||||
|
||||
/// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin`
|
||||
/// without naming the MCP config type parameter.
|
||||
pub trait EffectiveSkillRoots {
|
||||
fn effective_skill_roots(&self) -> Vec<PathBuf>;
|
||||
}
|
||||
|
||||
impl<M: Clone> EffectiveSkillRoots for PluginLoadOutcome<M> {
|
||||
fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
PluginLoadOutcome::effective_skill_roots(self)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user