Merge branch 'main' into pakrym/app-server-with-logs

# Conflicts:
#	codex-rs/app-server/src/lib.rs
This commit is contained in:
pakrym-oai
2026-02-20 18:10:57 -08:00
194 changed files with 9511 additions and 2346 deletions
+17 -1
View File
@@ -8,9 +8,25 @@ FROM ubuntu:24.04
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl git python3 ca-certificates && \
curl git python3 ca-certificates xz-utils && \
rm -rf /var/lib/apt/lists/*
COPY codex-rs/node-version.txt /tmp/node-version.txt
RUN set -eux; \
node_arch="$(dpkg --print-architecture)"; \
case "${node_arch}" in \
amd64) node_dist_arch="x64" ;; \
arm64) node_dist_arch="arm64" ;; \
*) echo "unsupported architecture: ${node_arch}"; exit 1 ;; \
esac; \
node_version="$(tr -d '[:space:]' </tmp/node-version.txt)"; \
curl -fsSLO "https://nodejs.org/dist/v${node_version}/node-v${node_version}-linux-${node_dist_arch}.tar.xz"; \
tar -xJf "node-v${node_version}-linux-${node_dist_arch}.tar.xz" -C /usr/local --strip-components=1; \
rm "node-v${node_version}-linux-${node_dist_arch}.tar.xz" /tmp/node-version.txt; \
node --version; \
npm --version
# Install dotslash.
RUN curl -LSfs "https://github.com/facebook/dotslash/releases/download/v0.5.8/dotslash-ubuntu-22.04.$(uname -m).tar.gz" | tar fxz - -C /usr/local/bin
+1
View File
@@ -20,6 +20,7 @@ osx.framework(name = "CFNetwork")
osx.framework(name = "FontServices")
osx.framework(name = "Foundation")
osx.framework(name = "ImageIO")
osx.framework(name = "IOKit")
osx.framework(name = "Kernel")
osx.framework(name = "OSLog")
osx.framework(name = "Security")
+1 -4
View File
@@ -612,10 +612,6 @@
"arrayvec_0.7.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.4\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"matches\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"ascii-canvas_3.0.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"diff\",\"req\":\"^0.1\"},{\"name\":\"term\",\"req\":\"^0.7\"}],\"features\":{}}",
"ascii_1.1.0": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"name\":\"serde_test\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}",
"askama_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"askama_macros\",\"optional\":true,\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"itoa\",\"req\":\"^1.0.11\"},{\"default_features\":false,\"name\":\"percent-encoding\",\"optional\":true,\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"alloc\":[\"askama_macros?/alloc\",\"serde?/alloc\",\"serde_json?/alloc\",\"percent-encoding?/alloc\"],\"code-in-doc\":[\"askama_macros?/code-in-doc\"],\"config\":[\"askama_macros?/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[\"dep:askama_macros\",\"dep:askama_macros\"],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_macros/nightly-spans\"],\"serde_json\":[\"std\",\"askama_macros?/serde_json\",\"dep:serde\",\"dep:serde_json\"],\"std\":[\"alloc\",\"askama_macros?/std\",\"serde?/std\",\"serde_json?/std\",\"percent-encoding?/std\"],\"urlencode\":[\"askama_macros?/urlencode\",\"dep:percent-encoding\"]}}",
"askama_derive_0.15.4": "{\"dependencies\":[{\"name\":\"basic-toml\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.16.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"memchr\",\"req\":\"^2\"},{\"name\":\"parser\",\"package\":\"askama_parser\",\"req\":\"=0.15.4\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.20\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"pulldown-cmark\",\"optional\":true,\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.6.0\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"derive\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.3\"}],\"features\":{\"alloc\":[],\"code-in-doc\":[\"dep:pulldown-cmark\"],\"config\":[\"external-sources\",\"dep:basic-toml\",\"dep:serde\",\"dep:serde_derive\",\"parser/config\"],\"default\":[\"alloc\",\"code-in-doc\",\"config\",\"external-sources\",\"proc-macro\",\"serde_json\",\"std\",\"urlencode\"],\"external-sources\":[],\"nightly-spans\":[],\"proc-macro\":[\"proc-macro2/proc-macro\"],\"serde_json\":[],\"std\":[\"alloc\"],\"urlencode\":[]}}",
"askama_macros_0.15.4": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"external-sources\",\"proc-macro\"],\"name\":\"askama_derive\",\"package\":\"askama_derive\",\"req\":\"=0.15.4\"}],\"features\":{\"alloc\":[\"askama_derive/alloc\"],\"code-in-doc\":[\"askama_derive/code-in-doc\"],\"config\":[\"askama_derive/config\"],\"default\":[\"config\",\"derive\",\"std\",\"urlencode\"],\"derive\":[],\"full\":[\"default\",\"code-in-doc\",\"serde_json\"],\"nightly-spans\":[\"askama_derive/nightly-spans\"],\"serde_json\":[\"askama_derive/serde_json\"],\"std\":[\"askama_derive/std\"],\"urlencode\":[\"askama_derive/urlencode\"]}}",
"askama_parser_0.15.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"name\":\"rustc-hash\",\"req\":\"^2.0.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"unicode-ident\",\"req\":\"^1.0.12\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.0\"}],\"features\":{\"config\":[\"dep:serde\",\"dep:serde_derive\"]}}",
"asn1-rs-derive_0.6.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"name\":\"synstructure\",\"req\":\"^0.13\"}],\"features\":{}}",
"asn1-rs-impl_0.2.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}",
"asn1-rs_0.7.1": "{\"dependencies\":[{\"name\":\"asn1-rs-derive\",\"req\":\"^0.6\"},{\"name\":\"asn1-rs-impl\",\"req\":\"^0.2\"},{\"name\":\"bitvec\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"colored\",\"optional\":true,\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"colored\",\"req\":\"^3.0\"},{\"name\":\"cookie-factory\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"displaydoc\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"nom\",\"req\":\"^7.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"num-traits\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"pem\",\"req\":\"^3.0\"},{\"name\":\"rusticata-macros\",\"req\":\"^4.0\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"macros\",\"parsing\",\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"bigint\":[\"num-bigint\"],\"bits\":[\"bitvec\"],\"datetime\":[\"time\"],\"debug\":[\"std\",\"colored\"],\"default\":[\"std\"],\"serialize\":[\"cookie-factory\"],\"std\":[],\"trace\":[\"debug\"]}}",
@@ -1336,6 +1332,7 @@
"tracing-error_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"registry\",\"fmt\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"}],\"features\":{\"default\":[\"traced-error\"],\"traced-error\":[]}}",
"tracing-log_0.2.0": "{\"dependencies\":[{\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.7.6\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"lru\",\"optional\":true,\"req\":\"^0.7.7\"},{\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"}],\"features\":{\"default\":[\"log-tracer\",\"std\"],\"interest-cache\":[\"lru\",\"ahash\"],\"log-tracer\":[],\"std\":[\"log/std\"]}}",
"tracing-opentelemetry_0.32.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"js-sys\",\"req\":\"^0.3.64\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"},{\"name\":\"lazy_static\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"trace\"],\"name\":\"opentelemetry\",\"req\":\"^0.31.0\"},{\"features\":[\"trace\",\"metrics\"],\"kind\":\"dev\",\"name\":\"opentelemetry\",\"req\":\"^0.31.0\"},{\"features\":[\"metrics\",\"grpc-tonic\"],\"kind\":\"dev\",\"name\":\"opentelemetry-otlp\",\"req\":\"^0.31.0\"},{\"features\":[\"semconv_experimental\"],\"kind\":\"dev\",\"name\":\"opentelemetry-semantic-conventions\",\"req\":\"^0.31.0\"},{\"features\":[\"trace\",\"metrics\"],\"kind\":\"dev\",\"name\":\"opentelemetry-stdout\",\"req\":\"^0.31.0\"},{\"default_features\":false,\"features\":[\"trace\",\"experimental_metrics_custom_reader\",\"testing\"],\"kind\":\"dev\",\"name\":\"opentelemetry_sdk\",\"req\":\"^0.31.0\"},{\"features\":[\"flamegraph\",\"criterion\"],\"kind\":\"dev\",\"name\":\"pprof\",\"req\":\"^0.15.0\",\"target\":\"cfg(not(target_os = \\\"windows\\\"))\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"std\",\"attributes\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.35\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"},{\"kind\":\"dev\",\"name\":\"tracing-error\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"registry\",\"std\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3.22\"},{\"default_features\":false,\"features\":[\"registry\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"web-time\",\"req\":\"^1.0.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\")))\"}],\"features\":{\"default\":[\"tracing-log\",\"metrics\"],\"metrics\":[\"opentelemetry/metrics\",\"smallvec\"]}}",
"tracing-serde_0.2.0": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"tracing-core\",\"req\":\"^0.1.28\"},{\"default_features\":false,\"name\":\"valuable-serde\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"},{\"default_features\":false,\"name\":\"valuable_crate\",\"optional\":true,\"package\":\"valuable\",\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"valuable\":[\"valuable_crate\",\"valuable-serde\",\"tracing-core/valuable\"]}}",
"tracing-subscriber_0.3.22": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"clock\",\"std\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.26\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.6\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"name\":\"matchers\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"nu-ansi-term\",\"optional\":true,\"req\":\"^0.50.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.13.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"regex-automata\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.140\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.82\"},{\"name\":\"sharded-slab\",\"optional\":true,\"req\":\"^0.1.4\"},{\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"thread_local\",\"optional\":true,\"req\":\"^1.1.4\"},{\"features\":[\"formatting\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.2\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"rt\",\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.43\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"name\":\"tracing-core\",\"req\":\"^0.1.35\"},{\"default_features\":false,\"features\":[\"std-future\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-futures\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"log-tracer\",\"std\"],\"name\":\"tracing-log\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"tracing-log\",\"req\":\"^0.2.0\"},{\"name\":\"tracing-serde\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"valuable-serde\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"},{\"default_features\":false,\"name\":\"valuable_crate\",\"optional\":true,\"package\":\"valuable\",\"req\":\"^0.1.0\",\"target\":\"cfg(tracing_unstable)\"}],\"features\":{\"alloc\":[],\"ansi\":[\"fmt\",\"nu-ansi-term\"],\"default\":[\"smallvec\",\"fmt\",\"ansi\",\"tracing-log\",\"std\"],\"env-filter\":[\"matchers\",\"once_cell\",\"tracing\",\"std\",\"thread_local\",\"dep:regex-automata\"],\"fmt\":[\"registry\",\"std\"],\"json\":[\"tracing-serde\",\"serde\",\"serde_json\"],\"local-time\":[\"time/local-offset\"],\"nu-ansi-term\":[\"dep:nu-ansi-term\"],\"regex\":[],\"registry\":[\"sharded-slab\",\"thread_local\",\"std\"],\"std\":[\"alloc\",\"tracing-core/std\"],\"valuable\":[\"tracing-core/valuable\",\"valuable_crate\",\"valuable-serde\",\"tracing-serde/valuable\"]}}",
"tracing-test-macro_0.2.5": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"no-env-filter\":[]}}",
"tracing-test_0.2.5": "{\"dependencies\":[{\"features\":[\"rt-multi-thread\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"name\":\"tracing-core\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\"],\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"tracing-test-macro\",\"req\":\"^0.2.5\"}],\"features\":{\"no-env-filter\":[\"tracing-test-macro/no-env-filter\"]}}",
+27 -20
View File
@@ -423,7 +423,7 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
"wl-clipboard-rs",
"x11rb",
]
@@ -1327,6 +1327,7 @@ dependencies = [
"time",
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"tracing",
"tracing-subscriber",
@@ -1354,6 +1355,7 @@ dependencies = [
"strum_macros 0.27.2",
"tempfile",
"thiserror 2.0.18",
"tracing",
"ts-rs",
"uuid",
]
@@ -1635,6 +1637,7 @@ dependencies = [
"codex-otel",
"codex-protocol",
"codex-rmcp-client",
"codex-secrets",
"codex-shell-command",
"codex-state",
"codex-utils-absolute-path",
@@ -1642,7 +1645,6 @@ dependencies = [
"codex-utils-home-dir",
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-sanitizer",
"codex-utils-string",
"codex-windows-sandbox",
"core-foundation 0.9.4",
@@ -1985,7 +1987,6 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"clap",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
@@ -2150,6 +2151,7 @@ dependencies = [
"keyring",
"pretty_assertions",
"rand 0.9.2",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
@@ -2438,19 +2440,11 @@ dependencies = [
"pretty_assertions",
]
[[package]]
name = "codex-utils-sanitizer"
version = "0.0.0"
dependencies = [
"regex",
]
[[package]]
name = "codex-utils-sleep-inhibitor"
version = "0.0.0"
dependencies = [
"core-foundation 0.9.4",
"libc",
"tracing",
]
@@ -3240,7 +3234,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3485,7 +3479,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4878,7 +4872,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5586,7 +5580,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6152,7 +6146,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6713,7 +6707,7 @@ dependencies = [
"once_cell",
"socket2 0.6.2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -7539,7 +7533,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8916,7 +8910,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.3",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9530,6 +9524,16 @@ dependencies = [
"web-time",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
@@ -9540,12 +9544,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
@@ -10276,7 +10283,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
-2
View File
@@ -53,7 +53,6 @@ members = [
"utils/cli",
"utils/elapsed",
"utils/sandbox-summary",
"utils/sanitizer",
"utils/sleep-inhibitor",
"utils/approval-presets",
"utils/oss",
@@ -131,7 +130,6 @@ codex-utils-pty = { path = "utils/pty" }
codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sanitizer = { path = "utils/sanitizer" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
codex-utils-string = { path = "utils/string" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
+1
View File
@@ -25,6 +25,7 @@ strum_macros = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }
inventory = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
@@ -507,6 +507,15 @@
"classification": {
"type": "string"
},
"extraLogFiles": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeLogs": {
"type": "boolean"
},
@@ -110,6 +110,30 @@
"type": "object"
}
]
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5Tcp",
"socks5Udp"
],
"type": "string"
}
},
"properties": {
@@ -147,6 +171,17 @@
"itemId": {
"type": "string"
},
"networkApprovalContext": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional context for managed-network approval prompts."
},
"proposedExecpolicyAmendment": {
"description": "Optional proposed execpolicy amendment to allow similar commands without prompting.",
"items": {
@@ -296,6 +296,75 @@
}
]
},
"CollabAgentRef": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"thread_id"
],
"type": "object"
},
"CollabAgentStatusEntry": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"status": {
"allOf": [
{
"$ref": "#/definitions/AgentStatus"
}
],
"description": "Last known status of the agent."
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"status",
"thread_id"
],
"type": "object"
},
"ContentItem": {
"oneOf": [
{
@@ -2548,6 +2617,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -2653,6 +2736,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2703,6 +2800,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -2738,6 +2842,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -2821,6 +2932,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2870,6 +2995,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2910,6 +3049,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -7528,6 +7681,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -7633,6 +7800,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -7683,6 +7864,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -7718,6 +7906,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -7801,6 +7996,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -7850,6 +8059,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -7890,6 +8113,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -808,6 +808,36 @@
}
]
},
"CollabAgentRef": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"thread_id"
],
"type": "object"
},
"CollabAgentState": {
"properties": {
"message": {
@@ -836,6 +866,45 @@
],
"type": "string"
},
"CollabAgentStatusEntry": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"status": {
"allOf": [
{
"$ref": "#/definitions/AgentStatus"
}
],
"description": "Last known status of the agent."
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"status",
"thread_id"
],
"type": "object"
},
"CollabAgentTool": {
"enum": [
"spawnAgent",
@@ -3364,6 +3433,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -3469,6 +3552,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -3519,6 +3616,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -3554,6 +3658,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -3637,6 +3748,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -3686,6 +3811,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -3726,6 +3865,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -6377,6 +6530,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -6524,6 +6691,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -213,6 +213,17 @@
"itemId": {
"type": "string"
},
"networkApprovalContext": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional context for managed-network approval prompts."
},
"proposedExecpolicyAmendment": {
"description": "Optional proposed execpolicy amendment to allow similar commands without prompting.",
"items": {
@@ -419,6 +430,30 @@
],
"type": "object"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5Tcp",
"socks5Udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -2029,6 +2029,75 @@
}
]
},
"CollabAgentRef": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"thread_id"
],
"type": "object"
},
"CollabAgentStatusEntry": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"status": {
"allOf": [
{
"$ref": "#/definitions/AgentStatus"
}
],
"description": "Last known status of the agent."
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"status",
"thread_id"
],
"type": "object"
},
"CommandExecutionApprovalDecision": {
"oneOf": [
{
@@ -2123,6 +2192,17 @@
"itemId": {
"type": "string"
},
"networkApprovalContext": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional context for managed-network approval prompts."
},
"proposedExecpolicyAmendment": {
"description": "Optional proposed execpolicy amendment to allow similar commands without prompting.",
"items": {
@@ -4592,6 +4672,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -4697,6 +4791,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -4747,6 +4855,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -4782,6 +4897,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -4865,6 +4987,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -4914,6 +5050,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -4954,6 +5104,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -9431,6 +9595,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -10487,30 +10665,51 @@
},
"AppConfig": {
"properties": {
"disabled_reason": {
"default_tools_approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppDisabledReason"
"$ref": "#/definitions/v2/AppToolApproval"
},
{
"type": "null"
}
]
},
"default_tools_enabled": {
"type": [
"boolean",
"null"
]
},
"destructive_enabled": {
"type": [
"boolean",
"null"
]
},
"enabled": {
"default": true,
"type": "boolean"
},
"open_world_enabled": {
"type": [
"boolean",
"null"
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppToolsConfig"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AppDisabledReason": {
"enum": [
"unknown",
"user"
],
"type": "string"
},
"AppInfo": {
"description": "EXPERIMENTAL - app metadata returned by app-list APIs.",
"properties": {
@@ -10736,7 +10935,69 @@
],
"type": "object"
},
"AppToolApproval": {
"enum": [
"auto",
"prompt",
"approve"
],
"type": "string"
},
"AppToolConfig": {
"properties": {
"approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppToolApproval"
},
{
"type": "null"
}
]
},
"enabled": {
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AppToolsConfig": {
"type": "object"
},
"AppsConfig": {
"properties": {
"_default": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppsDefaultConfig"
},
{
"type": "null"
}
],
"default": null
}
},
"type": "object"
},
"AppsDefaultConfig": {
"properties": {
"destructive_enabled": {
"default": true,
"type": "boolean"
},
"enabled": {
"default": true,
"type": "boolean"
},
"open_world_enabled": {
"default": true,
"type": "boolean"
}
},
"type": "object"
},
"AppsListParams": {
@@ -12288,6 +12549,15 @@
"classification": {
"type": "string"
},
"extraLogFiles": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeLogs": {
"type": "boolean"
},
@@ -13288,6 +13558,12 @@
"null"
]
},
"dangerouslyAllowAllUnixSockets": {
"type": [
"boolean",
"null"
]
},
"dangerouslyAllowNonLoopbackAdmin": {
"type": [
"boolean",
@@ -15092,6 +15368,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -15218,6 +15508,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -296,6 +296,75 @@
}
]
},
"CollabAgentRef": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"thread_id"
],
"type": "object"
},
"CollabAgentStatusEntry": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"status": {
"allOf": [
{
"$ref": "#/definitions/AgentStatus"
}
],
"description": "Last known status of the agent."
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"status",
"thread_id"
],
"type": "object"
},
"ContentItem": {
"oneOf": [
{
@@ -2548,6 +2617,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -2653,6 +2736,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2703,6 +2800,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -2738,6 +2842,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -2821,6 +2932,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2870,6 +2995,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2910,6 +3049,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -123,6 +123,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -123,6 +123,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -296,6 +296,75 @@
}
]
},
"CollabAgentRef": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"thread_id"
],
"type": "object"
},
"CollabAgentStatusEntry": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"status": {
"allOf": [
{
"$ref": "#/definitions/AgentStatus"
}
],
"description": "Last known status of the agent."
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"status",
"thread_id"
],
"type": "object"
},
"ContentItem": {
"oneOf": [
{
@@ -2548,6 +2617,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -2653,6 +2736,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2703,6 +2800,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -2738,6 +2842,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -2821,6 +2932,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2870,6 +2995,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2910,6 +3049,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -296,6 +296,75 @@
}
]
},
"CollabAgentRef": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"thread_id"
],
"type": "object"
},
"CollabAgentStatusEntry": {
"properties": {
"agent_nickname": {
"description": "Optional nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agent_role": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"status": {
"allOf": [
{
"$ref": "#/definitions/AgentStatus"
}
],
"description": "Last known status of the agent."
},
"thread_id": {
"allOf": [
{
"$ref": "#/definitions/ThreadId"
}
],
"description": "Thread ID of the receiver/new agent."
}
},
"required": [
"status",
"thread_id"
],
"type": "object"
},
"ContentItem": {
"oneOf": [
{
@@ -2548,6 +2617,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"new_agent_nickname": {
"description": "Optional nickname assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_agent_role": {
"description": "Optional role assigned to the new agent.",
"type": [
"string",
"null"
]
},
"new_thread_id": {
"anyOf": [
{
@@ -2653,6 +2736,20 @@
"description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2703,6 +2800,13 @@
"description": "ID of the waiting call.",
"type": "string"
},
"receiver_agents": {
"description": "Optional nicknames/roles for receivers.",
"items": {
"$ref": "#/definitions/CollabAgentRef"
},
"type": "array"
},
"receiver_thread_ids": {
"description": "Thread ID of the receivers.",
"items": {
@@ -2738,6 +2842,13 @@
{
"description": "Collab interaction: waiting end.",
"properties": {
"agent_statuses": {
"description": "Optional receiver metadata paired with final statuses.",
"items": {
"$ref": "#/definitions/CollabAgentStatusEntry"
},
"type": "array"
},
"call_id": {
"description": "ID of the waiting call.",
"type": "string"
@@ -2821,6 +2932,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2870,6 +2995,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -2910,6 +3049,20 @@
"description": "Identifier for the collab tool call.",
"type": "string"
},
"receiver_agent_nickname": {
"description": "Optional nickname assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_agent_role": {
"description": "Optional role assigned to the receiver agent.",
"type": [
"string",
"null"
]
},
"receiver_thread_id": {
"allOf": [
{
@@ -19,10 +19,65 @@
},
"AppConfig": {
"properties": {
"disabled_reason": {
"default_tools_approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/AppDisabledReason"
"$ref": "#/definitions/AppToolApproval"
},
{
"type": "null"
}
]
},
"default_tools_enabled": {
"type": [
"boolean",
"null"
]
},
"destructive_enabled": {
"type": [
"boolean",
"null"
]
},
"enabled": {
"default": true,
"type": "boolean"
},
"open_world_enabled": {
"type": [
"boolean",
"null"
]
},
"tools": {
"anyOf": [
{
"$ref": "#/definitions/AppToolsConfig"
},
{
"type": "null"
}
]
}
},
"type": "object"
},
"AppToolApproval": {
"enum": [
"auto",
"prompt",
"approve"
],
"type": "string"
},
"AppToolConfig": {
"properties": {
"approval_mode": {
"anyOf": [
{
"$ref": "#/definitions/AppToolApproval"
},
{
"type": "null"
@@ -30,20 +85,48 @@
]
},
"enabled": {
"default": true,
"type": "boolean"
"type": [
"boolean",
"null"
]
}
},
"type": "object"
},
"AppDisabledReason": {
"enum": [
"unknown",
"user"
],
"type": "string"
"AppToolsConfig": {
"type": "object"
},
"AppsConfig": {
"properties": {
"_default": {
"anyOf": [
{
"$ref": "#/definitions/AppsDefaultConfig"
},
{
"type": "null"
}
],
"default": null
}
},
"type": "object"
},
"AppsDefaultConfig": {
"properties": {
"destructive_enabled": {
"default": true,
"type": "boolean"
},
"enabled": {
"default": true,
"type": "boolean"
},
"open_world_enabled": {
"default": true,
"type": "boolean"
}
},
"type": "object"
},
"AskForApproval": {
@@ -117,6 +117,12 @@
"null"
]
},
"dangerouslyAllowAllUnixSockets": {
"type": [
"boolean",
"null"
]
},
"dangerouslyAllowNonLoopbackAdmin": {
"type": [
"boolean",
@@ -4,6 +4,15 @@
"classification": {
"type": "string"
},
"extraLogFiles": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeLogs": {
"type": "boolean"
},
@@ -716,6 +716,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -777,6 +791,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -550,6 +564,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -550,6 +564,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -716,6 +716,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -777,6 +791,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -550,6 +564,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -716,6 +716,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -777,6 +791,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -550,6 +564,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -489,6 +489,20 @@
"properties": {
"thread_spawn": {
"properties": {
"agent_nickname": {
"default": null,
"type": [
"string",
"null"
]
},
"agent_role": {
"default": null,
"type": [
"string",
"null"
]
},
"depth": {
"format": "int32",
"type": "integer"
@@ -550,6 +564,20 @@
},
"Thread": {
"properties": {
"agentNickname": {
"description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"agentRole": {
"description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.",
"type": [
"string",
"null"
]
},
"cliVersion": {
"description": "Version of the CLI that created the thread.",
"type": "string"
@@ -17,6 +17,14 @@ sender_thread_id: ThreadId,
* Thread ID of the receiver.
*/
receiver_thread_id: ThreadId,
/**
* Optional nickname assigned to the receiver agent.
*/
receiver_agent_nickname?: string | null,
/**
* Optional role assigned to the receiver agent.
*/
receiver_agent_role?: string | null,
/**
* Prompt sent from the sender to the receiver. Can be empty to prevent CoT
* leaking at the beginning.
@@ -0,0 +1,18 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadId } from "./ThreadId";
export type CollabAgentRef = {
/**
* Thread ID of the receiver/new agent.
*/
thread_id: ThreadId,
/**
* Optional nickname assigned to an AgentControl-spawned sub-agent.
*/
agent_nickname?: string | null,
/**
* Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
*/
agent_role?: string | null, };
@@ -17,6 +17,14 @@ sender_thread_id: ThreadId,
* Thread ID of the newly spawned agent, if it was created.
*/
new_thread_id: ThreadId | null,
/**
* Optional nickname assigned to the new agent.
*/
new_agent_nickname?: string | null,
/**
* Optional role assigned to the new agent.
*/
new_agent_role?: string | null,
/**
* Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the
* beginning.
@@ -0,0 +1,23 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentStatus } from "./AgentStatus";
import type { ThreadId } from "./ThreadId";
export type CollabAgentStatusEntry = {
/**
* Thread ID of the receiver/new agent.
*/
thread_id: ThreadId,
/**
* Optional nickname assigned to an AgentControl-spawned sub-agent.
*/
agent_nickname?: string | null,
/**
* Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
*/
agent_role?: string | null,
/**
* Last known status of the agent.
*/
status: AgentStatus, };
@@ -17,6 +17,14 @@ sender_thread_id: ThreadId,
* Thread ID of the receiver.
*/
receiver_thread_id: ThreadId,
/**
* Optional nickname assigned to the receiver agent.
*/
receiver_agent_nickname?: string | null,
/**
* Optional role assigned to the receiver agent.
*/
receiver_agent_role?: string | null,
/**
* Last known status of the receiver agent reported to the sender agent before
* the close.
@@ -15,4 +15,12 @@ sender_thread_id: ThreadId,
/**
* Thread ID of the receiver.
*/
receiver_thread_id: ThreadId, };
receiver_thread_id: ThreadId,
/**
* Optional nickname assigned to the receiver agent.
*/
receiver_agent_nickname?: string | null,
/**
* Optional role assigned to the receiver agent.
*/
receiver_agent_role?: string | null, };
@@ -17,6 +17,14 @@ sender_thread_id: ThreadId,
* Thread ID of the receiver.
*/
receiver_thread_id: ThreadId,
/**
* Optional nickname assigned to the receiver agent.
*/
receiver_agent_nickname?: string | null,
/**
* Optional role assigned to the receiver agent.
*/
receiver_agent_role?: string | null,
/**
* Last known status of the receiver agent reported to the sender agent after
* resume.
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { CollabAgentRef } from "./CollabAgentRef";
import type { ThreadId } from "./ThreadId";
export type CollabWaitingBeginEvent = {
@@ -12,6 +13,10 @@ sender_thread_id: ThreadId,
* Thread ID of the receivers.
*/
receiver_thread_ids: Array<ThreadId>,
/**
* Optional nicknames/roles for receivers.
*/
receiver_agents?: Array<CollabAgentRef>,
/**
* ID of the waiting call.
*/
@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentStatus } from "./AgentStatus";
import type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry";
import type { ThreadId } from "./ThreadId";
export type CollabWaitingEndEvent = {
@@ -13,6 +14,10 @@ sender_thread_id: ThreadId,
* ID of the waiting call.
*/
call_id: string,
/**
* Optional receiver metadata paired with final statuses.
*/
agent_statuses?: Array<CollabAgentStatusEntry>,
/**
* Last known status of the receiver agents reported to the sender agent.
*/
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadId } from "./ThreadId";
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | "memory_consolidation" | { "other": string };
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, agent_nickname: string | null, agent_role: string | null, } } | "memory_consolidation" | { "other": string };
@@ -33,8 +33,10 @@ export type { ClientRequest } from "./ClientRequest";
export type { CodexErrorInfo } from "./CodexErrorInfo";
export type { CollabAgentInteractionBeginEvent } from "./CollabAgentInteractionBeginEvent";
export type { CollabAgentInteractionEndEvent } from "./CollabAgentInteractionEndEvent";
export type { CollabAgentRef } from "./CollabAgentRef";
export type { CollabAgentSpawnBeginEvent } from "./CollabAgentSpawnBeginEvent";
export type { CollabAgentSpawnEndEvent } from "./CollabAgentSpawnEndEvent";
export type { CollabAgentStatusEntry } from "./CollabAgentStatusEntry";
export type { CollabCloseBeginEvent } from "./CollabCloseBeginEvent";
export type { CollabCloseEndEvent } from "./CollabCloseEndEvent";
export type { CollabResumeBeginEvent } from "./CollabResumeBeginEvent";
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppDisabledReason = "unknown" | "user";
export type AppToolApproval = "auto" | "prompt" | "approve";
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppToolApproval } from "./AppToolApproval";
export type AppToolsConfig = { [key in string]?: { enabled: boolean | null, approval_mode: AppToolApproval | null, } };
@@ -1,6 +1,8 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppDisabledReason } from "./AppDisabledReason";
import type { AppToolApproval } from "./AppToolApproval";
import type { AppToolsConfig } from "./AppToolsConfig";
import type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } };
export type AppsConfig = { _default: AppsDefaultConfig | null, } & ({ [key in string]?: { enabled: boolean, destructive_enabled: boolean | null, open_world_enabled: boolean | null, default_tools_approval_mode: AppToolApproval | null, default_tools_enabled: boolean | null, tools: AppToolsConfig | null, } });
@@ -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 AppsDefaultConfig = { enabled: boolean, destructive_enabled: boolean, open_world_enabled: boolean, };
@@ -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 { CommandAction } from "./CommandAction";
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type CommandExecutionRequestApprovalParams = { threadId: string, turnId: string, itemId: string,
/**
@@ -19,6 +20,10 @@ approvalId?: string | null,
* Optional explanatory reason (e.g. request for network access).
*/
reason?: string | null,
/**
* Optional context for managed-network approval prompts.
*/
networkApprovalContext?: NetworkApprovalContext | null,
/**
* The command to be executed.
*/
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, };
export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array<string> | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, };
@@ -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 NetworkApprovalProtocol = "http" | "https" | "socks5Tcp" | "socks5Udp";
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, allowedDomains: Array<string> | null, deniedDomains: Array<string> | null, allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, };
export type NetworkRequirements = { enabled: boolean | null, httpPort: number | null, socksPort: number | null, allowUpstreamProxy: boolean | null, dangerouslyAllowNonLoopbackProxy: boolean | null, dangerouslyAllowNonLoopbackAdmin: boolean | null, dangerouslyAllowAllUnixSockets: boolean | null, allowedDomains: Array<string> | null, deniedDomains: Array<string> | null, allowUnixSockets: Array<string> | null, allowLocalBinding: boolean | null, };
@@ -43,6 +43,14 @@ cliVersion: string,
* Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
*/
source: SessionSource,
/**
* Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
*/
agentNickname: string | null,
/**
* Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
*/
agentRole: string | null,
/**
* Optional Git metadata captured when the thread was created.
*/
@@ -7,13 +7,15 @@ export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppBranding } from "./AppBranding";
export type { AppDisabledReason } from "./AppDisabledReason";
export type { AppInfo } from "./AppInfo";
export type { AppListUpdatedNotification } from "./AppListUpdatedNotification";
export type { AppMetadata } from "./AppMetadata";
export type { AppReview } from "./AppReview";
export type { AppScreenshot } from "./AppScreenshot";
export type { AppToolApproval } from "./AppToolApproval";
export type { AppToolsConfig } from "./AppToolsConfig";
export type { AppsConfig } from "./AppsConfig";
export type { AppsDefaultConfig } from "./AppsDefaultConfig";
export type { AppsListParams } from "./AppsListParams";
export type { AppsListResponse } from "./AppsListResponse";
export type { AskForApproval } from "./AskForApproval";
@@ -100,6 +102,8 @@ export type { ModelListResponse } from "./ModelListResponse";
export type { ModelRerouteReason } from "./ModelRerouteReason";
export type { ModelReroutedNotification } from "./ModelReroutedNotification";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type { NetworkRequirements } from "./NetworkRequirements";
export type { OverriddenMetadata } from "./OverriddenMetadata";
export type { PatchApplyStatus } from "./PatchApplyStatus";
+247 -5
View File
@@ -32,10 +32,93 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::LazyLock;
use ts_rs::TS;
const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
static VARIANT_TITLE_COLLISION_OVERRIDES: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
HashMap::from([
(
"base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=ready|only_property=state|required_only=state",
"StateMcpStartupStatus2",
),
(
"base=McpStartupStatus|generated=StateMcpStartupStatus|literal:state=cancelled|only_property=state|required_only=state",
"StateMcpStartupStatus3",
),
])
});
static NUMBERED_DEFINITION_COLLISION_OVERRIDES: LazyLock<HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
HashMap::from([
(
"schema=ClientRequest|container=definitions|generated=AskForApproval2|base=AskForApproval",
"AskForApproval2",
),
(
"schema=ClientRequest|container=definitions|generated=NetworkAccess2|base=NetworkAccess",
"NetworkAccess2",
),
(
"schema=ClientRequest|container=definitions|generated=ReadOnlyAccess2|base=ReadOnlyAccess",
"ReadOnlyAccess2",
),
(
"schema=ClientRequest|container=definitions|generated=SandboxMode2|base=SandboxMode",
"SandboxMode2",
),
(
"schema=ClientRequest|container=definitions|generated=SandboxPolicy2|base=SandboxPolicy",
"SandboxPolicy2",
),
(
"schema=ServerNotification|container=definitions|generated=ByteRange2|base=ByteRange",
"ByteRange2",
),
(
"schema=ServerNotification|container=definitions|generated=CodexErrorInfo2|base=CodexErrorInfo",
"CodexErrorInfo2",
),
(
"schema=ServerNotification|container=definitions|generated=CreditsSnapshot2|base=CreditsSnapshot",
"CreditsSnapshot2",
),
(
"schema=ServerNotification|container=definitions|generated=MessagePhase2|base=MessagePhase",
"MessagePhase2",
),
(
"schema=ServerNotification|container=definitions|generated=ModelRerouteReason2|base=ModelRerouteReason",
"ModelRerouteReason2",
),
(
"schema=ServerNotification|container=definitions|generated=PatchApplyStatus2|base=PatchApplyStatus",
"PatchApplyStatus2",
),
(
"schema=ServerNotification|container=definitions|generated=RateLimitSnapshot2|base=RateLimitSnapshot",
"RateLimitSnapshot2",
),
(
"schema=ServerNotification|container=definitions|generated=RateLimitWindow2|base=RateLimitWindow",
"RateLimitWindow2",
),
(
"schema=ServerNotification|container=definitions|generated=TextElement2|base=TextElement",
"TextElement2",
),
(
"schema=ServerNotification|container=definitions|generated=UserInput2|base=UserInput",
"UserInput2",
),
(
"schema=ServerNotification|container=definitions|generated=WebSearchAction2|base=WebSearchAction",
"WebSearchAction2",
),
])
});
#[derive(Clone)]
pub struct GeneratedSchema {
@@ -950,6 +1033,7 @@ where
let file_stem = name.trim();
let schema = schema_for!(T);
let mut schema_value = serde_json::to_value(schema)?;
enforce_numbered_definition_collision_overrides(file_stem, &mut schema_value);
annotate_schema(&mut schema_value, Some(file_stem));
// If the name looks like a namespaced path (e.g., "v2::Type"), mirror
// the TypeScript layout and write to out_dir/v2/Type.json. Otherwise
@@ -980,6 +1064,101 @@ where
})
}
fn enforce_numbered_definition_collision_overrides(schema_name: &str, schema: &mut Value) {
for defs_key in ["definitions", "$defs"] {
let renames = {
let Some(defs) = schema.get(defs_key).and_then(Value::as_object) else {
continue;
};
collect_numbered_definition_collision_renames(schema_name, defs_key, defs)
};
if renames.is_empty() {
continue;
}
#[expect(clippy::expect_used)]
let defs = schema
.get_mut(defs_key)
.and_then(Value::as_object_mut)
.expect("definition map should still be present");
for (old_name, new_name, collision_key) in &renames {
if old_name == new_name {
continue;
}
assert!(
!defs.contains_key(new_name),
"Numbered definition collision override produced duplicate definition {new_name} for {collision_key}",
);
let schema_value = defs.remove(old_name).unwrap_or_else(|| {
panic!("Missing generated numbered definition {old_name} for {collision_key}")
});
defs.insert(new_name.clone(), schema_value);
}
for (old_name, new_name, _) in renames {
if old_name != new_name {
rewrite_named_local_ref(schema, defs_key, &old_name, &new_name);
}
}
}
}
fn collect_numbered_definition_collision_renames(
schema_name: &str,
defs_key: &str,
defs: &Map<String, Value>,
) -> Vec<(String, String, String)> {
let mut renames = Vec::new();
for generated_name in defs.keys() {
let base_name = generated_name.trim_end_matches(|c: char| c.is_ascii_digit());
if base_name == generated_name || !defs.contains_key(base_name) {
continue;
}
let collision_key = format!(
"schema={schema_name}|container={defs_key}|generated={generated_name}|base={base_name}"
);
let replacement = NUMBERED_DEFINITION_COLLISION_OVERRIDES
.get(collision_key.as_str())
.map(|value| (*value).to_string())
.unwrap_or_else(|| {
panic!("Missing numbered definition collision override for {collision_key}")
});
renames.push((generated_name.clone(), replacement, collision_key));
}
renames
}
fn rewrite_named_local_ref(value: &mut Value, defs_key: &str, old_name: &str, new_name: &str) {
let direct = format!("#/{defs_key}/{old_name}");
let prefixed = format!("{direct}/");
let replacement = format!("#/{defs_key}/{new_name}");
let replacement_prefixed = format!("{replacement}/");
match value {
Value::Object(obj) => {
if let Some(Value::String(reference)) = obj.get_mut("$ref") {
if reference == &direct {
*reference = replacement;
} else if let Some(rest) = reference.strip_prefix(&prefixed) {
*reference = format!("{replacement_prefixed}{rest}");
}
}
for child in obj.values_mut() {
rewrite_named_local_ref(child, defs_key, old_name, new_name);
}
}
Value::Array(items) => {
for child in items {
rewrite_named_local_ref(child, defs_key, old_name, new_name);
}
}
_ => {}
}
}
pub(crate) fn write_json_schema<T>(out_dir: &Path, name: &str) -> Result<GeneratedSchema>
where
T: JsonSchema,
@@ -1198,10 +1377,20 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) {
&& let Some(name) = variant_definition_name(base_name, variant)
{
let mut candidate = name.clone();
let mut index = 2;
while seen.contains(&candidate) {
candidate = format!("{name}{index}");
index += 1;
if seen.contains(&candidate) {
let collision_key = variant_title_collision_key(base_name, &name, variant);
candidate = VARIANT_TITLE_COLLISION_OVERRIDES
.get(collision_key.as_str())
.map(|value| (*value).to_string())
.unwrap_or_else(|| {
panic!(
"Missing variant title collision override for {collision_key} (generated name: {name})"
)
});
assert!(
!seen.contains(&candidate),
"Variant title collision override produced duplicate title {candidate} for {collision_key}",
);
}
if let Some(obj) = variant.as_object_mut() {
obj.insert("title".into(), Value::String(candidate.clone()));
@@ -1221,6 +1410,48 @@ fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) {
}
}
fn variant_title_collision_key(base: &str, generated_name: &str, variant: &Value) -> String {
let mut parts = vec![
format!("base={base}"),
format!("generated={generated_name}"),
];
if let Some(props) = variant.get("properties").and_then(Value::as_object) {
for key in DISCRIMINATOR_KEYS {
if let Some(value) = literal_from_property(props, key) {
parts.push(format!("{key}={value}"));
}
}
for (key, value) in props {
if DISCRIMINATOR_KEYS.contains(&key.as_str()) {
continue;
}
if let Some(literal) = string_literal(value) {
parts.push(format!("literal:{key}={literal}"));
}
}
if props.len() == 1
&& let Some(key) = props.keys().next()
{
parts.push(format!("only_property={key}"));
}
}
if let Some(required) = variant.get("required").and_then(Value::as_array)
&& required.len() == 1
&& let Some(key) = required[0].as_str()
{
parts.push(format!("required_only={key}"));
}
if parts.len() == 2 {
parts.push(format!("variant={variant}"));
}
parts.join("|")
}
const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"];
fn set_discriminator_titles(props: &mut Map<String, Value>, owner: &str) {
@@ -1475,7 +1706,18 @@ mod tests {
.file_stem()
.and_then(|stem| stem.to_str())
.is_some_and(|stem| {
stem.ends_with("Params") || stem == "InitializeCapabilities"
stem.ends_with("Params")
|| stem == "InitializeCapabilities"
|| matches!(
stem,
"CollabAgentRef"
| "CollabAgentStatusEntry"
| "CollabAgentSpawnEndEvent"
| "CollabAgentInteractionEndEvent"
| "CollabCloseEndEvent"
| "CollabResumeBeginEvent"
| "CollabResumeEndEvent"
)
});
let contents = fs::read_to_string(&path)?;
@@ -24,9 +24,13 @@ use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ContextCompactedEvent;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::RolloutItem;
@@ -36,8 +40,10 @@ use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::ViewImageToolCallEvent;
use codex_protocol::protocol::WebSearchBeginEvent;
use codex_protocol::protocol::WebSearchEndEvent;
use std::collections::HashMap;
use tracing::warn;
use uuid::Uuid;
#[cfg(test)]
@@ -57,14 +63,20 @@ pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
builder.finish()
}
struct ThreadHistoryBuilder {
pub struct ThreadHistoryBuilder {
turns: Vec<Turn>,
current_turn: Option<PendingTurn>,
next_item_index: i64,
}
impl Default for ThreadHistoryBuilder {
fn default() -> Self {
Self::new()
}
}
impl ThreadHistoryBuilder {
fn new() -> Self {
pub fn new() -> Self {
Self {
turns: Vec::new(),
current_turn: None,
@@ -72,14 +84,32 @@ impl ThreadHistoryBuilder {
}
}
fn finish(mut self) -> Vec<Turn> {
pub fn reset(&mut self) {
*self = Self::new();
}
pub fn finish(mut self) -> Vec<Turn> {
self.finish_current_turn();
self.turns
}
pub fn active_turn_snapshot(&self) -> Option<Turn> {
self.current_turn
.as_ref()
.map(Turn::from)
.or_else(|| self.turns.last().cloned())
}
pub fn has_active_turn(&self) -> bool {
self.current_turn.is_some()
}
/// Shared reducer for persisted rollout replay and in-memory current-turn
/// tracking used by running thread resume/rejoin.
///
/// This function should handle all EventMsg variants that can be persisted in a rollout file.
/// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`.
fn handle_event(&mut self, event: &EventMsg) {
pub fn handle_event(&mut self, event: &EventMsg) {
match event {
EventMsg::UserMessage(payload) => self.handle_user_message(payload),
EventMsg::AgentMessage(payload) => {
@@ -89,21 +119,35 @@ impl ThreadHistoryBuilder {
EventMsg::AgentReasoningRawContent(payload) => {
self.handle_agent_reasoning_raw_content(payload)
}
EventMsg::WebSearchBegin(payload) => self.handle_web_search_begin(payload),
EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload),
EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload),
EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload),
EventMsg::PatchApplyBegin(payload) => self.handle_patch_apply_begin(payload),
EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload),
EventMsg::McpToolCallBegin(payload) => self.handle_mcp_tool_call_begin(payload),
EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload),
EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload),
EventMsg::CollabAgentSpawnBegin(payload) => {
self.handle_collab_agent_spawn_begin(payload)
}
EventMsg::CollabAgentSpawnEnd(payload) => self.handle_collab_agent_spawn_end(payload),
EventMsg::CollabAgentInteractionBegin(payload) => {
self.handle_collab_agent_interaction_begin(payload)
}
EventMsg::CollabAgentInteractionEnd(payload) => {
self.handle_collab_agent_interaction_end(payload)
}
EventMsg::CollabWaitingBegin(payload) => self.handle_collab_waiting_begin(payload),
EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload),
EventMsg::CollabCloseBegin(payload) => self.handle_collab_close_begin(payload),
EventMsg::CollabCloseEnd(payload) => self.handle_collab_close_end(payload),
EventMsg::CollabResumeBegin(payload) => self.handle_collab_resume_begin(payload),
EventMsg::CollabResumeEnd(payload) => self.handle_collab_resume_end(payload),
EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload),
EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload),
EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload),
EventMsg::ItemStarted(payload) => self.handle_item_started(payload),
EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload),
EventMsg::Error(payload) => self.handle_error(payload),
EventMsg::TokenCount(_) => {}
@@ -116,7 +160,7 @@ impl ThreadHistoryBuilder {
}
}
fn handle_rollout_item(&mut self, item: &RolloutItem) {
pub fn handle_rollout_item(&mut self, item: &RolloutItem) {
match item {
RolloutItem::EventMsg(event) => self.handle_event(event),
RolloutItem::Compacted(payload) => self.handle_compacted(payload),
@@ -199,15 +243,51 @@ impl ThreadHistoryBuilder {
});
}
fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) {
if let codex_protocol::items::TurnItem::Plan(plan) = &payload.item
&& plan.text.is_empty()
{
return;
fn handle_item_started(&mut self, payload: &ItemStartedEvent) {
match &payload.item {
codex_protocol::items::TurnItem::Plan(plan) => {
if plan.text.is_empty() {
return;
}
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
let item = ThreadItem::from(payload.item.clone());
self.ensure_turn().items.push(item);
fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) {
match &payload.item {
codex_protocol::items::TurnItem::Plan(plan) => {
if plan.text.is_empty() {
return;
}
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) {
let item = ThreadItem::WebSearch {
id: payload.call_id.clone(),
query: String::new(),
action: None,
};
self.upsert_item_in_current_turn(item);
}
fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) {
@@ -216,7 +296,30 @@ impl ThreadHistoryBuilder {
query: payload.query.clone(),
action: Some(WebSearchAction::from(payload.action.clone())),
};
self.ensure_turn().items.push(item);
self.upsert_item_in_current_turn(item);
}
fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) {
let command = shlex::try_join(payload.command.iter().map(String::as_str))
.unwrap_or_else(|_| payload.command.join(" "));
let command_actions = payload
.parsed_cmd
.iter()
.cloned()
.map(CommandAction::from)
.collect();
let item = ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command,
cwd: payload.cwd.clone(),
process_id: payload.process_id.clone(),
status: CommandExecutionStatus::InProgress,
command_actions,
aggregated_output: None,
exit_code: None,
duration_ms: None,
};
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) {
@@ -246,33 +349,25 @@ impl ThreadHistoryBuilder {
exit_code: Some(payload.exit_code),
duration_ms: Some(duration_ms),
};
// Command completions can arrive out of order. Unified exec may return
// while a PTY is still running, then emit ExecCommandEnd later from a
// background exit watcher when that process finally exits. By then, a
// newer user turn may already have started. Route by event turn_id so
// replay preserves the original turn association.
if let Some(turn) = self.current_turn.as_mut()
&& turn.id == payload.turn_id
{
turn.items.push(item);
return;
}
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
// If the originating turn is already finalized, append there instead
// of attaching to whichever turn is currently active during replay.
if let Some(turn) = self
.turns
.iter_mut()
.find(|turn| turn.id == payload.turn_id)
{
turn.items.push(item);
return;
fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) {
let item = ThreadItem::FileChange {
id: payload.call_id.clone(),
changes: convert_patch_changes(&payload.changes),
status: PatchApplyStatus::InProgress,
};
if payload.turn_id.is_empty() {
self.upsert_item_in_current_turn(item);
} else {
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
// Backward-compatibility fallback for partial/legacy streams where the
// event turn_id does not match any known replay turn.
self.ensure_turn().items.push(item);
}
fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) {
@@ -282,7 +377,29 @@ impl ThreadHistoryBuilder {
changes: convert_patch_changes(&payload.changes),
status,
};
self.ensure_turn().items.push(item);
if payload.turn_id.is_empty() {
self.upsert_item_in_current_turn(item);
} else {
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
}
fn handle_mcp_tool_call_begin(&mut self, payload: &McpToolCallBeginEvent) {
let item = ThreadItem::McpToolCall {
id: payload.call_id.clone(),
server: payload.invocation.server.clone(),
tool: payload.invocation.tool.clone(),
status: McpToolCallStatus::InProgress,
arguments: payload
.invocation
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
result: None,
error: None,
duration_ms: None,
};
self.upsert_item_in_current_turn(item);
}
fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) {
@@ -321,7 +438,7 @@ impl ThreadHistoryBuilder {
error,
duration_ms,
};
self.ensure_turn().items.push(item);
self.upsert_item_in_current_turn(item);
}
fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) {
@@ -329,7 +446,23 @@ impl ThreadHistoryBuilder {
id: payload.call_id.clone(),
path: payload.path.to_string_lossy().into_owned(),
};
self.ensure_turn().items.push(item);
self.upsert_item_in_current_turn(item);
}
fn handle_collab_agent_spawn_begin(
&mut self,
payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some(payload.prompt.clone()),
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_collab_agent_spawn_end(
@@ -353,17 +486,31 @@ impl ThreadHistoryBuilder {
}
None => (Vec::new(), HashMap::new()),
};
self.ensure_turn()
.items
.push(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SpawnAgent,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: Some(payload.prompt.clone()),
agents_states,
});
self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SpawnAgent,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: Some(payload.prompt.clone()),
agents_states,
});
}
fn handle_collab_agent_interaction_begin(
&mut self,
payload: &codex_protocol::protocol::CollabAgentInteractionBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: Some(payload.prompt.clone()),
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_collab_agent_interaction_end(
@@ -376,17 +523,35 @@ impl ThreadHistoryBuilder {
};
let receiver_id = payload.receiver_thread_id.to_string();
let received_status = CollabAgentState::from(payload.status.clone());
self.ensure_turn()
.items
.push(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SendInput,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id.clone()],
prompt: Some(payload.prompt.clone()),
agents_states: [(receiver_id, received_status)].into_iter().collect(),
});
self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SendInput,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id.clone()],
prompt: Some(payload.prompt.clone()),
agents_states: [(receiver_id, received_status)].into_iter().collect(),
});
}
fn handle_collab_waiting_begin(
&mut self,
payload: &codex_protocol::protocol::CollabWaitingBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::Wait,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: payload
.receiver_thread_ids
.iter()
.map(ToString::to_string)
.collect(),
prompt: None,
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_collab_waiting_end(
@@ -410,17 +575,31 @@ impl ThreadHistoryBuilder {
.iter()
.map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone())))
.collect();
self.ensure_turn()
.items
.push(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::Wait,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: None,
agents_states,
});
self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::Wait,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids,
prompt: None,
agents_states,
});
}
fn handle_collab_close_begin(
&mut self,
payload: &codex_protocol::protocol::CollabCloseBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::CloseAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: None,
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) {
@@ -435,17 +614,31 @@ impl ThreadHistoryBuilder {
)]
.into_iter()
.collect();
self.ensure_turn()
.items
.push(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::CloseAgent,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
agents_states,
});
self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::CloseAgent,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
agents_states,
});
}
fn handle_collab_resume_begin(
&mut self,
payload: &codex_protocol::protocol::CollabResumeBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: None,
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
}
fn handle_collab_resume_end(
@@ -463,17 +656,15 @@ impl ThreadHistoryBuilder {
)]
.into_iter()
.collect();
self.ensure_turn()
.items
.push(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::ResumeAgent,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
agents_states,
});
self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::ResumeAgent,
status,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![receiver_id],
prompt: None,
agents_states,
});
}
fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) {
@@ -548,6 +739,7 @@ impl ThreadHistoryBuilder {
self.finish_current_turn();
self.current_turn = Some(
self.new_turn(Some(payload.turn_id.clone()))
.with_status(TurnStatus::InProgress)
.opened_explicitly(),
);
}
@@ -642,6 +834,30 @@ impl ThreadHistoryBuilder {
unreachable!("current turn must exist after initialization");
}
fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) {
if let Some(turn) = self.current_turn.as_mut()
&& turn.id == turn_id
{
upsert_turn_item(&mut turn.items, item);
return;
}
if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) {
upsert_turn_item(&mut turn.items, item);
return;
}
warn!(
item_id = item.id(),
"dropping turn-scoped item for unknown turn id `{turn_id}`"
);
}
fn upsert_item_in_current_turn(&mut self, item: ThreadItem) {
let turn = self.ensure_turn();
upsert_turn_item(&mut turn.items, item);
}
fn next_item_id(&mut self) -> String {
let id = format!("item-{}", self.next_item_index);
self.next_item_index += 1;
@@ -684,7 +900,7 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
}
}
fn convert_patch_changes(
pub fn convert_patch_changes(
changes: &HashMap<std::path::PathBuf, codex_protocol::protocol::FileChange>,
) -> Vec<FileUpdateChange> {
let mut converted: Vec<FileUpdateChange> = changes
@@ -726,6 +942,17 @@ fn format_file_change_diff(change: &codex_protocol::protocol::FileChange) -> Str
}
}
fn upsert_turn_item(items: &mut Vec<ThreadItem>, item: ThreadItem) {
if let Some(existing_item) = items
.iter_mut()
.find(|existing_item| existing_item.id() == item.id())
{
*existing_item = item;
return;
}
items.push(item);
}
struct PendingTurn {
id: String,
items: Vec<ThreadItem>,
@@ -744,6 +971,11 @@ impl PendingTurn {
self.opened_explicitly = true;
self
}
fn with_status(mut self, status: TurnStatus) -> Self {
self.status = status;
self
}
}
impl From<PendingTurn> for Turn {
@@ -757,10 +989,23 @@ impl From<PendingTurn> for Turn {
}
}
impl From<&PendingTurn> for Turn {
fn from(value: &PendingTurn) -> Self {
Self {
id: value.id.clone(),
items: value.items.clone(),
error: value.error.clone(),
status: value.status.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem as CoreUserMessageItem;
use codex_protocol::models::MessagePhase as CoreMessagePhase;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::parse_command::ParsedCommand;
@@ -771,6 +1016,7 @@ mod tests {
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::ThreadRolledBackEvent;
@@ -816,11 +1062,11 @@ mod tests {
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
let mut builder = ThreadHistoryBuilder::new();
for event in &events {
builder.handle_event(event);
}
let turns = builder.finish();
assert_eq!(turns.len(), 2);
let first = &turns[0];
@@ -883,6 +1129,55 @@ mod tests {
);
}
#[test]
fn ignores_non_plan_item_lifecycle_events() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "hello".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::ItemStarted(ItemStartedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
id: "user-item-id".to_string(),
content: Vec::new(),
}),
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].items.len(), 1);
assert_eq!(
turns[0].items[0],
ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
}
);
}
#[test]
fn preserves_agent_message_phase_in_history() {
let events = vec![EventMsg::AgentMessage(AgentMessageEvent {
@@ -1212,6 +1507,11 @@ mod tests {
#[test]
fn reconstructs_tool_items_from_persisted_completion_events() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "run tools".into(),
images: None,
@@ -1311,6 +1611,11 @@ mod tests {
#[test]
fn reconstructs_declined_exec_and_patch_items() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "run tools".into(),
images: None,
@@ -1471,6 +1776,82 @@ mod tests {
);
}
#[test]
fn drops_late_turn_scoped_item_for_unknown_turn_id() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "first".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-a".into(),
last_agent_message: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-b".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "second".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "exec-unknown-turn".into(),
process_id: Some("pid-42".into()),
turn_id: "turn-missing".into(),
command: vec!["echo".into(), "done".into()],
cwd: PathBuf::from("/tmp"),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo done".into(),
}],
source: ExecCommandSource::Agent,
interaction_input: None,
stdout: "done\n".into(),
stderr: String::new(),
aggregated_output: "done\n".into(),
exit_code: 0,
duration: Duration::from_millis(5),
formatted_output: "done\n".into(),
status: CoreExecCommandStatus::Completed,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-b".into(),
last_agent_message: None,
}),
];
let mut builder = ThreadHistoryBuilder::new();
for event in &events {
builder.handle_event(event);
}
let turns = builder.finish();
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].id, "turn-a");
assert_eq!(turns[1].id, "turn-b");
assert_eq!(turns[0].items.len(), 1);
assert_eq!(turns[1].items.len(), 1);
assert_eq!(
turns[1].items[0],
ThreadItem::UserMessage {
id: "item-2".into(),
content: vec![UserInput::Text {
text: "second".into(),
text_elements: Vec::new(),
}],
}
);
}
#[test]
fn late_turn_complete_does_not_close_active_turn() {
let events = vec![
@@ -1572,7 +1953,7 @@ mod tests {
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].id, "turn-a");
assert_eq!(turns[1].id, "turn-b");
assert_eq!(turns[1].status, TurnStatus::Completed);
assert_eq!(turns[1].status, TurnStatus::InProgress);
assert_eq!(turns[1].items.len(), 2);
}
@@ -1621,6 +2002,8 @@ mod tests {
.expect("valid sender thread id"),
receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002")
.expect("valid receiver thread id"),
receiver_agent_nickname: None,
receiver_agent_role: None,
status: AgentStatus::Completed(None),
}),
];
@@ -5,6 +5,8 @@ use crate::protocol::common::AuthMode;
use codex_experimental_api_macros::ExperimentalApi;
use codex_protocol::account::PlanType;
use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ForcedLoginMethod;
@@ -396,12 +398,41 @@ pub struct AnalyticsConfig {
pub additional: HashMap<String, JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum AppToolApproval {
Auto,
Prompt,
Approve,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum AppDisabledReason {
Unknown,
User,
pub struct AppsDefaultConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_enabled")]
pub destructive_enabled: bool,
#[serde(default = "default_enabled")]
pub open_world_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppToolConfig {
pub enabled: Option<bool>,
pub approval_mode: Option<AppToolApproval>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppToolsConfig {
#[serde(default, flatten)]
pub tools: HashMap<String, AppToolConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -410,15 +441,20 @@ pub enum AppDisabledReason {
pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub disabled_reason: Option<AppDisabledReason>,
pub destructive_enabled: Option<bool>,
pub open_world_enabled: Option<bool>,
pub default_tools_approval_mode: Option<AppToolApproval>,
pub default_tools_enabled: Option<bool>,
pub tools: Option<AppToolsConfig>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppsConfig {
#[serde(default, rename = "_default")]
pub default: Option<AppsDefaultConfig>,
#[serde(default, flatten)]
#[schemars(with = "HashMap<String, AppConfig>")]
pub apps: HashMap<String, AppConfig>,
}
@@ -575,6 +611,7 @@ pub struct NetworkRequirements {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_non_loopback_admin: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
@@ -650,6 +687,32 @@ pub enum CommandExecutionApprovalDecision {
Cancel,
}
v2_enum_from_core! {
pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol {
Http,
Https,
Socks5Tcp,
Socks5Udp,
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct NetworkApprovalContext {
pub host: String,
pub protocol: NetworkApprovalProtocol,
}
impl From<CoreNetworkApprovalContext> for NetworkApprovalContext {
fn from(value: CoreNetworkApprovalContext) -> Self {
Self {
host: value.host,
protocol: value.protocol.into(),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1443,6 +1506,8 @@ pub struct FeedbackUploadParams {
#[ts(optional = nullable)]
pub thread_id: Option<String>,
pub include_logs: bool,
#[ts(optional = nullable)]
pub extra_log_files: Option<Vec<PathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -2206,6 +2271,10 @@ pub struct Thread {
pub cli_version: String,
/// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.).
pub source: SessionSource,
/// Optional random unique nickname assigned to an AgentControl-spawned sub-agent.
pub agent_nickname: Option<String>,
/// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.
pub agent_role: Option<String>,
/// Optional Git metadata captured when the thread was created.
pub git_info: Option<GitInfo>,
/// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read`
@@ -2727,6 +2796,26 @@ pub enum ThreadItem {
ContextCompaction { id: String },
}
impl ThreadItem {
pub fn id(&self) -> &str {
match self {
ThreadItem::UserMessage { id, .. }
| ThreadItem::AgentMessage { id, .. }
| ThreadItem::Plan { id, .. }
| ThreadItem::Reasoning { id, .. }
| ThreadItem::CommandExecution { id, .. }
| ThreadItem::FileChange { id, .. }
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase")]
@@ -3307,6 +3396,10 @@ pub struct CommandExecutionRequestApprovalParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub reason: Option<String>,
/// Optional context for managed-network approval prompts.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub network_approval_context: Option<NetworkApprovalContext>,
/// The command to be executed.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
@@ -1491,6 +1491,7 @@ impl CodexClient {
item_id,
approval_id,
reason,
network_approval_context,
command,
cwd,
command_actions,
@@ -1506,6 +1507,9 @@ impl CodexClient {
if let Some(reason) = reason.as_deref() {
println!("< reason: {reason}");
}
if let Some(network_approval_context) = network_approval_context.as_ref() {
println!("< network approval context: {network_approval_context:?}");
}
if let Some(command) = command.as_deref() {
println!("< command: {command}");
}
+2 -1
View File
@@ -48,9 +48,10 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tokio-util = { workspace = true }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] }
uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies]
+10 -4
View File
@@ -28,6 +28,11 @@ Supported transports:
Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads.
Tracing/log output:
- `RUST_LOG` controls log filtering/verbosity.
- Set `LOG_FORMAT=json` to emit app-server tracing logs to `stderr` as JSON (one event per line).
Backpressure behavior:
- The server uses bounded queues between transport ingress, request processing, and outbound writes.
@@ -145,7 +150,7 @@ Example with notification opt-out:
- `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server.
- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination.
- `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); returns `{ started: true }` immediately and later emits `windowsSandbox/setupCompleted`.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id.
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering.
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
@@ -224,6 +229,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
Example:
@@ -235,7 +241,7 @@ Example:
} }
{ "id": 20, "result": {
"data": [
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" } },
{ "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111, "updatedAt": 1730831111, "status": { "type": "notLoaded" }, "agentNickname": "Atlas", "agentRole": "explorer" },
{ "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000, "updatedAt": 1730750000, "status": { "type": "notLoaded" } }
],
"nextCursor": "opaque-token-or-null"
@@ -271,7 +277,7 @@ When `nextCursor` is `null`, you’ve reached the final page.
### Example: Read a thread
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`.
Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
```json
{ "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } }
@@ -661,7 +667,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`, optionally `approvalId` (for subcommand callbacks), `reason`, plus `command`, `cwd`, and `commandActions` for friendly display.
2. `item/commandExecution/requestApproval` (request) — carries the same `itemId`, `threadId`, `turnId`, optionally `approvalId` (for subcommand callbacks), and `reason`. For normal command approvals, it also includes `command`, `cwd`, and `commandActions` for friendly display. For network-only approvals, those command fields may be omitted and `networkApprovalContext` is provided instead.
3. Client response — `{ "decision": "accept", "acceptSettings": { "forSession": false } }` or `{ "decision": "decline" }`.
4. `item/completed` — final `commandExecution` item with `status: "completed" | "failed" | "declined"` and execution output. Render this as the authoritative result.
@@ -44,8 +44,8 @@ use codex_app_server_protocol::McpToolCallError;
use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
use codex_app_server_protocol::PlanDeltaNotification;
use codex_app_server_protocol::RawResponseItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
@@ -72,6 +72,7 @@ use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_app_server_protocol::convert_patch_changes;
use codex_core::CodexThread;
use codex_core::ThreadManager;
use codex_core::parse_command::shlex_join;
@@ -81,7 +82,6 @@ use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange as CoreFileChange;
use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op;
@@ -107,6 +107,17 @@ use tracing::error;
type JsonValue = serde_json::Value;
enum CommandExecutionApprovalPresentation {
Network(V2NetworkApprovalContext),
Command(CommandExecutionCompletionItem),
}
struct CommandExecutionCompletionItem {
command: String,
cwd: PathBuf,
command_actions: Vec<V2ParsedCommand>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn apply_bespoke_event_handling(
event: Event,
@@ -246,6 +257,7 @@ pub(crate) async fn apply_bespoke_event_handling(
command,
cwd,
reason,
network_approval_context,
proposed_execpolicy_amendment,
parsed_cmd,
..
@@ -281,7 +293,32 @@ pub(crate) async fn apply_bespoke_event_handling(
.cloned()
.map(V2ParsedCommand::from)
.collect::<Vec<_>>();
let command_string = shlex_join(&command);
let presentation = if let Some(network_approval_context) =
network_approval_context.map(V2NetworkApprovalContext::from)
{
CommandExecutionApprovalPresentation::Network(network_approval_context)
} else {
let command_string = shlex_join(&command);
let completion_item = CommandExecutionCompletionItem {
command: command_string,
cwd: cwd.clone(),
command_actions: command_actions.clone(),
};
CommandExecutionApprovalPresentation::Command(completion_item)
};
let (network_approval_context, command, cwd, command_actions, completion_item) =
match presentation {
CommandExecutionApprovalPresentation::Network(
network_approval_context,
) => (Some(network_approval_context), None, None, None, None),
CommandExecutionApprovalPresentation::Command(completion_item) => (
None,
Some(completion_item.command.clone()),
Some(completion_item.cwd.clone()),
Some(completion_item.command_actions.clone()),
Some(completion_item),
),
};
let proposed_execpolicy_amendment_v2 =
proposed_execpolicy_amendment.map(V2ExecPolicyAmendment::from);
@@ -291,9 +328,10 @@ pub(crate) async fn apply_bespoke_event_handling(
item_id: call_id.clone(),
approval_id: approval_id.clone(),
reason,
command: Some(command_string.clone()),
cwd: Some(cwd.clone()),
command_actions: Some(command_actions.clone()),
network_approval_context,
command,
cwd,
command_actions,
proposed_execpolicy_amendment: proposed_execpolicy_amendment_v2,
};
let rx = outgoing
@@ -307,9 +345,7 @@ pub(crate) async fn apply_bespoke_event_handling(
conversation_id,
approval_id,
call_id,
command_string,
cwd,
command_actions,
completion_item,
rx,
conversation,
outgoing,
@@ -1706,46 +1742,6 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
}
}
fn convert_patch_changes(changes: &HashMap<PathBuf, CoreFileChange>) -> Vec<FileUpdateChange> {
let mut converted: Vec<FileUpdateChange> = changes
.iter()
.map(|(path, change)| FileUpdateChange {
path: path.to_string_lossy().into_owned(),
kind: map_patch_change_kind(change),
diff: format_file_change_diff(change),
})
.collect();
converted.sort_by(|a, b| a.path.cmp(&b.path));
converted
}
fn map_patch_change_kind(change: &CoreFileChange) -> V2PatchChangeKind {
match change {
CoreFileChange::Add { .. } => V2PatchChangeKind::Add,
CoreFileChange::Delete { .. } => V2PatchChangeKind::Delete,
CoreFileChange::Update { move_path, .. } => V2PatchChangeKind::Update {
move_path: move_path.clone(),
},
}
}
fn format_file_change_diff(change: &CoreFileChange) -> String {
match change {
CoreFileChange::Add { content } => content.clone(),
CoreFileChange::Delete { content } => content.clone(),
CoreFileChange::Update {
unified_diff,
move_path,
} => {
if let Some(path) = move_path {
format!("{unified_diff}\n\nMoved to: {}", path.display())
} else {
unified_diff.clone()
}
}
}
}
fn map_file_change_approval_decision(
decision: FileChangeApprovalDecision,
) -> (ReviewDecision, Option<PatchApplyStatus>) {
@@ -1831,9 +1827,7 @@ async fn on_command_execution_request_approval_response(
conversation_id: ThreadId,
approval_id: Option<String>,
item_id: String,
command: String,
cwd: PathBuf,
command_actions: Vec<V2ParsedCommand>,
completion_item: Option<CommandExecutionCompletionItem>,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
outgoing: ThreadScopedOutgoingMessageSender,
@@ -1905,15 +1899,16 @@ async fn on_command_execution_request_approval_response(
if let Some(status) = completion_status
&& !suppress_subcommand_completion_item
&& let Some(completion_item) = completion_item
{
complete_command_execution_item(
conversation_id,
event_turn_id.clone(),
item_id.clone(),
command.clone(),
cwd.clone(),
completion_item.command,
completion_item.cwd,
None,
command_actions.clone(),
completion_item.command_actions,
status,
&outgoing,
)
@@ -2100,6 +2095,8 @@ mod tests {
call_id: "call-1".to_string(),
sender_thread_id: ThreadId::new(),
receiver_thread_id: ThreadId::new(),
receiver_agent_nickname: None,
receiver_agent_role: None,
};
let item = collab_resume_begin_item(event.clone());
@@ -2121,6 +2118,8 @@ mod tests {
call_id: "call-2".to_string(),
sender_thread_id: ThreadId::new(),
receiver_thread_id: ThreadId::new(),
receiver_agent_nickname: None,
receiver_agent_role: None,
status: codex_protocol::protocol::AgentStatus::NotFound,
};
@@ -336,6 +336,7 @@ pub(crate) struct CodexMessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>,
single_client_mode: bool,
cli_overrides: Vec<(String, TomlValue)>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>,
@@ -361,6 +362,7 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
pub(crate) single_client_mode: bool,
pub(crate) feedback: CodexFeedback,
}
@@ -397,6 +399,7 @@ impl CodexMessageProcessor {
config,
cli_overrides,
cloud_requirements,
single_client_mode,
feedback,
} = args;
Self {
@@ -405,6 +408,7 @@ impl CodexMessageProcessor {
outgoing: outgoing.clone(),
codex_linux_sandbox_exe,
config,
single_client_mode,
cli_overrides,
cloud_requirements,
active_login: Arc::new(Mutex::new(None)),
@@ -1759,7 +1763,6 @@ impl CodexMessageProcessor {
network: started_network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy),
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level,
justification: None,
@@ -3042,21 +3045,14 @@ impl CodexMessageProcessor {
return true;
}
if let Err(err) = self
.ensure_conversation_listener(
existing_thread_id,
request_id.connection_id,
false,
ApiVersion::V2,
)
.await
{
tracing::warn!(
"failed to attach listener for thread {}: {}",
existing_thread_id,
err.message
);
}
let thread_state = self.thread_state_manager.thread_state(existing_thread_id);
self.ensure_listener_task_running(
existing_thread_id,
existing_thread.clone(),
thread_state.clone(),
ApiVersion::V2,
)
.await;
let config_snapshot = existing_thread.config_snapshot().await;
let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot);
@@ -3068,41 +3064,39 @@ impl CodexMessageProcessor {
);
}
let Some(mut thread) = self
.load_thread_from_rollout_or_send_internal(
request_id.clone(),
existing_thread_id,
rollout_path.as_path(),
config_snapshot.model_provider_id.as_str(),
)
.await
else {
let listener_command_tx = {
let thread_state = thread_state.lock().await;
thread_state.listener_command_tx()
};
let Some(listener_command_tx) = listener_command_tx else {
let err = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running"
),
data: None,
};
self.outgoing.send_error(request_id, err).await;
return true;
};
let ThreadConfigSnapshot {
model,
model_provider_id,
approval_policy,
sandbox_policy,
cwd,
reasoning_effort,
..
} = config_snapshot;
thread.status = self
.thread_watch_manager
.loaded_status_for_thread(&thread.id)
.await;
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
cwd,
approval_policy: approval_policy.into(),
sandbox: sandbox_policy.into(),
reasoning_effort,
};
self.outgoing.send_response(request_id, response).await;
let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
crate::thread_state::PendingThreadResumeRequest {
request_id: request_id.clone(),
rollout_path,
config_snapshot,
},
);
if listener_command_tx.send(command).is_err() {
let err = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed"
),
data: None,
};
self.outgoing.send_error(request_id, err).await;
}
return true;
}
false
@@ -5817,17 +5811,18 @@ impl CodexMessageProcessor {
api_version: ApiVersion,
) {
let (cancel_tx, mut cancel_rx) = oneshot::channel();
{
let (mut listener_command_rx, listener_generation) = {
let mut thread_state = thread_state.lock().await;
if thread_state.listener_matches(&conversation) {
return;
}
thread_state.set_listener(cancel_tx, &conversation);
}
thread_state.set_listener(cancel_tx, &conversation)
};
let outgoing_for_task = self.outgoing.clone();
let thread_manager = self.thread_manager.clone();
let thread_watch_manager = self.thread_watch_manager.clone();
let fallback_model_provider = self.config.model_provider_id.clone();
let single_client_mode = self.single_client_mode;
tokio::spawn(async move {
loop {
tokio::select! {
@@ -5869,7 +5864,10 @@ impl CodexMessageProcessor {
conversation_id.to_string().into(),
);
let (subscribed_connection_ids, raw_events_enabled) = {
let thread_state = thread_state.lock().await;
let mut thread_state = thread_state.lock().await;
if !single_client_mode {
thread_state.track_current_turn_event(&event.msg);
}
(
thread_state.subscribed_connection_ids(),
thread_state.experimental_raw_events,
@@ -5908,8 +5906,32 @@ impl CodexMessageProcessor {
)
.await;
}
listener_command = listener_command_rx.recv() => {
let Some(listener_command) = listener_command else {
break;
};
match listener_command {
crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
resume_request,
) => {
handle_pending_thread_resume_request(
conversation_id,
&thread_state,
&thread_watch_manager,
&outgoing_for_task,
resume_request,
)
.await;
}
}
}
}
}
let mut thread_state = thread_state.lock().await;
if thread_state.listener_generation == listener_generation {
thread_state.clear_listener();
}
});
}
async fn git_diff_to_origin(&self, request_id: ConnectionRequestId, cwd: PathBuf) {
@@ -6076,6 +6098,7 @@ impl CodexMessageProcessor {
reason,
thread_id,
include_logs,
extra_log_files,
} = params;
let conversation_id = match thread_id.as_deref() {
@@ -6105,15 +6128,19 @@ impl CodexMessageProcessor {
} else {
None
};
let mut attachment_paths = validated_rollout_path.into_iter().collect::<Vec<_>>();
if let Some(extra_log_files) = extra_log_files {
attachment_paths.extend(extra_log_files);
}
let session_source = self.thread_manager.session_source();
let upload_result = tokio::task::spawn_blocking(move || {
let rollout_path_ref = validated_rollout_path.as_deref();
snapshot.upload_feedback(
&classification,
reason.as_deref(),
include_logs,
rollout_path_ref,
&attachment_paths,
Some(session_source),
)
})
@@ -6206,6 +6233,106 @@ impl CodexMessageProcessor {
}
}
async fn handle_pending_thread_resume_request(
conversation_id: ThreadId,
thread_state: &Arc<Mutex<ThreadState>>,
thread_watch_manager: &ThreadWatchManager,
outgoing: &Arc<OutgoingMessageSender>,
pending: crate::thread_state::PendingThreadResumeRequest,
) {
let active_turn = {
let state = thread_state.lock().await;
state.active_turn_snapshot()
};
let request_id = pending.request_id;
let connection_id = request_id.connection_id;
let mut thread = match load_thread_for_running_resume_response(
conversation_id,
pending.rollout_path.as_path(),
pending.config_snapshot.model_provider_id.as_str(),
active_turn.as_ref(),
)
.await
{
Ok(thread) => thread,
Err(message) => {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message,
data: None,
},
)
.await;
return;
}
};
thread.status = thread_watch_manager
.loaded_status_for_thread(&thread.id)
.await;
let ThreadConfigSnapshot {
model,
model_provider_id,
approval_policy,
sandbox_policy,
cwd,
reasoning_effort,
..
} = pending.config_snapshot;
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
cwd,
approval_policy: approval_policy.into(),
sandbox: sandbox_policy.into(),
reasoning_effort,
};
outgoing.send_response(request_id, response).await;
thread_state.lock().await.add_connection(connection_id);
}
async fn load_thread_for_running_resume_response(
conversation_id: ThreadId,
rollout_path: &Path,
fallback_provider: &str,
active_turn: Option<&Turn>,
) -> std::result::Result<Thread, String> {
let mut thread = read_summary_from_rollout(rollout_path, fallback_provider)
.await
.map(summary_to_thread)
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {conversation_id}: {err}",
rollout_path.display()
)
})?;
let mut turns = read_rollout_items_from_rollout(rollout_path)
.await
.map(|items| build_turns_from_rollout_items(&items))
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {conversation_id}: {err}",
rollout_path.display()
)
})?;
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
}
thread.turns = turns;
Ok(thread)
}
fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn) {
turns.retain(|turn| turn.id != active_turn.id);
turns.push(active_turn);
}
fn collect_resume_override_mismatches(
request: &ThreadResumeParams,
config_snapshot: &ThreadConfigSnapshot,
@@ -6540,6 +6667,8 @@ async fn read_summary_from_state_db_context_by_thread_id(
metadata.cwd,
metadata.cli_version,
metadata.source,
metadata.agent_nickname,
metadata.agent_role,
metadata.git_sha,
metadata.git_branch,
metadata.git_origin_url,
@@ -6560,9 +6689,12 @@ async fn summary_from_thread_list_item(
.unwrap_or_else(|| fallback_provider.to_string());
let cwd = it.cwd?;
let cli_version = it.cli_version.unwrap_or_default();
let source = it
.source
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
let source = with_thread_spawn_agent_metadata(
it.source
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown),
it.agent_nickname.clone(),
it.agent_role.clone(),
);
return Some(ConversationSummary {
conversation_id: thread_id,
path: it.path,
@@ -6617,13 +6749,17 @@ fn summary_from_state_db_metadata(
cwd: PathBuf,
cli_version: String,
source: String,
agent_nickname: Option<String>,
agent_role: Option<String>,
git_sha: Option<String>,
git_branch: Option<String>,
git_origin_url: Option<String>,
) -> ConversationSummary {
let preview = first_user_message.unwrap_or_default();
let source = serde_json::from_value(serde_json::Value::String(source))
let source = serde_json::from_str(&source)
.or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone())))
.unwrap_or(codex_protocol::protocol::SessionSource::Unknown);
let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role);
let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() {
None
} else {
@@ -6671,6 +6807,12 @@ pub(crate) async fn read_summary_from_rollout(
meta: session_meta,
git,
} = session_meta_line;
let mut session_meta = session_meta;
session_meta.source = with_thread_spawn_agent_metadata(
session_meta.source.clone(),
session_meta.agent_nickname.clone(),
session_meta.agent_role.clone(),
);
let created_at = if session_meta.timestamp.is_empty() {
None
@@ -6783,6 +6925,35 @@ fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
}
}
fn with_thread_spawn_agent_metadata(
source: codex_protocol::protocol::SessionSource,
agent_nickname: Option<String>,
agent_role: Option<String>,
) -> codex_protocol::protocol::SessionSource {
if agent_nickname.is_none() && agent_role.is_none() {
return source;
}
match source {
codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: existing_agent_nickname,
agent_role: existing_agent_role,
},
) => codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: agent_nickname.or(existing_agent_nickname),
agent_role: agent_role.or(existing_agent_role),
},
),
_ => source,
}
}
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
@@ -6819,6 +6990,8 @@ fn build_thread_from_snapshot(
path,
cwd: config_snapshot.cwd.clone(),
cli_version: env!("CARGO_PKG_VERSION").to_string(),
agent_nickname: config_snapshot.session_source.get_nickname(),
agent_role: config_snapshot.session_source.get_agent_role(),
source: config_snapshot.session_source.clone().into(),
git_info: None,
turns: Vec::new(),
@@ -6857,6 +7030,8 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread {
path: Some(path),
cwd,
cli_version,
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source: source.into(),
git_info,
turns: Vec::new(),
@@ -6868,6 +7043,7 @@ mod tests {
use super::*;
use anyhow::Result;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
@@ -7013,6 +7189,87 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn read_summary_from_rollout_preserves_agent_nickname() -> Result<()> {
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMetaLine;
use std::fs;
let temp_dir = TempDir::new()?;
let path = temp_dir.path().join("rollout.jsonl");
let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?;
let parent_thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?;
let timestamp = "2025-09-05T16:53:11.850Z".to_string();
let session_meta = SessionMeta {
id: conversation_id,
timestamp: timestamp.clone(),
source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
agent_nickname: Some("atlas".to_string()),
agent_role: Some("explorer".to_string()),
model_provider: Some("test-provider".to_string()),
..SessionMeta::default()
};
let line = RolloutLine {
timestamp,
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: session_meta,
git: None,
}),
};
fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?;
let summary = read_summary_from_rollout(path.as_path(), "fallback").await?;
let thread = summary_to_thread(summary);
assert_eq!(thread.agent_nickname, Some("atlas".to_string()));
assert_eq!(thread.agent_role, Some("explorer".to_string()));
Ok(())
}
#[test]
fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> {
let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?;
let source =
serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?,
depth: 1,
agent_nickname: None,
agent_role: None,
}))?;
let summary = summary_from_state_db_metadata(
conversation_id,
PathBuf::from("/tmp/rollout.jsonl"),
Some("hi".to_string()),
"2025-09-05T16:53:11Z".to_string(),
"2025-09-05T16:53:12Z".to_string(),
"test-provider".to_string(),
PathBuf::from("/"),
"0.0.0".to_string(),
source,
Some("atlas".to_string()),
Some("explorer".to_string()),
None,
None,
None,
);
let thread = summary_to_thread(summary);
assert_eq!(thread.agent_nickname, Some("atlas".to_string()));
assert_eq!(thread.agent_role, Some("explorer".to_string()));
Ok(())
}
#[tokio::test]
async fn removing_one_listener_does_not_cancel_other_subscriptions_for_same_thread()
-> Result<()> {
+3
View File
@@ -161,6 +161,7 @@ fn map_network_requirements_to_api(
allow_upstream_proxy: network.allow_upstream_proxy,
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
dangerously_allow_non_loopback_admin: network.dangerously_allow_non_loopback_admin,
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
allowed_domains: network.allowed_domains,
denied_domains: network.denied_domains,
allow_unix_sockets: network.allow_unix_sockets,
@@ -221,6 +222,7 @@ mod tests {
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_non_loopback_admin: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
allowed_domains: Some(vec!["api.openai.com".to_string()]),
denied_domains: Some(vec!["example.com".to_string()]),
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
@@ -258,6 +260,7 @@ mod tests {
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_non_loopback_admin: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
allowed_domains: Some(vec!["api.openai.com".to_string()]),
denied_domains: Some(vec!["example.com".to_string()]),
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
+2
View File
@@ -133,6 +133,8 @@ mod tests {
let spawn = CoreSessionSource::SubAgent(CoreSubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
});
assert!(source_kind_matches(
+116 -61
View File
@@ -44,6 +44,7 @@ use codex_core::config_loader::TextRange as CoreTextRange;
use codex_feedback::CodexFeedback;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use toml::Value as TomlValue;
use tracing::error;
use tracing::info;
@@ -52,6 +53,7 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
use tracing_subscriber::fmt::writer::BoxMakeWriter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
mod bespoke_event_handling;
@@ -70,6 +72,16 @@ mod transport;
pub use crate::transport::AppServerTransport;
const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LogFormat {
Default,
Json,
}
type StderrLogLayer = Box<dyn Layer<Registry> + Send + Sync + 'static>;
/// Control-plane messages from the processor/transport side to the outbound router task.
///
/// `run_main_with_transport` now uses two loops/tasks:
@@ -84,6 +96,7 @@ enum OutboundControlEvent {
Opened {
connection_id: ConnectionId,
writer: mpsc::Sender<crate::outgoing_message::OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
initialized: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
},
@@ -201,6 +214,20 @@ fn project_config_warning(config: &Config) -> Option<ConfigWarningNotification>
})
}
impl LogFormat {
fn from_env_value(value: Option<&str>) -> Self {
match value.map(str::trim).map(str::to_ascii_lowercase) {
Some(value) if value == "json" => Self::Json,
_ => Self::Default,
}
}
}
fn log_format_from_env() -> LogFormat {
let value = std::env::var(LOG_FORMAT_ENV_VAR).ok();
LogFormat::from_env_value(value.as_deref())
}
pub async fn run_main(
codex_linux_sandbox_exe: Option<PathBuf>,
cli_config_overrides: CliConfigOverrides,
@@ -241,7 +268,8 @@ pub async fn run_main_with_transport(
Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?);
}
}
let shutdown_when_no_connections = matches!(transport, AppServerTransport::Stdio);
let single_client_mode = matches!(transport, AppServerTransport::Stdio);
let shutdown_when_no_connections = single_client_mode;
// Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values.
@@ -344,22 +372,26 @@ pub async fn run_main_with_transport(
)
})?;
// Install a simple subscriber so `tracing` output is visible. Users can
// control the log level with `RUST_LOG`.
let stderr_fmt = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
.with_filter(
EnvFilter::builder()
.with_default_directive(LevelFilter::TRACE.into())
.from_env_lossy(),
);
// Install a simple subscriber so `tracing` output is visible. Users can
// control the log level with `RUST_LOG` and switch to JSON logs with
// `LOG_FORMAT=json`.
let stderr_fmt: StderrLogLayer = match log_format_from_env() {
LogFormat::Json => tracing_subscriber::fmt::layer()
.json()
.with_writer(std::io::stderr)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
.with_filter(EnvFilter::from_default_env())
.boxed(),
LogFormat::Default => tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
.with_filter(EnvFilter::from_default_env())
.boxed(),
};
let feedback_layer = feedback.logger_layer();
let feedback_metadata_layer = feedback.metadata_layer();
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer());
let mut trace_log_setup_warning = None;
@@ -444,61 +476,43 @@ pub async fn run_main_with_transport(
}
}
let transport_event_tx_for_outbound = transport_event_tx.clone();
let outbound_handle = tokio::spawn(async move {
let mut outbound_connections = HashMap::<ConnectionId, OutboundConnectionState>::new();
let mut pending_closed_connections = VecDeque::<ConnectionId>::new();
loop {
tokio::select! {
biased;
event = outbound_control_rx.recv() => {
let Some(event) = event else {
break;
};
match event {
OutboundControlEvent::Opened {
connection_id,
writer,
initialized,
opted_out_notification_methods,
} => {
outbound_connections.insert(
biased;
event = outbound_control_rx.recv() => {
let Some(event) = event else {
break;
};
match event {
OutboundControlEvent::Opened {
connection_id,
OutboundConnectionState::new(
writer,
initialized,
opted_out_notification_methods,
),
);
}
OutboundControlEvent::Closed { connection_id } => {
outbound_connections.remove(&connection_id);
writer,
disconnect_sender,
initialized,
opted_out_notification_methods,
} => {
outbound_connections.insert(
connection_id,
OutboundConnectionState::new(
writer,
initialized,
opted_out_notification_methods,
disconnect_sender,
),
);
}
OutboundControlEvent::Closed { connection_id } => {
outbound_connections.remove(&connection_id);
}
}
}
}
envelope = outgoing_rx.recv() => {
envelope = outgoing_rx.recv() => {
let Some(envelope) = envelope else {
break;
};
let disconnected_connections =
route_outgoing_envelope(&mut outbound_connections, envelope).await;
pending_closed_connections.extend(disconnected_connections);
}
}
while let Some(connection_id) = pending_closed_connections.front().copied() {
match transport_event_tx_for_outbound
.try_send(TransportEvent::ConnectionClosed { connection_id })
{
Ok(()) => {
pending_closed_connections.pop_front();
}
Err(mpsc::error::TrySendError::Full(_)) => {
break;
}
Err(mpsc::error::TrySendError::Closed(_)) => {
return;
}
route_outgoing_envelope(&mut outbound_connections, envelope).await;
}
}
}
@@ -514,6 +528,7 @@ pub async fn run_main_with_transport(
outgoing: outgoing_message_sender,
codex_linux_sandbox_exe,
config: Arc::new(config),
single_client_mode,
cli_overrides,
loader_overrides,
cloud_requirements: cloud_requirements.clone(),
@@ -531,7 +546,11 @@ pub async fn run_main_with_transport(
break;
};
match event {
TransportEvent::ConnectionOpened { connection_id, writer } => {
TransportEvent::ConnectionOpened {
connection_id,
writer,
disconnect_sender,
} => {
let outbound_initialized = Arc::new(AtomicBool::new(false));
let outbound_opted_out_notification_methods =
Arc::new(RwLock::new(HashSet::new()));
@@ -539,6 +558,7 @@ pub async fn run_main_with_transport(
.send(OutboundControlEvent::Opened {
connection_id,
writer,
disconnect_sender,
initialized: Arc::clone(&outbound_initialized),
opted_out_notification_methods: Arc::clone(
&outbound_opted_out_notification_methods,
@@ -558,6 +578,9 @@ pub async fn run_main_with_transport(
);
}
TransportEvent::ConnectionClosed { connection_id } => {
if connections.remove(&connection_id).is_none() {
continue;
}
if outbound_control_tx
.send(OutboundControlEvent::Closed { connection_id })
.await
@@ -566,7 +589,6 @@ pub async fn run_main_with_transport(
break;
}
processor.connection_closed(connection_id).await;
connections.remove(&connection_id);
if shutdown_when_no_connections && connections.is_empty() {
break;
}
@@ -575,7 +597,7 @@ pub async fn run_main_with_transport(
match message {
JSONRPCMessage::Request(request) => {
let Some(connection_state) = connections.get_mut(&connection_id) else {
warn!("dropping request from unknown connection: {:?}", connection_id);
warn!("dropping request from unknown connection: {connection_id:?}");
continue;
};
let was_initialized = connection_state.session.initialized;
@@ -605,12 +627,24 @@ pub async fn run_main_with_transport(
}
}
JSONRPCMessage::Response(response) => {
if !connections.contains_key(&connection_id) {
warn!("dropping response from unknown connection: {connection_id:?}");
continue;
}
processor.process_response(response).await;
}
JSONRPCMessage::Notification(notification) => {
if !connections.contains_key(&connection_id) {
warn!("dropping notification from unknown connection: {connection_id:?}");
continue;
}
processor.process_notification(notification).await;
}
JSONRPCMessage::Error(err) => {
if !connections.contains_key(&connection_id) {
warn!("dropping error from unknown connection: {connection_id:?}");
continue;
}
processor.process_error(err).await;
}
}
@@ -668,3 +702,24 @@ pub async fn run_main_with_transport(
Ok(())
}
#[cfg(test)]
mod tests {
use super::LogFormat;
use pretty_assertions::assert_eq;
#[test]
fn log_format_from_env_value_matches_json_values_case_insensitively() {
assert_eq!(LogFormat::from_env_value(Some("json")), LogFormat::Json);
assert_eq!(LogFormat::from_env_value(Some("JSON")), LogFormat::Json);
assert_eq!(LogFormat::from_env_value(Some(" Json ")), LogFormat::Json);
}
#[test]
fn log_format_from_env_value_defaults_for_non_json_values() {
assert_eq!(LogFormat::from_env_value(None), LogFormat::Default);
assert_eq!(LogFormat::from_env_value(Some("")), LogFormat::Default);
assert_eq!(LogFormat::from_env_value(Some("text")), LogFormat::Default);
assert_eq!(LogFormat::from_env_value(Some("jsonl")), LogFormat::Default);
}
}
@@ -139,6 +139,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) config: Arc<Config>,
pub(crate) single_client_mode: bool,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) loader_overrides: LoaderOverrides,
pub(crate) cloud_requirements: CloudRequirementsLoader,
@@ -154,6 +155,7 @@ impl MessageProcessor {
outgoing,
codex_linux_sandbox_exe,
config,
single_client_mode,
cli_overrides,
loader_overrides,
cloud_requirements,
@@ -184,6 +186,7 @@ impl MessageProcessor {
config: Arc::clone(&config),
cli_overrides: cli_overrides.clone(),
cloud_requirements: cloud_requirements.clone(),
single_client_mode,
feedback,
});
let config_api = ConfigApi::new(
+58 -4
View File
@@ -1,13 +1,19 @@
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
use codex_core::CodexThread;
use codex_core::ThreadConfigSnapshot;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Weak;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use uuid::Uuid;
@@ -16,6 +22,16 @@ type PendingInterruptQueue = Vec<(
crate::codex_message_processor::ApiVersion,
)>;
pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
}
pub(crate) enum ThreadListenerCommand {
SendThreadResumeResponse(PendingThreadResumeRequest),
}
/// Per-conversation accumulation of the latest states e.g. error message while a turn runs.
#[derive(Default, Clone)]
pub(crate) struct TurnSummary {
@@ -31,6 +47,9 @@ pub(crate) struct ThreadState {
pub(crate) turn_summary: TurnSummary,
pub(crate) cancel_tx: Option<oneshot::Sender<()>>,
pub(crate) experimental_raw_events: bool,
pub(crate) listener_generation: u64,
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
current_turn_history: ThreadHistoryBuilder,
listener_thread: Option<Weak<CodexThread>>,
subscribed_connections: HashSet<ConnectionId>,
}
@@ -47,17 +66,23 @@ impl ThreadState {
&mut self,
cancel_tx: oneshot::Sender<()>,
conversation: &Arc<CodexThread>,
) {
) -> (mpsc::UnboundedReceiver<ThreadListenerCommand>, u64) {
if let Some(previous) = self.cancel_tx.replace(cancel_tx) {
let _ = previous.send(());
}
self.listener_generation = self.listener_generation.wrapping_add(1);
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
self.listener_command_tx = Some(listener_command_tx);
self.listener_thread = Some(Arc::downgrade(conversation));
(listener_command_rx, self.listener_generation)
}
pub(crate) fn clear_listener(&mut self) {
if let Some(cancel_tx) = self.cancel_tx.take() {
let _ = cancel_tx.send(());
}
self.listener_command_tx = None;
self.current_turn_history.reset();
self.listener_thread = None;
}
@@ -76,6 +101,23 @@ impl ThreadState {
pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) {
self.experimental_raw_events = enabled;
}
pub(crate) fn listener_command_tx(
&self,
) -> Option<mpsc::UnboundedSender<ThreadListenerCommand>> {
self.listener_command_tx.clone()
}
pub(crate) fn active_turn_snapshot(&self) -> Option<Turn> {
self.current_turn_history.active_turn_snapshot()
}
pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) {
self.current_turn_history.handle_event(event);
if !self.current_turn_history.has_active_turn() {
self.current_turn_history.reset();
}
}
}
#[derive(Clone, Copy)]
@@ -200,12 +242,24 @@ impl ThreadStateManager {
}
pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) {
let Some(thread_ids) = self.thread_ids_by_connection.remove(&connection_id) else {
return;
};
let thread_ids = self
.thread_ids_by_connection
.remove(&connection_id)
.unwrap_or_default();
self.subscription_state_by_id
.retain(|_, state| state.connection_id != connection_id);
if thread_ids.is_empty() {
for thread_state in self.thread_states.values() {
let mut thread_state = thread_state.lock().await;
thread_state.remove_connection(connection_id);
if thread_state.subscribed_connection_ids().is_empty() {
thread_state.clear_listener();
}
}
return;
}
for thread_id in thread_ids {
if let Some(thread_state) = self.thread_states.get(&thread_id) {
let mut thread_state = thread_state.lock().await;
+2
View File
@@ -622,6 +622,8 @@ mod tests {
path: None,
cwd: PathBuf::from("/tmp"),
cli_version: "test".to_string(),
agent_nickname: None,
agent_role: None,
source,
git_info: None,
turns: Vec::new(),
+309 -35
View File
@@ -32,6 +32,7 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
use tokio_util::sync::CancellationToken;
use tracing::debug;
use tracing::error;
use tracing::info;
@@ -135,6 +136,7 @@ pub(crate) enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
writer: mpsc::Sender<OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
connection_id: ConnectionId,
@@ -168,6 +170,7 @@ pub(crate) struct OutboundConnectionState {
pub(crate) initialized: Arc<AtomicBool>,
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
}
impl OutboundConnectionState {
@@ -175,11 +178,23 @@ impl OutboundConnectionState {
writer: mpsc::Sender<OutgoingMessage>,
initialized: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
disconnect_sender: Option<CancellationToken>,
) -> Self {
Self {
initialized,
opted_out_notification_methods,
writer,
disconnect_sender,
}
}
fn can_disconnect(&self) -> bool {
self.disconnect_sender.is_some()
}
fn request_disconnect(&self) {
if let Some(disconnect_sender) = &self.disconnect_sender {
disconnect_sender.cancel();
}
}
}
@@ -195,6 +210,7 @@ pub(crate) async fn start_stdio_connection(
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
disconnect_sender: None,
})
.await
.map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?;
@@ -299,12 +315,14 @@ async fn run_websocket_connection(
}
};
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
let (writer_tx, writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
let writer_tx_for_reader = writer_tx.clone();
let disconnect_token = CancellationToken::new();
if transport_event_tx
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
disconnect_sender: Some(disconnect_token.clone()),
})
.await
.is_err()
@@ -312,9 +330,62 @@ async fn run_websocket_connection(
return;
}
let (mut websocket_writer, mut websocket_reader) = websocket_stream.split();
let (websocket_writer, websocket_reader) = websocket_stream.split();
let (writer_control_tx, writer_control_rx) =
mpsc::channel::<WebSocketMessage>(CHANNEL_CAPACITY);
let mut outbound_task = tokio::spawn(run_websocket_outbound_loop(
websocket_writer,
writer_rx,
writer_control_rx,
disconnect_token.clone(),
));
let mut inbound_task = tokio::spawn(run_websocket_inbound_loop(
websocket_reader,
transport_event_tx.clone(),
writer_tx_for_reader,
writer_control_tx,
connection_id,
disconnect_token.clone(),
));
tokio::select! {
_ = &mut outbound_task => {
disconnect_token.cancel();
inbound_task.abort();
}
_ = &mut inbound_task => {
disconnect_token.cancel();
outbound_task.abort();
}
}
let _ = transport_event_tx
.send(TransportEvent::ConnectionClosed { connection_id })
.await;
}
async fn run_websocket_outbound_loop(
mut websocket_writer: futures::stream::SplitSink<
tokio_tungstenite::WebSocketStream<TcpStream>,
WebSocketMessage,
>,
mut writer_rx: mpsc::Receiver<OutgoingMessage>,
mut writer_control_rx: mpsc::Receiver<WebSocketMessage>,
disconnect_token: CancellationToken,
) {
loop {
tokio::select! {
_ = disconnect_token.cancelled() => {
break;
}
message = writer_control_rx.recv() => {
let Some(message) = message else {
break;
};
if websocket_writer.send(message).await.is_err() {
break;
}
}
outgoing_message = writer_rx.recv() => {
let Some(outgoing_message) = outgoing_message else {
break;
@@ -326,6 +397,25 @@ async fn run_websocket_connection(
break;
}
}
}
}
}
async fn run_websocket_inbound_loop(
mut websocket_reader: futures::stream::SplitStream<
tokio_tungstenite::WebSocketStream<TcpStream>,
>,
transport_event_tx: mpsc::Sender<TransportEvent>,
writer_tx_for_reader: mpsc::Sender<OutgoingMessage>,
writer_control_tx: mpsc::Sender<WebSocketMessage>,
connection_id: ConnectionId,
disconnect_token: CancellationToken,
) {
loop {
tokio::select! {
_ = disconnect_token.cancelled() => {
break;
}
incoming_message = websocket_reader.next() => {
match incoming_message {
Some(Ok(WebSocketMessage::Text(text))) => {
@@ -341,8 +431,13 @@ async fn run_websocket_connection(
}
}
Some(Ok(WebSocketMessage::Ping(payload))) => {
if websocket_writer.send(WebSocketMessage::Pong(payload)).await.is_err() {
break;
match writer_control_tx.try_send(WebSocketMessage::Pong(payload)) {
Ok(()) => {}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => break,
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
warn!("websocket control queue full while replying to ping; closing connection");
break;
}
}
}
Some(Ok(WebSocketMessage::Pong(_))) => {}
@@ -359,10 +454,6 @@ async fn run_websocket_connection(
}
}
}
let _ = transport_event_tx
.send(TransportEvent::ConnectionClosed { connection_id })
.await;
}
async fn forward_incoming_message(
@@ -461,30 +552,61 @@ fn should_skip_notification_for_connection(
}
}
fn disconnect_connection(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
connection_id: ConnectionId,
) -> bool {
if let Some(connection_state) = connections.remove(&connection_id) {
connection_state.request_disconnect();
return true;
}
false
}
async fn send_message_to_connection(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
connection_id: ConnectionId,
message: OutgoingMessage,
) -> bool {
let Some(connection_state) = connections.get(&connection_id) else {
warn!("dropping message for disconnected connection: {connection_id:?}");
return false;
};
if should_skip_notification_for_connection(connection_state, &message) {
return false;
}
let writer = connection_state.writer.clone();
if connection_state.can_disconnect() {
match writer.try_send(message) {
Ok(()) => false,
Err(mpsc::error::TrySendError::Full(_)) => {
warn!(
"disconnecting slow connection after outbound queue filled: {connection_id:?}"
);
disconnect_connection(connections, connection_id)
}
Err(mpsc::error::TrySendError::Closed(_)) => {
disconnect_connection(connections, connection_id)
}
}
} else if writer.send(message).await.is_err() {
disconnect_connection(connections, connection_id)
} else {
false
}
}
pub(crate) async fn route_outgoing_envelope(
connections: &mut HashMap<ConnectionId, OutboundConnectionState>,
envelope: OutgoingEnvelope,
) -> Vec<ConnectionId> {
let mut disconnected = Vec::new();
) {
match envelope {
OutgoingEnvelope::ToConnection {
connection_id,
message,
} => {
let Some(connection_state) = connections.get(&connection_id) else {
warn!(
"dropping message for disconnected connection: {:?}",
connection_id
);
return disconnected;
};
if should_skip_notification_for_connection(connection_state, &message) {
return disconnected;
}
if connection_state.writer.send(message).await.is_err() {
connections.remove(&connection_id);
disconnected.push(connection_id);
}
let _ = send_message_to_connection(connections, connection_id, message).await;
}
OutgoingEnvelope::Broadcast { message } => {
let target_connections: Vec<ConnectionId> = connections
@@ -501,17 +623,11 @@ pub(crate) async fn route_outgoing_envelope(
.collect();
for connection_id in target_connections {
let Some(connection_state) = connections.get(&connection_id) else {
continue;
};
if connection_state.writer.send(message.clone()).await.is_err() {
connections.remove(&connection_id);
disconnected.push(connection_id);
}
let _ =
send_message_to_connection(connections, connection_id, message.clone()).await;
}
}
}
disconnected
}
#[cfg(test)]
@@ -520,6 +636,8 @@ mod tests {
use crate::error_code::OVERLOADED_ERROR_CODE;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::Duration;
use tokio::time::timeout;
#[test]
fn app_server_transport_parses_stdio_listen_url() {
@@ -754,10 +872,15 @@ mod tests {
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(writer_tx, initialized, opted_out_notification_methods),
OutboundConnectionState::new(
writer_tx,
initialized,
opted_out_notification_methods,
None,
),
);
let disconnected = route_outgoing_envelope(
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
@@ -771,10 +894,161 @@ mod tests {
)
.await;
assert_eq!(disconnected, Vec::<ConnectionId>::new());
assert!(
writer_rx.try_recv().is_err(),
"opted-out notification should be dropped"
);
}
#[tokio::test]
async fn broadcast_does_not_block_on_slow_connection() {
let fast_connection_id = ConnectionId(1);
let slow_connection_id = ConnectionId(2);
let (fast_writer_tx, mut fast_writer_rx) = mpsc::channel(1);
let (slow_writer_tx, mut slow_writer_rx) = mpsc::channel(1);
let fast_disconnect_token = CancellationToken::new();
let slow_disconnect_token = CancellationToken::new();
let mut connections = HashMap::new();
connections.insert(
fast_connection_id,
OutboundConnectionState::new(
fast_writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
Some(fast_disconnect_token.clone()),
),
);
connections.insert(
slow_connection_id,
OutboundConnectionState::new(
slow_writer_tx.clone(),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
Some(slow_disconnect_token.clone()),
),
);
let queued_message =
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method: "codex/event/already-buffered".to_string(),
params: None,
});
slow_writer_tx
.try_send(queued_message)
.expect("channel should have room");
let broadcast_message =
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method: "codex/event/test".to_string(),
params: None,
});
timeout(
Duration::from_millis(100),
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::Broadcast {
message: broadcast_message,
},
),
)
.await
.expect("broadcast should not block on a full writer");
assert!(!connections.contains_key(&slow_connection_id));
assert!(slow_disconnect_token.is_cancelled());
assert!(!fast_disconnect_token.is_cancelled());
let fast_message = fast_writer_rx
.try_recv()
.expect("fast connection should receive broadcast");
assert!(matches!(
fast_message,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "codex/event/test"
));
let slow_message = slow_writer_rx
.try_recv()
.expect("slow connection should retain its original buffered message");
assert!(matches!(
slow_message,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "codex/event/already-buffered"
));
}
#[tokio::test]
async fn to_connection_stdio_waits_instead_of_disconnecting_when_writer_queue_is_full() {
let connection_id = ConnectionId(3);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
writer_tx
.send(OutgoingMessage::Notification(
crate::outgoing_message::OutgoingNotification {
method: "queued".to_string(),
params: None,
},
))
.await
.expect("channel should accept the first queued message");
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
None,
),
);
let route_task = tokio::spawn(async move {
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Notification(
crate::outgoing_message::OutgoingNotification {
method: "second".to_string(),
params: None,
},
),
},
)
.await
});
let first = timeout(Duration::from_millis(100), writer_rx.recv())
.await
.expect("first queued message should be readable")
.expect("first queued message should exist");
let second = timeout(Duration::from_millis(100), writer_rx.recv())
.await
.expect("second message should eventually be delivered")
.expect("second message should exist");
timeout(Duration::from_millis(100), route_task)
.await
.expect("routing should finish after writer drains")
.expect("routing task should succeed");
assert!(matches!(
first,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "queued"
));
assert!(matches!(
second,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "second"
));
}
}
@@ -61,6 +61,7 @@ use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnSteerParams;
@@ -572,6 +573,63 @@ impl McpProcess {
self.send_request("turn/interrupt", params).await
}
/// Deterministically clean up an intentionally in-flight turn.
///
/// Some tests assert behavior while a turn is still running. Returning from those tests
/// without an explicit interrupt + `codex/event/turn_aborted` wait can leave in-flight work
/// racing teardown and intermittently show up as `LEAK` in nextest.
///
/// In rare races, the turn can also fail or complete on its own after we send
/// `turn/interrupt` but before the server emits the interrupt response. The helper treats a
/// buffered matching `turn/completed` notification as sufficient terminal cleanup in that
/// case so teardown does not flap on timing.
pub async fn interrupt_turn_and_wait_for_aborted(
&mut self,
thread_id: String,
turn_id: String,
read_timeout: std::time::Duration,
) -> anyhow::Result<()> {
let interrupt_request_id = self
.send_turn_interrupt_request(TurnInterruptParams {
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
})
.await?;
match tokio::time::timeout(
read_timeout,
self.read_stream_until_response_message(RequestId::Integer(interrupt_request_id)),
)
.await
{
Ok(result) => {
result.with_context(|| "failed while waiting for turn interrupt response")?;
}
Err(err) => {
if self.pending_turn_completed_notification(&thread_id, &turn_id) {
return Ok(());
}
return Err(err).with_context(|| "timed out waiting for turn interrupt response");
}
}
match tokio::time::timeout(
read_timeout,
self.read_stream_until_notification_message("codex/event/turn_aborted"),
)
.await
{
Ok(result) => {
result.with_context(|| "failed while waiting for turn aborted notification")?;
}
Err(err) => {
if self.pending_turn_completed_notification(&thread_id, &turn_id) {
return Ok(());
}
return Err(err).with_context(|| "timed out waiting for turn aborted notification");
}
}
Ok(())
}
/// Send a `turn/steer` JSON-RPC request (v2).
pub async fn send_turn_steer_request(
&mut self,
@@ -940,6 +998,25 @@ impl McpProcess {
None
}
fn pending_turn_completed_notification(&self, thread_id: &str, turn_id: &str) -> bool {
self.pending_messages.iter().any(|message| {
let JSONRPCMessage::Notification(notification) = message else {
return false;
};
if notification.method != "turn/completed" {
return false;
}
let Some(params) = notification.params.as_ref() else {
return false;
};
let Ok(payload) = serde_json::from_value::<TurnCompletedNotification>(params.clone())
else {
return false;
};
payload.thread_id == thread_id && payload.turn.id == turn_id
})
}
fn message_request_id(message: &JSONRPCMessage) -> Option<&RequestId> {
match message {
JSONRPCMessage::Request(request) => Some(&request.id),
@@ -79,6 +79,8 @@ pub fn create_fake_rollout_with_source(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source,
agent_nickname: None,
agent_role: None,
model_provider: model_provider.map(str::to_string),
base_instructions: None,
dynamic_tools: None,
@@ -158,6 +160,8 @@ pub fn create_fake_rollout_with_text_elements(
originator: "codex".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::Cli,
agent_nickname: None,
agent_role: None,
model_provider: model_provider.map(str::to_string),
base_instructions: None,
dynamic_tools: None,
+63 -35
View File
@@ -15,6 +15,7 @@ use axum::Router;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::http::StatusCode;
use axum::http::Uri;
use axum::http::header::AUTHORIZATION;
use axum::routing::get;
use codex_app_server_protocol::AppBranding;
@@ -491,39 +492,60 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
})
.await?;
let expected_directory_first = vec![
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
];
let expected_accessible_first = vec![AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
}];
let first_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
first_update.data,
vec![
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: Some("https://example.com/alpha.png".to_string()),
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
name: "beta".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
]
// app/list emits an update after whichever async load finishes first. Even with
// a tools delay in this test, the accessible-tools path can return first if the
// process-global Codex Apps tools cache is warm from another test.
assert!(
first_update.data == expected_directory_first
|| first_update.data == expected_accessible_first,
"unexpected first app/list update: {:#?}",
first_update.data
);
let expected = vec![
@@ -1194,6 +1216,7 @@ async fn start_apps_server_with_delays_and_control(
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
uri: Uri,
) -> Result<impl axum::response::IntoResponse, StatusCode> {
if state.directory_delay > Duration::ZERO {
tokio::time::sleep(state.directory_delay).await;
@@ -1207,16 +1230,21 @@ async fn list_directory_connectors(
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == state.expected_account_id);
let external_logos_ok = uri
.query()
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
if bearer_ok && account_ok {
if !bearer_ok || !account_ok {
Err(StatusCode::UNAUTHORIZED)
} else if !external_logos_ok {
Err(StatusCode::BAD_REQUEST)
} else {
let response = state
.response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(Json(response))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
@@ -4,7 +4,7 @@ use app_test_support::test_path_buf_with_windows;
use app_test_support::test_tmp_path_buf;
use app_test_support::to_response;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppDisabledReason;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigBatchWriteParams;
@@ -156,7 +156,8 @@ async fn config_read_includes_apps() -> Result<()> {
r#"
[apps.app1]
enabled = false
disabled_reason = "user"
destructive_enabled = false
default_tools_approval_mode = "prompt"
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
@@ -185,11 +186,16 @@ disabled_reason = "user"
assert_eq!(
config.apps,
Some(AppsConfig {
default: None,
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
disabled_reason: Some(AppDisabledReason::User),
destructive_enabled: Some(false),
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: None,
},
)]),
})
@@ -202,7 +208,16 @@ disabled_reason = "user"
);
assert_eq!(
origins
.get("apps.app1.disabled_reason")
.get("apps.app1.destructive_enabled")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
);
assert_eq!(
origins
.get("apps.app1.default_tools_approval_mode")
.expect("origin")
.name,
ConfigLayerSource::User {
@@ -582,6 +582,8 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<()
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
)?;
@@ -643,6 +645,8 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
CoreSessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
)?;
let other_id = create_fake_rollout_with_source(
@@ -15,8 +15,6 @@ use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
@@ -317,9 +315,14 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]);
let second_response = responses::sse_response(responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "Done"),
responses::ev_completed("resp-2"),
]))
.set_delay(std::time::Duration::from_millis(500));
let _first_response_mock = responses::mount_sse_once(&server, first_body).await;
let _second_response_mock = responses::mount_sse_once(&server, second_body).await;
let _second_response_mock = responses::mount_response_once(&server, second_response).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -413,28 +416,9 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
resume_err.error.message
);
// This test intentionally keeps a turn running to exercise the resume error path.
// Keep this explicit interrupt + turn_aborted wait so teardown does not leave
// in-flight work behind (which can show up as LEAK in nextest).
let interrupt_id = primary
.send_turn_interrupt_request(TurnInterruptParams {
thread_id,
turn_id: running_turn.id,
})
primary
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
.await?;
let interrupt_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(interrupt_id)),
)
.await??;
let _turn_interrupt_response: TurnInterruptResponse =
to_response::<TurnInterruptResponse>(interrupt_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("codex/event/turn_aborted"),
)
.await??;
Ok(())
}
@@ -447,9 +431,14 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]);
let second_response = responses::sse_response(responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "Done"),
responses::ev_completed("resp-2"),
]))
.set_delay(std::time::Duration::from_millis(500));
let _first_response_mock = responses::mount_sse_once(&server, first_body).await;
let _second_response_mock = responses::mount_sse_once(&server, second_body).await;
let _second_response_mock = responses::mount_response_once(&server, second_response).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -533,28 +522,9 @@ async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Resul
resume_err.error.message
);
// This test intentionally keeps a turn running to exercise the resume error path.
// Keep this explicit interrupt + turn_aborted wait so teardown does not leave
// in-flight work behind (which can show up as LEAK in nextest).
let interrupt_id = primary
.send_turn_interrupt_request(TurnInterruptParams {
thread_id,
turn_id: running_turn.id,
})
primary
.interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT)
.await?;
let interrupt_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(interrupt_id)),
)
.await??;
let _turn_interrupt_response: TurnInterruptResponse =
to_response::<TurnInterruptResponse>(interrupt_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("codex/event/turn_aborted"),
)
.await??;
Ok(())
}
@@ -747,7 +747,12 @@ async fn turn_start_accepts_local_image_input() -> Result<()> {
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
assert!(!turn.id.is_empty());
// This test only validates that turn/start responds and returns a turn.
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
@@ -29,6 +29,7 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::features::FEATURES;
@@ -100,7 +101,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "run echo hi".to_string(),
text_elements: Vec::new(),
@@ -114,11 +115,12 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
..Default::default()
})
.await?;
timeout(
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
@@ -149,6 +151,9 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> {
assert!(command.contains(" -lc 'echo hi'"));
assert_eq!(cwd, workspace);
mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT)
.await?;
Ok(())
}
@@ -504,11 +509,12 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
..Default::default()
})
.await?;
timeout(
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let mut approval_ids = Vec::new();
for decision in [
@@ -577,6 +583,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2()
assert_eq!(approval_ids.len(), 2);
assert_ne!(approval_ids[0], approval_ids[1]);
mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT)
.await?;
Ok(())
}
@@ -136,7 +136,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id,
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
@@ -152,6 +152,9 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
let steer: TurnSteerResponse = to_response::<TurnSteerResponse>(steer_resp)?;
assert_eq!(steer.turn_id, turn.id);
mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT)
.await?;
Ok(())
}
+10 -62
View File
@@ -4,8 +4,6 @@ use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
use codex_core::config::Config;
use codex_core::default_client::is_first_party_chat_originator;
use codex_core::default_client::originator;
use codex_core::features::Feature;
use codex_core::token_data::TokenData;
use serde::Deserialize;
@@ -22,8 +20,10 @@ use codex_core::connectors::AppMetadata;
use codex_core::connectors::CONNECTORS_CACHE_TTL;
pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::connector_install_url;
use codex_core::connectors::filter_disallowed_connectors;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status;
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
use codex_core::connectors::merge_connectors;
pub use codex_core::connectors::with_app_enabled_state;
@@ -106,7 +106,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
}
let token_data = get_chatgpt_token_data()?;
let cache_key = all_connectors_cache_key(config, &token_data);
read_cached_all_connectors(&cache_key)
read_cached_all_connectors(&cache_key).map(filter_disallowed_connectors)
}
pub async fn list_all_connectors_with_options(
@@ -123,7 +123,7 @@ pub async fn list_all_connectors_with_options(
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
let cache_key = all_connectors_cache_key(config, &token_data);
if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) {
return Ok(cached_connectors);
return Ok(filter_disallowed_connectors(cached_connectors));
}
let mut apps = list_directory_connectors(config).await?;
@@ -149,6 +149,7 @@ pub async fn list_all_connectors_with_options(
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
let connectors = filter_disallowed_connectors(connectors);
write_cached_all_connectors(cache_key, &connectors);
Ok(connectors)
}
@@ -219,9 +220,11 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<Direct
let path = match next_token.as_deref() {
Some(token) => {
let encoded_token = urlencoding::encode(token);
format!("/connectors/directory/list?tier=categorized&token={encoded_token}")
format!(
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
)
}
None => "/connectors/directory/list?tier=categorized".to_string(),
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
};
let response: DirectoryListResponse =
chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT))
@@ -246,7 +249,7 @@ async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<Direct
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
config,
"/connectors/directory/list_workspace".to_string(),
"/connectors/directory/list_workspace?external_logos=true".to_string(),
Some(DIRECTORY_CONNECTORS_TIMEOUT),
)
.await;
@@ -453,45 +456,6 @@ fn normalize_connector_value(value: Option<&str>) -> Option<String> {
.filter(|value| !value.is_empty())
.map(str::to_string)
}
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",
"connector_0f9c9d4592e54d0a9a12b3f44a1e2010",
];
const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] =
&["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"];
const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_";
fn filter_disallowed_connectors(connectors: Vec<AppInfo>) -> Vec<AppInfo> {
filter_disallowed_connectors_for_originator(connectors, originator().value.as_str())
}
fn filter_disallowed_connectors_for_originator(
connectors: Vec<AppInfo>,
originator_value: &str,
) -> Vec<AppInfo> {
let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) {
FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS
} else {
DISALLOWED_CONNECTOR_IDS
};
connectors
.into_iter()
.filter(|connector| is_connector_allowed(connector, disallowed_connector_ids))
.collect()
}
fn is_connector_allowed(connector: &AppInfo, disallowed_connector_ids: &[&str]) -> bool {
let connector_id = connector.id.as_str();
!connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX)
&& !disallowed_connector_ids.contains(&connector_id)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -554,22 +518,6 @@ mod tests {
assert_eq!(filtered, vec![app("delta")]);
}
#[test]
fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() {
let filtered = filter_disallowed_connectors_for_originator(
vec![
app("connector_openai_foo"),
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"),
],
"codex_atlas",
);
assert_eq!(
filtered,
vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c"),]
);
}
fn merged_app(id: &str, is_accessible: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
@@ -175,6 +175,19 @@ pub struct ResponsesWebsocketConnection {
telemetry: Option<Arc<dyn WebsocketTelemetry>>,
}
impl std::fmt::Debug for ResponsesWebsocketConnection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResponsesWebsocketConnection")
.field("stream", &"<ws-stream>")
.field("idle_timeout", &self.idle_timeout)
.field("server_reasoning_included", &self.server_reasoning_included)
.field("models_etag", &self.models_etag)
.field("server_model", &self.server_model)
.field("telemetry", &self.telemetry.as_ref().map(|_| "<telemetry>"))
.finish()
}
}
impl ResponsesWebsocketConnection {
fn new(
stream: WsStream,
@@ -135,6 +135,7 @@ pub struct NetworkRequirementsToml {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_non_loopback_admin: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
@@ -150,6 +151,7 @@ pub struct NetworkConstraints {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_non_loopback_admin: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
pub allowed_domains: Option<Vec<String>>,
pub denied_domains: Option<Vec<String>>,
pub allow_unix_sockets: Option<Vec<String>>,
@@ -165,6 +167,7 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
allow_upstream_proxy,
dangerously_allow_non_loopback_proxy,
dangerously_allow_non_loopback_admin,
dangerously_allow_all_unix_sockets,
allowed_domains,
denied_domains,
allow_unix_sockets,
@@ -177,6 +180,7 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
allow_upstream_proxy,
dangerously_allow_non_loopback_proxy,
dangerously_allow_non_loopback_admin,
dangerously_allow_all_unix_sockets,
allowed_domains,
denied_domains,
allow_unix_sockets,
@@ -1040,6 +1044,7 @@ mod tests {
[experimental_network]
enabled = true
allow_upstream_proxy = false
dangerously_allow_all_unix_sockets = true
allowed_domains = ["api.example.com", "*.openai.com"]
denied_domains = ["blocked.example.com"]
allow_unix_sockets = ["/tmp/example.sock"]
@@ -1058,6 +1063,10 @@ mod tests {
assert_eq!(sourced_network.source, source);
assert_eq!(sourced_network.value.enabled, Some(true));
assert_eq!(sourced_network.value.allow_upstream_proxy, Some(false));
assert_eq!(
sourced_network.value.dangerously_allow_all_unix_sockets,
Some(true)
);
assert_eq!(
sourced_network.value.allowed_domains.as_ref(),
Some(&vec![
+1 -1
View File
@@ -48,7 +48,7 @@ codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-utils-sanitizer = { workspace = true }
codex-secrets = { workspace = true }
codex-utils-string = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
dirs = { workspace = true }
+91 -7
View File
@@ -85,34 +85,111 @@
"additionalProperties": false,
"description": "Config values for a single app/connector.",
"properties": {
"disabled_reason": {
"default_tools_approval_mode": {
"allOf": [
{
"$ref": "#/definitions/AppDisabledReason"
"$ref": "#/definitions/AppToolApproval"
}
],
"description": "Reason this app was disabled."
"description": "Approval mode for tools in this app unless a tool override exists."
},
"default_tools_enabled": {
"description": "Whether tools are enabled by default for this app.",
"type": "boolean"
},
"destructive_enabled": {
"description": "Whether tools with `destructive_hint = true` are allowed for this app.",
"type": "boolean"
},
"enabled": {
"default": true,
"description": "When `false`, Codex does not surface this app.",
"type": "boolean"
},
"open_world_enabled": {
"description": "Whether tools with `open_world_hint = true` are allowed for this app.",
"type": "boolean"
},
"tools": {
"allOf": [
{
"$ref": "#/definitions/AppToolsConfig"
}
],
"description": "Per-tool settings for this app."
}
},
"type": "object"
},
"AppDisabledReason": {
"AppToolApproval": {
"enum": [
"unknown",
"user"
"auto",
"prompt",
"approve"
],
"type": "string"
},
"AppToolConfig": {
"additionalProperties": false,
"description": "Per-tool settings for a single app tool.",
"properties": {
"approval_mode": {
"allOf": [
{
"$ref": "#/definitions/AppToolApproval"
}
],
"description": "Approval mode for this tool."
},
"enabled": {
"description": "Whether this tool is enabled. `Some(true)` explicitly allows this tool.",
"type": "boolean"
}
},
"type": "object"
},
"AppToolsConfig": {
"additionalProperties": {
"$ref": "#/definitions/AppToolConfig"
},
"description": "Tool settings for a single app.",
"type": "object"
},
"AppsConfigToml": {
"additionalProperties": {
"$ref": "#/definitions/AppConfig"
},
"description": "App/connector settings loaded from `config.toml`.",
"properties": {
"_default": {
"allOf": [
{
"$ref": "#/definitions/AppsDefaultConfig"
}
],
"description": "Default settings for all apps."
}
},
"type": "object"
},
"AppsDefaultConfig": {
"additionalProperties": false,
"description": "Default settings that apply to all apps.",
"properties": {
"destructive_enabled": {
"description": "Whether tools with `destructive_hint = true` are allowed by default.",
"type": "boolean"
},
"enabled": {
"default": true,
"description": "When `false`, apps are disabled unless overridden by per-app settings.",
"type": "boolean"
},
"open_world_enabled": {
"description": "Whether tools with `open_world_hint = true` are allowed by default.",
"type": "boolean"
}
},
"type": "object"
},
"AskForApproval": {
@@ -656,6 +733,9 @@
},
"type": "array"
},
"dangerously_allow_all_unix_sockets": {
"type": "boolean"
},
"dangerously_allow_non_loopback_admin": {
"type": "boolean"
},
@@ -1389,6 +1469,10 @@
],
"description": "Agent-related settings (thread limits, etc.)."
},
"allow_login_shell": {
"description": "Whether the model may request a login shell for shell-based tools. Default to `true`\n\nIf `true`, the model may request a login shell (`login = true`), and omitting `login` defaults to using a login shell. If `false`, the model can never use a login shell: `login = true` requests are rejected, and omitting `login` defaults to a non-login shell.",
"type": "boolean"
},
"analytics": {
"allOf": [
{
@@ -1723,7 +1807,7 @@
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional path to a JSON file containing a complete model catalog. When set, this replaces the bundled catalog for this process."
"description": "Optional path to a JSON model catalog (applied on startup only). Per-thread `config` overrides are accepted but do not reapply this (no-ops)."
},
"model_context_window": {
"description": "Size of the context window for the model, in tokens.",
+87
View File
@@ -0,0 +1,87 @@
Ash
Elm
Yew
Fir
Oak
Pine
Spruce
Cedar
Birch
Maple
Beech
Alder
Willow
Poplar
Aspen
Larch
Juniper
Cypress
Hemlock
Sequoia
Baobab
Acacia
Banyan
Teak
Olive
Cherry
Apple
Pear
Plum
Peach
Mango
Guava
Fig
Date
Palm
Holly
Ivy
Fern
Moss
Reed
Cane
Rush
Sage
Mint
Thyme
Basil
Dill
Fennel
Parsley
Laurel
Myrtle
Lotus
Tulip
Rose
Daisy
Lily
Iris
Poppy
Aster
Peony
Camellia
Azalea
Begonia
Petunia
Verbena
Marigold
Daffodil
Orchid
Violet
Clover
Bamboo
Eucalypt
Oleander
Yucca
Agave
Aloe
Cactus
Sedum
Boxwood
Hawthorn
Redwood
Ironwood
Sandal
Kapok
Mahonia
Spirea
Wisteria
@@ -1,2 +0,0 @@
model = "gpt-5.1-codex-mini"
model_reasoning_effort = "medium"
+273 -18
View File
@@ -3,7 +3,9 @@ use crate::agent::guards::Guards;
use crate::agent::status::is_final;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::find_thread_path_by_id_str;
use crate::session_prefix::format_subagent_notification_message;
use crate::state_db;
use crate::thread_manager::ThreadManagerState;
use codex_protocol::ThreadId;
use codex_protocol::protocol::Op;
@@ -11,11 +13,20 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Weak;
use tokio::sync::watch;
const AGENT_NAMES: &str = include_str!("agent_names.txt");
fn agent_nickname_list() -> Vec<&'static str> {
AGENT_NAMES
.lines()
.map(str::trim)
.filter(|name| !name.is_empty())
.collect()
}
/// Control-plane handle for multi-agent operations.
/// `AgentControl` is held by each session (via `SessionServices`). It provides capability to
/// spawn new agents and the inter-agent communication layer.
@@ -48,7 +59,24 @@ impl AgentControl {
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let session_source = match session_source {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_role,
..
})) => {
let agent_nickname = reservation.reserve_agent_nickname(&agent_nickname_list())?;
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: Some(agent_nickname),
agent_role,
}))
}
other => other,
};
let notification_source = session_source.clone();
// The same `AgentControl` is sent to spawn the thread.
@@ -77,12 +105,51 @@ impl AgentControl {
pub(crate) async fn resume_agent_from_rollout(
&self,
config: crate::config::Config,
rollout_path: PathBuf,
thread_id: ThreadId,
session_source: SessionSource,
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
let session_source = match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
..
}) => {
// Collab resume callers rebuild a placeholder ThreadSpawn source. Rehydrate the
// stored nickname/role from sqlite when available; otherwise leave both unset.
let (resumed_agent_nickname, resumed_agent_role) =
if let Some(state_db_ctx) = state_db::get_state_db(&config, None).await {
match state_db_ctx.get_thread(thread_id).await {
Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role),
Ok(None) | Err(_) => (None, None),
}
} else {
(None, None)
};
let reserved_agent_nickname = resumed_agent_nickname
.as_deref()
.map(|agent_nickname| {
reservation.reserve_agent_nickname_with_preference(
&agent_nickname_list(),
Some(agent_nickname),
)
})
.transpose()?;
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_nickname: reserved_agent_nickname,
agent_role: resumed_agent_role,
})
}
other => other,
};
let notification_source = session_source.clone();
let rollout_path =
find_thread_path_by_id_str(config.codex_home.as_path(), &thread_id.to_string())
.await?
.ok_or_else(|| CodexErr::ThreadNotFound(thread_id))?;
let resumed_thread = state
.resume_thread_from_rollout_with_source(
@@ -151,6 +218,23 @@ impl AgentControl {
thread.agent_status().await
}
pub(crate) async fn get_agent_nickname_and_role(
&self,
agent_id: ThreadId,
) -> Option<(Option<String>, Option<String>)> {
let Ok(state) = self.upgrade() else {
return None;
};
let Ok(thread) = state.get_thread(agent_id).await else {
return None;
};
let session_source = thread.config_snapshot().await.session_source;
Some((
session_source.get_nickname(),
session_source.get_agent_role(),
))
}
/// Subscribe to status updates for `agent_id`, yielding the latest value and changes.
pub(crate) async fn subscribe_status(
&self,
@@ -234,6 +318,8 @@ mod tests {
use crate::agent::agent_status_from_event;
use crate::config::Config;
use crate::config::ConfigBuilder;
use crate::config_loader::LoaderOverrides;
use crate::features::Feature;
use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG;
use assert_matches::assert_matches;
use codex_protocol::config_types::ModeKind;
@@ -261,6 +347,12 @@ mod tests {
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.cli_overrides(cli_overrides)
.loader_overrides(LoaderOverrides {
#[cfg(target_os = "macos")]
managed_preferences_base64: Some(String::new()),
macos_managed_config_requirements_base64: Some(String::new()),
..LoaderOverrides::default()
})
.build()
.await
.expect("load default test config");
@@ -441,11 +533,7 @@ mod tests {
let control = AgentControl::default();
let (_home, config) = test_config().await;
let err = control
.resume_agent_from_rollout(
config,
PathBuf::from("/tmp/missing-rollout.jsonl"),
SessionSource::Exec,
)
.resume_agent_from_rollout(config, ThreadId::new(), SessionSource::Exec)
.await
.expect_err("resume_agent should fail without a manager");
assert_eq!(
@@ -717,12 +805,6 @@ mod tests {
.spawn_agent(config.clone(), text_input("hello"), None)
.await
.expect("spawn_agent should succeed");
let rollout_path = manager
.get_thread(resumable_id)
.await
.expect("thread should exist")
.rollout_path()
.expect("rollout path should exist");
let _ = control
.shutdown_agent(resumable_id)
.await
@@ -734,7 +816,7 @@ mod tests {
.expect("spawn_agent should succeed for active slot");
let err = control
.resume_agent_from_rollout(config, rollout_path, SessionSource::Exec)
.resume_agent_from_rollout(config, resumable_id, SessionSource::Exec)
.await
.expect_err("resume should respect max threads");
let CodexErr::AgentLimitReached {
@@ -766,9 +848,8 @@ mod tests {
);
let control = manager.agent_control();
let missing_rollout = config.codex_home.join("sessions/missing-rollout.jsonl");
let _ = control
.resume_agent_from_rollout(config.clone(), missing_rollout, SessionSource::Exec)
.resume_agent_from_rollout(config.clone(), ThreadId::new(), SessionSource::Exec)
.await
.expect_err("resume should fail for missing rollout path");
@@ -795,6 +876,8 @@ mod tests {
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
@@ -812,4 +895,176 @@ mod tests {
assert_eq!(wait_for_subagent_notification(&parent_thread).await, true);
}
#[tokio::test]
async fn spawn_thread_subagent_gets_random_nickname_in_session_source() {
let harness = AgentControlHarness::new().await;
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should be registered");
let snapshot = child_thread.config_snapshot().await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: seen_parent_thread_id,
depth,
agent_nickname,
agent_role,
}) = snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(seen_parent_thread_id, parent_thread_id);
assert_eq!(depth, 1);
assert!(agent_nickname.is_some());
assert_eq!(agent_role, Some("explorer".to_string()));
}
#[tokio::test]
async fn resume_thread_subagent_restores_stored_nickname_and_role() {
let (home, mut config) = test_config().await;
config.features.enable(Feature::Sqlite);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.clone(),
);
let control = manager.agent_control();
let harness = AgentControlHarness {
_home: home,
config,
manager,
control,
};
let (parent_thread_id, _parent_thread) = harness.start_thread().await;
let child_thread_id = harness
.control
.spawn_agent(
harness.config.clone(),
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: Some("explorer".to_string()),
})),
)
.await
.expect("child spawn should succeed");
let child_thread = harness
.manager
.get_thread(child_thread_id)
.await
.expect("child thread should exist");
let mut status_rx = harness
.control
.subscribe_status(child_thread_id)
.await
.expect("status subscription should succeed");
if matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) {
timeout(Duration::from_secs(5), async {
loop {
status_rx
.changed()
.await
.expect("child status should advance past pending init");
if !matches!(status_rx.borrow().clone(), AgentStatus::PendingInit) {
break;
}
}
})
.await
.expect("child should initialize before shutdown");
}
let original_snapshot = child_thread.config_snapshot().await;
let original_nickname = original_snapshot
.session_source
.get_nickname()
.expect("spawned sub-agent should have a nickname");
let state_db = child_thread
.state_db()
.expect("sqlite state db should be available for nickname resume test");
timeout(Duration::from_secs(5), async {
loop {
if let Ok(Some(metadata)) = state_db.get_thread(child_thread_id).await
&& metadata.agent_nickname.is_some()
&& metadata.agent_role.as_deref() == Some("explorer")
{
break;
}
sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("child thread metadata should be persisted to sqlite before shutdown");
let _ = harness
.control
.shutdown_agent(child_thread_id)
.await
.expect("child shutdown should submit");
let resumed_thread_id = harness
.control
.resume_agent_from_rollout(
harness.config.clone(),
child_thread_id,
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_nickname: None,
agent_role: None,
}),
)
.await
.expect("resume should succeed");
assert_eq!(resumed_thread_id, child_thread_id);
let resumed_snapshot = harness
.manager
.get_thread(resumed_thread_id)
.await
.expect("resumed child thread should exist")
.config_snapshot()
.await;
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: resumed_parent_thread_id,
depth: resumed_depth,
agent_nickname: resumed_nickname,
agent_role: resumed_role,
}) = resumed_snapshot.session_source
else {
panic!("expected thread-spawn sub-agent source");
};
assert_eq!(resumed_parent_thread_id, parent_thread_id);
assert_eq!(resumed_depth, 1);
assert_eq!(resumed_nickname, Some(original_nickname));
assert_eq!(resumed_role, Some("explorer".to_string()));
let _ = harness
.control
.shutdown_agent(resumed_thread_id)
.await
.expect("resumed child shutdown should submit");
}
}
+172 -10
View File
@@ -3,6 +3,8 @@ use crate::error::Result;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use rand::prelude::IndexedRandom;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use std::sync::Mutex;
@@ -17,10 +19,18 @@ use std::sync::atomic::Ordering;
/// is).
#[derive(Default)]
pub(crate) struct Guards {
threads_set: Mutex<HashSet<ThreadId>>,
active_agents: Mutex<ActiveAgents>,
total_count: AtomicUsize,
}
#[derive(Default)]
struct ActiveAgents {
threads_set: HashSet<ThreadId>,
thread_agent_nicknames: HashMap<ThreadId, String>,
used_agent_nicknames: HashSet<String>,
nickname_reset_count: usize,
}
fn session_depth(session_source: &SessionSource) -> i32 {
match session_source {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth,
@@ -52,28 +62,69 @@ impl Guards {
Ok(SpawnReservation {
state: Arc::clone(self),
active: true,
reserved_agent_nickname: None,
})
}
pub(crate) fn release_spawned_thread(&self, thread_id: ThreadId) {
let removed = {
let mut threads = self
.threads_set
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
threads.remove(&thread_id)
let removed = active_agents.threads_set.remove(&thread_id);
active_agents.thread_agent_nicknames.remove(&thread_id);
removed
};
if removed {
self.total_count.fetch_sub(1, Ordering::AcqRel);
}
}
fn register_spawned_thread(&self, thread_id: ThreadId) {
let mut threads = self
.threads_set
fn register_spawned_thread(&self, thread_id: ThreadId, agent_nickname: Option<String>) {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
threads.insert(thread_id);
active_agents.threads_set.insert(thread_id);
if let Some(agent_nickname) = agent_nickname {
active_agents
.used_agent_nicknames
.insert(agent_nickname.clone());
active_agents
.thread_agent_nicknames
.insert(thread_id, agent_nickname);
}
}
fn reserve_agent_nickname(&self, names: &[&str], preferred: Option<&str>) -> Option<String> {
let mut active_agents = self
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let agent_nickname = if let Some(preferred) = preferred {
preferred.to_string()
} else {
if names.is_empty() {
return None;
}
let available_names: Vec<&str> = names
.iter()
.copied()
.filter(|name| !active_agents.used_agent_nicknames.contains(*name))
.collect();
if let Some(name) = available_names.choose(&mut rand::rng()) {
(*name).to_string()
} else {
active_agents.used_agent_nicknames.clear();
active_agents.nickname_reset_count += 1;
names.choose(&mut rand::rng())?.to_string()
}
};
active_agents
.used_agent_nicknames
.insert(agent_nickname.clone());
Some(agent_nickname)
}
fn try_increment_spawned(&self, max_threads: usize) -> bool {
@@ -98,11 +149,41 @@ impl Guards {
pub(crate) struct SpawnReservation {
state: Arc<Guards>,
active: bool,
reserved_agent_nickname: Option<String>,
}
impl SpawnReservation {
pub(crate) fn commit(mut self, thread_id: ThreadId) {
self.state.register_spawned_thread(thread_id);
pub(crate) fn reserve_agent_nickname(&mut self, names: &[&str]) -> Result<String> {
self.reserve_agent_nickname_with_preference(names, None)
}
pub(crate) fn reserve_agent_nickname_with_preference(
&mut self,
names: &[&str],
preferred: Option<&str>,
) -> Result<String> {
let agent_nickname = self
.state
.reserve_agent_nickname(names, preferred)
.ok_or_else(|| {
CodexErr::UnsupportedOperation("no available agent nicknames".to_string())
})?;
self.reserved_agent_nickname = Some(agent_nickname.clone());
Ok(agent_nickname)
}
pub(crate) fn commit(self, thread_id: ThreadId) {
self.commit_with_agent_nickname(thread_id, None);
}
pub(crate) fn commit_with_agent_nickname(
mut self,
thread_id: ThreadId,
agent_nickname: Option<String>,
) {
let agent_nickname = self.reserved_agent_nickname.take().or(agent_nickname);
self.state
.register_spawned_thread(thread_id, agent_nickname);
self.active = false;
}
}
@@ -130,6 +211,8 @@ mod tests {
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: ThreadId::new(),
depth: 1,
agent_nickname: None,
agent_role: None,
});
let child_depth = next_thread_spawn_depth(&session_source);
assert_eq!(child_depth, 2);
@@ -232,4 +315,83 @@ mod tests {
.expect("slot released after second thread removal");
drop(reservation);
}
#[test]
fn failed_spawn_keeps_nickname_marked_used() {
let guards = Arc::new(Guards::default());
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha"])
.expect("reserve agent name");
assert_eq!(agent_nickname, "alpha");
drop(reservation);
let mut reservation = guards.reserve_spawn_slot(None).expect("reserve slot");
let agent_nickname = reservation
.reserve_agent_nickname(&["alpha", "beta"])
.expect("unused name should still be preferred");
assert_eq!(agent_nickname, "beta");
}
#[test]
fn agent_nickname_resets_used_pool_when_exhausted() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha"])
.expect("name should be reused after pool reset");
assert_eq!(second_name, "alpha");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
#[test]
fn released_nickname_stays_used_until_pool_reset() {
let guards = Arc::new(Guards::default());
let mut first = guards.reserve_spawn_slot(None).expect("reserve first slot");
let first_name = first
.reserve_agent_nickname(&["alpha"])
.expect("reserve first agent name");
let first_id = ThreadId::new();
first.commit(first_id);
assert_eq!(first_name, "alpha");
guards.release_spawned_thread(first_id);
let mut second = guards
.reserve_spawn_slot(None)
.expect("reserve second slot");
let second_name = second
.reserve_agent_nickname(&["alpha", "beta"])
.expect("released name should still be marked used");
assert_eq!(second_name, "beta");
let second_id = ThreadId::new();
second.commit(second_id);
guards.release_spawned_thread(second_id);
let mut third = guards.reserve_spawn_slot(None).expect("reserve third slot");
let third_name = third
.reserve_agent_nickname(&["alpha", "beta"])
.expect("pool reset should permit a duplicate");
assert!(third_name == "alpha" || third_name == "beta");
let active_agents = guards
.active_agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert_eq!(active_agents.nickname_reset_count, 1);
}
}
+3 -3
View File
@@ -162,11 +162,10 @@ mod built_in {
(
"explorer".to_string(),
AgentRoleConfig {
description: Some(r#"Use `explorer` for all codebase questions.
description: Some(r#"Use `explorer` for specific codebase questions.
Explorers are fast and authoritative.
Always prefer them over manual search or file reading.
They must be used to ask specific, well-scoped questions on the codebase.
Rules:
- Ask explorers first and precisely.
- Do not re-read or re-search code they cover.
- Trust explorer results without verification.
- Run explorers in parallel when useful.
@@ -268,6 +267,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "No role requiring it for now"]
async fn apply_explorer_role_sets_model_and_adds_session_flags_layer() {
let (_home, mut config) = test_config_with_cli_overrides(Vec::new()).await;
let before_layers = session_flags_layer_count(&config);
+28 -1
View File
@@ -28,6 +28,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -123,6 +124,7 @@ struct ModelClientState {
include_timing_metrics: bool,
beta_features_header: Option<String>,
disable_websockets: AtomicBool,
cached_websocket_connection: StdMutex<Option<ApiWebSocketConnection>>,
}
/// Resolved API client setup for a single request attempt.
@@ -228,6 +230,7 @@ impl ModelClient {
include_timing_metrics,
beta_features_header,
disable_websockets: AtomicBool::new(false),
cached_websocket_connection: StdMutex::new(None),
}),
}
}
@@ -239,13 +242,29 @@ impl ModelClient {
pub fn new_session(&self) -> ModelClientSession {
ModelClientSession {
client: self.clone(),
connection: None,
connection: self.take_cached_websocket_connection(),
websocket_last_request: None,
websocket_last_response_rx: None,
turn_state: Arc::new(OnceLock::new()),
}
}
fn take_cached_websocket_connection(&self) -> Option<ApiWebSocketConnection> {
self.state
.cached_websocket_connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take()
}
fn store_cached_websocket_connection(&self, connection: ApiWebSocketConnection) {
*self
.state
.cached_websocket_connection
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(connection);
}
/// Compacts the current conversation history using the Compact endpoint.
///
/// This is a unary call (no streaming) that returns a new list of
@@ -452,6 +471,14 @@ impl ModelClient {
}
}
impl Drop for ModelClientSession {
fn drop(&mut self) {
if let Some(connection) = self.connection.take() {
self.client.store_cached_websocket_connection(connection);
}
}
}
impl ModelClientSession {
fn activate_http_fallback(&self, websocket_enabled: bool) -> bool {
websocket_enabled
+17 -12
View File
@@ -149,6 +149,7 @@ use crate::mcp::effective_mcp_servers;
use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only;
use crate::mcp_connection_manager::filter_mcp_tools_by_name;
use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
@@ -608,6 +609,7 @@ impl TurnContext {
features: &features,
web_search_mode: self.tools_config.web_search_mode,
})
.with_allow_login_shell(self.tools_config.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
Self {
@@ -940,6 +942,7 @@ impl Session {
features: &per_turn_config.features,
web_search_mode: Some(per_turn_config.web_search_mode.value()),
})
.with_allow_login_shell(per_turn_config.permissions.allow_login_shell)
.with_agent_roles(per_turn_config.agent_roles.clone());
let cwd = session_configuration.cwd.clone();
@@ -1420,6 +1423,8 @@ impl Session {
&session_configuration.approval_policy,
tx_event.clone(),
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
)
.await;
{
@@ -3068,6 +3073,8 @@ impl Session {
&turn_context.config.permissions.approval_policy,
self.get_tx_event(),
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
)
.await;
{
@@ -4117,6 +4124,7 @@ async fn spawn_review_thread(
features: &review_features,
web_search_mode: Some(review_web_search_mode),
})
.with_allow_login_shell(config.permissions.allow_login_shell)
.with_agent_roles(config.agent_roles.clone());
let review_prompt = resolved.prompt.clone();
@@ -4826,6 +4834,7 @@ fn connector_inserted_in_messages(
fn filter_codex_apps_mcp_tools(
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
connectors: &[connectors::AppInfo],
config: &Config,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
@@ -4841,7 +4850,7 @@ fn filter_codex_apps_mcp_tools(
let Some(connector_id) = codex_apps_connector_id(tool) else {
return false;
};
allowed.contains(connector_id)
allowed.contains(connector_id) && connectors::codex_app_tool_is_enabled(config, tool)
})
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
@@ -5015,9 +5024,9 @@ async fn built_tools(
None
};
let app_tools = connectors
.as_ref()
.map(|connectors| filter_codex_apps_mcp_tools(&mcp_tools, connectors));
let app_tools = connectors.as_ref().map(|connectors| {
filter_codex_apps_mcp_tools(&mcp_tools, connectors, &turn_context.config)
});
if let Some(connectors) = connectors.as_ref() {
let skill_name_counts_lower = skills_outcome.map_or_else(HashMap::new, |outcome| {
@@ -5042,7 +5051,8 @@ async fn built_tools(
explicitly_enabled.as_ref(),
));
mcp_tools = selected_mcp_tools;
mcp_tools =
connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config);
}
Ok(Arc::new(ToolRouter::from_config(
@@ -5993,7 +6003,7 @@ mod tests {
let config_toml_path = codex_home.join(CONFIG_TOML_FILE);
std::fs::write(
&config_toml_path,
"[apps.calendar]\nenabled = false\ndisabled_reason = \"user\"\n",
"[apps.calendar]\nenabled = false\ndestructive_enabled = false\n",
)
.expect("write user config");
@@ -6015,10 +6025,7 @@ mod tests {
.expect("calendar app config exists");
assert!(!app.enabled);
assert_eq!(
app.disabled_reason,
Some(crate::config::types::AppDisabledReason::User)
);
assert_eq!(app.destructive_enabled, Some(false));
}
#[test]
@@ -8342,7 +8349,6 @@ mod tests {
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
sandbox_permissions,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: Some("test".to_string()),
@@ -8356,7 +8362,6 @@ mod tests {
expiration: timeout_ms.into(),
env: HashMap::new(),
network: None,
network_attempt_id: None,
windows_sandbox_level: turn_context.windows_sandbox_level,
justification: params.justification.clone(),
arg0: None,
+49 -2
View File
@@ -139,6 +139,15 @@ pub struct Permissions {
pub sandbox_policy: Constrained<SandboxPolicy>,
/// Effective network configuration applied to all spawned processes.
pub network: Option<NetworkProxySpec>,
/// Whether the model may request a login shell for shell-based tools.
/// Default to `true`
///
/// If `true`, the model may request a login shell (`login = true`), and
/// omitting `login` defaults to using a login shell.
/// If `false`, the model can never use a login shell: `login = true`
/// requests are rejected, and omitting `login` defaults to a non-login
/// shell.
pub allow_login_shell: bool,
/// Policy used to build process environments for shell/unified exec.
pub shell_environment_policy: ShellEnvironmentPolicy,
/// Effective Windows sandbox mode derived from `[windows].sandbox` or
@@ -952,6 +961,16 @@ pub struct ConfigToml {
#[serde(default)]
pub shell_environment_policy: ShellEnvironmentPolicyToml,
/// Whether the model may request a login shell for shell-based tools.
/// Default to `true`
///
/// If `true`, the model may request a login shell (`login = true`), and
/// omitting `login` defaults to using a login shell.
/// If `false`, the model can never use a login shell: `login = true`
/// requests are rejected, and omitting `login` defaults to a non-login
/// shell.
pub allow_login_shell: Option<bool>,
/// Sandbox mode to use.
pub sandbox_mode: Option<SandboxMode>,
@@ -1090,8 +1109,8 @@ pub struct ConfigToml {
/// Override to force-enable reasoning summaries for the configured model.
pub model_supports_reasoning_summaries: Option<bool>,
/// Optional path to a JSON file containing a complete model catalog.
/// When set, this replaces the bundled catalog for this process.
/// Optional path to a JSON model catalog (applied on startup only).
/// Per-thread `config` overrides are accepted but do not reapply this (no-ops).
pub model_catalog_json: Option<AbsolutePathBuf>,
/// Optionally specify a personality for the model
@@ -1710,6 +1729,7 @@ impl Config {
.clone();
let shell_environment_policy = cfg.shell_environment_policy.into();
let allow_login_shell = cfg.allow_login_shell.unwrap_or(true);
let history = cfg.history.unwrap_or_default();
@@ -1950,6 +1970,7 @@ impl Config {
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
network,
allow_login_shell,
shell_environment_policy,
windows_sandbox_mode,
macos_seatbelt_profile_extensions: None,
@@ -2401,6 +2422,7 @@ allowed_domains = ["openai.com"]
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_non_loopback_admin: None,
dangerously_allow_all_unix_sockets: None,
mode: None,
allowed_domains: Some(vec!["openai.com".to_string()]),
denied_domains: None,
@@ -4518,6 +4540,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::Never),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@@ -4636,6 +4659,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@@ -4752,6 +4776,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@@ -4854,6 +4879,7 @@ model_verbosity = "high"
approval_policy: Constrained::allow_any(AskForApproval::OnFailure),
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
network: None,
allow_login_shell: true,
shell_environment_policy: ShellEnvironmentPolicy::default(),
windows_sandbox_mode: None,
macos_seatbelt_profile_extensions: None,
@@ -5428,6 +5454,27 @@ mcp_oauth_callback_port = 5678
Ok(())
}
#[test]
fn config_loads_allow_login_shell_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg: ConfigToml = toml::from_str(
r#"
model = "gpt-5.1"
allow_login_shell = false
"#,
)
.expect("TOML deserialization should succeed for allow_login_shell");
let config = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
codex_home.path().to_path_buf(),
)?;
assert!(!config.permissions.allow_login_shell);
Ok(())
}
#[test]
fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -178,6 +178,13 @@ impl NetworkProxySpec {
constraints.dangerously_allow_non_loopback_admin =
Some(dangerously_allow_non_loopback_admin);
}
if let Some(dangerously_allow_all_unix_sockets) =
requirements.dangerously_allow_all_unix_sockets
{
config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets;
constraints.dangerously_allow_all_unix_sockets =
Some(dangerously_allow_all_unix_sockets);
}
if let Some(allowed_domains) = requirements.allowed_domains.clone() {
config.network.allowed_domains = allowed_domains.clone();
constraints.allowed_domains = Some(allowed_domains);
+4
View File
@@ -24,6 +24,7 @@ pub struct NetworkToml {
pub allow_upstream_proxy: Option<bool>,
pub dangerously_allow_non_loopback_proxy: Option<bool>,
pub dangerously_allow_non_loopback_admin: Option<bool>,
pub dangerously_allow_all_unix_sockets: Option<bool>,
#[schemars(with = "Option<NetworkModeSchema>")]
pub mode: Option<NetworkMode>,
pub allowed_domains: Option<Vec<String>>,
@@ -74,6 +75,9 @@ impl NetworkToml {
config.network.dangerously_allow_non_loopback_admin =
dangerously_allow_non_loopback_admin;
}
if let Some(dangerously_allow_all_unix_sockets) = self.dangerously_allow_all_unix_sockets {
config.network.dangerously_allow_all_unix_sockets = dangerously_allow_all_unix_sockets;
}
if let Some(mode) = self.mode {
config.network.mode = mode;
}
+10 -5
View File
@@ -701,7 +701,7 @@ mod tests {
use super::*;
use anyhow::Result;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppDisabledReason;
use codex_app_server_protocol::AppToolApproval;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -827,13 +827,13 @@ personality = true
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "apps.app1.disabled_reason".to_string(),
value: serde_json::json!("user"),
key_path: "apps.app1.default_tools_approval_mode".to_string(),
value: serde_json::json!("prompt"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write apps.app1.disabled_reason succeeds");
.expect("write apps.app1.default_tools_approval_mode succeeds");
let read = service
.read(ConfigReadParams {
@@ -846,11 +846,16 @@ personality = true
assert_eq!(
read.config.apps,
Some(AppsConfig {
default: None,
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
disabled_reason: Some(AppDisabledReason::User),
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: None,
},
)]),
})
+71 -13
View File
@@ -425,20 +425,58 @@ impl From<MemoriesToml> for MemoriesConfig {
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppDisabledReason {
Unknown,
User,
pub enum AppToolApproval {
#[default]
Auto,
Prompt,
Approve,
}
impl fmt::Display for AppDisabledReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppDisabledReason::Unknown => write!(f, "unknown"),
AppDisabledReason::User => write!(f, "user"),
}
}
/// Default settings that apply to all apps.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppsDefaultConfig {
/// When `false`, apps are disabled unless overridden by per-app settings.
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Whether tools with `destructive_hint = true` are allowed by default.
#[serde(
default = "default_enabled",
skip_serializing_if = "std::clone::Clone::clone"
)]
pub destructive_enabled: bool,
/// Whether tools with `open_world_hint = true` are allowed by default.
#[serde(
default = "default_enabled",
skip_serializing_if = "std::clone::Clone::clone"
)]
pub open_world_enabled: bool,
}
/// Per-tool settings for a single app tool.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppToolConfig {
/// Whether this tool is enabled. `Some(true)` explicitly allows this tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
/// Approval mode for this tool.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_mode: Option<AppToolApproval>,
}
/// Tool settings for a single app.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppToolsConfig {
/// Per-tool overrides keyed by tool name (for example `repos/list`).
#[serde(default, flatten)]
pub tools: HashMap<String, AppToolConfig>,
}
/// Config values for a single app/connector.
@@ -449,15 +487,35 @@ pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Reason this app was disabled.
/// Whether tools with `destructive_hint = true` are allowed for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<AppDisabledReason>,
pub destructive_enabled: Option<bool>,
/// Whether tools with `open_world_hint = true` are allowed for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub open_world_enabled: Option<bool>,
/// Approval mode for tools in this app unless a tool override exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_tools_approval_mode: Option<AppToolApproval>,
/// Whether tools are enabled by default for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_tools_enabled: Option<bool>,
/// Per-tool settings for this app.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<AppToolsConfig>,
}
/// App/connector settings loaded from `config.toml`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppsConfigToml {
/// Default settings for all apps.
#[serde(default, rename = "_default", skip_serializing_if = "Option::is_none")]
pub default: Option<AppsDefaultConfig>,
/// Per-app settings keyed by app ID (for example `[apps.google_drive]`).
#[serde(default, flatten)]
pub apps: HashMap<String, AppConfig>,
+661 -19
View File
@@ -11,6 +11,7 @@ pub use codex_app_server_protocol::AppBranding;
pub use codex_app_server_protocol::AppInfo;
pub use codex_app_server_protocol::AppMetadata;
use codex_protocol::protocol::SandboxPolicy;
use rmcp::model::ToolAnnotations;
use serde::Deserialize;
use tracing::warn;
@@ -18,16 +19,35 @@ use crate::AuthManager;
use crate::CodexAuth;
use crate::SandboxState;
use crate::config::Config;
use crate::config::types::AppToolApproval;
use crate::config::types::AppsConfigToml;
use crate::default_client::is_first_party_chat_originator;
use crate::default_client::originator;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::token_data::TokenData;
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
pub enabled: bool,
pub approval: AppToolApproval,
}
impl Default for AppToolPolicy {
fn default() -> Self {
Self {
enabled: true,
approval: AppToolApproval::Auto,
}
}
}
#[derive(Clone, PartialEq, Eq)]
struct AccessibleConnectorsCacheKey {
@@ -47,10 +67,20 @@ struct CachedAccessibleConnectors {
static ACCESSIBLE_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAccessibleConnectors>>> =
LazyLock::new(|| StdMutex::new(None));
#[derive(Debug, Clone)]
pub struct AccessibleConnectorsStatus {
pub connectors: Vec<AppInfo>,
pub codex_apps_ready: bool,
}
pub async fn list_accessible_connectors_from_mcp_tools(
config: &Config,
) -> anyhow::Result<Vec<AppInfo>> {
list_accessible_connectors_from_mcp_tools_with_options(config, false).await
Ok(
list_accessible_connectors_from_mcp_tools_with_options_and_status(config, false)
.await?
.connectors,
)
}
pub async fn list_cached_accessible_connectors_from_mcp_tools(
@@ -63,15 +93,29 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
let auth_manager = auth_manager_from_config(config);
let auth = auth_manager.auth().await;
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
read_cached_accessible_connectors(&cache_key)
read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors)
}
pub async fn list_accessible_connectors_from_mcp_tools_with_options(
config: &Config,
force_refetch: bool,
) -> anyhow::Result<Vec<AppInfo>> {
Ok(
list_accessible_connectors_from_mcp_tools_with_options_and_status(config, force_refetch)
.await?
.connectors,
)
}
pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
config: &Config,
force_refetch: bool,
) -> anyhow::Result<AccessibleConnectorsStatus> {
if !config.features.enabled(Feature::Apps) {
return Ok(Vec::new());
return Ok(AccessibleConnectorsStatus {
connectors: Vec::new(),
codex_apps_ready: true,
});
}
let auth_manager = auth_manager_from_config(config);
@@ -79,12 +123,19 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options(
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
{
return Ok(cached_connectors);
let cached_connectors = filter_disallowed_connectors(cached_connectors);
return Ok(AccessibleConnectorsStatus {
connectors: cached_connectors,
codex_apps_ready: true,
});
}
let mcp_servers = with_codex_apps_mcp(HashMap::new(), true, auth.as_ref(), config);
if mcp_servers.is_empty() {
return Ok(Vec::new());
return Ok(AccessibleConnectorsStatus {
connectors: Vec::new(),
codex_apps_ready: true,
});
}
let auth_status_entries =
@@ -107,6 +158,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options(
&config.permissions.approval_policy,
tx_event,
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
)
.await;
@@ -120,23 +173,45 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options(
);
}
let mut tools = mcp_connection_manager.list_all_tools().await;
let mut should_reload_tools = false;
let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
let timeout = cfg.startup_timeout_sec.unwrap_or(DEFAULT_STARTUP_TIMEOUT);
mcp_connection_manager
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
.await
let immediate_ready = mcp_connection_manager
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
.await;
if immediate_ready {
true
} else if tools.is_empty() {
let timeout = cfg
.startup_timeout_sec
.unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS);
let ready = mcp_connection_manager
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout)
.await;
should_reload_tools = ready;
ready
} else {
false
}
} else {
false
};
if should_reload_tools {
tools = mcp_connection_manager.list_all_tools().await;
}
if codex_apps_ready {
cancel_token.cancel();
}
let tools = mcp_connection_manager.list_all_tools().await;
cancel_token.cancel();
let accessible_connectors = accessible_connectors_from_mcp_tools(&tools);
let accessible_connectors =
filter_disallowed_connectors(accessible_connectors_from_mcp_tools(&tools));
if codex_apps_ready || !accessible_connectors.is_empty() {
write_cached_accessible_connectors(cache_key, &accessible_connectors);
}
Ok(accessible_connectors)
Ok(AccessibleConnectorsStatus {
connectors: accessible_connectors,
codex_apps_ready,
})
}
fn accessible_connectors_cache_key(
@@ -279,21 +354,185 @@ pub fn merge_connectors(
}
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
let apps = read_apps_config(config).map(|apps_config| apps_config.apps);
for connector in &mut connectors {
if let Some(app) = apps.as_ref().and_then(|apps| apps.get(&connector.id)) {
connector.is_enabled = app.enabled;
let apps_config = read_apps_config(config);
if let Some(apps_config) = apps_config.as_ref() {
for connector in &mut connectors {
connector.is_enabled = app_is_enabled(apps_config, Some(connector.id.as_str()));
}
}
connectors
}
pub(crate) fn app_tool_policy(
config: &Config,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let apps_config = read_apps_config(config);
app_tool_policy_from_apps_config(
apps_config.as_ref(),
connector_id,
tool_name,
tool_title,
annotations,
)
}
pub(crate) fn codex_app_tool_is_enabled(
config: &Config,
tool_info: &crate::mcp_connection_manager::ToolInfo,
) -> bool {
if tool_info.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
}
app_tool_policy(
config,
tool_info.connector_id.as_deref(),
&tool_info.tool_name,
tool_info.tool.title.as_deref(),
tool_info.tool.annotations.as_ref(),
)
.enabled
}
pub(crate) fn filter_codex_apps_tools_by_policy(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
config: &Config,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info));
mcp_tools
}
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",
];
const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] =
&["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"];
const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_";
pub fn filter_disallowed_connectors(connectors: Vec<AppInfo>) -> Vec<AppInfo> {
filter_disallowed_connectors_for_originator(connectors, originator().value.as_str())
}
pub(crate) fn is_connector_id_allowed(connector_id: &str) -> bool {
is_connector_id_allowed_for_originator(connector_id, originator().value.as_str())
}
fn filter_disallowed_connectors_for_originator(
connectors: Vec<AppInfo>,
originator_value: &str,
) -> Vec<AppInfo> {
connectors
.into_iter()
.filter(|connector| {
is_connector_id_allowed_for_originator(connector.id.as_str(), originator_value)
})
.collect()
}
fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool {
let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) {
FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS
} else {
DISALLOWED_CONNECTOR_IDS
};
!connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX)
&& !disallowed_connector_ids.contains(&connector_id)
}
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
let effective_config = config.config_layer_stack.effective_config();
let apps_config = effective_config.as_table()?.get("apps")?.clone();
AppsConfigToml::deserialize(apps_config).ok()
}
fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool {
let default_enabled = apps_config
.default
.as_ref()
.map(|defaults| defaults.enabled)
.unwrap_or(true);
connector_id
.and_then(|connector_id| apps_config.apps.get(connector_id))
.map(|app| app.enabled)
.unwrap_or(default_enabled)
}
fn app_tool_policy_from_apps_config(
apps_config: Option<&AppsConfigToml>,
connector_id: Option<&str>,
tool_name: &str,
tool_title: Option<&str>,
annotations: Option<&ToolAnnotations>,
) -> AppToolPolicy {
let Some(apps_config) = apps_config else {
return AppToolPolicy::default();
};
let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id));
let tools = app.and_then(|app| app.tools.as_ref());
let tool_config = tools.and_then(|tools| {
tools
.tools
.get(tool_name)
.or_else(|| tool_title.and_then(|title| tools.tools.get(title)))
});
let approval = tool_config
.and_then(|tool| tool.approval_mode)
.or_else(|| app.and_then(|app| app.default_tools_approval_mode))
.unwrap_or(AppToolApproval::Auto);
if !app_is_enabled(apps_config, connector_id) {
return AppToolPolicy {
enabled: false,
approval,
};
}
if let Some(enabled) = tool_config.and_then(|tool| tool.enabled) {
return AppToolPolicy { enabled, approval };
}
if let Some(enabled) = app.and_then(|app| app.default_tools_enabled) {
return AppToolPolicy { enabled, approval };
}
let app_defaults = apps_config.default.as_ref();
let destructive_enabled = app
.and_then(|app| app.destructive_enabled)
.unwrap_or_else(|| {
app_defaults
.map(|defaults| defaults.destructive_enabled)
.unwrap_or(true)
});
let open_world_enabled = app
.and_then(|app| app.open_world_enabled)
.unwrap_or_else(|| {
app_defaults
.map(|defaults| defaults.open_world_enabled)
.unwrap_or(true)
});
let destructive_hint = annotations
.and_then(|annotations| annotations.destructive_hint)
.unwrap_or(false);
let open_world_hint = annotations
.and_then(|annotations| annotations.open_world_hint)
.unwrap_or(false);
let enabled =
(destructive_enabled || !destructive_hint) && (open_world_enabled || !open_world_hint);
AppToolPolicy { enabled, approval }
}
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
where
I: IntoIterator<Item = (String, Option<String>)>,
@@ -368,3 +607,406 @@ pub fn connector_name_slug(name: &str) -> String {
fn format_connector_label(name: &str, _id: &str) -> String {
name.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::types::AppConfig;
use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsDefaultConfig;
use pretty_assertions::assert_eq;
fn annotations(
destructive_hint: Option<bool>,
open_world_hint: Option<bool>,
) -> ToolAnnotations {
ToolAnnotations {
destructive_hint,
idempotent_hint: None,
open_world_hint,
read_only_hint: None,
title: None,
}
}
fn app(id: &str) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
branding: None,
app_metadata: None,
labels: None,
is_accessible: false,
is_enabled: true,
}
}
#[test]
fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
let apps_config = AppsConfigToml {
default: Some(AppsDefaultConfig {
enabled: true,
destructive_enabled: false,
open_world_enabled: true,
}),
apps: HashMap::new(),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/create",
None,
Some(&annotations(Some(true), None)),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: false,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_is_enabled_uses_default_for_unconfigured_apps() {
let apps_config = AppsConfigToml {
default: Some(AppsDefaultConfig {
enabled: false,
destructive_enabled: true,
open_world_enabled: true,
}),
apps: HashMap::new(),
};
assert!(!app_is_enabled(&apps_config, Some("calendar")));
assert!(!app_is_enabled(&apps_config, None));
}
#[test]
fn app_is_enabled_prefers_per_app_override_over_default() {
let apps_config = AppsConfigToml {
default: Some(AppsDefaultConfig {
enabled: false,
destructive_enabled: true,
open_world_enabled: true,
}),
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: None,
},
)]),
};
assert!(app_is_enabled(&apps_config, Some("calendar")));
assert!(!app_is_enabled(&apps_config, Some("drive")));
}
#[test]
fn app_tool_policy_honors_default_app_enabled_false() {
let apps_config = AppsConfigToml {
default: Some(AppsDefaultConfig {
enabled: false,
destructive_enabled: true,
open_world_enabled: true,
}),
apps: HashMap::new(),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: false,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() {
let apps_config = AppsConfigToml {
default: Some(AppsDefaultConfig {
enabled: false,
destructive_enabled: true,
open_world_enabled: true,
}),
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: None,
},
)]),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() {
let apps_config = AppsConfigToml {
default: None,
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: Some(false),
open_world_enabled: Some(false),
default_tools_approval_mode: None,
default_tools_enabled: None,
tools: Some(AppToolsConfig {
tools: HashMap::from([(
"events/create".to_string(),
AppToolConfig {
enabled: Some(true),
approval_mode: None,
},
)]),
}),
},
)]),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/create",
None,
Some(&annotations(Some(true), Some(true))),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() {
let apps_config = AppsConfigToml {
default: None,
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: Some(false),
open_world_enabled: Some(false),
default_tools_approval_mode: None,
default_tools_enabled: Some(true),
tools: None,
},
)]),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/create",
None,
Some(&annotations(Some(true), Some(true))),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Auto,
}
);
}
#[test]
fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() {
let apps_config = AppsConfigToml {
default: None,
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: Some(true),
open_world_enabled: Some(true),
default_tools_approval_mode: Some(AppToolApproval::Approve),
default_tools_enabled: Some(false),
tools: None,
},
)]),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: false,
approval: AppToolApproval::Approve,
}
);
}
#[test]
fn app_tool_policy_uses_default_tools_approval_mode() {
let apps_config = AppsConfigToml {
default: None,
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: None,
open_world_enabled: None,
default_tools_approval_mode: Some(AppToolApproval::Prompt),
default_tools_enabled: None,
tools: Some(AppToolsConfig {
tools: HashMap::new(),
}),
},
)]),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"events/list",
None,
Some(&annotations(None, None)),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Prompt,
}
);
}
#[test]
fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() {
let apps_config = AppsConfigToml {
default: None,
apps: HashMap::from([(
"calendar".to_string(),
AppConfig {
enabled: true,
destructive_enabled: Some(false),
open_world_enabled: Some(false),
default_tools_approval_mode: Some(AppToolApproval::Auto),
default_tools_enabled: Some(false),
tools: Some(AppToolsConfig {
tools: HashMap::from([(
"events/create".to_string(),
AppToolConfig {
enabled: Some(true),
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}),
},
)]),
};
let policy = app_tool_policy_from_apps_config(
Some(&apps_config),
Some("calendar"),
"calendar_events/create",
Some("events/create"),
Some(&annotations(Some(true), Some(true))),
);
assert_eq!(
policy,
AppToolPolicy {
enabled: true,
approval: AppToolApproval::Approve,
}
);
}
#[test]
fn filter_disallowed_connectors_allows_non_disallowed_connectors() {
let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]);
assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]);
}
#[test]
fn filter_disallowed_connectors_filters_openai_prefix() {
let filtered = filter_disallowed_connectors(vec![
app("connector_openai_foo"),
app("connector_openai_bar"),
app("gamma"),
]);
assert_eq!(filtered, vec![app("gamma")]);
}
#[test]
fn filter_disallowed_connectors_filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("delta"),
]);
assert_eq!(filtered, vec![app("delta")]);
}
#[test]
fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() {
let filtered = filter_disallowed_connectors_for_originator(
vec![
app("connector_openai_foo"),
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_0f9c9d4592e54d0a9a12b3f44a1e2010"),
],
"codex_atlas",
);
assert_eq!(
filtered,
vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")]
);
}
}
+3 -21
View File
@@ -15,7 +15,6 @@ use tokio::io::AsyncReadExt;
use tokio::io::BufReader;
use tokio::process::Child;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::error::CodexErr;
use crate::error::Result;
@@ -67,7 +66,6 @@ pub struct ExecParams {
pub expiration: ExecExpiration,
pub env: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub network_attempt_id: Option<Uuid>,
pub sandbox_permissions: SandboxPermissions,
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
pub justification: Option<String>,
@@ -186,15 +184,13 @@ pub async fn process_exec_tool_call(
mut env,
expiration,
network,
network_attempt_id,
sandbox_permissions,
windows_sandbox_level,
justification,
arg0: _,
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
let (program, args) = command.split_first().ok_or_else(|| {
CodexErr::Io(io::Error::new(
@@ -242,7 +238,6 @@ pub(crate) async fn execute_exec_env(
cwd,
env,
network,
network_attempt_id,
expiration,
sandbox,
windows_sandbox_level,
@@ -251,18 +246,12 @@ pub(crate) async fn execute_exec_env(
arg0,
} = env;
let network_attempt_id = match network_attempt_id.as_deref() {
Some(attempt_id) => Uuid::parse_str(attempt_id).ok(),
None => network.as_ref().map(|_| Uuid::new_v4()),
};
let params = ExecParams {
command,
cwd,
expiration,
env,
network: network.clone(),
network_attempt_id,
sandbox_permissions,
windows_sandbox_level,
justification,
@@ -356,14 +345,12 @@ async fn exec_windows_sandbox(
cwd,
mut env,
network,
network_attempt_id,
expiration,
windows_sandbox_level,
..
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
// TODO(iceweasel-oai): run_windows_sandbox_capture should support all
@@ -717,16 +704,13 @@ async fn exec(
cwd,
mut env,
network,
network_attempt_id,
arg0,
expiration,
windows_sandbox_level: _,
..
} = params;
let network_attempt_id = network_attempt_id.map(|attempt_id| attempt_id.to_string());
if let Some(network) = network.as_ref() {
network.apply_to_env_for_attempt(&mut env, network_attempt_id.as_deref());
network.apply_to_env(&mut env);
}
let (program, args) = command.split_first().ok_or_else(|| {
@@ -1137,7 +1121,6 @@ mod tests {
expiration: 500.into(),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
@@ -1191,7 +1174,6 @@ mod tests {
expiration: ExecExpiration::Cancellation(cancel_token),
env,
network: None,
network_attempt_id: None,
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
justification: None,
+4 -1
View File
@@ -24,13 +24,14 @@ use crate::features::Feature;
use crate::mcp::auth::compute_auth_statuses;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::SandboxState;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
const MCP_TOOL_NAME_PREFIX: &str = "mcp";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
pub(crate) const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
const CODEX_CONNECTORS_TOKEN_ENV_VAR: &str = "CODEX_CONNECTORS_TOKEN";
const OPENAI_CONNECTORS_MCP_BASE_URL: &str = "https://api.openai.com";
const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/mcp/";
const OPENAI_CONNECTORS_MCP_PATH: &str = "/v1/connectors/gateways/flat/mcp";
// Legacy vs new MCP gateway
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -208,6 +209,8 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
&config.permissions.approval_policy,
tx_event,
sandbox_state,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth.as_ref()),
)
.await;
File diff suppressed because it is too large Load Diff
+131 -39
View File
@@ -7,6 +7,8 @@ use crate::analytics_client::AppInvocation;
use crate::analytics_client::build_track_events_context;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::types::AppToolApproval;
use crate::connectors;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::protocol::EventMsg;
use crate::protocol::McpInvocation;
@@ -64,9 +66,51 @@ pub(crate) async fn handle_mcp_tool_call(
arguments: arguments_value.clone(),
};
if let Some(decision) =
maybe_request_mcp_tool_approval(sess.as_ref(), turn_context, &call_id, &server, &tool_name)
.await
let metadata = lookup_mcp_tool_metadata(sess.as_ref(), &server, &tool_name).await;
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
connectors::app_tool_policy(
&turn_context.config,
metadata
.as_ref()
.and_then(|metadata| metadata.connector_id.as_deref()),
&tool_name,
metadata
.as_ref()
.and_then(|metadata| metadata.tool_title.as_deref()),
metadata
.as_ref()
.and_then(|metadata| metadata.annotations.as_ref()),
)
} else {
connectors::AppToolPolicy::default()
};
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
let result = notify_mcp_tool_call_skip(
sess.as_ref(),
turn_context,
&call_id,
invocation,
"MCP tool call blocked by app configuration".to_string(),
)
.await;
let status = if result.is_ok() { "ok" } else { "error" };
turn_context
.otel_manager
.counter("codex.mcp.call", 1, &[("status", status)]);
return ResponseInputItem::McpToolCallOutput { call_id, result };
}
if let Some(decision) = maybe_request_mcp_tool_approval(
sess.as_ref(),
turn_context,
&call_id,
&server,
&tool_name,
metadata.as_ref(),
app_tool_policy.approval,
)
.await
{
let result = match decision {
McpToolApprovalDecision::Accept | McpToolApprovalDecision::AcceptAndRemember => {
@@ -267,7 +311,7 @@ enum McpToolApprovalDecision {
}
struct McpToolApprovalMetadata {
annotations: ToolAnnotations,
annotations: Option<ToolAnnotations>,
connector_id: Option<String>,
connector_name: Option<String>,
tool_title: Option<String>,
@@ -292,21 +336,39 @@ async fn maybe_request_mcp_tool_approval(
call_id: &str,
server: &str,
tool_name: &str,
metadata: Option<&McpToolApprovalMetadata>,
approval_mode: AppToolApproval,
) -> Option<McpToolApprovalDecision> {
if is_full_access_mode(turn_context) {
if approval_mode == AppToolApproval::Approve {
return None;
}
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
if approval_mode == AppToolApproval::Auto {
if is_full_access_mode(turn_context) {
return None;
}
if !annotations.is_some_and(requires_mcp_tool_approval) {
return None;
}
}
let metadata = lookup_mcp_tool_metadata(sess, server, tool_name).await?;
if !requires_mcp_tool_approval(&metadata.annotations) {
return None;
}
let approval_key = McpToolApprovalKey {
server: server.to_string(),
connector_id: metadata.connector_id.clone(),
tool_name: tool_name.to_string(),
let approval_key = if approval_mode == AppToolApproval::Auto {
let connector_id = metadata.and_then(|metadata| metadata.connector_id.clone());
if server == CODEX_APPS_MCP_SERVER_NAME && connector_id.is_none() {
None
} else {
Some(McpToolApprovalKey {
server: server.to_string(),
connector_id,
tool_name: tool_name.to_string(),
})
}
} else {
None
};
if mcp_tool_approval_is_remembered(sess, &approval_key).await {
if let Some(key) = approval_key.as_ref()
&& mcp_tool_approval_is_remembered(sess, key).await
{
return Some(McpToolApprovalDecision::Accept);
}
@@ -315,10 +377,10 @@ async fn maybe_request_mcp_tool_approval(
question_id.clone(),
server,
tool_name,
metadata.tool_title.as_deref(),
metadata.connector_name.as_deref(),
&metadata.annotations,
true,
metadata.and_then(|metadata| metadata.tool_title.as_deref()),
metadata.and_then(|metadata| metadata.connector_name.as_deref()),
annotations,
approval_key.is_some(),
);
let args = RequestUserInputArgs {
questions: vec![question],
@@ -326,9 +388,14 @@ async fn maybe_request_mcp_tool_approval(
let response = sess
.request_user_input(turn_context, call_id.to_string(), args)
.await;
let decision = parse_mcp_tool_approval_response(response, &question_id);
if matches!(decision, McpToolApprovalDecision::AcceptAndRemember) {
remember_mcp_tool_approval(sess, approval_key).await;
let decision = normalize_approval_decision_for_mode(
parse_mcp_tool_approval_response(response, &question_id),
approval_mode,
);
if matches!(decision, McpToolApprovalDecision::AcceptAndRemember)
&& let Some(key) = approval_key
{
remember_mcp_tool_approval(sess, key).await;
}
Some(decision)
}
@@ -356,15 +423,12 @@ async fn lookup_mcp_tool_metadata(
tools.into_values().find_map(|tool_info| {
if tool_info.server_name == server && tool_info.tool_name == tool_name {
tool_info
.tool
.annotations
.map(|annotations| McpToolApprovalMetadata {
annotations,
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
tool_title: tool_info.tool.title,
})
Some(McpToolApprovalMetadata {
annotations: tool_info.tool.annotations,
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
tool_title: tool_info.tool.title,
})
} else {
None
}
@@ -402,11 +466,12 @@ fn build_mcp_tool_approval_question(
tool_name: &str,
tool_title: Option<&str>,
connector_name: Option<&str>,
annotations: &ToolAnnotations,
annotations: Option<&ToolAnnotations>,
allow_remember_option: bool,
) -> RequestUserInputQuestion {
let destructive = annotations.destructive_hint == Some(true);
let open_world = annotations.open_world_hint == Some(true);
let destructive =
annotations.and_then(|annotations| annotations.destructive_hint) == Some(true);
let open_world = annotations.and_then(|annotations| annotations.open_world_hint) == Some(true);
let reason = match (destructive, open_world) {
(true, true) => "may modify data and access external systems",
(true, false) => "may modify or delete data",
@@ -493,6 +558,19 @@ fn parse_mcp_tool_approval_response(
}
}
fn normalize_approval_decision_for_mode(
decision: McpToolApprovalDecision,
approval_mode: AppToolApproval,
) -> McpToolApprovalDecision {
if approval_mode == AppToolApproval::Prompt
&& decision == McpToolApprovalDecision::AcceptAndRemember
{
McpToolApprovalDecision::Accept
} else {
decision
}
}
async fn mcp_tool_approval_is_remembered(sess: &Session, key: &McpToolApprovalKey) -> bool {
let store = sess.services.tool_approvals.lock().await;
matches!(store.get(key), Some(ReviewDecision::ApprovedForSession))
@@ -504,8 +582,11 @@ async fn remember_mcp_tool_approval(sess: &Session, key: McpToolApprovalKey) {
}
fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
annotations.read_only_hint == Some(false)
&& (annotations.destructive_hint == Some(true) || annotations.open_world_hint == Some(true))
if annotations.destructive_hint == Some(true) {
return true;
}
annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true)
}
async fn notify_mcp_tool_call_skip(
@@ -563,9 +644,20 @@ mod tests {
}
#[test]
fn approval_not_required_when_read_only_true() {
fn approval_required_when_destructive_even_if_read_only_true() {
let annotations = annotations(Some(true), Some(true), Some(true));
assert_eq!(requires_mcp_tool_approval(&annotations), false);
assert_eq!(requires_mcp_tool_approval(&annotations), true);
}
#[test]
fn prompt_mode_does_not_allow_session_remember() {
assert_eq!(
normalize_approval_decision_for_mode(
McpToolApprovalDecision::AcceptAndRemember,
AppToolApproval::Prompt,
),
McpToolApprovalDecision::Accept
);
}
#[test]
@@ -576,7 +668,7 @@ mod tests {
"run_action",
Some("Run Action"),
None,
&annotations(Some(false), Some(true), None),
Some(&annotations(Some(false), Some(true), None)),
true,
);
@@ -603,7 +695,7 @@ mod tests {
"run_action",
Some("Run Action"),
None,
&annotations(Some(false), Some(true), None),
Some(&annotations(Some(false), Some(true), None)),
true,
);
+6
View File
@@ -13,6 +13,8 @@ mod storage;
mod tests;
pub(crate) mod usage;
use codex_protocol::openai_models::ReasoningEffort;
/// Starts the memory startup pipeline for eligible root sessions.
/// This is the single entrypoint that `codex` uses to trigger memory startup.
///
@@ -28,6 +30,8 @@ mod artifacts {
mod phase_one {
/// Default model used for phase 1.
pub(super) const MODEL: &str = "gpt-5.1-codex-mini";
/// Default reasoning effort used for phase 1.
pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Low;
/// Prompt used for phase 1.
pub(super) const PROMPT: &str = include_str!("../../templates/memories/stage_one_system.md");
/// Concurrency cap for startup memory extraction and consolidation scheduling.
@@ -56,6 +60,8 @@ mod phase_one {
mod phase_two {
/// Default model used for phase 2.
pub(super) const MODEL: &str = "gpt-5.3-codex";
/// Default reasoning effort used for phase 2.
pub(super) const REASONING_EFFORT: super::ReasoningEffort = super::ReasoningEffort::Medium;
/// Lease duration (seconds) for phase-2 consolidation job ownership.
pub(super) const JOB_LEASE_SECONDS: i64 = 3_600;
/// Backoff delay (seconds) before retrying a failed phase-2 consolidation
+2 -2
View File
@@ -20,7 +20,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::TokenUsage;
use codex_utils_sanitizer::redact_secrets;
use codex_secrets::redact_secrets;
use futures::StreamExt;
use serde::Deserialize;
use serde_json::Value;
@@ -142,7 +142,7 @@ impl RequestContext {
model_info,
turn_metadata_header,
otel_manager: turn_context.otel_manager.clone(),
reasoning_effort: turn_context.reasoning_effort,
reasoning_effort: Some(phase_one::REASONING_EFFORT),
reasoning_summary: turn_context.reasoning_summary,
}
}
+4
View File
@@ -2,6 +2,7 @@ use crate::agent::AgentStatus;
use crate::agent::status::is_final as is_final_agent_status;
use crate::codex::Session;
use crate::config::Config;
use crate::features::Feature;
use crate::memories::memory_root;
use crate::memories::metrics;
use crate::memories::phase_two;
@@ -227,6 +228,8 @@ mod agent {
agent_config.cwd = root;
// Approval policy
agent_config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
// Consolidation runs as an internal sub-agent and must not recursively delegate.
agent_config.features.disable(Feature::Collab);
// Sandbox policy
let mut writable_roots = Vec::new();
@@ -258,6 +261,7 @@ mod agent {
.clone()
.unwrap_or(phase_two::MODEL.to_string()),
);
agent_config.model_reasoning_effort = Some(phase_two::REASONING_EFFORT);
Some(agent_config)
}
+4 -92
View File
@@ -82,11 +82,11 @@ async fn rebuild_raw_memories_file(
)
.map_err(raw_memories_format_error)?;
writeln!(body, "cwd: {}", memory.cwd.display()).map_err(raw_memories_format_error)?;
writeln!(body).map_err(raw_memories_format_error)?;
let rollout_summary_file = format!("{}.md", rollout_summary_file_stem(memory));
let raw_memory =
replace_rollout_summary_file_in_raw_memory(&memory.raw_memory, &rollout_summary_file);
body.push_str(raw_memory.trim());
writeln!(body, "rollout_summary_file: {rollout_summary_file}")
.map_err(raw_memories_format_error)?;
writeln!(body).map_err(raw_memories_format_error)?;
body.push_str(memory.raw_memory.trim());
body.push_str("\n\n");
}
@@ -161,26 +161,6 @@ fn rollout_summary_format_error(err: std::fmt::Error) -> std::io::Error {
std::io::Error::other(format!("format rollout summary: {err}"))
}
fn replace_rollout_summary_file_in_raw_memory(
raw_memory: &str,
rollout_summary_file: &str,
) -> String {
const ROLLOUT_SUMMARY_PREFIX: &str = "rollout_summary_file: ";
let replacement = format!("rollout_summary_file: {rollout_summary_file}");
raw_memory
.split('\n')
.map(|line| {
if line.starts_with(ROLLOUT_SUMMARY_PREFIX) {
replacement.as_str()
} else {
line
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn rollout_summary_file_stem(memory: &Stage1Output) -> String {
rollout_summary_file_stem_from_parts(
memory.thread_id,
@@ -270,7 +250,6 @@ pub(super) fn rollout_summary_file_stem_from_parts(
#[cfg(test)]
mod tests {
use super::replace_rollout_summary_file_in_raw_memory;
use super::rollout_summary_file_stem;
use super::rollout_summary_file_stem_from_parts;
use chrono::TimeZone;
@@ -339,71 +318,4 @@ mod tests {
assert_eq!(rollout_summary_file_stem(&memory), FIXED_PREFIX);
}
#[test]
fn replace_rollout_summary_file_in_raw_memory_replaces_existing_value() {
let raw_memory = "\
---
rollout_summary_file: wrong.md
description: demo
keywords: one, two
---
- body line";
let normalized = replace_rollout_summary_file_in_raw_memory(
raw_memory,
"2025-01-01T00-00-00-abcd-demo.md",
);
assert_eq!(
normalized,
"\
---
rollout_summary_file: 2025-01-01T00-00-00-abcd-demo.md
description: demo
keywords: one, two
---
- body line"
);
}
#[test]
fn replace_rollout_summary_file_in_raw_memory_replaces_placeholder() {
let raw_memory = "\
---
rollout_summary_file: <system_populated_file.md>
description: demo
keywords: one, two
---
- body line";
let normalized = replace_rollout_summary_file_in_raw_memory(
raw_memory,
"2025-01-01T00-00-00-abcd-demo.md",
);
assert_eq!(
normalized,
"\
---
rollout_summary_file: 2025-01-01T00-00-00-abcd-demo.md
description: demo
keywords: one, two
---
- body line"
);
}
#[test]
fn replace_rollout_summary_file_in_raw_memory_leaves_text_without_field_unchanged() {
let raw_memory = "\
---
description: demo
keywords: one, two
---
- body line";
let normalized = replace_rollout_summary_file_in_raw_memory(
raw_memory,
"2025-01-01T00-00-00-abcd-demo.md",
);
assert_eq!(normalized, raw_memory);
}
}

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