Compare commits

...

94 Commits

Author SHA1 Message Date
Aaron Levine
5491ffde72 paths for node module resolution can be specified for js_repl 2026-02-16 12:48:26 -08:00
jif-oai
af434b4f71 feat: drop MCP managing tools if no MCP servers (#11900)
Drop MCP tools if no MCP servers to save context

For this https://github.com/openai/codex/issues/11049
2026-02-16 18:40:45 +00:00
Vaibhav Srivastav
cef7fbc494 docs: mention Codex app in README intro (#11926)
Add mention of the app in the README.
2026-02-16 17:35:05 +01:00
jif-oai
e47045c806 feat: add customizable roles for multi-agents (#11917)
The idea is to have 2 family of agents.

1. Built-in that we packaged directly with Codex
2. User defined that are defined using the `agents_config.toml` file. It
can reference config files that will override the agent config. This
looks like this:
```
version = 1

[agents.explorer]
description = """Use `explorer` for all codebase questions.
Explorers are fast and authoritative.
Always prefer them over manual search or file reading.
Rules:
- Ask explorers first and precisely.
- Do not re-read or re-search code they cover.
- Trust explorer results without verification.
- Run explorers in parallel when useful.
- Reuse existing explorers for related questions."""
config_file = "explorer.toml"
```
2026-02-16 16:29:32 +00:00
jif-oai
50aea4b0dc nit: memory storage (#11924) 2026-02-16 16:18:53 +00:00
jif-oai
e41536944e chore: rename collab feature flag key to multi_agent (#11918)
Summary
- rename the collab feature key to multi_agent while keeping the Feature
enum unchanged
- add legacy alias support so both "multi_agent" and "collab" map to the
same feature
- cover the alias behavior with a new unit test
2026-02-16 15:28:31 +00:00
gt-oai
b3095679ed Allow hooks to error (#11615)
Allow hooks to return errors. 

We should do this before introducing more hook types, or we'll have to
migrate them all.
2026-02-16 14:11:05 +00:00
jif-oai
825a4af42f feat: use shell policy in shell snapshot (#11759)
Honor `shell_environment_policy.set` even after a shell snapshot
2026-02-16 09:11:00 +00:00
Anton Panasenko
1d95656149 bazel: fix snapshot parity for tests/*.rs rust_test targets (#11893)
## Summary
- make `rust_test` targets generated from `tests/*.rs` use Cargo-style
crate names (file stem) so snapshot names match Cargo (`all__...`
instead of Bazel-derived names)
- split lib vs `tests/*.rs` test env wiring in `codex_rust_crate` to
keep existing lib snapshot behavior while applying Bazel
runfiles-compatible workspace root for `tests/*.rs`
- compute the `tests/*.rs` snapshot workspace root from package depth so
`insta` resolves committed snapshots under Bazel `--noenable_runfiles`

## Validation
- `bazelisk test //codex-rs/core:core-all-test
--test_arg=suite::compact:: --cache_test_results=no`
- `bazelisk test //codex-rs/core:core-all-test
--test_arg=suite::compact_remote:: --cache_test_results=no`
2026-02-16 07:11:59 +00:00
sayan-oai
bdea9974d9 fix: only emit unknown model warning on user turns (#11884)
###### Context
unknown model warning added in #11690 has
[issues](https://github.com/openai/codex/actions/runs/22047424710/job/63700733887)
on ubuntu runners because we potentially emit it on all new turns,
including ones with intentionally fake models (i.e., `mock-model` in a
test).

###### Fix
change the warning to only emit on user turns/review turns.

###### Tests
CI now passes on ubuntu, still passes locally
2026-02-15 21:18:35 -08:00
Anton Panasenko
02abd9a8ea feat: persist and restore codex app's tools after search (#11780)
### What changed
1. Removed per-turn MCP selection reset in `core/src/tasks/mod.rs`.
2. Added `SessionState::set_mcp_tool_selection(Vec<String>)` in
`core/src/state/session.rs` for authoritative restore behavior (deduped,
order-preserving, empty clears).
3. Added rollout parsing in `core/src/codex.rs` to recover
`active_selected_tools` from prior `search_tool_bm25` outputs:
   - tracks matching `call_id`s
   - parses function output text JSON
   - extracts `active_selected_tools`
   - latest valid payload wins
   - malformed/non-matching payloads are ignored
4. Applied restore logic to resumed and forked startup paths in
`core/src/codex.rs`.
5. Updated instruction text to session/thread scope in
`core/templates/search_tool/tool_description.md`.
6. Expanded tests in `core/tests/suite/search_tool.rs`, plus unit
coverage in:
   - `core/src/codex.rs`
   - `core/src/state/session.rs`

### Behavior after change
1. Search activates matched tools.
2. Additional searches union into active selection.
3. Selection survives new turns in the same thread.
4. Resume/fork restores selection from rollout history.
5. Separate threads do not inherit selection unless forked.
2026-02-15 19:18:41 -08:00
sayan-oai
060a320e7d fix: show user warning when using default fallback metadata (#11690)
### What
It's currently unclear when the harness falls back to the default,
generic `ModelInfo`. This happens when the `remote_models` feature is
disabled or the model is truly unknown, and can lead to bad performance
and issues in the harness.

Add a user-facing warning when this happens so they are aware when their
setup is broken.

### Tests
Added tests, tested locally.
2026-02-15 18:46:05 -08:00
Charley Cunningham
85034b189e core: snapshot tests for compaction requests, post-compaction layout, some additional compaction tests (#11487)
This PR keeps compaction context-layout test coverage separate from
runtime compaction behavior changes, so runtime logic review can stay
focused.

## Included
- Adds reusable context snapshot helpers in
`core/tests/common/context_snapshot.rs` for rendering model-visible
request/history shapes.
- Standardizes helper naming for readability:
  - `format_request_input_snapshot`
  - `format_response_items_snapshot`
  - `format_labeled_requests_snapshot`
  - `format_labeled_items_snapshot`
- Expands snapshot coverage for both local and remote compaction flows:
  - pre-turn auto-compaction
  - pre-turn failure/context-window-exceeded paths
  - mid-turn continuation compaction
  - manual `/compact` with and without prior user turns
- Captures both sides where relevant:
  - compaction request shape
  - post-compaction history layout shape
- Adds/uses shared request-inspection helpers so assertions target
structured request content instead of ad-hoc JSON string parsing.
- Aligns snapshots/assertions to current behavior and leaves explicit
`TODO(ccunningham)` notes where behavior is known and intentionally
deferred.

## Not Included
- No runtime compaction logic changes.
- No model-visible context/state behavior changes.
2026-02-14 19:57:10 -08:00
Charley Cunningham
fce4ad9cf4 Add process_uuid to sqlite logs (#11534)
## Summary
This PR is the first slice of the per-session `/feedback` logging work:
it adds a process-unique identifier to SQLite log rows.

It does **not** change `/feedback` sourcing behavior yet.

## Changes
- Add migration `0009_logs_process_id.sql` to extend `logs` with:
  - `process_uuid TEXT`
  - `idx_logs_process_uuid` index
- Extend state log models:
  - `LogEntry.process_uuid: Option<String>`
  - `LogRow.process_uuid: Option<String>`
- Stamp each log row with a stable per-process UUID in the sqlite log
layer:
  - generated once per process as `pid:<pid>:<uuid>`
- Update sqlite log insert/query paths to persist and read
`process_uuid`:
  - `INSERT INTO logs (..., process_uuid, ...)`
  - `SELECT ..., process_uuid, ... FROM logs`

## Why
App-server runs many sessions in one process. This change provides a
process-scoping primitive we need for follow-up `/feedback` work, so
threadless/process-level logs can be associated with the emitting
process without mixing across processes.

## Non-goals in this PR
- No `/feedback` transport/source changes
- No attachment size changes
- No sqlite retention/trim policy changes

## Testing
- `just fmt`
- CI will run the full checks
2026-02-14 17:27:22 -08:00
viyatb-oai
db6aa80195 fix(core): add linux bubblewrap sandbox tag (#11767)
## Summary
- add a distinct `linux_bubblewrap` sandbox tag when the Linux
bubblewrap pipeline feature is enabled
- thread the bubblewrap feature flag into sandbox tag generation for:
  - turn metadata header emission
  - tool telemetry metric tags and after-tool-use hooks
- add focused unit tests for `sandbox_tag` precedence and Linux
bubblewrap behavior

## Validation
- `just fmt`
- `cargo clippy -p codex-core --all-targets`
- `cargo test -p codex-core sandbox_tags::tests`
- started `cargo test -p codex-core` and stopped it per request

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
2026-02-14 19:00:01 +00:00
Dylan Hurd
ebceb71db6 feat(tui) Permissions update history item (#11550)
## Summary
We should document in the tui when you switch permissions!

## Testing
- [x] Added unit tests
- [x] Tested locally
2026-02-13 23:44:27 -08:00
viyatb-oai
3164670101 feat(tui): render structured network approval prompts in approval overlay (#11674)
### Description
#### Summary
Adds the TUI UX layer for structured network approvals

#### What changed
- Updated approval overlay to display network-specific approval context
(host/protocol).
- Added/updated TUI wiring so approval prompts show correct network
messaging.
- Added tests covering the new approval overlay behavior.

#### Why
Core orchestration can now request structured network approvals; this
ensures users see clear, contextual prompts in the TUI.

#### Notes
- UX behavior activates only when network approval context is present.

---------

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
2026-02-13 22:38:36 -08:00
viyatb-oai
b527ee2890 feat(core): add structured network approval plumbing and policy decision model (#11672)
### Description
#### Summary
Introduces the core plumbing required for structured network approvals

#### What changed
- Added structured network policy decision modeling in core.
- Added approval payload/context types needed for network approval
semantics.
- Wired shell/unified-exec runtime plumbing to consume structured
decisions.
- Updated related core error/event surfaces for structured handling.
- Updated protocol plumbing used by core approval flow.
- Included small CLI debug sandbox compatibility updates needed by this
layer.

#### Why
establishes the minimal backend foundation for network approvals without
yet changing high-level orchestration or TUI behavior.

#### Notes
- Behavior remains constrained by existing requirements/config gating.
- Follow-up PRs in the stack handle orchestration, UX, and app-server
integration.

---------

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
2026-02-14 04:18:12 +00:00
Eric Traut
854e91e422 Fixed help text for mcp and mcp-server CLI commands (#11813)
Also removed the "[experimental]" tag since these have been stable for
many months

This addresses #11812
2026-02-13 20:16:22 -08:00
Charley Cunningham
67e577da53 Handle model-switch base instructions after compaction (#11659)
Strip trailing <model_switch> during model-switch compaction request,
and append <model_switch> after model switch compaction
2026-02-13 19:02:53 -08:00
alexsong-oai
8156c57234 add perf metrics for connectors load (#11803) 2026-02-13 18:15:07 -08:00
Josh McKinney
de93cef5b7 bazel: enforce MODULE.bazel.lock sync with Cargo.lock (#11790)
## Why this change

When Cargo dependencies change, it is easy to end up with an unexpected
local diff in
`MODULE.bazel.lock` after running Bazel. That creates noisy working
copies and pushes lockfile fixes
later in the cycle. This change addresses that pain point directly.

## What this change enforces

The expected invariant is: after dependency updates, `MODULE.bazel.lock`
is already in sync with
Cargo resolution. In practice, running `bazel mod deps` should not
mutate the lockfile in a clean
state. If it does, the dependency update is incomplete.

## How this is enforced

This change adds a single lockfile check script that snapshots
`MODULE.bazel.lock`, runs
`bazel mod deps`, and fails if the file changes. The same check is wired
into local workflow
commands (`just bazel-lock-update` and `just bazel-lock-check`) and into
Bazel CI (Linux x86_64 job)
so drift is caught early and consistently. The developer documentation
is updated in
`codex-rs/docs/bazel.md` and `AGENTS.md` to make the expected flow
explicit.

`MODULE.bazel.lock` is also refreshed in this PR to match the current
Cargo dependency resolution.

## Expected developer workflow

After changing `Cargo.toml` or `Cargo.lock`, run `just
bazel-lock-update`, then run
`just bazel-lock-check`, and include any resulting `MODULE.bazel.lock`
update in the same change.

## Testing

Ran `just bazel-lock-check` locally.
2026-02-14 02:11:19 +00:00
Celia Chen
5b6911cb1b feat(skills): add permission profiles from openai.yaml metadata (#11658)
## Summary

This PR adds support for skill-level permissions in .codex/openai.yaml
and wires that through the skill loading pipeline.

  ## What’s included

1. Added a new permissions section for skills (network, filesystem, and
macOS-related access).
2. Implemented permission parsing/normalization and translation into
runtime permission profiles.
3. Threaded the new permission profile through SkillMetadata and loader
flow.

  ## Follow-up

A follow-up PR will connect these permission profiles to actual sandbox
enforcement and add user approval prompts for executing binaries/scripts
from skill directories.


 ## Example 
`openai.yaml` snippet:
```
  permissions:
    network: true
    fs_read:
      - "./data"
      - "./data"
    fs_write:
      - "./output"
    macos_preferences: "readwrite"
    macos_automation:
      - "com.apple.Notes"
    macos_accessibility: true
    macos_calendar: true
```

compiled skill permission profile metadata (macOS): 
```
SkillPermissionProfile {
      sandbox_policy: SandboxPolicy::WorkspaceWrite {
          writable_roots: vec![
              AbsolutePathBuf::try_from("/ABS/PATH/TO/SKILL/output").unwrap(),
          ],
          read_only_access: ReadOnlyAccess::Restricted {
              include_platform_defaults: true,
              readable_roots: vec![
                  AbsolutePathBuf::try_from("/ABS/PATH/TO/SKILL/data").unwrap(),
              ],
          },
          network_access: true,
          exclude_tmpdir_env_var: false,
          exclude_slash_tmp: false,
      },
      // Truncated for readability; actual generated profile is longer.
      macos_seatbelt_permission_file: r#"
  (allow user-preference-write)
  (allow appleevent-send
      (appleevent-destination "com.apple.Notes"))
  (allow mach-lookup (global-name "com.apple.axserver"))
  (allow mach-lookup (global-name "com.apple.CalendarAgent"))
  ...
  "#.to_string(),
```
2026-02-14 01:43:44 +00:00
Curtis 'Fjord' Hawthorne
0d76d029b7 Fix js_repl in-flight tool-call waiter race (#11800)
## Summary

This PR fixes a race in `js_repl` tool-call draining that could leave an
exec waiting indefinitely for in-flight tool calls to finish.

The fix is in:

-
`/Users/fjord/code/codex-jsrepl-seq/codex-rs/core/src/tools/js_repl/mod.rs`

## Problem

`js_repl` tracks in-flight tool calls per exec and waits for them to
drain on completion/timeout/cancel paths.
The previous wait logic used a check-then-wait pattern with `Notify`
that could miss a wakeup:

1. Observe `in_flight > 0`
2. Drop lock
3. Register wait (`notified().await`)

If `notify_waiters()` happened between (2) and (3), the waiter could
sleep until another notification that never comes.

## What changed

- Updated all exec-tool-call wait loops to create an owned notification
future while holding the lock:
- use `Arc<Notify>::notified_owned()` instead of cloning notify and
awaiting later.
- Applied this consistently to:
  - `wait_for_exec_tool_calls`
  - `wait_for_all_exec_tool_calls`
  - `wait_for_exec_tool_calls_map`

This preserves existing behavior while eliminating the lost-wakeup
window.

## Test coverage

Added a regression test:

- `wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging`

The test repeatedly races waiter/finisher tasks and asserts bounded
completion to catch hangs.

## Impact

- No API changes.
- No user-facing behavior changes intended.
- Improves reliability of exec lifecycle boundaries when tool calls are
still in flight.


#### [git stack](https://github.com/magus/git-stack-cli)
-  `1` https://github.com/openai/codex/pull/11796
- 👉 `2` https://github.com/openai/codex/pull/11800
-  `3` https://github.com/openai/codex/pull/10673
-  `4` https://github.com/openai/codex/pull/10670
2026-02-14 01:24:52 +00:00
Curtis 'Fjord' Hawthorne
6cbb489e6e Fix js_repl view_image test runtime panic (#11796)
## Summary
Fixes a flaky/panicking `js_repl` image-path test by running it on a
multi-thread Tokio runtime and tightening assertions to focus on real
behavior.

## Problem
`js_repl_can_attach_image_via_view_image_tool` in  

`/Users/fjord/code/codex-jsrepl-seq/codex-rs/core/src/tools/js_repl/mod.rs`
can panic under single-thread test runtime with:

`can call blocking only when running on the multi-threaded runtime`

It also asserted a brittle user-facing text string.

## Changes
1. Updated the test runtime to:
   `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]`
2. Removed the brittle `"attached local image path"` string assertion.
3. Kept the concrete side-effect assertions:
   - tool call succeeds
- image is actually injected into pending input (`InputImage` with
`data:image/png;base64,...`)

## Why this is safe
This is test-only behavior. No production runtime code paths are
changed.

## Validation
- Ran:
`cargo test -p codex-core
tools::js_repl::tests::js_repl_can_attach_image_via_view_image_tool --
--nocapture`
- Result: pass


#### [git stack](https://github.com/magus/git-stack-cli)
- 👉 `1` https://github.com/openai/codex/pull/11796
-  `2` https://github.com/openai/codex/pull/11800
-  `3` https://github.com/openai/codex/pull/10673
-  `4` https://github.com/openai/codex/pull/10670
2026-02-14 01:11:13 +00:00
Josh McKinney
067f8b1be0 fix(protocol): make local image test Bazel-friendly (#11799)
Fixes Bazel build failure in //codex-rs/protocol:protocol-unit-tests.

The test used include_bytes! to read a PNG from codex-core assets; Cargo
can read it,
but Bazel sandboxing can't, so the crate fails to compile.

This change inlines a tiny valid PNG in the test to keep it hermetic.

Related regression: #10590 (cc: @charley-oai)
2026-02-14 00:53:15 +00:00
sayan-oai
6b466df146 fix: send unfiltered models over model/list (#11793)
### What
to unblock filtering models in VSCE, change `model/list` app-server
endpoint to send all models + visibility field `showInPicker` so
filtering can be done in VSCE if desired.

### Tests
Updated tests.
2026-02-13 16:26:32 -08:00
Max Johnson
fb0aaf94de codex-rs: fix thread resume rejoin semantics (#11756)
## Summary
- always rejoin an in-memory running thread on `thread/resume`, even
when overrides are present
- reject `thread/resume` when `history` is provided for a running thread
- reject `thread/resume` when `path` mismatches the running thread
rollout path
- warn (but do not fail) on override mismatches for running threads
- add more `thread_resume` integration tests and fixes; including
restart-based resume-with-overrides coverage

## Validation
- `just fmt`
- `cargo test -p codex-app-server --test all thread_resume`
- manual test with app-server-test-client
https://github.com/openai/codex/pull/11755
- manual test both stdio and websocket in app
2026-02-13 23:09:58 +00:00
Jeremy Rose
e4f8263798 [app-server] add fuzzyFileSearch/sessionCompleted (#11773)
this is to allow the client to know when to stop showing a spinner.
2026-02-13 15:08:14 -08:00
pash-openai
a5e8e69d18 turn metadata followups (#11782)
some trivial simplifications from #11677
2026-02-13 14:59:16 -08:00
Charley Cunningham
26a7cd21e2 tui: preserve remote image attachments across resume/backtrack (#10590)
## Summary
This PR makes app-server-provided image URLs first-class attachments in
TUI, so they survive resume/backtrack/history recall and are resubmitted
correctly.

<img width="715" height="491" alt="Screenshot 2026-02-12 at 8 27 08 PM"
src="https://github.com/user-attachments/assets/226cbd35-8f0c-4e51-a13e-459ef5dd1927"
/>

Can delete the attached image upon backtracking:
<img width="716" height="301" alt="Screenshot 2026-02-12 at 8 27 31 PM"
src="https://github.com/user-attachments/assets/4558d230-f1bd-4eed-a093-8e1ab9c6db27"
/>

In both history and composer, remote images are rendered as normal
`[Image #N]` placeholders, with numbering unified with local images.

## What changed
- Plumb remote image URLs through TUI message state:
  - `UserHistoryCell`
  - `BacktrackSelection`
  - `ChatComposerHistory::HistoryEntry`
  - `ChatWidget::UserMessage`
- Show remote images as placeholder rows inside the composer box (above
textarea), and in history cells.
- Support keyboard selection/deletion for remote image rows in composer
(`Up`/`Down`, `Delete`/`Backspace`).
- Preserve remote-image-only turns in local composer history (Up/Down
recall), including restore after backtrack.
- Ensure submit/queue/backtrack resubmit include remote images in model
input (`UserInput::Image`), and keep request shape stable for
remote-image-only turns.
- Keep image numbering contiguous across remote + local images:
  - remote images occupy `[Image #1]..[Image #M]`
  - local images start at `[Image #M+1]`
  - deletion renumbers consistently.
- In protocol conversion, increment shared image index for remote images
too, so mixed remote/local image tags stay in a single sequence.
- Simplify restore logic to trust in-memory attachment order (no
placeholder-number parsing path).
- Backtrack/replay rollback handling now queues trims through
`AppEvent::ApplyThreadRollback` and syncs transcript overlay/deferred
lines after trims, so overlay/transcript state stays consistent.
- Trim trailing blank rendered lines from user history rendering to
avoid oversized blank padding.

## Docs + tests
- Updated: `docs/tui-chat-composer.md` (remote image flow,
selection/deletion, numbering offsets)
- Added/updated tests across `tui/src/chatwidget/tests.rs`,
`tui/src/app.rs`, `tui/src/app_backtrack.rs`, `tui/src/history_cell.rs`,
and `tui/src/bottom_pane/chat_composer.rs`
- Added snapshot coverage for remote image composer states, including
deleting the first of two remote images.

## Validation
- `just fmt`
- `cargo test -p codex-tui`

## Codex author
`codex fork 019c2636-1571-74a1-8471-15a3b1c3f49d`
2026-02-13 14:54:06 -08:00
Max Johnson
395729910c rmcp-client: fix auth crash (#11692)
Don't load auth tokens if bearer token is present. This fixes a crash I
was getting on Linux:

```
2026-02-12T23:26:24.999408Z DEBUG session_init: codex_core::codex: Configuring session: model=gpt-5.3-codex-spark; provider=ModelProviderInfo { name: "OpenAI", base_url: None, env_key: None, env_key_instructions: No
ne, experimental_bearer_token: None, wire_api: Responses, query_params: None, http_headers: Some({"version": "0.0.0"}), env_http_headers: Some({"OpenAI-Project": "OPENAI_PROJECT", "OpenAI-Organization": "OPENAI_ORGA
NIZATION"}), request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, requires_openai_auth: true, supports_websockets: true }
2026-02-12T23:26:24.999799Z TRACE session_init: codex_keyring_store: keyring.load start, service=Codex MCP Credentials, account=codex_apps|20398391ad12d90b

thread 'tokio-runtime-worker' (96190) has overflowed its stack
fatal runtime error: stack overflow, aborting
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.35s
```
2026-02-13 14:32:01 -08:00
pash-openai
6c0a924203 turn metadata: per-turn non-blocking (#11677) 2026-02-13 12:48:29 -08:00
Alex Kwiatkowski
a4bb59884b fix(nix): use correct version from Cargo.toml in flake build (#11770)
## Summary

- When building via `nix build`, the binary reports `codex-cli 0.0.0`
because the workspace `Cargo.toml` uses `0.0.0` as a placeholder on
`main`. This causes the update checker to always prompt users to upgrade
even when running the latest code.
- Reads the version from `codex-rs/Cargo.toml` at flake evaluation time
using `builtins.fromTOML` and patches it into the workspace `Cargo.toml`
before cargo builds via `postPatch`.
- On release commits (e.g. tag `rust-v0.101.0`), the real version is
used as-is. On `main` branch builds, falls back to
`0.0.0-dev+<shortRev>` (or `0.0.0-dev+dirty`), which the update
checker's `parse_version` ignores — suppressing the spurious upgrade
prompt.

| Scenario | Cargo.toml version | Nix `version` | Binary reports |
Upgrade nag? |
|---|---|---|---|---|
| Release commit (e.g. `rust-v0.101.0`) | `0.101.0` | `0.101.0` |
`codex-cli 0.101.0` | Only if newer exists |
| Main branch (committed) | `0.0.0` | `0.0.0-dev+b934ffc` | `codex-cli
0.0.0-dev+b934ffc` | No |
| Main branch (uncommitted) | `0.0.0` | `0.0.0-dev+dirty` | `codex-cli
0.0.0-dev+dirty` | No |

## Test plan

- [ ] `nix build` from `main` branch and verify `codex --version`
reports `0.0.0-dev+<shortRev>` instead of `0.0.0`
- [ ] Verify the update checker does not show a spurious upgrade prompt
for dev builds
- [ ] Confirm that on a release commit where `Cargo.toml` has a real
version, the binary reports that version correctly
2026-02-13 12:19:25 -08:00
Eric Traut
ffef5ce5de Improve GitHub issue deduplication reliability by introducing a stage… (#11769)
…d two-pass Codex search strategy with deterministic fallback behavior,
and remove an obsolete prompt file that was no longer used.

### Changes
- Updated `workflows/issue-deduplicator.yml`:
- Added richer issue input fields (`state`, `updatedAt`, `labels`) for
model context.
  - Added two candidate pools:
    - `codex-existing-issues-all.json` (`--state all`)
    - `codex-existing-issues-open.json` (`--state open`)
- Added body truncation during JSON preparation to reduce prompt noise.
  - Added **Pass 1** Codex run over all issues.
  - Added normalization/validation step for Pass 1 output:
    - tolerant JSON parsing
    - self-issue filtering
    - deduplication
    - cap to 5 results
- Added **Pass 2 fallback** Codex run over open issues only, triggered
only when Pass 1 has no usable matches.
- Added normalization/validation step for Pass 2 output (same
filtering/dedup/cap behavior).
  - Added final deterministic selector:
    - prefer pass 2 if it finds matches
    - otherwise use pass 1
    - otherwise return no matches
  - Added observability logs:
    - pool sizes
    - per-pass parse/match status
    - final pass selected and final duplicate count
  - Kept public issue-comment format unchanged.
- Added comment documenting that prompt text now lives inline in
workflow.

- Deleted obsolete file:
  - `/prompts/issue-deduplicator.txt`

### Behavior Impact
- Better duplicate recall when broad search fails by retrying against
active issues only.
- More deterministic/noise-resistant output handling.
- No change to workflow trigger conditions, permissions, or issue
comment structure.
2026-02-13 12:01:07 -08:00
alexsong-oai
e71760fc64 support app usage analytics (#11687)
Emit app mentioned and app used events. Dedup by (turn_id, connector_id)

Example event params:
{
    "event_type": "codex_app_used",
    "connector_id": "asdk_app_xxx",
    "thread_id": "019c5527-36d4-xxx",
    "turn_id": "019c552c-cd17-xxx",
    "app_name": "Slack (OpenAI Internal)",
    "product_client_id": "codex_cli_rs",
    "invoke_type": "explicit",
    "model_slug": "gpt-5.3-codex"
}
2026-02-13 12:00:16 -08:00
Curtis 'Fjord' Hawthorne
a02342c9e1 Add js_repl kernel crash diagnostics (#11666)
## Summary

This PR improves `js_repl` crash diagnostics so kernel failures are
debuggable without weakening timeout/reset guarantees.

## What Changed

- Added bounded kernel stderr capture and truncation logic (line + byte
caps).
- Added structured kernel snapshots (`pid`, exit status, stderr tail)
for failure paths.
- Enriched model-visible kernel-failure errors with a structured
diagnostics payload:
  - `js_repl diagnostics: {...}`
  - Included only for likely kernel-failure write/EOF cases.
- Improved logging around kernel write failures, unexpected exits, and
kill/wait paths.
- Added/updated unit tests for:
  - UTF-8-safe truncation
  - stderr tail bounds
  - structured diagnostics shape/truncation
  - conditional diagnostics emission
  - timeout kill behavior
  - forced kernel-failure diagnostics

## Why

Before this, failures like broken pipe / unexpected kernel exit often
surfaced as generic errors with little context. This change preserves
existing behavior but adds actionable diagnostics while keeping output
bounded.

## Scope

- Code changes are limited to:
-
`/Users/fjord/code/codex-jsrepl-seq/codex-rs/core/src/tools/js_repl/mod.rs`

## Validation

- `cargo clippy -p codex-core --all-targets -- -D warnings`
- Targeted `codex-core` js_repl unit tests (including new
diagnostics/timeout coverage)
- Tried starting a long running js_repl command (sleep for 10 minutes),
verified error output was as expected after killing the node process.

#### [git stack](https://github.com/magus/git-stack-cli)
- 👉 `1` https://github.com/openai/codex/pull/11666
-  `2` https://github.com/openai/codex/pull/10673
-  `3` https://github.com/openai/codex/pull/10670
2026-02-13 11:57:11 -08:00
Matthew Zeng
8468871e2b [apps] Improve app listing filtering. (#11697)
- [x] If an installed app is not on the app listing, remove it from the
final list.
2026-02-13 11:54:16 -08:00
jif-oai
c54a4ec078 chore: mini (#11772)
https://github.com/openai/codex/issues/11764
2026-02-13 19:30:49 +00:00
zuxin-oai
b934ffcaaa Update read_path prompt (#11763)
## Summary

- Created branch zuxin/read-path-update from main.
- Copied codex-rs/core/templates/memories/read_path.md from the current
branch.
- Committed the content change.

## Testing
Not run (content copy + commit only).
2026-02-13 18:34:54 +00:00
Eric Traut
b98c810328 Report syntax errors in rules file (#11686)
Currently, if there are syntax errors detected in the starlark rules
file, the entire policy is silently ignored by the CLI. The app server
correctly emits a message that can be displayed in a GUI.

This PR changes the CLI (both the TUI and non-interactive exec) to fail
when the rules file can't be parsed. It then prints out an error message
and exits with a non-zero exit code. This is consistent with the
handling of errors in the config file.

This addresses #11603
2026-02-13 10:33:40 -08:00
Yaroslav Volovich
32da5eb358 feat(tui): prevent macOS idle sleep while turns run (#11711)
## Summary
- add a shared `codex-core` sleep inhibitor that uses native macOS IOKit
assertions (`IOPMAssertionCreateWithName` / `IOPMAssertionRelease`)
instead of spawning `caffeinate`
- wire sleep inhibition to turn lifecycle in `tui` (`TurnStarted`
enables; `TurnComplete` and abort/error finalization disable)
- gate this behavior behind a `/experimental` feature toggle
(`[features].prevent_idle_sleep`) instead of a dedicated `[tui]` config
flag
- expose the toggle in `/experimental` on macOS; keep it under
development on other platforms
- keep behavior no-op on non-macOS targets

<img width="1326" height="577" alt="image"
src="https://github.com/user-attachments/assets/73fac06b-97ae-46a2-800a-30f9516cf8a3"
/>

## Testing
- `cargo check -p codex-core -p codex-tui`
- `cargo test -p codex-core sleep_inhibitor::tests -- --nocapture`
- `cargo test -p codex-core
tui_config_missing_notifications_field_defaults_to_enabled --
--nocapture`
- `cargo test -p codex-core prevent_idle_sleep_is_ -- --nocapture`

## Semantics and API references
- This PR targets `caffeinate -i` semantics: prevent *idle system sleep*
while allowing display idle sleep.
- `caffeinate -i` mapping in Apple open source (`assertionMap`):
  - `kIdleAssertionFlag -> kIOPMAssertionTypePreventUserIdleSystemSleep`
- Source:
https://github.com/apple-oss-distributions/PowerManagement/blob/PowerManagement-1846.60.12/caffeinate/caffeinate.c#L52-L54
- Apple IOKit docs for assertion types and API:
-
https://developer.apple.com/documentation/iokit/iopmlib_h/iopmassertiontypes
-
https://developer.apple.com/documentation/iokit/1557092-iopmassertioncreatewithname
  - https://developer.apple.com/library/archive/qa/qa1340/_index.html

## Codex Electron vs this PR (full stack path)
- Codex Electron app requests sleep blocking with
`powerSaveBlocker.start("prevent-app-suspension")`:
-
https://github.com/openai/codex/blob/main/codex/codex-vscode/electron/src/electron-message-handler.ts
- Electron maps that string to Chromium wake lock type
`kPreventAppSuspension`:
-
https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_power_save_blocker.cc
- Chromium macOS backend maps wake lock types to IOKit assertion
constants and calls IOKit:
  - `kPreventAppSuspension -> kIOPMAssertionTypeNoIdleSleep`
- `kPreventDisplaySleep / kPreventDisplaySleepAllowDimming ->
kIOPMAssertionTypeNoDisplaySleep`
-
https://github.com/chromium/chromium/blob/main/services/device/wake_lock/power_save_blocker/power_save_blocker_mac.cc

## Why this PR uses a different macOS constant name
- This PR uses `"PreventUserIdleSystemSleep"` directly, via
`IOPMAssertionCreateWithName`, in
`codex-rs/core/src/sleep_inhibitor.rs`.
- Apple’s IOKit header documents `kIOPMAssertionTypeNoIdleSleep` as
deprecated and recommends `kIOPMAssertPreventUserIdleSystemSleep` /
`kIOPMAssertionTypePreventUserIdleSystemSleep`:
-
https://github.com/apple-oss-distributions/IOKitUser/blob/IOKitUser-100222.60.2/pwr_mgt.subproj/IOPMLib.h#L1000-L1030
- So Chromium and this PR are using different constant names, but
semantically equivalent idle-system-sleep prevention behavior.

## Future platform support
The architecture is intentionally set up for multi-platform extensions:
- UI code (`tui`) only calls `SleepInhibitor::set_turn_running(...)` on
turn lifecycle boundaries.
- Platform-specific behavior is isolated in
`codex-rs/core/src/sleep_inhibitor.rs` behind `cfg(...)` blocks.
- Feature exposure is centralized in `core/src/features.rs` and surfaced
via `/experimental`.
- Adding new OS backends should not require additional TUI wiring; only
the backend internals and feature stage metadata need to change.

Potential follow-up implementations:
- Windows:
- Add a backend using Win32 power APIs
(`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` as
baseline).
- Optionally move to `PowerCreateRequest` / `PowerSetRequest` /
`PowerClearRequest` for richer assertion semantics.
- Linux:
- Add a backend using logind inhibitors over D-Bus
(`org.freedesktop.login1.Manager.Inhibit` with `what="sleep"`).
  - Keep a no-op fallback where logind/D-Bus is unavailable.

This PR keeps the cross-platform API surface minimal so future PRs can
add Windows/Linux support incrementally with low churn.

---------

Co-authored-by: jif-oai <jif@openai.com>
2026-02-13 10:31:39 -08:00
jif-oai
851fcc377b feat: switch on dying sub-agents (#11477)
[codex-generated]

## Updated PR Description (Ready To Paste)

## Problem

When a sub-agent thread emits `ShutdownComplete`, the TUI switches back
to the primary thread.
That was also happening for user-requested exits (for example `Ctrl+C`),
which could prevent a
clean app exit and unexpectedly resurrect the main thread.

## Mental model

The app has one primary thread and one active thread. A non-primary
active thread shutting down
usually means "agent died, fail back to primary," but during
`ExitMode::ShutdownFirst` shutdown
means "the user is exiting," not "recover this session."

## Non-goals

No change to thread lifecycle, thread-manager ownership, or shutdown
protocol wire format.
No behavioral changes to non-shutdown events.

## Tradeoffs

This adds a small local marker (`pending_shutdown_exit_thread_id`)
instead of inferring intent
from event timing. It is deterministic and simple, but relies on
correctly setting and clearing
that marker around exit.

## Architecture

`App` tracks which thread is intentionally being shut down for exit.
`active_non_primary_shutdown_target` centralizes failover eligibility
for `ShutdownComplete` and
skips failover when shutdown matches the pending-exit thread.
`handle_active_thread_event` handles non-primary failover before generic
forwarding and clears the
pending-exit marker only when the matching active thread completes
shutdown.

## Observability

User-facing info/error messages continue to indicate whether failover to
the main thread succeeded.
The shutdown-intent path is now explicitly documented inline for easier
debugging.

## Tests

Added targeted tests for `active_non_primary_shutdown_target` covering
non-shutdown events,
primary-thread shutdown, non-primary shutdown failover, pending exit on
active thread (no failover),
and pending exit for another thread (still failover).

Validated with:
- `cargo test -p codex-tui` (pass)

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
2026-02-13 18:29:03 +00:00
Eric Traut
12f69b893f Updated app bug report template (#11695) 2026-02-13 10:03:04 -08:00
iceweasel-oai
99466f1f90 sandbox NUX metrics update (#11667)
just updating metrics to match the NUX tweaks we made this week.
2026-02-13 10:01:47 -08:00
Michael Bolin
2383978a2c fix: reduce flakiness of compact_resume_after_second_compaction_preserves_history (#11663)
## Why
`compact_resume_after_second_compaction_preserves_history` has been
intermittently flaky in Windows CI.

The test had two one-shot request matchers in the second compact/resume
phase that could overlap, and it waited for the first `Warning` event
after compaction. In practice, that made the test sensitive to
platform/config-specific prompt shape and unrelated warning timing.

## What Changed
- Hardened the second compaction matcher in
`codex-rs/core/tests/suite/compact_resume_fork.rs` so it accepts
expected compact-request variants while explicitly excluding the
`AFTER_SECOND_RESUME` payload.
- Updated `compact_conversation()` to wait for the specific compaction
warning (`COMPACT_WARNING_MESSAGE`) rather than any `Warning` event.
- Added an inline comment explaining why the matcher is intentionally
broad but disjoint from the follow-up resume matcher.

## Test Plan
- `cargo test -p codex-core --test all
suite::compact_resume_fork::compact_resume_after_second_compaction_preserves_history
-- --exact`
- Repeated the same test in a loop (40 runs) to check for local
nondeterminism.
2026-02-13 09:51:22 -08:00
Max Johnson
f687b074ca app-server-test-client websocket client and thread tools (#11755)
- add websocket endpoint mode with default ws://127.0.0.1:4222 while
keeping stdio codex-bin path compatibility
- add thread-resume (follow stream) and thread-list commands for manual
thread lifecycle testing
- quickstart docs
2026-02-13 17:34:35 +00:00
Anton Panasenko
38c442ca7f core: limit search_tool_bm25 to Apps and clarify discovery guidance (#11669)
## Summary
- Limit `search_tool_bm25` indexing to `codex_apps` tools only, so
non-Apps MCP servers are no longer discoverable through this search
path.
- Move search-tool discovery guidance into the `search_tool_bm25` tool
description (via template include) instead of injecting it as a separate
developer message.
- Update Apps discovery guidance wording to clarify when to use
`search_tool_bm25` for Apps-backed systems (for example Slack, Google
Drive, Jira, Notion) and when to call tools directly.
- Remove dead `core` helper code (`filter_codex_apps_mcp_tools` and
`codex_apps_connector_id`) that is no longer used after the
tool-selection refactor.
- Update `core` search-tool tests to assert codex-apps-only behavior and
to validate guidance from the tool description.

## Validation
-  `just fmt`
-  `cargo test -p codex-core search_tool`
- ⚠️ `cargo test -p codex-core` was attempted, but the run repeatedly
stalled on
`tools::js_repl::tests::js_repl_can_attach_image_via_view_image_tool`.

## Tickets
- None
2026-02-13 09:32:46 -08:00
jif-oai
c0749c349f Fix memories output schema requirements (#11748)
Summary
- make the phase1 memories schema require `rollout_slug` while still
allowing it to be `null`
- update the corresponding test to check the required fields and
nullable type list

Testing
- Not run (not requested)
2026-02-13 16:17:21 +00:00
jif-oai
561fc14045 chore: move explorer to spark (#11745) 2026-02-13 16:13:24 +00:00
jif-oai
db66d827be feat: add slug in name (#11739) 2026-02-13 15:24:03 +00:00
jif-oai
bc80a4a8ed feat: increase windows workers stack (#11736)
Switched arg0 runtime initialization from tokio::runtime::Runtime::new()
to an explicit multi-thread builder that sets the thread stack size to
16MiB.

This is only for Windows for now but we might need to do this for others
in the future. This is required because Codex becomes quite large and
Windows tends to consume stack a little bit faster (this is a known
thing even though everyone seems to have different theory on it)
2026-02-13 15:16:57 +00:00
jif-oai
e00080cea3 feat: memories config (#11731) 2026-02-13 14:18:15 +00:00
jif-oai
36541876f4 chore: streamline phase 2 (#11712) 2026-02-13 13:21:11 +00:00
jif-oai
feae389942 Lower missing rollout log level (#11722)
Fix this: https://github.com/openai/codex/issues/11634
2026-02-13 12:59:17 +00:00
jif-oai
e5e40e2d4b feat: add token usage on memories (#11618)
Add aggregated token usage metrics on phase 1 of memories
2026-02-13 09:31:20 +00:00
Dylan Hurd
e6eb6be683 fix(shell-tool-mcp) build dependencies (#11709)
## Summary
Based on our most recent [release
attempt](https://github.com/openai/codex/actions/runs/21980518940/job/63501739210)
we are not building the shell-tool-mcp job correctly. This one is
outside my expertise, but seems mostly reasonable.

## Testing
 - [x] We really need dry runs of these
2026-02-13 09:30:37 +00:00
viyatb-oai
2bced810da feat(network-proxy): structured policy signaling and attempt correlation to core (#11662)
## Summary
When network requests were blocked, downstream code often had to infer
ask vs deny from free-form response text. That was brittle and led to
incorrect approval behavior.
This PR fixes the proxy side so blocked decisions are structured and
request metadata survives reliably.

## Description
- Blocked proxy responses now carry consistent structured policy
decision data.
- Request attempt metadata is preserved across proxy env paths
(including ALL_PROXY flows).
- Header stripping was tightened so we still remove unsafe forwarding
headers, but keep metadata needed for policy handling.
- Block messages were clarified (for example, allowlist miss vs explicit
deny).
- Added unified violation log entries so policy failures can be
inspected in one place.
- Added/updated tests for these behaviors.

---------

Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
2026-02-13 09:01:11 +00:00
Dylan Hurd
fca5629e34 fix(ci) lock rust toolchain at 1.93.0 to unblock (#11703)
## Summary
CI is broken on main because our CI toolchain is trying to run 1.93.1
while our rust toolchain is locked at 1.93.0. I'm sure it's likely safe
to upgrade, but let's keep things stable for now.

## Testing
- [x] CI should hopefully pass
2026-02-13 08:44:23 +00:00
Dylan Hurd
e6e4c5fa3a chore(core) Restrict model-suggested rules (#11671)
## Summary
If the model suggests a bad rule, don't show it to the user. This does
not impact the parsing of existing rules, just the ones we show.

## Testing
- [x] Added unit tests
- [x] Ran locally
2026-02-12 23:57:53 -08:00
Josh McKinney
1e75173ebd Point Codex App tooltip links to app landing page (#11515)
### Motivation
- Ensure the in-TUI Codex App call-to-action opens the app landing page
variant `https://chatgpt.com/codex?app-landing-page=true` so users reach
the intended landing experience.

### Description
- Update tooltip constants in `codex-rs/tui/src/tooltips.rs` to replace
`https://chatgpt.com/codex` with
`https://chatgpt.com/codex?app-landing-page=true` for the PAID and OTHER
tooltip variants.

### Testing
- Ran `just fmt` in `codex-rs` and `cargo test -p codex-tui`, and the
test suite completed successfully.

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_698d20cf6f088329bb82b07d3ce76e61)
2026-02-12 23:35:57 -08:00
sayan-oai
abeafbdca1 fix: dont show NUX for upgrade-target models that are hidden (#11679)
dont show NUX for models marked with `visibility:hide`.

Tested locally
2026-02-12 20:29:22 -08:00
Matthew Zeng
f93037f55d [apps] Fix app loading logic. (#11518)
When `app/list` is called with `force_refetch=True`, we should seed the
results with what is already cached instead of starting from an empty
list. Otherwise when we send app/list/updated events, the client will
first see an empty list of accessible apps and then get the updated one.
2026-02-13 03:55:10 +00:00
Dylan Hurd
35692e99c1 chore(approvals) More approvals scenarios (#11660)
## Summary
Add some additional tests to approvals flow

## Testing
- [x] these are tests
2026-02-12 19:54:54 -08:00
acrognale-oai
ebe359b876 Add cwd as an optional field to thread/list (#11651)
Add's the ability to filter app-server thread/list by cwd
2026-02-13 02:05:04 +00:00
Eric Traut
537102e657 Added a test to verify that feature flags that are enabled by default are stable (#11275)
We've had a few cases recently where someone enabled a feature flag for
a feature that's still under development or experimental. This test
should prevent this.
2026-02-12 17:53:15 -08:00
Jeremy Rose
9cf7a07281 feat(shell-tool-mcp): add patched zsh build pipeline (#11668)
## Summary
- add `shell-tool-mcp/patches/zsh-exec-wrapper.patch` against upstream
zsh `77045ef899e53b9598bebc5a41db93a548a40ca6`
- add `zsh-linux` and `zsh-darwin` jobs to
`.github/workflows/shell-tool-mcp.yml`
- stage zsh binaries under `artifacts/vendor/<target>/zsh/<variant>/zsh`
- include zsh artifact jobs in `package.needs`
- mark staged zsh binaries executable during packaging

## Notes
- zsh source is cloned from `https://git.code.sf.net/p/zsh/code`
- workflow pins zsh commit `77045ef899e53b9598bebc5a41db93a548a40ca6`
- zsh build runs `./Util/preconfig` before `./configure`

## Validation
- parsed workflow YAML locally (`yaml-ok`)
- validated zsh patch applies cleanly with `git apply --check` on a
fresh zsh clone
2026-02-13 01:34:48 +00:00
Josh McKinney
fc073c9c5b Remove git commands from dangerous command checks (#11510)
### Motivation

- Git subcommand matching was being classified as "dangerous" and caused
benign developer workflows (for example `git push --force-with-lease`)
to be blocked by the preflight policy.
- The change aligns behavior with the intent to reserve the dangerous
checklist for truly destructive shell ops (e.g. `rm -rf`) and avoid
surprising developer-facing blocks.

### Description

- Remove git-specific subcommand checks from
`is_dangerous_to_call_with_exec` in
`codex-rs/shell-command/src/command_safety/is_dangerous_command.rs`,
leaving only explicit `rm` and `sudo` passthrough checks.
- Deleted the git-specific helper logic that classified `reset`,
`branch`-delete, `push` (force/delete/refspec) and `clean --force` as
dangerous.
- Updated unit tests in the same file to assert that various `git
reset`/`git branch`/`git push`/`git clean` variants are no longer
classified as dangerous.
- Kept `find_git_subcommand` (used by safe-command classification)
intact so safe/unsafe parsing elsewhere remains functional.

### Testing

- Ran formatter with `just fmt` successfully.  
- Ran unit tests with `cargo test -p codex-shell-command` and all tests
passed (`144 passed; 0 failed`).

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_i_698d19dedb4883299c3ceb5bbc6a0dcf)
2026-02-13 01:33:02 +00:00
Charley Cunningham
f24669d444 Persist complete TurnContextItem state via canonical conversion (#11656)
## Summary

This PR delivers the first small, shippable step toward model-visible
state diffing by making
`TurnContextItem` more complete and standardizing how it is built.

Specifically, it:
- Adds persisted network context to `TurnContextItem`.
- Introduces a single canonical `TurnContext -> TurnContextItem`
conversion path.
- Routes existing rollout write sites through that canonical conversion
helper.

No context injection/diff behavior changes are included in this PR.

## Why this change

The design goal is to make `TurnContextItem` the canonical source of
truth for context-diff
decisions.
Before this PR:
- `TurnContextItem` did not include all TurnContext-derived environment
inputs needed for v1
completeness.
- Construction was duplicated at multiple write sites.

This PR addresses both with a minimal, reviewable change.

## Changes

### 1) Extend `TurnContextItem` with network state
- Added `TurnContextNetworkItem { allowed_domains, denied_domains }`.
- Added `network: Option<TurnContextNetworkItem>` to `TurnContextItem`.
- Kept backward compatibility by making the new field optional and
skipped when absent.

Files:
- `codex-rs/protocol/src/protocol.rs`

### 2) Canonical conversion helper
- Added `TurnContext::to_turn_context_item(collaboration_mode)` in core.
- Added internal helper to derive network fields from
`config_layer_stack.requirements().network`.

Files:
- `codex-rs/core/src/codex.rs`

### 3) Use canonical conversion at rollout write sites
- Replaced ad hoc `TurnContextItem { ... }` construction with
`to_turn_context_item(...)` in:
  - sampling request path
  - compaction path

Files:
- `codex-rs/core/src/codex.rs`
- `codex-rs/core/src/compact.rs`

### 4) Update fixtures/tests for new optional field
- Updated existing `TurnContextItem` literals in tests to include
`network: None`.
- Added protocol tests for:
  - deserializing old payloads with no `network`
  - serializing when `network` is present

Files:
- `codex-rs/core/tests/suite/resume_warning.rs`
- No replay/diff logic changes.
- Persisted rollout `TurnContextItem` now carries additional network
context when available.
- Older rollout lines without `network` remain readable.
2026-02-12 17:22:44 -08:00
canvrno-oai
46b2da35d5 Add new apps_mcp_gateway (#11630)
Adds a new apps_mcp_gateway flag to route Apps MCP calls through
https://api.openai.com/v1/connectors/mcp/ when enabled, while keeping
legacy MCP routing as default.
2026-02-12 16:54:11 -08:00
Matthew Zeng
c37560069a [apps] Add is_enabled to app info. (#11417)
- [x] Add is_enabled to app info and the response of `app/list`.
- [x] Update TUI to have Enable/Disable button on the app detail page.
2026-02-13 00:30:52 +00:00
Owen Lin
8d97b5c246 fix(app-server): surface more helpful errors for json-rpc (#11638)
Propagate client JSON-RPC errors for app-server request callbacks.
Previously a number of possible errors were collapsed to `channel
closed`. Now we should be able to see the underlying client error.

### Summary
This change stops masking client JSON-RPC error responses as generic
callback cancellation in app-server server->client request flows.

Previously, when the client responded with a JSON-RPC error, we removed
the callback entry but did not send anything to the waiting oneshot
receiver. Waiters then observed channel closure (for example, auth
refresh request canceled: channel closed), which hid the actual client
error.

Now, client JSON-RPC errors are forwarded through the callback channel
and handled explicitly by request consumers.

### User-visible behavior
- External auth refresh now surfaces real client JSON-RPC errors when
provided.
- True transport/callback-drop cases still report
canceled/channel-closed semantics.

### Example: client JSON-RPC error is now propagated (not masked as
"canceled")

When app-server asks the client to refresh ChatGPT auth tokens, it sends
a server->client JSON-RPC request like:

```json
{
  "id": 42,
  "method": "account/chatgptAuthTokens/refresh",
  "params": {
    "reason": "unauthorized",
    "previousAccountId": "org-abc"
  }
}
```

If the client cannot refresh and responds with a JSON-RPC error:
```
{
  "id": 42,
  "error": {
    "code": -32000,
    "message": "refresh failed",
    "data": null
  }
}
```

app-server now forwards that error through the callback path and
surfaces:
`auth refresh request failed: code=-32000 message=refresh failed`

Previously, this same case could be reported as:
`auth refresh request canceled: channel closed`
2026-02-13 00:14:55 +00:00
Michael Bolin
2825ac85a8 app-server: stabilize detached review start on Windows (#11646)
## Why

`review_start_with_detached_delivery_returns_new_thread_id` has been
failing on Windows CI. The failure mode is a process crash
(`tokio-runtime-worker` stack overflow) during detached review setup,
which causes EOF in the test harness.

This test is intended to validate detached review thread identity, not
shell snapshot behavior. We also still want detached review to avoid
unnecessary rollout-path rediscovery when the parent thread is already
loaded.

## What Changed

- Updated detached review startup in
`codex-rs/app-server/src/codex_message_processor.rs`:
  - `start_detached_review` now receives the loaded parent thread.
  - It prefers `parent_thread.rollout_path()`.
- It falls back to `find_thread_path_by_id_str(...)` only if the
in-memory path is unavailable.
- Hardened the review test fixture in
`codex-rs/app-server/tests/suite/v2/review.rs` by setting
`shell_snapshot = false` in test config, so this test no longer depends
on unrelated Windows PowerShell snapshot initialization.

## Verification

- `cargo test -p codex-app-server`
- Verified
`suite::v2::review::review_start_with_detached_delivery_returns_new_thread_id`
passes locally.

## Notes

- Related context: rollout-path lookup behavior changed in #10532.
2026-02-12 16:12:44 -08:00
Michael Bolin
aef4af1079 app-server tests: disable shell_snapshot for review suite (#11657)
## Why


`suite::v2::review::review_start_with_detached_delivery_returns_new_thread_id`
was failing on Windows CI due to an unrelated process crash during shell
snapshot initialization (`tokio-runtime-worker` stack overflow).

This review test suite validates review API behavior and should not
depend on shell snapshot behavior. Keeping shell snapshot enabled in
this fixture made the test flaky for reasons outside the scenario under
test.

## What Changed

- Updated the review suite test config in
`codex-rs/app-server/tests/suite/v2/review.rs` to set:
  - `shell_snapshot = false`

This keeps the review tests focused on review behavior by disabling
shell snapshot initialization in this fixture.

## Verification

- `cargo test -p codex-app-server`
- Confirmed the previously failing Windows CI job for this test now
passes on this PR.
2026-02-12 23:56:43 +00:00
Curtis 'Fjord' Hawthorne
0dcfc59171 Add js_repl_tools_only model and routing restrictions (#10671)
# External (non-OpenAI) Pull Request Requirements

Before opening this Pull Request, please read the dedicated
"Contributing" markdown file or your PR may be closed:
https://github.com/openai/codex/blob/main/docs/contributing.md

If your PR conforms to our contribution guidelines, replace this text
with a detailed and high quality description of your changes.

Include a link to a bug report or enhancement request.


#### [git stack](https://github.com/magus/git-stack-cli)
-  `1` https://github.com/openai/codex/pull/10674
-  `2` https://github.com/openai/codex/pull/10672
- 👉 `3` https://github.com/openai/codex/pull/10671
-  `4` https://github.com/openai/codex/pull/10673
-  `5` https://github.com/openai/codex/pull/10670
2026-02-12 15:41:05 -08:00
Wendy Jiao
a7ce2a1c31 Remove absolute path in rollout_summary (#11622) 2026-02-12 23:32:41 +00:00
Celia Chen
dfd1e199a0 [feat] add seatbelt permission files (#11639)
Add seatbelt permission extension abstraction as permission files for
seatbelt profiles. This should complement our current sandbox policy
2026-02-12 23:30:22 +00:00
Owen Lin
76256a8cec fix: skip review_start_with_detached_delivery_returns_new_thread_id o… (#11645)
…n windows
2026-02-12 15:12:57 -08:00
Josh McKinney
75e79cf09a docs: require insta snapshot coverage for UI changes (#10669)
Adds an explicit requirement in AGENTS.md that any user-visible UI
change includes corresponding insta snapshot coverage and that snapshots
are reviewed/accepted in the PR.

Tests: N/A (docs only)
2026-02-12 22:47:09 +00:00
Michael Bolin
a4cc1a4a85 feat: introduce Permissions (#11633)
## Why
We currently carry multiple permission-related concepts directly on
`Config` for shell/unified-exec behavior (`approval_policy`,
`sandbox_policy`, `network`, `shell_environment_policy`,
`windows_sandbox_mode`).

Consolidating these into one in-memory struct makes permission handling
easier to reason about and sets up the next step: supporting named
permission profiles (`[permissions.PROFILE_NAME]`) without changing
behavior now.

This change is mostly mechanical: it updates existing callsites to go
through `config.permissions`, but it does not yet refactor those
callsites to take a single `Permissions` value in places where multiple
permission fields are still threaded separately.

This PR intentionally **does not** change the on-disk `config.toml`
format yet and keeps compatibility with legacy config keys.

## What Changed
- Introduced `Permissions` in `core/src/config/mod.rs`.
- Added `Config::permissions` and moved effective runtime permission
fields under it:
  - `approval_policy`
  - `sandbox_policy`
  - `network`
  - `shell_environment_policy`
  - `windows_sandbox_mode`
- Updated config loading/building so these effective values are still
derived from the same existing config inputs and constraints.
- Updated Windows sandbox helpers/resolution to read/write via
`permissions`.
- Threaded the new field through all permission consumers across core
runtime, app-server, CLI/exec, TUI, and sandbox summary code.
- Updated affected tests to reference `config.permissions.*`.
- Renamed the struct/field from
`EffectivePermissions`/`effective_permissions` to
`Permissions`/`permissions` and aligned variable naming accordingly.

## Verification
- `just fix -p codex-core -p codex-tui -p codex-cli -p codex-app-server
-p codex-exec -p codex-utils-sandbox-summary`
- `cargo build -p codex-core -p codex-tui -p codex-cli -p
codex-app-server -p codex-exec -p codex-utils-sandbox-summary`
2026-02-12 14:42:54 -08:00
xl-openai
d7cb70ed26 Better error message for model limit hit. (#11636)
<img width="553" height="147" alt="image"
src="https://github.com/user-attachments/assets/f04cdebd-608a-4055-a413-fae92aaf04e5"
/>
2026-02-12 14:10:30 -08:00
Dylan Hurd
4668feb43a chore(core) Deprecate approval_policy: on-failure (#11631)
## Summary
In an effort to start simplifying our sandbox setup, we're announcing
this approval_policy as deprecated. In general, it performs worse than
`on-request`, and we're focusing on making fewer sandbox configurations
perform much better.

## Testing
- [x] Tested locally
- [x] Existing tests pass
2026-02-12 13:23:30 -08:00
iceweasel-oai
5c3ca73914 add a slash command to grant sandbox read access to inaccessible directories (#11512)
There is an edge case where a directory is not readable by the sandbox.
In practice, we've seen very little of it, but it can happen so this
slash command unlocks users when it does.

Future idea is to make this a tool that the agent knows about so it can
be more integrated.
2026-02-12 12:48:36 -08:00
Curtis 'Fjord' Hawthorne
466be55abc Add js_repl host helpers and exec end events (#10672)
## Summary

This PR adds host-integrated helper APIs for `js_repl` and updates model
guidance so the agent can use them reliably.

### What’s included

- Add `codex.tool(name, args?)` in the JS kernel so `js_repl` can call
normal Codex tools.
- Keep persistent JS state and scratch-path helpers available:
  - `codex.state`
  - `codex.tmpDir`
- Wire `js_repl` tool calls through the standard tool router path.
- Add/align `js_repl` execution completion/end event behavior with
existing tool logging patterns.
- Update dynamic prompt injection (`project_doc`) to document:
  - how to call `codex.tool(...)`
  - raw output behavior
- image flow via `view_image` (`codex.tmpDir` +
`codex.tool("view_image", ...)`)
- stdio safety guidance (`console.log` / `codex.tool`, avoid direct
`process.std*`)

## Why

- Standardize JS-side tool usage on `codex.tool(...)`
- Make `js_repl` behavior more consistent with existing tool execution
and event/logging patterns.
- Give the model enough runtime guidance to use `js_repl` safely and
effectively.

## Testing

- Added/updated unit and runtime tests for:
  - `codex.tool` calls from `js_repl` (including shell/MCP paths)
  - image handoff flow via `view_image`
  - prompt-injection text for `js_repl` guidance
  - execution/end event behavior and related regression coverage




#### [git stack](https://github.com/magus/git-stack-cli)
-  `1` https://github.com/openai/codex/pull/10674
- 👉 `2` https://github.com/openai/codex/pull/10672
-  `3` https://github.com/openai/codex/pull/10671
-  `4` https://github.com/openai/codex/pull/10673
-  `5` https://github.com/openai/codex/pull/10670
2026-02-12 12:10:25 -08:00
Owen Lin
efc8d45750 feat(app-server): experimental flag to persist extended history (#11227)
This PR adds an experimental `persist_extended_history` bool flag to
app-server thread APIs so rollout logs can retain a richer set of
EventMsgs for non-lossy Thread > Turn > ThreadItems reconstruction (i.e.
on `thread/resume`).

### Motivation
Today, our rollout recorder only persists a small subset (e.g. user
message, reasoning, assistant message) of `EventMsg` types, dropping a
good number (like command exec, file change, etc.) that are important
for reconstructing full item history for `thread/resume`, `thread/read`,
and `thread/fork`.

Some clients want to be able to resume a thread without lossiness. This
lossiness is primarily a UI thing, since what the model sees are
`ResponseItem` and not `EventMsg`.

### Approach
This change introduces an opt-in `persist_full_history` flag to preserve
those events when you start/resume/fork a thread (defaults to `false`).

This is done by adding an `EventPersistenceMode` to the rollout
recorder:
- `Limited` (existing behavior, default)
- `Extended` (new opt-in behavior)

In `Extended` mode, persist additional `EventMsg` variants needed for
non-lossy app-server `ThreadItem` reconstruction. We now store the
following ThreadItems that we didn't before:
- web search
- command execution
- patch/file changes
- MCP tool calls
- image view calls
- collab tool outcomes
- context compaction
- review mode enter/exit

For **command executions** in particular, we truncate the output using
the existing `truncate_text` from core to store an upper bound of 10,000
bytes, which is also the default value for truncating tool outputs shown
to the model. This keeps the size of the rollout file and command
execution items returned over the wire reasonable.

And we also persist `EventMsg::Error` which we can now map back to the
Turn's status and populates the Turn's error metadata.

#### Updates to EventMsgs
To truly make `thread/resume` non-lossy, we also needed to persist the
`status` on `EventMsg::CommandExecutionEndEvent` and
`EventMsg::PatchApplyEndEvent`. Previously it was not obvious whether a
command failed or was declined (similar for apply_patch). These
EventMsgs were never persisted before so I made it a required field.
2026-02-12 19:34:22 +00:00
canvrno-oai
22fa283511 Parse first order skill/connector mentions (#11547)
This PR introduces a skill-expansion mechanism for mentions so nested or
skill or connection mentions are expanded if present in skills invoked
by the user. This keeps behavior aligned with existing mention handling
while extending coverage to deeper scenarios. With these changes, users
can create skills that invoke connectors, and skills that invoke other
skills.

Replaces #10863, which is not needed with the addition of
[search_tool_bm25](https://github.com/openai/codex/issues/10657)
2026-02-12 10:55:22 -08:00
Jeremy Rose
66e0c3aaa3 app-server: add fuzzy search sessions for streaming file search (#10268) 2026-02-12 10:49:44 -08:00
jif-oai
545b266839 fix: fmt (#11619) 2026-02-12 18:13:00 +00:00
jif-oai
b3674dcce0 chore: reduce concurrency of memories (#11614) 2026-02-12 17:55:21 +00:00
Wendy Jiao
88c5ca2573 Add cwd to memory files (#11591)
Add cwd to memory files so that model can deal with multi cwd memory
better.

---------

Co-authored-by: jif-oai <jif@openai.com>
2026-02-12 17:46:49 +00:00
Wendy Jiao
82acd815e4 exclude developer messages from phase-1 memory input (#11608)
Co-authored-by: jif-oai <jif@openai.com>
2026-02-12 17:43:38 +00:00
Dylan Hurd
f39f506700 fix(core) model_info preserves slug (#11602)
## Summary
Preserve the specified model slug when we get a prefix-based match

## Testing
- [x] added unit test

---------

Co-authored-by: Ahmed Ibrahim <aibrahim@openai.com>
2026-02-12 09:43:32 -08:00
jif-oai
f741fad5c0 chore: drop and clean from phase 1 (#11605)
This PR is mostly cleaning and simplifying phase 1 of memories
2026-02-12 17:23:00 +00:00
jif-oai
ba6f7a9e15 chore: drop mcp validation of dynamic tools (#11609)
Drop validation of dynamic tools using MCP names to reduce latency
2026-02-12 17:15:25 +00:00
329 changed files with 24983 additions and 4558 deletions

View File

@@ -1,3 +1,5 @@
iTerm
iTerm2
psuedo
psuedo
te
TE

View File

@@ -3,4 +3,4 @@
skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js
check-hidden = true
ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm
ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE

View File

@@ -21,6 +21,13 @@ body:
label: What subscription do you have?
validations:
required: true
- type: input
id: platform
attributes:
label: What platform is your computer?
description: |
For macOS and Linux: copy the output of `uname -mprs`
For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console
- type: textarea
id: actual
attributes:

View File

@@ -1,18 +0,0 @@
You are an assistant that triages new GitHub issues by identifying potential duplicates.
You will receive the following JSON files located in the current working directory:
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
- `codex-existing-issues.json`: JSON array of recent issues (each element includes number, title, body, createdAt).
Instructions:
- Load both files as JSON and review their contents carefully. The codex-existing-issues.json file is large, ensure you explore all of it.
- Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request.
- Only consider an issue a potential duplicate if there is a clear overlap in symptoms, feature requests, reproduction steps, or error messages.
- Prioritize newer issues when similarity is comparable.
- Ignore pull requests and issues whose similarity is tenuous.
- When unsure, prefer returning fewer matches.
Output requirements:
- Respond with a JSON array of issue numbers (integers), ordered from most likely duplicate to least.
- Include at most five numbers.
- If you find no plausible duplicates, respond with `[]`.

View File

@@ -65,6 +65,11 @@ jobs:
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
- name: Check MODULE.bazel.lock is up to date
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: ./scripts/check-module-bazel-lock.sh
# TODO(mbolin): Bring this back once we have caching working. Currently,
# we never seem to get a cache hit but we still end up paying the cost of
# uploading at the end of the build, which takes over a minute!

View File

@@ -15,34 +15,68 @@ jobs:
permissions:
contents: read
outputs:
codex_output: ${{ steps.codex.outputs.final-message }}
codex_output: ${{ steps.select-final.outputs.codex_output }}
steps:
- uses: actions/checkout@v6
- name: Prepare Codex inputs
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -eo pipefail
CURRENT_ISSUE_FILE=codex-current-issue.json
EXISTING_ISSUES_FILE=codex-existing-issues.json
EXISTING_ALL_FILE=codex-existing-issues-all.json
EXISTING_OPEN_FILE=codex-existing-issues-open.json
gh issue list --repo "${{ github.repository }}" \
--json number,title,body,createdAt \
gh issue list --repo "$REPO" \
--json number,title,body,createdAt,updatedAt,state,labels \
--limit 1000 \
--state all \
--search "sort:created-desc" \
| jq '.' \
> "$EXISTING_ISSUES_FILE"
| jq '[.[] | {
number,
title,
body: ((.body // "")[0:4000]),
createdAt,
updatedAt,
state,
labels: ((.labels // []) | map(.name))
}]' \
> "$EXISTING_ALL_FILE"
gh issue view "${{ github.event.issue.number }}" \
--repo "${{ github.repository }}" \
gh issue list --repo "$REPO" \
--json number,title,body,createdAt,updatedAt,state,labels \
--limit 1000 \
--state open \
--search "sort:created-desc" \
| jq '[.[] | {
number,
title,
body: ((.body // "")[0:4000]),
createdAt,
updatedAt,
state,
labels: ((.labels // []) | map(.name))
}]' \
> "$EXISTING_OPEN_FILE"
gh issue view "$ISSUE_NUMBER" \
--repo "$REPO" \
--json number,title,body \
| jq '.' \
| jq '{number, title, body: ((.body // "")[0:4000])}' \
> "$CURRENT_ISSUE_FILE"
- id: codex
echo "Prepared duplicate detection input files."
echo "all_issue_count=$(jq 'length' "$EXISTING_ALL_FILE")"
echo "open_issue_count=$(jq 'length' "$EXISTING_OPEN_FILE")"
# Prompt instructions are intentionally inline in this workflow. The old
# .github/prompts/issue-deduplicator.txt file is obsolete and removed.
- id: codex-all
name: Find duplicates (pass 1, all issues)
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
@@ -52,14 +86,17 @@ jobs:
You will receive the following JSON files located in the current working directory:
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
- `codex-existing-issues.json`: JSON array of recent issues (each element includes number, title, body, createdAt).
- `codex-existing-issues-all.json`: JSON array of recent issues with states, timestamps, and labels.
Instructions:
- Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request.
- Focus on the underlying intent and context of each issue—such as reported symptoms, feature requests, reproduction steps, or error messages—rather than relying solely on string similarity or synthetic metrics.
- After your analysis, validate your results in 1-2 lines explaining your decision to return the selected matches.
- When unsure, prefer returning fewer matches.
- Include at most five numbers.
- Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent.
- Prefer active unresolved issues when confidence is similar.
- Closed issues can still be valid duplicates if they clearly match.
- Return fewer matches rather than speculative ones.
- If confidence is low, return an empty list.
- Include at most five issue numbers.
- After analysis, provide a short reason for your decision.
output-schema: |
{
@@ -77,6 +114,179 @@ jobs:
"additionalProperties": false
}
- id: normalize-all
name: Normalize pass 1 output
env:
CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }}
CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -eo pipefail
raw=${CODEX_OUTPUT//$'\r'/}
parsed=false
issues='[]'
reason=''
if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then
parsed=true
issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]')
reason=$(printf '%s' "$raw" | jq -r '.reason // ""')
else
reason='Pass 1 output was empty or invalid JSON.'
fi
filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[
$issues[]
| tostring
| select(. != $current)
] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]')
has_matches=false
if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then
has_matches=true
fi
echo "Pass 1 parsed: $parsed"
echo "Pass 1 matches after filtering: $(jq 'length' <<< "$filtered")"
echo "Pass 1 reason: $reason"
{
echo "issues_json=$filtered"
echo "reason<<EOF"
echo "$reason"
echo "EOF"
echo "has_matches=$has_matches"
} >> "$GITHUB_OUTPUT"
- id: codex-open
name: Find duplicates (pass 2, open issues)
if: ${{ steps.normalize-all.outputs.has_matches != 'true' }}
uses: openai/codex-action@main
with:
openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }}
allow-users: "*"
prompt: |
You are an assistant that triages new GitHub issues by identifying potential duplicates.
This is a fallback pass because a broad search did not find convincing matches.
You will receive the following JSON files located in the current working directory:
- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body).
- `codex-existing-issues-open.json`: JSON array of open issues only.
Instructions:
- Search only these active unresolved issues for duplicates of the current issue.
- Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent.
- Prefer fewer, higher-confidence matches.
- If confidence is low, return an empty list.
- Include at most five issue numbers.
- After analysis, provide a short reason for your decision.
output-schema: |
{
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"type": "string"
}
},
"reason": { "type": "string" }
},
"required": ["issues", "reason"],
"additionalProperties": false
}
- id: normalize-open
name: Normalize pass 2 output
if: ${{ steps.normalize-all.outputs.has_matches != 'true' }}
env:
CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }}
CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
run: |
set -eo pipefail
raw=${CODEX_OUTPUT//$'\r'/}
parsed=false
issues='[]'
reason=''
if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then
parsed=true
issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]')
reason=$(printf '%s' "$raw" | jq -r '.reason // ""')
else
reason='Pass 2 output was empty or invalid JSON.'
fi
filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[
$issues[]
| tostring
| select(. != $current)
] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]')
has_matches=false
if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then
has_matches=true
fi
echo "Pass 2 parsed: $parsed"
echo "Pass 2 matches after filtering: $(jq 'length' <<< "$filtered")"
echo "Pass 2 reason: $reason"
{
echo "issues_json=$filtered"
echo "reason<<EOF"
echo "$reason"
echo "EOF"
echo "has_matches=$has_matches"
} >> "$GITHUB_OUTPUT"
- id: select-final
name: Select final duplicate set
env:
PASS1_ISSUES: ${{ steps.normalize-all.outputs.issues_json }}
PASS1_REASON: ${{ steps.normalize-all.outputs.reason }}
PASS2_ISSUES: ${{ steps.normalize-open.outputs.issues_json }}
PASS2_REASON: ${{ steps.normalize-open.outputs.reason }}
PASS1_HAS_MATCHES: ${{ steps.normalize-all.outputs.has_matches }}
PASS2_HAS_MATCHES: ${{ steps.normalize-open.outputs.has_matches }}
run: |
set -eo pipefail
selected_issues='[]'
selected_reason='No plausible duplicates found.'
selected_pass='none'
if [ "$PASS1_HAS_MATCHES" = "true" ]; then
selected_issues=${PASS1_ISSUES:-'[]'}
selected_reason=${PASS1_REASON:-'Pass 1 found duplicates.'}
selected_pass='all'
fi
if [ "$PASS2_HAS_MATCHES" = "true" ]; then
selected_issues=${PASS2_ISSUES:-'[]'}
selected_reason=${PASS2_REASON:-'Pass 2 found duplicates.'}
selected_pass='open-fallback'
fi
final_json=$(jq -cn \
--argjson issues "$selected_issues" \
--arg reason "$selected_reason" \
--arg pass "$selected_pass" \
'{issues: $issues, reason: $reason, pass: $pass}')
echo "Final pass used: $selected_pass"
echo "Final duplicate count: $(jq '.issues | length' <<< "$final_json")"
echo "Final reason: $(jq -r '.reason' <<< "$final_json")"
{
echo "codex_output<<EOF"
echo "$final_json"
echo "EOF"
} >> "$GITHUB_OUTPUT"
comment-on-issue:
name: Comment with potential duplicates
needs: gather-duplicates
@@ -105,11 +315,17 @@ jobs:
const issues = Array.isArray(parsed?.issues) ? parsed.issues : [];
const currentIssueNumber = String(context.payload.issue.number);
const passUsed = typeof parsed?.pass === 'string' ? parsed.pass : 'unknown';
const reason = typeof parsed?.reason === 'string' ? parsed.reason : '';
console.log(`Current issue number: ${currentIssueNumber}`);
console.log(`Pass used: ${passUsed}`);
if (reason) {
console.log(`Reason: ${reason}`);
}
console.log(issues);
const filteredIssues = issues.filter((value) => String(value) !== currentIssueNumber);
const filteredIssues = [...new Set(issues.map((value) => String(value)))].filter((value) => value !== currentIssueNumber).slice(0, 5);
if (filteredIssues.length === 0) {
core.info('Codex reported no potential duplicates.');

View File

@@ -59,7 +59,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
with:
components: rustfmt
- name: cargo fmt
@@ -75,7 +75,7 @@ jobs:
working-directory: codex-rs
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
- uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2
with:
tool: cargo-shear
@@ -196,7 +196,7 @@ jobs:
fi
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}"
fi
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}
components: clippy
@@ -513,7 +513,7 @@ jobs:
- name: Install DotSlash
uses: facebook/install-dotslash@v2
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}

View File

@@ -82,7 +82,7 @@ jobs:
Write-Host "Total RAM: $ramGiB GiB"
Write-Host "Disk usage:"
Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}}
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}

View File

@@ -123,7 +123,7 @@ jobs:
sudo apt-get update -y
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1
fi
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}

View File

@@ -31,7 +31,7 @@ jobs:
node-version: 22
cache: pnpm
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
- name: build codex
run: cargo build --bin codex

View File

@@ -105,7 +105,7 @@ jobs:
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1
fi
- uses: dtolnay/rust-toolchain@1.93
- uses: dtolnay/rust-toolchain@1.93.0
with:
targets: ${{ matrix.target }}
@@ -251,11 +251,11 @@ jobs:
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
elif command -v dnf >/dev/null 2>&1; then
dnf install -y git gcc gcc-c++ make bison autoconf gettext
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
elif command -v yum >/dev/null 2>&1; then
yum install -y git gcc gcc-c++ make bison autoconf gettext
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
else
echo "Unsupported package manager in container"
exit 1
@@ -329,6 +329,212 @@ jobs:
path: artifacts/**
if-no-files-found: error
zsh-linux:
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-22.04
image: ubuntu:22.04
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-12
image: debian:12
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: debian-11
image: debian:11
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-22.04
image: arm64v8/ubuntu:22.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-20.04
image: arm64v8/ubuntu:20.04
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-12
image: arm64v8/debian:12
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: debian-11
image: arm64v8/debian:11
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: centos-9
image: quay.io/centos/centos:stream9
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if command -v apt-get >/dev/null 2>&1; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
elif command -v dnf >/dev/null 2>&1; then
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
elif command -v yum >/dev/null 2>&1; then
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
else
echo "Unsupported package manager in container"
exit 1
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched zsh
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh
cd /tmp/zsh
git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
./Util/preconfig
./configure
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
mkdir -p "$dest"
cp Src/zsh "$dest/zsh"
- name: Smoke test zsh exec wrapper
shell: bash
run: |
set -euo pipefail
tmpdir="$(mktemp -d)"
cat > "$tmpdir/exec-wrapper" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$tmpdir/exec-wrapper"
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v6
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
zsh-darwin:
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
- runner: macos-14
target: aarch64-apple-darwin
variant: macos-14
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
if ! command -v autoconf >/dev/null 2>&1; then
brew install autoconf
fi
- name: Checkout repository
uses: actions/checkout@v6
- name: Build patched zsh
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh
cd /tmp/zsh
git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
./Util/preconfig
./configure
cores="$(getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
mkdir -p "$dest"
cp Src/zsh "$dest/zsh"
- name: Smoke test zsh exec wrapper
shell: bash
run: |
set -euo pipefail
tmpdir="$(mktemp -d)"
cat > "$tmpdir/exec-wrapper" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$tmpdir/exec-wrapper"
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
- uses: actions/upload-artifact@v6
with:
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
package:
name: Package npm module
needs:
@@ -336,6 +542,8 @@ jobs:
- rust-binaries
- bash-linux
- bash-darwin
- zsh-linux
- zsh-darwin
runs-on: ubuntu-latest
env:
PACKAGE_VERSION: ${{ needs.metadata.outputs.version }}
@@ -409,7 +617,8 @@ jobs:
chmod +x \
"$staging"/vendor/*/codex-exec-mcp-server \
"$staging"/vendor/*/codex-execve-wrapper \
"$staging"/vendor/*/bash/*/bash
"$staging"/vendor/*/bash/*/bash \
"$staging"/vendor/*/zsh/*/zsh
- name: Create npm tarball
shell: bash

View File

@@ -15,6 +15,10 @@ In the codex-rs folder where the rust code lives:
- When writing tests, prefer comparing the equality of entire objects over fields one by one.
- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable.
- If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`.
- If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the
repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change.
- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught
locally before CI.
- Do not create small helper methods that are referenced only once.
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:
@@ -60,7 +64,14 @@ See `codex-rs/tui/styles.md`.
### Snapshot tests
This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. When UI or text output changes intentionally, update the snapshots as follows:
This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output.
**Requirement:** any change that affects user-visible UI (including adding new UI) must include
corresponding `insta` snapshot coverage (add a new snapshot test if one doesn't exist yet, or
update the existing snapshot). Review and accept snapshot updates as part of the PR so UI impact
is easy to review and future diffs stay visual.
When UI or text output changes intentionally, update the snapshots as follows:
- Run tests to generate any updated snapshots:
- `cargo test -p codex-tui`
@@ -158,3 +169,5 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially:
`just write-app-server-schema`
(and `just write-app-server-schema --experimental` when experimental API fixtures are affected).
- Validate with `cargo test -p codex-app-server-protocol`.
- Avoid boilerplate tests that only assert experimental field markers for individual
request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead.

4
MODULE.bazel.lock generated

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,7 @@
</p>
</br>
If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="https://developers.openai.com/codex/ide">install in your IDE.</a>
</br>If you want the desktop app experience, run <code>codex app</code> or visit <a href="https://chatgpt.com/codex?app-landing-page=true">the Codex App page</a>.
</br>If you are looking for the <em>cloud-based agent</em> from OpenAI, <strong>Codex Web</strong>, go to <a href="https://chatgpt.com/codex">chatgpt.com/codex</a>.</p>
---

17
codex-rs/Cargo.lock generated
View File

@@ -1401,6 +1401,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"shlex",
"similar",
"strum_macros 0.27.2",
"tempfile",
@@ -1419,6 +1420,8 @@ dependencies = [
"codex-protocol",
"serde",
"serde_json",
"tungstenite",
"url",
"uuid",
]
@@ -1692,6 +1695,7 @@ dependencies = [
"codex-utils-home-dir",
"codex-utils-pty",
"codex-utils-readiness",
"codex-utils-sanitizer",
"codex-utils-string",
"codex-windows-sandbox",
"core-foundation 0.9.4",
@@ -1708,6 +1712,7 @@ dependencies = [
"include_dir",
"indexmap 2.13.0",
"indoc",
"insta",
"keyring",
"landlock",
"libc",
@@ -1720,7 +1725,6 @@ dependencies = [
"predicates",
"pretty_assertions",
"rand 0.9.2",
"regex",
"regex-lite",
"reqwest",
"rmcp",
@@ -2034,6 +2038,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"base64 0.22.1",
"clap",
"codex-utils-absolute-path",
"codex-utils-rustls-provider",
@@ -2293,6 +2298,7 @@ dependencies = [
"codex-utils-oss",
"codex-utils-pty",
"codex-utils-sandbox-summary",
"codex-utils-sleep-inhibitor",
"codex-windows-sandbox",
"color-eyre",
"crossterm",
@@ -2492,6 +2498,15 @@ dependencies = [
"regex",
]
[[package]]
name = "codex-utils-sleep-inhibitor"
version = "0.0.0"
dependencies = [
"core-foundation 0.9.4",
"libc",
"tracing",
]
[[package]]
name = "codex-utils-string"
version = "0.0.0"

View File

@@ -54,6 +54,7 @@ members = [
"utils/elapsed",
"utils/sandbox-summary",
"utils/sanitizer",
"utils/sleep-inhibitor",
"utils/approval-presets",
"utils/oss",
"utils/fuzzy-match",
@@ -131,6 +132,7 @@ codex-utils-readiness = { path = "utils/readiness" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sanitizer = { path = "utils/sanitizer" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
codex-utils-string = { path = "utils/string" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
core_test_support = { path = "core/tests/common" }
@@ -340,7 +342,6 @@ ignored = [
"icu_provider",
"openssl-sys",
"codex-utils-readiness",
"codex-utils-sanitizer",
"codex-secrets",
]

View File

@@ -20,6 +20,7 @@ codex-utils-absolute-path = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
shlex = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }

View File

@@ -88,7 +88,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -1128,6 +1128,13 @@
"null"
]
},
"includeHidden": {
"description": "When true, include models that are hidden from the default picker list.",
"type": [
"boolean",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",
@@ -2565,6 +2572,13 @@
"null"
]
},
"cwd": {
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View File

@@ -104,7 +104,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -1429,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -1805,6 +1825,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -1832,6 +1860,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@@ -2873,6 +2902,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@@ -3289,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -3400,6 +3461,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {
@@ -6185,6 +6254,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -6213,6 +6290,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -6265,6 +6343,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -6641,6 +6730,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -6668,6 +6765,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",

View File

@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"title": "FuzzyFileSearchSessionCompletedNotification",
"type": "object"
}

View File

@@ -0,0 +1,63 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"FuzzyFileSearchResult": {
"description": "Superset of [`codex_file_search::FileMatch`]",
"properties": {
"file_name": {
"type": "string"
},
"indices": {
"items": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"type": [
"array",
"null"
]
},
"path": {
"type": "string"
},
"root": {
"type": "string"
},
"score": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"file_name",
"path",
"root",
"score"
],
"type": "object"
}
},
"properties": {
"files": {
"items": {
"$ref": "#/definitions/FuzzyFileSearchResult"
},
"type": "array"
},
"query": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"files",
"query",
"sessionId"
],
"title": "FuzzyFileSearchSessionUpdatedNotification",
"type": "object"
}

View File

@@ -193,6 +193,11 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"default": true,
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",
@@ -241,7 +246,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -1965,6 +1970,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -1993,6 +2006,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -2045,6 +2059,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -2421,6 +2446,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus2"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -2448,6 +2481,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@@ -3489,6 +3523,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@@ -3684,6 +3726,76 @@
],
"type": "object"
},
"FuzzyFileSearchResult": {
"description": "Superset of [`codex_file_search::FileMatch`]",
"properties": {
"file_name": {
"type": "string"
},
"indices": {
"items": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
},
"type": [
"array",
"null"
]
},
"path": {
"type": "string"
},
"root": {
"type": "string"
},
"score": {
"format": "uint32",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"file_name",
"path",
"root",
"score"
],
"type": "object"
},
"FuzzyFileSearchSessionCompletedNotification": {
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"type": "object"
},
"FuzzyFileSearchSessionUpdatedNotification": {
"properties": {
"files": {
"items": {
"$ref": "#/definitions/FuzzyFileSearchResult"
},
"type": "array"
},
"query": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"files",
"query",
"sessionId"
],
"type": "object"
},
"GhostCommit": {
"description": "Details of a ghost commit created from a repository state.",
"properties": {
@@ -4106,6 +4218,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -4226,6 +4362,14 @@
],
"type": "string"
},
"PatchApplyStatus2": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PatchChangeKind": {
"oneOf": [
{
@@ -5899,7 +6043,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},
@@ -8171,6 +8316,46 @@
"title": "ConfigWarningNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fuzzyFileSearch/sessionUpdated"
],
"title": "FuzzyFileSearch/sessionUpdatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "FuzzyFileSearch/sessionUpdatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fuzzyFileSearch/sessionCompleted"
],
"title": "FuzzyFileSearch/sessionCompletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "FuzzyFileSearch/sessionCompletedNotification",
"type": "object"
},
{
"description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.",
"properties": {

View File

@@ -208,7 +208,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -3362,6 +3362,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -3390,6 +3398,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -3442,6 +3451,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -3818,6 +3838,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/v2/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -3845,6 +3873,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@@ -4942,6 +4971,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOneOffCommandParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -5386,6 +5423,43 @@
],
"type": "object"
},
"FuzzyFileSearchSessionCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"sessionId": {
"type": "string"
}
},
"required": [
"sessionId"
],
"title": "FuzzyFileSearchSessionCompletedNotification",
"type": "object"
},
"FuzzyFileSearchSessionUpdatedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"files": {
"items": {
"$ref": "#/definitions/FuzzyFileSearchResult"
},
"type": "array"
},
"query": {
"type": "string"
},
"sessionId": {
"type": "string"
}
},
"required": [
"files",
"query",
"sessionId"
],
"title": "FuzzyFileSearchSessionUpdatedNotification",
"type": "object"
},
"GetAuthStatusParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -6200,6 +6274,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"NewConversationParams": {
"properties": {
"approvalPolicy": {
@@ -6422,6 +6520,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {
@@ -8425,6 +8531,46 @@
"title": "ConfigWarningNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fuzzyFileSearch/sessionUpdated"
],
"title": "FuzzyFileSearch/sessionUpdatedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification"
}
},
"required": [
"method",
"params"
],
"title": "FuzzyFileSearch/sessionUpdatedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"fuzzyFileSearch/sessionCompleted"
],
"title": "FuzzyFileSearch/sessionCompletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "FuzzyFileSearch/sessionCompletedNotification",
"type": "object"
},
{
"description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.",
"properties": {
@@ -9048,7 +9194,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},
@@ -10123,6 +10270,11 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"default": true,
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",
@@ -12494,6 +12646,9 @@
"displayName": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"id": {
"type": "string"
},
@@ -12534,6 +12689,7 @@
"defaultReasoningEffort",
"description",
"displayName",
"hidden",
"id",
"isDefault",
"model",
@@ -12551,6 +12707,13 @@
"null"
]
},
"includeHidden": {
"description": "When true, include models that are hidden from the default picker list.",
"type": [
"boolean",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",
@@ -14397,7 +14560,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},
@@ -15251,6 +15415,13 @@
"null"
]
},
"cwd": {
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View File

@@ -12,7 +12,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],

View File

@@ -104,7 +104,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -1429,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -1805,6 +1825,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -1832,6 +1860,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@@ -2873,6 +2902,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@@ -3289,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -3400,6 +3461,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View File

@@ -113,7 +113,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -16,7 +16,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],

View File

@@ -113,7 +113,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -12,7 +12,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],

View File

@@ -12,7 +12,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],

View File

@@ -104,7 +104,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -1429,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -1805,6 +1825,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -1832,6 +1860,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@@ -2873,6 +2902,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@@ -3289,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -3400,6 +3461,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View File

@@ -16,7 +16,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],

View File

@@ -104,7 +104,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@@ -1429,6 +1438,17 @@
"description": "The command's working directory.",
"type": "string"
},
"network_approval_context": {
"anyOf": [
{
"$ref": "#/definitions/NetworkApprovalContext"
},
{
"type": "null"
}
],
"description": "Optional network context for a blocked request that can be approved."
},
"parsed_cmd": {
"items": {
"$ref": "#/definitions/ParsedCommand"
@@ -1805,6 +1825,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@@ -1832,6 +1860,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@@ -2873,6 +2902,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@@ -3289,6 +3326,30 @@
],
"type": "string"
},
"NetworkApprovalContext": {
"properties": {
"host": {
"type": "string"
},
"protocol": {
"$ref": "#/definitions/NetworkApprovalProtocol"
}
},
"required": [
"host",
"protocol"
],
"type": "object"
},
"NetworkApprovalProtocol": {
"enum": [
"http",
"https",
"socks5_tcp",
"socks5_udp"
],
"type": "string"
},
"ParsedCommand": {
"oneOf": [
{
@@ -3400,6 +3461,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View File

@@ -29,6 +29,11 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"default": true,
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",

View File

@@ -29,6 +29,11 @@
"default": false,
"type": "boolean"
},
"isEnabled": {
"default": true,
"description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```",
"type": "boolean"
},
"logoUrl": {
"type": [
"string",

View File

@@ -8,6 +8,13 @@
"null"
]
},
"includeHidden": {
"description": "When true, include models that are hidden from the default picker list.",
"type": [
"boolean",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View File

@@ -31,6 +31,9 @@
"displayName": {
"type": "string"
},
"hidden": {
"type": "boolean"
},
"id": {
"type": "string"
},
@@ -71,6 +74,7 @@
"defaultReasoningEffort",
"description",
"displayName",
"hidden",
"id",
"isDefault",
"model",

View File

@@ -666,7 +666,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -39,6 +39,13 @@
"null"
]
},
"cwd": {
"description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",

View File

@@ -472,7 +472,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -472,7 +472,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -666,7 +666,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -472,7 +472,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -666,7 +666,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -472,7 +472,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -472,7 +472,8 @@
{
"enum": [
"review",
"compact"
"compact",
"memory_consolidation"
],
"type": "string"
},

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
import type { NetworkApprovalContext } from "./NetworkApprovalContext";
import type { ParsedCommand } from "./ParsedCommand";
export type ExecApprovalRequestEvent = {
@@ -26,6 +27,10 @@ cwd: string,
* Optional human-readable reason for the approval (e.g. retry without sandbox).
*/
reason: string | null,
/**
* Optional network context for a blocked request that can be approved.
*/
network_approval_context?: NetworkApprovalContext,
/**
* Proposed execpolicy amendment that can be applied to allow future runs.
*/

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExecCommandSource } from "./ExecCommandSource";
import type { ExecCommandStatus } from "./ExecCommandStatus";
import type { ParsedCommand } from "./ParsedCommand";
export type ExecCommandEndEvent = {
@@ -56,4 +57,8 @@ duration: string,
/**
* Formatted output from the command, as seen by the model.
*/
formatted_output: string, };
formatted_output: string,
/**
* Completion status for this command execution.
*/
status: ExecCommandStatus, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExecCommandStatus = "completed" | "failed" | "declined";

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type FuzzyFileSearchSessionCompletedNotification = { sessionId: string, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult";
export type FuzzyFileSearchSessionUpdatedNotification = { sessionId: string, query: string, files: Array<FuzzyFileSearchResult>, };

View File

@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type NetworkApprovalContext = { host: string, protocol: NetworkApprovalProtocol, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NetworkApprovalProtocol = "http" | "https" | "socks5_tcp" | "socks5_udp";

View File

@@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileChange } from "./FileChange";
import type { PatchApplyStatus } from "./PatchApplyStatus";
export type PatchApplyEndEvent = {
/**
@@ -28,4 +29,8 @@ success: boolean,
/**
* The changes that were applied (mirrors PatchApplyBeginEvent::changes).
*/
changes: { [key in string]?: FileChange }, };
changes: { [key in string]?: FileChange },
/**
* Completion status for this patch application.
*/
status: PatchApplyStatus, };

View File

@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PatchApplyStatus = "completed" | "failed" | "declined";

View File

@@ -2,6 +2,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification";
import type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification";
import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification";
import type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification";
import type { SessionConfiguredNotification } from "./SessionConfiguredNotification";
import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification";
@@ -37,4 +39,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW
/**
* Notification sent from the server to the client.
*/
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification };
export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification };

View File

@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ThreadId } from "./ThreadId";
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | { "other": string };
export type SubAgentSource = "review" | "compact" | { "thread_spawn": { parent_thread_id: ThreadId, depth: number, } } | "memory_consolidation" | { "other": string };

View File

@@ -62,6 +62,7 @@ export type { ExecCommandBeginEvent } from "./ExecCommandBeginEvent";
export type { ExecCommandEndEvent } from "./ExecCommandEndEvent";
export type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent";
export type { ExecCommandSource } from "./ExecCommandSource";
export type { ExecCommandStatus } from "./ExecCommandStatus";
export type { ExecOneOffCommandParams } from "./ExecOneOffCommandParams";
export type { ExecOneOffCommandResponse } from "./ExecOneOffCommandResponse";
export type { ExecOutputStream } from "./ExecOutputStream";
@@ -77,6 +78,8 @@ export type { FunctionCallOutputPayload } from "./FunctionCallOutputPayload";
export type { FuzzyFileSearchParams } from "./FuzzyFileSearchParams";
export type { FuzzyFileSearchResponse } from "./FuzzyFileSearchResponse";
export type { FuzzyFileSearchResult } from "./FuzzyFileSearchResult";
export type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification";
export type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification";
export type { GetAuthStatusParams } from "./GetAuthStatusParams";
export type { GetAuthStatusResponse } from "./GetAuthStatusResponse";
export type { GetConversationSummaryParams } from "./GetConversationSummaryParams";
@@ -123,11 +126,14 @@ export type { McpToolCallEndEvent } from "./McpToolCallEndEvent";
export type { MessagePhase } from "./MessagePhase";
export type { ModeKind } from "./ModeKind";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
export type { NewConversationParams } from "./NewConversationParams";
export type { NewConversationResponse } from "./NewConversationResponse";
export type { ParsedCommand } from "./ParsedCommand";
export type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent";
export type { PatchApplyEndEvent } from "./PatchApplyEndEvent";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { Personality } from "./Personality";
export type { PlanDeltaEvent } from "./PlanDeltaEvent";
export type { PlanItem } from "./PlanItem";

View File

@@ -5,4 +5,13 @@
/**
* EXPERIMENTAL - app metadata returned by app-list APIs.
*/
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean, };
export type AppInfo = { id: string, name: string, description: string | null, logoUrl: string | null, logoUrlDark: string | null, distributionChannel: string | null, installUrl: string | null, isAccessible: boolean,
/**
* Whether this app is enabled in config.toml.
* Example:
* ```toml
* [apps.bad_app]
* enabled = false
* ```
*/
isEnabled: boolean, };

View File

@@ -5,4 +5,4 @@ import type { InputModality } from "../InputModality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, isDefault: boolean, };
export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, isDefault: boolean, };

View File

@@ -10,4 +10,8 @@ cursor?: string | null,
/**
* Optional page size; defaults to a reasonable server-side value.
*/
limit?: number | null, };
limit?: number | null,
/**
* When true, include models that are hidden from the default picker list.
*/
includeHidden?: boolean | null, };

