mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
2 Commits
patch-1
...
shell-proc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb6f67d284 | ||
|
|
9238c58460 |
23
.github/workflows/cla.yml
vendored
23
.github/workflows/cla.yml
vendored
@@ -16,27 +16,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: contributor-assistant/github-action@v2.6.1
|
||||
# Run on close only if the PR was merged. This will lock the PR to preserve
|
||||
# the CLA agreement. We don't want to lock PRs that have been closed without
|
||||
# merging because the contributor may want to respond with additional comments.
|
||||
# This action has a "lock-pullrequest-aftermerge" option that can be set to false,
|
||||
# but that would unconditionally skip locking even in cases where the PR was merged.
|
||||
if: |
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
(
|
||||
github.event.action == 'opened' ||
|
||||
github.event.action == 'synchronize' ||
|
||||
(github.event.action == 'closed' && github.event.pull_request.merged == true)
|
||||
)
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'
|
||||
)
|
||||
)
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
||||
2
.github/workflows/codespell.yml
vendored
2
.github/workflows/codespell.yml
vendored
@@ -22,6 +22,6 @@ jobs:
|
||||
- name: Annotate locations with typos
|
||||
uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||
uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1
|
||||
with:
|
||||
ignore_words_file: .codespellignore
|
||||
|
||||
10
.github/workflows/rust-ci.yml
vendored
10
.github/workflows/rust-ci.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dtolnay/rust-toolchain@1.90
|
||||
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
|
||||
with:
|
||||
tool: cargo-shear
|
||||
version: 1.5.1
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
# Install and restore sccache cache
|
||||
- name: Install sccache
|
||||
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
|
||||
with:
|
||||
tool: sccache
|
||||
version: 0.7.5
|
||||
@@ -228,7 +228,7 @@ jobs:
|
||||
|
||||
- name: Install cargo-chef
|
||||
if: ${{ matrix.profile == 'release' }}
|
||||
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
|
||||
with:
|
||||
tool: cargo-chef
|
||||
version: 0.1.71
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
|
||||
|
||||
- name: Install sccache
|
||||
uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
|
||||
with:
|
||||
tool: sccache
|
||||
version: 0.7.5
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-
|
||||
sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-
|
||||
|
||||
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
|
||||
- uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2
|
||||
with:
|
||||
tool: nextest
|
||||
version: 0.9.103
|
||||
|
||||
15
.github/workflows/rust-release.yml
vendored
15
.github/workflows/rust-release.yml
vendored
@@ -295,15 +295,6 @@ jobs:
|
||||
# ${{ matrix.target }}
|
||||
dest="dist/${{ matrix.target }}"
|
||||
|
||||
# We want to ship the raw Windows executables in the GitHub Release
|
||||
# in addition to the compressed archives. Keep the originals for
|
||||
# Windows targets; remove them elsewhere to limit the number of
|
||||
# artifacts that end up in the GitHub Release.
|
||||
keep_originals=false
|
||||
if [[ "${{ matrix.runner }}" == windows* ]]; then
|
||||
keep_originals=true
|
||||
fi
|
||||
|
||||
# For compatibility with environments that lack the `zstd` tool we
|
||||
# additionally create a `.tar.gz` for all platforms and `.zip` for
|
||||
# Windows alongside every single binary that we publish. The end result is:
|
||||
@@ -333,11 +324,7 @@ jobs:
|
||||
|
||||
# Also create .zst (existing behaviour) *and* remove the original
|
||||
# uncompressed binary to keep the directory small.
|
||||
zstd_args=(-T0 -19)
|
||||
if [[ "${keep_originals}" == false ]]; then
|
||||
zstd_args+=(--rm)
|
||||
fi
|
||||
zstd "${zstd_args[@]}" "$dest/$base"
|
||||
zstd -T0 -19 --rm "$dest/$base"
|
||||
done
|
||||
|
||||
- name: Remove signing keychain
|
||||
|
||||
195
codex-rs/Cargo.lock
generated
195
codex-rs/Cargo.lock
generated
@@ -211,7 +211,6 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -238,44 +237,46 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "askama"
|
||||
version = "0.14.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
|
||||
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
|
||||
dependencies = [
|
||||
"askama_derive",
|
||||
"itoa",
|
||||
"askama_escape",
|
||||
"humansize",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_derive"
|
||||
version = "0.14.0"
|
||||
version = "0.12.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
|
||||
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
|
||||
dependencies = [
|
||||
"askama_parser",
|
||||
"basic-toml",
|
||||
"memchr",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc-hash 2.1.1",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"syn 2.0.104",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.14.0"
|
||||
name = "askama_escape"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
|
||||
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
|
||||
|
||||
[[package]]
|
||||
name = "askama_parser"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"winnow",
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -980,23 +981,21 @@ dependencies = [
|
||||
"codex-mcp-server",
|
||||
"codex-process-hardening",
|
||||
"codex-protocol",
|
||||
"codex-protocol-ts",
|
||||
"codex-responses-api-proxy",
|
||||
"codex-rmcp-client",
|
||||
"codex-stdio-to-uds",
|
||||
"codex-tui",
|
||||
"codex-windows-sandbox",
|
||||
"ctor 0.5.0",
|
||||
"libc",
|
||||
"owo-colors",
|
||||
"predicates",
|
||||
"pretty_assertions",
|
||||
"regex-lite",
|
||||
"serde_json",
|
||||
"supports-color",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1067,7 +1066,6 @@ dependencies = [
|
||||
"chrono",
|
||||
"codex-app-server-protocol",
|
||||
"codex-apply-patch",
|
||||
"codex-arg0",
|
||||
"codex-async-utils",
|
||||
"codex-file-search",
|
||||
"codex-git",
|
||||
@@ -1082,7 +1080,6 @@ dependencies = [
|
||||
"codex-windows-sandbox",
|
||||
"core-foundation 0.9.4",
|
||||
"core_test_support",
|
||||
"ctor 0.5.0",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"env-flags",
|
||||
@@ -1368,6 +1365,16 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-protocol-ts"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-app-server-protocol",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-responses-api-proxy"
|
||||
version = "0.0.0"
|
||||
@@ -1559,7 +1566,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs-next",
|
||||
"dunce",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2884,6 +2890,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humansize"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.7.0"
|
||||
@@ -3527,6 +3542,12 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.6"
|
||||
@@ -4293,16 +4314,6 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_pipe"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.2.2"
|
||||
@@ -4450,7 +4461,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"indexmap 2.12.0",
|
||||
"quick-xml 0.38.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -4679,15 +4690,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.0"
|
||||
@@ -6725,18 +6727,6 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8"
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom",
|
||||
"once_cell",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -7074,76 +7064,6 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs",
|
||||
"rustix 1.0.8",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"rustix 1.0.8",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efd94963ed43cf9938a090ca4f7da58eb55325ec8200c3848963e98dc25b78ec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml 0.37.5",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.77"
|
||||
@@ -7715,25 +7635,6 @@ dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5ff8d0e60065f549fafd9d6cb626203ea64a798186c80d8e7df4f8af56baeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 0.38.44",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.2"
|
||||
@@ -7902,9 +7803,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
@@ -25,6 +25,7 @@ members = [
|
||||
"ollama",
|
||||
"process-hardening",
|
||||
"protocol",
|
||||
"protocol-ts",
|
||||
"rmcp-client",
|
||||
"responses-api-proxy",
|
||||
"stdio-to-uds",
|
||||
@@ -74,6 +75,7 @@ codex-ollama = { path = "ollama" }
|
||||
codex-otel = { path = "otel" }
|
||||
codex-process-hardening = { path = "process-hardening" }
|
||||
codex-protocol = { path = "protocol" }
|
||||
codex-protocol-ts = { path = "protocol-ts" }
|
||||
codex-responses-api-proxy = { path = "responses-api-proxy" }
|
||||
codex-rmcp-client = { path = "rmcp-client" }
|
||||
codex-stdio-to-uds = { path = "stdio-to-uds" }
|
||||
@@ -94,8 +96,8 @@ mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
allocative = "0.3.3"
|
||||
ansi-to-tui = "7.0.0"
|
||||
anyhow = "1"
|
||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||
askama = "0.14"
|
||||
arboard = "3"
|
||||
askama = "0.12"
|
||||
assert_cmd = "2"
|
||||
assert_matches = "1.5.0"
|
||||
async-channel = "2.3.1"
|
||||
@@ -211,7 +213,7 @@ which = "6"
|
||||
wildmatch = "2.5.0"
|
||||
|
||||
wiremock = "0.6"
|
||||
zeroize = "1.8.2"
|
||||
zeroize = "1.8.1"
|
||||
|
||||
[workspace.lints]
|
||||
rust = {}
|
||||
|
||||
@@ -58,7 +58,7 @@ To test to see what happens when a command is run under the sandbox provided by
|
||||
|
||||
```
|
||||
# macOS
|
||||
codex sandbox macos [--full-auto] [--log-denials] [COMMAND]...
|
||||
codex sandbox macos [--full-auto] [COMMAND]...
|
||||
|
||||
# Linux
|
||||
codex sandbox linux [--full-auto] [COMMAND]...
|
||||
@@ -67,7 +67,7 @@ codex sandbox linux [--full-auto] [COMMAND]...
|
||||
codex sandbox windows [--full-auto] [COMMAND]...
|
||||
|
||||
# Legacy aliases
|
||||
codex debug seatbelt [--full-auto] [--log-denials] [COMMAND]...
|
||||
codex debug seatbelt [--full-auto] [COMMAND]...
|
||||
codex debug landlock [--full-auto] [COMMAND]...
|
||||
```
|
||||
|
||||
|
||||
@@ -92,8 +92,6 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
{
|
||||
let status = Command::new(prettier_bin)
|
||||
.arg("--write")
|
||||
.arg("--log-level")
|
||||
.arg("warn")
|
||||
.args(ts_files.iter().map(|p| p.as_os_str()))
|
||||
.status()
|
||||
.with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?;
|
||||
@@ -668,8 +666,6 @@ fn ts_files_in_recursive(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Generate an index.ts file that re-exports all generated types.
|
||||
/// This allows consumers to import all types from a single file.
|
||||
fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
let mut stems: Vec<String> = ts_files_in(out_dir)?
|
||||
|
||||
@@ -46,7 +46,7 @@ macro_rules! client_request_definitions {
|
||||
(
|
||||
$(
|
||||
$(#[$variant_meta:meta])*
|
||||
$variant:ident $(=> $wire:literal)? {
|
||||
$variant:ident {
|
||||
params: $(#[$params_meta:meta])* $params:ty,
|
||||
response: $response:ty,
|
||||
}
|
||||
@@ -58,7 +58,6 @@ macro_rules! client_request_definitions {
|
||||
pub enum ClientRequest {
|
||||
$(
|
||||
$(#[$variant_meta])*
|
||||
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
|
||||
$variant {
|
||||
#[serde(rename = "id")]
|
||||
request_id: RequestId,
|
||||
@@ -102,78 +101,105 @@ macro_rules! client_request_definitions {
|
||||
}
|
||||
|
||||
client_request_definitions! {
|
||||
Initialize {
|
||||
params: v1::InitializeParams,
|
||||
response: v1::InitializeResponse,
|
||||
},
|
||||
|
||||
/// NEW APIs
|
||||
// Thread lifecycle
|
||||
ThreadStart => "thread/start" {
|
||||
#[serde(rename = "thread/start")]
|
||||
#[ts(rename = "thread/start")]
|
||||
ThreadStart {
|
||||
params: v2::ThreadStartParams,
|
||||
response: v2::ThreadStartResponse,
|
||||
},
|
||||
ThreadResume => "thread/resume" {
|
||||
#[serde(rename = "thread/resume")]
|
||||
#[ts(rename = "thread/resume")]
|
||||
ThreadResume {
|
||||
params: v2::ThreadResumeParams,
|
||||
response: v2::ThreadResumeResponse,
|
||||
},
|
||||
ThreadArchive => "thread/archive" {
|
||||
#[serde(rename = "thread/archive")]
|
||||
#[ts(rename = "thread/archive")]
|
||||
ThreadArchive {
|
||||
params: v2::ThreadArchiveParams,
|
||||
response: v2::ThreadArchiveResponse,
|
||||
},
|
||||
ThreadList => "thread/list" {
|
||||
#[serde(rename = "thread/list")]
|
||||
#[ts(rename = "thread/list")]
|
||||
ThreadList {
|
||||
params: v2::ThreadListParams,
|
||||
response: v2::ThreadListResponse,
|
||||
},
|
||||
ThreadCompact => "thread/compact" {
|
||||
#[serde(rename = "thread/compact")]
|
||||
#[ts(rename = "thread/compact")]
|
||||
ThreadCompact {
|
||||
params: v2::ThreadCompactParams,
|
||||
response: v2::ThreadCompactResponse,
|
||||
},
|
||||
TurnStart => "turn/start" {
|
||||
#[serde(rename = "turn/start")]
|
||||
#[ts(rename = "turn/start")]
|
||||
TurnStart {
|
||||
params: v2::TurnStartParams,
|
||||
response: v2::TurnStartResponse,
|
||||
},
|
||||
TurnInterrupt => "turn/interrupt" {
|
||||
#[serde(rename = "turn/interrupt")]
|
||||
#[ts(rename = "turn/interrupt")]
|
||||
TurnInterrupt {
|
||||
params: v2::TurnInterruptParams,
|
||||
response: v2::TurnInterruptResponse,
|
||||
},
|
||||
|
||||
ModelList => "model/list" {
|
||||
#[serde(rename = "model/list")]
|
||||
#[ts(rename = "model/list")]
|
||||
ModelList {
|
||||
params: v2::ModelListParams,
|
||||
response: v2::ModelListResponse,
|
||||
},
|
||||
|
||||
LoginAccount => "account/login/start" {
|
||||
#[serde(rename = "account/login/start")]
|
||||
#[ts(rename = "account/login/start")]
|
||||
LoginAccount {
|
||||
params: v2::LoginAccountParams,
|
||||
response: v2::LoginAccountResponse,
|
||||
},
|
||||
|
||||
CancelLoginAccount => "account/login/cancel" {
|
||||
#[serde(rename = "account/login/cancel")]
|
||||
#[ts(rename = "account/login/cancel")]
|
||||
CancelLoginAccount {
|
||||
params: v2::CancelLoginAccountParams,
|
||||
response: v2::CancelLoginAccountResponse,
|
||||
},
|
||||
|
||||
LogoutAccount => "account/logout" {
|
||||
#[serde(rename = "account/logout")]
|
||||
#[ts(rename = "account/logout")]
|
||||
LogoutAccount {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
response: v2::LogoutAccountResponse,
|
||||
},
|
||||
|
||||
GetAccountRateLimits => "account/rateLimits/read" {
|
||||
#[serde(rename = "account/rateLimits/read")]
|
||||
#[ts(rename = "account/rateLimits/read")]
|
||||
GetAccountRateLimits {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
response: v2::GetAccountRateLimitsResponse,
|
||||
},
|
||||
|
||||
FeedbackUpload => "feedback/upload" {
|
||||
#[serde(rename = "feedback/upload")]
|
||||
#[ts(rename = "feedback/upload")]
|
||||
FeedbackUpload {
|
||||
params: v2::FeedbackUploadParams,
|
||||
response: v2::FeedbackUploadResponse,
|
||||
},
|
||||
|
||||
GetAccount => "account/read" {
|
||||
#[serde(rename = "account/read")]
|
||||
#[ts(rename = "account/read")]
|
||||
GetAccount {
|
||||
params: v2::GetAccountParams,
|
||||
response: v2::GetAccountResponse,
|
||||
},
|
||||
|
||||
/// DEPRECATED APIs below
|
||||
Initialize {
|
||||
params: v1::InitializeParams,
|
||||
response: v1::InitializeResponse,
|
||||
},
|
||||
NewConversation {
|
||||
params: v1::NewConversationParams,
|
||||
response: v1::NewConversationResponse,
|
||||
|
||||
@@ -6,8 +6,6 @@ use codex_protocol::ConversationId;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_protocol::config_types::ReasoningEffort;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
|
||||
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
|
||||
use codex_protocol::user_input::UserInput as CoreUserInput;
|
||||
@@ -459,17 +457,6 @@ impl UserInput {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CoreUserInput> for UserInput {
|
||||
fn from(value: CoreUserInput) -> Self {
|
||||
match value {
|
||||
CoreUserInput::Text { text } => UserInput::Text { text },
|
||||
CoreUserInput::Image { image_url } => UserInput::Image { url: image_url },
|
||||
CoreUserInput::LocalImage { path } => UserInput::LocalImage { path },
|
||||
_ => unreachable!("unsupported user input variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
#[ts(tag = "type")]
|
||||
@@ -527,42 +514,6 @@ pub enum ThreadItem {
|
||||
},
|
||||
}
|
||||
|
||||
impl From<CoreTurnItem> for ThreadItem {
|
||||
fn from(value: CoreTurnItem) -> Self {
|
||||
match value {
|
||||
CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage {
|
||||
id: user.id,
|
||||
content: user.content.into_iter().map(UserInput::from).collect(),
|
||||
},
|
||||
CoreTurnItem::AgentMessage(agent) => {
|
||||
let text = agent
|
||||
.content
|
||||
.into_iter()
|
||||
.map(|entry| match entry {
|
||||
CoreAgentMessageContent::Text { text } => text,
|
||||
})
|
||||
.collect::<String>();
|
||||
ThreadItem::AgentMessage { id: agent.id, text }
|
||||
}
|
||||
CoreTurnItem::Reasoning(reasoning) => {
|
||||
let text = if !reasoning.summary_text.is_empty() {
|
||||
reasoning.summary_text.join("\n")
|
||||
} else {
|
||||
reasoning.raw_content.join("\n")
|
||||
};
|
||||
ThreadItem::Reasoning {
|
||||
id: reasoning.id,
|
||||
text,
|
||||
}
|
||||
}
|
||||
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch {
|
||||
id: search.id,
|
||||
query: search.query,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -757,100 +708,3 @@ pub struct AccountLoginCompletedNotification {
|
||||
pub success: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::items::AgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem;
|
||||
use codex_protocol::items::ReasoningItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::user_input::UserInput as CoreUserInput;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn core_turn_item_into_thread_item_converts_supported_variants() {
|
||||
let user_item = TurnItem::UserMessage(UserMessageItem {
|
||||
id: "user-1".to_string(),
|
||||
content: vec![
|
||||
CoreUserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
},
|
||||
CoreUserInput::Image {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
},
|
||||
CoreUserInput::LocalImage {
|
||||
path: PathBuf::from("local/image.png"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(user_item),
|
||||
ThreadItem::UserMessage {
|
||||
id: "user-1".to_string(),
|
||||
content: vec![
|
||||
UserInput::Text {
|
||||
text: "hello".to_string(),
|
||||
},
|
||||
UserInput::Image {
|
||||
url: "https://example.com/image.png".to_string(),
|
||||
},
|
||||
UserInput::LocalImage {
|
||||
path: PathBuf::from("local/image.png"),
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
let agent_item = TurnItem::AgentMessage(AgentMessageItem {
|
||||
id: "agent-1".to_string(),
|
||||
content: vec![
|
||||
AgentMessageContent::Text {
|
||||
text: "Hello ".to_string(),
|
||||
},
|
||||
AgentMessageContent::Text {
|
||||
text: "world".to_string(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(agent_item),
|
||||
ThreadItem::AgentMessage {
|
||||
id: "agent-1".to_string(),
|
||||
text: "Hello world".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
let reasoning_item = TurnItem::Reasoning(ReasoningItem {
|
||||
id: "reasoning-1".to_string(),
|
||||
summary_text: vec!["line one".to_string(), "line two".to_string()],
|
||||
raw_content: vec![],
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(reasoning_item),
|
||||
ThreadItem::Reasoning {
|
||||
id: "reasoning-1".to_string(),
|
||||
text: "line one\nline two".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
let search_item = TurnItem::WebSearch(WebSearchItem {
|
||||
id: "search-1".to_string(),
|
||||
query: "docs".to_string(),
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
ThreadItem::from(search_item),
|
||||
ThreadItem::WebSearch {
|
||||
id: "search-1".to_string(),
|
||||
query: "docs".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports
|
||||
|
||||
## Message Schema
|
||||
|
||||
Currently, you can dump a TypeScript version of the schema using `codex app-server generate-ts`, or a JSON Schema bundle via `codex app-server generate-json-schema`. Each output is specific to the version of Codex you used to run the command, so the generated artifacts are guaranteed to match that version.
|
||||
Currently, you can dump a TypeScript version of the schema using `codex generate-ts`. It is specific to the version of Codex you used to run `generate-ts`, so the two are guaranteed to be compatible.
|
||||
|
||||
```
|
||||
codex app-server generate-ts --out DIR
|
||||
codex app-server generate-json-schema --out DIR
|
||||
codex generate-ts --out DIR
|
||||
```
|
||||
|
||||
## Initialization
|
||||
@@ -50,16 +49,15 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr
|
||||
|
||||
### 1) Start or resume a thread
|
||||
|
||||
Start a fresh thread when you need a new Codex conversation.
|
||||
Start a fresh thread when you need a new Codex conversation. Optional fields mirror CLI defaults: set `model`, `modelProvider`, `cwd`, `approvalPolicy`, `sandbox`, or custom `config` values. Instructions can be set via `baseInstructions` and `developerInstructions`:
|
||||
|
||||
```json
|
||||
{ "method": "thread/start", "id": 10, "params": {
|
||||
// Optionally set config settings. If not specified, will use the user's
|
||||
// current config settings.
|
||||
"model": "gpt-5-codex",
|
||||
"cwd": "/Users/me/project",
|
||||
"approvalPolicy": "never",
|
||||
"sandbox": "workspaceWrite",
|
||||
"sandbox": "workspace-write",
|
||||
"baseInstructions": "You're helping with refactors."
|
||||
} }
|
||||
{ "id": 10, "result": {
|
||||
"thread": {
|
||||
@@ -92,6 +90,7 @@ Example:
|
||||
{ "method": "thread/list", "id": 20, "params": {
|
||||
"cursor": null,
|
||||
"limit": 25,
|
||||
"modelProviders": ["openai"]
|
||||
} }
|
||||
{ "id": 20, "result": {
|
||||
"data": [
|
||||
@@ -123,23 +122,18 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
|
||||
- `{"type":"image","url":"https://…png"}`
|
||||
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
|
||||
|
||||
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread.
|
||||
Override knobs apply to the new turn and become the defaults for subsequent turns on the same thread:
|
||||
|
||||
```json
|
||||
{ "method": "turn/start", "id": 30, "params": {
|
||||
"threadId": "thr_123",
|
||||
"input": [ { "type": "text", "text": "Run tests" } ],
|
||||
// Below are optional config overrides
|
||||
"cwd": "/Users/me/project",
|
||||
"approvalPolicy": "unlessTrusted",
|
||||
"sandboxPolicy": {
|
||||
"mode": "workspaceWrite",
|
||||
"writableRoots": ["/Users/me/project"],
|
||||
"networkAccess": true
|
||||
},
|
||||
"approvalPolicy": "untrusted",
|
||||
"sandboxPolicy": "workspace-write",
|
||||
"model": "gpt-5-codex",
|
||||
"effort": "medium",
|
||||
"summary": "concise"
|
||||
"summary": "focus-on-test-failures"
|
||||
} }
|
||||
{ "id": 30, "result": { "turn": {
|
||||
"id": "turn_456",
|
||||
@@ -165,7 +159,7 @@ The server requests cancellations for running subprocesses, then emits a `turn/c
|
||||
|
||||
## Auth endpoints
|
||||
|
||||
The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
|
||||
The v2 JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits.
|
||||
|
||||
### Quick reference
|
||||
- `account/read` — fetch current account info; optionally refresh tokens.
|
||||
@@ -255,6 +249,5 @@ Field notes:
|
||||
|
||||
### Dev notes
|
||||
|
||||
- `codex app-server generate-ts --out <dir>` emits v2 types under `v2/`.
|
||||
- `codex app-server generate-json-schema --out <dir>` outputs `codex_app_server_protocol.schemas.json`.
|
||||
- `codex generate-ts --out <dir>` emits v2 types under `v2/`.
|
||||
- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs.
|
||||
|
||||
@@ -46,8 +46,6 @@ use codex_app_server_protocol::GitDiffToRemoteResponse;
|
||||
use codex_app_server_protocol::InputItem as WireInputItem;
|
||||
use codex_app_server_protocol::InterruptConversationParams;
|
||||
use codex_app_server_protocol::InterruptConversationResponse;
|
||||
use codex_app_server_protocol::ItemCompletedNotification;
|
||||
use codex_app_server_protocol::ItemStartedNotification;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::ListConversationsParams;
|
||||
use codex_app_server_protocol::ListConversationsResponse;
|
||||
@@ -200,30 +198,6 @@ enum ApiVersion {
|
||||
}
|
||||
|
||||
impl CodexMessageProcessor {
|
||||
async fn conversation_from_thread_id(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
) -> Result<(ConversationId, Arc<CodexConversation>), JSONRPCErrorError> {
|
||||
// Resolve conversation id from v2 thread id string.
|
||||
let conversation_id =
|
||||
ConversationId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid thread id: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
let conversation = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
.map_err(|_| JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("conversation not found: {conversation_id}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
Ok((conversation_id, conversation))
|
||||
}
|
||||
pub fn new(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
conversation_manager: Arc<ConversationManager>,
|
||||
@@ -2171,14 +2145,34 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
|
||||
async fn turn_start(&self, request_id: RequestId, params: TurnStartParams) {
|
||||
let (_, conversation) = match self.conversation_from_thread_id(¶ms.thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
// Resolve conversation id from v2 thread id string.
|
||||
let conversation_id = match ConversationId::from_string(¶ms.thread_id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid thread id: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("conversation not found: {conversation_id}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
};
|
||||
|
||||
// Keep a copy of v2 inputs for the notification payload.
|
||||
let v2_inputs_for_notif = params.input.clone();
|
||||
|
||||
@@ -2252,14 +2246,33 @@ impl CodexMessageProcessor {
|
||||
async fn turn_interrupt(&mut self, request_id: RequestId, params: TurnInterruptParams) {
|
||||
let TurnInterruptParams { thread_id, .. } = params;
|
||||
|
||||
let (conversation_id, conversation) =
|
||||
match self.conversation_from_thread_id(&thread_id).await {
|
||||
Ok(v) => v,
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
// Resolve conversation id from v2 thread id string.
|
||||
let conversation_id = match ConversationId::from_string(&thread_id) {
|
||||
Ok(id) => id,
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("invalid thread id: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(conversation) = self
|
||||
.conversation_manager
|
||||
.get_conversation(conversation_id)
|
||||
.await
|
||||
else {
|
||||
let error = JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!("conversation not found: {conversation_id}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
};
|
||||
|
||||
// Record the pending interrupt so we can reply when TurnAborted arrives.
|
||||
{
|
||||
@@ -2611,20 +2624,6 @@ async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::ItemStarted(item_started_event) => {
|
||||
let item: ThreadItem = item_started_event.item.clone().into();
|
||||
let notification = ItemStartedNotification { item };
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemStarted(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::ItemCompleted(item_completed_event) => {
|
||||
let item: ThreadItem = item_completed_event.item.clone().into();
|
||||
let notification = ItemCompletedNotification { item };
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ItemCompleted(notification))
|
||||
.await;
|
||||
}
|
||||
// If this is a TurnAborted, reply to any pending interrupt requests.
|
||||
EventMsg::TurnAborted(turn_aborted_event) => {
|
||||
let pending = {
|
||||
|
||||
@@ -11,7 +11,32 @@ const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
|
||||
const APPLY_PATCH_ARG0: &str = "apply_patch";
|
||||
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
|
||||
|
||||
pub fn arg0_dispatch() -> Option<TempDir> {
|
||||
/// While we want to deploy the Codex CLI as a single executable for simplicity,
|
||||
/// we also want to expose some of its functionality as distinct CLIs, so we use
|
||||
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
|
||||
/// us to simulate deploying multiple executables as a single binary on Mac and
|
||||
/// Linux (but not Windows).
|
||||
///
|
||||
/// When the current executable is invoked through the hard-link or alias named
|
||||
/// `codex-linux-sandbox` we *directly* execute
|
||||
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
|
||||
///
|
||||
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
|
||||
/// 2. Construct a Tokio multi-thread runtime.
|
||||
/// 3. Derive the path to the current executable (so children can re-invoke the
|
||||
/// sandbox) when running on Linux.
|
||||
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
|
||||
/// error. Note that `main_fn` receives `codex_linux_sandbox_exe:
|
||||
/// Option<PathBuf>`, as an argument, which is generally needed as part of
|
||||
/// constructing [`codex_core::config::Config`].
|
||||
///
|
||||
/// This function should be used to wrap any `main()` function in binary crates
|
||||
/// in this workspace that depends on these helper CLIs.
|
||||
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
|
||||
where
|
||||
F: FnOnce(Option<PathBuf>) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
// Determine if we were invoked via the special alias.
|
||||
let mut args = std::env::args_os();
|
||||
let argv0 = args.next().unwrap_or_default();
|
||||
@@ -51,7 +76,10 @@ pub fn arg0_dispatch() -> Option<TempDir> {
|
||||
// before creating any threads/the Tokio runtime.
|
||||
load_dotenv();
|
||||
|
||||
match prepend_path_entry_for_codex_aliases() {
|
||||
// Retain the TempDir so it exists for the lifetime of the invocation of
|
||||
// this executable. Admittedly, we could invoke `keep()` on it, but it
|
||||
// would be nice to avoid leaving temporary directories behind, if possible.
|
||||
let _path_entry = match prepend_path_entry_for_apply_patch() {
|
||||
Ok(path_entry) => Some(path_entry),
|
||||
Err(err) => {
|
||||
// It is possible that Codex will proceed successfully even if
|
||||
@@ -59,39 +87,7 @@ pub fn arg0_dispatch() -> Option<TempDir> {
|
||||
eprintln!("WARNING: proceeding, even though we could not update PATH: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// While we want to deploy the Codex CLI as a single executable for simplicity,
|
||||
/// we also want to expose some of its functionality as distinct CLIs, so we use
|
||||
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
|
||||
/// us to simulate deploying multiple executables as a single binary on Mac and
|
||||
/// Linux (but not Windows).
|
||||
///
|
||||
/// When the current executable is invoked through the hard-link or alias named
|
||||
/// `codex-linux-sandbox` we *directly* execute
|
||||
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
|
||||
///
|
||||
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
|
||||
/// 2. Construct a Tokio multi-thread runtime.
|
||||
/// 3. Derive the path to the current executable (so children can re-invoke the
|
||||
/// sandbox) when running on Linux.
|
||||
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
|
||||
/// error. Note that `main_fn` receives `codex_linux_sandbox_exe:
|
||||
/// Option<PathBuf>`, as an argument, which is generally needed as part of
|
||||
/// constructing [`codex_core::config::Config`].
|
||||
///
|
||||
/// This function should be used to wrap any `main()` function in binary crates
|
||||
/// in this workspace that depends on these helper CLIs.
|
||||
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
|
||||
where
|
||||
F: FnOnce(Option<PathBuf>) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<()>>,
|
||||
{
|
||||
// Retain the TempDir so it exists for the lifetime of the invocation of
|
||||
// this executable. Admittedly, we could invoke `keep()` on it, but it
|
||||
// would be nice to avoid leaving temporary directories behind, if possible.
|
||||
let _path_entry = arg0_dispatch();
|
||||
};
|
||||
|
||||
// Regular invocation – create a Tokio runtime and execute the provided
|
||||
// async entry-point.
|
||||
@@ -148,16 +144,11 @@ where
|
||||
///
|
||||
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
|
||||
/// be called before multiple threads are spawned.
|
||||
pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<TempDir> {
|
||||
fn prepend_path_entry_for_apply_patch() -> std::io::Result<TempDir> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let path = temp_dir.path();
|
||||
|
||||
for filename in &[
|
||||
APPLY_PATCH_ARG0,
|
||||
MISSPELLED_APPLY_PATCH_ARG0,
|
||||
#[cfg(target_os = "linux")]
|
||||
LINUX_SANDBOX_ARG0,
|
||||
] {
|
||||
for filename in &[APPLY_PATCH_ARG0, MISSPELLED_APPLY_PATCH_ARG0] {
|
||||
let exe = std::env::current_exe()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -30,14 +30,13 @@ codex-login = { workspace = true }
|
||||
codex-mcp-server = { workspace = true }
|
||||
codex-process-hardening = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-protocol-ts = { workspace = true }
|
||||
codex-responses-api-proxy = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-stdio-to-uds = { workspace = true }
|
||||
codex-tui = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
regex-lite = { workspace = true}
|
||||
serde_json = { workspace = true }
|
||||
supports-color = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -48,7 +47,6 @@ tokio = { workspace = true, features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod pid_tracker;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod seatbelt;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::CliConfigOverrides;
|
||||
@@ -20,9 +15,6 @@ use crate::SeatbeltCommand;
|
||||
use crate::WindowsCommand;
|
||||
use crate::exit_status::handle_exit_status;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use seatbelt::DenialLogger;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub async fn run_command_under_seatbelt(
|
||||
command: SeatbeltCommand,
|
||||
@@ -30,7 +22,6 @@ pub async fn run_command_under_seatbelt(
|
||||
) -> anyhow::Result<()> {
|
||||
let SeatbeltCommand {
|
||||
full_auto,
|
||||
log_denials,
|
||||
config_overrides,
|
||||
command,
|
||||
} = command;
|
||||
@@ -40,7 +31,6 @@ pub async fn run_command_under_seatbelt(
|
||||
config_overrides,
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Seatbelt,
|
||||
log_denials,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -68,7 +58,6 @@ pub async fn run_command_under_landlock(
|
||||
config_overrides,
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Landlock,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -88,7 +77,6 @@ pub async fn run_command_under_windows(
|
||||
config_overrides,
|
||||
codex_linux_sandbox_exe,
|
||||
SandboxType::Windows,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -106,7 +94,6 @@ async fn run_command_under_sandbox(
|
||||
config_overrides: CliConfigOverrides,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
sandbox_type: SandboxType,
|
||||
log_denials: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let sandbox_mode = create_sandbox_mode(full_auto);
|
||||
let config = Config::load_with_cli_overrides(
|
||||
@@ -149,8 +136,6 @@ async fn run_command_under_sandbox(
|
||||
let env_map = env.clone();
|
||||
let command_vec = command.clone();
|
||||
let base_dir = config.codex_home.clone();
|
||||
|
||||
// Preflight audit is invoked elsewhere at the appropriate times.
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
run_windows_sandbox_capture(
|
||||
policy_str,
|
||||
@@ -193,11 +178,6 @@ async fn run_command_under_sandbox(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut denial_logger = log_denials.then(DenialLogger::new).flatten();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = log_denials;
|
||||
|
||||
let mut child = match sandbox_type {
|
||||
#[cfg(target_os = "macos")]
|
||||
SandboxType::Seatbelt => {
|
||||
@@ -231,27 +211,8 @@ async fn run_command_under_sandbox(
|
||||
unreachable!("Windows sandbox should have been handled above");
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(denial_logger) = &mut denial_logger {
|
||||
denial_logger.on_child_spawn(&child);
|
||||
}
|
||||
|
||||
let status = child.wait().await?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(denial_logger) = denial_logger {
|
||||
let denials = denial_logger.finish().await;
|
||||
eprintln!("\n=== Sandbox denials ===");
|
||||
if denials.is_empty() {
|
||||
eprintln!("None found.");
|
||||
} else {
|
||||
for seatbelt::SandboxDenial { name, capability } in denials {
|
||||
eprintln!("({name}) {capability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handle_exit_status(status);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
/// Tracks the (recursive) descendants of a process by using `kqueue` to watch for fork events, and
|
||||
/// `proc_listchildpids` to list the children of a process.
|
||||
pub(crate) struct PidTracker {
|
||||
kq: libc::c_int,
|
||||
handle: JoinHandle<HashSet<i32>>,
|
||||
}
|
||||
|
||||
impl PidTracker {
|
||||
pub(crate) fn new(root_pid: i32) -> Option<Self> {
|
||||
if root_pid <= 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let kq = unsafe { libc::kqueue() };
|
||||
let handle = tokio::task::spawn_blocking(move || track_descendants(kq, root_pid));
|
||||
|
||||
Some(Self { kq, handle })
|
||||
}
|
||||
|
||||
pub(crate) async fn stop(self) -> HashSet<i32> {
|
||||
trigger_stop_event(self.kq);
|
||||
self.handle.await.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
fn proc_listchildpids(
|
||||
ppid: libc::c_int,
|
||||
buffer: *mut libc::c_void,
|
||||
buffersize: libc::c_int,
|
||||
) -> libc::c_int;
|
||||
}
|
||||
|
||||
/// Wrap proc_listchildpids.
|
||||
fn list_child_pids(parent: i32) -> Vec<i32> {
|
||||
unsafe {
|
||||
let mut capacity: usize = 16;
|
||||
loop {
|
||||
let mut buf: Vec<i32> = vec![0; capacity];
|
||||
let count = proc_listchildpids(
|
||||
parent as libc::c_int,
|
||||
buf.as_mut_ptr() as *mut libc::c_void,
|
||||
(buf.len() * std::mem::size_of::<i32>()) as libc::c_int,
|
||||
);
|
||||
if count <= 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let returned = count as usize;
|
||||
if returned < capacity {
|
||||
buf.truncate(returned);
|
||||
return buf;
|
||||
}
|
||||
capacity = capacity.saturating_mul(2).max(returned + 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pid_is_alive(pid: i32) -> bool {
|
||||
if pid <= 0 {
|
||||
return false;
|
||||
}
|
||||
let res = unsafe { libc::kill(pid as libc::pid_t, 0) };
|
||||
if res == 0 {
|
||||
true
|
||||
} else {
|
||||
matches!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::EPERM)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum WatchPidError {
|
||||
ProcessGone,
|
||||
Other(std::io::Error),
|
||||
}
|
||||
|
||||
/// Add `pid` to the watch list in `kq`.
|
||||
fn watch_pid(kq: libc::c_int, pid: i32) -> Result<(), WatchPidError> {
|
||||
if pid <= 0 {
|
||||
return Err(WatchPidError::ProcessGone);
|
||||
}
|
||||
|
||||
let kev = libc::kevent {
|
||||
ident: pid as libc::uintptr_t,
|
||||
filter: libc::EVFILT_PROC,
|
||||
flags: libc::EV_ADD | libc::EV_CLEAR,
|
||||
fflags: libc::NOTE_FORK | libc::NOTE_EXEC | libc::NOTE_EXIT,
|
||||
data: 0,
|
||||
udata: std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
|
||||
if res < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::ESRCH) {
|
||||
Err(WatchPidError::ProcessGone)
|
||||
} else {
|
||||
Err(WatchPidError::Other(err))
|
||||
}
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_children(
|
||||
kq: libc::c_int,
|
||||
parent: i32,
|
||||
seen: &mut HashSet<i32>,
|
||||
active: &mut HashSet<i32>,
|
||||
) {
|
||||
for child_pid in list_child_pids(parent) {
|
||||
add_pid_watch(kq, child_pid, seen, active);
|
||||
}
|
||||
}
|
||||
|
||||
/// Watch `pid` and its children, updating `seen` and `active` sets.
|
||||
fn add_pid_watch(kq: libc::c_int, pid: i32, seen: &mut HashSet<i32>, active: &mut HashSet<i32>) {
|
||||
if pid <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let newly_seen = seen.insert(pid);
|
||||
let mut should_recurse = newly_seen;
|
||||
|
||||
if active.insert(pid) {
|
||||
match watch_pid(kq, pid) {
|
||||
Ok(()) => {
|
||||
should_recurse = true;
|
||||
}
|
||||
Err(WatchPidError::ProcessGone) => {
|
||||
active.remove(&pid);
|
||||
return;
|
||||
}
|
||||
Err(WatchPidError::Other(err)) => {
|
||||
warn!("failed to watch pid {pid}: {err}");
|
||||
active.remove(&pid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if should_recurse {
|
||||
watch_children(kq, pid, seen, active);
|
||||
}
|
||||
}
|
||||
const STOP_IDENT: libc::uintptr_t = 1;
|
||||
|
||||
fn register_stop_event(kq: libc::c_int) -> bool {
|
||||
let kev = libc::kevent {
|
||||
ident: STOP_IDENT,
|
||||
filter: libc::EVFILT_USER,
|
||||
flags: libc::EV_ADD | libc::EV_CLEAR,
|
||||
fflags: 0,
|
||||
data: 0,
|
||||
udata: std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let res = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
|
||||
res >= 0
|
||||
}
|
||||
|
||||
fn trigger_stop_event(kq: libc::c_int) {
|
||||
if kq < 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let kev = libc::kevent {
|
||||
ident: STOP_IDENT,
|
||||
filter: libc::EVFILT_USER,
|
||||
flags: 0,
|
||||
fflags: libc::NOTE_TRIGGER,
|
||||
data: 0,
|
||||
udata: std::ptr::null_mut(),
|
||||
};
|
||||
|
||||
let _ = unsafe { libc::kevent(kq, &kev, 1, std::ptr::null_mut(), 0, std::ptr::null()) };
|
||||
}
|
||||
|
||||
/// Put all of the above together to track all the descendants of `root_pid`.
|
||||
fn track_descendants(kq: libc::c_int, root_pid: i32) -> HashSet<i32> {
|
||||
if kq < 0 {
|
||||
let mut seen = HashSet::new();
|
||||
seen.insert(root_pid);
|
||||
return seen;
|
||||
}
|
||||
|
||||
if !register_stop_event(kq) {
|
||||
let mut seen = HashSet::new();
|
||||
seen.insert(root_pid);
|
||||
let _ = unsafe { libc::close(kq) };
|
||||
return seen;
|
||||
}
|
||||
|
||||
let mut seen: HashSet<i32> = HashSet::new();
|
||||
let mut active: HashSet<i32> = HashSet::new();
|
||||
|
||||
add_pid_watch(kq, root_pid, &mut seen, &mut active);
|
||||
|
||||
const EVENTS_CAP: usize = 32;
|
||||
let mut events: [libc::kevent; EVENTS_CAP] =
|
||||
unsafe { std::mem::MaybeUninit::zeroed().assume_init() };
|
||||
|
||||
let mut stop_requested = false;
|
||||
loop {
|
||||
if active.is_empty() {
|
||||
if !pid_is_alive(root_pid) {
|
||||
break;
|
||||
}
|
||||
add_pid_watch(kq, root_pid, &mut seen, &mut active);
|
||||
if active.is_empty() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let nev = unsafe {
|
||||
libc::kevent(
|
||||
kq,
|
||||
std::ptr::null::<libc::kevent>(),
|
||||
0,
|
||||
events.as_mut_ptr(),
|
||||
EVENTS_CAP as libc::c_int,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
|
||||
if nev < 0 {
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.kind() == std::io::ErrorKind::Interrupted {
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if nev == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
for ev in events.iter().take(nev as usize) {
|
||||
let pid = ev.ident as i32;
|
||||
|
||||
if ev.filter == libc::EVFILT_USER && ev.ident == STOP_IDENT {
|
||||
stop_requested = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (ev.flags & libc::EV_ERROR) != 0 {
|
||||
if ev.data == libc::ESRCH as isize {
|
||||
active.remove(&pid);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ev.fflags & libc::NOTE_FORK) != 0 {
|
||||
watch_children(kq, pid, &mut seen, &mut active);
|
||||
}
|
||||
|
||||
if (ev.fflags & libc::NOTE_EXIT) != 0 {
|
||||
active.remove(&pid);
|
||||
}
|
||||
}
|
||||
|
||||
if stop_requested {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = unsafe { libc::close(kq) };
|
||||
|
||||
seen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn pid_is_alive_detects_current_process() {
|
||||
let pid = std::process::id() as i32;
|
||||
assert!(pid_is_alive(pid));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn list_child_pids_includes_spawned_child() {
|
||||
let mut child = Command::new("/bin/sleep")
|
||||
.arg("5")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child process");
|
||||
|
||||
let child_pid = child.id() as i32;
|
||||
let parent_pid = std::process::id() as i32;
|
||||
|
||||
let mut found = false;
|
||||
for _ in 0..100 {
|
||||
if list_child_pids(parent_pid).contains(&child_pid) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert!(found, "expected to find child pid {child_pid} in list");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pid_tracker_collects_spawned_children() {
|
||||
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
|
||||
|
||||
let mut child = Command::new("/bin/sleep")
|
||||
.arg("0.1")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn child process");
|
||||
|
||||
let child_pid = child.id() as i32;
|
||||
let parent_pid = std::process::id() as i32;
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
let seen = tracker.stop().await;
|
||||
|
||||
assert!(
|
||||
seen.contains(&parent_pid),
|
||||
"expected tracker to include parent pid {parent_pid}"
|
||||
);
|
||||
assert!(
|
||||
seen.contains(&child_pid),
|
||||
"expected tracker to include child pid {child_pid}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn pid_tracker_collects_bash_subshell_descendants() {
|
||||
let tracker = PidTracker::new(std::process::id() as i32).expect("failed to create tracker");
|
||||
|
||||
let child = Command::new("/bin/bash")
|
||||
.arg("-c")
|
||||
.arg("(sleep 0.1 & echo $!; wait)")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("failed to spawn bash");
|
||||
|
||||
let output = child.wait_with_output().unwrap().stdout;
|
||||
let subshell_pid = String::from_utf8_lossy(&output)
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.expect("failed to parse subshell pid");
|
||||
|
||||
let seen = tracker.stop().await;
|
||||
|
||||
assert!(
|
||||
seen.contains(&subshell_pid),
|
||||
"expected tracker to include subshell pid {subshell_pid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::process::Child;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use super::pid_tracker::PidTracker;
|
||||
|
||||
pub struct SandboxDenial {
|
||||
pub name: String,
|
||||
pub capability: String,
|
||||
}
|
||||
|
||||
pub struct DenialLogger {
|
||||
log_stream: Child,
|
||||
pid_tracker: Option<PidTracker>,
|
||||
log_reader: Option<JoinHandle<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl DenialLogger {
|
||||
pub(crate) fn new() -> Option<Self> {
|
||||
let mut log_stream = start_log_stream()?;
|
||||
let stdout = log_stream.stdout.take()?;
|
||||
let log_reader = tokio::spawn(async move {
|
||||
let mut reader = tokio::io::BufReader::new(stdout);
|
||||
let mut logs = Vec::new();
|
||||
let mut chunk = Vec::new();
|
||||
loop {
|
||||
match reader.read_until(b'\n', &mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {
|
||||
logs.extend_from_slice(&chunk);
|
||||
chunk.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
logs
|
||||
});
|
||||
|
||||
Some(Self {
|
||||
log_stream,
|
||||
pid_tracker: None,
|
||||
log_reader: Some(log_reader),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn on_child_spawn(&mut self, child: &Child) {
|
||||
if let Some(root_pid) = child.id() {
|
||||
self.pid_tracker = PidTracker::new(root_pid as i32);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn finish(mut self) -> Vec<SandboxDenial> {
|
||||
let pid_set = match self.pid_tracker {
|
||||
Some(tracker) => tracker.stop().await,
|
||||
None => Default::default(),
|
||||
};
|
||||
|
||||
if pid_set.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let _ = self.log_stream.kill().await;
|
||||
let _ = self.log_stream.wait().await;
|
||||
|
||||
let logs_bytes = match self.log_reader.take() {
|
||||
Some(handle) => handle.await.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let logs = String::from_utf8_lossy(&logs_bytes);
|
||||
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
let mut denials: Vec<SandboxDenial> = Vec::new();
|
||||
for line in logs.lines() {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
|
||||
&& let Some(msg) = json.get("eventMessage").and_then(|v| v.as_str())
|
||||
&& let Some((pid, name, capability)) = parse_message(msg)
|
||||
&& pid_set.contains(&pid)
|
||||
&& seen.insert((name.clone(), capability.clone()))
|
||||
{
|
||||
denials.push(SandboxDenial { name, capability });
|
||||
}
|
||||
}
|
||||
denials
|
||||
}
|
||||
}
|
||||
|
||||
fn start_log_stream() -> Option<Child> {
|
||||
use std::process::Stdio;
|
||||
|
||||
const PREDICATE: &str = r#"(((processID == 0) AND (senderImagePath CONTAINS "/Sandbox")) OR (subsystem == "com.apple.sandbox.reporting"))"#;
|
||||
|
||||
tokio::process::Command::new("log")
|
||||
.args(["stream", "--style", "ndjson", "--predicate", PREDICATE])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn parse_message(msg: &str) -> Option<(i32, String, String)> {
|
||||
// Example message:
|
||||
// Sandbox: processname(1234) deny(1) capability-name args...
|
||||
static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
|
||||
let re = RE.get_or_init(|| {
|
||||
#[expect(clippy::unwrap_used)]
|
||||
regex_lite::Regex::new(r"^Sandbox:\s*(.+?)\((\d+)\)\s+deny\(.*?\)\s*(.+)$").unwrap()
|
||||
});
|
||||
|
||||
let (_, [name, pid_str, capability]) = re.captures(msg)?.extract();
|
||||
let pid = pid_str.trim().parse::<i32>().ok()?;
|
||||
Some((pid, name.to_string(), capability.to_string()))
|
||||
}
|
||||
@@ -11,10 +11,6 @@ pub struct SeatbeltCommand {
|
||||
#[arg(long = "full-auto", default_value_t = false)]
|
||||
pub full_auto: bool,
|
||||
|
||||
/// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit
|
||||
#[arg(long = "log-denials", default_value_t = false)]
|
||||
pub log_denials: bool,
|
||||
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use clap::Args;
|
||||
use clap::CommandFactory;
|
||||
use clap::Parser;
|
||||
use clap_complete::Shell;
|
||||
@@ -21,17 +20,16 @@ use codex_exec::Cli as ExecCli;
|
||||
use codex_responses_api_proxy::Args as ResponsesApiProxyArgs;
|
||||
use codex_tui::AppExitInfo;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
use codex_tui::update_action::UpdateAction;
|
||||
use codex_tui::updates::UpdateAction;
|
||||
use owo_colors::OwoColorize;
|
||||
use std::path::PathBuf;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod mcp_cmd;
|
||||
#[cfg(not(windows))]
|
||||
mod wsl_paths;
|
||||
|
||||
use crate::mcp_cmd::McpCli;
|
||||
|
||||
use crate::wsl_paths::normalize_for_wsl;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::features::is_known_feature_key;
|
||||
@@ -83,8 +81,8 @@ enum Subcommand {
|
||||
/// [experimental] Run the Codex MCP server (stdio transport).
|
||||
McpServer,
|
||||
|
||||
/// [experimental] Run the app server or related tooling.
|
||||
AppServer(AppServerCommand),
|
||||
/// [experimental] Run the app server.
|
||||
AppServer,
|
||||
|
||||
/// Generate shell completion scripts.
|
||||
Completion(CompletionCommand),
|
||||
@@ -100,6 +98,9 @@ enum Subcommand {
|
||||
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
|
||||
Resume(ResumeCommand),
|
||||
|
||||
/// Internal: generate TypeScript protocol bindings.
|
||||
#[clap(hide = true)]
|
||||
GenerateTs(GenerateTsCommand),
|
||||
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
|
||||
#[clap(name = "cloud", alias = "cloud-tasks")]
|
||||
Cloud(CloudTasksCli),
|
||||
@@ -206,22 +207,6 @@ struct LogoutCommand {
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct AppServerCommand {
|
||||
/// Omit to run the app server; specify a subcommand for tooling.
|
||||
#[command(subcommand)]
|
||||
subcommand: Option<AppServerSubcommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
enum AppServerSubcommand {
|
||||
/// [experimental] Generate TypeScript bindings for the app server protocol.
|
||||
GenerateTs(GenerateTsCommand),
|
||||
|
||||
/// [experimental] Generate JSON Schema for the app server protocol.
|
||||
GenerateJsonSchema(GenerateJsonSchemaCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct GenerateTsCommand {
|
||||
/// Output directory where .ts files will be written
|
||||
#[arg(short = 'o', long = "out", value_name = "DIR")]
|
||||
@@ -232,13 +217,6 @@ struct GenerateTsCommand {
|
||||
prettier: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
struct GenerateJsonSchemaCommand {
|
||||
/// Output directory where the schema bundle will be written
|
||||
#[arg(short = 'o', long = "out", value_name = "DIR")]
|
||||
out_dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct StdioToUdsCommand {
|
||||
/// Path to the Unix domain socket to connect to.
|
||||
@@ -291,30 +269,14 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
|
||||
/// Run the update action and print the result.
|
||||
fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
|
||||
println!();
|
||||
let (cmd, args) = action.command_args();
|
||||
let cmd_str = action.command_str();
|
||||
println!("Updating Codex via `{cmd_str}`...");
|
||||
|
||||
let status = {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics).
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", &cmd_str])
|
||||
.status()?
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let (cmd, args) = action.command_args();
|
||||
let command_path = crate::wsl_paths::normalize_for_wsl(cmd);
|
||||
let normalized_args: Vec<String> = args
|
||||
.iter()
|
||||
.map(crate::wsl_paths::normalize_for_wsl)
|
||||
.collect();
|
||||
std::process::Command::new(&command_path)
|
||||
.args(&normalized_args)
|
||||
.status()?
|
||||
}
|
||||
};
|
||||
let command_path = normalize_for_wsl(cmd);
|
||||
let normalized_args: Vec<String> = args.iter().map(normalize_for_wsl).collect();
|
||||
let status = std::process::Command::new(&command_path)
|
||||
.args(&normalized_args)
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
anyhow::bail!("`{cmd_str}` failed with status {status}");
|
||||
}
|
||||
@@ -431,20 +393,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone());
|
||||
mcp_cli.run().await?;
|
||||
}
|
||||
Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand {
|
||||
None => {
|
||||
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
|
||||
}
|
||||
Some(AppServerSubcommand::GenerateTs(gen_cli)) => {
|
||||
codex_app_server_protocol::generate_ts(
|
||||
&gen_cli.out_dir,
|
||||
gen_cli.prettier.as_deref(),
|
||||
)?;
|
||||
}
|
||||
Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => {
|
||||
codex_app_server_protocol::generate_json(&gen_cli.out_dir)?;
|
||||
}
|
||||
},
|
||||
Some(Subcommand::AppServer) => {
|
||||
codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?;
|
||||
}
|
||||
Some(Subcommand::Resume(ResumeCommand {
|
||||
session_id,
|
||||
last,
|
||||
@@ -559,6 +510,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path()))
|
||||
.await??;
|
||||
}
|
||||
Some(Subcommand::GenerateTs(gen_cli)) => {
|
||||
codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?;
|
||||
}
|
||||
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
|
||||
FeaturesSubcommand::List => {
|
||||
// Respect root-level `-c` overrides plus top-level flags like `--profile`.
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod util;
|
||||
pub use cli::Cli;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use codex_login::AuthManager;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
@@ -57,8 +56,20 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result<BackendContext>
|
||||
};
|
||||
append_error_log(format!("startup: base_url={base_url} path_style={style}"));
|
||||
|
||||
let auth_manager = util::load_auth_manager().await;
|
||||
let auth = match auth_manager.as_ref().and_then(AuthManager::auth) {
|
||||
let auth = match codex_core::config::find_codex_home()
|
||||
.ok()
|
||||
.map(|home| {
|
||||
let store_mode = codex_core::config::Config::load_from_base_config_with_overrides(
|
||||
codex_core::config::ConfigToml::default(),
|
||||
codex_core::config::ConfigOverrides::default(),
|
||||
home.clone(),
|
||||
)
|
||||
.map(|cfg| cfg.cli_auth_credentials_store_mode)
|
||||
.unwrap_or_default();
|
||||
codex_login::AuthManager::new(home, false, store_mode)
|
||||
})
|
||||
.and_then(|am| am.auth())
|
||||
{
|
||||
Some(auth) => auth,
|
||||
None => {
|
||||
eprintln!(
|
||||
|
||||
@@ -2,10 +2,6 @@ use base64::Engine as _;
|
||||
use chrono::Utc;
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_login::AuthManager;
|
||||
|
||||
pub fn set_user_agent_suffix(suffix: &str) {
|
||||
if let Ok(mut guard) = codex_core::default_client::USER_AGENT_SUFFIX.lock() {
|
||||
guard.replace(suffix.to_string());
|
||||
@@ -58,18 +54,6 @@ pub fn extract_chatgpt_account_id(token: &str) -> Option<String> {
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub async fn load_auth_manager() -> Option<AuthManager> {
|
||||
// TODO: pass in cli overrides once cloud tasks properly support them.
|
||||
let config = Config::load_with_cli_overrides(Vec::new(), ConfigOverrides::default())
|
||||
.await
|
||||
.ok()?;
|
||||
Some(AuthManager::new(
|
||||
config.codex_home,
|
||||
false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
))
|
||||
}
|
||||
|
||||
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
|
||||
/// and optional `ChatGPT-Account-Id`.
|
||||
pub async fn build_chatgpt_headers() -> HeaderMap {
|
||||
@@ -85,22 +69,31 @@ pub async fn build_chatgpt_headers() -> HeaderMap {
|
||||
USER_AGENT,
|
||||
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
|
||||
);
|
||||
if let Some(am) = load_auth_manager().await
|
||||
&& let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await
|
||||
&& !tok.is_empty()
|
||||
{
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = HeaderValue::from_str(&v) {
|
||||
headers.insert(AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(acc) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = HeaderValue::from_str(&acc)
|
||||
if let Ok(home) = codex_core::config::find_codex_home() {
|
||||
let store_mode = codex_core::config::Config::load_from_base_config_with_overrides(
|
||||
codex_core::config::ConfigToml::default(),
|
||||
codex_core::config::ConfigOverrides::default(),
|
||||
home.clone(),
|
||||
)
|
||||
.map(|cfg| cfg.cli_auth_credentials_store_mode)
|
||||
.unwrap_or_default();
|
||||
let am = codex_login::AuthManager::new(home, false, store_mode);
|
||||
if let Some(auth) = am.auth()
|
||||
&& let Ok(tok) = auth.get_token().await
|
||||
&& !tok.is_empty()
|
||||
{
|
||||
headers.insert(name, hv);
|
||||
let v = format!("Bearer {tok}");
|
||||
if let Ok(hv) = HeaderValue::from_str(&v) {
|
||||
headers.insert(AUTHORIZATION, hv);
|
||||
}
|
||||
if let Some(acc) = auth
|
||||
.get_account_id()
|
||||
.or_else(|| extract_chatgpt_account_id(&tok))
|
||||
&& let Ok(name) = HeaderName::from_bytes(b"ChatGPT-Account-Id")
|
||||
&& let Ok(hv) = HeaderValue::from_str(&acc)
|
||||
{
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
}
|
||||
}
|
||||
headers
|
||||
|
||||
@@ -19,8 +19,8 @@ use toml::Value;
|
||||
pub struct CliConfigOverrides {
|
||||
/// Override a configuration value that would otherwise be loaded from
|
||||
/// `~/.codex/config.toml`. Use a dotted path (`foo.bar.baz`) to override
|
||||
/// nested values. The `value` portion is parsed as TOML. If it fails to
|
||||
/// parse as TOML, the raw string is used as a literal.
|
||||
/// nested values. The `value` portion is parsed as JSON. If it fails to
|
||||
/// parse as JSON, the raw string is used as a literal.
|
||||
///
|
||||
/// Examples:
|
||||
/// - `-c model="o3"`
|
||||
@@ -59,7 +59,7 @@ impl CliConfigOverrides {
|
||||
return Err(format!("Empty key in override: {s}"));
|
||||
}
|
||||
|
||||
// Attempt to parse as TOML. If that fails, treat it as a raw
|
||||
// Attempt to parse as JSON. If that fails, treat it as a raw
|
||||
// string. This allows convenient usage such as
|
||||
// `-c model=o3` without the quotes.
|
||||
let value: Value = match parse_toml_value(value_str) {
|
||||
@@ -151,15 +151,6 @@ mod tests {
|
||||
assert_eq!(v.as_integer(), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_bool() {
|
||||
let true_literal = parse_toml_value("true").expect("parse");
|
||||
assert_eq!(true_literal.as_bool(), Some(true));
|
||||
|
||||
let false_literal = parse_toml_value("false").expect("parse");
|
||||
assert_eq!(false_literal.as_bool(), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fails_on_unquoted_string() {
|
||||
assert!(parse_toml_value("hello").is_err());
|
||||
|
||||
@@ -32,7 +32,6 @@ codex-utils-pty = { workspace = true }
|
||||
codex-utils-readiness = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
codex-utils-tokenizer = { workspace = true }
|
||||
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
env-flags = { workspace = true }
|
||||
@@ -84,6 +83,7 @@ tree-sitter-bash = { workspace = true }
|
||||
uuid = { workspace = true, features = ["serde", "v4", "v5"] }
|
||||
which = { workspace = true }
|
||||
wildmatch = { workspace = true }
|
||||
codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
|
||||
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
@@ -104,9 +104,7 @@ openssl-sys = { workspace = true, features = ["vendored"] }
|
||||
[dev-dependencies]
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
core_test_support = { workspace = true }
|
||||
ctor = { workspace = true }
|
||||
escargot = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg", "png"] }
|
||||
maplit = { workspace = true }
|
||||
|
||||
@@ -88,33 +88,17 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option<V
|
||||
Some(commands)
|
||||
}
|
||||
|
||||
pub fn is_well_known_sh_shell(shell: &str) -> bool {
|
||||
if shell == "bash" || shell == "zsh" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(shell);
|
||||
matches!(shell_name, "bash" | "zsh")
|
||||
}
|
||||
|
||||
pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
|
||||
let [shell, flag, script] = command else {
|
||||
return None;
|
||||
};
|
||||
if !matches!(flag.as_str(), "-lc" | "-c") || !is_well_known_sh_shell(shell) {
|
||||
return None;
|
||||
}
|
||||
Some((shell, script))
|
||||
}
|
||||
|
||||
/// Returns the sequence of plain commands within a `bash -lc "..."` or
|
||||
/// `zsh -lc "..."` invocation when the script only contains word-only commands
|
||||
/// joined by safe operators.
|
||||
pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option<Vec<Vec<String>>> {
|
||||
let (_, script) = extract_bash_command(command)?;
|
||||
let [shell, flag, script] = command else {
|
||||
return None;
|
||||
};
|
||||
|
||||
if flag != "-lc" || !(shell == "bash" || shell == "zsh") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let tree = try_parse_shell(script)?;
|
||||
try_parse_word_only_commands_sequence(&tree, script)
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::sync::atomic::AtomicU64;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::client_common::REVIEW_PROMPT;
|
||||
use crate::compact;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::auth::McpAuthStatusEntry;
|
||||
@@ -67,8 +66,6 @@ use crate::error::Result as CodexResult;
|
||||
use crate::exec::StreamOutput;
|
||||
// Removed: legacy executor wiring replaced by ToolOrchestrator flows.
|
||||
// legacy normalize_exec_result no longer used after orchestrator migration
|
||||
use crate::compact::build_compacted_history;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::model_family::find_family_for_model;
|
||||
@@ -97,7 +94,6 @@ use crate::protocol::Submission;
|
||||
use crate::protocol::TokenCountEvent;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::RolloutRecorderParams;
|
||||
use crate::shell;
|
||||
@@ -133,6 +129,10 @@ use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_readiness::Readiness;
|
||||
use codex_utils_readiness::ReadinessFlag;
|
||||
|
||||
pub mod compact;
|
||||
use self::compact::build_compacted_history;
|
||||
use self::compact::collect_user_messages;
|
||||
|
||||
/// The high-level interface to the Codex system.
|
||||
/// It operates as a queue pair where you send submissions and receive events.
|
||||
pub struct Codex {
|
||||
@@ -675,34 +675,6 @@ impl Session {
|
||||
let rollout_items = conversation_history.get_rollout_items();
|
||||
let persist = matches!(conversation_history, InitialHistory::Forked(_));
|
||||
|
||||
// If resuming, warn when the last recorded model differs from the current one.
|
||||
if let InitialHistory::Resumed(_) = conversation_history
|
||||
&& let Some(prev) = rollout_items.iter().rev().find_map(|it| {
|
||||
if let RolloutItem::TurnContext(ctx) = it {
|
||||
Some(ctx.model.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
let curr = turn_context.client.get_model();
|
||||
if prev != curr {
|
||||
warn!(
|
||||
"resuming session with different model: previous={prev}, current={curr}"
|
||||
);
|
||||
self.send_event(
|
||||
&turn_context,
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: format!(
|
||||
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
|
||||
Consider switching back to `{prev}` as it may affect Codex performance."
|
||||
),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Always add response items to conversation history
|
||||
let reconstructed_history =
|
||||
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);
|
||||
@@ -996,7 +968,7 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Append ResponseItems to the in-memory conversation history only.
|
||||
pub(crate) async fn record_into_history(&self, items: &[ResponseItem]) {
|
||||
async fn record_into_history(&self, items: &[ResponseItem]) {
|
||||
let mut state = self.state.lock().await;
|
||||
state.record_items(items.iter());
|
||||
}
|
||||
@@ -1048,7 +1020,7 @@ impl Session {
|
||||
items
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_rollout_items(&self, items: &[RolloutItem]) {
|
||||
async fn persist_rollout_items(&self, items: &[RolloutItem]) {
|
||||
let recorder = {
|
||||
let guard = self.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
@@ -1065,7 +1037,7 @@ impl Session {
|
||||
state.clone_history()
|
||||
}
|
||||
|
||||
pub(crate) async fn update_token_usage_info(
|
||||
async fn update_token_usage_info(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
token_usage: Option<&TokenUsage>,
|
||||
@@ -1082,7 +1054,7 @@ impl Session {
|
||||
self.send_token_count_event(turn_context).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn update_rate_limits(
|
||||
async fn update_rate_limits(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
new_rate_limits: RateLimitSnapshot,
|
||||
@@ -1103,7 +1075,7 @@ impl Session {
|
||||
self.send_event(turn_context, event).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
|
||||
async fn set_total_tokens_full(&self, turn_context: &TurnContext) {
|
||||
let context_window = turn_context.client.get_model_context_window();
|
||||
if let Some(context_window) = context_window {
|
||||
{
|
||||
@@ -1146,11 +1118,7 @@ impl Session {
|
||||
self.send_event(turn_context, event).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn notify_stream_error(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
message: impl Into<String>,
|
||||
) {
|
||||
async fn notify_stream_error(&self, turn_context: &TurnContext, message: impl Into<String>) {
|
||||
let event = EventMsg::StreamError(StreamErrorEvent {
|
||||
message: message.into(),
|
||||
});
|
||||
@@ -2352,7 +2320,6 @@ mod tests {
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
@@ -3092,48 +3059,6 @@ mod tests {
|
||||
assert!(exec_output.output.contains("hi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
|
||||
let (session, mut turn_context_raw) = make_session_and_context();
|
||||
turn_context_raw.approval_policy = AskForApproval::OnFailure;
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context_raw);
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
|
||||
let handler = UnifiedExecHandler;
|
||||
let resp = handler
|
||||
.handle(ToolInvocation {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn_context),
|
||||
tracker: Arc::clone(&tracker),
|
||||
call_id: "exec-call".to_string(),
|
||||
tool_name: "exec_command".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: serde_json::json!({
|
||||
"cmd": "echo hi",
|
||||
"with_escalated_permissions": true,
|
||||
"justification": "need unsandboxed execution",
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
})
|
||||
.await;
|
||||
|
||||
let Err(FunctionCallError::RespondToModel(output)) = resp else {
|
||||
panic!("expected error result");
|
||||
};
|
||||
|
||||
let expected = format!(
|
||||
"approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}",
|
||||
policy = turn_context.approval_policy
|
||||
);
|
||||
|
||||
pretty_assertions::assert_eq!(output, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_init_error_display_prompts_for_github_pat() {
|
||||
let server_name = "github";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::Session;
|
||||
use super::TurnContext;
|
||||
use super::get_last_assistant_message_from_turn;
|
||||
use crate::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::get_last_assistant_message_from_turn;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::AgentMessageEvent;
|
||||
@@ -25,7 +25,7 @@ use codex_protocol::user_input::UserInput;
|
||||
use futures::prelude::*;
|
||||
use tracing::error;
|
||||
|
||||
pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md");
|
||||
pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md");
|
||||
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
|
||||
|
||||
pub(crate) async fn run_inline_auto_compact_task(
|
||||
@@ -164,7 +164,7 @@ async fn run_compact_task_inner(
|
||||
sess.send_event(&turn_context, event).await;
|
||||
|
||||
let warning = EventMsg::Warning(WarningEvent {
|
||||
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
|
||||
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(),
|
||||
});
|
||||
sess.send_event(&turn_context, warning).await;
|
||||
}
|
||||
@@ -1,38 +1,4 @@
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
|
||||
use crate::bash::parse_shell_lc_plain_commands;
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
|
||||
pub fn requires_initial_appoval(
|
||||
policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
command: &[String],
|
||||
with_escalated_permissions: bool,
|
||||
) -> bool {
|
||||
if is_known_safe_command(command) {
|
||||
return false;
|
||||
}
|
||||
match policy {
|
||||
AskForApproval::Never | AskForApproval::OnFailure => false,
|
||||
AskForApproval::OnRequest => {
|
||||
// In DangerFullAccess, only prompt if the command looks dangerous.
|
||||
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
|
||||
return command_might_be_dangerous(command);
|
||||
}
|
||||
|
||||
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
|
||||
// non‑escalated, non‑dangerous commands — let the sandbox enforce
|
||||
// restrictions (e.g., block network/write) without a user prompt.
|
||||
let wants_escalation: bool = with_escalated_permissions;
|
||||
if wants_escalation {
|
||||
return true;
|
||||
}
|
||||
command_might_be_dangerous(command)
|
||||
}
|
||||
AskForApproval::UnlessTrusted => !is_known_safe_command(command),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command_might_be_dangerous(command: &[String]) -> bool {
|
||||
if is_dangerous_to_call_with_exec(command) {
|
||||
|
||||
@@ -25,8 +25,6 @@ pub enum ConfigEdit {
|
||||
SetNoticeHideFullAccessWarning(bool),
|
||||
/// Toggle the Windows world-writable directories warning acknowledgement flag.
|
||||
SetNoticeHideWorldWritableWarning(bool),
|
||||
/// Toggle the rate limit model nudge acknowledgement flag.
|
||||
SetNoticeHideRateLimitModelNudge(bool),
|
||||
/// Toggle the Windows onboarding acknowledgement flag.
|
||||
SetWindowsWslSetupAcknowledged(bool),
|
||||
/// Replace the entire `[mcp_servers]` table.
|
||||
@@ -248,11 +246,6 @@ impl ConfigDocument {
|
||||
&[Notice::TABLE_KEY, "hide_world_writable_warning"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&["windows_wsl_setup_acknowledged"],
|
||||
@@ -493,12 +486,6 @@ impl ConfigEditsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self {
|
||||
self.edits
|
||||
.push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_windows_wsl_setup_acknowledged(mut self, acknowledged: bool) -> Self {
|
||||
self.edits
|
||||
.push(ConfigEdit::SetWindowsWslSetupAcknowledged(acknowledged));
|
||||
@@ -746,34 +733,6 @@ hide_full_access_warning = true
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_hide_rate_limit_model_nudge_preserves_table() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"[notice]
|
||||
existing = "value"
|
||||
"#,
|
||||
)
|
||||
.expect("seed");
|
||||
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
None,
|
||||
&[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents =
|
||||
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[notice]
|
||||
existing = "value"
|
||||
hide_rate_limit_model_nudge = true
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_replace_mcp_servers_round_trips() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
||||
@@ -360,8 +360,6 @@ pub struct Notice {
|
||||
pub hide_full_access_warning: Option<bool>,
|
||||
/// Tracks whether the user has acknowledged the Windows world-writable directories warning.
|
||||
pub hide_world_writable_warning: Option<bool>,
|
||||
/// Tracks whether the user opted out of the rate limit model switch reminder.
|
||||
pub hide_rate_limit_model_nudge: Option<bool>,
|
||||
}
|
||||
|
||||
impl Notice {
|
||||
|
||||
@@ -14,7 +14,6 @@ use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::user_instructions::UserInstructions;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
|
||||
fn is_session_prefix(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
@@ -32,7 +31,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
for content_item in message.iter() {
|
||||
match content_item {
|
||||
ContentItem::InputText { text } => {
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
if is_session_prefix(text) {
|
||||
return None;
|
||||
}
|
||||
content.push(UserInput::Text { text: text.clone() });
|
||||
@@ -198,14 +197,7 @@ mod tests {
|
||||
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
|
||||
}],
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<user_shell_command>echo 42</user_shell_command>".to_string(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
];
|
||||
|
||||
for item in items {
|
||||
let turn_item = parse_turn_item(&item);
|
||||
|
||||
@@ -727,51 +727,4 @@ mod tests {
|
||||
let output = make_exec_output(exit_code, "", "", "");
|
||||
assert!(is_likely_sandbox_denied(SandboxType::LinuxSeccomp, &output));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> {
|
||||
let command = vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-c".to_string(),
|
||||
"sleep 60 & echo $!; sleep 60".to_string(),
|
||||
];
|
||||
let env: HashMap<String, String> = std::env::vars().collect();
|
||||
let params = ExecParams {
|
||||
command,
|
||||
cwd: std::env::current_dir()?,
|
||||
timeout_ms: Some(500),
|
||||
env,
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let output = exec(params, SandboxType::None, &SandboxPolicy::ReadOnly, None).await?;
|
||||
assert!(output.timed_out);
|
||||
|
||||
let stdout = output.stdout.from_utf8_lossy().text;
|
||||
let pid_line = stdout.lines().next().unwrap_or("").trim();
|
||||
let pid: i32 = pid_line.parse().map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("Failed to parse pid from stdout '{pid_line}': {error}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut killed = false;
|
||||
for _ in 0..20 {
|
||||
// Use kill(pid, 0) to check if the process is alive.
|
||||
if unsafe { libc::kill(pid, 0) } == -1
|
||||
&& let Some(libc::ESRCH) = std::io::Error::last_os_error().raw_os_error()
|
||||
{
|
||||
killed = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
assert!(killed, "grandchild process with pid {pid} is still alive");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,6 @@ pub enum Stage {
|
||||
pub enum Feature {
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Use the shell command tool that takes `command` as a single string of
|
||||
/// shell instead of an array of args passed to `execvp(3)`.
|
||||
ShellCommandTool,
|
||||
/// Enable experimental RMCP features such as OAuth login.
|
||||
RmcpClient,
|
||||
/// Include the freeform apply_patch tool.
|
||||
@@ -253,12 +250,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ShellCommandTool,
|
||||
key: "shell_command_tool",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::RmcpClient,
|
||||
key: "rmcp_client",
|
||||
@@ -293,7 +284,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
id: Feature::GhostCommit,
|
||||
key: "ghost_commit",
|
||||
stage: Stage::Experimental,
|
||||
default_enabled: true,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WindowsSandbox,
|
||||
|
||||
@@ -81,7 +81,6 @@ mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
mod user_notification;
|
||||
mod user_shell_command;
|
||||
pub mod util;
|
||||
|
||||
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
|
||||
@@ -100,12 +99,11 @@ pub use client_common::Prompt;
|
||||
pub use client_common::REVIEW_PROMPT;
|
||||
pub use client_common::ResponseEvent;
|
||||
pub use client_common::ResponseStream;
|
||||
pub use codex::compact::content_items_to_text;
|
||||
pub use codex_protocol::models::ContentItem;
|
||||
pub use codex_protocol::models::LocalShellAction;
|
||||
pub use codex_protocol::models::LocalShellExecAction;
|
||||
pub use codex_protocol::models::LocalShellStatus;
|
||||
pub use codex_protocol::models::ResponseItem;
|
||||
pub use compact::content_items_to_text;
|
||||
pub use event_mapping::parse_turn_item;
|
||||
pub mod compact;
|
||||
pub mod otel_init;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::bash::extract_bash_command;
|
||||
use crate::bash::try_parse_shell;
|
||||
use crate::bash::try_parse_word_only_commands_sequence;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
@@ -854,29 +853,6 @@ mod tests {
|
||||
}],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bin_bash_lc_sed() {
|
||||
assert_parsed(
|
||||
&shlex_split_safe("/bin/bash -lc 'sed -n '1,10p' Cargo.toml'"),
|
||||
vec![ParsedCommand::Read {
|
||||
cmd: "sed -n '1,10p' Cargo.toml".to_string(),
|
||||
name: "Cargo.toml".to_string(),
|
||||
path: PathBuf::from("Cargo.toml"),
|
||||
}],
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn bin_zsh_lc_sed() {
|
||||
assert_parsed(
|
||||
&shlex_split_safe("/bin/zsh -lc 'sed -n '1,10p' Cargo.toml'"),
|
||||
vec![ParsedCommand::Read {
|
||||
cmd: "sed -n '1,10p' Cargo.toml".to_string(),
|
||||
name: "Cargo.toml".to_string(),
|
||||
path: PathBuf::from("Cargo.toml"),
|
||||
}],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_command_impl(command: &[String]) -> Vec<ParsedCommand> {
|
||||
@@ -1190,13 +1166,18 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option<String>, Option<String>
|
||||
}
|
||||
|
||||
fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
let (_, script) = extract_bash_command(original)?;
|
||||
|
||||
let [shell, flag, script] = original else {
|
||||
return None;
|
||||
};
|
||||
if flag != "-lc" || !(shell == "bash" || shell == "zsh") {
|
||||
return None;
|
||||
}
|
||||
if let Some(tree) = try_parse_shell(script)
|
||||
&& let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script)
|
||||
&& !all_commands.is_empty()
|
||||
{
|
||||
let script_tokens = shlex_split(script).unwrap_or_else(|| vec![script.to_string()]);
|
||||
let script_tokens = shlex_split(script)
|
||||
.unwrap_or_else(|| vec![shell.clone(), flag.clone(), script.clone()]);
|
||||
// Strip small formatting helpers (e.g., head/tail/awk/wc/etc) so we
|
||||
// bias toward the primary command when pipelines are present.
|
||||
// First, drop obvious small formatting helpers (e.g., wc/awk/etc).
|
||||
@@ -1205,7 +1186,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
let filtered_commands = drop_small_formatting_commands(all_commands);
|
||||
if filtered_commands.is_empty() {
|
||||
return Some(vec![ParsedCommand::Unknown {
|
||||
cmd: script.to_string(),
|
||||
cmd: script.clone(),
|
||||
}]);
|
||||
}
|
||||
// Build parsed commands, tracking `cd` segments to compute effective file paths.
|
||||
@@ -1269,7 +1250,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
});
|
||||
if has_pipe && has_sed_n {
|
||||
ParsedCommand::Read {
|
||||
cmd: script.to_string(),
|
||||
cmd: script.clone(),
|
||||
name,
|
||||
path,
|
||||
}
|
||||
@@ -1314,7 +1295,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option<Vec<ParsedCommand>> {
|
||||
return Some(commands);
|
||||
}
|
||||
Some(vec![ParsedCommand::Unknown {
|
||||
cmd: script.to_string(),
|
||||
cmd: script.clone(),
|
||||
}])
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.pagesize")
|
||||
(sysctl-name "hw.physicalcpu")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
|
||||
@@ -31,37 +31,16 @@ pub enum Shell {
|
||||
impl Shell {
|
||||
pub fn name(&self) -> Option<String> {
|
||||
match self {
|
||||
Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => {
|
||||
std::path::Path::new(shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
}
|
||||
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string()),
|
||||
Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string()),
|
||||
Shell::PowerShell(ps) => Some(ps.exe.clone()),
|
||||
Shell::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes a string of shell and returns the full list of command args to
|
||||
/// use with `exec()` to run the shell command.
|
||||
pub fn derive_exec_args(&self, command: &str, use_login_shell: bool) -> Vec<String> {
|
||||
match self {
|
||||
Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => {
|
||||
let arg = if use_login_shell { "-lc" } else { "-c" };
|
||||
vec![shell_path.clone(), arg.to_string(), command.to_string()]
|
||||
}
|
||||
Shell::PowerShell(ps) => {
|
||||
let mut args = vec![ps.exe.clone(), "-NoLogo".to_string()];
|
||||
if !use_login_shell {
|
||||
args.push("-NoProfile".to_string());
|
||||
}
|
||||
|
||||
args.push("-Command".to_string());
|
||||
args.push(command.to_string());
|
||||
args
|
||||
}
|
||||
Shell::Unknown => shlex::split(command).unwrap_or_else(|| vec![command.to_string()]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -66,9 +66,8 @@ pub(crate) async fn spawn_child_async(
|
||||
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
#[cfg(target_os = "linux")]
|
||||
let parent_pid = libc::getpid();
|
||||
cmd.pre_exec(move || {
|
||||
cmd.pre_exec(|| {
|
||||
if libc::setpgid(0, 0) == -1 {
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::codex::TurnContext;
|
||||
use crate::compact;
|
||||
use crate::codex::compact;
|
||||
use crate::state::TaskKind;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
|
||||
@@ -1,35 +1,28 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_async_utils::CancelErr;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::exec::StdoutStream;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::exec::execute_exec_env;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandBeginEvent;
|
||||
use crate::protocol::ExecCommandEndEvent;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::TaskStartedEvent;
|
||||
use crate::sandboxing::ExecEnv;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tools::format_exec_output_str;
|
||||
use crate::user_shell_command::user_shell_command_record_item;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolRouter;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
|
||||
use super::SessionTask;
|
||||
use super::SessionTaskContext;
|
||||
|
||||
const USER_SHELL_TOOL_NAME: &str = "local_shell";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UserShellCommandTask {
|
||||
command: String,
|
||||
@@ -63,131 +56,56 @@ impl SessionTask for UserShellCommandTask {
|
||||
// Execute the user's script under their default shell when known; this
|
||||
// allows commands that use shell features (pipes, &&, redirects, etc.).
|
||||
// We do not source rc files or otherwise reformat the script.
|
||||
let use_login_shell = true;
|
||||
let shell_invocation = session
|
||||
.user_shell()
|
||||
.derive_exec_args(&self.command, use_login_shell);
|
||||
|
||||
let call_id = Uuid::new_v4().to_string();
|
||||
let raw_command = self.command.clone();
|
||||
|
||||
let parsed_cmd = parse_command(&shell_invocation);
|
||||
session
|
||||
.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
command: shell_invocation.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
parsed_cmd,
|
||||
is_user_shell_command: true,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let exec_env = ExecEnv {
|
||||
command: shell_invocation,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
env: create_env(&turn_context.shell_environment_policy),
|
||||
timeout_ms: None,
|
||||
sandbox: SandboxType::None,
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
let shell_invocation = match session.user_shell() {
|
||||
crate::shell::Shell::Zsh(zsh) => vec![
|
||||
zsh.shell_path.clone(),
|
||||
"-lc".to_string(),
|
||||
self.command.clone(),
|
||||
],
|
||||
crate::shell::Shell::Bash(bash) => vec![
|
||||
bash.shell_path.clone(),
|
||||
"-lc".to_string(),
|
||||
self.command.clone(),
|
||||
],
|
||||
crate::shell::Shell::PowerShell(ps) => vec![
|
||||
ps.exe.clone(),
|
||||
"-NoProfile".to_string(),
|
||||
"-Command".to_string(),
|
||||
self.command.clone(),
|
||||
],
|
||||
crate::shell::Shell::Unknown => {
|
||||
shlex::split(&self.command).unwrap_or_else(|| vec![self.command.clone()])
|
||||
}
|
||||
};
|
||||
|
||||
let stdout_stream = Some(StdoutStream {
|
||||
sub_id: turn_context.sub_id.clone(),
|
||||
call_id: call_id.clone(),
|
||||
tx_event: session.get_tx_event(),
|
||||
});
|
||||
let params = ShellToolCallParams {
|
||||
command: shell_invocation,
|
||||
workdir: None,
|
||||
timeout_ms: None,
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
};
|
||||
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
|
||||
.or_cancel(&cancellation_token)
|
||||
.await;
|
||||
let tool_call = ToolCall {
|
||||
tool_name: USER_SHELL_TOOL_NAME.to_string(),
|
||||
call_id: Uuid::new_v4().to_string(),
|
||||
payload: ToolPayload::LocalShell { params },
|
||||
};
|
||||
|
||||
match exec_result {
|
||||
Err(CancelErr::Cancelled) => {
|
||||
let aborted_message = "command aborted by user".to_string();
|
||||
let exec_output = ExecToolCallOutput {
|
||||
exit_code: -1,
|
||||
stdout: StreamOutput::new(String::new()),
|
||||
stderr: StreamOutput::new(aborted_message.clone()),
|
||||
aggregated_output: StreamOutput::new(aborted_message.clone()),
|
||||
duration: Duration::ZERO,
|
||||
timed_out: false,
|
||||
};
|
||||
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
|
||||
session
|
||||
.record_conversation_items(turn_context.as_ref(), &output_items)
|
||||
.await;
|
||||
session
|
||||
.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
stdout: String::new(),
|
||||
stderr: aborted_message.clone(),
|
||||
aggregated_output: aborted_message.clone(),
|
||||
exit_code: -1,
|
||||
duration: Duration::ZERO,
|
||||
formatted_output: aborted_message,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(Ok(output)) => {
|
||||
session
|
||||
.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
stdout: output.stdout.text.clone(),
|
||||
stderr: output.stderr.text.clone(),
|
||||
aggregated_output: output.aggregated_output.text.clone(),
|
||||
exit_code: output.exit_code,
|
||||
duration: output.duration,
|
||||
formatted_output: format_exec_output_str(&output),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let router = Arc::new(ToolRouter::from_config(&turn_context.tools_config, None));
|
||||
let tracker = Arc::new(Mutex::new(TurnDiffTracker::new()));
|
||||
let runtime = ToolCallRuntime::new(
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&session),
|
||||
Arc::clone(&turn_context),
|
||||
Arc::clone(&tracker),
|
||||
);
|
||||
|
||||
let output_items = [user_shell_command_record_item(&raw_command, &output)];
|
||||
session
|
||||
.record_conversation_items(turn_context.as_ref(), &output_items)
|
||||
.await;
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
error!("user shell command failed: {err:?}");
|
||||
let message = format!("execution error: {err:?}");
|
||||
let exec_output = ExecToolCallOutput {
|
||||
exit_code: -1,
|
||||
stdout: StreamOutput::new(String::new()),
|
||||
stderr: StreamOutput::new(message.clone()),
|
||||
aggregated_output: StreamOutput::new(message.clone()),
|
||||
duration: Duration::ZERO,
|
||||
timed_out: false,
|
||||
};
|
||||
session
|
||||
.send_event(
|
||||
turn_context.as_ref(),
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
stdout: exec_output.stdout.text.clone(),
|
||||
stderr: exec_output.stderr.text.clone(),
|
||||
aggregated_output: exec_output.aggregated_output.text.clone(),
|
||||
exit_code: exec_output.exit_code,
|
||||
duration: exec_output.duration,
|
||||
formatted_output: format_exec_output_str(&exec_output),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let output_items = [user_shell_command_record_item(&raw_command, &exec_output)];
|
||||
session
|
||||
.record_conversation_items(turn_context.as_ref(), &output_items)
|
||||
.await;
|
||||
}
|
||||
if let Err(err) = runtime
|
||||
.handle_tool_call(tool_call, cancellation_token)
|
||||
.await
|
||||
{
|
||||
error!("user shell command failed: {err:?}");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
pub use plan::PlanHandler;
|
||||
pub use read_file::ReadFileHandler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
pub use shell::ShellHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::models::ShellCommandToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -26,8 +25,6 @@ use crate::tools::sandboxing::ToolCtx;
|
||||
|
||||
pub struct ShellHandler;
|
||||
|
||||
pub struct ShellCommandHandler;
|
||||
|
||||
impl ShellHandler {
|
||||
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
|
||||
ExecParams {
|
||||
@@ -42,28 +39,6 @@ impl ShellHandler {
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellCommandHandler {
|
||||
fn to_exec_params(
|
||||
params: ShellCommandToolCallParams,
|
||||
session: &crate::codex::Session,
|
||||
turn_context: &TurnContext,
|
||||
) -> ExecParams {
|
||||
let shell = session.user_shell();
|
||||
let use_login_shell = true;
|
||||
let command = shell.derive_exec_args(¶ms.command, use_login_shell);
|
||||
|
||||
ExecParams {
|
||||
command,
|
||||
cwd: turn_context.resolve_path(params.workdir.clone()),
|
||||
timeout_ms: params.timeout_ms,
|
||||
env: create_env(&turn_context.shell_environment_policy),
|
||||
with_escalated_permissions: params.with_escalated_permissions,
|
||||
justification: params.justification,
|
||||
arg0: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ShellHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
@@ -127,49 +102,6 @@ impl ToolHandler for ShellHandler {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ShellCommandHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(payload, ToolPayload::Function { .. })
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call_id,
|
||||
tool_name,
|
||||
payload,
|
||||
} = invocation;
|
||||
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"unsupported payload for shell_command handler: {tool_name}"
|
||||
)));
|
||||
};
|
||||
|
||||
let params: ShellCommandToolCallParams = serde_json::from_str(&arguments).map_err(|e| {
|
||||
FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}"))
|
||||
})?;
|
||||
let exec_params = Self::to_exec_params(params, session.as_ref(), turn.as_ref());
|
||||
ShellHandler::run_exec_like(
|
||||
tool_name.as_str(),
|
||||
exec_params,
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call_id,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellHandler {
|
||||
async fn run_exec_like(
|
||||
tool_name: &str,
|
||||
@@ -308,49 +240,3 @@ impl ShellHandler {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::is_safe_command::is_known_safe_command;
|
||||
use crate::shell::BashShell;
|
||||
use crate::shell::Shell;
|
||||
use crate::shell::ZshShell;
|
||||
|
||||
/// The logic for is_known_safe_command() has heuristics for known shells,
|
||||
/// so we must ensure the commands generated by [ShellCommandHandler] can be
|
||||
/// recognized as safe if the `command` is safe.
|
||||
#[test]
|
||||
fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() {
|
||||
let bash_shell = Shell::Bash(BashShell {
|
||||
shell_path: "/bin/bash".to_string(),
|
||||
bashrc_path: "/home/user/.bashrc".to_string(),
|
||||
});
|
||||
assert_safe(&bash_shell, "ls -la");
|
||||
|
||||
let zsh_shell = Shell::Zsh(ZshShell {
|
||||
shell_path: "/bin/zsh".to_string(),
|
||||
zshrc_path: "/home/user/.zshrc".to_string(),
|
||||
});
|
||||
assert_safe(&zsh_shell, "ls -la");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use crate::shell::PowerShellConfig;
|
||||
|
||||
let powershell = Shell::PowerShell(PowerShellConfig {
|
||||
exe: "pwsh.exe".to_string(),
|
||||
bash_exe_fallback: None,
|
||||
});
|
||||
assert_safe(&powershell, "ls -Name");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_safe(shell: &Shell, command: &str) {
|
||||
assert!(is_known_safe_command(
|
||||
&shell.derive_exec_args(command, /* use_login_shell */ true)
|
||||
));
|
||||
assert!(is_known_safe_command(
|
||||
&shell.derive_exec_args(command, /* use_login_shell */ false)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -26,8 +24,6 @@ pub struct UnifiedExecHandler;
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExecCommandArgs {
|
||||
cmd: String,
|
||||
#[serde(default)]
|
||||
workdir: Option<String>,
|
||||
#[serde(default = "default_shell")]
|
||||
shell: String,
|
||||
#[serde(default = "default_login")]
|
||||
@@ -36,10 +32,6 @@ struct ExecCommandArgs {
|
||||
yield_time_ms: Option<u64>,
|
||||
#[serde(default)]
|
||||
max_output_tokens: Option<usize>,
|
||||
#[serde(default)]
|
||||
with_escalated_permissions: Option<bool>,
|
||||
#[serde(default)]
|
||||
justification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -104,34 +96,6 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
"failed to parse exec_command arguments: {err:?}"
|
||||
))
|
||||
})?;
|
||||
let ExecCommandArgs {
|
||||
cmd,
|
||||
workdir,
|
||||
shell,
|
||||
login,
|
||||
yield_time_ms,
|
||||
max_output_tokens,
|
||||
with_escalated_permissions,
|
||||
justification,
|
||||
} = args;
|
||||
|
||||
if with_escalated_permissions.unwrap_or(false)
|
||||
&& !matches!(
|
||||
context.turn.approval_policy,
|
||||
codex_protocol::protocol::AskForApproval::OnRequest
|
||||
)
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"approval policy is {policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {policy:?}",
|
||||
policy = context.turn.approval_policy
|
||||
)));
|
||||
}
|
||||
|
||||
let workdir = workdir
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from);
|
||||
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
|
||||
|
||||
let event_ctx = ToolEventCtx::new(
|
||||
context.session.as_ref(),
|
||||
@@ -139,20 +103,18 @@ impl ToolHandler for UnifiedExecHandler {
|
||||
&context.call_id,
|
||||
None,
|
||||
);
|
||||
let emitter = ToolEmitter::unified_exec(cmd.clone(), cwd.clone(), true);
|
||||
let emitter =
|
||||
ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true);
|
||||
emitter.emit(event_ctx, ToolEventStage::Begin).await;
|
||||
|
||||
manager
|
||||
.exec_command(
|
||||
ExecCommandRequest {
|
||||
command: &cmd,
|
||||
shell: &shell,
|
||||
login,
|
||||
yield_time_ms,
|
||||
max_output_tokens,
|
||||
workdir,
|
||||
with_escalated_permissions,
|
||||
justification,
|
||||
command: &args.cmd,
|
||||
shell: &args.shell,
|
||||
login: args.login,
|
||||
yield_time_ms: args.yield_time_ms,
|
||||
max_output_tokens: args.max_output_tokens,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
|
||||
@@ -65,9 +65,9 @@ impl ToolCallRuntime {
|
||||
Ok(Self::aborted_response(&call, secs))
|
||||
},
|
||||
res = async {
|
||||
tracing::trace!("waiting for tool gate");
|
||||
tracing::info!("waiting for tool gate");
|
||||
readiness.wait_ready().await;
|
||||
tracing::trace!("tool gate released");
|
||||
tracing::info!("tool gate released");
|
||||
let _guard = if supports_parallel {
|
||||
Either::Left(lock.read().await)
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,8 @@ Runtime: shell
|
||||
Executes shell requests under the orchestrator: asks for approval when needed,
|
||||
builds a CommandSpec, and runs it under the current SandboxAttempt.
|
||||
*/
|
||||
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
|
||||
use crate::command_safety::is_dangerous_command::command_might_be_dangerous;
|
||||
use crate::command_safety::is_safe_command::is_known_safe_command;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::sandboxing::execute_env;
|
||||
@@ -120,12 +121,28 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> bool {
|
||||
requires_initial_appoval(
|
||||
policy,
|
||||
sandbox_policy,
|
||||
&req.command,
|
||||
req.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
if is_known_safe_command(&req.command) {
|
||||
return false;
|
||||
}
|
||||
match policy {
|
||||
AskForApproval::Never | AskForApproval::OnFailure => false,
|
||||
AskForApproval::OnRequest => {
|
||||
// In DangerFullAccess, only prompt if the command looks dangerous.
|
||||
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
|
||||
return command_might_be_dangerous(&req.command);
|
||||
}
|
||||
|
||||
// In restricted sandboxes (ReadOnly/WorkspaceWrite), do not prompt for
|
||||
// non‑escalated, non‑dangerous commands — let the sandbox enforce
|
||||
// restrictions (e.g., block network/write) without a user prompt.
|
||||
let wants_escalation = req.with_escalated_permissions.unwrap_or(false);
|
||||
if wants_escalation {
|
||||
return true;
|
||||
}
|
||||
command_might_be_dangerous(&req.command)
|
||||
}
|
||||
AskForApproval::UnlessTrusted => !is_known_safe_command(&req.command),
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_escalated_first_attempt(&self, req: &ShellRequest) -> bool {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::command_safety::is_dangerous_command::requires_initial_appoval;
|
||||
/*
|
||||
Runtime: unified exec
|
||||
|
||||
@@ -22,9 +21,7 @@ use crate::tools::sandboxing::with_cached_approval;
|
||||
use crate::unified_exec::UnifiedExecError;
|
||||
use crate::unified_exec::UnifiedExecSession;
|
||||
use crate::unified_exec::UnifiedExecSessionManager;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use futures::future::BoxFuture;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -34,8 +31,6 @@ pub struct UnifiedExecRequest {
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub env: HashMap<String, String>,
|
||||
pub with_escalated_permissions: Option<bool>,
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
impl ProvidesSandboxRetryData for UnifiedExecRequest {
|
||||
@@ -51,7 +46,6 @@ impl ProvidesSandboxRetryData for UnifiedExecRequest {
|
||||
pub struct UnifiedExecApprovalKey {
|
||||
pub command: Vec<String>,
|
||||
pub cwd: PathBuf,
|
||||
pub escalated: bool,
|
||||
}
|
||||
|
||||
pub struct UnifiedExecRuntime<'a> {
|
||||
@@ -59,20 +53,8 @@ pub struct UnifiedExecRuntime<'a> {
|
||||
}
|
||||
|
||||
impl UnifiedExecRequest {
|
||||
pub fn new(
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
with_escalated_permissions: Option<bool>,
|
||||
justification: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
with_escalated_permissions,
|
||||
justification,
|
||||
}
|
||||
pub fn new(command: Vec<String>, cwd: PathBuf, env: HashMap<String, String>) -> Self {
|
||||
Self { command, cwd, env }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +81,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
UnifiedExecApprovalKey {
|
||||
command: req.command.clone(),
|
||||
cwd: req.cwd.clone(),
|
||||
escalated: req.with_escalated_permissions.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +95,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
let call_id = ctx.call_id.to_string();
|
||||
let command = req.command.clone();
|
||||
let cwd = req.cwd.clone();
|
||||
let reason = ctx
|
||||
.retry_reason
|
||||
.clone()
|
||||
.or_else(|| req.justification.clone());
|
||||
let reason = ctx.retry_reason.clone();
|
||||
let risk = ctx.risk.clone();
|
||||
Box::pin(async move {
|
||||
with_cached_approval(&session.services, key, || async move {
|
||||
@@ -128,24 +106,6 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
fn wants_initial_approval(
|
||||
&self,
|
||||
req: &UnifiedExecRequest,
|
||||
policy: AskForApproval,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
) -> bool {
|
||||
requires_initial_appoval(
|
||||
policy,
|
||||
sandbox_policy,
|
||||
&req.command,
|
||||
req.with_escalated_permissions.unwrap_or(false),
|
||||
)
|
||||
}
|
||||
|
||||
fn wants_escalated_first_attempt(&self, req: &UnifiedExecRequest) -> bool {
|
||||
req.with_escalated_permissions.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRuntime<'a> {
|
||||
@@ -155,15 +115,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecSession> for UnifiedExecRunt
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
_ctx: &ToolCtx<'_>,
|
||||
) -> Result<UnifiedExecSession, ToolError> {
|
||||
let spec = build_command_spec(
|
||||
&req.command,
|
||||
&req.cwd,
|
||||
&req.env,
|
||||
None,
|
||||
req.with_escalated_permissions,
|
||||
req.justification.clone(),
|
||||
)
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
let spec = build_command_spec(&req.command, &req.cwd, &req.env, None, None, None)
|
||||
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
|
||||
let exec_env = attempt
|
||||
.env_for(&spec)
|
||||
.map_err(|err| ToolError::Codex(err.into()))?;
|
||||
|
||||
@@ -20,8 +20,6 @@ pub enum ConfigShellToolType {
|
||||
Default,
|
||||
Local,
|
||||
UnifiedExec,
|
||||
/// Takes a command as a single string to be run in the user's default shell.
|
||||
ShellCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -50,8 +48,6 @@ impl ToolsConfig {
|
||||
|
||||
let shell_type = if features.enabled(Feature::UnifiedExec) {
|
||||
ConfigShellToolType::UnifiedExec
|
||||
} else if features.enabled(Feature::ShellCommandTool) {
|
||||
ConfigShellToolType::ShellCommand
|
||||
} else {
|
||||
model_family.shell_type.clone()
|
||||
};
|
||||
@@ -142,15 +138,6 @@ fn create_exec_command_tool() -> ToolSpec {
|
||||
description: Some("Shell command to execute.".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional working directory to run the command in; defaults to the turn cwd."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"shell".to_string(),
|
||||
JsonSchema::String {
|
||||
@@ -169,7 +156,8 @@ fn create_exec_command_tool() -> ToolSpec {
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for output before yielding.".to_string(),
|
||||
"Maximum time in milliseconds to wait for output after writing the input (default: 1000)."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -181,24 +169,6 @@ fn create_exec_command_tool() -> ToolSpec {
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"with_escalated_permissions".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions"
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "exec_command".to_string(),
|
||||
@@ -277,7 +247,9 @@ fn create_shell_tool() -> ToolSpec {
|
||||
properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
description: Some(
|
||||
"The timeout for the command in milliseconds (default: 1000).".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -306,53 +278,6 @@ fn create_shell_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_shell_command_tool() -> ToolSpec {
|
||||
let mut properties = BTreeMap::new();
|
||||
properties.insert(
|
||||
"command".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"The shell script to execute in the user's default shell".to_string(),
|
||||
),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"workdir".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("The working directory to execute the command in".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"timeout_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("The timeout for the command in milliseconds".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"with_escalated_permissions".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
|
||||
},
|
||||
);
|
||||
properties.insert(
|
||||
"justification".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "shell_command".to_string(),
|
||||
description: "Runs a shell command string and returns its output.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_view_image_tool() -> ToolSpec {
|
||||
// Support only local filesystem path.
|
||||
let mut properties = BTreeMap::new();
|
||||
@@ -942,7 +867,6 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
use crate::tools::handlers::PlanHandler;
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
@@ -958,7 +882,6 @@ pub(crate) fn build_specs(
|
||||
let view_image_handler = Arc::new(ViewImageHandler);
|
||||
let mcp_handler = Arc::new(McpHandler);
|
||||
let mcp_resource_handler = Arc::new(McpResourceHandler);
|
||||
let shell_command_handler = Arc::new(ShellCommandHandler);
|
||||
|
||||
match &config.shell_type {
|
||||
ConfigShellToolType::Default => {
|
||||
@@ -973,16 +896,12 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("exec_command", unified_exec_handler.clone());
|
||||
builder.register_handler("write_stdin", unified_exec_handler);
|
||||
}
|
||||
ConfigShellToolType::ShellCommand => {
|
||||
builder.push_spec(create_shell_command_tool());
|
||||
}
|
||||
}
|
||||
|
||||
// Always register shell aliases so older prompts remain compatible.
|
||||
builder.register_handler("shell", shell_handler.clone());
|
||||
builder.register_handler("container.exec", shell_handler.clone());
|
||||
builder.register_handler("local_shell", shell_handler);
|
||||
builder.register_handler("shell_command", shell_command_handler);
|
||||
|
||||
builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true);
|
||||
builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true);
|
||||
@@ -1118,7 +1037,6 @@ mod tests {
|
||||
ConfigShellToolType::Default => Some("shell"),
|
||||
ConfigShellToolType::Local => Some("local_shell"),
|
||||
ConfigShellToolType::UnifiedExec => None,
|
||||
ConfigShellToolType::ShellCommand => Some("shell_command"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1351,22 +1269,6 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &subset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_shell_command_present() {
|
||||
assert_model_tools(
|
||||
"codex-mini-latest",
|
||||
Features::with_defaults().enable(Feature::ShellCommandTool),
|
||||
&[
|
||||
"shell_command",
|
||||
"list_mcp_resources",
|
||||
"list_mcp_resource_templates",
|
||||
"read_mcp_resource",
|
||||
"update_plan",
|
||||
"view_image",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn test_parallel_support_flags() {
|
||||
@@ -1822,21 +1724,6 @@ mod tests {
|
||||
assert_eq!(description, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_command_tool() {
|
||||
let tool = super::create_shell_command_tool();
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description, name, ..
|
||||
}) = &tool
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert_eq!(name, "shell_command");
|
||||
|
||||
let expected = "Runs a shell command string and returns its output.";
|
||||
assert_eq!(description, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
let model_family = find_family_for_model("gpt-5-codex")
|
||||
|
||||
@@ -70,9 +70,6 @@ pub(crate) struct ExecCommandRequest<'a> {
|
||||
pub login: bool,
|
||||
pub yield_time_ms: Option<u64>,
|
||||
pub max_output_tokens: Option<usize>,
|
||||
pub workdir: Option<PathBuf>,
|
||||
pub with_escalated_permissions: Option<bool>,
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -202,9 +199,6 @@ mod tests {
|
||||
login: true,
|
||||
yield_time_ms,
|
||||
max_output_tokens: None,
|
||||
workdir: None,
|
||||
with_escalated_permissions: None,
|
||||
justification: None,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::Notify;
|
||||
@@ -39,10 +38,6 @@ impl UnifiedExecSessionManager {
|
||||
request: ExecCommandRequest<'_>,
|
||||
context: &UnifiedExecContext,
|
||||
) -> Result<UnifiedExecResponse, UnifiedExecError> {
|
||||
let cwd = request
|
||||
.workdir
|
||||
.clone()
|
||||
.unwrap_or_else(|| context.turn.cwd.clone());
|
||||
let shell_flag = if request.login { "-lc" } else { "-c" };
|
||||
let command = vec![
|
||||
request.shell.to_string(),
|
||||
@@ -50,15 +45,7 @@ impl UnifiedExecSessionManager {
|
||||
request.command.to_string(),
|
||||
];
|
||||
|
||||
let session = self
|
||||
.open_session_with_sandbox(
|
||||
command,
|
||||
cwd.clone(),
|
||||
request.with_escalated_permissions,
|
||||
request.justification,
|
||||
context,
|
||||
)
|
||||
.await?;
|
||||
let session = self.open_session_with_sandbox(command, context).await?;
|
||||
|
||||
let max_tokens = resolve_max_tokens(request.max_output_tokens);
|
||||
let yield_time_ms =
|
||||
@@ -79,7 +66,7 @@ impl UnifiedExecSessionManager {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.store_session(session, context, request.command, cwd.clone(), start)
|
||||
self.store_session(session, context, request.command, start)
|
||||
.await,
|
||||
)
|
||||
};
|
||||
@@ -100,7 +87,6 @@ impl UnifiedExecSessionManager {
|
||||
Self::emit_exec_end_from_context(
|
||||
context,
|
||||
request.command.to_string(),
|
||||
cwd,
|
||||
response.output.clone(),
|
||||
exit,
|
||||
response.wall_time,
|
||||
@@ -225,7 +211,6 @@ impl UnifiedExecSessionManager {
|
||||
session: UnifiedExecSession,
|
||||
context: &UnifiedExecContext,
|
||||
command: &str,
|
||||
cwd: PathBuf,
|
||||
started_at: Instant,
|
||||
) -> i32 {
|
||||
let session_id = self
|
||||
@@ -237,7 +222,7 @@ impl UnifiedExecSessionManager {
|
||||
turn_ref: Arc::clone(&context.turn),
|
||||
call_id: context.call_id.clone(),
|
||||
command: command.to_string(),
|
||||
cwd,
|
||||
cwd: context.turn.cwd.clone(),
|
||||
started_at,
|
||||
};
|
||||
self.sessions.lock().await.insert(session_id, entry);
|
||||
@@ -273,7 +258,6 @@ impl UnifiedExecSessionManager {
|
||||
async fn emit_exec_end_from_context(
|
||||
context: &UnifiedExecContext,
|
||||
command: String,
|
||||
cwd: PathBuf,
|
||||
aggregated_output: String,
|
||||
exit_code: i32,
|
||||
duration: Duration,
|
||||
@@ -292,7 +276,7 @@ impl UnifiedExecSessionManager {
|
||||
&context.call_id,
|
||||
None,
|
||||
);
|
||||
let emitter = ToolEmitter::unified_exec(command, cwd, true);
|
||||
let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true);
|
||||
emitter
|
||||
.emit(event_ctx, ToolEventStage::Success(output))
|
||||
.await;
|
||||
@@ -306,35 +290,24 @@ impl UnifiedExecSessionManager {
|
||||
.command
|
||||
.split_first()
|
||||
.ok_or(UnifiedExecError::MissingCommandLine)?;
|
||||
|
||||
let spawned = codex_utils_pty::spawn_pty_process(
|
||||
program,
|
||||
args,
|
||||
env.cwd.as_path(),
|
||||
&env.env,
|
||||
&env.arg0,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| UnifiedExecError::create_session(err.to_string()))?;
|
||||
let spawned =
|
||||
codex_utils_pty::spawn_pty_process(program, args, env.cwd.as_path(), &env.env)
|
||||
.await
|
||||
.map_err(|err| UnifiedExecError::create_session(err.to_string()))?;
|
||||
UnifiedExecSession::from_spawned(spawned, env.sandbox).await
|
||||
}
|
||||
|
||||
pub(super) async fn open_session_with_sandbox(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
with_escalated_permissions: Option<bool>,
|
||||
justification: Option<String>,
|
||||
context: &UnifiedExecContext,
|
||||
) -> Result<UnifiedExecSession, UnifiedExecError> {
|
||||
let mut orchestrator = ToolOrchestrator::new();
|
||||
let mut runtime = UnifiedExecRuntime::new(self);
|
||||
let req = UnifiedExecToolRequest::new(
|
||||
command,
|
||||
cwd,
|
||||
context.turn.cwd.clone(),
|
||||
create_env(&context.turn.shell_environment_policy),
|
||||
with_escalated_permissions,
|
||||
justification,
|
||||
);
|
||||
let tool_ctx = ToolCtx {
|
||||
session: context.session.as_ref(),
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
pub const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
|
||||
pub const USER_SHELL_COMMAND_CLOSE: &str = "</user_shell_command>";
|
||||
|
||||
pub fn is_user_shell_command_text(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(USER_SHELL_COMMAND_OPEN)
|
||||
}
|
||||
|
||||
fn format_duration_line(duration: Duration) -> String {
|
||||
let duration_seconds = duration.as_secs_f64();
|
||||
format!("Duration: {duration_seconds:.4} seconds")
|
||||
}
|
||||
|
||||
fn format_user_shell_command_body(command: &str, exec_output: &ExecToolCallOutput) -> String {
|
||||
let mut sections = Vec::new();
|
||||
sections.push("<command>".to_string());
|
||||
sections.push(command.to_string());
|
||||
sections.push("</command>".to_string());
|
||||
sections.push("<result>".to_string());
|
||||
sections.push(format!("Exit code: {}", exec_output.exit_code));
|
||||
sections.push(format_duration_line(exec_output.duration));
|
||||
sections.push("Output:".to_string());
|
||||
sections.push(format_exec_output_str(exec_output));
|
||||
sections.push("</result>".to_string());
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
pub fn format_user_shell_command_record(command: &str, exec_output: &ExecToolCallOutput) -> String {
|
||||
let body = format_user_shell_command_body(command, exec_output);
|
||||
format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}")
|
||||
}
|
||||
|
||||
pub fn user_shell_command_record_item(
|
||||
command: &str,
|
||||
exec_output: &ExecToolCallOutput,
|
||||
) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format_user_shell_command_record(command, exec_output),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::exec::StreamOutput;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn detects_user_shell_command_text_variants() {
|
||||
assert!(is_user_shell_command_text(
|
||||
"<user_shell_command>\necho hi\n</user_shell_command>"
|
||||
));
|
||||
assert!(!is_user_shell_command_text("echo hi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_basic_record() {
|
||||
let exec_output = ExecToolCallOutput {
|
||||
exit_code: 0,
|
||||
stdout: StreamOutput::new("hi".to_string()),
|
||||
stderr: StreamOutput::new(String::new()),
|
||||
aggregated_output: StreamOutput::new("hi".to_string()),
|
||||
duration: Duration::from_secs(1),
|
||||
timed_out: false,
|
||||
};
|
||||
let item = user_shell_command_record_item("echo hi", &exec_output);
|
||||
let ResponseItem::Message { content, .. } = item else {
|
||||
panic!("expected message");
|
||||
};
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("expected input text");
|
||||
};
|
||||
assert_eq!(
|
||||
text,
|
||||
"<user_shell_command>\n<command>\necho hi\n</command>\n<result>\nExit code: 0\nDuration: 1.0000 seconds\nOutput:\nhi\n</result>\n</user_shell_command>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uses_aggregated_output_over_streams() {
|
||||
let exec_output = ExecToolCallOutput {
|
||||
exit_code: 42,
|
||||
stdout: StreamOutput::new("stdout-only".to_string()),
|
||||
stderr: StreamOutput::new("stderr-only".to_string()),
|
||||
aggregated_output: StreamOutput::new("combined output wins".to_string()),
|
||||
duration: Duration::from_millis(120),
|
||||
timed_out: false,
|
||||
};
|
||||
let record = format_user_shell_command_record("false", &exec_output);
|
||||
assert_eq!(
|
||||
record,
|
||||
"<user_shell_command>\n<command>\nfalse\n</command>\n<result>\nExit code: 42\nDuration: 0.1200 seconds\nOutput:\ncombined output wins\n</result>\n</user_shell_command>"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -61,18 +61,6 @@ impl ResponsesRequest {
|
||||
self.0.body_json().unwrap()
|
||||
}
|
||||
|
||||
/// Returns all `input_text` spans from `message` inputs for the provided role.
|
||||
pub fn message_input_texts(&self, role: &str) -> Vec<String> {
|
||||
self.inputs_of_type("message")
|
||||
.into_iter()
|
||||
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
|
||||
.filter_map(|item| item.get("content").and_then(Value::as_array).cloned())
|
||||
.flatten()
|
||||
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
|
||||
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn input(&self) -> Vec<Value> {
|
||||
self.0.body_json::<Value>().unwrap()["input"]
|
||||
.as_array()
|
||||
@@ -446,6 +434,12 @@ pub async fn mount_sse_once(server: &MockServer, body: String) -> ResponseMock {
|
||||
response_mock
|
||||
}
|
||||
|
||||
pub async fn mount_sse(server: &MockServer, body: String) -> ResponseMock {
|
||||
let (mock, response_mock) = base_mock();
|
||||
mock.respond_with(sse_response(body)).mount(server).await;
|
||||
response_mock
|
||||
}
|
||||
|
||||
pub async fn start_mock_server() -> MockServer {
|
||||
MockServer::builder()
|
||||
.body_print_limit(BodyPrintLimit::Limited(80_000))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
use codex_core::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
@@ -25,7 +24,6 @@ use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
@@ -73,10 +71,6 @@ enum ActionKind {
|
||||
RunCommand {
|
||||
command: &'static [&'static str],
|
||||
},
|
||||
RunUnifiedExecCommand {
|
||||
command: &'static str,
|
||||
justification: Option<&'static str>,
|
||||
},
|
||||
ApplyPatchFunction {
|
||||
target: TargetPath,
|
||||
content: &'static str,
|
||||
@@ -87,9 +81,6 @@ enum ActionKind {
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_UNIFIED_EXEC_JUSTIFICATION: &str =
|
||||
"Requires escalated permissions to bypass the sandbox in tests.";
|
||||
|
||||
impl ActionKind {
|
||||
async fn prepare(
|
||||
&self,
|
||||
@@ -143,26 +134,6 @@ impl ActionKind {
|
||||
let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?;
|
||||
Ok((event, Some(command)))
|
||||
}
|
||||
ActionKind::RunUnifiedExecCommand {
|
||||
command,
|
||||
justification,
|
||||
} => {
|
||||
let event = exec_command_event(
|
||||
call_id,
|
||||
command,
|
||||
Some(1000),
|
||||
with_escalated_permissions,
|
||||
*justification,
|
||||
)?;
|
||||
Ok((
|
||||
event,
|
||||
Some(vec![
|
||||
"/bin/bash".to_string(),
|
||||
"-lc".to_string(),
|
||||
command.to_string(),
|
||||
]),
|
||||
))
|
||||
}
|
||||
ActionKind::ApplyPatchFunction { target, content } => {
|
||||
let (path, patch_path) = target.resolve_for_patch(test);
|
||||
let _ = fs::remove_file(&path);
|
||||
@@ -212,28 +183,6 @@ fn shell_event(
|
||||
Ok(ev_function_call(call_id, "shell", &args_str))
|
||||
}
|
||||
|
||||
fn exec_command_event(
|
||||
call_id: &str,
|
||||
cmd: &str,
|
||||
yield_time_ms: Option<u64>,
|
||||
with_escalated_permissions: bool,
|
||||
justification: Option<&str>,
|
||||
) -> Result<Value> {
|
||||
let mut args = json!({
|
||||
"cmd": cmd.to_string(),
|
||||
});
|
||||
if let Some(yield_time_ms) = yield_time_ms {
|
||||
args["yield_time_ms"] = json!(yield_time_ms);
|
||||
}
|
||||
if with_escalated_permissions {
|
||||
args["with_escalated_permissions"] = json!(true);
|
||||
let reason = justification.unwrap_or(DEFAULT_UNIFIED_EXEC_JUSTIFICATION);
|
||||
args["justification"] = json!(reason);
|
||||
}
|
||||
let args_str = serde_json::to_string(&args)?;
|
||||
Ok(ev_function_call(call_id, "exec_command", &args_str))
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Expectation {
|
||||
FileCreated {
|
||||
@@ -257,9 +206,6 @@ enum Expectation {
|
||||
CommandSuccess {
|
||||
stdout_contains: &'static str,
|
||||
},
|
||||
CommandFailure {
|
||||
output_contains: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl Expectation {
|
||||
@@ -391,19 +337,6 @@ impl Expectation {
|
||||
result.stdout
|
||||
);
|
||||
}
|
||||
Expectation::CommandFailure { output_contains } => {
|
||||
assert_ne!(
|
||||
result.exit_code,
|
||||
Some(0),
|
||||
"expected non-zero exit for command failure: {}",
|
||||
result.stdout
|
||||
);
|
||||
assert!(
|
||||
result.stdout.contains(output_contains),
|
||||
"command failure stderr missing {output_contains:?}: {}",
|
||||
result.stdout
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -429,7 +362,7 @@ struct ScenarioSpec {
|
||||
sandbox_policy: SandboxPolicy,
|
||||
action: ActionKind,
|
||||
with_escalated_permissions: bool,
|
||||
features: Vec<Feature>,
|
||||
requires_apply_patch_tool: bool,
|
||||
model_override: Option<&'static str>,
|
||||
outcome: Outcome,
|
||||
expectation: Expectation,
|
||||
@@ -477,24 +410,10 @@ fn parse_result(item: &Value) -> CommandResult {
|
||||
let stdout = parsed["output"].as_str().unwrap_or_default().to_string();
|
||||
CommandResult { exit_code, stdout }
|
||||
}
|
||||
Err(_) => {
|
||||
let regex =
|
||||
Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap();
|
||||
// parse freeform output
|
||||
if let Some(captures) = regex.captures(output_str) {
|
||||
let exit_code = captures.get(1).unwrap().as_str().parse::<i64>().unwrap();
|
||||
let output = captures.get(2).unwrap().as_str();
|
||||
CommandResult {
|
||||
exit_code: Some(exit_code),
|
||||
stdout: output.to_string(),
|
||||
}
|
||||
} else {
|
||||
CommandResult {
|
||||
exit_code: None,
|
||||
stdout: output_str.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => CommandResult {
|
||||
exit_code: None,
|
||||
stdout: output_str.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,7 +506,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "danger-on-request",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileCreated {
|
||||
@@ -604,7 +523,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
response_body: "danger-network-ok",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::NetworkSuccess {
|
||||
@@ -619,7 +538,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
command: &["echo", "trusted-unless"],
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::CommandSuccess {
|
||||
@@ -635,7 +554,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "danger-on-failure",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileCreated {
|
||||
@@ -652,7 +571,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "danger-unless-trusted",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -672,7 +591,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "danger-never",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileCreated {
|
||||
@@ -689,7 +608,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "read-only-approval",
|
||||
},
|
||||
with_escalated_permissions: true,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -708,7 +627,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
command: &["echo", "trusted-read-only"],
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::CommandSuccess {
|
||||
@@ -724,7 +643,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
response_body: "should-not-see",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
|
||||
@@ -738,7 +657,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "should-not-write",
|
||||
},
|
||||
with_escalated_permissions: true,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Denied,
|
||||
@@ -759,7 +678,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "read-only-on-failure",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -779,7 +698,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
response_body: "read-only-network-ok",
|
||||
},
|
||||
with_escalated_permissions: true,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -798,7 +717,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "shell-apply-patch",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: None,
|
||||
outcome: Outcome::PatchApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -818,7 +737,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "function-apply-patch",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: Some("gpt-5-codex"),
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::PatchApplied {
|
||||
@@ -835,7 +754,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "function-patch-danger",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![Feature::ApplyPatchFreeform],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: Some("gpt-5-codex"),
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::PatchApplied {
|
||||
@@ -852,7 +771,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "function-patch-outside",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: Some("gpt-5-codex"),
|
||||
outcome: Outcome::PatchApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -872,7 +791,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "function-patch-outside-denied",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: Some("gpt-5-codex"),
|
||||
outcome: Outcome::PatchApproval {
|
||||
decision: ReviewDecision::Denied,
|
||||
@@ -892,7 +811,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "shell-patch-outside",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: None,
|
||||
outcome: Outcome::PatchApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -912,7 +831,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "function-patch-unless-trusted",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: Some("gpt-5-codex"),
|
||||
outcome: Outcome::PatchApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -932,7 +851,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "function-patch-never",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: true,
|
||||
model_override: Some("gpt-5-codex"),
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileNotCreated {
|
||||
@@ -951,7 +870,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "read-only-unless-trusted",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -971,7 +890,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "read-only-never",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileNotCreated {
|
||||
@@ -991,7 +910,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
command: &["echo", "trusted-never"],
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::CommandSuccess {
|
||||
@@ -1007,7 +926,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "workspace-on-request",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileCreated {
|
||||
@@ -1024,7 +943,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
response_body: "workspace-network-blocked",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::NetworkFailure { expect_tag: "ERR:" },
|
||||
@@ -1038,7 +957,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "workspace-on-request-outside",
|
||||
},
|
||||
with_escalated_permissions: true,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -1058,7 +977,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
response_body: "workspace-network-ok",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::NetworkSuccess {
|
||||
@@ -1075,7 +994,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "workspace-on-failure",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -1095,7 +1014,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "workspace-unless-trusted",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
@@ -1115,7 +1034,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
content: "workspace-never",
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![],
|
||||
requires_apply_patch_tool: false,
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::FileNotCreated {
|
||||
@@ -1127,62 +1046,6 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
},
|
||||
},
|
||||
},
|
||||
ScenarioSpec {
|
||||
name: "unified exec on request no approval for safe command",
|
||||
approval_policy: OnRequest,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "echo \"hello unified exec\"",
|
||||
justification: None,
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![Feature::UnifiedExec],
|
||||
model_override: None,
|
||||
outcome: Outcome::Auto,
|
||||
expectation: Expectation::CommandSuccess {
|
||||
stdout_contains: "hello unified exec",
|
||||
},
|
||||
},
|
||||
#[cfg(not(all(target_os = "linux", target_arch = "aarch64")))]
|
||||
// Linux sandbox arg0 test workaround doesn't work on ARM
|
||||
ScenarioSpec {
|
||||
name: "unified exec on request escalated requires approval",
|
||||
approval_policy: OnRequest,
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "python3 -c 'print('\"'\"'escalated unified exec'\"'\"')'",
|
||||
justification: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
|
||||
},
|
||||
with_escalated_permissions: true,
|
||||
features: vec![Feature::UnifiedExec],
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Approved,
|
||||
expected_reason: Some(DEFAULT_UNIFIED_EXEC_JUSTIFICATION),
|
||||
},
|
||||
expectation: Expectation::CommandSuccess {
|
||||
stdout_contains: "escalated unified exec",
|
||||
},
|
||||
},
|
||||
ScenarioSpec {
|
||||
name: "unified exec on request requires approval unless trusted",
|
||||
approval_policy: AskForApproval::UnlessTrusted,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
action: ActionKind::RunUnifiedExecCommand {
|
||||
command: "git reset --hard",
|
||||
justification: None,
|
||||
},
|
||||
with_escalated_permissions: false,
|
||||
features: vec![Feature::UnifiedExec],
|
||||
model_override: None,
|
||||
outcome: Outcome::ExecApproval {
|
||||
decision: ReviewDecision::Denied,
|
||||
expected_reason: None,
|
||||
},
|
||||
expectation: Expectation::CommandFailure {
|
||||
output_contains: "rejected by user",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1202,7 +1065,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let approval_policy = scenario.approval_policy;
|
||||
let sandbox_policy = scenario.sandbox_policy.clone();
|
||||
let features = scenario.features.clone();
|
||||
let requires_apply_patch_tool = scenario.requires_apply_patch_tool;
|
||||
let model_override = scenario.model_override;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
@@ -1212,8 +1075,8 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
|
||||
config.model = model.to_string();
|
||||
config.model_family =
|
||||
find_family_for_model(model).expect("model should map to a known family");
|
||||
for feature in features {
|
||||
config.features.enable(feature);
|
||||
if requires_apply_patch_tool {
|
||||
config.include_apply_patch_tool = true;
|
||||
}
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -54,7 +54,7 @@ const COMPACT_PROMPT_MARKER: &str =
|
||||
pub(super) const TEST_COMPACT_PROMPT: &str =
|
||||
"You are performing a CONTEXT CHECKPOINT COMPACTION for a tool.\nTest-only compact prompt.";
|
||||
|
||||
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.";
|
||||
pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.";
|
||||
|
||||
fn auto_summary(summary: &str) -> String {
|
||||
summary.to_string()
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
// Aggregates all former standalone integration tests as modules.
|
||||
use codex_arg0::arg0_dispatch;
|
||||
use ctor::ctor;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// This code runs before any other tests are run.
|
||||
// It allows the test binary to behave like codex and dispatch to apply_patch and codex-linux-sandbox
|
||||
// based on the arg0.
|
||||
// NOTE: this doesn't work on ARM
|
||||
#[ctor]
|
||||
pub static CODEX_ALIASES_TEMP_DIR: TempDir = unsafe {
|
||||
#[allow(clippy::unwrap_used)]
|
||||
arg0_dispatch().unwrap()
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
mod abort_tasks;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -9,6 +8,7 @@ use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::mount_sse;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
@@ -101,7 +101,8 @@ async fn process_sse_emits_failed_event_on_parse_error() {
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -140,7 +141,8 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -188,18 +190,11 @@ async fn process_sse_failed_event_records_response_error_message() {
|
||||
})]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -245,18 +240,11 @@ async fn process_sse_failed_event_logs_parse_error() {
|
||||
})]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -300,7 +288,8 @@ async fn process_sse_failed_event_logs_missing_error() {
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -342,18 +331,10 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -442,7 +423,7 @@ async fn process_sse_emits_completed_telemetry() {
|
||||
async fn handle_response_item_records_tool_result_for_custom_tool_call() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_custom_tool_call(
|
||||
@@ -454,18 +435,11 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -512,7 +486,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
|
||||
async fn handle_response_item_records_tool_result_for_function_call() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_function_call("function-call", "nonexistent", "{\"value\":1}"),
|
||||
@@ -521,18 +495,10 @@ async fn handle_response_item_records_tool_result_for_function_call() {
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -579,7 +545,7 @@ async fn handle_response_item_records_tool_result_for_function_call() {
|
||||
async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
serde_json::json!({
|
||||
@@ -598,18 +564,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -650,7 +608,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
|
||||
async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]),
|
||||
@@ -659,18 +617,10 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(move |config| {
|
||||
config.features.disable(Feature::GhostCommit);
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -749,23 +699,10 @@ fn tool_decision_assertion<'a>(
|
||||
#[traced_test]
|
||||
async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call(
|
||||
"auto_config_call",
|
||||
"completed",
|
||||
vec!["/bin/echo", "local shell"],
|
||||
),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_local_shell_call("auto_config_call", "completed", vec!["/bin/echo", "hello"]),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
@@ -775,6 +712,8 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::OnRequest;
|
||||
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -789,7 +728,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
|
||||
|
||||
logs_assert(tool_decision_assertion(
|
||||
"auto_config_call",
|
||||
@@ -802,7 +741,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
|
||||
#[traced_test]
|
||||
async fn handle_container_exec_user_approved_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]),
|
||||
@@ -811,18 +750,11 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -861,7 +793,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
|
||||
async fn handle_container_exec_user_approved_for_session_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]),
|
||||
@@ -869,18 +801,12 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -919,7 +845,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
|
||||
async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]),
|
||||
@@ -927,18 +853,12 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -977,7 +897,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
|
||||
async fn handle_container_exec_user_denies_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]),
|
||||
@@ -986,17 +906,11 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1035,7 +949,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
|
||||
async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]),
|
||||
@@ -1043,18 +957,12 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
@@ -1093,7 +1001,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
|
||||
async fn handle_sandbox_error_user_denies_records_tool_decision() {
|
||||
let server = start_mock_server().await;
|
||||
|
||||
mount_sse_once(
|
||||
mount_sse(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]),
|
||||
@@ -1102,18 +1010,11 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
|
||||
)
|
||||
.await;
|
||||
|
||||
mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "local shell done"),
|
||||
ev_completed("done"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.approval_policy = AskForApproval::UnlessTrusted;
|
||||
config.model_provider.request_max_retries = Some(0);
|
||||
config.model_provider.stream_max_retries = Some(0);
|
||||
})
|
||||
.build(&server)
|
||||
.await
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InitialHistory;
|
||||
use codex_core::protocol::ResumedHistory;
|
||||
use codex_core::protocol::RolloutItem;
|
||||
use codex_core::protocol::TurnContextItem;
|
||||
use codex_core::protocol::WarningEvent;
|
||||
use codex_protocol::ConversationId;
|
||||
use core::time::Duration;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::wait_for_event;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory {
|
||||
let turn_ctx = TurnContextItem {
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy.clone(),
|
||||
model: previous_model.to_string(),
|
||||
effort: config.model_reasoning_effort,
|
||||
summary: config.model_reasoning_summary,
|
||||
};
|
||||
|
||||
InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: ConversationId::default(),
|
||||
history: vec![RolloutItem::TurnContext(turn_ctx)],
|
||||
rollout_path: rollout_path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn emits_warning_when_resumed_model_differs() {
|
||||
// Arrange a config with a current model and a prior rollout recorded under a different model.
|
||||
let home = TempDir::new().expect("tempdir");
|
||||
let mut config = load_default_config_for_test(&home);
|
||||
config.model = "current-model".to_string();
|
||||
// Ensure cwd is absolute (the helper sets it to the temp dir already).
|
||||
assert!(config.cwd.is_absolute());
|
||||
|
||||
let rollout_path = home.path().join("rollout.jsonl");
|
||||
std::fs::write(&rollout_path, "").expect("create rollout placeholder");
|
||||
|
||||
let initial_history = resume_history(&config, "previous-model", &rollout_path);
|
||||
|
||||
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test"));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
|
||||
|
||||
// Act: resume the conversation.
|
||||
let NewConversation { conversation, .. } = conversation_manager
|
||||
.resume_conversation_with_history(config, initial_history, auth_manager)
|
||||
.await
|
||||
.expect("resume conversation");
|
||||
|
||||
// Assert: a Warning event is emitted describing the model mismatch.
|
||||
let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await;
|
||||
let EventMsg::Warning(WarningEvent { message }) = warning else {
|
||||
panic!("expected warning event");
|
||||
};
|
||||
assert!(message.contains("previous-model"));
|
||||
assert!(message.contains("current-model"));
|
||||
|
||||
// Drain the TaskComplete/Shutdown window to avoid leaking tasks between tests.
|
||||
// The warning is emitted during initialization, so a short sleep is sufficient.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
@@ -66,7 +66,7 @@ fn parse_unified_exec_output(raw: &str) -> Result<ParsedUnifiedExecOutput> {
|
||||
let cleaned = raw.trim_matches('\r');
|
||||
let captures = regex
|
||||
.captures(cleaned)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output {raw}"))?;
|
||||
.ok_or_else(|| anyhow::anyhow!("missing Output section in unified exec output"))?;
|
||||
|
||||
let chunk_id = captures
|
||||
.name("chunk_id")
|
||||
@@ -223,90 +223,6 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_respects_workdir_override() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.use_experimental_unified_exec_tool = true;
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let workdir = cwd.path().join("uexec_workdir_test");
|
||||
std::fs::create_dir_all(&workdir)?;
|
||||
|
||||
let call_id = "uexec-workdir";
|
||||
let args = json!({
|
||||
"cmd": "pwd",
|
||||
"yield_time_ms": 250,
|
||||
"workdir": workdir.to_string_lossy().to_string(),
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "finished"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "run workdir test".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let output = outputs
|
||||
.get(call_id)
|
||||
.expect("missing exec_command workdir output");
|
||||
let output_text = output.output.trim();
|
||||
let output_canonical = std::fs::canonicalize(output_text)?;
|
||||
let expected_canonical = std::fs::canonicalize(&workdir)?;
|
||||
assert_eq!(
|
||||
output_canonical, expected_canonical,
|
||||
"pwd should reflect the requested workdir override"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -1368,8 +1284,6 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
// Skipped on arm because the ctor logic to handle arg0 doesn't work on ARM
|
||||
#[cfg(not(target_arch = "arm"))]
|
||||
async fn unified_exec_formats_large_output_summary() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
@@ -1453,75 +1367,3 @@ PY
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unified_exec_runs_under_sandbox() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
skip_if_sandbox!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
config.features.enable(Feature::UnifiedExec);
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let call_id = "uexec";
|
||||
let args = serde_json::json!({
|
||||
"cmd": "echo 'hello'",
|
||||
"yield_time_ms": 500,
|
||||
});
|
||||
|
||||
let responses = vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
];
|
||||
mount_sse_sequence(&server, responses).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "summarize large output".into(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
// Important!
|
||||
sandbox_policy: SandboxPolicy::ReadOnly,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let requests = server.received_requests().await.expect("recorded requests");
|
||||
assert!(!requests.is_empty(), "expected at least one POST request");
|
||||
|
||||
let bodies = requests
|
||||
.iter()
|
||||
.map(|req| req.body_json::<Value>().expect("request json"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let outputs = collect_tool_outputs(&bodies)?;
|
||||
let output = outputs.get(call_id).expect("missing output");
|
||||
|
||||
assert_regex_match("hello[\r\n]+", &output.output);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,20 +2,35 @@ use codex_core::ConversationManager;
|
||||
use codex_core::NewConversation;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::ExecCommandEndEvent;
|
||||
use codex_core::protocol::ExecOutputStream;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::TurnAbortReason;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::load_default_config_for_test;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use regex_lite::escape;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn detect_python_executable() -> Option<String> {
|
||||
let candidates = ["python3", "python"];
|
||||
candidates.iter().find_map(|candidate| {
|
||||
Command::new(candidate)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status()
|
||||
.ok()
|
||||
.and_then(|status| status.success().then(|| (*candidate).to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
let Some(python) = detect_python_executable() else {
|
||||
eprintln!("skipping test: python3 not found in PATH");
|
||||
return;
|
||||
};
|
||||
|
||||
// Create a temporary working directory with a known file.
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let file_name = "hello.txt";
|
||||
@@ -40,8 +55,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
.await
|
||||
.expect("create new conversation");
|
||||
|
||||
// 1) shell command should list the file
|
||||
let list_cmd = "ls".to_string();
|
||||
// 1) python should list the file
|
||||
let list_cmd = format!(
|
||||
"{python} -c \"import pathlib; print('\\n'.join(sorted(p.name for p in pathlib.Path('.').iterdir())))\""
|
||||
);
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand { command: list_cmd })
|
||||
.await
|
||||
@@ -59,8 +76,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
"ls output should include {file_name}, got: {stdout:?}"
|
||||
);
|
||||
|
||||
// 2) shell command should print the file contents verbatim
|
||||
let cat_cmd = format!("cat {file_name}");
|
||||
// 2) python should print the file contents verbatim
|
||||
let cat_cmd = format!(
|
||||
"{python} -c \"import pathlib; print(pathlib.Path('{file_name}').read_text(), end='')\""
|
||||
);
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand { command: cat_cmd })
|
||||
.await
|
||||
@@ -76,7 +95,7 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
};
|
||||
assert_eq!(exit_code, 0);
|
||||
if cfg!(windows) {
|
||||
// Windows shells emit CRLF line endings; normalize so the assertion remains portable.
|
||||
// Windows' Python writes CRLF line endings; normalize so the assertion remains portable.
|
||||
stdout = stdout.replace("\r\n", "\n");
|
||||
}
|
||||
assert_eq!(stdout, contents);
|
||||
@@ -84,6 +103,10 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_shell_cmd_can_be_interrupted() {
|
||||
let Some(python) = detect_python_executable() else {
|
||||
eprintln!("skipping test: python3 not found in PATH");
|
||||
return;
|
||||
};
|
||||
// Set up isolated config and conversation.
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let config = load_default_config_for_test(&codex_home);
|
||||
@@ -98,7 +121,7 @@ async fn user_shell_cmd_can_be_interrupted() {
|
||||
.expect("create new conversation");
|
||||
|
||||
// Start a long-running command and then interrupt it.
|
||||
let sleep_cmd = "sleep 5".to_string();
|
||||
let sleep_cmd = format!("{python} -c \"import time; time.sleep(5)\"");
|
||||
codex
|
||||
.submit(Op::RunUserShellCommand { command: sleep_cmd })
|
||||
.await
|
||||
@@ -115,137 +138,3 @@ async fn user_shell_cmd_can_be_interrupted() {
|
||||
};
|
||||
assert_eq!(ev.reason, TurnAbortReason::Interrupted);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_shell_command_history_is_persisted_and_shared_with_model() -> anyhow::Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = core_test_support::test_codex::test_codex();
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
#[cfg(windows)]
|
||||
let command = r#"$val = $env:CODEX_SANDBOX; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string();
|
||||
#[cfg(not(windows))]
|
||||
let command = r#"sh -c "printf '%s' \"${CODEX_SANDBOX:-not-set}\"""#.to_string();
|
||||
|
||||
test.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: command.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let begin_event = wait_for_event_match(&test.codex, |ev| match ev {
|
||||
EventMsg::ExecCommandBegin(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(begin_event.is_user_shell_command);
|
||||
let matches_last_arg = begin_event.command.last() == Some(&command);
|
||||
let matches_split = shlex::split(&command).is_some_and(|split| split == begin_event.command);
|
||||
assert!(
|
||||
matches_last_arg || matches_split,
|
||||
"user command begin event should include the original command; got: {:?}",
|
||||
begin_event.command
|
||||
);
|
||||
|
||||
let delta_event = wait_for_event_match(&test.codex, |ev| match ev {
|
||||
EventMsg::ExecCommandOutputDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(delta_event.stream, ExecOutputStream::Stdout);
|
||||
let chunk_text =
|
||||
String::from_utf8(delta_event.chunk.clone()).expect("user command chunk is valid utf-8");
|
||||
assert_eq!(chunk_text.trim(), "not-set");
|
||||
|
||||
let end_event = wait_for_event_match(&test.codex, |ev| match ev {
|
||||
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(end_event.exit_code, 0);
|
||||
assert_eq!(end_event.stdout.trim(), "not-set");
|
||||
|
||||
let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let responses = vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
])];
|
||||
let mock = responses::mount_sse_sequence(&server, responses).await;
|
||||
|
||||
test.submit_turn("follow-up after shell command").await?;
|
||||
|
||||
let request = mock.single_request();
|
||||
|
||||
let command_message = request
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.find(|text| text.contains("<user_shell_command>"))
|
||||
.expect("command message recorded in request");
|
||||
let command_message = command_message.replace("\r\n", "\n");
|
||||
let escaped_command = escape(&command);
|
||||
let expected_pattern = format!(
|
||||
r"(?m)\A<user_shell_command>\n<command>\n{escaped_command}\n</command>\n<result>\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nnot-set\n</result>\n</user_shell_command>\z"
|
||||
);
|
||||
assert_regex_match(&expected_pattern, &command_message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_shell_command_output_is_truncated_in_history() -> anyhow::Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = core_test_support::test_codex::test_codex();
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
#[cfg(windows)]
|
||||
let command = r#"for ($i=1; $i -le 400; $i++) { Write-Output $i }"#.to_string();
|
||||
#[cfg(not(windows))]
|
||||
let command = "seq 1 400".to_string();
|
||||
|
||||
test.codex
|
||||
.submit(Op::RunUserShellCommand {
|
||||
command: command.clone(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let end_event = wait_for_event_match(&test.codex, |ev| match ev {
|
||||
EventMsg::ExecCommandEnd(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(end_event.exit_code, 0);
|
||||
|
||||
let _ = wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
|
||||
|
||||
let responses = vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_assistant_message("msg-1", "done"),
|
||||
responses::ev_completed("resp-1"),
|
||||
])];
|
||||
let mock = responses::mount_sse_sequence(&server, responses).await;
|
||||
|
||||
test.submit_turn("follow-up after shell command").await?;
|
||||
|
||||
let request = mock.single_request();
|
||||
let command_message = request
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.find(|text| text.contains("<user_shell_command>"))
|
||||
.expect("command message recorded in request");
|
||||
let command_message = command_message.replace("\r\n", "\n");
|
||||
|
||||
let head = (1..=128).map(|i| format!("{i}\n")).collect::<String>();
|
||||
let tail = (273..=400).map(|i| format!("{i}\n")).collect::<String>();
|
||||
let truncated_body =
|
||||
format!("Total output lines: 400\n\n{head}\n[... omitted 144 of 400 lines ...]\n\n{tail}");
|
||||
let escaped_command = escape(&command);
|
||||
let escaped_truncated_body = escape(&truncated_body);
|
||||
let expected_pattern = format!(
|
||||
r"(?m)\A<user_shell_command>\n<command>\n{escaped_command}\n</command>\n<result>\nExit code: 0\nDuration: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n{escaped_truncated_body}\n</result>\n</user_shell_command>\z"
|
||||
);
|
||||
assert_regex_match(&expected_pattern, &command_message);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub struct Cli {
|
||||
#[arg(long = "profile", short = 'p')]
|
||||
pub config_profile: Option<String>,
|
||||
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write).
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write).
|
||||
#[arg(long = "full-auto", default_value_t = false)]
|
||||
pub full_auto: bool,
|
||||
|
||||
|
||||
@@ -548,7 +548,7 @@ fn warning_event_produces_error_item() {
|
||||
let out = ep.collect_thread_events(&event(
|
||||
"e1",
|
||||
EventMsg::Warning(WarningEvent {
|
||||
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
|
||||
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(),
|
||||
}),
|
||||
));
|
||||
assert_eq!(
|
||||
@@ -557,7 +557,7 @@ fn warning_event_produces_error_item() {
|
||||
item: ThreadItem {
|
||||
id: "item_0".to_string(),
|
||||
details: ThreadItemDetails::Error(ErrorItem {
|
||||
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
|
||||
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(),
|
||||
}),
|
||||
},
|
||||
})]
|
||||
|
||||
21
codex-rs/protocol-ts/Cargo.toml
Normal file
21
codex-rs/protocol-ts/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
edition = "2024"
|
||||
name = "codex-protocol-ts"
|
||||
version = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_protocol_ts"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "codex-protocol-ts"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
ts-rs = { workspace = true }
|
||||
10
codex-rs/protocol-ts/generate-ts
Executable file
10
codex-rs/protocol-ts/generate-ts
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
just codex generate-ts --prettier ../node_modules/.bin/prettier --out "$tmpdir"
|
||||
|
||||
echo "wrote output to $tmpdir"
|
||||
135
codex-rs/protocol-ts/src/lib.rs
Normal file
135
codex-rs/protocol-ts/src/lib.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::anyhow;
|
||||
use codex_app_server_protocol::ClientNotification;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::export_client_responses;
|
||||
use codex_app_server_protocol::export_server_responses;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use ts_rs::TS;
|
||||
|
||||
const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
|
||||
|
||||
pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> {
|
||||
ensure_dir(out_dir)?;
|
||||
|
||||
// Generate the TS bindings client -> server messages.
|
||||
ClientRequest::export_all_to(out_dir)?;
|
||||
export_client_responses(out_dir)?;
|
||||
ClientNotification::export_all_to(out_dir)?;
|
||||
|
||||
// Generate the TS bindings server -> client messages.
|
||||
ServerRequest::export_all_to(out_dir)?;
|
||||
export_server_responses(out_dir)?;
|
||||
ServerNotification::export_all_to(out_dir)?;
|
||||
|
||||
// Generate index.ts that re-exports all types.
|
||||
generate_index_ts(out_dir)?;
|
||||
|
||||
// Prepend header to each generated .ts file
|
||||
let ts_files = ts_files_in(out_dir)?;
|
||||
for file in &ts_files {
|
||||
prepend_header_if_missing(file)?;
|
||||
}
|
||||
|
||||
// Format with Prettier by passing individual files (no shell globbing)
|
||||
if let Some(prettier_bin) = prettier
|
||||
&& !ts_files.is_empty()
|
||||
{
|
||||
let status = Command::new(prettier_bin)
|
||||
.arg("--write")
|
||||
.arg("--log-level")
|
||||
.arg("warn")
|
||||
.args(ts_files.iter().map(|p| p.as_os_str()))
|
||||
.status()
|
||||
.with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?;
|
||||
if !status.success() {
|
||||
return Err(anyhow!("Prettier failed with status {status}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_dir(dir: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dir)
|
||||
.with_context(|| format!("Failed to create output directory {}", dir.display()))
|
||||
}
|
||||
|
||||
fn prepend_header_if_missing(path: &Path) -> Result<()> {
|
||||
let mut content = String::new();
|
||||
{
|
||||
let mut f = fs::File::open(path)
|
||||
.with_context(|| format!("Failed to open {} for reading", path.display()))?;
|
||||
f.read_to_string(&mut content)
|
||||
.with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
}
|
||||
|
||||
if content.starts_with(HEADER) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut f = fs::File::create(path)
|
||||
.with_context(|| format!("Failed to open {} for writing", path.display()))?;
|
||||
f.write_all(HEADER.as_bytes())
|
||||
.with_context(|| format!("Failed to write header to {}", path.display()))?;
|
||||
f.write_all(content.as_bytes())
|
||||
.with_context(|| format!("Failed to write content to {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ts_files_in(dir: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut files = Vec::new();
|
||||
for entry in
|
||||
fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))?
|
||||
{
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension() == Some(OsStr::new("ts")) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Generate an index.ts file that re-exports all generated types.
|
||||
/// This allows consumers to import all types from a single file.
|
||||
fn generate_index_ts(out_dir: &Path) -> Result<PathBuf> {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
let mut stems: Vec<String> = ts_files_in(out_dir)?
|
||||
.into_iter()
|
||||
.filter_map(|p| {
|
||||
let stem = p.file_stem()?.to_string_lossy().into_owned();
|
||||
if stem == "index" { None } else { Some(stem) }
|
||||
})
|
||||
.collect();
|
||||
stems.sort();
|
||||
stems.dedup();
|
||||
|
||||
for name in stems {
|
||||
entries.push(format!("export type {{ {name} }} from \"./{name}\";\n"));
|
||||
}
|
||||
|
||||
let mut content =
|
||||
String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::<usize>());
|
||||
content.push_str(HEADER);
|
||||
for line in &entries {
|
||||
content.push_str(line);
|
||||
}
|
||||
|
||||
let index_path = out_dir.join("index.ts");
|
||||
let mut f = fs::File::create(&index_path)
|
||||
.with_context(|| format!("Failed to create {}", index_path.display()))?;
|
||||
f.write_all(content.as_bytes())
|
||||
.with_context(|| format!("Failed to write {}", index_path.display()))?;
|
||||
Ok(index_path)
|
||||
}
|
||||
20
codex-rs/protocol-ts/src/main.rs
Normal file
20
codex-rs/protocol-ts/src/main.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(about = "Generate TypeScript bindings for the Codex protocol")]
|
||||
struct Args {
|
||||
/// Output directory where .ts files will be written
|
||||
#[arg(short = 'o', long = "out", value_name = "DIR")]
|
||||
out_dir: PathBuf,
|
||||
|
||||
/// Optional path to the Prettier executable to format generated files
|
||||
#[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")]
|
||||
prettier: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
codex_protocol_ts::generate_ts(&args.out_dir, args.prettier.as_deref())
|
||||
}
|
||||
@@ -292,29 +292,13 @@ impl From<Vec<UserInput>> for ResponseInputItem {
|
||||
}
|
||||
|
||||
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
|
||||
/// or `shell`, the `arguments` field should deserialize to this struct.
|
||||
/// or shell`, the `arguments` field should deserialize to this struct.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct ShellToolCallParams {
|
||||
pub command: Vec<String>,
|
||||
pub workdir: Option<String>,
|
||||
|
||||
/// This is the maximum time in milliseconds that the command is allowed to run.
|
||||
#[serde(alias = "timeout")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub with_escalated_permissions: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub justification: Option<String>,
|
||||
}
|
||||
|
||||
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
|
||||
/// `arguments` field should deserialize to this struct.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
pub struct ShellCommandToolCallParams {
|
||||
pub command: String,
|
||||
pub workdir: Option<String>,
|
||||
|
||||
/// This is the maximum time in milliseconds that the command is allowed to run.
|
||||
/// Maximum time in milliseconds that the command is allowed to run (defaults to 1_000 ms when omitted).
|
||||
#[serde(alias = "timeout")]
|
||||
pub timeout_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::render::renderable::Renderable;
|
||||
use crate::resume_picker::ResumeSelection;
|
||||
use crate::tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::updates::UpdateAction;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
@@ -174,14 +174,13 @@ impl App {
|
||||
skip_world_writable_scan_once: false,
|
||||
};
|
||||
|
||||
// On startup, if Auto mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows.
|
||||
// On startup, if Auto mode (workspace-write) is active, warn about world-writable dirs on Windows.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let should_check = codex_core::get_platform_sandbox().is_some()
|
||||
&& matches!(
|
||||
app.config.sandbox_policy,
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly
|
||||
)
|
||||
&& !app
|
||||
.config
|
||||
@@ -192,8 +191,7 @@ impl App {
|
||||
let cwd = app.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> = std::env::vars().collect();
|
||||
let tx = app.app_event_tx.clone();
|
||||
let logs_base_dir = app.config.codex_home.clone();
|
||||
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
|
||||
Self::spawn_world_writable_scan(cwd, env_map, tx, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +201,7 @@ impl App {
|
||||
tui,
|
||||
AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new(
|
||||
latest_version,
|
||||
crate::update_action::get_update_action(),
|
||||
crate::updates::get_update_action(),
|
||||
))),
|
||||
)
|
||||
.await?;
|
||||
@@ -387,18 +385,9 @@ impl App {
|
||||
AppEvent::OpenFullAccessConfirmation { preset } => {
|
||||
self.chat_widget.open_full_access_confirmation(preset);
|
||||
}
|
||||
AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
} => {
|
||||
self.chat_widget.open_world_writable_warning_confirmation(
|
||||
preset,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan,
|
||||
);
|
||||
AppEvent::OpenWorldWritableWarningConfirmation { preset } => {
|
||||
self.chat_widget
|
||||
.open_world_writable_warning_confirmation(preset);
|
||||
}
|
||||
AppEvent::OpenFeedbackNote {
|
||||
category,
|
||||
@@ -459,15 +448,14 @@ impl App {
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
#[cfg(target_os = "windows")]
|
||||
let policy_is_workspace_write_or_ro = matches!(
|
||||
let policy_is_workspace_write = matches!(
|
||||
policy,
|
||||
codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. }
|
||||
| codex_core::protocol::SandboxPolicy::ReadOnly
|
||||
);
|
||||
|
||||
self.chat_widget.set_sandbox_policy(policy);
|
||||
|
||||
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
||||
// If sandbox policy becomes workspace-write, run the Windows world-writable scan.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// One-shot suppression if the user just confirmed continue.
|
||||
@@ -477,15 +465,14 @@ impl App {
|
||||
}
|
||||
|
||||
let should_check = codex_core::get_platform_sandbox().is_some()
|
||||
&& policy_is_workspace_write_or_ro
|
||||
&& policy_is_workspace_write
|
||||
&& !self.chat_widget.world_writable_warning_hidden();
|
||||
if should_check {
|
||||
let cwd = self.config.cwd.clone();
|
||||
let env_map: std::collections::HashMap<String, String> =
|
||||
std::env::vars().collect();
|
||||
let tx = self.app_event_tx.clone();
|
||||
let logs_base_dir = self.config.codex_home.clone();
|
||||
Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, tx);
|
||||
Self::spawn_world_writable_scan(cwd, env_map, tx, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,9 +486,6 @@ impl App {
|
||||
self.chat_widget
|
||||
.set_world_writable_warning_acknowledged(ack);
|
||||
}
|
||||
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
|
||||
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
|
||||
}
|
||||
AppEvent::PersistFullAccessWarningAcknowledged => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_hide_full_access_warning(true)
|
||||
@@ -532,21 +516,6 @@ impl App {
|
||||
));
|
||||
}
|
||||
}
|
||||
AppEvent::PersistRateLimitSwitchPromptHidden => {
|
||||
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.set_hide_rate_limit_model_nudge(true)
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
error = %err,
|
||||
"failed to persist rate limit switch prompt preference"
|
||||
);
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to save rate limit reminder preference: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
AppEvent::OpenApprovalsPopup => {
|
||||
self.chat_widget.open_approvals_popup();
|
||||
}
|
||||
@@ -655,50 +624,23 @@ impl App {
|
||||
fn spawn_world_writable_scan(
|
||||
cwd: PathBuf,
|
||||
env_map: std::collections::HashMap<String, String>,
|
||||
logs_base_dir: PathBuf,
|
||||
tx: AppEventSender,
|
||||
apply_preset_on_continue: bool,
|
||||
) {
|
||||
#[inline]
|
||||
fn normalize_windows_path_for_display(p: &std::path::Path) -> String {
|
||||
let canon = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
canon.display().to_string().replace('/', "\\")
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = codex_windows_sandbox::preflight_audit_everyone_writable(
|
||||
&cwd,
|
||||
&env_map,
|
||||
Some(logs_base_dir.as_path()),
|
||||
);
|
||||
if let Ok(ref paths) = result
|
||||
&& !paths.is_empty()
|
||||
{
|
||||
let as_strings: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|p| normalize_windows_path_for_display(p))
|
||||
.collect();
|
||||
let sample_paths: Vec<String> = as_strings.iter().take(3).cloned().collect();
|
||||
let extra_count = if as_strings.len() > sample_paths.len() {
|
||||
as_strings.len() - sample_paths.len()
|
||||
if codex_windows_sandbox::preflight_audit_everyone_writable(&cwd, &env_map).is_err() {
|
||||
if apply_preset_on_continue {
|
||||
if let Some(preset) = codex_common::approval_presets::builtin_approval_presets()
|
||||
.into_iter()
|
||||
.find(|p| p.id == "auto")
|
||||
{
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: Some(preset),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: None,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan: false,
|
||||
});
|
||||
} else if result.is_err() {
|
||||
// Scan failed: still warn, but with no examples and mark as failed.
|
||||
let sample_paths: Vec<String> = Vec::new();
|
||||
let extra_count = 0usize;
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: None,
|
||||
sample_paths,
|
||||
extra_count,
|
||||
failed_scan: true,
|
||||
});
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,12 +79,6 @@ pub(crate) enum AppEvent {
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
OpenWorldWritableWarningConfirmation {
|
||||
preset: Option<ApprovalPreset>,
|
||||
/// Up to 3 sample world-writable directories to display in the warning.
|
||||
sample_paths: Vec<String>,
|
||||
/// If there are more than `sample_paths`, this carries the remaining count.
|
||||
extra_count: usize,
|
||||
/// True when the scan failed (e.g. ACL query error) and protections could not be verified.
|
||||
failed_scan: bool,
|
||||
},
|
||||
|
||||
/// Show Windows Subsystem for Linux setup instructions for auto mode.
|
||||
@@ -104,9 +98,6 @@ pub(crate) enum AppEvent {
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
UpdateWorldWritableWarningAcknowledged(bool),
|
||||
|
||||
/// Update whether the rate limit switch prompt has been acknowledged for the session.
|
||||
UpdateRateLimitSwitchPromptHidden(bool),
|
||||
|
||||
/// Persist the acknowledgement flag for the full access warning prompt.
|
||||
PersistFullAccessWarningAcknowledged,
|
||||
|
||||
@@ -114,9 +105,6 @@ pub(crate) enum AppEvent {
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
PersistWorldWritableWarningAcknowledged,
|
||||
|
||||
/// Persist the acknowledgement flag for the rate limit switch prompt.
|
||||
PersistRateLimitSwitchPromptHidden,
|
||||
|
||||
/// Skip the next world-writable scan (one-shot) after a user-confirmed continue.
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
SkipNextWorldWritableScan,
|
||||
|
||||
@@ -117,9 +117,7 @@ impl ApprovalOverlay {
|
||||
.iter()
|
||||
.map(|opt| SelectionItem {
|
||||
name: opt.label.clone(),
|
||||
display_shortcut: opt
|
||||
.display_shortcut
|
||||
.or_else(|| opt.additional_shortcuts.first().copied()),
|
||||
display_shortcut: opt.display_shortcut,
|
||||
dismiss_on_select: false,
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ use ratatui::layout::Rect;
|
||||
// Note: Table-based layout previously used Constraint; the manual renderer
|
||||
// below no longer requires it.
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
@@ -97,9 +96,8 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
|
||||
let this_name_width = Line::from(name_spans.clone()).width();
|
||||
let mut full_spans: Vec<Span> = name_spans;
|
||||
if let Some(display_shortcut) = row.display_shortcut {
|
||||
full_spans.push(" (".into());
|
||||
full_spans.push(" ".into());
|
||||
full_spans.push(display_shortcut.into());
|
||||
full_spans.push(")".into());
|
||||
}
|
||||
if let Some(desc) = row.description.as_ref() {
|
||||
let gap = desc_col.saturating_sub(this_name_width);
|
||||
@@ -181,9 +179,8 @@ pub(crate) fn render_rows(
|
||||
);
|
||||
if Some(i) == state.selected_idx {
|
||||
// Match previous behavior: cyan + bold for the selected row.
|
||||
// Reset the style first to avoid inheriting dim from keyboard shortcuts.
|
||||
full_line.spans.iter_mut().for_each(|span| {
|
||||
span.style = Style::default().fg(Color::Cyan).bold();
|
||||
span.style = span.style.fg(Color::Cyan).bold();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -522,7 +522,6 @@ impl ChatWidget {
|
||||
.unwrap_or(false);
|
||||
|
||||
if high_usage
|
||||
&& !self.rate_limit_switch_prompt_hidden()
|
||||
&& self.config.model != NUDGE_MODEL_SLUG
|
||||
&& !matches!(
|
||||
self.rate_limit_switch_prompt,
|
||||
@@ -1143,21 +1142,8 @@ impl ChatWidget {
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'v') => {
|
||||
match paste_image_to_temp_png() {
|
||||
Ok((path, info)) => {
|
||||
self.attach_image(
|
||||
path,
|
||||
info.width,
|
||||
info.height,
|
||||
info.encoded_format.label(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to paste image: {err}");
|
||||
self.add_to_history(history_cell::new_error_event(format!(
|
||||
"Failed to paste image: {err}",
|
||||
)));
|
||||
}
|
||||
if let Ok((path, info)) = paste_image_to_temp_png() {
|
||||
self.attach_image(path, info.width, info.height, info.encoded_format.label());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1724,18 +1710,7 @@ impl ChatWidget {
|
||||
.find(|preset| preset.model == NUDGE_MODEL_SLUG)
|
||||
}
|
||||
|
||||
fn rate_limit_switch_prompt_hidden(&self) -> bool {
|
||||
self.config
|
||||
.notices
|
||||
.hide_rate_limit_model_nudge
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn maybe_show_pending_rate_limit_prompt(&mut self) {
|
||||
if self.rate_limit_switch_prompt_hidden() {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
return;
|
||||
}
|
||||
if !matches!(
|
||||
self.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Pending
|
||||
@@ -1769,10 +1744,6 @@ impl ChatWidget {
|
||||
})];
|
||||
|
||||
let keep_actions: Vec<SelectionAction> = Vec::new();
|
||||
let never_actions: Vec<SelectionAction> = vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true));
|
||||
tx.send(AppEvent::PersistRateLimitSwitchPromptHidden);
|
||||
})];
|
||||
let description = if preset.description.is_empty() {
|
||||
Some("Uses fewer credits for upcoming turns.".to_string())
|
||||
} else {
|
||||
@@ -1798,17 +1769,6 @@ impl ChatWidget {
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Keep current model (never show again)".to_string(),
|
||||
description: Some(
|
||||
"Hide future rate limit reminders about switching models.".to_string(),
|
||||
),
|
||||
selected_description: None,
|
||||
is_current: false,
|
||||
actions: never_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
@@ -2085,48 +2045,9 @@ impl ChatWidget {
|
||||
&& self.windows_world_writable_flagged()
|
||||
{
|
||||
let preset_clone = preset.clone();
|
||||
// Compute sample paths for the warning popup.
|
||||
let mut env_map: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
for (k, v) in std::env::vars() {
|
||||
env_map.insert(k, v);
|
||||
}
|
||||
let (sample_paths, extra_count, failed_scan) =
|
||||
match codex_windows_sandbox::preflight_audit_everyone_writable(
|
||||
&self.config.cwd,
|
||||
&env_map,
|
||||
Some(self.config.codex_home.as_path()),
|
||||
) {
|
||||
Ok(paths) if !paths.is_empty() => {
|
||||
fn normalize_windows_path_for_display(
|
||||
p: &std::path::Path,
|
||||
) -> String {
|
||||
let canon = dunce::canonicalize(p)
|
||||
.unwrap_or_else(|_| p.to_path_buf());
|
||||
canon.display().to_string().replace('/', "\\")
|
||||
}
|
||||
let as_strings: Vec<String> = paths
|
||||
.iter()
|
||||
.map(|p| normalize_windows_path_for_display(p))
|
||||
.collect();
|
||||
let samples: Vec<String> =
|
||||
as_strings.iter().take(3).cloned().collect();
|
||||
let extra = if as_strings.len() > samples.len() {
|
||||
as_strings.len() - samples.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(samples, extra, false)
|
||||
}
|
||||
Err(_) => (Vec::new(), 0, true),
|
||||
_ => (Vec::new(), 0, false),
|
||||
};
|
||||
vec![Box::new(move |tx| {
|
||||
tx.send(AppEvent::OpenWorldWritableWarningConfirmation {
|
||||
preset: Some(preset_clone.clone()),
|
||||
sample_paths: sample_paths.clone(),
|
||||
extra_count,
|
||||
failed_scan,
|
||||
});
|
||||
})]
|
||||
} else {
|
||||
@@ -2185,12 +2106,8 @@ impl ChatWidget {
|
||||
for (k, v) in std::env::vars() {
|
||||
env_map.insert(k, v);
|
||||
}
|
||||
match codex_windows_sandbox::preflight_audit_everyone_writable(
|
||||
&self.config.cwd,
|
||||
&env_map,
|
||||
Some(self.config.codex_home.as_path()),
|
||||
) {
|
||||
Ok(paths) => !paths.is_empty(),
|
||||
match codex_windows_sandbox::preflight_audit_everyone_writable(&self.config.cwd, &env_map) {
|
||||
Ok(()) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
@@ -2263,66 +2180,31 @@ impl ChatWidget {
|
||||
pub(crate) fn open_world_writable_warning_confirmation(
|
||||
&mut self,
|
||||
preset: Option<ApprovalPreset>,
|
||||
sample_paths: Vec<String>,
|
||||
extra_count: usize,
|
||||
failed_scan: bool,
|
||||
) {
|
||||
let (approval, sandbox) = match &preset {
|
||||
Some(p) => (Some(p.approval), Some(p.sandbox.clone())),
|
||||
None => (None, None),
|
||||
};
|
||||
let mut header_children: Vec<Box<dyn Renderable>> = Vec::new();
|
||||
let mode_label = match self.config.sandbox_policy {
|
||||
SandboxPolicy::WorkspaceWrite { .. } => "Auto mode",
|
||||
SandboxPolicy::ReadOnly => "Read-Only mode",
|
||||
_ => "Auto mode",
|
||||
};
|
||||
let title_line = Line::from("Unprotected directories found").bold();
|
||||
let info_line = if failed_scan {
|
||||
Line::from(vec![
|
||||
"We couldn't complete the world-writable scan, so protections cannot be verified. "
|
||||
.into(),
|
||||
format!("The Windows sandbox cannot guarantee protection in {mode_label}.")
|
||||
.fg(Color::Red),
|
||||
])
|
||||
} else {
|
||||
Line::from(vec![
|
||||
"Some important directories on this system are world-writable. ".into(),
|
||||
format!(
|
||||
"The Windows sandbox cannot protect writes to these locations in {mode_label}."
|
||||
)
|
||||
let title_line = Line::from("Auto mode has unprotected directories").bold();
|
||||
let info_line = Line::from(vec![
|
||||
"Some important directories on this system are world-writable. ".into(),
|
||||
"The Windows sandbox cannot protect writes to these locations in Auto mode."
|
||||
.fg(Color::Red),
|
||||
])
|
||||
};
|
||||
]);
|
||||
header_children.push(Box::new(title_line));
|
||||
header_children.push(Box::new(
|
||||
Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }),
|
||||
));
|
||||
|
||||
if !sample_paths.is_empty() {
|
||||
// Show up to three examples and optionally an "and X more" line.
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from("Examples:").bold());
|
||||
for p in &sample_paths {
|
||||
lines.push(Line::from(format!(" - {p}")));
|
||||
}
|
||||
if extra_count > 0 {
|
||||
lines.push(Line::from(format!("and {extra_count} more")));
|
||||
}
|
||||
header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false })));
|
||||
}
|
||||
let header = ColumnRenderable::with(header_children);
|
||||
|
||||
// Build actions ensuring acknowledgement happens before applying the new sandbox policy,
|
||||
// so downstream policy-change hooks don't re-trigger the warning.
|
||||
let mut accept_actions: Vec<SelectionAction> = Vec::new();
|
||||
// Suppress the immediate re-scan only when a preset will be applied (i.e., via /approvals),
|
||||
// to avoid duplicate warnings from the ensuing policy change.
|
||||
if preset.is_some() {
|
||||
accept_actions.push(Box::new(|tx| {
|
||||
tx.send(AppEvent::SkipNextWorldWritableScan);
|
||||
}));
|
||||
}
|
||||
// Suppress the immediate re-scan once after user confirms continue.
|
||||
accept_actions.push(Box::new(|tx| {
|
||||
tx.send(AppEvent::SkipNextWorldWritableScan);
|
||||
}));
|
||||
if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) {
|
||||
accept_actions.extend(Self::approval_preset_actions(approval, sandbox));
|
||||
}
|
||||
@@ -2336,21 +2218,36 @@ impl ChatWidget {
|
||||
accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox));
|
||||
}
|
||||
|
||||
let deny_actions: Vec<SelectionAction> = if preset.is_some() {
|
||||
vec![Box::new(|tx| {
|
||||
tx.send(AppEvent::OpenApprovalsPopup);
|
||||
})]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let items = vec![
|
||||
SelectionItem {
|
||||
name: "Continue".to_string(),
|
||||
description: Some(format!("Apply {mode_label} for this session")),
|
||||
description: Some("Apply Auto mode for this session".to_string()),
|
||||
actions: accept_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Continue and don't warn again".to_string(),
|
||||
description: Some(format!("Enable {mode_label} and remember this choice")),
|
||||
description: Some("Enable Auto mode and remember this choice".to_string()),
|
||||
actions: accept_and_remember_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
SelectionItem {
|
||||
name: "Cancel".to_string(),
|
||||
description: Some("Go back without enabling Auto mode".to_string()),
|
||||
actions: deny_actions,
|
||||
dismiss_on_select: true,
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
|
||||
self.bottom_pane.show_selection_view(SelectionViewParams {
|
||||
@@ -2365,9 +2262,6 @@ impl ChatWidget {
|
||||
pub(crate) fn open_world_writable_warning_confirmation(
|
||||
&mut self,
|
||||
_preset: Option<ApprovalPreset>,
|
||||
_sample_paths: Vec<String>,
|
||||
_extra_count: usize,
|
||||
_failed_scan: bool,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -2426,13 +2320,6 @@ impl ChatWidget {
|
||||
self.config.notices.hide_world_writable_warning = Some(acknowledged);
|
||||
}
|
||||
|
||||
pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) {
|
||||
self.config.notices.hide_rate_limit_model_nudge = Some(hidden);
|
||||
if hidden {
|
||||
self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
pub(crate) fn world_writable_warning_hidden(&self) -> bool {
|
||||
self.config
|
||||
|
||||
@@ -9,8 +9,8 @@ expression: terminal.backend().vt100().screen().contents()
|
||||
|
||||
$ echo hello world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for this command (a)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
› 1. Yes, proceed
|
||||
2. Yes, and don't ask again for this command
|
||||
3. No, and tell Codex what to do differently esc
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
|
||||
@@ -6,8 +6,8 @@ expression: terminal.backend().vt100().screen().contents()
|
||||
|
||||
$ echo hello world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. Yes, and don't ask again for this command (a)
|
||||
3. No, and tell Codex what to do differently (esc)
|
||||
› 1. Yes, proceed
|
||||
2. Yes, and don't ask again for this command
|
||||
3. No, and tell Codex what to do differently esc
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
|
||||
@@ -11,7 +11,7 @@ expression: terminal.backend().vt100().screen().contents()
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
› 1. Yes, proceed (y)
|
||||
2. No, and tell Codex what to do differently (esc)
|
||||
› 1. Yes, proceed
|
||||
2. No, and tell Codex what to do differently esc
|
||||
|
||||
Press enter to confirm or esc to cancel
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 409
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
@@ -14,9 +15,9 @@ Buffer {
|
||||
" ",
|
||||
" $ echo hello world ",
|
||||
" ",
|
||||
"› 1. Yes, proceed (y) ",
|
||||
" 2. Yes, and don't ask again for this command (a) ",
|
||||
" 3. No, and tell Codex what to do differently (esc) ",
|
||||
"› 1. Yes, proceed ",
|
||||
" 2. Yes, and don't ask again for this command ",
|
||||
" 3. No, and tell Codex what to do differently esc ",
|
||||
" ",
|
||||
" Press enter to confirm or esc to cancel ",
|
||||
],
|
||||
@@ -29,11 +30,9 @@ Buffer {
|
||||
x: 2, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
|
||||
x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
|
||||
x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 17, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 47, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 50, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 500
|
||||
expression: popup
|
||||
---
|
||||
Approaching rate limits
|
||||
Switch to gpt-5-codex-mini for lower credit usage?
|
||||
|
||||
› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper,
|
||||
faster, but less capable.
|
||||
› 1. Switch to gpt-5-codex-mini Optimized for codex. Cheaper, faster, but
|
||||
less capable.
|
||||
2. Keep current model
|
||||
3. Keep current model (never show again) Hide future rate limit reminders
|
||||
about switching models.
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
|
||||
@@ -12,8 +12,8 @@ expression: terminal.backend()
|
||||
" "
|
||||
" $ echo 'hello world' "
|
||||
" "
|
||||
"› 1. Yes, proceed (y) "
|
||||
" 2. Yes, and don't ask again for this command (a) "
|
||||
" 3. No, and tell Codex what to do differently (esc) "
|
||||
"› 1. Yes, proceed "
|
||||
" 2. Yes, and don't ask again for this command "
|
||||
" 3. No, and tell Codex what to do differently esc "
|
||||
" "
|
||||
" Press enter to confirm or esc to cancel "
|
||||
|
||||
@@ -60,7 +60,7 @@ use tempfile::tempdir;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.";
|
||||
const TEST_WARNING_MESSAGE: &str = "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.";
|
||||
|
||||
fn test_config() -> Config {
|
||||
// Use base defaults to avoid depending on host state.
|
||||
@@ -448,22 +448,6 @@ fn rate_limit_switch_prompt_shows_once_per_session() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_switch_prompt_respects_hidden_notice() {
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let (mut chat, _, _) = make_chatwidget_manual();
|
||||
chat.config.model = "gpt-5".to_string();
|
||||
chat.auth_manager = AuthManager::from_auth_for_testing(auth);
|
||||
chat.config.notices.hide_rate_limit_model_nudge = Some(true);
|
||||
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(95.0)));
|
||||
|
||||
assert!(matches!(
|
||||
chat.rate_limit_switch_prompt,
|
||||
RateLimitSwitchPromptState::Idle
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_switch_prompt_defers_until_task_complete() {
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
|
||||
@@ -51,7 +51,7 @@ pub struct Cli {
|
||||
#[arg(long = "ask-for-approval", short = 'a')]
|
||||
pub approval_policy: Option<ApprovalModeCliArg>,
|
||||
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write).
|
||||
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, --sandbox workspace-write).
|
||||
#[arg(long = "full-auto", default_value_t = false)]
|
||||
pub full_auto: bool,
|
||||
|
||||
|
||||
@@ -317,13 +317,7 @@ impl ExecCell {
|
||||
Some(false) => "•".red().bold(),
|
||||
None => spinner(call.start_time),
|
||||
};
|
||||
let title = if self.is_active() {
|
||||
"Running"
|
||||
} else if call.is_user_shell_command {
|
||||
"You ran"
|
||||
} else {
|
||||
"Ran"
|
||||
};
|
||||
let title = if self.is_active() { "Running" } else { "Ran" };
|
||||
|
||||
let mut header_line =
|
||||
Line::from(vec![bullet.clone(), " ".into(), title.bold(), " ".into()]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::bash::extract_bash_command;
|
||||
use dirs::home_dir;
|
||||
use shlex::try_join;
|
||||
|
||||
@@ -9,11 +8,19 @@ pub(crate) fn escape_command(command: &[String]) -> String {
|
||||
try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" "))
|
||||
}
|
||||
|
||||
fn is_login_shell_with_lc(shell: &str) -> bool {
|
||||
let shell_name = std::path::Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(shell);
|
||||
matches!(shell_name, "bash" | "zsh")
|
||||
}
|
||||
|
||||
pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String {
|
||||
if let Some((_, script)) = extract_bash_command(command) {
|
||||
return script.to_string();
|
||||
match command {
|
||||
[first, second, third] if is_login_shell_with_lc(first) && second == "-lc" => third.clone(),
|
||||
_ => escape_command(command),
|
||||
}
|
||||
escape_command(command)
|
||||
}
|
||||
|
||||
/// If `path` is absolute and inside $HOME, return the part *after* the home
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::style::user_message_style;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::ui_consts::LIVE_PREFIX_COLS;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::updates::UpdateAction;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
|
||||
@@ -71,9 +71,8 @@ mod terminal_palette;
|
||||
mod text_formatting;
|
||||
mod tui;
|
||||
mod ui_consts;
|
||||
pub mod update_action;
|
||||
mod update_prompt;
|
||||
mod updates;
|
||||
pub mod updates;
|
||||
mod version;
|
||||
|
||||
mod wrapping;
|
||||
@@ -354,16 +353,6 @@ async fn run_ratatui_app(
|
||||
&mut tui,
|
||||
)
|
||||
.await?;
|
||||
if onboarding_result.should_exit {
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
let _ = tui.terminal.clear();
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
conversation_id: None,
|
||||
update_action: None,
|
||||
});
|
||||
}
|
||||
if onboarding_result.windows_install_selected {
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
|
||||
@@ -57,7 +57,6 @@ pub(crate) struct OnboardingScreen {
|
||||
steps: Vec<Step>,
|
||||
is_done: bool,
|
||||
windows_install_selected: bool,
|
||||
should_exit: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreenArgs {
|
||||
@@ -72,7 +71,6 @@ pub(crate) struct OnboardingScreenArgs {
|
||||
pub(crate) struct OnboardingResult {
|
||||
pub directory_trust_decision: Option<TrustDirectorySelection>,
|
||||
pub windows_install_selected: bool,
|
||||
pub should_exit: bool,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
@@ -139,7 +137,6 @@ impl OnboardingScreen {
|
||||
steps,
|
||||
is_done: false,
|
||||
windows_install_selected: false,
|
||||
should_exit: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,10 +200,6 @@ impl OnboardingScreen {
|
||||
pub fn windows_install_selected(&self) -> bool {
|
||||
self.windows_install_selected
|
||||
}
|
||||
|
||||
pub fn should_exit(&self) -> bool {
|
||||
self.should_exit
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
@@ -229,12 +222,9 @@ impl KeyboardHandler for OnboardingScreen {
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
if self.is_auth_in_progress() {
|
||||
// If the user cancels the auth menu, exit the app rather than
|
||||
// leave the user at a prompt in an unauthed state.
|
||||
self.should_exit = true;
|
||||
if !self.is_auth_in_progress() {
|
||||
self.is_done = true;
|
||||
}
|
||||
self.is_done = true;
|
||||
}
|
||||
_ => {
|
||||
if let Some(Step::Welcome(widget)) = self
|
||||
@@ -452,6 +442,5 @@ pub(crate) async fn run_onboarding_app(
|
||||
Ok(OnboardingResult {
|
||||
directory_trust_decision: onboarding_screen.directory_trust_decision(),
|
||||
windows_install_selected: onboarding_screen.windows_install_selected(),
|
||||
should_exit: onboarding_screen.should_exit(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,11 +211,10 @@ impl StatusHistoryCell {
|
||||
let mut lines = Vec::with_capacity(rows.len().saturating_mul(2));
|
||||
|
||||
for row in rows {
|
||||
let percent_remaining = (100.0 - row.percent_used).clamp(0.0, 100.0);
|
||||
let value_spans = vec![
|
||||
Span::from(render_status_limit_progress_bar(percent_remaining)),
|
||||
Span::from(render_status_limit_progress_bar(row.percent_used)),
|
||||
Span::from(" "),
|
||||
Span::from(format_status_limit_summary(percent_remaining)),
|
||||
Span::from(format_status_limit_summary(row.percent_used)),
|
||||
];
|
||||
let base_spans = formatter.full_spans(row.label.as_str(), value_spans);
|
||||
let base_line = Line::from(base_spans.clone());
|
||||
|
||||
@@ -124,8 +124,8 @@ pub(crate) fn compose_rate_limit_data(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String {
|
||||
let ratio = (percent_remaining / 100.0).clamp(0.0, 1.0);
|
||||
pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String {
|
||||
let ratio = (percent_used / 100.0).clamp(0.0, 1.0);
|
||||
let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize;
|
||||
let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS);
|
||||
let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled);
|
||||
@@ -136,8 +136,8 @@ pub(crate) fn render_status_limit_progress_bar(percent_remaining: f64) -> String
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn format_status_limit_summary(percent_remaining: f64) -> String {
|
||||
format!("{percent_remaining:.0}% left")
|
||||
pub(crate) fn format_status_limit_summary(percent_used: f64) -> String {
|
||||
format!("{percent_used:.0}% used")
|
||||
}
|
||||
|
||||
fn capitalize_first(label: &str) -> String {
|
||||
|
||||
@@ -18,5 +18,5 @@ expression: sanitized
|
||||
│ │
|
||||
│ Token usage: 1.2K total (800 input + 400 output) │
|
||||
│ Context window: 100% left (1.2K used / 272K) │
|
||||
│ Monthly limit: [██████████████████░░] 88% left (resets 07:08 on 7 May) │
|
||||
│ Monthly limit: [██░░░░░░░░░░░░░░░░░░] 12% used (resets 07:08 on 7 May) │
|
||||
╰────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -18,6 +18,6 @@ expression: sanitized
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ Context window: 100% left (2.25K used / 272K) │
|
||||
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │
|
||||
│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
|
||||
│ Weekly limit: [█████████░░░░░░░░░░░] 45% used (resets 03:24) │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -18,7 +18,7 @@ expression: sanitized
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ Context window: 100% left (2.25K used / 272K) │
|
||||
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │
|
||||
│ Weekly limit: [████████████░░░░░░░░] 60% left (resets 03:34) │
|
||||
│ 5h limit: [███████████████░░░░░] 72% used (resets 03:14) │
|
||||
│ Weekly limit: [████████░░░░░░░░░░░░] 40% used (resets 03:34) │
|
||||
│ Warning: limits may be stale - start new turn to refresh. │
|
||||
╰─────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
assertion_line: 257
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
@@ -19,6 +20,6 @@ expression: sanitized
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + │
|
||||
│ Context window: 100% left (2.25K used / │
|
||||
│ 5h limit: [██████░░░░░░░░░░░░░░] │
|
||||
│ 5h limit: [███████████████░░░░░] │
|
||||
│ (resets 03:14) │
|
||||
╰────────────────────────────────────────────╯
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
use std::fmt;
|
||||
use std::io::IsTerminal;
|
||||
use std::io::Result;
|
||||
use std::io::Stdout;
|
||||
use std::io::stdout;
|
||||
use std::panic;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
#[cfg(unix)]
|
||||
use std::sync::atomic::AtomicU8;
|
||||
#[cfg(unix)]
|
||||
use std::sync::atomic::AtomicU16;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crossterm::Command;
|
||||
use crossterm::SynchronizedUpdate;
|
||||
#[cfg(unix)]
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::event::DisableBracketedPaste;
|
||||
use crossterm::event::DisableFocusChange;
|
||||
use crossterm::event::EnableBracketedPaste;
|
||||
use crossterm::event::EnableFocusChange;
|
||||
use crossterm::event::Event;
|
||||
#[cfg(unix)]
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyboardEnhancementFlags;
|
||||
use crossterm::event::PopKeyboardEnhancementFlags;
|
||||
@@ -32,22 +38,20 @@ use ratatui::crossterm::terminal::disable_raw_mode;
|
||||
use ratatui::crossterm::terminal::enable_raw_mode;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::text::Line;
|
||||
use tokio::select;
|
||||
use tokio_stream::Stream;
|
||||
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
#[cfg(unix)]
|
||||
use crate::tui::job_control::SUSPEND_KEY;
|
||||
#[cfg(unix)]
|
||||
use crate::tui::job_control::SuspendContext;
|
||||
|
||||
#[cfg(unix)]
|
||||
mod job_control;
|
||||
use crate::key_hint;
|
||||
use tokio::select;
|
||||
use tokio_stream::Stream;
|
||||
|
||||
/// A type alias for the terminal type used in this application
|
||||
pub type Terminal = CustomTerminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
#[cfg(unix)]
|
||||
const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z'));
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
|
||||
@@ -75,12 +79,12 @@ pub fn set_modes() -> Result<()> {
|
||||
struct EnableAlternateScroll;
|
||||
|
||||
impl Command for EnableAlternateScroll {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||||
write!(f, "\x1b[?1007h")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> Result<()> {
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute EnableAlternateScroll using WinAPI; use ANSI instead",
|
||||
))
|
||||
@@ -96,12 +100,12 @@ impl Command for EnableAlternateScroll {
|
||||
struct DisableAlternateScroll;
|
||||
|
||||
impl Command for DisableAlternateScroll {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||||
write!(f, "\x1b[?1007l")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> Result<()> {
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute DisableAlternateScroll using WinAPI; use ANSI instead",
|
||||
))
|
||||
@@ -140,8 +144,8 @@ pub fn init() -> Result<Terminal> {
|
||||
}
|
||||
|
||||
fn set_panic_hook() {
|
||||
let hook = panic::take_hook();
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let _ = restore(); // ignore any errors as we are already failing
|
||||
hook(panic_info);
|
||||
}));
|
||||
@@ -161,7 +165,9 @@ pub struct Tui {
|
||||
pending_history_lines: Vec<Line<'static>>,
|
||||
alt_saved_viewport: Option<ratatui::layout::Rect>,
|
||||
#[cfg(unix)]
|
||||
suspend_context: SuspendContext,
|
||||
resume_pending: Arc<AtomicU8>, // Stores a ResumeAction
|
||||
#[cfg(unix)]
|
||||
suspend_cursor_y: Arc<AtomicU16>, // Bottom line of inline viewport
|
||||
// True when overlay alt-screen UI is active
|
||||
alt_screen_active: Arc<AtomicBool>,
|
||||
// True when terminal/tab is focused; updated internally from crossterm events
|
||||
@@ -169,6 +175,30 @@ pub struct Tui {
|
||||
enhanced_keys_supported: bool,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
#[repr(u8)]
|
||||
enum ResumeAction {
|
||||
None = 0,
|
||||
RealignInline = 1,
|
||||
RestoreAlt = 2,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
enum PreparedResumeAction {
|
||||
RestoreAltScreen,
|
||||
RealignViewport(ratatui::layout::Rect),
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn take_resume_action(pending: &AtomicU8) -> ResumeAction {
|
||||
match pending.swap(ResumeAction::None as u8, Ordering::Relaxed) {
|
||||
1 => ResumeAction::RealignInline,
|
||||
2 => ResumeAction::RestoreAlt,
|
||||
_ => ResumeAction::None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FrameRequester {
|
||||
frame_schedule_tx: tokio::sync::mpsc::UnboundedSender<Instant>,
|
||||
@@ -214,7 +244,9 @@ impl Tui {
|
||||
pending_history_lines: vec![],
|
||||
alt_saved_viewport: None,
|
||||
#[cfg(unix)]
|
||||
suspend_context: SuspendContext::new(),
|
||||
resume_pending: Arc::new(AtomicU8::new(0)),
|
||||
#[cfg(unix)]
|
||||
suspend_cursor_y: Arc::new(AtomicU16::new(0)),
|
||||
alt_screen_active: Arc::new(AtomicBool::new(false)),
|
||||
terminal_focused: Arc::new(AtomicBool::new(true)),
|
||||
enhanced_keys_supported,
|
||||
@@ -250,9 +282,26 @@ impl Tui {
|
||||
|
||||
// State for tracking how we should resume from ^Z suspend.
|
||||
#[cfg(unix)]
|
||||
let suspend_context = self.suspend_context.clone();
|
||||
let resume_pending = self.resume_pending.clone();
|
||||
#[cfg(unix)]
|
||||
let alt_screen_active = self.alt_screen_active.clone();
|
||||
#[cfg(unix)]
|
||||
let suspend_cursor_y = self.suspend_cursor_y.clone();
|
||||
|
||||
#[cfg(unix)]
|
||||
let suspend = move || {
|
||||
if alt_screen_active.load(Ordering::Relaxed) {
|
||||
// Disable alternate scroll when suspending from alt-screen
|
||||
let _ = execute!(stdout(), DisableAlternateScroll);
|
||||
let _ = execute!(stdout(), LeaveAlternateScreen);
|
||||
resume_pending.store(ResumeAction::RestoreAlt as u8, Ordering::Relaxed);
|
||||
} else {
|
||||
resume_pending.store(ResumeAction::RealignInline as u8, Ordering::Relaxed);
|
||||
}
|
||||
let y = suspend_cursor_y.load(Ordering::Relaxed);
|
||||
let _ = execute!(stdout(), MoveTo(0, y), crossterm::cursor::Show);
|
||||
let _ = Tui::suspend();
|
||||
};
|
||||
|
||||
let terminal_focused = self.terminal_focused.clone();
|
||||
let event_stream = async_stream::stream! {
|
||||
@@ -260,10 +309,10 @@ impl Tui {
|
||||
select! {
|
||||
Some(Ok(event)) = crossterm_events.next() => {
|
||||
match event {
|
||||
Event::Key(key_event) => {
|
||||
crossterm::event::Event::Key(key_event) => {
|
||||
#[cfg(unix)]
|
||||
if SUSPEND_KEY.is_press(key_event) {
|
||||
let _ = suspend_context.suspend(&alt_screen_active);
|
||||
suspend();
|
||||
// We continue here after resume.
|
||||
yield TuiEvent::Draw;
|
||||
continue;
|
||||
@@ -307,6 +356,67 @@ impl Tui {
|
||||
Box::pin(event_stream)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn suspend() -> Result<()> {
|
||||
restore()?;
|
||||
unsafe { libc::kill(0, libc::SIGTSTP) };
|
||||
set_modes()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When resuming from ^Z suspend, we want to put things back the way they were before suspend.
|
||||
/// We capture the action in an object so we can pass it into the event stream, since the relevant
|
||||
#[cfg(unix)]
|
||||
fn prepare_resume_action(
|
||||
&mut self,
|
||||
action: ResumeAction,
|
||||
) -> Result<Option<PreparedResumeAction>> {
|
||||
match action {
|
||||
ResumeAction::RealignInline => {
|
||||
let cursor_pos = self
|
||||
.terminal
|
||||
.get_cursor_position()
|
||||
.unwrap_or(self.terminal.last_known_cursor_pos);
|
||||
Ok(Some(PreparedResumeAction::RealignViewport(
|
||||
ratatui::layout::Rect::new(0, cursor_pos.y, 0, 0),
|
||||
)))
|
||||
}
|
||||
ResumeAction::RestoreAlt => {
|
||||
if let Ok(ratatui::layout::Position { y, .. }) = self.terminal.get_cursor_position()
|
||||
&& let Some(saved) = self.alt_saved_viewport.as_mut()
|
||||
{
|
||||
saved.y = y;
|
||||
}
|
||||
Ok(Some(PreparedResumeAction::RestoreAltScreen))
|
||||
}
|
||||
ResumeAction::None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn apply_prepared_resume_action(&mut self, prepared: PreparedResumeAction) -> Result<()> {
|
||||
match prepared {
|
||||
PreparedResumeAction::RealignViewport(area) => {
|
||||
self.terminal.set_viewport_area(area);
|
||||
}
|
||||
PreparedResumeAction::RestoreAltScreen => {
|
||||
execute!(self.terminal.backend_mut(), EnterAlternateScreen)?;
|
||||
// Enable "alternate scroll" so terminals may translate wheel to arrows
|
||||
execute!(self.terminal.backend_mut(), EnableAlternateScroll)?;
|
||||
if let Ok(size) = self.terminal.size() {
|
||||
self.terminal.set_viewport_area(ratatui::layout::Rect::new(
|
||||
0,
|
||||
0,
|
||||
size.width,
|
||||
size.height,
|
||||
));
|
||||
self.terminal.clear()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
|
||||
/// inline viewport for restoration when leaving.
|
||||
pub fn enter_alt_screen(&mut self) -> Result<()> {
|
||||
@@ -352,9 +462,8 @@ impl Tui {
|
||||
// If we are resuming from ^Z, we need to prepare the resume action now so we can apply it
|
||||
// in the synchronized update.
|
||||
#[cfg(unix)]
|
||||
let mut prepared_resume = self
|
||||
.suspend_context
|
||||
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
|
||||
let mut prepared_resume =
|
||||
self.prepare_resume_action(take_resume_action(&self.resume_pending))?;
|
||||
|
||||
// Precompute any viewport updates that need a cursor-position query before entering
|
||||
// the synchronized update, to avoid racing with the event reader.
|
||||
@@ -381,10 +490,12 @@ impl Tui {
|
||||
}
|
||||
}
|
||||
|
||||
stdout().sync_update(|_| {
|
||||
std::io::stdout().sync_update(|_| {
|
||||
#[cfg(unix)]
|
||||
if let Some(prepared) = prepared_resume.take() {
|
||||
prepared.apply(&mut self.terminal)?;
|
||||
{
|
||||
if let Some(prepared) = prepared_resume.take() {
|
||||
self.apply_prepared_resume_action(prepared)?;
|
||||
}
|
||||
}
|
||||
let terminal = &mut self.terminal;
|
||||
if let Some(new_area) = pending_viewport_area.take() {
|
||||
@@ -428,7 +539,8 @@ impl Tui {
|
||||
} else {
|
||||
area.bottom().saturating_sub(1)
|
||||
};
|
||||
self.suspend_context.set_cursor_y(inline_area_bottom);
|
||||
self.suspend_cursor_y
|
||||
.store(inline_area_bottom, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
terminal.draw(|frame| {
|
||||
@@ -488,12 +600,12 @@ fn spawn_frame_scheduler(
|
||||
pub struct PostNotification(pub String);
|
||||
|
||||
impl Command for PostNotification {
|
||||
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
|
||||
fn write_ansi(&self, f: &mut impl std::fmt::Write) -> std::fmt::Result {
|
||||
write!(f, "\x1b]9;{}\x07", self.0)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn execute_winapi(&self) -> Result<()> {
|
||||
fn execute_winapi(&self) -> std::io::Result<()> {
|
||||
Err(std::io::Error::other(
|
||||
"tried to execute PostNotification using WinAPI; use ANSI instead",
|
||||
))
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
use std::io::Result;
|
||||
use std::io::stdout;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::PoisonError;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::AtomicU16;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use crossterm::cursor::MoveTo;
|
||||
use crossterm::cursor::Show;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::terminal::EnterAlternateScreen;
|
||||
use crossterm::terminal::LeaveAlternateScreen;
|
||||
use ratatui::crossterm::execute;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::key_hint;
|
||||
|
||||
use super::DisableAlternateScroll;
|
||||
use super::EnableAlternateScroll;
|
||||
use super::Terminal;
|
||||
|
||||
pub const SUSPEND_KEY: key_hint::KeyBinding = key_hint::ctrl(KeyCode::Char('z'));
|
||||
|
||||
/// Coordinates suspend/resume handling so the TUI can restore terminal context after SIGTSTP.
|
||||
///
|
||||
/// On suspend, it records which resume path to take (realign inline viewport vs. restore alt
|
||||
/// screen) and caches the inline cursor row so the cursor can be placed meaningfully before
|
||||
/// yielding.
|
||||
///
|
||||
/// After resume, `prepare_resume_action` consumes the pending intent and returns a
|
||||
/// `PreparedResumeAction` describing any viewport adjustments to apply inside the synchronized
|
||||
/// draw.
|
||||
///
|
||||
/// Callers keep `suspend_cursor_y` up to date during normal drawing so the suspend step always
|
||||
/// has the latest cursor position.
|
||||
///
|
||||
/// The type is `Clone`, using Arc/atomic internals so bookkeeping can be shared across tasks
|
||||
/// and moved into the boxed `'static` event stream without borrowing `self`.
|
||||
#[derive(Clone)]
|
||||
pub struct SuspendContext {
|
||||
/// Resume intent captured at suspend time; cleared once applied after resume.
|
||||
resume_pending: Arc<Mutex<Option<ResumeAction>>>,
|
||||
/// Inline viewport cursor row used to place the cursor before yielding during suspend.
|
||||
suspend_cursor_y: Arc<AtomicU16>,
|
||||
}
|
||||
|
||||
impl SuspendContext {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
resume_pending: Arc::new(Mutex::new(None)),
|
||||
suspend_cursor_y: Arc::new(AtomicU16::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture how to resume, stash cursor position, and temporarily yield during SIGTSTP.
|
||||
///
|
||||
/// - If the alt screen is active, exit alt-scroll/alt-screen and record `RestoreAlt`;
|
||||
/// otherwise record `RealignInline`.
|
||||
/// - Update the cached inline cursor row so suspend can place the cursor meaningfully.
|
||||
/// - Trigger SIGTSTP so the process can be resumed and continue drawing with the saved state.
|
||||
pub(crate) fn suspend(&self, alt_screen_active: &Arc<AtomicBool>) -> Result<()> {
|
||||
if alt_screen_active.load(Ordering::Relaxed) {
|
||||
// Leave alt-screen so the terminal returns to the normal buffer while suspended; also turn off alt-scroll.
|
||||
let _ = execute!(stdout(), DisableAlternateScroll);
|
||||
let _ = execute!(stdout(), LeaveAlternateScreen);
|
||||
self.set_resume_action(ResumeAction::RestoreAlt);
|
||||
} else {
|
||||
self.set_resume_action(ResumeAction::RealignInline);
|
||||
}
|
||||
let y = self.suspend_cursor_y.load(Ordering::Relaxed);
|
||||
let _ = execute!(stdout(), MoveTo(0, y), Show);
|
||||
suspend_process()
|
||||
}
|
||||
|
||||
/// Consume the pending resume intent and precompute any viewport changes needed post-resume.
|
||||
///
|
||||
/// Returns a `PreparedResumeAction` describing how to realign the viewport once drawing
|
||||
/// resumes; returns `None` when there was no pending suspend intent.
|
||||
pub(crate) fn prepare_resume_action(
|
||||
&self,
|
||||
terminal: &mut Terminal,
|
||||
alt_saved_viewport: &mut Option<Rect>,
|
||||
) -> Option<PreparedResumeAction> {
|
||||
let action = self.take_resume_action()?;
|
||||
match action {
|
||||
ResumeAction::RealignInline => {
|
||||
let cursor_pos = terminal
|
||||
.get_cursor_position()
|
||||
.unwrap_or(terminal.last_known_cursor_pos);
|
||||
let viewport = Rect::new(0, cursor_pos.y, 0, 0);
|
||||
Some(PreparedResumeAction::RealignViewport(viewport))
|
||||
}
|
||||
ResumeAction::RestoreAlt => {
|
||||
if let Ok(Position { y, .. }) = terminal.get_cursor_position()
|
||||
&& let Some(saved) = alt_saved_viewport.as_mut()
|
||||
{
|
||||
saved.y = y;
|
||||
}
|
||||
Some(PreparedResumeAction::RestoreAltScreen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the cached inline cursor row so suspend can place the cursor meaningfully.
|
||||
///
|
||||
/// Call during normal drawing when the inline viewport moves so suspend has a fresh cursor
|
||||
/// position to restore before yielding.
|
||||
pub(crate) fn set_cursor_y(&self, value: u16) {
|
||||
self.suspend_cursor_y.store(value, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Record a pending resume action to apply after SIGTSTP returns control.
|
||||
fn set_resume_action(&self, value: ResumeAction) {
|
||||
*self
|
||||
.resume_pending
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner) = Some(value);
|
||||
}
|
||||
|
||||
/// Take and clear any pending resume action captured at suspend time.
|
||||
fn take_resume_action(&self) -> Option<ResumeAction> {
|
||||
self.resume_pending
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Captures what should happen when returning from suspend.
|
||||
///
|
||||
/// Either realign the inline viewport to keep the cursor position, or re-enter the alt screen
|
||||
/// to restore the overlay UI.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ResumeAction {
|
||||
/// Shift the inline viewport to keep the cursor anchored after resume.
|
||||
RealignInline,
|
||||
/// Re-enter the alt screen and restore the overlay UI.
|
||||
RestoreAlt,
|
||||
}
|
||||
|
||||
/// Describes the viewport change to apply when resuming from suspend during the synchronized draw.
|
||||
///
|
||||
/// Either restore the alt screen (with viewport reset) or realign the inline viewport.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum PreparedResumeAction {
|
||||
/// Re-enter the alt screen and reset the viewport to the terminal dimensions.
|
||||
RestoreAltScreen,
|
||||
/// Apply a viewport shift to keep the inline cursor position stable.
|
||||
RealignViewport(Rect),
|
||||
}
|
||||
|
||||
impl PreparedResumeAction {
|
||||
pub(crate) fn apply(self, terminal: &mut Terminal) -> Result<()> {
|
||||
match self {
|
||||
PreparedResumeAction::RealignViewport(area) => {
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
PreparedResumeAction::RestoreAltScreen => {
|
||||
execute!(terminal.backend_mut(), EnterAlternateScreen)?;
|
||||
// Enable "alternate scroll" so terminals may translate wheel to arrows
|
||||
execute!(terminal.backend_mut(), EnableAlternateScroll)?;
|
||||
if let Ok(size) = terminal.size() {
|
||||
terminal.set_viewport_area(Rect::new(0, 0, size.width, size.height));
|
||||
terminal.clear()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliver SIGTSTP after restoring terminal state, then re-applies terminal modes once resumed.
|
||||
fn suspend_process() -> Result<()> {
|
||||
super::restore()?;
|
||||
unsafe { libc::kill(0, libc::SIGTSTP) };
|
||||
// After the process resumes, reapply terminal modes so drawing can continue.
|
||||
super::set_modes()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/// Update action the CLI should perform after the TUI exits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UpdateAction {
|
||||
/// Update via `npm install -g @openai/codex@latest`.
|
||||
NpmGlobalLatest,
|
||||
/// Update via `bun install -g @openai/codex@latest`.
|
||||
BunGlobalLatest,
|
||||
/// Update via `brew upgrade codex`.
|
||||
BrewUpgrade,
|
||||
}
|
||||
|
||||
impl UpdateAction {
|
||||
/// Returns the list of command-line arguments for invoking the update.
|
||||
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]),
|
||||
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]),
|
||||
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns string representation of the command-line arguments for invoking the update.
|
||||
pub fn command_str(self) -> String {
|
||||
let (command, args) = self.command_args();
|
||||
shlex::try_join(std::iter::once(command).chain(args.iter().copied()))
|
||||
.unwrap_or_else(|_| format!("{command} {}", args.join(" ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
pub(crate) fn get_update_action() -> Option<UpdateAction> {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
|
||||
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
|
||||
|
||||
detect_update_action(
|
||||
cfg!(target_os = "macos"),
|
||||
&exe,
|
||||
managed_by_npm,
|
||||
managed_by_bun,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
fn detect_update_action(
|
||||
is_macos: bool,
|
||||
current_exe: &std::path::Path,
|
||||
managed_by_npm: bool,
|
||||
managed_by_bun: bool,
|
||||
) -> Option<UpdateAction> {
|
||||
if managed_by_npm {
|
||||
Some(UpdateAction::NpmGlobalLatest)
|
||||
} else if managed_by_bun {
|
||||
Some(UpdateAction::BunGlobalLatest)
|
||||
} else if is_macos
|
||||
&& (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local"))
|
||||
{
|
||||
Some(UpdateAction::BrewUpgrade)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_update_action_without_env_mutation() {
|
||||
assert_eq!(
|
||||
detect_update_action(false, std::path::Path::new("/any/path"), false, false),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(false, std::path::Path::new("/any/path"), true, false),
|
||||
Some(UpdateAction::NpmGlobalLatest)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(false, std::path::Path::new("/any/path"), false, true),
|
||||
Some(UpdateAction::BunGlobalLatest)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(
|
||||
true,
|
||||
std::path::Path::new("/opt/homebrew/bin/codex"),
|
||||
false,
|
||||
false
|
||||
),
|
||||
Some(UpdateAction::BrewUpgrade)
|
||||
);
|
||||
assert_eq!(
|
||||
detect_update_action(
|
||||
true,
|
||||
std::path::Path::new("/usr/local/bin/codex"),
|
||||
false,
|
||||
false
|
||||
),
|
||||
Some(UpdateAction::BrewUpgrade)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,8 @@ use crate::selection_list::selection_option_row;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use crate::update_action::UpdateAction;
|
||||
use crate::updates;
|
||||
use crate::updates::UpdateAction;
|
||||
use codex_core::config::Config;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -39,7 +39,7 @@ pub(crate) async fn run_update_prompt_if_needed(
|
||||
let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else {
|
||||
return Ok(UpdatePromptOutcome::Continue);
|
||||
};
|
||||
let Some(update_action) = crate::update_action::get_update_action() else {
|
||||
let Some(update_action) = crate::updates::get_update_action() else {
|
||||
return Ok(UpdatePromptOutcome::Continue);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
#![cfg(not(debug_assertions))]
|
||||
|
||||
use crate::update_action;
|
||||
use crate::update_action::UpdateAction;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::default_client::create_client;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::default_client::create_client;
|
||||
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
|
||||
pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||||
@@ -50,17 +47,14 @@ struct VersionInfo {
|
||||
dismissed_version: Option<String>,
|
||||
}
|
||||
|
||||
const VERSION_FILENAME: &str = "version.json";
|
||||
// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
|
||||
const HOMEBREW_CASK_URL: &str =
|
||||
"https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb";
|
||||
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
struct ReleaseInfo {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
const VERSION_FILENAME: &str = "version.json";
|
||||
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
|
||||
|
||||
fn version_filepath(config: &Config) -> PathBuf {
|
||||
config.codex_home.join(VERSION_FILENAME)
|
||||
}
|
||||
@@ -71,35 +65,23 @@ fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
|
||||
}
|
||||
|
||||
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
||||
let latest_version = match update_action::get_update_action() {
|
||||
Some(UpdateAction::BrewUpgrade) => {
|
||||
let cask_contents = create_client()
|
||||
.get(HOMEBREW_CASK_URL)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.text()
|
||||
.await?;
|
||||
extract_version_from_cask(&cask_contents)?
|
||||
}
|
||||
_ => {
|
||||
let ReleaseInfo {
|
||||
tag_name: latest_tag_name,
|
||||
} = create_client()
|
||||
.get(LATEST_RELEASE_URL)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<ReleaseInfo>()
|
||||
.await?;
|
||||
extract_version_from_latest_tag(&latest_tag_name)?
|
||||
}
|
||||
};
|
||||
let ReleaseInfo {
|
||||
tag_name: latest_tag_name,
|
||||
} = create_client()
|
||||
.get(LATEST_RELEASE_URL)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?
|
||||
.json::<ReleaseInfo>()
|
||||
.await?;
|
||||
|
||||
// Preserve any previously dismissed version if present.
|
||||
let prev_info = read_version_info(version_file).ok();
|
||||
let info = VersionInfo {
|
||||
latest_version,
|
||||
latest_version: latest_tag_name
|
||||
.strip_prefix("rust-v")
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))?
|
||||
.into(),
|
||||
last_checked_at: Utc::now(),
|
||||
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
|
||||
};
|
||||
@@ -119,25 +101,6 @@ fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
|
||||
cask_contents
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let line = line.trim();
|
||||
line.strip_prefix("version \"")
|
||||
.and_then(|rest| rest.strip_suffix('"'))
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
|
||||
}
|
||||
|
||||
fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
|
||||
latest_tag_name
|
||||
.strip_prefix("rust-v")
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
|
||||
}
|
||||
|
||||
/// Returns the latest version to show in a popup, if it should be shown.
|
||||
/// This respects the user's dismissal choice for the current latest version.
|
||||
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
|
||||
@@ -177,36 +140,57 @@ fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
|
||||
Some((maj, min, pat))
|
||||
}
|
||||
|
||||
/// Update action the CLI should perform after the TUI exits.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum UpdateAction {
|
||||
/// Update via `npm install -g @openai/codex@latest`.
|
||||
NpmGlobalLatest,
|
||||
/// Update via `bun install -g @openai/codex@latest`.
|
||||
BunGlobalLatest,
|
||||
/// Update via `brew upgrade codex`.
|
||||
BrewUpgrade,
|
||||
}
|
||||
|
||||
#[cfg(any(not(debug_assertions), test))]
|
||||
pub(crate) fn get_update_action() -> Option<UpdateAction> {
|
||||
let exe = std::env::current_exe().unwrap_or_default();
|
||||
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
|
||||
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
|
||||
if managed_by_npm {
|
||||
Some(UpdateAction::NpmGlobalLatest)
|
||||
} else if managed_by_bun {
|
||||
Some(UpdateAction::BunGlobalLatest)
|
||||
} else if cfg!(target_os = "macos")
|
||||
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
|
||||
{
|
||||
Some(UpdateAction::BrewUpgrade)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateAction {
|
||||
/// Returns the list of command-line arguments for invoking the update.
|
||||
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
|
||||
match self {
|
||||
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]),
|
||||
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]),
|
||||
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns string representation of the command-line arguments for invoking the update.
|
||||
pub fn command_str(self) -> String {
|
||||
let (command, args) = self.command_args();
|
||||
let args_str = args.join(" ");
|
||||
format!("{command} {args_str}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_version_from_cask_contents() {
|
||||
let cask = r#"
|
||||
cask "codex" do
|
||||
version "0.55.0"
|
||||
end
|
||||
"#;
|
||||
assert_eq!(
|
||||
extract_version_from_cask(cask).expect("failed to parse version"),
|
||||
"0.55.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_version_from_latest_tag() {
|
||||
assert_eq!(
|
||||
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
|
||||
"1.5.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_tag_without_prefix_is_invalid() {
|
||||
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prerelease_version_is_not_considered_newer() {
|
||||
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
|
||||
@@ -226,4 +210,24 @@ mod tests {
|
||||
assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
|
||||
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_update_action() {
|
||||
let prev = std::env::var_os("CODEX_MANAGED_BY_NPM");
|
||||
|
||||
// First: no npm var -> expect None (we do not run from brew in CI)
|
||||
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
|
||||
assert_eq!(get_update_action(), None);
|
||||
|
||||
// Then: with npm var -> expect NpmGlobalLatest
|
||||
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") };
|
||||
assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest));
|
||||
|
||||
// Restore prior value to avoid leaking state
|
||||
if let Some(v) = prev {
|
||||
unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) };
|
||||
} else {
|
||||
unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ pub async fn spawn_pty_process(
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
env: &HashMap<String, String>,
|
||||
arg0: &Option<String>,
|
||||
) -> Result<SpawnedPty> {
|
||||
if program.is_empty() {
|
||||
anyhow::bail!("missing program for PTY spawn");
|
||||
@@ -125,7 +124,7 @@ pub async fn spawn_pty_process(
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
|
||||
let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string()));
|
||||
let mut command_builder = CommandBuilder::new(program);
|
||||
command_builder.cwd(cwd);
|
||||
command_builder.env_clear();
|
||||
for arg in args {
|
||||
|
||||
@@ -11,7 +11,6 @@ path = "src/lib.rs"
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dunce = "1.0"
|
||||
[dependencies.rand]
|
||||
version = "0.8"
|
||||
default-features = false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::token::world_sid;
|
||||
use crate::winutil::to_wide;
|
||||
use anyhow::anyhow;
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::c_void;
|
||||
@@ -37,22 +38,6 @@ use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE;
|
||||
use windows_sys::Win32::Security::ACE_HEADER;
|
||||
use windows_sys::Win32::Security::EqualSid;
|
||||
|
||||
// Preflight scan limits
|
||||
const MAX_ITEMS_PER_DIR: i32 = 1000;
|
||||
const AUDIT_TIME_LIMIT_SECS: i64 = 2;
|
||||
const MAX_CHECKED_LIMIT: i32 = 50000;
|
||||
// Case-insensitive suffixes (normalized to forward slashes) to skip during one-level child scan
|
||||
const SKIP_DIR_SUFFIXES: &[&str] = &[
|
||||
"/windows/installer",
|
||||
"/windows/registration",
|
||||
"/programdata",
|
||||
];
|
||||
|
||||
fn normalize_path_key(p: &Path) -> String {
|
||||
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn unique_push(set: &mut HashSet<PathBuf>, out: &mut Vec<PathBuf>, p: PathBuf) {
|
||||
if let Ok(abs) = p.canonicalize() {
|
||||
if set.insert(abs.clone()) {
|
||||
@@ -92,7 +77,11 @@ fn gather_candidates(cwd: &Path, env: &std::collections::HashMap<String, String>
|
||||
}
|
||||
}
|
||||
// 5) Core system roots last
|
||||
for p in [PathBuf::from("C:/"), PathBuf::from("C:/Windows")] {
|
||||
for p in [
|
||||
PathBuf::from("C:/"),
|
||||
PathBuf::from("C:/Windows"),
|
||||
PathBuf::from("C:/ProgramData"),
|
||||
] {
|
||||
unique_push(&mut set, &mut out, p);
|
||||
}
|
||||
out
|
||||
@@ -175,18 +164,14 @@ unsafe fn path_has_world_write_allow(path: &Path) -> Result<bool> {
|
||||
pub fn audit_everyone_writable(
|
||||
cwd: &Path,
|
||||
env: &std::collections::HashMap<String, String>,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
) -> Result<()> {
|
||||
let start = Instant::now();
|
||||
let mut flagged: Vec<PathBuf> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut checked = 0usize;
|
||||
// Fast path: check CWD immediate children first so workspace issues are caught early.
|
||||
if let Ok(read) = std::fs::read_dir(cwd) {
|
||||
for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) {
|
||||
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|
||||
|| checked > MAX_CHECKED_LIMIT as usize
|
||||
{
|
||||
for ent in read.flatten().take(250) {
|
||||
if start.elapsed() > Duration::from_secs(5) || checked > 5000 {
|
||||
break;
|
||||
}
|
||||
let ft = match ent.file_type() {
|
||||
@@ -200,32 +185,26 @@ pub fn audit_everyone_writable(
|
||||
checked += 1;
|
||||
let has = unsafe { path_has_world_write_allow(&p)? };
|
||||
if has {
|
||||
let key = normalize_path_key(&p);
|
||||
if seen.insert(key) { flagged.push(p); }
|
||||
flagged.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Continue with broader candidate sweep
|
||||
let candidates = gather_candidates(cwd, env);
|
||||
for root in candidates {
|
||||
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|
||||
|| checked > MAX_CHECKED_LIMIT as usize
|
||||
{
|
||||
if start.elapsed() > Duration::from_secs(5) || checked > 5000 {
|
||||
break;
|
||||
}
|
||||
checked += 1;
|
||||
let has_root = unsafe { path_has_world_write_allow(&root)? };
|
||||
if has_root {
|
||||
let key = normalize_path_key(&root);
|
||||
if seen.insert(key) { flagged.push(root.clone()); }
|
||||
flagged.push(root.clone());
|
||||
}
|
||||
// one level down best-effort
|
||||
if let Ok(read) = std::fs::read_dir(&root) {
|
||||
for ent in read.flatten().take(MAX_ITEMS_PER_DIR as usize) {
|
||||
for ent in read.flatten().take(250) {
|
||||
let p = ent.path();
|
||||
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS as u64)
|
||||
|| checked > MAX_CHECKED_LIMIT as usize
|
||||
{
|
||||
if start.elapsed() > Duration::from_secs(5) || checked > 5000 {
|
||||
break;
|
||||
}
|
||||
// Skip reparse points (symlinks/junctions) to avoid auditing link ACLs
|
||||
@@ -236,16 +215,11 @@ pub fn audit_everyone_writable(
|
||||
if ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
// Skip noisy/irrelevant Windows system subdirectories
|
||||
let pl = p.to_string_lossy().to_ascii_lowercase();
|
||||
let norm = pl.replace('\\', "/");
|
||||
if SKIP_DIR_SUFFIXES.iter().any(|s| norm.ends_with(s)) { continue; }
|
||||
if ft.is_dir() {
|
||||
checked += 1;
|
||||
let has_child = unsafe { path_has_world_write_allow(&p)? };
|
||||
if has_child {
|
||||
let key = normalize_path_key(&p);
|
||||
if seen.insert(key) { flagged.push(p); }
|
||||
flagged.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,18 +236,25 @@ pub fn audit_everyone_writable(
|
||||
"AUDIT: world-writable scan FAILED; checked={checked}; duration_ms={elapsed_ms}; flagged:{}",
|
||||
list
|
||||
),
|
||||
logs_base_dir,
|
||||
Some(cwd),
|
||||
);
|
||||
return Ok(flagged);
|
||||
let mut list_err = String::new();
|
||||
for p in flagged {
|
||||
list_err.push_str(&format!("\n - {}", p.display()));
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"Refusing to run: found directories writable by Everyone: {}",
|
||||
list_err
|
||||
));
|
||||
}
|
||||
// Log success once if nothing flagged
|
||||
crate::logging::log_note(
|
||||
&format!(
|
||||
"AUDIT: world-writable scan OK; checked={checked}; duration_ms={elapsed_ms}"
|
||||
),
|
||||
logs_base_dir,
|
||||
Some(cwd),
|
||||
);
|
||||
Ok(Vec::new())
|
||||
Ok(())
|
||||
}
|
||||
// Fast mask-based check: does the DACL contain any ACCESS_ALLOWED ACE for
|
||||
// Everyone that includes generic or specific write bits? Skips inherit-only
|
||||
|
||||
@@ -171,9 +171,8 @@ mod windows_impl {
|
||||
pub fn preflight_audit_everyone_writable(
|
||||
cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<Vec<PathBuf>> {
|
||||
audit::audit_everyone_writable(cwd, env_map, logs_base_dir)
|
||||
) -> Result<()> {
|
||||
audit::audit_everyone_writable(cwd, env_map)
|
||||
}
|
||||
|
||||
pub fn run_windows_sandbox_capture(
|
||||
@@ -437,8 +436,7 @@ mod stub {
|
||||
pub fn preflight_audit_everyone_writable(
|
||||
_cwd: &Path,
|
||||
_env_map: &HashMap<String, String>,
|
||||
_logs_base_dir: Option<&Path>,
|
||||
) -> Result<Vec<std::path::PathBuf>> {
|
||||
) -> Result<()> {
|
||||
bail!("Windows sandbox is only available on Windows")
|
||||
}
|
||||
|
||||
|
||||
@@ -347,13 +347,11 @@ Use the optional `[tools]` table to toggle built-in tools that the agent may cal
|
||||
|
||||
```toml
|
||||
[tools]
|
||||
web_search = true # allow Codex to issue first-party web searches without prompting you (deprecated)
|
||||
web_search = true # allow Codex to issue first-party web searches without prompting you
|
||||
view_image = false # disable image uploads (they're enabled by default)
|
||||
```
|
||||
|
||||
`web_search` is deprecated; use the `web_search_request` feature flag instead.
|
||||
|
||||
The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
|
||||
`web_search` is also recognized under the legacy name `web_search_request`. The `view_image` toggle is useful when you want to include screenshots or diagrams from your repo without pasting them manually. Codex still respects sandboxing: it can only attach files inside the workspace roots you allow.
|
||||
|
||||
### approval_presets
|
||||
|
||||
@@ -919,7 +917,6 @@ Valid values:
|
||||
| `sandbox_workspace_write.exclude_slash_tmp` | boolean | Exclude `/tmp` from writable roots (default: false). |
|
||||
| `notify` | array<string> | External program for notifications. |
|
||||
| `instructions` | string | Currently ignored; use `experimental_instructions_file` or `AGENTS.md`. |
|
||||
| `features.<feature-flag>` | boolean | See [feature flags](#feature-flags) for details |
|
||||
| `mcp_servers.<id>.command` | string | MCP server launcher command (stdio servers only). |
|
||||
| `mcp_servers.<id>.args` | array<string> | MCP server args (stdio servers only). |
|
||||
| `mcp_servers.<id>.env` | map<string,string> | MCP server env vars (stdio servers only). |
|
||||
@@ -959,7 +956,7 @@ Valid values:
|
||||
| `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). |
|
||||
| `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. |
|
||||
| `projects.<path>.trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). |
|
||||
| `tools.web_search` | boolean | Enable web search tool (deprecated) (default: false). |
|
||||
| `tools.web_search` | boolean | Enable web search tool (alias: `web_search_request`) (default: false). |
|
||||
| `tools.view_image` | boolean | Enable or disable the `view_image` tool so Codex can attach local image files from the workspace (default: true). |
|
||||
| `forced_login_method` | `chatgpt` \| `api` | Only allow Codex to be used with ChatGPT or API keys. |
|
||||
| `forced_chatgpt_workspace_id` | string (uuid) | Only allow Codex to be used with the specified ChatGPT workspace. |
|
||||
|
||||
@@ -159,7 +159,6 @@ windows_wsl_setup_acknowledged = false
|
||||
# In-product notices (mostly set automatically by Codex).
|
||||
[notice]
|
||||
# hide_full_access_warning = true
|
||||
# hide_rate_limit_model_nudge = true
|
||||
|
||||
################################################################################
|
||||
# Authentication & Login
|
||||
|
||||
@@ -42,55 +42,16 @@ Custom prompts turn your repeatable instructions into reusable slash commands, s
|
||||
|
||||
### Examples
|
||||
|
||||
### Example 1: Basic named arguments
|
||||
**Draft PR helper**
|
||||
|
||||
**File**: `~/.codex/prompts/ticket.md`
|
||||
`~/.codex/prompts/draftpr.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Generate a commit message for a ticket
|
||||
argument-hint: TICKET_ID=<id> TICKET_TITLE=<title>
|
||||
description: Create feature branch, commit and open draft PR.
|
||||
---
|
||||
|
||||
Please write a concise commit message for ticket $TICKET_ID: $TICKET_TITLE
|
||||
Create a branch named `tibo/<feature_name>`, commit the changes, and open a draft PR.
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```
|
||||
/prompts:ticket TICKET_ID=JIRA-1234 TICKET_TITLE="Fix login bug"
|
||||
```
|
||||
|
||||
**Expanded prompt sent to Codex**:
|
||||
|
||||
```
|
||||
Please write a concise commit message for ticket JIRA-1234: Fix login bug
|
||||
```
|
||||
|
||||
**Note**: Both `TICKET_ID` and `TICKET_TITLE` are required. If either is missing, Codex will show a validation error. Values with spaces must be double-quoted.
|
||||
|
||||
### Example 2: Mixed positional and named arguments
|
||||
|
||||
**File**: `~/.codex/prompts/review.md`
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Review code in a specific file with focus area
|
||||
argument-hint: FILE=<path> [FOCUS=<section>]
|
||||
---
|
||||
|
||||
Review the code in $FILE. Pay special attention to $FOCUS.
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```
|
||||
/prompts:review FILE=src/auth.js FOCUS="error handling"
|
||||
```
|
||||
|
||||
**Expanded prompt**:
|
||||
|
||||
```
|
||||
Review the code in src/auth.js. Pay special attention to error handling.
|
||||
|
||||
```
|
||||
Usage: type `/prompts:draftpr` to have codex perform the work.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user