diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 724995e9c9..ded05aec1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: codex-npm-staging path: ${{ steps.stage_npm_package.outputs.pack_output }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 13fcaacbbd..dd2b76290e 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -166,7 +166,7 @@ jobs: # avoid caching the large target dir on the gnu-dev job. - name: Restore cargo home cache id: cache_cargo_home_restore - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | ~/.cargo/bin/ @@ -207,7 +207,7 @@ jobs: - name: Restore sccache cache (fallback) if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} id: cache_sccache_restore - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -226,7 +226,7 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Restore APT cache (musl) id: cache_apt_restore - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | /var/cache/apt @@ -280,7 +280,7 @@ jobs: - name: Save cargo home cache if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | ~/.cargo/bin/ @@ -292,7 +292,7 @@ jobs: - name: Save sccache cache (fallback) if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -317,7 +317,7 @@ jobs: - name: Save APT cache (musl) if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | /var/cache/apt @@ -427,7 +427,7 @@ jobs: - name: Restore cargo home cache id: cache_cargo_home_restore - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | ~/.cargo/bin/ @@ -467,7 +467,7 @@ jobs: - name: Restore sccache cache (fallback) if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} id: cache_sccache_restore - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -490,7 +490,7 @@ jobs: - name: Save cargo home cache if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | ~/.cargo/bin/ @@ -502,7 +502,7 @@ jobs: - name: Save sccache cache (fallback) if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1656b80218..8ed338cc22 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -84,7 +84,7 @@ jobs: with: targets: ${{ matrix.target }} - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: | ~/.cargo/bin/ @@ -306,6 +306,8 @@ jobs: if [[ "${{ matrix.runner }}" == windows* ]]; then cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" else cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" @@ -401,7 +403,7 @@ jobs: fi fi - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: ${{ matrix.target }} # Upload the per-binary .zst files as well as the new .tar.gz @@ -437,7 +439,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v7 with: path: dist diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 63ce308d10..b124dacacc 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -113,7 +113,7 @@ jobs: cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/" cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/" - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: shell-tool-mcp-rust-${{ matrix.target }} path: artifacts/** @@ -211,7 +211,7 @@ jobs: mkdir -p "$dest" cp bash "$dest/bash" - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} path: artifacts/** @@ -253,7 +253,7 @@ jobs: mkdir -p "$dest" cp bash "$dest/bash" - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }} path: artifacts/** @@ -291,7 +291,7 @@ jobs: run: pnpm --filter @openai/codex-shell-tool-mcp run build - name: Download build artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts @@ -352,7 +352,7 @@ jobs: filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);') mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz" - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: codex-shell-tool-mcp-npm path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz @@ -386,7 +386,7 @@ jobs: run: npm install -g npm@latest - name: Download npm tarball - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: codex-shell-tool-mcp-npm path: dist/npm diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 43faba4425..dbb195a85d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -12,6 +12,154 @@ dependencies = [ "regex", ] +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "bitflags 2.10.0", + "bytes", + "bytestring", + "derive_more 2.0.1", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "mime", + "percent-encoding", + "pin-project-lite", + "smallvec", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +dependencies = [ + "actix-codec", + "actix-http", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "bytes", + "bytestring", + "cfg-if", + "derive_more 2.0.1", + "encoding_rs", + "foldhash 0.1.5", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.1", + "time", + "tracing", + "url", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -455,7 +603,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", + "http 1.3.1", "http-body", "http-body-util", "hyper", @@ -483,7 +631,7 @@ checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" dependencies = [ "bytes", "futures-core", - "http", + "http 1.3.1", "http-body", "http-body-util", "mime", @@ -515,6 +663,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + [[package]] name = "beef" version = "0.5.2" @@ -620,6 +774,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -804,7 +967,7 @@ dependencies = [ "codex-protocol", "eventsource-stream", "futures", - "http", + "http 1.3.1", "pretty_assertions", "regex-lite", "reqwest", @@ -1013,7 +1176,7 @@ dependencies = [ "bytes", "eventsource-stream", "futures", - "http", + "http 1.3.1", "opentelemetry", "opentelemetry_sdk", "rand 0.9.2", @@ -1125,7 +1288,7 @@ dependencies = [ "escargot", "eventsource-stream", "futures", - "http", + "http 1.3.1", "image", "indexmap 2.12.0", "keyring", @@ -1226,7 +1389,7 @@ dependencies = [ "serde", "serde_json", "shlex", - "socket2 0.6.0", + "socket2 0.6.1", "tempfile", "tokio", "tokio-util", @@ -1423,7 +1586,7 @@ dependencies = [ "codex-protocol", "codex-utils-absolute-path", "eventsource-stream", - "http", + "http 1.3.1", "opentelemetry", "opentelemetry-appender-tracing", "opentelemetry-otlp", @@ -1685,7 +1848,7 @@ dependencies = [ name = "codex-utils-cache" version = "0.0.0" dependencies = [ - "lru", + "lru 0.16.2", "sha1", "tokio", ] @@ -2159,6 +2322,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.4" @@ -2699,6 +2872,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2929,7 +3108,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.3.1", "indexmap 2.12.0", "slab", "tokio", @@ -2971,7 +3150,7 @@ checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2979,6 +3158,11 @@ name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "heck" @@ -3036,6 +3220,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.3.1" @@ -3054,7 +3249,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.3.1", ] [[package]] @@ -3065,7 +3260,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.3.1", "http-body", "pin-project-lite", ] @@ -3093,7 +3288,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.3.1", "http-body", "httparse", "httpdate", @@ -3111,7 +3306,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.3.1", "hyper", "hyper-util", "rustls", @@ -3163,14 +3358,14 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", + "http 1.3.1", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3387,6 +3582,12 @@ dependencies = [ "zune-jpeg 0.5.5", ] +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + [[package]] name = "indenter" version = "0.3.3" @@ -3704,6 +3905,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3763,6 +3970,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.13" @@ -3811,6 +4024,15 @@ dependencies = [ "hashbrown 0.15.4", ] +[[package]] +name = "lru" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96051b46fc183dc9cd4a223960ef37b9af631b55191852a8274bfef064cda20f" +dependencies = [ + "hashbrown 0.16.0", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4208,7 +4430,7 @@ dependencies = [ "base64", "chrono", "getrandom 0.2.16", - "http", + "http 1.3.1", "rand 0.8.5", "reqwest", "serde", @@ -4401,7 +4623,7 @@ checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" dependencies = [ "async-trait", "bytes", - "http", + "http 1.3.1", "opentelemetry", "reqwest", ] @@ -4412,7 +4634,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" dependencies = [ - "http", + "http 1.3.1", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", @@ -4578,6 +4800,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -4920,7 +5151,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.0", + "socket2 0.5.10", "thiserror 2.0.17", "tokio", "tracing", @@ -4957,7 +5188,7 @@ dependencies = [ "cfg_aliases 0.2.1", "libc", "once_cell", - "socket2 0.6.0", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -5058,7 +5289,7 @@ dependencies = [ "indoc", "instability", "itertools 0.13.0", - "lru", + "lru 0.12.5", "paste", "strum 0.26.3", "unicode-segmentation", @@ -5180,7 +5411,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", + "http 1.3.1", "http-body", "http-body-util", "hyper", @@ -5241,7 +5472,7 @@ dependencies = [ "bytes", "chrono", "futures", - "http", + "http 1.3.1", "http-body", "http-body-util", "oauth2", @@ -5622,13 +5853,14 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "sentry" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +checksum = "d9794f69ad475e76c057e326175d3088509649e3aed98473106b9fe94ba59424" dependencies = [ "httpdate", "native-tls", "reqwest", + "sentry-actix", "sentry-backtrace", "sentry-contexts", "sentry-core", @@ -5640,22 +5872,34 @@ dependencies = [ ] [[package]] -name = "sentry-backtrace" -version = "0.34.0" +name = "sentry-actix" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +checksum = "e0fee202934063ace4f1d1d063113b8982293762628e563a2d2fba08fb20b110" +dependencies = [ + "actix-http", + "actix-web", + "bytes", + "futures-util", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e81137ad53b8592bd0935459ad74c0376053c40084aa170451e74eeea8dbc6c3" dependencies = [ "backtrace", - "once_cell", "regex", "sentry-core", ] [[package]] name = "sentry-contexts" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +checksum = "cfb403c66cc2651a01b9bacda2e7c22cd51f7e8f56f206aa4310147eb3259282" dependencies = [ "hostname", "libc", @@ -5667,33 +5911,32 @@ dependencies = [ [[package]] name = "sentry-core" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +checksum = "cfc409727ae90765ca8ea76fe6c949d6f159a11d02e130b357fa652ee9efcada" dependencies = [ - "once_cell", - "rand 0.8.5", + "rand 0.9.2", "sentry-types", "serde", "serde_json", + "url", ] [[package]] name = "sentry-debug-images" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +checksum = "06a2778a222fd90ebb01027c341a72f8e24b0c604c6126504a4fe34e5500e646" dependencies = [ "findshlibs", - "once_cell", "sentry-core", ] [[package]] name = "sentry-panic" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +checksum = "3df79f4e1e72b2a8b75a0ebf49e78709ceb9b3f0b451f13adc92a0361b0aaabe" dependencies = [ "sentry-backtrace", "sentry-core", @@ -5701,10 +5944,11 @@ dependencies = [ [[package]] name = "sentry-tracing" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +checksum = "ff2046f527fd4b75e0b6ab3bd656c67dce42072f828dc4d03c206d15dca74a93" dependencies = [ + "bitflags 2.10.0", "sentry-backtrace", "sentry-core", "tracing-core", @@ -5713,16 +5957,16 @@ dependencies = [ [[package]] name = "sentry-types" -version = "0.34.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +checksum = "c7b9b4e4c03a4d3643c18c78b8aa91d2cbee5da047d2fa0ca4bb29bc67e6c55c" dependencies = [ "debugid", "hex", - "rand 0.8.5", + "rand 0.9.2", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.17", "time", "url", "uuid", @@ -6049,12 +6293,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6620,7 +6864,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2 0.6.0", + "socket2 0.6.1", "tokio-macros", "windows-sys 0.59.0", ] @@ -6767,7 +7011,7 @@ dependencies = [ "base64", "bytes", "h2", - "http", + "http 1.3.1", "http-body", "http-body-util", "hyper", @@ -6815,7 +7059,7 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http", + "http 1.3.1", "http-body", "iri-string", "pin-project-lite", @@ -7136,15 +7380,31 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "ureq" -version = "2.12.1" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a" dependencies = [ "base64", + "der", "log", "native-tls", - "once_cell", - "url", + "percent-encoding", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http 1.3.1", + "httparse", + "log", ] [[package]] @@ -7165,6 +7425,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -7462,6 +7728,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.2" @@ -8048,7 +8323,7 @@ dependencies = [ "base64", "deadpool", "futures", - "http", + "http 1.3.1", "http-body-util", "hyper", "hyper-util", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9b8c2b192d..929f74d070 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -149,7 +149,7 @@ landlock = "0.4.1" lazy_static = "1" libc = "0.2.177" log = "0.4" -lru = "0.12.5" +lru = "0.16.2" maplit = "1.0.2" mime_guess = "2.0.5" multimap = "0.10.0" @@ -180,7 +180,7 @@ reqwest = "0.12" rmcp = { version = "0.10.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" -sentry = "0.34.0" +sentry = "0.46.0" serde = "1" serde_json = "1" serde_with = "3.16" @@ -190,7 +190,7 @@ sha1 = "0.10.6" sha2 = "0.10" shlex = "1.3.0" similar = "2.7.0" -socket2 = "0.6.0" +socket2 = "0.6.1" starlark = "0.13.0" strum = "0.27.2" strum_macros = "0.27.2" diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 4de66dfb37..a60c1be624 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -31,6 +31,7 @@ use std::process::Command; use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; +const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; #[derive(Clone)] pub struct GeneratedSchema { @@ -184,7 +185,6 @@ fn build_schema_bundle(schemas: Vec) -> Result { "ServerNotification", "ServerRequest", ]; - const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; let namespaced_types = collect_namespaced_types(&schemas); let mut definitions = Map::new(); @@ -304,8 +304,11 @@ where out_dir.join(format!("{file_stem}.json")) }; - write_pretty_json(out_path, &schema_value) - .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; + if !IGNORED_DEFINITIONS.contains(&logical_name) { + write_pretty_json(out_path, &schema_value) + .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; + } + let namespace = match raw_namespace { Some("v1") | None => None, Some(ns) => Some(ns.to_string()), diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 116a3c62dd..6e5f469306 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -121,6 +121,10 @@ client_request_definitions! { params: v2::ThreadCompactParams, response: v2::ThreadCompactResponse, }, + SkillsList => "skills/list" { + params: v2::SkillsListParams, + response: v2::SkillsListResponse, + }, TurnStart => "turn/start" { params: v2::TurnStartParams, response: v2::TurnStartResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index a0772a17b8..23dceae13c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -21,6 +21,9 @@ use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo; +use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; +use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::user_input::UserInput as CoreUserInput; @@ -967,6 +970,87 @@ pub struct ThreadCompactParams { #[ts(export_to = "v2/")] pub struct ThreadCompactResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillScope { + User, + Repo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMetadata { + pub name: String, + pub description: String, + pub path: PathBuf, + pub scope: SkillScope, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, + pub errors: Vec, +} + +impl From for SkillMetadata { + fn from(value: CoreSkillMetadata) -> Self { + Self { + name: value.name, + description: value.description, + path: value.path, + scope: value.scope.into(), + } + } +} + +impl From for SkillScope { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::User, + CoreSkillScope::Repo => Self::Repo, + } + } +} + +impl From for SkillErrorInfo { + fn from(value: CoreSkillErrorInfo) -> Self { + Self { + path: value.path, + message: value.message, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e9dee71271..80e882adfc 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -65,6 +65,7 @@ Example (from OpenAI's official VSCode extension): - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). - `model/list` — list available models (with reasoning effort options). +- `skills/list` — list skills for one or more `cwd` values. - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `mcpServers/list` — enumerate configured MCP servers with their tools, resources, resource templates, and auth status; supports cursor+limit pagination. - `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 53fabd22e4..627b84b096 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -81,6 +81,8 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; @@ -373,6 +375,9 @@ impl CodexMessageProcessor { self.send_unimplemented_error(request_id, "thread/compact") .await; } + ClientRequest::SkillsList { request_id, params } => { + self.skills_list(request_id, params).await; + } ClientRequest::TurnStart { request_id, params } => { self.turn_start(request_id, params).await; } @@ -1265,7 +1270,7 @@ impl CodexMessageProcessor { let mut cli_overrides = cli_overrides.unwrap_or_default(); if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { cli_overrides.insert( - "features.enable_experimental_windows_sandbox".to_string(), + "features.experimental_windows_sandbox".to_string(), serde_json::json!(true), ); } @@ -2182,7 +2187,7 @@ impl CodexMessageProcessor { let mut cli_overrides = cli_overrides.unwrap_or_default(); if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { cli_overrides.insert( - "features.enable_experimental_windows_sandbox".to_string(), + "features.experimental_windows_sandbox".to_string(), serde_json::json!(true), ); } @@ -2615,6 +2620,42 @@ impl CodexMessageProcessor { .await; } + async fn skills_list(&self, request_id: RequestId, params: SkillsListParams) { + let SkillsListParams { cwds } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.clone()] + } else { + cwds + }; + + let data = if self.config.features.enabled(Feature::Skills) { + let skills_manager = self.conversation_manager.skills_manager(); + cwds.into_iter() + .map(|cwd| { + let outcome = skills_manager.skills_for_cwd(&cwd); + let errors = errors_to_info(&outcome.errors); + let skills = skills_to_info(&outcome.skills); + codex_app_server_protocol::SkillsListEntry { + cwd, + skills, + errors, + } + }) + .collect() + } else { + cwds.into_iter() + .map(|cwd| codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: Vec::new(), + }) + .collect() + }; + self.outgoing + .send_response(request_id, SkillsListResponse { data }) + .await; + } + async fn interrupt_conversation( &mut self, request_id: RequestId, @@ -3260,6 +3301,32 @@ impl CodexMessageProcessor { } } +fn skills_to_info( + skills: &[codex_core::skills::SkillMetadata], +) -> Vec { + skills + .iter() + .map(|skill| codex_app_server_protocol::SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope.into(), + }) + .collect() +} + +fn errors_to_info( + errors: &[codex_core::skills::SkillError], +) -> Vec { + errors + .iter() + .map(|err| codex_app_server_protocol::SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() +} + async fn derive_config_from_params( overrides: ConfigOverrides, cli_overrides: Option>, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 20943982d4..933c80548a 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -64,6 +64,7 @@ const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; +#[cfg(any(test, feature = "test-support"))] static TEST_AUTH_TEMP_DIRS: Lazy>> = Lazy::new(|| Mutex::new(Vec::new())); #[derive(Debug, Error)] @@ -1111,6 +1112,18 @@ impl AuthManager { }) } + #[cfg(any(test, feature = "test-support"))] + /// Create an AuthManager with a specific CodexAuth and codex home, for testing only. + pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc { + let cached = CachedAuth { auth: Some(auth) }; + Arc::new(Self { + codex_home, + inner: RwLock::new(cached), + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + }) + } + /// Current cached auth (clone). May be `None` if not logged in or load failed. pub fn auth(&self) -> Option { self.inner.read().ok().and_then(|c| c.auth.clone()) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5d06f43e9d..1d5e2c95ef 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -77,6 +77,7 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::compact::collect_user_messages; use crate::config::Config; +use crate::config::GhostSnapshotConfig; use crate::config::types::ShellEnvironmentPolicy; use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; @@ -109,8 +110,7 @@ use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::SkillErrorInfo; -use crate::protocol::SkillInfo; -use crate::protocol::SkillLoadOutcomeInfo; +use crate::protocol::SkillMetadata as ProtocolSkillMetadata; use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TokenCountEvent; @@ -123,10 +123,11 @@ use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; use crate::shell; use crate::shell_snapshot::ShellSnapshot; +use crate::skills::SkillError; use crate::skills::SkillInjections; -use crate::skills::SkillLoadOutcome; +use crate::skills::SkillMetadata; +use crate::skills::SkillsManager; use crate::skills::build_skill_injections; -use crate::skills::load_skills; use crate::state::ActiveTurn; use crate::state::SessionServices; use crate::state::SessionState; @@ -210,6 +211,7 @@ impl Codex { config: Config, auth_manager: Arc, models_manager: Arc, + skills_manager: Arc, conversation_history: InitialHistory, session_source: SessionSource, ) -> CodexResult { @@ -217,7 +219,7 @@ impl Codex { let (tx_event, rx_event) = async_channel::unbounded(); let loaded_skills = if config.features.enabled(Feature::Skills) { - Some(load_skills(&config)) + Some(skills_manager.skills_for_cwd(&config.cwd)) } else { None }; @@ -232,11 +234,9 @@ impl Codex { } } - let skills_outcome = loaded_skills.clone(); - let user_instructions = get_user_instructions( &config, - skills_outcome + loaded_skills .as_ref() .map(|outcome| outcome.skills.as_slice()), ) @@ -289,7 +289,7 @@ impl Codex { tx_event.clone(), conversation_history, session_source_clone, - skills_outcome.clone(), + skills_manager, ) .await .map_err(|e| { @@ -386,6 +386,7 @@ pub(crate) struct TurnContext { pub(crate) sandbox_policy: SandboxPolicy, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) tools_config: ToolsConfig, + pub(crate) ghost_snapshot: GhostSnapshotConfig, pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, @@ -549,6 +550,7 @@ impl Session { sandbox_policy: session_configuration.sandbox_policy.clone(), shell_environment_policy: per_turn_config.shell_environment_policy.clone(), tools_config, + ghost_snapshot: per_turn_config.ghost_snapshot.clone(), final_output_json_schema: None, codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), @@ -569,7 +571,7 @@ impl Session { tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, - skills: Option, + skills_manager: Arc, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -689,7 +691,7 @@ impl Session { otel_manager, models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), - skills: skills.clone(), + skills_manager, }; let sess = Arc::new(Session { @@ -705,8 +707,6 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - let skill_load_outcome = skill_load_outcome_for_client(skills.as_ref()); - let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { @@ -720,7 +720,6 @@ impl Session { history_log_id, history_entry_count, initial_messages, - skill_load_outcome, rollout_path, }), }) @@ -1631,6 +1630,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::ListCustomPrompts => { handlers::list_custom_prompts(&sess, sub.id.clone()).await; } + Op::ListSkills { cwds } => { + handlers::list_skills(&sess, sub.id.clone(), cwds).await; + } Op::Undo => { handlers::undo(&sess, sub.id.clone()).await; } @@ -1675,6 +1677,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::features::Feature; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; @@ -1688,9 +1691,11 @@ mod handlers { use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ListCustomPromptsResponseEvent; + use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; + use codex_protocol::protocol::SkillsListEntry; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; @@ -1698,6 +1703,7 @@ mod handlers { use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use mcp_types::RequestId; + use std::path::PathBuf; use std::sync::Arc; use tracing::info; use tracing::warn; @@ -1925,6 +1931,43 @@ mod handlers { sess.send_event_raw(event).await; } + pub async fn list_skills(sess: &Session, sub_id: String, cwds: Vec) { + let cwds = if cwds.is_empty() { + let state = sess.state.lock().await; + vec![state.session_configuration.cwd.clone()] + } else { + cwds + }; + let skills = if sess.enabled(Feature::Skills) { + let skills_manager = &sess.services.skills_manager; + cwds.into_iter() + .map(|cwd| { + let outcome = skills_manager.skills_for_cwd(&cwd); + let errors = super::errors_to_info(&outcome.errors); + let skills = super::skills_to_info(&outcome.skills); + SkillsListEntry { + cwd, + skills, + errors, + } + }) + .collect() + } else { + cwds.into_iter() + .map(|cwd| SkillsListEntry { + cwd, + skills: Vec::new(), + errors: Vec::new(), + }) + .collect() + }; + let event = Event { + id: sub_id, + msg: EventMsg::ListSkillsResponse(ListSkillsResponseEvent { skills }), + }; + sess.send_event_raw(event).await; + } + pub async fn undo(sess: &Arc, sub_id: String) { let turn_context = sess .new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default()) @@ -2076,6 +2119,7 @@ async fn spawn_review_thread( sub_id: sub_id.to_string(), client, tools_config, + ghost_snapshot: parent_turn_context.ghost_snapshot.clone(), developer_instructions: None, user_instructions: None, base_instructions: Some(base_instructions.clone()), @@ -2107,28 +2151,26 @@ async fn spawn_review_thread( .await; } -fn skill_load_outcome_for_client( - outcome: Option<&SkillLoadOutcome>, -) -> Option { - outcome.map(|outcome| SkillLoadOutcomeInfo { - skills: outcome - .skills - .iter() - .map(|skill| SkillInfo { - name: skill.name.clone(), - description: skill.description.clone(), - path: skill.path.clone(), - }) - .collect(), - errors: outcome - .errors - .iter() - .map(|err| SkillErrorInfo { - path: err.path.clone(), - message: err.message.clone(), - }) - .collect(), - }) +fn skills_to_info(skills: &[SkillMetadata]) -> Vec { + skills + .iter() + .map(|skill| ProtocolSkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope, + }) + .collect() +} + +fn errors_to_info(errors: &[SkillError]) -> Vec { + errors + .iter() + .map(|err| SkillErrorInfo { + path: err.path.clone(), + message: err.message.clone(), + }) + .collect() } /// Takes a user message as input and runs a loop where, at each turn, the model @@ -2159,10 +2201,20 @@ pub(crate) async fn run_task( }); sess.send_event(&turn_context, event).await; + let skills_outcome = if sess.enabled(Feature::Skills) { + Some( + sess.services + .skills_manager + .skills_for_cwd(&turn_context.cwd), + ) + } else { + None + }; + let SkillInjections { items: skill_items, warnings: skill_warnings, - } = build_skill_injections(&input, sess.services.skills.as_ref()).await; + } = build_skill_injections(&input, skills_outcome.as_ref()).await; for message in skill_warnings { sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) @@ -3070,6 +3122,7 @@ mod tests { ); let state = SessionState::new(session_configuration.clone()); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -3083,7 +3136,7 @@ mod tests { otel_manager: otel_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), - skills: None, + skills_manager, }; let turn_context = Session::make_turn_context( @@ -3161,6 +3214,7 @@ mod tests { ); let state = SessionState::new(session_configuration.clone()); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone())); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::default())), @@ -3174,7 +3228,7 @@ mod tests { otel_manager: otel_manager.clone(), models_manager, tool_approvals: Mutex::new(ApprovalStore::default()), - skills: None, + skills_manager, }; let turn_context = Arc::new(Session::make_turn_context( diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 75b29eddee..c7aebbaf92 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -49,6 +49,7 @@ pub(crate) async fn run_codex_conversation_interactive( config, auth_manager, models_manager, + Arc::clone(&parent_session.services.skills_manager), initial_history.unwrap_or(InitialHistory::New), SessionSource::SubAgent(SubAgentSource::Review), ) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index eb1839b1ce..a7e11f45eb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -64,6 +64,8 @@ pub use service::ConfigServiceError; const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; +pub use codex_git::GhostSnapshotConfig; + /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of /// the context window. @@ -266,6 +268,9 @@ pub struct Config { /// https://github.com/modelcontextprotocol/rust-sdk pub use_experimental_use_rmcp_client: bool, + /// Settings for ghost snapshots (used for undo). + pub ghost_snapshot: GhostSnapshotConfig, + /// Centralized feature flags; source of truth for feature gating. pub features: Features, @@ -658,6 +663,10 @@ pub struct ConfigToml { #[serde(default)] pub features: Option, + /// Settings for ghost snapshots (used for undo). + #[serde(default)] + pub ghost_snapshot: Option, + /// When `true`, checks for Codex updates on startup and surfaces update prompts. /// Set to `false` only if your Codex updates are centrally managed. /// Defaults to `true`. @@ -747,6 +756,17 @@ impl From for Tools { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct GhostSnapshotToml { + /// Exclude untracked files larger than this many bytes from ghost snapshots. + #[serde(alias = "ignore_untracked_files_over_bytes")] + pub ignore_large_untracked_files: Option, + /// Ignore untracked directories that contain this many files or more. + /// (Still emits a warning.) + #[serde(alias = "large_untracked_dir_warning_threshold")] + pub ignore_large_untracked_dirs: Option, +} + #[derive(Debug, PartialEq, Eq)] pub struct SandboxPolicyResolution { pub policy: SandboxPolicy, @@ -1047,6 +1067,26 @@ impl Config { let history = cfg.history.unwrap_or_default(); + let ghost_snapshot = { + let mut config = GhostSnapshotConfig::default(); + if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() + && let Some(ignore_over_bytes) = ghost_snapshot.ignore_large_untracked_files + { + config.ignore_large_untracked_files = if ignore_over_bytes > 0 { + Some(ignore_over_bytes) + } else { + None + }; + } + if let Some(ghost_snapshot) = cfg.ghost_snapshot.as_ref() + && let Some(threshold) = ghost_snapshot.ignore_large_untracked_dirs + { + config.ignore_large_untracked_dirs = + if threshold > 0 { Some(threshold) } else { None }; + } + config + }; + let include_apply_patch_tool_flag = features.enabled(Feature::ApplyPatchFreeform); let tools_web_search_request = features.enabled(Feature::WebSearchRequest); let use_experimental_unified_exec_tool = features.enabled(Feature::UnifiedExec); @@ -1179,6 +1219,7 @@ impl Config { tools_web_search_request, use_experimental_unified_exec_tool, use_experimental_use_rmcp_client, + ghost_snapshot, features, active_profile: active_profile_name, active_project, @@ -2940,6 +2981,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, + ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -3014,6 +3056,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, + ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -3103,6 +3146,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, + ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, @@ -3178,6 +3222,7 @@ model_verbosity = "high" tools_web_search_request: false, use_experimental_unified_exec_tool: false, use_experimental_use_rmcp_client: false, + ghost_snapshot: GhostSnapshotConfig::default(), features: Features::with_defaults(), active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index cb59c97231..d121b7dc63 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -699,11 +699,8 @@ fn normalize_mixed_inserts_and_removals() { ); } -// In debug builds we panic on normalization errors instead of silently fixing them. -#[cfg(debug_assertions)] #[test] -#[should_panic] -fn normalize_adds_missing_output_for_function_call_panics_in_debug() { +fn normalize_adds_missing_output_for_function_call_inserts_output() { let items = vec![ResponseItem::FunctionCall { id: None, name: "do_it".to_string(), @@ -712,6 +709,24 @@ fn normalize_adds_missing_output_for_function_call_panics_in_debug() { }]; let mut h = create_history_with_items(items); h.normalize_history(); + assert_eq!( + h.contents(), + vec![ + ResponseItem::FunctionCall { + id: None, + name: "do_it".to_string(), + arguments: "{}".to_string(), + call_id: "call-x".to_string(), + }, + ResponseItem::FunctionCallOutput { + call_id: "call-x".to_string(), + output: FunctionCallOutputPayload { + content: "aborted".to_string(), + ..Default::default() + }, + }, + ] + ); } #[cfg(debug_assertions)] diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index ea38989c90..85e25e32aa 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -4,6 +4,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use crate::util::error_or_panic; +use tracing::info; pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { // Collect synthetic outputs to insert immediately after their calls. @@ -22,9 +23,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { }); if !has_output { - error_or_panic(format!( - "Function call output is missing for call id: {call_id}" - )); + info!("Function call output is missing for call id: {call_id}"); missing_outputs_to_insert.push(( idx, ResponseItem::FunctionCallOutput { diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index f340e1a833..ce38b0018c 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -1,4 +1,5 @@ use crate::AuthManager; +#[cfg(any(test, feature = "test-support"))] use crate::CodexAuth; #[cfg(any(test, feature = "test-support"))] use crate::ModelProviderInfo; @@ -14,6 +15,7 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; use crate::rollout::RolloutRecorder; +use crate::skills::SkillsManager; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; @@ -24,6 +26,8 @@ use codex_protocol::protocol::SessionSource; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +#[cfg(any(test, feature = "test-support"))] +use tempfile::TempDir; use tokio::sync::RwLock; /// Represents a newly created Codex conversation, including the first event @@ -40,16 +44,23 @@ pub struct ConversationManager { conversations: Arc>>>, auth_manager: Arc, models_manager: Arc, + skills_manager: Arc, session_source: SessionSource, + #[cfg(any(test, feature = "test-support"))] + _test_codex_home_guard: Option, } impl ConversationManager { pub fn new(auth_manager: Arc, session_source: SessionSource) -> Self { + let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf())); Self { conversations: Arc::new(RwLock::new(HashMap::new())), auth_manager: auth_manager.clone(), session_source, models_manager: Arc::new(ModelsManager::new(auth_manager)), + skills_manager, + #[cfg(any(test, feature = "test-support"))] + _test_codex_home_guard: None, } } @@ -57,12 +68,30 @@ impl ConversationManager { /// Construct with a dummy AuthManager containing the provided CodexAuth. /// Used for integration tests: should not be used by ordinary business logic. pub fn with_models_provider(auth: CodexAuth, provider: ModelProviderInfo) -> Self { - let auth_manager = crate::AuthManager::from_auth_for_testing(auth); + let temp_dir = tempfile::tempdir().unwrap_or_else(|err| panic!("temp codex home: {err}")); + let codex_home = temp_dir.path().to_path_buf(); + let mut manager = Self::with_models_provider_and_home(auth, provider, codex_home); + manager._test_codex_home_guard = Some(temp_dir); + manager + } + + #[cfg(any(test, feature = "test-support"))] + /// Construct with a dummy AuthManager containing the provided CodexAuth and codex home. + /// Used for integration tests: should not be used by ordinary business logic. + pub fn with_models_provider_and_home( + auth: CodexAuth, + provider: ModelProviderInfo, + codex_home: PathBuf, + ) -> Self { + let auth_manager = crate::AuthManager::from_auth_for_testing_with_home(auth, codex_home); + let skills_manager = Arc::new(SkillsManager::new(auth_manager.codex_home().to_path_buf())); Self { conversations: Arc::new(RwLock::new(HashMap::new())), auth_manager: auth_manager.clone(), session_source: SessionSource::Exec, models_manager: Arc::new(ModelsManager::with_provider(auth_manager, provider)), + skills_manager, + _test_codex_home_guard: None, } } @@ -70,6 +99,10 @@ impl ConversationManager { self.session_source.clone() } + pub fn skills_manager(&self) -> Arc { + self.skills_manager.clone() + } + pub async fn new_conversation(&self, config: Config) -> CodexResult { self.spawn_conversation( config, @@ -92,6 +125,7 @@ impl ConversationManager { config, auth_manager, models_manager, + self.skills_manager.clone(), InitialHistory::New, self.session_source.clone(), ) @@ -169,6 +203,7 @@ impl ConversationManager { config, auth_manager, self.models_manager.clone(), + self.skills_manager.clone(), initial_history, self.session_source.clone(), ) @@ -210,6 +245,7 @@ impl ConversationManager { config, auth_manager, self.models_manager.clone(), + self.skills_manager.clone(), history, self.session_source.clone(), ) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 20f405bf28..431eabe3c6 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -325,13 +325,13 @@ pub const FEATURES: &[FeatureSpec] = &[ }, FeatureSpec { id: Feature::WindowsSandbox, - key: "enable_experimental_windows_sandbox", + key: "experimental_windows_sandbox", stage: Stage::Experimental, default_enabled: false, }, FeatureSpec { id: Feature::WindowsSandboxElevated, - key: "enable_elevated_windows_sandbox", + key: "elevated_windows_sandbox", stage: Stage::Experimental, default_enabled: false, }, diff --git a/codex-rs/core/src/features/legacy.rs b/codex-rs/core/src/features/legacy.rs index 0c74d380e8..19210c6759 100644 --- a/codex-rs/core/src/features/legacy.rs +++ b/codex-rs/core/src/features/legacy.rs @@ -9,6 +9,10 @@ struct Alias { } const ALIASES: &[Alias] = &[ + Alias { + legacy_key: "enable_experimental_windows_sandbox", + feature: Feature::WindowsSandbox, + }, Alias { legacy_key: "experimental_use_unified_exec_tool", feature: Feature::UnifiedExec, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 47f051df4f..2980e768cf 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -79,6 +79,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete | EventMsg::ViewImageToolCall(_) diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 5d719a7922..f751287b2d 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -81,7 +81,7 @@ impl SandboxManager { SandboxablePreference::Forbid => SandboxType::None, SandboxablePreference::Require => { // Require a platform sandbox when available; on Windows this - // respects the enable_experimental_windows_sandbox feature. + // respects the experimental_windows_sandbox feature. crate::safety::get_platform_sandbox().unwrap_or(SandboxType::None) } SandboxablePreference::Auto => match policy { diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index f3ed7f0239..54b859caaf 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -3,6 +3,7 @@ use crate::git_info::resolve_root_git_project_for_trust; use crate::skills::model::SkillError; use crate::skills::model::SkillLoadOutcome; use crate::skills::model::SkillMetadata; +use codex_protocol::protocol::SkillScope; use dunce::canonicalize as normalize_path; use serde::Deserialize; use std::collections::VecDeque; @@ -53,10 +54,21 @@ impl fmt::Display for SkillParseError { impl Error for SkillParseError {} pub fn load_skills(config: &Config) -> SkillLoadOutcome { + load_skills_from_roots(skill_roots(config)) +} + +pub(crate) struct SkillRoot { + pub(crate) path: PathBuf, + pub(crate) scope: SkillScope, +} + +pub(crate) fn load_skills_from_roots(roots: I) -> SkillLoadOutcome +where + I: IntoIterator, +{ let mut outcome = SkillLoadOutcome::default(); - let roots = skill_roots(config); for root in roots { - discover_skills_under_root(&root, &mut outcome); + discover_skills_under_root(&root.path, root.scope, &mut outcome); } outcome @@ -66,21 +78,33 @@ pub fn load_skills(config: &Config) -> SkillLoadOutcome { outcome } -fn skill_roots(config: &Config) -> Vec { - let mut roots = vec![config.codex_home.join(SKILLS_DIR_NAME)]; +pub(crate) fn user_skills_root(codex_home: &Path) -> SkillRoot { + SkillRoot { + path: codex_home.join(SKILLS_DIR_NAME), + scope: SkillScope::User, + } +} - if let Some(repo_root) = resolve_root_git_project_for_trust(&config.cwd) { - roots.push( - repo_root - .join(REPO_ROOT_CONFIG_DIR_NAME) - .join(SKILLS_DIR_NAME), - ); +pub(crate) fn repo_skills_root(cwd: &Path) -> Option { + resolve_root_git_project_for_trust(cwd).map(|repo_root| SkillRoot { + path: repo_root + .join(REPO_ROOT_CONFIG_DIR_NAME) + .join(SKILLS_DIR_NAME), + scope: SkillScope::Repo, + }) +} + +fn skill_roots(config: &Config) -> Vec { + let mut roots = vec![user_skills_root(&config.codex_home)]; + + if let Some(repo_root) = repo_skills_root(&config.cwd) { + roots.push(repo_root); } roots } -fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { +fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) { let Ok(root) = normalize_path(root) else { return; }; @@ -124,7 +148,7 @@ fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { } if file_type.is_file() && file_name == SKILLS_FILENAME { - match parse_skill_file(&path) { + match parse_skill_file(&path, scope) { Ok(skill) => outcome.skills.push(skill), Err(err) => outcome.errors.push(SkillError { path, @@ -136,7 +160,7 @@ fn discover_skills_under_root(root: &Path, outcome: &mut SkillLoadOutcome) { } } -fn parse_skill_file(path: &Path) -> Result { +fn parse_skill_file(path: &Path, scope: SkillScope) -> Result { let contents = fs::read_to_string(path).map_err(SkillParseError::Read)?; let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?; @@ -156,6 +180,7 @@ fn parse_skill_file(path: &Path) -> Result { name, description, path: resolved_path, + scope, }) } diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core/src/skills/manager.rs new file mode 100644 index 0000000000..a031d66a98 --- /dev/null +++ b/codex-rs/core/src/skills/manager.rs @@ -0,0 +1,48 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::sync::RwLock; + +use crate::skills::SkillLoadOutcome; +use crate::skills::loader::load_skills_from_roots; +use crate::skills::loader::repo_skills_root; +use crate::skills::loader::user_skills_root; + +pub struct SkillsManager { + codex_home: PathBuf, + cache_by_cwd: RwLock>, +} + +impl SkillsManager { + pub fn new(codex_home: PathBuf) -> Self { + Self { + codex_home, + cache_by_cwd: RwLock::new(HashMap::new()), + } + } + + pub fn skills_for_cwd(&self, cwd: &Path) -> SkillLoadOutcome { + let cached = match self.cache_by_cwd.read() { + Ok(cache) => cache.get(cwd).cloned(), + Err(err) => err.into_inner().get(cwd).cloned(), + }; + if let Some(outcome) = cached { + return outcome; + } + + let mut roots = vec![user_skills_root(&self.codex_home)]; + if let Some(repo_root) = repo_skills_root(cwd) { + roots.push(repo_root); + } + let outcome = load_skills_from_roots(roots); + match self.cache_by_cwd.write() { + Ok(mut cache) => { + cache.insert(cwd.to_path_buf(), outcome.clone()); + } + Err(err) => { + err.into_inner().insert(cwd.to_path_buf(), outcome.clone()); + } + } + outcome + } +} diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core/src/skills/mod.rs index b2ab935ce5..9d15f0333c 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core/src/skills/mod.rs @@ -1,11 +1,13 @@ pub mod injection; pub mod loader; +pub mod manager; pub mod model; pub mod render; pub(crate) use injection::SkillInjections; pub(crate) use injection::build_skill_injections; pub use loader::load_skills; +pub use manager::SkillsManager; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core/src/skills/model.rs index 83a3317d57..8aff199c3f 100644 --- a/codex-rs/core/src/skills/model.rs +++ b/codex-rs/core/src/skills/model.rs @@ -1,10 +1,13 @@ use std::path::PathBuf; +use codex_protocol::protocol::SkillScope; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SkillMetadata { pub name: String, pub description: String, pub path: PathBuf, + pub scope: SkillScope, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index dbc6150292..e06691955f 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,7 +4,7 @@ use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_models::models_manager::ModelsManager; -use crate::skills::SkillLoadOutcome; +use crate::skills::SkillsManager; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -25,5 +25,5 @@ pub(crate) struct SessionServices { pub(crate) models_manager: Arc, pub(crate) otel_manager: OtelManager, pub(crate) tool_approvals: Mutex, - pub(crate) skills: Option, + pub(crate) skills_manager: Arc, } diff --git a/codex-rs/core/src/tasks/ghost_snapshot.rs b/codex-rs/core/src/tasks/ghost_snapshot.rs index 7e84c330f6..ec6f829c79 100644 --- a/codex-rs/core/src/tasks/ghost_snapshot.rs +++ b/codex-rs/core/src/tasks/ghost_snapshot.rs @@ -8,8 +8,7 @@ use async_trait::async_trait; use codex_git::CreateGhostCommitOptions; use codex_git::GhostSnapshotReport; use codex_git::GitToolingError; -use codex_git::capture_ghost_snapshot_report; -use codex_git::create_ghost_commit; +use codex_git::create_ghost_commit_with_report; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use codex_utils_readiness::Readiness; @@ -73,18 +72,23 @@ impl SessionTask for GhostSnapshotTask { _ = cancellation_token.cancelled() => true, _ = async { let repo_path = ctx_for_task.cwd.clone(); - // First, compute a snapshot report so we can warn about - // large untracked directories before running the heavier - // snapshot logic. - if let Ok(Ok(report)) = tokio::task::spawn_blocking({ - let repo_path = repo_path.clone(); - move || { - let options = CreateGhostCommitOptions::new(&repo_path); - capture_ghost_snapshot_report(&options) - } + let ghost_snapshot = ctx_for_task.ghost_snapshot.clone(); + let ghost_snapshot_for_commit = ghost_snapshot.clone(); + // Required to run in a dedicated blocking pool. + match tokio::task::spawn_blocking(move || { + let options = + CreateGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot_for_commit); + create_ghost_commit_with_report(&options) }) .await - && let Some(message) = format_large_untracked_warning(&report) { + { + Ok(Ok((ghost_commit, report))) => { + info!("ghost snapshot blocking task finished"); + for message in format_snapshot_warnings( + ghost_snapshot.ignore_large_untracked_files, + ghost_snapshot.ignore_large_untracked_dirs, + &report, + ) { session .session .send_event( @@ -93,16 +97,6 @@ impl SessionTask for GhostSnapshotTask { ) .await; } - - // Required to run in a dedicated blocking pool. - match tokio::task::spawn_blocking(move || { - let options = CreateGhostCommitOptions::new(&repo_path); - create_ghost_commit(&options) - }) - .await - { - Ok(Ok(ghost_commit)) => { - info!("ghost snapshot blocking task finished"); session .session .record_conversation_items(&ctx, &[ResponseItem::GhostSnapshot { @@ -161,10 +155,31 @@ impl GhostSnapshotTask { } } -fn format_large_untracked_warning(report: &GhostSnapshotReport) -> Option { +fn format_snapshot_warnings( + ignore_large_untracked_files: Option, + ignore_large_untracked_dirs: Option, + report: &GhostSnapshotReport, +) -> Vec { + let mut warnings = Vec::new(); + if let Some(message) = format_large_untracked_warning(ignore_large_untracked_dirs, report) { + warnings.push(message); + } + if let Some(message) = + format_ignored_untracked_files_warning(ignore_large_untracked_files, report) + { + warnings.push(message); + } + warnings +} + +fn format_large_untracked_warning( + ignore_large_untracked_dirs: Option, + report: &GhostSnapshotReport, +) -> Option { if report.large_untracked_dirs.is_empty() { return None; } + let threshold = ignore_large_untracked_dirs?; const MAX_DIRS: usize = 3; let mut parts: Vec = Vec::new(); for dir in report.large_untracked_dirs.iter().take(MAX_DIRS) { @@ -175,7 +190,85 @@ fn format_large_untracked_warning(report: &GhostSnapshotReport) -> Option= {threshold} files): {}. These directories are excluded from snapshots and undo cleanup. Adjust `ghost_snapshot.ignore_large_untracked_dirs` to change this behavior.", parts.join(", ") )) } + +fn format_ignored_untracked_files_warning( + ignore_large_untracked_files: Option, + report: &GhostSnapshotReport, +) -> Option { + let threshold = ignore_large_untracked_files?; + if report.ignored_untracked_files.is_empty() { + return None; + } + + const MAX_FILES: usize = 3; + let mut parts: Vec = Vec::new(); + for file in report.ignored_untracked_files.iter().take(MAX_FILES) { + parts.push(format!( + "{} ({})", + file.path.display(), + format_bytes(file.byte_size) + )); + } + if report.ignored_untracked_files.len() > MAX_FILES { + let remaining = report.ignored_untracked_files.len() - MAX_FILES; + parts.push(format!("{remaining} more")); + } + + Some(format!( + "Repository snapshot ignored untracked files larger than {}: {}. These files are preserved during undo cleanup, but their contents are not captured in the snapshot. Adjust `ghost_snapshot.ignore_large_untracked_files` to change this behavior. To avoid this message in the future, update your `.gitignore`.", + format_bytes(threshold), + parts.join(", ") + )) +} + +fn format_bytes(bytes: i64) -> String { + const KIB: i64 = 1024; + const MIB: i64 = 1024 * 1024; + + if bytes >= MIB { + return format!("{} MiB", bytes / MIB); + } + if bytes >= KIB { + return format!("{} KiB", bytes / KIB); + } + format!("{bytes} B") +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_git::LargeUntrackedDir; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn large_untracked_warning_includes_threshold() { + let report = GhostSnapshotReport { + large_untracked_dirs: vec![LargeUntrackedDir { + path: PathBuf::from("models"), + file_count: 250, + }], + ignored_untracked_files: Vec::new(), + }; + + let message = format_large_untracked_warning(Some(200), &report).unwrap(); + assert!(message.contains(">= 200 files")); + } + + #[test] + fn large_untracked_warning_disabled_when_threshold_disabled() { + let report = GhostSnapshotReport { + large_untracked_dirs: vec![LargeUntrackedDir { + path: PathBuf::from("models"), + file_count: 250, + }], + ignored_untracked_files: Vec::new(), + }; + + assert_eq!(format_large_untracked_warning(None, &report), None); + } +} diff --git a/codex-rs/core/src/tasks/undo.rs b/codex-rs/core/src/tasks/undo.rs index 9862a7ecd1..5da7edd16f 100644 --- a/codex-rs/core/src/tasks/undo.rs +++ b/codex-rs/core/src/tasks/undo.rs @@ -8,7 +8,8 @@ use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use async_trait::async_trait; -use codex_git::restore_ghost_commit; +use codex_git::RestoreGhostCommitOptions; +use codex_git::restore_ghost_commit_with_options; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; @@ -85,9 +86,12 @@ impl SessionTask for UndoTask { let commit_id = ghost_commit.id().to_string(); let repo_path = ctx.cwd.clone(); - let restore_result = - tokio::task::spawn_blocking(move || restore_ghost_commit(&repo_path, &ghost_commit)) - .await; + let ghost_snapshot = ctx.ghost_snapshot.clone(); + let restore_result = tokio::task::spawn_blocking(move || { + let options = RestoreGhostCommitOptions::new(&repo_path).ghost_snapshot(ghost_snapshot); + restore_ghost_commit_with_options(&options, &ghost_commit) + }) + .await; match restore_result { Ok(Ok(())) => { diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index b07f4d3741..7a12a24a69 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -107,8 +107,11 @@ impl TestCodexBuilder { let (config, cwd) = self.prepare_config(server, &home).await?; let auth = self.auth.clone(); - let conversation_manager = - ConversationManager::with_models_provider(auth.clone(), config.model_provider.clone()); + let conversation_manager = ConversationManager::with_models_provider_and_home( + auth.clone(), + config.model_provider.clone(), + config.codex_home.clone(), + ); let new_conversation = match resume_from { Some(path) => { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cdde1616f2..42a06d3f38 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -259,9 +259,10 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let auth_manager = codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); @@ -345,9 +346,10 @@ async fn includes_conversation_id_and_model_headers_in_request() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let NewConversation { conversation: codex, @@ -406,9 +408,10 @@ async fn includes_base_instructions_override_in_request() { config.base_instructions = Some("test instructions".to_string()); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -466,9 +469,10 @@ async fn chatgpt_auth_sends_correct_request() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( create_dummy_codex_auth(), config.model_provider.clone(), + config.codex_home.clone(), ); let NewConversation { conversation: codex, @@ -602,9 +606,10 @@ async fn includes_user_instructions_message_in_request() { config.model_provider = model_provider; config.user_instructions = Some("be nice".to_string()); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -671,9 +676,10 @@ async fn skills_append_to_instructions_when_feature_enabled() { config.features.enable(Feature::Skills); config.cwd = codex_home.path().to_path_buf(); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -713,6 +719,7 @@ async fn skills_append_to_instructions_when_feature_enabled() { instructions_text.contains(&expected_path_str), "expected path {expected_path_str} in instructions" ); + let _codex_home_guard = codex_home; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1027,9 +1034,10 @@ async fn includes_developer_instructions_message_in_request() { config.user_instructions = Some("be nice".to_string()); config.developer_instructions = Some("be useful".to_string()); - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1256,9 +1264,10 @@ async fn token_count_includes_rate_limits_snapshot() { let mut config = load_default_config_for_test(&home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("test"), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1610,9 +1619,10 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( create_dummy_codex_auth(), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1691,9 +1701,10 @@ async fn env_var_overrides_loaded_auth() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( create_dummy_codex_auth(), config.model_provider.clone(), + config.codex_home.clone(), ); let codex = conversation_manager .new_conversation(config) @@ -1772,9 +1783,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let conversation_manager = ConversationManager::with_models_provider( + let conversation_manager = ConversationManager::with_models_provider_and_home( CodexAuth::from_api_key("Test API Key"), config.model_provider.clone(), + config.codex_home.clone(), ); let NewConversation { conversation: codex, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index d6ced3c1dc..25a68be763 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -6,7 +6,6 @@ use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol::SkillLoadOutcomeInfo; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -115,11 +114,23 @@ async fn skill_load_errors_surface_in_session_configured() -> Result<()> { }); let test = builder.build(&server).await?; - let SkillLoadOutcomeInfo { skills, errors } = test - .session_configured - .skill_load_outcome - .as_ref() - .expect("skill outcome present"); + test.codex + .submit(Op::ListSkills { cwds: Vec::new() }) + .await?; + let response = + core_test_support::wait_for_event_match(test.codex.as_ref(), |event| match event { + codex_core::protocol::EventMsg::ListSkillsResponse(response) => Some(response.clone()), + _ => None, + }) + .await; + + let cwd = test.cwd_path(); + let (skills, errors) = response + .skills + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| (entry.skills.clone(), entry.errors.clone())) + .unwrap_or_default(); assert!( skills.is_empty(), diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index f6405c9bc3..075377f61e 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -68,6 +68,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::UserInput` – Any input from the user to kick off a `Task` - `Op::Interrupt` – Interrupts a running task - `Op::ExecApproval` – Approve or deny code execution + - `Op::ListSkills` – Request skills for one or more cwd values - `EventMsg` - `EventMsg::AgentMessage` – Messages from the `Model` - `EventMsg::ExecApprovalRequest` – Request approval from user to execute a command @@ -75,6 +76,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `EventMsg::Error` – A task stopped with an error - `EventMsg::Warning` – A non-fatal warning that the client should surface to the user - `EventMsg::TurnComplete` – Contains a `response_id` bookmark for last `response_id` executed by the task. This can be used to continue the task at a later point in time, perhaps with additional user input. + - `EventMsg::ListSkillsResponse` – Response payload with per-cwd skill entries (`cwd`, `skills`, `errors`) The `response_id` returned from each task matches the OpenAI `response_id` stored in the API's `/responses` endpoint. It can be stored and used in future `Sessions` to resume threads of work. diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md new file mode 100644 index 0000000000..a12eaa19af --- /dev/null +++ b/codex-rs/exec-server/README.md @@ -0,0 +1,25 @@ +# codex-exec-server + +This crate contains the code for two executables: + +- `codex-exec-mcp-server` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed instance of Bash. Every resulting `execve(2)` call made within Bash is intercepted and run via the executable defined by the `BASH_EXEC_WRAPPER` environment variable within the Bash process. In practice, `BASH_EXEC_WRAPPER` is set to `codex-execve-wrapper`. +- `codex-execve-wrapper` is the executable that takes the arguments to the `execve(2)` call and "escalates" it to the MCP server via a shared file descriptor (specified by the `CODEX_ESCALATE_SOCKET` environment variable) for consideration. Based on the [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview), the MCP server replies with one of: + - `Run`: `codex-execve-wrapper` should invoke `execve(2)` on itself to run the original command within Bash + - `Escalate`: forward the file descriptors of the current process to the MCP server so the command can be run faithfully outside the sandbox. Because the MCP server will have the original FDs for `stdout` and `stderr`, it can write those directly. When the process completes, the MCP server forwards the exit code to `codex-execve-wrapper` so that it exits in a consistent manner. + - `Deny`: the MCP server has declared the proposed command to be "forbidden," so `codex-execve-wrapper` will print an error to `stderr` and exit with `1`. + +## Patched Bash + +We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `BASH_EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually: + +```bash +git clone https://github.com/bminor/bash +git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b +git apply /path/to/patches/bash-exec-wrapper.patch +./configure --without-bash-malloc +make -j"$(nproc)" +``` + +## Release workflow + +`.github/workflows/shell-tool-mcp.yml` builds the Rust binaries, compiles the patched Bash variants, assembles the `vendor/` tree, and creates `codex-shell-tool-mcp-npm-.tgz` for inclusion in the Rust GitHub Release. When the version is a stable or alpha tag, the workflow also publishes the tarball to npm using OIDC. The workflow is invoked from `rust-release.yml` so the package ships alongside other Codex artifacts. diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 1da0796a75..a833426dc2 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -572,6 +572,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) | EventMsg::RawResponseItem(_) | EventMsg::UserMessage(_) | EventMsg::EnteredReviewMode(_) diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2291698d66..2b3673f5a6 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -85,7 +85,6 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path, }), ); diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml index 0697704072..0ac0351333 100644 --- a/codex-rs/feedback/Cargo.toml +++ b/codex-rs/feedback/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true [dependencies] anyhow = { workspace = true } codex-protocol = { workspace = true } -sentry = { version = "0.34" } +sentry = { version = "0.46" } tracing-subscriber = { workspace = true } [dev-dependencies] diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d39a38cde9..73e56a605c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -279,6 +279,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpToolCallEnd(_) | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) + | EventMsg::ListSkillsResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 3af472ddb9..83ac25fdfd 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -266,7 +266,6 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; @@ -306,7 +305,6 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; let event = Event { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 14dc426cea..c333d431ca 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -186,6 +186,15 @@ pub enum Op { /// Request the list of available custom prompts. ListCustomPrompts, + /// Request the list of skills for the provided `cwd` values or the session default. + ListSkills { + /// Working directories to scope repo skills discovery. + /// + /// When empty, the session default working directory is used. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + cwds: Vec, + }, + /// Request the agent to summarize the current conversation context. /// The agent will use its existing context (either conversation history or previous response id) /// to generate a summary which will be returned as an AgentMessage event. @@ -588,6 +597,9 @@ pub enum EventMsg { /// List of custom prompts available to the agent. ListCustomPromptsResponse(ListCustomPromptsResponseEvent), + /// List of skills available to the agent. + ListSkillsResponse(ListSkillsResponseEvent), + PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), @@ -1650,11 +1662,26 @@ pub struct ListCustomPromptsResponseEvent { pub custom_prompts: Vec, } +/// Response payload for `Op::ListSkills`. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct SkillInfo { +pub struct ListSkillsResponseEvent { + pub skills: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum SkillScope { + User, + Repo, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillMetadata { pub name: String, pub description: String, pub path: PathBuf, + pub scope: SkillScope, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -1663,9 +1690,10 @@ pub struct SkillErrorInfo { pub message: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS, Default)] -pub struct SkillLoadOutcomeInfo { - pub skills: Vec, +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, pub errors: Vec, } @@ -1704,9 +1732,6 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub skill_load_outcome: Option, - pub rollout_path: PathBuf, } @@ -1834,7 +1859,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }), }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5397a2eaec..14be12cb51 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -31,9 +31,10 @@ use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFI use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::skills::SkillError; use codex_protocol::ConversationId; @@ -50,6 +51,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -86,9 +88,8 @@ fn session_summary( }) } -fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .errors +fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec { + errors .iter() .map(|err| SkillError { path: err.path.clone(), @@ -97,6 +98,15 @@ fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec .collect() } +fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { + response + .skills + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| entry.errors.clone()) + .unwrap_or_default() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -688,11 +698,14 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } - if let EventMsg::SessionConfigured(cfg) = &event.msg - && let Some(outcome) = cfg.skill_load_outcome.as_ref() - && !outcome.errors.is_empty() - { - let errors = skill_errors_from_outcome(outcome); + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, response); + if errors.is_empty() { + self.chat_widget.handle_codex_event(event); + return Ok(true); + } + let errors = skill_errors_from_info(&errors); match run_skill_error_prompt(tui, &errors).await { SkillErrorPromptOutcome::Exit => { self.chat_widget.submit_op(Op::Shutdown); @@ -1382,7 +1395,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( @@ -1438,7 +1450,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 919866b00c..17877e4bfa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1511,7 +1511,8 @@ impl ChatComposer { let toggles = matches!(key_event.code, KeyCode::Char('?')) && !has_ctrl_or_alt(key_event.modifiers) - && self.is_empty(); + && self.is_empty() + && !self.is_in_paste_burst(); if !toggles { return false; @@ -2171,6 +2172,35 @@ mod tests { assert_eq!(composer.footer_mode(), FooterMode::ContextOnly); } + #[test] + fn question_mark_does_not_toggle_during_paste_burst() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + for ch in ['h', 'i', '?', 't', 'h', 'e', 'r', 'e'] { + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); + } + assert!(composer.is_in_paste_burst()); + assert_eq!(composer.textarea.text(), ""); + + std::thread::sleep(ChatComposer::recommended_paste_flush_delay()); + let _ = composer.flush_paste_burst_if_due(); + + assert_eq!(composer.textarea.text(), "hi?there"); + assert_ne!(composer.footer_mode, FooterMode::ShortcutOverlay); + } + #[test] fn shortcut_overlay_persists_while_task_running() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e3f95a612e..37cd004a15 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -33,6 +33,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; @@ -44,7 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillsListEntry; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TerminalInteractionEvent; @@ -392,7 +393,7 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); - self.set_skills_from_outcome(event.skill_load_outcome.as_ref()); + self.set_skills(None); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -409,6 +410,7 @@ impl ChatWidget { } // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); + self.submit_op(Op::ListSkills { cwds: Vec::new() }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -417,11 +419,15 @@ impl ChatWidget { } } - fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { - let skills = outcome.map(skills_from_outcome); + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } + fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { + let skills = skills_for_cwd(&self.config.cwd, &response.skills); + self.set_skills(Some(skills)); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1879,6 +1885,7 @@ impl ChatWidget { EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), + EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), @@ -3092,6 +3099,10 @@ impl ChatWidget { self.bottom_pane.set_custom_prompts(ev.custom_prompts); } + fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { + self.set_skills_from_response(&ev); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -3476,16 +3487,23 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } -fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .skills +fn skills_for_cwd(cwd: &Path, skills_entries: &[SkillsListEntry]) -> Vec { + skills_entries .iter() - .map(|skill| SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - path: skill.path.clone(), + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| { + entry + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope, + }) + .collect() }) - .collect() + .unwrap_or_default() } fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e8432b0af8..362b1678f4 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -125,7 +125,6 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index f4291f3bfc..b15bcdbd12 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -1,11 +1,11 @@ Use /compact when the conversation gets long to summarize history and free up context. -Start a fresh idea with /new; the previous session stays available in history. +Start a fresh idea with /new; the previous session stays in history. If a turn went sideways, /undo asks Codex to revert the last changes. Use /feedback to send logs to the maintainers when something looks off. Switch models or reasoning effort quickly with /model. -You can run any shell commands from codex using `!` (e.g. `!ls`) +You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. -You can define your own `/` commands with custom prompts. More info here: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts -With the composer empty, press Esc to step back and edit your last message; Enter confirms. +You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts +When the composer is empty, press Esc to step back and edit your last message; Enter confirms. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index ea0673400a..bb4c632447 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -31,9 +31,10 @@ use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFI use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::skills::SkillError; use codex_protocol::ConversationId; @@ -50,6 +51,7 @@ use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -96,9 +98,8 @@ fn session_summary( }) } -fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .errors +fn skill_errors_from_info(errors: &[SkillErrorInfo]) -> Vec { + errors .iter() .map(|err| SkillError { path: err.path.clone(), @@ -107,6 +108,15 @@ fn skill_errors_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec .collect() } +fn errors_for_cwd(cwd: &Path, response: &ListSkillsResponseEvent) -> Vec { + response + .skills + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| entry.errors.clone()) + .unwrap_or_default() +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -698,11 +708,14 @@ impl App { self.suppress_shutdown_complete = false; return Ok(true); } - if let EventMsg::SessionConfigured(cfg) = &event.msg - && let Some(outcome) = cfg.skill_load_outcome.as_ref() - && !outcome.errors.is_empty() - { - let errors = skill_errors_from_outcome(outcome); + if let EventMsg::ListSkillsResponse(response) = &event.msg { + let cwd = self.chat_widget.config_ref().cwd.clone(); + let errors = errors_for_cwd(&cwd, response); + if errors.is_empty() { + self.chat_widget.handle_codex_event(event); + return Ok(true); + } + let errors = skill_errors_from_info(&errors); match run_skill_error_prompt(tui, &errors).await { SkillErrorPromptOutcome::Exit => { self.chat_widget.submit_op(Op::Shutdown); @@ -1392,7 +1405,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; Arc::new(new_session_info( @@ -1448,7 +1460,6 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - skill_load_outcome: None, rollout_path: PathBuf::new(), }; diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index e3f95a612e..7dec1ae542 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -33,6 +33,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::ListCustomPromptsResponseEvent; +use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::McpListToolsResponseEvent; use codex_core::protocol::McpStartupCompleteEvent; use codex_core::protocol::McpStartupStatus; @@ -44,7 +45,7 @@ use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; -use codex_core::protocol::SkillLoadOutcomeInfo; +use codex_core::protocol::SkillsListEntry; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TerminalInteractionEvent; @@ -392,7 +393,7 @@ impl ChatWidget { fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); - self.set_skills_from_outcome(event.skill_load_outcome.as_ref()); + self.set_skills(None); self.conversation_id = Some(event.session_id); self.current_rollout_path = Some(event.rollout_path.clone()); let initial_messages = event.initial_messages.clone(); @@ -409,6 +410,7 @@ impl ChatWidget { } // Ask codex-core to enumerate custom prompts for this session. self.submit_op(Op::ListCustomPrompts); + self.submit_op(Op::ListSkills { cwds: Vec::new() }); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -417,11 +419,15 @@ impl ChatWidget { } } - fn set_skills_from_outcome(&mut self, outcome: Option<&SkillLoadOutcomeInfo>) { - let skills = outcome.map(skills_from_outcome); + fn set_skills(&mut self, skills: Option>) { self.bottom_pane.set_skills(skills); } + fn set_skills_from_response(&mut self, response: &ListSkillsResponseEvent) { + let skills = skills_for_cwd(&self.config.cwd, &response.skills); + self.set_skills(Some(skills)); + } + pub(crate) fn open_feedback_note( &mut self, category: crate::app_event::FeedbackCategory, @@ -1879,6 +1885,7 @@ impl ChatWidget { EventMsg::GetHistoryEntryResponse(ev) => self.on_get_history_entry_response(ev), EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev), EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev), + EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev), EventMsg::ShutdownComplete => self.on_shutdown_complete(), EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => self.on_turn_diff(unified_diff), EventMsg::DeprecationNotice(ev) => self.on_deprecation_notice(ev), @@ -3092,6 +3099,10 @@ impl ChatWidget { self.bottom_pane.set_custom_prompts(ev.custom_prompts); } + fn on_list_skills(&mut self, ev: ListSkillsResponseEvent) { + self.set_skills_from_response(&ev); + } + pub(crate) fn open_review_popup(&mut self) { let mut items: Vec = Vec::new(); @@ -3476,18 +3487,6 @@ pub(crate) fn show_review_commit_picker_with_entries( }); } -fn skills_from_outcome(outcome: &SkillLoadOutcomeInfo) -> Vec { - outcome - .skills - .iter() - .map(|skill| SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - path: skill.path.clone(), - }) - .collect() -} - fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec { let mut seen: HashSet = HashSet::new(); let mut matches: Vec = Vec::new(); @@ -3504,5 +3503,24 @@ fn find_skill_mentions(text: &str, skills: &[SkillMetadata]) -> Vec Vec { + skills_entries + .iter() + .find(|entry| entry.cwd.as_path() == cwd) + .map(|entry| { + entry + .skills + .iter() + .map(|skill| SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + path: skill.path.clone(), + scope: skill.scope, + }) + .collect() + }) + .unwrap_or_default() +} + #[cfg(test)] pub(crate) mod tests; diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index e8432b0af8..362b1678f4 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -125,7 +125,6 @@ fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), - skill_load_outcome: None, rollout_path: rollout_file.path().to_path_buf(), }; diff --git a/codex-rs/tui2/tooltips.txt b/codex-rs/tui2/tooltips.txt index f4291f3bfc..b15bcdbd12 100644 --- a/codex-rs/tui2/tooltips.txt +++ b/codex-rs/tui2/tooltips.txt @@ -1,11 +1,11 @@ Use /compact when the conversation gets long to summarize history and free up context. -Start a fresh idea with /new; the previous session stays available in history. +Start a fresh idea with /new; the previous session stays in history. If a turn went sideways, /undo asks Codex to revert the last changes. Use /feedback to send logs to the maintainers when something looks off. Switch models or reasoning effort quickly with /model. -You can run any shell commands from codex using `!` (e.g. `!ls`) +You can run any shell command from Codex using `!` (e.g. `!ls`) Type / to open the command popup; Tab autocompletes slash commands and saved prompts. -You can define your own `/` commands with custom prompts. More info here: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts -With the composer empty, press Esc to step back and edit your last message; Enter confirms. +You can define your own `/` commands with custom prompts. More info: https://developers.openai.com/codex/guides/slash-commands#create-your-own-slash-commands-with-custom-prompts +When the composer is empty, press Esc to step back and edit your last message; Enter confirms. Paste an image with Ctrl+V to attach it to your next message. You can resume a previous conversation by running `codex resume` diff --git a/codex-rs/utils/git/src/ghost_commits.rs b/codex-rs/utils/git/src/ghost_commits.rs index 8544525f0f..1e3c606f09 100644 --- a/codex-rs/utils/git/src/ghost_commits.rs +++ b/codex-rs/utils/git/src/ghost_commits.rs @@ -23,8 +23,10 @@ use crate::operations::run_git_for_stdout_all; /// Default commit message used for ghost commits when none is provided. const DEFAULT_COMMIT_MESSAGE: &str = "codex snapshot"; -/// Default threshold that triggers a warning about large untracked directories. -const LARGE_UNTRACKED_WARNING_THRESHOLD: usize = 200; +/// Default threshold for ignoring large untracked directories. +const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; +/// Default threshold (10 MiB) for excluding large untracked files from ghost snapshots. +const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; /// Directories that should always be ignored when capturing ghost snapshots, /// even if they are not listed in .gitignore. /// @@ -50,19 +52,49 @@ pub struct CreateGhostCommitOptions<'a> { pub repo_path: &'a Path, pub message: Option<&'a str>, pub force_include: Vec, + pub ghost_snapshot: GhostSnapshotConfig, +} + +/// Options to control ghost commit restoration. +pub struct RestoreGhostCommitOptions<'a> { + pub repo_path: &'a Path, + pub ghost_snapshot: GhostSnapshotConfig, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GhostSnapshotConfig { + pub ignore_large_untracked_files: Option, + pub ignore_large_untracked_dirs: Option, +} + +impl Default for GhostSnapshotConfig { + fn default() -> Self { + Self { + ignore_large_untracked_files: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_FILES), + ignore_large_untracked_dirs: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS), + } + } } /// Summary produced alongside a ghost snapshot. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct GhostSnapshotReport { pub large_untracked_dirs: Vec, + pub ignored_untracked_files: Vec, } /// Directory containing a large amount of untracked content. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LargeUntrackedDir { pub path: PathBuf, - pub file_count: usize, + pub file_count: i64, +} + +/// Untracked file excluded from the snapshot because of its size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IgnoredUntrackedFile { + pub path: PathBuf, + pub byte_size: i64, } impl<'a> CreateGhostCommitOptions<'a> { @@ -72,6 +104,7 @@ impl<'a> CreateGhostCommitOptions<'a> { repo_path, message: None, force_include: Vec::new(), + ghost_snapshot: GhostSnapshotConfig::default(), } } @@ -81,6 +114,24 @@ impl<'a> CreateGhostCommitOptions<'a> { self } + pub fn ghost_snapshot(mut self, ghost_snapshot: GhostSnapshotConfig) -> Self { + self.ghost_snapshot = ghost_snapshot; + self + } + + /// Exclude untracked files larger than `bytes` from the snapshot commit. + /// + /// These files are still treated as untracked for preservation purposes (i.e. they will not be + /// deleted by undo), but they will not be captured in the snapshot tree. + pub fn ignore_large_untracked_files(mut self, bytes: i64) -> Self { + if bytes > 0 { + self.ghost_snapshot.ignore_large_untracked_files = Some(bytes); + } else { + self.ghost_snapshot.ignore_large_untracked_files = None; + } + self + } + /// Supplies the entire force-include path list at once. pub fn force_include(mut self, paths: I) -> Self where @@ -100,8 +151,56 @@ impl<'a> CreateGhostCommitOptions<'a> { } } -fn detect_large_untracked_dirs(files: &[PathBuf], dirs: &[PathBuf]) -> Vec { - let mut counts: BTreeMap = BTreeMap::new(); +impl<'a> RestoreGhostCommitOptions<'a> { + /// Creates restore options scoped to the provided repository path. + pub fn new(repo_path: &'a Path) -> Self { + Self { + repo_path, + ghost_snapshot: GhostSnapshotConfig::default(), + } + } + + pub fn ghost_snapshot(mut self, ghost_snapshot: GhostSnapshotConfig) -> Self { + self.ghost_snapshot = ghost_snapshot; + self + } + + /// Exclude untracked files larger than `bytes` from undo cleanup. + /// + /// These files are treated as "always preserve" to avoid deleting large local artifacts. + pub fn ignore_large_untracked_files(mut self, bytes: i64) -> Self { + if bytes > 0 { + self.ghost_snapshot.ignore_large_untracked_files = Some(bytes); + } else { + self.ghost_snapshot.ignore_large_untracked_files = None; + } + self + } + + /// Ignore untracked directories that contain at least `file_count` untracked files. + pub fn ignore_large_untracked_dirs(mut self, file_count: i64) -> Self { + if file_count > 0 { + self.ghost_snapshot.ignore_large_untracked_dirs = Some(file_count); + } else { + self.ghost_snapshot.ignore_large_untracked_dirs = None; + } + self + } +} + +fn detect_large_untracked_dirs( + files: &[PathBuf], + dirs: &[PathBuf], + threshold: Option, +) -> Vec { + let Some(threshold) = threshold else { + return Vec::new(); + }; + if threshold <= 0 { + return Vec::new(); + } + + let mut counts: BTreeMap = BTreeMap::new(); let mut sorted_dirs: Vec<&PathBuf> = dirs.iter().collect(); sorted_dirs.sort_by(|a, b| { @@ -129,7 +228,7 @@ fn detect_large_untracked_dirs(files: &[PathBuf], dirs: &[PathBuf]) -> Vec = counts .into_iter() - .filter(|(_, count)| *count >= LARGE_UNTRACKED_WARNING_THRESHOLD) + .filter(|(_, count)| *count >= threshold) .map(|(path, file_count)| LargeUntrackedDir { path, file_count }) .collect(); result.sort_by(|a, b| { @@ -165,22 +264,35 @@ pub fn capture_ghost_snapshot_report( let repo_root = resolve_repository_root(options.repo_path)?; let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); - let existing_untracked = - capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; + let force_include = prepare_force_include(repo_prefix.as_deref(), &options.force_include)?; + let existing_untracked = capture_existing_untracked( + repo_root.as_path(), + repo_prefix.as_deref(), + options.ghost_snapshot.ignore_large_untracked_files, + options.ghost_snapshot.ignore_large_untracked_dirs, + &force_include, + )?; - let warning_files = existing_untracked - .files + let warning_ignored_files = existing_untracked + .ignored_untracked_files .iter() - .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) + .map(|file| IgnoredUntrackedFile { + path: to_session_relative_path(file.path.as_path(), repo_prefix.as_deref()), + byte_size: file.byte_size, + }) .collect::>(); - let warning_dirs = existing_untracked - .dirs + let warning_ignored_dirs = existing_untracked + .ignored_large_untracked_dirs .iter() - .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) + .map(|dir| LargeUntrackedDir { + path: to_session_relative_path(dir.path.as_path(), repo_prefix.as_deref()), + file_count: dir.file_count, + }) .collect::>(); Ok(GhostSnapshotReport { - large_untracked_dirs: detect_large_untracked_dirs(&warning_files, &warning_dirs), + large_untracked_dirs: warning_ignored_dirs, + ignored_untracked_files: warning_ignored_files, }) } @@ -193,34 +305,44 @@ pub fn create_ghost_commit_with_report( let repo_root = resolve_repository_root(options.repo_path)?; let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); let parent = resolve_head(repo_root.as_path())?; - let existing_untracked = - capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; + let force_include = prepare_force_include(repo_prefix.as_deref(), &options.force_include)?; + let status_snapshot = capture_status_snapshot( + repo_root.as_path(), + repo_prefix.as_deref(), + options.ghost_snapshot.ignore_large_untracked_files, + options.ghost_snapshot.ignore_large_untracked_dirs, + &force_include, + )?; + let existing_untracked = status_snapshot.untracked; - let warning_files = existing_untracked - .files + let warning_ignored_files = existing_untracked + .ignored_untracked_files .iter() - .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) + .map(|file| IgnoredUntrackedFile { + path: to_session_relative_path(file.path.as_path(), repo_prefix.as_deref()), + byte_size: file.byte_size, + }) .collect::>(); - let warning_dirs = existing_untracked - .dirs + let large_untracked_dirs = existing_untracked + .ignored_large_untracked_dirs .iter() - .map(|path| to_session_relative_path(path, repo_prefix.as_deref())) + .map(|dir| LargeUntrackedDir { + path: to_session_relative_path(dir.path.as_path(), repo_prefix.as_deref()), + file_count: dir.file_count, + }) .collect::>(); - let large_untracked_dirs = detect_large_untracked_dirs(&warning_files, &warning_dirs); - - let normalized_force = options - .force_include - .iter() - .map(|path| normalize_relative_path(path)) - .collect::, _>>()?; - let force_include = - apply_repo_prefix_to_force_include(repo_prefix.as_deref(), &normalized_force); let index_tempdir = Builder::new().prefix("codex-git-index-").tempdir()?; let index_path = index_tempdir.path().join("index"); let base_env = vec![( OsString::from("GIT_INDEX_FILE"), OsString::from(index_path.as_os_str()), )]; + // Use a temporary index so snapshotting does not disturb the user's index state. + // Example plumbing sequence: + // GIT_INDEX_FILE=/tmp/index git read-tree HEAD + // GIT_INDEX_FILE=/tmp/index git add --all -- + // GIT_INDEX_FILE=/tmp/index git write-tree + // GIT_INDEX_FILE=/tmp/index git commit-tree -p -m "codex snapshot" // Pre-populate the temporary index with HEAD so unchanged tracked files // are included in the snapshot tree. @@ -232,12 +354,12 @@ pub fn create_ghost_commit_with_report( )?; } - let mut add_args = vec![OsString::from("add"), OsString::from("--all")]; - if let Some(prefix) = repo_prefix.as_deref() { - add_args.extend([OsString::from("--"), prefix.as_os_str().to_os_string()]); - } - - run_git_for_status(repo_root.as_path(), add_args, Some(base_env.as_slice()))?; + let mut index_paths = status_snapshot.tracked_paths; + index_paths.extend(existing_untracked.untracked_files_for_index.iter().cloned()); + let index_paths = dedupe_paths(index_paths); + // Stage tracked + new files into the temp index so write-tree reflects the working tree. + // We use `git add --all` to make deletions show up in the snapshot tree too. + add_paths_to_index(repo_root.as_path(), base_env.as_slice(), &index_paths)?; if !force_include.is_empty() { let mut args = Vec::with_capacity(force_include.len() + 2); args.push(OsString::from("add")); @@ -268,6 +390,8 @@ pub fn create_ghost_commit_with_report( result }; + // `git commit-tree` writes a detached commit object without updating refs, + // which keeps snapshots out of the user's branch history. // Retrieve commit ID. let commit_id = run_git_for_stdout( repo_root.as_path(), @@ -278,26 +402,46 @@ pub fn create_ghost_commit_with_report( let ghost_commit = GhostCommit::new( commit_id, parent, - existing_untracked.files, - existing_untracked.dirs, + merge_preserved_untracked_files( + existing_untracked.files, + &existing_untracked.ignored_untracked_files, + ), + merge_preserved_untracked_dirs( + existing_untracked.dirs, + &existing_untracked.ignored_large_untracked_dirs, + ), ); Ok(( ghost_commit, GhostSnapshotReport { large_untracked_dirs, + ignored_untracked_files: warning_ignored_files, }, )) } /// Restore the working tree to match the provided ghost commit. pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> { - ensure_git_repository(repo_path)?; + restore_ghost_commit_with_options(&RestoreGhostCommitOptions::new(repo_path), commit) +} - let repo_root = resolve_repository_root(repo_path)?; - let repo_prefix = repo_subdir(repo_root.as_path(), repo_path); - let current_untracked = - capture_existing_untracked(repo_root.as_path(), repo_prefix.as_deref())?; +/// Restore the working tree using the provided options. +pub fn restore_ghost_commit_with_options( + options: &RestoreGhostCommitOptions<'_>, + commit: &GhostCommit, +) -> Result<(), GitToolingError> { + ensure_git_repository(options.repo_path)?; + + let repo_root = resolve_repository_root(options.repo_path)?; + let repo_prefix = repo_subdir(repo_root.as_path(), options.repo_path); + let current_untracked = capture_existing_untracked( + repo_root.as_path(), + repo_prefix.as_deref(), + options.ghost_snapshot.ignore_large_untracked_files, + options.ghost_snapshot.ignore_large_untracked_dirs, + &[], + )?; restore_to_commit_inner(repo_root.as_path(), repo_prefix.as_deref(), commit.id())?; remove_new_untracked( repo_root.as_path(), @@ -323,6 +467,9 @@ fn restore_to_commit_inner( repo_prefix: Option<&Path>, commit_id: &str, ) -> Result<(), GitToolingError> { + // `git restore` resets both the index and working tree to the snapshot commit. + // Example: + // git restore --source --worktree --staged -- let mut restore_args = vec![ OsString::from("restore"), OsString::from("--source"), @@ -345,16 +492,30 @@ fn restore_to_commit_inner( struct UntrackedSnapshot { files: Vec, dirs: Vec, + untracked_files_for_index: Vec, + ignored_untracked_files: Vec, + ignored_large_untracked_dirs: Vec, + ignored_large_untracked_dir_files: Vec, } -/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`. -/// Returns the result as an `UntrackedSnapshot`. -fn capture_existing_untracked( +#[derive(Default)] +struct StatusSnapshot { + tracked_paths: Vec, + untracked: UntrackedSnapshot, +} + +/// Captures the working tree status under `repo_root`, optionally limited by `repo_prefix`. +/// Returns the result as a `StatusSnapshot`. +fn capture_status_snapshot( repo_root: &Path, repo_prefix: Option<&Path>, -) -> Result { + ignore_large_untracked_files: Option, + ignore_large_untracked_dirs: Option, + force_include: &[PathBuf], +) -> Result { // Ask git for the zero-delimited porcelain status so we can enumerate - // every untracked path (including ones filtered by prefix). + // tracked, untracked, and ignored entries (including ones filtered by prefix). + // This keeps the snapshot consistent without multiple git invocations. let mut args = vec![ OsString::from("status"), OsString::from("--porcelain=2"), @@ -368,45 +529,175 @@ fn capture_existing_untracked( let output = run_git_for_stdout_all(repo_root, args, None)?; if output.is_empty() { - return Ok(UntrackedSnapshot::default()); + return Ok(StatusSnapshot::default()); } - let mut snapshot = UntrackedSnapshot::default(); - // Each entry is of the form " " where code is '?' (untracked) - // or '!' (ignored); everything else is irrelevant to this snapshot. + let mut snapshot = StatusSnapshot::default(); + let mut untracked_files_for_dir_scan: Vec = Vec::new(); + let mut expect_rename_source = false; for entry in output.split('\0') { if entry.is_empty() { continue; } - let mut parts = entry.splitn(2, ' '); - let code = parts.next(); - let path_part = parts.next(); - let (Some(code), Some(path_part)) = (code, path_part) else { - continue; - }; - if code != "?" && code != "!" { - continue; - } - if path_part.is_empty() { + if expect_rename_source { + let normalized = normalize_relative_path(Path::new(entry))?; + snapshot.tracked_paths.push(normalized); + expect_rename_source = false; continue; } - let normalized = normalize_relative_path(Path::new(path_part))?; - if should_ignore_for_snapshot(&normalized) { - continue; + let record_type = entry.as_bytes().first().copied().unwrap_or(b' '); + match record_type { + b'?' | b'!' => { + let mut parts = entry.splitn(2, ' '); + let code = parts.next(); + let path_part = parts.next(); + let (Some(code), Some(path_part)) = (code, path_part) else { + continue; + }; + if path_part.is_empty() { + continue; + } + + let normalized = normalize_relative_path(Path::new(path_part))?; + if should_ignore_for_snapshot(&normalized) { + continue; + } + let absolute = repo_root.join(&normalized); + let is_dir = absolute.is_dir(); + if is_dir { + snapshot.untracked.dirs.push(normalized); + } else if code == "?" { + untracked_files_for_dir_scan.push(normalized.clone()); + if let Some(threshold) = ignore_large_untracked_files + && threshold > 0 + && !is_force_included(&normalized, force_include) + && let Ok(Some(byte_size)) = untracked_file_size(&absolute) + && byte_size > threshold + { + snapshot + .untracked + .ignored_untracked_files + .push(IgnoredUntrackedFile { + path: normalized, + byte_size, + }); + } else { + snapshot.untracked.files.push(normalized.clone()); + snapshot + .untracked + .untracked_files_for_index + .push(normalized); + } + } else { + snapshot.untracked.files.push(normalized); + } + } + b'1' => { + if let Some(path) = extract_status_path_after_fields(entry, 8) { + let normalized = normalize_relative_path(Path::new(path))?; + snapshot.tracked_paths.push(normalized); + } + } + b'2' => { + if let Some(path) = extract_status_path_after_fields(entry, 9) { + let normalized = normalize_relative_path(Path::new(path))?; + snapshot.tracked_paths.push(normalized); + } + expect_rename_source = true; + } + b'u' => { + if let Some(path) = extract_status_path_after_fields(entry, 10) { + let normalized = normalize_relative_path(Path::new(path))?; + snapshot.tracked_paths.push(normalized); + } + } + _ => {} } - let absolute = repo_root.join(&normalized); - let is_dir = absolute.is_dir(); - if is_dir { - snapshot.dirs.push(normalized); - } else { - snapshot.files.push(normalized); + } + + if let Some(threshold) = ignore_large_untracked_dirs + && threshold > 0 + { + let ignored_large_untracked_dirs = detect_large_untracked_dirs( + &untracked_files_for_dir_scan, + &snapshot.untracked.dirs, + Some(threshold), + ) + .into_iter() + .filter(|entry| !entry.path.as_os_str().is_empty() && entry.path != Path::new(".")) + .collect::>(); + + if !ignored_large_untracked_dirs.is_empty() { + let ignored_dir_paths = ignored_large_untracked_dirs + .iter() + .map(|entry| entry.path.as_path()) + .collect::>(); + + snapshot + .untracked + .files + .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); + snapshot + .untracked + .dirs + .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); + snapshot + .untracked + .untracked_files_for_index + .retain(|path| !ignored_dir_paths.iter().any(|dir| path.starts_with(dir))); + snapshot.untracked.ignored_untracked_files.retain(|file| { + !ignored_dir_paths + .iter() + .any(|dir| file.path.starts_with(dir)) + }); + + snapshot.untracked.ignored_large_untracked_dir_files = untracked_files_for_dir_scan + .into_iter() + .filter(|path| ignored_dir_paths.iter().any(|dir| path.starts_with(dir))) + .collect(); + snapshot.untracked.ignored_large_untracked_dirs = ignored_large_untracked_dirs; } } Ok(snapshot) } +/// Captures the untracked and ignored entries under `repo_root`, optionally limited by `repo_prefix`. +/// Returns the result as an `UntrackedSnapshot`. +fn capture_existing_untracked( + repo_root: &Path, + repo_prefix: Option<&Path>, + ignore_large_untracked_files: Option, + ignore_large_untracked_dirs: Option, + force_include: &[PathBuf], +) -> Result { + Ok(capture_status_snapshot( + repo_root, + repo_prefix, + ignore_large_untracked_files, + ignore_large_untracked_dirs, + force_include, + )? + .untracked) +} + +fn extract_status_path_after_fields(record: &str, fields_before_path: i64) -> Option<&str> { + if fields_before_path <= 0 { + return None; + } + let mut spaces = 0_i64; + for (idx, byte) in record.as_bytes().iter().enumerate() { + if *byte == b' ' { + spaces += 1; + if spaces == fields_before_path { + return record.get((idx + 1)..).filter(|path| !path.is_empty()); + } + } + } + None +} + fn should_ignore_for_snapshot(path: &Path) -> bool { path.components().any(|component| { if let Component::Normal(name) = component @@ -420,6 +711,103 @@ fn should_ignore_for_snapshot(path: &Path) -> bool { }) } +fn prepare_force_include( + repo_prefix: Option<&Path>, + force_include: &[PathBuf], +) -> Result, GitToolingError> { + let normalized_force = force_include + .iter() + .map(PathBuf::as_path) + .map(normalize_relative_path) + .collect::, _>>()?; + Ok(apply_repo_prefix_to_force_include( + repo_prefix, + &normalized_force, + )) +} + +fn is_force_included(path: &Path, force_include: &[PathBuf]) -> bool { + force_include + .iter() + .any(|candidate| path.starts_with(candidate.as_path())) +} + +fn untracked_file_size(path: &Path) -> io::Result> { + let Ok(metadata) = fs::symlink_metadata(path) else { + return Ok(None); + }; + + let Ok(len_i64) = i64::try_from(metadata.len()) else { + return Ok(Some(i64::MAX)); + }; + Ok(Some(len_i64)) +} + +fn add_paths_to_index( + repo_root: &Path, + env: &[(OsString, OsString)], + paths: &[PathBuf], +) -> Result<(), GitToolingError> { + if paths.is_empty() { + return Ok(()); + } + + let chunk_size = usize::try_from(64_i64).unwrap_or(1); + for chunk in paths.chunks(chunk_size) { + let mut args = vec![ + OsString::from("add"), + OsString::from("--all"), + OsString::from("--"), + ]; + args.extend(chunk.iter().map(|path| path.as_os_str().to_os_string())); + // Chunk the argv to avoid oversized command lines on large repos. + run_git_for_status(repo_root, args, Some(env))?; + } + + Ok(()) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for path in paths { + if seen.insert(path.clone()) { + result.push(path); + } + } + result +} + +fn merge_preserved_untracked_files( + mut files: Vec, + ignored: &[IgnoredUntrackedFile], +) -> Vec { + if ignored.is_empty() { + return files; + } + + files.extend(ignored.iter().map(|entry| entry.path.clone())); + files +} + +fn merge_preserved_untracked_dirs( + mut dirs: Vec, + ignored_large_dirs: &[LargeUntrackedDir], +) -> Vec { + if ignored_large_dirs.is_empty() { + return dirs; + } + + for entry in ignored_large_dirs { + if dirs.iter().any(|dir| dir == &entry.path) { + continue; + } + dirs.push(entry.path.clone()); + } + + dirs +} + /// Removes untracked files and directories that were not present when the snapshot was captured. fn remove_new_untracked( repo_root: &Path, @@ -514,6 +902,7 @@ mod tests { use crate::operations::run_git_for_stdout; use assert_matches::assert_matches; use pretty_assertions::assert_eq; + use std::fs::File; use std::process::Command; use walkdir::WalkDir; @@ -544,6 +933,14 @@ mod tests { run_git_in(repo, &["config", "core.autocrlf", "false"]); } + fn create_sparse_file(path: &Path, bytes: i64) -> io::Result<()> { + let file_len = + u64::try_from(bytes).map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + let file = File::create(path)?; + file.set_len(file_len)?; + Ok(()) + } + #[test] /// Verifies a ghost commit can be created and restored end to end. fn create_and_restore_roundtrip() -> Result<(), GitToolingError> { @@ -615,6 +1012,62 @@ mod tests { Ok(()) } + #[test] + fn snapshot_ignores_large_untracked_files() -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join("tracked.txt"), "contents\n")?; + run_git_in(repo, &["add", "tracked.txt"]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + let big = repo.join("big.bin"); + let big_size = 2 * 1024 * 1024; + create_sparse_file(&big, big_size)?; + + let (ghost, report) = create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(repo).ignore_large_untracked_files(1024), + )?; + assert!(ghost.parent().is_some()); + assert_eq!( + report.ignored_untracked_files, + vec![IgnoredUntrackedFile { + path: PathBuf::from("big.bin"), + byte_size: big_size, + }] + ); + + let exists_in_commit = Command::new("git") + .current_dir(repo) + .args(["cat-file", "-e", &format!("{}:big.bin", ghost.id())]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + assert!(!exists_in_commit); + + std::fs::write(repo.join("ephemeral.txt"), "temp\n")?; + restore_ghost_commit(repo, &ghost)?; + assert!( + big.exists(), + "big.bin should be preserved during undo cleanup" + ); + assert!(!repo.join("ephemeral.txt").exists()); + + Ok(()) + } + #[test] fn create_snapshot_reports_large_untracked_dirs() -> Result<(), GitToolingError> { let temp = tempfile::tempdir()?; @@ -638,7 +1091,8 @@ mod tests { let models = repo.join("models"); std::fs::create_dir(&models)?; - for idx in 0..(LARGE_UNTRACKED_WARNING_THRESHOLD + 1) { + let threshold = DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS; + for idx in 0..(threshold + 1) { let file = models.join(format!("weights-{idx}.bin")); std::fs::write(file, "data\n")?; } @@ -650,10 +1104,83 @@ mod tests { report.large_untracked_dirs, vec![LargeUntrackedDir { path: PathBuf::from("models"), - file_count: LARGE_UNTRACKED_WARNING_THRESHOLD + 1, + file_count: threshold + 1, }] ); + let exists_in_commit = Command::new("git") + .current_dir(repo) + .args([ + "cat-file", + "-e", + &format!("{}:models/weights-0.bin", ghost.id()), + ]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + assert!(!exists_in_commit); + + std::fs::write(repo.join("ephemeral.txt"), "temp\n")?; + restore_ghost_commit(repo, &ghost)?; + assert!( + repo.join("models/weights-0.bin").exists(), + "ignored untracked directories should be preserved during undo cleanup" + ); + assert!(!repo.join("ephemeral.txt").exists()); + + Ok(()) + } + + #[test] + fn restore_preserves_large_untracked_dirs_when_threshold_disabled() + -> Result<(), GitToolingError> { + let temp = tempfile::tempdir()?; + let repo = temp.path(); + init_test_repo(repo); + + std::fs::write(repo.join("tracked.txt"), "contents\n")?; + run_git_in(repo, &["add", "tracked.txt"]); + run_git_in( + repo, + &[ + "-c", + "user.name=Tester", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "initial", + ], + ); + + let models = repo.join("models"); + std::fs::create_dir(&models)?; + let threshold: i64 = 2; + for idx in 0..(threshold + 1) { + let file = models.join(format!("weights-{idx}.bin")); + std::fs::write(file, "data\n")?; + } + + let snapshot_config = GhostSnapshotConfig { + ignore_large_untracked_files: Some(DEFAULT_IGNORE_LARGE_UNTRACKED_FILES), + ignore_large_untracked_dirs: Some(threshold), + }; + let (ghost, _report) = create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(repo).ghost_snapshot(snapshot_config), + )?; + + std::fs::write(repo.join("ephemeral.txt"), "temp\n")?; + restore_ghost_commit_with_options( + &RestoreGhostCommitOptions::new(repo).ignore_large_untracked_dirs(0), + &ghost, + )?; + + assert!( + repo.join("models/weights-0.bin").exists(), + "ignored untracked directories should be preserved during undo cleanup, even when the threshold is disabled at restore time" + ); + assert!(!repo.join("ephemeral.txt").exists()); + Ok(()) } @@ -847,12 +1374,14 @@ mod tests { // Create a large untracked tree nested under the tracked src directory. let generated = src.join("generated").join("cache"); std::fs::create_dir_all(&generated)?; - for idx in 0..(LARGE_UNTRACKED_WARNING_THRESHOLD + 1) { + let threshold = DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS; + for idx in 0..(threshold + 1) { let file = generated.join(format!("file-{idx}.bin")); std::fs::write(file, "data\n")?; } - let (_, report) = create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; + let (ghost, report) = + create_ghost_commit_with_report(&CreateGhostCommitOptions::new(repo))?; assert_eq!(report.large_untracked_dirs.len(), 1); let entry = &report.large_untracked_dirs[0]; assert_ne!(entry.path, PathBuf::from("src")); @@ -861,7 +1390,19 @@ mod tests { "unexpected path for large untracked directory: {}", entry.path.display() ); - assert_eq!(entry.file_count, LARGE_UNTRACKED_WARNING_THRESHOLD + 1); + assert_eq!(entry.file_count, threshold + 1); + + let exists_in_commit = Command::new("git") + .current_dir(repo) + .args([ + "cat-file", + "-e", + &format!("{}:src/generated/cache/file-0.bin", ghost.id()), + ]) + .status() + .map(|status| status.success()) + .unwrap_or(false); + assert!(!exists_in_commit); Ok(()) } diff --git a/codex-rs/utils/git/src/lib.rs b/codex-rs/utils/git/src/lib.rs index 7dca369062..c4db10fc69 100644 --- a/codex-rs/utils/git/src/lib.rs +++ b/codex-rs/utils/git/src/lib.rs @@ -17,12 +17,16 @@ pub use apply::stage_paths; pub use branch::merge_base_with_head; pub use errors::GitToolingError; pub use ghost_commits::CreateGhostCommitOptions; +pub use ghost_commits::GhostSnapshotConfig; pub use ghost_commits::GhostSnapshotReport; +pub use ghost_commits::IgnoredUntrackedFile; pub use ghost_commits::LargeUntrackedDir; +pub use ghost_commits::RestoreGhostCommitOptions; pub use ghost_commits::capture_ghost_snapshot_report; pub use ghost_commits::create_ghost_commit; pub use ghost_commits::create_ghost_commit_with_report; pub use ghost_commits::restore_ghost_commit; +pub use ghost_commits::restore_ghost_commit_with_options; pub use ghost_commits::restore_to_commit; pub use platform::create_symlink; use schemars::JsonSchema; diff --git a/docs/config.md b/docs/config.md index ca4ba015e9..0b0d13d19a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -49,6 +49,7 @@ Supported features: | `ghost_commit` | false | Experimental | Create a ghost commit each turn | | `enable_experimental_windows_sandbox` | false | Experimental | Use the Windows restricted-token sandbox | | `tui2` | false | Experimental | Use the experimental TUI v2 (viewport) implementation | +| `skills` | false | Experimental | Enable discovery and injection of skills | Notes: @@ -189,7 +190,7 @@ model = "mistral" ### model_reasoning_effort -If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5.1-codex-max`, `gpt-5.1`, `gpt-5.1-codex`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: +If the selected model is known to support reasoning (for example: `o3`, `o4-mini`, `codex-*`, `gpt-5.1-codex-max`, `gpt-5.1`, `gpt-5.1-codex`, `gpt5-2`), reasoning is enabled by default when using the Responses API. As explained in the [OpenAI Platform documentation](https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning), this can be set to: - `"minimal"` - `"low"` @@ -943,6 +944,8 @@ Valid values: | `tui.animations` | boolean | Enable terminal animations (welcome screen, shimmer, spinner). Defaults to true; set to `false` to disable visual motion. | | `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. | | `features.` | boolean | See [feature flags](#feature-flags) for details | +| `ghost_snapshot.ignore_large_untracked_files` | number | Exclude untracked files larger than this many bytes from ghost snapshots (default: 10 MiB). Set to `0` to disable. | +| `ghost_snapshot.ignore_large_untracked_dirs` | number | Ignore untracked directories with at least this many files (default: 200). Set to `0` to disable. | | `mcp_servers..command` | string | MCP server launcher command (stdio servers only). | | `mcp_servers..args` | array | MCP server args (stdio servers only). | | `mcp_servers..env` | map | MCP server env vars (stdio servers only). | @@ -974,7 +977,7 @@ Valid values: | `hide_agent_reasoning` | boolean | Hide model reasoning events. | | `check_for_update_on_startup` | boolean | Check for Codex updates on startup (default: true). Set to `false` only if updates are centrally managed. | | `show_raw_agent_reasoning` | boolean | Show raw reasoning (when available). | -| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high` | Responses API reasoning effort. | +| `model_reasoning_effort` | `minimal` \| `low` \| `medium` \| `high`\|`xhigh` | Responses API reasoning effort. | | `model_reasoning_summary` | `auto` \| `concise` \| `detailed` \| `none` | Reasoning summaries. | | `model_verbosity` | `low` \| `medium` \| `high` | GPT‑5 text verbosity (Responses API). | | `model_supports_reasoning_summaries` | boolean | Force‑enable reasoning summaries. | diff --git a/docs/example-config.md b/docs/example-config.md index f1d36cd453..0d09a61869 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -220,6 +220,7 @@ view_image_tool = true web_search_request = false ghost_commit = false enable_experimental_windows_sandbox = false +skills = false ################################################################################ # Experimental toggles (legacy; prefer [features]) diff --git a/docs/skills.md b/docs/skills.md index 81bc278797..5a9f17f0b0 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -4,6 +4,19 @@ Codex can automatically discover reusable "skills" you keep on disk. A skill is a small bundle with a name, a short description (what it does and when to use it), and an optional body of instructions you can open when needed. Codex injects only the name, description, and file path into the runtime context; the body stays on disk. +## Enable skills + +Skills are behind the experimental `skills` feature flag and are disabled by default. + +- Enable in config (preferred): add the following to `$CODEX_HOME/config.toml` (usually `~/.codex/config.toml`) and restart Codex: + + ```toml + [features] + skills = true + ``` + +- Enable for a single run: launch Codex with `codex --enable skills` + ## Where skills live - Location (v1): `~/.codex/skills/**/SKILL.md` (recursive). Hidden entries and symlinks are skipped. Only files named exactly `SKILL.md` count. @@ -23,6 +36,11 @@ Codex can automatically discover reusable "skills" you keep on disk. A skill is - If valid skills exist, Codex appends a runtime-only `## Skills` section after `AGENTS.md`, one bullet per skill: `- : (file: /absolute/path/to/SKILL.md)`. - If no valid skills exist, the section is omitted. On-disk files are never modified. +## Using skills + +- Mention a skill by name in a message using `$`. +- In the TUI, you can also use `/skills` to browse and insert skills. + ## Validation and errors - Invalid skills (missing/invalid YAML, empty/over-length fields) trigger a blocking, dismissible startup modal in the TUI that lists each path and error. Errors are also logged. You can dismiss to continue (invalid skills are ignored) or exit. Fix SKILL.md files and restart to clear the modal. diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 6961461d42..ed5b4dc85a 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -10,23 +10,24 @@ Slash commands are special commands you can type that start with `/`. Control Codex’s behavior during an interactive session with slash commands. -| Command | Purpose | -| ------------ | ----------------------------------------------------------- | -| `/model` | choose what model and reasoning effort to use | -| `/approvals` | choose what Codex can do without approval | -| `/review` | review my current changes and find issues | -| `/new` | start a new chat during a conversation | -| `/resume` | resume an old chat | -| `/init` | create an AGENTS.md file with instructions for Codex | -| `/compact` | summarize conversation to prevent hitting the context limit | -| `/undo` | ask Codex to undo a turn | -| `/diff` | show git diff (including untracked files) | -| `/mention` | mention a file | -| `/status` | show current session configuration and token usage | -| `/mcp` | list configured MCP tools | -| `/logout` | log out of Codex | -| `/quit` | exit Codex | -| `/exit` | exit Codex | -| `/feedback` | send logs to maintainers | +| Command | Purpose | +| ------------ | -------------------------------------------------------------------------- | +| `/model` | choose what model and reasoning effort to use | +| `/approvals` | choose what Codex can do without approval | +| `/review` | review my current changes and find issues | +| `/new` | start a new chat during a conversation | +| `/resume` | resume an old chat | +| `/init` | create an AGENTS.md file with instructions for Codex | +| `/compact` | summarize conversation to prevent hitting the context limit | +| `/undo` | ask Codex to undo a turn | +| `/diff` | show git diff (including untracked files) | +| `/mention` | mention a file | +| `/status` | show current session configuration and token usage | +| `/mcp` | list configured MCP tools | +| `/skills` | browse and insert skills (experimental; see [docs/skills.md](./skills.md)) | +| `/logout` | log out of Codex | +| `/quit` | exit Codex | +| `/exit` | exit Codex | +| `/feedback` | send logs to maintainers | --- diff --git a/shell-tool-mcp/README.md b/shell-tool-mcp/README.md index ba9d65a690..16a8492656 100644 --- a/shell-tool-mcp/README.md +++ b/shell-tool-mcp/README.md @@ -1,31 +1,95 @@ # @openai/codex-shell-tool-mcp -This package wraps the `codex-exec-mcp-server` binary and its helpers so that the shell MCP can be invoked via `npx @openai/codex-shell-tool-mcp`. It bundles: +**Note: This MCP server is still experimental. When using it with Codex CLI, ensure the CLI version matches the MCP server version.** + +`@openai/codex-shell-tool-mcp` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed instance of Bash. This special instance of Bash intercepts requests to spawn new processes (specifically, [`execve(2)`](https://man7.org/linux/man-pages/man2/execve.2.html) calls). For each call, it makes a request back to the MCP server to determine whether to allow the proposed command to execute. It also has the option of _escalating_ the command to run unprivileged outside of the sandbox governing the Bash process. + +The user can use [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview) files to define how a command should be handled. The action to take is determined by the `decision` parameter of a matching rule as follows: + +- `allow`: the command will be _escalated_ and run outside the sandbox +- `prompt`: the command will be subject to human approval via an [MCP elicitation](https://modelcontextprotocol.io/specification/draft/client/elicitation) (it will run _escalated_ if approved) +- `forbidden`: the command will fail with exit code `1` and an error message will be written to `stderr` + +Commands that do not match an explicit rule in `.rules` will be allowed to run as-is, though they will still be subject to the sandbox applied to the parent Bash process. + +## Motivation + +When a software agent asks if it is safe to run a command like `ls`, without more context, it is unclear whether it will result in executing `/bin/ls`. Consider: + +- There could be another executable named `ls` that appears before `/bin/ls` on the `$PATH`. +- `ls` could be mapped to a shell alias or function. + +Because `@openai/codex-shell-tool-mcp` intercepts `execve(2)` calls directly, it _always_ knows the full path to the program being executed. In turn, this makes it possible to provide stronger guarantees on how [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview) are enforced. + +## Usage + +First, verify that you can download and run the MCP executable: + +```bash +npx -y @openai/codex-shell-tool-mcp --version +``` + +To test out the MCP with a one-off invocation of Codex CLI, it is important to _disable_ the default shell tool in addition to enabling the MCP so Codex has exactly one shell-like tool available to it: + +```bash +codex --disable shell_tool \ + --config 'mcp_servers.bash={command = "npx", args = ["-y", "@openai/codex-shell-tool-mcp"]}' +``` + +To configure this permanently so you can use the MCP while running `codex` without additional command-line flags, add the following to your `~/.codex/config.toml`: + +```toml +[features] +shell_tool = false + +[mcp_servers.shell-tool] +command = "npx" +args = ["-y", "@openai/codex-shell-tool-mcp"] +``` + +Note when the `@openai/codex-shell-tool-mcp` launcher runs, it selects the appropriate native binary to run based on the host OS/architecture. For the Bash wrapper, it inspects `/etc/os-release` on Linux or the Darwin major version on macOS to try to find the best match it has available. See [`bashSelection.ts`](https://github.com/openai/codex/blob/main/shell-tool-mcp/src/bashSelection.ts) for details. + +## MCP Client Requirements + +This MCP server is designed to be used with [Codex](https://developers.openai.com/codex/cli), as it declares the following `capability` that Codex supports when acting as an MCP client: + +```json +{ + "capabilities": { + "experimental": { + "codex/sandbox-state": { + "version": "1.0.0" + } + } + } +} +``` + +This capability means the MCP server honors notifications like the following to update the sandbox policy the MCP server uses when spawning Bash: + +```json +{ + "method": "codex/sandbox-state/update", + "params": { + "sandboxPolicy": { + "type": "workspace-write", + "writable_roots": ["/home/user/code/codex"], + "network_access": false, + "exclude_tmpdir_env_var": false, + "exclude_slash_tmp": false + } + } +} +``` + +The Codex harness (used by the CLI and the VS Code extension) sends such notifications to MCP servers that declare the `codex/sandbox-state` capability. + +## Package Contents + +This package wraps the `codex-exec-mcp-server` binary and its helpers so that the shell MCP can be invoked via `npx -y @openai/codex-shell-tool-mcp`. It bundles: - `codex-exec-mcp-server` and `codex-execve-wrapper` built for macOS (arm64, x64) and Linux (musl arm64, musl x64). - A patched Bash that honors `BASH_EXEC_WRAPPER`, built for multiple glibc baselines (Ubuntu 24.04/22.04/20.04, Debian 12/11, CentOS-like 9) and macOS (15/14/13). - A launcher (`bin/mcp-server.js`) that picks the correct binaries for the current `process.platform` / `process.arch`, specifying `--execve` and `--bash` for the MCP, as appropriate. -## Usage - -```bash -npx @openai/codex-shell-tool-mcp --help -``` - -The launcher selects a Rust target triple based on the host and chooses the closest Bash variant by inspecting `/etc/os-release` on Linux or the Darwin major version on macOS. - -## Patched Bash - -We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `BASH_EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually: - -```bash -git clone https://github.com/bminor/bash -git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b -git apply /path/to/patches/bash-exec-wrapper.patch -./configure --without-bash-malloc -make -j"$(nproc)" -``` - -## Release workflow - -`.github/workflows/shell-tool-mcp.yml` builds the Rust binaries, compiles the patched Bash variants, assembles the `vendor/` tree, and creates `codex-shell-tool-mcp-npm-.tgz` for inclusion in the Rust GitHub Release. When the version is a stable or alpha tag, the workflow also publishes the tarball to npm using OIDC. The workflow is invoked from `rust-release.yml` so the package ships alongside other Codex artifacts. +See [the README in the Codex repo](https://github.com/openai/codex/blob/main/codex-rs/exec-server/README.md) for details.