View File

@@ -21,4 +21,8 @@ export type ThreadForkParams = {threadId: string, /**
path?: string | null, /**
* Configuration overrides for the forked thread, if any.
*/
model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null};
model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
persistExtendedHistory: boolean};

View File

@@ -31,4 +31,9 @@ sourceKinds?: Array<ThreadSourceKind> | null,
* Optional archived filter; when set to true, only archived threads are returned.
* If false or null, only non-archived threads are returned.
*/
archived?: boolean | null, };
archived?: boolean | null,
/**
* Optional cwd filter; when set, only threads whose session cwd exactly
* matches this path are returned.
*/
cwd?: string | null, };

View File

@@ -30,4 +30,8 @@ history?: Array<ResponseItem> | null, /**
path?: string | null, /**
* Configuration overrides for the resumed thread, if any.
*/
model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null};
model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
persistExtendedHistory: boolean};

View File

@@ -10,4 +10,8 @@ export type ThreadStartParams = {model?: string | null, modelProvider?: string |
* If true, opt into emitting raw Responses API items on the event stream.
* This is for internal use only (e.g. Codex Cloud).
*/
experimentalRawEvents: boolean};
experimentalRawEvents: boolean, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on resume/fork/read.
*/
persistExtendedHistory: boolean};

View File

@@ -458,6 +458,21 @@ client_request_definitions! {
params: FuzzyFileSearchParams,
response: FuzzyFileSearchResponse,
},
#[experimental("fuzzyFileSearch/sessionStart")]
FuzzyFileSearchSessionStart => "fuzzyFileSearch/sessionStart" {
params: FuzzyFileSearchSessionStartParams,
response: FuzzyFileSearchSessionStartResponse,
},
#[experimental("fuzzyFileSearch/sessionUpdate")]
FuzzyFileSearchSessionUpdate => "fuzzyFileSearch/sessionUpdate" {
params: FuzzyFileSearchSessionUpdateParams,
response: FuzzyFileSearchSessionUpdateResponse,
},
#[experimental("fuzzyFileSearch/sessionStop")]
FuzzyFileSearchSessionStop => "fuzzyFileSearch/sessionStop" {
params: FuzzyFileSearchSessionStopParams,
response: FuzzyFileSearchSessionStopResponse,
},
/// Execute a command (argv vector) under the server's sandbox.
ExecOneOffCommand {
params: v1::ExecOneOffCommandParams,
@@ -702,6 +717,54 @@ pub struct FuzzyFileSearchResponse {
pub files: Vec<FuzzyFileSearchResult>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct FuzzyFileSearchSessionStartParams {
pub session_id: String,
pub roots: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
pub struct FuzzyFileSearchSessionStartResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct FuzzyFileSearchSessionUpdateParams {
pub session_id: String,
pub query: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
pub struct FuzzyFileSearchSessionUpdateResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct FuzzyFileSearchSessionStopParams {
pub session_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
pub struct FuzzyFileSearchSessionStopResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct FuzzyFileSearchSessionUpdatedNotification {
pub session_id: String,
pub query: String,
pub files: Vec<FuzzyFileSearchResult>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct FuzzyFileSearchSessionCompletedNotification {
pub session_id: String,
}
server_notification_definitions! {
/// NEW NOTIFICATIONS
Error => "error" (v2::ErrorNotification),
@@ -734,6 +797,8 @@ server_notification_definitions! {
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),
FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification),
FuzzyFileSearchSessionCompleted => "fuzzyFileSearch/sessionCompleted" (FuzzyFileSearchSessionCompletedNotification),
/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),
@@ -1170,7 +1235,8 @@ mod tests {
"id": 6,
"params": {
"limit": null,
"cursor": null
"cursor": null,
"includeHidden": null
}
}),
serde_json::to_value(&request)?,
@@ -1266,17 +1332,4 @@ mod tests {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("mock/experimentalMethod"));
}
#[test]
fn thread_start_mock_field_is_marked_experimental() {
let request = ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: v2::ThreadStartParams {
mock_experimental_field: Some("mock".to_string()),
..Default::default()
},
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("thread/start.mockExperimentalField"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,9 @@ use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess;
@@ -1108,6 +1110,9 @@ pub struct ModelListParams {
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// When true, include models that are hidden from the default picker list.
#[ts(optional = nullable)]
pub include_hidden: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1119,6 +1124,7 @@ pub struct Model {
pub upgrade: Option<String>,
pub display_name: String,
pub description: String,
pub hidden: bool,
pub supported_reasoning_efforts: Vec<ReasoningEffortOption>,
pub default_reasoning_effort: ReasoningEffort,
#[serde(default = "default_input_modalities")]
@@ -1288,6 +1294,14 @@ pub struct AppInfo {
pub install_url: Option<String>,
#[serde(default)]
pub is_accessible: bool,
/// Whether this app is enabled in config.toml.
/// Example:
/// ```toml
/// [apps.bad_app]
/// enabled = false
/// ```
#[serde(default = "default_enabled")]
pub is_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1422,6 +1436,11 @@ pub struct ThreadStartParams {
#[experimental("thread/start.experimentalRawEvents")]
#[serde(default)]
pub experimental_raw_events: bool,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on resume/fork/read.
#[experimental("thread/start.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)]
@@ -1503,6 +1522,11 @@ pub struct ThreadResumeParams {
pub developer_instructions: Option<String>,
#[ts(optional = nullable)]
pub personality: Option<Personality>,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on subsequent resume/fork/read.
#[experimental("thread/resume.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1556,6 +1580,11 @@ pub struct ThreadForkParams {
pub base_instructions: Option<String>,
#[ts(optional = nullable)]
pub developer_instructions: Option<String>,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on subsequent resume/fork/read.
#[experimental("thread/fork.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@@ -1683,6 +1712,10 @@ pub struct ThreadListParams {
/// If false or null, only non-archived threads are returned.
#[ts(optional = nullable)]
pub archived: Option<bool>,
/// Optional cwd filter; when set, only threads whose session cwd exactly
/// matches this path are returned.
#[ts(optional = nullable)]
pub cwd: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
@@ -2622,6 +2655,22 @@ pub enum CommandExecutionStatus {
Declined,
}
impl From<CoreExecCommandStatus> for CommandExecutionStatus {
fn from(value: CoreExecCommandStatus) -> Self {
Self::from(&value)
}
}
impl From<&CoreExecCommandStatus> for CommandExecutionStatus {
fn from(value: &CoreExecCommandStatus) -> Self {
match value {
CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed,
CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed,
CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2662,6 +2711,22 @@ pub enum PatchApplyStatus {
Declined,
}
impl From<CorePatchApplyStatus> for PatchApplyStatus {
fn from(value: CorePatchApplyStatus) -> Self {
Self::from(&value)
}
}
impl From<&CorePatchApplyStatus> for PatchApplyStatus {
fn from(value: &CorePatchApplyStatus) -> Self {
match value {
CorePatchApplyStatus::Completed => PatchApplyStatus::Completed,
CorePatchApplyStatus::Failed => PatchApplyStatus::Failed,
CorePatchApplyStatus::Declined => PatchApplyStatus::Declined,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View File

@@ -14,4 +14,6 @@ codex-app-server-protocol = { workspace = true }
codex-protocol = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tungstenite = { workspace = true }
url = { workspace = true }
uuid = { workspace = true, features = ["v4"] }

View File

@@ -1,2 +1,49 @@
# App Server Test Client
Exercises simple `codex app-server` flows end-to-end, logging JSON-RPC messages sent between client and server to stdout.
Quickstart for running and hitting `codex app-server`.
## Quickstart
Run from `<reporoot>/codex-rs`.
```bash
# 1) Build debug codex binary
cargo build -p codex-cli --bin codex
# 2) Start websocket app-server in background
cargo run -p codex-app-server-test-client -- \
--codex-bin ./target/debug/codex \
serve --listen ws://127.0.0.1:4222 --kill
# 3) Call app-server (defaults to ws://127.0.0.1:4222)
cargo run -p codex-app-server-test-client -- model-list
```
## Testing Thread Rejoin Behavior
Build and start an app server using commands above. The app-server log is written to `/tmp/codex-app-server-test-client/app-server.log`
### 1) Get a thread id
Create at least one thread, then list threads:
```bash
cargo run -p codex-app-server-test-client -- send-message-v2 "seed thread for rejoin test"
cargo run -p codex-app-server-test-client -- thread-list --limit 5
```
Copy a thread id from the `thread-list` output.
### 2) Rejoin while a turn is in progress (two terminals)
Terminal A:
```bash
cargo run --bin codex-app-server-test-client -- \
resume-message-v2 <THREAD_ID> "respond with thorough docs on the rust core"
```
Terminal B (while Terminal A is still streaming):
```bash
cargo run --bin codex-app-server-test-client -- thread-resume <THREAD_ID>
```

View File

@@ -1,8 +1,10 @@
use std::collections::VecDeque;
use std::fs;
use std::fs::OpenOptions;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::process::Child;
@@ -53,6 +55,8 @@ use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -67,15 +71,28 @@ use codex_protocol::protocol::EventMsg;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use tungstenite::Message;
use tungstenite::WebSocket;
use tungstenite::connect;
use tungstenite::stream::MaybeTlsStream;
use url::Url;
use uuid::Uuid;
/// Minimal launcher that initializes the Codex app-server and logs the handshake.
#[derive(Parser)]
#[command(author = "Codex", version, about = "Bootstrap Codex app-server", long_about = None)]
struct Cli {
/// Path to the `codex` CLI binary.
#[arg(long, env = "CODEX_BIN", default_value = "codex")]
codex_bin: PathBuf,
/// Path to the `codex` CLI binary. When set, requests use stdio by
/// spawning `codex app-server` as a child process.
#[arg(long, env = "CODEX_BIN", global = true)]
codex_bin: Option<PathBuf>,
/// Existing websocket server URL to connect to.
///
/// If neither `--codex-bin` nor `--url` is provided, defaults to
/// `ws://127.0.0.1:4222`.
#[arg(long, env = "CODEX_APP_SERVER_URL", global = true)]
url: Option<String>,
/// Forwarded to the `codex` CLI as `--config key=value`. Repeatable.
///
@@ -105,6 +122,18 @@ struct Cli {
#[derive(Subcommand)]
enum CliCommand {
/// Start `codex app-server` on a websocket endpoint in the background.
///
/// Logs are written to:
/// `/tmp/codex-app-server-test-client/`
Serve {
/// WebSocket listen URL passed to `codex app-server --listen`.
#[arg(long, default_value = "ws://127.0.0.1:4222")]
listen: String,
/// Kill any process listening on the same port before starting.
#[arg(long, default_value_t = false)]
kill: bool,
},
/// Send a user message through the Codex app-server.
SendMessage {
/// User message to send to Codex.
@@ -122,6 +151,13 @@ enum CliCommand {
/// User message to send to Codex.
user_message: String,
},
/// Resume a V2 thread and continuously stream notifications/events.
///
/// This command does not auto-exit; stop it with SIGINT/SIGTERM/SIGKILL.
ThreadResume {
/// Existing thread id to resume.
thread_id: String,
},
/// Start a V2 turn that elicits an ExecCommand approval.
#[command(name = "trigger-cmd-approval")]
TriggerCmdApproval {
@@ -151,11 +187,19 @@ enum CliCommand {
/// List the available models from the Codex app-server.
#[command(name = "model-list")]
ModelList,
/// List stored threads from the Codex app-server.
#[command(name = "thread-list")]
ThreadList {
/// Number of threads to return.
#[arg(long, default_value_t = 20)]
limit: u32,
},
}
pub fn run() -> Result<()> {
let Cli {
codex_bin,
url,
config_overrides,
dynamic_tools,
command,
@@ -164,59 +208,222 @@ pub fn run() -> Result<()> {
let dynamic_tools = parse_dynamic_tools_arg(&dynamic_tools)?;
match command {
CliCommand::Serve { listen, kill } => {
ensure_dynamic_tools_unused(&dynamic_tools, "serve")?;
let codex_bin = codex_bin.unwrap_or_else(|| PathBuf::from("codex"));
serve(&codex_bin, &config_overrides, &listen, kill)
}
CliCommand::SendMessage { user_message } => {
ensure_dynamic_tools_unused(&dynamic_tools, "send-message")?;
send_message(&codex_bin, &config_overrides, user_message)
let endpoint = resolve_endpoint(codex_bin, url)?;
send_message(&endpoint, &config_overrides, user_message)
}
CliCommand::SendMessageV2 { user_message } => {
send_message_v2(&codex_bin, &config_overrides, user_message, &dynamic_tools)
let endpoint = resolve_endpoint(codex_bin, url)?;
send_message_v2_endpoint(&endpoint, &config_overrides, user_message, &dynamic_tools)
}
CliCommand::ResumeMessageV2 {
thread_id,
user_message,
} => resume_message_v2(
&codex_bin,
&config_overrides,
thread_id,
user_message,
&dynamic_tools,
),
} => {
let endpoint = resolve_endpoint(codex_bin, url)?;
resume_message_v2(
&endpoint,
&config_overrides,
thread_id,
user_message,
&dynamic_tools,
)
}
CliCommand::ThreadResume { thread_id } => {
ensure_dynamic_tools_unused(&dynamic_tools, "thread-resume")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
thread_resume_follow(&endpoint, &config_overrides, thread_id)
}
CliCommand::TriggerCmdApproval { user_message } => {
trigger_cmd_approval(&codex_bin, &config_overrides, user_message, &dynamic_tools)
let endpoint = resolve_endpoint(codex_bin, url)?;
trigger_cmd_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
}
CliCommand::TriggerPatchApproval { user_message } => {
trigger_patch_approval(&codex_bin, &config_overrides, user_message, &dynamic_tools)
let endpoint = resolve_endpoint(codex_bin, url)?;
trigger_patch_approval(&endpoint, &config_overrides, user_message, &dynamic_tools)
}
CliCommand::NoTriggerCmdApproval => {
no_trigger_cmd_approval(&codex_bin, &config_overrides, &dynamic_tools)
let endpoint = resolve_endpoint(codex_bin, url)?;
no_trigger_cmd_approval(&endpoint, &config_overrides, &dynamic_tools)
}
CliCommand::SendFollowUpV2 {
first_message,
follow_up_message,
} => send_follow_up_v2(
&codex_bin,
&config_overrides,
first_message,
follow_up_message,
&dynamic_tools,
),
} => {
let endpoint = resolve_endpoint(codex_bin, url)?;
send_follow_up_v2(
&endpoint,
&config_overrides,
first_message,
follow_up_message,
&dynamic_tools,
)
}
CliCommand::TestLogin => {
ensure_dynamic_tools_unused(&dynamic_tools, "test-login")?;
test_login(&codex_bin, &config_overrides)
let endpoint = resolve_endpoint(codex_bin, url)?;
test_login(&endpoint, &config_overrides)
}
CliCommand::GetAccountRateLimits => {
ensure_dynamic_tools_unused(&dynamic_tools, "get-account-rate-limits")?;
get_account_rate_limits(&codex_bin, &config_overrides)
let endpoint = resolve_endpoint(codex_bin, url)?;
get_account_rate_limits(&endpoint, &config_overrides)
}
CliCommand::ModelList => {
ensure_dynamic_tools_unused(&dynamic_tools, "model-list")?;
model_list(&codex_bin, &config_overrides)
let endpoint = resolve_endpoint(codex_bin, url)?;
model_list(&endpoint, &config_overrides)
}
CliCommand::ThreadList { limit } => {
ensure_dynamic_tools_unused(&dynamic_tools, "thread-list")?;
let endpoint = resolve_endpoint(codex_bin, url)?;
thread_list(&endpoint, &config_overrides, limit)
}
}
}
fn send_message(codex_bin: &Path, config_overrides: &[String], user_message: String) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
enum Endpoint {
SpawnCodex(PathBuf),
ConnectWs(String),
}
fn resolve_endpoint(codex_bin: Option<PathBuf>, url: Option<String>) -> Result<Endpoint> {
if codex_bin.is_some() && url.is_some() {
bail!("--codex-bin and --url are mutually exclusive");
}
if let Some(codex_bin) = codex_bin {
return Ok(Endpoint::SpawnCodex(codex_bin));
}
if let Some(url) = url {
return Ok(Endpoint::ConnectWs(url));
}
Ok(Endpoint::ConnectWs("ws://127.0.0.1:4222".to_string()))
}
fn serve(codex_bin: &Path, config_overrides: &[String], listen: &str, kill: bool) -> Result<()> {
let runtime_dir = PathBuf::from("/tmp/codex-app-server-test-client");
fs::create_dir_all(&runtime_dir)
.with_context(|| format!("failed to create runtime dir {}", runtime_dir.display()))?;
let log_path = runtime_dir.join("app-server.log");
if kill {
kill_listeners_on_same_port(listen)?;
}
let log_file = OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.with_context(|| format!("failed to open log file {}", log_path.display()))?;
let log_file_stderr = log_file
.try_clone()
.with_context(|| format!("failed to clone log file handle {}", log_path.display()))?;
let mut cmdline = format!(
"tail -f /dev/null | RUST_BACKTRACE=full RUST_LOG=warn,codex_=trace {}",
shell_quote(&codex_bin.display().to_string())
);
for override_kv in config_overrides {
cmdline.push_str(&format!(" --config {}", shell_quote(override_kv)));
}
cmdline.push_str(&format!(" app-server --listen {}", shell_quote(listen)));
let child = Command::new("nohup")
.arg("sh")
.arg("-c")
.arg(cmdline)
.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(log_file_stderr))
.spawn()
.with_context(|| format!("failed to start `{}` app-server", codex_bin.display()))?;
let pid = child.id();
println!("started codex app-server");
println!("listen: {listen}");
println!("pid: {pid} (launcher process)");
println!("log: {}", log_path.display());
Ok(())
}
fn kill_listeners_on_same_port(listen: &str) -> Result<()> {
let url = Url::parse(listen).with_context(|| format!("invalid --listen URL `{listen}`"))?;
let port = url
.port_or_known_default()
.with_context(|| format!("unable to infer port from --listen URL `{listen}`"))?;
let output = Command::new("lsof")
.arg("-nP")
.arg(format!("-tiTCP:{port}"))
.arg("-sTCP:LISTEN")
.output()
.with_context(|| format!("failed to run lsof for port {port}"))?;
if !output.status.success() {
return Ok(());
}
let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect();
if pids.is_empty() {
return Ok(());
}
for pid in pids {
println!("killing listener pid {pid} on port {port}");
let pid_str = pid.to_string();
let term_status = Command::new("kill")
.arg(&pid_str)
.status()
.with_context(|| format!("failed to send SIGTERM to pid {pid}"))?;
if !term_status.success() {
continue;
}
}
thread::sleep(Duration::from_millis(300));
let output = Command::new("lsof")
.arg("-nP")
.arg(format!("-tiTCP:{port}"))
.arg("-sTCP:LISTEN")
.output()
.with_context(|| format!("failed to re-check listeners on port {port}"))?;
if !output.status.success() {
return Ok(());
}
let remaining: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse::<u32>().ok())
.collect();
for pid in remaining {
println!("force killing remaining listener pid {pid} on port {port}");
let _ = Command::new("kill").arg("-9").arg(pid.to_string()).status();
}
Ok(())
}
fn shell_quote(input: &str) -> String {
format!("'{}'", input.replace('\'', "'\\''"))
}
fn send_message(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -242,9 +449,19 @@ pub fn send_message_v2(
config_overrides: &[String],
user_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let endpoint = Endpoint::SpawnCodex(codex_bin.to_path_buf());
send_message_v2_endpoint(&endpoint, config_overrides, user_message, dynamic_tools)
}
fn send_message_v2_endpoint(
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
send_message_v2_with_policies(
codex_bin,
endpoint,
config_overrides,
user_message,
None,
@@ -254,7 +471,7 @@ pub fn send_message_v2(
}
fn resume_message_v2(
codex_bin: &Path,
endpoint: &Endpoint,
config_overrides: &[String],
thread_id: String,
user_message: String,
@@ -262,7 +479,7 @@ fn resume_message_v2(
) -> Result<()> {
ensure_dynamic_tools_unused(dynamic_tools, "resume-message-v2")?;
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -288,8 +505,28 @@ fn resume_message_v2(
Ok(())
}
fn thread_resume_follow(
endpoint: &Endpoint,
config_overrides: &[String],
thread_id: String,
) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let resume_response = client.thread_resume(ThreadResumeParams {
thread_id,
..Default::default()
})?;
println!("< thread/resume response: {resume_response:?}");
println!("< streaming notifications until process is terminated");
client.stream_notifications_forever()
}
fn trigger_cmd_approval(
codex_bin: &Path,
endpoint: &Endpoint,
config_overrides: &[String],
user_message: Option<String>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
@@ -298,7 +535,7 @@ fn trigger_cmd_approval(
"Run `touch /tmp/should-trigger-approval` so I can confirm the file exists.";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
send_message_v2_with_policies(
codex_bin,
endpoint,
config_overrides,
message,
Some(AskForApproval::OnRequest),
@@ -310,7 +547,7 @@ fn trigger_cmd_approval(
}
fn trigger_patch_approval(
codex_bin: &Path,
endpoint: &Endpoint,
config_overrides: &[String],
user_message: Option<String>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
@@ -319,7 +556,7 @@ fn trigger_patch_approval(
"Create a file named APPROVAL_DEMO.txt containing a short hello message using apply_patch.";
let message = user_message.unwrap_or_else(|| default_prompt.to_string());
send_message_v2_with_policies(
codex_bin,
endpoint,
config_overrides,
message,
Some(AskForApproval::OnRequest),
@@ -331,13 +568,13 @@ fn trigger_patch_approval(
}
fn no_trigger_cmd_approval(
codex_bin: &Path,
endpoint: &Endpoint,
config_overrides: &[String],
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let prompt = "Run `touch should_not_trigger_approval.txt`";
send_message_v2_with_policies(
codex_bin,
endpoint,
config_overrides,
prompt.to_string(),
None,
@@ -347,14 +584,14 @@ fn no_trigger_cmd_approval(
}
fn send_message_v2_with_policies(
codex_bin: &Path,
endpoint: &Endpoint,
config_overrides: &[String],
user_message: String,
approval_policy: Option<AskForApproval>,
sandbox_policy: Option<SandboxPolicy>,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -385,13 +622,13 @@ fn send_message_v2_with_policies(
}
fn send_follow_up_v2(
codex_bin: &Path,
endpoint: &Endpoint,
config_overrides: &[String],
first_message: String,
follow_up_message: String,
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -431,8 +668,8 @@ fn send_follow_up_v2(
Ok(())
}
fn test_login(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -461,8 +698,8 @@ fn test_login(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
}
}
fn get_account_rate_limits(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
fn get_account_rate_limits(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -473,8 +710,8 @@ fn get_account_rate_limits(codex_bin: &Path, config_overrides: &[String]) -> Res
Ok(())
}
fn model_list(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::spawn(codex_bin, config_overrides)?;
fn model_list(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
@@ -485,6 +722,26 @@ fn model_list(codex_bin: &Path, config_overrides: &[String]) -> Result<()> {
Ok(())
}
fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) -> Result<()> {
let mut client = CodexClient::connect(endpoint, config_overrides)?;
let initialize = client.initialize()?;
println!("< initialize response: {initialize:?}");
let response = client.thread_list(ThreadListParams {
cursor: None,
limit: Some(limit),
sort_key: None,
model_providers: None,
source_kinds: None,
archived: None,
cwd: None,
})?;
println!("< thread/list response: {response:?}");
Ok(())
}
fn ensure_dynamic_tools_unused(
dynamic_tools: &Option<Vec<DynamicToolSpec>>,
command: &str,
@@ -519,15 +776,32 @@ fn parse_dynamic_tools_arg(dynamic_tools: &Option<String>) -> Result<Option<Vec<
Ok(Some(tools))
}
enum ClientTransport {
Stdio {
child: Child,
stdin: Option<ChildStdin>,
stdout: BufReader<ChildStdout>,
},
WebSocket {
url: String,
socket: Box<WebSocket<MaybeTlsStream<TcpStream>>>,
},
}
struct CodexClient {
child: Child,
stdin: Option<ChildStdin>,
stdout: BufReader<ChildStdout>,
transport: ClientTransport,
pending_notifications: VecDeque<JSONRPCNotification>,
}
impl CodexClient {
fn spawn(codex_bin: &Path, config_overrides: &[String]) -> Result<Self> {
fn connect(endpoint: &Endpoint, config_overrides: &[String]) -> Result<Self> {
match endpoint {
Endpoint::SpawnCodex(codex_bin) => Self::spawn_stdio(codex_bin, config_overrides),
Endpoint::ConnectWs(url) => Self::connect_websocket(url),
}
}
fn spawn_stdio(codex_bin: &Path, config_overrides: &[String]) -> Result<Self> {
let codex_bin_display = codex_bin.display();
let mut cmd = Command::new(codex_bin);
for override_kv in config_overrides {
@@ -551,9 +825,27 @@ impl CodexClient {
.context("codex app-server stdout unavailable")?;
Ok(Self {
child: codex_app_server,
stdin: Some(stdin),
stdout: BufReader::new(stdout),
transport: ClientTransport::Stdio {
child: codex_app_server,
stdin: Some(stdin),
stdout: BufReader::new(stdout),
},
pending_notifications: VecDeque::new(),
})
}
fn connect_websocket(url: &str) -> Result<Self> {
let parsed = Url::parse(url).with_context(|| format!("invalid websocket URL `{url}`"))?;
let (socket, _response) = connect(parsed.as_str()).with_context(|| {
format!(
"failed to connect to websocket app-server at `{url}`; if no server is running, start one with `codex-app-server-test-client serve --listen {url}`"
)
})?;
Ok(Self {
transport: ClientTransport::WebSocket {
url: url.to_string(),
socket: Box::new(socket),
},
pending_notifications: VecDeque::new(),
})
}
@@ -575,7 +867,16 @@ impl CodexClient {
},
};
self.send_request(request, request_id, "initialize")
let response: InitializeResponse = self.send_request(request, request_id, "initialize")?;
// Complete the initialize handshake.
let initialized = JSONRPCMessage::Notification(JSONRPCNotification {
method: "initialized".to_string(),
params: None,
});
self.write_jsonrpc_message(initialized)?;
Ok(response)
}
fn start_thread(&mut self) -> Result<NewConversationResponse> {
@@ -701,6 +1002,16 @@ impl CodexClient {
self.send_request(request, request_id, "model/list")
}
fn thread_list(&mut self, params: ThreadListParams) -> Result<ThreadListResponse> {
let request_id = self.request_id();
let request = ClientRequest::ThreadList {
request_id: request_id.clone(),
params,
};
self.send_request(request, request_id, "thread/list")
}
fn stream_conversation(&mut self, conversation_id: &ThreadId) -> Result<()> {
loop {
let notification = self.next_notification()?;
@@ -835,6 +1146,12 @@ impl CodexClient {
Ok(())
}
fn stream_notifications_forever(&mut self) -> Result<()> {
loop {
let _ = self.next_notification()?;
}
}
fn extract_event(
&self,
notification: JSONRPCNotification,
@@ -882,17 +1199,7 @@ impl CodexClient {
let request_json = serde_json::to_string(request)?;
let request_pretty = serde_json::to_string_pretty(request)?;
print_multiline_with_prefix("> ", &request_pretty);
if let Some(stdin) = self.stdin.as_mut() {
writeln!(stdin, "{request_json}")?;
stdin
.flush()
.context("failed to flush request to codex app-server")?;
} else {
bail!("codex app-server stdin closed");
}
Ok(())
self.write_payload(&request_json)
}
fn wait_for_response<T>(&mut self, request_id: RequestId, method: &str) -> Result<T>
@@ -947,17 +1254,8 @@ impl CodexClient {
fn read_jsonrpc_message(&mut self) -> Result<JSONRPCMessage> {
loop {
let mut response_line = String::new();
let bytes = self
.stdout
.read_line(&mut response_line)
.context("failed to read from codex app-server")?;
if bytes == 0 {
bail!("codex app-server closed stdout");
}
let trimmed = response_line.trim();
let raw = self.read_payload()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
@@ -1086,16 +1384,56 @@ impl CodexClient {
let payload = serde_json::to_string(&message)?;
let pretty = serde_json::to_string_pretty(&message)?;
print_multiline_with_prefix("> ", &pretty);
self.write_payload(&payload)
}
if let Some(stdin) = self.stdin.as_mut() {
writeln!(stdin, "{payload}")?;
stdin
.flush()
.context("failed to flush response to codex app-server")?;
return Ok(());
fn write_payload(&mut self, payload: &str) -> Result<()> {
match &mut self.transport {
ClientTransport::Stdio { stdin, .. } => {
if let Some(stdin) = stdin.as_mut() {
writeln!(stdin, "{payload}")?;
stdin
.flush()
.context("failed to flush payload to codex app-server")?;
return Ok(());
}
bail!("codex app-server stdin closed")
}
ClientTransport::WebSocket { socket, url } => {
socket
.send(Message::Text(payload.to_string().into()))
.with_context(|| format!("failed to write websocket message to `{url}`"))?;
Ok(())
}
}
}
bail!("codex app-server stdin closed")
fn read_payload(&mut self) -> Result<String> {
match &mut self.transport {
ClientTransport::Stdio { stdout, .. } => {
let mut response_line = String::new();
let bytes = stdout
.read_line(&mut response_line)
.context("failed to read from codex app-server")?;
if bytes == 0 {
bail!("codex app-server closed stdout");
}
Ok(response_line)
}
ClientTransport::WebSocket { socket, url } => loop {
let frame = socket
.read()
.with_context(|| format!("failed to read websocket message from `{url}`"))?;
match frame {
Message::Text(text) => return Ok(text.to_string()),
Message::Binary(_) | Message::Ping(_) | Message::Pong(_) => continue,
Message::Close(_) => {
bail!("websocket app-server at `{url}` closed the connection")
}
Message::Frame(_) => continue,
}
},
}
}
}
@@ -1107,21 +1445,25 @@ fn print_multiline_with_prefix(prefix: &str, payload: &str) {
impl Drop for CodexClient {
fn drop(&mut self) {
let _ = self.stdin.take();
let ClientTransport::Stdio { child, stdin, .. } = &mut self.transport else {
return;
};
if let Ok(Some(status)) = self.child.try_wait() {
let _ = stdin.take();
if let Ok(Some(status)) = child.try_wait() {
println!("[codex app-server exited: {status}]");
return;
}
thread::sleep(Duration::from_millis(100));
if let Ok(Some(status)) = self.child.try_wait() {
if let Ok(Some(status)) = child.try_wait() {
println!("[codex app-server exited: {status}]");
return;
}
let _ = self.child.kill();
let _ = self.child.wait();
let _ = child.kill();
let _ = child.wait();
}
}

View File

@@ -117,7 +117,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, and `cwd` filters.
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`.
- `thread/archive` — move a threads rollout file into the archived directory; returns `{}` on success.
@@ -131,7 +131,7 @@ Example with notification opt-out:
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `review/start` — kick off Codexs automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `model/list` — list available models (with reasoning effort options and optional `upgrade` model ids).
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options and optional `upgrade` model ids.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination).
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
@@ -209,6 +209,8 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
{ "method": "thread/started", "params": { "thread": { } } }
```
Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously.
### Example: List threads (with pagination & filters)
`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of:
@@ -219,6 +221,7 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers.
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
Example:
@@ -532,6 +535,13 @@ Examples:
- Opt out of legacy session setup event: `codex/event/session_configured`
- Opt out of streamed agent text deltas: `item/agentMessage/delta`
### Fuzzy file search events (experimental)
The fuzzy file search session API emits per-query notifications:
- `fuzzyFileSearch/sessionUpdated``{ sessionId, query, files }` with the current matching files for the active query.
- `fuzzyFileSearch/sessionCompleted``{ sessionId, query }` once indexing/matching for that query has completed.
### Turn events
The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`.
@@ -761,7 +771,7 @@ To enable or disable a skill by path:
## Apps
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, and whether it is currently accessible.
Use `app/list` to fetch available apps (connectors). Each entry includes metadata like the app `id`, display `name`, `installUrl`, whether it is currently accessible, and whether it is enabled in config.
```json
{ "method": "app/list", "id": 50, "params": {
@@ -780,7 +790,8 @@ Use `app/list` to fetch available apps (connectors). Each entry includes metadat
"logoUrlDark": null,
"distributionChannel": null,
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
"isAccessible": true
"isAccessible": true,
"isEnabled": true
}
],
"nextCursor": null
@@ -806,7 +817,8 @@ The server also emits `app/list/updated` notifications whenever either source (a
"logoUrlDark": null,
"distributionChannel": null,
"installUrl": "https://chatgpt.com/apps/demo-app/demo-app",
"isAccessible": true
"isAccessible": true,
"isEnabled": true
}
]
}

View File

@@ -4,6 +4,7 @@ use crate::codex_message_processor::read_summary_from_rollout;
use crate::codex_message_processor::summary_to_thread;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::thread_state::ThreadState;
use crate::thread_state::TurnSummary;
@@ -205,6 +206,7 @@ pub(crate) async fn apply_bespoke_event_handling(
reason,
proposed_execpolicy_amendment,
parsed_cmd,
..
}) => match api_version {
ApiVersion::V1 => {
let params = ExecCommandApprovalParams {
@@ -717,6 +719,10 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
};
if !ev.affects_turn_status() {
return;
}
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
@@ -887,11 +893,7 @@ pub(crate) async fn apply_bespoke_event_handling(
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
let item_id = patch_end_event.call_id.clone();
let status = if patch_end_event.success {
PatchApplyStatus::Completed
} else {
PatchApplyStatus::Failed
};
let status: PatchApplyStatus = (&patch_end_event.status).into();
let changes = convert_patch_changes(&patch_end_event.changes);
complete_file_change_item(
conversation_id,
@@ -998,14 +1000,11 @@ pub(crate) async fn apply_bespoke_event_handling(
aggregated_output,
exit_code,
duration,
status,
..
} = exec_command_end_event;
let status = if exit_code == 0 {
CommandExecutionStatus::Completed
} else {
CommandExecutionStatus::Failed
};
let status: CommandExecutionStatus = (&status).into();
let command_actions = parsed_cmd
.into_iter()
.map(V2ParsedCommand::from)
@@ -1411,12 +1410,25 @@ async fn handle_error(
async fn on_patch_approval_response(
call_id: String,
receiver: oneshot::Receiver<JsonValue>,
receiver: oneshot::Receiver<ClientRequestResult>,
codex: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Ok(Ok(value)) => value,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
if let Err(submit_err) = codex
.submit(Op::PatchApproval {
id: call_id.clone(),
decision: ReviewDecision::Denied,
})
.await
{
error!("failed to submit denied PatchApproval after request failure: {submit_err}");
}
return;
}
Err(err) => {
error!("request failed: {err:?}");
if let Err(submit_err) = codex
@@ -1454,12 +1466,16 @@ async fn on_patch_approval_response(
async fn on_exec_approval_response(
call_id: String,
turn_id: String,
receiver: oneshot::Receiver<JsonValue>,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Ok(Ok(value)) => value,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
return;
}
Err(err) => {
error!("request failed: {err:?}");
return;
@@ -1491,12 +1507,28 @@ async fn on_exec_approval_response(
async fn on_request_user_input_response(
event_turn_id: String,
receiver: oneshot::Receiver<JsonValue>,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Ok(Ok(value)) => value,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
let empty = CoreRequestUserInputResponse {
answers: HashMap::new(),
};
if let Err(err) = conversation
.submit(Op::UserInputAnswer {
id: event_turn_id,
response: empty,
})
.await
{
error!("failed to submit UserInputAnswer: {err}");
}
return;
}
Err(err) => {
error!("request failed: {err:?}");
let empty = CoreRequestUserInputResponse {
@@ -1631,14 +1663,14 @@ async fn on_file_change_request_approval_response(
conversation_id: ThreadId,
item_id: String,
changes: Vec<FileUpdateChange>,
receiver: oneshot::Receiver<JsonValue>,
receiver: oneshot::Receiver<ClientRequestResult>,
codex: Arc<CodexThread>,
outgoing: ThreadScopedOutgoingMessageSender,
thread_state: Arc<Mutex<ThreadState>>,
) {
let response = receiver.await;
let (decision, completion_status) = match response {
Ok(value) => {
Ok(Ok(value)) => {
let response = serde_json::from_value::<FileChangeRequestApprovalResponse>(value)
.unwrap_or_else(|err| {
error!("failed to deserialize FileChangeRequestApprovalResponse: {err}");
@@ -1653,6 +1685,10 @@ async fn on_file_change_request_approval_response(
// Only short-circuit on declines/cancels/failures.
(decision, completion_status)
}
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
}
Err(err) => {
error!("request failed: {err:?}");
(ReviewDecision::Denied, Some(PatchApplyStatus::Failed))
@@ -1691,13 +1727,13 @@ async fn on_command_execution_request_approval_response(
command: String,
cwd: PathBuf,
command_actions: Vec<V2ParsedCommand>,
receiver: oneshot::Receiver<JsonValue>,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
outgoing: ThreadScopedOutgoingMessageSender,
) {
let response = receiver.await;
let (decision, completion_status) = match response {
Ok(value) => {
Ok(Ok(value)) => {
let response = serde_json::from_value::<CommandExecutionRequestApprovalResponse>(value)
.unwrap_or_else(|err| {
error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}");
@@ -1732,6 +1768,10 @@ async fn on_command_execution_request_approval_response(
};
(decision, completion_status)
}
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))
}
Err(err) => {
error!("request failed: {err:?}");
(ReviewDecision::Denied, Some(CommandExecutionStatus::Failed))

File diff suppressed because it is too large Load Diff

View File

@@ -7,14 +7,35 @@ use std::sync::Arc;
use tokio::sync::oneshot;
use tracing::error;
use crate::outgoing_message::ClientRequestResult;
pub(crate) async fn on_call_response(
call_id: String,
receiver: oneshot::Receiver<serde_json::Value>,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Ok(Ok(value)) => value,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
let fallback = CoreDynamicToolResponse {
content_items: vec![CoreDynamicToolCallOutputContentItem::InputText {
text: "dynamic tool request failed".to_string(),
}],
success: false,
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: fallback,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
return;
}
Err(err) => {
error!("request failed: {err:?}");
let fallback = CoreDynamicToolResponse {

View File

@@ -1,12 +1,19 @@
use std::num::NonZero;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use codex_app_server_protocol::FuzzyFileSearchResult;
use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification;
use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_file_search as file_search;
use tracing::warn;
use crate::outgoing_message::OutgoingMessageSender;
const MATCH_LIMIT: usize = 50;
const MAX_THREADS: usize = 12;
@@ -77,3 +84,164 @@ pub(crate) async fn run_fuzzy_file_search(
files
}
pub(crate) struct FuzzyFileSearchSession {
session: file_search::FileSearchSession,
shared: Arc<SessionShared>,
}
impl FuzzyFileSearchSession {
pub(crate) fn update_query(&self, query: String) {
if self.shared.canceled.load(Ordering::Relaxed) {
return;
}
{
#[expect(clippy::unwrap_used)]
let mut latest_query = self.shared.latest_query.lock().unwrap();
*latest_query = query.clone();
}
self.session.update_query(&query);
}
}
impl Drop for FuzzyFileSearchSession {
fn drop(&mut self) {
self.shared.canceled.store(true, Ordering::Relaxed);
}
}
pub(crate) fn start_fuzzy_file_search_session(
session_id: String,
roots: Vec<String>,
outgoing: Arc<OutgoingMessageSender>,
) -> anyhow::Result<FuzzyFileSearchSession> {
#[expect(clippy::expect_used)]
let limit = NonZero::new(MATCH_LIMIT).expect("MATCH_LIMIT should be a valid non-zero usize");
let cores = std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(1);
let threads = cores.min(MAX_THREADS);
#[expect(clippy::expect_used)]
let threads = NonZero::new(threads.max(1)).expect("threads should be non-zero");
let search_dirs: Vec<PathBuf> = roots.iter().map(PathBuf::from).collect();
let canceled = Arc::new(AtomicBool::new(false));
let shared = Arc::new(SessionShared {
session_id,
latest_query: Mutex::new(String::new()),
outgoing,
runtime: tokio::runtime::Handle::current(),
canceled: canceled.clone(),
});
let reporter = Arc::new(SessionReporterImpl {
shared: shared.clone(),
});
let session = file_search::create_session(
search_dirs,
file_search::FileSearchOptions {
limit,
threads,
compute_indices: true,
..Default::default()
},
reporter,
Some(canceled),
)?;
Ok(FuzzyFileSearchSession { session, shared })
}
struct SessionShared {
session_id: String,
latest_query: Mutex<String>,
outgoing: Arc<OutgoingMessageSender>,
runtime: tokio::runtime::Handle,
canceled: Arc<AtomicBool>,
}
struct SessionReporterImpl {
shared: Arc<SessionShared>,
}
impl SessionReporterImpl {
fn send_snapshot(&self, snapshot: &file_search::FileSearchSnapshot) {
if self.shared.canceled.load(Ordering::Relaxed) {
return;
}
let query = {
#[expect(clippy::unwrap_used)]
self.shared.latest_query.lock().unwrap().clone()
};
if snapshot.query != query {
return;
}
let files = if query.is_empty() {
Vec::new()
} else {
collect_files(snapshot)
};
let notification = ServerNotification::FuzzyFileSearchSessionUpdated(
FuzzyFileSearchSessionUpdatedNotification {
session_id: self.shared.session_id.clone(),
query,
files,
},
);
let outgoing = self.shared.outgoing.clone();
self.shared.runtime.spawn(async move {
outgoing.send_server_notification(notification).await;
});
}
fn send_complete(&self) {
if self.shared.canceled.load(Ordering::Relaxed) {
return;
}
let session_id = self.shared.session_id.clone();
let outgoing = self.shared.outgoing.clone();
self.shared.runtime.spawn(async move {
let notification = ServerNotification::FuzzyFileSearchSessionCompleted(
FuzzyFileSearchSessionCompletedNotification { session_id },
);
outgoing.send_server_notification(notification).await;
});
}
}
impl file_search::SessionReporter for SessionReporterImpl {
fn on_update(&self, snapshot: &file_search::FileSearchSnapshot) {
self.send_snapshot(snapshot);
}
fn on_complete(&self) {
self.send_complete();
}
}
fn collect_files(snapshot: &file_search::FileSearchSnapshot) -> Vec<FuzzyFileSearchResult> {
let mut files = snapshot
.matches
.iter()
.map(|m| {
let file_name = m.path.file_name().unwrap_or_default();
FuzzyFileSearchResult {
root: m.root.to_string_lossy().to_string(),
path: m.path.to_string_lossy().to_string(),
file_name: file_name.to_string_lossy().to_string(),
score: m.score,
indices: m.indices.clone(),
}
})
.collect::<Vec<_>>();
files.sort_by(file_search::cmp_by_score_desc_then_path_asc::<
FuzzyFileSearchResult,
_,
_,
>(|f| f.score, |f| f.path.as_str()));
files
}

View File

@@ -86,9 +86,20 @@ impl ExternalAuthRefresher for ExternalAuthRefreshBridge {
.await;
let result = match timeout(EXTERNAL_AUTH_REFRESH_TIMEOUT, rx).await {
Ok(result) => result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?,
Ok(result) => {
// Two failure scenarios:
// 1) `oneshot::Receiver` failed (sender dropped) => request canceled/channel closed.
// 2) client answered with JSON-RPC error payload => propagate code/message.
let result = result.map_err(|err| {
std::io::Error::other(format!("auth refresh request canceled: {err}"))
})?;
result.map_err(|err| {
std::io::Error::other(format!(
"auth refresh request failed: code={} message={}",
err.code, err.message
))
})?
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&request_id).await;
return Err(std::io::Error::other(format!(

View File

@@ -8,12 +8,16 @@ use codex_core::models_manager::manager::RefreshStrategy;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
pub async fn supported_models(thread_manager: Arc<ThreadManager>, config: &Config) -> Vec<Model> {
pub async fn supported_models(
thread_manager: Arc<ThreadManager>,
config: &Config,
include_hidden: bool,
) -> Vec<Model> {
thread_manager
.list_models(config, RefreshStrategy::OnlineIfUncached)
.await
.into_iter()
.filter(|preset| preset.show_in_picker)
.filter(|preset| include_hidden || preset.show_in_picker)
.map(model_from_preset)
.collect()
}
@@ -25,6 +29,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
upgrade: preset.upgrade.map(|upgrade| upgrade.id),
display_name: preset.display_name.to_string(),
description: preset.description.to_string(),
hidden: !preset.show_in_picker,
supported_reasoning_efforts: reasoning_efforts_from_preset(
preset.supported_reasoning_efforts,
),

View File

@@ -20,6 +20,8 @@ use crate::error_code::INTERNAL_ERROR_CODE;
#[cfg(test)]
use codex_protocol::account::PlanType;
pub(crate) type ClientRequestResult = std::result::Result<Result, JSONRPCErrorError>;
/// Stable identifier for a transport connection.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionId(pub(crate) u64);
@@ -46,7 +48,7 @@ pub(crate) enum OutgoingEnvelope {
pub(crate) struct OutgoingMessageSender {
next_server_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingEnvelope>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<ClientRequestResult>>>,
}
#[derive(Clone)]
@@ -69,7 +71,7 @@ impl ThreadScopedOutgoingMessageSender {
pub(crate) async fn send_request(
&self,
payload: ServerRequestPayload,
) -> oneshot::Receiver<Result> {
) -> oneshot::Receiver<ClientRequestResult> {
if self.connection_ids.is_empty() {
let (_tx, rx) = oneshot::channel();
return rx;
@@ -118,7 +120,7 @@ impl OutgoingMessageSender {
&self,
connection_ids: &[ConnectionId],
request: ServerRequestPayload,
) -> oneshot::Receiver<Result> {
) -> oneshot::Receiver<ClientRequestResult> {
let (_id, rx) = self
.send_request_with_id_to_connections(connection_ids, request)
.await;
@@ -128,7 +130,7 @@ impl OutgoingMessageSender {
pub(crate) async fn send_request_with_id(
&self,
request: ServerRequestPayload,
) -> (RequestId, oneshot::Receiver<Result>) {
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
self.send_request_with_id_to_connections(&[], request).await
}
@@ -136,7 +138,7 @@ impl OutgoingMessageSender {
&self,
connection_ids: &[ConnectionId],
request: ServerRequestPayload,
) -> (RequestId, oneshot::Receiver<Result>) {
) -> (RequestId, oneshot::Receiver<ClientRequestResult>) {
let id = RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed));
let outgoing_message_id = id.clone();
let (tx_approve, rx_approve) = oneshot::channel();
@@ -190,7 +192,7 @@ impl OutgoingMessageSender {
match entry {
Some((id, sender)) => {
if let Err(err) = sender.send(result) {
if let Err(err) = sender.send(Ok(result)) {
warn!("could not notify callback for {id:?} due to: {err:?}");
}
}
@@ -207,8 +209,11 @@ impl OutgoingMessageSender {
};
match entry {
Some((id, _sender)) => {
Some((id, sender)) => {
warn!("client responded with error for {id:?}: {error:?}");
if let Err(err) = sender.send(Err(error)) {
warn!("could not notify callback for {id:?} due to: {err:?}");
}
}
None => {
warn!("could not find callback for {id:?}");
@@ -390,11 +395,13 @@ mod tests {
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::ApplyPatchApprovalParams;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::LoginChatGptCompleteNotification;
use codex_app_server_protocol::RateLimitSnapshot;
use codex_app_server_protocol::RateLimitWindow;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::timeout;
@@ -609,4 +616,38 @@ mod tests {
other => panic!("expected targeted error envelope, got: {other:?}"),
}
}
#[tokio::test]
async fn notify_client_error_forwards_error_to_waiter() {
let (tx, _rx) = mpsc::channel::<OutgoingEnvelope>(4);
let outgoing = OutgoingMessageSender::new(tx);
let (request_id, wait_for_result) = outgoing
.send_request_with_id(ServerRequestPayload::ApplyPatchApproval(
ApplyPatchApprovalParams {
conversation_id: ThreadId::new(),
call_id: "call-id".to_string(),
file_changes: HashMap::new(),
reason: None,
grant_root: None,
},
))
.await;
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "refresh failed".to_string(),
data: None,
};
outgoing
.notify_client_error(request_id, error.clone())
.await;
let result = timeout(Duration::from_secs(1), wait_for_result)
.await
.expect("wait should not time out")
.expect("waiter should receive a callback");
assert_eq!(result, Err(error));
}
}

View File

@@ -678,6 +678,78 @@ impl McpProcess {
self.send_request("fuzzyFileSearch", Some(params)).await
}
pub async fn send_fuzzy_file_search_session_start_request(
&mut self,
session_id: &str,
roots: Vec<String>,
) -> anyhow::Result<i64> {
let params = serde_json::json!({
"sessionId": session_id,
"roots": roots,
});
self.send_request("fuzzyFileSearch/sessionStart", Some(params))
.await
}
pub async fn start_fuzzy_file_search_session(
&mut self,
session_id: &str,
roots: Vec<String>,
) -> anyhow::Result<JSONRPCResponse> {
let request_id = self
.send_fuzzy_file_search_session_start_request(session_id, roots)
.await?;
self.read_stream_until_response_message(RequestId::Integer(request_id))
.await
}
pub async fn send_fuzzy_file_search_session_update_request(
&mut self,
session_id: &str,
query: &str,
) -> anyhow::Result<i64> {
let params = serde_json::json!({
"sessionId": session_id,
"query": query,
});
self.send_request("fuzzyFileSearch/sessionUpdate", Some(params))
.await
}
pub async fn update_fuzzy_file_search_session(
&mut self,
session_id: &str,
query: &str,
) -> anyhow::Result<JSONRPCResponse> {
let request_id = self
.send_fuzzy_file_search_session_update_request(session_id, query)
.await?;
self.read_stream_until_response_message(RequestId::Integer(request_id))
.await
}
pub async fn send_fuzzy_file_search_session_stop_request(
&mut self,
session_id: &str,
) -> anyhow::Result<i64> {
let params = serde_json::json!({
"sessionId": session_id,
});
self.send_request("fuzzyFileSearch/sessionStop", Some(params))
.await
}
pub async fn stop_fuzzy_file_search_session(
&mut self,
session_id: &str,
) -> anyhow::Result<JSONRPCResponse> {
let request_id = self
.send_fuzzy_file_search_session_stop_request(session_id)
.await?;
self.read_stream_until_response_message(RequestId::Integer(request_id))
.await
}
async fn send_request(
&mut self,
method: &str,

View File

@@ -41,6 +41,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
experimental_supported_tools: Vec::new(),
input_modalities: default_input_modalities(),
prefer_websockets: false,
used_fallback_model_metadata: false,
}
}

View File

@@ -1,6 +1,8 @@
use anyhow::Result;
use anyhow::anyhow;
use app_test_support::McpProcess;
use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification;
use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
@@ -9,6 +11,154 @@ use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const SHORT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(500);
const STOP_GRACE_PERIOD: std::time::Duration = std::time::Duration::from_millis(250);
const SESSION_UPDATED_METHOD: &str = "fuzzyFileSearch/sessionUpdated";
const SESSION_COMPLETED_METHOD: &str = "fuzzyFileSearch/sessionCompleted";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum FileExpectation {
Any,
Empty,
NonEmpty,
}
async fn initialized_mcp(codex_home: &TempDir) -> Result<McpProcess> {
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
Ok(mcp)
}
async fn wait_for_session_updated(
mcp: &mut McpProcess,
session_id: &str,
query: &str,
file_expectation: FileExpectation,
) -> Result<FuzzyFileSearchSessionUpdatedNotification> {
for _ in 0..20 {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message(SESSION_UPDATED_METHOD),
)
.await??;
let params = notification
.params
.ok_or_else(|| anyhow!("missing notification params"))?;
let payload = serde_json::from_value::<FuzzyFileSearchSessionUpdatedNotification>(params)?;
if payload.session_id != session_id || payload.query != query {
continue;
}
let files_match = match file_expectation {
FileExpectation::Any => true,
FileExpectation::Empty => payload.files.is_empty(),
FileExpectation::NonEmpty => !payload.files.is_empty(),
};
if files_match {
return Ok(payload);
}
}
anyhow::bail!(
"did not receive expected session update for sessionId={session_id}, query={query}"
);
}
async fn wait_for_session_completed(
mcp: &mut McpProcess,
session_id: &str,
) -> Result<FuzzyFileSearchSessionCompletedNotification> {
for _ in 0..20 {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message(SESSION_COMPLETED_METHOD),
)
.await??;
let params = notification
.params
.ok_or_else(|| anyhow!("missing notification params"))?;
let payload =
serde_json::from_value::<FuzzyFileSearchSessionCompletedNotification>(params)?;
if payload.session_id == session_id {
return Ok(payload);
}
}
anyhow::bail!("did not receive expected session completion for sessionId={session_id}");
}
async fn assert_update_request_fails_for_missing_session(
mcp: &mut McpProcess,
session_id: &str,
query: &str,
) -> Result<()> {
let request_id = mcp
.send_fuzzy_file_search_session_update_request(session_id, query)
.await?;
let err = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert_eq!(
err.error.message,
format!("fuzzy file search session not found: {session_id}")
);
Ok(())
}
async fn assert_no_session_updates_for(
mcp: &mut McpProcess,
session_id: &str,
grace_period: std::time::Duration,
duration: std::time::Duration,
) -> Result<()> {
let grace_deadline = tokio::time::Instant::now() + grace_period;
loop {
let now = tokio::time::Instant::now();
if now >= grace_deadline {
break;
}
let remaining = grace_deadline - now;
match timeout(
remaining,
mcp.read_stream_until_notification_message(SESSION_UPDATED_METHOD),
)
.await
{
Err(_) => break,
Ok(Err(err)) => return Err(err),
Ok(Ok(_)) => {}
}
}
let deadline = tokio::time::Instant::now() + duration;
loop {
let now = tokio::time::Instant::now();
if now >= deadline {
return Ok(());
}
let remaining = deadline - now;
match timeout(
remaining,
mcp.read_stream_until_notification_message(SESSION_UPDATED_METHOD),
)
.await
{
Err(_) => return Ok(()),
Ok(Err(err)) => return Err(err),
Ok(Ok(notification)) => {
let params = notification
.params
.ok_or_else(|| anyhow!("missing notification params"))?;
let payload =
serde_json::from_value::<FuzzyFileSearchSessionUpdatedNotification>(params)?;
if payload.session_id == session_id {
anyhow::bail!("received unexpected session update after stop: {payload:?}");
}
}
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> {
@@ -125,3 +275,246 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_streams_updates() -> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-1";
mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()])
.await?;
mcp.update_fuzzy_file_search_session(session_id, "alp")
.await?;
let payload =
wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?;
assert_eq!(payload.files.len(), 1);
assert_eq!(payload.files[0].root, root_path);
assert_eq!(payload.files[0].path, "alpha.txt");
let completed = wait_for_session_completed(&mut mcp, session_id).await?;
assert_eq!(completed.session_id, session_id);
mcp.stop_fuzzy_file_search_session(session_id).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_no_updates_after_complete_until_query_edited() -> Result<()>
{
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-complete-invariant";
mcp.start_fuzzy_file_search_session(session_id, vec![root_path])
.await?;
mcp.update_fuzzy_file_search_session(session_id, "alp")
.await?;
wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?;
wait_for_session_completed(&mut mcp, session_id).await?;
assert_no_session_updates_for(&mut mcp, session_id, STOP_GRACE_PERIOD, SHORT_READ_TIMEOUT)
.await?;
mcp.update_fuzzy_file_search_session(session_id, "alpha")
.await?;
wait_for_session_updated(&mut mcp, session_id, "alpha", FileExpectation::NonEmpty).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_update_before_start_errors() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = initialized_mcp(&codex_home).await?;
assert_update_request_fails_for_missing_session(&mut mcp, "missing", "alp").await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_update_works_without_waiting_for_start_response()
-> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-no-wait";
let start_request_id = mcp
.send_fuzzy_file_search_session_start_request(session_id, vec![root_path.clone()])
.await?;
let update_request_id = mcp
.send_fuzzy_file_search_session_update_request(session_id, "alp")
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(update_request_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)),
)
.await??;
let payload =
wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?;
assert_eq!(payload.files.len(), 1);
assert_eq!(payload.files[0].root, root_path);
assert_eq!(payload.files[0].path, "alpha.txt");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_multiple_query_updates_work() -> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
std::fs::write(root.path().join("alphabet.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-multi-update";
mcp.start_fuzzy_file_search_session(session_id, vec![root_path.clone()])
.await?;
mcp.update_fuzzy_file_search_session(session_id, "alp")
.await?;
let alp_payload =
wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?;
assert_eq!(
alp_payload.files.iter().all(|file| file.root == root_path),
true
);
wait_for_session_completed(&mut mcp, session_id).await?;
mcp.update_fuzzy_file_search_session(session_id, "zzzz")
.await?;
let zzzz_payload =
wait_for_session_updated(&mut mcp, session_id, "zzzz", FileExpectation::Any).await?;
assert_eq!(zzzz_payload.query, "zzzz");
assert_eq!(zzzz_payload.files.is_empty(), true);
wait_for_session_completed(&mut mcp, session_id).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_update_after_stop_fails() -> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let session_id = "session-stop-fail";
let root_path = root.path().to_string_lossy().to_string();
mcp.start_fuzzy_file_search_session(session_id, vec![root_path])
.await?;
mcp.stop_fuzzy_file_search_session(session_id).await?;
assert_update_request_fails_for_missing_session(&mut mcp, session_id, "alp").await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_session_stops_sending_updates_after_stop() -> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
for i in 0..10_000 {
let file_path = root.path().join(format!("file-{i:04}.txt"));
std::fs::write(file_path, "contents")?;
}
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-stop-no-updates";
mcp.start_fuzzy_file_search_session(session_id, vec![root_path])
.await?;
mcp.update_fuzzy_file_search_session(session_id, "file-")
.await?;
wait_for_session_updated(&mut mcp, session_id, "file-", FileExpectation::NonEmpty).await?;
mcp.stop_fuzzy_file_search_session(session_id).await?;
assert_no_session_updates_for(&mut mcp, session_id, STOP_GRACE_PERIOD, SHORT_READ_TIMEOUT)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_two_sessions_are_independent() -> Result<()> {
let codex_home = TempDir::new()?;
let root_a = TempDir::new()?;
let root_b = TempDir::new()?;
std::fs::write(root_a.path().join("alpha.txt"), "contents")?;
std::fs::write(root_b.path().join("beta.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_a_path = root_a.path().to_string_lossy().to_string();
let root_b_path = root_b.path().to_string_lossy().to_string();
let session_a = "session-a";
let session_b = "session-b";
mcp.start_fuzzy_file_search_session(session_a, vec![root_a_path.clone()])
.await?;
mcp.start_fuzzy_file_search_session(session_b, vec![root_b_path.clone()])
.await?;
mcp.update_fuzzy_file_search_session(session_a, "alp")
.await?;
let session_a_update =
wait_for_session_updated(&mut mcp, session_a, "alp", FileExpectation::NonEmpty).await?;
assert_eq!(session_a_update.files.len(), 1);
assert_eq!(session_a_update.files[0].root, root_a_path);
assert_eq!(session_a_update.files[0].path, "alpha.txt");
mcp.update_fuzzy_file_search_session(session_b, "bet")
.await?;
let session_b_update =
wait_for_session_updated(&mut mcp, session_b, "bet", FileExpectation::NonEmpty).await?;
assert_eq!(session_b_update.files.len(), 1);
assert_eq!(session_b_update.files[0].root, root_b_path);
assert_eq!(session_b_update.files[0].path, "beta.txt");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_fuzzy_file_search_query_cleared_sends_blank_snapshot() -> Result<()> {
let codex_home = TempDir::new()?;
let root = TempDir::new()?;
std::fs::write(root.path().join("alpha.txt"), "contents")?;
let mut mcp = initialized_mcp(&codex_home).await?;
let root_path = root.path().to_string_lossy().to_string();
let session_id = "session-clear-query";
mcp.start_fuzzy_file_search_session(session_id, vec![root_path])
.await?;
mcp.update_fuzzy_file_search_session(session_id, "alp")
.await?;
wait_for_session_updated(&mut mcp, session_id, "alp", FileExpectation::NonEmpty).await?;
mcp.update_fuzzy_file_search_session(session_id, "").await?;
let payload =
wait_for_session_updated(&mut mcp, session_id, "", FileExpectation::Empty).await?;
assert_eq!(payload.files.is_empty(), true);
Ok(())
}

View File

@@ -564,6 +564,7 @@ fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std
cwd: PathBuf::from("/"),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
network: None,
model: model.to_string(),
personality: None,
collaboration_mode: None,

View File

@@ -1,5 +1,6 @@
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use anyhow::Result;
@@ -86,6 +87,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) =
@@ -173,6 +175,78 @@ connectors = false
Ok(())
}
#[tokio::test]
async fn list_apps_reports_is_enabled_from_config() -> Result<()> {
let connectors = vec![AppInfo {
id: "beta".to_string(),
name: "Beta".to_string(),
description: Some("Beta connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) =
start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{server_url}"
[features]
connectors = true
[apps.beta]
enabled = false
"#
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: false,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let AppsListResponse {
data: response_data,
next_cursor,
} = to_response(response)?;
assert!(next_cursor.is_none());
assert_eq!(response_data.len(), 1);
assert_eq!(response_data[0].id, "beta");
assert!(!response_data[0].is_enabled);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<()> {
let connectors = vec![
@@ -185,6 +259,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -195,6 +270,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
@@ -239,6 +315,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
}];
let first_update = read_app_list_updated_notification(&mut mcp).await?;
@@ -254,6 +331,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
is_enabled: true,
},
AppInfo {
id: "alpha".to_string(),
@@ -264,6 +342,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<(
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
];
@@ -300,6 +379,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -310,6 +390,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
@@ -358,6 +439,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -368,6 +450,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
]
);
@@ -382,6 +465,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
is_enabled: true,
},
AppInfo {
id: "alpha".to_string(),
@@ -392,6 +476,7 @@ async fn list_apps_returns_connectors_with_accessible_flags() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
];
@@ -423,6 +508,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
@@ -433,6 +519,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
@@ -486,6 +573,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta/beta".to_string()),
is_accessible: true,
is_enabled: true,
}];
assert_eq!(first_page, expected_first);
@@ -525,6 +613,7 @@ async fn list_apps_paginates_results() -> Result<()> {
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
}];
assert_eq!(second_page, expected_second);
@@ -545,6 +634,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) =
@@ -633,6 +723,201 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result
Ok(())
}
#[tokio::test]
async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Result<()> {
let initial_connectors = vec![
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: Some("Beta v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
},
];
let initial_tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle, server_control) = start_apps_server_with_delays_and_control(
initial_connectors,
initial_tools,
Duration::from_millis(300),
Duration::ZERO,
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let warm_request = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: false,
})
.await?;
let warm_first_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
warm_first_update.data,
vec![AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
}]
);
let warm_second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
warm_second_update.data,
vec![
AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: Some("Beta v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
},
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
]
);
let warm_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(warm_request)),
)
.await??;
let AppsListResponse {
data: warm_data,
next_cursor: warm_next_cursor,
} = to_response(warm_response)?;
assert_eq!(warm_data, warm_second_update.data);
assert!(warm_next_cursor.is_none());
server_control.set_connectors(vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v2".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}]);
server_control.set_tools(Vec::new());
let refetch_request = mcp
.send_apps_list_request(AppsListParams {
limit: None,
cursor: None,
thread_id: None,
force_refetch: true,
})
.await?;
let first_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
first_update.data,
vec![
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: Some("Beta v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: false,
is_enabled: true,
},
]
);
let expected_final = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v2".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
}];
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(second_update.data, expected_final);
let refetch_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(refetch_request)),
)
.await??;
let AppsListResponse {
data: refetch_data,
next_cursor: refetch_next_cursor,
} = to_response(refetch_response)?;
assert_eq!(refetch_data, expected_final);
assert!(refetch_next_cursor.is_none());
server_handle.abort();
Ok(())
}
async fn read_app_list_updated_notification(
mcp: &mut McpProcess,
) -> Result<AppListUpdatedNotification> {
@@ -652,22 +937,46 @@ async fn read_app_list_updated_notification(
struct AppsServerState {
expected_bearer: String,
expected_account_id: String,
response: serde_json::Value,
response: Arc<StdMutex<serde_json::Value>>,
directory_delay: Duration,
}
#[derive(Clone)]
struct AppListMcpServer {
tools: Arc<Vec<Tool>>,
tools: Arc<StdMutex<Vec<Tool>>>,
tools_delay: Duration,
}
impl AppListMcpServer {
fn new(tools: Arc<Vec<Tool>>, tools_delay: Duration) -> Self {
fn new(tools: Arc<StdMutex<Vec<Tool>>>, tools_delay: Duration) -> Self {
Self { tools, tools_delay }
}
}
#[derive(Clone)]
struct AppsServerControl {
response: Arc<StdMutex<serde_json::Value>>,
tools: Arc<StdMutex<Vec<Tool>>>,
}
impl AppsServerControl {
fn set_connectors(&self, connectors: Vec<AppInfo>) {
let mut response_guard = self
.response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*response_guard = json!({ "apps": connectors, "next_token": null });
}
fn set_tools(&self, tools: Vec<Tool>) {
let mut tools_guard = self
.tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*tools_guard = tools;
}
}
impl ServerHandler for AppListMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
@@ -688,8 +997,12 @@ impl ServerHandler for AppListMcpServer {
if tools_delay > Duration::ZERO {
tokio::time::sleep(tools_delay).await;
}
let tools = tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(ListToolsResult {
tools: (*tools).clone(),
tools,
next_cursor: None,
meta: None,
})
@@ -703,14 +1016,33 @@ async fn start_apps_server_with_delays(
directory_delay: Duration,
tools_delay: Duration,
) -> Result<(String, JoinHandle<()>)> {
let (server_url, server_handle, _server_control) =
start_apps_server_with_delays_and_control(connectors, tools, directory_delay, tools_delay)
.await?;
Ok((server_url, server_handle))
}
async fn start_apps_server_with_delays_and_control(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
directory_delay: Duration,
tools_delay: Duration,
) -> Result<(String, JoinHandle<()>, AppsServerControl)> {
let response = Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
));
let tools = Arc::new(StdMutex::new(tools));
let state = AppsServerState {
expected_bearer: "Bearer chatgpt-token".to_string(),
expected_account_id: "account-123".to_string(),
response: json!({ "apps": connectors, "next_token": null }),
response: response.clone(),
directory_delay,
};
let state = Arc::new(state);
let tools = Arc::new(tools);
let server_control = AppsServerControl {
response,
tools: tools.clone(),
};
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
@@ -737,7 +1069,7 @@ async fn start_apps_server_with_delays(
let _ = axum::serve(listener, router).await;
});
Ok((format!("http://{addr}"), handle))
Ok((format!("http://{addr}"), handle, server_control))
}
async fn list_directory_connectors(
@@ -758,7 +1090,12 @@ async fn list_directory_connectors(
.is_some_and(|value| value == state.expected_account_id);
if bearer_ok && account_ok {
Ok(Json(state.response.clone()))
let response = state
.response
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(Json(response))
} else {
Err(StatusCode::UNAUTHORIZED)
}

View File

@@ -33,6 +33,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
.send_list_models_request(ModelListParams {
limit: Some(100),
cursor: None,
include_hidden: None,
})
.await?;
@@ -54,6 +55,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
upgrade: None,
display_name: "gpt-5.2-codex".to_string(),
description: "Latest frontier agentic coding model.".to_string(),
hidden: false,
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
@@ -84,6 +86,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
upgrade: Some("gpt-5.2-codex".to_string()),
display_name: "gpt-5.1-codex-max".to_string(),
description: "Codex-optimized flagship for deep and fast reasoning.".to_string(),
hidden: false,
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
@@ -114,6 +117,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
upgrade: Some("gpt-5.2-codex".to_string()),
display_name: "gpt-5.1-codex-mini".to_string(),
description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(),
hidden: false,
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Medium,
@@ -138,6 +142,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
description:
"Latest frontier model with improvements across knowledge, reasoning and coding"
.to_string(),
hidden: false,
supported_reasoning_efforts: vec![
ReasoningEffortOption {
reasoning_effort: ReasoningEffort::Low,
@@ -173,6 +178,38 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn list_models_includes_hidden_models() -> Result<()> {
let codex_home = TempDir::new()?;
write_models_cache(codex_home.path())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_list_models_request(ModelListParams {
limit: Some(100),
cursor: None,
include_hidden: Some(true),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ModelListResponse {
data: items,
next_cursor,
} = to_response::<ModelListResponse>(response)?;
assert!(items.iter().any(|item| item.hidden));
assert!(next_cursor.is_none());
Ok(())
}
#[tokio::test]
async fn list_models_pagination_works() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -185,6 +222,7 @@ async fn list_models_pagination_works() -> Result<()> {
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: None,
include_hidden: None,
})
.await?;
@@ -207,6 +245,7 @@ async fn list_models_pagination_works() -> Result<()> {
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(next_cursor.clone()),
include_hidden: None,
})
.await?;
@@ -229,6 +268,7 @@ async fn list_models_pagination_works() -> Result<()> {
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(third_cursor.clone()),
include_hidden: None,
})
.await?;
@@ -251,6 +291,7 @@ async fn list_models_pagination_works() -> Result<()> {
.send_list_models_request(ModelListParams {
limit: Some(1),
cursor: Some(fourth_cursor.clone()),
include_hidden: None,
})
.await?;
@@ -283,6 +324,7 @@ async fn list_models_rejects_invalid_cursor() -> Result<()> {
.send_list_models_request(ModelListParams {
limit: None,
cursor: Some("invalid".to_string()),
include_hidden: None,
})
.await?;

View File

@@ -255,6 +255,7 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> {
Ok(())
}
#[cfg_attr(target_os = "windows", ignore = "flaky on windows CI")]
#[tokio::test]
async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<()> {
let review_payload = json!({
@@ -437,6 +438,7 @@ model_provider = "mock_provider"
[features]
remote_models = false
shell_snapshot = false
[model_providers.mock_provider]
name = "Mock provider"

View File

@@ -17,6 +17,8 @@ use codex_app_server_protocol::ThreadSourceKind;
use codex_core::ARCHIVED_SESSIONS_SUBDIR;
use codex_protocol::ThreadId;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
@@ -66,6 +68,7 @@ async fn list_threads_with_sort(
model_providers: providers,
source_kinds,
archived,
cwd: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -127,6 +130,26 @@ fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
Ok(())
}
fn set_rollout_cwd(path: &Path, cwd: &Path) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let first_line = lines
.first_mut()
.ok_or_else(|| anyhow::anyhow!("rollout at {} is empty", path.display()))?;
let mut rollout_line: RolloutLine = serde_json::from_str(first_line)?;
let RolloutItem::SessionMeta(mut session_meta_line) = rollout_line.item else {
return Err(anyhow::anyhow!(
"rollout at {} does not start with session metadata",
path.display()
));
};
session_meta_line.meta.cwd = cwd.to_path_buf();
rollout_line.item = RolloutItem::SessionMeta(session_meta_line);
*first_line = serde_json::to_string(&rollout_line)?;
fs::write(path, lines.join("\n") + "\n")?;
Ok(())
}
#[tokio::test]
async fn thread_list_basic_empty() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -300,6 +323,63 @@ async fn thread_list_respects_provider_filter() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_list_respects_cwd_filter() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let filtered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"filtered",
Some("mock_provider"),
None,
)?;
let unfiltered_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"unfiltered",
Some("mock_provider"),
None,
)?;
let target_cwd = codex_home.path().join("target-cwd");
fs::create_dir_all(&target_cwd)?;
set_rollout_cwd(
rollout_path(codex_home.path(), "2025-01-02T10-00-00", &filtered_id).as_path(),
&target_cwd,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: Some(target_cwd.to_string_lossy().into_owned()),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadListResponse { data, next_cursor } = to_response::<ThreadListResponse>(resp)?;
assert_eq!(next_cursor, None);
assert_eq!(data.len(), 1);
assert_eq!(data[0].id, filtered_id);
assert_ne!(data[0].id, unfiltered_id);
assert_eq!(data[0].cwd, target_cwd);
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1107,6 +1187,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: None,
})
.await?;
let error: JSONRPCError = timeout(

View File

@@ -209,18 +209,412 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() -
Ok(())
}
#[tokio::test]
async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut primary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let seed_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
primary.clear_message_buffer();
let mut secondary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??;
let turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "respond with docs".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
let resume_id = secondary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
secondary.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
let server = responses::start_mock_server().await;
let first_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]);
let _first_response_mock = responses::mount_sse_once(&server, first_body).await;
let _second_response_mock = responses::mount_sse_once(&server, second_body).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut primary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let seed_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
primary.clear_message_buffer();
let running_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "keep running".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
let resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
history: Some(vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "history override".to_string(),
}],
end_turn: None,
phase: None,
}]),
..Default::default()
})
.await?;
let resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??;
assert!(
resume_err.error.message.contains("cannot resume thread")
&& resume_err.error.message.contains("with history")
&& resume_err.error.message.contains("running"),
"unexpected resume error: {}",
resume_err.error.message
);
Ok(())
}
#[tokio::test]
async fn thread_resume_rejects_mismatched_path_when_thread_is_running() -> Result<()> {
let server = responses::start_mock_server().await;
let first_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]);
let _first_response_mock = responses::mount_sse_once(&server, first_body).await;
let _second_response_mock = responses::mount_sse_once(&server, second_body).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut primary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let seed_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
primary.clear_message_buffer();
let running_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "keep running".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
let resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
path: Some(PathBuf::from("/tmp/does-not-match-running-rollout.jsonl")),
..Default::default()
})
.await?;
let resume_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??;
assert!(
resume_err.error.message.contains("mismatched path"),
"unexpected resume error: {}",
resume_err.error.message
);
Ok(())
}
#[tokio::test]
async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> Result<()> {
let server = responses::start_mock_server().await;
let first_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let second_body = responses::sse(vec![responses::ev_response_created("resp-2")]);
let _response_mock =
responses::mount_sse_sequence(&server, vec![first_body, second_body]).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut primary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let seed_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "seed history".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
primary.clear_message_buffer();
let running_turn_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: "keep running".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
let resume_id = primary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
model: Some("not-the-running-model".to_string()),
cwd: Some("/tmp".to_string()),
..Default::default()
})
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { model, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(model, "gpt-5.1-codex-max");
timeout(
DEFAULT_READ_TIMEOUT,
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
Ok(())
}
#[tokio::test]
async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let RestartedThreadFixture {
mut mcp,
thread_id,
rollout_file_path,
} = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?;
let expected_updated_at_rfc3339 = "2025-01-07T00:00:00Z";
set_rollout_mtime(rollout_file_path.as_path(), expected_updated_at_rfc3339)?;
let before_modified = std::fs::metadata(&rollout_file_path)?.modified()?;
let expected_updated_at = chrono::DateTime::parse_from_rfc3339(expected_updated_at_rfc3339)?
.with_timezone(&Utc)
.timestamp();
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: rollout.conversation_id.clone(),
thread_id,
model: Some("mock-model".to_string()),
..Default::default()
})
@@ -230,16 +624,19 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
let ThreadResumeResponse {
thread: resumed_thread,
..
} = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(thread.updated_at, rollout.expected_updated_at);
assert_eq!(resumed_thread.updated_at, expected_updated_at);
let after_resume_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?;
assert_eq!(after_resume_modified, rollout.before_modified);
let after_resume_modified = std::fs::metadata(&rollout_file_path)?.modified()?;
assert_eq!(after_resume_modified, before_modified);
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: rollout.conversation_id,
thread_id: resumed_thread.id,
input: vec![UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
@@ -258,8 +655,8 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re
)
.await??;
let after_turn_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?;
assert!(after_turn_modified > rollout.before_modified);
let after_turn_modified = std::fs::metadata(&rollout_file_path)?.modified()?;
assert!(after_turn_modified > before_modified);
Ok(())
}
@@ -374,22 +771,9 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// Start a thread.
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let RestartedThreadFixture {
mut mcp, thread_id, ..
} = start_materialized_thread_and_restart(codex_home.path(), "seed history").await?;
let history_text = "Hello from history";
let history = vec![ResponseItem::Message {
@@ -405,7 +789,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
// Resume with explicit history and override the model.
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
thread_id,
history: Some(history),
model: Some("mock-model".to_string()),
model_provider: Some("mock_provider".to_string()),
@@ -429,6 +813,70 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
Ok(())
}
struct RestartedThreadFixture {
mcp: McpProcess,
thread_id: String,
rollout_file_path: PathBuf,
}
async fn start_materialized_thread_and_restart(
codex_home: &Path,
seed_text: &str,
) -> Result<RestartedThreadFixture> {
let mut first_mcp = McpProcess::new(codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??;
let start_id = first_mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.1-codex-max".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
first_mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let materialize_turn_id = first_mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
text: seed_text.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
first_mcp.read_stream_until_response_message(RequestId::Integer(materialize_turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
first_mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let thread_id = thread.id;
let rollout_file_path = thread
.path
.ok_or_else(|| anyhow::anyhow!("thread path missing from thread/start response"))?;
drop(first_mcp);
let mut second_mcp = McpProcess::new(codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??;
Ok(RestartedThreadFixture {
mcp: second_mcp,
thread_id,
rollout_file_path,
})
}
#[tokio::test]
async fn thread_resume_accepts_personality_override() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -449,10 +897,10 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let mut primary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??;
let start_id = mcp
let start_id = primary
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.2-codex".to_string()),
..Default::default()
@@ -460,12 +908,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
primary.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let materialize_id = mcp
let materialize_id = primary
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![UserInput::Text {
@@ -477,16 +925,19 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(materialize_id)),
primary.read_stream_until_response_message(RequestId::Integer(materialize_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
primary.read_stream_until_notification_message("turn/completed"),
)
.await??;
let resume_id = mcp
let mut secondary = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??;
let resume_id = secondary
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
model: Some("gpt-5.2-codex".to_string()),
@@ -496,12 +947,12 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.await?;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
secondary.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let resume: ThreadResumeResponse = to_response::<ThreadResumeResponse>(resume_resp)?;
let turn_id = mcp
let turn_id = secondary
.send_turn_start_request(TurnStartParams {
thread_id: resume.thread.id,
input: vec![UserInput::Text {
@@ -513,13 +964,13 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
secondary.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
secondary.read_stream_until_notification_message("turn/completed"),
)
.await??;

View File

@@ -12,6 +12,8 @@ const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox";
const APPLY_PATCH_ARG0: &str = "apply_patch";
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
const LOCK_FILENAME: &str = ".lock";
#[cfg(target_os = "windows")]
const WINDOWS_TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;
/// Keeps the per-session PATH entry alive and locked for the process lifetime.
pub struct Arg0PathEntryGuard {
@@ -112,7 +114,7 @@ where
// Regular invocation create a Tokio runtime and execute the provided
// async entry-point.
let runtime = tokio::runtime::Runtime::new()?;
let runtime = build_runtime()?;
runtime.block_on(async move {
let codex_linux_sandbox_exe: Option<PathBuf> = if cfg!(target_os = "linux") {
std::env::current_exe().ok()
@@ -124,6 +126,18 @@ where
})
}
fn build_runtime() -> anyhow::Result<tokio::runtime::Runtime> {
let mut builder = tokio::runtime::Builder::new_multi_thread();
builder.enable_all();
#[cfg(target_os = "windows")]
{
// Defensive hardening: Windows worker threads have lower effective
// stack headroom, so use a larger stack for runtime workers.
builder.thread_stack_size(WINDOWS_TOKIO_WORKER_STACK_SIZE_BYTES);
}
Ok(builder.build()?)
}
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Load env vars from ~/.codex/.env.

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
@@ -19,7 +20,9 @@ pub use codex_core::connectors::connector_display_label;
use codex_core::connectors::connector_install_url;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
pub use codex_core::connectors::list_cached_accessible_connectors_from_mcp_tools;
use codex_core::connectors::merge_connectors;
pub use codex_core::connectors::with_app_enabled_state;
#[derive(Debug, Deserialize)]
struct DirectoryListResponse {
@@ -72,13 +75,32 @@ pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
);
let connectors = connectors_result?;
let accessible = accessible_result?;
Ok(merge_connectors_with_accessible(connectors, accessible))
Ok(with_app_enabled_state(
merge_connectors_with_accessible(connectors, accessible, true),
config,
))
}
pub async fn list_all_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
list_all_connectors_with_options(config, false).await
}
pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>> {
if !config.features.enabled(Feature::Apps) {
return Some(Vec::new());
}
if init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode)
.await
.is_err()
{
return None;
}
let token_data = get_chatgpt_token_data()?;
let cache_key = all_connectors_cache_key(config, &token_data);
read_cached_all_connectors(&cache_key)
}
pub async fn list_all_connectors_with_options(
config: &Config,
force_refetch: bool,
@@ -164,7 +186,20 @@ fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[A
pub fn merge_connectors_with_accessible(
connectors: Vec<AppInfo>,
accessible_connectors: Vec<AppInfo>,
all_connectors_loaded: bool,
) -> Vec<AppInfo> {
let accessible_connectors = if all_connectors_loaded {
let connector_ids: HashSet<&str> = connectors
.iter()
.map(|connector| connector.id.as_str())
.collect();
accessible_connectors
.into_iter()
.filter(|connector| connector_ids.contains(connector.id.as_str()))
.collect()
} else {
accessible_connectors
};
let merged = merge_connectors(connectors, accessible_connectors);
filter_disallowed_connectors(merged)
}
@@ -283,6 +318,7 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
distribution_channel: app.distribution_channel,
install_url: None,
is_accessible: false,
is_enabled: true,
}
}
@@ -341,6 +377,7 @@ mod tests {
distribution_channel: None,
install_url: None,
is_accessible: false,
is_enabled: true,
}
}
@@ -383,4 +420,41 @@ mod tests {
]);
assert_eq!(filtered, vec![app("delta")]);
}
fn merged_app(id: &str, is_accessible: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: Some(connector_install_url(id, id)),
is_accessible,
is_enabled: true,
}
}
#[test]
fn excludes_accessible_connectors_not_in_all_when_all_loaded() {
let merged = merge_connectors_with_accessible(
vec![app("alpha")],
vec![app("alpha"), app("beta")],
true,
);
assert_eq!(merged, vec![merged_app("alpha", true)]);
}
#[test]
fn keeps_accessible_connectors_not_in_all_while_all_loading() {
let merged = merge_connectors_with_accessible(
vec![app("alpha")],
vec![app("alpha"), app("beta")],
false,
);
assert_eq!(
merged,
vec![merged_app("alpha", true), merged_app("beta", true)]
);
}
}

View File

@@ -130,7 +130,7 @@ async fn run_command_under_sandbox(
let sandbox_policy_cwd = cwd.clone();
let stdio_policy = StdioPolicy::Inherit;
let env = create_env(&config.shell_environment_policy, None);
let env = create_env(&config.permissions.shell_environment_policy, None);
// Special-case Windows sandbox: execute and exit the process to emulate inherited stdio.
if let SandboxType::Windows = sandbox_type {
@@ -141,7 +141,7 @@ async fn run_command_under_sandbox(
use codex_windows_sandbox::run_windows_sandbox_capture;
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
let policy_str = serde_json::to_string(config.sandbox_policy.get())?;
let policy_str = serde_json::to_string(config.permissions.sandbox_policy.get())?;
let sandbox_cwd = sandbox_policy_cwd.clone();
let cwd_clone = cwd.clone();
@@ -213,12 +213,19 @@ async fn run_command_under_sandbox(
#[cfg(not(target_os = "macos"))]
let _ = log_denials;
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
// This proxy should only live for the lifetime of the child process.
let network_proxy = match config.network.as_ref() {
let network_proxy = match config.permissions.network.as_ref() {
Some(spec) => Some(
spec.start_proxy()
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
spec.start_proxy(
config.permissions.sandbox_policy.get(),
None,
None,
managed_network_requirements_enabled,
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
),
None => None,
};
@@ -232,7 +239,7 @@ async fn run_command_under_sandbox(
spawn_command_under_seatbelt(
command,
cwd,
config.sandbox_policy.get(),
config.permissions.sandbox_policy.get(),
sandbox_policy_cwd.as_path(),
stdio_policy,
network.as_ref(),
@@ -251,7 +258,7 @@ async fn run_command_under_sandbox(
codex_linux_sandbox_exe,
command,
cwd,
config.sandbox_policy.get(),
config.permissions.sandbox_policy.get(),
sandbox_policy_cwd.as_path(),
use_bwrap_sandbox,
stdio_policy,

View File

@@ -93,10 +93,10 @@ enum Subcommand {
/// Remove stored authentication credentials.
Logout(LogoutCommand),
/// [experimental] Run Codex as an MCP server and manage MCP servers.
/// Manage external MCP servers for Codex.
Mcp(McpCli),
/// [experimental] Run the Codex MCP server (stdio transport).
/// Start Codex as an MCP server (stdio).
McpServer,
/// [experimental] Run the app server or related tooling.

View File

@@ -17,6 +17,9 @@ pub(crate) fn subagent_header(source: &Option<SessionSource>) -> Option<String>
match sub {
codex_protocol::protocol::SubAgentSource::Review => Some("review".to_string()),
codex_protocol::protocol::SubAgentSource::Compact => Some("compact".to_string()),
codex_protocol::protocol::SubAgentSource::MemoryConsolidation => {
Some("memory_consolidation".to_string())
}
codex_protocol::protocol::SubAgentSource::ThreadSpawn { .. } => {
Some("collab_spawn".to_string())
}

View File

@@ -89,6 +89,7 @@ async fn models_client_hits_models_endpoint() {
experimental_supported_tools: Vec::new(),
input_modalities: default_input_modalities(),
prefer_websockets: false,
used_fallback_model_metadata: false,
}],
};

View File

@@ -49,6 +49,7 @@ codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-utils-sanitizer = { workspace = true }
codex-utils-string = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
dirs = { workspace = true }
@@ -67,7 +68,6 @@ notify = { workspace = true }
once_cell = { workspace = true }
os_info = { workspace = true }
rand = { workspace = true }
regex = { workspace = true }
regex-lite = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
rmcp = { workspace = true, default-features = false, features = [
@@ -152,6 +152,7 @@ codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
ctor = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
insta = { workspace = true }
maplit = { workspace = true }
predicates = { workspace = true }
pretty_assertions = { workspace = true }

View File

@@ -14,6 +14,30 @@ When using the workspace-write sandbox policy, the Seatbelt profile allows
writes under the configured writable roots while keeping `.git` (directory or
pointer file), the resolved `gitdir:` target, and `.codex` read-only.
Network access and filesystem read/write roots are controlled by
`SandboxPolicy`. Seatbelt consumes the resolved policy and enforces it.
Seatbelt also supports macOS permission-profile extensions layered on top of
`SandboxPolicy`:
- no extension profile provided:
keeps legacy default preferences read access (`user-preference-read`).
- extension profile provided with no `macos_preferences` grant:
does not add preferences access clauses.
- `macos_preferences = "readonly"`:
enables cfprefs read clauses and `user-preference-read`.
- `macos_preferences = "readwrite"`:
includes readonly clauses plus `user-preference-write` and cfprefs shm write
clauses.
- `macos_automation = true`:
enables broad Apple Events send permissions.
- `macos_automation = ["com.apple.Notes", ...]`:
enables Apple Events send only to listed bundle IDs.
- `macos_accessibility = true`:
enables `com.apple.axserver` mach lookup.
- `macos_calendar = true`:
enables `com.apple.CalendarAgent` mach lookup.
### Linux
Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details.

View File

@@ -100,7 +100,7 @@
"type": "string"
},
{
"description": "*All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox.",
"description": "DEPRECATED: *All* commands are autoapproved, but they are expected to run inside a sandbox where network access is disabled and writes are confined to a specific set of paths. If the command fails, it will be escalated to the user to approve execution without a sandbox. Prefer `OnRequest` for interactive runs or `Never` for non-interactive runs.",
"enum": [
"on-failure"
],
@@ -188,6 +188,9 @@
"apps": {
"type": "boolean"
},
"apps_mcp_gateway": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -224,15 +227,24 @@
"js_repl": {
"type": "boolean"
},
"js_repl_tools_only": {
"type": "boolean"
},
"memory_tool": {
"type": "boolean"
},
"multi_agent": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
@@ -293,6 +305,13 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"js_repl_node_module_dirs": {
"description": "Ordered list of directories to search for Node modules in `js_repl`.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"js_repl_node_path": {
"$ref": "#/definitions/AbsolutePathBuf"
},
@@ -428,6 +447,43 @@
}
]
},
"MemoriesToml": {
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
"properties": {
"max_raw_memories_for_global": {
"description": "Maximum number of recent raw memories retained for global consolidation.",
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"max_rollout_age_days": {
"description": "Maximum age of the threads used for memories.",
"format": "int64",
"type": "integer"
},
"max_rollouts_per_startup": {
"description": "Maximum number of rollout candidates processed per pass.",
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"min_rollout_idle_hours": {
"description": "Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.",
"format": "int64",
"type": "integer"
},
"phase_1_model": {
"description": "Model used for thread summarisation.",
"type": "string"
},
"phase_2_model": {
"description": "Model used for memory consolidation.",
"type": "string"
}
},
"type": "object"
},
"ModeKind": {
"description": "Initial collaboration mode to use when the TUI starts.",
"enum": [
@@ -1272,6 +1328,9 @@
"apps": {
"type": "boolean"
},
"apps_mcp_gateway": {
"type": "boolean"
},
"child_agents_md": {
"type": "boolean"
},
@@ -1308,15 +1367,24 @@
"js_repl": {
"type": "boolean"
},
"js_repl_tools_only": {
"type": "boolean"
},
"memory_tool": {
"type": "boolean"
},
"multi_agent": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"prevent_idle_sleep": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
@@ -1430,6 +1498,13 @@
"description": "System instructions.",
"type": "string"
},
"js_repl_node_module_dirs": {
"description": "Ordered list of directories to search for Node modules in `js_repl`.",
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"js_repl_node_path": {
"allOf": [
{
@@ -1469,6 +1544,14 @@
"description": "Definition for MCP servers that Codex can reach out to for tool calls.",
"type": "object"
},
"memories": {
"allOf": [
{
"$ref": "#/definitions/MemoriesToml"
}
],
"description": "Memories subsystem settings."
},
"model": {
"description": "Optional override of model selection.",
"type": "string"

View File

@@ -0,0 +1,2 @@
model = "gpt-5.1-codex-mini"
model_reasoning_effort = "medium"

View File

@@ -0,0 +1,26 @@
version = 1
[agents.default]
description = "Default agent."
[agents.worker]
description = """Use for execution and production work.
Typical tasks:
- Implement part of a feature
- Fix tests or bugs
- Split large refactors into independent chunks
Rules:
- Explicitly assign **ownership** of the task (files / responsibility).
- Always tell workers they are **not alone in the codebase**, and they should ignore edits made by others without touching them."""
[agents.explorer]
description = """Use `explorer` for all codebase questions.
Explorers are fast and authoritative.
Always prefer them over manual search or file reading.
Rules:
- Ask explorers first and precisely.
- Do not re-read or re-search code they cover.
- Trust explorer results without verification.
- Run explorers in parallel when useful.
- Reuse existing explorers for related questions."""
config_file = "explorer.toml"

View File

@@ -50,7 +50,7 @@ impl AgentControl {
let new_thread = match session_source {
Some(session_source) => {
state
.spawn_new_thread_with_source(config, self.clone(), session_source)
.spawn_new_thread_with_source(config, self.clone(), session_source, false)
.await?
}
None => state.spawn_new_thread(config, self.clone()).await?,

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